216 lines
5.6 KiB
Go
216 lines
5.6 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"html/template"
|
|
"io/ioutil"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/sirupsen/logrus"
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
// Procedure represents a single procedure for a visit reason
|
|
type Procedure struct {
|
|
Name string `yaml:"name" json:"name"`
|
|
Price int `yaml:"price" json:"price"`
|
|
DurationMin int `yaml:"duration_minutes" json:"duration_minutes"`
|
|
}
|
|
|
|
// Reason represents a visit reason entry
|
|
type Reason struct {
|
|
ID string `yaml:"id" json:"id"`
|
|
Reason string `yaml:"reason" json:"reason"`
|
|
Keywords []string `yaml:"keywords" json:"keywords"`
|
|
Procedures []Procedure `yaml:"procedures" json:"procedures"`
|
|
Notes string `yaml:"notes" json:"notes,omitempty"`
|
|
}
|
|
|
|
var reasonsDB []Reason
|
|
|
|
func loadYAMLDB(path string) error {
|
|
data, err := ioutil.ReadFile(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return yaml.Unmarshal(data, &reasonsDB)
|
|
}
|
|
|
|
// ChatRequest represents the incoming chat message
|
|
type ChatRequest struct {
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
// ChatResponse represents the response to the frontend
|
|
type ChatResponse struct {
|
|
Match *string `json:"match"`
|
|
Procedures []Procedure `json:"procedures,omitempty"`
|
|
TotalPrice int `json:"total_price,omitempty"`
|
|
TotalDuration int `json:"total_duration,omitempty"`
|
|
Notes string `json:"notes,omitempty"`
|
|
}
|
|
|
|
// naiveKeywordExtract splits message into lowercase words (placeholder for LLM)
|
|
func naiveKeywordExtract(msg string) []string {
|
|
// TODO: Replace with LLM call
|
|
words := make(map[string]struct{})
|
|
for _, w := range strings.FieldsFunc(strings.ToLower(msg), func(r rune) bool {
|
|
return r < 'a' || r > 'z' && r < 'á' || r > 'ű'
|
|
}) {
|
|
words[w] = struct{}{}
|
|
}
|
|
res := make([]string, 0, len(words))
|
|
for w := range words {
|
|
res = append(res, w)
|
|
}
|
|
return res
|
|
}
|
|
|
|
// findCandidates returns reasons with overlapping keywords
|
|
func findCandidates(keywords []string) []Reason {
|
|
kwSet := make(map[string]struct{})
|
|
for _, k := range keywords {
|
|
kwSet[k] = struct{}{}
|
|
}
|
|
var candidates []Reason
|
|
for _, r := range reasonsDB {
|
|
for _, k := range r.Keywords {
|
|
if _, ok := kwSet[strings.ToLower(k)]; ok {
|
|
candidates = append(candidates, r)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
return candidates
|
|
}
|
|
|
|
// sumProcedures calculates total price and duration
|
|
func sumProcedures(procs []Procedure) (int, int) {
|
|
totalPrice := 0
|
|
totalDuration := 0
|
|
for _, p := range procs {
|
|
totalPrice += p.Price
|
|
totalDuration += p.DurationMin
|
|
}
|
|
return totalPrice, totalDuration
|
|
}
|
|
|
|
var uiTemplate *template.Template
|
|
|
|
func loadUITemplate(path string) error {
|
|
tmpl, err := template.ParseFiles(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
uiTemplate = tmpl
|
|
return nil
|
|
}
|
|
|
|
func main() {
|
|
logrus.SetFormatter(&logrus.TextFormatter{FullTimestamp: true})
|
|
logrus.SetLevel(logrus.InfoLevel)
|
|
|
|
if err := loadConfig("config.yaml"); err != nil {
|
|
logrus.Fatalf("Failed to load config.yaml: %v", err)
|
|
}
|
|
logrus.Infof("Loaded config: %+v", appConfig)
|
|
if err := loadYAMLDB("db.yaml"); err != nil {
|
|
logrus.Fatalf("Failed to load db.yaml: %v", err)
|
|
}
|
|
fmt.Printf("Loaded %d reasons from db.yaml\n", len(reasonsDB))
|
|
if err := loadUITemplate("ui.html"); err != nil {
|
|
logrus.Fatalf("Failed to load ui.html: %v", err)
|
|
}
|
|
llm := &LLMClient{
|
|
APIKey: os.Getenv("OPENAI_API_KEY"),
|
|
BaseURL: os.Getenv("OPENAI_BASE_URL"),
|
|
}
|
|
r := gin.Default()
|
|
r.GET("/", func(c *gin.Context) {
|
|
c.Status(200)
|
|
uiTemplate.Execute(c.Writer, nil)
|
|
})
|
|
r.POST("/chat", func(c *gin.Context) {
|
|
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
|
|
}
|
|
ctx := context.Background()
|
|
keywords, err := llm.ExtractKeywords(ctx, req.Message)
|
|
kwIface := keywords["keyword"]
|
|
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
|
|
}
|
|
candidates := findCandidates(kwArr)
|
|
bestID := ""
|
|
if len(candidates) > 0 && err == nil {
|
|
bestID, err = llm.DisambiguateBestMatch(ctx, req.Message, candidates)
|
|
}
|
|
logRequest(req, keywords, candidates, bestID, err)
|
|
if err != nil || len(keywords) == 0 || len(candidates) == 0 || bestID == "" {
|
|
c.JSON(http.StatusOK, ChatResponse{Match: nil})
|
|
return
|
|
}
|
|
var best *Reason
|
|
for i := range candidates {
|
|
if candidates[i].ID == bestID {
|
|
best = &candidates[i]
|
|
break
|
|
}
|
|
}
|
|
if best == nil {
|
|
c.JSON(http.StatusOK, ChatResponse{Match: nil})
|
|
return
|
|
}
|
|
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")
|
|
c.JSON(http.StatusOK, ChatResponse{
|
|
Match: &best.ID,
|
|
Procedures: best.Procedures,
|
|
TotalPrice: totalPrice,
|
|
TotalDuration: totalDuration,
|
|
Notes: best.Notes,
|
|
})
|
|
})
|
|
|
|
r.Run(":8080")
|
|
}
|
|
|
|
// logRequest logs incoming chat requests and extracted info
|
|
func logRequest(req ChatRequest, keywords map[string]interface{}, candidates []Reason, bestID string, err error) {
|
|
logrus.WithFields(logrus.Fields{
|
|
"message": req.Message,
|
|
"keywords": keywords,
|
|
"candidates": getCandidateIDs(candidates),
|
|
"bestID": bestID,
|
|
"err": err,
|
|
}).Info("Chat request trace")
|
|
}
|
|
|
|
func getCandidateIDs(candidates []Reason) []string {
|
|
ids := make([]string, len(candidates))
|
|
for i, c := range candidates {
|
|
ids[i] = c.ID
|
|
}
|
|
return ids
|
|
}
|