diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..17ec726 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +reasons.bleve diff --git a/chat_service.go b/chat_service.go index b3346d2..e8e6bb7 100644 --- a/chat_service.go +++ b/chat_service.go @@ -2,6 +2,7 @@ package main import ( "context" + "fmt" "net/http" "github.com/gin-gonic/gin" @@ -9,11 +10,12 @@ import ( ) type ChatService struct { - LLM *LLMClient + LLM *LLMClient + reasonsDB ReasonDB } -func NewChatService(llm *LLMClient) *ChatService { - return &ChatService{LLM: llm} +func NewChatService(llm *LLMClient, db ReasonDB) *ChatService { + return &ChatService{LLM: llm, reasonsDB: db} } func (cs *ChatService) HandleChat(c *gin.Context) { @@ -22,15 +24,15 @@ func (cs *ChatService) HandleChat(c *gin.Context) { if err != nil { return } - keywords, err := cs.extractKeywords(ctx, req.Message) + keywordsResponse, err := cs.getKeywordsResponse(ctx, req.Message) if err != nil { - cs.logChat(req, keywords, nil, "", err) + cs.logChat(req, keywordsResponse, nil, "", err) c.JSON(http.StatusOK, ChatResponse{Match: nil}) return } - kwArr := cs.keywordsToStrings(keywords["keyword"]) - best, bestID, candidates, err := cs.findBestCandidate(ctx, req, kwArr) - cs.logChat(req, keywords, candidates, bestID, err) + keywordList := cs.keywordsToStrings(keywordsResponse["keyword"]) + best, err := cs.findVisitReason(ctx, req, keywordList) + resp := cs.buildResponse(best) c.JSON(http.StatusOK, resp) } @@ -45,7 +47,7 @@ func (cs *ChatService) parseRequest(c *gin.Context) (ChatRequest, error) { return req, nil } -func (cs *ChatService) extractKeywords(ctx context.Context, message string) (map[string]interface{}, error) { +func (cs *ChatService) getKeywordsResponse(ctx context.Context, message string) (map[string]interface{}, error) { keywords, err := cs.LLM.ExtractKeywords(ctx, message) return keywords, err } @@ -65,38 +67,52 @@ func (cs *ChatService) keywordsToStrings(kwIface interface{}) []string { return kwArr } -func (cs *ChatService) findBestCandidate(ctx context.Context, req ChatRequest, kwArr []string) (*Reason, string, []Reason, error) { - candidates := findCandidates(kwArr) +func (cs *ChatService) findVisitReason(ctx context.Context, req ChatRequest, keywordList []string) (*Reason, error) { + logrus.WithFields(logrus.Fields{ + "keywords": keywordList, + "message": req.Message, + }).Info("Finding visit reason candidates") + + candidateReasonse, err := cs.reasonsDB.findCandidates(keywordList) + logrus.WithFields(logrus.Fields{ + "candidates": candidateReasonse, + "error": err, + }).Info("Candidate reasons found") + if err != nil { + return nil, err + } bestID := "" - var err error - if len(candidates) > 0 { - bestID, err = cs.LLM.DisambiguateBestMatch(ctx, req.Message, candidates) + if len(candidateReasonse) > 0 { + bestID, err = cs.LLM.DisambiguateBestMatch(ctx, req.Message, candidateReasonse) + logrus.WithFields(logrus.Fields{ + "bestID": bestID, + "error": err, + }).Info("Disambiguated best match") } - var best *Reason - for i := range candidates { - if candidates[i].ID == bestID { - best = &candidates[i] - break - } + reason, err := cs.reasonsDB.findById(bestID) + if err != nil { + return nil, fmt.Errorf("findById: %w", err) } - if err != nil || len(kwArr) == 0 || len(candidates) == 0 || bestID == "" || best == nil { - return nil, bestID, candidates, err - } - return best, bestID, candidates, nil + return &reason, nil + } func (cs *ChatService) buildResponse(best *Reason) ChatResponse { if best == nil { - return ChatResponse{Match: nil} + resp := ChatResponse{Match: nil} + logrus.WithFields(logrus.Fields{"response": resp}).Info("Build response: no match") + return resp } totalPrice, totalDuration := sumProcedures(best.Procedures) - return ChatResponse{ + resp := ChatResponse{ Match: &best.ID, Procedures: best.Procedures, TotalPrice: totalPrice, TotalDuration: totalDuration, Notes: best.Notes, } + logrus.WithFields(logrus.Fields{"response": resp}).Info("Build response: match found") + return resp } func (cs *ChatService) logChat(req ChatRequest, keywords map[string]interface{}, candidates []Reason, bestID string, err error) { diff --git a/config.yaml b/config.yaml index a4ffdc0..561afb2 100644 --- a/config.yaml +++ b/config.yaml @@ -1,4 +1,4 @@ llm: - extract_keywords_prompt: "Translate [{{.Message}}] to English, then output only 3–5 comma-separated veterinary-related keywords derived strictly from [{{.Message}}]. example output [\"keyword1\",\"keyword2\"] No other text, no extra punctuation, no explanations, no quotes, no formatting." - 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." + extract_keywords_prompt: "Translate [{{.Message}}] to English, then output only 3–5 comma-separated veterinary-related keywords IN ENGLISH derived strictly from [{{.Message}}]. example output [\"keyword1\",\"keyword2\"] No other text, no extra punctuation, no explanations, no quotes, no formatting." + disambiguate_prompt: "Given these possible vet visit reasons: [{{.Entries}}], choose the single best match for this user message: {{.Message}}. Reply with id ex {\"visitReason\":\"bloodwork\"} No other text, no extra punctuation, no explanations, no quotes, no formatting." diff --git a/db.go b/db.go index 9590272..4df460e 100644 --- a/db.go +++ b/db.go @@ -1,38 +1,97 @@ package main import ( + "errors" "io/ioutil" + "os" "strings" + "github.com/sirupsen/logrus" "gopkg.in/yaml.v3" + + "github.com/blevesearch/bleve/v2" ) -var reasonsDB []Reason +type ReasonDB struct { + reasonsDB []Reason + reasonsIdx bleve.Index +} -func loadYAMLDB(path string) error { +func NewReasonDB() ReasonDB { + db := ReasonDB{} + db.init() + return db +} +func (rdb *ReasonDB) findById(reasonId string) (Reason, error) { + for _, reason := range rdb.reasonsDB { + if reason.ID == reasonId { + return reason, nil + } + } + return Reason{}, errors.New("reason not found") +} +func (rdb *ReasonDB) init() { + idxPath := "reasons.bleve" + if _, err := os.Stat(idxPath); err == nil { + rdb.reasonsIdx, err = bleve.Open(idxPath) + if err != nil { + panic(err) + } + } else if os.IsNotExist(err) { + rdb.reasonsIdx, err = bleve.New(idxPath, bleve.NewIndexMapping()) + if err != nil { + panic(err) + } + } else { + panic(err) + } + err := rdb.loadYAMLDB("db.yaml") + if err != nil { + logrus.Fatalf("Failed to load db.yaml: %v", err) + panic(err) + } +} + +func (rdb *ReasonDB) loadYAMLDB(path string) error { data, err := ioutil.ReadFile(path) if err != nil { return err } - return yaml.Unmarshal(data, &reasonsDB) + if err := yaml.Unmarshal(data, &rdb.reasonsDB); err != nil { + return err + } + return rdb.indexReasons(rdb.reasonsDB) +} + +func (rdb *ReasonDB) indexReasons(reasons []Reason) error { + batch := rdb.reasonsIdx.NewBatch() + for _, reason := range reasons { + if err := batch.Index(reason.ID, reason); err != nil { + return err + } + } + return rdb.reasonsIdx.Batch(batch) } // findCandidates returns reasons with overlapping keywords -func findCandidates(keywords []string) []Reason { - kwSet := make(map[string]struct{}) - for _, k := range keywords { - kwSet[k] = struct{}{} +func (rdb *ReasonDB) findCandidates(keywords []string) ([]Reason, error) { + query := bleve.NewMatchQuery(strings.Join(keywords, " ")) + search := bleve.NewSearchRequest(query) + searchResults, err := rdb.reasonsIdx.Search(search) + if err != nil { + return nil, err } + var candidates []Reason - for _, r := range reasonsDB { - for _, k := range r.Keywords { - if _, ok := kwSet[strings.ToLower(k)]; ok { + for _, hit := range searchResults.Hits { + for _, r := range rdb.reasonsDB { + if r.ID == hit.ID { candidates = append(candidates, r) break } } } - return candidates + return candidates, nil } // sumProcedures calculates total price and duration diff --git a/db.yaml b/db.yaml index 63e3f9a..77bb4d3 100644 --- a/db.yaml +++ b/db.yaml @@ -1,6 +1,6 @@ - id: deworming reason: "Féregtelenítés kutyának" - keywords: ["féreg", "féregtelenítés", "parazita", "bélféreg", "kutya"] + keywords: ["worm", "deworming", "parasite", "intestinal worm", "dog"] procedures: - name: "Alap vérvizsgálat" price: 12000 @@ -12,7 +12,7 @@ - id: vaccination reason: "Oltás kutyának" - keywords: ["oltás", "vakcina", "oltani", "kutyabetegség", "veszettség"] + keywords: ["vaccination", "vaccine", "to vaccinate", "dog disease", "rabies"] procedures: - name: "Általános állapotfelmérés" price: 6000 @@ -24,7 +24,7 @@ - id: neutering reason: "Ivartalanítás macskának" - keywords: ["ivartalanítás", "műtét", "macska", "kandúr", "nőstény"] + keywords: ["neutering", "surgery", "cat", "tomcat", "female cat"] procedures: - name: "Műtéti előzetes vizsgálat" price: 15000 @@ -36,7 +36,7 @@ - id: dental_cleaning reason: "Fogkő eltávolítás kutyának" - keywords: ["fog", "fogkő", "fogsor", "fogtisztítás", "kutyafog"] + keywords: ["tooth", "tartar", "dentition", "tooth cleaning", "dog teeth","plaque"] procedures: - name: "Altatás előtti vizsgálat" price: 12000 @@ -48,7 +48,7 @@ - id: checkup reason: "Általános állapotfelmérés" - keywords: ["vizsgálat", "ellenőrzés", "checkup", "állapotfelmérés"] + keywords: ["examination", "checkup", "check-up", "general assessment"] procedures: - name: "Teljes fizikai vizsgálat" price: 10000 @@ -57,7 +57,7 @@ - id: allergy reason: "Allergiás tünetek vizsgálata" - keywords: ["allergia", "viszketés", "kiütés", "bőrpír", "allergiás"] + keywords: ["allergy", "itching", "rash", "redness", "allergic"] procedures: - name: "Bőr- és vérvizsgálat" price: 20000 @@ -66,7 +66,7 @@ - id: ultrasound reason: "Ultrahangos vizsgálat" - keywords: ["ultrahang", "has", "vizsgálat", "UH"] + keywords: ["ultrasound", "abdomen", "examination", "US"] procedures: - name: "Ultrahang vizsgálat" price: 18000 @@ -75,7 +75,7 @@ - id: bloodwork reason: "Laborvizsgálat vérből" - keywords: ["vér", "vérvizsgálat", "labor", "teszt"] + keywords: ["blood", "blood test", "lab", "test"] procedures: - name: "Teljes vérkép" price: 15000 @@ -84,7 +84,7 @@ - id: xray reason: "Röntgenfelvétel" - keywords: ["röntgen", "csont", "felvétel", "törés"] + keywords: ["x-ray", "bone", "scan", "fracture"] procedures: - name: "Röntgen vizsgálat" price: 16000 @@ -93,7 +93,7 @@ - id: diarrhea reason: "Hasmenés vizsgálata" - keywords: ["hasmenés", "hányás", "gyomor", "bél"] + keywords: ["diarrhea", "vomiting", "stomach", "intestine"] procedures: - name: "Állatorvosi konzultáció" price: 8000 diff --git a/go.mod b/go.mod index 892f757..988c3bb 100644 --- a/go.mod +++ b/go.mod @@ -3,12 +3,32 @@ module vetrag go 1.25 require ( + github.com/blevesearch/bleve/v2 v2.5.3 github.com/gin-gonic/gin v1.11.0 github.com/sirupsen/logrus v1.9.3 gopkg.in/yaml.v3 v3.0.1 ) require ( + github.com/RoaringBitmap/roaring/v2 v2.4.5 // indirect + github.com/bits-and-blooms/bitset v1.22.0 // indirect + github.com/blevesearch/bleve_index_api v1.2.8 // indirect + github.com/blevesearch/geo v0.2.4 // indirect + github.com/blevesearch/go-faiss v1.0.25 // indirect + github.com/blevesearch/go-porterstemmer v1.0.3 // indirect + github.com/blevesearch/gtreap v0.1.1 // indirect + github.com/blevesearch/mmap-go v1.0.4 // indirect + github.com/blevesearch/scorch_segment_api/v2 v2.3.10 // indirect + github.com/blevesearch/segment v0.9.1 // indirect + github.com/blevesearch/snowballstem v0.9.0 // indirect + github.com/blevesearch/upsidedown_store_api v1.0.2 // indirect + github.com/blevesearch/vellum v1.1.0 // indirect + github.com/blevesearch/zapx/v11 v11.4.2 // indirect + github.com/blevesearch/zapx/v12 v12.4.2 // indirect + github.com/blevesearch/zapx/v13 v13.4.2 // indirect + github.com/blevesearch/zapx/v14 v14.4.2 // indirect + github.com/blevesearch/zapx/v15 v15.4.2 // indirect + github.com/blevesearch/zapx/v16 v16.2.4 // indirect 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 @@ -19,17 +39,21 @@ require ( 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/golang/protobuf v1.5.0 // indirect + github.com/golang/snappy v0.0.4 // 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/mschoch/smat v0.2.0 // 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.etcd.io/bbolt v1.4.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 diff --git a/go.sum b/go.sum index e29ec95..7ec9598 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,44 @@ +github.com/RoaringBitmap/roaring/v2 v2.4.5 h1:uGrrMreGjvAtTBobc0g5IrW1D5ldxDQYe2JW2gggRdg= +github.com/RoaringBitmap/roaring/v2 v2.4.5/go.mod h1:FiJcsfkGje/nZBZgCu0ZxCPOKD/hVXDS2dXi7/eUFE0= +github.com/bits-and-blooms/bitset v1.12.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= +github.com/bits-and-blooms/bitset v1.22.0 h1:Tquv9S8+SGaS3EhyA+up3FXzmkhxPGjQQCkcs2uw7w4= +github.com/bits-and-blooms/bitset v1.22.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= +github.com/blevesearch/bleve/v2 v2.5.3 h1:9l1xtKaETv64SZc1jc4Sy0N804laSa/LeMbYddq1YEM= +github.com/blevesearch/bleve/v2 v2.5.3/go.mod h1:Z/e8aWjiq8HeX+nW8qROSxiE0830yQA071dwR3yoMzw= +github.com/blevesearch/bleve_index_api v1.2.8 h1:Y98Pu5/MdlkRyLM0qDHostYo7i+Vv1cDNhqTeR4Sy6Y= +github.com/blevesearch/bleve_index_api v1.2.8/go.mod h1:rKQDl4u51uwafZxFrPD1R7xFOwKnzZW7s/LSeK4lgo0= +github.com/blevesearch/geo v0.2.4 h1:ECIGQhw+QALCZaDcogRTNSJYQXRtC8/m8IKiA706cqk= +github.com/blevesearch/geo v0.2.4/go.mod h1:K56Q33AzXt2YExVHGObtmRSFYZKYGv0JEN5mdacJJR8= +github.com/blevesearch/go-faiss v1.0.25 h1:lel1rkOUGbT1CJ0YgzKwC7k+XH0XVBHnCVWahdCXk4U= +github.com/blevesearch/go-faiss v1.0.25/go.mod h1:OMGQwOaRRYxrmeNdMrXJPvVx8gBnvE5RYrr0BahNnkk= +github.com/blevesearch/go-porterstemmer v1.0.3 h1:GtmsqID0aZdCSNiY8SkuPJ12pD4jI+DdXTAn4YRcHCo= +github.com/blevesearch/go-porterstemmer v1.0.3/go.mod h1:angGc5Ht+k2xhJdZi511LtmxuEf0OVpvUUNrwmM1P7M= +github.com/blevesearch/gtreap v0.1.1 h1:2JWigFrzDMR+42WGIN/V2p0cUvn4UP3C4Q5nmaZGW8Y= +github.com/blevesearch/gtreap v0.1.1/go.mod h1:QaQyDRAT51sotthUWAH4Sj08awFSSWzgYICSZ3w0tYk= +github.com/blevesearch/mmap-go v1.0.4 h1:OVhDhT5B/M1HNPpYPBKIEJaD0F3Si+CrEKULGCDPWmc= +github.com/blevesearch/mmap-go v1.0.4/go.mod h1:EWmEAOmdAS9z/pi/+Toxu99DnsbhG1TIxUoRmJw/pSs= +github.com/blevesearch/scorch_segment_api/v2 v2.3.10 h1:Yqk0XD1mE0fDZAJXTjawJ8If/85JxnLd8v5vG/jWE/s= +github.com/blevesearch/scorch_segment_api/v2 v2.3.10/go.mod h1:Z3e6ChN3qyN35yaQpl00MfI5s8AxUJbpTR/DL8QOQ+8= +github.com/blevesearch/segment v0.9.1 h1:+dThDy+Lvgj5JMxhmOVlgFfkUtZV2kw49xax4+jTfSU= +github.com/blevesearch/segment v0.9.1/go.mod h1:zN21iLm7+GnBHWTao9I+Au/7MBiL8pPFtJBJTsk6kQw= +github.com/blevesearch/snowballstem v0.9.0 h1:lMQ189YspGP6sXvZQ4WZ+MLawfV8wOmPoD/iWeNXm8s= +github.com/blevesearch/snowballstem v0.9.0/go.mod h1:PivSj3JMc8WuaFkTSRDW2SlrulNWPl4ABg1tC/hlgLs= +github.com/blevesearch/upsidedown_store_api v1.0.2 h1:U53Q6YoWEARVLd1OYNc9kvhBMGZzVrdmaozG2MfoB+A= +github.com/blevesearch/upsidedown_store_api v1.0.2/go.mod h1:M01mh3Gpfy56Ps/UXHjEO/knbqyQ1Oamg8If49gRwrQ= +github.com/blevesearch/vellum v1.1.0 h1:CinkGyIsgVlYf8Y2LUQHvdelgXr6PYuvoDIajq6yR9w= +github.com/blevesearch/vellum v1.1.0/go.mod h1:QgwWryE8ThtNPxtgWJof5ndPfx0/YMBh+W2weHKPw8Y= +github.com/blevesearch/zapx/v11 v11.4.2 h1:l46SV+b0gFN+Rw3wUI1YdMWdSAVhskYuvxlcgpQFljs= +github.com/blevesearch/zapx/v11 v11.4.2/go.mod h1:4gdeyy9oGa/lLa6D34R9daXNUvfMPZqUYjPwiLmekwc= +github.com/blevesearch/zapx/v12 v12.4.2 h1:fzRbhllQmEMUuAQ7zBuMvKRlcPA5ESTgWlDEoB9uQNE= +github.com/blevesearch/zapx/v12 v12.4.2/go.mod h1:TdFmr7afSz1hFh/SIBCCZvcLfzYvievIH6aEISCte58= +github.com/blevesearch/zapx/v13 v13.4.2 h1:46PIZCO/ZuKZYgxI8Y7lOJqX3Irkc3N8W82QTK3MVks= +github.com/blevesearch/zapx/v13 v13.4.2/go.mod h1:knK8z2NdQHlb5ot/uj8wuvOq5PhDGjNYQQy0QDnopZk= +github.com/blevesearch/zapx/v14 v14.4.2 h1:2SGHakVKd+TrtEqpfeq8X+So5PShQ5nW6GNxT7fWYz0= +github.com/blevesearch/zapx/v14 v14.4.2/go.mod h1:rz0XNb/OZSMjNorufDGSpFpjoFKhXmppH9Hi7a877D8= +github.com/blevesearch/zapx/v15 v15.4.2 h1:sWxpDE0QQOTjyxYbAVjt3+0ieu8NCE0fDRaFxEsp31k= +github.com/blevesearch/zapx/v15 v15.4.2/go.mod h1:1pssev/59FsuWcgSnTa0OeEpOzmhtmr/0/11H0Z8+Nw= +github.com/blevesearch/zapx/v16 v16.2.4 h1:tGgfvleXTAkwsD5mEzgM3zCS/7pgocTCnO1oyAUjlww= +github.com/blevesearch/zapx/v16 v16.2.4/go.mod h1:Rti/REtuuMmzwsI8/C/qIzRaEoSK/wiFYw5e5ctUKKs= 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= @@ -25,9 +66,16 @@ 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/golang/protobuf v1.5.0 h1:LUVKkCeviFUMKqHa4tXIIij/lbhnMbP7Fn5wKdKkRh4= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= +github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 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/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 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= @@ -40,6 +88,8 @@ github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OH 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/mschoch/smat v0.2.0 h1:8imxQsjDm8yFEAVBe7azKmKSgzSkZXDuKkSq9374khM= +github.com/mschoch/smat v0.2.0/go.mod h1:kc9mz7DoBKqDyiRL7VZN8KvXQMWeTaVnttLRXOlotKw= 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= @@ -64,6 +114,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS 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.etcd.io/bbolt v1.4.0 h1:TU77id3TnN/zKr7CO/uk+fBCwF2jGcMuw2B/FMAzYIk= +go.etcd.io/bbolt v1.4.0/go.mod h1:AsD+OCi/qPN1giOX1aiLAha3o1U8rAz65bvN4j0sRuk= 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= @@ -76,6 +128,7 @@ 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.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= @@ -84,10 +137,13 @@ 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= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= 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.0/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/llm.go b/llm.go index 702b430..a653201 100644 --- a/llm.go +++ b/llm.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "fmt" "net/http" "strings" "text/template" @@ -61,6 +62,13 @@ func (llm *LLMClient) ExtractKeywords(ctx context.Context, message string) (map[ // DisambiguateBestMatch calls LLM to pick best match from candidates func (llm *LLMClient) DisambiguateBestMatch(ctx context.Context, message string, candidates []Reason) (string, error) { + format := map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "visitReason": map[string]interface{}{"type": "string"}, + }, + "required": []string{"visitReason"}, + } entries, _ := json.Marshal(candidates) prompt, err := renderPrompt(appConfig.LLM.DisambiguatePrompt, map[string]string{"Entries": string(entries), "Message": message}) if err != nil { @@ -68,16 +76,22 @@ func (llm *LLMClient) DisambiguateBestMatch(ctx context.Context, message string, return "", err } logrus.WithField("prompt", prompt).Info("[LLM] DisambiguateBestMatch prompt") - resp, err := llm.openAICompletion(ctx, prompt, nil) + resp, err := llm.openAICompletion(ctx, prompt, format) logrus.WithFields(logrus.Fields{"response": resp, "err": err}).Info("[LLM] DisambiguateBestMatch response") if err != nil { return "", err } - id := strings.TrimSpace(resp) - if id == "none" || id == "null" { - return "", nil + var parsed map[string]string + if err := json.Unmarshal([]byte(resp), &parsed); err != nil { + return "", fmt.Errorf("failed to unmarshal disambiguation response: %w", err) } - return id, nil + + visitReason := strings.TrimSpace(parsed["visitReason"]) + if visitReason == "" { + return "", fmt.Errorf("visitReason not found in response") + } + + return visitReason, nil } // openAICompletion calls Ollama API with prompt and structure, returns structured result diff --git a/main.go b/main.go index aeaf817..369f34a 100644 --- a/main.go +++ b/main.go @@ -1,7 +1,6 @@ package main import ( - "fmt" "os" "github.com/gin-gonic/gin" @@ -16,10 +15,8 @@ func main() { logrus.Fatalf("Failed to load config.yaml: %v", err) } logrus.Infof("Loaded config: %+v", appConfig) - if err := loadYAMLDB("db.yaml"); err != nil { - logrus.Fatalf("Failed to load db.yaml: %v", err) - } - fmt.Printf("Loaded %d reasons from db.yaml\n", len(reasonsDB)) + reasonDB := NewReasonDB() + if err := loadUITemplate("ui.html"); err != nil { logrus.Fatalf("Failed to load ui.html: %v", err) } @@ -27,7 +24,7 @@ func main() { APIKey: os.Getenv("OPENAI_API_KEY"), BaseURL: os.Getenv("OPENAI_BASE_URL"), } - chatService := NewChatService(llm) + chatService := NewChatService(llm, reasonDB) r := gin.Default() r.GET("/", func(c *gin.Context) { c.Status(200)