commit 1a0bdce02ddb0303fc9e317ae4c0a1e99823cc8e Author: lehel Date: Wed Sep 24 13:05:24 2025 +0200 base service diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/copilot.data.migration.agent.xml b/.idea/copilot.data.migration.agent.xml new file mode 100644 index 0000000..4ea72a9 --- /dev/null +++ b/.idea/copilot.data.migration.agent.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/copilot.data.migration.ask.xml b/.idea/copilot.data.migration.ask.xml new file mode 100644 index 0000000..7ef04e2 --- /dev/null +++ b/.idea/copilot.data.migration.ask.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/copilot.data.migration.ask2agent.xml b/.idea/copilot.data.migration.ask2agent.xml new file mode 100644 index 0000000..1f2ea11 --- /dev/null +++ b/.idea/copilot.data.migration.ask2agent.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/copilot.data.migration.edit.xml b/.idea/copilot.data.migration.edit.xml new file mode 100644 index 0000000..8648f94 --- /dev/null +++ b/.idea/copilot.data.migration.edit.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..00aebdb --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vetrag.iml b/.idea/vetrag.iml new file mode 100644 index 0000000..5e764c4 --- /dev/null +++ b/.idea/vetrag.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..e64deae --- /dev/null +++ b/config.yaml @@ -0,0 +1,4 @@ +llm: + extract_keywords_prompt: "Extract 3–5 key veterinary-related terms from this user message: {{.Message}}" + disambiguate_prompt: "Given these possible vet visit reasons: {{.Entries}}, choose the single best match for this user message: {{.Message}}. Reply with the id or none." + diff --git a/db.yaml b/db.yaml new file mode 100644 index 0000000..63e3f9a --- /dev/null +++ b/db.yaml @@ -0,0 +1,104 @@ +- id: deworming + reason: "Féregtelenítés kutyának" + keywords: ["féreg", "féregtelenítés", "parazita", "bélféreg", "kutya"] + procedures: + - name: "Alap vérvizsgálat" + price: 12000 + duration_minutes: 30 + - name: "Féregtelenítő kezelés" + price: 8000 + duration_minutes: 15 + notes: "A kezelés előtt vérvizsgálat szükséges a biztonságos gyógyszeradás miatt." + +- id: vaccination + reason: "Oltás kutyának" + keywords: ["oltás", "vakcina", "oltani", "kutyabetegség", "veszettség"] + procedures: + - name: "Általános állapotfelmérés" + price: 6000 + duration_minutes: 15 + - name: "Oltás beadása" + price: 10000 + duration_minutes: 10 + notes: "A kutyák oltási programja eltérhet az életkortól és korábbi oltásoktól függően." + +- id: neutering + reason: "Ivartalanítás macskának" + keywords: ["ivartalanítás", "műtét", "macska", "kandúr", "nőstény"] + procedures: + - name: "Műtéti előzetes vizsgálat" + price: 15000 + duration_minutes: 30 + - name: "Ivartalanító műtét" + price: 35000 + duration_minutes: 90 + notes: "A műtét után 2-3 nap pihenő szükséges." + +- id: dental_cleaning + reason: "Fogkő eltávolítás kutyának" + keywords: ["fog", "fogkő", "fogsor", "fogtisztítás", "kutyafog"] + procedures: + - name: "Altatás előtti vizsgálat" + price: 12000 + duration_minutes: 20 + - name: "Fogkő eltávolítás ultrahanggal" + price: 25000 + duration_minutes: 60 + notes: "Az altatás kockázata miatt minden esetben szükséges előzetes vizsgálat." + +- id: checkup + reason: "Általános állapotfelmérés" + keywords: ["vizsgálat", "ellenőrzés", "checkup", "állapotfelmérés"] + procedures: + - name: "Teljes fizikai vizsgálat" + price: 10000 + duration_minutes: 30 + notes: "Évente legalább egyszer javasolt a rutin állapotfelmérés." + +- id: allergy + reason: "Allergiás tünetek vizsgálata" + keywords: ["allergia", "viszketés", "kiütés", "bőrpír", "allergiás"] + procedures: + - name: "Bőr- és vérvizsgálat" + price: 20000 + duration_minutes: 45 + notes: "Az allergia gyakran étel vagy környezeti tényező miatt alakul ki." + +- id: ultrasound + reason: "Ultrahangos vizsgálat" + keywords: ["ultrahang", "has", "vizsgálat", "UH"] + procedures: + - name: "Ultrahang vizsgálat" + price: 18000 + duration_minutes: 30 + notes: "Terhesség vagy belső szervi problémák vizsgálatára gyakran használt módszer." + +- id: bloodwork + reason: "Laborvizsgálat vérből" + keywords: ["vér", "vérvizsgálat", "labor", "teszt"] + procedures: + - name: "Teljes vérkép" + price: 15000 + duration_minutes: 20 + notes: "Sok más vizsgálat alapja a laboreredmény." + +- id: xray + reason: "Röntgenfelvétel" + keywords: ["röntgen", "csont", "felvétel", "törés"] + procedures: + - name: "Röntgen vizsgálat" + price: 16000 + duration_minutes: 25 + notes: "Törések, csontelváltozások vizsgálatára javasolt." + +- id: diarrhea + reason: "Hasmenés vizsgálata" + keywords: ["hasmenés", "hányás", "gyomor", "bél"] + procedures: + - name: "Állatorvosi konzultáció" + price: 8000 + duration_minutes: 20 + - name: "Székletvizsgálat" + price: 10000 + duration_minutes: 30 + notes: "Akut hasmenés esetén mindig javasolt a mihamarabbi vizsgálat." diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..780375f --- /dev/null +++ b/go.mod @@ -0,0 +1,42 @@ +module vetrag + +go 1.25 + +require ( + github.com/gin-gonic/gin v1.11.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/bytedance/sonic v1.14.0 // indirect + github.com/bytedance/sonic/loader v0.3.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/gabriel-vasile/mimetype v1.4.8 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.27.0 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/goccy/go-yaml v1.18.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/quic-go/qpack v0.5.1 // indirect + github.com/quic-go/quic-go v0.54.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.0 // indirect + go.uber.org/mock v0.5.0 // indirect + golang.org/x/arch v0.20.0 // indirect + golang.org/x/crypto v0.40.0 // indirect + golang.org/x/mod v0.25.0 // indirect + golang.org/x/net v0.42.0 // indirect + golang.org/x/sync v0.16.0 // indirect + golang.org/x/sys v0.35.0 // indirect + golang.org/x/text v0.27.0 // indirect + golang.org/x/tools v0.34.0 // indirect + google.golang.org/protobuf v1.36.9 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..bdae7bb --- /dev/null +++ b/go.sum @@ -0,0 +1,89 @@ +github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ= +github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA= +github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= +github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= +github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk= +github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4= +github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= +github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= +github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= +github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg= +github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= +github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= +go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= +golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c= +golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= +golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= +golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= +golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= +golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= +golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= +golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= +golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= +golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= +google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= +google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..5a5c947 --- /dev/null +++ b/main.go @@ -0,0 +1,304 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io/ioutil" + "log" + "net/http" + "os" + "strings" + "text/template" + "time" + + "github.com/gin-gonic/gin" + "gopkg.in/yaml.v3" +) + +// Procedure represents a single procedure for a visit reason +type Procedure struct { + Name string `yaml:"name" json:"name"` + Price int `yaml:"price" json:"price"` + DurationMin int `yaml:"duration_minutes" json:"duration_minutes"` +} + +// Reason represents a visit reason entry +type Reason struct { + ID string `yaml:"id" json:"id"` + Reason string `yaml:"reason" json:"reason"` + Keywords []string `yaml:"keywords" json:"keywords"` + Procedures []Procedure `yaml:"procedures" json:"procedures"` + Notes string `yaml:"notes" json:"notes,omitempty"` +} + +var reasonsDB []Reason + +func loadYAMLDB(path string) error { + data, err := ioutil.ReadFile(path) + if err != nil { + return err + } + return yaml.Unmarshal(data, &reasonsDB) +} + +// ChatRequest represents the incoming chat message +type ChatRequest struct { + Message string `json:"message"` +} + +// ChatResponse represents the response to the frontend +type ChatResponse struct { + Match *string `json:"match"` + Procedures []Procedure `json:"procedures,omitempty"` + TotalPrice int `json:"total_price,omitempty"` + TotalDuration int `json:"total_duration,omitempty"` + Notes string `json:"notes,omitempty"` +} + +// LLMClient abstracts LLM API calls +type LLMClient struct { + APIKey string + BaseURL string +} + +// Config holds all prompts and settings +type Config struct { + LLM struct { + ExtractKeywordsPrompt string `yaml:"extract_keywords_prompt"` + DisambiguatePrompt string `yaml:"disambiguate_prompt"` + } `yaml:"llm"` +} + +var appConfig Config + +func loadConfig(path string) error { + data, err := ioutil.ReadFile(path) + if err != nil { + return err + } + return yaml.Unmarshal(data, &appConfig) +} + +// ExtractKeywords calls LLM to extract keywords from user message +func (llm *LLMClient) ExtractKeywords(ctx context.Context, message string) ([]string, error) { + prompt, err := renderPrompt(appConfig.LLM.ExtractKeywordsPrompt, map[string]string{"Message": message}) + if err != nil { + log.Printf("[CONFIG] Failed to render ExtractKeywords prompt: %v", err) + return nil, err + } + log.Printf("[LLM] ExtractKeywords prompt: %q", prompt) + resp, err := llm.openAICompletion(ctx, prompt) + log.Printf("[LLM] ExtractKeywords response: %q, err: %v", resp, err) + if err != nil { + return nil, err + } + var keywords []string + if err := json.Unmarshal([]byte(resp), &keywords); err == nil { + return keywords, nil + } + // fallback: try splitting by comma + for _, k := range bytes.Split([]byte(resp), []byte{','}) { + kw := strings.TrimSpace(string(k)) + if kw != "" { + keywords = append(keywords, kw) + } + } + return keywords, nil +} + +// DisambiguateBestMatch calls LLM to pick best match from candidates +func (llm *LLMClient) DisambiguateBestMatch(ctx context.Context, message string, candidates []Reason) (string, error) { + entries, _ := json.Marshal(candidates) + prompt, err := renderPrompt(appConfig.LLM.DisambiguatePrompt, map[string]string{"Entries": string(entries), "Message": message}) + if err != nil { + log.Printf("[CONFIG] Failed to render Disambiguate prompt: %v", err) + return "", err + } + log.Printf("[LLM] DisambiguateBestMatch prompt: %q", prompt) + resp, err := llm.openAICompletion(ctx, prompt) + log.Printf("[LLM] DisambiguateBestMatch response: %q, err: %v", resp, err) + if err != nil { + return "", err + } + id := strings.TrimSpace(resp) + if id == "none" || id == "null" { + return "", nil + } + return id, nil +} + +// openAICompletion is a minimal OpenAI API call (text-davinci-003 or gpt-3.5-turbo-instruct) +func (llm *LLMClient) openAICompletion(ctx context.Context, prompt string) (string, error) { + apiURL := llm.BaseURL + if apiURL == "" { + apiURL = "https://api.openai.com/v1/completions" + } + log.Printf("[LLM] openAICompletion POST %s | prompt: %q", apiURL, prompt) + body := map[string]interface{}{ + "model": "text-davinci-003", + "prompt": prompt, + "max_tokens": 64, + "temperature": 0, + } + jsonBody, _ := json.Marshal(body) + req, _ := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewBuffer(jsonBody)) + if llm.APIKey != "" { + req.Header.Set("Authorization", "Bearer "+llm.APIKey) + } + req.Header.Set("Content-Type", "application/json") + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + log.Printf("[LLM] openAICompletion error: %v", err) + return "", err + } + defer resp.Body.Close() + var result struct { + Choices []struct { + Text string `json:"text"` + } `json:"choices"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + log.Printf("[LLM] openAICompletion decode error: %v", err) + return "", err + } + if len(result.Choices) == 0 { + log.Printf("[LLM] openAICompletion: no choices returned") + return "", nil + } + log.Printf("[LLM] openAICompletion: got text: %q", result.Choices[0].Text) + return result.Choices[0].Text, nil +} + +// naiveKeywordExtract splits message into lowercase words (placeholder for LLM) +func naiveKeywordExtract(msg string) []string { + // TODO: Replace with LLM call + words := make(map[string]struct{}) + for _, w := range strings.FieldsFunc(strings.ToLower(msg), func(r rune) bool { + return r < 'a' || r > 'z' && r < 'á' || r > 'ű' + }) { + words[w] = struct{}{} + } + res := make([]string, 0, len(words)) + for w := range words { + res = append(res, w) + } + return res +} + +// findCandidates returns reasons with overlapping keywords +func findCandidates(keywords []string) []Reason { + kwSet := make(map[string]struct{}) + for _, k := range keywords { + kwSet[k] = struct{}{} + } + var candidates []Reason + for _, r := range reasonsDB { + for _, k := range r.Keywords { + if _, ok := kwSet[strings.ToLower(k)]; ok { + candidates = append(candidates, r) + break + } + } + } + return candidates +} + +// sumProcedures calculates total price and duration +func sumProcedures(procs []Procedure) (int, int) { + totalPrice := 0 + totalDuration := 0 + for _, p := range procs { + totalPrice += p.Price + totalDuration += p.DurationMin + } + return totalPrice, totalDuration +} + +// renderPrompt renders a Go template with the given data +func renderPrompt(tmplStr string, data any) (string, error) { + tmpl, err := template.New("").Parse(tmplStr) + if err != nil { + return "", err + } + var buf bytes.Buffer + if err := tmpl.Execute(&buf, data); err != nil { + return "", err + } + return buf.String(), nil +} + +func main() { + if err := loadConfig("config.yaml"); err != nil { + log.Fatalf("Failed to load config.yaml: %v", err) + } + log.Printf("Loaded config: %+v", appConfig) + if err := loadYAMLDB("db.yaml"); err != nil { + log.Fatalf("Failed to load db.yaml: %v", err) + } + fmt.Printf("Loaded %d reasons from db.yaml\n", len(reasonsDB)) + + llm := &LLMClient{ + APIKey: os.Getenv("OPENAI_API_KEY"), + BaseURL: os.Getenv("OPENAI_BASE_URL"), // e.g. http://localhost:1234/v1/completions + } + r := gin.Default() + r.POST("/chat", func(c *gin.Context) { + var req ChatRequest + if err := c.ShouldBindJSON(&req); err != nil { + log.Printf("[ERROR] Invalid request: %v", err) + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"}) + return + } + ctx := c.Request.Context() + keywords, err := llm.ExtractKeywords(ctx, req.Message) + candidates := findCandidates(keywords) + bestID := "" + if len(candidates) > 0 && err == nil { + bestID, err = llm.DisambiguateBestMatch(ctx, req.Message, candidates) + } + logRequest(req, keywords, candidates, bestID, err) + if err != nil || len(keywords) == 0 || len(candidates) == 0 || bestID == "" { + c.JSON(http.StatusOK, ChatResponse{Match: nil}) + return + } + var best *Reason + for i := range candidates { + if candidates[i].ID == bestID { + best = &candidates[i] + break + } + } + if best == nil { + c.JSON(http.StatusOK, ChatResponse{Match: nil}) + return + } + totalPrice, totalDuration := sumProcedures(best.Procedures) + log.Printf("[TRACE] Responding with match: %q, totalPrice: %d, totalDuration: %d, notes: %q", best.ID, totalPrice, totalDuration, best.Notes) + c.JSON(http.StatusOK, ChatResponse{ + Match: &best.ID, + Procedures: best.Procedures, + TotalPrice: totalPrice, + TotalDuration: totalDuration, + Notes: best.Notes, + }) + }) + + r.Run(":8080") +} + +// logRequest logs incoming chat requests and extracted info +func logRequest(req ChatRequest, keywords []string, candidates []Reason, bestID string, err error) { + log.Printf("[TRACE] %s | message: %q | keywords: %v | candidates: %v | bestID: %q | err: %v", + time.Now().Format(time.RFC3339), req.Message, keywords, getCandidateIDs(candidates), bestID, err) +} + +func getCandidateIDs(candidates []Reason) []string { + ids := make([]string, len(candidates)) + for i, c := range candidates { + ids[i] = c.ID + } + return ids +} diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..cbe6193 --- /dev/null +++ b/main_test.go @@ -0,0 +1,145 @@ +package main + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/gin-gonic/gin" +) + +type testDB struct { + file string + data string +} + +func (tdb *testDB) setup() { + err := os.WriteFile(tdb.file, []byte(tdb.data), 0644) + if err != nil { + panic(err) + } +} + +func (tdb *testDB) teardown() { + _ = os.Remove(tdb.file) +} + +func TestChatEndpoint_MatchFound(t *testing.T) { + tdb := testDB{ + file: "db.yaml", + data: ` +- id: deworming + reason: Deworming for dogs + keywords: ["worms", "deworming", "parasite"] + procedures: + - name: Deworming tablet + price: 30 + duration_minutes: 10 + - name: Bloodwork + price: 35 + duration_minutes: 35 + notes: Bloodwork ensures organs are safe for treatment. +- id: vaccination + reason: Annual vaccination + keywords: ["vaccine", "vaccination", "shots"] + procedures: + - name: Vaccine injection + price: 50 + duration_minutes: 15 +`, + } + tdb.setup() + defer tdb.teardown() + + if err := loadYAMLDB(tdb.file); err != nil { + t.Fatalf("Failed to load test db: %v", err) + } + + r := setupRouter() + + w := httptest.NewRecorder() + body := map[string]string{"message": "My dog needs deworming and bloodwork"} + jsonBody, _ := json.Marshal(body) + req, _ := http.NewRequest("POST", "/chat", bytes.NewBuffer(jsonBody)) + req.Header.Set("Content-Type", "application/json") + + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("Expected 200, got %d", w.Code) + } + respBody, _ := io.ReadAll(w.Body) + if !bytes.Contains(respBody, []byte("deworming")) { + t.Errorf("Expected match for deworming, got %s", string(respBody)) + } +} + +func TestChatEndpoint_NoMatch(t *testing.T) { + tdb := testDB{ + file: "db.yaml", + data: ` +- id: vaccination + reason: Annual vaccination + keywords: ["vaccine", "vaccination", "shots"] + procedures: + - name: Vaccine injection + price: 50 + duration_minutes: 15 +`, + } + tdb.setup() + defer tdb.teardown() + + if err := loadYAMLDB(tdb.file); err != nil { + t.Fatalf("Failed to load test db: %v", err) + } + + r := setupRouter() + + w := httptest.NewRecorder() + body := map[string]string{"message": "My dog has worms"} + jsonBody, _ := json.Marshal(body) + req, _ := http.NewRequest("POST", "/chat", bytes.NewBuffer(jsonBody)) + req.Header.Set("Content-Type", "application/json") + + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("Expected 200, got %d", w.Code) + } + respBody, _ := io.ReadAll(w.Body) + if !bytes.Contains(respBody, []byte(`"match":null`)) { + t.Errorf("Expected no match, got %s", string(respBody)) + } +} + +func setupRouter() *gin.Engine { + r := gin.Default() + r.POST("/chat", func(c *gin.Context) { + var req ChatRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"}) + return + } + keywords := naiveKeywordExtract(req.Message) + candidates := findCandidates(keywords) + if len(candidates) == 0 { + c.JSON(http.StatusOK, ChatResponse{Match: nil}) + return + } + best := candidates[0] + totalPrice, totalDuration := sumProcedures(best.Procedures) + c.JSON(http.StatusOK, ChatResponse{ + Match: &best.ID, + Procedures: best.Procedures, + TotalPrice: totalPrice, + TotalDuration: totalDuration, + Notes: best.Notes, + }) + }) + return r +} diff --git a/maindb.yaml b/maindb.yaml new file mode 100644 index 0000000..63e3f9a --- /dev/null +++ b/maindb.yaml @@ -0,0 +1,104 @@ +- id: deworming + reason: "Féregtelenítés kutyának" + keywords: ["féreg", "féregtelenítés", "parazita", "bélféreg", "kutya"] + procedures: + - name: "Alap vérvizsgálat" + price: 12000 + duration_minutes: 30 + - name: "Féregtelenítő kezelés" + price: 8000 + duration_minutes: 15 + notes: "A kezelés előtt vérvizsgálat szükséges a biztonságos gyógyszeradás miatt." + +- id: vaccination + reason: "Oltás kutyának" + keywords: ["oltás", "vakcina", "oltani", "kutyabetegség", "veszettség"] + procedures: + - name: "Általános állapotfelmérés" + price: 6000 + duration_minutes: 15 + - name: "Oltás beadása" + price: 10000 + duration_minutes: 10 + notes: "A kutyák oltási programja eltérhet az életkortól és korábbi oltásoktól függően." + +- id: neutering + reason: "Ivartalanítás macskának" + keywords: ["ivartalanítás", "műtét", "macska", "kandúr", "nőstény"] + procedures: + - name: "Műtéti előzetes vizsgálat" + price: 15000 + duration_minutes: 30 + - name: "Ivartalanító műtét" + price: 35000 + duration_minutes: 90 + notes: "A műtét után 2-3 nap pihenő szükséges." + +- id: dental_cleaning + reason: "Fogkő eltávolítás kutyának" + keywords: ["fog", "fogkő", "fogsor", "fogtisztítás", "kutyafog"] + procedures: + - name: "Altatás előtti vizsgálat" + price: 12000 + duration_minutes: 20 + - name: "Fogkő eltávolítás ultrahanggal" + price: 25000 + duration_minutes: 60 + notes: "Az altatás kockázata miatt minden esetben szükséges előzetes vizsgálat." + +- id: checkup + reason: "Általános állapotfelmérés" + keywords: ["vizsgálat", "ellenőrzés", "checkup", "állapotfelmérés"] + procedures: + - name: "Teljes fizikai vizsgálat" + price: 10000 + duration_minutes: 30 + notes: "Évente legalább egyszer javasolt a rutin állapotfelmérés." + +- id: allergy + reason: "Allergiás tünetek vizsgálata" + keywords: ["allergia", "viszketés", "kiütés", "bőrpír", "allergiás"] + procedures: + - name: "Bőr- és vérvizsgálat" + price: 20000 + duration_minutes: 45 + notes: "Az allergia gyakran étel vagy környezeti tényező miatt alakul ki." + +- id: ultrasound + reason: "Ultrahangos vizsgálat" + keywords: ["ultrahang", "has", "vizsgálat", "UH"] + procedures: + - name: "Ultrahang vizsgálat" + price: 18000 + duration_minutes: 30 + notes: "Terhesség vagy belső szervi problémák vizsgálatára gyakran használt módszer." + +- id: bloodwork + reason: "Laborvizsgálat vérből" + keywords: ["vér", "vérvizsgálat", "labor", "teszt"] + procedures: + - name: "Teljes vérkép" + price: 15000 + duration_minutes: 20 + notes: "Sok más vizsgálat alapja a laboreredmény." + +- id: xray + reason: "Röntgenfelvétel" + keywords: ["röntgen", "csont", "felvétel", "törés"] + procedures: + - name: "Röntgen vizsgálat" + price: 16000 + duration_minutes: 25 + notes: "Törések, csontelváltozások vizsgálatára javasolt." + +- id: diarrhea + reason: "Hasmenés vizsgálata" + keywords: ["hasmenés", "hányás", "gyomor", "bél"] + procedures: + - name: "Állatorvosi konzultáció" + price: 8000 + duration_minutes: 20 + - name: "Székletvizsgálat" + price: 10000 + duration_minutes: 30 + notes: "Akut hasmenés esetén mindig javasolt a mihamarabbi vizsgálat." diff --git a/run.sh b/run.sh new file mode 100755 index 0000000..7362815 --- /dev/null +++ b/run.sh @@ -0,0 +1,3 @@ +export OPENAI_BASE_URL=http://localhost:1234/v1/completions +export OPENAI_API_KEY=sk-no-key-needed # (if LM Studio doesn't require a real key) +go run main.go \ No newline at end of file diff --git a/test.sh b/test.sh new file mode 100755 index 0000000..b001949 --- /dev/null +++ b/test.sh @@ -0,0 +1,5 @@ +curl -s -X POST http://localhost:8080/chat \ + -H "Content-Type: application/json" \ + -d '{ + "message": "A kutyámnak féregtelenítésre lenne szükség mert férgeket láttam a székletében." + }' \ No newline at end of file