admin chat ui
This commit is contained in:
parent
cc518cd76f
commit
f452c709c6
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
27
Makefile
27
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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
7
go.mod
7
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
|
||||
|
|
|
|||
18
go.sum
18
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=
|
||||
|
|
|
|||
|
|
@ -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
67
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.
|
||||
|
|
|
|||
91
main.go
91
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")
|
||||
|
|
|
|||
|
|
@ -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
10
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 => ({ '&':'&','<':'<','>':'>','"':'"','\'':''' }[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.
Loading…
Reference in New Issue