diff --git a/.idea/dataSources.xml b/.idea/dataSources.xml
new file mode 100644
index 0000000..58368e9
--- /dev/null
+++ b/.idea/dataSources.xml
@@ -0,0 +1,17 @@
+
+
+
+
+ postgresql
+ true
+ org.postgresql.Driver
+ jdbc:postgresql://localhost:5432/ledger-balance-service
+
+
+
+
+
+ $ProjectFileDir$
+
+
+
\ No newline at end of file
diff --git a/.idea/data_source_mapping.xml b/.idea/data_source_mapping.xml
new file mode 100644
index 0000000..9dbc1ed
--- /dev/null
+++ b/.idea/data_source_mapping.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Makefile b/Makefile
index 0d217c2..a59373e 100644
--- a/Makefile
+++ b/Makefile
@@ -20,9 +20,32 @@ ollama-pull:
ollama-status:
ollama list
-# Run the Go server (assumes Ollama is running)
+# Database configuration (override via: make run DB_PASSWORD=secret DB_NAME=other)
+DB_HOST ?= localhost
+DB_PORT ?= 5432
+DB_USER ?= postgres
+DB_PASSWORD ?= postgres
+DB_NAME ?= ledger-balance-service
+DB_SSLMODE ?= disable
+
+# Derived env export snippet for DB
+db_env = PGHOST=$(DB_HOST) PGPORT=$(DB_PORT) PGUSER=$(DB_USER) PGPASSWORD=$(DB_PASSWORD) PGDATABASE=$(DB_NAME) PGSSLMODE=$(DB_SSLMODE)
+
+# Run the Go server (assumes Ollama is running) with DB env vars
run: ollama-pull
- OPENAI_API_KEY=ollama OPENAI_BASE_URL=http://localhost:11434/api/chat OPENAI_MODEL=qwen3:latest go run .
+ $(db_env) OPENAI_API_KEY=ollama OPENAI_BASE_URL=http://localhost:11434/api/chat OPENAI_MODEL=qwen3:latest go run .
+
+# Run without pulling model (faster if already present)
+run-fast:
+ $(db_env) OPENAI_API_KEY=ollama OPENAI_BASE_URL=http://localhost:11434/api/chat OPENAI_MODEL=qwen3:latest go run .
+
+# Quick psql shell (requires psql installed)
+psql:
+ $(db_env) psql || true
+
+# Print the DSN that main.go will assemble
+print-dsn:
+ @echo postgres://$(DB_USER):******@$(DB_HOST):$(DB_PORT)/$(DB_NAME)?sslmode=$(DB_SSLMODE)
# Run tests
.PHONY: test
diff --git a/chat_service.go b/chat_service.go
index 513c574..bd89a38 100644
--- a/chat_service.go
+++ b/chat_service.go
@@ -6,6 +6,7 @@ import (
"net/http"
"github.com/gin-gonic/gin"
+ "github.com/google/uuid"
"github.com/sirupsen/logrus"
)
@@ -20,29 +21,35 @@ type ChatServiceAPI interface {
type ChatService struct {
LLM LLMClientAPI
visitsDB VisitDBAPI
+ repo ChatRepositoryAPI
}
var _ ChatServiceAPI = (*ChatService)(nil)
-func NewChatService(llm LLMClientAPI, db VisitDBAPI) ChatServiceAPI {
- return &ChatService{LLM: llm, visitsDB: db}
+func NewChatService(llm LLMClientAPI, db VisitDBAPI, repo ChatRepositoryAPI) ChatServiceAPI {
+ return &ChatService{LLM: llm, visitsDB: db, repo: repo}
}
// HandleChat is the main entrypoint for chat requests. It delegates to modular helpers.
func (cs *ChatService) HandleChat(c *gin.Context) {
- ctx := context.Background()
+ corrID := uuid.New().String()
+ ctx := context.WithValue(context.Background(), correlationIDCtxKey, corrID)
+ c.Header("X-Correlation-ID", corrID)
req, err := cs.parseRequest(c)
if err != nil {
return
}
- keywords, err := cs.extractKeywords(ctx, req.Message)
+ kwResp, err := cs.LLM.ExtractKeywords(ctx, req.Message)
if err != nil {
- cs.respondWithError(c, req, keywords, err)
+ cs.persistInteraction(ctx, corrID, req.Message, nil, nil, ChatResponse{Match: nil})
+ cs.respondWithError(c, req, nil, err)
return
}
- best, err := cs.findBestVisit(ctx, req, keywords)
+ keywords := cs.keywordsToStrings(kwResp["keyword"])
+ best, _, err := cs.findBestVisit(ctx, req, keywords)
resp := cs.buildResponse(best)
c.JSON(http.StatusOK, resp)
+ cs.persistInteraction(ctx, corrID, req.Message, kwResp, best, resp)
}
// parseRequest parses and validates the incoming chat request.
@@ -66,23 +73,35 @@ func (cs *ChatService) extractKeywords(ctx context.Context, message string) ([]s
}
// findBestVisit finds candidate visits and disambiguates the best match.
-func (cs *ChatService) findBestVisit(ctx context.Context, req ChatRequest, keywords []string) (*Visit, error) {
+func (cs *ChatService) findBestVisit(ctx context.Context, req ChatRequest, keywords []string) (*Visit, string, error) {
cs.logKeywords(keywords, req.Message)
candidates, err := cs.visitsDB.FindCandidates(keywords)
cs.logCandidates(candidates, err)
if err != nil {
- return nil, err
+ return nil, "", err
}
bestID := ""
+ rawDis := ""
if len(candidates) > 0 {
- bestID, err = cs.LLM.DisambiguateBestMatch(ctx, req.Message, candidates)
- cs.logBestID(bestID, err)
+ if real, ok := cs.LLM.(*LLMClient); ok {
+ raw, vr, derr := real.DisambiguateBestMatchRaw(ctx, req.Message, candidates)
+ rawDis = raw
+ bestID = vr
+ if derr != nil {
+ cs.logBestID(bestID, derr)
+ } else {
+ cs.logBestID(bestID, nil)
+ }
+ } else {
+ bestID, err = cs.LLM.DisambiguateBestMatch(ctx, req.Message, candidates)
+ cs.logBestID(bestID, err)
+ }
}
visit, err := cs.visitsDB.FindById(bestID)
if err != nil {
- return nil, fmt.Errorf("FindById: %w", err)
+ return nil, rawDis, fmt.Errorf("FindById: %w", err)
}
- return &visit, nil
+ return &visit, rawDis, nil
}
// buildResponse constructs the ChatResponse from the best Visit.
@@ -182,3 +201,38 @@ func (cs *ChatService) logChat(req ChatRequest, keywords interface{}, candidates
}
}
}
+
+// persistInteraction saves chat interaction to repository (best effort)
+func (cs *ChatService) persistInteraction(ctx context.Context, correlationID string, userMsg string, kwResp map[string]interface{}, best *Visit, resp ChatResponse) {
+ if cs.repo == nil {
+ return
+ }
+ var translate, animal string
+ var keywords []string
+ if kwResp != nil {
+ if t, ok := kwResp["translate"].(string); ok {
+ translate = t
+ }
+ if a, ok := kwResp["animal"].(string); ok {
+ animal = a
+ }
+ keywords = cs.keywordsToStrings(kwResp["keyword"])
+ }
+ bestID := ""
+ if best != nil {
+ bestID = best.ID
+ }
+ rec := ChatInteraction{
+ CorrelationID: correlationID,
+ UserMessage: userMsg,
+ Translate: translate,
+ Animal: animal,
+ Keywords: keywords,
+ BestVisitID: bestID,
+ TotalPrice: resp.TotalPrice,
+ TotalDuration: resp.TotalDuration,
+ }
+ if err := cs.repo.SaveChatInteraction(ctx, rec); err != nil {
+ logrus.WithError(err).Debug("failed to save chat interaction")
+ }
+}
diff --git a/chat_service_integration_test.go b/chat_service_integration_test.go
index 6886d58..1d3cdfa 100644
--- a/chat_service_integration_test.go
+++ b/chat_service_integration_test.go
@@ -64,7 +64,7 @@ func TestChatService_MatchFound(t *testing.T) {
candidates: []Visit{visit},
byID: map[string]Visit{"deworming": visit},
}
- var cs ChatServiceAPI = NewChatService(llm, db)
+ var cs ChatServiceAPI = NewChatService(llm, db, nil)
r := gin.New()
r.POST("/chat", cs.HandleChat)
@@ -103,7 +103,7 @@ func TestChatService_NoMatch(t *testing.T) {
candidates: []Visit{},
byID: map[string]Visit{},
}
- cs := NewChatService(llm, db)
+ cs := NewChatService(llm, db, nil)
r := gin.New()
r.POST("/chat", cs.HandleChat)
@@ -132,7 +132,7 @@ func TestChatService_LLMError(t *testing.T) {
keywordsErr: context.DeadlineExceeded,
}
db := &testVisitDB{}
- cs := NewChatService(llm, db)
+ cs := NewChatService(llm, db, nil)
r := gin.New()
r.POST("/chat", cs.HandleChat)
diff --git a/go.mod b/go.mod
index 988c3bb..f3de178 100644
--- a/go.mod
+++ b/go.mod
@@ -5,6 +5,8 @@ go 1.25
require (
github.com/blevesearch/bleve/v2 v2.5.3
github.com/gin-gonic/gin v1.11.0
+ github.com/google/uuid v1.6.0
+ github.com/jackc/pgx/v5 v5.6.0
github.com/sirupsen/logrus v1.9.3
gopkg.in/yaml.v3 v3.0.1
)
@@ -41,8 +43,12 @@ require (
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/jackc/pgpassfile v1.0.0 // indirect
+ github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
+ github.com/jackc/puddle/v2 v2.2.1 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
+ github.com/kr/text v0.2.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
@@ -51,6 +57,7 @@ require (
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/rogpeppe/go-internal v1.14.1 // 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
diff --git a/go.sum b/go.sum
index 7ec9598..ac5f7da 100644
--- a/go.sum
+++ b/go.sum
@@ -45,6 +45,7 @@ github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZw
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/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
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=
@@ -76,10 +77,22 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX
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/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
+github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
+github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
+github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
+github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
+github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
+github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
+github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
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/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
+github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
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=
@@ -98,6 +111,8 @@ 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/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
+github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@@ -141,8 +156,9 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T
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/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
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=
diff --git a/handlechat_integration_test.go b/handlechat_integration_test.go
new file mode 100644
index 0000000..0b15de1
--- /dev/null
+++ b/handlechat_integration_test.go
@@ -0,0 +1,168 @@
+package main
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "sync"
+ "testing"
+
+ "github.com/gin-gonic/gin"
+)
+
+// mockHandleChatLLM mocks LLM behavior for integration tests
+// It implements only the public interface methods.
+type mockHandleChatLLM struct {
+ keywordsResp map[string]interface{}
+ disambigID string
+ keywordsErr error
+ disambigErr error
+}
+
+func (m *mockHandleChatLLM) ExtractKeywords(ctx context.Context, msg string) (map[string]interface{}, error) {
+ return m.keywordsResp, m.keywordsErr
+}
+func (m *mockHandleChatLLM) DisambiguateBestMatch(ctx context.Context, msg string, candidates []Visit) (string, error) {
+ return m.disambigID, m.disambigErr
+}
+
+// mapChatRepo is an in-memory implementation of ChatRepositoryAPI for tests.
+type mapChatRepo struct {
+ mu sync.Mutex
+ interactions []ChatInteraction
+ rawEvents []struct{ CorrelationID, Phase, Raw string }
+}
+
+func (r *mapChatRepo) SaveChatInteraction(ctx context.Context, rec ChatInteraction) error {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ r.interactions = append(r.interactions, rec)
+ return nil
+}
+func (r *mapChatRepo) ListChatInteractions(ctx context.Context, limit, offset int) ([]ChatInteraction, error) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ if offset >= len(r.interactions) {
+ return []ChatInteraction{}, nil
+ }
+ end := offset + limit
+ if end > len(r.interactions) {
+ end = len(r.interactions)
+ }
+ // return a copy slice to avoid mutation
+ out := make([]ChatInteraction, end-offset)
+ copy(out, r.interactions[offset:end])
+ return out, nil
+}
+func (r *mapChatRepo) SaveLLMRawEvent(ctx context.Context, correlationID, phase, raw string) error {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ r.rawEvents = append(r.rawEvents, struct{ CorrelationID, Phase, Raw string }{correlationID, phase, raw})
+ return nil
+}
+
+// testVisitDB2 replicates a minimal VisitDB for integration
+// (avoids relying on real Bleve index)
+type testVisitDB2 struct {
+ byID map[string]Visit
+ candidates []Visit
+ findErr error
+}
+
+func (db *testVisitDB2) FindCandidates(keywords []string) ([]Visit, error) {
+ return db.candidates, db.findErr
+}
+func (db *testVisitDB2) FindById(id string) (Visit, error) {
+ if v, ok := db.byID[id]; ok {
+ return v, nil
+ }
+ return Visit{}, context.DeadlineExceeded
+}
+
+func TestHandleChat_PersistsSuccessInteraction(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ visit := Visit{ID: "xray", Notes: "Exam note", Procedures: []Procedure{{Name: "Röntgen vizsgálat", Price: 16000, DurationMin: 25}}}
+ db := &testVisitDB2{byID: map[string]Visit{"xray": visit}, candidates: []Visit{visit}}
+ llm := &mockHandleChatLLM{keywordsResp: map[string]interface{}{"translate": "xray leg", "animal": "dog", "keyword": []string{"xray", "bone"}}, disambigID: "xray"}
+ repo := &mapChatRepo{}
+ cs := NewChatService(llm, db, repo)
+ r := gin.New()
+ r.POST("/chat", cs.HandleChat)
+
+ body := map[string]string{"message": "my dog needs an x-ray"}
+ b, _ := json.Marshal(body)
+ req, _ := http.NewRequest(http.MethodPost, "/chat", bytes.NewReader(b))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected 200 got %d", w.Code)
+ }
+ corrID := w.Header().Get("X-Correlation-ID")
+ if corrID == "" {
+ t.Fatalf("expected correlation id header set")
+ }
+
+ repo.mu.Lock()
+ if len(repo.interactions) != 1 {
+ repo.mu.Unlock()
+ bdy := w.Body.String()
+ t.Fatalf("expected 1 interaction persisted, got %d; body=%s", len(repo.interactions), bdy)
+ }
+ rec := repo.interactions[0]
+ repo.mu.Unlock()
+ if rec.CorrelationID != corrID {
+ t.Errorf("correlation mismatch: header=%s rec=%s", corrID, rec.CorrelationID)
+ }
+ if rec.BestVisitID != "xray" {
+ t.Errorf("expected BestVisitID xray got %s", rec.BestVisitID)
+ }
+ if rec.TotalPrice != 16000 || rec.TotalDuration != 25 {
+ t.Errorf("unexpected totals: %+v", rec)
+ }
+ if len(rec.Keywords) != 2 {
+ t.Errorf("expected 2 keywords got %v", rec.Keywords)
+ }
+}
+
+func TestHandleChat_PersistsOnLLMError(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ llm := &mockHandleChatLLM{keywordsErr: context.DeadlineExceeded}
+ db := &testVisitDB2{byID: map[string]Visit{}, candidates: []Visit{}}
+ repo := &mapChatRepo{}
+ cs := NewChatService(llm, db, repo)
+ r := gin.New()
+ r.POST("/chat", cs.HandleChat)
+
+ body := map[string]string{"message": "some message"}
+ b, _ := json.Marshal(body)
+ req, _ := http.NewRequest(http.MethodPost, "/chat", bytes.NewReader(b))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected 200 got %d", w.Code)
+ }
+
+ repo.mu.Lock()
+ cnt := len(repo.interactions)
+ var rec ChatInteraction
+ if cnt == 1 {
+ rec = repo.interactions[0]
+ }
+ repo.mu.Unlock()
+ if cnt != 1 {
+ t.Fatalf("expected 1 interaction persisted on error got %d", cnt)
+ }
+ if rec.BestVisitID != "" {
+ t.Errorf("expected no best visit on error got %s", rec.BestVisitID)
+ }
+ cid := w.Header().Get("X-Correlation-ID")
+ if cid == "" {
+ t.Fatalf("expected correlation id header on error path")
+ }
+}
diff --git a/llm.go b/llm.go
index 0489687..3ec8dd0 100644
--- a/llm.go
+++ b/llm.go
@@ -19,15 +19,36 @@ type LLMClient struct {
APIKey string
BaseURL string
Model string
+ Repo ChatRepositoryAPI
}
-// NewLLMClient constructs a new LLMClient with the given API key and base URL
-func NewLLMClient(apiKey, baseURL string, model string) *LLMClient {
- return &LLMClient{
- APIKey: apiKey,
- BaseURL: baseURL,
- Model: model,
+// NewLLMClient constructs a new LLMClient with the given API key, base URL, model, and optional repository
+func NewLLMClient(apiKey, baseURL string, model string, repo ChatRepositoryAPI) *LLMClient {
+ return &LLMClient{APIKey: apiKey, BaseURL: baseURL, Model: model, Repo: repo}
+}
+
+func (llm *LLMClient) SetRepository(r ChatRepositoryAPI) { llm.Repo = r }
+
+// helper to get correlation id from context
+const correlationIDCtxKey = "corr_id"
+
+func correlationIDFromCtx(ctx context.Context) string {
+ v := ctx.Value(correlationIDCtxKey)
+ if s, ok := v.(string); ok {
+ return s
}
+ return ""
+}
+
+func (llm *LLMClient) persistRaw(ctx context.Context, phase, raw string) {
+ if llm == nil || llm.Repo == nil || raw == "" {
+ return
+ }
+ cid := correlationIDFromCtx(ctx)
+ if cid == "" {
+ return
+ }
+ _ = llm.Repo.SaveLLMRawEvent(ctx, cid, phase, raw)
}
// renderPrompt renders a Go template with the given data
@@ -45,10 +66,16 @@ func renderPrompt(tmplStr string, data any) (string, error) {
// ExtractKeywords calls LLM to extract keywords from user message
func (llm *LLMClient) ExtractKeywords(ctx context.Context, message string) (map[string]interface{}, error) {
+ _, parsed, err := llm.ExtractKeywordsRaw(ctx, message)
+ return parsed, err
+}
+
+// ExtractKeywordsRaw returns the raw JSON string and parsed map
+func (llm *LLMClient) ExtractKeywordsRaw(ctx context.Context, message string) (string, map[string]interface{}, error) {
prompt, err := renderPrompt(appConfig.LLM.ExtractKeywordsPrompt, map[string]string{"Message": message})
if err != nil {
logrus.WithError(err).Error("[CONFIG] Failed to render ExtractKeywords prompt")
- return nil, err
+ return "", nil, err
}
logrus.WithField("prompt", prompt).Info("[LLM] ExtractKeywords prompt")
format := map[string]interface{}{
@@ -63,17 +90,24 @@ func (llm *LLMClient) ExtractKeywords(ctx context.Context, message string) (map[
resp, err := llm.openAICompletion(ctx, prompt, format)
logrus.WithFields(logrus.Fields{"response": resp, "err": err}).Info("[LLM] ExtractKeywords response")
if err != nil {
- return nil, err
+ return resp, nil, err // return whatever raw we got (may be empty)
}
var result map[string]interface{}
if err := json.Unmarshal([]byte(resp), &result); err != nil {
- return nil, err
+ return resp, nil, err
}
- return result, nil
+ llm.persistRaw(ctx, "extract_keywords", resp)
+ return resp, result, nil
}
// DisambiguateBestMatch calls LLM to pick best match from candidates
func (llm *LLMClient) DisambiguateBestMatch(ctx context.Context, message string, candidates []Visit) (string, error) {
+ _, vr, err := llm.DisambiguateBestMatchRaw(ctx, message, candidates)
+ return vr, err
+}
+
+// DisambiguateBestMatchRaw returns raw JSON and visitReason
+func (llm *LLMClient) DisambiguateBestMatchRaw(ctx context.Context, message string, candidates []Visit) (string, string, error) {
format := map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
@@ -85,25 +119,24 @@ func (llm *LLMClient) DisambiguateBestMatch(ctx context.Context, message string,
prompt, err := renderPrompt(appConfig.LLM.DisambiguatePrompt, map[string]string{"Entries": string(entries), "Message": message})
if err != nil {
logrus.WithError(err).Error("[CONFIG] Failed to render Disambiguate prompt")
- return "", err
+ return "", "", err
}
logrus.WithField("prompt", prompt).Info("[LLM] DisambiguateBestMatch prompt")
resp, err := llm.openAICompletion(ctx, prompt, format)
logrus.WithFields(logrus.Fields{"response": resp, "err": err}).Info("[LLM] DisambiguateBestMatch response")
if err != nil {
- return "", err
+ return resp, "", err
}
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 resp, "", fmt.Errorf("failed to unmarshal disambiguation response: %w", err)
}
-
visitReason := strings.TrimSpace(parsed["visitReason"])
if visitReason == "" {
- return "", fmt.Errorf("visitReason not found in response")
+ return resp, "", fmt.Errorf("visitReason not found in response")
}
-
- return visitReason, nil
+ llm.persistRaw(ctx, "disambiguate", resp)
+ return resp, visitReason, nil
}
// openAICompletion now supports both Ollama (default local) and OpenRouter/OpenAI-compatible APIs without external branching.
diff --git a/main.go b/main.go
index 6843d33..5bba265 100644
--- a/main.go
+++ b/main.go
@@ -1,7 +1,9 @@
package main
import (
+ "context"
"os"
+ "strconv"
"github.com/gin-gonic/gin"
"github.com/sirupsen/logrus"
@@ -20,13 +22,30 @@ func main() {
if err := loadUITemplate("ui.html"); err != nil {
logrus.Fatalf("Failed to load ui.html: %v", err)
}
- var llm LLMClientAPI = NewLLMClient(
+
+ // Initialize PostgreSQL repository first
+ dsn := buildDefaultDSN()
+ logrus.Info("Connecting to PostgreSQL with DSN: ", dsn)
+ repo, err := NewPGChatRepository(context.Background(), dsn)
+ if err != nil {
+ logrus.WithError(err).Warn("PostgreSQL repository disabled (connection failed)")
+ } else if repo == nil {
+ logrus.Info("PostgreSQL repository not configured (no DSN)")
+ }
+ // defer repo.Close() // optionally enable
+
+ // Initialize LLM client
+ llmClient := NewLLMClient(
os.Getenv("OPENAI_API_KEY"),
os.Getenv("OPENAI_BASE_URL"),
os.Getenv("OPENAI_MODEL"),
+ repo,
)
- chatService := NewChatService(llm, &visitDB)
+ var llm LLMClientAPI = llmClient
+
+ chatService := NewChatService(llm, &visitDB, repo)
r := gin.Default()
+ // Routes
r.GET("/", func(c *gin.Context) {
c.Status(200)
if err := uiTemplate.Execute(c.Writer, nil); err != nil {
@@ -42,14 +61,78 @@ func main() {
if err := loadDBEditTemplate("ui_dbedit.html"); err != nil {
logrus.Fatalf("Failed to load ui_dbedit.html: %v", err)
}
+ if err := loadAdminChatsTemplate("ui_admin_chats.html"); err != nil {
+ logrus.Fatalf("Failed to load ui_admin_chats.html: %v", err)
+ }
r.GET("/admin", func(c *gin.Context) {
c.Status(200)
if err := uiDBEditTemplate.Execute(c.Writer, nil); err != nil {
logrus.Errorf("Failed to execute ui_dbedit.html template: %v", err)
}
})
- r.GET("/db.yaml", func(c *gin.Context) {
- c.File("db.yaml")
+ r.GET("/db.yaml", func(c *gin.Context) { c.File("db.yaml") })
+
+ // JSON: list chat interactions
+ r.GET("/admin/chats", func(c *gin.Context) {
+ if repo == nil {
+ c.JSON(200, gin.H{"items": []ChatInteraction{}, "pagination": gin.H{"limit": 0, "offset": 0, "count": 0}, "warning": "repository not configured"})
+ return
+ }
+ limit := 50
+ if ls := c.Query("limit"); ls != "" {
+ if v, err := strconv.Atoi(ls); err == nil {
+ limit = v
+ }
+ }
+ offset := 0
+ if osf := c.Query("offset"); osf != "" {
+ if v, err := strconv.Atoi(osf); err == nil {
+ offset = v
+ }
+ }
+ items, err := repo.ListChatInteractions(c.Request.Context(), limit, offset)
+ if err != nil {
+ c.JSON(500, gin.H{"error": "failed to list interactions"})
+ return
+ }
+ c.JSON(200, gin.H{"items": items, "pagination": gin.H{"limit": limit, "offset": offset, "count": len(items)}})
+ })
+ // JSON: list raw LLM events for a correlation id
+ r.GET("/admin/chats/events", func(c *gin.Context) {
+ if repo == nil {
+ c.JSON(200, gin.H{"items": []RawLLMEvent{}, "pagination": gin.H{"limit": 0, "offset": 0, "count": 0}, "warning": "repository not configured"})
+ return
+ }
+ corr := c.Query("correlation_id")
+ if corr == "" {
+ c.JSON(400, gin.H{"error": "missing correlation_id"})
+ return
+ }
+ limit := 100
+ if ls := c.Query("limit"); ls != "" {
+ if v, err := strconv.Atoi(ls); err == nil {
+ limit = v
+ }
+ }
+ offset := 0
+ if osf := c.Query("offset"); osf != "" {
+ if v, err := strconv.Atoi(osf); err == nil {
+ offset = v
+ }
+ }
+ events, err := repo.ListLLMRawEvents(c.Request.Context(), corr, limit, offset)
+ if err != nil {
+ c.JSON(500, gin.H{"error": "failed to list events"})
+ return
+ }
+ c.JSON(200, gin.H{"items": events, "pagination": gin.H{"limit": limit, "offset": offset, "count": len(events)}})
+ })
+ // HTML UI for chats & events
+ r.GET("/admin/chats/ui", func(c *gin.Context) {
+ c.Status(200)
+ if err := uiAdminChatsTemplate.Execute(c.Writer, nil); err != nil {
+ logrus.Errorf("Failed to execute ui_admin_chats.html template: %v", err)
+ }
})
r.Run(":8080")
diff --git a/repository.go b/repository.go
new file mode 100644
index 0000000..959ced1
--- /dev/null
+++ b/repository.go
@@ -0,0 +1,244 @@
+package main
+
+import (
+ "context"
+ "os"
+ "time"
+
+ "github.com/jackc/pgx/v5/pgconn"
+ "github.com/jackc/pgx/v5/pgxpool"
+ "github.com/sirupsen/logrus"
+)
+
+// ChatInteraction represents a persisted chat request/response metadata (raw JSON moved to chat_llm_raw)
+type ChatInteraction struct {
+ CorrelationID string `json:"correlation_id"`
+ UserMessage string `json:"user_message"`
+ Translate string `json:"translate"`
+ Animal string `json:"animal"`
+ Keywords []string `json:"keywords"`
+ BestVisitID string `json:"best_visit_id"`
+ TotalPrice int `json:"total_price"`
+ TotalDuration int `json:"total_duration"`
+ CreatedAt time.Time `json:"created_at"`
+}
+
+// ChatRepositoryAPI defines persistence operations
+//
+//go:generate mockgen -destination=mock_repo.go -package=main . ChatRepositoryAPI
+type ChatRepositoryAPI interface {
+ SaveChatInteraction(ctx context.Context, rec ChatInteraction) error
+ ListChatInteractions(ctx context.Context, limit, offset int) ([]ChatInteraction, error)
+ SaveLLMRawEvent(ctx context.Context, correlationID, phase, raw string) error
+ ListLLMRawEvents(ctx context.Context, correlationID string, limit, offset int) ([]RawLLMEvent, error)
+}
+
+// RawLLMEvent represents a stored raw LLM exchange phase
+type RawLLMEvent struct {
+ CorrelationID string `json:"correlation_id"`
+ Phase string `json:"phase"`
+ RawJSON string `json:"raw_json"`
+ CreatedAt time.Time `json:"created_at"`
+}
+
+// PGChatRepository is a PostgreSQL implementation using pgxpool
+type PGChatRepository struct {
+ pool *pgxpool.Pool
+}
+
+// NewPGChatRepository creates a new repository if dsn provided, returns nil if empty dsn
+func NewPGChatRepository(ctx context.Context, dsn string) (*PGChatRepository, error) {
+ if dsn == "" {
+ return nil, nil
+ }
+ cfg, err := pgxpool.ParseConfig(dsn)
+ if err != nil {
+ return nil, err
+ }
+ p, err := pgxpool.NewWithConfig(ctx, cfg)
+ if err != nil {
+ return nil, err
+ }
+ r := &PGChatRepository{pool: p}
+ if err := r.ensureSchema(ctx); err != nil {
+ p.Close()
+ return nil, err
+ }
+ return r, nil
+}
+
+// ensureSchema creates/adjusts tables. Drops legacy raw columns.
+func (r *PGChatRepository) ensureSchema(ctx context.Context) error {
+ ddlInteractions := `CREATE TABLE IF NOT EXISTS chat_interactions (
+ id BIGSERIAL PRIMARY KEY,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ correlation_id TEXT NOT NULL,
+ user_message TEXT NOT NULL,
+ translate TEXT,
+ animal TEXT,
+ keywords TEXT[] NOT NULL,
+ best_visit_id TEXT,
+ total_price INT,
+ total_duration INT
+ );`
+ if _, err := r.pool.Exec(ctx, ddlInteractions); err != nil {
+ return err
+ }
+ ddlRaw := `CREATE TABLE IF NOT EXISTS chat_llm_raw (
+ id BIGSERIAL PRIMARY KEY,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ correlation_id TEXT NOT NULL,
+ phase TEXT NOT NULL,
+ raw_json TEXT
+ );`
+ if _, err := r.pool.Exec(ctx, ddlRaw); err != nil {
+ return err
+ }
+ // Legacy column cleanup (ignore errors)
+ for _, drop := range []string{
+ "ALTER TABLE chat_interactions DROP COLUMN IF EXISTS raw_keywords_json",
+ "ALTER TABLE chat_interactions DROP COLUMN IF EXISTS raw_disambig_json",
+ } {
+ if _, err := r.pool.Exec(ctx, drop); err != nil {
+ logrus.WithError(err).Debug("drop legacy column failed (ignored)")
+ }
+ }
+ return nil
+}
+
+// SaveChatInteraction inserts a record
+func (r *PGChatRepository) SaveChatInteraction(ctx context.Context, rec ChatInteraction) error {
+ if r == nil || r.pool == nil {
+ return nil
+ }
+ ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
+ defer cancel()
+ _, err := r.pool.Exec(ctx, `INSERT INTO chat_interactions
+ (correlation_id, user_message, translate, animal, keywords, best_visit_id, total_price, total_duration)
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8)`,
+ rec.CorrelationID, rec.UserMessage, rec.Translate, rec.Animal, rec.Keywords, nullIfEmpty(rec.BestVisitID), rec.TotalPrice, rec.TotalDuration)
+ if err != nil {
+ logrus.WithError(err).Warn("failed to persist chat interaction")
+ }
+ return err
+}
+
+// ListChatInteractions retrieves records with pagination
+func (r *PGChatRepository) ListChatInteractions(ctx context.Context, limit, offset int) ([]ChatInteraction, error) {
+ if r == nil || r.pool == nil {
+ return []ChatInteraction{}, nil
+ }
+ if limit <= 0 || limit > 500 {
+ limit = 50
+ }
+ if offset < 0 {
+ offset = 0
+ }
+ ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
+ defer cancel()
+ qry := `SELECT correlation_id, user_message, COALESCE(translate,'') as translate, COALESCE(animal,'') as animal, keywords, COALESCE(best_visit_id,'') as best_visit_id, total_price, total_duration, created_at
+ FROM chat_interactions ORDER BY created_at DESC LIMIT $1 OFFSET $2`
+ rows, err := r.pool.Query(ctx, qry, limit, offset)
+ if err != nil {
+ if pgErr, ok := err.(*pgconn.PgError); ok && (pgErr.Code == "42P01" || pgErr.Code == "42703") {
+ logrus.WithError(err).Warn("listing: attempting schema repair")
+ if r.ensureSchema(context.Background()) == nil {
+ rows, err = r.pool.Query(ctx, qry, limit, offset)
+ }
+ }
+ }
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []ChatInteraction
+ for rows.Next() {
+ var rec ChatInteraction
+ if err := rows.Scan(&rec.CorrelationID, &rec.UserMessage, &rec.Translate, &rec.Animal, &rec.Keywords, &rec.BestVisitID, &rec.TotalPrice, &rec.TotalDuration, &rec.CreatedAt); err != nil {
+ return nil, err
+ }
+ out = append(out, rec)
+ }
+ return out, rows.Err()
+}
+
+// SaveLLMRawEvent inserts a raw event record
+func (r *PGChatRepository) SaveLLMRawEvent(ctx context.Context, correlationID, phase, raw string) error {
+ if r == nil || r.pool == nil {
+ return nil
+ }
+ ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
+ defer cancel()
+ _, err := r.pool.Exec(ctx, `INSERT INTO chat_llm_raw (correlation_id, phase, raw_json) VALUES ($1,$2,$3)`, correlationID, phase, raw)
+ if err != nil {
+ logrus.WithError(err).Warn("failed to persist raw llm event")
+ }
+ return err
+}
+
+// ListLLMRawEvents retrieves raw LLM events with pagination
+func (r *PGChatRepository) ListLLMRawEvents(ctx context.Context, correlationID string, limit, offset int) ([]RawLLMEvent, error) {
+ if r == nil || r.pool == nil || correlationID == "" {
+ return []RawLLMEvent{}, nil
+ }
+ if limit <= 0 || limit > 500 {
+ limit = 50
+ }
+ if offset < 0 {
+ offset = 0
+ }
+ ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
+ defer cancel()
+ rows, err := r.pool.Query(ctx, `SELECT correlation_id, phase, COALESCE(raw_json,'') as raw_json, created_at FROM chat_llm_raw WHERE correlation_id=$1 ORDER BY created_at ASC LIMIT $2 OFFSET $3`, correlationID, limit, offset)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []RawLLMEvent
+ for rows.Next() {
+ var ev RawLLMEvent
+ if err := rows.Scan(&ev.CorrelationID, &ev.Phase, &ev.RawJSON, &ev.CreatedAt); err != nil {
+ return nil, err
+ }
+ out = append(out, ev)
+ }
+ return out, rows.Err()
+}
+
+// Close releases pool resources
+func (r *PGChatRepository) Close() {
+ if r != nil && r.pool != nil {
+ r.pool.Close()
+ }
+}
+
+func nullIfEmpty(s string) interface{} {
+ if s == "" {
+ return nil
+ }
+ return s
+}
+
+// Helper to build DSN from env if DATABASE_URL not provided
+func buildDefaultDSN() string {
+ if dsn := os.Getenv("DATABASE_URL"); dsn != "" {
+ return dsn
+ }
+ host := envOr("PGHOST", "localhost")
+ port := envOr("PGPORT", "5432")
+ user := envOr("PGUSER", "postgres")
+ pass := os.Getenv("PGPASSWORD")
+ db := envOr("PGDATABASE", "vetrag")
+ ssl := envOr("PGSSLMODE", "disable")
+ if pass != "" {
+ return "postgres://" + user + ":" + pass + "@" + host + ":" + port + "/" + db + "?sslmode=" + ssl
+ }
+ return "postgres://" + user + "@" + host + ":" + port + "/" + db + "?sslmode=" + ssl
+}
+
+func envOr(k, def string) string {
+ if v := os.Getenv(k); v != "" {
+ return v
+ }
+ return def
+}
diff --git a/ui.go b/ui.go
index e3cf703..1a7e6db 100644
--- a/ui.go
+++ b/ui.go
@@ -6,6 +6,7 @@ import (
var uiTemplate *template.Template
var uiDBEditTemplate *template.Template
+var uiAdminChatsTemplate *template.Template
func loadUITemplate(path string) error {
tmpl, err := template.ParseFiles(path)
@@ -24,3 +25,12 @@ func loadDBEditTemplate(path string) error {
uiDBEditTemplate = tmpl
return nil
}
+
+func loadAdminChatsTemplate(path string) error {
+ tmpl, err := template.ParseFiles(path)
+ if err != nil {
+ return err
+ }
+ uiAdminChatsTemplate = tmpl
+ return nil
+}
diff --git a/ui_admin_chats.html b/ui_admin_chats.html
new file mode 100644
index 0000000..e619e7d
--- /dev/null
+++ b/ui_admin_chats.html
@@ -0,0 +1,136 @@
+
+
+
+
+
+ Chat Interactions Admin
+
+
+
+
+
+
Chat Interactions
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | Correlation |
+ Message |
+ Match |
+ Price |
+ Duration |
+ Created |
+ Events |
+
+
+
+
+
+
+
+
Raw LLM Events
+
+
Select a row to view events.
+
+
+
+
+
+
+
diff --git a/visits.bleve/store/root.bolt b/visits.bleve/store/root.bolt
index a9d8665..28eeff7 100644
Binary files a/visits.bleve/store/root.bolt and b/visits.bleve/store/root.bolt differ