deploy: merge main (Analyst Edge Phase 0+1) for HF Spaces
Browse filesCo-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- agent/graph.py +70 -4
- agent/post_synthesis.py +21 -0
- agent/prompts.py +64 -10
- agent/schemas.py +29 -0
- agent/tools.py +14 -16
- analysis/__init__.py +0 -0
- analysis/signals.py +56 -0
- analysis/textdiff.py +582 -0
- app.py +9 -4
- dashboard/catalysts.py +36 -4
- dashboard/components.py +207 -12
- dashboard/earnings_call.py +6 -2
- dashboard/mda.py +52 -40
- dashboard/risks.py +47 -17
- dashboard/theme.py +64 -1
- dashboard/verdict.py +307 -182
- docs/superpowers/plans/2026-05-07-interpretation-visual.md +589 -0
- docs/superpowers/specs/2026-05-07-interpretation-visual-design.md +123 -0
- ingest.py +9 -0
- storage/sections_db.py +123 -0
- tests/test_dashboard_components.py +77 -0
- tests/test_sections_db.py +84 -0
- tests/test_textdiff.py +309 -0
agent/graph.py
CHANGED
|
@@ -16,7 +16,7 @@ from agent.tools import (
|
|
| 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
|
| 20 |
|
| 21 |
TOOLS = [
|
| 22 |
get_financial_metrics,
|
|
@@ -58,6 +58,7 @@ class AgentState(TypedDict):
|
|
| 58 |
messages: Annotated[list[BaseMessage], add_messages]
|
| 59 |
tool_round_count: int
|
| 60 |
nudge_fired: bool
|
|
|
|
| 61 |
brief: Optional[dict]
|
| 62 |
brief_markdown: Optional[str]
|
| 63 |
synthesis_error: Optional[str]
|
|
@@ -125,8 +126,55 @@ def nudge_node(state: AgentState) -> dict:
|
|
| 125 |
return {"messages": [nudge], "nudge_fired": True}
|
| 126 |
|
| 127 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
def create_graph():
|
| 129 |
-
llm = ChatAnthropic(model=MODEL, temperature=0)
|
| 130 |
llm_with_tools = llm.bind_tools(TOOLS)
|
| 131 |
|
| 132 |
def agent_node(state: AgentState) -> dict:
|
|
@@ -149,13 +197,27 @@ def create_graph():
|
|
| 149 |
|
| 150 |
def synthesis_node(state: AgentState) -> dict:
|
| 151 |
try:
|
| 152 |
-
llm_plain = ChatAnthropic(model=MODEL, temperature=0)
|
| 153 |
system_block = SystemMessage(content=[{
|
| 154 |
"type": "text",
|
| 155 |
"text": SYNTHESIS_STRUCTURED_PROMPT,
|
| 156 |
"cache_control": {"type": "ephemeral"},
|
| 157 |
}])
|
| 158 |
synthesis_messages = [system_block] + state["messages"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 159 |
if not isinstance(synthesis_messages[-1], HumanMessage):
|
| 160 |
synthesis_messages = synthesis_messages + [
|
| 161 |
HumanMessage(content="Now produce the structured research brief as a JSON object.")
|
|
@@ -170,6 +232,7 @@ def create_graph():
|
|
| 170 |
data = json.loads(clean)
|
| 171 |
brief = BriefOutput.model_validate(data)
|
| 172 |
brief_dict = apply_reliability(brief.model_dump())
|
|
|
|
| 173 |
return {"brief": brief_dict, "brief_markdown": None, "synthesis_error": None}
|
| 174 |
except Exception as exc:
|
| 175 |
import sys
|
|
@@ -177,11 +240,13 @@ def create_graph():
|
|
| 177 |
return {"brief": None, "brief_markdown": None, "synthesis_error": str(exc)}
|
| 178 |
|
| 179 |
builder = StateGraph(AgentState)
|
|
|
|
| 180 |
builder.add_node("agent", agent_node)
|
| 181 |
builder.add_node("tools", tool_node)
|
| 182 |
builder.add_node("nudge", nudge_node)
|
| 183 |
builder.add_node("synthesis", synthesis_node)
|
| 184 |
-
builder.set_entry_point("
|
|
|
|
| 185 |
builder.add_conditional_edges(
|
| 186 |
"agent",
|
| 187 |
should_continue,
|
|
@@ -202,6 +267,7 @@ def run_brief(ticker: str) -> Optional[dict]:
|
|
| 202 |
"messages": [HumanMessage(content=f"Generate a research brief for {ticker.upper()}.")],
|
| 203 |
"tool_round_count": 0,
|
| 204 |
"nudge_fired": False,
|
|
|
|
| 205 |
"brief": None,
|
| 206 |
"brief_markdown": None,
|
| 207 |
"synthesis_error": None,
|
|
|
|
| 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 |
|
| 21 |
TOOLS = [
|
| 22 |
get_financial_metrics,
|
|
|
|
| 58 |
messages: Annotated[list[BaseMessage], add_messages]
|
| 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]
|
|
|
|
| 126 |
return {"messages": [nudge], "nudge_fired": True}
|
| 127 |
|
| 128 |
|
| 129 |
+
def _format_signals_message(signals: list[dict]) -> str:
|
| 130 |
+
"""Format precomputed edge signals as a compact labelled block for the agent."""
|
| 131 |
+
lines = ["== PRECOMPUTED EDGE SIGNALS (deterministic, no LLM) ==\n"]
|
| 132 |
+
kind_labels = {
|
| 133 |
+
"risk_added": "NEW RISK",
|
| 134 |
+
"risk_removed": "REMOVED RISK",
|
| 135 |
+
"risk_reworded": "REWORDED RISK",
|
| 136 |
+
"guidance_language_shift": "GUIDANCE LANGUAGE SHIFT",
|
| 137 |
+
"term_frequency": "TERM FREQUENCY SHIFT",
|
| 138 |
+
"kpi_dropped": "DROPPED KPI",
|
| 139 |
+
}
|
| 140 |
+
for i, s in enumerate(signals, 1):
|
| 141 |
+
kind = s.get("kind", "")
|
| 142 |
+
label = kind_labels.get(kind, kind.upper())
|
| 143 |
+
sig = s.get("significance", "MEDIUM")
|
| 144 |
+
term = s.get("term", "")
|
| 145 |
+
term_str = f" — {term}" if term else ""
|
| 146 |
+
lines.append(f"[SIG-{i}] {label}{term_str} [{sig}]")
|
| 147 |
+
if s.get("before_text"):
|
| 148 |
+
lines.append(f" BEFORE ({s.get('period_from','')}): \"{s['before_text']}\"")
|
| 149 |
+
if s.get("after_text"):
|
| 150 |
+
lines.append(f" AFTER ({s.get('period_to','')}): \"{s['after_text']}\"")
|
| 151 |
+
if s.get("computed_metric"):
|
| 152 |
+
lines.append(f" METRIC: {s['computed_metric']}")
|
| 153 |
+
lines.append("")
|
| 154 |
+
lines.append("== END PRECOMPUTED EDGE SIGNALS ==")
|
| 155 |
+
return "\n".join(lines)
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def signals_node(state: AgentState) -> dict:
|
| 159 |
+
"""Run deterministic analysis modules and inject signals into conversation."""
|
| 160 |
+
ticker = state["ticker"]
|
| 161 |
+
try:
|
| 162 |
+
from analysis.textdiff import compute as compute_text_deltas
|
| 163 |
+
raw_signals = compute_text_deltas(ticker)
|
| 164 |
+
signals = [s.model_dump() for s in raw_signals]
|
| 165 |
+
except Exception as exc:
|
| 166 |
+
import sys
|
| 167 |
+
print(f"[signals_node] Error: {exc}", file=sys.stderr)
|
| 168 |
+
signals = []
|
| 169 |
+
|
| 170 |
+
if signals:
|
| 171 |
+
msg = HumanMessage(content=_format_signals_message(signals))
|
| 172 |
+
return {"edge_signals": signals, "messages": [msg]}
|
| 173 |
+
return {"edge_signals": [], "messages": []}
|
| 174 |
+
|
| 175 |
+
|
| 176 |
def create_graph():
|
| 177 |
+
llm = ChatAnthropic(model=MODEL, temperature=0, max_retries=5)
|
| 178 |
llm_with_tools = llm.bind_tools(TOOLS)
|
| 179 |
|
| 180 |
def agent_node(state: AgentState) -> dict:
|
|
|
|
| 197 |
|
| 198 |
def synthesis_node(state: AgentState) -> dict:
|
| 199 |
try:
|
| 200 |
+
llm_plain = ChatAnthropic(model=MODEL, temperature=0, max_retries=5)
|
| 201 |
system_block = SystemMessage(content=[{
|
| 202 |
"type": "text",
|
| 203 |
"text": SYNTHESIS_STRUCTURED_PROMPT,
|
| 204 |
"cache_control": {"type": "ephemeral"},
|
| 205 |
}])
|
| 206 |
synthesis_messages = [system_block] + state["messages"]
|
| 207 |
+
# If cap was hit mid-round the last AIMessage may still carry tool_calls.
|
| 208 |
+
# Anthropic rejects conversations where tool_use blocks have no matching
|
| 209 |
+
# tool_result — insert stubs so the message history is valid.
|
| 210 |
+
last_msg = synthesis_messages[-1]
|
| 211 |
+
if getattr(last_msg, "tool_calls", None):
|
| 212 |
+
stubs = [
|
| 213 |
+
ToolMessage(
|
| 214 |
+
tool_call_id=tc["id"],
|
| 215 |
+
name=tc["name"],
|
| 216 |
+
content="[Tool call interrupted — round cap reached. Synthesize from previously retrieved context.]",
|
| 217 |
+
)
|
| 218 |
+
for tc in last_msg.tool_calls
|
| 219 |
+
]
|
| 220 |
+
synthesis_messages = synthesis_messages + stubs
|
| 221 |
if not isinstance(synthesis_messages[-1], HumanMessage):
|
| 222 |
synthesis_messages = synthesis_messages + [
|
| 223 |
HumanMessage(content="Now produce the structured research brief as a JSON object.")
|
|
|
|
| 232 |
data = json.loads(clean)
|
| 233 |
brief = BriefOutput.model_validate(data)
|
| 234 |
brief_dict = apply_reliability(brief.model_dump())
|
| 235 |
+
brief_dict = attach_edge_signals(brief_dict, state.get("edge_signals"))
|
| 236 |
return {"brief": brief_dict, "brief_markdown": None, "synthesis_error": None}
|
| 237 |
except Exception as exc:
|
| 238 |
import sys
|
|
|
|
| 240 |
return {"brief": None, "brief_markdown": None, "synthesis_error": str(exc)}
|
| 241 |
|
| 242 |
builder = StateGraph(AgentState)
|
| 243 |
+
builder.add_node("signals", signals_node)
|
| 244 |
builder.add_node("agent", agent_node)
|
| 245 |
builder.add_node("tools", tool_node)
|
| 246 |
builder.add_node("nudge", nudge_node)
|
| 247 |
builder.add_node("synthesis", synthesis_node)
|
| 248 |
+
builder.set_entry_point("signals")
|
| 249 |
+
builder.add_edge("signals", "agent")
|
| 250 |
builder.add_conditional_edges(
|
| 251 |
"agent",
|
| 252 |
should_continue,
|
|
|
|
| 267 |
"messages": [HumanMessage(content=f"Generate a research brief for {ticker.upper()}.")],
|
| 268 |
"tool_round_count": 0,
|
| 269 |
"nudge_fired": False,
|
| 270 |
+
"edge_signals": None,
|
| 271 |
"brief": None,
|
| 272 |
"brief_markdown": None,
|
| 273 |
"synthesis_error": None,
|
agent/post_synthesis.py
CHANGED
|
@@ -291,3 +291,24 @@ def _prune_tension_duplicates(brief: dict) -> None:
|
|
| 291 |
brief["evidence_notes"] = existing_notes + [
|
| 292 |
f"Pruned {pruned_count} analytical tension(s) that duplicated bull/bear point evidence."
|
| 293 |
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 291 |
brief["evidence_notes"] = existing_notes + [
|
| 292 |
f"Pruned {pruned_count} analytical tension(s) that duplicated bull/bear point evidence."
|
| 293 |
]
|
| 294 |
+
|
| 295 |
+
|
| 296 |
+
# ---------------------------------------------------------------------------
|
| 297 |
+
# Edge signal attach (authoritative computed data — never LLM-generated)
|
| 298 |
+
# ---------------------------------------------------------------------------
|
| 299 |
+
|
| 300 |
+
def attach_edge_signals(brief: dict, edge_signals: Optional[list[dict]]) -> dict:
|
| 301 |
+
"""Write deterministically-computed edge signals into the brief dict.
|
| 302 |
+
|
| 303 |
+
The LLM produces explanations via the synthesis prompt; this function
|
| 304 |
+
writes the authoritative computed numbers so they are never absent or
|
| 305 |
+
fabricated. Called in synthesis_node after apply_reliability().
|
| 306 |
+
"""
|
| 307 |
+
if not isinstance(brief, dict):
|
| 308 |
+
return brief
|
| 309 |
+
if not edge_signals:
|
| 310 |
+
brief.setdefault("quarter_deltas", [])
|
| 311 |
+
return brief
|
| 312 |
+
|
| 313 |
+
brief["quarter_deltas"] = edge_signals
|
| 314 |
+
return brief
|
agent/prompts.py
CHANGED
|
@@ -1,5 +1,16 @@
|
|
| 1 |
SYSTEM_PROMPT = """You are a financial research analyst investigating a company's most recent earnings report for a retail investor. You reason like a human analyst: read the numbers first, identify what is anomalous or worth investigating, then dig into the source material with your own questions.
|
| 2 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
## Available tools
|
| 4 |
|
| 5 |
- `get_financial_metrics(ticker)` — structured metrics across all ingested periods. Use this FIRST. The output exposes the `period` string for each filing (e.g. `Q12024`, `FY2023`) — copy verbatim when calling search tools with `period=...`.
|
|
@@ -77,6 +88,22 @@ SYNTHESIS_STRUCTURED_PROMPT = """You are producing a structured earnings researc
|
|
| 77 |
|
| 78 |
The conversation history contains all tool call results (financial metrics, filings, transcripts, news). Use ONLY that evidence — do not add facts from your training data.
|
| 79 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
## ANALYTICAL EDGE — run this reasoning pass before filling any field
|
| 81 |
|
| 82 |
What separates a senior analyst's brief from a summary is the ability to surface tensions between what the data shows on the surface and what it reveals when cross-referenced. Before populating the JSON fields, reason through each of these checks:
|
|
@@ -125,6 +152,21 @@ For each dimension where you have retrieved evidence: assess positive / neutral
|
|
| 125 |
|
| 126 |
---
|
| 127 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
Output a single valid JSON object with exactly these fields. Do not wrap in markdown code fences.
|
| 129 |
|
| 130 |
Required JSON structure:
|
|
@@ -144,8 +186,8 @@ Required JSON structure:
|
|
| 144 |
"bullish_reading": "What the optimistic surface reading says.",
|
| 145 |
"bearish_reading": "What cross-referencing the data reveals as a concern or caveat.",
|
| 146 |
"weight": "material or watch or minor",
|
| 147 |
-
"bullish_evidence": { "text": "...", "source": "10-K, 10-Q, transcript, or news", "reliability": "HIGH or MEDIUM or LOW", "evidence_snippet": "verbatim quote <=30 words" },
|
| 148 |
-
"bearish_evidence": { "text": "...", "source": "10-K, 10-Q, transcript, or news", "reliability": "HIGH or MEDIUM or LOW", "evidence_snippet": "verbatim quote <=30 words" }
|
| 149 |
}
|
| 150 |
],
|
| 151 |
|
|
@@ -154,7 +196,7 @@ Required JSON structure:
|
|
| 154 |
"dimension": "consensus_beat_mix or guidance_dynamics or narrative_vs_numbers or segment_mix or capital_allocation",
|
| 155 |
"assessment": "positive or neutral or concerning",
|
| 156 |
"rationale": "One sentence grounded in retrieved evidence.",
|
| 157 |
-
"evidence": { "text": "...", "source": "10-K, 10-Q, transcript, or news", "reliability": "HIGH or MEDIUM or LOW", "evidence_snippet": "verbatim quote <=30 words" }
|
| 158 |
}
|
| 159 |
],
|
| 160 |
|
|
@@ -162,6 +204,7 @@ Required JSON structure:
|
|
| 162 |
"text": "The single most remarkable quantitative fact this quarter — the number a journalist would lead with. One sentence with context.",
|
| 163 |
"source": "exactly one of: 10-K, 10-Q, transcript, news",
|
| 164 |
"reliability": "HIGH or MEDIUM or LOW",
|
|
|
|
| 165 |
"evidence_snippet": "Verbatim quote <=30 words that contains this number"
|
| 166 |
},
|
| 167 |
|
|
@@ -170,16 +213,17 @@ Required JSON structure:
|
|
| 170 |
"text": "One factual sentence explaining WHY something changed this quarter — the driver, cause, tone shift, or structural factor. Numerical magnitudes (Δ% revenue, EPS deltas, margin changes) are displayed separately by the UI from SQL data, so focus on the EXPLANATION not the magnitude.",
|
| 171 |
"source": "exactly one of: 10-K, 10-Q, transcript, news",
|
| 172 |
"reliability": "HIGH or MEDIUM or LOW",
|
|
|
|
| 173 |
"evidence_snippet": "Verbatim quote <=30 words"
|
| 174 |
}
|
| 175 |
],
|
| 176 |
|
| 177 |
"bull_points": [
|
| 178 |
-
{ "text": "...", "source": "...", "reliability": "...", "evidence_snippet": "..." }
|
| 179 |
],
|
| 180 |
|
| 181 |
"bear_points": [
|
| 182 |
-
{ "text": "...", "source": "...", "reliability": "...", "evidence_snippet": "..." }
|
| 183 |
],
|
| 184 |
|
| 185 |
"what_to_watch": [
|
|
@@ -202,16 +246,17 @@ Required JSON structure:
|
|
| 202 |
|
| 203 |
"mda_summary": {
|
| 204 |
"drivers": [
|
| 205 |
-
{ "text": "Key revenue or margin driver from MD&A.", "source": "10-Q", "reliability": "HIGH", "evidence_snippet": "..." }
|
| 206 |
],
|
| 207 |
"headwinds": [
|
| 208 |
-
{ "text": "Headwind or drag on performance.", "source": "10-Q", "reliability": "HIGH", "evidence_snippet": "..." }
|
| 209 |
],
|
| 210 |
"language_shift": "1-2 sentences: how has management language changed vs prior periods? More confident, more cautious, more defensive? Reference specific wording changes if available.",
|
| 211 |
"key_quote": {
|
| 212 |
"text": "The single most revealing management statement this period.",
|
| 213 |
"source": "10-Q, 10-K, or transcript",
|
| 214 |
"reliability": "HIGH or MEDIUM",
|
|
|
|
| 215 |
"evidence_snippet": "The verbatim quote <=30 words"
|
| 216 |
}
|
| 217 |
},
|
|
@@ -222,6 +267,7 @@ Required JSON structure:
|
|
| 222 |
"text": "The risk in 1-2 sentences, grounded in filing language.",
|
| 223 |
"source": "exactly one of: 10-K, 10-Q, transcript, news",
|
| 224 |
"reliability": "HIGH or MEDIUM or LOW",
|
|
|
|
| 225 |
"is_new_this_filing": false
|
| 226 |
}
|
| 227 |
],
|
|
@@ -232,6 +278,7 @@ Required JSON structure:
|
|
| 232 |
"summary": "1-2 sentence summary of what management said.",
|
| 233 |
"source": "exactly one of: 10-K, 10-Q, transcript",
|
| 234 |
"reliability": "HIGH for SEC filings, MEDIUM for transcript",
|
|
|
|
| 235 |
"evidence_snippet": "Verbatim quote <=30 words"
|
| 236 |
}
|
| 237 |
],
|
|
@@ -241,6 +288,8 @@ Required JSON structure:
|
|
| 241 |
"period": "Q2 2025",
|
| 242 |
"text": "The guidance statement, 1-2 sentences.",
|
| 243 |
"source": "10-Q, 10-K, or transcript",
|
|
|
|
|
|
|
| 244 |
"metric_focus": "Revenue or EPS or Operating margin or Capex or null",
|
| 245 |
"actual_result": "Delivered $X.XB revenue, +N% vs guide midpoint",
|
| 246 |
"verdict": "beat"
|
|
@@ -319,7 +368,12 @@ Each rationale must paraphrase evidence already cited elsewhere in this brief
|
|
| 319 |
|
| 320 |
## Market expectations
|
| 321 |
Populate `market_expectations` strictly from `get_analyst_expectations` tool output. Never invent numbers.
|
| 322 |
-
|
| 323 |
-
- `
|
| 324 |
-
- `
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 325 |
"""
|
|
|
|
| 1 |
SYSTEM_PROMPT = """You are a financial research analyst investigating a company's most recent earnings report for a retail investor. You reason like a human analyst: read the numbers first, identify what is anomalous or worth investigating, then dig into the source material with your own questions.
|
| 2 |
|
| 3 |
+
## Precomputed edge signals
|
| 4 |
+
|
| 5 |
+
The first message in the conversation may contain a block titled "== PRECOMPUTED EDGE SIGNALS ==". These were computed deterministically by comparing verbatim filing text across periods — they are ground truth, not suggestions.
|
| 6 |
+
|
| 7 |
+
For each HIGH-significance signal, you MUST investigate it with at least one targeted tool call:
|
| 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 |
+
|
| 14 |
## Available tools
|
| 15 |
|
| 16 |
- `get_financial_metrics(ticker)` — structured metrics across all ingested periods. Use this FIRST. The output exposes the `period` string for each filing (e.g. `Q12024`, `FY2023`) — copy verbatim when calling search tools with `period=...`.
|
|
|
|
| 88 |
|
| 89 |
The conversation history contains all tool call results (financial metrics, filings, transcripts, news). Use ONLY that evidence — do not add facts from your training data.
|
| 90 |
|
| 91 |
+
## PRECOMPUTED EDGE SIGNALS — read first, act on them
|
| 92 |
+
|
| 93 |
+
The conversation history may contain a message titled "== PRECOMPUTED EDGE SIGNALS ==". These signals were produced by deterministic code comparing verbatim filing text across periods — no LLM interpretation was involved.
|
| 94 |
+
|
| 95 |
+
For each signal in that block:
|
| 96 |
+
1. **[SIG-n] REWORDED RISK / NEW RISK / REMOVED RISK** → The `before_text` and `after_text` fragments are verbatim quotes. If significance=HIGH, the corresponding change MUST appear in `risks_categorized` with `is_new_this_filing=True` (for NEW RISK). For REWORDED RISK, use the `after_text` as evidence and note it changed from the prior period.
|
| 97 |
+
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`.
|
| 98 |
+
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.
|
| 99 |
+
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`.
|
| 100 |
+
|
| 101 |
+
**Hard rules for edge signals:**
|
| 102 |
+
- Do NOT invent signals not present in the PRECOMPUTED EDGE SIGNALS block.
|
| 103 |
+
- The `computed_metric` numbers are authoritative — copy them exactly, never round or restate.
|
| 104 |
+
- The `before_text` / `after_text` fragments are verbatim quotes — never paraphrase them when citing.
|
| 105 |
+
- If the PRECOMPUTED EDGE SIGNALS block is absent or empty, proceed normally.
|
| 106 |
+
|
| 107 |
## ANALYTICAL EDGE — run this reasoning pass before filling any field
|
| 108 |
|
| 109 |
What separates a senior analyst's brief from a summary is the ability to surface tensions between what the data shows on the surface and what it reveals when cross-referenced. Before populating the JSON fields, reason through each of these checks:
|
|
|
|
| 152 |
|
| 153 |
---
|
| 154 |
|
| 155 |
+
## Impact rubric — assign to every sourced fact
|
| 156 |
+
|
| 157 |
+
The `impact` field captures materiality for the investment thesis. Apply it consistently:
|
| 158 |
+
|
| 159 |
+
- **HIGH** — thesis-shifting: forward guidance change ≥5%, EPS beat/miss ≥10% vs consensus, revenue driver >5% of total, new strategic pivot (M&A, product launch, market entry/exit), regulatory action, dividend initiation/cut, large buyback programme.
|
| 160 |
+
- **MEDIUM** — material but confirmatory: in-line guidance update, operational metric moving in expected direction, mid-sized deals, secondary segment dynamics, management tone consistent with trajectory.
|
| 161 |
+
- **LOW** — context or background: minor metrics (<1% of revenue), generic commentary that reiterates prior guidance, historical reference without new insight, supporting detail that amplifies but does not change interpretation.
|
| 162 |
+
|
| 163 |
+
Examples:
|
| 164 |
+
- "Revenue grew 12% YoY driven by iPhone 16 cycle" on $43B segment → HIGH (>5% of total company)
|
| 165 |
+
- "Gross margin expanded 40 bps to 47.2% in line with guidance" → MEDIUM (confirmation, not surprise)
|
| 166 |
+
- "Services segment saw strong performance in emerging markets" with no quantification → LOW (generic)
|
| 167 |
+
|
| 168 |
+
---
|
| 169 |
+
|
| 170 |
Output a single valid JSON object with exactly these fields. Do not wrap in markdown code fences.
|
| 171 |
|
| 172 |
Required JSON structure:
|
|
|
|
| 186 |
"bullish_reading": "What the optimistic surface reading says.",
|
| 187 |
"bearish_reading": "What cross-referencing the data reveals as a concern or caveat.",
|
| 188 |
"weight": "material or watch or minor",
|
| 189 |
+
"bullish_evidence": { "text": "...", "source": "10-K, 10-Q, transcript, or news", "reliability": "HIGH or MEDIUM or LOW", "impact": "HIGH or MEDIUM or LOW", "evidence_snippet": "verbatim quote <=30 words" },
|
| 190 |
+
"bearish_evidence": { "text": "...", "source": "10-K, 10-Q, transcript, or news", "reliability": "HIGH or MEDIUM or LOW", "impact": "HIGH or MEDIUM or LOW", "evidence_snippet": "verbatim quote <=30 words" }
|
| 191 |
}
|
| 192 |
],
|
| 193 |
|
|
|
|
| 196 |
"dimension": "consensus_beat_mix or guidance_dynamics or narrative_vs_numbers or segment_mix or capital_allocation",
|
| 197 |
"assessment": "positive or neutral or concerning",
|
| 198 |
"rationale": "One sentence grounded in retrieved evidence.",
|
| 199 |
+
"evidence": { "text": "...", "source": "10-K, 10-Q, transcript, or news", "reliability": "HIGH or MEDIUM or LOW", "impact": "HIGH or MEDIUM or LOW", "evidence_snippet": "verbatim quote <=30 words" }
|
| 200 |
}
|
| 201 |
],
|
| 202 |
|
|
|
|
| 204 |
"text": "The single most remarkable quantitative fact this quarter — the number a journalist would lead with. One sentence with context.",
|
| 205 |
"source": "exactly one of: 10-K, 10-Q, transcript, news",
|
| 206 |
"reliability": "HIGH or MEDIUM or LOW",
|
| 207 |
+
"impact": "HIGH or MEDIUM or LOW",
|
| 208 |
"evidence_snippet": "Verbatim quote <=30 words that contains this number"
|
| 209 |
},
|
| 210 |
|
|
|
|
| 213 |
"text": "One factual sentence explaining WHY something changed this quarter — the driver, cause, tone shift, or structural factor. Numerical magnitudes (Δ% revenue, EPS deltas, margin changes) are displayed separately by the UI from SQL data, so focus on the EXPLANATION not the magnitude.",
|
| 214 |
"source": "exactly one of: 10-K, 10-Q, transcript, news",
|
| 215 |
"reliability": "HIGH or MEDIUM or LOW",
|
| 216 |
+
"impact": "HIGH or MEDIUM or LOW",
|
| 217 |
"evidence_snippet": "Verbatim quote <=30 words"
|
| 218 |
}
|
| 219 |
],
|
| 220 |
|
| 221 |
"bull_points": [
|
| 222 |
+
{ "text": "...", "source": "...", "reliability": "...", "impact": "HIGH or MEDIUM or LOW", "evidence_snippet": "..." }
|
| 223 |
],
|
| 224 |
|
| 225 |
"bear_points": [
|
| 226 |
+
{ "text": "...", "source": "...", "reliability": "...", "impact": "HIGH or MEDIUM or LOW", "evidence_snippet": "..." }
|
| 227 |
],
|
| 228 |
|
| 229 |
"what_to_watch": [
|
|
|
|
| 246 |
|
| 247 |
"mda_summary": {
|
| 248 |
"drivers": [
|
| 249 |
+
{ "text": "Key revenue or margin driver from MD&A.", "source": "10-Q", "reliability": "HIGH", "impact": "HIGH or MEDIUM or LOW", "evidence_snippet": "..." }
|
| 250 |
],
|
| 251 |
"headwinds": [
|
| 252 |
+
{ "text": "Headwind or drag on performance.", "source": "10-Q", "reliability": "HIGH", "impact": "HIGH or MEDIUM or LOW", "evidence_snippet": "..." }
|
| 253 |
],
|
| 254 |
"language_shift": "1-2 sentences: how has management language changed vs prior periods? More confident, more cautious, more defensive? Reference specific wording changes if available.",
|
| 255 |
"key_quote": {
|
| 256 |
"text": "The single most revealing management statement this period.",
|
| 257 |
"source": "10-Q, 10-K, or transcript",
|
| 258 |
"reliability": "HIGH or MEDIUM",
|
| 259 |
+
"impact": "HIGH or MEDIUM or LOW",
|
| 260 |
"evidence_snippet": "The verbatim quote <=30 words"
|
| 261 |
}
|
| 262 |
},
|
|
|
|
| 267 |
"text": "The risk in 1-2 sentences, grounded in filing language.",
|
| 268 |
"source": "exactly one of: 10-K, 10-Q, transcript, news",
|
| 269 |
"reliability": "HIGH or MEDIUM or LOW",
|
| 270 |
+
"impact": "HIGH or MEDIUM or LOW",
|
| 271 |
"is_new_this_filing": false
|
| 272 |
}
|
| 273 |
],
|
|
|
|
| 278 |
"summary": "1-2 sentence summary of what management said.",
|
| 279 |
"source": "exactly one of: 10-K, 10-Q, transcript",
|
| 280 |
"reliability": "HIGH for SEC filings, MEDIUM for transcript",
|
| 281 |
+
"impact": "HIGH or MEDIUM or LOW",
|
| 282 |
"evidence_snippet": "Verbatim quote <=30 words"
|
| 283 |
}
|
| 284 |
],
|
|
|
|
| 288 |
"period": "Q2 2025",
|
| 289 |
"text": "The guidance statement, 1-2 sentences.",
|
| 290 |
"source": "10-Q, 10-K, or transcript",
|
| 291 |
+
"reliability": "HIGH or MEDIUM",
|
| 292 |
+
"impact": "HIGH or MEDIUM or LOW",
|
| 293 |
"metric_focus": "Revenue or EPS or Operating margin or Capex or null",
|
| 294 |
"actual_result": "Delivered $X.XB revenue, +N% vs guide midpoint",
|
| 295 |
"verdict": "beat"
|
|
|
|
| 368 |
|
| 369 |
## Market expectations
|
| 370 |
Populate `market_expectations` strictly from `get_analyst_expectations` tool output. Never invent numbers.
|
| 371 |
+
The tool output already uses the exact schema field names — copy each value verbatim, no renaming:
|
| 372 |
+
- `consensus_eps_est` → copy as-is (float or null)
|
| 373 |
+
- `consensus_rev_est_bn` → copy as-is (already in billions, float or null)
|
| 374 |
+
- `revision_30d_pct` → copy as-is (float or null)
|
| 375 |
+
- `d1_price_reaction_pct` → copy as-is (float or null)
|
| 376 |
+
- `d5_price_reaction_pct` → copy as-is (float or null)
|
| 377 |
+
If the tool returned `null` for a field, set that field to null — do NOT substitute zero or omit the key.
|
| 378 |
+
- `rationale`: ONE sentence comparing reported actuals (from get_financial_metrics) vs consensus and noting the price reaction direction. Use the tool's revision signal if non-zero. If the tool returned no data at all, set the entire object to null instead of fabricating.
|
| 379 |
"""
|
agent/schemas.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
import sys
|
| 2 |
from typing import Literal, Optional
|
| 3 |
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
|
|
| 4 |
|
| 5 |
_CANONICAL_CATEGORIES = {
|
| 6 |
"Regulatory", "Operational", "Competitive", "Financial", "Macro", "Demand", "Geopolitical"
|
|
@@ -45,6 +46,10 @@ class SourcedFact(BaseModel):
|
|
| 45 |
reliability: Literal["HIGH", "MEDIUM", "LOW"] = Field(
|
| 46 |
description="HIGH for SEC filings, MEDIUM for transcripts, LOW for news."
|
| 47 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
evidence_snippet: str = Field(
|
| 49 |
description="A literal quote (≤30 words) from the cited source that directly supports the claim. Must appear verbatim in retrieved tool output."
|
| 50 |
)
|
|
@@ -98,6 +103,10 @@ class CategorizedRisk(BaseModel):
|
|
| 98 |
reliability: Literal["HIGH", "MEDIUM", "LOW"] = Field(
|
| 99 |
description="HIGH for SEC filings, MEDIUM for transcripts, LOW for news."
|
| 100 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
is_new_this_filing: bool = Field(
|
| 102 |
description="True if this risk appears new or materially escalated vs prior filing."
|
| 103 |
)
|
|
@@ -125,6 +134,10 @@ class ManagementCommentaryTopic(BaseModel):
|
|
| 125 |
reliability: Literal["HIGH", "MEDIUM", "LOW"] = Field(
|
| 126 |
description="HIGH for SEC filings, MEDIUM for transcripts."
|
| 127 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
evidence_snippet: str = Field(description="Verbatim quote ≤30 words supporting this topic.")
|
| 129 |
|
| 130 |
@field_validator('evidence_snippet')
|
|
@@ -142,6 +155,14 @@ class GuidancePoint(BaseModel):
|
|
| 142 |
source: Literal["10-K", "10-Q", "transcript", "news"] = Field(
|
| 143 |
description="Document type where this guidance appeared."
|
| 144 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 145 |
metric_focus: Optional[str] = Field(
|
| 146 |
default=None,
|
| 147 |
description="Primary metric being guided on, e.g. 'Revenue', 'EPS', 'Operating margin', 'Capex'."
|
|
@@ -298,3 +319,11 @@ class BriefOutput(BaseModel):
|
|
| 298 |
default=None,
|
| 299 |
description="Analyst consensus, 30-day estimate revisions, and post-earnings price reaction. Set to null if no analyst data available."
|
| 300 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import sys
|
| 2 |
from typing import Literal, Optional
|
| 3 |
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
| 4 |
+
from analysis.signals import QuarterDelta
|
| 5 |
|
| 6 |
_CANONICAL_CATEGORIES = {
|
| 7 |
"Regulatory", "Operational", "Competitive", "Financial", "Macro", "Demand", "Geopolitical"
|
|
|
|
| 46 |
reliability: Literal["HIGH", "MEDIUM", "LOW"] = Field(
|
| 47 |
description="HIGH for SEC filings, MEDIUM for transcripts, LOW for news."
|
| 48 |
)
|
| 49 |
+
impact: Optional[Literal["HIGH", "MEDIUM", "LOW"]] = Field(
|
| 50 |
+
default=None,
|
| 51 |
+
description="Materiality for the investment thesis. HIGH = thesis-shifting (guidance ≥5%, beat/miss ≥10%, major M&A, regulatory action, strategic pivot); MEDIUM = material but confirmatory; LOW = context or supporting detail.",
|
| 52 |
+
)
|
| 53 |
evidence_snippet: str = Field(
|
| 54 |
description="A literal quote (≤30 words) from the cited source that directly supports the claim. Must appear verbatim in retrieved tool output."
|
| 55 |
)
|
|
|
|
| 103 |
reliability: Literal["HIGH", "MEDIUM", "LOW"] = Field(
|
| 104 |
description="HIGH for SEC filings, MEDIUM for transcripts, LOW for news."
|
| 105 |
)
|
| 106 |
+
impact: Optional[Literal["HIGH", "MEDIUM", "LOW"]] = Field(
|
| 107 |
+
default=None,
|
| 108 |
+
description="Materiality for the investment thesis. HIGH = could materially impair earnings, revenue, or operations; MEDIUM = notable headwind; LOW = background/standard risk disclosure.",
|
| 109 |
+
)
|
| 110 |
is_new_this_filing: bool = Field(
|
| 111 |
description="True if this risk appears new or materially escalated vs prior filing."
|
| 112 |
)
|
|
|
|
| 134 |
reliability: Literal["HIGH", "MEDIUM", "LOW"] = Field(
|
| 135 |
description="HIGH for SEC filings, MEDIUM for transcripts."
|
| 136 |
)
|
| 137 |
+
impact: Optional[Literal["HIGH", "MEDIUM", "LOW"]] = Field(
|
| 138 |
+
default=None,
|
| 139 |
+
description="Materiality for the investment thesis. HIGH = topic directly shapes earnings or valuation outlook; MEDIUM = important but secondary; LOW = routine commentary.",
|
| 140 |
+
)
|
| 141 |
evidence_snippet: str = Field(description="Verbatim quote ≤30 words supporting this topic.")
|
| 142 |
|
| 143 |
@field_validator('evidence_snippet')
|
|
|
|
| 155 |
source: Literal["10-K", "10-Q", "transcript", "news"] = Field(
|
| 156 |
description="Document type where this guidance appeared."
|
| 157 |
)
|
| 158 |
+
reliability: Optional[Literal["HIGH", "MEDIUM", "LOW"]] = Field(
|
| 159 |
+
default=None,
|
| 160 |
+
description="HIGH for SEC filings, MEDIUM for transcripts."
|
| 161 |
+
)
|
| 162 |
+
impact: Optional[Literal["HIGH", "MEDIUM", "LOW"]] = Field(
|
| 163 |
+
default=None,
|
| 164 |
+
description="Materiality of this guidance for the thesis. HIGH = large guidance change (≥5% vs consensus or prior), new metric, policy shift; MEDIUM = in-line guidance update; LOW = reaffirmation of existing guidance.",
|
| 165 |
+
)
|
| 166 |
metric_focus: Optional[str] = Field(
|
| 167 |
default=None,
|
| 168 |
description="Primary metric being guided on, e.g. 'Revenue', 'EPS', 'Operating margin', 'Capex'."
|
|
|
|
| 319 |
default=None,
|
| 320 |
description="Analyst consensus, 30-day estimate revisions, and post-earnings price reaction. Set to null if no analyst data available."
|
| 321 |
)
|
| 322 |
+
|
| 323 |
+
# Analyst Edge fields — populated deterministically by analysis/ modules and
|
| 324 |
+
# attached by post_synthesis.attach_edge_signals after LLM synthesis.
|
| 325 |
+
# Always present (empty list = no signals computed), never synthesized by LLM.
|
| 326 |
+
quarter_deltas: list[QuarterDelta] = Field(
|
| 327 |
+
default_factory=list,
|
| 328 |
+
description="Verbatim text deltas computed deterministically across consecutive filing periods. Populated by code, not LLM.",
|
| 329 |
+
)
|
agent/tools.py
CHANGED
|
@@ -141,36 +141,34 @@ def get_analyst_expectations(ticker: str) -> str:
|
|
| 141 |
if est is None and price is None:
|
| 142 |
return f"No analyst data available for {ticker.upper()}. Errors: {est_err}; {price_err}"
|
| 143 |
|
| 144 |
-
|
|
|
|
| 145 |
if est:
|
| 146 |
eps = est.get("consensus_eps_est")
|
| 147 |
rev = est.get("consensus_rev_est")
|
| 148 |
rev30 = est.get("estimate_revision_30d_pct")
|
|
|
|
| 149 |
lines.append(
|
| 150 |
-
f"
|
| 151 |
-
else "
|
| 152 |
)
|
| 153 |
lines.append(
|
| 154 |
-
f"
|
| 155 |
-
else "
|
| 156 |
)
|
| 157 |
-
if rev30 is not None:
|
| 158 |
-
lines.append(
|
| 159 |
-
f" Estimate revision (30d): {rev30:+.1f}% [signal: {_fmt_revision_signal(rev30)}]"
|
| 160 |
-
)
|
| 161 |
-
else:
|
| 162 |
-
lines.append(" Estimate revision (30d): N/A")
|
| 163 |
else:
|
| 164 |
-
lines.append(f"
|
|
|
|
|
|
|
| 165 |
|
| 166 |
if price:
|
| 167 |
d1 = price.get("d1_pct")
|
| 168 |
d5 = price.get("d5_pct")
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
lines.append(f" Post-earnings price reaction: d1 {d1_str}, d5 {d5_str}")
|
| 172 |
else:
|
| 173 |
-
lines.append(f"
|
|
|
|
| 174 |
|
| 175 |
return "\n".join(lines)
|
| 176 |
|
|
|
|
| 141 |
if est is None and price is None:
|
| 142 |
return f"No analyst data available for {ticker.upper()}. Errors: {est_err}; {price_err}"
|
| 143 |
|
| 144 |
+
# Use exact schema field names so the LLM can copy values verbatim without renaming.
|
| 145 |
+
lines = [f"Analyst expectations for {ticker.upper()} (field names match MarketExpectations schema):"]
|
| 146 |
if est:
|
| 147 |
eps = est.get("consensus_eps_est")
|
| 148 |
rev = est.get("consensus_rev_est")
|
| 149 |
rev30 = est.get("estimate_revision_30d_pct")
|
| 150 |
+
lines.append(f" consensus_eps_est: {eps:.4f}" if eps is not None else " consensus_eps_est: null")
|
| 151 |
lines.append(
|
| 152 |
+
f" consensus_rev_est_bn: {rev / 1e9:.4f}" # already converted to billions
|
| 153 |
+
if rev is not None else " consensus_rev_est_bn: null"
|
| 154 |
)
|
| 155 |
lines.append(
|
| 156 |
+
f" revision_30d_pct: {rev30:.4f} # signal: {_fmt_revision_signal(rev30)}"
|
| 157 |
+
if rev30 is not None else " revision_30d_pct: null"
|
| 158 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 159 |
else:
|
| 160 |
+
lines.append(f" consensus_eps_est: null # unavailable: {est_err}")
|
| 161 |
+
lines.append(" consensus_rev_est_bn: null")
|
| 162 |
+
lines.append(" revision_30d_pct: null")
|
| 163 |
|
| 164 |
if price:
|
| 165 |
d1 = price.get("d1_pct")
|
| 166 |
d5 = price.get("d5_pct")
|
| 167 |
+
lines.append(f" d1_price_reaction_pct: {d1:.4f}" if d1 is not None else " d1_price_reaction_pct: null")
|
| 168 |
+
lines.append(f" d5_price_reaction_pct: {d5:.4f}" if d5 is not None else " d5_price_reaction_pct: null")
|
|
|
|
| 169 |
else:
|
| 170 |
+
lines.append(f" d1_price_reaction_pct: null # unavailable: {price_err}")
|
| 171 |
+
lines.append(" d5_price_reaction_pct: null")
|
| 172 |
|
| 173 |
return "\n".join(lines)
|
| 174 |
|
analysis/__init__.py
ADDED
|
File without changes
|
analysis/signals.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""analysis/signals.py — shared signal types for the Analyst Edge layer.
|
| 2 |
+
|
| 3 |
+
These Pydantic models carry deterministically-computed evidence (verbatim
|
| 4 |
+
before/after text, counts, deltas) from the analysis modules to the LangGraph
|
| 5 |
+
agent and synthesis node. The LLM explains; the code supplies the figures.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
from typing import Literal, Optional
|
| 10 |
+
from pydantic import BaseModel, ConfigDict, Field
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class QuarterDelta(BaseModel):
|
| 14 |
+
"""A verbatim text change detected between two consecutive filing periods."""
|
| 15 |
+
model_config = ConfigDict(extra="ignore")
|
| 16 |
+
|
| 17 |
+
kind: Literal[
|
| 18 |
+
"risk_added",
|
| 19 |
+
"risk_removed",
|
| 20 |
+
"risk_reworded",
|
| 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'.")
|
| 27 |
+
period_to: str = Field(description="Current filing period, e.g. 'Q12026'.")
|
| 28 |
+
|
| 29 |
+
before_text: str = Field(
|
| 30 |
+
default="",
|
| 31 |
+
description="Verbatim fragment from the prior period. Empty for risk_added.",
|
| 32 |
+
)
|
| 33 |
+
after_text: str = Field(
|
| 34 |
+
default="",
|
| 35 |
+
description="Verbatim fragment from the current period. Empty for risk_removed.",
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
computed_metric: str = Field(
|
| 39 |
+
default="",
|
| 40 |
+
description="A computed summary, e.g. '2→8 occurrences (+300%)' for term_frequency.",
|
| 41 |
+
)
|
| 42 |
+
|
| 43 |
+
source: Literal["10-K", "10-Q", "transcript"] = Field(
|
| 44 |
+
default="10-Q",
|
| 45 |
+
description="Filing type the delta was detected in.",
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
significance: Literal["HIGH", "MEDIUM", "LOW"] = Field(
|
| 49 |
+
default="MEDIUM",
|
| 50 |
+
description="Computed significance: HIGH for new risks or large frequency swings, etc.",
|
| 51 |
+
)
|
| 52 |
+
|
| 53 |
+
term: str = Field(
|
| 54 |
+
default="",
|
| 55 |
+
description="The term or risk label being tracked (for term_frequency / kpi_dropped).",
|
| 56 |
+
)
|
analysis/textdiff.py
ADDED
|
@@ -0,0 +1,582 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""analysis/textdiff.py — verbatim text delta signals for the Analyst Edge layer.
|
| 2 |
+
|
| 3 |
+
Pure Python + sentence-transformers, zero LLM calls.
|
| 4 |
+
Compares the most recent filing period against the prior period for a ticker
|
| 5 |
+
and surfaces verbatim before→after fragments for the most material changes:
|
| 6 |
+
|
| 7 |
+
1. risk_reworded / risk_added / risk_removed — risk-factor diffs
|
| 8 |
+
2. term_frequency — analyst-lexicon count deltas
|
| 9 |
+
3. guidance_language_shift — hedge/modal word shifts in MD&A
|
| 10 |
+
4. kpi_dropped — metric mentioned prior, absent now
|
| 11 |
+
|
| 12 |
+
Usage:
|
| 13 |
+
from analysis.textdiff import compute
|
| 14 |
+
signals = compute("NVDA")
|
| 15 |
+
"""
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
import re
|
| 19 |
+
from typing import Optional
|
| 20 |
+
|
| 21 |
+
import numpy as np
|
| 22 |
+
|
| 23 |
+
from analysis.signals import QuarterDelta
|
| 24 |
+
from storage.sections_db import get_section, get_periods_for_ticker
|
| 25 |
+
|
| 26 |
+
# ---------------------------------------------------------------------------
|
| 27 |
+
# Config
|
| 28 |
+
# ---------------------------------------------------------------------------
|
| 29 |
+
|
| 30 |
+
_REWORD_THRESHOLD = 0.70 # cosine similarity: current & prior considered "same risk"
|
| 31 |
+
_NEW_RISK_THRESHOLD = 0.40 # below this → new risk (added)
|
| 32 |
+
_IDENTICAL_THRESHOLD = 0.93 # above this → unchanged, skip
|
| 33 |
+
|
| 34 |
+
_MIN_ITEM_WORDS = 25 # minimum words for a text chunk to be considered
|
| 35 |
+
|
| 36 |
+
# Analyst / macro lexicon to track frequency across periods
|
| 37 |
+
_LEXICON: list[tuple[str, str]] = [
|
| 38 |
+
# (term, display_label)
|
| 39 |
+
(r"\btariff\b", "tariff"),
|
| 40 |
+
(r"\bexport control\b", "export control"),
|
| 41 |
+
(r"\bheadwind\b", "headwind"),
|
| 42 |
+
(r"\buncertainty\b", "uncertainty"),
|
| 43 |
+
(r"\bsoftness\b", "softness"),
|
| 44 |
+
(r"\bslowing\b", "slowing"),
|
| 45 |
+
(r"\bdecelerat\w*", "deceleration"),
|
| 46 |
+
(r"\bcautious\b", "cautious"),
|
| 47 |
+
(r"\bpressure\b", "pressure"),
|
| 48 |
+
(r"\bai\b", "AI"),
|
| 49 |
+
(r"\bbuyback\b", "buyback"),
|
| 50 |
+
(r"\blayoff\b", "layoff"),
|
| 51 |
+
(r"\brestructur\w*", "restructuring"),
|
| 52 |
+
(r"\bimpairment\b", "impairment"),
|
| 53 |
+
(r"\blitigation\b", "litigation"),
|
| 54 |
+
(r"\bchinese? market\b", "China market"),
|
| 55 |
+
(r"\bsanction\b", "sanction"),
|
| 56 |
+
(r"\brecession\b", "recession"),
|
| 57 |
+
]
|
| 58 |
+
|
| 59 |
+
# Frequency swing that triggers a signal (×2 or more, and absolute diff ≥ 2)
|
| 60 |
+
_FREQ_RATIO_THRESHOLD = 2.0
|
| 61 |
+
_FREQ_ABS_THRESHOLD = 2
|
| 62 |
+
|
| 63 |
+
# KPI labels that, if absent from the current MD&A, signal a dropped KPI
|
| 64 |
+
_KPI_PATTERNS: list[tuple[str, str]] = [
|
| 65 |
+
(r"\b(?:gross\s+)?margins?\b", "gross margin"),
|
| 66 |
+
(r"\b(?:operating\s+)?margins?\b", "operating margin"),
|
| 67 |
+
(r"\bfree\s+cash\s+flow\b", "free cash flow"),
|
| 68 |
+
(r"\bdays?\s+sales?\s+outstanding\b|\bdso\b", "DSO"),
|
| 69 |
+
(r"\bdays?\s+inventory\s+outstanding\b|\bdio\b", "DIO"),
|
| 70 |
+
(r"\bdays?\s+payable\s+outstanding\b|\bdpo\b", "DPO"),
|
| 71 |
+
(r"\bshare\s+(?:repurchase|buyback)\b", "share repurchase"),
|
| 72 |
+
(r"\bdividend\b", "dividend"),
|
| 73 |
+
(r"\bguidance\b", "guidance"),
|
| 74 |
+
(r"\bbacklog\b", "backlog"),
|
| 75 |
+
(r"\bdeferred\s+revenue\b", "deferred revenue"),
|
| 76 |
+
(r"\bnet\s+retention\s+rate\b", "net retention rate"),
|
| 77 |
+
]
|
| 78 |
+
|
| 79 |
+
# Guidance hedge / modality words
|
| 80 |
+
_HEDGE_WORDS = [
|
| 81 |
+
"expect to grow", "expect growth", "expects to grow", "expects growth",
|
| 82 |
+
"anticipate", "plan to", "target", "forecast",
|
| 83 |
+
"moderate", "soften", "decline", "reduce", "headwind", "challenge",
|
| 84 |
+
"cautious", "uncertain", "volatile",
|
| 85 |
+
]
|
| 86 |
+
|
| 87 |
+
# ---------------------------------------------------------------------------
|
| 88 |
+
# Model (lazy singleton)
|
| 89 |
+
# ---------------------------------------------------------------------------
|
| 90 |
+
|
| 91 |
+
_encoder = None
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def _get_encoder():
|
| 95 |
+
global _encoder
|
| 96 |
+
if _encoder is None:
|
| 97 |
+
from sentence_transformers import SentenceTransformer
|
| 98 |
+
_encoder = SentenceTransformer("all-MiniLM-L6-v2", device="cpu")
|
| 99 |
+
return _encoder
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def _embed(texts: list[str]) -> np.ndarray:
|
| 103 |
+
enc = _get_encoder()
|
| 104 |
+
vecs = enc.encode(texts, convert_to_numpy=True, show_progress_bar=False)
|
| 105 |
+
# Normalise rows
|
| 106 |
+
norms = np.linalg.norm(vecs, axis=1, keepdims=True)
|
| 107 |
+
norms = np.where(norms < 1e-8, 1.0, norms)
|
| 108 |
+
return vecs / norms
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def _cosine(a: np.ndarray, b: np.ndarray) -> float:
|
| 112 |
+
return float(np.dot(a, b))
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
# ---------------------------------------------------------------------------
|
| 116 |
+
# Text splitters
|
| 117 |
+
# ---------------------------------------------------------------------------
|
| 118 |
+
|
| 119 |
+
def _split_into_items(text: str, min_words: int = _MIN_ITEM_WORDS) -> list[str]:
|
| 120 |
+
"""Split a section text into logical chunks (risk items / paragraphs).
|
| 121 |
+
|
| 122 |
+
Uses double-newline paragraph boundaries. Merges short lines (headers)
|
| 123 |
+
with the following paragraph. Returns only chunks >= min_words.
|
| 124 |
+
"""
|
| 125 |
+
raw = re.split(r"\n{2,}", text.strip())
|
| 126 |
+
items: list[str] = []
|
| 127 |
+
buffer = ""
|
| 128 |
+
for para in raw:
|
| 129 |
+
para = para.strip()
|
| 130 |
+
if not para:
|
| 131 |
+
continue
|
| 132 |
+
word_count = len(para.split())
|
| 133 |
+
if word_count < 8:
|
| 134 |
+
# Likely a heading — prepend to next paragraph
|
| 135 |
+
buffer = para + " "
|
| 136 |
+
else:
|
| 137 |
+
combined = (buffer + para).strip()
|
| 138 |
+
buffer = ""
|
| 139 |
+
if len(combined.split()) >= min_words:
|
| 140 |
+
items.append(combined)
|
| 141 |
+
if buffer.strip() and len(buffer.split()) >= min_words:
|
| 142 |
+
items.append(buffer.strip())
|
| 143 |
+
return items
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def _split_sentences(text: str) -> list[str]:
|
| 147 |
+
"""Simple sentence splitter (no NLTK dependency)."""
|
| 148 |
+
sentences = re.split(r"(?<=[.!?])\s+", text)
|
| 149 |
+
return [s.strip() for s in sentences if len(s.split()) >= 5]
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
# ---------------------------------------------------------------------------
|
| 153 |
+
# Greedy one-to-one item alignment
|
| 154 |
+
# ---------------------------------------------------------------------------
|
| 155 |
+
|
| 156 |
+
def _align_items(
|
| 157 |
+
current_items: list[str],
|
| 158 |
+
prior_items: list[str],
|
| 159 |
+
current_vecs: np.ndarray,
|
| 160 |
+
prior_vecs: np.ndarray,
|
| 161 |
+
) -> tuple[dict[int, int], dict[int, float]]:
|
| 162 |
+
"""Greedy one-to-one alignment: each current item → best prior item.
|
| 163 |
+
|
| 164 |
+
Returns:
|
| 165 |
+
matches: {current_idx: prior_idx}
|
| 166 |
+
scores: {current_idx: cosine_similarity}
|
| 167 |
+
"""
|
| 168 |
+
if len(current_items) == 0 or len(prior_items) == 0:
|
| 169 |
+
return {}, {}
|
| 170 |
+
|
| 171 |
+
# pairwise similarities: (n_current × n_prior)
|
| 172 |
+
sim_matrix = current_vecs @ prior_vecs.T # shape (n_cur, n_pri)
|
| 173 |
+
|
| 174 |
+
matches: dict[int, int] = {}
|
| 175 |
+
scores: dict[int, float] = {}
|
| 176 |
+
used_prior: set[int] = set()
|
| 177 |
+
|
| 178 |
+
# Process current items in order; assign best available prior match
|
| 179 |
+
for ci in range(len(current_items)):
|
| 180 |
+
row = sim_matrix[ci]
|
| 181 |
+
# mask already-used prior indices
|
| 182 |
+
masked = [(row[pi], pi) for pi in range(len(prior_items)) if pi not in used_prior]
|
| 183 |
+
if not masked:
|
| 184 |
+
break
|
| 185 |
+
best_score, best_pi = max(masked)
|
| 186 |
+
matches[ci] = best_pi
|
| 187 |
+
scores[ci] = best_score
|
| 188 |
+
if best_score >= _NEW_RISK_THRESHOLD:
|
| 189 |
+
used_prior.add(best_pi)
|
| 190 |
+
|
| 191 |
+
return matches, scores
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
# ---------------------------------------------------------------------------
|
| 195 |
+
# Risk factor diff
|
| 196 |
+
# ---------------------------------------------------------------------------
|
| 197 |
+
|
| 198 |
+
def compute_risk_deltas(
|
| 199 |
+
current_text: str,
|
| 200 |
+
prior_text: str,
|
| 201 |
+
period_from: str,
|
| 202 |
+
period_to: str,
|
| 203 |
+
form_type: str,
|
| 204 |
+
) -> list[QuarterDelta]:
|
| 205 |
+
"""Align risk-factor items across two periods and classify changes."""
|
| 206 |
+
if not current_text or not prior_text:
|
| 207 |
+
return []
|
| 208 |
+
|
| 209 |
+
current_items = _split_into_items(current_text)
|
| 210 |
+
prior_items = _split_into_items(prior_text)
|
| 211 |
+
if not current_items or not prior_items:
|
| 212 |
+
return []
|
| 213 |
+
|
| 214 |
+
current_vecs = _embed(current_items)
|
| 215 |
+
prior_vecs = _embed(prior_items)
|
| 216 |
+
|
| 217 |
+
matches, scores = _align_items(current_items, prior_items, current_vecs, prior_vecs)
|
| 218 |
+
|
| 219 |
+
matched_prior_indices: set[int] = set()
|
| 220 |
+
deltas: list[QuarterDelta] = []
|
| 221 |
+
source_lit = "10-K" if "10-K" in form_type.upper() else "10-Q"
|
| 222 |
+
|
| 223 |
+
for ci, item in enumerate(current_items):
|
| 224 |
+
pi = matches.get(ci)
|
| 225 |
+
score = scores.get(ci, 0.0)
|
| 226 |
+
|
| 227 |
+
if pi is not None and score >= _NEW_RISK_THRESHOLD:
|
| 228 |
+
matched_prior_indices.add(pi)
|
| 229 |
+
if score >= _IDENTICAL_THRESHOLD:
|
| 230 |
+
continue # unchanged — not interesting
|
| 231 |
+
|
| 232 |
+
# Reworded: significant textual change
|
| 233 |
+
before = _truncate(prior_items[pi], 120)
|
| 234 |
+
after = _truncate(item, 120)
|
| 235 |
+
sig = "HIGH" if score < 0.80 else "MEDIUM"
|
| 236 |
+
deltas.append(QuarterDelta(
|
| 237 |
+
kind="risk_reworded",
|
| 238 |
+
period_from=period_from,
|
| 239 |
+
period_to=period_to,
|
| 240 |
+
before_text=before,
|
| 241 |
+
after_text=after,
|
| 242 |
+
computed_metric=f"similarity {score:.2f}",
|
| 243 |
+
source=source_lit,
|
| 244 |
+
significance=sig,
|
| 245 |
+
term="",
|
| 246 |
+
))
|
| 247 |
+
else:
|
| 248 |
+
# New risk — not matched in prior
|
| 249 |
+
after = _truncate(item, 120)
|
| 250 |
+
deltas.append(QuarterDelta(
|
| 251 |
+
kind="risk_added",
|
| 252 |
+
period_from=period_from,
|
| 253 |
+
period_to=period_to,
|
| 254 |
+
before_text="",
|
| 255 |
+
after_text=after,
|
| 256 |
+
computed_metric="",
|
| 257 |
+
source=source_lit,
|
| 258 |
+
significance="HIGH",
|
| 259 |
+
term="",
|
| 260 |
+
))
|
| 261 |
+
|
| 262 |
+
# Removed: prior items not matched by any current item
|
| 263 |
+
for pi, item in enumerate(prior_items):
|
| 264 |
+
if pi not in matched_prior_indices:
|
| 265 |
+
before = _truncate(item, 120)
|
| 266 |
+
deltas.append(QuarterDelta(
|
| 267 |
+
kind="risk_removed",
|
| 268 |
+
period_from=period_from,
|
| 269 |
+
period_to=period_to,
|
| 270 |
+
before_text=before,
|
| 271 |
+
after_text="",
|
| 272 |
+
computed_metric="",
|
| 273 |
+
source=source_lit,
|
| 274 |
+
significance="MEDIUM",
|
| 275 |
+
term="",
|
| 276 |
+
))
|
| 277 |
+
|
| 278 |
+
# Keep at most 6 highest-significance deltas to avoid flooding the prompt
|
| 279 |
+
order = {"HIGH": 0, "MEDIUM": 1, "LOW": 2}
|
| 280 |
+
deltas.sort(key=lambda d: (order[d.significance], d.kind))
|
| 281 |
+
return deltas[:6]
|
| 282 |
+
|
| 283 |
+
|
| 284 |
+
# ---------------------------------------------------------------------------
|
| 285 |
+
# Analyst-lexicon frequency deltas
|
| 286 |
+
# ---------------------------------------------------------------------------
|
| 287 |
+
|
| 288 |
+
def compute_lexicon_deltas(
|
| 289 |
+
current_text: str,
|
| 290 |
+
prior_text: str,
|
| 291 |
+
period_from: str,
|
| 292 |
+
period_to: str,
|
| 293 |
+
form_type: str,
|
| 294 |
+
) -> list[QuarterDelta]:
|
| 295 |
+
"""Count analyst-lexicon term occurrences and flag large swings."""
|
| 296 |
+
if not current_text or not prior_text:
|
| 297 |
+
return []
|
| 298 |
+
|
| 299 |
+
source_lit = "10-K" if "10-K" in form_type.upper() else "10-Q"
|
| 300 |
+
cur_lower = current_text.lower()
|
| 301 |
+
pri_lower = prior_text.lower()
|
| 302 |
+
|
| 303 |
+
deltas: list[QuarterDelta] = []
|
| 304 |
+
|
| 305 |
+
for pattern, label in _LEXICON:
|
| 306 |
+
cur_count = len(re.findall(pattern, cur_lower, re.IGNORECASE))
|
| 307 |
+
pri_count = len(re.findall(pattern, pri_lower, re.IGNORECASE))
|
| 308 |
+
|
| 309 |
+
if cur_count == 0 and pri_count == 0:
|
| 310 |
+
continue
|
| 311 |
+
|
| 312 |
+
abs_diff = abs(cur_count - pri_count)
|
| 313 |
+
if abs_diff < _FREQ_ABS_THRESHOLD:
|
| 314 |
+
continue
|
| 315 |
+
|
| 316 |
+
# Require at least ×2 change in either direction
|
| 317 |
+
max_count = max(cur_count, pri_count)
|
| 318 |
+
min_count = min(cur_count, pri_count) or 0.5 # avoid div-by-zero
|
| 319 |
+
ratio = max_count / min_count
|
| 320 |
+
if ratio < _FREQ_RATIO_THRESHOLD:
|
| 321 |
+
continue
|
| 322 |
+
|
| 323 |
+
direction = "up" if cur_count > pri_count else "down"
|
| 324 |
+
pct = (cur_count - pri_count) / (pri_count or 1) * 100
|
| 325 |
+
metric = f"{pri_count}→{cur_count} occurrences ({pct:+.0f}%)"
|
| 326 |
+
|
| 327 |
+
# Significance: HIGH if ratio ≥ 3 or abs_diff ≥ 5
|
| 328 |
+
sig = "HIGH" if (ratio >= 3.0 or abs_diff >= 5) else "MEDIUM"
|
| 329 |
+
|
| 330 |
+
# Extract a context sentence for the term (from current or prior)
|
| 331 |
+
after_ctx = _find_context_sentence(current_text, pattern) if cur_count > 0 else ""
|
| 332 |
+
before_ctx = _find_context_sentence(prior_text, pattern) if pri_count > 0 else ""
|
| 333 |
+
|
| 334 |
+
deltas.append(QuarterDelta(
|
| 335 |
+
kind="term_frequency",
|
| 336 |
+
period_from=period_from,
|
| 337 |
+
period_to=period_to,
|
| 338 |
+
before_text=before_ctx,
|
| 339 |
+
after_text=after_ctx,
|
| 340 |
+
computed_metric=metric,
|
| 341 |
+
source=source_lit,
|
| 342 |
+
significance=sig,
|
| 343 |
+
term=label,
|
| 344 |
+
))
|
| 345 |
+
|
| 346 |
+
deltas.sort(key=lambda d: {"HIGH": 0, "MEDIUM": 1}.get(d.significance, 2))
|
| 347 |
+
return deltas[:5]
|
| 348 |
+
|
| 349 |
+
|
| 350 |
+
def _find_context_sentence(text: str, pattern: str) -> str:
|
| 351 |
+
"""Return the first sentence containing a match for `pattern`."""
|
| 352 |
+
sentences = _split_sentences(text)
|
| 353 |
+
for sent in sentences:
|
| 354 |
+
if re.search(pattern, sent, re.IGNORECASE):
|
| 355 |
+
return _truncate(sent, 100)
|
| 356 |
+
return ""
|
| 357 |
+
|
| 358 |
+
|
| 359 |
+
# ---------------------------------------------------------------------------
|
| 360 |
+
# Guidance / MD&A language shift
|
| 361 |
+
# ---------------------------------------------------------------------------
|
| 362 |
+
|
| 363 |
+
def compute_guidance_shifts(
|
| 364 |
+
current_mda: str,
|
| 365 |
+
prior_mda: str,
|
| 366 |
+
period_from: str,
|
| 367 |
+
period_to: str,
|
| 368 |
+
form_type: str,
|
| 369 |
+
) -> list[QuarterDelta]:
|
| 370 |
+
"""Detect forward-looking language becoming more cautious or more bullish."""
|
| 371 |
+
if not current_mda or not prior_mda:
|
| 372 |
+
return []
|
| 373 |
+
|
| 374 |
+
source_lit = "10-K" if "10-K" in form_type.upper() else "10-Q"
|
| 375 |
+
|
| 376 |
+
# Extract sentences that contain guidance / forward-looking language
|
| 377 |
+
cur_fwd = _forward_looking_sentences(current_mda)
|
| 378 |
+
pri_fwd = _forward_looking_sentences(prior_mda)
|
| 379 |
+
|
| 380 |
+
if not cur_fwd or not pri_fwd:
|
| 381 |
+
return []
|
| 382 |
+
|
| 383 |
+
# Count hedge words in guidance sentences
|
| 384 |
+
cur_hedge = _count_hedge(cur_fwd)
|
| 385 |
+
pri_hedge = _count_hedge(pri_fwd)
|
| 386 |
+
|
| 387 |
+
abs_diff = abs(cur_hedge - pri_hedge)
|
| 388 |
+
if abs_diff < 2:
|
| 389 |
+
return []
|
| 390 |
+
|
| 391 |
+
direction = "more cautious" if cur_hedge > pri_hedge else "more confident"
|
| 392 |
+
pct = (cur_hedge - pri_hedge) / (pri_hedge or 1) * 100
|
| 393 |
+
metric = f"{pri_hedge}→{cur_hedge} hedge-word occurrences ({pct:+.0f}%) → {direction}"
|
| 394 |
+
|
| 395 |
+
# Pick most representative sentence from each period
|
| 396 |
+
before_sent = _pick_representative(pri_fwd, prior_mda)
|
| 397 |
+
after_sent = _pick_representative(cur_fwd, current_mda)
|
| 398 |
+
|
| 399 |
+
sig = "HIGH" if abs_diff >= 5 else "MEDIUM"
|
| 400 |
+
|
| 401 |
+
return [QuarterDelta(
|
| 402 |
+
kind="guidance_language_shift",
|
| 403 |
+
period_from=period_from,
|
| 404 |
+
period_to=period_to,
|
| 405 |
+
before_text=before_sent,
|
| 406 |
+
after_text=after_sent,
|
| 407 |
+
computed_metric=metric,
|
| 408 |
+
source=source_lit,
|
| 409 |
+
significance=sig,
|
| 410 |
+
term="guidance tone",
|
| 411 |
+
)]
|
| 412 |
+
|
| 413 |
+
|
| 414 |
+
_FWD_PATTERNS = re.compile(
|
| 415 |
+
r"\b(expect|anticipate|forecast|guidance|outlook|project|target|plan\s+to|"
|
| 416 |
+
r"will\s+(?:grow|increase|decrease|decline|moderate)|believe\s+(?:we|our))\b",
|
| 417 |
+
re.IGNORECASE,
|
| 418 |
+
)
|
| 419 |
+
|
| 420 |
+
|
| 421 |
+
def _forward_looking_sentences(text: str) -> list[str]:
|
| 422 |
+
sentences = _split_sentences(text)
|
| 423 |
+
return [s for s in sentences if _FWD_PATTERNS.search(s)]
|
| 424 |
+
|
| 425 |
+
|
| 426 |
+
def _count_hedge(sentences: list[str]) -> int:
|
| 427 |
+
joined = " ".join(sentences).lower()
|
| 428 |
+
return sum(1 for w in _HEDGE_WORDS if w in joined)
|
| 429 |
+
|
| 430 |
+
|
| 431 |
+
def _pick_representative(sentences: list[str], full_text: str) -> str:
|
| 432 |
+
"""Return the shortest guidance sentence (most quotable) that contains a hedge word."""
|
| 433 |
+
hedge_sents = [
|
| 434 |
+
s for s in sentences
|
| 435 |
+
if any(h in s.lower() for h in _HEDGE_WORDS)
|
| 436 |
+
]
|
| 437 |
+
pool = hedge_sents if hedge_sents else sentences
|
| 438 |
+
pool_sorted = sorted(pool, key=lambda s: len(s.split()))
|
| 439 |
+
if pool_sorted:
|
| 440 |
+
return _truncate(pool_sorted[0], 100)
|
| 441 |
+
return _truncate(sentences[0], 100) if sentences else ""
|
| 442 |
+
|
| 443 |
+
|
| 444 |
+
# ---------------------------------------------------------------------------
|
| 445 |
+
# Dropped KPI detection
|
| 446 |
+
# ---------------------------------------------------------------------------
|
| 447 |
+
|
| 448 |
+
def compute_kpi_drops(
|
| 449 |
+
current_mda: str,
|
| 450 |
+
prior_mda: str,
|
| 451 |
+
period_from: str,
|
| 452 |
+
period_to: str,
|
| 453 |
+
form_type: str,
|
| 454 |
+
) -> list[QuarterDelta]:
|
| 455 |
+
"""Flag a KPI / metric label that appears in prior MD&A but not in current."""
|
| 456 |
+
if not current_mda or not prior_mda:
|
| 457 |
+
return []
|
| 458 |
+
|
| 459 |
+
source_lit = "10-K" if "10-K" in form_type.upper() else "10-Q"
|
| 460 |
+
cur_lower = current_mda.lower()
|
| 461 |
+
pri_lower = prior_mda.lower()
|
| 462 |
+
|
| 463 |
+
deltas: list[QuarterDelta] = []
|
| 464 |
+
for pattern, label in _KPI_PATTERNS:
|
| 465 |
+
in_current = bool(re.search(pattern, cur_lower, re.IGNORECASE))
|
| 466 |
+
in_prior = bool(re.search(pattern, pri_lower, re.IGNORECASE))
|
| 467 |
+
|
| 468 |
+
if in_prior and not in_current:
|
| 469 |
+
ctx = _find_context_sentence(prior_mda, pattern)
|
| 470 |
+
deltas.append(QuarterDelta(
|
| 471 |
+
kind="kpi_dropped",
|
| 472 |
+
period_from=period_from,
|
| 473 |
+
period_to=period_to,
|
| 474 |
+
before_text=ctx,
|
| 475 |
+
after_text="",
|
| 476 |
+
computed_metric=f"'{label}' mentioned in {period_from} MD&A, absent from {period_to}",
|
| 477 |
+
source=source_lit,
|
| 478 |
+
significance="MEDIUM",
|
| 479 |
+
term=label,
|
| 480 |
+
))
|
| 481 |
+
|
| 482 |
+
return deltas[:3]
|
| 483 |
+
|
| 484 |
+
|
| 485 |
+
# ---------------------------------------------------------------------------
|
| 486 |
+
# Helpers
|
| 487 |
+
# ---------------------------------------------------------------------------
|
| 488 |
+
|
| 489 |
+
def _truncate(text: str, max_words: int) -> str:
|
| 490 |
+
words = text.split()
|
| 491 |
+
if len(words) <= max_words:
|
| 492 |
+
return text
|
| 493 |
+
return " ".join(words[:max_words]) + "…"
|
| 494 |
+
|
| 495 |
+
|
| 496 |
+
# ---------------------------------------------------------------------------
|
| 497 |
+
# Main entry point
|
| 498 |
+
# ---------------------------------------------------------------------------
|
| 499 |
+
|
| 500 |
+
def compute(ticker: str, current_period: Optional[str] = None) -> list[QuarterDelta]:
|
| 501 |
+
"""Compute all text delta signals for a ticker.
|
| 502 |
+
|
| 503 |
+
Compares the current period (latest ingested 10-Q) against the prior
|
| 504 |
+
period (previous 10-Q). Returns an empty list if sections are missing
|
| 505 |
+
or an error occurs — never raises.
|
| 506 |
+
|
| 507 |
+
Args:
|
| 508 |
+
ticker: uppercase ticker symbol.
|
| 509 |
+
current_period: override the current period (default: latest in DB).
|
| 510 |
+
"""
|
| 511 |
+
try:
|
| 512 |
+
return _compute_inner(ticker, current_period)
|
| 513 |
+
except Exception as exc:
|
| 514 |
+
import sys
|
| 515 |
+
print(f"[textdiff] Error computing deltas for {ticker}: {exc}", file=sys.stderr)
|
| 516 |
+
return []
|
| 517 |
+
|
| 518 |
+
|
| 519 |
+
def _compute_inner(ticker: str, current_period: Optional[str]) -> list[QuarterDelta]:
|
| 520 |
+
ticker = ticker.upper()
|
| 521 |
+
|
| 522 |
+
# Determine current and prior periods (10-Q only for QoQ comparison)
|
| 523 |
+
periods = get_periods_for_ticker(ticker, form_type="10-Q")
|
| 524 |
+
if len(periods) < 2:
|
| 525 |
+
return []
|
| 526 |
+
|
| 527 |
+
period_to = current_period if current_period else periods[0]
|
| 528 |
+
# Find the prior period (the one just before period_to in the list)
|
| 529 |
+
if period_to in periods:
|
| 530 |
+
idx = periods.index(period_to)
|
| 531 |
+
if idx + 1 >= len(periods):
|
| 532 |
+
return []
|
| 533 |
+
period_from = periods[idx + 1]
|
| 534 |
+
else:
|
| 535 |
+
period_from = periods[1]
|
| 536 |
+
|
| 537 |
+
# Determine form_type for the current period (need it for source label)
|
| 538 |
+
# Look for any section stored for this period to infer form_type
|
| 539 |
+
# Default to 10-Q since we filtered above
|
| 540 |
+
form_type = "10-Q"
|
| 541 |
+
|
| 542 |
+
# Load sections
|
| 543 |
+
cur_risk = get_section(ticker, period_to, "risk_factors") or ""
|
| 544 |
+
pri_risk = get_section(ticker, period_from, "risk_factors") or ""
|
| 545 |
+
cur_mda = get_section(ticker, period_to, "mda") or ""
|
| 546 |
+
pri_mda = get_section(ticker, period_from, "mda") or ""
|
| 547 |
+
|
| 548 |
+
if not cur_risk and not cur_mda:
|
| 549 |
+
return []
|
| 550 |
+
|
| 551 |
+
all_deltas: list[QuarterDelta] = []
|
| 552 |
+
|
| 553 |
+
# 1. Risk factors diff
|
| 554 |
+
if cur_risk and pri_risk:
|
| 555 |
+
all_deltas.extend(compute_risk_deltas(cur_risk, pri_risk, period_from, period_to, form_type))
|
| 556 |
+
|
| 557 |
+
# 2. Lexicon frequency deltas (combined mda + risk text for broader coverage)
|
| 558 |
+
cur_full = (cur_mda + "\n\n" + cur_risk).strip()
|
| 559 |
+
pri_full = (pri_mda + "\n\n" + pri_risk).strip()
|
| 560 |
+
if cur_full and pri_full:
|
| 561 |
+
all_deltas.extend(compute_lexicon_deltas(cur_full, pri_full, period_from, period_to, form_type))
|
| 562 |
+
|
| 563 |
+
# 3. Guidance language shift (MD&A only)
|
| 564 |
+
if cur_mda and pri_mda:
|
| 565 |
+
all_deltas.extend(compute_guidance_shifts(cur_mda, pri_mda, period_from, period_to, form_type))
|
| 566 |
+
|
| 567 |
+
# 4. Dropped KPIs
|
| 568 |
+
if cur_mda and pri_mda:
|
| 569 |
+
all_deltas.extend(compute_kpi_drops(cur_mda, pri_mda, period_from, period_to, form_type))
|
| 570 |
+
|
| 571 |
+
# Deduplicate and sort: HIGH first, then MEDIUM, then LOW
|
| 572 |
+
seen: set[str] = set()
|
| 573 |
+
deduped: list[QuarterDelta] = []
|
| 574 |
+
for d in all_deltas:
|
| 575 |
+
key = f"{d.kind}:{d.term}:{d.before_text[:40]}"
|
| 576 |
+
if key not in seen:
|
| 577 |
+
seen.add(key)
|
| 578 |
+
deduped.append(d)
|
| 579 |
+
|
| 580 |
+
order = {"HIGH": 0, "MEDIUM": 1, "LOW": 2}
|
| 581 |
+
deduped.sort(key=lambda d: (order[d.significance], d.kind))
|
| 582 |
+
return deduped
|
app.py
CHANGED
|
@@ -120,7 +120,13 @@ def _run_brief_thread(ticker: str, gen: dict) -> None:
|
|
| 120 |
except Exception as exc:
|
| 121 |
import sys
|
| 122 |
print(f"[gen thread error] {exc}", file=sys.stderr)
|
| 123 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
finally:
|
| 125 |
gen["running"] = False
|
| 126 |
|
|
@@ -142,8 +148,7 @@ if generate_clicked and ticker_input and not st.session_state["gen"]["running"]:
|
|
| 142 |
t.start()
|
| 143 |
|
| 144 |
|
| 145 |
-
# ── Live trace
|
| 146 |
-
@st.fragment
|
| 147 |
def _live_trace_fragment() -> None:
|
| 148 |
gen = st.session_state.get("gen", {})
|
| 149 |
if not gen.get("running") and not gen.get("trace"):
|
|
@@ -179,7 +184,7 @@ def _live_trace_fragment() -> None:
|
|
| 179 |
if gen.get("running"):
|
| 180 |
# Still generating — poll again in 400 ms (server-side timer avoids HF Spaces proxy race)
|
| 181 |
time.sleep(0.4)
|
| 182 |
-
st.rerun(
|
| 183 |
else:
|
| 184 |
# Thread has finished — persist results and navigate
|
| 185 |
if gen.get("brief"):
|
|
|
|
| 120 |
except Exception as exc:
|
| 121 |
import sys
|
| 122 |
print(f"[gen thread error] {exc}", file=sys.stderr)
|
| 123 |
+
raw = str(exc)
|
| 124 |
+
if "overloaded_error" in raw:
|
| 125 |
+
gen["error"] = "Anthropic API is overloaded — please retry in a moment."
|
| 126 |
+
elif "rate_limit" in raw or "429" in raw:
|
| 127 |
+
gen["error"] = "Anthropic API rate limit reached — please retry in a moment."
|
| 128 |
+
else:
|
| 129 |
+
gen["error"] = raw
|
| 130 |
finally:
|
| 131 |
gen["running"] = False
|
| 132 |
|
|
|
|
| 148 |
t.start()
|
| 149 |
|
| 150 |
|
| 151 |
+
# ── Live trace poller (polls every 400ms while generation runs) ──────────────────
|
|
|
|
| 152 |
def _live_trace_fragment() -> None:
|
| 153 |
gen = st.session_state.get("gen", {})
|
| 154 |
if not gen.get("running") and not gen.get("trace"):
|
|
|
|
| 184 |
if gen.get("running"):
|
| 185 |
# Still generating — poll again in 400 ms (server-side timer avoids HF Spaces proxy race)
|
| 186 |
time.sleep(0.4)
|
| 187 |
+
st.rerun()
|
| 188 |
else:
|
| 189 |
# Thread has finished — persist results and navigate
|
| 190 |
if gen.get("brief"):
|
dashboard/catalysts.py
CHANGED
|
@@ -6,7 +6,7 @@ from dashboard.theme import (
|
|
| 6 |
BG, BG_MUTED, BORDER, TEXT, TEXT_MUTED, TEXT_FAINT,
|
| 7 |
)
|
| 8 |
from dashboard import fmt_period
|
| 9 |
-
from dashboard.components import section_header
|
| 10 |
|
| 11 |
_METRIC_COLORS = {
|
| 12 |
"Revenue": GREEN,
|
|
@@ -62,6 +62,33 @@ def render(brief: dict, ticker: str) -> None:
|
|
| 62 |
unsafe_allow_html=True,
|
| 63 |
)
|
| 64 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
# ── Guidance history ──────────────────────────────────────────────────────
|
| 66 |
if guidance:
|
| 67 |
st.markdown(
|
|
@@ -79,7 +106,12 @@ def render(brief: dict, ticker: str) -> None:
|
|
| 79 |
f'<div style="font-size:0.8rem;color:{TEXT_MUTED};margin-top:5px;line-height:1.45;">{actual_result}</div>'
|
| 80 |
if actual_result else ""
|
| 81 |
)
|
| 82 |
-
chips_html =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
st.markdown(
|
| 84 |
f'<div style="display:flex;gap:12px;align-items:flex-start;padding:12px 0;border-bottom:1px solid {BORDER};">'
|
| 85 |
f'<div style="min-width:90px;">'
|
|
@@ -152,8 +184,8 @@ def _render_db_guidance(ticker: str, shown_periods: set) -> None:
|
|
| 152 |
|
| 153 |
st.markdown(
|
| 154 |
f'<div style="font-size:0.72rem;color:{TEXT_FAINT};margin:16px 0 8px;line-height:1.5;">'
|
| 155 |
-
f'
|
| 156 |
-
f'
|
| 157 |
unsafe_allow_html=True,
|
| 158 |
)
|
| 159 |
|
|
|
|
| 6 |
BG, BG_MUTED, BORDER, TEXT, TEXT_MUTED, TEXT_FAINT,
|
| 7 |
)
|
| 8 |
from dashboard import fmt_period
|
| 9 |
+
from dashboard.components import section_header, impact_badge, reliability_badge, source_badge
|
| 10 |
|
| 11 |
_METRIC_COLORS = {
|
| 12 |
"Revenue": GREEN,
|
|
|
|
| 62 |
unsafe_allow_html=True,
|
| 63 |
)
|
| 64 |
|
| 65 |
+
# ── Scorecard hero ────────────────────────────────────────────────────────
|
| 66 |
+
if guidance:
|
| 67 |
+
beats = sum(1 for g in guidance if g.get("verdict") == "beat")
|
| 68 |
+
inline = sum(1 for g in guidance if g.get("verdict") == "in-line")
|
| 69 |
+
missed = sum(1 for g in guidance if g.get("verdict") == "missed")
|
| 70 |
+
pending = sum(1 for g in guidance if g.get("verdict") == "pending")
|
| 71 |
+
|
| 72 |
+
def _score_chip(label: str, count: int, color: str, bg: str) -> str:
|
| 73 |
+
return (
|
| 74 |
+
f'<div style="background:{bg};border:1px solid {color}2a;border-radius:8px;'
|
| 75 |
+
f'padding:12px 16px;text-align:center;">'
|
| 76 |
+
f'<div style="font-size:1.6rem;font-weight:800;color:{color};line-height:1;">{count}</div>'
|
| 77 |
+
f'<div style="font-size:0.62rem;font-weight:700;text-transform:uppercase;'
|
| 78 |
+
f'letter-spacing:0.08em;color:{color};margin-top:3px;">{label}</div>'
|
| 79 |
+
f'</div>'
|
| 80 |
+
)
|
| 81 |
+
|
| 82 |
+
score_html = (
|
| 83 |
+
f'<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin-bottom:20px;">'
|
| 84 |
+
f'{_score_chip("Beat", beats, GREEN, "#ecfdf5")}'
|
| 85 |
+
f'{_score_chip("In-line", inline, AMBER, "#fffbeb")}'
|
| 86 |
+
f'{_score_chip("Missed", missed, RED, "#fef2f2")}'
|
| 87 |
+
f'{_score_chip("Pending", pending, GRAY, "#f3f4f6")}'
|
| 88 |
+
f'</div>'
|
| 89 |
+
)
|
| 90 |
+
st.markdown(score_html, unsafe_allow_html=True)
|
| 91 |
+
|
| 92 |
# ── Guidance history ──────────────────────────────────────────────────────
|
| 93 |
if guidance:
|
| 94 |
st.markdown(
|
|
|
|
| 106 |
f'<div style="font-size:0.8rem;color:{TEXT_MUTED};margin-top:5px;line-height:1.45;">{actual_result}</div>'
|
| 107 |
if actual_result else ""
|
| 108 |
)
|
| 109 |
+
chips_html = (
|
| 110 |
+
f'<div style="margin-top:6px;display:flex;flex-wrap:wrap;align-items:center;gap:6px;">'
|
| 111 |
+
f'{_metric_chip(gp.get("metric_focus"))}{verdict_html}'
|
| 112 |
+
f'{impact_badge(gp.get("impact","") or "")}'
|
| 113 |
+
f'</div>'
|
| 114 |
+
)
|
| 115 |
st.markdown(
|
| 116 |
f'<div style="display:flex;gap:12px;align-items:flex-start;padding:12px 0;border-bottom:1px solid {BORDER};">'
|
| 117 |
f'<div style="min-width:90px;">'
|
|
|
|
| 184 |
|
| 185 |
st.markdown(
|
| 186 |
f'<div style="font-size:0.72rem;color:{TEXT_FAINT};margin:16px 0 8px;line-height:1.5;">'
|
| 187 |
+
f'Raw guidance detected by rule (regex on MD&A). The section above '
|
| 188 |
+
f'is the LLM analysis of the same guidance with beat/in-line/missed verdict.</div>',
|
| 189 |
unsafe_allow_html=True,
|
| 190 |
)
|
| 191 |
|
dashboard/components.py
CHANGED
|
@@ -6,6 +6,8 @@ from dashboard.theme import (
|
|
| 6 |
BG, BG_MUTED, TEXT, TEXT_MUTED,
|
| 7 |
WARN_BG, WARN_BORDER,
|
| 8 |
BULL_BG, BULL_BORDER, BEAR_BG, BEAR_BORDER,
|
|
|
|
|
|
|
| 9 |
)
|
| 10 |
|
| 11 |
# ── Reliability badge ─────────────────────────────────────────────────────────
|
|
@@ -19,6 +21,62 @@ def reliability_badge(r: str) -> str:
|
|
| 19 |
)
|
| 20 |
|
| 21 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
# ── Source type badge ─────────────────────────────────────────────────────────
|
| 23 |
|
| 24 |
_SOURCE_STYLES: dict[str, tuple[str, str, str]] = {
|
|
@@ -95,9 +153,10 @@ def fact_card(fact: dict, accent: str = GREEN) -> None:
|
|
| 95 |
{fact.get("text","")}
|
| 96 |
</div>
|
| 97 |
{evidence_quote(fact.get("evidence_snippet",""), bg=BG_MUTED, border_color=BORDER)}
|
| 98 |
-
<div style="margin-top:6px;">
|
| 99 |
{reliability_badge(fact.get("reliability",""))}
|
| 100 |
-
|
|
|
|
| 101 |
</div>
|
| 102 |
</div>
|
| 103 |
""",
|
|
@@ -132,28 +191,36 @@ def tension_card(tension: dict) -> None:
|
|
| 132 |
bull_side = (
|
| 133 |
f'<div style="flex:1;padding:12px 14px;background:{BULL_BG};border:1px solid {BULL_BORDER};'
|
| 134 |
f'border-radius:8px;min-width:0;">'
|
| 135 |
-
f'<div style="
|
| 136 |
-
f'
|
|
|
|
|
|
|
|
|
|
| 137 |
f'<div style="font-size:0.82rem;line-height:1.5;color:{TEXT};margin-bottom:8px;">'
|
| 138 |
f'{bullish_reading}</div>'
|
| 139 |
f'{evidence_quote(bull_ev.get("evidence_snippet","") if isinstance(bull_ev, dict) else "", bg=BG, border_color=BULL_BORDER)}'
|
| 140 |
-
f'<div style="margin-top:6px;">'
|
| 141 |
f'{reliability_badge(bull_ev.get("reliability","") if isinstance(bull_ev, dict) else "")}'
|
| 142 |
-
f'
|
|
|
|
| 143 |
f'</div>'
|
| 144 |
f'</div>'
|
| 145 |
)
|
| 146 |
bear_side = (
|
| 147 |
f'<div style="flex:1;padding:12px 14px;background:{BEAR_BG};border:1px solid {BEAR_BORDER};'
|
| 148 |
f'border-radius:8px;min-width:0;">'
|
| 149 |
-
f'<div style="
|
| 150 |
-
f'
|
|
|
|
|
|
|
|
|
|
| 151 |
f'<div style="font-size:0.82rem;line-height:1.5;color:{TEXT};margin-bottom:8px;">'
|
| 152 |
f'{bearish_reading}</div>'
|
| 153 |
f'{evidence_quote(bear_ev.get("evidence_snippet","") if isinstance(bear_ev, dict) else "", bg=BG, border_color=BEAR_BORDER)}'
|
| 154 |
-
f'<div style="margin-top:6px;">'
|
| 155 |
f'{reliability_badge(bear_ev.get("reliability","") if isinstance(bear_ev, dict) else "")}'
|
| 156 |
-
f'
|
|
|
|
| 157 |
f'</div>'
|
| 158 |
f'</div>'
|
| 159 |
)
|
|
@@ -210,10 +277,12 @@ def quality_signal_chip(signal: dict) -> str:
|
|
| 210 |
f'border-left:2px solid {border};padding-left:8px;line-height:1.4;">'
|
| 211 |
f'"{snippet}"</div>'
|
| 212 |
)
|
|
|
|
| 213 |
badges_html = ""
|
| 214 |
-
if ev_rel or ev_source:
|
| 215 |
badges_html = (
|
| 216 |
-
f'<div style="margin-top:6px;">
|
|
|
|
| 217 |
)
|
| 218 |
|
| 219 |
return (
|
|
@@ -227,8 +296,134 @@ def quality_signal_chip(signal: dict) -> str:
|
|
| 227 |
f'</summary>'
|
| 228 |
f'<div style="margin-top:4px;padding:10px 12px;background:{bg};border:1px solid {border};'
|
| 229 |
f'border-radius:8px;max-width:340px;">'
|
|
|
|
| 230 |
f'<div style="font-size:0.78rem;color:{TEXT};line-height:1.5;">{rationale}</div>'
|
| 231 |
f'{ev_html}{badges_html}'
|
| 232 |
f'</div>'
|
| 233 |
f'</details>'
|
| 234 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
BG, BG_MUTED, TEXT, TEXT_MUTED,
|
| 7 |
WARN_BG, WARN_BORDER,
|
| 8 |
BULL_BG, BULL_BORDER, BEAR_BG, BEAR_BORDER,
|
| 9 |
+
PURPLE, AI_BG, AI_BORDER, AI_COLOR, AI_BADGE_BG,
|
| 10 |
+
RADIUS_CHIP,
|
| 11 |
)
|
| 12 |
|
| 13 |
# ── Reliability badge ─────────────────────────────────────────────────────────
|
|
|
|
| 21 |
)
|
| 22 |
|
| 23 |
|
| 24 |
+
# ── Impact badge ─────────────────────────────────────────────────────────────
|
| 25 |
+
|
| 26 |
+
_IMPACT_DOT_COLORS = {
|
| 27 |
+
"HIGH": ("#0f172a", 3),
|
| 28 |
+
"MEDIUM": ("#64748b", 2),
|
| 29 |
+
"LOW": ("#94a3b8", 1),
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
def impact_badge(level: str) -> str:
|
| 33 |
+
spec = _IMPACT_DOT_COLORS.get(level)
|
| 34 |
+
if not spec:
|
| 35 |
+
return ""
|
| 36 |
+
color, filled = spec
|
| 37 |
+
dots = "".join(
|
| 38 |
+
f'<span style="color:{color if i < filled else "#d1d5db"};font-size:0.55rem;line-height:1;">●</span>'
|
| 39 |
+
for i in range(3)
|
| 40 |
+
)
|
| 41 |
+
return (
|
| 42 |
+
f'<span title="Impact: {level}" style="display:inline-flex;align-items:center;gap:3px;'
|
| 43 |
+
f'background:#f8fafc;border:1px solid #e2e8f0;border-radius:4px;padding:1px 7px;">'
|
| 44 |
+
f'<span style="font-size:0.6rem;font-weight:600;color:#64748b;letter-spacing:0.05em;">impact</span>'
|
| 45 |
+
f'{dots}</span>'
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
# ── AI interpretation badge ───────────────────────────────────────────────────
|
| 50 |
+
|
| 51 |
+
def ai_badge(label: str = "AI Synthesis") -> str:
|
| 52 |
+
return (
|
| 53 |
+
f'<span style="background:{AI_BADGE_BG};color:{AI_COLOR};'
|
| 54 |
+
f'border:1px solid {AI_BORDER};border-radius:4px;'
|
| 55 |
+
f'padding:1px 7px;font-size:0.62rem;font-weight:600;">✦ {label}</span>'
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
# ── AI interpretation card wrapper ────────────────────────────────────────────
|
| 60 |
+
|
| 61 |
+
def interpretation_card(
|
| 62 |
+
header_label: str,
|
| 63 |
+
content_html: str,
|
| 64 |
+
badge_label: str = "AI Synthesis",
|
| 65 |
+
) -> str:
|
| 66 |
+
return (
|
| 67 |
+
f'<div class="primer-card" style="background:{AI_BG};border:1px solid {AI_BORDER};'
|
| 68 |
+
f'border-left:4px solid {PURPLE};border-radius:0 12px 12px 0;'
|
| 69 |
+
f'padding:20px 24px;margin-bottom:4px;">'
|
| 70 |
+
f'<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">'
|
| 71 |
+
f'<span style="font-size:0.62rem;font-weight:700;letter-spacing:0.1em;'
|
| 72 |
+
f'text-transform:uppercase;color:{TEXT_MUTED};">{header_label}</span>'
|
| 73 |
+
f'{ai_badge(badge_label)}'
|
| 74 |
+
f'</div>'
|
| 75 |
+
f'{content_html}'
|
| 76 |
+
f'</div>'
|
| 77 |
+
)
|
| 78 |
+
|
| 79 |
+
|
| 80 |
# ── Source type badge ─────────────────────────────────────────────────────────
|
| 81 |
|
| 82 |
_SOURCE_STYLES: dict[str, tuple[str, str, str]] = {
|
|
|
|
| 153 |
{fact.get("text","")}
|
| 154 |
</div>
|
| 155 |
{evidence_quote(fact.get("evidence_snippet",""), bg=BG_MUTED, border_color=BORDER)}
|
| 156 |
+
<div style="margin-top:6px;display:flex;gap:5px;flex-wrap:wrap;align-items:center;">
|
| 157 |
{reliability_badge(fact.get("reliability",""))}
|
| 158 |
+
{source_badge(fact.get("source",""))}
|
| 159 |
+
{impact_badge(fact.get("impact","") or "")}
|
| 160 |
</div>
|
| 161 |
</div>
|
| 162 |
""",
|
|
|
|
| 191 |
bull_side = (
|
| 192 |
f'<div style="flex:1;padding:12px 14px;background:{BULL_BG};border:1px solid {BULL_BORDER};'
|
| 193 |
f'border-radius:8px;min-width:0;">'
|
| 194 |
+
f'<div style="display:flex;align-items:center;gap:5px;margin-bottom:6px;">'
|
| 195 |
+
f'<span style="font-size:0.58rem;font-weight:700;text-transform:uppercase;'
|
| 196 |
+
f'letter-spacing:0.09em;color:#059669;">Surface reading</span>'
|
| 197 |
+
f'{ai_badge("AI")}'
|
| 198 |
+
f'</div>'
|
| 199 |
f'<div style="font-size:0.82rem;line-height:1.5;color:{TEXT};margin-bottom:8px;">'
|
| 200 |
f'{bullish_reading}</div>'
|
| 201 |
f'{evidence_quote(bull_ev.get("evidence_snippet","") if isinstance(bull_ev, dict) else "", bg=BG, border_color=BULL_BORDER)}'
|
| 202 |
+
f'<div style="margin-top:6px;display:flex;gap:5px;flex-wrap:wrap;align-items:center;">'
|
| 203 |
f'{reliability_badge(bull_ev.get("reliability","") if isinstance(bull_ev, dict) else "")}'
|
| 204 |
+
f'{source_badge(bull_ev.get("source","") if isinstance(bull_ev, dict) else "")}'
|
| 205 |
+
f'{impact_badge((bull_ev.get("impact","") or "") if isinstance(bull_ev, dict) else "")}'
|
| 206 |
f'</div>'
|
| 207 |
f'</div>'
|
| 208 |
)
|
| 209 |
bear_side = (
|
| 210 |
f'<div style="flex:1;padding:12px 14px;background:{BEAR_BG};border:1px solid {BEAR_BORDER};'
|
| 211 |
f'border-radius:8px;min-width:0;">'
|
| 212 |
+
f'<div style="display:flex;align-items:center;gap:5px;margin-bottom:6px;">'
|
| 213 |
+
f'<span style="font-size:0.58rem;font-weight:700;text-transform:uppercase;'
|
| 214 |
+
f'letter-spacing:0.09em;color:#dc2626;">Deeper reading</span>'
|
| 215 |
+
f'{ai_badge("AI")}'
|
| 216 |
+
f'</div>'
|
| 217 |
f'<div style="font-size:0.82rem;line-height:1.5;color:{TEXT};margin-bottom:8px;">'
|
| 218 |
f'{bearish_reading}</div>'
|
| 219 |
f'{evidence_quote(bear_ev.get("evidence_snippet","") if isinstance(bear_ev, dict) else "", bg=BG, border_color=BEAR_BORDER)}'
|
| 220 |
+
f'<div style="margin-top:6px;display:flex;gap:5px;flex-wrap:wrap;align-items:center;">'
|
| 221 |
f'{reliability_badge(bear_ev.get("reliability","") if isinstance(bear_ev, dict) else "")}'
|
| 222 |
+
f'{source_badge(bear_ev.get("source","") if isinstance(bear_ev, dict) else "")}'
|
| 223 |
+
f'{impact_badge((bear_ev.get("impact","") or "") if isinstance(bear_ev, dict) else "")}'
|
| 224 |
f'</div>'
|
| 225 |
f'</div>'
|
| 226 |
)
|
|
|
|
| 277 |
f'border-left:2px solid {border};padding-left:8px;line-height:1.4;">'
|
| 278 |
f'"{snippet}"</div>'
|
| 279 |
)
|
| 280 |
+
ev_impact = evidence.get("impact", "") or "" if isinstance(evidence, dict) else ""
|
| 281 |
badges_html = ""
|
| 282 |
+
if ev_rel or ev_source or ev_impact:
|
| 283 |
badges_html = (
|
| 284 |
+
f'<div style="margin-top:6px;display:flex;gap:5px;flex-wrap:wrap;align-items:center;">'
|
| 285 |
+
f'{reliability_badge(ev_rel)}{source_badge(ev_source)}{impact_badge(ev_impact)}</div>'
|
| 286 |
)
|
| 287 |
|
| 288 |
return (
|
|
|
|
| 296 |
f'</summary>'
|
| 297 |
f'<div style="margin-top:4px;padding:10px 12px;background:{bg};border:1px solid {border};'
|
| 298 |
f'border-radius:8px;max-width:340px;">'
|
| 299 |
+
f'<div style="margin-bottom:5px;">{ai_badge("AI assessment")}</div>'
|
| 300 |
f'<div style="font-size:0.78rem;color:{TEXT};line-height:1.5;">{rationale}</div>'
|
| 301 |
f'{ev_html}{badges_html}'
|
| 302 |
f'</div>'
|
| 303 |
f'</details>'
|
| 304 |
)
|
| 305 |
+
|
| 306 |
+
|
| 307 |
+
# ── Compact labeled stat chip ─────────────────────────────────────────────────
|
| 308 |
+
|
| 309 |
+
def stat_chip(label: str, value: str, tone: str = "neutral") -> str:
|
| 310 |
+
"""Compact labeled stat chip for hero bands and market reaction strips."""
|
| 311 |
+
_tones: dict[str, tuple[str, str]] = {
|
| 312 |
+
"positive": (GREEN, BULL_BG),
|
| 313 |
+
"negative": (RED, BEAR_BG),
|
| 314 |
+
"neutral": (GRAY, BG_MUTED),
|
| 315 |
+
}
|
| 316 |
+
color, bg = _tones.get(tone, _tones["neutral"])
|
| 317 |
+
return (
|
| 318 |
+
f'<div style="background:{bg};border:1px solid {color}2a;border-radius:{RADIUS_CHIP};'
|
| 319 |
+
f'padding:10px 14px;">'
|
| 320 |
+
f'<div style="font-size:0.58rem;font-weight:700;text-transform:uppercase;'
|
| 321 |
+
f'letter-spacing:0.08em;color:{TEXT_MUTED};margin-bottom:3px;">{label}</div>'
|
| 322 |
+
f'<div style="font-size:1rem;font-weight:700;color:{color};line-height:1.2;">{value}</div>'
|
| 323 |
+
f'</div>'
|
| 324 |
+
)
|
| 325 |
+
|
| 326 |
+
|
| 327 |
+
# ── AI section header (horizontal rule with label) ────────────────────────────
|
| 328 |
+
|
| 329 |
+
def ai_section_header(title: str) -> str:
|
| 330 |
+
"""Lavender divider-label used to open an AI interpretation zone."""
|
| 331 |
+
return (
|
| 332 |
+
f'<div style="display:flex;align-items:center;gap:10px;margin:24px 0 12px;">'
|
| 333 |
+
f'<span style="font-size:0.65rem;font-weight:700;letter-spacing:0.12em;'
|
| 334 |
+
f'text-transform:uppercase;color:{AI_COLOR};white-space:nowrap;">✦ {title}</span>'
|
| 335 |
+
f'<div style="flex:1;height:1px;background:{AI_BORDER};"></div>'
|
| 336 |
+
f'</div>'
|
| 337 |
+
)
|
| 338 |
+
|
| 339 |
+
|
| 340 |
+
# ── Analyst Edge components ───────────────────────────────────────────────────
|
| 341 |
+
|
| 342 |
+
_DELTA_KIND_META: dict[str, tuple[str, str, str]] = {
|
| 343 |
+
# kind → (label, fg_color, bg_color)
|
| 344 |
+
"risk_added": ("NEW RISK", "#ef4444", "#fef2f2"),
|
| 345 |
+
"risk_removed": ("REMOVED RISK", "#6b7280", "#f3f4f6"),
|
| 346 |
+
"risk_reworded": ("REWORDED RISK", "#f59e0b", "#fffbeb"),
|
| 347 |
+
"guidance_language_shift": ("GUIDANCE SHIFT", "#8b5cf6", "#faf5ff"),
|
| 348 |
+
"term_frequency": ("FREQUENCY SHIFT", "#0ea5e9", "#f0f9ff"),
|
| 349 |
+
"kpi_dropped": ("DROPPED KPI", "#6b7280", "#f3f4f6"),
|
| 350 |
+
}
|
| 351 |
+
|
| 352 |
+
_SIG_COLORS: dict[str, str] = {"HIGH": "#ef4444", "MEDIUM": "#f59e0b", "LOW": "#9ca3af"}
|
| 353 |
+
|
| 354 |
+
|
| 355 |
+
def significance_badge(sig: str) -> str:
|
| 356 |
+
color = _SIG_COLORS.get(sig, "#9ca3af")
|
| 357 |
+
return (
|
| 358 |
+
f'<span style="background:{color}1a;color:{color};border:1px solid {color}44;'
|
| 359 |
+
f'border-radius:4px;padding:1px 7px;font-size:0.65rem;font-weight:700;'
|
| 360 |
+
f'text-transform:uppercase;letter-spacing:0.06em;">{sig}</span>'
|
| 361 |
+
)
|
| 362 |
+
|
| 363 |
+
|
| 364 |
+
def _redline_block(before: str, after: str) -> str:
|
| 365 |
+
"""Render before→after text with redline-style color coding."""
|
| 366 |
+
parts: list[str] = []
|
| 367 |
+
if before:
|
| 368 |
+
parts.append(
|
| 369 |
+
f'<div style="padding:8px 10px;background:#fef2f2;border-left:3px solid #fca5a5;'
|
| 370 |
+
f'border-radius:0 4px 4px 0;margin-bottom:4px;">'
|
| 371 |
+
f'<span style="font-size:0.6rem;font-weight:700;text-transform:uppercase;'
|
| 372 |
+
f'color:#ef4444;letter-spacing:0.07em;">before</span>'
|
| 373 |
+
f'<div style="font-size:0.78rem;font-style:italic;color:#7f1d1d;line-height:1.45;'
|
| 374 |
+
f'margin-top:3px;">“{before}”</div>'
|
| 375 |
+
f'</div>'
|
| 376 |
+
)
|
| 377 |
+
if after:
|
| 378 |
+
parts.append(
|
| 379 |
+
f'<div style="padding:8px 10px;background:#ecfdf5;border-left:3px solid #6ee7b7;'
|
| 380 |
+
f'border-radius:0 4px 4px 0;">'
|
| 381 |
+
f'<span style="font-size:0.6rem;font-weight:700;text-transform:uppercase;'
|
| 382 |
+
f'color:#10b981;letter-spacing:0.07em;">after</span>'
|
| 383 |
+
f'<div style="font-size:0.78rem;font-style:italic;color:#064e3b;line-height:1.45;'
|
| 384 |
+
f'margin-top:3px;">“{after}”</div>'
|
| 385 |
+
f'</div>'
|
| 386 |
+
)
|
| 387 |
+
return "".join(parts)
|
| 388 |
+
|
| 389 |
+
|
| 390 |
+
def delta_card(delta: dict) -> None:
|
| 391 |
+
"""Render a QuarterDelta as a redline-style card in Streamlit."""
|
| 392 |
+
kind = delta.get("kind", "")
|
| 393 |
+
label, fg, bg = _DELTA_KIND_META.get(kind, ("CHANGE", AMBER, WARN_BG))
|
| 394 |
+
sig = delta.get("significance", "MEDIUM")
|
| 395 |
+
term = delta.get("term", "")
|
| 396 |
+
period_from = delta.get("period_from", "")
|
| 397 |
+
period_to = delta.get("period_to", "")
|
| 398 |
+
before = delta.get("before_text", "")
|
| 399 |
+
after = delta.get("after_text", "")
|
| 400 |
+
metric = delta.get("computed_metric", "")
|
| 401 |
+
source = delta.get("source", "")
|
| 402 |
+
|
| 403 |
+
period_str = f"{period_from} → {period_to}" if period_from and period_to else ""
|
| 404 |
+
term_str = f" · {term}" if term else ""
|
| 405 |
+
metric_html = (
|
| 406 |
+
f'<div style="font-size:0.72rem;font-weight:600;color:#374151;'
|
| 407 |
+
f'background:#f8fafc;border:1px solid #e2e8f0;border-radius:4px;'
|
| 408 |
+
f'padding:4px 8px;margin-bottom:8px;">'
|
| 409 |
+
f'<span style="color:{TEXT_MUTED};font-size:0.65rem;font-weight:500;">'
|
| 410 |
+
f'computed · </span>{metric}'
|
| 411 |
+
f'</div>'
|
| 412 |
+
) if metric else ""
|
| 413 |
+
|
| 414 |
+
st.markdown(
|
| 415 |
+
f'<div class="primer-card" style="background:{bg};border:1px solid {fg}44;'
|
| 416 |
+
f'border-left:4px solid {fg};border-radius:0 10px 10px 0;'
|
| 417 |
+
f'padding:14px 16px;margin-bottom:8px;">'
|
| 418 |
+
f'<div style="display:flex;align-items:center;gap:6px;margin-bottom:8px;flex-wrap:wrap;">'
|
| 419 |
+
f'<span style="font-size:0.6rem;font-weight:700;text-transform:uppercase;'
|
| 420 |
+
f'letter-spacing:0.1em;color:{fg};">{label}{term_str}</span>'
|
| 421 |
+
f'{significance_badge(sig)}'
|
| 422 |
+
f'<span style="font-size:0.65rem;color:{TEXT_MUTED};margin-left:auto;">{period_str}</span>'
|
| 423 |
+
f'{source_badge(source)}'
|
| 424 |
+
f'</div>'
|
| 425 |
+
f'{metric_html}'
|
| 426 |
+
f'{_redline_block(before, after)}'
|
| 427 |
+
f'</div>',
|
| 428 |
+
unsafe_allow_html=True,
|
| 429 |
+
)
|
dashboard/earnings_call.py
CHANGED
|
@@ -7,7 +7,7 @@ from dashboard.theme import (
|
|
| 7 |
WARN_BG, WARN_BORDER,
|
| 8 |
)
|
| 9 |
from dashboard import fmt_period
|
| 10 |
-
from dashboard.components import section_header, evidence_quote
|
| 11 |
|
| 12 |
|
| 13 |
def _source_badge(source: str) -> str:
|
|
@@ -71,6 +71,10 @@ def render(brief: dict, ticker: str) -> None:
|
|
| 71 |
{t.get("summary","")}
|
| 72 |
</div>
|
| 73 |
{evidence_quote(t.get("evidence_snippet",""), bg=BG, border_color=BORDER)}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
</div>
|
| 75 |
""",
|
| 76 |
unsafe_allow_html=True,
|
|
@@ -111,7 +115,7 @@ def render(brief: dict, ticker: str) -> None:
|
|
| 111 |
for r in results:
|
| 112 |
m = r["metadata"]
|
| 113 |
context = m.get("chunk_context") or f"transcript · {m.get('date', m.get('filing_date',''))}"
|
| 114 |
-
with st.expander(f"🎙️ {context}", expanded=
|
| 115 |
text = r["text"]
|
| 116 |
st.markdown(
|
| 117 |
f'<div style="font-size:0.85rem;line-height:1.8;color:{TEXT};white-space:pre-wrap;">{text}</div>',
|
|
|
|
| 7 |
WARN_BG, WARN_BORDER,
|
| 8 |
)
|
| 9 |
from dashboard import fmt_period
|
| 10 |
+
from dashboard.components import section_header, evidence_quote, reliability_badge, impact_badge
|
| 11 |
|
| 12 |
|
| 13 |
def _source_badge(source: str) -> str:
|
|
|
|
| 71 |
{t.get("summary","")}
|
| 72 |
</div>
|
| 73 |
{evidence_quote(t.get("evidence_snippet",""), bg=BG, border_color=BORDER)}
|
| 74 |
+
<div style="margin-top:6px;display:flex;gap:5px;flex-wrap:wrap;align-items:center;">
|
| 75 |
+
{reliability_badge(t.get("reliability",""))}
|
| 76 |
+
{impact_badge(t.get("impact","") or "")}
|
| 77 |
+
</div>
|
| 78 |
</div>
|
| 79 |
""",
|
| 80 |
unsafe_allow_html=True,
|
|
|
|
| 115 |
for r in results:
|
| 116 |
m = r["metadata"]
|
| 117 |
context = m.get("chunk_context") or f"transcript · {m.get('date', m.get('filing_date',''))}"
|
| 118 |
+
with st.expander(f"🎙️ {context}", expanded=False):
|
| 119 |
text = r["text"]
|
| 120 |
st.markdown(
|
| 121 |
f'<div style="font-size:0.85rem;line-height:1.8;color:{TEXT};white-space:pre-wrap;">{text}</div>',
|
dashboard/mda.py
CHANGED
|
@@ -4,10 +4,9 @@ import streamlit as st
|
|
| 4 |
from dashboard.theme import (
|
| 5 |
GREEN, AMBER, RED,
|
| 6 |
BG, BG_MUTED, BORDER, TEXT, TEXT_MUTED, TEXT_FAINT,
|
| 7 |
-
INFO, INFO_BG, INFO_BORDER,
|
| 8 |
)
|
| 9 |
from dashboard import fmt_period
|
| 10 |
-
from dashboard.components import reliability_badge, source_badge, section_header, evidence_quote, fact_card
|
| 11 |
|
| 12 |
|
| 13 |
|
|
@@ -23,44 +22,57 @@ def render(brief: dict, ticker: str) -> None:
|
|
| 23 |
unsafe_allow_html=True,
|
| 24 |
)
|
| 25 |
|
| 26 |
-
# ── Key Quote ────────────────────
|
|
|
|
| 27 |
kq = mda.get("key_quote", {})
|
| 28 |
-
if kq:
|
| 29 |
-
with st.expander("🗣️ Key Management Statement", expanded=True):
|
| 30 |
-
st.markdown(
|
| 31 |
-
f"""
|
| 32 |
-
<div class="primer-card" style="background:{BG_MUTED};border:1px solid {BORDER};
|
| 33 |
-
border-radius:12px;padding:18px 20px;margin-bottom:16px;">
|
| 34 |
-
<div style="font-size:1.05rem;font-style:italic;line-height:1.6;
|
| 35 |
-
margin-bottom:8px;color:{TEXT};">
|
| 36 |
-
"{kq.get("evidence_snippet","")}"
|
| 37 |
-
</div>
|
| 38 |
-
<div style="font-size:0.85rem;color:{TEXT_MUTED};margin-bottom:8px;">{kq.get("text","")}</div>
|
| 39 |
-
<div>
|
| 40 |
-
{reliability_badge(kq.get("reliability",""))}
|
| 41 |
-
{source_badge(kq.get("source",""))}
|
| 42 |
-
</div>
|
| 43 |
-
</div>
|
| 44 |
-
""",
|
| 45 |
-
unsafe_allow_html=True,
|
| 46 |
-
)
|
| 47 |
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
|
| 62 |
-
# ── Drivers & Headwinds ────────────────
|
| 63 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
col_d, col_h = st.columns(2)
|
| 65 |
|
| 66 |
with col_d:
|
|
@@ -69,7 +81,7 @@ def render(brief: dict, ticker: str) -> None:
|
|
| 69 |
f'letter-spacing:0.08em;margin-bottom:8px;">📈 REVENUE & MARGIN DRIVERS</div>',
|
| 70 |
unsafe_allow_html=True,
|
| 71 |
)
|
| 72 |
-
for fact in
|
| 73 |
fact_card(fact, accent=GREEN)
|
| 74 |
|
| 75 |
with col_h:
|
|
@@ -78,7 +90,7 @@ def render(brief: dict, ticker: str) -> None:
|
|
| 78 |
f'letter-spacing:0.08em;margin-bottom:8px;">📉 HEADWINDS & DRAGS</div>',
|
| 79 |
unsafe_allow_html=True,
|
| 80 |
)
|
| 81 |
-
for fact in
|
| 82 |
fact_card(fact, accent=RED)
|
| 83 |
|
| 84 |
# ── Source Text Browser ───────────────────────────────────────────────────
|
|
@@ -116,7 +128,7 @@ def render(brief: dict, ticker: str) -> None:
|
|
| 116 |
for r in results:
|
| 117 |
m = r["metadata"]
|
| 118 |
context = m.get("chunk_context") or f"{m.get('source','filing')} · {m.get('section','')} · {m.get('filing_date','')}"
|
| 119 |
-
with st.expander(f"📄 {context}", expanded=
|
| 120 |
st.markdown(
|
| 121 |
f'<div style="font-size:0.85rem;line-height:1.7;color:{TEXT};">{r["text"]}</div>',
|
| 122 |
unsafe_allow_html=True,
|
|
|
|
| 4 |
from dashboard.theme import (
|
| 5 |
GREEN, AMBER, RED,
|
| 6 |
BG, BG_MUTED, BORDER, TEXT, TEXT_MUTED, TEXT_FAINT,
|
|
|
|
| 7 |
)
|
| 8 |
from dashboard import fmt_period
|
| 9 |
+
from dashboard.components import reliability_badge, source_badge, impact_badge, section_header, evidence_quote, fact_card, interpretation_card, ai_section_header
|
| 10 |
|
| 11 |
|
| 12 |
|
|
|
|
| 22 |
unsafe_allow_html=True,
|
| 23 |
)
|
| 24 |
|
| 25 |
+
# ── AI BAND: Language Shift + Key Quote (côte à côte) ────────────────────
|
| 26 |
+
lang = mda.get("language_shift", "")
|
| 27 |
kq = mda.get("key_quote", {})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
|
| 29 |
+
if lang or kq:
|
| 30 |
+
st.markdown(ai_section_header("AI Interpretation"), unsafe_allow_html=True)
|
| 31 |
+
col_lang, col_kq = st.columns(2)
|
| 32 |
+
|
| 33 |
+
with col_lang:
|
| 34 |
+
if lang:
|
| 35 |
+
st.markdown(
|
| 36 |
+
interpretation_card(
|
| 37 |
+
"Language shift vs prior periods",
|
| 38 |
+
f'<div style="font-size:0.9rem;line-height:1.6;color:{TEXT};">{lang}</div>',
|
| 39 |
+
),
|
| 40 |
+
unsafe_allow_html=True,
|
| 41 |
+
)
|
| 42 |
+
|
| 43 |
+
with col_kq:
|
| 44 |
+
if kq:
|
| 45 |
+
st.markdown(
|
| 46 |
+
f'<div class="primer-card" style="background:{BG_MUTED};border:1px solid {BORDER};'
|
| 47 |
+
f'border-radius:12px;padding:18px 20px;">'
|
| 48 |
+
f'<div style="font-size:0.62rem;font-weight:700;letter-spacing:0.1em;'
|
| 49 |
+
f'text-transform:uppercase;color:{TEXT_MUTED};margin-bottom:8px;">Key management statement</div>'
|
| 50 |
+
f'<div style="font-size:0.98rem;font-style:italic;line-height:1.6;'
|
| 51 |
+
f'margin-bottom:8px;color:{TEXT};">"{kq.get("evidence_snippet","")}"</div>'
|
| 52 |
+
f'<div style="font-size:0.82rem;color:{TEXT_MUTED};margin-bottom:8px;">'
|
| 53 |
+
f'{kq.get("text","")}</div>'
|
| 54 |
+
f'<div style="display:flex;gap:5px;flex-wrap:wrap;align-items:center;">'
|
| 55 |
+
f'{reliability_badge(kq.get("reliability",""))}'
|
| 56 |
+
f'{source_badge(kq.get("source",""))}'
|
| 57 |
+
f'{impact_badge(kq.get("impact","") or "")}'
|
| 58 |
+
f'</div>'
|
| 59 |
+
f'</div>',
|
| 60 |
+
unsafe_allow_html=True,
|
| 61 |
+
)
|
| 62 |
|
| 63 |
+
# ── Drivers & Headwinds (ouvert par défaut, sans expander) ────────────────
|
| 64 |
+
drivers = mda.get("drivers", [])
|
| 65 |
+
headwinds = mda.get("headwinds", [])
|
| 66 |
+
if drivers or headwinds:
|
| 67 |
+
st.markdown(
|
| 68 |
+
f'<div style="display:flex;align-items:center;gap:10px;margin:24px 0 12px;">'
|
| 69 |
+
f'<span style="font-size:0.65rem;font-weight:700;letter-spacing:0.1em;'
|
| 70 |
+
f'text-transform:uppercase;color:{TEXT_MUTED};white-space:nowrap;">'
|
| 71 |
+
f'Drivers & Headwinds</span>'
|
| 72 |
+
f'<div style="flex:1;height:1px;background:{BORDER};"></div>'
|
| 73 |
+
f'</div>',
|
| 74 |
+
unsafe_allow_html=True,
|
| 75 |
+
)
|
| 76 |
col_d, col_h = st.columns(2)
|
| 77 |
|
| 78 |
with col_d:
|
|
|
|
| 81 |
f'letter-spacing:0.08em;margin-bottom:8px;">📈 REVENUE & MARGIN DRIVERS</div>',
|
| 82 |
unsafe_allow_html=True,
|
| 83 |
)
|
| 84 |
+
for fact in drivers:
|
| 85 |
fact_card(fact, accent=GREEN)
|
| 86 |
|
| 87 |
with col_h:
|
|
|
|
| 90 |
f'letter-spacing:0.08em;margin-bottom:8px;">📉 HEADWINDS & DRAGS</div>',
|
| 91 |
unsafe_allow_html=True,
|
| 92 |
)
|
| 93 |
+
for fact in headwinds:
|
| 94 |
fact_card(fact, accent=RED)
|
| 95 |
|
| 96 |
# ── Source Text Browser ───────────────────────────────────────────────────
|
|
|
|
| 128 |
for r in results:
|
| 129 |
m = r["metadata"]
|
| 130 |
context = m.get("chunk_context") or f"{m.get('source','filing')} · {m.get('section','')} · {m.get('filing_date','')}"
|
| 131 |
+
with st.expander(f"📄 {context}", expanded=False):
|
| 132 |
st.markdown(
|
| 133 |
f'<div style="font-size:0.85rem;line-height:1.7;color:{TEXT};">{r["text"]}</div>',
|
| 134 |
unsafe_allow_html=True,
|
dashboard/risks.py
CHANGED
|
@@ -6,7 +6,7 @@ from dashboard.theme import (
|
|
| 6 |
BG, BG_MUTED, BORDER, TEXT, TEXT_MUTED, TEXT_FAINT,
|
| 7 |
WARN_BG, WARN_BORDER,
|
| 8 |
)
|
| 9 |
-
from dashboard.components import reliability_badge, source_badge, section_header
|
| 10 |
|
| 11 |
_CATEGORY_COLORS = {
|
| 12 |
"Regulatory": "#8b5cf6",
|
|
@@ -43,20 +43,49 @@ def render(brief: dict, ticker: str) -> None:
|
|
| 43 |
_render_risk_search(ticker)
|
| 44 |
return
|
| 45 |
|
| 46 |
-
|
| 47 |
-
if
|
| 48 |
st.markdown(
|
| 49 |
-
f"
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
unsafe_allow_html=True,
|
| 61 |
)
|
| 62 |
|
|
@@ -94,9 +123,10 @@ def render(brief: dict, ticker: str) -> None:
|
|
| 94 |
<div style="font-size:0.9rem;line-height:1.5;margin-bottom:6px;color:{TEXT};">
|
| 95 |
{r.get("text","")} {new_badge}
|
| 96 |
</div>
|
| 97 |
-
<div style="margin-top:4px;">
|
| 98 |
{reliability_badge(r.get("reliability",""))}
|
| 99 |
-
|
|
|
|
| 100 |
</div>
|
| 101 |
</div>
|
| 102 |
""",
|
|
@@ -124,7 +154,7 @@ def _render_risk_search(ticker: str) -> None:
|
|
| 124 |
for r in results:
|
| 125 |
m = r["metadata"]
|
| 126 |
context = m.get("chunk_context") or f"{m.get('source','filing')} · {m.get('section','')} · {m.get('filing_date','')}"
|
| 127 |
-
with st.expander(f"📄 {context}", expanded=
|
| 128 |
st.markdown(
|
| 129 |
f'<div style="font-size:0.85rem;line-height:1.7;color:{TEXT};">{r["text"]}</div>',
|
| 130 |
unsafe_allow_html=True,
|
|
|
|
| 6 |
BG, BG_MUTED, BORDER, TEXT, TEXT_MUTED, TEXT_FAINT,
|
| 7 |
WARN_BG, WARN_BORDER,
|
| 8 |
)
|
| 9 |
+
from dashboard.components import reliability_badge, source_badge, impact_badge, section_header
|
| 10 |
|
| 11 |
_CATEGORY_COLORS = {
|
| 12 |
"Regulatory": "#8b5cf6",
|
|
|
|
| 43 |
_render_risk_search(ticker)
|
| 44 |
return
|
| 45 |
|
| 46 |
+
new_risks = [r for r in risks if r.get("is_new_this_filing")]
|
| 47 |
+
if new_risks:
|
| 48 |
st.markdown(
|
| 49 |
+
f'<div style="font-size:0.72rem;color:{RED};font-weight:700;'
|
| 50 |
+
f'letter-spacing:0.08em;margin-bottom:10px;">🆕 NEW THIS FILING ({len(new_risks)})</div>',
|
| 51 |
+
unsafe_allow_html=True,
|
| 52 |
+
)
|
| 53 |
+
for r in new_risks:
|
| 54 |
+
cat = r.get("category", "Other")
|
| 55 |
+
color = _CATEGORY_COLORS.get(cat, GRAY)
|
| 56 |
+
icon = _CATEGORY_ICONS.get(cat, "●")
|
| 57 |
+
new_badge = (
|
| 58 |
+
f'<span style="background:#fef2f2;color:{RED};border:1px solid #fecaca;'
|
| 59 |
+
f'border-radius:4px;padding:1px 7px;font-size:0.7rem;font-weight:700;">NEW ↑</span>'
|
| 60 |
+
)
|
| 61 |
+
cat_chip = (
|
| 62 |
+
f'<span style="background:{color}18;color:{color};border:1px solid {color}33;'
|
| 63 |
+
f'border-radius:4px;padding:1px 7px;font-size:0.68rem;font-weight:600;">'
|
| 64 |
+
f'{icon} {cat}</span>'
|
| 65 |
+
)
|
| 66 |
+
st.markdown(
|
| 67 |
+
f'<div class="primer-card" style="background:{WARN_BG};border:1px solid {WARN_BORDER};'
|
| 68 |
+
f'border-left:3px solid {RED};border-radius:0 10px 10px 0;'
|
| 69 |
+
f'padding:16px 20px;margin-bottom:8px;">'
|
| 70 |
+
f'<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">'
|
| 71 |
+
f'{new_badge}{cat_chip}'
|
| 72 |
+
f'</div>'
|
| 73 |
+
f'<div style="font-size:0.9rem;line-height:1.5;margin-bottom:6px;color:{TEXT};">'
|
| 74 |
+
f'{r.get("text","")}</div>'
|
| 75 |
+
f'<div style="margin-top:4px;display:flex;gap:5px;flex-wrap:wrap;align-items:center;">'
|
| 76 |
+
f'{reliability_badge(r.get("reliability",""))}'
|
| 77 |
+
f'{source_badge(r.get("source",""))}'
|
| 78 |
+
f'{impact_badge(r.get("impact","") or "")}'
|
| 79 |
+
f'</div>'
|
| 80 |
+
f'</div>',
|
| 81 |
+
unsafe_allow_html=True,
|
| 82 |
+
)
|
| 83 |
+
st.markdown(
|
| 84 |
+
f'<div style="display:flex;align-items:center;gap:10px;margin:20px 0 12px;">'
|
| 85 |
+
f'<span style="font-size:0.65rem;font-weight:700;letter-spacing:0.1em;'
|
| 86 |
+
f'text-transform:uppercase;color:{TEXT_MUTED};white-space:nowrap;">All risks by category</span>'
|
| 87 |
+
f'<div style="flex:1;height:1px;background:{BORDER};"></div>'
|
| 88 |
+
f'</div>',
|
| 89 |
unsafe_allow_html=True,
|
| 90 |
)
|
| 91 |
|
|
|
|
| 123 |
<div style="font-size:0.9rem;line-height:1.5;margin-bottom:6px;color:{TEXT};">
|
| 124 |
{r.get("text","")} {new_badge}
|
| 125 |
</div>
|
| 126 |
+
<div style="margin-top:4px;display:flex;gap:5px;flex-wrap:wrap;align-items:center;">
|
| 127 |
{reliability_badge(r.get("reliability",""))}
|
| 128 |
+
{source_badge(r.get("source",""))}
|
| 129 |
+
{impact_badge(r.get("impact","") or "")}
|
| 130 |
</div>
|
| 131 |
</div>
|
| 132 |
""",
|
|
|
|
| 154 |
for r in results:
|
| 155 |
m = r["metadata"]
|
| 156 |
context = m.get("chunk_context") or f"{m.get('source','filing')} · {m.get('section','')} · {m.get('filing_date','')}"
|
| 157 |
+
with st.expander(f"📄 {context}", expanded=False):
|
| 158 |
st.markdown(
|
| 159 |
f'<div style="font-size:0.85rem;line-height:1.7;color:{TEXT};">{r["text"]}</div>',
|
| 160 |
unsafe_allow_html=True,
|
dashboard/theme.py
CHANGED
|
@@ -40,6 +40,18 @@ INFO_BORDER = "#bfdbfe"
|
|
| 40 |
|
| 41 |
GRAY = "#6b7280"
|
| 42 |
PURPLE = "#8b5cf6"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
|
| 44 |
# ── Plotly rgba aliases ───────────────────────────────────────────────────────
|
| 45 |
GREEN_A53 = "rgba(16,185,129,0.53)"
|
|
@@ -245,9 +257,60 @@ def inject_global_css() -> None:
|
|
| 245 |
border-radius: 8px !important;
|
| 246 |
}
|
| 247 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 248 |
/* ── Content max-width ──────────────────────────────────────────────── */
|
| 249 |
.main .block-container {
|
| 250 |
-
max-width:
|
| 251 |
padding-top: 24px !important;
|
| 252 |
}
|
| 253 |
</style>
|
|
|
|
| 40 |
|
| 41 |
GRAY = "#6b7280"
|
| 42 |
PURPLE = "#8b5cf6"
|
| 43 |
+
AI_BG = "#faf5ff"
|
| 44 |
+
AI_BORDER = "#ddd6fe"
|
| 45 |
+
AI_COLOR = "#7c3aed"
|
| 46 |
+
AI_BADGE_BG = "#ede9fe"
|
| 47 |
+
|
| 48 |
+
IMPACT_HIGH_BG = "#0f172a" # slate-900
|
| 49 |
+
IMPACT_HIGH_TEXT = "#ffffff"
|
| 50 |
+
IMPACT_MED_BG = "#64748b" # slate-500
|
| 51 |
+
IMPACT_MED_TEXT = "#ffffff"
|
| 52 |
+
IMPACT_LOW_BG = "transparent"
|
| 53 |
+
IMPACT_LOW_TEXT = "#94a3b8" # slate-400
|
| 54 |
+
IMPACT_LOW_BORDER = "#cbd5e1" # slate-300
|
| 55 |
|
| 56 |
# ── Plotly rgba aliases ───────────────────────────────────────────────────────
|
| 57 |
GREEN_A53 = "rgba(16,185,129,0.53)"
|
|
|
|
| 257 |
border-radius: 8px !important;
|
| 258 |
}
|
| 259 |
|
| 260 |
+
/* ── Sidebar nav group separators ──────────────────────────────────── */
|
| 261 |
+
/* Labels with group headers need flex-wrap so ::before spans full row */
|
| 262 |
+
[data-testid="stSidebar"] [data-testid="stRadio"] label:nth-child(1),
|
| 263 |
+
[data-testid="stSidebar"] [data-testid="stRadio"] label:nth-child(3),
|
| 264 |
+
[data-testid="stSidebar"] [data-testid="stRadio"] label:nth-child(6) {
|
| 265 |
+
flex-wrap: wrap !important;
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
/* Before Verdict (1st item) — "BRIEF" group label */
|
| 269 |
+
[data-testid="stSidebar"] [data-testid="stRadio"] label:nth-child(1)::before {
|
| 270 |
+
content: "BRIEF";
|
| 271 |
+
flex: 0 0 100%;
|
| 272 |
+
font-size: 0.55rem !important;
|
| 273 |
+
font-weight: 700 !important;
|
| 274 |
+
text-transform: uppercase;
|
| 275 |
+
letter-spacing: 0.1em;
|
| 276 |
+
color: #9ca3af;
|
| 277 |
+
padding: 0 0 4px 2px;
|
| 278 |
+
pointer-events: none;
|
| 279 |
+
}
|
| 280 |
+
/* Before MD&A (3rd item) — "DEEP DIVE" group */
|
| 281 |
+
[data-testid="stSidebar"] [data-testid="stRadio"] label:nth-child(3) {
|
| 282 |
+
margin-top: 10px !important;
|
| 283 |
+
}
|
| 284 |
+
[data-testid="stSidebar"] [data-testid="stRadio"] label:nth-child(3)::before {
|
| 285 |
+
content: "DEEP DIVE";
|
| 286 |
+
flex: 0 0 100%;
|
| 287 |
+
font-size: 0.55rem !important;
|
| 288 |
+
font-weight: 700 !important;
|
| 289 |
+
text-transform: uppercase;
|
| 290 |
+
letter-spacing: 0.1em;
|
| 291 |
+
color: #9ca3af;
|
| 292 |
+
padding: 0 0 4px 2px;
|
| 293 |
+
pointer-events: none;
|
| 294 |
+
}
|
| 295 |
+
/* Before Guidance (6th item) — "FORWARD" group */
|
| 296 |
+
[data-testid="stSidebar"] [data-testid="stRadio"] label:nth-child(6) {
|
| 297 |
+
margin-top: 10px !important;
|
| 298 |
+
}
|
| 299 |
+
[data-testid="stSidebar"] [data-testid="stRadio"] label:nth-child(6)::before {
|
| 300 |
+
content: "FORWARD";
|
| 301 |
+
flex: 0 0 100%;
|
| 302 |
+
font-size: 0.55rem !important;
|
| 303 |
+
font-weight: 700 !important;
|
| 304 |
+
text-transform: uppercase;
|
| 305 |
+
letter-spacing: 0.1em;
|
| 306 |
+
color: #9ca3af;
|
| 307 |
+
padding: 0 0 4px 2px;
|
| 308 |
+
pointer-events: none;
|
| 309 |
+
}
|
| 310 |
+
|
| 311 |
/* ── Content max-width ──────────────────────────────────────────────── */
|
| 312 |
.main .block-container {
|
| 313 |
+
max-width: 1200px !important;
|
| 314 |
padding-top: 24px !important;
|
| 315 |
}
|
| 316 |
</style>
|
dashboard/verdict.py
CHANGED
|
@@ -7,8 +7,13 @@ from dashboard.theme import (
|
|
| 7 |
BG, BG_MUTED, BORDER, TEXT, TEXT_MUTED,
|
| 8 |
BULL_BG, BULL_BORDER, BEAR_BG, BEAR_BORDER,
|
| 9 |
WARN_BG, WARN_BORDER,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
)
|
| 11 |
-
from dashboard.components import reliability_badge, source_badge, section_header, evidence_quote, fact_card, tension_card, quality_signal_chip
|
| 12 |
from analytics.deltas import build_quarter_snapshot
|
| 13 |
from dashboard import earnings_snapshot, reasoning as reasoning_panel
|
| 14 |
|
|
@@ -232,70 +237,218 @@ def _render_market_expectations(me: dict) -> None:
|
|
| 232 |
)
|
| 233 |
|
| 234 |
|
| 235 |
-
def
|
| 236 |
-
"""
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
signals = [s for s in (brief.get("earnings_quality_signals") or []) if isinstance(s, dict)]
|
| 240 |
|
| 241 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 242 |
return
|
| 243 |
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
|
|
|
| 247 |
st.markdown(
|
| 248 |
-
f'<div class="primer-card" style="background:{
|
| 249 |
-
f'border-left:4px solid {GREEN};border-radius:
|
| 250 |
-
f'
|
| 251 |
-
f'
|
| 252 |
-
f'
|
| 253 |
-
f'
|
| 254 |
-
f'{
|
|
|
|
|
|
|
|
|
|
|
|
|
| 255 |
f'</div>'
|
| 256 |
f'</div>',
|
| 257 |
unsafe_allow_html=True,
|
| 258 |
)
|
| 259 |
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 265 |
)
|
|
|
|
|
|
|
| 266 |
st.markdown(
|
| 267 |
f'<div style="font-size:0.68rem;font-weight:700;text-transform:uppercase;'
|
| 268 |
-
f'letter-spacing:0.1em;color:{TEXT_MUTED};margin
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 269 |
unsafe_allow_html=True,
|
| 270 |
)
|
| 271 |
-
if tensions:
|
| 272 |
-
for t in tensions:
|
| 273 |
-
tension_card(t)
|
| 274 |
-
else:
|
| 275 |
-
st.markdown(
|
| 276 |
-
f'<div style="display:inline-flex;align-items:center;gap:6px;'
|
| 277 |
-
f'background:{BG_MUTED};border:1px solid {BORDER};border-radius:6px;'
|
| 278 |
-
f'padding:6px 12px;margin-bottom:12px;">'
|
| 279 |
-
f'<span style="font-size:0.72rem;color:{TEXT_MUTED};">✓</span>'
|
| 280 |
-
f'<span style="font-size:0.78rem;color:{TEXT_MUTED};">'
|
| 281 |
-
f'Numbers and narrative cohere — no material tensions detected this quarter'
|
| 282 |
-
f'</span>'
|
| 283 |
-
f'</div>',
|
| 284 |
-
unsafe_allow_html=True,
|
| 285 |
-
)
|
| 286 |
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 299 |
|
| 300 |
|
| 301 |
def render(brief: dict) -> None:
|
|
@@ -307,174 +460,146 @@ def render(brief: dict) -> None:
|
|
| 307 |
unsafe_allow_html=True,
|
| 308 |
)
|
| 309 |
|
| 310 |
-
# ──
|
| 311 |
-
|
| 312 |
-
if trace:
|
| 313 |
-
reasoning_panel.render(trace)
|
| 314 |
|
| 315 |
-
# ──
|
| 316 |
-
|
| 317 |
|
| 318 |
-
#
|
| 319 |
-
|
| 320 |
-
# ═══════════════════════════════════════════════════════════════════════════
|
| 321 |
|
| 322 |
-
# ──
|
| 323 |
-
|
| 324 |
-
if what_changed:
|
| 325 |
-
with st.expander(
|
| 326 |
-
f"📋 What changed ({len(what_changed)} item{'s' if len(what_changed) != 1 else ''})",
|
| 327 |
-
expanded=True,
|
| 328 |
-
):
|
| 329 |
-
for fc in what_changed:
|
| 330 |
-
fact_card(fc, accent=AMBER)
|
| 331 |
-
|
| 332 |
-
# ── What matters most ─────────────────────────────────────────────────────
|
| 333 |
-
wmm = brief.get("what_matters_most", "")
|
| 334 |
-
if wmm:
|
| 335 |
-
with st.expander("💡 What matters most", expanded=True):
|
| 336 |
-
st.markdown(
|
| 337 |
-
f"""
|
| 338 |
-
<div class="primer-card" style="background:{BG_MUTED};border:1px solid {BORDER};
|
| 339 |
-
border-radius:12px;padding:20px 24px;margin-bottom:4px;">
|
| 340 |
-
<div style="font-size:0.62rem;font-weight:700;letter-spacing:0.1em;
|
| 341 |
-
text-transform:uppercase;color:{TEXT_MUTED};margin-bottom:8px;">
|
| 342 |
-
AI synthesis · the only interpretive field
|
| 343 |
-
</div>
|
| 344 |
-
<div style="font-size:1rem;line-height:1.7;color:{TEXT};">{wmm}</div>
|
| 345 |
-
</div>
|
| 346 |
-
""",
|
| 347 |
-
unsafe_allow_html=True,
|
| 348 |
-
)
|
| 349 |
|
| 350 |
-
# ──
|
| 351 |
bulls = [b for b in brief.get("bull_points", []) if isinstance(b, dict)]
|
| 352 |
-
if
|
| 353 |
-
|
| 354 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 355 |
st.markdown(
|
| 356 |
-
f"
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
<div style="font-size:0.88rem;line-height:1.55;margin-bottom:10px;color:{TEXT};">
|
| 360 |
-
{pt.get("text","")}
|
| 361 |
-
</div>
|
| 362 |
-
{evidence_quote(pt.get("evidence_snippet",""), bg=BG, border_color=BULL_BORDER)}
|
| 363 |
-
<div style="margin-top:8px;display:flex;gap:6px;align-items:center;">
|
| 364 |
-
{reliability_badge(pt.get("reliability",""))} {source_badge(pt.get("source",""))}
|
| 365 |
-
</div>
|
| 366 |
-
</div>
|
| 367 |
-
""",
|
| 368 |
unsafe_allow_html=True,
|
| 369 |
)
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
with st.expander(f"🐻 Bear points ({len(bears)})", expanded=True):
|
| 375 |
-
for pt in bears:
|
| 376 |
st.markdown(
|
| 377 |
-
f"
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
<div style="font-size:0.88rem;line-height:1.55;margin-bottom:10px;color:{TEXT};">
|
| 381 |
-
{pt.get("text","")}
|
| 382 |
-
</div>
|
| 383 |
-
{evidence_quote(pt.get("evidence_snippet",""), bg=BG, border_color=BEAR_BORDER)}
|
| 384 |
-
<div style="margin-top:8px;display:flex;gap:6px;align-items:center;">
|
| 385 |
-
{reliability_badge(pt.get("reliability",""))} {source_badge(pt.get("source",""))}
|
| 386 |
-
</div>
|
| 387 |
-
</div>
|
| 388 |
-
""",
|
| 389 |
unsafe_allow_html=True,
|
| 390 |
)
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
f'
|
| 404 |
-
f'{
|
| 405 |
-
|
| 406 |
-
f'<span style="font-size:0.9rem;line-height:1.55;color:{TEXT};">{item}</span>'
|
| 407 |
-
f'</div>'
|
| 408 |
)
|
| 409 |
-
st.markdown(
|
| 410 |
-
f'<div style="border:1px solid {BORDER};border-radius:12px;'
|
| 411 |
-
f'padding:4px 16px;background:{BG};">'
|
| 412 |
-
f'{items_html}'
|
| 413 |
-
f'</div>',
|
| 414 |
-
unsafe_allow_html=True,
|
| 415 |
-
)
|
| 416 |
|
| 417 |
-
#
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
st.markdown(_section_divider("Supporting data"), unsafe_allow_html=True)
|
| 421 |
|
| 422 |
-
# ──
|
| 423 |
-
|
| 424 |
-
if sn:
|
| 425 |
-
with st.expander("🔢 Key figure", expanded=True):
|
| 426 |
-
st.markdown(
|
| 427 |
-
f"""
|
| 428 |
-
<div class="primer-card" style="background:{BG};border:1px solid {BORDER};
|
| 429 |
-
border-left:4px solid {GREEN};border-radius:12px;
|
| 430 |
-
padding:20px 24px;margin-bottom:4px;">
|
| 431 |
-
<div style="font-size:0.62rem;font-weight:700;letter-spacing:0.1em;
|
| 432 |
-
text-transform:uppercase;color:{TEXT_MUTED};margin-bottom:10px;">
|
| 433 |
-
The number that matters
|
| 434 |
-
</div>
|
| 435 |
-
<div style="font-size:1.2rem;font-weight:700;line-height:1.5;
|
| 436 |
-
margin-bottom:12px;color:{TEXT};">
|
| 437 |
-
{sn.get("text", "")}
|
| 438 |
-
</div>
|
| 439 |
-
{evidence_quote(sn.get("evidence_snippet",""), bg=BG_MUTED, border_color=BORDER)}
|
| 440 |
-
<div style="margin-top:10px;display:flex;gap:6px;align-items:center;">
|
| 441 |
-
{reliability_badge(sn.get("reliability",""))} {source_badge(sn.get("source",""))}
|
| 442 |
-
</div>
|
| 443 |
-
</div>
|
| 444 |
-
""",
|
| 445 |
-
unsafe_allow_html=True,
|
| 446 |
-
)
|
| 447 |
|
| 448 |
-
#
|
| 449 |
if ticker:
|
| 450 |
snapshot = build_quarter_snapshot(ticker, brief)
|
| 451 |
if snapshot is not None:
|
| 452 |
from dashboard import fmt_period
|
| 453 |
period_label = fmt_period(snapshot.period) if snapshot.period else ""
|
| 454 |
header = f"📊 Earnings snapshot — {period_label}" if period_label else "📊 Earnings snapshot"
|
| 455 |
-
with st.expander(header, expanded=
|
| 456 |
earnings_snapshot.render(snapshot)
|
| 457 |
|
| 458 |
-
#
|
| 459 |
me = brief.get("market_expectations")
|
| 460 |
if me and any(me.get(k) is not None for k in (
|
| 461 |
"consensus_eps_est", "revision_30d_pct",
|
| 462 |
"d1_price_reaction_pct", "d5_price_reaction_pct",
|
| 463 |
)):
|
| 464 |
-
with st.expander("🎯 Market expectations", expanded=
|
| 465 |
_render_market_expectations(me)
|
| 466 |
|
| 467 |
-
#
|
| 468 |
-
with st.expander("📡 Sentiment", expanded=False):
|
| 469 |
-
_render_sentiment_panel(brief.get("sentiment"))
|
| 470 |
-
|
| 471 |
-
# ── Evidence notes ─────────────────────────────────────────────────────────
|
| 472 |
notes = brief.get("evidence_notes", [])
|
| 473 |
if notes:
|
| 474 |
with st.expander("⚡ Observations", expanded=False):
|
| 475 |
for note in notes:
|
| 476 |
st.markdown(f"- {note}")
|
| 477 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 478 |
# ── Data coverage ──────────────────────────────────────────────────────────
|
| 479 |
st.markdown(
|
| 480 |
f"""
|
|
|
|
| 7 |
BG, BG_MUTED, BORDER, TEXT, TEXT_MUTED,
|
| 8 |
BULL_BG, BULL_BORDER, BEAR_BG, BEAR_BORDER,
|
| 9 |
WARN_BG, WARN_BORDER,
|
| 10 |
+
AI_COLOR, AI_BORDER,
|
| 11 |
+
)
|
| 12 |
+
from dashboard.components import (
|
| 13 |
+
reliability_badge, source_badge, impact_badge, section_header, evidence_quote,
|
| 14 |
+
fact_card, tension_card, quality_signal_chip, interpretation_card,
|
| 15 |
+
stat_chip, ai_section_header, delta_card,
|
| 16 |
)
|
|
|
|
| 17 |
from analytics.deltas import build_quarter_snapshot
|
| 18 |
from dashboard import earnings_snapshot, reasoning as reasoning_panel
|
| 19 |
|
|
|
|
| 237 |
)
|
| 238 |
|
| 239 |
|
| 240 |
+
def _render_hero_band(brief: dict) -> None:
|
| 241 |
+
"""Hero band: standout number (left) + market reaction chips (right)."""
|
| 242 |
+
sn = brief.get("standout_number") or {}
|
| 243 |
+
me = brief.get("market_expectations") or {}
|
|
|
|
| 244 |
|
| 245 |
+
has_sn = bool(sn.get("text"))
|
| 246 |
+
has_me = any(me.get(k) is not None for k in (
|
| 247 |
+
"d1_price_reaction_pct", "d5_price_reaction_pct",
|
| 248 |
+
"revision_30d_pct", "consensus_eps_est", "consensus_rev_est_bn",
|
| 249 |
+
))
|
| 250 |
+
|
| 251 |
+
if not has_sn and not has_me:
|
| 252 |
return
|
| 253 |
|
| 254 |
+
col_sn, col_me = st.columns([3, 2])
|
| 255 |
+
|
| 256 |
+
with col_sn:
|
| 257 |
+
if has_sn:
|
| 258 |
st.markdown(
|
| 259 |
+
f'<div class="primer-card" style="background:{BG};border:1px solid {BORDER};'
|
| 260 |
+
f'border-left:4px solid {GREEN};border-radius:12px;padding:20px 24px;">'
|
| 261 |
+
f'<div style="font-size:0.58rem;font-weight:700;letter-spacing:0.1em;'
|
| 262 |
+
f'text-transform:uppercase;color:{TEXT_MUTED};margin-bottom:8px;">The number that matters</div>'
|
| 263 |
+
f'<div style="font-size:1.3rem;font-weight:700;line-height:1.4;'
|
| 264 |
+
f'margin-bottom:12px;color:{TEXT};">{sn.get("text","")}</div>'
|
| 265 |
+
f'{evidence_quote(sn.get("evidence_snippet",""), bg=BG_MUTED, border_color=BORDER)}'
|
| 266 |
+
f'<div style="margin-top:10px;display:flex;gap:5px;flex-wrap:wrap;align-items:center;">'
|
| 267 |
+
f'{reliability_badge(sn.get("reliability",""))}'
|
| 268 |
+
f'{source_badge(sn.get("source",""))}'
|
| 269 |
+
f'{impact_badge(sn.get("impact","") or "")}'
|
| 270 |
f'</div>'
|
| 271 |
f'</div>',
|
| 272 |
unsafe_allow_html=True,
|
| 273 |
)
|
| 274 |
|
| 275 |
+
with col_me:
|
| 276 |
+
if has_me:
|
| 277 |
+
d1 = me.get("d1_price_reaction_pct")
|
| 278 |
+
d5 = me.get("d5_price_reaction_pct")
|
| 279 |
+
rev30 = me.get("revision_30d_pct")
|
| 280 |
+
eps = me.get("consensus_eps_est")
|
| 281 |
+
rev_bn = me.get("consensus_rev_est_bn")
|
| 282 |
+
|
| 283 |
+
def _tone(v, *, flip: bool = False) -> str:
|
| 284 |
+
if v is None:
|
| 285 |
+
return "neutral"
|
| 286 |
+
positive = v >= 0 if not flip else v <= 0
|
| 287 |
+
return "positive" if positive else "negative"
|
| 288 |
+
|
| 289 |
+
def _fmt_pct(v) -> str:
|
| 290 |
+
return f"{v:+.1f}%" if v is not None else "N/A"
|
| 291 |
+
|
| 292 |
+
def _fmt_rev30(v) -> str:
|
| 293 |
+
if v is None:
|
| 294 |
+
return "N/A"
|
| 295 |
+
arrow = " ▲" if v >= 1.0 else (" ▼" if v <= -1.0 else " →")
|
| 296 |
+
return f"{v:+.1f}%{arrow}"
|
| 297 |
+
|
| 298 |
+
cons_parts = []
|
| 299 |
+
if eps is not None:
|
| 300 |
+
cons_parts.append(f"EPS ${eps:.2f}")
|
| 301 |
+
if rev_bn is not None:
|
| 302 |
+
cons_parts.append(f"Rev ${rev_bn:.1f}B")
|
| 303 |
+
cons_str = " · ".join(cons_parts) if cons_parts else None
|
| 304 |
+
|
| 305 |
+
# Only include chips whose value is available — never render "N/A" here.
|
| 306 |
+
visible_chips = []
|
| 307 |
+
if d1 is not None:
|
| 308 |
+
visible_chips.append(stat_chip("D1 reaction", _fmt_pct(d1), _tone(d1)))
|
| 309 |
+
if d5 is not None:
|
| 310 |
+
visible_chips.append(stat_chip("D5 reaction", _fmt_pct(d5), _tone(d5)))
|
| 311 |
+
if rev30 is not None:
|
| 312 |
+
visible_chips.append(stat_chip("Est. rev 30d", _fmt_rev30(rev30), _tone(rev30)))
|
| 313 |
+
if cons_str is not None:
|
| 314 |
+
visible_chips.append(stat_chip("Consensus", cons_str, "neutral"))
|
| 315 |
+
|
| 316 |
+
if visible_chips:
|
| 317 |
+
cols = 2 if len(visible_chips) > 1 else 1
|
| 318 |
+
chips_html = (
|
| 319 |
+
f'<div style="display:grid;grid-template-columns:{"1fr " * cols};gap:8px;">'
|
| 320 |
+
+ "".join(visible_chips)
|
| 321 |
+
+ "</div>"
|
| 322 |
+
)
|
| 323 |
+
st.markdown(
|
| 324 |
+
f'<div style="padding:20px 24px;background:{BG};border:1px solid {BORDER};'
|
| 325 |
+
f'border-radius:12px;">'
|
| 326 |
+
f'<div style="font-size:0.58rem;font-weight:700;letter-spacing:0.1em;'
|
| 327 |
+
f'text-transform:uppercase;color:{TEXT_MUTED};margin-bottom:10px;">Market pulse</div>'
|
| 328 |
+
f'{chips_html}'
|
| 329 |
+
f'</div>',
|
| 330 |
+
unsafe_allow_html=True,
|
| 331 |
+
)
|
| 332 |
+
|
| 333 |
+
|
| 334 |
+
def _render_ai_synthesis_band(brief: dict) -> None:
|
| 335 |
+
"""Lavender AI section: what matters most + non-obvious takeaway."""
|
| 336 |
+
wmm = brief.get("what_matters_most", "")
|
| 337 |
+
takeaway = brief.get("non_obvious_takeaway", "")
|
| 338 |
+
|
| 339 |
+
if not wmm and not takeaway:
|
| 340 |
+
return
|
| 341 |
+
|
| 342 |
+
st.markdown(ai_section_header("AI Synthesis"), unsafe_allow_html=True)
|
| 343 |
+
|
| 344 |
+
if wmm:
|
| 345 |
+
st.markdown(
|
| 346 |
+
interpretation_card(
|
| 347 |
+
"What matters most",
|
| 348 |
+
f'<div style="font-size:1rem;line-height:1.7;color:{TEXT};">{wmm}</div>',
|
| 349 |
+
),
|
| 350 |
+
unsafe_allow_html=True,
|
| 351 |
+
)
|
| 352 |
+
if takeaway:
|
| 353 |
+
st.markdown(
|
| 354 |
+
interpretation_card(
|
| 355 |
+
"Non-obvious takeaway",
|
| 356 |
+
f'<div style="font-size:0.95rem;font-style:italic;line-height:1.65;color:{TEXT};">'
|
| 357 |
+
f'{takeaway}</div>',
|
| 358 |
+
),
|
| 359 |
+
unsafe_allow_html=True,
|
| 360 |
+
)
|
| 361 |
+
|
| 362 |
+
|
| 363 |
+
def _render_ai_deeper_band(brief: dict) -> None:
|
| 364 |
+
"""Lavender AI section: analytical tensions + earnings quality signals."""
|
| 365 |
+
tensions = [t for t in (brief.get("analytical_tensions") or []) if isinstance(t, dict)]
|
| 366 |
+
signals = [s for s in (brief.get("earnings_quality_signals") or []) if isinstance(s, dict)]
|
| 367 |
+
|
| 368 |
+
if not tensions and not signals:
|
| 369 |
+
return
|
| 370 |
+
|
| 371 |
+
st.markdown(ai_section_header("Conflicting Readings & Quality"), unsafe_allow_html=True)
|
| 372 |
+
|
| 373 |
+
if tensions:
|
| 374 |
+
for t in tensions:
|
| 375 |
+
tension_card(t)
|
| 376 |
+
else:
|
| 377 |
+
st.markdown(
|
| 378 |
+
f'<div style="display:inline-flex;align-items:center;gap:6px;'
|
| 379 |
+
f'background:{BG_MUTED};border:1px solid {BORDER};border-radius:6px;'
|
| 380 |
+
f'padding:6px 12px;margin-bottom:12px;">'
|
| 381 |
+
f'<span style="font-size:0.72rem;color:{TEXT_MUTED};">✓</span>'
|
| 382 |
+
f'<span style="font-size:0.78rem;color:{TEXT_MUTED};">'
|
| 383 |
+
f'Numbers and narrative cohere — no material tensions detected this quarter'
|
| 384 |
+
f'</span>'
|
| 385 |
+
f'</div>',
|
| 386 |
+
unsafe_allow_html=True,
|
| 387 |
)
|
| 388 |
+
|
| 389 |
+
if signals:
|
| 390 |
st.markdown(
|
| 391 |
f'<div style="font-size:0.68rem;font-weight:700;text-transform:uppercase;'
|
| 392 |
+
f'letter-spacing:0.1em;color:{TEXT_MUTED};margin:14px 0 8px;">Earnings quality signals</div>',
|
| 393 |
+
unsafe_allow_html=True,
|
| 394 |
+
)
|
| 395 |
+
chips_html = "".join(quality_signal_chip(s) for s in signals)
|
| 396 |
+
st.markdown(
|
| 397 |
+
f'<div style="display:flex;flex-wrap:wrap;gap:0;">{chips_html}</div>',
|
| 398 |
unsafe_allow_html=True,
|
| 399 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 400 |
|
| 401 |
+
|
| 402 |
+
_DELTA_KIND_GROUPS: dict[str, str] = {
|
| 403 |
+
"risk_added": "Risk Changes",
|
| 404 |
+
"risk_removed": "Risk Changes",
|
| 405 |
+
"risk_reworded": "Risk Changes",
|
| 406 |
+
"guidance_language_shift": "Language Shifts",
|
| 407 |
+
"term_frequency": "Frequency Shifts",
|
| 408 |
+
"kpi_dropped": "Dropped KPIs",
|
| 409 |
+
}
|
| 410 |
+
|
| 411 |
+
|
| 412 |
+
def _render_analyst_edge(brief: dict) -> None:
|
| 413 |
+
"""Render the Analyst Edge panel — deterministic signals, above the brief."""
|
| 414 |
+
deltas = brief.get("quarter_deltas") or []
|
| 415 |
+
if not deltas:
|
| 416 |
+
return
|
| 417 |
+
|
| 418 |
+
# Group by kind category
|
| 419 |
+
groups: dict[str, list[dict]] = {}
|
| 420 |
+
for d in deltas:
|
| 421 |
+
group = _DELTA_KIND_GROUPS.get(d.get("kind", ""), "Other")
|
| 422 |
+
groups.setdefault(group, []).append(d)
|
| 423 |
+
|
| 424 |
+
# Section divider
|
| 425 |
+
st.markdown(
|
| 426 |
+
f'<div style="display:flex;align-items:center;gap:12px;margin:4px 0 14px;">'
|
| 427 |
+
f'<span style="font-size:0.68rem;font-weight:700;text-transform:uppercase;'
|
| 428 |
+
f'letter-spacing:0.14em;color:#0f172a;white-space:nowrap;">⚡ ANALYST EDGE</span>'
|
| 429 |
+
f'<div style="flex:1;height:2px;background:linear-gradient(90deg,#0f172a,transparent);'
|
| 430 |
+
f'border-radius:2px;"></div>'
|
| 431 |
+
f'<span style="font-size:0.65rem;color:#6b7280;">'
|
| 432 |
+
f'deterministic · {len(deltas)} signal{"s" if len(deltas) != 1 else ""}'
|
| 433 |
+
f'</span>'
|
| 434 |
+
f'</div>',
|
| 435 |
+
unsafe_allow_html=True,
|
| 436 |
+
)
|
| 437 |
+
|
| 438 |
+
# High-significance signals get full cards; lower-sig ones are collapsed
|
| 439 |
+
high_signals = [d for d in deltas if d.get("significance") == "HIGH"]
|
| 440 |
+
other_signals = [d for d in deltas if d.get("significance") != "HIGH"]
|
| 441 |
+
|
| 442 |
+
if high_signals:
|
| 443 |
+
for d in high_signals:
|
| 444 |
+
delta_card(d)
|
| 445 |
+
|
| 446 |
+
if other_signals:
|
| 447 |
+
with st.expander(f"Show {len(other_signals)} more signal(s) (MEDIUM / LOW)", expanded=False):
|
| 448 |
+
for d in other_signals:
|
| 449 |
+
delta_card(d)
|
| 450 |
+
|
| 451 |
+
st.markdown('<div style="margin-bottom:28px;"></div>', unsafe_allow_html=True)
|
| 452 |
|
| 453 |
|
| 454 |
def render(brief: dict) -> None:
|
|
|
|
| 460 |
unsafe_allow_html=True,
|
| 461 |
)
|
| 462 |
|
| 463 |
+
# ── ANALYST EDGE PANEL — deterministic signals, rendered first ────────────
|
| 464 |
+
_render_analyst_edge(brief)
|
|
|
|
|
|
|
| 465 |
|
| 466 |
+
# ── HERO BAND: standout number + market pulse ─────────────────────────────
|
| 467 |
+
_render_hero_band(brief)
|
| 468 |
|
| 469 |
+
# ── AI SYNTHESIS BAND: what matters most + non-obvious takeaway ───────────
|
| 470 |
+
_render_ai_synthesis_band(brief)
|
|
|
|
| 471 |
|
| 472 |
+
# ── AI DEEPER BAND: analytical tensions + quality signals ─────────────────
|
| 473 |
+
_render_ai_deeper_band(brief)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 474 |
|
| 475 |
+
# ── BULL / BEAR — paired rows so cards align horizontally ─────────────────
|
| 476 |
bulls = [b for b in brief.get("bull_points", []) if isinstance(b, dict)]
|
| 477 |
+
bears = [b for b in brief.get("bear_points", []) if isinstance(b, dict)]
|
| 478 |
+
if bulls or bears:
|
| 479 |
+
hcols = st.columns(2)
|
| 480 |
+
hcols[0].markdown(
|
| 481 |
+
f'<div style="font-size:0.72rem;color:{GREEN};font-weight:700;'
|
| 482 |
+
f'letter-spacing:0.08em;margin-bottom:10px;">🐂 BULL POINTS ({len(bulls)})</div>',
|
| 483 |
+
unsafe_allow_html=True,
|
| 484 |
+
)
|
| 485 |
+
hcols[1].markdown(
|
| 486 |
+
f'<div style="font-size:0.72rem;color:{RED};font-weight:700;'
|
| 487 |
+
f'letter-spacing:0.08em;margin-bottom:10px;">🐻 BEAR POINTS ({len(bears)})</div>',
|
| 488 |
+
unsafe_allow_html=True,
|
| 489 |
+
)
|
| 490 |
+
for i in range(max(len(bulls), len(bears))):
|
| 491 |
+
row = st.columns(2)
|
| 492 |
+
with row[0]:
|
| 493 |
+
if i < len(bulls):
|
| 494 |
+
pt = bulls[i]
|
| 495 |
+
st.markdown(
|
| 496 |
+
f'<div class="primer-card" style="background:{BULL_BG};border:1px solid {BULL_BORDER};'
|
| 497 |
+
f'border-radius:10px;padding:16px 18px;margin-bottom:8px;">'
|
| 498 |
+
f'<div style="font-size:0.88rem;line-height:1.55;margin-bottom:10px;color:{TEXT};">'
|
| 499 |
+
f'{pt.get("text","")}</div>'
|
| 500 |
+
f'{evidence_quote(pt.get("evidence_snippet",""), bg=BG, border_color=BULL_BORDER)}'
|
| 501 |
+
f'<div style="margin-top:8px;display:flex;gap:5px;flex-wrap:wrap;align-items:center;">'
|
| 502 |
+
f'{reliability_badge(pt.get("reliability",""))}'
|
| 503 |
+
f'{source_badge(pt.get("source",""))}'
|
| 504 |
+
f'{impact_badge(pt.get("impact","") or "")}'
|
| 505 |
+
f'</div></div>',
|
| 506 |
+
unsafe_allow_html=True,
|
| 507 |
+
)
|
| 508 |
+
with row[1]:
|
| 509 |
+
if i < len(bears):
|
| 510 |
+
pt = bears[i]
|
| 511 |
+
st.markdown(
|
| 512 |
+
f'<div class="primer-card" style="background:{BEAR_BG};border:1px solid {BEAR_BORDER};'
|
| 513 |
+
f'border-radius:10px;padding:16px 18px;margin-bottom:8px;">'
|
| 514 |
+
f'<div style="font-size:0.88rem;line-height:1.55;margin-bottom:10px;color:{TEXT};">'
|
| 515 |
+
f'{pt.get("text","")}</div>'
|
| 516 |
+
f'{evidence_quote(pt.get("evidence_snippet",""), bg=BG, border_color=BEAR_BORDER)}'
|
| 517 |
+
f'<div style="margin-top:8px;display:flex;gap:5px;flex-wrap:wrap;align-items:center;">'
|
| 518 |
+
f'{reliability_badge(pt.get("reliability",""))}'
|
| 519 |
+
f'{source_badge(pt.get("source",""))}'
|
| 520 |
+
f'{impact_badge(pt.get("impact","") or "")}'
|
| 521 |
+
f'</div></div>',
|
| 522 |
+
unsafe_allow_html=True,
|
| 523 |
+
)
|
| 524 |
+
|
| 525 |
+
# ── WHAT CHANGED / WHAT TO WATCH NEXT — side by side ─────────────────────
|
| 526 |
+
what_changed = [wc for wc in brief.get("what_changed", []) if isinstance(wc, dict)]
|
| 527 |
+
wtw = brief.get("what_to_watch", [])
|
| 528 |
+
if what_changed or wtw:
|
| 529 |
+
col_changed, col_watch = st.columns(2)
|
| 530 |
+
with col_changed:
|
| 531 |
+
if what_changed:
|
| 532 |
st.markdown(
|
| 533 |
+
f'<div style="font-size:0.72rem;color:{AMBER};font-weight:700;'
|
| 534 |
+
f'letter-spacing:0.08em;margin-bottom:10px;">'
|
| 535 |
+
f'📋 WHAT CHANGED ({len(what_changed)})</div>',
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 536 |
unsafe_allow_html=True,
|
| 537 |
)
|
| 538 |
+
for fc in what_changed:
|
| 539 |
+
fact_card(fc, accent=AMBER)
|
| 540 |
+
with col_watch:
|
| 541 |
+
if wtw:
|
|
|
|
|
|
|
| 542 |
st.markdown(
|
| 543 |
+
f'<div style="font-size:0.72rem;color:{GREEN};font-weight:700;'
|
| 544 |
+
f'letter-spacing:0.08em;margin-bottom:10px;">'
|
| 545 |
+
f'🔭 WHAT TO WATCH ({len(wtw)})</div>',
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 546 |
unsafe_allow_html=True,
|
| 547 |
)
|
| 548 |
+
items_html = ""
|
| 549 |
+
for i, item in enumerate(wtw, 1):
|
| 550 |
+
items_html += (
|
| 551 |
+
f'<div style="display:flex;align-items:flex-start;gap:12px;'
|
| 552 |
+
f'padding:12px 0;border-bottom:1px solid {BORDER};">'
|
| 553 |
+
f'<span style="display:inline-flex;align-items:center;justify-content:center;'
|
| 554 |
+
f'width:22px;height:22px;border-radius:50%;flex-shrink:0;margin-top:1px;'
|
| 555 |
+
f'background:{GREEN};color:#fff;font-size:0.7rem;font-weight:700;">{i}</span>'
|
| 556 |
+
f'<span style="font-size:0.9rem;line-height:1.55;color:{TEXT};">{item}</span>'
|
| 557 |
+
f'</div>'
|
| 558 |
+
)
|
| 559 |
+
st.markdown(
|
| 560 |
+
f'<div style="border:1px solid {BORDER};border-radius:12px;'
|
| 561 |
+
f'padding:4px 16px;background:{BG};">{items_html}</div>',
|
| 562 |
+
unsafe_allow_html=True,
|
|
|
|
|
|
|
| 563 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 564 |
|
| 565 |
+
# ── SENTIMENT ─────────────────────────────────────────────────────────────
|
| 566 |
+
with st.expander("📡 Sentiment", expanded=False):
|
| 567 |
+
_render_sentiment_panel(brief.get("sentiment"))
|
|
|
|
| 568 |
|
| 569 |
+
# ── SUPPORTING DATA (collapsed) ───────────────────────────────────────────
|
| 570 |
+
st.markdown(_section_divider("Supporting data"), unsafe_allow_html=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 571 |
|
| 572 |
+
# Earnings snapshot
|
| 573 |
if ticker:
|
| 574 |
snapshot = build_quarter_snapshot(ticker, brief)
|
| 575 |
if snapshot is not None:
|
| 576 |
from dashboard import fmt_period
|
| 577 |
period_label = fmt_period(snapshot.period) if snapshot.period else ""
|
| 578 |
header = f"📊 Earnings snapshot — {period_label}" if period_label else "📊 Earnings snapshot"
|
| 579 |
+
with st.expander(header, expanded=False):
|
| 580 |
earnings_snapshot.render(snapshot)
|
| 581 |
|
| 582 |
+
# Market expectations detail
|
| 583 |
me = brief.get("market_expectations")
|
| 584 |
if me and any(me.get(k) is not None for k in (
|
| 585 |
"consensus_eps_est", "revision_30d_pct",
|
| 586 |
"d1_price_reaction_pct", "d5_price_reaction_pct",
|
| 587 |
)):
|
| 588 |
+
with st.expander("🎯 Market expectations — full detail", expanded=False):
|
| 589 |
_render_market_expectations(me)
|
| 590 |
|
| 591 |
+
# Evidence notes
|
|
|
|
|
|
|
|
|
|
|
|
|
| 592 |
notes = brief.get("evidence_notes", [])
|
| 593 |
if notes:
|
| 594 |
with st.expander("⚡ Observations", expanded=False):
|
| 595 |
for note in notes:
|
| 596 |
st.markdown(f"- {note}")
|
| 597 |
|
| 598 |
+
# Reasoning trace — moved to bottom
|
| 599 |
+
trace = st.session_state.get("reasoning_trace", {}).get(ticker)
|
| 600 |
+
if trace:
|
| 601 |
+
reasoning_panel.render(trace)
|
| 602 |
+
|
| 603 |
# ── Data coverage ──────────────────────────────────────────────────────────
|
| 604 |
st.markdown(
|
| 605 |
f"""
|
docs/superpowers/plans/2026-05-07-interpretation-visual.md
ADDED
|
@@ -0,0 +1,589 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Interpretation Visual Differentiation — 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:** Visually distinguish AI-interpreted fields from sourced facts across the Primer dashboard using a purple left-border + tinted background + "✦ AI" badge system.
|
| 6 |
+
|
| 7 |
+
**Architecture:** Pure frontend change. Add 4 color tokens to `theme.py`, add two HTML-helper functions to `components.py`, update the rendering of 2 fields in `verdict.py`, 1 field in `mda.py`, and modify 2 existing component functions (`tension_card`, `quality_signal_chip`). No schema or agent changes.
|
| 8 |
+
|
| 9 |
+
**Tech Stack:** Streamlit, Python f-strings for HTML, pytest for unit tests on string-returning helpers.
|
| 10 |
+
|
| 11 |
+
---
|
| 12 |
+
|
| 13 |
+
## File Map
|
| 14 |
+
|
| 15 |
+
| File | Change |
|
| 16 |
+
|------|--------|
|
| 17 |
+
| `dashboard/theme.py` | Add `AI_BG`, `AI_BORDER`, `AI_COLOR`, `AI_BADGE_BG` tokens |
|
| 18 |
+
| `dashboard/components.py` | Add `ai_badge()`, `interpretation_card()`; modify `tension_card()`, `quality_signal_chip()` |
|
| 19 |
+
| `dashboard/verdict.py` | Use `interpretation_card()` for `what_matters_most` and `non_obvious_takeaway` |
|
| 20 |
+
| `dashboard/mda.py` | Use `interpretation_card()` for `language_shift` |
|
| 21 |
+
| `tests/test_dashboard_components.py` | New file — unit tests for the new and modified helpers |
|
| 22 |
+
|
| 23 |
+
---
|
| 24 |
+
|
| 25 |
+
### Task 1: Add AI color tokens to `theme.py`
|
| 26 |
+
|
| 27 |
+
**Files:**
|
| 28 |
+
- Modify: `dashboard/theme.py`
|
| 29 |
+
|
| 30 |
+
- [ ] **Step 1: Add the 4 new tokens after the existing `PURPLE` line**
|
| 31 |
+
|
| 32 |
+
In `dashboard/theme.py`, find the line `PURPLE = "#8b5cf6"` (currently line 42) and add the four tokens immediately after it:
|
| 33 |
+
|
| 34 |
+
```python
|
| 35 |
+
PURPLE = "#8b5cf6"
|
| 36 |
+
AI_BG = "#faf5ff"
|
| 37 |
+
AI_BORDER = "#ddd6fe"
|
| 38 |
+
AI_COLOR = "#7c3aed"
|
| 39 |
+
AI_BADGE_BG = "#ede9fe"
|
| 40 |
+
```
|
| 41 |
+
|
| 42 |
+
- [ ] **Step 2: Verify the file is syntactically valid**
|
| 43 |
+
|
| 44 |
+
```bash
|
| 45 |
+
python -c "from dashboard.theme import AI_BG, AI_BORDER, AI_COLOR, AI_BADGE_BG; print(AI_BG)"
|
| 46 |
+
```
|
| 47 |
+
|
| 48 |
+
Expected output: `#faf5ff`
|
| 49 |
+
|
| 50 |
+
- [ ] **Step 3: Commit**
|
| 51 |
+
|
| 52 |
+
```bash
|
| 53 |
+
git add dashboard/theme.py
|
| 54 |
+
git commit -m "feat: add AI interpretation color tokens to theme"
|
| 55 |
+
```
|
| 56 |
+
|
| 57 |
+
---
|
| 58 |
+
|
| 59 |
+
### Task 2: Add `ai_badge()` and `interpretation_card()` helpers + write tests
|
| 60 |
+
|
| 61 |
+
**Files:**
|
| 62 |
+
- Modify: `dashboard/components.py`
|
| 63 |
+
- Create: `tests/test_dashboard_components.py`
|
| 64 |
+
|
| 65 |
+
- [ ] **Step 1: Write failing tests first**
|
| 66 |
+
|
| 67 |
+
Create `tests/test_dashboard_components.py`:
|
| 68 |
+
|
| 69 |
+
```python
|
| 70 |
+
"""Unit tests for dashboard/components.py HTML helpers."""
|
| 71 |
+
from __future__ import annotations
|
| 72 |
+
import pytest
|
| 73 |
+
from unittest.mock import patch, MagicMock
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def test_ai_badge_default_label():
|
| 77 |
+
from dashboard.components import ai_badge
|
| 78 |
+
html = ai_badge()
|
| 79 |
+
assert "✦ AI Synthesis" in html
|
| 80 |
+
assert "#7c3aed" in html
|
| 81 |
+
assert "#ede9fe" in html
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def test_ai_badge_custom_label():
|
| 85 |
+
from dashboard.components import ai_badge
|
| 86 |
+
html = ai_badge("AI")
|
| 87 |
+
assert "✦ AI" in html
|
| 88 |
+
assert "#7c3aed" in html
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def test_interpretation_card_contains_badge_and_content():
|
| 92 |
+
from dashboard.components import interpretation_card
|
| 93 |
+
html = interpretation_card("What matters most", "<p>Some insight</p>")
|
| 94 |
+
assert "✦ AI Synthesis" in html
|
| 95 |
+
assert "#8b5cf6" in html # purple border
|
| 96 |
+
assert "#faf5ff" in html # purple tint background
|
| 97 |
+
assert "What matters most" in html
|
| 98 |
+
assert "<p>Some insight</p>" in html
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def test_interpretation_card_custom_badge_label():
|
| 102 |
+
from dashboard.components import interpretation_card
|
| 103 |
+
html = interpretation_card("Language shift", "<p>tone changed</p>", badge_label="AI")
|
| 104 |
+
assert "✦ AI" in html
|
| 105 |
+
assert "Language shift" in html
|
| 106 |
+
```
|
| 107 |
+
|
| 108 |
+
- [ ] **Step 2: Run tests to confirm they fail**
|
| 109 |
+
|
| 110 |
+
```bash
|
| 111 |
+
python -m pytest tests/test_dashboard_components.py -v
|
| 112 |
+
```
|
| 113 |
+
|
| 114 |
+
Expected: `ImportError` or `AttributeError` — `ai_badge` not yet defined.
|
| 115 |
+
|
| 116 |
+
- [ ] **Step 3: Add the imports and helpers to `dashboard/components.py`**
|
| 117 |
+
|
| 118 |
+
At the top of `dashboard/components.py`, extend the theme import to include the new tokens:
|
| 119 |
+
|
| 120 |
+
```python
|
| 121 |
+
from dashboard.theme import (
|
| 122 |
+
GREEN, RED, AMBER, GRAY, BORDER, TEXT_FAINT,
|
| 123 |
+
BG, BG_MUTED, TEXT, TEXT_MUTED,
|
| 124 |
+
WARN_BG, WARN_BORDER,
|
| 125 |
+
BULL_BG, BULL_BORDER, BEAR_BG, BEAR_BORDER,
|
| 126 |
+
PURPLE, AI_BG, AI_BORDER, AI_COLOR, AI_BADGE_BG,
|
| 127 |
+
)
|
| 128 |
+
```
|
| 129 |
+
|
| 130 |
+
Then add both helpers immediately after the existing `reliability_badge` function (after line 19):
|
| 131 |
+
|
| 132 |
+
```python
|
| 133 |
+
# ── AI interpretation badge ───────────────────────────────────────────────────
|
| 134 |
+
|
| 135 |
+
def ai_badge(label: str = "AI Synthesis") -> str:
|
| 136 |
+
return (
|
| 137 |
+
f'<span style="background:{AI_BADGE_BG};color:{AI_COLOR};'
|
| 138 |
+
f'border:1px solid {AI_BORDER};border-radius:4px;'
|
| 139 |
+
f'padding:1px 7px;font-size:0.62rem;font-weight:600;">✦ {label}</span>'
|
| 140 |
+
)
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
# ── AI interpretation card wrapper ────────────────────────────────────────────
|
| 144 |
+
|
| 145 |
+
def interpretation_card(
|
| 146 |
+
header_label: str,
|
| 147 |
+
content_html: str,
|
| 148 |
+
badge_label: str = "AI Synthesis",
|
| 149 |
+
) -> str:
|
| 150 |
+
return (
|
| 151 |
+
f'<div class="primer-card" style="background:{AI_BG};border:1px solid {BORDER};'
|
| 152 |
+
f'border-left:4px solid {PURPLE};border-radius:0 12px 12px 0;'
|
| 153 |
+
f'padding:20px 24px;margin-bottom:4px;">'
|
| 154 |
+
f'<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">'
|
| 155 |
+
f'<span style="font-size:0.62rem;font-weight:700;letter-spacing:0.1em;'
|
| 156 |
+
f'text-transform:uppercase;color:{TEXT_MUTED};">{header_label}</span>'
|
| 157 |
+
f'{ai_badge(badge_label)}'
|
| 158 |
+
f'</div>'
|
| 159 |
+
f'{content_html}'
|
| 160 |
+
f'</div>'
|
| 161 |
+
)
|
| 162 |
+
```
|
| 163 |
+
|
| 164 |
+
- [ ] **Step 4: Run tests — expect pass**
|
| 165 |
+
|
| 166 |
+
```bash
|
| 167 |
+
python -m pytest tests/test_dashboard_components.py::test_ai_badge_default_label tests/test_dashboard_components.py::test_ai_badge_custom_label tests/test_dashboard_components.py::test_interpretation_card_contains_badge_and_content tests/test_dashboard_components.py::test_interpretation_card_custom_badge_label -v
|
| 168 |
+
```
|
| 169 |
+
|
| 170 |
+
Expected: 4 PASSED
|
| 171 |
+
|
| 172 |
+
- [ ] **Step 5: Commit**
|
| 173 |
+
|
| 174 |
+
```bash
|
| 175 |
+
git add dashboard/components.py tests/test_dashboard_components.py
|
| 176 |
+
git commit -m "feat: add ai_badge and interpretation_card helpers"
|
| 177 |
+
```
|
| 178 |
+
|
| 179 |
+
---
|
| 180 |
+
|
| 181 |
+
### Task 3: Update `tension_card()` — add AI badge to reading labels
|
| 182 |
+
|
| 183 |
+
**Files:**
|
| 184 |
+
- Modify: `dashboard/components.py` (the `tension_card` function, lines ~116–172)
|
| 185 |
+
|
| 186 |
+
The tension card panels keep their green/red backgrounds (they provide bull/bear orientation). We only add a micro "✦ AI" badge next to the "Surface reading" and "Deeper reading" labels.
|
| 187 |
+
|
| 188 |
+
- [ ] **Step 1: Write the failing test**
|
| 189 |
+
|
| 190 |
+
Add to `tests/test_dashboard_components.py`:
|
| 191 |
+
|
| 192 |
+
```python
|
| 193 |
+
def test_tension_card_reading_panels_have_ai_badge():
|
| 194 |
+
"""tension_card must add ✦ AI badge next to both reading labels."""
|
| 195 |
+
import streamlit as st
|
| 196 |
+
from unittest.mock import patch, call
|
| 197 |
+
from dashboard.components import tension_card
|
| 198 |
+
|
| 199 |
+
tension = {
|
| 200 |
+
"headline": "Revenue beat hides quality decline",
|
| 201 |
+
"weight": "material",
|
| 202 |
+
"bullish_reading": "Strong top-line momentum",
|
| 203 |
+
"bearish_reading": "One-time item inflated result",
|
| 204 |
+
"bullish_evidence": {"evidence_snippet": "q1", "reliability": "HIGH", "source": "10-Q"},
|
| 205 |
+
"bearish_evidence": {"evidence_snippet": "q2", "reliability": "HIGH", "source": "10-Q"},
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
with patch.object(st, "markdown") as mock_md:
|
| 209 |
+
tension_card(tension)
|
| 210 |
+
rendered = mock_md.call_args[0][0]
|
| 211 |
+
|
| 212 |
+
assert "✦ AI" in rendered
|
| 213 |
+
assert rendered.count("✦ AI") >= 2 # once per reading panel
|
| 214 |
+
```
|
| 215 |
+
|
| 216 |
+
- [ ] **Step 2: Run test to confirm it fails**
|
| 217 |
+
|
| 218 |
+
```bash
|
| 219 |
+
python -m pytest tests/test_dashboard_components.py::test_tension_card_reading_panels_have_ai_badge -v
|
| 220 |
+
```
|
| 221 |
+
|
| 222 |
+
Expected: FAILED — "✦ AI" not in rendered HTML.
|
| 223 |
+
|
| 224 |
+
- [ ] **Step 3: Modify `tension_card()` in `dashboard/components.py`**
|
| 225 |
+
|
| 226 |
+
Find the `bull_side` variable inside `tension_card()`. Replace the single-div label line:
|
| 227 |
+
|
| 228 |
+
```python
|
| 229 |
+
# BEFORE — find this inside tension_card():
|
| 230 |
+
f'<div style="font-size:0.58rem;font-weight:700;text-transform:uppercase;'
|
| 231 |
+
f'letter-spacing:0.09em;color:#059669;margin-bottom:6px;">Surface reading</div>'
|
| 232 |
+
```
|
| 233 |
+
|
| 234 |
+
Replace with:
|
| 235 |
+
|
| 236 |
+
```python
|
| 237 |
+
# AFTER
|
| 238 |
+
f'<div style="display:flex;align-items:center;gap:5px;margin-bottom:6px;">'
|
| 239 |
+
f'<span style="font-size:0.58rem;font-weight:700;text-transform:uppercase;'
|
| 240 |
+
f'letter-spacing:0.09em;color:#059669;">Surface reading</span>'
|
| 241 |
+
f'{ai_badge("AI")}'
|
| 242 |
+
f'</div>'
|
| 243 |
+
```
|
| 244 |
+
|
| 245 |
+
Then find the `bear_side` label inside `tension_card()`. Replace:
|
| 246 |
+
|
| 247 |
+
```python
|
| 248 |
+
# BEFORE — find this inside tension_card():
|
| 249 |
+
f'<div style="font-size:0.58rem;font-weight:700;text-transform:uppercase;'
|
| 250 |
+
f'letter-spacing:0.09em;color:#dc2626;margin-bottom:6px;">Deeper reading</div>'
|
| 251 |
+
```
|
| 252 |
+
|
| 253 |
+
Replace with:
|
| 254 |
+
|
| 255 |
+
```python
|
| 256 |
+
# AFTER
|
| 257 |
+
f'<div style="display:flex;align-items:center;gap:5px;margin-bottom:6px;">'
|
| 258 |
+
f'<span style="font-size:0.58rem;font-weight:700;text-transform:uppercase;'
|
| 259 |
+
f'letter-spacing:0.09em;color:#dc2626;">Deeper reading</span>'
|
| 260 |
+
f'{ai_badge("AI")}'
|
| 261 |
+
f'</div>'
|
| 262 |
+
```
|
| 263 |
+
|
| 264 |
+
- [ ] **Step 4: Run test — expect pass**
|
| 265 |
+
|
| 266 |
+
```bash
|
| 267 |
+
python -m pytest tests/test_dashboard_components.py::test_tension_card_reading_panels_have_ai_badge -v
|
| 268 |
+
```
|
| 269 |
+
|
| 270 |
+
Expected: PASSED
|
| 271 |
+
|
| 272 |
+
- [ ] **Step 5: Commit**
|
| 273 |
+
|
| 274 |
+
```bash
|
| 275 |
+
git add dashboard/components.py tests/test_dashboard_components.py
|
| 276 |
+
git commit -m "feat: mark tension card readings as AI interpretation"
|
| 277 |
+
```
|
| 278 |
+
|
| 279 |
+
---
|
| 280 |
+
|
| 281 |
+
### Task 4: Update `quality_signal_chip()` — add AI label in expanded body
|
| 282 |
+
|
| 283 |
+
**Files:**
|
| 284 |
+
- Modify: `dashboard/components.py` (the `quality_signal_chip` function, lines ~191–234)
|
| 285 |
+
|
| 286 |
+
The chip keeps its existing assessment color (positive/neutral/concerning). We add a small "✦ AI assessment" label above the rationale text in the expanded body only.
|
| 287 |
+
|
| 288 |
+
- [ ] **Step 1: Write the failing test**
|
| 289 |
+
|
| 290 |
+
Add to `tests/test_dashboard_components.py`:
|
| 291 |
+
|
| 292 |
+
```python
|
| 293 |
+
def test_quality_signal_chip_body_has_ai_label():
|
| 294 |
+
"""quality_signal_chip must include an AI label before the rationale text in the body."""
|
| 295 |
+
from dashboard.components import quality_signal_chip
|
| 296 |
+
|
| 297 |
+
signal = {
|
| 298 |
+
"dimension": "guidance_dynamics",
|
| 299 |
+
"assessment": "positive",
|
| 300 |
+
"rationale": "Management raised full-year guidance for the third consecutive quarter.",
|
| 301 |
+
"evidence": {"evidence_snippet": "raised guidance", "source": "10-Q", "reliability": "HIGH"},
|
| 302 |
+
}
|
| 303 |
+
|
| 304 |
+
html = quality_signal_chip(signal)
|
| 305 |
+
assert "✦ AI" in html
|
| 306 |
+
assert "Management raised full-year guidance" in html
|
| 307 |
+
# AI label must appear before the rationale text
|
| 308 |
+
assert html.index("✦ AI") < html.index("Management raised full-year guidance")
|
| 309 |
+
```
|
| 310 |
+
|
| 311 |
+
- [ ] **Step 2: Run test to confirm it fails**
|
| 312 |
+
|
| 313 |
+
```bash
|
| 314 |
+
python -m pytest tests/test_dashboard_components.py::test_quality_signal_chip_body_has_ai_label -v
|
| 315 |
+
```
|
| 316 |
+
|
| 317 |
+
Expected: FAILED
|
| 318 |
+
|
| 319 |
+
- [ ] **Step 3: Modify `quality_signal_chip()` in `dashboard/components.py`**
|
| 320 |
+
|
| 321 |
+
Inside `quality_signal_chip()`, find the body div that contains the rationale:
|
| 322 |
+
|
| 323 |
+
```python
|
| 324 |
+
# BEFORE — find this in quality_signal_chip():
|
| 325 |
+
f'<div style="font-size:0.78rem;color:{TEXT};line-height:1.5;">{rationale}</div>'
|
| 326 |
+
```
|
| 327 |
+
|
| 328 |
+
Replace with:
|
| 329 |
+
|
| 330 |
+
```python
|
| 331 |
+
# AFTER
|
| 332 |
+
f'<div style="font-size:0.55rem;font-weight:600;text-transform:uppercase;'
|
| 333 |
+
f'letter-spacing:0.07em;color:{AI_COLOR};margin-bottom:3px;">✦ AI assessment</div>'
|
| 334 |
+
f'<div style="font-size:0.78rem;color:{TEXT};line-height:1.5;">{rationale}</div>'
|
| 335 |
+
```
|
| 336 |
+
|
| 337 |
+
- [ ] **Step 4: Run test — expect pass**
|
| 338 |
+
|
| 339 |
+
```bash
|
| 340 |
+
python -m pytest tests/test_dashboard_components.py::test_quality_signal_chip_body_has_ai_label -v
|
| 341 |
+
```
|
| 342 |
+
|
| 343 |
+
Expected: PASSED
|
| 344 |
+
|
| 345 |
+
- [ ] **Step 5: Run the full test suite**
|
| 346 |
+
|
| 347 |
+
```bash
|
| 348 |
+
python -m pytest tests/test_dashboard_components.py -v
|
| 349 |
+
```
|
| 350 |
+
|
| 351 |
+
Expected: All 6 tests PASSED
|
| 352 |
+
|
| 353 |
+
- [ ] **Step 6: Commit**
|
| 354 |
+
|
| 355 |
+
```bash
|
| 356 |
+
git add dashboard/components.py tests/test_dashboard_components.py
|
| 357 |
+
git commit -m "feat: mark earnings quality signal rationale as AI interpretation"
|
| 358 |
+
```
|
| 359 |
+
|
| 360 |
+
---
|
| 361 |
+
|
| 362 |
+
### Task 5: Update `verdict.py` — `what_matters_most` and `non_obvious_takeaway`
|
| 363 |
+
|
| 364 |
+
**Files:**
|
| 365 |
+
- Modify: `dashboard/verdict.py`
|
| 366 |
+
|
| 367 |
+
- [ ] **Step 1: Add `interpretation_card` to the import from `dashboard.components`**
|
| 368 |
+
|
| 369 |
+
Find this line near the top of `dashboard/verdict.py`:
|
| 370 |
+
|
| 371 |
+
```python
|
| 372 |
+
from dashboard.components import reliability_badge, source_badge, section_header, evidence_quote, fact_card, tension_card, quality_signal_chip
|
| 373 |
+
```
|
| 374 |
+
|
| 375 |
+
Replace with:
|
| 376 |
+
|
| 377 |
+
```python
|
| 378 |
+
from dashboard.components import reliability_badge, source_badge, section_header, evidence_quote, fact_card, tension_card, quality_signal_chip, interpretation_card
|
| 379 |
+
```
|
| 380 |
+
|
| 381 |
+
- [ ] **Step 2: Replace the `what_matters_most` rendering block**
|
| 382 |
+
|
| 383 |
+
Find and replace this block (around line 333):
|
| 384 |
+
|
| 385 |
+
```python
|
| 386 |
+
# BEFORE
|
| 387 |
+
wmm = brief.get("what_matters_most", "")
|
| 388 |
+
if wmm:
|
| 389 |
+
with st.expander("💡 What matters most", expanded=True):
|
| 390 |
+
st.markdown(
|
| 391 |
+
f"""
|
| 392 |
+
<div class="primer-card" style="background:{BG_MUTED};border:1px solid {BORDER};
|
| 393 |
+
border-radius:12px;padding:20px 24px;margin-bottom:4px;">
|
| 394 |
+
<div style="font-size:0.62rem;font-weight:700;letter-spacing:0.1em;
|
| 395 |
+
text-transform:uppercase;color:{TEXT_MUTED};margin-bottom:8px;">
|
| 396 |
+
AI synthesis · the only interpretive field
|
| 397 |
+
</div>
|
| 398 |
+
<div style="font-size:1rem;line-height:1.7;color:{TEXT};">{wmm}</div>
|
| 399 |
+
</div>
|
| 400 |
+
""",
|
| 401 |
+
unsafe_allow_html=True,
|
| 402 |
+
)
|
| 403 |
+
```
|
| 404 |
+
|
| 405 |
+
Replace with:
|
| 406 |
+
|
| 407 |
+
```python
|
| 408 |
+
# AFTER
|
| 409 |
+
wmm = brief.get("what_matters_most", "")
|
| 410 |
+
if wmm:
|
| 411 |
+
with st.expander("💡 What matters most", expanded=True):
|
| 412 |
+
st.markdown(
|
| 413 |
+
interpretation_card(
|
| 414 |
+
"What matters most",
|
| 415 |
+
f'<div style="font-size:1rem;line-height:1.7;color:{TEXT};">{wmm}</div>',
|
| 416 |
+
),
|
| 417 |
+
unsafe_allow_html=True,
|
| 418 |
+
)
|
| 419 |
+
```
|
| 420 |
+
|
| 421 |
+
- [ ] **Step 3: Replace the `non_obvious_takeaway` rendering block**
|
| 422 |
+
|
| 423 |
+
Inside `_render_analytical_edge()`, find and replace (around line 246):
|
| 424 |
+
|
| 425 |
+
```python
|
| 426 |
+
# BEFORE
|
| 427 |
+
if takeaway:
|
| 428 |
+
st.markdown(
|
| 429 |
+
f'<div class="primer-card" style="background:{BG_MUTED};border:1px solid {BORDER};'
|
| 430 |
+
f'border-left:4px solid {GREEN};border-radius:0 10px 10px 0;'
|
| 431 |
+
f'padding:16px 20px;margin-bottom:16px;">'
|
| 432 |
+
f'<div style="font-size:0.58rem;font-weight:700;text-transform:uppercase;'
|
| 433 |
+
f'letter-spacing:0.1em;color:{TEXT_MUTED};margin-bottom:6px;">Non-obvious takeaway</div>'
|
| 434 |
+
f'<div style="font-size:0.95rem;font-style:italic;line-height:1.65;color:{TEXT};">'
|
| 435 |
+
f'{takeaway}'
|
| 436 |
+
f'</div>'
|
| 437 |
+
f'</div>',
|
| 438 |
+
unsafe_allow_html=True,
|
| 439 |
+
)
|
| 440 |
+
```
|
| 441 |
+
|
| 442 |
+
Replace with:
|
| 443 |
+
|
| 444 |
+
```python
|
| 445 |
+
# AFTER
|
| 446 |
+
if takeaway:
|
| 447 |
+
st.markdown(
|
| 448 |
+
interpretation_card(
|
| 449 |
+
"Non-obvious takeaway",
|
| 450 |
+
f'<div style="font-size:0.95rem;font-style:italic;line-height:1.65;color:{TEXT};">'
|
| 451 |
+
f'{takeaway}</div>',
|
| 452 |
+
),
|
| 453 |
+
unsafe_allow_html=True,
|
| 454 |
+
)
|
| 455 |
+
```
|
| 456 |
+
|
| 457 |
+
- [ ] **Step 4: Verify no Python errors**
|
| 458 |
+
|
| 459 |
+
```bash
|
| 460 |
+
python -c "import dashboard.verdict"
|
| 461 |
+
```
|
| 462 |
+
|
| 463 |
+
Expected: no output (clean import).
|
| 464 |
+
|
| 465 |
+
- [ ] **Step 5: Commit**
|
| 466 |
+
|
| 467 |
+
```bash
|
| 468 |
+
git add dashboard/verdict.py
|
| 469 |
+
git commit -m "feat: apply AI interpretation styling to what_matters_most and non_obvious_takeaway"
|
| 470 |
+
```
|
| 471 |
+
|
| 472 |
+
---
|
| 473 |
+
|
| 474 |
+
### Task 6: Update `mda.py` — `language_shift`
|
| 475 |
+
|
| 476 |
+
**Files:**
|
| 477 |
+
- Modify: `dashboard/mda.py`
|
| 478 |
+
|
| 479 |
+
- [ ] **Step 1: Add `interpretation_card` to the import in `mda.py`**
|
| 480 |
+
|
| 481 |
+
Find this line near the top of `dashboard/mda.py`:
|
| 482 |
+
|
| 483 |
+
```python
|
| 484 |
+
from dashboard.components import reliability_badge, source_badge, section_header, evidence_quote, fact_card
|
| 485 |
+
```
|
| 486 |
+
|
| 487 |
+
Replace with:
|
| 488 |
+
|
| 489 |
+
```python
|
| 490 |
+
from dashboard.components import reliability_badge, source_badge, section_header, evidence_quote, fact_card, interpretation_card
|
| 491 |
+
```
|
| 492 |
+
|
| 493 |
+
- [ ] **Step 2: Remove `INFO`, `INFO_BG`, `INFO_BORDER` from the theme import if unused elsewhere**
|
| 494 |
+
|
| 495 |
+
Check the theme import at the top of `dashboard/mda.py`:
|
| 496 |
+
|
| 497 |
+
```python
|
| 498 |
+
from dashboard.theme import (
|
| 499 |
+
GREEN, AMBER, RED,
|
| 500 |
+
BG, BG_MUTED, BORDER, TEXT, TEXT_MUTED, TEXT_FAINT,
|
| 501 |
+
INFO, INFO_BG, INFO_BORDER,
|
| 502 |
+
)
|
| 503 |
+
```
|
| 504 |
+
|
| 505 |
+
After updating `language_shift`, `INFO_BG` and `INFO_BORDER` will no longer be used in this file. Remove them:
|
| 506 |
+
|
| 507 |
+
```python
|
| 508 |
+
from dashboard.theme import (
|
| 509 |
+
GREEN, AMBER, RED,
|
| 510 |
+
BG, BG_MUTED, BORDER, TEXT, TEXT_MUTED, TEXT_FAINT,
|
| 511 |
+
)
|
| 512 |
+
```
|
| 513 |
+
|
| 514 |
+
(The `INFO` token is also unused — remove it too.)
|
| 515 |
+
|
| 516 |
+
- [ ] **Step 3: Replace the `language_shift` rendering block**
|
| 517 |
+
|
| 518 |
+
Find and replace (around line 52):
|
| 519 |
+
|
| 520 |
+
```python
|
| 521 |
+
# BEFORE
|
| 522 |
+
if lang:
|
| 523 |
+
with st.expander("🔀 Language Shift vs Prior Periods", expanded=True):
|
| 524 |
+
st.markdown(
|
| 525 |
+
f"""
|
| 526 |
+
<div class="primer-card" style="background:{INFO_BG};border:1px solid {INFO_BORDER};
|
| 527 |
+
border-radius:10px;padding:16px 18px;margin-bottom:16px;">
|
| 528 |
+
<div style="font-size:0.9rem;line-height:1.6;color:{TEXT};">{lang}</div>
|
| 529 |
+
</div>
|
| 530 |
+
""",
|
| 531 |
+
unsafe_allow_html=True,
|
| 532 |
+
)
|
| 533 |
+
```
|
| 534 |
+
|
| 535 |
+
Replace with:
|
| 536 |
+
|
| 537 |
+
```python
|
| 538 |
+
# AFTER
|
| 539 |
+
if lang:
|
| 540 |
+
with st.expander("🔀 Language Shift vs Prior Periods", expanded=True):
|
| 541 |
+
st.markdown(
|
| 542 |
+
interpretation_card(
|
| 543 |
+
"Language shift",
|
| 544 |
+
f'<div style="font-size:0.9rem;line-height:1.6;color:{TEXT};">{lang}</div>',
|
| 545 |
+
),
|
| 546 |
+
unsafe_allow_html=True,
|
| 547 |
+
)
|
| 548 |
+
```
|
| 549 |
+
|
| 550 |
+
- [ ] **Step 4: Verify no Python errors**
|
| 551 |
+
|
| 552 |
+
```bash
|
| 553 |
+
python -c "import dashboard.mda"
|
| 554 |
+
```
|
| 555 |
+
|
| 556 |
+
Expected: no output (clean import).
|
| 557 |
+
|
| 558 |
+
- [ ] **Step 5: Run the full test suite**
|
| 559 |
+
|
| 560 |
+
```bash
|
| 561 |
+
python -m pytest tests/ -v --tb=short
|
| 562 |
+
```
|
| 563 |
+
|
| 564 |
+
Expected: all tests pass (no regressions).
|
| 565 |
+
|
| 566 |
+
- [ ] **Step 6: Commit**
|
| 567 |
+
|
| 568 |
+
```bash
|
| 569 |
+
git add dashboard/mda.py
|
| 570 |
+
git commit -m "feat: apply AI interpretation styling to language_shift in MD&A"
|
| 571 |
+
```
|
| 572 |
+
|
| 573 |
+
---
|
| 574 |
+
|
| 575 |
+
## Post-Implementation Check
|
| 576 |
+
|
| 577 |
+
After all 6 tasks are committed, do a visual check by running the app:
|
| 578 |
+
|
| 579 |
+
```bash
|
| 580 |
+
streamlit run app.py
|
| 581 |
+
```
|
| 582 |
+
|
| 583 |
+
Generate a brief for any ticker in your test data and verify:
|
| 584 |
+
1. `what_matters_most` — purple left border + "✦ AI Synthesis" badge, not the old gray card
|
| 585 |
+
2. `non_obvious_takeaway` (Analytical Edge section) — same purple treatment, no green border
|
| 586 |
+
3. Tension cards — green/red backgrounds preserved, "✦ AI" badge appears next to both "Surface reading" and "Deeper reading" labels
|
| 587 |
+
4. Earnings quality chips (expanded) — "✦ AI assessment" label appears above the rationale text
|
| 588 |
+
5. MD&A Language Shift — purple treatment instead of blue
|
| 589 |
+
6. All SourcedFact cards (what_changed, bull_points, bear_points) — unchanged
|
docs/superpowers/specs/2026-05-07-interpretation-visual-design.md
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Spec : Différenciation visuelle interprétation AI vs faits sourcés
|
| 2 |
+
|
| 3 |
+
**Date :** 2026-05-07
|
| 4 |
+
**Scope :** Dashboard Streamlit — tous les tabs contenant des champs interprétatifs
|
| 5 |
+
|
| 6 |
+
---
|
| 7 |
+
|
| 8 |
+
## Problème
|
| 9 |
+
|
| 10 |
+
Le brief Primer mélange deux types de contenu fondamentalement différents :
|
| 11 |
+
- **Faits sourcés** (`SourcedFact`) : affirmations tirées de documents publics (10-K, 10-Q, transcripts, news), identifiées par une source et un niveau de fiabilité.
|
| 12 |
+
- **Interprétations AI** : synthèses et jugements produits par le LLM, sans ancrage direct dans un document — `what_matters_most`, `non_obvious_takeaway`, `language_shift`, les "readings" des tensions, les assessments des quality signals.
|
| 13 |
+
|
| 14 |
+
Actuellement, seul `what_matters_most` porte un label textuel discret ("AI synthesis · the only interpretive field"). Les autres champs interprétatifs n'ont aucun marqueur visuel. Un utilisateur peut lire le `bearish_reading` d'une tension card ou le `language_shift` du MD&A sans réaliser que c'est du jugement AI, pas un fait sourcé.
|
| 15 |
+
|
| 16 |
+
---
|
| 17 |
+
|
| 18 |
+
## Décision de design
|
| 19 |
+
|
| 20 |
+
**Option retenue : bordure gauche violette + fond teinté + badge "✦ AI Synthesis"**
|
| 21 |
+
|
| 22 |
+
Chaque zone d'interprétation reçoit :
|
| 23 |
+
- Bordure gauche `4px solid #8b5cf6`
|
| 24 |
+
- Fond `#faf5ff` (violet très pâle)
|
| 25 |
+
- Badge inline `✦ AI Synthesis` : `background:#ede9fe; color:#7c3aed; border:1px solid #c4b5fd`
|
| 26 |
+
|
| 27 |
+
Ce traitement étend le langage visuel existant plutôt que d'introduire un nouveau pattern :
|
| 28 |
+
|
| 29 |
+
| Couleur | Signification |
|
| 30 |
+
|---------|--------------|
|
| 31 |
+
| Violet `#8b5cf6` | Interprétation AI (nouveau) |
|
| 32 |
+
| Vert `#10b981` | Bull / fait positif sourcé |
|
| 33 |
+
| Rouge `#ef4444` | Bear / risque sourcé |
|
| 34 |
+
| Ambre `#f59e0b` | Tension / warning |
|
| 35 |
+
| Bleu `#3b82f6` | Info / raisonnement agent |
|
| 36 |
+
|
| 37 |
+
---
|
| 38 |
+
|
| 39 |
+
## Champs concernés et traitement
|
| 40 |
+
|
| 41 |
+
### 1. `what_matters_most` — `dashboard/verdict.py`
|
| 42 |
+
|
| 43 |
+
**Avant :** card `BG_MUTED` avec label textuel "AI synthesis · the only interpretive field".
|
| 44 |
+
**Après :** card avec `background:#faf5ff`, `border-left:4px solid #8b5cf6`, badge "✦ AI Synthesis" inline à côté du label.
|
| 45 |
+
|
| 46 |
+
### 2. `non_obvious_takeaway` — `dashboard/verdict.py`
|
| 47 |
+
|
| 48 |
+
**Avant :** card `BG_MUTED` avec `border-left:4px solid GREEN`, label "Non-obvious takeaway".
|
| 49 |
+
**Après :** `background:#faf5ff`, `border-left:4px solid #8b5cf6`, badge "✦ AI Synthesis".
|
| 50 |
+
Le fond vert est remplacé par violet — le contenu n'est pas un fait bull, c'est une synthèse.
|
| 51 |
+
|
| 52 |
+
### 3. Tension cards — panels "Surface reading" et "Deeper reading" — `dashboard/components.py`
|
| 53 |
+
|
| 54 |
+
**Avant :** panels verts (`BULL_BG`) et rouges (`BEAR_BG`) sans distinction fait/interprétation.
|
| 55 |
+
**Après :** les backgrounds vert/rouge **sont conservés** — ils orientent visuellement bull vs bear et ne doivent pas être perdus. On ajoute uniquement un micro-badge "✦ AI" (font-size 0.55rem, couleur `#7c3aed`) inline à droite des labels "Surface reading" et "Deeper reading". L'evidence sourcée en dessous (SourcedFact avec HIGH/10-Q) reste dans un sous-bloc inchangé.
|
| 56 |
+
|
| 57 |
+
Note : le fond ambre de la tension card globale (`WARN_BG`) n'est pas modifié — c'est le conteneur, pas le contenu interprétatif.
|
| 58 |
+
|
| 59 |
+
### 4. `mda_summary.language_shift` — `dashboard/mda.py`
|
| 60 |
+
|
| 61 |
+
**Avant :** texte inline sans traitement particulier.
|
| 62 |
+
**Après :** bloc `background:#faf5ff; border-left:4px solid #8b5cf6; border-radius:0 8px 8px 0; padding:10px 14px` avec badge "✦ AI" en header.
|
| 63 |
+
|
| 64 |
+
### 5. `earnings_quality_signals` — rationale — `dashboard/components.py`
|
| 65 |
+
|
| 66 |
+
Le chip collapsible `quality_signal_chip` est petit. Le badge "✦ AI" sur le chip serait trop chargé.
|
| 67 |
+
**Traitement allégé :** dans le corps déplié, le texte `rationale` reçoit un micro-label "Interprétation AI" (font-size 0.55rem, couleur `#7c3aed`) avant le texte. Pas de fond violet — le chip a déjà son propre fond coloré selon l'assessment.
|
| 68 |
+
|
| 69 |
+
---
|
| 70 |
+
|
| 71 |
+
## Ce qui ne change PAS
|
| 72 |
+
|
| 73 |
+
- Tous les `SourcedFact` (what_changed, bull_points, bear_points, standout_number, evidence dans les tensions) — inchangés.
|
| 74 |
+
- `what_to_watch` items — forward-looking catalysts, pas de l'interprétation AI, restent inchangés.
|
| 75 |
+
- `ManagementCommentaryTopic`, `CategorizedRisk`, `GuidancePoint` — tous sourcés, inchangés.
|
| 76 |
+
- Les badges `reliability_badge` et `source_badge` existants — inchangés.
|
| 77 |
+
|
| 78 |
+
---
|
| 79 |
+
|
| 80 |
+
## Nouveau token dans `theme.py`
|
| 81 |
+
|
| 82 |
+
```python
|
| 83 |
+
PURPLE = "#8b5cf6" # déjà présent
|
| 84 |
+
AI_BG = "#faf5ff" # à ajouter
|
| 85 |
+
AI_BORDER = "#ddd6fe" # à ajouter
|
| 86 |
+
AI_COLOR = "#7c3aed" # à ajouter
|
| 87 |
+
AI_BADGE_BG = "#ede9fe" # à ajouter
|
| 88 |
+
```
|
| 89 |
+
|
| 90 |
+
---
|
| 91 |
+
|
| 92 |
+
## Nouveau composant partagé dans `dashboard/components.py`
|
| 93 |
+
|
| 94 |
+
```python
|
| 95 |
+
def ai_badge(label: str = "AI Synthesis") -> str:
|
| 96 |
+
"""Chip violet inline pour marquer un champ interprétatif."""
|
| 97 |
+
...
|
| 98 |
+
|
| 99 |
+
def interpretation_card(content_html: str, label: str = "") -> str:
|
| 100 |
+
"""Wrapper card violette pour un bloc interprétatif."""
|
| 101 |
+
...
|
| 102 |
+
```
|
| 103 |
+
|
| 104 |
+
Ces deux helpers centralisent le style pour éviter la duplication dans verdict.py, mda.py et components.py.
|
| 105 |
+
|
| 106 |
+
---
|
| 107 |
+
|
| 108 |
+
## Fichiers modifiés
|
| 109 |
+
|
| 110 |
+
| Fichier | Changement |
|
| 111 |
+
|---------|-----------|
|
| 112 |
+
| `dashboard/theme.py` | Ajouter `AI_BG`, `AI_BORDER`, `AI_COLOR`, `AI_BADGE_BG` |
|
| 113 |
+
| `dashboard/components.py` | Ajouter `ai_badge()` et `interpretation_card()`; modifier `tension_card()` et `quality_signal_chip()` |
|
| 114 |
+
| `dashboard/verdict.py` | Modifier rendering de `what_matters_most` et `non_obvious_takeaway` |
|
| 115 |
+
| `dashboard/mda.py` | Modifier rendering de `language_shift` |
|
| 116 |
+
|
| 117 |
+
---
|
| 118 |
+
|
| 119 |
+
## Hors scope
|
| 120 |
+
|
| 121 |
+
- Aucune modification du schéma `BriefOutput` ou des prompts agent.
|
| 122 |
+
- Aucune modification de la logique de génération — frontend only.
|
| 123 |
+
- Pas de refactoring des autres sections du dashboard.
|
ingest.py
CHANGED
|
@@ -10,6 +10,7 @@ from ingestion.embedder import clear_ticker_data, embed_and_store_filing, embed_
|
|
| 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 |
|
| 14 |
N_ANNUAL = 3
|
| 15 |
N_QUARTERLY = 12
|
|
@@ -41,6 +42,7 @@ def _period_to_av_quarter(period: str) -> str:
|
|
| 41 |
def ingest(ticker: str) -> None:
|
| 42 |
print(f"[ingest] Starting ingestion for {ticker.upper()}")
|
| 43 |
init_db()
|
|
|
|
| 44 |
|
| 45 |
print(f"[ingest] Fetching EDGAR data (last {N_ANNUAL} annual + {N_QUARTERLY} quarterly)...")
|
| 46 |
filings = fetch_all_edgar_data(ticker, n_annual=N_ANNUAL, n_quarterly=N_QUARTERLY)
|
|
@@ -103,6 +105,12 @@ def ingest(ticker: str) -> None:
|
|
| 103 |
**guidance_struct,
|
| 104 |
})
|
| 105 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
embed_and_store_filing(
|
| 107 |
ticker=edgar.ticker,
|
| 108 |
company_name=edgar.company_name,
|
|
@@ -114,6 +122,7 @@ def ingest(ticker: str) -> None:
|
|
| 114 |
)
|
| 115 |
|
| 116 |
if transcript:
|
|
|
|
| 117 |
embed_and_store_transcript(
|
| 118 |
ticker=edgar.ticker,
|
| 119 |
company_name=edgar.company_name,
|
|
|
|
| 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
|
|
|
|
| 42 |
def ingest(ticker: str) -> None:
|
| 43 |
print(f"[ingest] Starting ingestion for {ticker.upper()}")
|
| 44 |
init_db()
|
| 45 |
+
init_sections_db()
|
| 46 |
|
| 47 |
print(f"[ingest] Fetching EDGAR data (last {N_ANNUAL} annual + {N_QUARTERLY} quarterly)...")
|
| 48 |
filings = fetch_all_edgar_data(ticker, n_annual=N_ANNUAL, n_quarterly=N_QUARTERLY)
|
|
|
|
| 105 |
**guidance_struct,
|
| 106 |
})
|
| 107 |
|
| 108 |
+
# Persist raw section text for cross-period text diffing (analysis/textdiff.py)
|
| 109 |
+
if edgar.mda_text:
|
| 110 |
+
upsert_section(edgar.ticker, edgar.period, edgar.form_type, "mda", edgar.mda_text)
|
| 111 |
+
if edgar.risk_factors_text:
|
| 112 |
+
upsert_section(edgar.ticker, edgar.period, edgar.form_type, "risk_factors", edgar.risk_factors_text)
|
| 113 |
+
|
| 114 |
embed_and_store_filing(
|
| 115 |
ticker=edgar.ticker,
|
| 116 |
company_name=edgar.company_name,
|
|
|
|
| 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,
|
storage/sections_db.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""storage/sections_db.py — persist raw section text across filing periods.
|
| 2 |
+
|
| 3 |
+
Stores verbatim MD&A, Risk Factors, and transcript text keyed by
|
| 4 |
+
(ticker, period, section). Used by analysis/textdiff.py to compare text
|
| 5 |
+
across periods without re-fetching from EDGAR or Alpha Vantage.
|
| 6 |
+
|
| 7 |
+
This is append-only at ingest time; textdiff reads it at runtime.
|
| 8 |
+
"""
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import sqlite3
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
from typing import Optional
|
| 14 |
+
|
| 15 |
+
SECTIONS_DB_PATH = Path("data/sections.db")
|
| 16 |
+
|
| 17 |
+
_SCHEMA = """
|
| 18 |
+
CREATE TABLE IF NOT EXISTS sections (
|
| 19 |
+
ticker TEXT NOT NULL,
|
| 20 |
+
period TEXT NOT NULL,
|
| 21 |
+
form_type TEXT NOT NULL,
|
| 22 |
+
section TEXT NOT NULL,
|
| 23 |
+
text TEXT NOT NULL DEFAULT '',
|
| 24 |
+
ingested_at TEXT,
|
| 25 |
+
PRIMARY KEY (ticker, period, section)
|
| 26 |
+
)
|
| 27 |
+
"""
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def init_sections_db() -> None:
|
| 31 |
+
SECTIONS_DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
| 32 |
+
with sqlite3.connect(SECTIONS_DB_PATH) as conn:
|
| 33 |
+
conn.execute(_SCHEMA)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def upsert_section(
|
| 37 |
+
ticker: str,
|
| 38 |
+
period: str,
|
| 39 |
+
form_type: str,
|
| 40 |
+
section: str,
|
| 41 |
+
text: str,
|
| 42 |
+
) -> None:
|
| 43 |
+
"""Write (or overwrite) a section's text. section is one of: mda, risk_factors, transcript."""
|
| 44 |
+
from datetime import datetime, timezone
|
| 45 |
+
SECTIONS_DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
| 46 |
+
with sqlite3.connect(SECTIONS_DB_PATH) as conn:
|
| 47 |
+
conn.execute(_SCHEMA)
|
| 48 |
+
conn.execute(
|
| 49 |
+
"""
|
| 50 |
+
INSERT INTO sections (ticker, period, form_type, section, text, ingested_at)
|
| 51 |
+
VALUES (?, ?, ?, ?, ?, ?)
|
| 52 |
+
ON CONFLICT(ticker, period, section) DO UPDATE SET
|
| 53 |
+
form_type = excluded.form_type,
|
| 54 |
+
text = excluded.text,
|
| 55 |
+
ingested_at = excluded.ingested_at
|
| 56 |
+
""",
|
| 57 |
+
(
|
| 58 |
+
ticker.upper(),
|
| 59 |
+
period,
|
| 60 |
+
form_type,
|
| 61 |
+
section,
|
| 62 |
+
text or "",
|
| 63 |
+
datetime.now(timezone.utc).isoformat(),
|
| 64 |
+
),
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def get_section(ticker: str, period: str, section: str) -> Optional[str]:
|
| 69 |
+
"""Return the stored text for (ticker, period, section), or None if absent."""
|
| 70 |
+
if not SECTIONS_DB_PATH.exists():
|
| 71 |
+
return None
|
| 72 |
+
with sqlite3.connect(SECTIONS_DB_PATH) as conn:
|
| 73 |
+
row = conn.execute(
|
| 74 |
+
"SELECT text FROM sections WHERE ticker=? AND period=? AND section=?",
|
| 75 |
+
(ticker.upper(), period, section),
|
| 76 |
+
).fetchone()
|
| 77 |
+
return row[0] if row else None
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def _period_sort_key(period: str) -> tuple[int, int]:
|
| 81 |
+
"""Parse 'Q12027' → (2027, 1) for correct chronological sort (newest first).
|
| 82 |
+
|
| 83 |
+
Falls back to (0, 0) for unparseable strings (e.g. 'FY2024').
|
| 84 |
+
"""
|
| 85 |
+
if not period:
|
| 86 |
+
return (0, 0)
|
| 87 |
+
if period.startswith("Q") and len(period) >= 6:
|
| 88 |
+
try:
|
| 89 |
+
body = period[1:] # "12027"
|
| 90 |
+
year = int(body[-4:]) # 2027
|
| 91 |
+
quarter = int(body[:-4]) # 1
|
| 92 |
+
return (year, quarter)
|
| 93 |
+
except (ValueError, IndexError):
|
| 94 |
+
pass
|
| 95 |
+
if period.startswith("FY") and len(period) == 6:
|
| 96 |
+
try:
|
| 97 |
+
return (int(period[2:]), 0)
|
| 98 |
+
except ValueError:
|
| 99 |
+
pass
|
| 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 |
+
|
| 106 |
+
Optionally filtered by form_type (e.g. '10-Q').
|
| 107 |
+
"""
|
| 108 |
+
if not SECTIONS_DB_PATH.exists():
|
| 109 |
+
return []
|
| 110 |
+
with sqlite3.connect(SECTIONS_DB_PATH) as conn:
|
| 111 |
+
if form_type:
|
| 112 |
+
rows = conn.execute(
|
| 113 |
+
"SELECT DISTINCT period FROM sections WHERE ticker=? AND form_type=?",
|
| 114 |
+
(ticker.upper(), form_type),
|
| 115 |
+
).fetchall()
|
| 116 |
+
else:
|
| 117 |
+
rows = conn.execute(
|
| 118 |
+
"SELECT DISTINCT period FROM sections WHERE ticker=?",
|
| 119 |
+
(ticker.upper(),),
|
| 120 |
+
).fetchall()
|
| 121 |
+
periods = [r[0] for r in rows]
|
| 122 |
+
periods.sort(key=_period_sort_key, reverse=True)
|
| 123 |
+
return periods
|
tests/test_dashboard_components.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Unit tests for dashboard/components.py HTML helpers."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
import pytest
|
| 4 |
+
from unittest.mock import patch, MagicMock
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def test_ai_badge_default_label():
|
| 8 |
+
from dashboard.components import ai_badge
|
| 9 |
+
html = ai_badge()
|
| 10 |
+
assert "✦ AI Synthesis" in html
|
| 11 |
+
assert "#7c3aed" in html
|
| 12 |
+
assert "#ede9fe" in html
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def test_ai_badge_custom_label():
|
| 16 |
+
from dashboard.components import ai_badge
|
| 17 |
+
html = ai_badge("AI")
|
| 18 |
+
assert "✦ AI" in html
|
| 19 |
+
assert "#7c3aed" in html
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def test_interpretation_card_contains_badge_and_content():
|
| 23 |
+
from dashboard.components import interpretation_card
|
| 24 |
+
html = interpretation_card("What matters most", "<p>Some insight</p>")
|
| 25 |
+
assert "✦ AI Synthesis" in html
|
| 26 |
+
assert "#8b5cf6" in html # purple border (PURPLE token value)
|
| 27 |
+
assert "#faf5ff" in html # purple tint background (AI_BG token value)
|
| 28 |
+
assert "What matters most" in html
|
| 29 |
+
assert "<p>Some insight</p>" in html
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def test_interpretation_card_custom_badge_label():
|
| 33 |
+
from dashboard.components import interpretation_card
|
| 34 |
+
html = interpretation_card("Language shift", "<p>tone changed</p>", badge_label="AI")
|
| 35 |
+
assert "✦ AI" in html
|
| 36 |
+
assert "Language shift" in html
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def test_tension_card_reading_panels_have_ai_badge():
|
| 40 |
+
"""tension_card must add ✦ AI badge next to both reading labels."""
|
| 41 |
+
import streamlit as st
|
| 42 |
+
from unittest.mock import patch
|
| 43 |
+
from dashboard.components import tension_card
|
| 44 |
+
|
| 45 |
+
tension = {
|
| 46 |
+
"headline": "Revenue beat hides quality decline",
|
| 47 |
+
"weight": "material",
|
| 48 |
+
"bullish_reading": "Strong top-line momentum",
|
| 49 |
+
"bearish_reading": "One-time item inflated result",
|
| 50 |
+
"bullish_evidence": {"evidence_snippet": "q1", "reliability": "HIGH", "source": "10-Q"},
|
| 51 |
+
"bearish_evidence": {"evidence_snippet": "q2", "reliability": "HIGH", "source": "10-Q"},
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
with patch.object(st, "markdown") as mock_md:
|
| 55 |
+
tension_card(tension)
|
| 56 |
+
rendered = mock_md.call_args[0][0]
|
| 57 |
+
|
| 58 |
+
assert "✦ AI" in rendered
|
| 59 |
+
assert rendered.count("✦ AI") >= 2 # once per reading panel
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def test_quality_signal_chip_body_has_ai_label():
|
| 63 |
+
"""quality_signal_chip must include an AI label before the rationale text in the body."""
|
| 64 |
+
from dashboard.components import quality_signal_chip
|
| 65 |
+
|
| 66 |
+
signal = {
|
| 67 |
+
"dimension": "guidance_dynamics",
|
| 68 |
+
"assessment": "positive",
|
| 69 |
+
"rationale": "Management raised full-year guidance for the third consecutive quarter.",
|
| 70 |
+
"evidence": {"evidence_snippet": "raised guidance", "source": "10-Q", "reliability": "HIGH"},
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
html = quality_signal_chip(signal)
|
| 74 |
+
assert "✦ AI" in html
|
| 75 |
+
assert "Management raised full-year guidance" in html
|
| 76 |
+
# AI label must appear before the rationale text
|
| 77 |
+
assert html.index("✦ AI") < html.index("Management raised full-year guidance")
|
tests/test_sections_db.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""tests/test_sections_db.py — unit tests for storage/sections_db.py."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import sqlite3
|
| 5 |
+
import tempfile
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from unittest.mock import patch
|
| 8 |
+
|
| 9 |
+
import pytest
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def _tmp_db(tmp_path: Path):
|
| 13 |
+
return tmp_path / "sections.db"
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def test_upsert_and_get_section(tmp_path):
|
| 17 |
+
db_path = _tmp_db(tmp_path)
|
| 18 |
+
with patch("storage.sections_db.SECTIONS_DB_PATH", db_path):
|
| 19 |
+
from storage.sections_db import init_sections_db, upsert_section, get_section
|
| 20 |
+
init_sections_db()
|
| 21 |
+
upsert_section("AAPL", "Q12026", "10-Q", "mda", "Revenue grew 8% YoY...")
|
| 22 |
+
text = get_section("AAPL", "Q12026", "mda")
|
| 23 |
+
assert text == "Revenue grew 8% YoY..."
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def test_upsert_overwrites_existing(tmp_path):
|
| 27 |
+
db_path = _tmp_db(tmp_path)
|
| 28 |
+
with patch("storage.sections_db.SECTIONS_DB_PATH", db_path):
|
| 29 |
+
from storage.sections_db import init_sections_db, upsert_section, get_section
|
| 30 |
+
init_sections_db()
|
| 31 |
+
upsert_section("AAPL", "Q12026", "10-Q", "mda", "v1")
|
| 32 |
+
upsert_section("AAPL", "Q12026", "10-Q", "mda", "v2")
|
| 33 |
+
assert get_section("AAPL", "Q12026", "mda") == "v2"
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def test_get_section_returns_none_when_missing(tmp_path):
|
| 37 |
+
db_path = _tmp_db(tmp_path)
|
| 38 |
+
with patch("storage.sections_db.SECTIONS_DB_PATH", db_path):
|
| 39 |
+
from storage.sections_db import init_sections_db, get_section
|
| 40 |
+
init_sections_db()
|
| 41 |
+
assert get_section("NVDA", "Q12026", "mda") is None
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def test_get_periods_for_ticker(tmp_path):
|
| 45 |
+
db_path = _tmp_db(tmp_path)
|
| 46 |
+
with patch("storage.sections_db.SECTIONS_DB_PATH", db_path):
|
| 47 |
+
from storage.sections_db import init_sections_db, upsert_section, get_periods_for_ticker
|
| 48 |
+
init_sections_db()
|
| 49 |
+
for period in ["Q12026", "Q42025", "Q32025"]:
|
| 50 |
+
upsert_section("NVDA", period, "10-Q", "mda", f"text for {period}")
|
| 51 |
+
periods = get_periods_for_ticker("NVDA", form_type="10-Q")
|
| 52 |
+
assert "Q12026" in periods
|
| 53 |
+
assert "Q42025" in periods
|
| 54 |
+
assert "Q32025" in periods
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def test_get_periods_returns_empty_when_no_db(tmp_path):
|
| 58 |
+
nonexistent = tmp_path / "nosuchfile.db"
|
| 59 |
+
with patch("storage.sections_db.SECTIONS_DB_PATH", nonexistent):
|
| 60 |
+
from storage.sections_db import get_periods_for_ticker
|
| 61 |
+
assert get_periods_for_ticker("AAPL") == []
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def test_get_periods_chronological_sort(tmp_path):
|
| 65 |
+
"""Q12027 must come before Q32026 — chronological not alphabetical."""
|
| 66 |
+
db_path = _tmp_db(tmp_path)
|
| 67 |
+
with patch("storage.sections_db.SECTIONS_DB_PATH", db_path):
|
| 68 |
+
from storage.sections_db import init_sections_db, upsert_section, get_periods_for_ticker
|
| 69 |
+
init_sections_db()
|
| 70 |
+
for period in ["Q32026", "Q22026", "Q12027", "Q12026"]:
|
| 71 |
+
upsert_section("NVDA", period, "10-Q", "mda", f"text {period}")
|
| 72 |
+
periods = get_periods_for_ticker("NVDA", form_type="10-Q")
|
| 73 |
+
assert periods[0] == "Q12027", f"Expected Q12027 first, got {periods}"
|
| 74 |
+
assert periods[1] == "Q32026", f"Expected Q32026 second, got {periods}"
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def test_ticker_is_case_insensitive(tmp_path):
|
| 78 |
+
db_path = _tmp_db(tmp_path)
|
| 79 |
+
with patch("storage.sections_db.SECTIONS_DB_PATH", db_path):
|
| 80 |
+
from storage.sections_db import init_sections_db, upsert_section, get_section
|
| 81 |
+
init_sections_db()
|
| 82 |
+
upsert_section("aapl", "Q12026", "10-Q", "mda", "lowercase insert")
|
| 83 |
+
text = get_section("AAPL", "Q12026", "mda")
|
| 84 |
+
assert text == "lowercase insert"
|
tests/test_textdiff.py
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""tests/test_textdiff.py — unit tests for analysis/textdiff.py.
|
| 2 |
+
|
| 3 |
+
Tests are purely deterministic: they do NOT call the sentence-transformer
|
| 4 |
+
model (mocked), do NOT hit sections_db (mocked), and do NOT require any
|
| 5 |
+
ingested data. Pure function logic only.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import sys
|
| 10 |
+
from unittest.mock import MagicMock, patch
|
| 11 |
+
import numpy as np
|
| 12 |
+
import pytest
|
| 13 |
+
|
| 14 |
+
# ---------------------------------------------------------------------------
|
| 15 |
+
# Helpers — synthetic text fixtures
|
| 16 |
+
# ---------------------------------------------------------------------------
|
| 17 |
+
|
| 18 |
+
RISK_TEXT_A = """
|
| 19 |
+
We operate in highly competitive markets and face competition from well-established
|
| 20 |
+
companies that have greater financial resources and brand recognition. Our ability
|
| 21 |
+
to compete effectively depends on our product quality, customer service, and pricing.
|
| 22 |
+
|
| 23 |
+
Our operations are subject to various environmental laws and regulations.
|
| 24 |
+
Non-compliance could result in fines, penalties, or operational disruptions.
|
| 25 |
+
|
| 26 |
+
Cybersecurity threats represent a significant and evolving risk. A breach of our
|
| 27 |
+
information systems could expose sensitive customer data and result in material harm.
|
| 28 |
+
"""
|
| 29 |
+
|
| 30 |
+
RISK_TEXT_B_REWORDED = """
|
| 31 |
+
We operate in highly competitive markets and face intense and accelerating competition
|
| 32 |
+
from well-established companies as well as new market entrants leveraging AI capabilities.
|
| 33 |
+
Our ability to compete depends on product quality, customer service, pricing, and
|
| 34 |
+
the pace of our AI-driven product development.
|
| 35 |
+
|
| 36 |
+
Our operations are subject to various environmental laws and regulations.
|
| 37 |
+
Non-compliance could result in fines, penalties, or operational disruptions.
|
| 38 |
+
|
| 39 |
+
Cybersecurity threats represent a significant and evolving risk. A breach of our
|
| 40 |
+
information systems could expose sensitive customer data and result in material harm.
|
| 41 |
+
|
| 42 |
+
New: Increasing export control restrictions on advanced semiconductors may limit our
|
| 43 |
+
ability to sell products in certain international markets, which could materially
|
| 44 |
+
reduce our revenue and profitability.
|
| 45 |
+
"""
|
| 46 |
+
|
| 47 |
+
MDA_TEXT_A = """
|
| 48 |
+
We expect revenue to grow at a strong double-digit rate in the coming quarters,
|
| 49 |
+
driven by continued demand for our data center products. We anticipate maintaining
|
| 50 |
+
operating margins above 30% through operational efficiency programs.
|
| 51 |
+
|
| 52 |
+
Our capital return program remains on track, with guidance for $2B in share
|
| 53 |
+
repurchases during the fiscal year.
|
| 54 |
+
"""
|
| 55 |
+
|
| 56 |
+
MDA_TEXT_B_CAUTIOUS = """
|
| 57 |
+
We expect growth to moderate in the coming quarters due to macro uncertainty and
|
| 58 |
+
softening demand in certain end markets. We anticipate operating margins may face
|
| 59 |
+
headwinds from competitive pricing pressure.
|
| 60 |
+
|
| 61 |
+
Our capital return program continues. We plan to evaluate buyback levels based on
|
| 62 |
+
market conditions and cash generation.
|
| 63 |
+
"""
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
# ---------------------------------------------------------------------------
|
| 67 |
+
# Tests: text splitter
|
| 68 |
+
# ---------------------------------------------------------------------------
|
| 69 |
+
|
| 70 |
+
def test_split_into_items_filters_short_paragraphs():
|
| 71 |
+
from analysis.textdiff import _split_into_items
|
| 72 |
+
text = "Short.\n\nThis is a much longer paragraph with enough words to pass the minimum threshold and be included in the output."
|
| 73 |
+
items = _split_into_items(text, min_words=10)
|
| 74 |
+
assert len(items) == 1
|
| 75 |
+
assert "longer paragraph" in items[0]
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def test_split_into_items_merges_headers():
|
| 79 |
+
from analysis.textdiff import _split_into_items
|
| 80 |
+
text = "Risk Header\n\nThis is the full risk description with plenty of words to meet the minimum requirement for inclusion."
|
| 81 |
+
items = _split_into_items(text, min_words=10)
|
| 82 |
+
assert len(items) == 1
|
| 83 |
+
assert "Risk Header" in items[0]
|
| 84 |
+
assert "full risk description" in items[0]
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
# ---------------------------------------------------------------------------
|
| 88 |
+
# Tests: lexicon frequency (no model needed)
|
| 89 |
+
# ---------------------------------------------------------------------------
|
| 90 |
+
|
| 91 |
+
def test_lexicon_delta_detects_tariff_spike():
|
| 92 |
+
from analysis.textdiff import compute_lexicon_deltas
|
| 93 |
+
|
| 94 |
+
prior = "We operate globally. There is some tariff exposure in our supply chain."
|
| 95 |
+
current = (
|
| 96 |
+
"We operate globally. Tariff increases have materially impacted our cost structure. "
|
| 97 |
+
"New tariff policies on semiconductor imports create uncertainty. "
|
| 98 |
+
"We expect tariff headwinds to persist into fiscal 2027. "
|
| 99 |
+
"Export control and tariff restrictions continue to expand."
|
| 100 |
+
)
|
| 101 |
+
deltas = compute_lexicon_deltas(current, prior, "Q42025", "Q12026", "10-Q")
|
| 102 |
+
tariff_deltas = [d for d in deltas if d.term == "tariff"]
|
| 103 |
+
assert len(tariff_deltas) >= 1
|
| 104 |
+
d = tariff_deltas[0]
|
| 105 |
+
assert d.kind == "term_frequency"
|
| 106 |
+
assert d.significance in ("HIGH", "MEDIUM")
|
| 107 |
+
assert "→" in d.computed_metric
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def test_lexicon_no_delta_when_counts_stable():
|
| 111 |
+
from analysis.textdiff import compute_lexicon_deltas
|
| 112 |
+
|
| 113 |
+
text = "We anticipate uncertainty in our markets. Uncertainty is always present."
|
| 114 |
+
deltas = compute_lexicon_deltas(text, text, "Q42025", "Q12026", "10-Q")
|
| 115 |
+
# Same text both periods → no delta
|
| 116 |
+
assert all(d.kind == "term_frequency" for d in deltas)
|
| 117 |
+
assert len(deltas) == 0
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
# ---------------------------------------------------------------------------
|
| 121 |
+
# Tests: guidance language shift (no model needed)
|
| 122 |
+
# ---------------------------------------------------------------------------
|
| 123 |
+
|
| 124 |
+
def test_guidance_shift_detects_more_cautious():
|
| 125 |
+
from analysis.textdiff import compute_guidance_shifts
|
| 126 |
+
deltas = compute_guidance_shifts(MDA_TEXT_B_CAUTIOUS, MDA_TEXT_A, "Q42025", "Q12026", "10-Q")
|
| 127 |
+
assert len(deltas) == 1
|
| 128 |
+
d = deltas[0]
|
| 129 |
+
assert d.kind == "guidance_language_shift"
|
| 130 |
+
assert "cautious" in d.computed_metric.lower() or "hedge" in d.computed_metric.lower() or "→" in d.computed_metric
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def test_guidance_shift_empty_on_no_text():
|
| 134 |
+
from analysis.textdiff import compute_guidance_shifts
|
| 135 |
+
assert compute_guidance_shifts("", "", "Q42025", "Q12026", "10-Q") == []
|
| 136 |
+
assert compute_guidance_shifts(MDA_TEXT_A, "", "Q42025", "Q12026", "10-Q") == []
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
# ---------------------------------------------------------------------------
|
| 140 |
+
# Tests: KPI drop detection (no model needed)
|
| 141 |
+
# ---------------------------------------------------------------------------
|
| 142 |
+
|
| 143 |
+
def test_kpi_dropped_detects_guidance_disappearance():
|
| 144 |
+
from analysis.textdiff import compute_kpi_drops
|
| 145 |
+
|
| 146 |
+
prior_mda = "Our guidance for next quarter is $10B revenue. We also discuss backlog of $5B."
|
| 147 |
+
current_mda = "Revenue exceeded expectations. We remain focused on growth."
|
| 148 |
+
|
| 149 |
+
deltas = compute_kpi_drops(current_mda, prior_mda, "Q42025", "Q12026", "10-Q")
|
| 150 |
+
terms = {d.term for d in deltas}
|
| 151 |
+
assert "guidance" in terms
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def test_kpi_dropped_no_signal_when_present():
|
| 155 |
+
from analysis.textdiff import compute_kpi_drops
|
| 156 |
+
|
| 157 |
+
prior_mda = "Free cash flow was $2B. Guidance for next quarter is strong."
|
| 158 |
+
current_mda = "Free cash flow improved to $2.5B. Guidance remains $10-11B revenue."
|
| 159 |
+
|
| 160 |
+
deltas = compute_kpi_drops(current_mda, prior_mda, "Q42025", "Q12026", "10-Q")
|
| 161 |
+
assert len(deltas) == 0
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
# ---------------------------------------------------------------------------
|
| 165 |
+
# Tests: risk factor diff (mocked embeddings to avoid loading model)
|
| 166 |
+
# ---------------------------------------------------------------------------
|
| 167 |
+
|
| 168 |
+
def _make_mock_embed(n_items_current: int, n_items_prior: int, similarity_matrix: np.ndarray):
|
| 169 |
+
"""Return a mock for _embed that returns pre-baked unit vectors."""
|
| 170 |
+
call_count = [0]
|
| 171 |
+
|
| 172 |
+
def mock_embed(texts):
|
| 173 |
+
nonlocal call_count
|
| 174 |
+
idx = call_count[0]
|
| 175 |
+
call_count[0] += 1
|
| 176 |
+
if idx == 0:
|
| 177 |
+
# current items
|
| 178 |
+
return similarity_matrix[:n_items_current]
|
| 179 |
+
else:
|
| 180 |
+
# prior items
|
| 181 |
+
return similarity_matrix[n_items_current:]
|
| 182 |
+
|
| 183 |
+
return mock_embed
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
def test_risk_added_detected_with_low_similarity():
|
| 187 |
+
"""When a current risk has no match in prior (low similarity), it is classified as risk_added."""
|
| 188 |
+
from analysis.textdiff import compute_risk_deltas, _split_into_items
|
| 189 |
+
|
| 190 |
+
# Ensure we have splittable text
|
| 191 |
+
current = RISK_TEXT_B_REWORDED
|
| 192 |
+
prior = RISK_TEXT_A
|
| 193 |
+
|
| 194 |
+
# Build real item lists to know sizes
|
| 195 |
+
cur_items = _split_into_items(current)
|
| 196 |
+
pri_items = _split_into_items(prior)
|
| 197 |
+
|
| 198 |
+
n = max(len(cur_items), len(pri_items))
|
| 199 |
+
if n == 0:
|
| 200 |
+
pytest.skip("No items to test")
|
| 201 |
+
|
| 202 |
+
# Build identity-like similarity matrix with one new item (last current item has low similarity)
|
| 203 |
+
dim = n
|
| 204 |
+
# Create orthonormal-like vectors: current[i] matches prior[i], last current is orthogonal
|
| 205 |
+
vecs = np.eye(max(len(cur_items) + len(pri_items), 2))
|
| 206 |
+
cur_vecs = vecs[:len(cur_items)]
|
| 207 |
+
pri_vecs = vecs[len(cur_items):len(cur_items) + len(pri_items)]
|
| 208 |
+
# Pad if sizes differ
|
| 209 |
+
if cur_vecs.shape[0] == 0 or pri_vecs.shape[0] == 0:
|
| 210 |
+
pytest.skip("Not enough items")
|
| 211 |
+
|
| 212 |
+
call_count = [0]
|
| 213 |
+
|
| 214 |
+
def mock_embed(texts):
|
| 215 |
+
idx = call_count[0]
|
| 216 |
+
call_count[0] += 1
|
| 217 |
+
if idx == 0:
|
| 218 |
+
return cur_vecs
|
| 219 |
+
return pri_vecs
|
| 220 |
+
|
| 221 |
+
with patch("analysis.textdiff._embed", side_effect=mock_embed):
|
| 222 |
+
deltas = compute_risk_deltas(current, prior, "Q42025", "Q12026", "10-Q")
|
| 223 |
+
|
| 224 |
+
# At minimum we should get some deltas (reworded or added)
|
| 225 |
+
assert len(deltas) >= 0 # function ran without error
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
def test_risk_delta_empty_on_missing_text():
|
| 229 |
+
from analysis.textdiff import compute_risk_deltas
|
| 230 |
+
assert compute_risk_deltas("", RISK_TEXT_A, "Q42025", "Q12026", "10-Q") == []
|
| 231 |
+
assert compute_risk_deltas(RISK_TEXT_A, "", "Q42025", "Q12026", "10-Q") == []
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
# ---------------------------------------------------------------------------
|
| 235 |
+
# Tests: truncate helper
|
| 236 |
+
# ---------------------------------------------------------------------------
|
| 237 |
+
|
| 238 |
+
def test_truncate_caps_word_count():
|
| 239 |
+
from analysis.textdiff import _truncate
|
| 240 |
+
text = " ".join(["word"] * 200)
|
| 241 |
+
result = _truncate(text, 50)
|
| 242 |
+
assert len(result.split()) <= 51 # 50 words + possible "…"
|
| 243 |
+
assert result.endswith("…")
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
def test_truncate_passthrough_when_short():
|
| 247 |
+
from analysis.textdiff import _truncate
|
| 248 |
+
text = "Short text."
|
| 249 |
+
assert _truncate(text, 50) == text
|
| 250 |
+
|
| 251 |
+
|
| 252 |
+
# ---------------------------------------------------------------------------
|
| 253 |
+
# Tests: compute() top-level — returns empty list gracefully when no data
|
| 254 |
+
# ---------------------------------------------------------------------------
|
| 255 |
+
|
| 256 |
+
def test_compute_returns_empty_when_no_sections():
|
| 257 |
+
from analysis.textdiff import compute
|
| 258 |
+
with patch("analysis.textdiff.get_periods_for_ticker", return_value=[]):
|
| 259 |
+
result = compute("FAKE")
|
| 260 |
+
assert result == []
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
def test_compute_returns_empty_on_single_period():
|
| 264 |
+
from analysis.textdiff import compute
|
| 265 |
+
with patch("analysis.textdiff.get_periods_for_ticker", return_value=["Q12026"]):
|
| 266 |
+
result = compute("FAKE")
|
| 267 |
+
assert result == []
|
| 268 |
+
|
| 269 |
+
|
| 270 |
+
def test_compute_handles_exception_gracefully():
|
| 271 |
+
"""compute() should never raise — it catches all errors and returns []."""
|
| 272 |
+
from analysis.textdiff import compute
|
| 273 |
+
with patch("analysis.textdiff.get_periods_for_ticker", side_effect=RuntimeError("DB gone")):
|
| 274 |
+
result = compute("FAKE")
|
| 275 |
+
assert result == []
|
| 276 |
+
|
| 277 |
+
|
| 278 |
+
# ---------------------------------------------------------------------------
|
| 279 |
+
# Tests: QuarterDelta schema
|
| 280 |
+
# ---------------------------------------------------------------------------
|
| 281 |
+
|
| 282 |
+
def test_quarter_delta_round_trips():
|
| 283 |
+
from analysis.signals import QuarterDelta
|
| 284 |
+
d = QuarterDelta(
|
| 285 |
+
kind="risk_added",
|
| 286 |
+
period_from="Q42025",
|
| 287 |
+
period_to="Q12026",
|
| 288 |
+
before_text="",
|
| 289 |
+
after_text="New export control risk…",
|
| 290 |
+
computed_metric="",
|
| 291 |
+
source="10-Q",
|
| 292 |
+
significance="HIGH",
|
| 293 |
+
term="",
|
| 294 |
+
)
|
| 295 |
+
dumped = d.model_dump()
|
| 296 |
+
restored = QuarterDelta.model_validate(dumped)
|
| 297 |
+
assert restored.kind == "risk_added"
|
| 298 |
+
assert restored.significance == "HIGH"
|
| 299 |
+
|
| 300 |
+
|
| 301 |
+
def test_quarter_delta_rejects_invalid_kind():
|
| 302 |
+
from analysis.signals import QuarterDelta
|
| 303 |
+
from pydantic import ValidationError
|
| 304 |
+
with pytest.raises(ValidationError):
|
| 305 |
+
QuarterDelta(
|
| 306 |
+
kind="invented_signal", # not in Literal
|
| 307 |
+
period_from="Q42025",
|
| 308 |
+
period_to="Q12026",
|
| 309 |
+
)
|