From f452c709c6d5972e2c261f6d0a8a710e79470e23 Mon Sep 17 00:00:00 2001 From: lehel Date: Thu, 2 Oct 2025 10:48:20 +0200 Subject: [PATCH] admin chat ui --- .idea/dataSources.xml | 17 +++ .idea/data_source_mapping.xml | 6 + Makefile | 27 +++- chat_service.go | 78 ++++++++-- chat_service_integration_test.go | 6 +- go.mod | 7 + go.sum | 18 ++- handlechat_integration_test.go | 168 +++++++++++++++++++++ llm.go | 67 ++++++--- main.go | 91 +++++++++++- repository.go | 244 +++++++++++++++++++++++++++++++ ui.go | 10 ++ ui_admin_chats.html | 136 +++++++++++++++++ visits.bleve/store/root.bolt | Bin 262144 -> 262144 bytes 14 files changed, 836 insertions(+), 39 deletions(-) create mode 100644 .idea/dataSources.xml create mode 100644 .idea/data_source_mapping.xml create mode 100644 handlechat_integration_test.go create mode 100644 repository.go create mode 100644 ui_admin_chats.html 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

+
+
+ + +
+
+ + +
+
+ +
+
+ +
+
+
+ + + + + + + + + + + + + +
CorrelationMessageMatchPriceDurationCreatedEvents
+
+
+ +

Raw LLM Events

+
+

Select a row to view events.

+ +
+
+ + + + diff --git a/visits.bleve/store/root.bolt b/visits.bleve/store/root.bolt index a9d86654fe6d715c364eddc15e04b2211288821e..28eeff7bc2ab6728f6759dbaa872a1143a7c257d 100644 GIT binary patch delta 317 zcmZo@5NK!+nBX7~#{dEOTKqd_|C?vIF>t;8S{C?x5?{CqzE>^fUi delta 373 zcmZo@5NK!+nBXAb!~g-GgU&qJFV(emW8ixG$^Y#g1stIgd+YBWYIosO0ZDBBUoXj| zZ5U!?WMyDtWon>jY;J03Y^-en0d)&7ESv1upfNeSNtwwo zb@PO#Ikuc#KygNp++@>+XvXf%6AuJ3O3Fd4atG2ZK+Fu~+fDx6D65HVou#n_PRmw6 zEmL7M0NPbxJNe)LNnjh=8IhfAVqj`uVM4Si(;OHbRF*SAT?C_6z?=vZSjn<6aE|@9 h21W%3CJ=kQ{lo