72 lines
2.2 KiB
Go
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]
|
|
}
|