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 citesRaw = cites
} else { } else {
// Unscoped: ground on the latest briefing + watchlist. // Unscoped: ground on the latest briefing + watchlist.
_, payload, cites, err := s.DB.LatestBriefing() _, payload, cites, _, err := s.DB.LatestBriefing()
if err != nil { if err != nil {
ground = "no briefing or report data yet" ground = "no briefing or report data yet"
} else { } else {
+1 -1
View File
@@ -16,7 +16,7 @@ func (s *Server) Health(w http.ResponseWriter, r *http.Request) {
lastCycle, schedOK := s.Sched.Status() lastCycle, schedOK := s.Sched.Status()
today := time.Now().Format("2006-01-02") today := time.Now().Format("2006-01-02")
credits := s.DB.CreditsToday(today) credits := s.DB.CreditsToday(today)
lastDate, _, _, _ := s.DB.LatestBriefing() lastDate, _, _, _, _ := s.DB.LatestBriefing()
cutoff := model.StaleSession(time.Now()) cutoff := model.StaleSession(time.Now())
stale := []string{} stale := []string{}
if lastDate != "" { if lastDate != "" {
+18 -2
View File
@@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"strconv" "strconv"
"strings"
"time" "time"
"github.com/go-chi/chi/v5" "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 // When no briefing exists yet it generates one on demand from current
// snapshots (so the UI never shows an empty page), then persists it. // snapshots (so the UI never shows an empty page), then persists it.
func (s *Server) BriefingToday(w http.ResponseWriter, r *http.Request) { 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 { if err != nil {
uk := s.userKey(r) uk := s.userKey(r)
p, cc, gerr := s.Engine.BriefingFor(r.Context(), uk) 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) 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{ 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"` Synthesis agents.Synthesis `json:"synthesis"`
AgentScores []model.AgentResult `json:"agent_scores"` AgentScores []model.AgentResult `json:"agent_scores"`
AllCitations []model.Citation `json:"citations"` AllCitations []model.Citation `json:"citations"`
NarasiAwam string `json:"narasi_awam,omitempty"`
} }
// Builder assembles reports from agent runs over stored snapshots. // 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(), Ticker: ticker, GeneratedAt: todayStr(),
Sections: sections, Synthesis: synth, Sections: sections, Synthesis: synth,
AgentScores: results, AllCitations: all, AgentScores: results, AllCitations: all,
NarasiAwam: b.narasiAwam(ctx, ticker, synth, sections),
}, id, nil }, 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 { func citesOf(byAgent map[string]model.AgentResult, names ...string) []model.Citation {
var out []model.Citation var out []model.Citation
for _, n := range names { 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 return err
} }
// LatestBriefing returns the newest briefing. // SaveNarasi caches the LLM-polished narration for a briefing date
func (db *DB) LatestBriefing() (date, payload, cites string, err error) { // (best-effort; empty string clears it).
err = db.QueryRow(`SELECT date, payload_json, citations_json FROM briefings func (db *DB) SaveNarasi(date, narasi string) error {
ORDER BY date DESC LIMIT 1`).Scan(&date, &payload, &cites) _, err := db.Exec(`UPDATE briefings SET narasi=? WHERE date=?`, narasi, date)
return date, payload, cites, err 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. // Accuracy ledger.
+2 -2
View File
@@ -21,7 +21,7 @@ export interface FlowSummary { date: string; foreign_net_total: number; top_accu
export interface ScreenRow { symbol: string; name: string; composite: number; breakdown: Record<string, unknown>; citations: Citation[] } export interface ScreenRow { symbol: string; name: string; composite: number; breakdown: Record<string, unknown>; citations: Citation[] }
export interface Routine { id: number; user_key: string; type: string; schedule_cron: string; channels_json?: string; channels?: string[]; enabled: boolean; last_run?: unknown } export interface Routine { id: number; user_key: string; type: string; schedule_cron: string; channels_json?: string; channels?: string[]; enabled: boolean; last_run?: unknown }
export interface AlertItem { id: number; user_key: string; name: string; rule_json: string; channels_json: string; last_fired: string } export interface AlertItem { id: number; user_key: string; name: string; rule_json: string; channels_json: string; last_fired: string }
export interface ReportPayload { ticker: string; generated_at: string; sections: { name: string; body: string; citations: Citation[] }[]; synthesis: { recommendation: string; conviction: number; thesis: string; position_pct: number; conflict: boolean; conflict_note?: string }; agent_scores: unknown[]; citations: Citation[] } export interface ReportPayload { ticker: string; generated_at: string; sections: { name: string; body: string; citations: Citation[] }[]; synthesis: { recommendation: string; conviction: number; thesis: string; position_pct: number; conflict: boolean; conflict_note?: string }; agent_scores: unknown[]; citations: Citation[]; narasi_awam?: string }
export interface AuthUser { id: number; email: string; name: string; avatar_url: string; user_key: string; google_configured: boolean } export interface AuthUser { id: number; email: string; name: string; avatar_url: string; user_key: string; google_configured: boolean }
export const api = { export const api = {
health: (force = false) => req<Health>(`/api/health${force ? "?force=1" : ""}`), health: (force = false) => req<Health>(`/api/health${force ? "?force=1" : ""}`),
@@ -33,7 +33,7 @@ export const api = {
createRoutine: (body: Record<string, unknown>) => req<{ id: number }>("/api/routines", { method: "POST", body: JSON.stringify(body) }), createRoutine: (body: Record<string, unknown>) => req<{ id: number }>("/api/routines", { method: "POST", body: JSON.stringify(body) }),
updateRoutine: (id: number, body: Record<string, unknown>) => req<unknown>(`/api/routines/${id}`, { method: "PATCH", body: JSON.stringify(body) }), updateRoutine: (id: number, body: Record<string, unknown>) => req<unknown>(`/api/routines/${id}`, { method: "PATCH", body: JSON.stringify(body) }),
runs: (routine_id?: number) => req<{ runs: Record<string, unknown>[] }>(`/api/routine-runs${routine_id ? `?routine_id=${routine_id}` : ""}`), runs: (routine_id?: number) => req<{ runs: Record<string, unknown>[] }>(`/api/routine-runs${routine_id ? `?routine_id=${routine_id}` : ""}`),
briefing: () => req<{ date: string; payload: string; citations: string }>("/api/briefing/today"), briefing: () => req<{ date: string; payload: string; citations: string; narasi?: string }>("/api/briefing/today"),
destinations: () => req<{ destinations: { id: number; kind: string; label: string; enabled: boolean; configured: boolean }[] }>("/api/destinations"), destinations: () => req<{ destinations: { id: number; kind: string; label: string; enabled: boolean; configured: boolean }[] }>("/api/destinations"),
createDestination: (body: Record<string, unknown>) => req<{ id: number }>("/api/destinations", { method: "POST", body: JSON.stringify(body) }), createDestination: (body: Record<string, unknown>) => req<{ id: number }>("/api/destinations", { method: "POST", body: JSON.stringify(body) }),
updateDestination: (id: number, body: Record<string, unknown>) => req<unknown>(`/api/destinations/${id}`, { method: "PATCH", body: JSON.stringify(body) }), updateDestination: (id: number, body: Record<string, unknown>) => req<unknown>(`/api/destinations/${id}`, { method: "PATCH", body: JSON.stringify(body) }),
+3 -1
View File
@@ -21,7 +21,9 @@ export default function Dashboard() {
const [foreign] = createResource(chartTiker, (t) => api.flowForeign(t).catch(() => null)); const [foreign] = createResource(chartTiker, (t) => api.flowForeign(t).catch(() => null));
const ringkasan = () => { const ringkasan = () => {
const p = (briefing() as { payload: string } | null)?.payload; const n = briefing()?.narasi?.trim();
if (n) return n.split("\n").map((s) => s.replace(/^[-•\d.]+\s*/, "")).filter(Boolean).slice(0, 3);
const p = briefing()?.payload;
return p ? heroSummary(p) : []; return p ? heroSummary(p) : [];
}; };
const sorotan = () => (screen()?.rows || []).map((r) => ({ row: r, ...verdictFor(r) })); const sorotan = () => (screen()?.rows || []).map((r) => ({ row: r, ...verdictFor(r) }));
+6
View File
@@ -48,6 +48,12 @@ export default function Report() {
<Show when={err()}><p class="text-sm text-destructive">{err()}</p></Show> <Show when={err()}><p class="text-sm text-destructive">{err()}</p></Show>
<Show when={busy()}><div class="space-y-2"><Skeleton class="h-32 w-full" /><Skeleton class="h-24 w-full" /></div></Show> <Show when={busy()}><div class="space-y-2"><Skeleton class="h-32 w-full" /><Skeleton class="h-24 w-full" /></div></Show>
<Show when={rep()}> <Show when={rep()}>
<Show when={rep()!.narasi_awam}>
<Card class="border-primary/30 bg-gradient-to-br from-primary/10 to-transparent">
<CardHeader><CardTitle class="text-xl">Kesimpulan buat kamu 🤝</CardTitle></CardHeader>
<CardContent><p class="whitespace-pre-wrap text-base leading-relaxed">{rep()!.narasi_awam}</p></CardContent>
</Card>
</Show>
<Card class="border-l-4 border-l-primary"> <Card class="border-l-4 border-l-primary">
<CardContent class="flex flex-wrap items-center gap-2 pt-6"> <CardContent class="flex flex-wrap items-center gap-2 pt-6">
<RecBadge rec={rep()!.synthesis.recommendation} /> <RecBadge rec={rep()!.synthesis.recommendation} />