feat: merge Company Overview generation into single synthesis call
Browse filesCompany Primer (renamed Company Overview) was generated by a separate
background LLM call, decoupled from the main brief synthesis. It now
shares the same synthesis call via a deterministic profile-evidence
node, dropping the standalone thread/job in app.py. Nav reordered for
a PM/equity-analyst flow (Overview -> PM Flash -> Evidence & Deltas ->
Financials -> Ask AI) and hardcoded nav labels now route through i18n.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- ARCHITECTURE.md +13 -5
- README.md +13 -6
- agent/company_profile.py +56 -6
- agent/company_profile_schemas.py +14 -1
- agent/graph.py +66 -2
- agent/prompts.py +47 -0
- agent/schemas.py +8 -0
- app.py +26 -120
- dashboard/company_primer.py +1 -1
- dashboard/i18n.py +9 -9
- dashboard/nav.py +7 -7
- tests/test_company_profile.py +59 -1
- tests/test_graph.py +153 -1
- tests/test_prompts.py +18 -1
- tests/test_verdict.py +17 -8
ARCHITECTURE.md
CHANGED
|
@@ -18,16 +18,18 @@ flowchart TB
|
|
| 18 |
classDef io fill:#fce7f3,stroke:#db2777,color:#831843
|
| 19 |
classDef gate fill:#fee2e2,stroke:#dc2626,color:#7f1d1d
|
| 20 |
|
| 21 |
-
USER([PM / senior equity analyst]):::io --> UI["Streamlit research stack<br/>Company
|
| 22 |
-
UI -->
|
| 23 |
|
| 24 |
subgraph LG ["LangGraph Β· short-term state"]
|
| 25 |
direction LR
|
|
|
|
| 26 |
AG["agent<br/>Claude Haiku 4.5"]:::state
|
| 27 |
TN["tools<br/>parallel execution"]:::state
|
| 28 |
ND["coverage nudge Β· once<br/>if filing + transcript<br/>were not both searched"]:::state
|
| 29 |
SY["synthesis<br/>validated BriefOutput"]:::state
|
| 30 |
PS["post_synthesis<br/>item reliability + deltas"]:::state
|
|
|
|
| 31 |
AG == "tool calls Β· bounded rounds" ==> TN
|
| 32 |
TN == "ToolMessage" ==> AG
|
| 33 |
AG -. "coverage floor" .-> ND -.-> AG
|
|
@@ -99,13 +101,19 @@ flowchart TB
|
|
| 99 |
|
| 100 |
| Surface | Primary payload |
|
| 101 |
| --- | --- |
|
| 102 |
-
| **Company
|
| 103 |
| **PM Flash** | As-of and coverage; experimental PM read-through; thesis-confirming/challenging sourced points; experimental Swing Factor; watch items. |
|
| 104 |
| **Evidence & Deltas** | Source-backed facts, verbatim evidence, deterministic detector outputs labelled heuristic, risks, commentary, and experimental AI hypotheses. |
|
| 105 |
| **Financials** | Structured historical metrics, trends, guidance history, earnings history, and exports. |
|
| 106 |
-
| **Ask
|
| 107 |
|
| 108 |
-
The navigation order is intentional: company onboarding β decision memo β
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
|
| 110 |
## Output taxonomy
|
| 111 |
|
|
|
|
| 18 |
classDef io fill:#fce7f3,stroke:#db2777,color:#831843
|
| 19 |
classDef gate fill:#fee2e2,stroke:#dc2626,color:#7f1d1d
|
| 20 |
|
| 21 |
+
USER([PM / senior equity analyst]):::io --> UI["Streamlit research stack<br/>Company Overview Β· PM Flash Β· Evidence & Deltas Β· Financials Β· Ask AI"]:::io
|
| 22 |
+
UI --> PE
|
| 23 |
|
| 24 |
subgraph LG ["LangGraph Β· short-term state"]
|
| 25 |
direction LR
|
| 26 |
+
PE["profile_evidence<br/>deterministic evidence.v1 retrieval"]:::state
|
| 27 |
AG["agent<br/>Claude Haiku 4.5"]:::state
|
| 28 |
TN["tools<br/>parallel execution"]:::state
|
| 29 |
ND["coverage nudge Β· once<br/>if filing + transcript<br/>were not both searched"]:::state
|
| 30 |
SY["synthesis<br/>validated BriefOutput"]:::state
|
| 31 |
PS["post_synthesis<br/>item reliability + deltas"]:::state
|
| 32 |
+
PE --> AG
|
| 33 |
AG == "tool calls Β· bounded rounds" ==> TN
|
| 34 |
TN == "ToolMessage" ==> AG
|
| 35 |
AG -. "coverage floor" .-> ND -.-> AG
|
|
|
|
| 101 |
|
| 102 |
| Surface | Primary payload |
|
| 103 |
| --- | --- |
|
| 104 |
+
| **Company Overview** | Stable sourced company profile plus independently refreshed price context and public news. Business model, geographic exposure, three-year trends, attention themes, associated price events, and monitoring variables remain concise and inspectable. |
|
| 105 |
| **PM Flash** | As-of and coverage; experimental PM read-through; thesis-confirming/challenging sourced points; experimental Swing Factor; watch items. |
|
| 106 |
| **Evidence & Deltas** | Source-backed facts, verbatim evidence, deterministic detector outputs labelled heuristic, risks, commentary, and experimental AI hypotheses. |
|
| 107 |
| **Financials** | Structured historical metrics, trends, guidance history, earnings history, and exports. |
|
| 108 |
+
| **Ask AI** | Cross-cutting grounded Q&A over the available evidence. |
|
| 109 |
|
| 110 |
+
The navigation order is intentional: company onboarding β decision memo β evidence audit β model depth β cross-cutting AI tool.
|
| 111 |
+
|
| 112 |
+
## Company Overview
|
| 113 |
+
|
| 114 |
+
Company Overview is produced inside the same streaming synthesis call as the rest of the brief. Before the agent loop, a deterministic node retrieves the stable profile evidence setβ10-K Business, segments/geography, strategic evolution, and transcriptsβand injects each `evidence.v1` envelope as a raw human message. Those messages do not satisfy the brief's tool-coverage gate, so the agent must still retrieve the current-quarter evidence required for the decision memo. After synthesis, the overview section is validated separately, passed through the unchanged deterministic evidence verifier, stripped of every non-verified fact, and saved under its source fingerprint. A profile failure is isolated and cannot fail the brief.
|
| 115 |
+
|
| 116 |
+
The former standalone call existed for three practical reasons: its source fingerprint made the profile stable between earnings; its evidence set was broader and more structural than the latest-quarter brief; and a separate response kept the main synthesis output smaller. The merged design deliberately accepts those trade-offs. Profile evidence is now injected deterministically before the agent loop, which adds only marginal context; the synthesis output limit is raised to 16,384 tokens; and fingerprint-based cache lookup remains in the UI, while a new overview is generated only with a new brief run. This means βGenerate Briefβ regenerates the overview each time, an acceptable marginal output-token cost in exchange for one billed synthesis call and one coherent evidence-grounded result.
|
| 117 |
|
| 118 |
## Output taxonomy
|
| 119 |
|
README.md
CHANGED
|
@@ -19,11 +19,11 @@ The product is designed for the first review after an earnings release: what cha
|
|
| 19 |
|
| 20 |
| Surface | Role in the workflow |
|
| 21 |
| --- | --- |
|
| 22 |
-
| **Company
|
| 23 |
| **PM Flash** | One-screen decision memo: filing date and source coverage, experimental AI read-through, thesis-confirming and thesis-challenging evidence, the swing factor, and the next items to watch. |
|
| 24 |
| **Evidence & Deltas** | Audit layer: source-backed claims, before/after evidence, heuristic period deltas, risks, management commentary, and experimental AI hypotheses. |
|
| 25 |
| **Financials** | Historical KPI trends, guidance history, earnings history, and exportable structured data. |
|
| 26 |
-
| **Ask
|
| 27 |
|
| 28 |
## Evidence contract
|
| 29 |
|
|
@@ -54,11 +54,11 @@ Legacy briefs without a deterministic `evidence_coverage` result are shown as **
|
|
| 54 |
## Architecture
|
| 55 |
|
| 56 |
- **Offline ingestion** β SEC EDGAR XBRL into SQLite; 10-K/10-Q Business, Segments/Geography, MD&A, Risk Factors, and earnings-call transcripts into the evidence stores.
|
| 57 |
-
- **Company
|
| 58 |
-
- **Runtime agent** β a LangGraph tool loop retrieves structured financials, filings, transcripts, public news, and analyst data before
|
| 59 |
- **Retrieval** β vector search over-fetches candidates and reranks them with a cross-encoder.
|
| 60 |
- **Post-synthesis controls** β deterministic source-reliability rules, corroboration checks, and heuristic period-delta detectors run after model synthesis.
|
| 61 |
-
- **Decision UI** β Company
|
| 62 |
|
| 63 |
See [WRITEUP.md](WRITEUP.md) for product and methodology detail and [ARCHITECTURE.md](ARCHITECTURE.md) for data lineage and presentation controls.
|
| 64 |
|
|
@@ -77,9 +77,16 @@ metrics without verified period context are intentionally hidden rather than
|
|
| 77 |
silently upgraded.
|
| 78 |
|
| 79 |
For an already lineage-compatible database, `python ingest.py AAPL` is enough
|
| 80 |
-
to backfill the Company
|
| 81 |
the delta path reuses any transcript already stored.
|
| 82 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
## Intended use
|
| 84 |
|
| 85 |
Amplegest is a research accelerator based on public information. It helps an experienced investor review evidence faster, challenge a thesis, and identify the next falsifiable datapoint. The analyst or portfolio manager retains responsibility for source verification, modelling, valuation, and the investment decision.
|
|
|
|
| 19 |
|
| 20 |
| Surface | Role in the workflow |
|
| 21 |
| --- | --- |
|
| 22 |
+
| **Company Overview** | Concise company onboarding: business model, revenue engines, geographic exposure, three-year evolution, investor-attention themes, relative price events, recent news, and the variables to watch. |
|
| 23 |
| **PM Flash** | One-screen decision memo: filing date and source coverage, experimental AI read-through, thesis-confirming and thesis-challenging evidence, the swing factor, and the next items to watch. |
|
| 24 |
| **Evidence & Deltas** | Audit layer: source-backed claims, before/after evidence, heuristic period deltas, risks, management commentary, and experimental AI hypotheses. |
|
| 25 |
| **Financials** | Historical KPI trends, guidance history, earnings history, and exportable structured data. |
|
| 26 |
+
| **Ask AI** | Cross-cutting utility for grounded follow-up questions. It supports the review; it is not the investment conclusion. |
|
| 27 |
|
| 28 |
## Evidence contract
|
| 29 |
|
|
|
|
| 54 |
## Architecture
|
| 55 |
|
| 56 |
- **Offline ingestion** β SEC EDGAR XBRL into SQLite; 10-K/10-Q Business, Segments/Geography, MD&A, Risk Factors, and earnings-call transcripts into the evidence stores.
|
| 57 |
+
- **Company Overview** β deterministic filing/call retrieval is injected before the agent loop; the source-fingerprinted English overview is emitted and verified within the brief's single synthesis call, while prices and public news refresh independently at display time.
|
| 58 |
+
- **Runtime agent** β a LangGraph tool loop retrieves structured financials, filings, transcripts, public news, and analyst data before one synthesis call produces the validated Pydantic brief and Company Overview.
|
| 59 |
- **Retrieval** β vector search over-fetches candidates and reranks them with a cross-encoder.
|
| 60 |
- **Post-synthesis controls** β deterministic source-reliability rules, corroboration checks, and heuristic period-delta detectors run after model synthesis.
|
| 61 |
+
- **Decision UI** β Company Overview onboards; PM Flash frames the decision; Evidence & Deltas provides auditability; Financials provides depth; Ask AI is the final cross-cutting tool.
|
| 62 |
|
| 63 |
See [WRITEUP.md](WRITEUP.md) for product and methodology detail and [ARCHITECTURE.md](ARCHITECTURE.md) for data lineage and presentation controls.
|
| 64 |
|
|
|
|
| 77 |
silently upgraded.
|
| 78 |
|
| 79 |
For an already lineage-compatible database, `python ingest.py AAPL` is enough
|
| 80 |
+
to backfill the Company Overviewβs 10-K Business and Segments/Geography sections;
|
| 81 |
the delta path reuses any transcript already stored.
|
| 82 |
|
| 83 |
+
### Deploy to the Hugging Face Space
|
| 84 |
+
|
| 85 |
+
The Space serves the committed `data/` snapshot through Git LFS. After any local
|
| 86 |
+
ingestion, commit `data/`, then run `python deploy_check.py`; only run
|
| 87 |
+
`git push hf main:main` when the preflight passes. Space runtime storage is
|
| 88 |
+
ephemeral, so runtime-written caches and saved briefs do not survive a restart.
|
| 89 |
+
|
| 90 |
## Intended use
|
| 91 |
|
| 92 |
Amplegest is a research accelerator based on public information. It helps an experienced investor review evidence faster, challenge a thesis, and identify the next falsifiable datapoint. The analyst or portfolio manager retains responsibility for source verification, modelling, valuation, and the investment decision.
|
agent/company_profile.py
CHANGED
|
@@ -1,15 +1,15 @@
|
|
| 1 |
-
"""Evidence-grounded Company
|
| 2 |
from __future__ import annotations
|
| 3 |
|
| 4 |
import copy
|
| 5 |
import hashlib
|
| 6 |
import json
|
| 7 |
from datetime import datetime, timezone
|
| 8 |
-
from typing import Callable, Optional
|
| 9 |
|
| 10 |
from langchain_core.messages import HumanMessage
|
| 11 |
|
| 12 |
-
from agent.company_profile_schemas import CompanyProfile
|
| 13 |
from agent.evidence import evidence_records_from, verify_brief_evidence, verify_fact
|
| 14 |
from agent.llm import RunConfig, build_system_message, make_chat_model
|
| 15 |
from agent.tools import get_financial_metrics, search_filing, search_transcript
|
|
@@ -158,11 +158,11 @@ def _invoke_tool(tool, arguments: dict) -> str:
|
|
| 158 |
def collect_profile_evidence(
|
| 159 |
ticker: str,
|
| 160 |
progress: Optional[Callable[[str], None]] = None,
|
|
|
|
| 161 |
) -> list[str]:
|
| 162 |
ticker = ticker.upper()
|
| 163 |
payloads: list[str] = []
|
| 164 |
calls = [
|
| 165 |
-
(get_financial_metrics, {"ticker": ticker}, "Loading verified financial history"),
|
| 166 |
(
|
| 167 |
search_filing,
|
| 168 |
{
|
|
@@ -204,6 +204,11 @@ def collect_profile_evidence(
|
|
| 204 |
"Finding investor attention themes",
|
| 205 |
),
|
| 206 |
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 207 |
for tool, arguments, label in calls:
|
| 208 |
if progress:
|
| 209 |
progress(label)
|
|
@@ -395,6 +400,7 @@ def generate_company_profile(
|
|
| 395 |
config: RunConfig,
|
| 396 |
progress: Optional[Callable[[str], None]] = None,
|
| 397 |
) -> dict:
|
|
|
|
| 398 |
ticker = ticker.upper()
|
| 399 |
fingerprint = source_fingerprint(ticker)
|
| 400 |
coverage = profile_source_coverage(ticker)
|
|
@@ -425,19 +431,39 @@ def generate_company_profile(
|
|
| 425 |
else:
|
| 426 |
raise ValueError("The model did not return a CompanyProfile")
|
| 427 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 428 |
profile.update(
|
| 429 |
{
|
| 430 |
"ticker": ticker,
|
| 431 |
"company_name": company_name or ticker,
|
| 432 |
"schema_version": PROFILE_SCHEMA_VERSION,
|
| 433 |
-
"source_fingerprint":
|
| 434 |
"language": "English",
|
| 435 |
"generated_at": datetime.now(timezone.utc).isoformat(),
|
| 436 |
"data_as_of": max(
|
| 437 |
(record.ref.as_of for record in records if record.ref.as_of),
|
| 438 |
default=(rows[0].get("filing_date") if rows else None),
|
| 439 |
),
|
| 440 |
-
"model":
|
| 441 |
"annual_trends": _annual_trends(ticker),
|
| 442 |
"source_coverage": coverage,
|
| 443 |
"status": coverage["status"],
|
|
@@ -464,6 +490,30 @@ def generate_company_profile(
|
|
| 464 |
return CompanyProfile.model_validate(profile).model_dump(mode="json")
|
| 465 |
|
| 466 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 467 |
def _collect_translation_segments(node, path=()):
|
| 468 |
result = []
|
| 469 |
if isinstance(node, dict):
|
|
|
|
| 1 |
+
"""Evidence-grounded Company Overview generation and translation."""
|
| 2 |
from __future__ import annotations
|
| 3 |
|
| 4 |
import copy
|
| 5 |
import hashlib
|
| 6 |
import json
|
| 7 |
from datetime import datetime, timezone
|
| 8 |
+
from typing import Callable, Iterable, Optional
|
| 9 |
|
| 10 |
from langchain_core.messages import HumanMessage
|
| 11 |
|
| 12 |
+
from agent.company_profile_schemas import CompanyProfile, CompanyProfileSection
|
| 13 |
from agent.evidence import evidence_records_from, verify_brief_evidence, verify_fact
|
| 14 |
from agent.llm import RunConfig, build_system_message, make_chat_model
|
| 15 |
from agent.tools import get_financial_metrics, search_filing, search_transcript
|
|
|
|
| 158 |
def collect_profile_evidence(
|
| 159 |
ticker: str,
|
| 160 |
progress: Optional[Callable[[str], None]] = None,
|
| 161 |
+
include_metrics: bool = True,
|
| 162 |
) -> list[str]:
|
| 163 |
ticker = ticker.upper()
|
| 164 |
payloads: list[str] = []
|
| 165 |
calls = [
|
|
|
|
| 166 |
(
|
| 167 |
search_filing,
|
| 168 |
{
|
|
|
|
| 204 |
"Finding investor attention themes",
|
| 205 |
),
|
| 206 |
]
|
| 207 |
+
if include_metrics:
|
| 208 |
+
calls.insert(
|
| 209 |
+
0,
|
| 210 |
+
(get_financial_metrics, {"ticker": ticker}, "Loading verified financial history"),
|
| 211 |
+
)
|
| 212 |
for tool, arguments, label in calls:
|
| 213 |
if progress:
|
| 214 |
progress(label)
|
|
|
|
| 400 |
config: RunConfig,
|
| 401 |
progress: Optional[Callable[[str], None]] = None,
|
| 402 |
) -> dict:
|
| 403 |
+
"""Legacy standalone path β no longer called by the app; kept for tests/backfill CLI."""
|
| 404 |
ticker = ticker.upper()
|
| 405 |
fingerprint = source_fingerprint(ticker)
|
| 406 |
coverage = profile_source_coverage(ticker)
|
|
|
|
| 431 |
else:
|
| 432 |
raise ValueError("The model did not return a CompanyProfile")
|
| 433 |
|
| 434 |
+
return finalize_profile(ticker, profile, payloads, config.model)
|
| 435 |
+
|
| 436 |
+
|
| 437 |
+
def finalize_profile(
|
| 438 |
+
ticker: str,
|
| 439 |
+
profile: dict,
|
| 440 |
+
payloads: Iterable,
|
| 441 |
+
model: str | None,
|
| 442 |
+
) -> dict:
|
| 443 |
+
"""Attach deterministic metadata and trends, verify, prune, and validate a profile."""
|
| 444 |
+
ticker = ticker.upper()
|
| 445 |
+
if isinstance(payloads, (str, bytes, dict)) or hasattr(payloads, "content"):
|
| 446 |
+
payloads = [payloads]
|
| 447 |
+
else:
|
| 448 |
+
payloads = list(payloads)
|
| 449 |
+
records = evidence_records_from(payloads)
|
| 450 |
+
rows = metrics_db.get_all_metrics(ticker)
|
| 451 |
+
company_name = rows[0].get("company_name") if rows else ticker
|
| 452 |
+
coverage = profile_source_coverage(ticker)
|
| 453 |
+
profile = copy.deepcopy(profile)
|
| 454 |
profile.update(
|
| 455 |
{
|
| 456 |
"ticker": ticker,
|
| 457 |
"company_name": company_name or ticker,
|
| 458 |
"schema_version": PROFILE_SCHEMA_VERSION,
|
| 459 |
+
"source_fingerprint": source_fingerprint(ticker),
|
| 460 |
"language": "English",
|
| 461 |
"generated_at": datetime.now(timezone.utc).isoformat(),
|
| 462 |
"data_as_of": max(
|
| 463 |
(record.ref.as_of for record in records if record.ref.as_of),
|
| 464 |
default=(rows[0].get("filing_date") if rows else None),
|
| 465 |
),
|
| 466 |
+
"model": model,
|
| 467 |
"annual_trends": _annual_trends(ticker),
|
| 468 |
"source_coverage": coverage,
|
| 469 |
"status": coverage["status"],
|
|
|
|
| 490 |
return CompanyProfile.model_validate(profile).model_dump(mode="json")
|
| 491 |
|
| 492 |
|
| 493 |
+
def finalize_profile_from_synthesis(
|
| 494 |
+
ticker: str,
|
| 495 |
+
section: dict,
|
| 496 |
+
payloads: Iterable,
|
| 497 |
+
model: str | None,
|
| 498 |
+
) -> dict:
|
| 499 |
+
"""Validate and finalize a profile subsection emitted by the brief synthesis."""
|
| 500 |
+
ticker = ticker.upper()
|
| 501 |
+
validated = CompanyProfileSection.model_validate(section).model_dump(mode="json")
|
| 502 |
+
rows = metrics_db.get_all_metrics(ticker)
|
| 503 |
+
company_name = rows[0].get("company_name") if rows else ticker
|
| 504 |
+
base = {
|
| 505 |
+
"ticker": ticker,
|
| 506 |
+
"company_name": company_name or ticker,
|
| 507 |
+
"schema_version": PROFILE_SCHEMA_VERSION,
|
| 508 |
+
"source_fingerprint": source_fingerprint(ticker),
|
| 509 |
+
"source_coverage": profile_source_coverage(ticker),
|
| 510 |
+
"generated_at": datetime.now(timezone.utc).isoformat(),
|
| 511 |
+
"language": "English",
|
| 512 |
+
**validated,
|
| 513 |
+
}
|
| 514 |
+
return finalize_profile(ticker, base, payloads, model)
|
| 515 |
+
|
| 516 |
+
|
| 517 |
def _collect_translation_segments(node, path=()):
|
| 518 |
result = []
|
| 519 |
if isinstance(node, dict):
|
agent/company_profile_schemas.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
"""Structured payload for the Company
|
| 2 |
from __future__ import annotations
|
| 3 |
|
| 4 |
from typing import Any, Literal, Optional
|
|
@@ -83,6 +83,19 @@ class WatchVariable(BaseModel):
|
|
| 83 |
evidence: SourcedFact
|
| 84 |
|
| 85 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
class AnnualTrend(BaseModel):
|
| 87 |
model_config = ConfigDict(extra="ignore")
|
| 88 |
|
|
|
|
| 1 |
+
"""Structured payload for the Company Overview research surface."""
|
| 2 |
from __future__ import annotations
|
| 3 |
|
| 4 |
from typing import Any, Literal, Optional
|
|
|
|
| 83 |
evidence: SourcedFact
|
| 84 |
|
| 85 |
|
| 86 |
+
class CompanyProfileSection(BaseModel):
|
| 87 |
+
"""Company-profile subsection produced by the same synthesis call as the brief."""
|
| 88 |
+
|
| 89 |
+
model_config = ConfigDict(extra="ignore")
|
| 90 |
+
|
| 91 |
+
identity: CompanyIdentity = Field(default_factory=CompanyIdentity)
|
| 92 |
+
business_lines: list[BusinessLine] = Field(default_factory=list)
|
| 93 |
+
geographic_exposures: list[GeographicExposure] = Field(default_factory=list)
|
| 94 |
+
strategic_changes: list[StrategicChange] = Field(default_factory=list)
|
| 95 |
+
attention_themes: list[AttentionTheme] = Field(default_factory=list)
|
| 96 |
+
watch_variables: list[WatchVariable] = Field(default_factory=list)
|
| 97 |
+
|
| 98 |
+
|
| 99 |
class AnnualTrend(BaseModel):
|
| 100 |
model_config = ConfigDict(extra="ignore")
|
| 101 |
|
agent/graph.py
CHANGED
|
@@ -79,6 +79,7 @@ class AgentState(TypedDict):
|
|
| 79 |
tool_round_count: int
|
| 80 |
nudge_fired: bool
|
| 81 |
edge_signals: Optional[list[dict]] # precomputed deterministic signals
|
|
|
|
| 82 |
language: Optional[str] # e.g. "French" β prose fields in brief will use this language
|
| 83 |
brief: Optional[dict]
|
| 84 |
brief_markdown: Optional[str]
|
|
@@ -308,6 +309,7 @@ def _partial_brief(state: AgentState, reason: str) -> dict:
|
|
| 308 |
"aggregate_reliability_meaningful": False,
|
| 309 |
},
|
| 310 |
"language": state.get("language") or "English",
|
|
|
|
| 311 |
}
|
| 312 |
|
| 313 |
|
|
@@ -397,6 +399,58 @@ def signals_node(state: AgentState) -> dict:
|
|
| 397 |
return {"edge_signals": [], "messages": []}
|
| 398 |
|
| 399 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 400 |
def create_graph(config: Optional[RunConfig] = None):
|
| 401 |
cfg = config or default_config()
|
| 402 |
llm = make_chat_model(cfg)
|
|
@@ -428,7 +482,7 @@ def create_graph(config: Optional[RunConfig] = None):
|
|
| 428 |
|
| 429 |
def synthesis_node(state: AgentState) -> dict:
|
| 430 |
try:
|
| 431 |
-
llm_plain = make_chat_model(cfg)
|
| 432 |
# Main prompt β cached (ephemeral) on Anthropic. Keep this block stable
|
| 433 |
# so the cache hit rate is preserved regardless of the chosen language.
|
| 434 |
lang = state.get("language") or "English"
|
|
@@ -465,6 +519,7 @@ def create_graph(config: Optional[RunConfig] = None):
|
|
| 465 |
raw = "".join(chunks)
|
| 466 |
clean = _extract_json(raw)
|
| 467 |
data = json.loads(clean)
|
|
|
|
| 468 |
brief = BriefOutput.model_validate(data)
|
| 469 |
brief_dict = apply_reliability(
|
| 470 |
brief.model_dump(), evidence_payloads=state.get("messages", [])
|
|
@@ -513,6 +568,12 @@ def create_graph(config: Optional[RunConfig] = None):
|
|
| 513 |
),
|
| 514 |
"aggregate_reliability_meaningful": False,
|
| 515 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 516 |
return {
|
| 517 |
"brief": brief_dict,
|
| 518 |
"brief_markdown": None,
|
|
@@ -534,13 +595,15 @@ def create_graph(config: Optional[RunConfig] = None):
|
|
| 534 |
|
| 535 |
builder = StateGraph(AgentState)
|
| 536 |
builder.add_node("signals", signals_node)
|
|
|
|
| 537 |
builder.add_node("agent", agent_node)
|
| 538 |
builder.add_node("tools", tool_node)
|
| 539 |
builder.add_node("nudge", nudge_node)
|
| 540 |
builder.add_node("synthesis", synthesis_node)
|
| 541 |
builder.add_node("partial", partial_node)
|
| 542 |
builder.set_entry_point("signals")
|
| 543 |
-
builder.add_edge("signals", "
|
|
|
|
| 544 |
builder.add_conditional_edges(
|
| 545 |
"agent",
|
| 546 |
should_continue,
|
|
@@ -561,6 +624,7 @@ def run_brief(ticker: str, language: str = "English", config: Optional[RunConfig
|
|
| 561 |
"tool_round_count": 0,
|
| 562 |
"nudge_fired": False,
|
| 563 |
"edge_signals": None,
|
|
|
|
| 564 |
"language": language,
|
| 565 |
"brief": None,
|
| 566 |
"brief_markdown": None,
|
|
|
|
| 79 |
tool_round_count: int
|
| 80 |
nudge_fired: bool
|
| 81 |
edge_signals: Optional[list[dict]] # precomputed deterministic signals
|
| 82 |
+
profile_payloads: Optional[list[str]]
|
| 83 |
language: Optional[str] # e.g. "French" β prose fields in brief will use this language
|
| 84 |
brief: Optional[dict]
|
| 85 |
brief_markdown: Optional[str]
|
|
|
|
| 309 |
"aggregate_reliability_meaningful": False,
|
| 310 |
},
|
| 311 |
"language": state.get("language") or "English",
|
| 312 |
+
"company_profile": None,
|
| 313 |
}
|
| 314 |
|
| 315 |
|
|
|
|
| 399 |
return {"edge_signals": [], "messages": []}
|
| 400 |
|
| 401 |
|
| 402 |
+
def profile_evidence_node(state: AgentState) -> dict:
|
| 403 |
+
"""Inject deterministic profile evidence as parseable human messages."""
|
| 404 |
+
from agent.company_profile import collect_profile_evidence
|
| 405 |
+
|
| 406 |
+
payloads = collect_profile_evidence(state["ticker"], include_metrics=False)
|
| 407 |
+
if not payloads:
|
| 408 |
+
return {"profile_payloads": [], "messages": []}
|
| 409 |
+
messages = [
|
| 410 |
+
HumanMessage(
|
| 411 |
+
content=(
|
| 412 |
+
"== COMPANY PROFILE EVIDENCE "
|
| 413 |
+
"(deterministic retrieval, evidence.v1 envelopes follow) =="
|
| 414 |
+
)
|
| 415 |
+
)
|
| 416 |
+
]
|
| 417 |
+
messages.extend(HumanMessage(content=payload) for payload in payloads)
|
| 418 |
+
return {"profile_payloads": payloads, "messages": messages}
|
| 419 |
+
|
| 420 |
+
|
| 421 |
+
def _pop_company_profile(data: dict) -> dict | None:
|
| 422 |
+
"""Remove the separately validated profile section from synthesis output."""
|
| 423 |
+
return data.pop("company_profile", None)
|
| 424 |
+
|
| 425 |
+
|
| 426 |
+
def _finalize_synthesis_profile(
|
| 427 |
+
state: AgentState,
|
| 428 |
+
profile_section: dict,
|
| 429 |
+
model: str | None,
|
| 430 |
+
) -> dict | None:
|
| 431 |
+
"""Finalize and persist a synthesized profile without risking the brief."""
|
| 432 |
+
try:
|
| 433 |
+
from agent.company_profile import finalize_profile_from_synthesis
|
| 434 |
+
from storage import company_profiles
|
| 435 |
+
|
| 436 |
+
all_payloads = list(state.get("messages") or []) + list(
|
| 437 |
+
state.get("profile_payloads") or []
|
| 438 |
+
)
|
| 439 |
+
profile = finalize_profile_from_synthesis(
|
| 440 |
+
state["ticker"], profile_section, all_payloads, model
|
| 441 |
+
)
|
| 442 |
+
company_profiles.save_profile(state["ticker"], profile)
|
| 443 |
+
return profile
|
| 444 |
+
except Exception as exc:
|
| 445 |
+
import sys
|
| 446 |
+
|
| 447 |
+
print(
|
| 448 |
+
f"[synthesis] company profile finalization failed: {exc}",
|
| 449 |
+
file=sys.stderr,
|
| 450 |
+
)
|
| 451 |
+
return None
|
| 452 |
+
|
| 453 |
+
|
| 454 |
def create_graph(config: Optional[RunConfig] = None):
|
| 455 |
cfg = config or default_config()
|
| 456 |
llm = make_chat_model(cfg)
|
|
|
|
| 482 |
|
| 483 |
def synthesis_node(state: AgentState) -> dict:
|
| 484 |
try:
|
| 485 |
+
llm_plain = make_chat_model(cfg, max_tokens=16384)
|
| 486 |
# Main prompt β cached (ephemeral) on Anthropic. Keep this block stable
|
| 487 |
# so the cache hit rate is preserved regardless of the chosen language.
|
| 488 |
lang = state.get("language") or "English"
|
|
|
|
| 519 |
raw = "".join(chunks)
|
| 520 |
clean = _extract_json(raw)
|
| 521 |
data = json.loads(clean)
|
| 522 |
+
profile_section = _pop_company_profile(data)
|
| 523 |
brief = BriefOutput.model_validate(data)
|
| 524 |
brief_dict = apply_reliability(
|
| 525 |
brief.model_dump(), evidence_payloads=state.get("messages", [])
|
|
|
|
| 568 |
),
|
| 569 |
"aggregate_reliability_meaningful": False,
|
| 570 |
}
|
| 571 |
+
if isinstance(profile_section, dict) and profile_section:
|
| 572 |
+
brief_dict["company_profile"] = _finalize_synthesis_profile(
|
| 573 |
+
state, profile_section, cfg.model
|
| 574 |
+
)
|
| 575 |
+
else:
|
| 576 |
+
brief_dict["company_profile"] = None
|
| 577 |
return {
|
| 578 |
"brief": brief_dict,
|
| 579 |
"brief_markdown": None,
|
|
|
|
| 595 |
|
| 596 |
builder = StateGraph(AgentState)
|
| 597 |
builder.add_node("signals", signals_node)
|
| 598 |
+
builder.add_node("profile_evidence", profile_evidence_node)
|
| 599 |
builder.add_node("agent", agent_node)
|
| 600 |
builder.add_node("tools", tool_node)
|
| 601 |
builder.add_node("nudge", nudge_node)
|
| 602 |
builder.add_node("synthesis", synthesis_node)
|
| 603 |
builder.add_node("partial", partial_node)
|
| 604 |
builder.set_entry_point("signals")
|
| 605 |
+
builder.add_edge("signals", "profile_evidence")
|
| 606 |
+
builder.add_edge("profile_evidence", "agent")
|
| 607 |
builder.add_conditional_edges(
|
| 608 |
"agent",
|
| 609 |
should_continue,
|
|
|
|
| 624 |
"tool_round_count": 0,
|
| 625 |
"nudge_fired": False,
|
| 626 |
"edge_signals": None,
|
| 627 |
+
"profile_payloads": None,
|
| 628 |
"language": language,
|
| 629 |
"brief": None,
|
| 630 |
"brief_markdown": None,
|
agent/prompts.py
CHANGED
|
@@ -154,6 +154,20 @@ Copy every field's value exactly as it appeared in `records[].ref` β never sho
|
|
| 154 |
|
| 155 |
Never invent or edit an evidence ID, hash, document ID, URL, date, or locator. A precomputed edge signal is a hypothesis, not evidence: retrieve a supporting record or omit the claim. Do not set `verification_status`; deterministic code owns it after synthesis.
|
| 156 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 157 |
## PRECOMPUTED EDGE SIGNALS β read first, act on them
|
| 158 |
|
| 159 |
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.
|
|
@@ -423,6 +437,32 @@ Required JSON structure:
|
|
| 423 |
}
|
| 424 |
],
|
| 425 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 426 |
"sentiment": {
|
| 427 |
"metrics": { "score": 1, "label": "Bullish", "rationale": "Revenue grew 12% YoY with expanding margins across three consecutive quarters." },
|
| 428 |
"mda": { "score": 1, "label": "Bullish", "rationale": "MD&A highlights three drivers vs one headwind; language shift toward confidence." },
|
|
@@ -449,6 +489,11 @@ Required JSON structure:
|
|
| 449 |
- risks_categorized: 3-6 items; category must be exactly one of: Regulatory, Operational, Competitive, Financial, Macro, Demand, Geopolitical β do not invent new buckets
|
| 450 |
- management_commentary: 3-5 items (prefer MD&A sources; use transcript for tone/Q&A color not in filings)
|
| 451 |
- guidance_history: up to 4 items, most recent first (cover the last 4 quarterly periods; one entry will typically be from an annual 10-K)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 452 |
- sentiment: rate all 5 sections you have evidence for. Set a section to null ONLY if the corresponding tool returned no usable evidence. Avoid 0/Neutral as a hedge β pick a side unless the evidence is genuinely balanced.
|
| 453 |
|
| 454 |
## Source hierarchy β follow strictly
|
|
@@ -505,6 +550,8 @@ PROSE_FIELDS = frozenset({
|
|
| 505 |
"summary", "headline", "bullish_reading", "bearish_reading",
|
| 506 |
"language_shift", "actual_result", "topic",
|
| 507 |
"observation", "reading", "implication",
|
|
|
|
|
|
|
| 508 |
})
|
| 509 |
PROSE_LIST_FIELDS = frozenset({"what_to_watch", "evidence_notes"}) # list[str] of prose
|
| 510 |
NEVER_TRANSLATE_FIELDS = frozenset({"evidence_snippet"}) # verbatim quotes
|
|
|
|
| 154 |
|
| 155 |
Never invent or edit an evidence ID, hash, document ID, URL, date, or locator. A precomputed edge signal is a hypothesis, not evidence: retrieve a supporting record or omit the claim. Do not set `verification_status`; deterministic code owns it after synthesis.
|
| 156 |
|
| 157 |
+
## COMPANY PROFILE SECTION
|
| 158 |
+
|
| 159 |
+
The conversation may contain a message titled "== COMPANY PROFILE EVIDENCE ==" followed by raw `evidence.v1` envelopes retrieved deterministically from the Business section of the 10-K, segment/geography disclosures, strategic-history filings, and transcripts. Use these records in priority for `company_profile`, together with relevant evidence from the agent's tool calls. The evidence contract above applies without exception to every SourcedFact object nested in `company_profile`.
|
| 160 |
+
|
| 161 |
+
Content rules:
|
| 162 |
+
- `identity`: give a one-line description, how the company makes money, customer types, and competitive position; prefer the 10-K Business section.
|
| 163 |
+
- `business_lines`: identify 3-5 economic engines. Set `revenue_share_pct` to null unless the exact percentage is disclosed in the cited evidence.
|
| 164 |
+
- `geographic_exposures`: distinguish disclosed revenue geography from qualitative sales, operational, supply-chain, or regulatory exposure. Never turn a country mention into materiality or a revenue percentage.
|
| 165 |
+
- `strategic_changes`: include at most 3 material changes across the available years.
|
| 166 |
+
- `attention_themes`: include exactly the 3 strongest investor questions supported by earnings-call or filing evidence. Keep `why_it_matters` to one sentence.
|
| 167 |
+
- `watch_variables`: include 3-4 variables, each with a next datapoint and a falsifiable alert signal. These are monitoring prompts, never recommendations.
|
| 168 |
+
|
| 169 |
+
Keep every prose field concise. `economics`, `implication`, `why_it_matters`, and `alert_signal` are AI hypotheses anchored to the adjacent cited fact and displayed as AI Β· experimental. Never provide a buy/sell recommendation, price target, valuation conclusion, or claim that an event caused a stock-price move.
|
| 170 |
+
|
| 171 |
## PRECOMPUTED EDGE SIGNALS β read first, act on them
|
| 172 |
|
| 173 |
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.
|
|
|
|
| 437 |
}
|
| 438 |
],
|
| 439 |
|
| 440 |
+
"company_profile": {
|
| 441 |
+
"identity": {
|
| 442 |
+
"one_liner": { "text": "Concise company description.", "source": "10-K", "reliability": "HIGH", "impact": "LOW", "evidence_snippet": "verbatim company description", "evidence_ref": { "evidence_id": "copy from ref.evidence_id", "source": "copy from ref.source", "content_hash": "copy from ref.content_hash", "document_id": "copy from ref.document_id", "chunk_id": "copy from ref.chunk_id, or null", "source_url": "copy from ref.source_url, or null", "as_of": "copy from ref.as_of, or null" } },
|
| 443 |
+
"business_model": { "text": "How the company makes money.", "source": "10-K", "reliability": "HIGH", "impact": "MEDIUM", "evidence_snippet": "verbatim business-model support", "evidence_ref": { "evidence_id": "copy from ref.evidence_id", "source": "copy from ref.source", "content_hash": "copy from ref.content_hash", "document_id": "copy from ref.document_id", "chunk_id": "copy from ref.chunk_id, or null", "source_url": "copy from ref.source_url, or null", "as_of": "copy from ref.as_of, or null" } },
|
| 444 |
+
"customer_types": [
|
| 445 |
+
{ "text": "A disclosed customer type.", "source": "10-K", "reliability": "HIGH", "impact": "LOW", "evidence_snippet": "verbatim customer-type support", "evidence_ref": { "evidence_id": "copy from ref.evidence_id", "source": "copy from ref.source", "content_hash": "copy from ref.content_hash", "document_id": "copy from ref.document_id", "chunk_id": "copy from ref.chunk_id, or null", "source_url": "copy from ref.source_url, or null", "as_of": "copy from ref.as_of, or null" } }
|
| 446 |
+
],
|
| 447 |
+
"competitive_position": { "text": "Evidence-grounded competitive position.", "source": "10-K", "reliability": "HIGH", "impact": "MEDIUM", "evidence_snippet": "verbatim competitive-position support", "evidence_ref": { "evidence_id": "copy from ref.evidence_id", "source": "copy from ref.source", "content_hash": "copy from ref.content_hash", "document_id": "copy from ref.document_id", "chunk_id": "copy from ref.chunk_id, or null", "source_url": "copy from ref.source_url, or null", "as_of": "copy from ref.as_of, or null" } }
|
| 448 |
+
},
|
| 449 |
+
"business_lines": [
|
| 450 |
+
{ "name": "Economic engine", "description": { "text": "What the business line provides.", "source": "10-K", "reliability": "HIGH", "impact": "MEDIUM", "evidence_snippet": "verbatim business-line support", "evidence_ref": { "evidence_id": "copy from ref.evidence_id", "source": "copy from ref.source", "content_hash": "copy from ref.content_hash", "document_id": "copy from ref.document_id", "chunk_id": "copy from ref.chunk_id, or null", "source_url": "copy from ref.source_url, or null", "as_of": "copy from ref.as_of, or null" } }, "economics": "Concise analytical framing.", "trend": "not_disclosed", "revenue_share_pct": null, "share_period": null }
|
| 451 |
+
],
|
| 452 |
+
"geographic_exposures": [
|
| 453 |
+
{ "name": "Disclosed geography", "exposure_types": ["revenue"], "description": { "text": "Nature of the geographic exposure.", "source": "10-K", "reliability": "HIGH", "impact": "MEDIUM", "evidence_snippet": "verbatim geographic support", "evidence_ref": { "evidence_id": "copy from ref.evidence_id", "source": "copy from ref.source", "content_hash": "copy from ref.content_hash", "document_id": "copy from ref.document_id", "chunk_id": "copy from ref.chunk_id, or null", "source_url": "copy from ref.source_url, or null", "as_of": "copy from ref.as_of, or null" } }, "revenue_share_pct": null, "period": null }
|
| 454 |
+
],
|
| 455 |
+
"strategic_changes": [
|
| 456 |
+
{ "period_from": "FY2023", "period_to": "FY2025", "change": { "text": "Material strategic change.", "source": "10-K", "reliability": "HIGH", "impact": "HIGH", "evidence_snippet": "verbatim strategic-change support", "evidence_ref": { "evidence_id": "copy from ref.evidence_id", "source": "copy from ref.source", "content_hash": "copy from ref.content_hash", "document_id": "copy from ref.document_id", "chunk_id": "copy from ref.chunk_id, or null", "source_url": "copy from ref.source_url, or null", "as_of": "copy from ref.as_of, or null" } }, "implication": "Concise AI interpretation." }
|
| 457 |
+
],
|
| 458 |
+
"attention_themes": [
|
| 459 |
+
{ "theme": "Investor question", "why_it_matters": "One-sentence analytical relevance.", "evidence": { "text": "Evidence supporting the attention theme.", "source": "transcript", "reliability": "MEDIUM", "impact": "MEDIUM", "evidence_snippet": "verbatim attention-theme support", "evidence_ref": { "evidence_id": "copy from ref.evidence_id", "source": "copy from ref.source", "content_hash": "copy from ref.content_hash", "document_id": "copy from ref.document_id", "chunk_id": "copy from ref.chunk_id, or null", "source_url": "copy from ref.source_url, or null", "as_of": "copy from ref.as_of, or null" } } }
|
| 460 |
+
],
|
| 461 |
+
"watch_variables": [
|
| 462 |
+
{ "variable": "Variable to monitor", "why_it_matters": "One-sentence analytical relevance.", "next_datapoint": "Specific next disclosure.", "alert_signal": "Falsifiable condition to monitor.", "evidence": { "text": "Evidence anchoring the monitoring variable.", "source": "10-Q", "reliability": "HIGH", "impact": "MEDIUM", "evidence_snippet": "verbatim watch-variable support", "evidence_ref": { "evidence_id": "copy from ref.evidence_id", "source": "copy from ref.source", "content_hash": "copy from ref.content_hash", "document_id": "copy from ref.document_id", "chunk_id": "copy from ref.chunk_id, or null", "source_url": "copy from ref.source_url, or null", "as_of": "copy from ref.as_of, or null" } } }
|
| 463 |
+
]
|
| 464 |
+
},
|
| 465 |
+
|
| 466 |
"sentiment": {
|
| 467 |
"metrics": { "score": 1, "label": "Bullish", "rationale": "Revenue grew 12% YoY with expanding margins across three consecutive quarters." },
|
| 468 |
"mda": { "score": 1, "label": "Bullish", "rationale": "MD&A highlights three drivers vs one headwind; language shift toward confidence." },
|
|
|
|
| 489 |
- risks_categorized: 3-6 items; category must be exactly one of: Regulatory, Operational, Competitive, Financial, Macro, Demand, Geopolitical β do not invent new buckets
|
| 490 |
- management_commentary: 3-5 items (prefer MD&A sources; use transcript for tone/Q&A color not in filings)
|
| 491 |
- guidance_history: up to 4 items, most recent first (cover the last 4 quarterly periods; one entry will typically be from an annual 10-K)
|
| 492 |
+
- company_profile.business_lines: 3-5 items
|
| 493 |
+
- company_profile.geographic_exposures: at most 8 items
|
| 494 |
+
- company_profile.strategic_changes: at most 3 items
|
| 495 |
+
- company_profile.attention_themes: exactly 3 items
|
| 496 |
+
- company_profile.watch_variables: 3-4 items
|
| 497 |
- sentiment: rate all 5 sections you have evidence for. Set a section to null ONLY if the corresponding tool returned no usable evidence. Avoid 0/Neutral as a hedge β pick a side unless the evidence is genuinely balanced.
|
| 498 |
|
| 499 |
## Source hierarchy β follow strictly
|
|
|
|
| 550 |
"summary", "headline", "bullish_reading", "bearish_reading",
|
| 551 |
"language_shift", "actual_result", "topic",
|
| 552 |
"observation", "reading", "implication",
|
| 553 |
+
"economics", "why_it_matters", "theme", "variable",
|
| 554 |
+
"next_datapoint", "alert_signal",
|
| 555 |
})
|
| 556 |
PROSE_LIST_FIELDS = frozenset({"what_to_watch", "evidence_notes"}) # list[str] of prose
|
| 557 |
NEVER_TRANSLATE_FIELDS = frozenset({"evidence_snippet"}) # verbatim quotes
|
agent/schemas.py
CHANGED
|
@@ -781,6 +781,14 @@ class BriefOutput(BaseModel):
|
|
| 781 |
data_as_of: Optional[str] = None
|
| 782 |
verification_report: dict[str, Any] = Field(default_factory=dict)
|
| 783 |
display_policy: dict[str, bool] = Field(default_factory=dict)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 784 |
|
| 785 |
what_matters_most: str = Field(
|
| 786 |
description="2-3 sentence AI synthesis of the single most important theme. This is the only interpretation field."
|
|
|
|
| 781 |
data_as_of: Optional[str] = None
|
| 782 |
verification_report: dict[str, Any] = Field(default_factory=dict)
|
| 783 |
display_policy: dict[str, bool] = Field(default_factory=dict)
|
| 784 |
+
company_profile: Optional[dict[str, Any]] = Field(
|
| 785 |
+
default=None,
|
| 786 |
+
description=(
|
| 787 |
+
"Company Overview section (identity, business_lines, geographic_exposures, "
|
| 788 |
+
"strategic_changes, attention_themes, watch_variables) produced in the same "
|
| 789 |
+
"synthesis call. Validated separately against CompanyProfileSection."
|
| 790 |
+
),
|
| 791 |
+
)
|
| 792 |
|
| 793 |
what_matters_most: str = Field(
|
| 794 |
description="2-3 sentence AI synthesis of the single most important theme. This is the only interpretation field."
|
app.py
CHANGED
|
@@ -10,6 +10,7 @@ from agent.llm import RunConfig, classify_llm_error
|
|
| 10 |
from dashboard import i18n
|
| 11 |
from dashboard import model_picker
|
| 12 |
from dashboard.i18n import t
|
|
|
|
| 13 |
from dashboard.theme import inject_global_css, LOGO_SVG
|
| 14 |
from dashboard.nav import NAV_ITEMS, LEGACY_NAV, VALID_KEYS, render as render_nav
|
| 15 |
from dashboard import reasoning as reasoning_panel
|
|
@@ -27,7 +28,7 @@ if not st.session_state.get("_reranker_warmed"):
|
|
| 27 |
# ββ UI language β default to English (finance lingua franca) βββββββββββββββββββ
|
| 28 |
st.session_state.setdefault("ui_lang", "en")
|
| 29 |
|
| 30 |
-
# ββ Navigation state β default to Company
|
| 31 |
st.session_state.setdefault("nav_key", "company")
|
| 32 |
# Remap stale route keys from the pre-Decision-Stack navigation (open sessions).
|
| 33 |
_nk = st.session_state["nav_key"]
|
|
@@ -44,10 +45,6 @@ st.session_state.setdefault("gen", {
|
|
| 44 |
"all_messages": [],
|
| 45 |
"config": None,
|
| 46 |
})
|
| 47 |
-
st.session_state.setdefault("profile_jobs", {})
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
# ββ Sidebar βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 52 |
with st.sidebar:
|
| 53 |
st.markdown(
|
|
@@ -426,6 +423,7 @@ def _run_brief_thread(ticker: str, gen: dict, language: str, config: RunConfig)
|
|
| 426 |
"messages": [HumanMessage(content=f"Generate a research brief for {ticker}.")],
|
| 427 |
"tool_round_count": 0,
|
| 428 |
"nudge_fired": False,
|
|
|
|
| 429 |
"language": language,
|
| 430 |
"brief": None,
|
| 431 |
"brief_markdown": None,
|
|
@@ -567,74 +565,6 @@ def _live_trace_fragment() -> None:
|
|
| 567 |
|
| 568 |
|
| 569 |
# ββ Main content routing ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 570 |
-
def _run_company_profile_thread(
|
| 571 |
-
ticker: str,
|
| 572 |
-
job: dict,
|
| 573 |
-
config: RunConfig,
|
| 574 |
-
) -> None:
|
| 575 |
-
"""Generate and persist one canonical English Company Primer."""
|
| 576 |
-
try:
|
| 577 |
-
from agent.company_profile import generate_company_profile
|
| 578 |
-
from storage import company_profiles
|
| 579 |
-
|
| 580 |
-
def _progress(label: str) -> None:
|
| 581 |
-
job["label"] = label
|
| 582 |
-
job["completed_steps"] = min(int(job.get("completed_steps") or 0) + 1, 6)
|
| 583 |
-
|
| 584 |
-
profile = generate_company_profile(ticker, config, progress=_progress)
|
| 585 |
-
profile_id = company_profiles.save_profile(ticker, profile)
|
| 586 |
-
job["profile"] = profile
|
| 587 |
-
job["profile_id"] = profile_id
|
| 588 |
-
except Exception as exc:
|
| 589 |
-
import sys
|
| 590 |
-
|
| 591 |
-
print(f"[company profile thread error] {exc}", file=sys.stderr)
|
| 592 |
-
job["error"] = classify_llm_error(exc, config.provider) or str(exc)
|
| 593 |
-
finally:
|
| 594 |
-
job["running"] = False
|
| 595 |
-
|
| 596 |
-
|
| 597 |
-
def _start_company_profile_job(ticker: str, fingerprint: str, config: RunConfig) -> dict:
|
| 598 |
-
jobs = st.session_state.setdefault("profile_jobs", {})
|
| 599 |
-
job = {
|
| 600 |
-
"running": True,
|
| 601 |
-
"ticker": ticker,
|
| 602 |
-
"fingerprint": fingerprint,
|
| 603 |
-
"label": t("primer_generating"),
|
| 604 |
-
"completed_steps": 0,
|
| 605 |
-
"profile": None,
|
| 606 |
-
"profile_id": None,
|
| 607 |
-
"error": None,
|
| 608 |
-
"config": config,
|
| 609 |
-
}
|
| 610 |
-
jobs[ticker] = job
|
| 611 |
-
thread = threading.Thread(
|
| 612 |
-
target=_run_company_profile_thread,
|
| 613 |
-
args=(ticker, job, config),
|
| 614 |
-
daemon=True,
|
| 615 |
-
)
|
| 616 |
-
thread.start()
|
| 617 |
-
return job
|
| 618 |
-
|
| 619 |
-
|
| 620 |
-
def _render_company_profile_progress(job: dict) -> None:
|
| 621 |
-
completed = int(job.get("completed_steps") or 0)
|
| 622 |
-
progress = min(max(completed / 6, 0.04), 0.96 if job.get("running") else 1.0)
|
| 623 |
-
st.markdown(
|
| 624 |
-
f'<div style="background:#ffffff;border:1px solid #e5e7eb;border-radius:12px;'
|
| 625 |
-
f'padding:16px 20px;margin-bottom:16px;">'
|
| 626 |
-
f'<div style="display:flex;justify-content:space-between;gap:12px;">'
|
| 627 |
-
f'<strong>{t("primer_generating")} Β· {job.get("ticker", "")}</strong>'
|
| 628 |
-
f'<span style="color:#10b981;font-weight:700;">{int(progress * 100)}%</span></div>'
|
| 629 |
-
f'<div style="font-size:0.75rem;color:#6b7280;margin:5px 0 10px;">'
|
| 630 |
-
f'{job.get("label") or ""}</div>'
|
| 631 |
-
f'<div style="height:7px;background:#e5e7eb;border-radius:999px;overflow:hidden;">'
|
| 632 |
-
f'<div style="height:100%;width:{int(progress * 100)}%;background:#10b981;'
|
| 633 |
-
f'border-radius:999px;"></div></div></div>',
|
| 634 |
-
unsafe_allow_html=True,
|
| 635 |
-
)
|
| 636 |
-
|
| 637 |
-
|
| 638 |
def _display_company_profile(profile: dict, config: RunConfig | None) -> dict:
|
| 639 |
target_language = i18n.report_language()
|
| 640 |
if target_language == "English":
|
|
@@ -674,61 +604,37 @@ def _render_company_route(ticker: str, config: RunConfig | None) -> None:
|
|
| 674 |
source_fingerprint=fingerprint,
|
| 675 |
language="English",
|
| 676 |
)
|
| 677 |
-
|
| 678 |
-
job = jobs.get(ticker)
|
| 679 |
-
if job and job.get("fingerprint") != fingerprint and not job.get("running"):
|
| 680 |
-
jobs.pop(ticker, None)
|
| 681 |
-
job = None
|
| 682 |
-
|
| 683 |
if profile is None:
|
| 684 |
-
|
| 685 |
-
|
| 686 |
-
|
| 687 |
-
|
| 688 |
-
|
| 689 |
-
|
| 690 |
-
|
| 691 |
-
|
| 692 |
-
|
| 693 |
-
|
| 694 |
-
|
| 695 |
-
|
| 696 |
-
|
| 697 |
-
|
| 698 |
-
|
| 699 |
-
|
| 700 |
-
|
| 701 |
-
st.info(t("primer_identity_unavailable"))
|
| 702 |
-
return
|
| 703 |
-
profile["_profile_id"] = job.get("profile_id")
|
| 704 |
|
| 705 |
coverage = profile_source_coverage(ticker)
|
| 706 |
if (
|
| 707 |
coverage.get("business_sections_attempted", 0) < 1
|
| 708 |
or coverage.get("segments_geography_sections_attempted", 0) < 1
|
| 709 |
):
|
| 710 |
-
|
| 711 |
-
|
| 712 |
-
|
| 713 |
-
|
| 714 |
-
if st.button(
|
| 715 |
-
t("primer_refresh"),
|
| 716 |
-
key=f"primer_refresh_{ticker}",
|
| 717 |
-
disabled=bool(job and job.get("running")),
|
| 718 |
-
):
|
| 719 |
-
if config is None:
|
| 720 |
-
st.warning(t("model_blocked_caption"))
|
| 721 |
-
else:
|
| 722 |
-
_start_company_profile_job(ticker, fingerprint, config)
|
| 723 |
-
st.rerun()
|
| 724 |
-
if job and job.get("running"):
|
| 725 |
-
_render_company_profile_progress(job)
|
| 726 |
-
time.sleep(0.4)
|
| 727 |
-
st.rerun()
|
| 728 |
-
if job and job.get("error"):
|
| 729 |
-
st.warning(
|
| 730 |
-
f"{t('primer_refresh_failed')} {job['error']}"
|
| 731 |
)
|
|
|
|
| 732 |
|
| 733 |
display_profile = _display_company_profile(profile, config)
|
| 734 |
company_name = str(display_profile.get("company_name") or ticker)
|
|
|
|
| 10 |
from dashboard import i18n
|
| 11 |
from dashboard import model_picker
|
| 12 |
from dashboard.i18n import t
|
| 13 |
+
from dashboard.runtime_env import is_hosted_space
|
| 14 |
from dashboard.theme import inject_global_css, LOGO_SVG
|
| 15 |
from dashboard.nav import NAV_ITEMS, LEGACY_NAV, VALID_KEYS, render as render_nav
|
| 16 |
from dashboard import reasoning as reasoning_panel
|
|
|
|
| 28 |
# ββ UI language β default to English (finance lingua franca) βββββββββββββββββββ
|
| 29 |
st.session_state.setdefault("ui_lang", "en")
|
| 30 |
|
| 31 |
+
# ββ Navigation state β default to Company Overview (the onboarding view) βββββββ
|
| 32 |
st.session_state.setdefault("nav_key", "company")
|
| 33 |
# Remap stale route keys from the pre-Decision-Stack navigation (open sessions).
|
| 34 |
_nk = st.session_state["nav_key"]
|
|
|
|
| 45 |
"all_messages": [],
|
| 46 |
"config": None,
|
| 47 |
})
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
# ββ Sidebar βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 49 |
with st.sidebar:
|
| 50 |
st.markdown(
|
|
|
|
| 423 |
"messages": [HumanMessage(content=f"Generate a research brief for {ticker}.")],
|
| 424 |
"tool_round_count": 0,
|
| 425 |
"nudge_fired": False,
|
| 426 |
+
"profile_payloads": None,
|
| 427 |
"language": language,
|
| 428 |
"brief": None,
|
| 429 |
"brief_markdown": None,
|
|
|
|
| 565 |
|
| 566 |
|
| 567 |
# ββ Main content routing ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 568 |
def _display_company_profile(profile: dict, config: RunConfig | None) -> dict:
|
| 569 |
target_language = i18n.report_language()
|
| 570 |
if target_language == "English":
|
|
|
|
| 604 |
source_fingerprint=fingerprint,
|
| 605 |
language="English",
|
| 606 |
)
|
| 607 |
+
stale = False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 608 |
if profile is None:
|
| 609 |
+
profile = company_profiles.get_latest_profile(ticker, language="English")
|
| 610 |
+
stale = profile is not None
|
| 611 |
+
if profile is None:
|
| 612 |
+
st.markdown(
|
| 613 |
+
f"""
|
| 614 |
+
<div style="text-align:center;padding:48px 0;color:#6b7280;">
|
| 615 |
+
<div style="width:32px;height:32px;margin:0 auto 12px;border-radius:8px;
|
| 616 |
+
background:#ecfdf5;border:1px solid #a7f3d0;"></div>
|
| 617 |
+
<div style="font-size:1.1rem;font-weight:600;margin-bottom:6px;color:#0a0a0a;">{t("overview_empty_title")}</div>
|
| 618 |
+
<div style="font-size:0.9rem;">{t("overview_empty_body")}</div>
|
| 619 |
+
</div>
|
| 620 |
+
""",
|
| 621 |
+
unsafe_allow_html=True,
|
| 622 |
+
)
|
| 623 |
+
return
|
| 624 |
+
if stale:
|
| 625 |
+
st.caption(t("overview_stale"))
|
|
|
|
|
|
|
|
|
|
| 626 |
|
| 627 |
coverage = profile_source_coverage(ticker)
|
| 628 |
if (
|
| 629 |
coverage.get("business_sections_attempted", 0) < 1
|
| 630 |
or coverage.get("segments_geography_sections_attempted", 0) < 1
|
| 631 |
):
|
| 632 |
+
warning_key = (
|
| 633 |
+
"primer_backfill_warning_hosted"
|
| 634 |
+
if is_hosted_space()
|
| 635 |
+
else "primer_backfill_warning"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 636 |
)
|
| 637 |
+
st.warning(t(warning_key).format(ticker=ticker))
|
| 638 |
|
| 639 |
display_profile = _display_company_profile(profile, config)
|
| 640 |
company_name = str(display_profile.get("company_name") or ticker)
|
dashboard/company_primer.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
"""Company
|
| 2 |
from __future__ import annotations
|
| 3 |
|
| 4 |
from datetime import datetime
|
|
|
|
| 1 |
+
"""Company Overview β compact company-onboarding surface."""
|
| 2 |
from __future__ import annotations
|
| 3 |
|
| 4 |
from datetime import datetime
|
dashboard/i18n.py
CHANGED
|
@@ -279,10 +279,10 @@ STRINGS: dict[str, dict[str, str]] = {
|
|
| 279 |
},
|
| 280 |
|
| 281 |
# ββ Flat navigation (Decision Stack) βββββββββββββββββββββββββββββββββββ
|
| 282 |
-
"nav_company": {"en": "Company
|
| 283 |
-
"nav_verdict": {"en": "
|
| 284 |
"nav_chat": {"en": "Ask AI", "fr": "Demander Γ l'IA", "es": "Preguntar a la IA", "de": "KI fragen"},
|
| 285 |
-
"nav_signals": {"en": "
|
| 286 |
"nav_financials": {"en": "Financials", "fr": "Finances", "es": "Finanzas", "de": "Finanzen"},
|
| 287 |
|
| 288 |
"nav_no_data_tooltip": {
|
|
@@ -544,13 +544,13 @@ STRINGS: dict[str, dict[str, str]] = {
|
|
| 544 |
"band_strong_bear": {"en": "Strongly Bearish", "fr": "Très baissier", "es": "Muy bajista", "de": "Stark bÀrisch"},
|
| 545 |
|
| 546 |
# ββ Financials screen ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 547 |
-
# Company
|
| 548 |
-
"
|
| 549 |
-
"
|
| 550 |
-
"
|
| 551 |
-
"primer_refresh_failed": {"en": "Profile refresh failed; the cached version remains available.", "fr": "Lβactualisation du profil a Γ©chouΓ© ; la version en cache reste disponible.", "es": "La actualizaciΓ³n del perfil fallΓ³; la versiΓ³n en cachΓ© sigue disponible.", "de": "Die Profilaktualisierung ist fehlgeschlagen; die zwischengespeicherte Version bleibt verfΓΌgbar."},
|
| 552 |
"primer_loading_market": {"en": "Loading current market contextβ¦", "fr": "Chargement du contexte de marchΓ© actuelβ¦", "es": "Cargando el contexto actual de mercadoβ¦", "de": "Aktueller Marktkontext wird geladenβ¦"},
|
| 553 |
-
"primer_backfill_warning": {"en": "Company
|
|
|
|
| 554 |
"primer_identity_unavailable": {"en": "The business description is not yet available from verified sources.", "fr": "La description de lβactivitΓ© nβest pas encore disponible dans les sources vΓ©rifiΓ©es.", "es": "La descripciΓ³n del negocio aΓΊn no estΓ‘ disponible en fuentes verificadas.", "de": "Die GeschΓ€ftsbeschreibung ist aus verifizierten Quellen noch nicht verfΓΌgbar."},
|
| 555 |
"primer_business": {"en": "How it makes money", "fr": "Comment lβentreprise gagne de lβargent", "es": "CΓ³mo gana dinero", "de": "Wie das Unternehmen Geld verdient"},
|
| 556 |
"primer_business_unavailable": {"en": "No verified business-line disclosure is available.", "fr": "Aucune ventilation vΓ©rifiΓ©e des activitΓ©s nβest disponible.", "es": "No hay desglose verificado de lΓneas de negocio.", "de": "Keine verifizierte AufschlΓΌsselung der GeschΓ€ftsbereiche verfΓΌgbar."},
|
|
|
|
| 279 |
},
|
| 280 |
|
| 281 |
# ββ Flat navigation (Decision Stack) βββββββββββββββββββββββββββββββββββ
|
| 282 |
+
"nav_company": {"en": "Company Overview", "fr": "Vue d'ensemble", "es": "Panorama de la empresa", "de": "UnternehmensΓΌberblick"},
|
| 283 |
+
"nav_verdict": {"en": "PM Flash", "fr": "Flash PM", "es": "Flash PM", "de": "PM-Flash"},
|
| 284 |
"nav_chat": {"en": "Ask AI", "fr": "Demander Γ l'IA", "es": "Preguntar a la IA", "de": "KI fragen"},
|
| 285 |
+
"nav_signals": {"en": "Evidence & Deltas", "fr": "Preuves & Γ©carts", "es": "Evidencia y deltas", "de": "Evidenz & Deltas"},
|
| 286 |
"nav_financials": {"en": "Financials", "fr": "Finances", "es": "Finanzas", "de": "Finanzen"},
|
| 287 |
|
| 288 |
"nav_no_data_tooltip": {
|
|
|
|
| 544 |
"band_strong_bear": {"en": "Strongly Bearish", "fr": "Très baissier", "es": "Muy bajista", "de": "Stark bÀrisch"},
|
| 545 |
|
| 546 |
# ββ Financials screen ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 547 |
+
# Company Overview
|
| 548 |
+
"overview_stale": {"en": "Source data changed since this overview was generated β regenerate the brief to refresh it.", "fr": "Les donnΓ©es sources ont changΓ© depuis la gΓ©nΓ©ration de cette vue d'ensemble β rΓ©gΓ©nΓ©rez le brief pour l'actualiser.", "es": "Los datos de origen han cambiado desde que se generΓ³ este panorama; vuelva a generar el informe para actualizarlo.", "de": "Die Quelldaten haben sich seit der Erstellung dieses Γberblicks geΓ€ndert β erstellen Sie den Brief erneut, um ihn zu aktualisieren."},
|
| 549 |
+
"overview_empty_title": {"en": "No company overview yet", "fr": "Aucune vue d'ensemble pour le moment", "es": "AΓΊn no hay panorama de la empresa", "de": "Noch kein UnternehmensΓΌberblick"},
|
| 550 |
+
"overview_empty_body": {"en": "Generate a brief from the sidebar β the overview is produced in the same run.", "fr": "GΓ©nΓ©rez un brief depuis la barre latΓ©rale β la vue d'ensemble est produite au cours de la mΓͺme exΓ©cution.", "es": "Genere un informe desde la barra lateral; el panorama se produce en la misma ejecuciΓ³n.", "de": "Erstellen Sie ΓΌber die Seitenleiste einen Brief β der Γberblick wird im selben Durchlauf erzeugt."},
|
|
|
|
| 551 |
"primer_loading_market": {"en": "Loading current market contextβ¦", "fr": "Chargement du contexte de marchΓ© actuelβ¦", "es": "Cargando el contexto actual de mercadoβ¦", "de": "Aktueller Marktkontext wird geladenβ¦"},
|
| 552 |
+
"primer_backfill_warning": {"en": "Company Overview source coverage is incomplete. Run `python ingest.py {ticker}` to backfill the 10-K Business and segment/geography sections.", "fr": "La couverture des sources de la vue d'ensemble est incomplΓ¨te. Lancez `python ingest.py {ticker}` pour rΓ©cupΓ©rer les sections Business et segments/gΓ©ographies du 10-K.", "es": "La cobertura de fuentes del panorama de la empresa estΓ‘ incompleta. Ejecute `python ingest.py {ticker}` para recuperar las secciones Business y segmentos/geografΓa del 10-K.", "de": "Die Quellenabdeckung des UnternehmensΓΌberblicks ist unvollstΓ€ndig. FΓΌhren Sie `python ingest.py {ticker}` aus, um die 10-K-Abschnitte Business und Segmente/Geografie nachzuladen."},
|
| 553 |
+
"primer_backfill_warning_hosted": {"en": "This deployment's data snapshot does not yet include the 10-K Business and segment/geography sections for {ticker}. The maintainer needs to re-ingest locally and redeploy the data β this cannot be fixed from within the app.", "fr": "Le snapshot de donnΓ©es de ce dΓ©ploiement ne contient pas encore les sections Business et segments/gΓ©ographies du 10-K pour {ticker}. Le mainteneur doit relancer lβingestion en local et redΓ©ployer les donnΓ©es β ce problΓ¨me ne peut pas Γͺtre corrigΓ© depuis lβapplication.", "es": "La instantΓ‘nea de datos de este despliegue aΓΊn no incluye las secciones Business y segmentos/geografΓa del 10-K para {ticker}. El responsable debe volver a ejecutar la ingesta localmente y redesplegar los datos; este problema no se puede corregir desde la aplicaciΓ³n.", "de": "Der Daten-Snapshot dieses Deployments enthΓ€lt die 10-K-Abschnitte Business und Segmente/Geografie fΓΌr {ticker} noch nicht. Der Betreiber muss die Daten lokal neu einlesen und erneut bereitstellen; dies kann nicht innerhalb der App behoben werden."},
|
| 554 |
"primer_identity_unavailable": {"en": "The business description is not yet available from verified sources.", "fr": "La description de lβactivitΓ© nβest pas encore disponible dans les sources vΓ©rifiΓ©es.", "es": "La descripciΓ³n del negocio aΓΊn no estΓ‘ disponible en fuentes verificadas.", "de": "Die GeschΓ€ftsbeschreibung ist aus verifizierten Quellen noch nicht verfΓΌgbar."},
|
| 555 |
"primer_business": {"en": "How it makes money", "fr": "Comment lβentreprise gagne de lβargent", "es": "CΓ³mo gana dinero", "de": "Wie das Unternehmen Geld verdient"},
|
| 556 |
"primer_business_unavailable": {"en": "No verified business-line disclosure is available.", "fr": "Aucune ventilation vΓ©rifiΓ©e des activitΓ©s nβest disponible.", "es": "No hay desglose verificado de lΓneas de negocio.", "de": "Keine verifizierte AufschlΓΌsselung der GeschΓ€ftsbereiche verfΓΌgbar."},
|
dashboard/nav.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
"""Data-driven sidebar navigation for Amplegest β institutional style.
|
| 2 |
|
| 3 |
-
Five primary destinations, from company onboarding to decision and
|
| 4 |
-
Company
|
| 5 |
|
| 6 |
Each item has a stable ``key`` decoupled from its display label,
|
| 7 |
so renaming a section never breaks routing.
|
|
@@ -24,14 +24,14 @@ SECTION_COLORS: dict[str, tuple[str, str, str]] = {
|
|
| 24 |
}
|
| 25 |
|
| 26 |
# ββ Navigation items ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 27 |
-
# Company
|
| 28 |
-
#
|
| 29 |
NAV_ITEMS: list[dict] = [
|
| 30 |
{"key": "company", "i18n_key": "nav_company"},
|
| 31 |
-
{"key": "verdict", "i18n_key": "nav_verdict"
|
| 32 |
-
{"key": "
|
| 33 |
-
{"key": "signals", "i18n_key": "nav_signals", "display_label": "Evidence & Deltas"},
|
| 34 |
{"key": "financials", "i18n_key": "nav_financials"},
|
|
|
|
| 35 |
]
|
| 36 |
|
| 37 |
VALID_KEYS: frozenset[str] = frozenset(item["key"] for item in NAV_ITEMS)
|
|
|
|
| 1 |
"""Data-driven sidebar navigation for Amplegest β institutional style.
|
| 2 |
|
| 3 |
+
Five primary destinations, from company onboarding to decision, audit, and tooling:
|
| 4 |
+
Company Overview β PM Flash β Evidence & Deltas β Financials β Ask AI
|
| 5 |
|
| 6 |
Each item has a stable ``key`` decoupled from its display label,
|
| 7 |
so renaming a section never breaks routing.
|
|
|
|
| 24 |
}
|
| 25 |
|
| 26 |
# ββ Navigation items ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 27 |
+
# Company Overview onboards the analyst; the violet cross-cutting AI tool stays
|
| 28 |
+
# last, after the decision, evidence, and financial-data screens.
|
| 29 |
NAV_ITEMS: list[dict] = [
|
| 30 |
{"key": "company", "i18n_key": "nav_company"},
|
| 31 |
+
{"key": "verdict", "i18n_key": "nav_verdict"},
|
| 32 |
+
{"key": "signals", "i18n_key": "nav_signals"},
|
|
|
|
| 33 |
{"key": "financials", "i18n_key": "nav_financials"},
|
| 34 |
+
{"key": "chat", "i18n_key": "nav_chat"},
|
| 35 |
]
|
| 36 |
|
| 37 |
VALID_KEYS: frozenset[str] = frozenset(item["key"] for item in NAV_ITEMS)
|
tests/test_company_profile.py
CHANGED
|
@@ -10,10 +10,12 @@ from agent.company_profile import (
|
|
| 10 |
_collect_translation_segments,
|
| 11 |
_prune_unverified,
|
| 12 |
_quarantine_unsupported_shares,
|
|
|
|
|
|
|
| 13 |
profile_source_coverage,
|
| 14 |
source_fingerprint,
|
| 15 |
)
|
| 16 |
-
from agent.evidence import make_evidence_record
|
| 17 |
from agent.company_profile_schemas import CompanyProfile
|
| 18 |
from analysis.company_attention import attach_attention_stats, cluster_questions
|
| 19 |
from analytics.company_market import (
|
|
@@ -199,6 +201,62 @@ def test_company_profile_schema_accepts_partial_payload():
|
|
| 199 |
assert profile.business_lines == []
|
| 200 |
|
| 201 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 202 |
def test_prune_unverified_removes_nested_profile_claims():
|
| 203 |
profile = {
|
| 204 |
"identity": {
|
|
|
|
| 10 |
_collect_translation_segments,
|
| 11 |
_prune_unverified,
|
| 12 |
_quarantine_unsupported_shares,
|
| 13 |
+
collect_profile_evidence,
|
| 14 |
+
finalize_profile_from_synthesis,
|
| 15 |
profile_source_coverage,
|
| 16 |
source_fingerprint,
|
| 17 |
)
|
| 18 |
+
from agent.evidence import evidence_envelope, make_evidence_record
|
| 19 |
from agent.company_profile_schemas import CompanyProfile
|
| 20 |
from analysis.company_attention import attach_attention_stats, cluster_questions
|
| 21 |
from analytics.company_market import (
|
|
|
|
| 201 |
assert profile.business_lines == []
|
| 202 |
|
| 203 |
|
| 204 |
+
def test_finalize_profile_from_synthesis_prunes_unverified(monkeypatch):
|
| 205 |
+
record = make_evidence_record(
|
| 206 |
+
source="10-K",
|
| 207 |
+
content="The company sells verified products.",
|
| 208 |
+
document_id="sec:AAPL:profile",
|
| 209 |
+
chunk_id="business:0",
|
| 210 |
+
)
|
| 211 |
+
section = {
|
| 212 |
+
"business_lines": [{
|
| 213 |
+
"name": "Unsupported line",
|
| 214 |
+
"description": {
|
| 215 |
+
"text": "An unsupported business line.",
|
| 216 |
+
"source": "10-K",
|
| 217 |
+
"reliability": "HIGH",
|
| 218 |
+
"evidence_snippet": "This snippet was never retrieved.",
|
| 219 |
+
"evidence_ref": record.ref.model_dump(mode="json"),
|
| 220 |
+
},
|
| 221 |
+
}],
|
| 222 |
+
}
|
| 223 |
+
monkeypatch.setattr(
|
| 224 |
+
"agent.company_profile.metrics_db.get_all_metrics",
|
| 225 |
+
lambda ticker: [{"company_name": "Apple Inc.", "filing_date": "2026-01-01"}],
|
| 226 |
+
)
|
| 227 |
+
monkeypatch.setattr("agent.company_profile.source_fingerprint", lambda ticker: "fingerprint")
|
| 228 |
+
monkeypatch.setattr(
|
| 229 |
+
"agent.company_profile.profile_source_coverage",
|
| 230 |
+
lambda ticker: {"status": "COMPLETE"},
|
| 231 |
+
)
|
| 232 |
+
monkeypatch.setattr("agent.company_profile._annual_trends", lambda ticker: [])
|
| 233 |
+
monkeypatch.setattr(
|
| 234 |
+
"analysis.company_attention.attach_attention_stats",
|
| 235 |
+
lambda profile, ticker, news: profile,
|
| 236 |
+
)
|
| 237 |
+
|
| 238 |
+
profile = finalize_profile_from_synthesis("AAPL", section, [], "test-model")
|
| 239 |
+
|
| 240 |
+
assert profile["business_lines"] == []
|
| 241 |
+
assert profile["status"] == "PARTIAL"
|
| 242 |
+
assert profile["verification_report"]["removed"] >= 1
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
def test_collect_profile_evidence_can_skip_metrics(monkeypatch):
|
| 246 |
+
called = []
|
| 247 |
+
|
| 248 |
+
def fake_invoke(tool, arguments):
|
| 249 |
+
called.append(tool.name)
|
| 250 |
+
return evidence_envelope(tool=tool.name, status="EMPTY", query=arguments)
|
| 251 |
+
|
| 252 |
+
monkeypatch.setattr("agent.company_profile._invoke_tool", fake_invoke)
|
| 253 |
+
|
| 254 |
+
payloads = collect_profile_evidence("AAPL", include_metrics=False)
|
| 255 |
+
|
| 256 |
+
assert len(payloads) == 4
|
| 257 |
+
assert "get_financial_metrics" not in called
|
| 258 |
+
|
| 259 |
+
|
| 260 |
def test_prune_unverified_removes_nested_profile_claims():
|
| 261 |
profile = {
|
| 262 |
"identity": {
|
tests/test_graph.py
CHANGED
|
@@ -8,7 +8,7 @@ import json
|
|
| 8 |
|
| 9 |
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
|
| 10 |
|
| 11 |
-
from agent.evidence import evidence_envelope, make_evidence_record
|
| 12 |
from agent.graph import (
|
| 13 |
should_continue,
|
| 14 |
nudge_node,
|
|
@@ -20,6 +20,10 @@ from agent.graph import (
|
|
| 20 |
MAX_FILING_SIGNALS,
|
| 21 |
MAX_TRANSCRIPT_SIGNALS,
|
| 22 |
_coverage_report,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
)
|
| 24 |
|
| 25 |
|
|
@@ -30,6 +34,7 @@ def _state(messages, tool_round_count, nudge_fired: bool = False) -> AgentState:
|
|
| 30 |
"tool_round_count": tool_round_count,
|
| 31 |
"nudge_fired": nudge_fired,
|
| 32 |
"edge_signals": None,
|
|
|
|
| 33 |
"language": "English",
|
| 34 |
"brief": None,
|
| 35 |
"brief_markdown": None,
|
|
@@ -106,6 +111,153 @@ def _error_tool_msg(name: str) -> ToolMessage:
|
|
| 106 |
)
|
| 107 |
|
| 108 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
# ββ Cap-based routing (existing tests, renamed) ββββββββββββββββββββββββββββββ
|
| 110 |
|
| 111 |
|
|
|
|
| 8 |
|
| 9 |
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
|
| 10 |
|
| 11 |
+
from agent.evidence import evidence_envelope, make_evidence_record, parse_evidence_envelope
|
| 12 |
from agent.graph import (
|
| 13 |
should_continue,
|
| 14 |
nudge_node,
|
|
|
|
| 20 |
MAX_FILING_SIGNALS,
|
| 21 |
MAX_TRANSCRIPT_SIGNALS,
|
| 22 |
_coverage_report,
|
| 23 |
+
_finalize_synthesis_profile,
|
| 24 |
+
_partial_brief,
|
| 25 |
+
_pop_company_profile,
|
| 26 |
+
profile_evidence_node,
|
| 27 |
)
|
| 28 |
|
| 29 |
|
|
|
|
| 34 |
"tool_round_count": tool_round_count,
|
| 35 |
"nudge_fired": nudge_fired,
|
| 36 |
"edge_signals": None,
|
| 37 |
+
"profile_payloads": None,
|
| 38 |
"language": "English",
|
| 39 |
"brief": None,
|
| 40 |
"brief_markdown": None,
|
|
|
|
| 111 |
)
|
| 112 |
|
| 113 |
|
| 114 |
+
def test_profile_evidence_node_injects_parseable_envelopes(monkeypatch):
|
| 115 |
+
payloads = [
|
| 116 |
+
evidence_envelope(
|
| 117 |
+
tool="search_filing",
|
| 118 |
+
records=[make_evidence_record(
|
| 119 |
+
source="10-K",
|
| 120 |
+
content=f"Profile evidence {index}.",
|
| 121 |
+
document_id=f"sec:AAPL:profile:{index}",
|
| 122 |
+
chunk_id=str(index),
|
| 123 |
+
)],
|
| 124 |
+
)
|
| 125 |
+
for index in range(2)
|
| 126 |
+
]
|
| 127 |
+
monkeypatch.setattr(
|
| 128 |
+
"agent.company_profile.collect_profile_evidence",
|
| 129 |
+
lambda ticker, include_metrics=True: payloads,
|
| 130 |
+
)
|
| 131 |
+
|
| 132 |
+
result = profile_evidence_node(_state([], 0))
|
| 133 |
+
|
| 134 |
+
assert result["profile_payloads"] == payloads
|
| 135 |
+
assert len(result["messages"]) == 3
|
| 136 |
+
assert all(isinstance(message, HumanMessage) for message in result["messages"])
|
| 137 |
+
assert result["messages"][0].content == (
|
| 138 |
+
"== COMPANY PROFILE EVIDENCE "
|
| 139 |
+
"(deterministic retrieval, evidence.v1 envelopes follow) =="
|
| 140 |
+
)
|
| 141 |
+
assert parse_evidence_envelope(result["messages"][0]) is None
|
| 142 |
+
assert all(
|
| 143 |
+
parse_evidence_envelope(message) is not None
|
| 144 |
+
for message in result["messages"][1:]
|
| 145 |
+
)
|
| 146 |
+
assert [message.content for message in result["messages"][1:]] == payloads
|
| 147 |
+
|
| 148 |
+
monkeypatch.setattr(
|
| 149 |
+
"agent.company_profile.collect_profile_evidence",
|
| 150 |
+
lambda ticker, include_metrics=True: [],
|
| 151 |
+
)
|
| 152 |
+
assert profile_evidence_node(_state([], 0)) == {
|
| 153 |
+
"profile_payloads": [],
|
| 154 |
+
"messages": [],
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def test_partial_brief_has_company_profile_none():
|
| 159 |
+
partial = _partial_brief(_state([], 0), "No usable evidence.")
|
| 160 |
+
assert partial["company_profile"] is None
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
def test_synthesis_pops_company_profile_before_validation(monkeypatch):
|
| 164 |
+
from agent.post_synthesis import apply_reliability
|
| 165 |
+
from agent.schemas import BriefOutput
|
| 166 |
+
|
| 167 |
+
record = make_evidence_record(
|
| 168 |
+
source="10-Q",
|
| 169 |
+
content="Revenue increased due to higher demand.",
|
| 170 |
+
document_id="sec:AAPL:brief",
|
| 171 |
+
chunk_id="mda:0",
|
| 172 |
+
as_of="2026-04-30",
|
| 173 |
+
)
|
| 174 |
+
fact = {
|
| 175 |
+
"text": "Revenue increased due to higher demand.",
|
| 176 |
+
"source": "10-Q",
|
| 177 |
+
"reliability": "HIGH",
|
| 178 |
+
"evidence_snippet": "Revenue increased due to higher demand.",
|
| 179 |
+
"evidence_ref": record.ref.model_dump(mode="json"),
|
| 180 |
+
}
|
| 181 |
+
payload = evidence_envelope(tool="search_filing", records=[record])
|
| 182 |
+
data = {
|
| 183 |
+
"ticker": "AAPL",
|
| 184 |
+
"company_name": "Apple Inc.",
|
| 185 |
+
"filing_date": "2026-04-30",
|
| 186 |
+
"what_matters_most": "Verified demand evidence is the central fact.",
|
| 187 |
+
"standout_number": fact,
|
| 188 |
+
"what_changed": [],
|
| 189 |
+
"bull_points": [],
|
| 190 |
+
"bear_points": [],
|
| 191 |
+
"what_to_watch": [],
|
| 192 |
+
"trends": [],
|
| 193 |
+
"mda_summary": {
|
| 194 |
+
"drivers": [],
|
| 195 |
+
"headwinds": [],
|
| 196 |
+
"language_shift": "No verified cross-period shift.",
|
| 197 |
+
"key_quote": fact,
|
| 198 |
+
},
|
| 199 |
+
"risks_categorized": [],
|
| 200 |
+
"management_commentary": [],
|
| 201 |
+
"guidance_history": [],
|
| 202 |
+
"company_profile": {
|
| 203 |
+
"business_lines": [{
|
| 204 |
+
"name": "Unsupported",
|
| 205 |
+
"description": {
|
| 206 |
+
**fact,
|
| 207 |
+
"text": "This profile fact is not in the record.",
|
| 208 |
+
},
|
| 209 |
+
}],
|
| 210 |
+
},
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
profile_section = _pop_company_profile(data)
|
| 214 |
+
brief = BriefOutput.model_validate(data)
|
| 215 |
+
verified = apply_reliability(brief.model_dump(), evidence_payloads=[payload])
|
| 216 |
+
|
| 217 |
+
assert "company_profile" not in data
|
| 218 |
+
assert profile_section["business_lines"][0]["name"] == "Unsupported"
|
| 219 |
+
assert verified["evidence_coverage"] == {
|
| 220 |
+
"status": "VERIFIED",
|
| 221 |
+
"verified": 2,
|
| 222 |
+
"unverified": 0,
|
| 223 |
+
"failed": 0,
|
| 224 |
+
"total": 2,
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
finalized = {"ticker": "AAPL", "status": "PARTIAL"}
|
| 228 |
+
calls = {}
|
| 229 |
+
|
| 230 |
+
def fake_finalize(ticker, section, payloads, model):
|
| 231 |
+
calls["finalize"] = (ticker, section, payloads, model)
|
| 232 |
+
return finalized
|
| 233 |
+
|
| 234 |
+
def fake_save(ticker, profile):
|
| 235 |
+
calls["save"] = (ticker, profile)
|
| 236 |
+
|
| 237 |
+
monkeypatch.setattr(
|
| 238 |
+
"agent.company_profile.finalize_profile_from_synthesis", fake_finalize
|
| 239 |
+
)
|
| 240 |
+
monkeypatch.setattr("storage.company_profiles.save_profile", fake_save)
|
| 241 |
+
state = _state([_tool_msg("search_filing")], 1)
|
| 242 |
+
state["profile_payloads"] = [payload]
|
| 243 |
+
|
| 244 |
+
assert _finalize_synthesis_profile(state, profile_section, "test-model") == finalized
|
| 245 |
+
assert calls["finalize"][0] == "AAPL"
|
| 246 |
+
assert calls["finalize"][2] == state["messages"] + [payload]
|
| 247 |
+
assert calls["save"] == ("AAPL", finalized)
|
| 248 |
+
|
| 249 |
+
monkeypatch.setattr(
|
| 250 |
+
"agent.company_profile.finalize_profile_from_synthesis",
|
| 251 |
+
lambda *args: (_ for _ in ()).throw(RuntimeError("profile failed")),
|
| 252 |
+
)
|
| 253 |
+
before_failure = {k: v for k, v in verified.items() if k != "company_profile"}
|
| 254 |
+
verified["company_profile"] = _finalize_synthesis_profile(
|
| 255 |
+
state, profile_section, "test-model"
|
| 256 |
+
)
|
| 257 |
+
assert verified["company_profile"] is None
|
| 258 |
+
assert {key: value for key, value in verified.items() if key != "company_profile"} == before_failure
|
| 259 |
+
|
| 260 |
+
|
| 261 |
# ββ Cap-based routing (existing tests, renamed) ββββββββββββββββββββββββββββββ
|
| 262 |
|
| 263 |
|
tests/test_prompts.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
from agent.prompts import SYNTHESIS_STRUCTURED_PROMPT
|
| 2 |
|
| 3 |
|
| 4 |
def _json_template_block() -> str:
|
|
@@ -32,3 +32,20 @@ def test_guidance_history_example_does_not_contradict_the_null_out_rule():
|
|
| 32 |
guidance_block = block[guidance_start:block.index("],", guidance_start) + 2]
|
| 33 |
assert '"actual_result": null' in guidance_block
|
| 34 |
assert '"verdict": null' in guidance_block
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from agent.prompts import PROSE_FIELDS, SYNTHESIS_STRUCTURED_PROMPT
|
| 2 |
|
| 3 |
|
| 4 |
def _json_template_block() -> str:
|
|
|
|
| 32 |
guidance_block = block[guidance_start:block.index("],", guidance_start) + 2]
|
| 33 |
assert '"actual_result": null' in guidance_block
|
| 34 |
assert '"verdict": null' in guidance_block
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def test_company_profile_contract_is_in_synthesis_prompt():
|
| 38 |
+
assert "## COMPANY PROFILE SECTION" in SYNTHESIS_STRUCTURED_PROMPT
|
| 39 |
+
assert '"company_profile"' in _json_template_block()
|
| 40 |
+
for field in (
|
| 41 |
+
"identity", "business_lines", "geographic_exposures",
|
| 42 |
+
"strategic_changes", "attention_themes", "watch_variables",
|
| 43 |
+
):
|
| 44 |
+
assert f'"{field}"' in _json_template_block()
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def test_company_profile_analytical_prose_fields_are_translatable():
|
| 48 |
+
assert {
|
| 49 |
+
"economics", "why_it_matters", "theme", "variable",
|
| 50 |
+
"next_datapoint", "alert_signal",
|
| 51 |
+
} <= PROSE_FIELDS
|
tests/test_verdict.py
CHANGED
|
@@ -8,6 +8,7 @@ from dashboard.verdict import (
|
|
| 8 |
_select_swing_factor,
|
| 9 |
)
|
| 10 |
from dashboard.signals_view import _sentiment_display_allowed
|
|
|
|
| 11 |
from dashboard.nav import NAV_ITEMS, VALID_KEYS
|
| 12 |
|
| 13 |
|
|
@@ -125,12 +126,20 @@ def test_sentiment_display_policy_fails_closed():
|
|
| 125 |
assert _sentiment_display_allowed({"display_policy": {"sentiment_calibrated": True}}) is True
|
| 126 |
|
| 127 |
|
| 128 |
-
def
|
| 129 |
-
assert VALID_KEYS == {"verdict", "signals", "financials", "chat"}
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
assert "secondary" not in chat
|
| 134 |
-
assert NAV_ITEMS[0]["key"] == "verdict"
|
| 135 |
-
assert NAV_ITEMS[1]["key"] == "chat"
|
| 136 |
assert not any(item.get("secondary") for item in NAV_ITEMS)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
_select_swing_factor,
|
| 9 |
)
|
| 10 |
from dashboard.signals_view import _sentiment_display_allowed
|
| 11 |
+
from dashboard.i18n import STRINGS, t
|
| 12 |
from dashboard.nav import NAV_ITEMS, VALID_KEYS
|
| 13 |
|
| 14 |
|
|
|
|
| 126 |
assert _sentiment_display_allowed({"display_policy": {"sentiment_calibrated": True}}) is True
|
| 127 |
|
| 128 |
|
| 129 |
+
def test_nav_order_is_analyst_flow():
|
| 130 |
+
assert VALID_KEYS == {"company", "verdict", "signals", "financials", "chat"}
|
| 131 |
+
assert [item["key"] for item in NAV_ITEMS] == [
|
| 132 |
+
"company", "verdict", "signals", "financials", "chat"
|
| 133 |
+
]
|
|
|
|
|
|
|
|
|
|
| 134 |
assert not any(item.get("secondary") for item in NAV_ITEMS)
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def test_nav_items_have_no_hardcoded_display_label():
|
| 138 |
+
assert not any("display_label" in item for item in NAV_ITEMS)
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def test_nav_i18n_labels_complete():
|
| 142 |
+
for item in NAV_ITEMS:
|
| 143 |
+
labels = STRINGS[item["i18n_key"]]
|
| 144 |
+
assert set(labels) == {"en", "fr", "es", "de"}
|
| 145 |
+
assert t("nav_company") == "Company Overview"
|