fix: align research readiness and search rendering

prakhar82 committed Sep 14, 2026 at 19:30 UTC bec005d2a4a69d4d929477efebf9feb33702f397
5 files changed +144 -2
ai/research-engine/app/portfolio_orchestration.py
+2
@@ -1013,6 +1013,8 @@ class PortfolioResearchOrchestrator:
1013 safe_error_code="COMPANY_NOT_RESOLVED",
1014 safe_error_message="Holding identity did not match a registered research company.",
1015 )
1016 + if not instrument.get("provider"):
1017 + _refresh_profile_from_global_instrument(profile, instrument)
1018 allow_demo = not _is_real_broker_instrument(instrument)
1019 summary = self.repository.summary(profile.instrument_id, allow_demo=allow_demo)
1020 summary_counts.update(
ai/research-engine/tests/test_global_presentation_mapping_trust.py new
+127
@@ -0,0 +1,127 @@
1 +"""Regression tests for global presentation vs. readiness provider-mapping trust alignment.
2 +
3 +Canonical rule:
4 + globalInstrumentId -> verified provider mapping -> provider symbol
5 +
6 +`read_global_company_state` (presentation/summary) projects through
7 +`_read_company_state_impl` -> `_resolve_profile` -> `_hydrate_verified_exchange_mappings`,
8 +which RETAINS previously-verified provider mappings (it only adds trusted mappings
9 +from the instrument and never removes stale ones). The readiness path
10 +(`register_global_profile_metadata` -> `_refresh_profile_from_global_instrument`)
11 +REPLACE`S` the mapping set with verified-only entries via `_trusted_provider_mapping`.
12 +
13 +These projections must agree, so `_read_company_state_impl` now re-aligns the global
14 +(provider-less) instrument through `_refresh_profile_from_global_instrument`. Portfolio
15 +positions always carry a broker `provider`, so the `if not instrument.get("provider")`
16 +guard leaves the portfolio retain path -- and broker-provenance identity -- untouched.
17 +"""
18 +import asyncio
19 +from uuid import UUID, uuid4
20 +
21 +from app.portfolio_orchestration import PortfolioResearchOrchestrator
22 +from app.repository import ResearchRepository
23 +from app.settings import Settings
24 +
25 +
26 +def _global_instrument_payload(global_id: UUID, yahoo_status: str = "VERIFIED") -> dict:
27 + """A portfolio-service global-instrument master payload (no broker `provider`)."""
28 + return {
29 + "globalInstrumentId": str(global_id),
30 + "canonicalName": "Venus Pipes And Fittings Limited",
31 + "isin": "INE000V01010",
32 + "assetType": "EQUITY",
33 + "country": "IN",
34 + "currency": "INR",
35 + "primaryExchange": "NSE",
36 + "primarySymbol": "VENUSPIPES",
37 + "providerMappings": [
38 + {"provider": "NSE", "providerSymbol": "VENUSPIPES", "status": "VERIFIED", "exchange": "NSE"},
39 + {"provider": "YAHOO_FINANCE", "providerSymbol": "VENUSPIPES.NS", "status": yahoo_status, "exchange": "NSE"},
40 + ],
41 + }
42 +
43 +
44 +def _no_yahoo_payload(global_id: UUID) -> dict:
45 + """Global instrument whose master mappings contain no Yahoo entry at all."""
46 + return {
47 + "globalInstrumentId": str(global_id),
48 + "canonicalName": "No Yahoo Company Limited",
49 + "isin": "INE000N01010",
50 + "assetType": "EQUITY",
51 + "country": "IN",
52 + "currency": "INR",
53 + "primaryExchange": "NSE",
54 + "primarySymbol": "NOYAHOO",
55 + "providerMappings": [
56 + {"provider": "NSE", "providerSymbol": "NOYAHOO", "status": "VERIFIED", "exchange": "NSE"},
57 + ],
58 + }
59 +
60 +
61 +class _OfflineClient:
62 + """Asserts the offline global presentation path performs no portfolio-service HTTP."""
63 +
64 + async def get(self, *args, **kwargs):
65 + raise AssertionError("read_global_company_state(metadata=) must not perform HTTP")
66 +
67 + async def post(self, *args, **kwargs):
68 + raise AssertionError("read_global_company_state(metadata=) must not perform HTTP")
69 +
70 +
71 +def _orchestrator(repo: ResearchRepository) -> PortfolioResearchOrchestrator:
72 + settings = Settings(
73 + research_demo_enabled=False,
74 + structured_provider_enabled=False,
75 + portfolio_service_base_url="http://portfolio-service",
76 + )
77 + return PortfolioResearchOrchestrator(repo, settings, client=_OfflineClient())
78 +
79 +
80 +def test_global_presentation_keeps_verified_yahoo_mapping() -> None:
81 + global_id = uuid4()
82 + repo = ResearchRepository(settings=Settings(research_demo_enabled=False))
83 + orchestrator = _orchestrator(repo)
84 + payload = _global_instrument_payload(global_id, yahoo_status="VERIFIED")
85 +
86 + company = asyncio.run(orchestrator.read_global_company_state(global_id, metadata=payload))
87 + assert company.verified_provider_mappings == {
88 + "NSE": "VENUSPIPES",
89 + "YAHOO_FINANCE": "VENUSPIPES.NS",
90 + }
91 +
92 +
93 +def test_global_presentation_drops_drifted_unverified_yahoo_mapping_from_summary_and_readiness() -> None:
94 + global_id = uuid4()
95 + repo = ResearchRepository(settings=Settings(research_demo_enabled=False))
96 + orchestrator = _orchestrator(repo)
97 + verified = _global_instrument_payload(global_id, yahoo_status="VERIFIED")
98 + drifted = _global_instrument_payload(global_id, yahoo_status="INVALID")
99 +
100 + # Seed a process-local profile whose Yahoo mapping was VERIFIED.
101 + assert orchestrator.register_global_profile_metadata(global_id, verified) is True
102 + assert repo.profile(global_id).provider_instrument_ids == {
103 + "NSE": "VENUSPIPES",
104 + "YAHOO_FINANCE": "VENUSPIPES.NS",
105 + }
106 +
107 + # The instrument now reports the Yahoo mapping as INVALID. The presentation
108 + # projection (read_global_company_state) must not retain the stale, now-unverified
109 + # Yahoo mapping as verified -- it must agree with the readiness projection.
110 + company = asyncio.run(orchestrator.read_global_company_state(global_id, metadata=drifted))
111 + assert "YAHOO_FINANCE" not in company.verified_provider_mappings
112 + assert company.verified_provider_mappings == {"NSE": "VENUSPIPES"}
113 +
114 + # The readiness projection agrees after its own refresh.
115 + assert orchestrator.register_global_profile_metadata(global_id, drifted) is True
116 + assert "YAHOO_FINANCE" not in repo.profile(global_id).provider_instrument_ids
117 +
118 +
119 +def test_global_presentation_never_guesses_a_yahoo_symbol() -> None:
120 + global_id = uuid4()
121 + repo = ResearchRepository(settings=Settings(research_demo_enabled=False))
122 + orchestrator = _orchestrator(repo)
123 + payload = _no_yahoo_payload(global_id)
124 +
125 + company = asyncio.run(orchestrator.read_global_company_state(global_id, metadata=payload))
126 + assert "YAHOO_FINANCE" not in company.verified_provider_mappings
127 + assert company.verified_provider_mappings == {"NSE": "NOYAHOO"}
frontend/app/components/investment-workspace.tsx
+1 -1
@@ -1968,7 +1968,7 @@ function ResearchReadinessRow({
1968 {requirement.applicabilityReason ? <p>Applicability: {requirement.applicability?.replaceAll("_", " ")} · {requirement.applicabilityReason.replaceAll("_", " ")}{requirement.businessClassification ? ` (${requirement.businessClassification})` : ""}</p> : null}
1969 {requirement.status === "NOT_APPLICABLE" ? <p>Excluded from completeness. No score assigned.</p> : null}
1970 {requirement.requirementId === "CURRENT_NEWS" && requirement.acquisitionObservation?.history?.some((scan) => scan.outcome === "SUCCESS_EMPTY") ? <p>The latest successful provider scan found no qualifying current events. No news score was inferred.</p> : null}
1971 - {requirement.acquisitionObservation ? <p>Last acquisition: {requirement.acquisitionObservation.outcome.replaceAll("_", " ")} · {requirement.acquisitionObservation.provider.replaceAll("_", " ")}{requirement.acquisitionObservation.failure_reason ? `: ${requirement.acquisitionObservation.failure_reason}` : ""}</p> : null}
1971 + {requirement.acquisitionObservation && requirement.acquisitionObservation.outcome && requirement.acquisitionObservation.provider ? <p>Last acquisition: {requirement.acquisitionObservation.outcome.replaceAll("_", " ")} · {requirement.acquisitionObservation.provider.replaceAll("_", " ")}{requirement.acquisitionObservation.failure_reason ? `: ${requirement.acquisitionObservation.failure_reason}` : ""}</p> : null}
1972 {requirement.missingInputIds.length ? <p>Missing inputs: {requirement.missingInputIds.map((id) => id.replaceAll("_", " ")).join(", ")}</p> : null}
1973 {requirement.concreteRequirements?.some((input) => input.applicability === "NOT_APPLICABLE") ? <p>Not applicable: {requirement.concreteRequirements.filter((input) => input.applicability === "NOT_APPLICABLE").map((input) => input.inputId.replaceAll("_", " ")).join(", ")}</p> : null}
1974 {requirement.missingReason ? <p className="readiness-reason">{requirement.missingReason.replaceAll("_", " ")}</p> : null}
frontend/app/lib/portfolio-api.ts
+1 -1
@@ -84,7 +84,7 @@ export type ResearchReadinessRequirement = {
84 applicabilityReason?: string | null;
85 businessClassification?: string | null;
86 classificationSource?: string | null;
87 - acquisitionObservation?: { outcome: string; provider: string; observed_at: string; failure_reason?: string | null; history?: { outcome: string; provider: string; observed_at: string }[] } | null;
87 + acquisitionObservation?: { outcome?: string | null; provider?: string | null; observed_at?: string | null; failure_reason?: string | null; history?: { outcome: string; provider: string; observed_at: string }[]; news_readiness?: string | null; coverage?: number | null; run_id?: string | null; } | null;
88 sourceProvider?: string | null;
89 sourceTier?: string | null;
90 sourceUrl?: string | null;
frontend/tests/research-stock-search-ui.test.mjs
+13
@@ -155,3 +155,16 @@ test("research option colors have theme-safe dark-mode overrides", () => {
155 assert.equal((styles.match(/--research-option-text: #f1f5f9;/g) ?? []).length, 2);
156 assert.equal((styles.match(/--research-option-muted: #94a3b8;/g) ?? []).length, 2);
157 });
158 +
159 + test("readiness acquisition observation is null-safe for the news-run shape (no replaceAll on undefined outcome/provider)", () => {
160 + assert.doesNotMatch(workspace, /requirement\.acquisitionObservation \? <p>Last acquisition/);
161 + assert.match(workspace, /requirement\.acquisitionObservation && requirement\.acquisitionObservation\.outcome && requirement\.acquisitionObservation\.provider \? <p>Last acquisition/);
162 + });
163 +
164 + test("readiness acquisition observation type models the news-run shape with optional outcome/provider", () => {
165 + assert.match(api, /outcome\?: string \| null/);
166 + assert.match(api, /provider\?: string \| null/);
167 + assert.match(api, /news_readiness\?: string \| null/);
168 + assert.match(api, /coverage\?: number \| null/);
169 + assert.match(api, /run_id\?: string \| null/);
170 + });