chore: checkpoint sidebar nav + i18n work-in-progress before Decision Stack redesign
Browse files- .superpowers/brainstorm/677-1778190449/content/full-design.html +127 -0
- .superpowers/brainstorm/677-1778190449/content/interpretation-approaches.html +113 -0
- .superpowers/brainstorm/677-1778190449/content/waiting.html +3 -0
- .superpowers/brainstorm/677-1778190449/persona_critique.md +39 -0
- .superpowers/brainstorm/677-1778190449/state/server-stopped +1 -0
- .superpowers/brainstorm/677-1778190449/state/server.pid +1 -0
- agent/graph.py +61 -8
- agent/prompts.py +6 -0
- analysis/signals.py +5 -0
- analysis/tone_drift.py +417 -0
- analysis/transcript_parse.py +186 -0
- app.py +225 -172
- dashboard/chat.py +4 -5
- dashboard/components.py +4 -0
- dashboard/i18n.py +268 -0
- dashboard/nav.py +193 -0
- dashboard/quality_tone.py +11 -0
- dashboard/theme.py +25 -98
- data/briefs.db +3 -0
- data/chroma/4c22a7aa-c603-468d-a7f1-3fc65686a95b/data_level0.bin +2 -2
- data/chroma/4c22a7aa-c603-468d-a7f1-3fc65686a95b/header.bin +1 -1
- data/chroma/4c22a7aa-c603-468d-a7f1-3fc65686a95b/index_metadata.pickle +2 -2
- data/chroma/4c22a7aa-c603-468d-a7f1-3fc65686a95b/length.bin +2 -2
- data/chroma/4c22a7aa-c603-468d-a7f1-3fc65686a95b/link_lists.bin +2 -2
- data/chroma/chroma.sqlite3 +2 -2
- data/cost_log.jsonl +1 -0
- data/metrics.db +2 -2
- data/sections.db +2 -2
- docs/superpowers/plans/2026-06-04-exposure-radar.md +1118 -0
- ingest.py +65 -26
- ingestion/transcript.py +7 -4
- storage/sections_db.py +32 -0
- tests/test_graph.py +85 -4
- tests/test_sections_db.py +33 -0
- tests/test_tone_drift.py +417 -0
.superpowers/brainstorm/677-1778190449/content/full-design.html
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<h2>Design complet — Option C appliquée à tous les champs</h2>
|
| 2 |
+
<p class="subtitle">Bordure violette + tint + badge "✦ AI" sur chaque zone d'interprétation. Les faits sourcés restent inchangés.</p>
|
| 3 |
+
|
| 4 |
+
<div style="font-family:Inter,sans-serif;max-width:780px;margin:0 auto;">
|
| 5 |
+
|
| 6 |
+
<!-- SECTION DIVIDER -->
|
| 7 |
+
<div style="display:flex;align-items:center;gap:12px;margin:8px 0 18px;">
|
| 8 |
+
<span style="font-size:0.65rem;font-weight:700;text-transform:uppercase;letter-spacing:0.12em;color:#6b7280;white-space:nowrap;">Champs interprétatifs — traitement violet</span>
|
| 9 |
+
<div style="flex:1;height:1px;background:#e5e7eb;"></div>
|
| 10 |
+
</div>
|
| 11 |
+
|
| 12 |
+
<!-- 1. WHAT MATTERS MOST -->
|
| 13 |
+
<div style="margin-bottom:12px;">
|
| 14 |
+
<div style="font-size:0.6rem;color:#9ca3af;font-weight:600;text-transform:uppercase;letter-spacing:0.08em;margin-bottom:5px;">① what_matters_most</div>
|
| 15 |
+
<div style="background:#faf5ff;border:1px solid #e5e7eb;border-left:4px solid #8b5cf6;border-radius:0 12px 12px 0;padding:20px 24px;box-shadow:0 1px 2px rgba(0,0,0,0.04);">
|
| 16 |
+
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
|
| 17 |
+
<span style="font-size:0.6rem;font-weight:700;letter-spacing:0.1em;text-transform:uppercase;color:#6b7280;">What matters most</span>
|
| 18 |
+
<span style="background:#ede9fe;color:#7c3aed;border:1px solid #c4b5fd;border-radius:4px;padding:1px 7px;font-size:0.62rem;font-weight:600;">✦ AI Synthesis</span>
|
| 19 |
+
</div>
|
| 20 |
+
<div style="font-size:0.98rem;line-height:1.7;color:#0a0a0a;">
|
| 21 |
+
La contraction des marges brutes masque une réallocation stratégique vers les services cloud à plus forte valeur — le mix-shift, pas l'inefficacité opérationnelle, explique le delta de 120 bps.
|
| 22 |
+
</div>
|
| 23 |
+
</div>
|
| 24 |
+
</div>
|
| 25 |
+
|
| 26 |
+
<!-- 2. NON-OBVIOUS TAKEAWAY -->
|
| 27 |
+
<div style="margin-bottom:12px;">
|
| 28 |
+
<div style="font-size:0.6rem;color:#9ca3af;font-weight:600;text-transform:uppercase;letter-spacing:0.08em;margin-bottom:5px;">② non_obvious_takeaway</div>
|
| 29 |
+
<div style="background:#faf5ff;border:1px solid #e5e7eb;border-left:4px solid #8b5cf6;border-radius:0 12px 12px 0;padding:16px 20px;box-shadow:0 1px 2px rgba(0,0,0,0.04);">
|
| 30 |
+
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;">
|
| 31 |
+
<span style="font-size:0.6rem;font-weight:700;letter-spacing:0.1em;text-transform:uppercase;color:#6b7280;">Non-obvious takeaway</span>
|
| 32 |
+
<span style="background:#ede9fe;color:#7c3aed;border:1px solid #c4b5fd;border-radius:4px;padding:1px 7px;font-size:0.62rem;font-weight:600;">✦ AI Synthesis</span>
|
| 33 |
+
</div>
|
| 34 |
+
<div style="font-size:0.9rem;font-style:italic;line-height:1.65;color:#0a0a0a;">
|
| 35 |
+
Le retournement du free cash flow en Q3 est le signal que la plupart des modèles sell-side n'ont pas encore intégré dans leurs prévisions de capex 2025.
|
| 36 |
+
</div>
|
| 37 |
+
</div>
|
| 38 |
+
</div>
|
| 39 |
+
|
| 40 |
+
<!-- SECTION DIVIDER -->
|
| 41 |
+
<div style="display:flex;align-items:center;gap:12px;margin:20px 0 14px;">
|
| 42 |
+
<span style="font-size:0.65rem;font-weight:700;text-transform:uppercase;letter-spacing:0.12em;color:#6b7280;white-space:nowrap;">Tension card — interprétation + faits</span>
|
| 43 |
+
<div style="flex:1;height:1px;background:#e5e7eb;"></div>
|
| 44 |
+
</div>
|
| 45 |
+
|
| 46 |
+
<!-- 3. TENSION CARD -->
|
| 47 |
+
<div style="margin-bottom:12px;">
|
| 48 |
+
<div style="font-size:0.6rem;color:#9ca3af;font-weight:600;text-transform:uppercase;letter-spacing:0.08em;margin-bottom:5px;">③ analytical_tensions — readings (AI) vs evidence (sourcé)</div>
|
| 49 |
+
<div style="background:#fffbeb;border:1px solid #fde68a;border-radius:10px;padding:14px 16px;">
|
| 50 |
+
<!-- Headline + weight -->
|
| 51 |
+
<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:8px;margin-bottom:10px;">
|
| 52 |
+
<div style="font-size:0.88rem;font-weight:600;color:#0a0a0a;line-height:1.4;">Revenue beat cache une dégradation silencieuse de la qualité des revenus récurrents</div>
|
| 53 |
+
<span style="background:#fef2f2;color:#ef4444;border:1px solid #ef444433;border-radius:4px;padding:1px 7px;font-size:0.65rem;font-weight:600;text-transform:uppercase;letter-spacing:0.06em;white-space:nowrap;">material</span>
|
| 54 |
+
</div>
|
| 55 |
+
<div style="display:flex;gap:10px;">
|
| 56 |
+
<!-- Bull reading — AI -->
|
| 57 |
+
<div style="flex:1;padding:12px 14px;background:#faf5ff;border:1px solid #ddd6fe;border-radius:8px;min-width:0;">
|
| 58 |
+
<div style="display:flex;align-items:center;gap:6px;margin-bottom:6px;">
|
| 59 |
+
<div style="font-size:0.55rem;font-weight:700;text-transform:uppercase;letter-spacing:0.09em;color:#059669;">Surface reading</div>
|
| 60 |
+
<span style="background:#ede9fe;color:#7c3aed;border:1px solid #c4b5fd;border-radius:3px;padding:0px 5px;font-size:0.55rem;font-weight:600;">✦ AI</span>
|
| 61 |
+
</div>
|
| 62 |
+
<div style="font-size:0.82rem;line-height:1.5;color:#0a0a0a;margin-bottom:8px;">Revenue beat de 3.2% — momentum commercial solide, pipeline en croissance.</div>
|
| 63 |
+
<!-- Evidence sourced -->
|
| 64 |
+
<div style="background:#fff;border:1px solid #e5e7eb;border-radius:6px;padding:8px 10px;margin-top:4px;">
|
| 65 |
+
<div style="font-size:0.55rem;font-weight:700;text-transform:uppercase;letter-spacing:0.07em;color:#6b7280;margin-bottom:4px;">Evidence sourcée</div>
|
| 66 |
+
<div style="font-size:0.73rem;color:#6b7280;font-style:italic;">"Revenue of $24.1B exceeded consensus of $23.4B..."</div>
|
| 67 |
+
<div style="margin-top:5px;display:flex;gap:4px;">
|
| 68 |
+
<span style="background:#ecfdf5;color:#059669;border:1px solid #a7f3d0;border-radius:3px;padding:1px 6px;font-size:0.6rem;font-weight:600;">HIGH</span>
|
| 69 |
+
<span style="background:#eff6ff;color:#2563eb;border:1px solid #93c5fd;border-radius:3px;padding:1px 6px;font-size:0.6rem;">10-Q</span>
|
| 70 |
+
</div>
|
| 71 |
+
</div>
|
| 72 |
+
</div>
|
| 73 |
+
<!-- Bear reading — AI -->
|
| 74 |
+
<div style="flex:1;padding:12px 14px;background:#faf5ff;border:1px solid #ddd6fe;border-radius:8px;min-width:0;">
|
| 75 |
+
<div style="display:flex;align-items:center;gap:6px;margin-bottom:6px;">
|
| 76 |
+
<div style="font-size:0.55rem;font-weight:700;text-transform:uppercase;letter-spacing:0.09em;color:#dc2626;">Deeper reading</div>
|
| 77 |
+
<span style="background:#ede9fe;color:#7c3aed;border:1px solid #c4b5fd;border-radius:3px;padding:0px 5px;font-size:0.55rem;font-weight:600;">✦ AI</span>
|
| 78 |
+
</div>
|
| 79 |
+
<div style="font-size:0.82rem;line-height:1.5;color:#0a0a0a;margin-bottom:8px;">33% du beat provient d'un one-time licensing deal non-récurrent — ARR organique sous-jacent est flat.</div>
|
| 80 |
+
<div style="background:#fff;border:1px solid #e5e7eb;border-radius:6px;padding:8px 10px;margin-top:4px;">
|
| 81 |
+
<div style="font-size:0.55rem;font-weight:700;text-transform:uppercase;letter-spacing:0.07em;color:#6b7280;margin-bottom:4px;">Evidence sourcée</div>
|
| 82 |
+
<div style="font-size:0.73rem;color:#6b7280;font-style:italic;">"One-time licensing contributed $230M to Q3 revenue..."</div>
|
| 83 |
+
<div style="margin-top:5px;display:flex;gap:4px;">
|
| 84 |
+
<span style="background:#ecfdf5;color:#059669;border:1px solid #a7f3d0;border-radius:3px;padding:1px 6px;font-size:0.6rem;font-weight:600;">HIGH</span>
|
| 85 |
+
<span style="background:#eff6ff;color:#2563eb;border:1px solid #93c5fd;border-radius:3px;padding:1px 6px;font-size:0.6rem;">10-Q</span>
|
| 86 |
+
</div>
|
| 87 |
+
</div>
|
| 88 |
+
</div>
|
| 89 |
+
</div>
|
| 90 |
+
</div>
|
| 91 |
+
</div>
|
| 92 |
+
|
| 93 |
+
<!-- SECTION DIVIDER -->
|
| 94 |
+
<div style="display:flex;align-items:center;gap:12px;margin:20px 0 14px;">
|
| 95 |
+
<span style="font-size:0.65rem;font-weight:700;text-transform:uppercase;letter-spacing:0.12em;color:#6b7280;white-space:nowrap;">MD&A — language_shift</span>
|
| 96 |
+
<div style="flex:1;height:1px;background:#e5e7eb;"></div>
|
| 97 |
+
</div>
|
| 98 |
+
|
| 99 |
+
<!-- 4. LANGUAGE SHIFT -->
|
| 100 |
+
<div style="margin-bottom:24px;">
|
| 101 |
+
<div style="font-size:0.6rem;color:#9ca3af;font-weight:600;text-transform:uppercase;letter-spacing:0.08em;margin-bottom:5px;">④ mda_summary.language_shift — lecture du ton management</div>
|
| 102 |
+
<div style="background:#faf5ff;border:1px solid #e5e7eb;border-left:4px solid #8b5cf6;border-radius:0 8px 8px 0;padding:12px 16px;">
|
| 103 |
+
<div style="display:flex;align-items:center;gap:7px;margin-bottom:6px;">
|
| 104 |
+
<span style="font-size:0.58rem;font-weight:700;text-transform:uppercase;letter-spacing:0.08em;color:#6b7280;">Language shift</span>
|
| 105 |
+
<span style="background:#ede9fe;color:#7c3aed;border:1px solid #c4b5fd;border-radius:4px;padding:0px 6px;font-size:0.58rem;font-weight:600;">✦ AI</span>
|
| 106 |
+
</div>
|
| 107 |
+
<div style="font-size:0.85rem;line-height:1.5;color:#0a0a0a;font-style:italic;">
|
| 108 |
+
Le management s'est montré nettement plus défensif sur la guidance de marges vs Q2, substituant "nous visons" par "nous continuerons à surveiller" — signal de conviction réduite.
|
| 109 |
+
</div>
|
| 110 |
+
</div>
|
| 111 |
+
</div>
|
| 112 |
+
|
| 113 |
+
<!-- RECAP -->
|
| 114 |
+
<div style="background:#f9fafb;border:1px solid #e5e7eb;border-radius:10px;padding:16px 20px;">
|
| 115 |
+
<div style="font-size:0.65rem;font-weight:700;text-transform:uppercase;letter-spacing:0.1em;color:#6b7280;margin-bottom:10px;">Récapitulatif du système de couleurs</div>
|
| 116 |
+
<div style="display:flex;flex-wrap:wrap;gap:8px;font-size:0.8rem;">
|
| 117 |
+
<div style="display:flex;align-items:center;gap:6px;"><div style="width:12px;height:12px;border-radius:2px;background:#8b5cf6;"></div> <strong>Violet</strong> — Interprétation AI (nouveau)</div>
|
| 118 |
+
<div style="display:flex;align-items:center;gap:6px;"><div style="width:12px;height:12px;border-radius:2px;background:#10b981;"></div> <strong>Vert</strong> — Bull / fait positif</div>
|
| 119 |
+
<div style="display:flex;align-items:center;gap:6px;"><div style="width:12px;height:12px;border-radius:2px;background:#ef4444;"></div> <strong>Rouge</strong> — Bear / risque</div>
|
| 120 |
+
<div style="display:flex;align-items:center;gap:6px;"><div style="width:12px;height:12px;border-radius:2px;background:#f59e0b;"></div> <strong>Ambre</strong> — Tension / warning</div>
|
| 121 |
+
<div style="display:flex;align-items:center;gap:6px;"><div style="width:12px;height:12px;border-radius:2px;background:#3b82f6;"></div> <strong>Bleu</strong> — Info / raisonnement</div>
|
| 122 |
+
</div>
|
| 123 |
+
</div>
|
| 124 |
+
|
| 125 |
+
</div>
|
| 126 |
+
|
| 127 |
+
<p class="subtitle" style="margin-top:18px;">Le design vous convient ? Répondez dans le terminal pour valider ou demander des ajustements.</p>
|
.superpowers/brainstorm/677-1778190449/content/interpretation-approaches.html
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<h2>Différencier interprétation vs fait sourcé</h2>
|
| 2 |
+
<p class="subtitle">3 traitements visuels — même contenu, même card <code>what_matters_most</code></p>
|
| 3 |
+
|
| 4 |
+
<div class="options">
|
| 5 |
+
|
| 6 |
+
<!-- OPTION A -->
|
| 7 |
+
<div class="option" data-choice="a" onclick="toggleSelect(this)">
|
| 8 |
+
<div class="letter">A</div>
|
| 9 |
+
<div class="content">
|
| 10 |
+
<h3>Badge discret</h3>
|
| 11 |
+
<p>Un chip "AI Synthesis" à côté du label existant. Minimal, ne perturbe pas le flux de lecture.</p>
|
| 12 |
+
|
| 13 |
+
<div style="margin-top:14px;font-family:Inter,sans-serif;">
|
| 14 |
+
<div style="background:#fff;border:1px solid #e5e7eb;border-radius:12px;padding:20px 24px;box-shadow:0 1px 3px rgba(0,0,0,0.06);">
|
| 15 |
+
<div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
|
| 16 |
+
<span style="font-size:0.6rem;font-weight:700;letter-spacing:0.1em;text-transform:uppercase;color:#6b7280;">What matters most</span>
|
| 17 |
+
<span style="background:#f5f3ff;color:#7c3aed;border:1px solid #ddd6fe;border-radius:4px;padding:1px 7px;font-size:0.65rem;font-weight:600;">AI Synthesis</span>
|
| 18 |
+
</div>
|
| 19 |
+
<div style="font-size:0.95rem;line-height:1.7;color:#0a0a0a;">
|
| 20 |
+
La contraction des marges brutes masque une <em>réallocation stratégique</em> vers les services cloud à plus forte valeur — le mix-shift, pas l'inefficacité opérationnelle, explique le delta de 120 bps.
|
| 21 |
+
</div>
|
| 22 |
+
</div>
|
| 23 |
+
<div style="margin-top:10px;background:#fafaf9;border:1px solid #e5e7eb;border-left:3px solid #10b981;border-radius:0 8px 8px 0;padding:14px 18px;">
|
| 24 |
+
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;">
|
| 25 |
+
<span style="font-size:0.6rem;font-weight:700;letter-spacing:0.08em;text-transform:uppercase;color:#6b7280;">Fait sourcé</span>
|
| 26 |
+
<span style="background:#ecfdf5;color:#059669;border:1px solid #a7f3d0;border-radius:4px;padding:1px 7px;font-size:0.65rem;font-weight:600;">HIGH</span>
|
| 27 |
+
<span style="background:#eff6ff;color:#2563eb;border:1px solid #93c5fd;border-radius:4px;padding:1px 8px;font-size:0.65rem;font-weight:500;">10-Q</span>
|
| 28 |
+
</div>
|
| 29 |
+
<div style="font-size:0.85rem;line-height:1.5;color:#0a0a0a;">Gross margin declined 120 bps YoY to 44.2%, driven by cloud infrastructure investment.</div>
|
| 30 |
+
</div>
|
| 31 |
+
</div>
|
| 32 |
+
|
| 33 |
+
<div class="pros-cons" style="margin-top:12px;">
|
| 34 |
+
<div class="pros"><h4>Pour</h4><ul><li>Changement minimal</li><li>S'intègre dans le flux</li></ul></div>
|
| 35 |
+
<div class="cons"><h4>Contre</h4><ul><li>Facile à ignorer</li><li>Pas assez distinctif au scan</li></ul></div>
|
| 36 |
+
</div>
|
| 37 |
+
</div>
|
| 38 |
+
</div>
|
| 39 |
+
|
| 40 |
+
<!-- OPTION B -->
|
| 41 |
+
<div class="option" data-choice="b" onclick="toggleSelect(this)">
|
| 42 |
+
<div class="letter">B</div>
|
| 43 |
+
<div class="content">
|
| 44 |
+
<h3>Bandeau header</h3>
|
| 45 |
+
<p>Une barre colorée en haut de la card, explicitement libellée. Impossible à manquer.</p>
|
| 46 |
+
|
| 47 |
+
<div style="margin-top:14px;font-family:Inter,sans-serif;">
|
| 48 |
+
<div style="background:#fff;border:1px solid #e5e7eb;border-radius:12px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,0.06);">
|
| 49 |
+
<div style="background:#f5f3ff;border-bottom:1px solid #ddd6fe;padding:7px 18px;display:flex;align-items:center;gap:7px;">
|
| 50 |
+
<span style="font-size:0.65rem;">✦</span>
|
| 51 |
+
<span style="font-size:0.65rem;font-weight:700;text-transform:uppercase;letter-spacing:0.1em;color:#7c3aed;">Interprétation AI · non sourcé</span>
|
| 52 |
+
</div>
|
| 53 |
+
<div style="padding:18px 24px;">
|
| 54 |
+
<div style="font-size:0.6rem;font-weight:700;letter-spacing:0.1em;text-transform:uppercase;color:#6b7280;margin-bottom:8px;">What matters most</div>
|
| 55 |
+
<div style="font-size:0.95rem;line-height:1.7;color:#0a0a0a;">
|
| 56 |
+
La contraction des marges brutes masque une <em>réallocation stratégique</em> vers les services cloud à plus forte valeur — le mix-shift, pas l'inefficacité opérationnelle, explique le delta de 120 bps.
|
| 57 |
+
</div>
|
| 58 |
+
</div>
|
| 59 |
+
</div>
|
| 60 |
+
<div style="margin-top:10px;background:#fafaf9;border:1px solid #e5e7eb;border-left:3px solid #10b981;border-radius:0 8px 8px 0;padding:14px 18px;">
|
| 61 |
+
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;">
|
| 62 |
+
<span style="font-size:0.6rem;font-weight:700;letter-spacing:0.08em;text-transform:uppercase;color:#6b7280;">Fait sourcé</span>
|
| 63 |
+
<span style="background:#ecfdf5;color:#059669;border:1px solid #a7f3d0;border-radius:4px;padding:1px 7px;font-size:0.65rem;font-weight:600;">HIGH</span>
|
| 64 |
+
<span style="background:#eff6ff;color:#2563eb;border:1px solid #93c5fd;border-radius:4px;padding:1px 8px;font-size:0.65rem;font-weight:500;">10-Q</span>
|
| 65 |
+
</div>
|
| 66 |
+
<div style="font-size:0.85rem;line-height:1.5;color:#0a0a0a;">Gross margin declined 120 bps YoY to 44.2%, driven by cloud infrastructure investment.</div>
|
| 67 |
+
</div>
|
| 68 |
+
</div>
|
| 69 |
+
|
| 70 |
+
<div class="pros-cons" style="margin-top:12px;">
|
| 71 |
+
<div class="pros"><h4>Pour</h4><ul><li>Impossible à rater</li><li>Très explicite</li></ul></div>
|
| 72 |
+
<div class="cons"><h4>Contre</h4><ul><li>Un peu lourd visuellement</li><li>Répétitif sur plusieurs cards</li></ul></div>
|
| 73 |
+
</div>
|
| 74 |
+
</div>
|
| 75 |
+
</div>
|
| 76 |
+
|
| 77 |
+
<!-- OPTION C — Recommended -->
|
| 78 |
+
<div class="option" data-choice="c" onclick="toggleSelect(this)">
|
| 79 |
+
<div class="letter">C</div>
|
| 80 |
+
<div class="content">
|
| 81 |
+
<h3>Bordure violette + tint ★ Recommandé</h3>
|
| 82 |
+
<p>Bordure gauche violette + fond très légèrement teinté. Étend le langage visuel existant (vert = bull, rouge = bear, violet = AI) sans nouveau pattern.</p>
|
| 83 |
+
|
| 84 |
+
<div style="margin-top:14px;font-family:Inter,sans-serif;">
|
| 85 |
+
<div style="background:#faf5ff;border:1px solid #e5e7eb;border-left:4px solid #8b5cf6;border-radius:0 12px 12px 0;padding:20px 24px;box-shadow:0 1px 3px rgba(0,0,0,0.06);">
|
| 86 |
+
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
|
| 87 |
+
<span style="font-size:0.6rem;font-weight:700;letter-spacing:0.1em;text-transform:uppercase;color:#6b7280;">What matters most</span>
|
| 88 |
+
<span style="background:#ede9fe;color:#7c3aed;border:1px solid #c4b5fd;border-radius:4px;padding:1px 7px;font-size:0.62rem;font-weight:600;">✦ AI Synthesis</span>
|
| 89 |
+
</div>
|
| 90 |
+
<div style="font-size:0.95rem;line-height:1.7;color:#0a0a0a;">
|
| 91 |
+
La contraction des marges brutes masque une <em>réallocation stratégique</em> vers les services cloud à plus forte valeur — le mix-shift, pas l'inefficacité opérationnelle, explique le delta de 120 bps.
|
| 92 |
+
</div>
|
| 93 |
+
</div>
|
| 94 |
+
<div style="margin-top:10px;background:#fafaf9;border:1px solid #e5e7eb;border-left:3px solid #10b981;border-radius:0 8px 8px 0;padding:14px 18px;">
|
| 95 |
+
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;">
|
| 96 |
+
<span style="font-size:0.6rem;font-weight:700;letter-spacing:0.08em;text-transform:uppercase;color:#6b7280;">Fait sourcé</span>
|
| 97 |
+
<span style="background:#ecfdf5;color:#059669;border:1px solid #a7f3d0;border-radius:4px;padding:1px 7px;font-size:0.65rem;font-weight:600;">HIGH</span>
|
| 98 |
+
<span style="background:#eff6ff;color:#2563eb;border:1px solid #93c5fd;border-radius:4px;padding:1px 8px;font-size:0.65rem;font-weight:500;">10-Q</span>
|
| 99 |
+
</div>
|
| 100 |
+
<div style="font-size:0.85rem;line-height:1.5;color:#0a0a0a;">Gross margin declined 120 bps YoY to 44.2%, driven by cloud infrastructure investment.</div>
|
| 101 |
+
</div>
|
| 102 |
+
</div>
|
| 103 |
+
|
| 104 |
+
<div class="pros-cons" style="margin-top:12px;">
|
| 105 |
+
<div class="pros"><h4>Pour</h4><ul><li>Cohérent avec le système de couleurs</li><li>Lisible sans être intrusif</li><li>Scalable sur tous les champs</li></ul></div>
|
| 106 |
+
<div class="cons"><h4>Contre</h4><ul><li>Le violet peut se confondre avec le bleu info sur certains écrans</li></ul></div>
|
| 107 |
+
</div>
|
| 108 |
+
</div>
|
| 109 |
+
</div>
|
| 110 |
+
|
| 111 |
+
</div>
|
| 112 |
+
|
| 113 |
+
<p class="subtitle" style="margin-top:16px;">Cliquez une option dans le browser, ou répondez directement dans le terminal.</p>
|
.superpowers/brainstorm/677-1778190449/content/waiting.html
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<div style="display:flex;align-items:center;justify-content:center;min-height:60vh">
|
| 2 |
+
<p class="subtitle">Rédaction du spec en cours dans le terminal...</p>
|
| 3 |
+
</div>
|
.superpowers/brainstorm/677-1778190449/persona_critique.md
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
### Marcus — l'analyste sell-side semi-conducteurs (II-ranked, bulge bracket)
|
| 2 |
+
|
| 3 |
+
Je couvre les semis pour une banque bulge bracket, je suis classé II, je vis dans mon modèle et je publie le lendemain matin du print. Quand j'ouvre cette app, je cherche une seule chose : le delta vs mon modèle — revenue et EPS vs consensus, la trajectoire de marge brute, le mix par segment, et surtout le pont de guidance.
|
| 4 |
+
|
| 5 |
+
Ce qui marche : le « number that matters » — la charge de 4,5 Md$ sur les stocks H20 — est correctement sourcé (10-Q, fiabilité HIGH). Je peux le citer en confiance dès demain matin, sans aller revérifier le filing. C'est exactement le genre d'ancrage dont j'ai besoin. Le problème, c'est qu'on me ressert ce même chiffre quatre fois sur le seul onglet Verdict : dans le number that matters, dans l'executive summary, dans un bear point, puis dans le « what changed ». Dis-le-moi une fois, puis donne-moi l'implication sur le modèle. Pire, le beat/miss EPS et la streak sont enterrés dans un expander replié, alors qu'un onglet entier « Earnings Tracker » recalcule la même chose depuis une autre source — yfinance contre AlphaVantage. Quel chiffre est le vrai print ? Je veux UN surprise number réconcilié, en haut. Et la guidance est éparpillée façon puzzle : ranges chiffrés dans Numbers, verdict LLM plus texte brut regex dans Guidance, plus une pastille dans le snapshot. Donnez-moi un seul pont : guide précédent → réalisé → nouveau guide, avec le verdict.
|
| 6 |
+
|
| 7 |
+
**Ce que je veux voir, et où :** faire démarrer le Verdict par la ligne de delta vs modèle et placer le surprise EPS réconcilié dans le hero — jamais dans un expander ; ajouter un onglet Numbers au niveau segment, un scorecard de guidance unique, et rendre explicite le walk de marge brute (75,8% → 60,5%, soit 13,3 pp), parce que c'est le swing factor de tout mon modèle.
|
| 8 |
+
|
| 9 |
+
### Sofia — la PM hedge fund long/short (book concentré)
|
| 10 |
+
|
| 11 |
+
Je gère un book L/S concentré, je lis quarante dossiers par matinée et je décide en trente secondes si je ré-underwrite une position. Ce que je viens chercher : la variant view, ce qui est priced in, le risk/reward, et au fond « qu'est-ce que je fais ».
|
| 12 |
+
|
| 13 |
+
J'adore le « non-obvious takeaway » — c'est la seule chose que je ne tire pas déjà de la tape, et c'est précisément pour ça que j'ouvre l'app. Mais l'onglet Verdict est épuisant. L'Earnings Quality apparaît deux fois : une sous-section « concerning » dans les Watch-Outs, puis la grille complète. Les Analytical Tensions, deux fois : les red flags bearish-only, puis les cartes complètes. Les Market Expectations, trois fois : les chips Market Pulse, un panneau trois colonnes, puis encore dans un expander. Si tu me montres deux fois la même carte, j'arrête de croire que tu sais ce qui compte. Ce qui est priced in — consensus, révision 30j, réaction D1/D5 — devrait être un seul panneau ; là je le vois reformaté trois fois.
|
| 14 |
+
|
| 15 |
+
**Ce que je veux voir, et où :** un Verdict qui tient littéralement sur un écran — number, non-obvious takeaway, asymétrie bull/bear, ce qui est priced in une seule fois, et LA chose à surveiller ; tout le reste à un clic. Sortez les analyses profondes vers un deep-dive « Quality & Tone » et laissez le Verdict être l'écran de décision, rien d'autre.
|
| 16 |
+
|
| 17 |
+
### David — l'analyste buy-side long-only (mutual fund, horizon pluriannuel)
|
| 18 |
+
|
| 19 |
+
Je suis fondamental, mon horizon est pluriannuel, et ce qui m'occupe c'est la durabilité de la thèse et la crédibilité du management. J'ouvre l'app pour l'earnings quality, le ton et le langage du management, l'allocation du capital et la durabilité des segments.
|
| 20 |
+
|
| 21 |
+
Ce que je valorise, ce sont les source tags et les niveaux de fiabilité : je distingue le fait de l'inférence, et j'aime que « what_matters_most » soit le seul champ d'interprétation assumé. Mais cette distinction fait-vs-IA est sabotée par la couleur. Le violet veut dire « synthèse IA » dans l'executive summary, mais il veut aussi dire « risque réglementaire » dans les chips de risque, ET « language shift » de guidance, ET une série dans un graphique. Si le violet veut dire « l'IA parle », il ne peut pas aussi être une catégorie de risque. Autre point : le Tone & sentiment est enterré dans les « QoQ Watch-Outs ». Or le glissement du management de « confiant » à « working to hold » sur les marges, c'est précisément mon signal — je veux le ton et le language-shift comme une vue deep-dive de plein droit.
|
| 22 |
+
|
| 23 |
+
**Ce que je veux voir, et où :** promouvoir tone/sentiment et earnings quality dans un seul onglet deep-dive « Quality & Tone » cohérent ; adopter un style de zone-IA unique et garder le violet sacré pour l'IA uniquement ; et donner à l'allocation du capital — buybacks, FCF — un emplacement clair et identifiable.
|
| 24 |
+
|
| 25 |
+
### Aisha — la PM quant / systématique
|
| 26 |
+
|
| 27 |
+
Je suis data-driven et orientée signal : je veux des chiffres propres, réconciliés et exportables, point. Ce que je viens chercher : le score de sentiment, les révisions d'estimés, la réaction de prix et la série de surprises EPS.
|
| 28 |
+
|
| 29 |
+
Mon problème de fond : deux providers, deux chiffres. La réaction D1/D5 et la surprise EPS sont calculées une fois via le chemin LLM/AlphaVantage dans le Verdict, puis de nouveau via yfinance dans l'Earnings Tracker, sans aucune réconciliation. Je ne peux pas faire confiance à un chiffre qui se contredit d'un onglet à l'autre — pour moi, c'est disqualifiant. Cela dit, j'aime l'export CSV de l'Earnings Tracker ; je veux juste l'étendre. Et le score de sentiment (−0,5) est bon, mais isolé dans les Watch-Outs : j'en ferais une métrique de tête, pas une note de bas de page.
|
| 30 |
+
|
| 31 |
+
**Ce que je veux voir, et où :** une bande « Market read » unique — une seule source de vérité — regroupant le score de sentiment (−0,5), la révision 30j (+6,5%), le D1/D5 (−1,8 / −4,1), la surprise EPS et la streak, le tout réconcilié et exportable ; cette bande référencée partout où c'est nécessaire plutôt que re-rendue, avec la provenance affichée sur chaque chiffre.
|
| 32 |
+
|
| 33 |
+
### Henrik — le CIO / PM généraliste
|
| 34 |
+
|
| 35 |
+
Je supervise quatre-vingts valeurs, je suis pressé par le temps : je lis le haut du verdict, puis je fais confiance ou je passe. Ce que je cherche tient en trois questions : le takeaway en une ligne, puis-je faire confiance, et le solde est-il bull ou bear.
|
| 36 |
+
|
| 37 |
+
Le « number that matters » plus l'executive summary, c'est exactement mon point d'entrée — quand ça s'arrête là, c'est parfait. Mais le Verdict défile sans fin et se répète : à la troisième répétition de la charge Chine, je suppose que l'outil « remplit ». La longueur me signale que tu ne sais pas ce qui est important. Le bloc disclaimers/sources, lui, se répète sur chaque onglet : une fois, ça inspire confiance ; à chaque onglet, c'est du bruit. Et l'iconographie est brouillonne — le 📊 veut dire cinq choses différentes, le 🎯 est à la fois l'onglet Verdict ET une sous-section. Si les icônes ne veulent pas dire une seule chose, j'arrête de les lire.
|
| 38 |
+
|
| 39 |
+
**Ce que je veux voir, et où :** compresser le Verdict à un seul écran ; un disclaimer global unique en pied de page plutôt que répété par onglet ; et une iconographie disciplinée — une icône par concept, pas une icône pour cinq.
|
.superpowers/brainstorm/677-1778190449/state/server-stopped
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
{"reason":"idle timeout","timestamp":1778192730909}
|
.superpowers/brainstorm/677-1778190449/state/server.pid
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
677
|
agent/graph.py
CHANGED
|
@@ -14,7 +14,7 @@ from agent.tools import (
|
|
| 14 |
search_news,
|
| 15 |
get_analyst_expectations,
|
| 16 |
)
|
| 17 |
-
from agent.prompts import SYSTEM_PROMPT, SYNTHESIS_STRUCTURED_PROMPT
|
| 18 |
from agent.schemas import BriefOutput
|
| 19 |
from agent.post_synthesis import apply_reliability, attach_edge_signals
|
| 20 |
|
|
@@ -59,6 +59,7 @@ class AgentState(TypedDict):
|
|
| 59 |
tool_round_count: int
|
| 60 |
nudge_fired: bool
|
| 61 |
edge_signals: Optional[list[dict]] # precomputed deterministic signals
|
|
|
|
| 62 |
brief: Optional[dict]
|
| 63 |
brief_markdown: Optional[str]
|
| 64 |
synthesis_error: Optional[str]
|
|
@@ -144,6 +145,10 @@ def _format_signals_message(signals: list[dict]) -> str:
|
|
| 144 |
"guidance_language_shift": "GUIDANCE LANGUAGE SHIFT",
|
| 145 |
"term_frequency": "TERM FREQUENCY SHIFT",
|
| 146 |
"kpi_dropped": "DROPPED KPI",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
}
|
| 148 |
for i, s in enumerate(signals, 1):
|
| 149 |
kind = s.get("kind", "")
|
|
@@ -163,17 +168,52 @@ def _format_signals_message(signals: list[dict]) -> str:
|
|
| 163 |
return "\n".join(lines)
|
| 164 |
|
| 165 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 166 |
def signals_node(state: AgentState) -> dict:
|
| 167 |
"""Run deterministic analysis modules and inject signals into conversation."""
|
| 168 |
ticker = state["ticker"]
|
|
|
|
| 169 |
try:
|
| 170 |
from analysis.textdiff import compute as compute_text_deltas
|
| 171 |
-
raw_signals
|
| 172 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 173 |
except Exception as exc:
|
| 174 |
import sys
|
| 175 |
-
print(f"[signals_node]
|
| 176 |
-
|
| 177 |
|
| 178 |
if signals:
|
| 179 |
msg = HumanMessage(content=_format_signals_message(signals))
|
|
@@ -206,11 +246,23 @@ def create_graph():
|
|
| 206 |
def synthesis_node(state: AgentState) -> dict:
|
| 207 |
try:
|
| 208 |
llm_plain = ChatAnthropic(model=MODEL, temperature=0, max_retries=5)
|
| 209 |
-
|
|
|
|
|
|
|
| 210 |
"type": "text",
|
| 211 |
"text": SYNTHESIS_STRUCTURED_PROMPT,
|
| 212 |
"cache_control": {"type": "ephemeral"},
|
| 213 |
-
}]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 214 |
synthesis_messages = [system_block] + state["messages"]
|
| 215 |
# If cap was hit mid-round the last AIMessage may still carry tool_calls.
|
| 216 |
# Anthropic rejects conversations where tool_use blocks have no matching
|
|
@@ -269,13 +321,14 @@ def create_graph():
|
|
| 269 |
graph = create_graph()
|
| 270 |
|
| 271 |
|
| 272 |
-
def run_brief(ticker: str) -> Optional[dict]:
|
| 273 |
final = graph.invoke({
|
| 274 |
"ticker": ticker.upper(),
|
| 275 |
"messages": [HumanMessage(content=f"Generate a research brief for {ticker.upper()}.")],
|
| 276 |
"tool_round_count": 0,
|
| 277 |
"nudge_fired": False,
|
| 278 |
"edge_signals": None,
|
|
|
|
| 279 |
"brief": None,
|
| 280 |
"brief_markdown": None,
|
| 281 |
"synthesis_error": None,
|
|
|
|
| 14 |
search_news,
|
| 15 |
get_analyst_expectations,
|
| 16 |
)
|
| 17 |
+
from agent.prompts import SYSTEM_PROMPT, SYNTHESIS_STRUCTURED_PROMPT, language_directive
|
| 18 |
from agent.schemas import BriefOutput
|
| 19 |
from agent.post_synthesis import apply_reliability, attach_edge_signals
|
| 20 |
|
|
|
|
| 59 |
tool_round_count: int
|
| 60 |
nudge_fired: bool
|
| 61 |
edge_signals: Optional[list[dict]] # precomputed deterministic signals
|
| 62 |
+
language: Optional[str] # e.g. "French" — prose fields in brief will use this language
|
| 63 |
brief: Optional[dict]
|
| 64 |
brief_markdown: Optional[str]
|
| 65 |
synthesis_error: Optional[str]
|
|
|
|
| 145 |
"guidance_language_shift": "GUIDANCE LANGUAGE SHIFT",
|
| 146 |
"term_frequency": "TERM FREQUENCY SHIFT",
|
| 147 |
"kpi_dropped": "DROPPED KPI",
|
| 148 |
+
"tone_trend": "MANAGEMENT TONE TREND",
|
| 149 |
+
"topic_arc": "TRANSCRIPT TOPIC ARC",
|
| 150 |
+
"recurring_evasion": "RECURRING Q&A EVASION",
|
| 151 |
+
"topic_fade": "PREPARED-REMARKS TOPIC FADE",
|
| 152 |
}
|
| 153 |
for i, s in enumerate(signals, 1):
|
| 154 |
kind = s.get("kind", "")
|
|
|
|
| 168 |
return "\n".join(lines)
|
| 169 |
|
| 170 |
|
| 171 |
+
MAX_EDGE_SIGNALS = 12
|
| 172 |
+
MAX_FILING_SIGNALS = 8
|
| 173 |
+
MAX_TRANSCRIPT_SIGNALS = 6
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def _cap_signals(signals: list[dict], max_total: int = MAX_EDGE_SIGNALS) -> list[dict]:
|
| 177 |
+
"""Bound the edge-signal block: per-source caps, then a global cap.
|
| 178 |
+
|
| 179 |
+
Sorts HIGH→MEDIUM→LOW, keeps at most MAX_FILING_SIGNALS filing-sourced and
|
| 180 |
+
MAX_TRANSCRIPT_SIGNALS transcript-sourced signals, then truncates to
|
| 181 |
+
max_total so neither module can flood the prompt.
|
| 182 |
+
"""
|
| 183 |
+
order = {"HIGH": 0, "MEDIUM": 1, "LOW": 2}
|
| 184 |
+
signals = sorted(signals, key=lambda s: order.get(s.get("significance", "MEDIUM"), 2))
|
| 185 |
+
capped: list[dict] = []
|
| 186 |
+
filing_n = transcript_n = 0
|
| 187 |
+
for s in signals:
|
| 188 |
+
if s.get("source") == "transcript":
|
| 189 |
+
if transcript_n >= MAX_TRANSCRIPT_SIGNALS:
|
| 190 |
+
continue
|
| 191 |
+
transcript_n += 1
|
| 192 |
+
else:
|
| 193 |
+
if filing_n >= MAX_FILING_SIGNALS:
|
| 194 |
+
continue
|
| 195 |
+
filing_n += 1
|
| 196 |
+
capped.append(s)
|
| 197 |
+
return capped[:max_total]
|
| 198 |
+
|
| 199 |
+
|
| 200 |
def signals_node(state: AgentState) -> dict:
|
| 201 |
"""Run deterministic analysis modules and inject signals into conversation."""
|
| 202 |
ticker = state["ticker"]
|
| 203 |
+
raw_signals = []
|
| 204 |
try:
|
| 205 |
from analysis.textdiff import compute as compute_text_deltas
|
| 206 |
+
raw_signals.extend(compute_text_deltas(ticker))
|
| 207 |
+
except Exception as exc:
|
| 208 |
+
import sys
|
| 209 |
+
print(f"[signals_node] textdiff error: {exc}", file=sys.stderr)
|
| 210 |
+
try:
|
| 211 |
+
from analysis.tone_drift import compute as compute_tone_deltas
|
| 212 |
+
raw_signals.extend(compute_tone_deltas(ticker))
|
| 213 |
except Exception as exc:
|
| 214 |
import sys
|
| 215 |
+
print(f"[signals_node] tone_drift error: {exc}", file=sys.stderr)
|
| 216 |
+
signals = _cap_signals([s.model_dump() for s in raw_signals])
|
| 217 |
|
| 218 |
if signals:
|
| 219 |
msg = HumanMessage(content=_format_signals_message(signals))
|
|
|
|
| 246 |
def synthesis_node(state: AgentState) -> dict:
|
| 247 |
try:
|
| 248 |
llm_plain = ChatAnthropic(model=MODEL, temperature=0, max_retries=5)
|
| 249 |
+
# Main prompt — cached (ephemeral). Keep this block stable so the cache
|
| 250 |
+
# hit rate is preserved regardless of the chosen language.
|
| 251 |
+
system_content: list[dict] = [{
|
| 252 |
"type": "text",
|
| 253 |
"text": SYNTHESIS_STRUCTURED_PROMPT,
|
| 254 |
"cache_control": {"type": "ephemeral"},
|
| 255 |
+
}]
|
| 256 |
+
# Language directive — appended as a second (uncached) block only when
|
| 257 |
+
# the target language is not English, so English runs are identical to
|
| 258 |
+
# the current behaviour and the cached block is never evicted.
|
| 259 |
+
lang = state.get("language") or "English"
|
| 260 |
+
if lang != "English":
|
| 261 |
+
system_content.append({
|
| 262 |
+
"type": "text",
|
| 263 |
+
"text": language_directive(lang),
|
| 264 |
+
})
|
| 265 |
+
system_block = SystemMessage(content=system_content)
|
| 266 |
synthesis_messages = [system_block] + state["messages"]
|
| 267 |
# If cap was hit mid-round the last AIMessage may still carry tool_calls.
|
| 268 |
# Anthropic rejects conversations where tool_use blocks have no matching
|
|
|
|
| 321 |
graph = create_graph()
|
| 322 |
|
| 323 |
|
| 324 |
+
def run_brief(ticker: str, language: str = "English") -> Optional[dict]:
|
| 325 |
final = graph.invoke({
|
| 326 |
"ticker": ticker.upper(),
|
| 327 |
"messages": [HumanMessage(content=f"Generate a research brief for {ticker.upper()}.")],
|
| 328 |
"tool_round_count": 0,
|
| 329 |
"nudge_fired": False,
|
| 330 |
"edge_signals": None,
|
| 331 |
+
"language": language,
|
| 332 |
"brief": None,
|
| 333 |
"brief_markdown": None,
|
| 334 |
"synthesis_error": None,
|
agent/prompts.py
CHANGED
|
@@ -8,6 +8,8 @@ For each HIGH-significance signal, you MUST investigate it with at least one tar
|
|
| 8 |
- REWORDED RISK or NEW RISK → call `search_filing` with a query that targets the specific risk language.
|
| 9 |
- TERM FREQUENCY SHIFT (large swing) → call `search_filing` or `search_transcript` to find the context for the term's use.
|
| 10 |
- GUIDANCE LANGUAGE SHIFT → call `search_filing` with a query targeting the guidance language in both the current and prior period.
|
|
|
|
|
|
|
| 11 |
|
| 12 |
Treat the before→after fragments as hypotheses to verify, not as pre-written conclusions. If a signal turns out to be noise (e.g., a legal boilerplate change), note that in your reasoning.
|
| 13 |
|
|
@@ -140,6 +142,10 @@ For each signal in that block:
|
|
| 140 |
2. **[SIG-n] TERM FREQUENCY SHIFT** → The `computed_metric` gives the exact count change (e.g., "2→8 occurrences (+300%)"). Cite this number verbatim in the relevant `what_changed` item or `analytical_tensions`. The term label and context sentence are in `term` and `after_text`.
|
| 141 |
3. **[SIG-n] GUIDANCE LANGUAGE SHIFT** → The `before_text`/`after_text` sentences are verbatim. Use them in `mda_summary.language_shift` or an `analytical_tension`. Cite the `computed_metric` (hedge-word count delta) as evidence of the shift direction.
|
| 142 |
4. **[SIG-n] DROPPED KPI** → A metric label discussed in the prior filing is absent now. Note this in `bear_points` or `what_to_watch`.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
|
| 144 |
**Hard rules for edge signals:**
|
| 145 |
- Do NOT invent signals not present in the PRECOMPUTED EDGE SIGNALS block.
|
|
|
|
| 8 |
- REWORDED RISK or NEW RISK → call `search_filing` with a query that targets the specific risk language.
|
| 9 |
- TERM FREQUENCY SHIFT (large swing) → call `search_filing` or `search_transcript` to find the context for the term's use.
|
| 10 |
- GUIDANCE LANGUAGE SHIFT → call `search_filing` with a query targeting the guidance language in both the current and prior period.
|
| 11 |
+
- RECURRING Q&A EVASION → call `search_transcript` targeting the question topic in the latest period to verify the non-answer in its full context.
|
| 12 |
+
- MANAGEMENT TONE TREND / TRANSCRIPT TOPIC ARC / PREPARED-REMARKS TOPIC FADE → call `search_transcript` for the term in the newest period of the window (and the oldest, via `period=`, if the contrast matters).
|
| 13 |
|
| 14 |
Treat the before→after fragments as hypotheses to verify, not as pre-written conclusions. If a signal turns out to be noise (e.g., a legal boilerplate change), note that in your reasoning.
|
| 15 |
|
|
|
|
| 142 |
2. **[SIG-n] TERM FREQUENCY SHIFT** → The `computed_metric` gives the exact count change (e.g., "2→8 occurrences (+300%)"). Cite this number verbatim in the relevant `what_changed` item or `analytical_tensions`. The term label and context sentence are in `term` and `after_text`.
|
| 143 |
3. **[SIG-n] GUIDANCE LANGUAGE SHIFT** → The `before_text`/`after_text` sentences are verbatim. Use them in `mda_summary.language_shift` or an `analytical_tension`. Cite the `computed_metric` (hedge-word count delta) as evidence of the shift direction.
|
| 144 |
4. **[SIG-n] DROPPED KPI** → A metric label discussed in the prior filing is absent now. Note this in `bear_points` or `what_to_watch`.
|
| 145 |
+
5. **[SIG-n] RECURRING Q&A EVASION** → A question topic analysts raised on 2+ consecutive calls where management's answers stayed non-quantitative. This MUST become a `between_the_lines` item with `signal_type="qa_evasion"`: cite the SIG-n label, copy the `computed_metric` verbatim into the `observation`, and use the `after_text` answer fragment as the `evidence.evidence_snippet`.
|
| 146 |
+
6. **[SIG-n] MANAGEMENT TONE TREND** → Hedge/certainty word rates trending across 3+ calls. Feed `mda_summary.language_shift` and/or a `between_the_lines` item with `signal_type="language_drift"`; weigh it in `sentiment.earnings_call`. The `computed_metric` rate trajectory is authoritative — cite it verbatim.
|
| 147 |
+
7. **[SIG-n] TRANSCRIPT TOPIC ARC** → A topic's mention count rising or falling monotonically across 3+ calls. Cite the exact count trajectory in `what_changed` or an `analytical_tension`.
|
| 148 |
+
8. **[SIG-n] PREPARED-REMARKS TOPIC FADE** → A topic management discussed prominently in prior prepared remarks is absent from the latest call. Surface it as a `between_the_lines` item with `signal_type="emphasis_shift"` or a `what_to_watch` item.
|
| 149 |
|
| 150 |
**Hard rules for edge signals:**
|
| 151 |
- Do NOT invent signals not present in the PRECOMPUTED EDGE SIGNALS block.
|
analysis/signals.py
CHANGED
|
@@ -21,6 +21,11 @@ class QuarterDelta(BaseModel):
|
|
| 21 |
"guidance_language_shift",
|
| 22 |
"term_frequency",
|
| 23 |
"kpi_dropped",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
] = Field(description="Type of delta detected.")
|
| 25 |
|
| 26 |
period_from: str = Field(description="Prior filing period, e.g. 'Q42025'.")
|
|
|
|
| 21 |
"guidance_language_shift",
|
| 22 |
"term_frequency",
|
| 23 |
"kpi_dropped",
|
| 24 |
+
# transcript drift kinds (analysis/tone_drift.py)
|
| 25 |
+
"tone_trend",
|
| 26 |
+
"topic_arc",
|
| 27 |
+
"recurring_evasion",
|
| 28 |
+
"topic_fade",
|
| 29 |
] = Field(description="Type of delta detected.")
|
| 30 |
|
| 31 |
period_from: str = Field(description="Prior filing period, e.g. 'Q42025'.")
|
analysis/tone_drift.py
ADDED
|
@@ -0,0 +1,417 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""analysis/tone_drift.py — multi-quarter transcript drift signals for the Analyst Edge layer.
|
| 2 |
+
|
| 3 |
+
Pure Python + sentence-transformers, zero LLM calls. Extends the textdiff
|
| 4 |
+
approach (filings) to earnings-call transcripts across the last N quarters:
|
| 5 |
+
|
| 6 |
+
1. tone_trend — hedge/certainty word rate trajectory in management speech
|
| 7 |
+
2. topic_arc — lexicon term frequency rising/falling across 3+ calls
|
| 8 |
+
3. recurring_evasion — analyst question asked on 2+ calls, answered evasively
|
| 9 |
+
4. topic_fade — topic prominent in prior prepared remarks, absent now
|
| 10 |
+
|
| 11 |
+
Usage:
|
| 12 |
+
from analysis.tone_drift import compute
|
| 13 |
+
signals = compute("NVDA")
|
| 14 |
+
"""
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
import re
|
| 18 |
+
from collections import Counter
|
| 19 |
+
|
| 20 |
+
from analysis.signals import QuarterDelta
|
| 21 |
+
from analysis.textdiff import (
|
| 22 |
+
_LEXICON,
|
| 23 |
+
_KPI_PATTERNS,
|
| 24 |
+
_detect_trend,
|
| 25 |
+
_embed,
|
| 26 |
+
_find_context_sentence,
|
| 27 |
+
_split_sentences,
|
| 28 |
+
_truncate,
|
| 29 |
+
)
|
| 30 |
+
from analysis.transcript_parse import ParsedCall, parse_call
|
| 31 |
+
from storage.sections_db import _transcript_sort_key, get_recent_transcripts
|
| 32 |
+
|
| 33 |
+
# ---------------------------------------------------------------------------
|
| 34 |
+
# Config
|
| 35 |
+
# ---------------------------------------------------------------------------
|
| 36 |
+
|
| 37 |
+
# Spoken hedging — looser register than the written _HEDGE_WORDS in textdiff.
|
| 38 |
+
_SPOKEN_HEDGES = [
|
| 39 |
+
"i think", "we believe", "sort of", "kind of", "we'll see",
|
| 40 |
+
"hard to say", "too early", "it depends", "uncertain", "cautious",
|
| 41 |
+
"headwind", "challenge", "moderate", "soften", "roughly",
|
| 42 |
+
"somewhat", "a bit of", "remains to be seen",
|
| 43 |
+
]
|
| 44 |
+
|
| 45 |
+
_CERTAINTY_WORDS = [
|
| 46 |
+
"we will", "confident", "strong", "record", "robust",
|
| 47 |
+
"momentum", "accelerat", "very pleased", "outstanding", "exceed",
|
| 48 |
+
"ahead of plan", "better than expected",
|
| 49 |
+
]
|
| 50 |
+
|
| 51 |
+
# Phrases that mark a non-answer to an analyst question.
|
| 52 |
+
_DEFLECTIONS = [
|
| 53 |
+
"we don't guide", "we do not guide", "not going to guide",
|
| 54 |
+
"too early to say", "too early to tell", "as i said", "as we said",
|
| 55 |
+
"we don't disclose", "we do not disclose", "won't break out",
|
| 56 |
+
"don't break out", "not going to get into", "stay tuned",
|
| 57 |
+
"more to come", "we'll see how",
|
| 58 |
+
]
|
| 59 |
+
|
| 60 |
+
# Transcript-specific topics beyond the shared filing lexicon.
|
| 61 |
+
_TRANSCRIPT_EXTRA_TERMS: list[tuple[str, str]] = [
|
| 62 |
+
(r"\bdemand\b", "demand"),
|
| 63 |
+
(r"\binventory\b", "inventory"),
|
| 64 |
+
(r"\bpricing\b", "pricing"),
|
| 65 |
+
(r"\bvisibility\b", "visibility"),
|
| 66 |
+
(r"\bsupply\b", "supply"),
|
| 67 |
+
(r"\bchina\b", "China"),
|
| 68 |
+
(r"\bbacklog\b", "backlog"),
|
| 69 |
+
]
|
| 70 |
+
|
| 71 |
+
_EVASION_SIM_THRESHOLD = 0.60 # cosine: two questions considered the same topic
|
| 72 |
+
_MIN_QUESTION_WORDS = 15
|
| 73 |
+
_MAX_QUESTIONS_PER_CALL = 30
|
| 74 |
+
_QUESTION_EMBED_WORDS = 120
|
| 75 |
+
_DIGIT_RATIO_THRESHOLD = 0.005 # answers with fewer digits than this are "non-quantitative"
|
| 76 |
+
_SHORT_ANSWER_WORDS = 80
|
| 77 |
+
|
| 78 |
+
_MAX_TOTAL = 6
|
| 79 |
+
|
| 80 |
+
_STOPWORDS = frozenset(
|
| 81 |
+
"a an and are as at be but by can could for from has have how i if in is it "
|
| 82 |
+
"just like me my of on or our so that the then there this to was we what when "
|
| 83 |
+
"which will with would you your about more very really them they those these "
|
| 84 |
+
"going get got want wanted maybe think know kind sort little also any do does "
|
| 85 |
+
"your guys thanks thank question congrats curious wondering color give us "
|
| 86 |
+
"side year years quarter quarters talk talked talking look looking lot bit "
|
| 87 |
+
"say said see seeing help understand grew growing mentioned should".split()
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
# ---------------------------------------------------------------------------
|
| 91 |
+
# Helpers
|
| 92 |
+
# ---------------------------------------------------------------------------
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def _word_count(text: str) -> int:
|
| 96 |
+
return len(text.split())
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def _phrase_rate(text: str, phrases: list[str]) -> int:
|
| 100 |
+
"""Occurrences of any phrase per 10k words, rounded to int."""
|
| 101 |
+
words = _word_count(text)
|
| 102 |
+
if words == 0:
|
| 103 |
+
return 0
|
| 104 |
+
lower = text.lower()
|
| 105 |
+
hits = sum(lower.count(p) for p in phrases)
|
| 106 |
+
return round(hits / words * 10_000)
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def _rep_sentence(text: str, phrases: list[str]) -> str:
|
| 110 |
+
"""Shortest quotable sentence containing one of the phrases."""
|
| 111 |
+
matches = [
|
| 112 |
+
s for s in _split_sentences(text)
|
| 113 |
+
if any(p in s.lower() for p in phrases)
|
| 114 |
+
]
|
| 115 |
+
if not matches:
|
| 116 |
+
return ""
|
| 117 |
+
return _truncate(min(matches, key=lambda s: len(s.split())), 60)
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def _question_topic(question: str) -> str:
|
| 121 |
+
"""2-3 most frequent non-stopword tokens as a compact topic label."""
|
| 122 |
+
tokens = re.findall(r"[a-z]{3,}", question.lower())
|
| 123 |
+
counts = Counter(t for t in tokens if t not in _STOPWORDS)
|
| 124 |
+
return " / ".join(t for t, _ in counts.most_common(3))
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def _is_evasive(answer: str) -> tuple[bool, str]:
|
| 128 |
+
"""(evasive?, deflection phrase found or '')."""
|
| 129 |
+
lower = answer.lower()
|
| 130 |
+
for phrase in _DEFLECTIONS:
|
| 131 |
+
if phrase in lower:
|
| 132 |
+
return True, phrase
|
| 133 |
+
words = answer.split()
|
| 134 |
+
if words and len(words) < _SHORT_ANSWER_WORDS:
|
| 135 |
+
digit_tokens = sum(1 for w in words if any(c.isdigit() for c in w))
|
| 136 |
+
if digit_tokens / len(words) < _DIGIT_RATIO_THRESHOLD:
|
| 137 |
+
return True, ""
|
| 138 |
+
return False, ""
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
# ---------------------------------------------------------------------------
|
| 142 |
+
# 1. Management tone trend
|
| 143 |
+
# ---------------------------------------------------------------------------
|
| 144 |
+
|
| 145 |
+
def compute_tone_trend(calls: list[ParsedCall]) -> list[QuarterDelta]:
|
| 146 |
+
"""Hedge/certainty word rate trajectory across 3+ calls (management speech only)."""
|
| 147 |
+
usable = [c for c in calls if _word_count(c.management_text) >= 500]
|
| 148 |
+
if len(usable) < 3:
|
| 149 |
+
return []
|
| 150 |
+
|
| 151 |
+
deltas: list[QuarterDelta] = []
|
| 152 |
+
series_specs = [
|
| 153 |
+
(_SPOKEN_HEDGES, "hedging language", "hedge-word",
|
| 154 |
+
{"rising": "more cautious", "falling": "more confident"}),
|
| 155 |
+
(_CERTAINTY_WORDS, "confidence language", "certainty-word",
|
| 156 |
+
{"rising": "more confident", "falling": "more cautious"}),
|
| 157 |
+
]
|
| 158 |
+
|
| 159 |
+
for phrases, term, rate_label, direction_map in series_specs:
|
| 160 |
+
rates = [_phrase_rate(c.management_text, phrases) for c in usable]
|
| 161 |
+
trend = _detect_trend(rates)
|
| 162 |
+
if trend is None:
|
| 163 |
+
continue
|
| 164 |
+
|
| 165 |
+
direction, run_str, _ = trend.split()
|
| 166 |
+
run_quarters = int(run_str)
|
| 167 |
+
reading = direction_map[direction]
|
| 168 |
+
first, last = rates[0], rates[-1]
|
| 169 |
+
net_change = abs(last - first) / (first or 1)
|
| 170 |
+
sig = "HIGH" if (run_quarters >= 4 or net_change >= 0.5) else "MEDIUM"
|
| 171 |
+
|
| 172 |
+
trajectory = "→".join(str(r) for r in rates)
|
| 173 |
+
metric = (
|
| 174 |
+
f"{rate_label} rate {trajectory} per 10k words over "
|
| 175 |
+
f"{usable[0].period}→{usable[-1].period} ({trend}) → {reading}"
|
| 176 |
+
)
|
| 177 |
+
deltas.append(QuarterDelta(
|
| 178 |
+
kind="tone_trend",
|
| 179 |
+
period_from=usable[0].period,
|
| 180 |
+
period_to=usable[-1].period,
|
| 181 |
+
before_text=_rep_sentence(usable[0].management_text, phrases),
|
| 182 |
+
after_text=_rep_sentence(usable[-1].management_text, phrases),
|
| 183 |
+
computed_metric=metric,
|
| 184 |
+
source="transcript",
|
| 185 |
+
significance=sig,
|
| 186 |
+
term=term,
|
| 187 |
+
))
|
| 188 |
+
|
| 189 |
+
return deltas
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
# ---------------------------------------------------------------------------
|
| 193 |
+
# 2. Topic emphasis arcs
|
| 194 |
+
# ---------------------------------------------------------------------------
|
| 195 |
+
|
| 196 |
+
def compute_topic_arcs(
|
| 197 |
+
calls: list[ParsedCall],
|
| 198 |
+
raw_texts: list[str],
|
| 199 |
+
) -> list[QuarterDelta]:
|
| 200 |
+
"""Lexicon terms whose mention count rises/falls monotonically across 3+ calls."""
|
| 201 |
+
if len(calls) < 3:
|
| 202 |
+
return []
|
| 203 |
+
|
| 204 |
+
deltas: list[QuarterDelta] = []
|
| 205 |
+
for pattern, label in _LEXICON + _TRANSCRIPT_EXTRA_TERMS:
|
| 206 |
+
counts = [len(re.findall(pattern, t, re.IGNORECASE)) for t in raw_texts]
|
| 207 |
+
if max(counts) < 3:
|
| 208 |
+
continue # noise floor
|
| 209 |
+
trend = _detect_trend(counts)
|
| 210 |
+
if trend is None:
|
| 211 |
+
continue
|
| 212 |
+
|
| 213 |
+
run_quarters = int(trend.split()[1])
|
| 214 |
+
metric = (
|
| 215 |
+
f"{counts[0]}→{counts[-1]} mentions over "
|
| 216 |
+
f"{calls[0].period}→{calls[-1].period} ({trend})"
|
| 217 |
+
)
|
| 218 |
+
deltas.append(QuarterDelta(
|
| 219 |
+
kind="topic_arc",
|
| 220 |
+
period_from=calls[0].period,
|
| 221 |
+
period_to=calls[-1].period,
|
| 222 |
+
before_text=_find_context_sentence(raw_texts[0], pattern) if counts[0] else "",
|
| 223 |
+
after_text=_find_context_sentence(raw_texts[-1], pattern) if counts[-1] else "",
|
| 224 |
+
computed_metric=metric,
|
| 225 |
+
source="transcript",
|
| 226 |
+
significance="HIGH" if run_quarters >= 4 else "MEDIUM",
|
| 227 |
+
term=label,
|
| 228 |
+
))
|
| 229 |
+
|
| 230 |
+
deltas.sort(key=lambda d: {"HIGH": 0, "MEDIUM": 1}.get(d.significance, 2))
|
| 231 |
+
return deltas[:3]
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
# ---------------------------------------------------------------------------
|
| 235 |
+
# 3. Recurring Q&A evasions
|
| 236 |
+
# ---------------------------------------------------------------------------
|
| 237 |
+
|
| 238 |
+
def compute_recurring_evasions(calls: list[ParsedCall]) -> list[QuarterDelta]:
|
| 239 |
+
"""Analyst question topics raised on 2+ calls where answers stay non-quantitative."""
|
| 240 |
+
qa_calls = [c for c in calls if c.qa]
|
| 241 |
+
if len(qa_calls) < 2:
|
| 242 |
+
return []
|
| 243 |
+
|
| 244 |
+
latest = qa_calls[-1]
|
| 245 |
+
priors = qa_calls[:-1]
|
| 246 |
+
|
| 247 |
+
def _select(call: ParsedCall) -> list:
|
| 248 |
+
picked = [x for x in call.qa if _word_count(x.question) >= _MIN_QUESTION_WORDS]
|
| 249 |
+
return picked[:_MAX_QUESTIONS_PER_CALL]
|
| 250 |
+
|
| 251 |
+
latest_qs = _select(latest)
|
| 252 |
+
prior_qs: list[tuple[str, object]] = [] # (period, QAExchange)
|
| 253 |
+
for call in priors:
|
| 254 |
+
prior_qs.extend((call.period, x) for x in _select(call))
|
| 255 |
+
if not latest_qs or not prior_qs:
|
| 256 |
+
return []
|
| 257 |
+
|
| 258 |
+
texts = (
|
| 259 |
+
[_truncate(x.question, _QUESTION_EMBED_WORDS) for x in latest_qs]
|
| 260 |
+
+ [_truncate(x.question, _QUESTION_EMBED_WORDS) for _, x in prior_qs]
|
| 261 |
+
)
|
| 262 |
+
vecs = _embed(texts)
|
| 263 |
+
latest_vecs = vecs[: len(latest_qs)]
|
| 264 |
+
prior_vecs = vecs[len(latest_qs):]
|
| 265 |
+
sim = latest_vecs @ prior_vecs.T # (n_latest, n_prior)
|
| 266 |
+
|
| 267 |
+
deltas: list[QuarterDelta] = []
|
| 268 |
+
used_prior: set[int] = set()
|
| 269 |
+
|
| 270 |
+
for li, lx in enumerate(latest_qs):
|
| 271 |
+
matched = [
|
| 272 |
+
pi for pi in range(len(prior_qs))
|
| 273 |
+
if pi not in used_prior and sim[li, pi] >= _EVASION_SIM_THRESHOLD
|
| 274 |
+
]
|
| 275 |
+
if not matched:
|
| 276 |
+
continue
|
| 277 |
+
|
| 278 |
+
# prior_qs is in chronological call order, so cluster[0] is the earliest.
|
| 279 |
+
cluster = [(prior_qs[pi][0], prior_qs[pi][1]) for pi in matched]
|
| 280 |
+
cluster.append((latest.period, lx))
|
| 281 |
+
periods = sorted({p for p, _ in cluster}, key=_transcript_sort_key)
|
| 282 |
+
if len(periods) < 2:
|
| 283 |
+
continue
|
| 284 |
+
|
| 285 |
+
evasive_flags = [_is_evasive(x.answer) for _, x in cluster]
|
| 286 |
+
n_evasive = sum(1 for flag, _ in evasive_flags if flag)
|
| 287 |
+
# Require a majority of evasive answers, not just two outliers in a
|
| 288 |
+
# large cluster of recurring questions.
|
| 289 |
+
if n_evasive < 2 or n_evasive * 2 < len(cluster):
|
| 290 |
+
continue
|
| 291 |
+
used_prior.update(matched)
|
| 292 |
+
|
| 293 |
+
deflection = next((p for flag, p in evasive_flags if flag and p), "")
|
| 294 |
+
deflection_str = f" (deflection: '{deflection}')" if deflection else ""
|
| 295 |
+
metric = (
|
| 296 |
+
f"asked in {', '.join(periods)}; "
|
| 297 |
+
f"{n_evasive}/{len(cluster)} answers non-quantitative{deflection_str}"
|
| 298 |
+
)
|
| 299 |
+
|
| 300 |
+
earliest_period, earliest_x = cluster[0]
|
| 301 |
+
latest_evasive = next(
|
| 302 |
+
(x for (_, x), (flag, _) in zip(reversed(cluster), reversed(evasive_flags)) if flag),
|
| 303 |
+
lx,
|
| 304 |
+
)
|
| 305 |
+
answer_quote = (
|
| 306 |
+
_rep_sentence(latest_evasive.answer, [deflection]) if deflection else ""
|
| 307 |
+
) or _truncate(latest_evasive.answer, 60)
|
| 308 |
+
|
| 309 |
+
deltas.append(QuarterDelta(
|
| 310 |
+
kind="recurring_evasion",
|
| 311 |
+
period_from=periods[0],
|
| 312 |
+
period_to=latest.period,
|
| 313 |
+
before_text=_truncate(f"{earliest_x.analyst}: {earliest_x.question}", 60),
|
| 314 |
+
after_text=answer_quote,
|
| 315 |
+
computed_metric=metric,
|
| 316 |
+
source="transcript",
|
| 317 |
+
significance="HIGH" if len(periods) >= 3 else "MEDIUM",
|
| 318 |
+
term=_question_topic(lx.question),
|
| 319 |
+
))
|
| 320 |
+
|
| 321 |
+
deltas.sort(key=lambda d: {"HIGH": 0, "MEDIUM": 1}.get(d.significance, 2))
|
| 322 |
+
return deltas[:2]
|
| 323 |
+
|
| 324 |
+
|
| 325 |
+
# ---------------------------------------------------------------------------
|
| 326 |
+
# 4. Prepared-remarks topic fades
|
| 327 |
+
# ---------------------------------------------------------------------------
|
| 328 |
+
|
| 329 |
+
def compute_topic_fades(calls: list[ParsedCall]) -> list[QuarterDelta]:
|
| 330 |
+
"""Topic with 2+ mentions in 2+ prior prepared remarks, absent from the latest."""
|
| 331 |
+
usable = [c for c in calls if _word_count(c.prepared_text) >= 300]
|
| 332 |
+
if len(usable) < 3:
|
| 333 |
+
return []
|
| 334 |
+
|
| 335 |
+
latest = usable[-1]
|
| 336 |
+
priors = usable[:-1]
|
| 337 |
+
|
| 338 |
+
deltas: list[QuarterDelta] = []
|
| 339 |
+
seen_labels: set[str] = set()
|
| 340 |
+
for pattern, label in _KPI_PATTERNS + _TRANSCRIPT_EXTRA_TERMS:
|
| 341 |
+
if label in seen_labels:
|
| 342 |
+
continue # 'backlog' appears in both lists
|
| 343 |
+
seen_labels.add(label)
|
| 344 |
+
prior_counts = [
|
| 345 |
+
(c, len(re.findall(pattern, c.prepared_text, re.IGNORECASE)))
|
| 346 |
+
for c in priors
|
| 347 |
+
]
|
| 348 |
+
prominent = [(c, n) for c, n in prior_counts if n >= 2]
|
| 349 |
+
if len(prominent) < 2:
|
| 350 |
+
continue
|
| 351 |
+
if re.search(pattern, latest.prepared_text, re.IGNORECASE):
|
| 352 |
+
continue
|
| 353 |
+
|
| 354 |
+
periods_str = " and ".join(c.period for c, _ in prominent)
|
| 355 |
+
counts_str = ", ".join(str(n) for _, n in prominent)
|
| 356 |
+
last_prominent = prominent[-1][0]
|
| 357 |
+
deltas.append(QuarterDelta(
|
| 358 |
+
kind="topic_fade",
|
| 359 |
+
period_from=prominent[0][0].period,
|
| 360 |
+
period_to=latest.period,
|
| 361 |
+
before_text=_find_context_sentence(last_prominent.prepared_text, pattern),
|
| 362 |
+
after_text="",
|
| 363 |
+
computed_metric=(
|
| 364 |
+
f"'{label}' in prepared remarks of {periods_str} "
|
| 365 |
+
f"({counts_str} mentions), absent in {latest.period}"
|
| 366 |
+
),
|
| 367 |
+
source="transcript",
|
| 368 |
+
significance="MEDIUM",
|
| 369 |
+
term=label,
|
| 370 |
+
))
|
| 371 |
+
|
| 372 |
+
return deltas[:2]
|
| 373 |
+
|
| 374 |
+
|
| 375 |
+
# ---------------------------------------------------------------------------
|
| 376 |
+
# Main entry point
|
| 377 |
+
# ---------------------------------------------------------------------------
|
| 378 |
+
|
| 379 |
+
def compute(ticker: str, n: int = 4) -> list[QuarterDelta]:
|
| 380 |
+
"""Compute all transcript drift signals for a ticker.
|
| 381 |
+
|
| 382 |
+
Examines the last *n* non-empty transcripts. Returns an empty list if
|
| 383 |
+
fewer than 2 are available or on any error — never raises.
|
| 384 |
+
"""
|
| 385 |
+
try:
|
| 386 |
+
return _compute_inner(ticker, n)
|
| 387 |
+
except Exception as exc:
|
| 388 |
+
import sys
|
| 389 |
+
print(f"[tone_drift] Error computing deltas for {ticker}: {exc}", file=sys.stderr)
|
| 390 |
+
return []
|
| 391 |
+
|
| 392 |
+
|
| 393 |
+
def _compute_inner(ticker: str, n: int) -> list[QuarterDelta]:
|
| 394 |
+
transcripts = get_recent_transcripts(ticker.upper(), n=n)
|
| 395 |
+
if len(transcripts) < 2:
|
| 396 |
+
return []
|
| 397 |
+
|
| 398 |
+
raw_texts = [text for _, text in transcripts]
|
| 399 |
+
calls = [parse_call(period, text) for period, text in transcripts]
|
| 400 |
+
|
| 401 |
+
all_deltas: list[QuarterDelta] = []
|
| 402 |
+
all_deltas.extend(compute_tone_trend(calls))
|
| 403 |
+
all_deltas.extend(compute_topic_arcs(calls, raw_texts))
|
| 404 |
+
all_deltas.extend(compute_recurring_evasions(calls))
|
| 405 |
+
all_deltas.extend(compute_topic_fades(calls))
|
| 406 |
+
|
| 407 |
+
# Dedupe by (kind, term), HIGH first, global cap.
|
| 408 |
+
seen: set[tuple[str, str]] = set()
|
| 409 |
+
deduped: list[QuarterDelta] = []
|
| 410 |
+
order = {"HIGH": 0, "MEDIUM": 1, "LOW": 2}
|
| 411 |
+
all_deltas.sort(key=lambda d: (order[d.significance], d.kind))
|
| 412 |
+
for d in all_deltas:
|
| 413 |
+
key = (d.kind, d.term)
|
| 414 |
+
if key not in seen:
|
| 415 |
+
seen.add(key)
|
| 416 |
+
deduped.append(d)
|
| 417 |
+
return deduped[:_MAX_TOTAL]
|
analysis/transcript_parse.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""analysis/transcript_parse.py — structural parsing of flattened earnings-call text.
|
| 2 |
+
|
| 3 |
+
Pure regex, zero embeddings, zero LLM. Operates on the Alpha Vantage transcript
|
| 4 |
+
format produced by ingestion/transcript.py: one line per speaker segment,
|
| 5 |
+
either "Speaker: content" or "Speaker (Title): content".
|
| 6 |
+
|
| 7 |
+
Reconstructs:
|
| 8 |
+
- prepared remarks vs Q&A boundary
|
| 9 |
+
- speaker roles (operator / management / analyst)
|
| 10 |
+
- analyst question → management answer exchanges
|
| 11 |
+
|
| 12 |
+
Degrades gracefully: if the structure cannot be recovered, the full text is
|
| 13 |
+
treated as management speech and the Q&A exchange list is empty.
|
| 14 |
+
"""
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
import re
|
| 18 |
+
from dataclasses import dataclass, field
|
| 19 |
+
|
| 20 |
+
# "Speaker: content" or "Speaker (Title): content" at line start.
|
| 21 |
+
_SPEAKER_RE = re.compile(r"^([A-Z][\w.\-' ]{0,60}?)(?:\s*\(([^)]{1,60})\))?:\s+(.*)$")
|
| 22 |
+
|
| 23 |
+
# Marks the transition from prepared remarks to analyst Q&A.
|
| 24 |
+
_QA_BOUNDARY_RE = re.compile(
|
| 25 |
+
r"question-and-answer session"
|
| 26 |
+
r"|question and answer session"
|
| 27 |
+
r"|ready to (?:start|begin) the q\s*&\s*a"
|
| 28 |
+
r"|open (?:up )?the (?:call|line|floor)s? for questions"
|
| 29 |
+
r"|poll (?:the audience|the lines?|for) ?(?:for questions|questions)?"
|
| 30 |
+
r"|(?:take|taking) (?:your|the first) questions?"
|
| 31 |
+
r"|first question comes? from",
|
| 32 |
+
re.IGNORECASE,
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
# Operator hand-offs that name the analyst and their firm.
|
| 36 |
+
_ANALYST_INTRO_RE = re.compile(
|
| 37 |
+
r"(?:line of|comes? from(?: the line of)?)\s+([A-Z][\w.\-' ]+?)\s+(?:with|from|at)\s+",
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
_MIN_SEGMENTS = 5
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
@dataclass
|
| 44 |
+
class Segment:
|
| 45 |
+
speaker: str
|
| 46 |
+
text: str
|
| 47 |
+
role: str = "unknown" # operator | management | analyst | unknown
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
@dataclass
|
| 51 |
+
class QAExchange:
|
| 52 |
+
analyst: str
|
| 53 |
+
question: str
|
| 54 |
+
answer: str
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
@dataclass
|
| 58 |
+
class ParsedCall:
|
| 59 |
+
period: str
|
| 60 |
+
prepared_text: str # management prepared remarks (pre-Q&A)
|
| 61 |
+
management_text: str # prepared remarks + all answers
|
| 62 |
+
qa: list[QAExchange] = field(default_factory=list)
|
| 63 |
+
n_segments: int = 0
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def _last_name(name: str) -> str:
|
| 67 |
+
parts = name.strip().rstrip(".").split()
|
| 68 |
+
return parts[-1].lower() if parts else ""
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def _split_segments(text: str) -> list[Segment]:
|
| 72 |
+
segments: list[Segment] = []
|
| 73 |
+
for line in text.splitlines():
|
| 74 |
+
line = line.strip()
|
| 75 |
+
if not line:
|
| 76 |
+
continue
|
| 77 |
+
m = _SPEAKER_RE.match(line)
|
| 78 |
+
if m:
|
| 79 |
+
title = m.group(2) or ""
|
| 80 |
+
speaker = m.group(1).strip()
|
| 81 |
+
seg = Segment(speaker=speaker, text=m.group(3).strip())
|
| 82 |
+
if title and re.search(r"analyst", title, re.IGNORECASE):
|
| 83 |
+
seg.role = "analyst"
|
| 84 |
+
segments.append(seg)
|
| 85 |
+
elif segments:
|
| 86 |
+
segments[-1].text += " " + line
|
| 87 |
+
return segments
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def parse_call(period: str, text: str) -> ParsedCall:
|
| 91 |
+
"""Parse one flattened transcript into roles and Q&A exchanges.
|
| 92 |
+
|
| 93 |
+
Never raises. Falls back to a Q&A-free ParsedCall whose prepared/management
|
| 94 |
+
text is the full transcript when structure cannot be recovered.
|
| 95 |
+
"""
|
| 96 |
+
segments = _split_segments(text or "")
|
| 97 |
+
if len(segments) < _MIN_SEGMENTS:
|
| 98 |
+
full = (text or "").strip()
|
| 99 |
+
return ParsedCall(
|
| 100 |
+
period=period, prepared_text=full, management_text=full,
|
| 101 |
+
qa=[], n_segments=len(segments),
|
| 102 |
+
)
|
| 103 |
+
|
| 104 |
+
# Locate the prepared-remarks → Q&A boundary. Operator intros often
|
| 105 |
+
# announce "there will be a question-and-answer session" before anyone
|
| 106 |
+
# has spoken — only accept a boundary once a non-operator segment exists.
|
| 107 |
+
qa_start: int | None = None
|
| 108 |
+
for i, seg in enumerate(segments):
|
| 109 |
+
if not _QA_BOUNDARY_RE.search(seg.text):
|
| 110 |
+
continue
|
| 111 |
+
has_speech_before = any(
|
| 112 |
+
s.speaker.lower() != "operator" for s in segments[:i]
|
| 113 |
+
)
|
| 114 |
+
if has_speech_before:
|
| 115 |
+
qa_start = i
|
| 116 |
+
break
|
| 117 |
+
|
| 118 |
+
# Analyst roster from Operator hand-offs (anywhere in the call).
|
| 119 |
+
roster: set[str] = set()
|
| 120 |
+
for seg in segments:
|
| 121 |
+
if seg.speaker.lower() == "operator":
|
| 122 |
+
seg.role = "operator"
|
| 123 |
+
for m in _ANALYST_INTRO_RE.finditer(seg.text):
|
| 124 |
+
roster.add(_last_name(m.group(1)))
|
| 125 |
+
|
| 126 |
+
# Management = non-operator speakers heard before the Q&A boundary.
|
| 127 |
+
pre_qa_end = qa_start if qa_start is not None else len(segments)
|
| 128 |
+
management: set[str] = {
|
| 129 |
+
_last_name(seg.speaker)
|
| 130 |
+
for seg in segments[:pre_qa_end]
|
| 131 |
+
if seg.role not in ("operator", "analyst") and seg.speaker
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
# Assign roles.
|
| 135 |
+
prev_role = ""
|
| 136 |
+
for i, seg in enumerate(segments):
|
| 137 |
+
if seg.role in ("operator", "analyst"):
|
| 138 |
+
prev_role = seg.role
|
| 139 |
+
continue
|
| 140 |
+
key = _last_name(seg.speaker)
|
| 141 |
+
if key in management:
|
| 142 |
+
seg.role = "management"
|
| 143 |
+
elif key in roster:
|
| 144 |
+
seg.role = "analyst"
|
| 145 |
+
elif qa_start is not None and i > qa_start:
|
| 146 |
+
# Unknown speaker in Q&A: question-shaped or operator hand-off → analyst.
|
| 147 |
+
if seg.text.rstrip().endswith("?") or prev_role == "operator":
|
| 148 |
+
seg.role = "analyst"
|
| 149 |
+
else:
|
| 150 |
+
seg.role = "management"
|
| 151 |
+
else:
|
| 152 |
+
seg.role = "management"
|
| 153 |
+
prev_role = seg.role
|
| 154 |
+
|
| 155 |
+
prepared_parts = [
|
| 156 |
+
seg.text for seg in segments[:pre_qa_end] if seg.role == "management"
|
| 157 |
+
]
|
| 158 |
+
management_parts = [seg.text for seg in segments if seg.role == "management"]
|
| 159 |
+
|
| 160 |
+
# Pair analyst turns with the management turns that follow them.
|
| 161 |
+
qa: list[QAExchange] = []
|
| 162 |
+
if qa_start is not None:
|
| 163 |
+
current: QAExchange | None = None
|
| 164 |
+
for seg in segments[qa_start:]:
|
| 165 |
+
if seg.role == "analyst":
|
| 166 |
+
if current is None or current.answer:
|
| 167 |
+
if current is not None and current.answer:
|
| 168 |
+
qa.append(current)
|
| 169 |
+
current = QAExchange(analyst=seg.speaker, question=seg.text, answer="")
|
| 170 |
+
else:
|
| 171 |
+
current.question += " " + seg.text
|
| 172 |
+
elif seg.role == "management" and current is not None:
|
| 173 |
+
current.answer = (current.answer + " " + seg.text).strip()
|
| 174 |
+
elif seg.role == "operator" and current is not None and current.answer:
|
| 175 |
+
qa.append(current)
|
| 176 |
+
current = None
|
| 177 |
+
if current is not None and current.answer:
|
| 178 |
+
qa.append(current)
|
| 179 |
+
|
| 180 |
+
return ParsedCall(
|
| 181 |
+
period=period,
|
| 182 |
+
prepared_text="\n".join(prepared_parts).strip(),
|
| 183 |
+
management_text="\n".join(management_parts).strip(),
|
| 184 |
+
qa=qa,
|
| 185 |
+
n_segments=len(segments),
|
| 186 |
+
)
|
app.py
CHANGED
|
@@ -7,7 +7,10 @@ load_dotenv()
|
|
| 7 |
|
| 8 |
from langchain_core.messages import HumanMessage
|
| 9 |
from agent.graph import graph
|
| 10 |
-
from dashboard
|
|
|
|
|
|
|
|
|
|
| 11 |
from dashboard import reasoning as reasoning_panel
|
| 12 |
import dashboard.agent_graph as agent_graph
|
| 13 |
|
|
@@ -20,6 +23,12 @@ if not st.session_state.get("_reranker_warmed"):
|
|
| 20 |
_warmup_reranker()
|
| 21 |
st.session_state["_reranker_warmed"] = True
|
| 22 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
# ── Generation state ────────────────────────────────────────────────────────────
|
| 24 |
st.session_state.setdefault("gen", {
|
| 25 |
"running": False,
|
|
@@ -31,22 +40,6 @@ st.session_state.setdefault("gen", {
|
|
| 31 |
})
|
| 32 |
|
| 33 |
|
| 34 |
-
# ── Sidebar state bootstrap (before widgets) ──────────────────────────────────────
|
| 35 |
-
# portfolio_input lives in session_state under its widget key — initialise once so
|
| 36 |
-
# programmatic mutations (auto-add after generation) take effect before the widget renders.
|
| 37 |
-
st.session_state.setdefault("portfolio_input", "")
|
| 38 |
-
|
| 39 |
-
# Consume pending portfolio additions set by the generation thread.
|
| 40 |
-
# Must happen BEFORE the text_area widget is instantiated so the updated string
|
| 41 |
-
# is used as the widget's initial value on this run.
|
| 42 |
-
_pending_add = st.session_state.pop("_portfolio_add", None)
|
| 43 |
-
if _pending_add:
|
| 44 |
-
_cur = st.session_state["portfolio_input"]
|
| 45 |
-
_existing = [t.strip().upper() for t in _cur.replace("\n", ",").split(",") if t.strip()]
|
| 46 |
-
if _pending_add.upper() not in _existing:
|
| 47 |
-
_existing.append(_pending_add.upper())
|
| 48 |
-
st.session_state["portfolio_input"] = ", ".join(_existing)
|
| 49 |
-
|
| 50 |
|
| 51 |
# ── Sidebar ─────────────────────────────────────────────────────────────────────
|
| 52 |
with st.sidebar:
|
|
@@ -61,36 +54,56 @@ with st.sidebar:
|
|
| 61 |
unsafe_allow_html=True,
|
| 62 |
)
|
| 63 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
# ── Ticker input — single company at a time ───────────────────────────────
|
| 65 |
-
_raw_ticker = st.text_input(
|
| 66 |
-
|
|
|
|
|
|
|
| 67 |
|
| 68 |
# Graceful multi-ticker: if the user types "NVDA AAPL", take the first as the
|
| 69 |
-
# active ticker and
|
| 70 |
-
_ticker_tokens = [
|
| 71 |
ticker_input = _ticker_tokens[0] if _ticker_tokens else ""
|
| 72 |
if len(_ticker_tokens) > 1:
|
| 73 |
-
|
| 74 |
-
_cur_p = st.session_state["portfolio_input"]
|
| 75 |
-
_existing_p = [t.strip().upper() for t in _cur_p.replace("\n", ",").split(",") if t.strip()]
|
| 76 |
-
for _t in _ticker_tokens:
|
| 77 |
-
if _t.upper() not in _existing_p:
|
| 78 |
-
_existing_p.append(_t.upper())
|
| 79 |
-
st.session_state["portfolio_input"] = ", ".join(_existing_p)
|
| 80 |
-
st.info(f"Plusieurs tickers détectés — ajoutés à votre Portfolio 👇")
|
| 81 |
|
| 82 |
generate_clicked = st.button(
|
| 83 |
-
"
|
| 84 |
disabled=not ticker_input or st.session_state["gen"]["running"],
|
| 85 |
)
|
| 86 |
|
| 87 |
st.divider()
|
| 88 |
|
| 89 |
-
|
| 90 |
-
if "
|
| 91 |
-
st.session_state["
|
| 92 |
-
|
| 93 |
-
|
|
|
|
|
|
|
|
|
|
| 94 |
|
| 95 |
# Cost footer
|
| 96 |
cost_state = st.session_state.get("_last_cost")
|
|
@@ -103,60 +116,6 @@ with st.sidebar:
|
|
| 103 |
f"Est. cost: ${c['cost_usd']:.4f} USD"
|
| 104 |
)
|
| 105 |
|
| 106 |
-
# ── Portfolio section ─────────────────────────────────────────────────────
|
| 107 |
-
st.divider()
|
| 108 |
-
st.markdown(
|
| 109 |
-
'<div style="font-size:0.72rem;font-weight:700;letter-spacing:0.06em;'
|
| 110 |
-
'text-transform:uppercase;color:#6b7280;margin-bottom:2px;">Portfolio</div>',
|
| 111 |
-
unsafe_allow_html=True,
|
| 112 |
-
)
|
| 113 |
-
st.caption("Sociétés que vous suivez. Générez un brief pour chacune, puis ouvrez l'onglet Portfolio.")
|
| 114 |
-
|
| 115 |
-
_raw_portfolio = st.text_area(
|
| 116 |
-
"Portfolio tickers",
|
| 117 |
-
placeholder="e.g. AAPL, MSFT, NVDA",
|
| 118 |
-
height=72,
|
| 119 |
-
label_visibility="collapsed",
|
| 120 |
-
key="portfolio_input",
|
| 121 |
-
)
|
| 122 |
-
|
| 123 |
-
# Parse portfolio tickers
|
| 124 |
-
_portfolio_tickers = [
|
| 125 |
-
t.strip().upper()
|
| 126 |
-
for t in _raw_portfolio.replace("\n", ",").split(",")
|
| 127 |
-
if t.strip()
|
| 128 |
-
]
|
| 129 |
-
st.session_state["portfolio_tickers"] = _portfolio_tickers
|
| 130 |
-
|
| 131 |
-
# Coverage chips: show which tickers have a brief (green ✓) or not (grey ○)
|
| 132 |
-
if _portfolio_tickers:
|
| 133 |
-
try:
|
| 134 |
-
from storage.briefs_db import list_tickers as _list_briefs
|
| 135 |
-
_briefs_ready = set(_list_briefs())
|
| 136 |
-
except Exception:
|
| 137 |
-
_briefs_ready = set()
|
| 138 |
-
|
| 139 |
-
_chips_html = []
|
| 140 |
-
for _t in _portfolio_tickers:
|
| 141 |
-
if _t in _briefs_ready:
|
| 142 |
-
_chips_html.append(
|
| 143 |
-
f'<span style="background:#ecfdf5;color:#10b981;border:1px solid #a7f3d0;'
|
| 144 |
-
f'border-radius:5px;padding:2px 8px;font-size:0.7rem;font-weight:700;'
|
| 145 |
-
f'white-space:nowrap;">{_t} ✓</span>'
|
| 146 |
-
)
|
| 147 |
-
else:
|
| 148 |
-
_chips_html.append(
|
| 149 |
-
f'<span style="background:#f3f4f6;color:#6b7280;border:1px solid #d1d5db;'
|
| 150 |
-
f'border-radius:5px;padding:2px 8px;font-size:0.7rem;font-weight:600;'
|
| 151 |
-
f'white-space:nowrap;">{_t} ○</span>'
|
| 152 |
-
)
|
| 153 |
-
st.markdown(
|
| 154 |
-
f'<div style="display:flex;flex-wrap:wrap;gap:5px;margin-top:6px;">'
|
| 155 |
-
+ "".join(_chips_html)
|
| 156 |
-
+ f'</div>'
|
| 157 |
-
f'<div style="font-size:0.62rem;color:#9ca3af;margin-top:4px;">✓ prêt · ○ à générer</div>',
|
| 158 |
-
unsafe_allow_html=True,
|
| 159 |
-
)
|
| 160 |
|
| 161 |
|
| 162 |
# ── Helpers ─────────────────────────────────────────────────────────────────────
|
|
@@ -168,16 +127,115 @@ def _get_cached_brief() -> dict | None:
|
|
| 168 |
|
| 169 |
|
| 170 |
def _brief_placeholder(tab_name: str) -> None:
|
| 171 |
-
st.info(f"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 172 |
|
| 173 |
|
| 174 |
# ── Background generation thread ─────────────────────────────────────────────────
|
| 175 |
-
def _run_brief_thread(ticker: str, gen: dict) -> None:
|
| 176 |
initial = {
|
| 177 |
"ticker": ticker,
|
| 178 |
"messages": [HumanMessage(content=f"Generate a research brief for {ticker}.")],
|
| 179 |
"tool_round_count": 0,
|
| 180 |
"nudge_fired": False,
|
|
|
|
| 181 |
"brief": None,
|
| 182 |
"brief_markdown": None,
|
| 183 |
"synthesis_error": None,
|
|
@@ -227,14 +285,15 @@ if generate_clicked and ticker_input and not st.session_state["gen"]["running"]:
|
|
| 227 |
running=True, ticker=ticker_input, trace=[],
|
| 228 |
brief=None, error=None, all_messages=[],
|
| 229 |
)
|
|
|
|
| 230 |
try:
|
| 231 |
from streamlit.runtime.scriptrunner import add_script_run_ctx
|
| 232 |
-
t = threading.Thread(target=_run_brief_thread, args=(ticker_input, gen), daemon=True)
|
| 233 |
add_script_run_ctx(t)
|
| 234 |
t.start()
|
| 235 |
except Exception:
|
| 236 |
# Fallback: run synchronously if thread context attachment fails
|
| 237 |
-
t = threading.Thread(target=_run_brief_thread, args=(ticker_input, gen), daemon=True)
|
| 238 |
t.start()
|
| 239 |
|
| 240 |
|
|
@@ -253,7 +312,7 @@ def _live_trace_fragment() -> None:
|
|
| 253 |
f'padding:16px 20px;margin-bottom:16px;">'
|
| 254 |
f'<div style="display:flex;justify-content:space-between;align-items:baseline;margin-bottom:4px;">'
|
| 255 |
f'<div style="font-size:0.88rem;font-weight:600;color:#0a0a0a;">'
|
| 256 |
-
f'
|
| 257 |
f'</div>'
|
| 258 |
f'<div style="font-size:1rem;font-weight:700;color:#10b981;">{pct}%</div>'
|
| 259 |
f'</div>'
|
|
@@ -266,11 +325,11 @@ def _live_trace_fragment() -> None:
|
|
| 266 |
unsafe_allow_html=True,
|
| 267 |
)
|
| 268 |
agent_graph.render(trace)
|
| 269 |
-
with st.expander("
|
| 270 |
reasoning_panel.render_trace_body(trace)
|
| 271 |
|
| 272 |
if gen.get("error") and not gen.get("brief"):
|
| 273 |
-
st.error(f"
|
| 274 |
|
| 275 |
if gen.get("running"):
|
| 276 |
# Still generating — poll again in 400 ms (server-side timer avoids HF Spaces proxy race)
|
|
@@ -295,17 +354,15 @@ def _live_trace_fragment() -> None:
|
|
| 295 |
try:
|
| 296 |
from storage import briefs_db
|
| 297 |
briefs_db.save_brief(gen["ticker"], gen["brief"])
|
| 298 |
-
# Signal sidebar to auto-add this ticker to the portfolio field
|
| 299 |
-
st.session_state["_portfolio_add"] = gen["ticker"]
|
| 300 |
except Exception:
|
| 301 |
pass
|
| 302 |
st.session_state.setdefault("reasoning_trace", {})[gen["ticker"]] = list(gen["trace"])
|
| 303 |
-
st.session_state["
|
| 304 |
gen.update(running=False, trace=[], brief=None, error=None, all_messages=[])
|
| 305 |
st.rerun()
|
| 306 |
else:
|
| 307 |
# Failure: store error message, clean up, return to normal UI
|
| 308 |
-
err_msg = gen.get("error") or
|
| 309 |
st.session_state["_gen_error"] = (gen.get("ticker", ""), err_msg)
|
| 310 |
gen.update(running=False, trace=[], brief=None, error=None, all_messages=[])
|
| 311 |
st.rerun()
|
|
@@ -315,97 +372,93 @@ def _live_trace_fragment() -> None:
|
|
| 315 |
gen_error = st.session_state.pop("_gen_error", None)
|
| 316 |
if gen_error:
|
| 317 |
err_ticker, err_msg = gen_error
|
| 318 |
-
st.error(f"
|
| 319 |
|
| 320 |
gen = st.session_state["gen"]
|
|
|
|
|
|
|
| 321 |
if gen["running"] or gen.get("trace"):
|
| 322 |
_live_trace_fragment()
|
| 323 |
elif not ticker_input:
|
| 324 |
st.markdown(
|
| 325 |
-
"""
|
| 326 |
<div style="text-align:center;padding:80px 0;color:#6b7280;">
|
| 327 |
<div style="font-size:2.5rem;margin-bottom:16px;">📊</div>
|
| 328 |
-
<div style="font-size:1.2rem;font-weight:600;margin-bottom:8px;color:#0a0a0a;">
|
| 329 |
<div style="font-size:0.9rem;max-width:420px;margin:0 auto;line-height:1.6;">
|
| 330 |
-
|
| 331 |
-
earnings transcripts, and live news.
|
| 332 |
</div>
|
| 333 |
</div>
|
| 334 |
""",
|
| 335 |
unsafe_allow_html=True,
|
| 336 |
)
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
render_portfolio(st.session_state.get("portfolio_tickers", []))
|
| 340 |
-
elif active_name == "Verdict":
|
| 341 |
-
brief = _get_cached_brief()
|
| 342 |
-
if brief:
|
| 343 |
-
from dashboard.verdict import render as render_verdict
|
| 344 |
-
render_verdict(brief)
|
| 345 |
-
else:
|
| 346 |
-
st.markdown(
|
| 347 |
-
"""
|
| 348 |
-
<div style="text-align:center;padding:48px 0;color:#6b7280;">
|
| 349 |
-
<div style="font-size:2rem;margin-bottom:12px;">📊</div>
|
| 350 |
-
<div style="font-size:1.1rem;font-weight:600;margin-bottom:6px;color:#0a0a0a;">Ready to analyze</div>
|
| 351 |
-
<div style="font-size:0.9rem;">Click <strong>Generate Brief</strong> in the sidebar to analyze the latest filing.</div>
|
| 352 |
-
</div>
|
| 353 |
-
""",
|
| 354 |
-
unsafe_allow_html=True,
|
| 355 |
-
)
|
| 356 |
-
elif active_name == "Numbers":
|
| 357 |
-
from dashboard.numbers import render as render_numbers
|
| 358 |
-
render_numbers(ticker_input)
|
| 359 |
-
elif active_name == "MD&A":
|
| 360 |
-
brief = _get_cached_brief()
|
| 361 |
-
if brief:
|
| 362 |
-
from dashboard.mda import render as render_mda
|
| 363 |
-
render_mda(brief, ticker_input)
|
| 364 |
-
else:
|
| 365 |
-
_brief_placeholder("MD&A")
|
| 366 |
-
elif active_name == "Risks":
|
| 367 |
-
brief = _get_cached_brief()
|
| 368 |
-
if brief:
|
| 369 |
-
from dashboard.risks import render as render_risks
|
| 370 |
-
render_risks(brief, ticker_input)
|
| 371 |
-
else:
|
| 372 |
-
_brief_placeholder("Risks")
|
| 373 |
-
elif active_name == "Earnings Call":
|
| 374 |
brief = _get_cached_brief()
|
| 375 |
if brief:
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 400 |
|
| 401 |
# ── Global data disclaimer ─────────────────────────────────────────────────────
|
| 402 |
if ticker_input and not gen.get("running") and not gen.get("trace"):
|
| 403 |
st.markdown(
|
| 404 |
-
'<div style="margin-top:40px;border-top:1px solid #e5e7eb;padding-top:12px;'
|
| 405 |
-
'font-size:0.72rem;color:#9ca3af;line-height:1.5;">'
|
| 406 |
-
'
|
| 407 |
-
'
|
| 408 |
-
'<em>Not investment advice. For informational purposes only.</em>'
|
| 409 |
-
'</div>',
|
| 410 |
unsafe_allow_html=True,
|
| 411 |
)
|
|
|
|
| 7 |
|
| 8 |
from langchain_core.messages import HumanMessage
|
| 9 |
from agent.graph import graph
|
| 10 |
+
from dashboard import i18n
|
| 11 |
+
from dashboard.i18n import t
|
| 12 |
+
from dashboard.theme import inject_global_css, LOGO_SVG
|
| 13 |
+
from dashboard.nav import NAV_GROUPS, render as render_nav
|
| 14 |
from dashboard import reasoning as reasoning_panel
|
| 15 |
import dashboard.agent_graph as agent_graph
|
| 16 |
|
|
|
|
| 23 |
_warmup_reranker()
|
| 24 |
st.session_state["_reranker_warmed"] = True
|
| 25 |
|
| 26 |
+
# ── UI language — default to English (finance lingua franca) ───────────────────
|
| 27 |
+
st.session_state.setdefault("ui_lang", "en")
|
| 28 |
+
|
| 29 |
+
# ── Navigation state — default to Snapshot (the summary view) ──────────────────
|
| 30 |
+
st.session_state.setdefault("nav_key", "snapshot")
|
| 31 |
+
|
| 32 |
# ── Generation state ────────────────────────────────────────────────────────────
|
| 33 |
st.session_state.setdefault("gen", {
|
| 34 |
"running": False,
|
|
|
|
| 40 |
})
|
| 41 |
|
| 42 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
|
| 44 |
# ── Sidebar ─────────────────────────────────────────────────────────────────────
|
| 45 |
with st.sidebar:
|
|
|
|
| 54 |
unsafe_allow_html=True,
|
| 55 |
)
|
| 56 |
|
| 57 |
+
# ── Language selector — top of sidebar so first-time visitors set it before ──
|
| 58 |
+
# anything else. Endonym labels (English / Français / Español / Deutsch) are
|
| 59 |
+
# self-identifying regardless of the current display language.
|
| 60 |
+
_lang_labels = list(i18n.LANGUAGES.keys())
|
| 61 |
+
_current_lang_label = next(
|
| 62 |
+
(lbl for lbl, code in i18n.LANGUAGES.items()
|
| 63 |
+
if code == st.session_state.get("ui_lang", "en")),
|
| 64 |
+
_lang_labels[0],
|
| 65 |
+
)
|
| 66 |
+
_selected_lang_label = st.selectbox(
|
| 67 |
+
t("language"),
|
| 68 |
+
options=_lang_labels,
|
| 69 |
+
index=_lang_labels.index(_current_lang_label),
|
| 70 |
+
key="_lang_selectbox",
|
| 71 |
+
label_visibility="collapsed",
|
| 72 |
+
)
|
| 73 |
+
# Persist and rerun when the language changes so all t() calls refresh.
|
| 74 |
+
_new_lang_code = i18n.LANGUAGES[_selected_lang_label]
|
| 75 |
+
if _new_lang_code != st.session_state.get("ui_lang", "en"):
|
| 76 |
+
st.session_state["ui_lang"] = _new_lang_code
|
| 77 |
+
st.rerun()
|
| 78 |
+
|
| 79 |
# ── Ticker input — single company at a time ───────────────────────────────
|
| 80 |
+
_raw_ticker = st.text_input(
|
| 81 |
+
t("ticker_label"), placeholder=t("ticker_placeholder")
|
| 82 |
+
).strip().upper()
|
| 83 |
+
st.caption(t("ticker_caption"))
|
| 84 |
|
| 85 |
# Graceful multi-ticker: if the user types "NVDA AAPL", take the first as the
|
| 86 |
+
# active ticker and ignore the rest with a note.
|
| 87 |
+
_ticker_tokens = [tok for tok in _raw_ticker.replace(",", " ").split() if tok]
|
| 88 |
ticker_input = _ticker_tokens[0] if _ticker_tokens else ""
|
| 89 |
if len(_ticker_tokens) > 1:
|
| 90 |
+
st.info(t("ticker_multi_warning"))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
|
| 92 |
generate_clicked = st.button(
|
| 93 |
+
t("generate_brief"), type="primary", use_container_width=True,
|
| 94 |
disabled=not ticker_input or st.session_state["gen"]["running"],
|
| 95 |
)
|
| 96 |
|
| 97 |
st.divider()
|
| 98 |
|
| 99 |
+
# Resolve any pending nav redirect (e.g. auto-navigate to Snapshot after generation)
|
| 100 |
+
if "_nav_pending_key" in st.session_state:
|
| 101 |
+
st.session_state["nav_key"] = st.session_state.pop("_nav_pending_key")
|
| 102 |
+
|
| 103 |
+
# Inline brief lookup — _get_cached_brief() is defined after this block
|
| 104 |
+
_sb = st.session_state.get("brief")
|
| 105 |
+
_sidebar_brief = _sb if (_sb and ticker_input and st.session_state.get("brief_ticker") == ticker_input) else None
|
| 106 |
+
render_nav(NAV_GROUPS, st.session_state["nav_key"], brief=_sidebar_brief)
|
| 107 |
|
| 108 |
# Cost footer
|
| 109 |
cost_state = st.session_state.get("_last_cost")
|
|
|
|
| 116 |
f"Est. cost: ${c['cost_usd']:.4f} USD"
|
| 117 |
)
|
| 118 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 119 |
|
| 120 |
|
| 121 |
# ── Helpers ─────────────────────────────────────────────────────────────────────
|
|
|
|
| 127 |
|
| 128 |
|
| 129 |
def _brief_placeholder(tab_name: str) -> None:
|
| 130 |
+
st.info(f"{t('section_placeholder')} {tab_name}.")
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def _reliability_summary(brief: dict) -> str:
|
| 134 |
+
"""Aggregate HIGH/MED/LOW counts across all SourcedFact fields in the brief.
|
| 135 |
+
|
| 136 |
+
Returns a compact summary string e.g. "Sources: HIGH 72% · MED 18% · LOW 10%"
|
| 137 |
+
or an empty string if no reliability data is found.
|
| 138 |
+
"""
|
| 139 |
+
counts: dict[str, int] = {"HIGH": 0, "MEDIUM": 0, "LOW": 0}
|
| 140 |
+
|
| 141 |
+
def _tally(obj: object) -> None:
|
| 142 |
+
if isinstance(obj, dict):
|
| 143 |
+
rel = obj.get("reliability")
|
| 144 |
+
if rel in counts:
|
| 145 |
+
counts[rel] += 1
|
| 146 |
+
for v in obj.values():
|
| 147 |
+
_tally(v)
|
| 148 |
+
elif isinstance(obj, list):
|
| 149 |
+
for item in obj:
|
| 150 |
+
_tally(item)
|
| 151 |
+
|
| 152 |
+
_tally(brief)
|
| 153 |
+
total = sum(counts.values())
|
| 154 |
+
if total == 0:
|
| 155 |
+
return ""
|
| 156 |
+
|
| 157 |
+
pct = {k: round(v * 100 / total) for k, v in counts.items()}
|
| 158 |
+
parts = [f"{k} {pct[k]}%" for k in ("HIGH", "MEDIUM", "LOW") if pct[k] > 0]
|
| 159 |
+
return f"Sources: {' · '.join(parts)}"
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
def _render_ticker_bar(ticker: str, brief: dict) -> None:
|
| 163 |
+
"""Render the sticky context bar above the main content area.
|
| 164 |
+
|
| 165 |
+
Shows: ticker · company · filing as-of · D1 reaction · reliability summary.
|
| 166 |
+
Reuses .primer-sticky-bar from theme.py.
|
| 167 |
+
"""
|
| 168 |
+
from dashboard.theme import GREEN, RED, TEXT_MUTED
|
| 169 |
+
|
| 170 |
+
company = brief.get("company_name", ticker)
|
| 171 |
+
filing_date = brief.get("filing_date", "")
|
| 172 |
+
me = brief.get("market_expectations") or {}
|
| 173 |
+
d1 = me.get("d1_price_reaction_pct")
|
| 174 |
+
|
| 175 |
+
# D1 reaction
|
| 176 |
+
d1_html = ""
|
| 177 |
+
if d1 is not None:
|
| 178 |
+
color = GREEN if d1 >= 0 else RED
|
| 179 |
+
prefix = "+" if d1 >= 0 else ""
|
| 180 |
+
d1_html = (
|
| 181 |
+
f'<span style="background:{"#ecfdf5" if d1 >= 0 else "#fef2f2"};'
|
| 182 |
+
f'color:{color};border:1px solid {color}33;'
|
| 183 |
+
f'border-radius:4px;padding:2px 8px;font-size:0.75rem;font-weight:600;">'
|
| 184 |
+
f'D1 {prefix}{d1:.1f}%</span>'
|
| 185 |
+
)
|
| 186 |
+
|
| 187 |
+
# Filing date — compact
|
| 188 |
+
date_html = ""
|
| 189 |
+
if filing_date:
|
| 190 |
+
date_html = (
|
| 191 |
+
f'<span style="font-size:0.75rem;color:{TEXT_MUTED};">{t("as_of")} {filing_date}</span>'
|
| 192 |
+
)
|
| 193 |
+
|
| 194 |
+
# Reliability summary
|
| 195 |
+
rel_text = _reliability_summary(brief)
|
| 196 |
+
if rel_text:
|
| 197 |
+
# Replace hardcoded "Sources:" prefix with localised version
|
| 198 |
+
_src_prefix = t("sources_prefix")
|
| 199 |
+
rel_text = _src_prefix + rel_text[rel_text.index(":") + 1:] if ":" in rel_text else rel_text
|
| 200 |
+
rel_html = (
|
| 201 |
+
f'<span style="font-size:0.7rem;color:{TEXT_MUTED};">{rel_text}</span>'
|
| 202 |
+
if rel_text else ""
|
| 203 |
+
)
|
| 204 |
+
|
| 205 |
+
# Dot separator helper
|
| 206 |
+
sep = f'<span style="color:#d1d5db;font-size:0.7rem;">·</span>'
|
| 207 |
+
|
| 208 |
+
inner_parts = [
|
| 209 |
+
f'<span style="font-weight:700;font-size:0.9rem;color:#0a0a0a;">{ticker}</span>',
|
| 210 |
+
f'<span style="font-size:0.82rem;color:#374151;">{company}</span>',
|
| 211 |
+
]
|
| 212 |
+
if date_html:
|
| 213 |
+
inner_parts.append(date_html)
|
| 214 |
+
if d1_html:
|
| 215 |
+
inner_parts.append(d1_html)
|
| 216 |
+
if rel_html:
|
| 217 |
+
inner_parts.append(rel_html)
|
| 218 |
+
|
| 219 |
+
inner = f" {sep} ".join(inner_parts)
|
| 220 |
+
|
| 221 |
+
st.markdown(
|
| 222 |
+
f'<div class="primer-sticky-bar">'
|
| 223 |
+
f'<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;">'
|
| 224 |
+
f'{inner}'
|
| 225 |
+
f'</div>'
|
| 226 |
+
f'</div>',
|
| 227 |
+
unsafe_allow_html=True,
|
| 228 |
+
)
|
| 229 |
|
| 230 |
|
| 231 |
# ── Background generation thread ─────────────────────────────────────────────────
|
| 232 |
+
def _run_brief_thread(ticker: str, gen: dict, language: str = "English") -> None:
|
| 233 |
initial = {
|
| 234 |
"ticker": ticker,
|
| 235 |
"messages": [HumanMessage(content=f"Generate a research brief for {ticker}.")],
|
| 236 |
"tool_round_count": 0,
|
| 237 |
"nudge_fired": False,
|
| 238 |
+
"language": language,
|
| 239 |
"brief": None,
|
| 240 |
"brief_markdown": None,
|
| 241 |
"synthesis_error": None,
|
|
|
|
| 285 |
running=True, ticker=ticker_input, trace=[],
|
| 286 |
brief=None, error=None, all_messages=[],
|
| 287 |
)
|
| 288 |
+
_chosen_language = i18n.report_language()
|
| 289 |
try:
|
| 290 |
from streamlit.runtime.scriptrunner import add_script_run_ctx
|
| 291 |
+
t = threading.Thread(target=_run_brief_thread, args=(ticker_input, gen, _chosen_language), daemon=True)
|
| 292 |
add_script_run_ctx(t)
|
| 293 |
t.start()
|
| 294 |
except Exception:
|
| 295 |
# Fallback: run synchronously if thread context attachment fails
|
| 296 |
+
t = threading.Thread(target=_run_brief_thread, args=(ticker_input, gen, _chosen_language), daemon=True)
|
| 297 |
t.start()
|
| 298 |
|
| 299 |
|
|
|
|
| 312 |
f'padding:16px 20px;margin-bottom:16px;">'
|
| 313 |
f'<div style="display:flex;justify-content:space-between;align-items:baseline;margin-bottom:4px;">'
|
| 314 |
f'<div style="font-size:0.88rem;font-weight:600;color:#0a0a0a;">'
|
| 315 |
+
f'{t("generating_brief")} — <code style="background:#f3f4f6;padding:1px 7px;border-radius:5px;font-size:0.82rem;">{ticker_label}</code>'
|
| 316 |
f'</div>'
|
| 317 |
f'<div style="font-size:1rem;font-weight:700;color:#10b981;">{pct}%</div>'
|
| 318 |
f'</div>'
|
|
|
|
| 325 |
unsafe_allow_html=True,
|
| 326 |
)
|
| 327 |
agent_graph.render(trace)
|
| 328 |
+
with st.expander(t("detailed_reasoning"), expanded=False):
|
| 329 |
reasoning_panel.render_trace_body(trace)
|
| 330 |
|
| 331 |
if gen.get("error") and not gen.get("brief"):
|
| 332 |
+
st.error(f"{t('gen_error_prefix')} {gen['error']}")
|
| 333 |
|
| 334 |
if gen.get("running"):
|
| 335 |
# Still generating — poll again in 400 ms (server-side timer avoids HF Spaces proxy race)
|
|
|
|
| 354 |
try:
|
| 355 |
from storage import briefs_db
|
| 356 |
briefs_db.save_brief(gen["ticker"], gen["brief"])
|
|
|
|
|
|
|
| 357 |
except Exception:
|
| 358 |
pass
|
| 359 |
st.session_state.setdefault("reasoning_trace", {})[gen["ticker"]] = list(gen["trace"])
|
| 360 |
+
st.session_state["_nav_pending_key"] = "snapshot"
|
| 361 |
gen.update(running=False, trace=[], brief=None, error=None, all_messages=[])
|
| 362 |
st.rerun()
|
| 363 |
else:
|
| 364 |
# Failure: store error message, clean up, return to normal UI
|
| 365 |
+
err_msg = gen.get("error") or t("no_brief_error").format(ticker=gen.get("ticker", ""))
|
| 366 |
st.session_state["_gen_error"] = (gen.get("ticker", ""), err_msg)
|
| 367 |
gen.update(running=False, trace=[], brief=None, error=None, all_messages=[])
|
| 368 |
st.rerun()
|
|
|
|
| 372 |
gen_error = st.session_state.pop("_gen_error", None)
|
| 373 |
if gen_error:
|
| 374 |
err_ticker, err_msg = gen_error
|
| 375 |
+
st.error(f"{t('brief_error_prefix')} **{err_ticker}**: {err_msg}")
|
| 376 |
|
| 377 |
gen = st.session_state["gen"]
|
| 378 |
+
active_key = st.session_state.get("nav_key", "snapshot")
|
| 379 |
+
|
| 380 |
if gen["running"] or gen.get("trace"):
|
| 381 |
_live_trace_fragment()
|
| 382 |
elif not ticker_input:
|
| 383 |
st.markdown(
|
| 384 |
+
f"""
|
| 385 |
<div style="text-align:center;padding:80px 0;color:#6b7280;">
|
| 386 |
<div style="font-size:2.5rem;margin-bottom:16px;">📊</div>
|
| 387 |
+
<div style="font-size:1.2rem;font-weight:600;margin-bottom:8px;color:#0a0a0a;">{t("landing_title")}</div>
|
| 388 |
<div style="font-size:0.9rem;max-width:420px;margin:0 auto;line-height:1.6;">
|
| 389 |
+
{t("landing_body")}
|
|
|
|
| 390 |
</div>
|
| 391 |
</div>
|
| 392 |
""",
|
| 393 |
unsafe_allow_html=True,
|
| 394 |
)
|
| 395 |
+
else:
|
| 396 |
+
# Active ticker, not generating — show context bar then route to section
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 397 |
brief = _get_cached_brief()
|
| 398 |
if brief:
|
| 399 |
+
_render_ticker_bar(ticker_input, brief)
|
| 400 |
+
|
| 401 |
+
if active_key == "snapshot":
|
| 402 |
+
if brief:
|
| 403 |
+
from dashboard.verdict import render as render_verdict
|
| 404 |
+
render_verdict(brief)
|
| 405 |
+
else:
|
| 406 |
+
st.markdown(
|
| 407 |
+
f"""
|
| 408 |
+
<div style="text-align:center;padding:48px 0;color:#6b7280;">
|
| 409 |
+
<div style="font-size:2rem;margin-bottom:12px;">📊</div>
|
| 410 |
+
<div style="font-size:1.1rem;font-weight:600;margin-bottom:6px;color:#0a0a0a;">{t("snapshot_empty_title")}</div>
|
| 411 |
+
<div style="font-size:0.9rem;">{t("snapshot_empty_body")}</div>
|
| 412 |
+
</div>
|
| 413 |
+
""",
|
| 414 |
+
unsafe_allow_html=True,
|
| 415 |
+
)
|
| 416 |
+
elif active_key == "numbers":
|
| 417 |
+
from dashboard.numbers import render as render_numbers
|
| 418 |
+
render_numbers(ticker_input)
|
| 419 |
+
elif active_key == "mda":
|
| 420 |
+
if brief:
|
| 421 |
+
from dashboard.mda import render as render_mda
|
| 422 |
+
render_mda(brief, ticker_input)
|
| 423 |
+
else:
|
| 424 |
+
_brief_placeholder("MD&A")
|
| 425 |
+
elif active_key == "risks":
|
| 426 |
+
if brief:
|
| 427 |
+
from dashboard.risks import render as render_risks
|
| 428 |
+
render_risks(brief, ticker_input)
|
| 429 |
+
else:
|
| 430 |
+
_brief_placeholder("Risks")
|
| 431 |
+
elif active_key == "earnings_call":
|
| 432 |
+
if brief:
|
| 433 |
+
from dashboard.earnings_call import render as render_call
|
| 434 |
+
render_call(brief, ticker_input)
|
| 435 |
+
else:
|
| 436 |
+
_brief_placeholder("Earnings Call")
|
| 437 |
+
elif active_key == "quality_tone":
|
| 438 |
+
if brief:
|
| 439 |
+
from dashboard.quality_tone import render as render_quality_tone
|
| 440 |
+
render_quality_tone(brief, ticker_input)
|
| 441 |
+
else:
|
| 442 |
+
_brief_placeholder("Quality & Tone")
|
| 443 |
+
elif active_key == "guidance":
|
| 444 |
+
if brief:
|
| 445 |
+
from dashboard.catalysts import render as render_catalysts
|
| 446 |
+
render_catalysts(brief, ticker_input)
|
| 447 |
+
else:
|
| 448 |
+
_brief_placeholder("Guidance")
|
| 449 |
+
elif active_key == "earnings_tracker":
|
| 450 |
+
from dashboard.earnings_tracker import render as render_earnings
|
| 451 |
+
render_earnings(ticker_input)
|
| 452 |
+
elif active_key == "chat":
|
| 453 |
+
from dashboard.chat import render as render_chat
|
| 454 |
+
render_chat(ticker_input)
|
| 455 |
|
| 456 |
# ── Global data disclaimer ─────────────────────────────────────────────────────
|
| 457 |
if ticker_input and not gen.get("running") and not gen.get("trace"):
|
| 458 |
st.markdown(
|
| 459 |
+
f'<div style="margin-top:40px;border-top:1px solid #e5e7eb;padding-top:12px;'
|
| 460 |
+
f'font-size:0.72rem;color:#9ca3af;line-height:1.5;">'
|
| 461 |
+
f'{t("disclaimer")}'
|
| 462 |
+
f'</div>',
|
|
|
|
|
|
|
| 463 |
unsafe_allow_html=True,
|
| 464 |
)
|
dashboard/chat.py
CHANGED
|
@@ -19,6 +19,7 @@ from dashboard.theme import (
|
|
| 19 |
SPACE_6,
|
| 20 |
)
|
| 21 |
from storage import metrics_db
|
|
|
|
| 22 |
|
| 23 |
|
| 24 |
# ── Public entry point ────────────────────────────────────────────────────────
|
|
@@ -39,8 +40,7 @@ def render(ticker: str) -> None:
|
|
| 39 |
💬 Ask about {ticker}
|
| 40 |
</div>
|
| 41 |
<div style="font-size:{FS_META};color:{TEXT_MUTED};margin-top:4px;">
|
| 42 |
-
|
| 43 |
-
financial metrics. No live data or external sources.
|
| 44 |
</div>
|
| 45 |
</div>
|
| 46 |
""",
|
|
@@ -51,8 +51,7 @@ def render(ticker: str) -> None:
|
|
| 51 |
rows = metrics_db.get_all_metrics(ticker)
|
| 52 |
if not rows:
|
| 53 |
st.warning(
|
| 54 |
-
|
| 55 |
-
f"Lance `python ingest.py {ticker}` pour charger les documents.",
|
| 56 |
icon="⚠️",
|
| 57 |
)
|
| 58 |
return
|
|
@@ -70,7 +69,7 @@ def render(ticker: str) -> None:
|
|
| 70 |
_render_sources_expander(msg["sources"])
|
| 71 |
|
| 72 |
# Capture new user input.
|
| 73 |
-
user_input = st.chat_input(
|
| 74 |
if not user_input:
|
| 75 |
return
|
| 76 |
|
|
|
|
| 19 |
SPACE_6,
|
| 20 |
)
|
| 21 |
from storage import metrics_db
|
| 22 |
+
from dashboard.i18n import t
|
| 23 |
|
| 24 |
|
| 25 |
# ── Public entry point ────────────────────────────────────────────────────────
|
|
|
|
| 40 |
💬 Ask about {ticker}
|
| 41 |
</div>
|
| 42 |
<div style="font-size:{FS_META};color:{TEXT_MUTED};margin-top:4px;">
|
| 43 |
+
{t("chat_subtitle")}
|
|
|
|
| 44 |
</div>
|
| 45 |
</div>
|
| 46 |
""",
|
|
|
|
| 51 |
rows = metrics_db.get_all_metrics(ticker)
|
| 52 |
if not rows:
|
| 53 |
st.warning(
|
| 54 |
+
t("chat_not_ingested").format(ticker=ticker),
|
|
|
|
| 55 |
icon="⚠️",
|
| 56 |
)
|
| 57 |
return
|
|
|
|
| 69 |
_render_sources_expander(msg["sources"])
|
| 70 |
|
| 71 |
# Capture new user input.
|
| 72 |
+
user_input = st.chat_input(t("chat_input_placeholder").format(ticker=ticker))
|
| 73 |
if not user_input:
|
| 74 |
return
|
| 75 |
|
dashboard/components.py
CHANGED
|
@@ -393,6 +393,10 @@ _DELTA_KIND_META: dict[str, tuple[str, str, str]] = {
|
|
| 393 |
"guidance_language_shift": ("GUIDANCE SHIFT", "#4f46e5", "#eef2ff"),
|
| 394 |
"term_frequency": ("FREQUENCY SHIFT", "#0ea5e9", "#f0f9ff"),
|
| 395 |
"kpi_dropped": ("DROPPED KPI", "#6b7280", "#f3f4f6"),
|
|
|
|
|
|
|
|
|
|
|
|
|
| 396 |
}
|
| 397 |
|
| 398 |
_SIG_COLORS: dict[str, str] = {"HIGH": "#ef4444", "MEDIUM": "#f59e0b", "LOW": "#9ca3af"}
|
|
|
|
| 393 |
"guidance_language_shift": ("GUIDANCE SHIFT", "#4f46e5", "#eef2ff"),
|
| 394 |
"term_frequency": ("FREQUENCY SHIFT", "#0ea5e9", "#f0f9ff"),
|
| 395 |
"kpi_dropped": ("DROPPED KPI", "#6b7280", "#f3f4f6"),
|
| 396 |
+
"tone_trend": ("TONE TREND", "#8b5cf6", "#f5f3ff"),
|
| 397 |
+
"topic_arc": ("TOPIC ARC", "#0ea5e9", "#f0f9ff"),
|
| 398 |
+
"recurring_evasion": ("RECURRING EVASION", "#dc2626", "#fef2f2"),
|
| 399 |
+
"topic_fade": ("TOPIC FADE", "#6b7280", "#f3f4f6"),
|
| 400 |
}
|
| 401 |
|
| 402 |
_SIG_COLORS: dict[str, str] = {"HIGH": "#ef4444", "MEDIUM": "#f59e0b", "LOW": "#9ca3af"}
|
dashboard/i18n.py
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Lightweight i18n layer for Primer.
|
| 2 |
+
|
| 3 |
+
A single ``ui_lang`` session-state key (default ``"en"``) controls both the
|
| 4 |
+
UI chrome and the language passed to the synthesis prompt. Streamlit's
|
| 5 |
+
single-threaded per-user model means session_state is safe to read from here.
|
| 6 |
+
|
| 7 |
+
Usage::
|
| 8 |
+
|
| 9 |
+
from dashboard.i18n import t, report_language
|
| 10 |
+
|
| 11 |
+
# In UI code
|
| 12 |
+
st.button(t("generate_brief"), ...)
|
| 13 |
+
|
| 14 |
+
# Before launching generation thread
|
| 15 |
+
language = report_language() # e.g. "French"
|
| 16 |
+
"""
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
import streamlit as st
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
# ── Language options ──────────────────────────────────────────────────────────
|
| 22 |
+
# Keys are *endonyms* (each language written in itself) so a user can always
|
| 23 |
+
# identify their language regardless of the current UI language.
|
| 24 |
+
LANGUAGES: dict[str, str] = {
|
| 25 |
+
"English": "en",
|
| 26 |
+
"Français": "fr",
|
| 27 |
+
"Español": "es",
|
| 28 |
+
"Deutsch": "de",
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
# Canonical language name consumed by agent/prompts.language_directive().
|
| 32 |
+
_CANONICAL: dict[str, str] = {
|
| 33 |
+
"en": "English",
|
| 34 |
+
"fr": "French",
|
| 35 |
+
"es": "Spanish",
|
| 36 |
+
"de": "German",
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def report_language() -> str:
|
| 41 |
+
"""Return the canonical language name for the synthesis prompt.
|
| 42 |
+
|
| 43 |
+
e.g. "French" — consumed by agent/prompts.language_directive().
|
| 44 |
+
"""
|
| 45 |
+
lang = st.session_state.get("ui_lang", "en")
|
| 46 |
+
return _CANONICAL.get(lang, "English")
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
# ── Translation strings ───────────────────────────────────────────────────────
|
| 50 |
+
# Keys are camelCase identifiers; values are per-language strings.
|
| 51 |
+
# Fallback: if a key is missing in a language, t() returns the English value.
|
| 52 |
+
STRINGS: dict[str, dict[str, str]] = {
|
| 53 |
+
|
| 54 |
+
# ── Sidebar / global chrome ────────────────────────────────────────────
|
| 55 |
+
"language": {
|
| 56 |
+
"en": "Language",
|
| 57 |
+
"fr": "Langue",
|
| 58 |
+
"es": "Idioma",
|
| 59 |
+
"de": "Sprache",
|
| 60 |
+
},
|
| 61 |
+
"ticker_label": {
|
| 62 |
+
"en": "Ticker",
|
| 63 |
+
"fr": "Ticker",
|
| 64 |
+
"es": "Ticker",
|
| 65 |
+
"de": "Ticker",
|
| 66 |
+
},
|
| 67 |
+
"ticker_placeholder": {
|
| 68 |
+
"en": "e.g. AAPL",
|
| 69 |
+
"fr": "ex. AAPL",
|
| 70 |
+
"es": "ej. AAPL",
|
| 71 |
+
"de": "z.B. AAPL",
|
| 72 |
+
},
|
| 73 |
+
"ticker_caption": {
|
| 74 |
+
"en": "Analyse one company at a time.",
|
| 75 |
+
"fr": "Analysez une société à la fois.",
|
| 76 |
+
"es": "Analice una empresa a la vez.",
|
| 77 |
+
"de": "Analysieren Sie ein Unternehmen auf einmal.",
|
| 78 |
+
},
|
| 79 |
+
"ticker_multi_warning": {
|
| 80 |
+
"en": "Multiple tickers detected — only the first will be analysed.",
|
| 81 |
+
"fr": "Plusieurs tickers détectés — seul le premier sera analysé.",
|
| 82 |
+
"es": "Se detectaron varios tickers — solo se analizará el primero.",
|
| 83 |
+
"de": "Mehrere Ticker erkannt — nur der erste wird analysiert.",
|
| 84 |
+
},
|
| 85 |
+
"generate_brief": {
|
| 86 |
+
"en": "Generate Brief",
|
| 87 |
+
"fr": "Générer le rapport",
|
| 88 |
+
"es": "Generar informe",
|
| 89 |
+
"de": "Bericht erstellen",
|
| 90 |
+
},
|
| 91 |
+
|
| 92 |
+
# ── Landing / empty states ─────────────────────────────────────────────
|
| 93 |
+
"landing_title": {
|
| 94 |
+
"en": "Ready to analyze",
|
| 95 |
+
"fr": "Prêt à analyser",
|
| 96 |
+
"es": "Listo para analizar",
|
| 97 |
+
"de": "Bereit zur Analyse",
|
| 98 |
+
},
|
| 99 |
+
"landing_body": {
|
| 100 |
+
"en": "Pick a ticker and click <strong>Generate Brief</strong> — sourced from SEC filings, earnings transcripts, and live news.",
|
| 101 |
+
"fr": "Choisissez un ticker et cliquez sur <strong>Générer le rapport</strong> — données issues des dépôts SEC, transcriptions et actualités.",
|
| 102 |
+
"es": "Elija un ticker y haga clic en <strong>Generar informe</strong> — datos de archivos SEC, transcripciones y noticias.",
|
| 103 |
+
"de": "Wählen Sie einen Ticker und klicken Sie auf <strong>Bericht erstellen</strong> — Quellen: SEC-Einreichungen, Transkripte und aktuelle Nachrichten.",
|
| 104 |
+
},
|
| 105 |
+
"snapshot_empty_title": {
|
| 106 |
+
"en": "Ready to analyze",
|
| 107 |
+
"fr": "Prêt à analyser",
|
| 108 |
+
"es": "Listo para analizar",
|
| 109 |
+
"de": "Bereit zur Analyse",
|
| 110 |
+
},
|
| 111 |
+
"snapshot_empty_body": {
|
| 112 |
+
"en": "Click <strong>Generate Brief</strong> in the sidebar to analyse the latest filing.",
|
| 113 |
+
"fr": "Cliquez sur <strong>Générer le rapport</strong> dans la barre latérale pour analyser le dernier dépôt.",
|
| 114 |
+
"es": "Haga clic en <strong>Generar informe</strong> en la barra lateral para analizar el último archivo.",
|
| 115 |
+
"de": "Klicken Sie auf <strong>Bericht erstellen</strong> in der Seitenleiste, um die neueste Einreichung zu analysieren.",
|
| 116 |
+
},
|
| 117 |
+
"section_placeholder": {
|
| 118 |
+
# Used in _brief_placeholder(tab_name) — tab_name is appended separately.
|
| 119 |
+
"en": "Generate the brief using the <strong>Generate Brief</strong> button in the sidebar to populate",
|
| 120 |
+
"fr": "Générez le rapport via le bouton <strong>Générer le rapport</strong> dans la barre latérale pour afficher",
|
| 121 |
+
"es": "Genere el informe con el botón <strong>Generar informe</strong> de la barra lateral para mostrar",
|
| 122 |
+
"de": "Erstellen Sie den Bericht mit <strong>Bericht erstellen</strong> in der Seitenleiste, um Folgendes anzuzeigen:",
|
| 123 |
+
},
|
| 124 |
+
|
| 125 |
+
# ── Ticker / sticky bar ────────────────────────────────────────────────
|
| 126 |
+
"as_of": {
|
| 127 |
+
"en": "as of",
|
| 128 |
+
"fr": "au",
|
| 129 |
+
"es": "al",
|
| 130 |
+
"de": "Stand",
|
| 131 |
+
},
|
| 132 |
+
"sources_prefix": {
|
| 133 |
+
"en": "Sources:",
|
| 134 |
+
"fr": "Sources :",
|
| 135 |
+
"es": "Fuentes:",
|
| 136 |
+
"de": "Quellen:",
|
| 137 |
+
},
|
| 138 |
+
|
| 139 |
+
# ── Generation progress ────────────────────────────────────────────────
|
| 140 |
+
"generating_brief": {
|
| 141 |
+
"en": "Generating brief",
|
| 142 |
+
"fr": "Génération du rapport",
|
| 143 |
+
"es": "Generando informe",
|
| 144 |
+
"de": "Bericht wird erstellt",
|
| 145 |
+
},
|
| 146 |
+
"detailed_reasoning": {
|
| 147 |
+
"en": "🔍 Detailed reasoning",
|
| 148 |
+
"fr": "🔍 Raisonnement détaillé",
|
| 149 |
+
"es": "🔍 Razonamiento detallado",
|
| 150 |
+
"de": "🔍 Ausführliche Analyse",
|
| 151 |
+
},
|
| 152 |
+
"gen_error_prefix": {
|
| 153 |
+
"en": "Generation failed:",
|
| 154 |
+
"fr": "Échec de la génération :",
|
| 155 |
+
"es": "Error en la generación:",
|
| 156 |
+
"de": "Generierung fehlgeschlagen:",
|
| 157 |
+
},
|
| 158 |
+
"no_brief_error": {
|
| 159 |
+
"en": "No brief produced — make sure `{ticker}` has been ingested.",
|
| 160 |
+
"fr": "Aucun rapport produit — assurez-vous que `{ticker}` a été ingéré.",
|
| 161 |
+
"es": "No se generó ningún informe — asegúrese de que `{ticker}` haya sido procesado.",
|
| 162 |
+
"de": "Kein Bericht erstellt — stellen Sie sicher, dass `{ticker}` aufgenommen wurde.",
|
| 163 |
+
},
|
| 164 |
+
"brief_error_prefix": {
|
| 165 |
+
"en": "Could not generate brief for",
|
| 166 |
+
"fr": "Impossible de générer le rapport pour",
|
| 167 |
+
"es": "No se pudo generar el informe para",
|
| 168 |
+
"de": "Bericht konnte nicht erstellt werden für",
|
| 169 |
+
},
|
| 170 |
+
|
| 171 |
+
# ── Cost footer ────────────────────────────────────────────────────────
|
| 172 |
+
"tokens_label": {
|
| 173 |
+
"en": "Tokens:",
|
| 174 |
+
"fr": "Tokens :",
|
| 175 |
+
"es": "Tokens:",
|
| 176 |
+
"de": "Token:",
|
| 177 |
+
},
|
| 178 |
+
"cost_label": {
|
| 179 |
+
"en": "Est. cost:",
|
| 180 |
+
"fr": "Coût estimé :",
|
| 181 |
+
"es": "Costo estimado:",
|
| 182 |
+
"de": "Geschätzte Kosten:",
|
| 183 |
+
},
|
| 184 |
+
|
| 185 |
+
# ── Disclaimer ─────────────────────────────────────────────────────────
|
| 186 |
+
"disclaimer": {
|
| 187 |
+
"en": "Data: SEC EDGAR (HIGH) · Earnings transcript via Alpha Vantage (MEDIUM) · News via Tavily (LOW) · Price data via yfinance · EPS surprise via Alpha Vantage. <em>Not investment advice. For informational purposes only.</em>",
|
| 188 |
+
"fr": "Données : SEC EDGAR (HIGH) · Transcriptions via Alpha Vantage (MEDIUM) · Actualités via Tavily (LOW) · Cours via yfinance · Surprise BPA via Alpha Vantage. <em>Pas de conseil en investissement. À titre informatif uniquement.</em>",
|
| 189 |
+
"es": "Datos: SEC EDGAR (HIGH) · Transcripciones via Alpha Vantage (MEDIUM) · Noticias via Tavily (LOW) · Precios via yfinance · Sorpresa BPA via Alpha Vantage. <em>No es asesoramiento de inversión. Solo con fines informativos.</em>",
|
| 190 |
+
"de": "Daten: SEC EDGAR (HIGH) · Transkripte via Alpha Vantage (MEDIUM) · Nachrichten via Tavily (LOW) · Kursdaten via yfinance · EPS-Überraschung via Alpha Vantage. <em>Keine Anlageberatung. Nur zu Informationszwecken.</em>",
|
| 191 |
+
},
|
| 192 |
+
|
| 193 |
+
# ── Nav group headers ──────────────────────────────────────────────────
|
| 194 |
+
"nav_group_synthesis": {
|
| 195 |
+
"en": "SYNTHESIS",
|
| 196 |
+
"fr": "SYNTHÈSE",
|
| 197 |
+
"es": "SÍNTESIS",
|
| 198 |
+
"de": "SYNTHESE",
|
| 199 |
+
},
|
| 200 |
+
"nav_group_numbers": {
|
| 201 |
+
"en": "NUMBERS",
|
| 202 |
+
"fr": "LES CHIFFRES",
|
| 203 |
+
"es": "LOS NÚMEROS",
|
| 204 |
+
"de": "ZAHLEN",
|
| 205 |
+
},
|
| 206 |
+
"nav_group_management": {
|
| 207 |
+
"en": "MANAGEMENT",
|
| 208 |
+
"fr": "MANAGEMENT",
|
| 209 |
+
"es": "DIRECCIÓN",
|
| 210 |
+
"de": "MANAGEMENT",
|
| 211 |
+
},
|
| 212 |
+
"nav_group_risks": {
|
| 213 |
+
"en": "RISKS",
|
| 214 |
+
"fr": "RISQUES",
|
| 215 |
+
"es": "RIESGOS",
|
| 216 |
+
"de": "RISIKEN",
|
| 217 |
+
},
|
| 218 |
+
"nav_group_outlook": {
|
| 219 |
+
"en": "OUTLOOK",
|
| 220 |
+
"fr": "PERSPECTIVES",
|
| 221 |
+
"es": "PERSPECTIVAS",
|
| 222 |
+
"de": "AUSBLICK",
|
| 223 |
+
},
|
| 224 |
+
"nav_group_qa": {
|
| 225 |
+
"en": "Q&A",
|
| 226 |
+
"fr": "Q&R",
|
| 227 |
+
"es": "P&R",
|
| 228 |
+
"de": "F&A",
|
| 229 |
+
},
|
| 230 |
+
|
| 231 |
+
# ── Chat section ───────────────────────────────────────────────────────
|
| 232 |
+
"chat_title": {
|
| 233 |
+
"en": "Chat",
|
| 234 |
+
"fr": "Chat",
|
| 235 |
+
"es": "Chat",
|
| 236 |
+
"de": "Chat",
|
| 237 |
+
},
|
| 238 |
+
"chat_subtitle": {
|
| 239 |
+
"en": "Ask questions about the stored documents for this company.",
|
| 240 |
+
"fr": "Posez des questions sur les documents enregistrés pour cette société.",
|
| 241 |
+
"es": "Haga preguntas sobre los documentos almacenados de esta empresa.",
|
| 242 |
+
"de": "Stellen Sie Fragen zu den gespeicherten Dokumenten dieses Unternehmens.",
|
| 243 |
+
},
|
| 244 |
+
"chat_not_ingested": {
|
| 245 |
+
"en": "**{ticker}** has not been ingested yet. Run `python ingest.py {ticker}` to load the documents.",
|
| 246 |
+
"fr": "**{ticker}** n'a pas encore été ingéré. Lancez `python ingest.py {ticker}` pour charger les documents.",
|
| 247 |
+
"es": "**{ticker}** aún no ha sido procesado. Ejecute `python ingest.py {ticker}` para cargar los documentos.",
|
| 248 |
+
"de": "**{ticker}** wurde noch nicht aufgenommen. Führen Sie `python ingest.py {ticker}` aus, um die Dokumente zu laden.",
|
| 249 |
+
},
|
| 250 |
+
"chat_input_placeholder": {
|
| 251 |
+
"en": "Ask a question about {ticker}'s documents…",
|
| 252 |
+
"fr": "Posez une question sur les documents de {ticker}…",
|
| 253 |
+
"es": "Haga una pregunta sobre los documentos de {ticker}…",
|
| 254 |
+
"de": "Stellen Sie eine Frage zu den Dokumenten von {ticker}…",
|
| 255 |
+
},
|
| 256 |
+
}
|
| 257 |
+
|
| 258 |
+
|
| 259 |
+
def t(key: str) -> str:
|
| 260 |
+
"""Return the UI string for *key* in the current session language.
|
| 261 |
+
|
| 262 |
+
Falls back to English if the key is missing in the requested language,
|
| 263 |
+
then falls back to the raw key so a missing translation is always
|
| 264 |
+
surfaced in development rather than silently breaking.
|
| 265 |
+
"""
|
| 266 |
+
lang = st.session_state.get("ui_lang", "en")
|
| 267 |
+
bucket = STRINGS.get(key, {})
|
| 268 |
+
return bucket.get(lang) or bucket.get("en") or key
|
dashboard/nav.py
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Data-driven sidebar navigation for Primer — institutional style.
|
| 2 |
+
|
| 3 |
+
Nav structure follows the analyst decision flow:
|
| 4 |
+
Synthèse → Les Chiffres → Management → Risques → Outlook → Q&A
|
| 5 |
+
|
| 6 |
+
Each section has a stable ``key`` decoupled from its display label,
|
| 7 |
+
so renaming a section never breaks routing.
|
| 8 |
+
"""
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
import streamlit as st
|
| 11 |
+
from dashboard.i18n import t
|
| 12 |
+
|
| 13 |
+
# ── Navigation groups ─────────────────────────────────────────────────────────
|
| 14 |
+
# always_visible=True → rendered inline (no expander); used for anchor groups.
|
| 15 |
+
# always_visible=False → rendered in a collapsible st.expander (deep-dive).
|
| 16 |
+
NAV_GROUPS: list[dict] = [
|
| 17 |
+
{
|
| 18 |
+
"i18n_key": "nav_group_synthesis",
|
| 19 |
+
"always_visible": True,
|
| 20 |
+
"items": [
|
| 21 |
+
{"key": "snapshot", "label": "Snapshot"},
|
| 22 |
+
],
|
| 23 |
+
},
|
| 24 |
+
{
|
| 25 |
+
"i18n_key": "nav_group_numbers",
|
| 26 |
+
"always_visible": False,
|
| 27 |
+
"items": [
|
| 28 |
+
{"key": "numbers", "label": "Numbers"},
|
| 29 |
+
],
|
| 30 |
+
},
|
| 31 |
+
{
|
| 32 |
+
"i18n_key": "nav_group_management",
|
| 33 |
+
"always_visible": False,
|
| 34 |
+
"items": [
|
| 35 |
+
{"key": "mda", "label": "MD&A"},
|
| 36 |
+
{"key": "earnings_call", "label": "Earnings Call"},
|
| 37 |
+
{"key": "quality_tone", "label": "Quality & Tone"},
|
| 38 |
+
],
|
| 39 |
+
},
|
| 40 |
+
{
|
| 41 |
+
"i18n_key": "nav_group_risks",
|
| 42 |
+
"always_visible": False,
|
| 43 |
+
"items": [
|
| 44 |
+
{"key": "risks", "label": "Risks"},
|
| 45 |
+
],
|
| 46 |
+
},
|
| 47 |
+
{
|
| 48 |
+
"i18n_key": "nav_group_outlook",
|
| 49 |
+
"always_visible": False,
|
| 50 |
+
"items": [
|
| 51 |
+
{"key": "guidance", "label": "Guidance"},
|
| 52 |
+
{"key": "earnings_tracker", "label": "Earnings Tracker"},
|
| 53 |
+
],
|
| 54 |
+
},
|
| 55 |
+
{
|
| 56 |
+
"i18n_key": "nav_group_qa",
|
| 57 |
+
"always_visible": True,
|
| 58 |
+
"items": [
|
| 59 |
+
{"key": "chat", "label": "Chat"},
|
| 60 |
+
],
|
| 61 |
+
},
|
| 62 |
+
]
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def section_state(brief: dict | None) -> dict[str, str]:
|
| 66 |
+
"""Return a data-availability state per nav key from the brief.
|
| 67 |
+
|
| 68 |
+
Returns:
|
| 69 |
+
dict mapping key → "ready" | "—" | ""
|
| 70 |
+
"ready" section has data
|
| 71 |
+
"—" section has no data (e.g. no transcript retrieved)
|
| 72 |
+
"" always-available or indeterminate
|
| 73 |
+
"""
|
| 74 |
+
if not brief:
|
| 75 |
+
return {}
|
| 76 |
+
|
| 77 |
+
states: dict[str, str] = {}
|
| 78 |
+
|
| 79 |
+
# Snapshot — always ready once a brief exists
|
| 80 |
+
states["snapshot"] = "ready"
|
| 81 |
+
|
| 82 |
+
# Numbers — reads SQLite directly, available once ticker is ingested
|
| 83 |
+
states["numbers"] = "ready"
|
| 84 |
+
|
| 85 |
+
# MD&A — ready when mda_summary.drivers is populated
|
| 86 |
+
mda = brief.get("mda_summary") or {}
|
| 87 |
+
states["mda"] = "ready" if mda.get("drivers") else "—"
|
| 88 |
+
|
| 89 |
+
# Earnings Call + Quality & Tone — need transcript data
|
| 90 |
+
mgmt = brief.get("management_commentary") or []
|
| 91 |
+
has_transcript = any(
|
| 92 |
+
str(f.get("source", "")).lower() == "transcript" for f in mgmt
|
| 93 |
+
)
|
| 94 |
+
states["earnings_call"] = "ready" if has_transcript else "—"
|
| 95 |
+
states["quality_tone"] = "ready" if has_transcript else "—"
|
| 96 |
+
|
| 97 |
+
# Risks — ready when risks_categorized is non-empty
|
| 98 |
+
states["risks"] = "ready" if brief.get("risks_categorized") else "—"
|
| 99 |
+
|
| 100 |
+
# Guidance — ready when guidance_history is non-empty
|
| 101 |
+
states["guidance"] = "ready" if brief.get("guidance_history") else "—"
|
| 102 |
+
|
| 103 |
+
# Earnings Tracker — reads AlphaVantage, always available
|
| 104 |
+
states["earnings_tracker"] = "ready"
|
| 105 |
+
|
| 106 |
+
# Chat — always available
|
| 107 |
+
states["chat"] = "ready"
|
| 108 |
+
|
| 109 |
+
return states
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def render(groups: list[dict], active_key: str, brief: dict | None = None) -> None:
|
| 113 |
+
"""Render the sidebar navigation.
|
| 114 |
+
|
| 115 |
+
Injects dynamic CSS that highlights the active button by its Streamlit key
|
| 116 |
+
attribute, then renders each group inline or in a collapsible expander.
|
| 117 |
+
State badges ("○") are appended to labels for sections lacking data.
|
| 118 |
+
"""
|
| 119 |
+
states = section_state(brief)
|
| 120 |
+
|
| 121 |
+
# Dynamic CSS — active button highlight + base button styles.
|
| 122 |
+
# The active key changes per rerun, so this must be injected here rather
|
| 123 |
+
# than in inject_global_css().
|
| 124 |
+
st.markdown(
|
| 125 |
+
f"""<style>
|
| 126 |
+
/* ── Nav button base — sidebar only, scoped to nav_ keys ─────────────── */
|
| 127 |
+
[data-testid="stSidebar"] [data-testid="stBaseButton-secondary"][key^="nav_"] {{
|
| 128 |
+
background: transparent !important;
|
| 129 |
+
border: 1px solid transparent !important;
|
| 130 |
+
border-left: 3px solid transparent !important;
|
| 131 |
+
border-radius: 6px !important;
|
| 132 |
+
padding: 7px 10px !important;
|
| 133 |
+
font-size: 0.84rem !important;
|
| 134 |
+
font-weight: 500 !important;
|
| 135 |
+
color: #4b5563 !important;
|
| 136 |
+
box-shadow: none !important;
|
| 137 |
+
justify-content: flex-start !important;
|
| 138 |
+
}}
|
| 139 |
+
[data-testid="stSidebar"] [data-testid="stBaseButton-secondary"][key^="nav_"]:hover {{
|
| 140 |
+
background: #f1f0ef !important;
|
| 141 |
+
}}
|
| 142 |
+
/* Active nav item */
|
| 143 |
+
[data-testid="stSidebar"] [data-testid="stBaseButton-secondary"][key="nav_{active_key}"] {{
|
| 144 |
+
background: #ecfdf5 !important;
|
| 145 |
+
border-left: 3px solid #10b981 !important;
|
| 146 |
+
color: #059669 !important;
|
| 147 |
+
font-weight: 600 !important;
|
| 148 |
+
}}
|
| 149 |
+
</style>""",
|
| 150 |
+
unsafe_allow_html=True,
|
| 151 |
+
)
|
| 152 |
+
|
| 153 |
+
for group in groups:
|
| 154 |
+
# Resolve group header through i18n; fall back to the raw i18n_key if
|
| 155 |
+
# missing so a forgotten translation is always visible in dev.
|
| 156 |
+
label = t(group.get("i18n_key", ""))
|
| 157 |
+
items = group["items"]
|
| 158 |
+
always_vis = group.get("always_visible", False)
|
| 159 |
+
group_active = any(item["key"] == active_key for item in items)
|
| 160 |
+
|
| 161 |
+
if always_vis:
|
| 162 |
+
# Inline group header — matches the visual weight of expander labels
|
| 163 |
+
st.markdown(
|
| 164 |
+
f'<div style="font-size:0.6rem;font-weight:700;text-transform:uppercase;'
|
| 165 |
+
f'letter-spacing:0.1em;color:#9ca3af;padding:8px 2px 4px;">{label}</div>',
|
| 166 |
+
unsafe_allow_html=True,
|
| 167 |
+
)
|
| 168 |
+
_render_items(items, active_key, states)
|
| 169 |
+
else:
|
| 170 |
+
# Collapsible group — auto-expanded when it contains the active item
|
| 171 |
+
with st.expander(label, expanded=group_active):
|
| 172 |
+
_render_items(items, active_key, states)
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
def _render_items(
|
| 176 |
+
items: list[dict],
|
| 177 |
+
active_key: str,
|
| 178 |
+
states: dict[str, str],
|
| 179 |
+
) -> None:
|
| 180 |
+
"""Render a button per nav item with optional state suffix."""
|
| 181 |
+
for item in items:
|
| 182 |
+
key = item["key"]
|
| 183 |
+
label = item["label"]
|
| 184 |
+
state = states.get(key, "")
|
| 185 |
+
|
| 186 |
+
# "○" suffix signals no data for this section; no suffix = data available.
|
| 187 |
+
# Buttons don't support inline HTML so we embed a plain-text marker.
|
| 188 |
+
suffix = " ○" if state == "—" else ""
|
| 189 |
+
display_label = f"{label}{suffix}"
|
| 190 |
+
|
| 191 |
+
if st.button(display_label, key=f"nav_{key}", use_container_width=True):
|
| 192 |
+
st.session_state["nav_key"] = key
|
| 193 |
+
st.rerun()
|
dashboard/quality_tone.py
CHANGED
|
@@ -173,6 +173,17 @@ def _classify_delta_caution(delta: dict) -> str:
|
|
| 173 |
return "caution" if "+" in metric else "positive"
|
| 174 |
if kind == "risk_removed":
|
| 175 |
return "positive"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 176 |
return "neutral"
|
| 177 |
|
| 178 |
|
|
|
|
| 173 |
return "caution" if "+" in metric else "positive"
|
| 174 |
if kind == "risk_removed":
|
| 175 |
return "positive"
|
| 176 |
+
if kind in ("recurring_evasion", "topic_fade"):
|
| 177 |
+
return "caution"
|
| 178 |
+
if kind == "tone_trend":
|
| 179 |
+
if "more cautious" in metric:
|
| 180 |
+
return "caution"
|
| 181 |
+
if "more confident" in metric:
|
| 182 |
+
return "positive"
|
| 183 |
+
return "neutral"
|
| 184 |
+
if kind == "topic_arc":
|
| 185 |
+
# Tracked topics are mostly pressure-ish (uncertainty, China, inventory…)
|
| 186 |
+
return "caution" if "rising" in metric else "positive"
|
| 187 |
return "neutral"
|
| 188 |
|
| 189 |
|
dashboard/theme.py
CHANGED
|
@@ -81,19 +81,11 @@ SPACE_4 = "16px"
|
|
| 81 |
SPACE_5 = "20px"
|
| 82 |
SPACE_6 = "28px" # intentionally 8px jump — used for section-break vertical rhythm
|
| 83 |
|
| 84 |
-
#
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
("MD&A", "📖"),
|
| 90 |
-
("Risks", "⚠️"),
|
| 91 |
-
("Earnings Call", "🎙️"),
|
| 92 |
-
("Quality & Tone", "🔍"),
|
| 93 |
-
("Guidance", "🔭"),
|
| 94 |
-
("Earnings Tracker", "📈"),
|
| 95 |
-
("Chat", "💬"),
|
| 96 |
-
]
|
| 97 |
|
| 98 |
# ── Logo SVG — geometric P in emerald rounded square ─────────────────────────
|
| 99 |
LOGO_SVG = """<svg width="36" height="36" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
@@ -239,29 +231,6 @@ def inject_global_css() -> None:
|
|
| 239 |
border-bottom: 1px solid #e5e7eb !important;
|
| 240 |
}
|
| 241 |
|
| 242 |
-
/* ── Sidebar radio nav ──────────────────────────────────────────────── */
|
| 243 |
-
[data-testid="stSidebar"] [data-testid="stRadio"] > div {
|
| 244 |
-
gap: 2px !important;
|
| 245 |
-
}
|
| 246 |
-
[data-testid="stSidebar"] [data-testid="stRadio"] label {
|
| 247 |
-
font-size: 0.84rem !important;
|
| 248 |
-
font-weight: 500 !important;
|
| 249 |
-
padding: 7px 10px !important;
|
| 250 |
-
border-radius: 6px !important;
|
| 251 |
-
cursor: pointer !important;
|
| 252 |
-
color: #4b5563 !important;
|
| 253 |
-
border-left: 3px solid transparent !important;
|
| 254 |
-
}
|
| 255 |
-
[data-testid="stSidebar"] [data-testid="stRadio"] label:hover {
|
| 256 |
-
background: #f1f0ef !important;
|
| 257 |
-
}
|
| 258 |
-
[data-testid="stSidebar"] [data-testid="stRadio"] label:has(input[type="radio"]:checked) {
|
| 259 |
-
background: #ecfdf5 !important;
|
| 260 |
-
border-left: 3px solid #10b981 !important;
|
| 261 |
-
color: #059669 !important;
|
| 262 |
-
padding-left: 8px !important;
|
| 263 |
-
}
|
| 264 |
-
|
| 265 |
/* ── Chip nav buttons (Guidance tab / Risks tab) ───────────────────── */
|
| 266 |
[data-testid="stBaseButton-secondary"][key="nav_to_guidance"],
|
| 267 |
[data-testid="stBaseButton-secondary"][key="nav_to_risks"] {
|
|
@@ -280,55 +249,30 @@ def inject_global_css() -> None:
|
|
| 280 |
border-radius: 8px !important;
|
| 281 |
}
|
| 282 |
|
| 283 |
-
/* ── Sidebar nav group
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
/* Before Verdict (1st item) — "BRIEF" group label */
|
| 292 |
-
[data-testid="stSidebar"] [data-testid="stRadio"] label:nth-child(1)::before {
|
| 293 |
-
content: "BRIEF";
|
| 294 |
-
flex: 0 0 100%;
|
| 295 |
-
font-size: 0.55rem !important;
|
| 296 |
-
font-weight: 700 !important;
|
| 297 |
-
text-transform: uppercase;
|
| 298 |
-
letter-spacing: 0.1em;
|
| 299 |
-
color: #9ca3af;
|
| 300 |
-
padding: 0 0 4px 2px;
|
| 301 |
-
pointer-events: none;
|
| 302 |
-
}
|
| 303 |
-
/* Before MD&A (3rd item) — "DEEP DIVE" group */
|
| 304 |
-
[data-testid="stSidebar"] [data-testid="stRadio"] label:nth-child(3) {
|
| 305 |
-
margin-top: 10px !important;
|
| 306 |
}
|
| 307 |
-
[data-testid="stSidebar"] [data-testid="
|
| 308 |
-
|
| 309 |
-
flex: 0 0 100%;
|
| 310 |
-
font-size: 0.55rem !important;
|
| 311 |
font-weight: 700 !important;
|
| 312 |
-
text-transform: uppercase;
|
| 313 |
-
letter-spacing: 0.1em;
|
| 314 |
-
color: #9ca3af;
|
| 315 |
-
padding:
|
| 316 |
-
pointer-events: none;
|
| 317 |
}
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
margin-top: 10px !important;
|
| 321 |
}
|
| 322 |
-
[data-testid="stSidebar"] [data-testid="
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
font-weight: 700 !important;
|
| 327 |
-
text-transform: uppercase;
|
| 328 |
-
letter-spacing: 0.1em;
|
| 329 |
-
color: #9ca3af;
|
| 330 |
-
padding: 0 0 4px 2px;
|
| 331 |
-
pointer-events: none;
|
| 332 |
}
|
| 333 |
|
| 334 |
/* ── Content max-width ──────────────────────────────────────────────── */
|
|
@@ -355,23 +299,6 @@ def inject_global_css() -> None:
|
|
| 355 |
margin: 0 0 16px;
|
| 356 |
}
|
| 357 |
|
| 358 |
-
/* ── Sidebar nav group — Chat (10th item) ──────────────────────────── */
|
| 359 |
-
[data-testid="stSidebar"] [data-testid="stRadio"] label:nth-child(10) {
|
| 360 |
-
flex-wrap: wrap !important;
|
| 361 |
-
margin-top: 10px !important;
|
| 362 |
-
}
|
| 363 |
-
[data-testid="stSidebar"] [data-testid="stRadio"] label:nth-child(10)::before {
|
| 364 |
-
content: "Q&A";
|
| 365 |
-
flex: 0 0 100%;
|
| 366 |
-
font-size: 0.55rem !important;
|
| 367 |
-
font-weight: 700 !important;
|
| 368 |
-
text-transform: uppercase;
|
| 369 |
-
letter-spacing: 0.1em;
|
| 370 |
-
color: #9ca3af;
|
| 371 |
-
padding: 0 0 4px 2px;
|
| 372 |
-
pointer-events: none;
|
| 373 |
-
}
|
| 374 |
-
|
| 375 |
/* ── Chat messages ──────────────────────────────────────────────────── */
|
| 376 |
[data-testid="stChatMessage"] {
|
| 377 |
border-radius: 10px !important;
|
|
|
|
| 81 |
SPACE_5 = "20px"
|
| 82 |
SPACE_6 = "28px" # intentionally 8px jump — used for section-break vertical rhythm
|
| 83 |
|
| 84 |
+
# NAV_GROUPS now lives in dashboard.nav — imported from there in app.py.
|
| 85 |
+
# Kept as a re-export for any legacy callers.
|
| 86 |
+
def _lazy_nav_groups(): # noqa: F401 — accessed via dashboard.nav.NAV_GROUPS
|
| 87 |
+
from dashboard.nav import NAV_GROUPS # noqa: F401
|
| 88 |
+
return NAV_GROUPS
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
|
| 90 |
# ── Logo SVG — geometric P in emerald rounded square ─────────────────────────
|
| 91 |
LOGO_SVG = """<svg width="36" height="36" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
|
|
| 231 |
border-bottom: 1px solid #e5e7eb !important;
|
| 232 |
}
|
| 233 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 234 |
/* ── Chip nav buttons (Guidance tab / Risks tab) ───────────────────── */
|
| 235 |
[data-testid="stBaseButton-secondary"][key="nav_to_guidance"],
|
| 236 |
[data-testid="stBaseButton-secondary"][key="nav_to_risks"] {
|
|
|
|
| 249 |
border-radius: 8px !important;
|
| 250 |
}
|
| 251 |
|
| 252 |
+
/* ── Sidebar nav group expanders (deep-dive accordion) ──────────────── */
|
| 253 |
+
[data-testid="stSidebar"] [data-testid="stExpander"] {
|
| 254 |
+
border: none !important;
|
| 255 |
+
border-top: 1px solid #f0f0ef !important;
|
| 256 |
+
border-radius: 0 !important;
|
| 257 |
+
box-shadow: none !important;
|
| 258 |
+
background: transparent !important;
|
| 259 |
+
margin-top: 2px !important;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 260 |
}
|
| 261 |
+
[data-testid="stSidebar"] [data-testid="stExpander"] summary {
|
| 262 |
+
font-size: 0.6rem !important;
|
|
|
|
|
|
|
| 263 |
font-weight: 700 !important;
|
| 264 |
+
text-transform: uppercase !important;
|
| 265 |
+
letter-spacing: 0.1em !important;
|
| 266 |
+
color: #9ca3af !important;
|
| 267 |
+
padding: 6px 2px !important;
|
|
|
|
| 268 |
}
|
| 269 |
+
[data-testid="stSidebar"] [data-testid="stExpander"] summary:hover {
|
| 270 |
+
color: #6b7280 !important;
|
|
|
|
| 271 |
}
|
| 272 |
+
[data-testid="stSidebar"] [data-testid="stExpander"] summary svg {
|
| 273 |
+
width: 12px !important;
|
| 274 |
+
height: 12px !important;
|
| 275 |
+
color: #9ca3af !important;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 276 |
}
|
| 277 |
|
| 278 |
/* ── Content max-width ──────────────────────────────────────────────── */
|
|
|
|
| 299 |
margin: 0 0 16px;
|
| 300 |
}
|
| 301 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 302 |
/* ── Chat messages ──────────────────────────────────────────────────── */
|
| 303 |
[data-testid="stChatMessage"] {
|
| 304 |
border-radius: 10px !important;
|
data/briefs.db
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:d2f72ea4a7e922efc21fe28ebe5593b309556f0b8f4e13c963b89e30e26361b4
|
| 3 |
+
size 40960
|
data/chroma/4c22a7aa-c603-468d-a7f1-3fc65686a95b/data_level0.bin
CHANGED
|
@@ -1,3 +1,3 @@
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:
|
| 3 |
-
size
|
|
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:012c080a8fb228941fe0270a7be5cbe2da000117f0eb7d33fb2c7d244b83440e
|
| 3 |
+
size 3077136
|
data/chroma/4c22a7aa-c603-468d-a7f1-3fc65686a95b/header.bin
CHANGED
|
@@ -1,3 +1,3 @@
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:
|
| 3 |
size 100
|
|
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:5e126c81a5b2507846618dbb7f2a54de7990c92876d7530e03447340dae98318
|
| 3 |
size 100
|
data/chroma/4c22a7aa-c603-468d-a7f1-3fc65686a95b/index_metadata.pickle
CHANGED
|
@@ -1,3 +1,3 @@
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:
|
| 3 |
-
size
|
|
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:4a479a22fbb92c5bf69b34a0397bb9b0ee095a89b030b4858430197b9c67569a
|
| 3 |
+
size 43614
|
data/chroma/4c22a7aa-c603-468d-a7f1-3fc65686a95b/length.bin
CHANGED
|
@@ -1,3 +1,3 @@
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:
|
| 3 |
-
size
|
|
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:c6ce6cc8c8584ce0e899a29a897c8f8b7e9dc2f62e80867dd600eef5a67d6ed7
|
| 3 |
+
size 7344
|
data/chroma/4c22a7aa-c603-468d-a7f1-3fc65686a95b/link_lists.bin
CHANGED
|
@@ -1,3 +1,3 @@
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:
|
| 3 |
-
size
|
|
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:b11887967c64e998bcbc8c0055f5a00928a7b7bd54943bf816124590d61612d2
|
| 3 |
+
size 14960
|
data/chroma/chroma.sqlite3
CHANGED
|
@@ -1,3 +1,3 @@
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:
|
| 3 |
-
size
|
|
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:bcada73106e922e9de7caa502afd4436c35f035ac43767ce13dd0a9d4e32eff6
|
| 3 |
+
size 40972288
|
data/cost_log.jsonl
CHANGED
|
@@ -30,3 +30,4 @@
|
|
| 30 |
{"ts": "2026-05-07T14:39:53.156126+00:00", "ticker": "AAPL", "model": "claude-haiku-4-5-20251001", "input_tokens": 56141, "output_tokens": 4555, "cost_usd": 0.063133}
|
| 31 |
{"ts": "2026-05-07T16:29:43.963815+00:00", "ticker": "NVDA", "model": "claude-haiku-4-5-20251001", "input_tokens": 56242, "output_tokens": 6149, "cost_usd": 0.06959}
|
| 32 |
{"ts": "2026-05-07T16:56:30.795971+00:00", "ticker": "NVDA", "model": "claude-haiku-4-5-20251001", "input_tokens": 49009, "output_tokens": 5488, "cost_usd": 0.061159}
|
|
|
|
|
|
| 30 |
{"ts": "2026-05-07T14:39:53.156126+00:00", "ticker": "AAPL", "model": "claude-haiku-4-5-20251001", "input_tokens": 56141, "output_tokens": 4555, "cost_usd": 0.063133}
|
| 31 |
{"ts": "2026-05-07T16:29:43.963815+00:00", "ticker": "NVDA", "model": "claude-haiku-4-5-20251001", "input_tokens": 56242, "output_tokens": 6149, "cost_usd": 0.06959}
|
| 32 |
{"ts": "2026-05-07T16:56:30.795971+00:00", "ticker": "NVDA", "model": "claude-haiku-4-5-20251001", "input_tokens": 49009, "output_tokens": 5488, "cost_usd": 0.061159}
|
| 33 |
+
{"ts": "2026-06-08T21:02:24.902537+00:00", "ticker": "NVDA", "model": "claude-haiku-4-5-20251001", "input_tokens": 46802, "output_tokens": 6475, "cost_usd": 0.063342}
|
data/metrics.db
CHANGED
|
@@ -1,3 +1,3 @@
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:
|
| 3 |
-
size
|
|
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:dec20ad1beee014da09b1bfe1c9cbaf08fbc803d26419fdc5977766545b7744f
|
| 3 |
+
size 81920
|
data/sections.db
CHANGED
|
@@ -1,3 +1,3 @@
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:
|
| 3 |
-
size
|
|
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:5a8c3798b6dc2b96f9c482e90b91942ae8d875d509822ace5978b2942a027da3
|
| 3 |
+
size 2322432
|
docs/superpowers/plans/2026-06-04-exposure-radar.md
ADDED
|
@@ -0,0 +1,1118 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Exposure Radar Implementation Plan
|
| 2 |
+
|
| 3 |
+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
| 4 |
+
|
| 5 |
+
**Goal:** Add an Exposure Radar screen that scans a user-defined portfolio of tickers and surfaces concentrated risk exposures across the book (e.g. "4/7 positions cite Geopolitical risk this quarter"), with drill-down to sourced facts — zero LLM calls at render time.
|
| 6 |
+
|
| 7 |
+
**Architecture:** Generated briefs (currently ephemeral in session_state) are persisted to a new SQLite table `briefs.db` after each run. A deterministic aggregation module groups `risks_categorized` facts by their 7 canonical categories across all tickers with persisted briefs. A new `dashboard/portfolio.py` renders the radar — coverage panel, cluster cards, fact drill-down — reusing existing badge/theme helpers. The user defines their portfolio via a sidebar text area; no implicit generation ever happens.
|
| 8 |
+
|
| 9 |
+
**Tech Stack:** Python 3.11+, SQLite (`sqlite3`), Streamlit, dataclasses, existing `dashboard/components.py` badge helpers, existing `dashboard/theme.py` tokens.
|
| 10 |
+
|
| 11 |
+
---
|
| 12 |
+
|
| 13 |
+
## File Map
|
| 14 |
+
|
| 15 |
+
| Action | File | Responsibility |
|
| 16 |
+
|--------|------|----------------|
|
| 17 |
+
| Create | `storage/briefs_db.py` | Persist and retrieve brief dicts by ticker |
|
| 18 |
+
| Create | `analytics/portfolio_exposure.py` | Aggregate risk facts cross-ticker (pure Python, no LLM) |
|
| 19 |
+
| Create | `dashboard/portfolio.py` | Render the Exposure Radar screen |
|
| 20 |
+
| Create | `tests/test_briefs_db.py` | Unit tests for briefs persistence |
|
| 21 |
+
| Create | `tests/test_portfolio_exposure.py` | Unit tests for aggregation logic |
|
| 22 |
+
| Modify | `agent/cost_log.py` | Add `average_brief_cost_usd()` helper |
|
| 23 |
+
| Modify | `app.py` | (1) save brief hook after generation, (2) portfolio sidebar input, (3) routing |
|
| 24 |
+
| Modify | `dashboard/theme.py` | Add Portfolio to `NAV_SECTIONS` |
|
| 25 |
+
|
| 26 |
+
---
|
| 27 |
+
|
| 28 |
+
## Task 1: Brief Persistence — `storage/briefs_db.py`
|
| 29 |
+
|
| 30 |
+
**Files:**
|
| 31 |
+
- Create: `storage/briefs_db.py`
|
| 32 |
+
- Test: `tests/test_briefs_db.py`
|
| 33 |
+
|
| 34 |
+
- [ ] **Step 1.1: Write the failing tests**
|
| 35 |
+
|
| 36 |
+
```python
|
| 37 |
+
# tests/test_briefs_db.py
|
| 38 |
+
import pytest
|
| 39 |
+
|
| 40 |
+
# --------------------------------------------------------------------------
|
| 41 |
+
# Fixture: redirect DB_PATH to a temp directory so tests don't touch data/
|
| 42 |
+
# --------------------------------------------------------------------------
|
| 43 |
+
@pytest.fixture(autouse=True)
|
| 44 |
+
def _patch_db(tmp_path, monkeypatch):
|
| 45 |
+
import storage.briefs_db as m
|
| 46 |
+
monkeypatch.setattr(m, "DB_PATH", tmp_path / "briefs.db")
|
| 47 |
+
m.init_db()
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def test_save_and_get_round_trip():
|
| 51 |
+
from storage.briefs_db import save_brief, get_brief
|
| 52 |
+
brief = {"ticker": "AAPL", "company_name": "Apple Inc.", "what_changed": ["Revenue up 5%"]}
|
| 53 |
+
save_brief("AAPL", brief)
|
| 54 |
+
result = get_brief("AAPL")
|
| 55 |
+
assert result == brief
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def test_get_brief_returns_none_for_unknown_ticker():
|
| 59 |
+
from storage.briefs_db import get_brief
|
| 60 |
+
assert get_brief("NVDA") is None
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def test_save_brief_overwrites_on_same_ticker():
|
| 64 |
+
from storage.briefs_db import save_brief, get_brief
|
| 65 |
+
save_brief("AAPL", {"v": 1})
|
| 66 |
+
save_brief("AAPL", {"v": 2})
|
| 67 |
+
assert get_brief("AAPL") == {"v": 2}
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def test_save_brief_normalizes_ticker_to_uppercase():
|
| 71 |
+
from storage.briefs_db import save_brief, get_brief
|
| 72 |
+
save_brief("aapl", {"company_name": "Apple"})
|
| 73 |
+
assert get_brief("AAPL") == {"company_name": "Apple"}
|
| 74 |
+
assert get_brief("aapl") == {"company_name": "Apple"}
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def test_list_tickers_returns_all_saved():
|
| 78 |
+
from storage.briefs_db import save_brief, list_tickers
|
| 79 |
+
save_brief("AAPL", {})
|
| 80 |
+
save_brief("MSFT", {})
|
| 81 |
+
save_brief("NVDA", {})
|
| 82 |
+
assert set(list_tickers()) == {"AAPL", "MSFT", "NVDA"}
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def test_list_tickers_empty_db():
|
| 86 |
+
from storage.briefs_db import list_tickers
|
| 87 |
+
assert list_tickers() == []
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def test_init_db_is_idempotent():
|
| 91 |
+
from storage.briefs_db import init_db, save_brief, get_brief
|
| 92 |
+
init_db() # second call should not crash or wipe data
|
| 93 |
+
init_db()
|
| 94 |
+
save_brief("AAPL", {"x": 1})
|
| 95 |
+
assert get_brief("AAPL") == {"x": 1}
|
| 96 |
+
```
|
| 97 |
+
|
| 98 |
+
- [ ] **Step 1.2: Run tests to confirm they fail**
|
| 99 |
+
|
| 100 |
+
```
|
| 101 |
+
pytest tests/test_briefs_db.py -v
|
| 102 |
+
```
|
| 103 |
+
|
| 104 |
+
Expected: `ModuleNotFoundError: No module named 'storage.briefs_db'`
|
| 105 |
+
|
| 106 |
+
- [ ] **Step 1.3: Implement `storage/briefs_db.py`**
|
| 107 |
+
|
| 108 |
+
```python
|
| 109 |
+
"""storage/briefs_db.py — persist the last generated brief per ticker.
|
| 110 |
+
|
| 111 |
+
Stores the full BriefOutput dict as JSON, keyed by ticker.
|
| 112 |
+
One row per ticker: re-generating overwrites the previous brief.
|
| 113 |
+
Used by analytics/portfolio_exposure.py to power the Exposure Radar
|
| 114 |
+
without re-running the LLM agent.
|
| 115 |
+
"""
|
| 116 |
+
from __future__ import annotations
|
| 117 |
+
|
| 118 |
+
import json
|
| 119 |
+
import sqlite3
|
| 120 |
+
from pathlib import Path
|
| 121 |
+
from typing import Optional
|
| 122 |
+
|
| 123 |
+
DB_PATH = Path("data/briefs.db")
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def init_db() -> None:
|
| 127 |
+
"""Create the briefs table if it doesn't exist. Safe to call multiple times."""
|
| 128 |
+
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
| 129 |
+
with sqlite3.connect(DB_PATH) as conn:
|
| 130 |
+
conn.execute("""
|
| 131 |
+
CREATE TABLE IF NOT EXISTS briefs (
|
| 132 |
+
ticker TEXT PRIMARY KEY,
|
| 133 |
+
filing_date TEXT,
|
| 134 |
+
brief_json TEXT NOT NULL,
|
| 135 |
+
saved_at TEXT NOT NULL
|
| 136 |
+
)
|
| 137 |
+
""")
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def save_brief(ticker: str, brief_dict: dict) -> None:
|
| 141 |
+
"""Persist brief_dict for ticker, overwriting any previous brief for that ticker."""
|
| 142 |
+
from datetime import datetime, timezone
|
| 143 |
+
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
| 144 |
+
init_db()
|
| 145 |
+
with sqlite3.connect(DB_PATH) as conn:
|
| 146 |
+
conn.execute(
|
| 147 |
+
"""
|
| 148 |
+
INSERT INTO briefs (ticker, filing_date, brief_json, saved_at)
|
| 149 |
+
VALUES (?, ?, ?, ?)
|
| 150 |
+
ON CONFLICT(ticker) DO UPDATE SET
|
| 151 |
+
filing_date = excluded.filing_date,
|
| 152 |
+
brief_json = excluded.brief_json,
|
| 153 |
+
saved_at = excluded.saved_at
|
| 154 |
+
""",
|
| 155 |
+
(
|
| 156 |
+
ticker.upper(),
|
| 157 |
+
brief_dict.get("filing_date", ""),
|
| 158 |
+
json.dumps(brief_dict),
|
| 159 |
+
datetime.now(timezone.utc).isoformat(),
|
| 160 |
+
),
|
| 161 |
+
)
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def get_brief(ticker: str) -> Optional[dict]:
|
| 165 |
+
"""Return the persisted brief dict for ticker, or None if not found."""
|
| 166 |
+
if not DB_PATH.exists():
|
| 167 |
+
return None
|
| 168 |
+
with sqlite3.connect(DB_PATH) as conn:
|
| 169 |
+
row = conn.execute(
|
| 170 |
+
"SELECT brief_json FROM briefs WHERE ticker = ?",
|
| 171 |
+
(ticker.upper(),),
|
| 172 |
+
).fetchone()
|
| 173 |
+
if row is None:
|
| 174 |
+
return None
|
| 175 |
+
return json.loads(row[0])
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
def list_tickers() -> list[str]:
|
| 179 |
+
"""Return all tickers that have a persisted brief, sorted alphabetically."""
|
| 180 |
+
if not DB_PATH.exists():
|
| 181 |
+
return []
|
| 182 |
+
with sqlite3.connect(DB_PATH) as conn:
|
| 183 |
+
rows = conn.execute(
|
| 184 |
+
"SELECT ticker FROM briefs ORDER BY ticker ASC"
|
| 185 |
+
).fetchall()
|
| 186 |
+
return [r[0] for r in rows]
|
| 187 |
+
```
|
| 188 |
+
|
| 189 |
+
- [ ] **Step 1.4: Run tests to confirm they pass**
|
| 190 |
+
|
| 191 |
+
```
|
| 192 |
+
pytest tests/test_briefs_db.py -v
|
| 193 |
+
```
|
| 194 |
+
|
| 195 |
+
Expected: 7 tests PASSED.
|
| 196 |
+
|
| 197 |
+
- [ ] **Step 1.5: Commit**
|
| 198 |
+
|
| 199 |
+
```
|
| 200 |
+
git add storage/briefs_db.py tests/test_briefs_db.py
|
| 201 |
+
git commit -m "feat: add briefs_db — persist last brief per ticker for portfolio Radar"
|
| 202 |
+
```
|
| 203 |
+
|
| 204 |
+
---
|
| 205 |
+
|
| 206 |
+
## Task 2: Aggregation Engine — `analytics/portfolio_exposure.py`
|
| 207 |
+
|
| 208 |
+
**Files:**
|
| 209 |
+
- Create: `analytics/portfolio_exposure.py`
|
| 210 |
+
- Test: `tests/test_portfolio_exposure.py`
|
| 211 |
+
|
| 212 |
+
- [ ] **Step 2.1: Write the failing tests**
|
| 213 |
+
|
| 214 |
+
```python
|
| 215 |
+
# tests/test_portfolio_exposure.py
|
| 216 |
+
import pytest
|
| 217 |
+
from analytics.portfolio_exposure import aggregate_risk_exposure, ExposureCluster
|
| 218 |
+
|
| 219 |
+
# ── Fixture briefs ─────────────────────────────────────────────────────────────
|
| 220 |
+
|
| 221 |
+
_BRIEF_AAPL = {
|
| 222 |
+
"risks_categorized": [
|
| 223 |
+
{
|
| 224 |
+
"category": "Geopolitical",
|
| 225 |
+
"text": "China tariff risk on iPhone supply chain.",
|
| 226 |
+
"source": "10-Q",
|
| 227 |
+
"reliability": "HIGH",
|
| 228 |
+
"impact": "HIGH",
|
| 229 |
+
"is_new_this_filing": True,
|
| 230 |
+
},
|
| 231 |
+
{
|
| 232 |
+
"category": "Competitive",
|
| 233 |
+
"text": "Android gaining share in emerging markets.",
|
| 234 |
+
"source": "10-Q",
|
| 235 |
+
"reliability": "HIGH",
|
| 236 |
+
"impact": "MEDIUM",
|
| 237 |
+
"is_new_this_filing": False,
|
| 238 |
+
},
|
| 239 |
+
]
|
| 240 |
+
}
|
| 241 |
+
|
| 242 |
+
_BRIEF_MSFT = {
|
| 243 |
+
"risks_categorized": [
|
| 244 |
+
{
|
| 245 |
+
"category": "Geopolitical",
|
| 246 |
+
"text": "Export controls restrict cloud expansion in China.",
|
| 247 |
+
"source": "10-Q",
|
| 248 |
+
"reliability": "HIGH",
|
| 249 |
+
"impact": "MEDIUM",
|
| 250 |
+
"is_new_this_filing": False,
|
| 251 |
+
},
|
| 252 |
+
{
|
| 253 |
+
"category": "Regulatory",
|
| 254 |
+
"text": "EU antitrust review of Teams bundling.",
|
| 255 |
+
"source": "10-Q",
|
| 256 |
+
"reliability": "HIGH",
|
| 257 |
+
"impact": "HIGH",
|
| 258 |
+
"is_new_this_filing": True,
|
| 259 |
+
},
|
| 260 |
+
]
|
| 261 |
+
}
|
| 262 |
+
|
| 263 |
+
_BRIEF_NVDA = {
|
| 264 |
+
"risks_categorized": [
|
| 265 |
+
{
|
| 266 |
+
"category": "Geopolitical",
|
| 267 |
+
"text": "US export restrictions on H100 to China.",
|
| 268 |
+
"source": "10-Q",
|
| 269 |
+
"reliability": "HIGH",
|
| 270 |
+
"impact": "HIGH",
|
| 271 |
+
"is_new_this_filing": True,
|
| 272 |
+
},
|
| 273 |
+
]
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
|
| 277 |
+
# ── Tests ──────────────────────────────────────────────────────────────────────
|
| 278 |
+
|
| 279 |
+
def test_aggregate_returns_list_of_exposure_clusters():
|
| 280 |
+
clusters = aggregate_risk_exposure({"AAPL": _BRIEF_AAPL})
|
| 281 |
+
assert isinstance(clusters, list)
|
| 282 |
+
assert all(isinstance(c, ExposureCluster) for c in clusters)
|
| 283 |
+
|
| 284 |
+
|
| 285 |
+
def test_aggregate_counts_holdings_per_category():
|
| 286 |
+
clusters = aggregate_risk_exposure({"AAPL": _BRIEF_AAPL, "MSFT": _BRIEF_MSFT})
|
| 287 |
+
geo = next(c for c in clusters if c.category == "Geopolitical")
|
| 288 |
+
assert geo.count == 2
|
| 289 |
+
assert set(geo.holdings) == {"AAPL", "MSFT"}
|
| 290 |
+
|
| 291 |
+
|
| 292 |
+
def test_aggregate_count_three_tickers():
|
| 293 |
+
briefs = {"AAPL": _BRIEF_AAPL, "MSFT": _BRIEF_MSFT, "NVDA": _BRIEF_NVDA}
|
| 294 |
+
clusters = aggregate_risk_exposure(briefs)
|
| 295 |
+
geo = next(c for c in clusters if c.category == "Geopolitical")
|
| 296 |
+
assert geo.count == 3
|
| 297 |
+
assert set(geo.holdings) == {"AAPL", "MSFT", "NVDA"}
|
| 298 |
+
|
| 299 |
+
|
| 300 |
+
def test_aggregate_sorted_by_count_descending():
|
| 301 |
+
briefs = {"AAPL": _BRIEF_AAPL, "MSFT": _BRIEF_MSFT}
|
| 302 |
+
clusters = aggregate_risk_exposure(briefs)
|
| 303 |
+
counts = [c.count for c in clusters]
|
| 304 |
+
assert counts == sorted(counts, reverse=True)
|
| 305 |
+
|
| 306 |
+
|
| 307 |
+
def test_aggregate_severity_picks_worst_impact():
|
| 308 |
+
# AAPL Geo = HIGH, MSFT Geo = MEDIUM → cluster severity = HIGH
|
| 309 |
+
clusters = aggregate_risk_exposure({"AAPL": _BRIEF_AAPL, "MSFT": _BRIEF_MSFT})
|
| 310 |
+
geo = next(c for c in clusters if c.category == "Geopolitical")
|
| 311 |
+
assert geo.severity == "HIGH"
|
| 312 |
+
|
| 313 |
+
|
| 314 |
+
def test_aggregate_severity_all_medium():
|
| 315 |
+
brief = {
|
| 316 |
+
"risks_categorized": [
|
| 317 |
+
{"category": "Macro", "text": "Inflation risk.", "source": "10-Q",
|
| 318 |
+
"reliability": "HIGH", "impact": "MEDIUM", "is_new_this_filing": False}
|
| 319 |
+
]
|
| 320 |
+
}
|
| 321 |
+
clusters = aggregate_risk_exposure({"AAPL": brief})
|
| 322 |
+
macro = next(c for c in clusters if c.category == "Macro")
|
| 323 |
+
assert macro.severity == "MEDIUM"
|
| 324 |
+
|
| 325 |
+
|
| 326 |
+
def test_aggregate_new_count():
|
| 327 |
+
clusters = aggregate_risk_exposure({"AAPL": _BRIEF_AAPL, "MSFT": _BRIEF_MSFT})
|
| 328 |
+
geo = next(c for c in clusters if c.category == "Geopolitical")
|
| 329 |
+
# AAPL geo is_new=True, MSFT geo is_new=False → new_count = 1
|
| 330 |
+
assert geo.new_count == 1
|
| 331 |
+
|
| 332 |
+
|
| 333 |
+
def test_aggregate_facts_contain_ticker_and_text():
|
| 334 |
+
clusters = aggregate_risk_exposure({"AAPL": _BRIEF_AAPL})
|
| 335 |
+
geo = next(c for c in clusters if c.category == "Geopolitical")
|
| 336 |
+
assert any(f["ticker"] == "AAPL" for f in geo.facts)
|
| 337 |
+
assert any("tariff" in f["text"].lower() for f in geo.facts)
|
| 338 |
+
|
| 339 |
+
|
| 340 |
+
def test_aggregate_empty_briefs_returns_empty_list():
|
| 341 |
+
assert aggregate_risk_exposure({}) == []
|
| 342 |
+
|
| 343 |
+
|
| 344 |
+
def test_aggregate_skips_empty_categories():
|
| 345 |
+
# Only Geopolitical and Competitive have risks — Macro etc. should be absent
|
| 346 |
+
clusters = aggregate_risk_exposure({"AAPL": _BRIEF_AAPL})
|
| 347 |
+
categories = {c.category for c in clusters}
|
| 348 |
+
assert "Macro" not in categories
|
| 349 |
+
assert "Geopolitical" in categories
|
| 350 |
+
assert "Competitive" in categories
|
| 351 |
+
|
| 352 |
+
|
| 353 |
+
def test_aggregate_brief_with_no_risks():
|
| 354 |
+
clusters = aggregate_risk_exposure({"AAPL": {"risks_categorized": []}})
|
| 355 |
+
assert clusters == []
|
| 356 |
+
|
| 357 |
+
|
| 358 |
+
def test_aggregate_ignores_unknown_category():
|
| 359 |
+
brief = {
|
| 360 |
+
"risks_categorized": [
|
| 361 |
+
{"category": "UnknownCategory", "text": "Something.", "source": "10-Q",
|
| 362 |
+
"reliability": "HIGH", "impact": "HIGH", "is_new_this_filing": False}
|
| 363 |
+
]
|
| 364 |
+
}
|
| 365 |
+
# Should not crash and should produce no clusters for the unknown category
|
| 366 |
+
clusters = aggregate_risk_exposure({"AAPL": brief})
|
| 367 |
+
assert not any(c.category == "UnknownCategory" for c in clusters)
|
| 368 |
+
|
| 369 |
+
|
| 370 |
+
def test_load_portfolio_splits_present_and_missing(monkeypatch):
|
| 371 |
+
from analytics.portfolio_exposure import load_portfolio
|
| 372 |
+
monkeypatch.setattr(
|
| 373 |
+
"analytics.portfolio_exposure.get_brief",
|
| 374 |
+
lambda t: _BRIEF_AAPL if t == "AAPL" else None,
|
| 375 |
+
)
|
| 376 |
+
briefs, missing = load_portfolio(["AAPL", "NVDA", "MSFT"])
|
| 377 |
+
assert set(briefs.keys()) == {"AAPL"}
|
| 378 |
+
assert set(missing) == {"NVDA", "MSFT"}
|
| 379 |
+
|
| 380 |
+
|
| 381 |
+
def test_load_portfolio_empty_list():
|
| 382 |
+
from analytics.portfolio_exposure import load_portfolio
|
| 383 |
+
briefs, missing = load_portfolio([])
|
| 384 |
+
assert briefs == {}
|
| 385 |
+
assert missing == []
|
| 386 |
+
```
|
| 387 |
+
|
| 388 |
+
- [ ] **Step 2.2: Run tests to confirm they fail**
|
| 389 |
+
|
| 390 |
+
```
|
| 391 |
+
pytest tests/test_portfolio_exposure.py -v
|
| 392 |
+
```
|
| 393 |
+
|
| 394 |
+
Expected: `ModuleNotFoundError: No module named 'analytics.portfolio_exposure'`
|
| 395 |
+
|
| 396 |
+
- [ ] **Step 2.3: Implement `analytics/portfolio_exposure.py`**
|
| 397 |
+
|
| 398 |
+
```python
|
| 399 |
+
"""analytics/portfolio_exposure.py — deterministic portfolio risk aggregation.
|
| 400 |
+
|
| 401 |
+
Loads persisted briefs for a list of tickers and groups risk facts by their
|
| 402 |
+
canonical category, surfacing concentrated cross-ticker exposures.
|
| 403 |
+
|
| 404 |
+
No LLM calls. No Streamlit imports. Pure Python + storage layer only.
|
| 405 |
+
"""
|
| 406 |
+
from __future__ import annotations
|
| 407 |
+
|
| 408 |
+
from dataclasses import dataclass, field
|
| 409 |
+
from typing import Optional
|
| 410 |
+
|
| 411 |
+
# The 7 canonical risk categories enforced by agent/schemas.py:CategorizedRisk.
|
| 412 |
+
_CANONICAL_CATEGORIES = [
|
| 413 |
+
"Regulatory",
|
| 414 |
+
"Operational",
|
| 415 |
+
"Competitive",
|
| 416 |
+
"Financial",
|
| 417 |
+
"Macro",
|
| 418 |
+
"Demand",
|
| 419 |
+
"Geopolitical",
|
| 420 |
+
]
|
| 421 |
+
|
| 422 |
+
# Severity ordering: lower number = worse/higher severity
|
| 423 |
+
_SEVERITY_ORDER: dict[str, int] = {"HIGH": 0, "MEDIUM": 1, "LOW": 2}
|
| 424 |
+
|
| 425 |
+
|
| 426 |
+
@dataclass
|
| 427 |
+
class ExposureCluster:
|
| 428 |
+
"""Aggregated risk exposure for one canonical category across the portfolio."""
|
| 429 |
+
category: str # one of _CANONICAL_CATEGORIES
|
| 430 |
+
holdings: list[str] # tickers with ≥1 risk in this category, sorted A-Z
|
| 431 |
+
count: int # len(holdings) — number of positions exposed
|
| 432 |
+
severity: str # worst impact across all facts: "HIGH", "MEDIUM", or "LOW"
|
| 433 |
+
new_count: int # number of facts with is_new_this_filing=True
|
| 434 |
+
facts: list[dict] = field(default_factory=list)
|
| 435 |
+
# Each fact: {ticker, text, source, reliability, impact, is_new}
|
| 436 |
+
|
| 437 |
+
|
| 438 |
+
def load_portfolio(
|
| 439 |
+
tickers: list[str],
|
| 440 |
+
) -> tuple[dict[str, dict], list[str]]:
|
| 441 |
+
"""Load persisted briefs for each ticker.
|
| 442 |
+
|
| 443 |
+
Returns:
|
| 444 |
+
briefs_by_ticker: {ticker: brief_dict} for tickers with a persisted brief.
|
| 445 |
+
missing_tickers: tickers that have no persisted brief yet.
|
| 446 |
+
"""
|
| 447 |
+
from storage.briefs_db import get_brief
|
| 448 |
+
|
| 449 |
+
briefs: dict[str, dict] = {}
|
| 450 |
+
missing: list[str] = []
|
| 451 |
+
for ticker in tickers:
|
| 452 |
+
brief = get_brief(ticker)
|
| 453 |
+
if brief is not None:
|
| 454 |
+
briefs[ticker] = brief
|
| 455 |
+
else:
|
| 456 |
+
missing.append(ticker)
|
| 457 |
+
return briefs, missing
|
| 458 |
+
|
| 459 |
+
|
| 460 |
+
def aggregate_risk_exposure(
|
| 461 |
+
briefs: dict[str, dict],
|
| 462 |
+
) -> list[ExposureCluster]:
|
| 463 |
+
"""Aggregate risks_categorized across all briefs by canonical category.
|
| 464 |
+
|
| 465 |
+
Returns a list of ExposureCluster, sorted by:
|
| 466 |
+
1. count DESC (most-exposed first)
|
| 467 |
+
2. severity ASC (HIGH before MEDIUM before LOW, for ties)
|
| 468 |
+
|
| 469 |
+
Categories with zero matching risks are omitted.
|
| 470 |
+
"""
|
| 471 |
+
if not briefs:
|
| 472 |
+
return []
|
| 473 |
+
|
| 474 |
+
# Collect all facts per canonical category
|
| 475 |
+
by_category: dict[str, list[dict]] = {cat: [] for cat in _CANONICAL_CATEGORIES}
|
| 476 |
+
|
| 477 |
+
for ticker, brief in briefs.items():
|
| 478 |
+
for risk in brief.get("risks_categorized", []):
|
| 479 |
+
cat = risk.get("category", "")
|
| 480 |
+
if cat not in by_category:
|
| 481 |
+
continue # unknown/unmapped category — skip silently
|
| 482 |
+
by_category[cat].append({
|
| 483 |
+
"ticker": ticker,
|
| 484 |
+
"text": risk.get("text", ""),
|
| 485 |
+
"source": risk.get("source", ""),
|
| 486 |
+
"reliability": risk.get("reliability", ""),
|
| 487 |
+
"impact": risk.get("impact") or "",
|
| 488 |
+
"is_new": bool(risk.get("is_new_this_filing")),
|
| 489 |
+
})
|
| 490 |
+
|
| 491 |
+
clusters: list[ExposureCluster] = []
|
| 492 |
+
for cat in _CANONICAL_CATEGORIES:
|
| 493 |
+
facts = by_category[cat]
|
| 494 |
+
if not facts:
|
| 495 |
+
continue
|
| 496 |
+
|
| 497 |
+
holdings = sorted({f["ticker"] for f in facts})
|
| 498 |
+
|
| 499 |
+
# Worst (highest) severity across all facts in this cluster
|
| 500 |
+
impact_keys = [
|
| 501 |
+
_SEVERITY_ORDER[f["impact"]]
|
| 502 |
+
for f in facts
|
| 503 |
+
if f["impact"] in _SEVERITY_ORDER
|
| 504 |
+
]
|
| 505 |
+
if impact_keys:
|
| 506 |
+
best_key = min(impact_keys)
|
| 507 |
+
severity = {0: "HIGH", 1: "MEDIUM", 2: "LOW"}[best_key]
|
| 508 |
+
else:
|
| 509 |
+
severity = "LOW"
|
| 510 |
+
|
| 511 |
+
new_count = sum(1 for f in facts if f["is_new"])
|
| 512 |
+
|
| 513 |
+
clusters.append(ExposureCluster(
|
| 514 |
+
category=cat,
|
| 515 |
+
holdings=holdings,
|
| 516 |
+
count=len(holdings),
|
| 517 |
+
severity=severity,
|
| 518 |
+
new_count=new_count,
|
| 519 |
+
facts=facts,
|
| 520 |
+
))
|
| 521 |
+
|
| 522 |
+
# Sort: count DESC, then severity ASC (HIGH=0 first on ties)
|
| 523 |
+
clusters.sort(
|
| 524 |
+
key=lambda c: (-c.count, _SEVERITY_ORDER.get(c.severity, 2))
|
| 525 |
+
)
|
| 526 |
+
return clusters
|
| 527 |
+
```
|
| 528 |
+
|
| 529 |
+
- [ ] **Step 2.4: Run tests to confirm they pass**
|
| 530 |
+
|
| 531 |
+
```
|
| 532 |
+
pytest tests/test_portfolio_exposure.py -v
|
| 533 |
+
```
|
| 534 |
+
|
| 535 |
+
Expected: 13 tests PASSED.
|
| 536 |
+
|
| 537 |
+
- [ ] **Step 2.5: Commit**
|
| 538 |
+
|
| 539 |
+
```
|
| 540 |
+
git add analytics/portfolio_exposure.py tests/test_portfolio_exposure.py
|
| 541 |
+
git commit -m "feat: add portfolio_exposure — deterministic risk aggregation for Exposure Radar"
|
| 542 |
+
```
|
| 543 |
+
|
| 544 |
+
---
|
| 545 |
+
|
| 546 |
+
## Task 3: Cost Estimate Helper — `agent/cost_log.py`
|
| 547 |
+
|
| 548 |
+
**Files:**
|
| 549 |
+
- Modify: `agent/cost_log.py`
|
| 550 |
+
|
| 551 |
+
No new test file — this is a pure read from disk, exercised by the portfolio render.
|
| 552 |
+
|
| 553 |
+
- [ ] **Step 3.1: Add `average_brief_cost_usd()` to `agent/cost_log.py`**
|
| 554 |
+
|
| 555 |
+
Open `agent/cost_log.py` and add the following function **after** the existing `compute_run_cost` function (after line 52):
|
| 556 |
+
|
| 557 |
+
```python
|
| 558 |
+
def average_brief_cost_usd() -> Optional[float]:
|
| 559 |
+
"""Return average cost_usd per brief run from cost_log.jsonl, or None if log is empty.
|
| 560 |
+
|
| 561 |
+
Used by the Portfolio screen to show an estimated cost before bulk generation.
|
| 562 |
+
"""
|
| 563 |
+
if not COST_LOG_PATH.exists():
|
| 564 |
+
return None
|
| 565 |
+
costs: list[float] = []
|
| 566 |
+
try:
|
| 567 |
+
with COST_LOG_PATH.open("r", encoding="utf-8") as f:
|
| 568 |
+
for line in f:
|
| 569 |
+
line = line.strip()
|
| 570 |
+
if not line:
|
| 571 |
+
continue
|
| 572 |
+
record = json.loads(line)
|
| 573 |
+
c = record.get("cost_usd")
|
| 574 |
+
if c is not None:
|
| 575 |
+
costs.append(float(c))
|
| 576 |
+
except Exception:
|
| 577 |
+
return None
|
| 578 |
+
return sum(costs) / len(costs) if costs else None
|
| 579 |
+
```
|
| 580 |
+
|
| 581 |
+
Also add `Optional` to the import at the top of `agent/cost_log.py` — the file currently doesn't import it. Add:
|
| 582 |
+
|
| 583 |
+
```python
|
| 584 |
+
from typing import Optional
|
| 585 |
+
```
|
| 586 |
+
|
| 587 |
+
at the top of the file, after the existing imports.
|
| 588 |
+
|
| 589 |
+
- [ ] **Step 3.2: Verify the module imports cleanly**
|
| 590 |
+
|
| 591 |
+
```
|
| 592 |
+
python -c "from agent.cost_log import average_brief_cost_usd; print('ok')"
|
| 593 |
+
```
|
| 594 |
+
|
| 595 |
+
Expected: `ok`
|
| 596 |
+
|
| 597 |
+
- [ ] **Step 3.3: Commit**
|
| 598 |
+
|
| 599 |
+
```
|
| 600 |
+
git add agent/cost_log.py
|
| 601 |
+
git commit -m "feat: add average_brief_cost_usd helper to cost_log for portfolio cost estimation"
|
| 602 |
+
```
|
| 603 |
+
|
| 604 |
+
---
|
| 605 |
+
|
| 606 |
+
## Task 4: Portfolio Radar Screen — `dashboard/portfolio.py`
|
| 607 |
+
|
| 608 |
+
**Files:**
|
| 609 |
+
- Create: `dashboard/portfolio.py`
|
| 610 |
+
|
| 611 |
+
No unit tests — render functions use Streamlit and are tested via manual verification.
|
| 612 |
+
|
| 613 |
+
- [ ] **Step 4.1: Create `dashboard/portfolio.py`**
|
| 614 |
+
|
| 615 |
+
```python
|
| 616 |
+
"""dashboard/portfolio.py — Exposure Radar portfolio view.
|
| 617 |
+
|
| 618 |
+
Renders a cross-ticker risk concentration screen.
|
| 619 |
+
No LLM calls are made here — all data comes from persisted briefs.
|
| 620 |
+
"""
|
| 621 |
+
from __future__ import annotations
|
| 622 |
+
|
| 623 |
+
import streamlit as st
|
| 624 |
+
from dashboard.theme import (
|
| 625 |
+
BG, BG_MUTED, BORDER, TEXT, TEXT_MUTED, TEXT_FAINT,
|
| 626 |
+
GREEN, RED, AMBER, GRAY, BLUE,
|
| 627 |
+
WARN_BG, WARN_BORDER,
|
| 628 |
+
BRAND_GREEN, BRAND_GREEN_DARK,
|
| 629 |
+
FS_EYEBROW, FS_META, FS_BODY, FS_CARD,
|
| 630 |
+
RADIUS_CHIP,
|
| 631 |
+
)
|
| 632 |
+
from dashboard.components import reliability_badge, source_badge, impact_badge
|
| 633 |
+
|
| 634 |
+
# ── Category display config (mirrors dashboard/risks.py) ─────────────────────
|
| 635 |
+
|
| 636 |
+
_CATEGORY_COLORS: dict[str, str] = {
|
| 637 |
+
"Regulatory": "#0891b2",
|
| 638 |
+
"Operational": AMBER,
|
| 639 |
+
"Competitive": BLUE,
|
| 640 |
+
"Financial": RED,
|
| 641 |
+
"Macro": GRAY,
|
| 642 |
+
"Demand": "#0891b2",
|
| 643 |
+
"Geopolitical": "#f97316",
|
| 644 |
+
}
|
| 645 |
+
|
| 646 |
+
_CATEGORY_ICONS: dict[str, str] = {
|
| 647 |
+
"Regulatory": "⚖️",
|
| 648 |
+
"Operational": "⚙️",
|
| 649 |
+
"Competitive": "🏁",
|
| 650 |
+
"Financial": "💸",
|
| 651 |
+
"Macro": "🌐",
|
| 652 |
+
"Demand": "🛍️",
|
| 653 |
+
"Geopolitical": "🏛️",
|
| 654 |
+
}
|
| 655 |
+
|
| 656 |
+
_SEVERITY_BG: dict[str, str] = {
|
| 657 |
+
"HIGH": "#fef2f2",
|
| 658 |
+
"MEDIUM": "#fffbeb",
|
| 659 |
+
"LOW": "#f0fdf4",
|
| 660 |
+
}
|
| 661 |
+
_SEVERITY_BORDER: dict[str, str] = {
|
| 662 |
+
"HIGH": "#fecaca",
|
| 663 |
+
"MEDIUM": "#fde68a",
|
| 664 |
+
"LOW": "#bbf7d0",
|
| 665 |
+
}
|
| 666 |
+
_SEVERITY_COLOR: dict[str, str] = {
|
| 667 |
+
"HIGH": RED,
|
| 668 |
+
"MEDIUM": AMBER,
|
| 669 |
+
"LOW": GREEN,
|
| 670 |
+
}
|
| 671 |
+
|
| 672 |
+
|
| 673 |
+
# ── Internal helpers ──────────────────────────────────────────────────────────
|
| 674 |
+
|
| 675 |
+
def _ticker_chip(ticker: str, color: str) -> str:
|
| 676 |
+
"""Render a small ticker pill in the cluster's category colour."""
|
| 677 |
+
return (
|
| 678 |
+
f'<span style="background:{color}18;color:{color};border:1px solid {color}44;'
|
| 679 |
+
f'border-radius:{RADIUS_CHIP};padding:1px 9px;font-size:{FS_META};font-weight:700;">'
|
| 680 |
+
f'{ticker}</span>'
|
| 681 |
+
)
|
| 682 |
+
|
| 683 |
+
|
| 684 |
+
def _severity_chip(severity: str) -> str:
|
| 685 |
+
color = _SEVERITY_COLOR.get(severity, GRAY)
|
| 686 |
+
bg = _SEVERITY_BG.get(severity, BG_MUTED)
|
| 687 |
+
bdr = _SEVERITY_BORDER.get(severity, BORDER)
|
| 688 |
+
return (
|
| 689 |
+
f'<span style="background:{bg};color:{color};border:1px solid {bdr};'
|
| 690 |
+
f'border-radius:{RADIUS_CHIP};padding:1px 9px;font-size:{FS_META};font-weight:600;">'
|
| 691 |
+
f'{severity}</span>'
|
| 692 |
+
)
|
| 693 |
+
|
| 694 |
+
|
| 695 |
+
def _render_header(covered: int, total: int) -> None:
|
| 696 |
+
st.markdown(
|
| 697 |
+
f'<div style="margin-bottom:4px;">'
|
| 698 |
+
f'<div style="font-size:{FS_EYEBROW};font-weight:700;letter-spacing:0.1em;'
|
| 699 |
+
f'text-transform:uppercase;color:{TEXT_MUTED};margin-bottom:6px;">Portfolio</div>'
|
| 700 |
+
f'<h2 style="margin:0;font-size:1.6rem;font-weight:700;color:{TEXT};">Exposure Radar</h2>'
|
| 701 |
+
f'<p style="margin:4px 0 0;color:{TEXT_MUTED};font-size:{FS_BODY};">'
|
| 702 |
+
f'Cross-ticker risk concentration · {covered} of {total} positions analysed'
|
| 703 |
+
f'</p>'
|
| 704 |
+
f'</div>',
|
| 705 |
+
unsafe_allow_html=True,
|
| 706 |
+
)
|
| 707 |
+
|
| 708 |
+
|
| 709 |
+
def _render_empty_portfolio() -> None:
|
| 710 |
+
st.markdown(
|
| 711 |
+
f'<div style="text-align:center;padding:60px 0;color:{TEXT_MUTED};">'
|
| 712 |
+
f'<div style="font-size:2rem;margin-bottom:12px;">🧭</div>'
|
| 713 |
+
f'<div style="font-size:1.1rem;font-weight:600;margin-bottom:6px;color:{TEXT};">'
|
| 714 |
+
f'No portfolio defined</div>'
|
| 715 |
+
f'<div style="font-size:{FS_BODY};max-width:380px;margin:0 auto;line-height:1.6;">'
|
| 716 |
+
f'Enter tickers in the <strong>Portfolio</strong> field in the sidebar '
|
| 717 |
+
f'(e.g. AAPL, MSFT, NVDA), then generate a brief for each position.'
|
| 718 |
+
f'</div>'
|
| 719 |
+
f'</div>',
|
| 720 |
+
unsafe_allow_html=True,
|
| 721 |
+
)
|
| 722 |
+
|
| 723 |
+
|
| 724 |
+
def _render_coverage_panel(missing: list[str], covered: int, total: int) -> None:
|
| 725 |
+
"""Show which tickers still need a brief generated, with cost estimate."""
|
| 726 |
+
from agent.cost_log import average_brief_cost_usd
|
| 727 |
+
|
| 728 |
+
avg_cost = average_brief_cost_usd()
|
| 729 |
+
est_total = avg_cost * len(missing) if avg_cost is not None else None
|
| 730 |
+
|
| 731 |
+
missing_chips = " ".join(
|
| 732 |
+
f'<span style="background:{BG_MUTED};border:1px solid {BORDER};border-radius:{RADIUS_CHIP};'
|
| 733 |
+
f'padding:2px 10px;font-size:{FS_META};font-weight:600;color:{TEXT};">{t}</span>'
|
| 734 |
+
for t in missing
|
| 735 |
+
)
|
| 736 |
+
cost_line = (
|
| 737 |
+
f'Estimated cost to generate: <strong>~${est_total:.4f}</strong>'
|
| 738 |
+
if est_total is not None
|
| 739 |
+
else 'No prior runs logged — cost estimate unavailable.'
|
| 740 |
+
)
|
| 741 |
+
st.markdown(
|
| 742 |
+
f'<div style="background:{WARN_BG};border:1px solid {WARN_BORDER};border-radius:10px;'
|
| 743 |
+
f'padding:14px 18px;margin-bottom:20px;">'
|
| 744 |
+
f'<div style="font-size:{FS_EYEBROW};font-weight:700;letter-spacing:0.08em;'
|
| 745 |
+
f'text-transform:uppercase;color:{AMBER};margin-bottom:8px;">'
|
| 746 |
+
f'⚠ {len(missing)} position{"s" if len(missing) != 1 else ""} missing a brief'
|
| 747 |
+
f'</div>'
|
| 748 |
+
f'<div style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:8px;">{missing_chips}</div>'
|
| 749 |
+
f'<div style="font-size:{FS_META};color:{TEXT_MUTED};line-height:1.5;">'
|
| 750 |
+
f'{cost_line} · To generate, enter each ticker in the <strong>Ticker</strong> field '
|
| 751 |
+
f'above and click <strong>Generate Brief</strong>.'
|
| 752 |
+
f'</div>'
|
| 753 |
+
f'</div>',
|
| 754 |
+
unsafe_allow_html=True,
|
| 755 |
+
)
|
| 756 |
+
|
| 757 |
+
|
| 758 |
+
def _render_top_exposure_card(cluster, total: int) -> None:
|
| 759 |
+
"""Highlight the single most concentrated exposure at the top of the page."""
|
| 760 |
+
from analytics.portfolio_exposure import ExposureCluster
|
| 761 |
+
cat = cluster.category
|
| 762 |
+
color = _CATEGORY_COLORS.get(cat, GRAY)
|
| 763 |
+
icon = _CATEGORY_ICONS.get(cat, "●")
|
| 764 |
+
chips = " ".join(_ticker_chip(t, color) for t in cluster.holdings)
|
| 765 |
+
new_note = (
|
| 766 |
+
f' · <span style="color:{RED};font-weight:600;">🆕 {cluster.new_count} new this filing</span>'
|
| 767 |
+
if cluster.new_count > 0 else ""
|
| 768 |
+
)
|
| 769 |
+
st.markdown(
|
| 770 |
+
f'<div style="background:linear-gradient(135deg,{color}0d,{color}06);'
|
| 771 |
+
f'border:1.5px solid {color}33;border-left:4px solid {color};border-radius:0 12px 12px 0;'
|
| 772 |
+
f'padding:20px 24px;margin-bottom:20px;">'
|
| 773 |
+
f'<div style="font-size:{FS_EYEBROW};font-weight:700;letter-spacing:0.1em;'
|
| 774 |
+
f'text-transform:uppercase;color:{TEXT_MUTED};margin-bottom:6px;">Top exposure</div>'
|
| 775 |
+
f'<div style="display:flex;align-items:baseline;gap:10px;flex-wrap:wrap;">'
|
| 776 |
+
f'<span style="font-size:1.35rem;font-weight:800;color:{color};">'
|
| 777 |
+
f'{icon} {cat}</span>'
|
| 778 |
+
f'<span style="font-size:1.1rem;font-weight:700;color:{TEXT};">'
|
| 779 |
+
f'{cluster.count}/{total} positions'
|
| 780 |
+
f'</span>'
|
| 781 |
+
f'{_severity_chip(cluster.severity)}'
|
| 782 |
+
f'</div>'
|
| 783 |
+
f'<div style="margin-top:10px;display:flex;flex-wrap:wrap;gap:6px;">{chips}</div>'
|
| 784 |
+
f'<div style="font-size:{FS_META};color:{TEXT_MUTED};margin-top:8px;">'
|
| 785 |
+
f'{len(cluster.facts)} risk factor{"s" if len(cluster.facts) != 1 else ""} sourced{new_note}'
|
| 786 |
+
f'</div>'
|
| 787 |
+
f'</div>',
|
| 788 |
+
unsafe_allow_html=True,
|
| 789 |
+
)
|
| 790 |
+
|
| 791 |
+
|
| 792 |
+
def _render_cluster_row(cluster, total: int) -> None:
|
| 793 |
+
"""Render one ExposureCluster as an expandable card with fact drill-down."""
|
| 794 |
+
cat = cluster.category
|
| 795 |
+
color = _CATEGORY_COLORS.get(cat, GRAY)
|
| 796 |
+
icon = _CATEGORY_ICONS.get(cat, "●")
|
| 797 |
+
chips = " ".join(_ticker_chip(t, color) for t in cluster.holdings)
|
| 798 |
+
new_badge = (
|
| 799 |
+
f'<span style="background:#fef2f2;color:{RED};border:1px solid #fecaca;'
|
| 800 |
+
f'border-radius:{RADIUS_CHIP};padding:1px 8px;font-size:{FS_EYEBROW};font-weight:700;">'
|
| 801 |
+
f'🆕 {cluster.new_count} new</span> '
|
| 802 |
+
if cluster.new_count > 0 else ""
|
| 803 |
+
)
|
| 804 |
+
|
| 805 |
+
# Cluster header (always visible)
|
| 806 |
+
st.markdown(
|
| 807 |
+
f'<div style="background:{BG};border:1px solid {BORDER};border-left:3px solid {color};'
|
| 808 |
+
f'border-radius:0 10px 10px 0;padding:14px 18px;margin-bottom:2px;">'
|
| 809 |
+
f'<div style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:8px;">'
|
| 810 |
+
f'<div style="display:flex;align-items:center;gap:10px;">'
|
| 811 |
+
f'<span style="font-size:{FS_CARD};font-weight:700;color:{color};">{icon} {cat}</span>'
|
| 812 |
+
f'<span style="font-size:{FS_META};color:{TEXT_MUTED};font-weight:600;">'
|
| 813 |
+
f'{cluster.count}/{total} positions</span>'
|
| 814 |
+
f'{new_badge}'
|
| 815 |
+
f'{_severity_chip(cluster.severity)}'
|
| 816 |
+
f'</div>'
|
| 817 |
+
f'<div style="display:flex;flex-wrap:wrap;gap:5px;">{chips}</div>'
|
| 818 |
+
f'</div>'
|
| 819 |
+
f'</div>',
|
| 820 |
+
unsafe_allow_html=True,
|
| 821 |
+
)
|
| 822 |
+
|
| 823 |
+
# Drill-down: sourced facts per position
|
| 824 |
+
with st.expander(f"See {len(cluster.facts)} sourced risk factor{'s' if len(cluster.facts) != 1 else ''}"):
|
| 825 |
+
for fact in cluster.facts:
|
| 826 |
+
ticker = fact.get("ticker", "")
|
| 827 |
+
text = fact.get("text", "")
|
| 828 |
+
new_flag = (
|
| 829 |
+
f'<span style="background:#fef2f2;color:{RED};border:1px solid #fecaca;'
|
| 830 |
+
f'border-radius:4px;padding:1px 7px;font-size:{FS_EYEBROW};font-weight:700;">'
|
| 831 |
+
f'NEW ↑</span> '
|
| 832 |
+
if fact.get("is_new") else ""
|
| 833 |
+
)
|
| 834 |
+
st.markdown(
|
| 835 |
+
f'<div style="background:{BG};border:1px solid {BORDER};border-radius:10px;'
|
| 836 |
+
f'padding:14px 18px;margin-bottom:8px;">'
|
| 837 |
+
f'<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;">'
|
| 838 |
+
f'{new_flag}'
|
| 839 |
+
f'<span style="background:{color}18;color:{color};border:1px solid {color}33;'
|
| 840 |
+
f'border-radius:{RADIUS_CHIP};padding:1px 9px;font-size:{FS_META};font-weight:700;">'
|
| 841 |
+
f'{ticker}</span>'
|
| 842 |
+
f'</div>'
|
| 843 |
+
f'<div style="font-size:{FS_BODY};line-height:1.5;color:{TEXT};margin-bottom:8px;">'
|
| 844 |
+
f'{text}</div>'
|
| 845 |
+
f'<div style="display:flex;gap:5px;flex-wrap:wrap;">'
|
| 846 |
+
f'{reliability_badge(fact.get("reliability",""))}'
|
| 847 |
+
f'{source_badge(fact.get("source",""))}'
|
| 848 |
+
f'{impact_badge(fact.get("impact","") or "")}'
|
| 849 |
+
f'</div>'
|
| 850 |
+
f'</div>',
|
| 851 |
+
unsafe_allow_html=True,
|
| 852 |
+
)
|
| 853 |
+
|
| 854 |
+
|
| 855 |
+
# ── Public entry point ─────────────────────────────────────────────────────────
|
| 856 |
+
|
| 857 |
+
def render(tickers: list[str]) -> None:
|
| 858 |
+
"""Render the Exposure Radar for the given portfolio tickers.
|
| 859 |
+
|
| 860 |
+
Args:
|
| 861 |
+
tickers: List of uppercase ticker symbols defining the portfolio.
|
| 862 |
+
Tickers without a persisted brief are shown in the coverage panel
|
| 863 |
+
but excluded from the radar — no LLM calls are made here.
|
| 864 |
+
"""
|
| 865 |
+
from analytics.portfolio_exposure import load_portfolio, aggregate_risk_exposure
|
| 866 |
+
|
| 867 |
+
if not tickers:
|
| 868 |
+
_render_empty_portfolio()
|
| 869 |
+
return
|
| 870 |
+
|
| 871 |
+
briefs, missing = load_portfolio(tickers)
|
| 872 |
+
total = len(tickers)
|
| 873 |
+
covered = len(briefs)
|
| 874 |
+
|
| 875 |
+
_render_header(covered, total)
|
| 876 |
+
st.divider()
|
| 877 |
+
|
| 878 |
+
if missing:
|
| 879 |
+
_render_coverage_panel(missing, covered, total)
|
| 880 |
+
|
| 881 |
+
if not briefs:
|
| 882 |
+
st.info(
|
| 883 |
+
"No briefs generated yet. Enter each ticker in the **Ticker** field above "
|
| 884 |
+
"and click **Generate Brief** to populate the Radar."
|
| 885 |
+
)
|
| 886 |
+
return
|
| 887 |
+
|
| 888 |
+
clusters = aggregate_risk_exposure(briefs)
|
| 889 |
+
|
| 890 |
+
if not clusters:
|
| 891 |
+
st.info("No risk data found in generated briefs. Regenerate them to refresh.")
|
| 892 |
+
return
|
| 893 |
+
|
| 894 |
+
_render_top_exposure_card(clusters[0], total)
|
| 895 |
+
|
| 896 |
+
st.markdown(
|
| 897 |
+
f'<div style="display:flex;align-items:center;gap:10px;margin:20px 0 12px;">'
|
| 898 |
+
f'<span style="font-size:{FS_EYEBROW};font-weight:700;letter-spacing:0.1em;'
|
| 899 |
+
f'text-transform:uppercase;color:{TEXT_MUTED};white-space:nowrap;">All exposures</span>'
|
| 900 |
+
f'<div style="flex:1;height:1px;background:{BORDER};"></div>'
|
| 901 |
+
f'</div>',
|
| 902 |
+
unsafe_allow_html=True,
|
| 903 |
+
)
|
| 904 |
+
|
| 905 |
+
for cluster in clusters:
|
| 906 |
+
_render_cluster_row(cluster, total)
|
| 907 |
+
```
|
| 908 |
+
|
| 909 |
+
- [ ] **Step 4.2: Verify `source_badge` exists in `dashboard/components.py`**
|
| 910 |
+
|
| 911 |
+
```
|
| 912 |
+
python -c "from dashboard.components import source_badge; print('ok')"
|
| 913 |
+
```
|
| 914 |
+
|
| 915 |
+
If this returns `ImportError`, open `dashboard/components.py` and add the following function after `reliability_badge`:
|
| 916 |
+
|
| 917 |
+
```python
|
| 918 |
+
def source_badge(s: str) -> str:
|
| 919 |
+
colors = {
|
| 920 |
+
"10-K": (BLUE, INFO_BG),
|
| 921 |
+
"10-Q": (BLUE, INFO_BG),
|
| 922 |
+
"transcript": (PURPLE, AI_BG),
|
| 923 |
+
"news": (GRAY, BG_MUTED),
|
| 924 |
+
}
|
| 925 |
+
color, bg = colors.get(s, (GRAY, BG_MUTED))
|
| 926 |
+
label = s.upper() if s in ("10-K", "10-Q") else s.capitalize() if s else "—"
|
| 927 |
+
return (
|
| 928 |
+
f'<span style="background:{bg};color:{color};border:1px solid {color}33;'
|
| 929 |
+
f'border-radius:4px;padding:1px 7px;font-size:0.7rem;font-weight:600;">{label}</span>'
|
| 930 |
+
)
|
| 931 |
+
```
|
| 932 |
+
|
| 933 |
+
Note: add `INFO_BG` and `AI_BG` to the import at the top of `components.py` if not already present. The current imports from `dashboard/theme.py` at the top of `components.py` include `PURPLE` and `AI_BG` already; `INFO_BG` needs to be added.
|
| 934 |
+
|
| 935 |
+
- [ ] **Step 4.3: Commit**
|
| 936 |
+
|
| 937 |
+
```
|
| 938 |
+
git add dashboard/portfolio.py dashboard/components.py
|
| 939 |
+
git commit -m "feat: add portfolio.py — Exposure Radar render, coverage panel, cluster drill-down"
|
| 940 |
+
```
|
| 941 |
+
|
| 942 |
+
---
|
| 943 |
+
|
| 944 |
+
## Task 5: Wire Everything — `app.py` + `dashboard/theme.py`
|
| 945 |
+
|
| 946 |
+
**Files:**
|
| 947 |
+
- Modify: `dashboard/theme.py` (1 line)
|
| 948 |
+
- Modify: `app.py` (3 locations)
|
| 949 |
+
|
| 950 |
+
- [ ] **Step 5.1: Add Portfolio to `NAV_SECTIONS` in `dashboard/theme.py`**
|
| 951 |
+
|
| 952 |
+
In `dashboard/theme.py`, find `NAV_SECTIONS` at line 85 and add `("Portfolio", "🧭")` as the **first** entry so it appears at the top of the nav:
|
| 953 |
+
|
| 954 |
+
```python
|
| 955 |
+
NAV_SECTIONS = [
|
| 956 |
+
("Portfolio", "🧭"), # ← new
|
| 957 |
+
("Verdict", "🎯"),
|
| 958 |
+
("Numbers", "📊"),
|
| 959 |
+
("MD&A", "📖"),
|
| 960 |
+
("Risks", "⚠️"),
|
| 961 |
+
("Earnings Call", "🎙️"),
|
| 962 |
+
("Quality & Tone", "🔍"),
|
| 963 |
+
("Guidance", "🔭"),
|
| 964 |
+
("Earnings Tracker", "📈"),
|
| 965 |
+
]
|
| 966 |
+
```
|
| 967 |
+
|
| 968 |
+
- [ ] **Step 5.2: Add portfolio tickers sidebar input to `app.py`**
|
| 969 |
+
|
| 970 |
+
In `app.py`, find the sidebar section (inside `with st.sidebar:`). After the cost footer block (around line 70), add the portfolio input section. The full sidebar block should look like this at the end:
|
| 971 |
+
|
| 972 |
+
```python
|
| 973 |
+
# Portfolio tickers input
|
| 974 |
+
st.divider()
|
| 975 |
+
st.markdown(
|
| 976 |
+
f'<div style="font-size:0.72rem;font-weight:700;letter-spacing:0.06em;'
|
| 977 |
+
f'text-transform:uppercase;color:#6b7280;margin-bottom:4px;">Portfolio</div>',
|
| 978 |
+
unsafe_allow_html=True,
|
| 979 |
+
)
|
| 980 |
+
_raw_portfolio = st.text_area(
|
| 981 |
+
"Portfolio tickers",
|
| 982 |
+
value=", ".join(st.session_state.get("portfolio_tickers", [])),
|
| 983 |
+
placeholder="AAPL, MSFT, NVDA…",
|
| 984 |
+
height=80,
|
| 985 |
+
label_visibility="collapsed",
|
| 986 |
+
key="portfolio_input",
|
| 987 |
+
)
|
| 988 |
+
# Parse and persist tickers to session_state
|
| 989 |
+
_portfolio_tickers = [
|
| 990 |
+
t.strip().upper()
|
| 991 |
+
for t in _raw_portfolio.replace("\n", ",").split(",")
|
| 992 |
+
if t.strip()
|
| 993 |
+
]
|
| 994 |
+
st.session_state["portfolio_tickers"] = _portfolio_tickers
|
| 995 |
+
```
|
| 996 |
+
|
| 997 |
+
Place this block just before the closing of the `with st.sidebar:` block (i.e., after the cost footer `if cost_state and ...` block).
|
| 998 |
+
|
| 999 |
+
- [ ] **Step 5.3: Add brief persistence hook to `app.py`**
|
| 1000 |
+
|
| 1001 |
+
In `app.py`, find the block around line 203 where the brief is stored after a successful generation run:
|
| 1002 |
+
|
| 1003 |
+
```python
|
| 1004 |
+
st.session_state["brief"] = gen["brief"]
|
| 1005 |
+
st.session_state["brief_ticker"] = gen["ticker"]
|
| 1006 |
+
```
|
| 1007 |
+
|
| 1008 |
+
Add the persistence call **immediately after** these two lines:
|
| 1009 |
+
|
| 1010 |
+
```python
|
| 1011 |
+
st.session_state["brief"] = gen["brief"]
|
| 1012 |
+
st.session_state["brief_ticker"] = gen["ticker"]
|
| 1013 |
+
# Persist brief for portfolio Exposure Radar (non-critical — never blocks UI)
|
| 1014 |
+
try:
|
| 1015 |
+
from storage import briefs_db
|
| 1016 |
+
briefs_db.save_brief(gen["ticker"], gen["brief"])
|
| 1017 |
+
except Exception:
|
| 1018 |
+
pass
|
| 1019 |
+
```
|
| 1020 |
+
|
| 1021 |
+
- [ ] **Step 5.4: Add Portfolio routing to `app.py`**
|
| 1022 |
+
|
| 1023 |
+
In `app.py`, find the `elif active_name == "Verdict":` block around line 240. Add the Portfolio route as the **first** elif (before Verdict), since Portfolio doesn't depend on `_get_cached_brief()`:
|
| 1024 |
+
|
| 1025 |
+
```python
|
| 1026 |
+
elif active_name == "Portfolio":
|
| 1027 |
+
from dashboard.portfolio import render as render_portfolio
|
| 1028 |
+
render_portfolio(st.session_state.get("portfolio_tickers", []))
|
| 1029 |
+
elif active_name == "Verdict":
|
| 1030 |
+
# ... existing code unchanged ...
|
| 1031 |
+
```
|
| 1032 |
+
|
| 1033 |
+
- [ ] **Step 5.5: Commit**
|
| 1034 |
+
|
| 1035 |
+
```
|
| 1036 |
+
git add dashboard/theme.py app.py
|
| 1037 |
+
git commit -m "feat: wire Exposure Radar into app — nav, sidebar portfolio input, brief persistence hook"
|
| 1038 |
+
```
|
| 1039 |
+
|
| 1040 |
+
---
|
| 1041 |
+
|
| 1042 |
+
## Task 6: End-to-End Verification
|
| 1043 |
+
|
| 1044 |
+
- [ ] **Step 6.1: Run all new unit tests**
|
| 1045 |
+
|
| 1046 |
+
```
|
| 1047 |
+
pytest tests/test_briefs_db.py tests/test_portfolio_exposure.py -v
|
| 1048 |
+
```
|
| 1049 |
+
|
| 1050 |
+
Expected: all tests PASSED, no warnings.
|
| 1051 |
+
|
| 1052 |
+
- [ ] **Step 6.2: Run the full test suite to check for regressions**
|
| 1053 |
+
|
| 1054 |
+
```
|
| 1055 |
+
pytest -v
|
| 1056 |
+
```
|
| 1057 |
+
|
| 1058 |
+
Expected: existing tests still pass. If anything breaks, check whether `NAV_SECTIONS` change affects existing snapshot tests.
|
| 1059 |
+
|
| 1060 |
+
- [ ] **Step 6.3: Start the app**
|
| 1061 |
+
|
| 1062 |
+
```
|
| 1063 |
+
streamlit run app.py
|
| 1064 |
+
```
|
| 1065 |
+
|
| 1066 |
+
- [ ] **Step 6.4: Generate briefs for 2–3 tickers**
|
| 1067 |
+
|
| 1068 |
+
In the sidebar, enter `AAPL` → click **Generate Brief** → wait for completion.
|
| 1069 |
+
Repeat for `MSFT` and a third ticker (e.g. `NVDA` if ingested, or any other ingested ticker).
|
| 1070 |
+
|
| 1071 |
+
After each brief, verify `data/briefs.db` is created and grows by running in a separate terminal:
|
| 1072 |
+
```
|
| 1073 |
+
python -c "from storage.briefs_db import list_tickers; print(list_tickers())"
|
| 1074 |
+
```
|
| 1075 |
+
|
| 1076 |
+
Expected: e.g. `['AAPL', 'MSFT', 'NVDA']`
|
| 1077 |
+
|
| 1078 |
+
- [ ] **Step 6.5: Open the Portfolio screen and verify the Radar**
|
| 1079 |
+
|
| 1080 |
+
1. Enter `AAPL, MSFT, NVDA` (and optionally a ticker with no brief, e.g. `TSLA`) in the Portfolio field in the sidebar.
|
| 1081 |
+
2. Click **🧭 Portfolio** in the nav.
|
| 1082 |
+
3. Verify:
|
| 1083 |
+
- Header shows "N of M positions analysed" (e.g. "3 of 4").
|
| 1084 |
+
- Coverage panel appears for `TSLA` with cost estimate and instruction to generate.
|
| 1085 |
+
- Top exposure card shows the category with the most tickers (e.g. Geopolitical if 3 positions cite it).
|
| 1086 |
+
- All exposures list is sorted by count descending.
|
| 1087 |
+
- Expanding a cluster shows sourced risk facts per ticker with reliability/source/impact badges.
|
| 1088 |
+
- The `🆕 X new` badge appears for clusters with `is_new_this_filing=True` facts.
|
| 1089 |
+
4. Remove the missing ticker (leave only briefs-generated tickers) → coverage panel disappears.
|
| 1090 |
+
|
| 1091 |
+
- [ ] **Step 6.6: Verify no implicit LLM calls in Portfolio view**
|
| 1092 |
+
|
| 1093 |
+
Open the Portfolio screen with tickers — check that no LangSmith traces are created and `data/cost_log.jsonl` does not gain new entries. The Radar must be zero-cost to render.
|
| 1094 |
+
|
| 1095 |
+
---
|
| 1096 |
+
|
| 1097 |
+
## Self-Review Checklist
|
| 1098 |
+
|
| 1099 |
+
**Spec coverage:**
|
| 1100 |
+
- ✅ Brief persistence (`briefs_db.py`) — Task 1
|
| 1101 |
+
- ✅ Hook in `app.py` to save on generation — Task 5.3
|
| 1102 |
+
- ✅ Deterministic aggregation engine (`portfolio_exposure.py`) — Task 2
|
| 1103 |
+
- ✅ Portfolio sidebar input — Task 5.2
|
| 1104 |
+
- ✅ Coverage panel with cost estimate + instruction — Task 4 (`_render_coverage_panel`)
|
| 1105 |
+
- ✅ Exposure Radar render — Task 4 (`render`)
|
| 1106 |
+
- ✅ Top exposure card — Task 4 (`_render_top_exposure_card`)
|
| 1107 |
+
- ✅ Per-cluster drill-down with sourced facts — Task 4 (`_render_cluster_row`)
|
| 1108 |
+
- ✅ Navigation wiring — Task 5.1 + 5.4
|
| 1109 |
+
- ✅ Zero LLM calls at render time — confirmed in `portfolio_exposure.py` (no anthropic imports)
|
| 1110 |
+
- ✅ No implicit generation — coverage panel links to existing manual flow
|
| 1111 |
+
- ✅ `🆕` badges for new-this-filing risks — `new_count` + `new_badge` in render
|
| 1112 |
+
|
| 1113 |
+
**Type consistency check:**
|
| 1114 |
+
- `ExposureCluster.holdings` is `list[str]` — used as such in `_ticker_chip` iteration ✅
|
| 1115 |
+
- `ExposureCluster.facts` is `list[dict]` with keys `ticker, text, source, reliability, impact, is_new` — all accessed the same way in `_render_cluster_row` ✅
|
| 1116 |
+
- `load_portfolio` returns `tuple[dict[str, dict], list[str]]` — destructured as `briefs, missing` in both `render()` and the test ✅
|
| 1117 |
+
- `aggregate_risk_exposure` receives `dict[str, dict]` and returns `list[ExposureCluster]` ✅
|
| 1118 |
+
- `average_brief_cost_usd()` returns `Optional[float]` — guarded with `if avg_cost is not None` in `_render_coverage_panel` ✅
|
ingest.py
CHANGED
|
@@ -9,8 +9,8 @@ from ingestion.transcript import fetch_transcript
|
|
| 9 |
from ingestion.embedder import clear_ticker_data, embed_and_store_filing, embed_and_store_transcript
|
| 10 |
from ingestion.guidance_parser import parse_guidance
|
| 11 |
from ingestion.yf_fallback import fill_missing_metrics
|
| 12 |
-
from storage.metrics_db import init_db, upsert_metrics, prune_old_metrics
|
| 13 |
-
from storage.sections_db import init_sections_db, upsert_section
|
| 14 |
|
| 15 |
N_ANNUAL = 3
|
| 16 |
N_QUARTERLY = 12
|
|
@@ -39,8 +39,8 @@ def _period_to_av_quarter(period: str) -> str:
|
|
| 39 |
return ""
|
| 40 |
|
| 41 |
|
| 42 |
-
def ingest(ticker: str) -> None:
|
| 43 |
-
print(f"[ingest] Starting ingestion for {ticker.upper()}")
|
| 44 |
init_db()
|
| 45 |
init_sections_db()
|
| 46 |
|
|
@@ -52,28 +52,59 @@ def ingest(ticker: str) -> None:
|
|
| 52 |
|
| 53 |
print(f"[ingest] Found {len(filings)} filing(s) for {filings[0].company_name}")
|
| 54 |
|
| 55 |
-
|
| 56 |
-
|
|
|
|
| 57 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
for edgar in filings:
|
| 59 |
-
edgar = fill_missing_metrics(edgar)
|
| 60 |
label = f"{edgar.form_type} {edgar.period} ({edgar.filing_date})"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
print(f"[ingest] Processing {label}...")
|
| 62 |
|
| 63 |
guidance = _extract_guidance(edgar.mda_text)
|
| 64 |
|
| 65 |
# Fetch transcript for both 10-Q and 10-K (annual Q4 call).
|
| 66 |
-
transcript
|
|
|
|
|
|
|
| 67 |
if edgar.form_type in ("10-Q", "10-K"):
|
| 68 |
quarter = _period_to_av_quarter(edgar.period)
|
| 69 |
if not quarter:
|
| 70 |
print(f"[ingest] WARNING: Cannot derive quarter from period '{edgar.period}'. Skipping transcript.")
|
| 71 |
else:
|
| 72 |
-
|
| 73 |
-
if not
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
if guidance_struct.get("guidance_period") or guidance_struct.get("guidance_revenue_low") is not None:
|
| 78 |
print(f"[ingest] Parsed guidance for {guidance_struct.get('guidance_period')}: "
|
| 79 |
f"rev=[{guidance_struct.get('guidance_revenue_low')},{guidance_struct.get('guidance_revenue_high')}] "
|
|
@@ -121,26 +152,34 @@ def ingest(ticker: str) -> None:
|
|
| 121 |
form_type=edgar.form_type,
|
| 122 |
)
|
| 123 |
|
| 124 |
-
|
|
|
|
|
|
|
| 125 |
upsert_section(edgar.ticker, edgar.period, edgar.form_type, "transcript", transcript)
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
|
|
|
| 134 |
|
| 135 |
# Prune SQLite to configured limits
|
| 136 |
prune_old_metrics(ticker.upper(), "10-K", N_ANNUAL)
|
| 137 |
prune_old_metrics(ticker.upper(), "10-Q", N_QUARTERLY)
|
| 138 |
|
| 139 |
-
|
|
|
|
| 140 |
|
| 141 |
|
| 142 |
if __name__ == "__main__":
|
| 143 |
-
|
| 144 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 145 |
sys.exit(1)
|
| 146 |
-
ingest(
|
|
|
|
| 9 |
from ingestion.embedder import clear_ticker_data, embed_and_store_filing, embed_and_store_transcript
|
| 10 |
from ingestion.guidance_parser import parse_guidance
|
| 11 |
from ingestion.yf_fallback import fill_missing_metrics
|
| 12 |
+
from storage.metrics_db import init_db, upsert_metrics, prune_old_metrics, get_all_metrics
|
| 13 |
+
from storage.sections_db import init_sections_db, upsert_section, get_section
|
| 14 |
|
| 15 |
N_ANNUAL = 3
|
| 16 |
N_QUARTERLY = 12
|
|
|
|
| 39 |
return ""
|
| 40 |
|
| 41 |
|
| 42 |
+
def ingest(ticker: str, full: bool = False) -> None:
|
| 43 |
+
print(f"[ingest] Starting {'FULL' if full else 'delta'} ingestion for {ticker.upper()}")
|
| 44 |
init_db()
|
| 45 |
init_sections_db()
|
| 46 |
|
|
|
|
| 52 |
|
| 53 |
print(f"[ingest] Found {len(filings)} filing(s) for {filings[0].company_name}")
|
| 54 |
|
| 55 |
+
if full:
|
| 56 |
+
print("[ingest] --full: clearing existing Chroma data for this ticker...")
|
| 57 |
+
clear_ticker_data(ticker.upper())
|
| 58 |
|
| 59 |
+
# Periods already fully resolved (metrics stored + transcript resolved).
|
| 60 |
+
# sections.db persists across runs and is the source of truth for transcript resolution:
|
| 61 |
+
# None → never attempted (must fetch from AV)
|
| 62 |
+
# "" → attempted, none available (skip AV, re-embed not needed)
|
| 63 |
+
# text → transcript present (reuse, no AV call)
|
| 64 |
+
existing_metric_periods: set[str] = {
|
| 65 |
+
m["period"] for m in get_all_metrics(ticker.upper())
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
processed = skipped = 0
|
| 69 |
for edgar in filings:
|
|
|
|
| 70 |
label = f"{edgar.form_type} {edgar.period} ({edgar.filing_date})"
|
| 71 |
+
|
| 72 |
+
# --- Delta check ---
|
| 73 |
+
if not full:
|
| 74 |
+
transcript_resolved = get_section(edgar.ticker, edgar.period, "transcript")
|
| 75 |
+
if edgar.period in existing_metric_periods and transcript_resolved is not None:
|
| 76 |
+
print(f"[ingest] SKIP {label} (already ingested)")
|
| 77 |
+
skipped += 1
|
| 78 |
+
continue
|
| 79 |
+
|
| 80 |
+
processed += 1
|
| 81 |
+
edgar = fill_missing_metrics(edgar)
|
| 82 |
print(f"[ingest] Processing {label}...")
|
| 83 |
|
| 84 |
guidance = _extract_guidance(edgar.mda_text)
|
| 85 |
|
| 86 |
# Fetch transcript for both 10-Q and 10-K (annual Q4 call).
|
| 87 |
+
# In delta mode: reuse the cached transcript from sections.db when available,
|
| 88 |
+
# so no AV API call is made for already-resolved periods.
|
| 89 |
+
transcript = ""
|
| 90 |
if edgar.form_type in ("10-Q", "10-K"):
|
| 91 |
quarter = _period_to_av_quarter(edgar.period)
|
| 92 |
if not quarter:
|
| 93 |
print(f"[ingest] WARNING: Cannot derive quarter from period '{edgar.period}'. Skipping transcript.")
|
| 94 |
else:
|
| 95 |
+
stored = None if full else get_section(edgar.ticker, edgar.period, "transcript")
|
| 96 |
+
if stored is not None:
|
| 97 |
+
# Reuse cached value (may be "" if previously unavailable) — 0 AV calls.
|
| 98 |
+
transcript = stored
|
| 99 |
+
if transcript:
|
| 100 |
+
print(f"[ingest] Transcript reused from cache ({len(transcript):,} chars)")
|
| 101 |
+
else:
|
| 102 |
+
# First time or forced full — fetch from Alpha Vantage.
|
| 103 |
+
transcript = fetch_transcript(edgar.ticker, quarter) or ""
|
| 104 |
+
if not transcript:
|
| 105 |
+
print(f"[ingest] WARNING: No transcript found for {quarter}.")
|
| 106 |
+
|
| 107 |
+
guidance_struct = parse_guidance(edgar.mda_text, transcript or None, edgar.period)
|
| 108 |
if guidance_struct.get("guidance_period") or guidance_struct.get("guidance_revenue_low") is not None:
|
| 109 |
print(f"[ingest] Parsed guidance for {guidance_struct.get('guidance_period')}: "
|
| 110 |
f"rev=[{guidance_struct.get('guidance_revenue_low')},{guidance_struct.get('guidance_revenue_high')}] "
|
|
|
|
| 152 |
form_type=edgar.form_type,
|
| 153 |
)
|
| 154 |
|
| 155 |
+
# Always mark transcript resolution — even empty — so future delta runs skip the AV call.
|
| 156 |
+
# embed_and_store_transcript is only called when text is non-empty.
|
| 157 |
+
if edgar.form_type in ("10-Q", "10-K"):
|
| 158 |
upsert_section(edgar.ticker, edgar.period, edgar.form_type, "transcript", transcript)
|
| 159 |
+
if transcript:
|
| 160 |
+
embed_and_store_transcript(
|
| 161 |
+
ticker=edgar.ticker,
|
| 162 |
+
company_name=edgar.company_name,
|
| 163 |
+
transcript_text=transcript,
|
| 164 |
+
transcript_date=edgar.filing_date,
|
| 165 |
+
period=edgar.period,
|
| 166 |
+
)
|
| 167 |
+
print(f"[ingest] Transcript stored ({len(transcript):,} chars)")
|
| 168 |
|
| 169 |
# Prune SQLite to configured limits
|
| 170 |
prune_old_metrics(ticker.upper(), "10-K", N_ANNUAL)
|
| 171 |
prune_old_metrics(ticker.upper(), "10-Q", N_QUARTERLY)
|
| 172 |
|
| 173 |
+
summary = f"{processed} processed, {skipped} skipped (already up-to-date)"
|
| 174 |
+
print(f"[ingest] Done. {filings[0].company_name} ({ticker.upper()}) — {summary}.")
|
| 175 |
|
| 176 |
|
| 177 |
if __name__ == "__main__":
|
| 178 |
+
args = sys.argv[1:]
|
| 179 |
+
full_flag = "--full" in args
|
| 180 |
+
tickers = [a for a in args if not a.startswith("--")]
|
| 181 |
+
if len(tickers) != 1:
|
| 182 |
+
print("Usage: python ingest.py <TICKER> [--full]")
|
| 183 |
+
print(" --full Wipe and re-ingest all periods (fetches all AV transcripts again)")
|
| 184 |
sys.exit(1)
|
| 185 |
+
ingest(tickers[0], full=full_flag)
|
ingestion/transcript.py
CHANGED
|
@@ -10,10 +10,13 @@ logger = logging.getLogger(__name__)
|
|
| 10 |
def _parse_transcript(data: dict) -> str:
|
| 11 |
raw = data.get("transcript", "")
|
| 12 |
if isinstance(raw, list):
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
|
|
|
|
|
|
|
|
|
| 17 |
if raw is None:
|
| 18 |
return ""
|
| 19 |
return str(raw)
|
|
|
|
| 10 |
def _parse_transcript(data: dict) -> str:
|
| 11 |
raw = data.get("transcript", "")
|
| 12 |
if isinstance(raw, list):
|
| 13 |
+
lines = []
|
| 14 |
+
for item in raw:
|
| 15 |
+
speaker = item.get("speaker", "")
|
| 16 |
+
title = item.get("title", "")
|
| 17 |
+
label = f"{speaker} ({title})" if title else speaker
|
| 18 |
+
lines.append(f"{label}: {item.get('content', '')}")
|
| 19 |
+
return "\n".join(lines)
|
| 20 |
if raw is None:
|
| 21 |
return ""
|
| 22 |
return str(raw)
|
storage/sections_db.py
CHANGED
|
@@ -100,6 +100,38 @@ def _period_sort_key(period: str) -> tuple[int, int]:
|
|
| 100 |
return (0, 0)
|
| 101 |
|
| 102 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
def get_periods_for_ticker(ticker: str, form_type: Optional[str] = None) -> list[str]:
|
| 104 |
"""Return all period strings stored for a ticker, sorted newest first (chronologically).
|
| 105 |
|
|
|
|
| 100 |
return (0, 0)
|
| 101 |
|
| 102 |
|
| 103 |
+
def _transcript_sort_key(period: str) -> tuple[int, int]:
|
| 104 |
+
"""Chronological sort key that places FY calls as Q4 of their year.
|
| 105 |
+
|
| 106 |
+
'Q12026' → (2026, 1); 'FY2025' → (2025, 4) so the Q4/FY earnings call
|
| 107 |
+
sorts between Q32025 and Q12026. Unparseable strings → (0, 0).
|
| 108 |
+
"""
|
| 109 |
+
if period and period.startswith("FY") and len(period) == 6:
|
| 110 |
+
try:
|
| 111 |
+
return (int(period[2:]), 4)
|
| 112 |
+
except ValueError:
|
| 113 |
+
return (0, 0)
|
| 114 |
+
return _period_sort_key(period)
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def get_recent_transcripts(ticker: str, n: int = 4) -> list[tuple[str, str]]:
|
| 118 |
+
"""Return [(period, text)] for the n most recent non-empty transcripts.
|
| 119 |
+
|
| 120 |
+
Mixes 10-Q and 10-K (FY) earnings calls; FY periods sort as Q4 of their
|
| 121 |
+
year. Results are in chronological order (oldest first).
|
| 122 |
+
"""
|
| 123 |
+
if not SECTIONS_DB_PATH.exists():
|
| 124 |
+
return []
|
| 125 |
+
with sqlite3.connect(SECTIONS_DB_PATH) as conn:
|
| 126 |
+
rows = conn.execute(
|
| 127 |
+
"SELECT period, text FROM sections "
|
| 128 |
+
"WHERE ticker=? AND section='transcript' AND length(text) > 0",
|
| 129 |
+
(ticker.upper(),),
|
| 130 |
+
).fetchall()
|
| 131 |
+
rows.sort(key=lambda r: _transcript_sort_key(r[0]))
|
| 132 |
+
return [(r[0], r[1]) for r in rows[-n:]]
|
| 133 |
+
|
| 134 |
+
|
| 135 |
def get_periods_for_ticker(ticker: str, form_type: Optional[str] = None) -> list[str]:
|
| 136 |
"""Return all period strings stored for a ticker, sorted newest first (chronologically).
|
| 137 |
|
tests/test_graph.py
CHANGED
|
@@ -8,7 +8,17 @@ import json
|
|
| 8 |
|
| 9 |
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
|
| 10 |
|
| 11 |
-
from agent.graph import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
|
| 13 |
|
| 14 |
def _state(messages, tool_round_count, nudge_fired: bool = False) -> AgentState:
|
|
@@ -42,10 +52,11 @@ def _tool_msg(name: str, content: str = "ok") -> ToolMessage:
|
|
| 42 |
|
| 43 |
|
| 44 |
def test_routes_to_synthesis_when_no_tool_calls():
|
| 45 |
-
# Floor satisfied: filing + transcript
|
| 46 |
messages = [
|
| 47 |
HumanMessage(content="brief"),
|
| 48 |
_tool_msg("search_filing"),
|
|
|
|
| 49 |
_tool_msg("search_transcript"),
|
| 50 |
_ai_done(),
|
| 51 |
]
|
|
@@ -106,10 +117,11 @@ def test_routes_to_synthesis_after_nudge_fired():
|
|
| 106 |
|
| 107 |
|
| 108 |
def test_routes_to_synthesis_when_filing_and_transcript_present():
|
| 109 |
-
"""Floor satisfied → no nudge
|
| 110 |
messages = [
|
| 111 |
HumanMessage(content="brief"),
|
| 112 |
_tool_msg("search_filing"),
|
|
|
|
| 113 |
_tool_msg("search_transcript"),
|
| 114 |
_ai_done(),
|
| 115 |
]
|
|
@@ -148,10 +160,11 @@ def test_nudge_node_sets_flag_and_appends_message():
|
|
| 148 |
|
| 149 |
|
| 150 |
def test_nudge_node_mentions_only_missing_tool():
|
| 151 |
-
"""If only
|
| 152 |
messages = [
|
| 153 |
HumanMessage(content="brief"),
|
| 154 |
_tool_msg("search_filing"),
|
|
|
|
| 155 |
_ai_done(),
|
| 156 |
]
|
| 157 |
state = _state(messages, tool_round_count=3, nudge_fired=False)
|
|
@@ -202,3 +215,71 @@ def test_extract_json_markdown_fence():
|
|
| 202 |
raw = "```json\n{\"ticker\": \"MSFT\"}\n```"
|
| 203 |
result = _extract_json(raw)
|
| 204 |
assert json.loads(result) == {"ticker": "MSFT"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
|
| 9 |
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
|
| 10 |
|
| 11 |
+
from agent.graph import (
|
| 12 |
+
should_continue,
|
| 13 |
+
nudge_node,
|
| 14 |
+
AgentState,
|
| 15 |
+
_extract_json,
|
| 16 |
+
_cap_signals,
|
| 17 |
+
_format_signals_message,
|
| 18 |
+
MAX_EDGE_SIGNALS,
|
| 19 |
+
MAX_FILING_SIGNALS,
|
| 20 |
+
MAX_TRANSCRIPT_SIGNALS,
|
| 21 |
+
)
|
| 22 |
|
| 23 |
|
| 24 |
def _state(messages, tool_round_count, nudge_fired: bool = False) -> AgentState:
|
|
|
|
| 52 |
|
| 53 |
|
| 54 |
def test_routes_to_synthesis_when_no_tool_calls():
|
| 55 |
+
# Floor satisfied: ≥2 filing calls + transcript touched.
|
| 56 |
messages = [
|
| 57 |
HumanMessage(content="brief"),
|
| 58 |
_tool_msg("search_filing"),
|
| 59 |
+
ToolMessage(content="ok", name="search_filing", tool_call_id="id_search_filing_2"),
|
| 60 |
_tool_msg("search_transcript"),
|
| 61 |
_ai_done(),
|
| 62 |
]
|
|
|
|
| 117 |
|
| 118 |
|
| 119 |
def test_routes_to_synthesis_when_filing_and_transcript_present():
|
| 120 |
+
"""Floor satisfied (≥2 filing calls + transcript) → no nudge, go to synthesis."""
|
| 121 |
messages = [
|
| 122 |
HumanMessage(content="brief"),
|
| 123 |
_tool_msg("search_filing"),
|
| 124 |
+
ToolMessage(content="ok", name="search_filing", tool_call_id="id_search_filing_2"),
|
| 125 |
_tool_msg("search_transcript"),
|
| 126 |
_ai_done(),
|
| 127 |
]
|
|
|
|
| 160 |
|
| 161 |
|
| 162 |
def test_nudge_node_mentions_only_missing_tool():
|
| 163 |
+
"""If only the transcript requirement is missing, nudge mentions just that one."""
|
| 164 |
messages = [
|
| 165 |
HumanMessage(content="brief"),
|
| 166 |
_tool_msg("search_filing"),
|
| 167 |
+
ToolMessage(content="ok", name="search_filing", tool_call_id="id_search_filing_2"),
|
| 168 |
_ai_done(),
|
| 169 |
]
|
| 170 |
state = _state(messages, tool_round_count=3, nudge_fired=False)
|
|
|
|
| 215 |
raw = "```json\n{\"ticker\": \"MSFT\"}\n```"
|
| 216 |
result = _extract_json(raw)
|
| 217 |
assert json.loads(result) == {"ticker": "MSFT"}
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
# ── _cap_signals ──────────────────────────────────────────────────────────────
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
def _sig(kind: str, source: str, significance: str) -> dict:
|
| 224 |
+
return {"kind": kind, "source": source, "significance": significance, "term": ""}
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
def test_cap_signals_global_cap():
|
| 228 |
+
signals = [_sig("risk_added", "10-Q", "HIGH") for _ in range(20)]
|
| 229 |
+
capped = _cap_signals(signals)
|
| 230 |
+
assert len(capped) <= MAX_EDGE_SIGNALS
|
| 231 |
+
assert len(capped) <= MAX_FILING_SIGNALS # all filing-sourced here
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
def test_cap_signals_per_source_caps():
|
| 235 |
+
signals = (
|
| 236 |
+
[_sig("risk_added", "10-Q", "HIGH") for _ in range(10)]
|
| 237 |
+
+ [_sig("recurring_evasion", "transcript", "HIGH") for _ in range(10)]
|
| 238 |
+
)
|
| 239 |
+
capped = _cap_signals(signals)
|
| 240 |
+
filing = [s for s in capped if s["source"] != "transcript"]
|
| 241 |
+
transcript = [s for s in capped if s["source"] == "transcript"]
|
| 242 |
+
assert len(filing) <= MAX_FILING_SIGNALS
|
| 243 |
+
assert len(transcript) <= MAX_TRANSCRIPT_SIGNALS
|
| 244 |
+
assert len(capped) <= MAX_EDGE_SIGNALS
|
| 245 |
+
|
| 246 |
+
|
| 247 |
+
def test_cap_signals_high_significance_first():
|
| 248 |
+
signals = [
|
| 249 |
+
_sig("term_frequency", "10-Q", "MEDIUM"),
|
| 250 |
+
_sig("risk_added", "10-Q", "HIGH"),
|
| 251 |
+
_sig("kpi_dropped", "10-Q", "LOW"),
|
| 252 |
+
]
|
| 253 |
+
capped = _cap_signals(signals)
|
| 254 |
+
assert [s["significance"] for s in capped] == ["HIGH", "MEDIUM", "LOW"]
|
| 255 |
+
|
| 256 |
+
|
| 257 |
+
def test_cap_signals_empty():
|
| 258 |
+
assert _cap_signals([]) == []
|
| 259 |
+
|
| 260 |
+
|
| 261 |
+
# ── _format_signals_message — transcript kinds ────────────────────────────────
|
| 262 |
+
|
| 263 |
+
|
| 264 |
+
def test_format_signals_message_labels_transcript_kinds():
|
| 265 |
+
signals = [
|
| 266 |
+
{"kind": "tone_trend", "significance": "HIGH", "term": "hedging language",
|
| 267 |
+
"computed_metric": "hedge-word rate 31→44→59 per 10k words", "source": "transcript",
|
| 268 |
+
"period_from": "Q32025", "period_to": "Q12026", "before_text": "", "after_text": ""},
|
| 269 |
+
{"kind": "recurring_evasion", "significance": "HIGH", "term": "china / pricing",
|
| 270 |
+
"computed_metric": "asked in Q32025, Q12026; 2/2 answers non-quantitative",
|
| 271 |
+
"source": "transcript", "period_from": "Q32025", "period_to": "Q12026",
|
| 272 |
+
"before_text": "Analyst: question?", "after_text": "Too early to say."},
|
| 273 |
+
{"kind": "topic_arc", "significance": "MEDIUM", "term": "inventory",
|
| 274 |
+
"computed_metric": "1→4→7 mentions", "source": "transcript",
|
| 275 |
+
"period_from": "Q32025", "period_to": "Q12026", "before_text": "", "after_text": ""},
|
| 276 |
+
{"kind": "topic_fade", "significance": "MEDIUM", "term": "backlog",
|
| 277 |
+
"computed_metric": "'backlog' absent in Q12026", "source": "transcript",
|
| 278 |
+
"period_from": "Q32025", "period_to": "Q12026", "before_text": "Backlog grew.", "after_text": ""},
|
| 279 |
+
]
|
| 280 |
+
msg = _format_signals_message(signals)
|
| 281 |
+
assert "MANAGEMENT TONE TREND" in msg
|
| 282 |
+
assert "RECURRING Q&A EVASION" in msg
|
| 283 |
+
assert "TRANSCRIPT TOPIC ARC" in msg
|
| 284 |
+
assert "PREPARED-REMARKS TOPIC FADE" in msg
|
| 285 |
+
assert "hedge-word rate 31→44→59" in msg
|
tests/test_sections_db.py
CHANGED
|
@@ -82,3 +82,36 @@ def test_ticker_is_case_insensitive(tmp_path):
|
|
| 82 |
upsert_section("aapl", "Q12026", "10-Q", "mda", "lowercase insert")
|
| 83 |
text = get_section("AAPL", "Q12026", "mda")
|
| 84 |
assert text == "lowercase insert"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
upsert_section("aapl", "Q12026", "10-Q", "mda", "lowercase insert")
|
| 83 |
text = get_section("AAPL", "Q12026", "mda")
|
| 84 |
assert text == "lowercase insert"
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def test_get_recent_transcripts_chronological_with_fy(tmp_path):
|
| 88 |
+
"""FY2025 sorts as Q4 2025: between Q32025 and Q12026, oldest first."""
|
| 89 |
+
db_path = _tmp_db(tmp_path)
|
| 90 |
+
with patch("storage.sections_db.SECTIONS_DB_PATH", db_path):
|
| 91 |
+
from storage.sections_db import init_sections_db, upsert_section, get_recent_transcripts
|
| 92 |
+
init_sections_db()
|
| 93 |
+
upsert_section("AMD", "Q12026", "10-Q", "transcript", "call q1 2026")
|
| 94 |
+
upsert_section("AMD", "FY2025", "10-K", "transcript", "call fy 2025")
|
| 95 |
+
upsert_section("AMD", "Q32025", "10-Q", "transcript", "call q3 2025")
|
| 96 |
+
upsert_section("AMD", "Q22025", "10-Q", "transcript", "call q2 2025")
|
| 97 |
+
result = get_recent_transcripts("AMD", n=3)
|
| 98 |
+
assert [p for p, _ in result] == ["Q32025", "FY2025", "Q12026"]
|
| 99 |
+
assert result[-1][1] == "call q1 2026"
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def test_get_recent_transcripts_excludes_empty(tmp_path):
|
| 103 |
+
db_path = _tmp_db(tmp_path)
|
| 104 |
+
with patch("storage.sections_db.SECTIONS_DB_PATH", db_path):
|
| 105 |
+
from storage.sections_db import init_sections_db, upsert_section, get_recent_transcripts
|
| 106 |
+
init_sections_db()
|
| 107 |
+
upsert_section("AMD", "Q12026", "10-Q", "transcript", "real call")
|
| 108 |
+
upsert_section("AMD", "Q42025", "10-Q", "transcript", "") # AV gap
|
| 109 |
+
result = get_recent_transcripts("AMD", n=4)
|
| 110 |
+
assert [p for p, _ in result] == ["Q12026"]
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def test_get_recent_transcripts_no_db(tmp_path):
|
| 114 |
+
nonexistent = tmp_path / "nosuchfile.db"
|
| 115 |
+
with patch("storage.sections_db.SECTIONS_DB_PATH", nonexistent):
|
| 116 |
+
from storage.sections_db import get_recent_transcripts
|
| 117 |
+
assert get_recent_transcripts("AAPL") == []
|
tests/test_tone_drift.py
ADDED
|
@@ -0,0 +1,417 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""tests/test_tone_drift.py — unit tests for analysis/tone_drift.py and
|
| 2 |
+
analysis/transcript_parse.py.
|
| 3 |
+
|
| 4 |
+
Tests are purely deterministic: they do NOT call the sentence-transformer
|
| 5 |
+
model (mocked), do NOT hit sections_db (mocked), and do NOT require any
|
| 6 |
+
ingested data. Fixtures mimic the flattened Alpha Vantage format produced
|
| 7 |
+
by ingestion/transcript.py (one "Speaker: content" line per segment).
|
| 8 |
+
"""
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
from unittest.mock import patch
|
| 12 |
+
|
| 13 |
+
import numpy as np
|
| 14 |
+
|
| 15 |
+
from analysis.signals import QuarterDelta
|
| 16 |
+
from analysis.tone_drift import (
|
| 17 |
+
_is_evasive,
|
| 18 |
+
_phrase_rate,
|
| 19 |
+
_question_topic,
|
| 20 |
+
compute,
|
| 21 |
+
compute_recurring_evasions,
|
| 22 |
+
compute_tone_trend,
|
| 23 |
+
compute_topic_arcs,
|
| 24 |
+
compute_topic_fades,
|
| 25 |
+
)
|
| 26 |
+
from analysis.transcript_parse import ParsedCall, QAExchange, parse_call
|
| 27 |
+
|
| 28 |
+
# ---------------------------------------------------------------------------
|
| 29 |
+
# Fixtures — flattened AV-format transcript
|
| 30 |
+
# ---------------------------------------------------------------------------
|
| 31 |
+
|
| 32 |
+
CALL_TEXT = """\
|
| 33 |
+
Operator: Good afternoon, and welcome to the Examplecorp third quarter 2025 earnings conference call. At this time all participants are in a listen-only mode.
|
| 34 |
+
Jane Smith: Thank you, operator, and good afternoon everyone. Revenue for the quarter came in at the high end of our outlook, driven by data center strength across all regions and continued adoption of our newest platform by enterprise customers worldwide.
|
| 35 |
+
John Doe: Thanks, Jane. Gross margin was consistent with our prior commentary and operating expenses were well controlled across the organization during the period under review.
|
| 36 |
+
Operator: We will now begin the question-and-answer session. The first question comes from the line of Alex Carter with Big Bank.
|
| 37 |
+
Alex Carter: Thanks for taking my question. Can you quantify the China headwind to data center revenue this quarter and how should we think about it going forward?
|
| 38 |
+
Jane Smith: It is too early to say how that dynamic plays out and we are not going to get into specifics on any single region today.
|
| 39 |
+
Operator: The next question comes from the line of Morgan Lee with Other Firm.
|
| 40 |
+
Morgan Lee: Great, thank you. Could you walk us through the drivers of gross margin in the quarter and the puts and takes for next quarter?
|
| 41 |
+
John Doe: Sure. Gross margin was 54.3% in the quarter, up 120 basis points sequentially, driven by mix and better unit costs, and we expect roughly 54% next quarter.
|
| 42 |
+
"""
|
| 43 |
+
|
| 44 |
+
CALL_TEXT_WITH_TITLES = CALL_TEXT.replace(
|
| 45 |
+
"Jane Smith:", "Jane Smith (CEO):"
|
| 46 |
+
).replace(
|
| 47 |
+
"John Doe:", "John Doe (CFO):"
|
| 48 |
+
).replace(
|
| 49 |
+
"Alex Carter:", "Alex Carter (Analyst):"
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def _mgmt_call(period: str, n_words: int, hedge_hits: int) -> ParsedCall:
|
| 54 |
+
"""ParsedCall whose management text has an exact hedge-phrase count."""
|
| 55 |
+
filler_words = n_words - 2 * hedge_hits
|
| 56 |
+
text = ("alpha " * filler_words) + ("i think " * hedge_hits)
|
| 57 |
+
return ParsedCall(period=period, prepared_text=text, management_text=text)
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def _prepared_call(period: str, topic_mentions: int) -> ParsedCall:
|
| 61 |
+
base = "alpha " * 320
|
| 62 |
+
topic = "Our backlog grew again this period. " * topic_mentions
|
| 63 |
+
text = base + topic
|
| 64 |
+
return ParsedCall(period=period, prepared_text=text, management_text=text)
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def _qa_call(period: str, exchanges: list[QAExchange]) -> ParsedCall:
|
| 68 |
+
return ParsedCall(
|
| 69 |
+
period=period, prepared_text="alpha " * 400,
|
| 70 |
+
management_text="alpha " * 400, qa=exchanges,
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
_CHINA_Q = (
|
| 75 |
+
"Can you quantify the China headwind to data center revenue this quarter "
|
| 76 |
+
"and how should we think about the trajectory going forward into next year?"
|
| 77 |
+
)
|
| 78 |
+
_MARGIN_Q = (
|
| 79 |
+
"Could you walk us through the drivers of gross margin in the quarter and "
|
| 80 |
+
"the puts and takes that we should model for the next several quarters please?"
|
| 81 |
+
)
|
| 82 |
+
_EVASIVE_A = "It is too early to say and we are not going to guide on that level of detail."
|
| 83 |
+
_QUANT_A = (
|
| 84 |
+
"Gross margin was 54.3% in the quarter, up 120 basis points sequentially, "
|
| 85 |
+
"driven by product mix and better unit costs, and we expect roughly 54% next quarter "
|
| 86 |
+
"as the new platform ramps through the year."
|
| 87 |
+
)
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def _keyword_embed(texts):
|
| 91 |
+
"""Deterministic embedding: china-questions → e1, margin-questions → e2."""
|
| 92 |
+
vecs = []
|
| 93 |
+
for t in texts:
|
| 94 |
+
if "china" in t.lower():
|
| 95 |
+
vecs.append([1.0, 0.0])
|
| 96 |
+
else:
|
| 97 |
+
vecs.append([0.0, 1.0])
|
| 98 |
+
return np.array(vecs)
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
# ---------------------------------------------------------------------------
|
| 102 |
+
# Tests: transcript parser
|
| 103 |
+
# ---------------------------------------------------------------------------
|
| 104 |
+
|
| 105 |
+
def test_parse_call_finds_qa_boundary_and_roles():
|
| 106 |
+
call = parse_call("Q32025", CALL_TEXT)
|
| 107 |
+
assert call.n_segments == 9
|
| 108 |
+
assert "data center strength" in call.prepared_text
|
| 109 |
+
# Operator and analyst speech never lands in management text
|
| 110 |
+
assert "listen-only" not in call.management_text
|
| 111 |
+
assert "taking my question" not in call.management_text
|
| 112 |
+
# Q&A answers are management speech
|
| 113 |
+
assert "too early to say" in call.management_text
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def test_parse_call_pairs_exchanges():
|
| 117 |
+
call = parse_call("Q32025", CALL_TEXT)
|
| 118 |
+
assert len(call.qa) == 2
|
| 119 |
+
first, second = call.qa
|
| 120 |
+
assert first.analyst == "Alex Carter"
|
| 121 |
+
assert "China headwind" in first.question
|
| 122 |
+
assert "too early to say" in first.answer
|
| 123 |
+
assert second.analyst == "Morgan Lee"
|
| 124 |
+
assert "54.3%" in second.answer
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def test_parse_call_tolerates_title_format():
|
| 128 |
+
call = parse_call("Q32025", CALL_TEXT_WITH_TITLES)
|
| 129 |
+
assert len(call.qa) == 2
|
| 130 |
+
assert call.qa[0].analyst == "Alex Carter"
|
| 131 |
+
assert "data center strength" in call.prepared_text
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def test_parse_call_fallback_without_structure():
|
| 135 |
+
text = "This is a raw transcript blob without any speaker structure at all."
|
| 136 |
+
call = parse_call("Q32025", text)
|
| 137 |
+
assert call.qa == []
|
| 138 |
+
assert call.prepared_text == text
|
| 139 |
+
assert call.management_text == text
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def test_parse_call_unknown_qa_speaker_with_question_mark_is_analyst():
|
| 143 |
+
text = CALL_TEXT + (
|
| 144 |
+
"Sam Park: What is your capital expenditure plan for the next fiscal year "
|
| 145 |
+
"given the capacity constraints you mentioned earlier in the call today?\n"
|
| 146 |
+
"Jane Smith: We plan to invest ahead of demand as we have said before.\n"
|
| 147 |
+
)
|
| 148 |
+
call = parse_call("Q32025", text)
|
| 149 |
+
assert len(call.qa) == 3
|
| 150 |
+
assert call.qa[2].analyst == "Sam Park"
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def test_parse_call_never_raises_on_garbage():
|
| 154 |
+
for garbage in ["", "::::\n::::", "1234\n5678", None and "" or "?? ?? ??"]:
|
| 155 |
+
call = parse_call("Q12026", garbage)
|
| 156 |
+
assert isinstance(call, ParsedCall)
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
# ---------------------------------------------------------------------------
|
| 160 |
+
# Tests: tone trend
|
| 161 |
+
# ---------------------------------------------------------------------------
|
| 162 |
+
|
| 163 |
+
def test_tone_trend_detects_rising_hedge_rate():
|
| 164 |
+
calls = [
|
| 165 |
+
_mgmt_call("Q32025", 1000, 2), # rate 20 per 10k
|
| 166 |
+
_mgmt_call("Q42025", 1000, 4), # rate 40
|
| 167 |
+
_mgmt_call("Q12026", 1000, 6), # rate 60
|
| 168 |
+
]
|
| 169 |
+
deltas = compute_tone_trend(calls)
|
| 170 |
+
hedge = [d for d in deltas if d.term == "hedging language"]
|
| 171 |
+
assert len(hedge) == 1
|
| 172 |
+
d = hedge[0]
|
| 173 |
+
assert d.kind == "tone_trend"
|
| 174 |
+
assert d.source == "transcript"
|
| 175 |
+
assert "more cautious" in d.computed_metric
|
| 176 |
+
assert "rising 3 quarters" in d.computed_metric
|
| 177 |
+
assert d.significance == "HIGH" # net change (60-20)/20 = 200% ≥ 50%
|
| 178 |
+
assert d.period_from == "Q32025" and d.period_to == "Q12026"
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
def test_tone_trend_falling_hedge_rate_reads_more_confident():
|
| 182 |
+
calls = [
|
| 183 |
+
_mgmt_call("Q32025", 1000, 6),
|
| 184 |
+
_mgmt_call("Q42025", 1000, 4),
|
| 185 |
+
_mgmt_call("Q12026", 1000, 2),
|
| 186 |
+
]
|
| 187 |
+
deltas = compute_tone_trend(calls)
|
| 188 |
+
hedge = [d for d in deltas if d.term == "hedging language"]
|
| 189 |
+
assert len(hedge) == 1
|
| 190 |
+
assert "more confident" in hedge[0].computed_metric
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
def test_tone_trend_requires_three_calls():
|
| 194 |
+
calls = [_mgmt_call("Q42025", 1000, 2), _mgmt_call("Q12026", 1000, 6)]
|
| 195 |
+
assert compute_tone_trend(calls) == []
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
def test_tone_trend_skips_flat_series():
|
| 199 |
+
calls = [_mgmt_call(p, 1000, 3) for p in ("Q32025", "Q42025", "Q12026")]
|
| 200 |
+
assert compute_tone_trend(calls) == []
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
def test_phrase_rate_zero_on_empty():
|
| 204 |
+
assert _phrase_rate("", ["i think"]) == 0
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
# ---------------------------------------------------------------------------
|
| 208 |
+
# Tests: topic arcs
|
| 209 |
+
# ---------------------------------------------------------------------------
|
| 210 |
+
|
| 211 |
+
def test_topic_arc_detects_rising_inventory_mentions():
|
| 212 |
+
calls = [
|
| 213 |
+
ParsedCall(period=p, prepared_text="", management_text="")
|
| 214 |
+
for p in ("Q32025", "Q42025", "Q12026")
|
| 215 |
+
]
|
| 216 |
+
raw = [
|
| 217 |
+
"We watch inventory closely. " * 1,
|
| 218 |
+
"We watch inventory closely. " * 4,
|
| 219 |
+
"We watch inventory closely. " * 7,
|
| 220 |
+
]
|
| 221 |
+
deltas = compute_topic_arcs(calls, raw)
|
| 222 |
+
inv = [d for d in deltas if d.term == "inventory"]
|
| 223 |
+
assert len(inv) == 1
|
| 224 |
+
d = inv[0]
|
| 225 |
+
assert d.kind == "topic_arc"
|
| 226 |
+
assert "1→7 mentions" in d.computed_metric
|
| 227 |
+
assert "rising 3 quarters" in d.computed_metric
|
| 228 |
+
assert d.significance == "MEDIUM"
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
def test_topic_arc_noise_floor():
|
| 232 |
+
calls = [
|
| 233 |
+
ParsedCall(period=p, prepared_text="", management_text="")
|
| 234 |
+
for p in ("Q32025", "Q42025", "Q12026")
|
| 235 |
+
]
|
| 236 |
+
raw = ["no mention", "inventory once", "inventory twice inventory"]
|
| 237 |
+
# max count is 2 < 3 → below noise floor
|
| 238 |
+
assert compute_topic_arcs(calls, raw) == []
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
# ---------------------------------------------------------------------------
|
| 242 |
+
# Tests: recurring evasions
|
| 243 |
+
# ---------------------------------------------------------------------------
|
| 244 |
+
|
| 245 |
+
def test_recurring_evasion_detected_across_two_calls():
|
| 246 |
+
calls = [
|
| 247 |
+
_qa_call("Q32025", [
|
| 248 |
+
QAExchange("Alex Carter", _CHINA_Q, _EVASIVE_A),
|
| 249 |
+
QAExchange("Morgan Lee", _MARGIN_Q, _QUANT_A),
|
| 250 |
+
]),
|
| 251 |
+
_qa_call("Q12026", [
|
| 252 |
+
QAExchange("Alex Carter", _CHINA_Q, _EVASIVE_A),
|
| 253 |
+
QAExchange("Morgan Lee", _MARGIN_Q, _QUANT_A),
|
| 254 |
+
]),
|
| 255 |
+
]
|
| 256 |
+
with patch("analysis.tone_drift._embed", side_effect=_keyword_embed):
|
| 257 |
+
deltas = compute_recurring_evasions(calls)
|
| 258 |
+
assert len(deltas) == 1
|
| 259 |
+
d = deltas[0]
|
| 260 |
+
assert d.kind == "recurring_evasion"
|
| 261 |
+
assert d.significance == "MEDIUM" # 2 distinct periods
|
| 262 |
+
assert "asked in Q32025, Q12026" in d.computed_metric
|
| 263 |
+
assert "2/2 answers non-quantitative" in d.computed_metric
|
| 264 |
+
assert "deflection: '" in d.computed_metric # first matching phrase from _DEFLECTIONS
|
| 265 |
+
assert d.before_text.startswith("Alex Carter:")
|
| 266 |
+
assert "china" in d.term.lower()
|
| 267 |
+
|
| 268 |
+
|
| 269 |
+
def test_recurring_evasion_high_on_three_calls():
|
| 270 |
+
exch = QAExchange("Alex Carter", _CHINA_Q, _EVASIVE_A)
|
| 271 |
+
calls = [_qa_call(p, [exch]) for p in ("Q22025", "Q32025", "Q12026")]
|
| 272 |
+
with patch("analysis.tone_drift._embed", side_effect=_keyword_embed):
|
| 273 |
+
deltas = compute_recurring_evasions(calls)
|
| 274 |
+
assert len(deltas) == 1
|
| 275 |
+
assert deltas[0].significance == "HIGH"
|
| 276 |
+
|
| 277 |
+
|
| 278 |
+
def test_no_evasion_when_answers_are_quantitative():
|
| 279 |
+
calls = [
|
| 280 |
+
_qa_call("Q32025", [QAExchange("Morgan Lee", _MARGIN_Q, _QUANT_A)]),
|
| 281 |
+
_qa_call("Q12026", [QAExchange("Morgan Lee", _MARGIN_Q, _QUANT_A)]),
|
| 282 |
+
]
|
| 283 |
+
with patch("analysis.tone_drift._embed", side_effect=_keyword_embed):
|
| 284 |
+
assert compute_recurring_evasions(calls) == []
|
| 285 |
+
|
| 286 |
+
|
| 287 |
+
def test_no_evasion_when_question_topic_not_recurring():
|
| 288 |
+
calls = [
|
| 289 |
+
_qa_call("Q32025", [QAExchange("Morgan Lee", _MARGIN_Q, _EVASIVE_A)]),
|
| 290 |
+
_qa_call("Q12026", [QAExchange("Alex Carter", _CHINA_Q, _EVASIVE_A)]),
|
| 291 |
+
]
|
| 292 |
+
with patch("analysis.tone_drift._embed", side_effect=_keyword_embed):
|
| 293 |
+
assert compute_recurring_evasions(calls) == []
|
| 294 |
+
|
| 295 |
+
|
| 296 |
+
def test_recurring_evasion_requires_two_qa_calls():
|
| 297 |
+
calls = [_qa_call("Q12026", [QAExchange("Alex Carter", _CHINA_Q, _EVASIVE_A)])]
|
| 298 |
+
assert compute_recurring_evasions(calls) == []
|
| 299 |
+
|
| 300 |
+
|
| 301 |
+
# ---------------------------------------------------------------------------
|
| 302 |
+
# Tests: evasion scoring
|
| 303 |
+
# ---------------------------------------------------------------------------
|
| 304 |
+
|
| 305 |
+
def test_is_evasive_on_deflection_phrase():
|
| 306 |
+
evasive, phrase = _is_evasive("Honestly it is too early to say anything about that.")
|
| 307 |
+
assert evasive and phrase == "too early to say"
|
| 308 |
+
|
| 309 |
+
|
| 310 |
+
def test_is_evasive_on_short_nonquantitative_answer():
|
| 311 |
+
evasive, phrase = _is_evasive(
|
| 312 |
+
"We feel good about the trajectory and remain focused on execution across the portfolio."
|
| 313 |
+
)
|
| 314 |
+
assert evasive and phrase == ""
|
| 315 |
+
|
| 316 |
+
|
| 317 |
+
def test_not_evasive_when_quantitative():
|
| 318 |
+
evasive, _ = _is_evasive(_QUANT_A)
|
| 319 |
+
assert not evasive
|
| 320 |
+
|
| 321 |
+
|
| 322 |
+
def test_question_topic_extracts_keywords():
|
| 323 |
+
topic = _question_topic(_CHINA_Q)
|
| 324 |
+
assert "china" in topic
|
| 325 |
+
|
| 326 |
+
|
| 327 |
+
# ---------------------------------------------------------------------------
|
| 328 |
+
# Tests: topic fades
|
| 329 |
+
# ---------------------------------------------------------------------------
|
| 330 |
+
|
| 331 |
+
def test_topic_fade_detects_dropped_backlog():
|
| 332 |
+
calls = [
|
| 333 |
+
_prepared_call("Q22025", 3),
|
| 334 |
+
_prepared_call("Q32025", 2),
|
| 335 |
+
_prepared_call("Q12026", 0), # backlog gone
|
| 336 |
+
]
|
| 337 |
+
deltas = compute_topic_fades(calls)
|
| 338 |
+
backlog = [d for d in deltas if d.term == "backlog"]
|
| 339 |
+
assert len(backlog) == 1
|
| 340 |
+
d = backlog[0]
|
| 341 |
+
assert d.kind == "topic_fade"
|
| 342 |
+
assert "absent in Q12026" in d.computed_metric
|
| 343 |
+
assert "Q22025 and Q32025" in d.computed_metric
|
| 344 |
+
assert d.after_text == ""
|
| 345 |
+
|
| 346 |
+
|
| 347 |
+
def test_topic_fade_skips_when_still_present():
|
| 348 |
+
calls = [
|
| 349 |
+
_prepared_call("Q22025", 3),
|
| 350 |
+
_prepared_call("Q32025", 2),
|
| 351 |
+
_prepared_call("Q12026", 1), # still mentioned
|
| 352 |
+
]
|
| 353 |
+
assert [d for d in compute_topic_fades(calls) if d.term == "backlog"] == []
|
| 354 |
+
|
| 355 |
+
|
| 356 |
+
def test_topic_fade_requires_prominence_in_two_priors():
|
| 357 |
+
calls = [
|
| 358 |
+
_prepared_call("Q22025", 0),
|
| 359 |
+
_prepared_call("Q32025", 2), # only one prominent prior
|
| 360 |
+
_prepared_call("Q12026", 0),
|
| 361 |
+
]
|
| 362 |
+
assert [d for d in compute_topic_fades(calls) if d.term == "backlog"] == []
|
| 363 |
+
|
| 364 |
+
|
| 365 |
+
# ---------------------------------------------------------------------------
|
| 366 |
+
# Tests: compute() top level
|
| 367 |
+
# ---------------------------------------------------------------------------
|
| 368 |
+
|
| 369 |
+
def test_compute_empty_with_fewer_than_two_transcripts():
|
| 370 |
+
with patch("analysis.tone_drift.get_recent_transcripts", return_value=[("Q12026", CALL_TEXT)]):
|
| 371 |
+
assert compute("FAKE") == []
|
| 372 |
+
|
| 373 |
+
|
| 374 |
+
def test_compute_never_raises():
|
| 375 |
+
with patch("analysis.tone_drift.get_recent_transcripts", side_effect=RuntimeError("DB gone")):
|
| 376 |
+
assert compute("FAKE") == []
|
| 377 |
+
|
| 378 |
+
|
| 379 |
+
def test_compute_handles_garbage_transcripts():
|
| 380 |
+
garbage = [("Q32025", "?!? ###"), ("Q42025", "no structure here"), ("Q12026", "still nothing")]
|
| 381 |
+
with patch("analysis.tone_drift.get_recent_transcripts", return_value=garbage):
|
| 382 |
+
result = compute("FAKE")
|
| 383 |
+
assert isinstance(result, list)
|
| 384 |
+
|
| 385 |
+
|
| 386 |
+
def test_compute_caps_at_six_and_dedupes():
|
| 387 |
+
def _many(kind: str, n: int, term_prefix: str) -> list[QuarterDelta]:
|
| 388 |
+
return [
|
| 389 |
+
QuarterDelta(
|
| 390 |
+
kind=kind, period_from="Q32025", period_to="Q12026",
|
| 391 |
+
source="transcript", significance="MEDIUM", term=f"{term_prefix}{i}",
|
| 392 |
+
)
|
| 393 |
+
for i in range(n)
|
| 394 |
+
]
|
| 395 |
+
|
| 396 |
+
transcripts = [("Q32025", CALL_TEXT), ("Q42025", CALL_TEXT), ("Q12026", CALL_TEXT)]
|
| 397 |
+
high = QuarterDelta(
|
| 398 |
+
kind="recurring_evasion", period_from="Q32025", period_to="Q12026",
|
| 399 |
+
source="transcript", significance="HIGH", term="china",
|
| 400 |
+
)
|
| 401 |
+
dupe = high.model_copy()
|
| 402 |
+
with patch("analysis.tone_drift.get_recent_transcripts", return_value=transcripts), \
|
| 403 |
+
patch("analysis.tone_drift.compute_tone_trend", return_value=_many("tone_trend", 3, "t")), \
|
| 404 |
+
patch("analysis.tone_drift.compute_topic_arcs", return_value=_many("topic_arc", 3, "a")), \
|
| 405 |
+
patch("analysis.tone_drift.compute_recurring_evasions", return_value=[high, dupe]), \
|
| 406 |
+
patch("analysis.tone_drift.compute_topic_fades", return_value=_many("topic_fade", 3, "f")):
|
| 407 |
+
result = compute("FAKE")
|
| 408 |
+
|
| 409 |
+
assert len(result) == 6
|
| 410 |
+
assert result[0].significance == "HIGH"
|
| 411 |
+
assert sum(1 for d in result if d.kind == "recurring_evasion") == 1 # deduped
|
| 412 |
+
|
| 413 |
+
|
| 414 |
+
def test_quarter_delta_accepts_new_kinds():
|
| 415 |
+
for kind in ("tone_trend", "topic_arc", "recurring_evasion", "topic_fade"):
|
| 416 |
+
d = QuarterDelta(kind=kind, period_from="Q32025", period_to="Q12026", source="transcript")
|
| 417 |
+
assert QuarterDelta.model_validate(d.model_dump()).kind == kind
|