Files
asepharyana 1ec860f9be fix: audit round 3 — IDOR report-scoping, auth rate-limit, briefing chat scoping, DailyVolumes dedup, N+1 list routines, FE export/ask error handling
- 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
2026-09-16 14:12:32 +07:00

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