admin chat ui

This commit is contained in:
lehel 2025-10-02 10:48:20 +02:00
parent cc518cd76f
commit f452c709c6
No known key found for this signature in database
GPG Key ID: 9C4F9D6111EE5CFA
14 changed files with 836 additions and 39 deletions

17
.idea/dataSources.xml Normal file
View File

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
<data-source source="LOCAL" name="ledger-balance-service@localhost" uuid="85be2c57-4234-463a-a995-092322f406a0">
<driver-ref>postgresql</driver-ref>
<synchronize>true</synchronize>
<jdbc-driver>org.postgresql.Driver</jdbc-driver>
<jdbc-url>jdbc:postgresql://localhost:5432/ledger-balance-service</jdbc-url>
<jdbc-additional-properties>
<property name="com.intellij.clouds.kubernetes.db.host.port" />
<property name="com.intellij.clouds.kubernetes.db.enabled" value="false" />
<property name="com.intellij.clouds.kubernetes.db.container.port" />
</jdbc-additional-properties>
<working-dir>$ProjectFileDir$</working-dir>
</data-source>
</component>
</project>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourcePerFileMappings">
<file url="file://$APPLICATION_CONFIG_DIR$/consoles/db/85be2c57-4234-463a-a995-092322f406a0/console.sql" value="85be2c57-4234-463a-a995-092322f406a0" />
</component>
</project>

View File

@ -20,9 +20,32 @@ ollama-pull:
ollama-status: ollama-status:
ollama list 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 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 # Run tests
.PHONY: test .PHONY: test

View File

@ -6,6 +6,7 @@ import (
"net/http" "net/http"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
) )
@ -20,29 +21,35 @@ type ChatServiceAPI interface {
type ChatService struct { type ChatService struct {
LLM LLMClientAPI LLM LLMClientAPI
visitsDB VisitDBAPI visitsDB VisitDBAPI
repo ChatRepositoryAPI
} }
var _ ChatServiceAPI = (*ChatService)(nil) var _ ChatServiceAPI = (*ChatService)(nil)
func NewChatService(llm LLMClientAPI, db VisitDBAPI) ChatServiceAPI { func NewChatService(llm LLMClientAPI, db VisitDBAPI, repo ChatRepositoryAPI) ChatServiceAPI {
return &ChatService{LLM: llm, visitsDB: db} return &ChatService{LLM: llm, visitsDB: db, repo: repo}
} }
// HandleChat is the main entrypoint for chat requests. It delegates to modular helpers. // HandleChat is the main entrypoint for chat requests. It delegates to modular helpers.
func (cs *ChatService) HandleChat(c *gin.Context) { 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) req, err := cs.parseRequest(c)
if err != nil { if err != nil {
return return
} }
keywords, err := cs.extractKeywords(ctx, req.Message) kwResp, err := cs.LLM.ExtractKeywords(ctx, req.Message)
if err != nil { 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 return
} }
best, err := cs.findBestVisit(ctx, req, keywords) keywords := cs.keywordsToStrings(kwResp["keyword"])
best, _, err := cs.findBestVisit(ctx, req, keywords)
resp := cs.buildResponse(best) resp := cs.buildResponse(best)
c.JSON(http.StatusOK, resp) c.JSON(http.StatusOK, resp)
cs.persistInteraction(ctx, corrID, req.Message, kwResp, best, resp)
} }
// parseRequest parses and validates the incoming chat request. // 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. // 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) cs.logKeywords(keywords, req.Message)
candidates, err := cs.visitsDB.FindCandidates(keywords) candidates, err := cs.visitsDB.FindCandidates(keywords)
cs.logCandidates(candidates, err) cs.logCandidates(candidates, err)
if err != nil { if err != nil {
return nil, err return nil, "", err
} }
bestID := "" bestID := ""
rawDis := ""
if len(candidates) > 0 { if len(candidates) > 0 {
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) bestID, err = cs.LLM.DisambiguateBestMatch(ctx, req.Message, candidates)
cs.logBestID(bestID, err) cs.logBestID(bestID, err)
} }
}
visit, err := cs.visitsDB.FindById(bestID) visit, err := cs.visitsDB.FindById(bestID)
if err != nil { 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. // 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")
}
}

View File

@ -64,7 +64,7 @@ func TestChatService_MatchFound(t *testing.T) {
candidates: []Visit{visit}, candidates: []Visit{visit},
byID: map[string]Visit{"deworming": visit}, byID: map[string]Visit{"deworming": visit},
} }
var cs ChatServiceAPI = NewChatService(llm, db) var cs ChatServiceAPI = NewChatService(llm, db, nil)
r := gin.New() r := gin.New()
r.POST("/chat", cs.HandleChat) r.POST("/chat", cs.HandleChat)
@ -103,7 +103,7 @@ func TestChatService_NoMatch(t *testing.T) {
candidates: []Visit{}, candidates: []Visit{},
byID: map[string]Visit{}, byID: map[string]Visit{},
} }
cs := NewChatService(llm, db) cs := NewChatService(llm, db, nil)
r := gin.New() r := gin.New()
r.POST("/chat", cs.HandleChat) r.POST("/chat", cs.HandleChat)
@ -132,7 +132,7 @@ func TestChatService_LLMError(t *testing.T) {
keywordsErr: context.DeadlineExceeded, keywordsErr: context.DeadlineExceeded,
} }
db := &testVisitDB{} db := &testVisitDB{}
cs := NewChatService(llm, db) cs := NewChatService(llm, db, nil)
r := gin.New() r := gin.New()
r.POST("/chat", cs.HandleChat) r.POST("/chat", cs.HandleChat)

7
go.mod
View File

@ -5,6 +5,8 @@ go 1.25
require ( require (
github.com/blevesearch/bleve/v2 v2.5.3 github.com/blevesearch/bleve/v2 v2.5.3
github.com/gin-gonic/gin v1.11.0 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 github.com/sirupsen/logrus v1.9.3
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
) )
@ -41,8 +43,12 @@ require (
github.com/goccy/go-yaml v1.18.0 // indirect github.com/goccy/go-yaml v1.18.0 // indirect
github.com/golang/protobuf v1.5.0 // indirect github.com/golang/protobuf v1.5.0 // indirect
github.com/golang/snappy v0.0.4 // 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/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // 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/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect
@ -51,6 +57,7 @@ require (
github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/quic-go/qpack v0.5.1 // indirect github.com/quic-go/qpack v0.5.1 // indirect
github.com/quic-go/quic-go v0.54.0 // 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/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect github.com/ugorji/go/codec v1.3.0 // indirect
go.etcd.io/bbolt v1.4.0 // indirect go.etcd.io/bbolt v1.4.0 // indirect

18
go.sum
View File

@ -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/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 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= 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.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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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/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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 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 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= 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 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= 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 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 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/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 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY= 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 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 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.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 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= 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 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-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.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

View File

@ -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")
}
}

67
llm.go
View File

@ -19,15 +19,36 @@ type LLMClient struct {
APIKey string APIKey string
BaseURL string BaseURL string
Model string Model string
Repo ChatRepositoryAPI
} }
// NewLLMClient constructs a new LLMClient with the given API key and base URL // NewLLMClient constructs a new LLMClient with the given API key, base URL, model, and optional repository
func NewLLMClient(apiKey, baseURL string, model string) *LLMClient { func NewLLMClient(apiKey, baseURL string, model string, repo ChatRepositoryAPI) *LLMClient {
return &LLMClient{ return &LLMClient{APIKey: apiKey, BaseURL: baseURL, Model: model, Repo: repo}
APIKey: apiKey, }
BaseURL: baseURL,
Model: model, 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 // 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 // ExtractKeywords calls LLM to extract keywords from user message
func (llm *LLMClient) ExtractKeywords(ctx context.Context, message string) (map[string]interface{}, error) { 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}) prompt, err := renderPrompt(appConfig.LLM.ExtractKeywordsPrompt, map[string]string{"Message": message})
if err != nil { if err != nil {
logrus.WithError(err).Error("[CONFIG] Failed to render ExtractKeywords prompt") logrus.WithError(err).Error("[CONFIG] Failed to render ExtractKeywords prompt")
return nil, err return "", nil, err
} }
logrus.WithField("prompt", prompt).Info("[LLM] ExtractKeywords prompt") logrus.WithField("prompt", prompt).Info("[LLM] ExtractKeywords prompt")
format := map[string]interface{}{ 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) resp, err := llm.openAICompletion(ctx, prompt, format)
logrus.WithFields(logrus.Fields{"response": resp, "err": err}).Info("[LLM] ExtractKeywords response") logrus.WithFields(logrus.Fields{"response": resp, "err": err}).Info("[LLM] ExtractKeywords response")
if err != nil { if err != nil {
return nil, err return resp, nil, err // return whatever raw we got (may be empty)
} }
var result map[string]interface{} var result map[string]interface{}
if err := json.Unmarshal([]byte(resp), &result); err != nil { 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 // DisambiguateBestMatch calls LLM to pick best match from candidates
func (llm *LLMClient) DisambiguateBestMatch(ctx context.Context, message string, candidates []Visit) (string, error) { 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{}{ format := map[string]interface{}{
"type": "object", "type": "object",
"properties": map[string]interface{}{ "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}) prompt, err := renderPrompt(appConfig.LLM.DisambiguatePrompt, map[string]string{"Entries": string(entries), "Message": message})
if err != nil { if err != nil {
logrus.WithError(err).Error("[CONFIG] Failed to render Disambiguate prompt") logrus.WithError(err).Error("[CONFIG] Failed to render Disambiguate prompt")
return "", err return "", "", err
} }
logrus.WithField("prompt", prompt).Info("[LLM] DisambiguateBestMatch prompt") logrus.WithField("prompt", prompt).Info("[LLM] DisambiguateBestMatch prompt")
resp, err := llm.openAICompletion(ctx, prompt, format) resp, err := llm.openAICompletion(ctx, prompt, format)
logrus.WithFields(logrus.Fields{"response": resp, "err": err}).Info("[LLM] DisambiguateBestMatch response") logrus.WithFields(logrus.Fields{"response": resp, "err": err}).Info("[LLM] DisambiguateBestMatch response")
if err != nil { if err != nil {
return "", err return resp, "", err
} }
var parsed map[string]string var parsed map[string]string
if err := json.Unmarshal([]byte(resp), &parsed); err != nil { 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"]) visitReason := strings.TrimSpace(parsed["visitReason"])
if visitReason == "" { if visitReason == "" {
return "", fmt.Errorf("visitReason not found in response") return resp, "", fmt.Errorf("visitReason not found in response")
} }
llm.persistRaw(ctx, "disambiguate", resp)
return visitReason, nil return resp, visitReason, nil
} }
// openAICompletion now supports both Ollama (default local) and OpenRouter/OpenAI-compatible APIs without external branching. // openAICompletion now supports both Ollama (default local) and OpenRouter/OpenAI-compatible APIs without external branching.

91
main.go
View File

@ -1,7 +1,9 @@
package main package main
import ( import (
"context"
"os" "os"
"strconv"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
@ -20,13 +22,30 @@ func main() {
if err := loadUITemplate("ui.html"); err != nil { if err := loadUITemplate("ui.html"); err != nil {
logrus.Fatalf("Failed to load ui.html: %v", err) 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_API_KEY"),
os.Getenv("OPENAI_BASE_URL"), os.Getenv("OPENAI_BASE_URL"),
os.Getenv("OPENAI_MODEL"), os.Getenv("OPENAI_MODEL"),
repo,
) )
chatService := NewChatService(llm, &visitDB) var llm LLMClientAPI = llmClient
chatService := NewChatService(llm, &visitDB, repo)
r := gin.Default() r := gin.Default()
// Routes
r.GET("/", func(c *gin.Context) { r.GET("/", func(c *gin.Context) {
c.Status(200) c.Status(200)
if err := uiTemplate.Execute(c.Writer, nil); err != nil { if err := uiTemplate.Execute(c.Writer, nil); err != nil {
@ -42,14 +61,78 @@ func main() {
if err := loadDBEditTemplate("ui_dbedit.html"); err != nil { if err := loadDBEditTemplate("ui_dbedit.html"); err != nil {
logrus.Fatalf("Failed to load ui_dbedit.html: %v", err) 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) { r.GET("/admin", func(c *gin.Context) {
c.Status(200) c.Status(200)
if err := uiDBEditTemplate.Execute(c.Writer, nil); err != nil { if err := uiDBEditTemplate.Execute(c.Writer, nil); err != nil {
logrus.Errorf("Failed to execute ui_dbedit.html template: %v", err) logrus.Errorf("Failed to execute ui_dbedit.html template: %v", err)
} }
}) })
r.GET("/db.yaml", func(c *gin.Context) { r.GET("/db.yaml", func(c *gin.Context) { c.File("db.yaml") })
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") r.Run(":8080")

244
repository.go Normal file
View File

@ -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
}

10
ui.go
View File

@ -6,6 +6,7 @@ import (
var uiTemplate *template.Template var uiTemplate *template.Template
var uiDBEditTemplate *template.Template var uiDBEditTemplate *template.Template
var uiAdminChatsTemplate *template.Template
func loadUITemplate(path string) error { func loadUITemplate(path string) error {
tmpl, err := template.ParseFiles(path) tmpl, err := template.ParseFiles(path)
@ -24,3 +25,12 @@ func loadDBEditTemplate(path string) error {
uiDBEditTemplate = tmpl uiDBEditTemplate = tmpl
return nil return nil
} }
func loadAdminChatsTemplate(path string) error {
tmpl, err := template.ParseFiles(path)
if err != nil {
return err
}
uiAdminChatsTemplate = tmpl
return nil
}

136
ui_admin_chats.html Normal file
View File

@ -0,0 +1,136 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Chat Interactions Admin</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>pre{white-space:pre-wrap;word-break:break-word}</style>
</head>
<body class="bg-gray-50 min-h-screen p-4">
<div class="max-w-7xl mx-auto">
<h1 class="text-2xl font-bold mb-4 text-green-700">Chat Interactions</h1>
<div class="flex flex-wrap gap-2 items-end mb-4">
<div>
<label class="block text-xs font-semibold">Limit</label>
<input id="limitInput" type="number" value="50" class="border rounded px-2 py-1 w-24" />
</div>
<div>
<label class="block text-xs font-semibold">Offset</label>
<input id="offsetInput" type="number" value="0" class="border rounded px-2 py-1 w-24" />
</div>
<div>
<button id="loadBtn" class="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700">Load</button>
</div>
<div class="ml-auto">
<input id="filterInput" placeholder="Filter message / visit..." class="border rounded px-2 py-1" />
</div>
</div>
<div class="overflow-auto border rounded bg-white shadow">
<table class="min-w-full text-sm" id="chatsTable">
<thead class="bg-gray-100">
<tr>
<th class="p-2 text-left">Correlation</th>
<th class="p-2 text-left">Message</th>
<th class="p-2 text-left">Match</th>
<th class="p-2 text-left">Price</th>
<th class="p-2 text-left">Duration</th>
<th class="p-2 text-left">Created</th>
<th class="p-2">Events</th>
</tr>
</thead>
<tbody id="chatsBody"></tbody>
</table>
</div>
<div id="paginationInfo" class="text-xs text-gray-600 mt-2"></div>
<h2 class="text-xl font-semibold mt-8 mb-2">Raw LLM Events</h2>
<div id="eventsBox" class="border rounded bg-white shadow p-3 min-h-[120px]">
<p class="text-gray-500" id="eventsEmpty">Select a row to view events.</p>
<div id="eventsContainer" class="hidden"></div>
</div>
</div>
<script>
const chatsBody = document.getElementById('chatsBody');
const limitInput = document.getElementById('limitInput');
const offsetInput = document.getElementById('offsetInput');
const loadBtn = document.getElementById('loadBtn');
const paginationInfo = document.getElementById('paginationInfo');
const filterInput = document.getElementById('filterInput');
const eventsContainer = document.getElementById('eventsContainer');
const eventsEmpty = document.getElementById('eventsEmpty');
let chats = [];
function fmtDate(s) { try { return new Date(s).toLocaleString(); } catch { return s; } }
function escapeHtml(t) { return t.replace(/[&<>"']/g, c => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;','\'':'&#39;' }[c])); }
async function loadChats() {
const limit = Number(limitInput.value) || 50;
const offset = Number(offsetInput.value) || 0;
const resp = await fetch(`/admin/chats?limit=${limit}&offset=${offset}`);
const data = await resp.json();
chats = data.items || [];
renderChats();
paginationInfo.textContent = `Showing ${chats.length} (limit=${limit} offset=${offset})`;
}
function renderChats() {
const f = filterInput.value.toLowerCase();
chatsBody.innerHTML = '';
chats
.filter(r => {
if(!f) return true;
return (r.user_message || '').toLowerCase().includes(f) || (r.best_visit_id || '').toLowerCase().includes(f);
})
.forEach(r => {
const tr = document.createElement('tr');
tr.className = 'border-b hover:bg-green-50';
tr.innerHTML = `
<td class='p-2 align-top font-mono text-[11px] break-all'>${escapeHtml(r.correlation_id)}</td>
<td class='p-2 align-top'>${escapeHtml((r.user_message||'').slice(0,140))}</td>
<td class='p-2 align-top'>${escapeHtml(r.best_visit_id||'')}</td>
<td class='p-2 align-top'>${r.total_price||0}</td>
<td class='p-2 align-top'>${r.total_duration||0}</td>
<td class='p-2 align-top whitespace-nowrap'>${fmtDate(r.created_at)}</td>
<td class='p-2 align-top'><button class='text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700' data-corr='${r.correlation_id}'>View</button></td>
`;
chatsBody.appendChild(tr);
});
document.querySelectorAll('#chatsBody button[data-corr]').forEach(btn => {
btn.onclick = () => loadEvents(btn.getAttribute('data-corr'));
});
}
async function loadEvents(corr) {
eventsEmpty.classList.add('hidden');
eventsContainer.classList.remove('hidden');
eventsContainer.innerHTML = '<div class="text-xs text-gray-500">Loading events...</div>';
const resp = await fetch(`/admin/chats/events?correlation_id=${encodeURIComponent(corr)}`);
if(!resp.ok) {
eventsContainer.innerHTML = '<div class="text-red-600 text-sm">Failed to load events</div>';
return;
}
const data = await resp.json();
const items = data.items || [];
if(items.length === 0) {
eventsContainer.innerHTML = '<div class="text-gray-500 text-sm">No events found.</div>';
return;
}
eventsContainer.innerHTML = '';
items.forEach(ev => {
const pre = document.createElement('pre');
pre.className = 'border rounded mb-3 p-2 bg-gray-900 text-green-200 text-xs overflow-auto';
let raw;
try { raw = JSON.stringify(JSON.parse(ev.raw_json), null, 2); } catch { raw = ev.raw_json; }
pre.innerHTML = `Phase: ${escapeHtml(ev.phase)}\nTime: ${fmtDate(ev.created_at)}\n---\n${escapeHtml(raw)}`;
eventsContainer.appendChild(pre);
});
}
loadBtn.onclick = loadChats;
filterInput.oninput = renderChats;
loadChats();
</script>
</body>
</html>

Binary file not shown.