diff --git a/.gitignore b/.gitignore index c29f702..1e3a592 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ reasons.bleve +visits.bleve/** visits.bleve # JetBrains / IntelliJ / GoLand project files .idea/ diff --git a/controllers.go b/controllers.go new file mode 100644 index 0000000..34731ca --- /dev/null +++ b/controllers.go @@ -0,0 +1,203 @@ +package main + +import ( + "net/http" + "strconv" + "strings" + + "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" +) + +// Controller struct to hold dependencies +// Add more dependencies as needed +// e.g. templates, services, etc. +type Controller struct { + repo ChatRepositoryAPI + llm LLMClientAPI + visitDB *VisitDB + template TemplateExecutor + uiDBEditTemplate TemplateExecutor + uiAdminChatsTemplate TemplateExecutor +} + +// NewController creates a new Controller instance +func NewController(repo ChatRepositoryAPI, llm LLMClientAPI, visitDB *VisitDB, template, uiDBEditTemplate, uiAdminChatsTemplate TemplateExecutor) *Controller { + return &Controller{ + repo: repo, + llm: llm, + visitDB: visitDB, + template: template, + uiDBEditTemplate: uiDBEditTemplate, + uiAdminChatsTemplate: uiAdminChatsTemplate, + } +} + +// Handler for root UI +func (ctrl *Controller) RootUI(c *gin.Context) { + c.Status(http.StatusOK) + if err := ctrl.template.Execute(c.Writer, nil); err != nil { + logrus.Errorf("Failed to execute ui.html template: %v", err) + } +} + +// Handler for health check +func (ctrl *Controller) Health(c *gin.Context) { + c.Status(http.StatusOK) + c.JSON(http.StatusOK, gin.H{"status": "ok"}) +} + +// Handler for chat +func (ctrl *Controller) HandleChat(c *gin.Context) { + // Use your existing chatService logic here + // You may want to move NewChatService to a service file for full MVC + chatService := NewChatService(ctrl.llm, ctrl.visitDB, ctrl.repo) + chatService.HandleChat(c) +} + +// Handler for admin UI +func (ctrl *Controller) AdminUI(c *gin.Context) { + c.Status(http.StatusOK) + if err := ctrl.uiDBEditTemplate.Execute(c.Writer, nil); err != nil { + logrus.Errorf("Failed to execute ui_dbedit.html template: %v", err) + } +} + +// Handler for download db.yaml +func (ctrl *Controller) DownloadDB(c *gin.Context) { + c.File("db.yaml") +} + +// Handler for saving knowledgeModel (snapshot) +func (ctrl *Controller) SaveKnowledgeModel(c *gin.Context) { + if ctrl.repo == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "repository not configured"}) + return + } + var req struct { + Text string `json:"text"` + } + if err := c.BindJSON(&req); err != nil || req.Text == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) + return + } + if err := ctrl.repo.SaveKnowledgeModel(c.Request.Context(), req.Text); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save snapshot"}) + return + } + c.JSON(http.StatusOK, gin.H{"status": "ok"}) +} + +// Handler for listing chat interactions +func (ctrl *Controller) ListChatInteractions(c *gin.Context) { + if ctrl.repo == nil { + c.JSON(http.StatusOK, 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 := ctrl.repo.ListChatInteractions(c.Request.Context(), limit, offset) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list interactions"}) + return + } + c.JSON(http.StatusOK, gin.H{"items": items, "pagination": gin.H{"limit": limit, "offset": offset, "count": len(items)}}) +} + +// Handler for listing LLM events +func (ctrl *Controller) ListLLMEvents(c *gin.Context) { + corr := c.Query("correlation_id") + if corr == "" { + if strings.Contains(c.GetHeader("Accept"), "application/json") { + c.JSON(http.StatusBadRequest, gin.H{"error": "missing correlation_id"}) + return + } + c.Status(http.StatusOK) + if err := ctrl.uiAdminChatsTemplate.Execute(c.Writer, nil); err != nil { + logrus.Errorf("Failed to execute ui_admin_chats.html template: %v", err) + } + return + } + if ctrl.repo == nil { + c.JSON(http.StatusOK, gin.H{"items": []RawLLMEvent{}, "pagination": gin.H{"limit": 0, "offset": 0, "count": 0}, "warning": "repository not configured"}) + 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 := ctrl.repo.ListLLMRawEvents(c.Request.Context(), corr, limit, offset) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list events"}) + return + } + c.JSON(http.StatusOK, gin.H{"items": events, "pagination": gin.H{"limit": limit, "offset": offset, "count": len(events)}}) +} + +// Handler for admin chats UI +func (ctrl *Controller) AdminChatsUI(c *gin.Context) { + c.Status(http.StatusOK) + if err := ctrl.uiAdminChatsTemplate.Execute(c.Writer, nil); err != nil { + logrus.Errorf("Failed to execute ui_admin_chats.html template: %v", err) + } +} + +// Handler for listing knowledgeModel entries +func (ctrl *Controller) ListKnowledgeModels(c *gin.Context) { + if ctrl.repo == nil { + c.JSON(http.StatusOK, gin.H{"items": []interface{}{}}) + return + } + models, err := ctrl.repo.ListKnowledgeModels(c.Request.Context(), 100, 0) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list snapshots"}) + return + } + c.JSON(http.StatusOK, gin.H{"items": models}) +} + +// Handler for getting a specific knowledgeModel entry +func (ctrl *Controller) GetKnowledgeModel(c *gin.Context) { + if ctrl.repo == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "repository not configured"}) + return + } + idStr := c.Param("id") + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + text, err := ctrl.repo.GetKnowledgeModelText(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "snapshot not found"}) + return + } + c.Data(http.StatusOK, "text/yaml; charset=utf-8", []byte(text)) +} + +// TemplateExecutor is an interface for template execution +// This allows for easier testing and decoupling +// You can implement this interface for your templates + +type TemplateExecutor interface { + Execute(wr http.ResponseWriter, data interface{}) error +} diff --git a/main.go b/main.go index bf22092..e767010 100644 --- a/main.go +++ b/main.go @@ -2,14 +2,24 @@ package main import ( "context" + "html/template" + "net/http" "os" - "strconv" - "strings" "github.com/gin-gonic/gin" "github.com/sirupsen/logrus" ) +// TemplateWrapper adapts html/template.Template to TemplateExecutor +// (implements Execute(http.ResponseWriter, interface{}) error) +type TemplateWrapper struct { + Tmpl *template.Template +} + +func (tw *TemplateWrapper) Execute(wr http.ResponseWriter, data interface{}) error { + return tw.Tmpl.Execute(wr, data) +} + func main() { logrus.SetFormatter(&logrus.TextFormatter{FullTimestamp: true}) logrus.SetLevel(logrus.InfoLevel) @@ -23,6 +33,12 @@ func main() { if err := loadUITemplate("ui.html"); err != nil { logrus.Fatalf("Failed to load ui.html: %v", err) } + 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) + } // Initialize PostgreSQL repository first dsn := buildDefaultDSN() @@ -44,158 +60,39 @@ func main() { ) var llm LLMClientAPI = llmClient - chatService := NewChatService(llm, &visitDB, repo) + // Wrap templates for controller + uiTmpl := &TemplateWrapper{Tmpl: uiTemplate} + uiDBEditTmpl := &TemplateWrapper{Tmpl: uiDBEditTemplate} + uiAdminChatsTmpl := &TemplateWrapper{Tmpl: uiAdminChatsTemplate} + + // Create controller + ctrl := NewController( + repo, + llm, + &visitDB, + uiTmpl, + uiDBEditTmpl, + uiAdminChatsTmpl, + ) + r := gin.Default() // Routes - r.GET("/", func(c *gin.Context) { - c.Status(200) - if err := uiTemplate.Execute(c.Writer, nil); err != nil { - logrus.Errorf("Failed to execute ui.html template: %v", err) - } - }) - r.GET("/health", func(c *gin.Context) { - c.Status(200) - c.JSON(200, gin.H{"status": "ok"}) - }) - r.POST("/chat", chatService.HandleChat) + r.GET("/", ctrl.RootUI) + r.GET("/health", ctrl.Health) + r.POST("/chat", ctrl.HandleChat) - 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("/admin", ctrl.AdminUI) + r.GET("/db.yaml", ctrl.DownloadDB) - // Add knowledgeModel save endpoint (replaces snapshot) - r.POST("/admin/chats/snapshot", func(c *gin.Context) { - if repo == nil { - c.JSON(500, gin.H{"error": "repository not configured"}) - return - } - var req struct { - Text string `json:"text"` - } - if err := c.BindJSON(&req); err != nil || req.Text == "" { - c.JSON(400, gin.H{"error": "invalid request"}) - return - } - if err := repo.SaveKnowledgeModel(c.Request.Context(), req.Text); err != nil { - c.JSON(500, gin.H{"error": "failed to save snapshot"}) - return - } - c.JSON(200, gin.H{"status": "ok"}) - }) + // KnowledgeModel endpoints (replaces snapshot) + r.POST("/admin/chats/snapshot", ctrl.SaveKnowledgeModel) + r.GET("/admin/chats/snapshots", ctrl.ListKnowledgeModels) + r.GET("/admin/chats/snapshot/:id", ctrl.GetKnowledgeModel) - // 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 OR serve UI when no correlation_id provided - r.GET("/admin/chats/events", func(c *gin.Context) { - corr := c.Query("correlation_id") - if corr == "" { - if strings.Contains(c.GetHeader("Accept"), "application/json") { - c.JSON(400, gin.H{"error": "missing correlation_id"}) - return - } - c.Status(200) - if err := uiAdminChatsTemplate.Execute(c.Writer, nil); err != nil { - logrus.Errorf("Failed to execute ui_admin_chats.html template: %v", err) - } - return - } - if repo == nil { // repository not configured, return empty JSON set for events - c.JSON(200, gin.H{"items": []RawLLMEvent{}, "pagination": gin.H{"limit": 0, "offset": 0, "count": 0}, "warning": "repository not configured"}) - 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) - } - }) - - // List knowledgeModel entries (replaces snapshots) - r.GET("/admin/chats/snapshots", func(c *gin.Context) { - if repo == nil { - c.JSON(200, gin.H{"items": []interface{}{}}) - return - } - models, err := repo.ListKnowledgeModels(c.Request.Context(), 100, 0) - if err != nil { - c.JSON(500, gin.H{"error": "failed to list snapshots"}) - return - } - c.JSON(200, gin.H{"items": models}) - }) - - // Get a specific knowledgeModel entry (replaces snapshot) - r.GET("/admin/chats/snapshot/:id", func(c *gin.Context) { - if repo == nil { - c.JSON(404, gin.H{"error": "repository not configured"}) - return - } - idStr := c.Param("id") - id, err := strconv.ParseInt(idStr, 10, 64) - if err != nil { - c.JSON(400, gin.H{"error": "invalid id"}) - return - } - text, err := repo.GetKnowledgeModelText(c.Request.Context(), id) - if err != nil { - c.JSON(404, gin.H{"error": "snapshot not found"}) - return - } - c.Data(200, "text/yaml; charset=utf-8", []byte(text)) - }) + // Chat interactions and events + r.GET("/admin/chats", ctrl.ListChatInteractions) + r.GET("/admin/chats/events", ctrl.ListLLMEvents) + r.GET("/admin/chats/ui", ctrl.AdminChatsUI) r.Run(":8080") } diff --git a/repository.go b/repository.go index 070cf1d..674d204 100644 --- a/repository.go +++ b/repository.go @@ -130,6 +130,10 @@ func (r *PGChatRepository) SaveChatInteraction(ctx context.Context, rec ChatInte if r == nil || r.pool == nil { return nil } + // Ensure keywords is not nil to satisfy NOT NULL constraint + if rec.Keywords == nil { + rec.Keywords = []string{} + } ctx, cancel := context.WithTimeout(ctx, 3*time.Second) defer cancel() _, err := r.pool.Exec(ctx, `INSERT INTO chat_interactions @@ -224,56 +228,62 @@ func (r *PGChatRepository) ListLLMRawEvents(ctx context.Context, correlationID s return out, rows.Err() } -// SaveKnowledgeModel inserts a new knowledgeModel entry -func (r *PGChatRepository) SaveKnowledgeModel(ctx context.Context, text string) error { - if r == nil || r.pool == nil { +// SaveKnowledgeModel saves a knowledge model snapshot +func (p *PGChatRepository) SaveKnowledgeModel(ctx context.Context, text string) error { + if p == nil || p.pool == nil { return nil } - ctx, cancel := context.WithTimeout(ctx, 2*time.Second) + ctx, cancel := context.WithTimeout(ctx, 3*time.Second) defer cancel() - _, err := r.pool.Exec(ctx, `INSERT INTO knowledgeModel (knowledge_text) VALUES ($1)`, text) + _, err := p.pool.Exec(ctx, `INSERT INTO knowledgeModel (knowledge_text) VALUES ($1)`, text) + if err != nil { + logrus.WithError(err).Warn("failed to persist knowledge model") + } return err } -// ListKnowledgeModels returns a list of knowledgeModel metadata (id, created_at) -func (r *PGChatRepository) ListKnowledgeModels(ctx context.Context, limit, offset int) ([]knowledgeModelMeta, error) { - if r == nil || r.pool == nil { - return nil, nil +// ListKnowledgeModels lists knowledge model metadata +func (p *PGChatRepository) ListKnowledgeModels(ctx context.Context, limit, offset int) ([]knowledgeModelMeta, error) { + if p == nil || p.pool == nil { + return []knowledgeModelMeta{}, nil } - if limit <= 0 || limit > 1000 { + if limit <= 0 || limit > 500 { limit = 100 } if offset < 0 { offset = 0 } - ctx, cancel := context.WithTimeout(ctx, 2*time.Second) + ctx, cancel := context.WithTimeout(ctx, 3*time.Second) defer cancel() - rows, err := r.pool.Query(ctx, `SELECT id, created_at FROM knowledgeModel ORDER BY created_at DESC LIMIT $1 OFFSET $2`, limit, offset) + rows, err := p.pool.Query(ctx, `SELECT id, created_at FROM knowledgeModel ORDER BY created_at DESC LIMIT $1 OFFSET $2`, limit, offset) if err != nil { return nil, err } defer rows.Close() var out []knowledgeModelMeta for rows.Next() { - var s knowledgeModelMeta - if err := rows.Scan(&s.ID, &s.CreatedAt); err != nil { + var meta knowledgeModelMeta + if err := rows.Scan(&meta.ID, &meta.CreatedAt); err != nil { return nil, err } - out = append(out, s) + out = append(out, meta) } return out, rows.Err() } -// GetKnowledgeModelText returns the knowledge_text for a given id -func (r *PGChatRepository) GetKnowledgeModelText(ctx context.Context, id int64) (string, error) { - if r == nil || r.pool == nil { +// GetKnowledgeModelText retrieves the text of a knowledge model by ID +func (p *PGChatRepository) GetKnowledgeModelText(ctx context.Context, id int64) (string, error) { + if p == nil || p.pool == nil { return "", nil } - ctx, cancel := context.WithTimeout(ctx, 2*time.Second) + ctx, cancel := context.WithTimeout(ctx, 3*time.Second) defer cancel() var text string - err := r.pool.QueryRow(ctx, `SELECT knowledge_text FROM knowledgeModel WHERE id=$1`, id).Scan(&text) - return text, err + err := p.pool.QueryRow(ctx, `SELECT knowledge_text FROM knowledgeModel WHERE id = $1`, id).Scan(&text) + if err != nil { + return "", err + } + return text, nil } // Close releases pool resources diff --git a/visits.bleve/store/root.bolt b/visits.bleve/store/root.bolt index a896a59..042f9c8 100644 Binary files a/visits.bleve/store/root.bolt and b/visits.bleve/store/root.bolt differ