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 { 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, 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") } }