vetrag/chat_service.go

233 lines
6.7 KiB
Go

package main
import (
"context"
"fmt"
"net/http"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/sirupsen/logrus"
)
// ChatServiceAPI allows mocking ChatService in other places
// Only public methods should be included
type ChatServiceAPI interface {
HandleChat(c *gin.Context)
}
// ChatService handles chat interactions and orchestrates LLM and DB calls.
type ChatService struct {
LLM LLMClientAPI
visitsDB VisitDBAPI
repo ChatRepositoryAPI
}
var _ ChatServiceAPI = (*ChatService)(nil)
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) {
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
}
kwResp, err := cs.LLM.ExtractKeywords(ctx, req.Message)
if err != nil {
cs.persistInteraction(ctx, corrID, req.Message, nil, nil, ChatResponse{Match: nil})
cs.respondWithError(c, req, nil, err)
return
}
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.
func (cs *ChatService) parseRequest(c *gin.Context) (ChatRequest, error) {
var req ChatRequest
if err := c.ShouldBindJSON(&req); err != nil {
logrus.WithError(err).Error("Invalid request")
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"})
return req, err
}
return req, nil
}
// extractKeywords gets keywords from the LLM and normalizes them.
func (cs *ChatService) extractKeywords(ctx context.Context, message string) ([]string, error) {
kwResp, err := cs.LLM.ExtractKeywords(ctx, message)
if err != nil {
return nil, err
}
return cs.keywordsToStrings(kwResp["keyword"]), nil
}
// findBestVisit finds candidate visits and disambiguates the best match.
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
}
bestID := ""
rawDis := ""
if len(candidates) > 0 {
bestID, err = cs.LLM.DisambiguateBestMatch(ctx, req.Message, candidates)
cs.logBestID(bestID, err)
}
visit, err := cs.visitsDB.FindById(bestID)
if err != nil {
return nil, rawDis, fmt.Errorf("FindById: %w", err)
}
return &visit, rawDis, nil
}
// buildResponse constructs the ChatResponse from the best Visit.
func (cs *ChatService) buildResponse(best *Visit) ChatResponse {
if best == nil {
resp := ChatResponse{Match: nil}
logrus.WithFields(logrus.Fields{"response": resp}).Info("Build response: no match")
return resp
}
totalPrice, totalDuration := sumProcedures(best.Procedures)
resp := ChatResponse{
Match: &best.ID,
Procedures: best.Procedures,
TotalPrice: totalPrice,
TotalDuration: totalDuration,
Notes: best.Notes,
}
logrus.WithFields(logrus.Fields{"response": resp}).Info("Build response: match found")
return resp
}
// respondWithError logs and responds with error details.
func (cs *ChatService) respondWithError(c *gin.Context, req ChatRequest, keywords []string, err error) {
kwMap := map[string]interface{}{"keyword": keywords}
cs.logChat(req, kwMap, nil, "", err)
c.JSON(http.StatusOK, ChatResponse{Match: nil})
}
// keywordsToStrings normalizes keyword interface to []string.
func (cs *ChatService) keywordsToStrings(kwIface interface{}) []string {
var kwArr []string
switch v := kwIface.(type) {
case []interface{}:
for _, item := range v {
if s, ok := item.(string); ok {
kwArr = append(kwArr, s)
}
}
case []string:
kwArr = v
}
return kwArr
}
// logKeywords logs extracted keywords.
func (cs *ChatService) logKeywords(keywords []string, message string) {
logrus.WithFields(logrus.Fields{
"keywords": keywords,
"message": message,
}).Info("Finding visit candidates")
}
// logCandidates logs candidate visits.
func (cs *ChatService) logCandidates(candidates []Visit, err error) {
logrus.WithFields(logrus.Fields{
"candidates": candidates,
"error": err,
}).Info("Candidate visits found")
}
// logBestID logs the best candidate ID.
func (cs *ChatService) logBestID(bestID string, err error) {
logrus.WithFields(logrus.Fields{
"bestID": bestID,
"error": err,
}).Info("Disambiguated best match")
}
// logChat logs the chat request and result details.
func (cs *ChatService) logChat(req ChatRequest, keywords interface{}, candidates []Visit, bestID string, err error) {
var kwMap map[string]interface{}
switch v := keywords.(type) {
case []string:
kwMap = map[string]interface{}{"keyword": v}
case map[string]interface{}:
kwMap = v
default:
kwMap = map[string]interface{}{"keyword": v}
}
logRequest(req, kwMap, candidates, bestID, err)
if candidates != nil && bestID != "" {
var best *Visit
for i := range candidates {
if candidates[i].ID == bestID {
best = &candidates[i]
break
}
}
if best != nil {
totalPrice, totalDuration := sumProcedures(best.Procedures)
logrus.WithFields(logrus.Fields{
"match": best.ID,
"total_price": totalPrice,
"total_duration": totalDuration,
"notes": best.Notes,
}).Info("Responding with match")
}
}
}
// 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")
}
}
// Add this at the top-level (outside any function)
type correlationIDCtxKeyType struct{}
var correlationIDCtxKey = correlationIDCtxKeyType{}