feat: auto-narasi LLM (OmniRoute sonnet-5) — hero briefing + narasi_awam report, migrasi 0006
CI / go test + web typecheck (push) Canceled after 0s
CI / build + deploy (Nix) — flowsight (push) Canceled after 0s

This commit is contained in:
asepharyana
2026-09-15 19:50:48 +07:00
parent 390133663a
commit 9fa47240dc
9 changed files with 86 additions and 12 deletions
+1 -1
View File
@@ -42,7 +42,7 @@ func (s *Server) Chat(w http.ResponseWriter, r *http.Request) {
citesRaw = cites
} else {
// Unscoped: ground on the latest briefing + watchlist.
_, payload, cites, err := s.DB.LatestBriefing()
_, payload, cites, _, err := s.DB.LatestBriefing()
if err != nil {
ground = "no briefing or report data yet"
} else {
+1 -1
View File
@@ -16,7 +16,7 @@ func (s *Server) Health(w http.ResponseWriter, r *http.Request) {
lastCycle, schedOK := s.Sched.Status()
today := time.Now().Format("2006-01-02")
credits := s.DB.CreditsToday(today)
lastDate, _, _, _ := s.DB.LatestBriefing()
lastDate, _, _, _, _ := s.DB.LatestBriefing()
cutoff := model.StaleSession(time.Now())
stale := []string{}
if lastDate != "" {
+18 -2
View File
@@ -4,6 +4,7 @@ import (
"encoding/json"
"net/http"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
@@ -115,7 +116,7 @@ func (s *Server) RunHistory(w http.ResponseWriter, r *http.Request) {
// When no briefing exists yet it generates one on demand from current
// snapshots (so the UI never shows an empty page), then persists it.
func (s *Server) BriefingToday(w http.ResponseWriter, r *http.Request) {
date, payload, cites, err := s.DB.LatestBriefing()
date, payload, cites, narasi, err := s.DB.LatestBriefing()
if err != nil {
uk := s.userKey(r)
p, cc, gerr := s.Engine.BriefingFor(r.Context(), uk)
@@ -130,8 +131,23 @@ func (s *Server) BriefingToday(w http.ResponseWriter, r *http.Request) {
}
date, payload, cites = todayUTC(), p, string(ccJSON)
}
// Lazy narration: polish once via LLM, cache in DB, serve thereafter.
// Any LLM failure leaves narasi empty (FE falls back to rules summary).
if narasi == "" && s.LLM.Available() {
if text, nerr := s.LLM.Complete(r.Context(), s.Cfg.LLMSynth,
"Kamu asisten pasar saham Indonesia yang ramah. Poles ringkasan data "+
"berikut jadi TEPAT 3 kalimat Bahasa Indonesia santai untuk orang awam. "+
"Kalimat 1: saham apa yang paling diborong + artinya. "+
"Kalimat 2: arah uang asing. Kalimat 3: hal yang perlu diperhatikan. "+
"Setiap angka WAJIB berasal dari data — jangan mengarang. Tanpa sapaan.",
"Data:\n"+head(payload, 2500), 300); nerr == nil && text != "" {
narasi = strings.TrimSpace(text)
_ = s.DB.SaveNarasi(date, narasi)
}
}
writeJSON(w, http.StatusOK, map[string]any{
"date": date, "payload": payload, "citations": cites,
"date": date, "payload": payload, "citations": cites, "narasi": narasi,
})
}
+38
View File
@@ -32,6 +32,7 @@ type Report struct {
Synthesis agents.Synthesis `json:"synthesis"`
AgentScores []model.AgentResult `json:"agent_scores"`
AllCitations []model.Citation `json:"citations"`
NarasiAwam string `json:"narasi_awam,omitempty"`
}
// Builder assembles reports from agent runs over stored snapshots.
@@ -85,9 +86,46 @@ func (b *Builder) Build(ctx context.Context, ticker, profile string) (Report, in
Ticker: ticker, GeneratedAt: todayStr(),
Sections: sections, Synthesis: synth,
AgentScores: results, AllCitations: all,
NarasiAwam: b.narasiAwam(ctx, ticker, synth, sections),
}, id, nil
}
// narasiAwam polishes the rule-based synthesis into plain Indonesian for
// non-experts (verdict + 3 alasan + arti + risiko). Best-effort: any LLM
// failure returns "" and the FE falls back to raw sections.
func (b *Builder) narasiAwam(ctx context.Context, ticker string, synth agents.Synthesis, sections []Section) string {
if b.Deps.LLM == nil || !b.Deps.LLM.Available() {
return ""
}
var sb strings.Builder
for _, s := range sections {
if strings.TrimSpace(s.Body) != "" {
sb.WriteString("- " + s.Name + ": " + s.Body + "\n")
}
}
text, err := b.Deps.LLM.Complete(ctx, b.Deps.SynthModel,
"Kamu asisten pasar saham Indonesia yang ramah. Dari data report berikut, tulis Bahasa Indonesia santai untuk orang awam dengan format:\n"+
"Baris 1: KESIMPULAN: <Dilirik/Tahan/Dilepas> — 1 kalimat.\n"+
"Baris 2-4: 3 alasan (masing-masing 1 kalimat, sebut angkanya).\n"+
"Baris 5: ARTINYA BUAT SAYA: 1 kalimat.\n"+
"Baris 6: RISIKO: 1 kalimat.\n"+
"Setiap angka WAJIB dari data — jangan mengarang. Maksimal 150 kata.",
"Ticker: "+ticker+"\nRekomendasi sistem: "+synth.Recommendation+
" (conviction "+fmt.Sprint(synth.Conviction)+"/5)\nTesis: "+synth.Thesis+
"\nData:\n"+head(sb.String(), 2500), 400)
if err != nil || strings.TrimSpace(text) == "" {
return ""
}
return strings.TrimSpace(text)
}
func head(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n]
}
func citesOf(byAgent map[string]model.AgentResult, names ...string) []model.Citation {
var out []model.Citation
for _, n := range names {
@@ -0,0 +1,4 @@
-- FlowSight schema v6: cached LLM narration for briefings.
-- narasi = 3 kalimat santai Indonesia hasil poles LLM (OmniRoute),
-- best-effort: kosong berarti FE pakai ringkasan rules.
ALTER TABLE briefings ADD COLUMN narasi TEXT NOT NULL DEFAULT '';
+13 -5
View File
@@ -637,11 +637,19 @@ func (db *DB) SaveBriefing(date, payload, cites string) error {
return err
}
// LatestBriefing returns the newest briefing.
func (db *DB) LatestBriefing() (date, payload, cites string, err error) {
err = db.QueryRow(`SELECT date, payload_json, citations_json FROM briefings
ORDER BY date DESC LIMIT 1`).Scan(&date, &payload, &cites)
return date, payload, cites, err
// SaveNarasi caches the LLM-polished narration for a briefing date
// (best-effort; empty string clears it).
func (db *DB) SaveNarasi(date, narasi string) error {
_, err := db.Exec(`UPDATE briefings SET narasi=? WHERE date=?`, narasi, date)
return err
}
// LatestBriefing returns the newest briefing (plus cached narration).
func (db *DB) LatestBriefing() (date, payload, cites, narasi string, err error) {
err = db.QueryRow(`SELECT date, payload_json, citations_json,
COALESCE(narasi,'') FROM briefings
ORDER BY date DESC LIMIT 1`).Scan(&date, &payload, &cites, &narasi)
return date, payload, cites, narasi, err
}
// Accuracy ledger.