This commit is contained in:
lehel 2025-10-07 15:27:57 +02:00
parent 3f2789b4da
commit 5651b89a6b
No known key found for this signature in database
GPG Key ID: 9C4F9D6111EE5CFA
5 changed files with 281 additions and 170 deletions

1
.gitignore vendored
View File

@ -1,4 +1,5 @@
reasons.bleve
visits.bleve/**
visits.bleve
# JetBrains / IntelliJ / GoLand project files
.idea/

203
controllers.go Normal file
View File

@ -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
}

195
main.go
View File

@ -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")
}

View File

@ -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

Binary file not shown.