- IDOR: interrogate without report_id now scoped to user_key (LatestReportForUser); regression test TestInterrogateIDORScoped - auth: signup/login per-IP rate limit 10/60s (new internal/api/ratelimit.go) + test - chat unscoped grounding: build caller's own briefing instead of global LatestBriefing - DailyVolumes: dedupe by bar date (snapshot rows hold 30-day windows) — fixes volume-anomaly skew - GetDestination: direct (id,user_key) query instead of listing all - ListRoutines: single LastRunsByRoutine query instead of N+1 RunHistory - FE: exportMd/ask/HTML/PDF export now surface errors; alerts create clears channels
107 lines
3.7 KiB
Go
107 lines
3.7 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
)
|
|
|
|
// Interrogate serves POST /api/report/:ticker/ask {question, report_id?}:
|
|
// follow-up Q&A grounded ONLY in that report's persisted citations.
|
|
// Without report_id it uses the latest report for the ticker.
|
|
func (s *Server) Interrogate(w http.ResponseWriter, r *http.Request) {
|
|
ticker := strings.ToUpper(chi.URLParam(r, "ticker"))
|
|
var req struct {
|
|
Question string `json:"question" validate:"required"`
|
|
ReportID int64 `json:"report_id"`
|
|
}
|
|
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, "question is required")
|
|
return
|
|
}
|
|
payload, citesRaw, at, id, err := s.loadReport(ticker, req.ReportID, s.userKey(r))
|
|
if err != nil {
|
|
writeErr(w, http.StatusNotFound, "no report for "+ticker+" yet — POST /api/report/"+ticker+" first")
|
|
return
|
|
}
|
|
var cites []map[string]any
|
|
_ = json.Unmarshal([]byte(citesRaw), &cites)
|
|
answer := groundedAnswer(req.Question, payload)
|
|
if s.LLM.Available() {
|
|
if text, err := s.LLM.Complete(r.Context(), s.Cfg.LLMTriage,
|
|
"Answer ONLY from the report JSON below. Every number must quote its cited value. "+
|
|
"If the report lacks the answer, say exactly: not in this report.",
|
|
"Question: "+req.Question+"\n\nReport:\n"+head(payload, 3000), 400); err == nil && text != "" {
|
|
answer = strings.TrimSpace(text)
|
|
}
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"answer": answer, "ticker": ticker, "report_id": id,
|
|
"generated_at": at, "citations": cites,
|
|
})
|
|
}
|
|
|
|
// loadReport fetches (payload, citations, generated_at, id) for an explicit
|
|
// report id (scoped to the caller's userKey) or the latest report for a ticker.
|
|
func (s *Server) loadReport(ticker string, id int64, userKey string) (string, string, string, int64, error) {
|
|
if id > 0 {
|
|
var t, p, c, at string
|
|
var rid int64
|
|
err := s.DB.QueryRow(`SELECT id, ticker, payload_json, citations_json, generated_at
|
|
FROM reports WHERE id=? AND user_key=?`, id, userKey).Scan(&rid, &t, &p, &c, &at)
|
|
if err != nil {
|
|
return "", "", "", 0, err
|
|
}
|
|
if t != ticker {
|
|
return "", "", "", 0, fmt.Errorf("report %d belongs to %s", id, t)
|
|
}
|
|
return p, c, at, rid, nil
|
|
}
|
|
p, c, at, err := s.DB.LatestReportForUser(ticker, userKey)
|
|
if err != nil {
|
|
return "", "", "", 0, err
|
|
}
|
|
var rid int64
|
|
_ = s.DB.QueryRow(`SELECT id FROM reports WHERE ticker=? AND user_key=? ORDER BY id DESC LIMIT 1`,
|
|
ticker, userKey).Scan(&rid)
|
|
return p, c, at, rid, nil
|
|
}
|
|
|
|
// groundedAnswer is the offline fallback: it extracts the recommendation +
|
|
// conviction + cited lines matching question keywords from the payload.
|
|
func groundedAnswer(question, payload string) string {
|
|
var rep struct {
|
|
Synthesis struct {
|
|
Recommendation string `json:"recommendation"`
|
|
Conviction int `json:"conviction"`
|
|
Thesis string `json:"thesis"`
|
|
} `json:"synthesis"`
|
|
Sections []struct {
|
|
Name string `json:"name"`
|
|
Body string `json:"body"`
|
|
} `json:"sections"`
|
|
}
|
|
if err := json.Unmarshal([]byte(payload), &rep); err != nil {
|
|
return "not in this report"
|
|
}
|
|
q := strings.ToLower(question)
|
|
if strings.Contains(q, "conviction") || strings.Contains(q, "kenapa") || strings.Contains(q, "why") {
|
|
return fmt.Sprintf("%s with conviction %d/5: %s",
|
|
rep.Synthesis.Recommendation, rep.Synthesis.Conviction, rep.Synthesis.Thesis)
|
|
}
|
|
for _, sec := range rep.Sections {
|
|
if strings.Contains(q, strings.ToLower(sec.Name)) {
|
|
return sec.Body
|
|
}
|
|
}
|
|
return fmt.Sprintf("%s (conviction %d/5): %s",
|
|
rep.Synthesis.Recommendation, rep.Synthesis.Conviction, rep.Synthesis.Thesis)
|
|
}
|