Viney Claude Sonnet 5 commited on
Commit
8727e56
Β·
1 Parent(s): bd4975b

feat: merge Company Overview generation into single synthesis call

Browse files

Company 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 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 Primer Β· PM Flash Β· Evidence & Deltas Β· Financials<br/>Ask Evidence β€” secondary"]:::io
22
- UI --> AG
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 Primer** | 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. |
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 Evidence** | Secondary grounded Q&A over the available evidence. |
107
 
108
- The navigation order is intentional: company onboarding β†’ decision memo β†’ grounded Q&A β†’ audit β†’ model depth.
 
 
 
 
 
 
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 Primer** | 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 Evidence** | Secondary utility for grounded follow-up questions. It supports the review; it is not the primary navigation or the investment conclusion. |
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 profile** β€” a cached, source-fingerprinted English profile separates stable filing/call analysis from independently refreshed prices and public news.
58
- - **Runtime agent** β€” a LangGraph tool loop retrieves structured financials, filings, transcripts, public news, and analyst data before producing a validated Pydantic brief.
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 Primer onboards; PM Flash frames the decision; Evidence & Deltas provides auditability; Financials provides depth; Ask Evidence stays secondary.
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 Primer’s 10-K Business and Segments/Geography sections;
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 Primer 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, 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": 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": config.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 Primer research surface."""
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", "agent")
 
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 Primer (the onboarding view) ─────────
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
- jobs = st.session_state.setdefault("profile_jobs", {})
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
- if config is None:
685
- st.warning(t("model_blocked_caption"))
686
- return
687
- if job is None:
688
- job = _start_company_profile_job(ticker, fingerprint, config)
689
- if job.get("running"):
690
- _render_company_profile_progress(job)
691
- time.sleep(0.4)
692
- st.rerun()
693
- if job.get("error"):
694
- st.error(job["error"])
695
- if st.button(t("primer_retry"), key=f"primer_retry_{ticker}"):
696
- _start_company_profile_job(ticker, fingerprint, config)
697
- st.rerun()
698
- return
699
- profile = job.get("profile")
700
- if profile is None:
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
- st.warning(t("primer_backfill_warning").format(ticker=ticker))
711
-
712
- refresh_col, _ = st.columns([1, 5])
713
- with refresh_col:
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 Primer β€” compact company-onboarding surface."""
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 Primer", "fr": "Profil sociΓ©tΓ©", "es": "Perfil de empresa", "de": "Unternehmensprofil"},
283
- "nav_verdict": {"en": "Verdict", "fr": "Verdict", "es": "Veredicto", "de": "Fazit"},
284
  "nav_chat": {"en": "Ask AI", "fr": "Demander Γ  l'IA", "es": "Preguntar a la IA", "de": "KI fragen"},
285
- "nav_signals": {"en": "Signals", "fr": "Signaux", "es": "SeΓ±ales", "de": "Signale"},
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 Primer
548
- "primer_generating": {"en": "Building the Company Primer", "fr": "CrΓ©ation du profil sociΓ©tΓ©", "es": "Creando el perfil de empresa", "de": "Unternehmensprofil wird erstellt"},
549
- "primer_retry": {"en": "Retry Company Primer", "fr": "Relancer le profil sociΓ©tΓ©", "es": "Reintentar el perfil de empresa", "de": "Unternehmensprofil erneut erstellen"},
550
- "primer_refresh": {"en": "Refresh profile", "fr": "Actualiser le profil", "es": "Actualizar perfil", "de": "Profil aktualisieren"},
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-profile source coverage is incomplete. Run `python ingest.py {ticker}` to backfill the 10-K Business and segment/geography sections.", "fr": "La couverture des sources du profil 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 perfil estÑ incompleta. Ejecute `python ingest.py {ticker}` para recuperar las secciones Business y segmentos/geografía del 10-K.", "de": "Die Quellenabdeckung des Unternehmensprofils ist unvollstÀndig. Führen Sie `python ingest.py {ticker}` aus, um die 10-K-Abschnitte Business und Segmente/Geografie nachzuladen."},
 
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 audit:
4
- Company Primer β†’ PM Flash β†’ Ask AI β†’ Evidence & Deltas β†’ Financials
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 Primer onboards the analyst; Chat stays immediately after PM Flash
28
- # for fast interrogation of the decision evidence.
29
  NAV_ITEMS: list[dict] = [
30
  {"key": "company", "i18n_key": "nav_company"},
31
- {"key": "verdict", "i18n_key": "nav_verdict", "display_label": "PM Flash"},
32
- {"key": "chat", "i18n_key": "nav_chat"},
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 test_pm_navigation_preserves_route_keys_and_promotes_chat():
129
- assert VALID_KEYS == {"verdict", "signals", "financials", "chat"}
130
- chat = next(item for item in NAV_ITEMS if item["key"] == "chat")
131
- # Chat is promoted to primary nav, right after the decision view (PM Flash) β€”
132
- # no longer a "secondary" item relegated under an "Evidence tool" heading.
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"