From 9fa47240dca57c6d21dcc4912a886b918e510e92 Mon Sep 17 00:00:00 2001 From: asepharyana Date: Tue, 15 Sep 2026 19:50:48 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20auto-narasi=20LLM=20(OmniRoute=20sonnet?= =?UTF-8?q?-5)=20=E2=80=94=20hero=20briefing=20+=20narasi=5Fawam=20report,?= =?UTF-8?q?=20migrasi=200006?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/api/chat.go | 2 +- backend/internal/api/health.go | 2 +- backend/internal/api/routines.go | 20 +++++++++- backend/internal/reports/report.go | 38 +++++++++++++++++++ .../internal/store/migrations/0006_narasi.sql | 4 ++ backend/internal/store/rows.go | 18 ++++++--- web/src/lib/api.ts | 4 +- web/src/pages/Dashboard.tsx | 4 +- web/src/pages/Report.tsx | 6 +++ 9 files changed, 86 insertions(+), 12 deletions(-) create mode 100644 backend/internal/store/migrations/0006_narasi.sql diff --git a/backend/internal/api/chat.go b/backend/internal/api/chat.go index 43739a3..5b6fefc 100644 --- a/backend/internal/api/chat.go +++ b/backend/internal/api/chat.go @@ -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 { diff --git a/backend/internal/api/health.go b/backend/internal/api/health.go index f06cfbe..5be0ac8 100644 --- a/backend/internal/api/health.go +++ b/backend/internal/api/health.go @@ -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 != "" { diff --git a/backend/internal/api/routines.go b/backend/internal/api/routines.go index ad98e70..5e37dd1 100644 --- a/backend/internal/api/routines.go +++ b/backend/internal/api/routines.go @@ -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, }) } diff --git a/backend/internal/reports/report.go b/backend/internal/reports/report.go index d33ec76..458b445 100644 --- a/backend/internal/reports/report.go +++ b/backend/internal/reports/report.go @@ -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: — 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 { diff --git a/backend/internal/store/migrations/0006_narasi.sql b/backend/internal/store/migrations/0006_narasi.sql new file mode 100644 index 0000000..eaeab9d --- /dev/null +++ b/backend/internal/store/migrations/0006_narasi.sql @@ -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 ''; diff --git a/backend/internal/store/rows.go b/backend/internal/store/rows.go index 2b5b79e..0f13cd2 100644 --- a/backend/internal/store/rows.go +++ b/backend/internal/store/rows.go @@ -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. diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 4bf183b..18f5769 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -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; 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 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 const api = { health: (force = false) => req(`/api/health${force ? "?force=1" : ""}`), @@ -33,7 +33,7 @@ export const api = { createRoutine: (body: Record) => req<{ id: number }>("/api/routines", { method: "POST", body: JSON.stringify(body) }), updateRoutine: (id: number, body: Record) => req(`/api/routines/${id}`, { method: "PATCH", body: JSON.stringify(body) }), runs: (routine_id?: number) => req<{ runs: Record[] }>(`/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"), createDestination: (body: Record) => req<{ id: number }>("/api/destinations", { method: "POST", body: JSON.stringify(body) }), updateDestination: (id: number, body: Record) => req(`/api/destinations/${id}`, { method: "PATCH", body: JSON.stringify(body) }), diff --git a/web/src/pages/Dashboard.tsx b/web/src/pages/Dashboard.tsx index ed2a532..8271d46 100644 --- a/web/src/pages/Dashboard.tsx +++ b/web/src/pages/Dashboard.tsx @@ -21,7 +21,9 @@ export default function Dashboard() { const [foreign] = createResource(chartTiker, (t) => api.flowForeign(t).catch(() => null)); 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) : []; }; const sorotan = () => (screen()?.rows || []).map((r) => ({ row: r, ...verdictFor(r) })); diff --git a/web/src/pages/Report.tsx b/web/src/pages/Report.tsx index b02630d..3f196ca 100644 --- a/web/src/pages/Report.tsx +++ b/web/src/pages/Report.tsx @@ -48,6 +48,12 @@ export default function Report() {

{err()}

+ + + Kesimpulan buat kamu 🤝 +

{rep()!.narasi_awam}

+
+