Files
flowsight/backend/internal/api/chat.go
T

72 lines
2.2 KiB
Go

package api
import (
"encoding/json"
"net/http"
"strings"
)
// ChatRequest is POST /api/chat {message, scope?: {report_id}}.
type ChatRequest struct {
Message string `json:"message" validate:"required"`
Scope *struct {
ReportID int64 `json:"report_id"`
} `json:"scope"`
}
// Chat serves POST /api/chat: cited answers. With scope.report_id the
// grounding is restricted to that report's citations (report interrogation);
// without scope it answers from latest snapshots. The LLM refines prose only
// — numbers always come from stored data, never from generation.
func (s *Server) Chat(w http.ResponseWriter, r *http.Request) {
var req ChatRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, "invalid JSON body")
return
}
if err := s.Validate.Struct(req); err != nil {
writeErr(w, http.StatusUnprocessableEntity, "message is required")
return
}
// Scope grounding: report citations when scoped (owner-scoped).
var ground, citesRaw string
if req.Scope != nil && req.Scope.ReportID > 0 {
var cites string
var at string
err := s.DB.QueryRow(`SELECT payload_json, citations_json, generated_at FROM reports WHERE id=? AND user_key=?`,
req.Scope.ReportID, s.userKey(r)).Scan(&ground, &cites, &at)
if err != nil {
writeErr(w, http.StatusNotFound, "report not found")
return
}
citesRaw = cites
} else {
// Unscoped: ground on the latest briefing + watchlist.
_, payload, cites, _, err := s.DB.LatestBriefing()
if err != nil {
ground = "no briefing or report data yet"
} else {
ground, citesRaw = payload, cites
}
}
answer := "Based on stored data: " + head(ground, 600)
if s.LLM.Available() {
if text, err := s.LLM.Complete(r.Context(), s.Cfg.LLMTriage,
"You answer questions about Indonesian stocks using ONLY the grounded data below. "+
"Every number in your answer must cite its source. If the data lacks the answer, say so.",
"Question: "+req.Message+"\n\nGrounded data:\n"+head(ground, 3000), 400); err == nil {
answer = strings.TrimSpace(text)
}
}
writeJSON(w, http.StatusOK, map[string]any{
"answer": answer, "grounding": head(ground, 600), "citations": citesRaw,
})
}
func head(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n]
}