rename reason -> db

This commit is contained in:
lehel 2025-10-01 10:12:26 +02:00
parent a2e9e6e273
commit 8e0fc65bb2
No known key found for this signature in database
GPG Key ID: 9C4F9D6111EE5CFA
8 changed files with 101 additions and 101 deletions

View File

@ -18,14 +18,14 @@ type ChatServiceAPI interface {
// ChatService handles chat interactions and orchestrates LLM and DB calls.
type ChatService struct {
LLM LLMClientAPI
reasonsDB ReasonDBAPI
LLM LLMClientAPI
visitsDB VisitDBAPI
}
var _ ChatServiceAPI = (*ChatService)(nil)
func NewChatService(llm LLMClientAPI, db ReasonDBAPI) ChatServiceAPI {
return &ChatService{LLM: llm, reasonsDB: db}
func NewChatService(llm LLMClientAPI, db VisitDBAPI) ChatServiceAPI {
return &ChatService{LLM: llm, visitsDB: db}
}
// HandleChat is the main entrypoint for chat requests. It delegates to modular helpers.
@ -40,7 +40,7 @@ func (cs *ChatService) HandleChat(c *gin.Context) {
cs.respondWithError(c, req, keywords, err)
return
}
best, err := cs.findBestReason(ctx, req, keywords)
best, err := cs.findBestVisit(ctx, req, keywords)
resp := cs.buildResponse(best)
c.JSON(http.StatusOK, resp)
}
@ -65,10 +65,10 @@ func (cs *ChatService) extractKeywords(ctx context.Context, message string) ([]s
return cs.keywordsToStrings(kwResp["keyword"]), nil
}
// findBestReason finds candidate reasons and disambiguates the best match.
func (cs *ChatService) findBestReason(ctx context.Context, req ChatRequest, keywords []string) (*Reason, error) {
// findBestVisit finds candidate visits and disambiguates the best match.
func (cs *ChatService) findBestVisit(ctx context.Context, req ChatRequest, keywords []string) (*Visit, error) {
cs.logKeywords(keywords, req.Message)
candidates, err := cs.reasonsDB.FindCandidates(keywords)
candidates, err := cs.visitsDB.FindCandidates(keywords)
cs.logCandidates(candidates, err)
if err != nil {
return nil, err
@ -78,15 +78,15 @@ func (cs *ChatService) findBestReason(ctx context.Context, req ChatRequest, keyw
bestID, err = cs.LLM.DisambiguateBestMatch(ctx, req.Message, candidates)
cs.logBestID(bestID, err)
}
reason, err := cs.reasonsDB.FindById(bestID)
visit, err := cs.visitsDB.FindById(bestID)
if err != nil {
return nil, fmt.Errorf("FindById: %w", err)
}
return &reason, nil
return &visit, nil
}
// buildResponse constructs the ChatResponse from the best Reason.
func (cs *ChatService) buildResponse(best *Reason) ChatResponse {
// buildResponse constructs the ChatResponse from the best Visit.
func (cs *ChatService) buildResponse(best *Visit) ChatResponse {
if best == nil {
resp := ChatResponse{Match: nil}
logrus.WithFields(logrus.Fields{"response": resp}).Info("Build response: no match")
@ -132,15 +132,15 @@ func (cs *ChatService) logKeywords(keywords []string, message string) {
logrus.WithFields(logrus.Fields{
"keywords": keywords,
"message": message,
}).Info("Finding visit reason candidates")
}).Info("Finding visit candidates")
}
// logCandidates logs candidate reasons.
func (cs *ChatService) logCandidates(candidates []Reason, err error) {
// logCandidates logs candidate visits.
func (cs *ChatService) logCandidates(candidates []Visit, err error) {
logrus.WithFields(logrus.Fields{
"candidates": candidates,
"error": err,
}).Info("Candidate reasons found")
}).Info("Candidate visits found")
}
// logBestID logs the best candidate ID.
@ -152,7 +152,7 @@ func (cs *ChatService) logBestID(bestID string, err error) {
}
// logChat logs the chat request and result details.
func (cs *ChatService) logChat(req ChatRequest, keywords interface{}, candidates []Reason, bestID string, err error) {
func (cs *ChatService) logChat(req ChatRequest, keywords interface{}, candidates []Visit, bestID string, err error) {
var kwMap map[string]interface{}
switch v := keywords.(type) {
case []string:
@ -164,7 +164,7 @@ func (cs *ChatService) logChat(req ChatRequest, keywords interface{}, candidates
}
logRequest(req, kwMap, candidates, bestID, err)
if candidates != nil && bestID != "" {
var best *Reason
var best *Visit
for i := range candidates {
if candidates[i].ID == bestID {
best = &candidates[i]

View File

@ -24,26 +24,26 @@ var _ LLMClientAPI = (*mockLLM)(nil)
func (m *mockLLM) ExtractKeywords(ctx context.Context, msg string) (map[string]interface{}, error) {
return m.keywordsResp, m.keywordsErr
}
func (m *mockLLM) DisambiguateBestMatch(ctx context.Context, msg string, candidates []Reason) (string, error) {
func (m *mockLLM) DisambiguateBestMatch(ctx context.Context, msg string, candidates []Visit) (string, error) {
return m.disambigID, m.disambigErr
}
// --- Test ReasonDB ---
type testReasonDB struct {
candidates []Reason
// --- Test VisitDB ---
type testVisitDB struct {
candidates []Visit
findErr error
byID map[string]Reason
byID map[string]Visit
}
var _ ReasonDBAPI = (*testReasonDB)(nil)
var _ VisitDBAPI = (*testVisitDB)(nil)
func (db *testReasonDB) FindCandidates(keywords []string) ([]Reason, error) {
func (db *testVisitDB) FindCandidates(keywords []string) ([]Visit, error) {
return db.candidates, db.findErr
}
func (db *testReasonDB) FindById(id string) (Reason, error) {
func (db *testVisitDB) FindById(id string) (Visit, error) {
r, ok := db.byID[id]
if !ok {
return Reason{}, context.DeadlineExceeded
return Visit{}, context.DeadlineExceeded
}
return r, nil
}
@ -55,14 +55,14 @@ func TestChatService_MatchFound(t *testing.T) {
keywordsResp: map[string]interface{}{"keyword": []string{"worms", "deworming"}},
disambigID: "deworming",
}
reason := Reason{
visit := Visit{
ID: "deworming",
Procedures: []Procedure{{Name: "Deworming tablet", Price: 30, DurationMin: 10}},
Notes: "Bloodwork ensures organs are safe for treatment.",
}
var db ReasonDBAPI = &testReasonDB{
candidates: []Reason{reason},
byID: map[string]Reason{"deworming": reason},
var db VisitDBAPI = &testVisitDB{
candidates: []Visit{visit},
byID: map[string]Visit{"deworming": visit},
}
var cs ChatServiceAPI = NewChatService(llm, db)
r := gin.New()
@ -88,8 +88,8 @@ func TestChatService_MatchFound(t *testing.T) {
if len(resp.Procedures) != 1 || resp.Procedures[0].Name != "Deworming tablet" {
t.Errorf("Expected procedure 'Deworming tablet', got %+v", resp.Procedures)
}
if resp.Notes != reason.Notes {
t.Errorf("Expected notes '%s', got '%s'", reason.Notes, resp.Notes)
if resp.Notes != visit.Notes {
t.Errorf("Expected notes '%s', got '%s'", visit.Notes, resp.Notes)
}
}
@ -99,9 +99,9 @@ func TestChatService_NoMatch(t *testing.T) {
keywordsResp: map[string]interface{}{"keyword": []string{"unknown"}},
disambigID: "",
}
db := &testReasonDB{
candidates: []Reason{},
byID: map[string]Reason{},
db := &testVisitDB{
candidates: []Visit{},
byID: map[string]Visit{},
}
cs := NewChatService(llm, db)
r := gin.New()
@ -131,7 +131,7 @@ func TestChatService_LLMError(t *testing.T) {
llm := &mockLLM{
keywordsErr: context.DeadlineExceeded,
}
db := &testReasonDB{}
db := &testVisitDB{}
cs := NewChatService(llm, db)
r := gin.New()
r.POST("/chat", cs.HandleChat)

66
db.go
View File

@ -12,79 +12,79 @@ import (
"github.com/blevesearch/bleve/v2"
)
type ReasonDB struct {
reasonsDB []Reason
reasonsIdx bleve.Index
type VisitDB struct {
visitsDB []Visit
visitsIdx bleve.Index
}
func NewReasonDB() ReasonDB {
db := ReasonDB{}
func NewVisitDB() VisitDB {
db := VisitDB{}
db.init()
return db
}
func (rdb *ReasonDB) FindById(reasonId string) (Reason, error) {
for _, reason := range rdb.reasonsDB {
if reason.ID == reasonId {
return reason, nil
func (vdb *VisitDB) FindById(visitId string) (Visit, error) {
for _, visit := range vdb.visitsDB {
if visit.ID == visitId {
return visit, nil
}
}
return Reason{}, errors.New("reason not found")
return Visit{}, errors.New("visit not found")
}
func (rdb *ReasonDB) init() {
idxPath := "reasons.bleve"
func (vdb *VisitDB) init() {
idxPath := "visits.bleve"
if _, err := os.Stat(idxPath); err == nil {
rdb.reasonsIdx, err = bleve.Open(idxPath)
vdb.visitsIdx, err = bleve.Open(idxPath)
if err != nil {
panic(err)
}
} else if os.IsNotExist(err) {
rdb.reasonsIdx, err = bleve.New(idxPath, bleve.NewIndexMapping())
vdb.visitsIdx, err = bleve.New(idxPath, bleve.NewIndexMapping())
if err != nil {
panic(err)
}
} else {
panic(err)
}
err := rdb.loadYAMLDB("db.yaml")
err := vdb.loadYAMLDB("db.yaml")
if err != nil {
logrus.Fatalf("Failed to load db.yaml: %v", err)
panic(err)
}
}
func (rdb *ReasonDB) loadYAMLDB(path string) error {
func (vdb *VisitDB) loadYAMLDB(path string) error {
data, err := ioutil.ReadFile(path)
if err != nil {
return err
}
if err := yaml.Unmarshal(data, &rdb.reasonsDB); err != nil {
if err := yaml.Unmarshal(data, &vdb.visitsDB); err != nil {
return err
}
return rdb.indexReasons(rdb.reasonsDB)
return vdb.indexVisits(vdb.visitsDB)
}
func (rdb *ReasonDB) indexReasons(reasons []Reason) error {
batch := rdb.reasonsIdx.NewBatch()
for _, reason := range reasons {
if err := batch.Index(reason.ID, reason); err != nil {
func (vdb *VisitDB) indexVisits(visits []Visit) error {
batch := vdb.visitsIdx.NewBatch()
for _, visit := range visits {
if err := batch.Index(visit.ID, visit); err != nil {
return err
}
}
return rdb.reasonsIdx.Batch(batch)
return vdb.visitsIdx.Batch(batch)
}
// FindCandidates returns reasons with overlapping keywords
func (rdb *ReasonDB) FindCandidates(keywords []string) ([]Reason, error) {
// FindCandidates returns visits with overlapping keywords
func (vdb *VisitDB) FindCandidates(keywords []string) ([]Visit, error) {
query := bleve.NewMatchQuery(strings.Join(keywords, " "))
search := bleve.NewSearchRequest(query)
searchResults, err := rdb.reasonsIdx.Search(search)
searchResults, err := vdb.visitsIdx.Search(search)
if err != nil {
return nil, err
}
var candidates []Reason
var candidates []Visit
for _, hit := range searchResults.Hits {
for _, r := range rdb.reasonsDB {
for _, r := range vdb.visitsDB {
if r.ID == hit.ID {
candidates = append(candidates, r)
break
@ -105,12 +105,12 @@ func sumProcedures(procs []Procedure) (int, int) {
return totalPrice, totalDuration
}
// ReasonDBAPI allows mocking ReasonDB in other places
// VisitDBAPI allows mocking VisitDB in other places
// Only public methods should be included
type ReasonDBAPI interface {
FindById(reasonId string) (Reason, error)
FindCandidates(keywords []string) ([]Reason, error)
type VisitDBAPI interface {
FindById(visitId string) (Visit, error)
FindCandidates(keywords []string) ([]Visit, error)
}
var _ ReasonDBAPI = (*ReasonDB)(nil)
var _ VisitDBAPI = (*VisitDB)(nil)

20
db.yaml
View File

@ -1,5 +1,5 @@
- id: deworming
reason: "Féregtelenítés kutyának"
visit: "Féregtelenítés kutyának"
keywords: ["worm", "deworming", "parasite", "intestinal worm", "dog"]
procedures:
- name: "Alap vérvizsgálat"
@ -11,7 +11,7 @@
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"
visit: "Oltás kutyának"
keywords: ["vaccination", "vaccine", "to vaccinate", "dog disease", "rabies"]
procedures:
- name: "Általános állapotfelmérés"
@ -23,7 +23,7 @@
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"
visit: "Ivartalanítás macskának"
keywords: ["neutering", "surgery", "cat", "tomcat", "female cat"]
procedures:
- name: "Műtéti előzetes vizsgálat"
@ -35,7 +35,7 @@
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"
visit: "Fogkő eltávolítás kutyának"
keywords: ["tooth", "tartar", "dentition", "tooth cleaning", "dog teeth","plaque"]
procedures:
- name: "Altatás előtti vizsgálat"
@ -47,7 +47,7 @@
notes: "Az altatás kockázata miatt minden esetben szükséges előzetes vizsgálat."
- id: checkup
reason: "Általános állapotfelmérés"
visit: "Általános állapotfelmérés"
keywords: ["examination", "checkup", "check-up", "general assessment"]
procedures:
- name: "Teljes fizikai vizsgálat"
@ -56,7 +56,7 @@
notes: "Évente legalább egyszer javasolt a rutin állapotfelmérés."
- id: allergy
reason: "Allergiás tünetek vizsgálata"
visit: "Allergiás tünetek vizsgálata"
keywords: ["allergy", "itching", "rash", "redness", "allergic"]
procedures:
- name: "Bőr- és vérvizsgálat"
@ -65,7 +65,7 @@
notes: "Az allergia gyakran étel vagy környezeti tényező miatt alakul ki."
- id: ultrasound
reason: "Ultrahangos vizsgálat"
visit: "Ultrahangos vizsgálat"
keywords: ["ultrasound", "abdomen", "examination", "US"]
procedures:
- name: "Ultrahang vizsgálat"
@ -74,7 +74,7 @@
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"
visit: "Laborvizsgálat vérből"
keywords: ["blood", "blood test", "lab", "test"]
procedures:
- name: "Teljes vérkép"
@ -83,7 +83,7 @@
notes: "Sok más vizsgálat alapja a laboreredmény."
- id: xray
reason: "Röntgenfelvétel"
visit: "Röntgenfelvétel"
keywords: ["x-ray", "bone", "scan", "fracture"]
procedures:
- name: "Röntgen vizsgálat"
@ -92,7 +92,7 @@
notes: "Törések, csontelváltozások vizsgálatára javasolt."
- id: diarrhea
reason: "Hasmenés vizsgálata"
visit: "Hasmenés vizsgálata"
keywords: ["diarrhea", "vomiting", "stomach", "intestine"]
procedures:
- name: "Állatorvosi konzultáció"

6
llm.go
View File

@ -71,7 +71,7 @@ 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) {
func (llm *LLMClient) DisambiguateBestMatch(ctx context.Context, message string, candidates []Visit) (string, error) {
format := map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
@ -138,7 +138,7 @@ func (llm *LLMClient) openAICompletion(ctx context.Context, prompt string, forma
return "", err
}
if result.Message.Content == "" {
logrus.Warn("[LLM] openAICompletion: no content returned")
logrus.Warn("[LLM] openAICompletion: no content returned %v body:[%v]", resp.Status, resp.Body)
return "", nil
}
logrus.WithField("content", result.Message.Content).Info("[LLM] openAICompletion: got content")
@ -150,7 +150,7 @@ func (llm *LLMClient) openAICompletion(ctx context.Context, prompt string, forma
type LLMClientAPI interface {
ExtractKeywords(ctx context.Context, message string) (map[string]interface{}, error)
DisambiguateBestMatch(ctx context.Context, message string, candidates []Reason) (string, error)
DisambiguateBestMatch(ctx context.Context, message string, candidates []Visit) (string, error)
}
var _ LLMClientAPI = (*LLMClient)(nil)

12
log.go
View File

@ -5,7 +5,7 @@ import (
)
// logRequest logs incoming chat requests and extracted info
func logRequest(req ChatRequest, keywords map[string]interface{}, candidates []Reason, bestID string, err error) {
func logRequest(req ChatRequest, keywords map[string]interface{}, candidates []Visit, bestID string, err error) {
logrus.WithFields(logrus.Fields{
"message": req.Message,
"keywords": keywords,
@ -15,7 +15,7 @@ func logRequest(req ChatRequest, keywords map[string]interface{}, candidates []R
}).Info("Chat request trace")
}
func getCandidateIDs(candidates []Reason) []string {
func getCandidateIDs(candidates []Visit) []string {
ids := make([]string, len(candidates))
for i, c := range candidates {
ids[i] = c.ID
@ -23,17 +23,17 @@ func getCandidateIDs(candidates []Reason) []string {
return ids
}
// Procedure represents a single procedure for a visit reason
// Procedure represents a single procedure for a visit
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 {
// Visit represents a visit entry
type Visit struct {
ID string `yaml:"id" json:"id"`
Reason string `yaml:"reason" json:"reason"`
Visit string `yaml:"visit" json:"visit"`
Keywords []string `yaml:"keywords" json:"keywords"`
Procedures []Procedure `yaml:"procedures" json:"procedures"`
Notes string `yaml:"notes" json:"notes,omitempty"`

View File

@ -15,7 +15,7 @@ func main() {
logrus.Fatalf("Failed to load config.yaml: %v", err)
}
logrus.Infof("Loaded config: %+v", appConfig)
reasonDB := NewReasonDB()
visitDB := NewVisitDB()
if err := loadUITemplate("ui.html"); err != nil {
logrus.Fatalf("Failed to load ui.html: %v", err)
@ -25,7 +25,7 @@ func main() {
os.Getenv("OPENAI_BASE_URL"),
os.Getenv("OPENAI_MODEL"),
)
chatService := NewChatService(llm, &reasonDB)
chatService := NewChatService(llm, &visitDB)
r := gin.Default()
r.GET("/", func(c *gin.Context) {
c.Status(200)

View File

@ -1,5 +1,5 @@
- id: deworming
reason: "Féregtelenítés kutyának"
visit: "Féregtelenítés kutyának"
keywords: ["féreg", "féregtelenítés", "parazita", "bélféreg", "kutya"]
procedures:
- name: "Alap vérvizsgálat"
@ -11,7 +11,7 @@
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"
visit: "Oltás kutyának"
keywords: ["oltás", "vakcina", "oltani", "kutyabetegség", "veszettség"]
procedures:
- name: "Általános állapotfelmérés"
@ -23,7 +23,7 @@
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"
visit: "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"
@ -35,7 +35,7 @@
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"
visit: "Fogkő eltávolítás kutyának"
keywords: ["fog", "fogkő", "fogsor", "fogtisztítás", "kutyafog"]
procedures:
- name: "Altatás előtti vizsgálat"
@ -47,7 +47,7 @@
notes: "Az altatás kockázata miatt minden esetben szükséges előzetes vizsgálat."
- id: checkup
reason: "Általános állapotfelmérés"
visit: "Általános állapotfelmérés"
keywords: ["vizsgálat", "ellenőrzés", "checkup", "állapotfelmérés"]
procedures:
- name: "Teljes fizikai vizsgálat"
@ -56,7 +56,7 @@
notes: "Évente legalább egyszer javasolt a rutin állapotfelmérés."
- id: allergy
reason: "Allergiás tünetek vizsgálata"
visit: "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"
@ -65,7 +65,7 @@
notes: "Az allergia gyakran étel vagy környezeti tényező miatt alakul ki."
- id: ultrasound
reason: "Ultrahangos vizsgálat"
visit: "Ultrahangos vizsgálat"
keywords: ["ultrahang", "has", "vizsgálat", "UH"]
procedures:
- name: "Ultrahang vizsgálat"
@ -74,7 +74,7 @@
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"
visit: "Laborvizsgálat vérből"
keywords: ["vér", "vérvizsgálat", "labor", "teszt"]
procedures:
- name: "Teljes vérkép"
@ -83,7 +83,7 @@
notes: "Sok más vizsgálat alapja a laboreredmény."
- id: xray
reason: "Röntgenfelvétel"
visit: "Röntgenfelvétel"
keywords: ["röntgen", "csont", "felvétel", "törés"]
procedures:
- name: "Röntgen vizsgálat"
@ -92,7 +92,7 @@
notes: "Törések, csontelváltozások vizsgálatára javasolt."
- id: diarrhea
reason: "Hasmenés vizsgálata"
visit: "Hasmenés vizsgálata"
keywords: ["hasmenés", "hányás", "gyomor", "bél"]
procedures:
- name: "Állatorvosi konzultáció"