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
+ });