Viney Claude Fable 5 commited on
Commit
e6496c0
·
1 Parent(s): d265738

chore: checkpoint sidebar nav + i18n work-in-progress before Decision Stack redesign

Browse files
.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 = compute_text_deltas(ticker)
172
- signals = [s.model_dump() for s in raw_signals]
 
 
 
 
 
173
  except Exception as exc:
174
  import sys
175
- print(f"[signals_node] Error: {exc}", file=sys.stderr)
176
- signals = []
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
- system_block = SystemMessage(content=[{
 
 
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.theme import inject_global_css, LOGO_SVG, NAV_SECTIONS
 
 
 
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("Ticker", placeholder="e.g. AAPL").strip().upper()
66
- st.caption("Analyse une société à la fois.")
 
 
67
 
68
  # Graceful multi-ticker: if the user types "NVDA AAPL", take the first as the
69
- # active ticker and silently merge the rest into the portfolio field.
70
- _ticker_tokens = [t for t in _raw_ticker.replace(",", " ").split() if t]
71
  ticker_input = _ticker_tokens[0] if _ticker_tokens else ""
72
  if len(_ticker_tokens) > 1:
73
- # Merge extra tokens into portfolio_input (dedup, preserve order)
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
- "Generate Brief", type="primary", use_container_width=True,
84
  disabled=not ticker_input or st.session_state["gen"]["running"],
85
  )
86
 
87
  st.divider()
88
 
89
- nav_options = [f"{icon} {name}" for name, icon in NAV_SECTIONS]
90
- if "_nav_pending" in st.session_state:
91
- st.session_state["nav_radio"] = st.session_state.pop("_nav_pending")
92
- active_section = st.radio("Section", nav_options, label_visibility="collapsed", key="nav_radio")
93
- active_name = active_section.split(" ", 1)[1] if " " in active_section else active_section
 
 
 
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"Generate the brief using the **Generate Brief** button in the sidebar to populate {tab_name}.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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'Generating brief — <code style="background:#f3f4f6;padding:1px 7px;border-radius:5px;font-size:0.82rem;">{ticker_label}</code>'
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("🔍 Detailed reasoning", expanded=False):
270
  reasoning_panel.render_trace_body(trace)
271
 
272
  if gen.get("error") and not gen.get("brief"):
273
- st.error(f"Generation failed: {gen['error']}")
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["_nav_pending"] = "🎯 Verdict"
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 f"No brief produced — make sure `{gen['ticker']}` has been ingested."
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"Could not generate brief for **{err_ticker}**: {err_msg}")
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;">Ready to analyze</div>
329
  <div style="font-size:0.9rem;max-width:420px;margin:0 auto;line-height:1.6;">
330
- Pick a ticker and click <strong>Generate Brief</strong> — sourced from SEC filings,
331
- earnings transcripts, and live news.
332
  </div>
333
  </div>
334
  """,
335
  unsafe_allow_html=True,
336
  )
337
- elif active_name == "Portfolio":
338
- from dashboard.portfolio import render as render_portfolio
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
- from dashboard.earnings_call import render as render_call
377
- render_call(brief, ticker_input)
378
- else:
379
- _brief_placeholder("Earnings Call")
380
- elif active_name == "Quality & Tone":
381
- brief = _get_cached_brief()
382
- if brief:
383
- from dashboard.quality_tone import render as render_quality_tone
384
- render_quality_tone(brief, ticker_input)
385
- else:
386
- _brief_placeholder("Quality & Tone")
387
- elif active_name == "Guidance":
388
- brief = _get_cached_brief()
389
- if brief:
390
- from dashboard.catalysts import render as render_catalysts
391
- render_catalysts(brief, ticker_input)
392
- else:
393
- _brief_placeholder("Guidance")
394
- elif active_name == "Earnings Tracker":
395
- from dashboard.earnings_tracker import render as render_earnings
396
- render_earnings(ticker_input)
397
- elif active_name == "Chat":
398
- from dashboard.chat import render as render_chat
399
- render_chat(ticker_input)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- 'Data: SEC EDGAR (HIGH) · Earnings transcript via Alpha Vantage (MEDIUM) · '
407
- 'News via Tavily (LOW) · Price data via yfinance · EPS surprise via Alpha Vantage. '
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
- Questions answered from SEC filings, earnings call transcripts, and
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
- f"**{ticker}** n'a pas encore été ingéré. "
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(f"Pose une question sur les documents de {ticker}…")
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
- # ── Navigation sections ───────────────────────────────────────────────────────
85
- NAV_SECTIONS = [
86
- ("Portfolio", "🧭"),
87
- ("Verdict", "🎯"),
88
- ("Numbers", "📊"),
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 separators ──────────────────────────────────── */
284
- /* Labels with group headers need flex-wrap so ::before spans full row */
285
- [data-testid="stSidebar"] [data-testid="stRadio"] label:nth-child(1),
286
- [data-testid="stSidebar"] [data-testid="stRadio"] label:nth-child(3),
287
- [data-testid="stSidebar"] [data-testid="stRadio"] label:nth-child(7) {
288
- flex-wrap: wrap !important;
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="stRadio"] label:nth-child(3)::before {
308
- content: "DEEP DIVE";
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: 0 0 4px 2px;
316
- pointer-events: none;
317
  }
318
- /* Before Guidance (7th item) — "FORWARD" group */
319
- [data-testid="stSidebar"] [data-testid="stRadio"] label:nth-child(7) {
320
- margin-top: 10px !important;
321
  }
322
- [data-testid="stSidebar"] [data-testid="stRadio"] label:nth-child(7)::before {
323
- content: "FORWARD";
324
- flex: 0 0 100%;
325
- font-size: 0.55rem !important;
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:d93bb6f54b5620e94654cc051e74f3bd79c562f116f47719b6fb4ecf91c04dbf
3
- size 2012876
 
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:80f6ad8b4124409fcc4969703d79cb0dfa70625334cd6241f4581d5ad92b00d1
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:7d83fc77846303af75dfe8933144ea8673028a75d4f0350cf1d9937cee4c9f31
3
- size 26088
 
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:e46f767fdfa20345857509fb6f82052e37655dc80a05b19c5ebd66724c81004d
3
- size 4804
 
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:5ced4717f7d5eda6dc05252059e3af84378f254799d4f75708bb4218eadc01d8
3
- size 9768
 
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:35b3c182a27901f96ef664e53e671bd1707adaea5ca8b0a0e4e1eb9188cbdc29
3
- size 34598912
 
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:44f9e628d60bbf0d519cbc6affb3a5422200f062f386a943848aaf7467e1fa17
3
- size 73728
 
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:05277ab67b9e35dd6bac3f42d9bb4e3780607bbcc817998a23edd2be388dffb3
3
- size 1040384
 
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
- print("[ingest] Clearing existing Chroma data for this ticker...")
56
- clear_ticker_data(ticker.upper())
 
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 = None
 
 
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
- transcript = fetch_transcript(edgar.ticker, quarter)
73
- if not transcript:
74
- print(f"[ingest] WARNING: No transcript found for {quarter}.")
75
-
76
- guidance_struct = parse_guidance(edgar.mda_text, transcript, edgar.period)
 
 
 
 
 
 
 
 
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
- if transcript:
 
 
125
  upsert_section(edgar.ticker, edgar.period, edgar.form_type, "transcript", transcript)
126
- embed_and_store_transcript(
127
- ticker=edgar.ticker,
128
- company_name=edgar.company_name,
129
- transcript_text=transcript,
130
- transcript_date=edgar.filing_date,
131
- period=edgar.period,
132
- )
133
- print(f"[ingest] Transcript stored ({len(transcript):,} chars)")
 
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
- print(f"[ingest] Done. {filings[0].company_name} ({ticker.upper()}) {len(filings)} filing(s) ready.")
 
140
 
141
 
142
  if __name__ == "__main__":
143
- if len(sys.argv) != 2:
144
- print("Usage: python ingest.py <TICKER>")
 
 
 
 
145
  sys.exit(1)
146
- ingest(sys.argv[1])
 
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
- return "\n".join(
14
- f"{item.get('speaker', '')}: {item.get('content', '')}"
15
- for item in raw
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 should_continue, nudge_node, AgentState, _extract_json
 
 
 
 
 
 
 
 
 
 
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 both touched.
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 needed, go to synthesis."""
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 one of {filing, transcript} is missing, nudge mentions just that one."""
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