feat: auto-narasi LLM (OmniRoute sonnet-5) — hero briefing + narasi_awam report, migrasi 0006
This commit is contained in:
@@ -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 {
|
||||||
|
|||||||
@@ -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 != "" {
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 '';
|
||||||
@@ -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
@@ -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) }),
|
||||||
|
|||||||
@@ -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) }));
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
Reference in New Issue
Block a user