Viney Claude Sonnet 5 commited on
Commit
6ddda49
·
1 Parent(s): e526b3a

fix: normalize off-list enum values in company profile schemas

Browse files

GeographicExposure.exposure_types and BusinessLine.trend rejected any
model output outside their narrow Literal set, raising a validation
error that discarded the entire Company Overview for the run (caught
by synthesis_node's try/except, but silently — confirmed on a live
AAPL run right after the previous two fixes let the model correctly
produce values like "regulatory" and "geopolitical").

Both fields now normalize via a bounded synonym table (mirroring
RiskItem._normalize_category) before validation: known synonyms map
to the canonical value, unknown ones fall back safely (trend ->
not_disclosed) or are dropped with a stderr log (exposure_types).
"geopolitical" is added as a legitimate new category, not just an
alias. The synthesis prompt now spells out both closed enumerations
explicitly, matching the existing pattern for risks_categorized.category.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

agent/company_profile_schemas.py CHANGED
@@ -1,13 +1,56 @@
1
  """Structured payload for the Company Overview research surface."""
2
  from __future__ import annotations
3
 
 
4
  from typing import Any, Literal, Optional
5
 
6
- from pydantic import BaseModel, ConfigDict, Field
7
 
8
  from agent.schemas import SourcedFact
9
 
10
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  class CompanyIdentity(BaseModel):
12
  model_config = ConfigDict(extra="ignore")
13
 
@@ -30,18 +73,60 @@ class BusinessLine(BaseModel):
30
  revenue_share_pct: Optional[float] = Field(default=None, ge=0, le=100)
31
  share_period: Optional[str] = None
32
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
 
34
  class GeographicExposure(BaseModel):
35
  model_config = ConfigDict(extra="ignore")
36
 
37
  name: str
38
  exposure_types: list[
39
- Literal["revenue", "operations", "supply_chain", "regulation"]
 
 
40
  ] = Field(default_factory=list)
41
  description: SourcedFact
42
  revenue_share_pct: Optional[float] = Field(default=None, ge=0, le=100)
43
  period: Optional[str] = None
44
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
 
46
  class StrategicChange(BaseModel):
47
  model_config = ConfigDict(extra="ignore")
 
1
  """Structured payload for the Company Overview research surface."""
2
  from __future__ import annotations
3
 
4
+ import sys
5
  from typing import Any, Literal, Optional
6
 
7
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
8
 
9
  from agent.schemas import SourcedFact
10
 
11
 
12
+ _CANONICAL_TRENDS = {"growing", "stable", "declining", "mixed", "not_disclosed"}
13
+
14
+ _TREND_ALIASES: dict[str, str] = {
15
+ "increasing": "growing",
16
+ "expanding": "growing",
17
+ "accelerating": "growing",
18
+ "rising": "growing",
19
+ "flat": "stable",
20
+ "steady": "stable",
21
+ "unchanged": "stable",
22
+ "decreasing": "declining",
23
+ "shrinking": "declining",
24
+ "contracting": "declining",
25
+ "falling": "declining",
26
+ "varied": "mixed",
27
+ "uneven": "mixed",
28
+ }
29
+
30
+ _CANONICAL_EXPOSURE_TYPES = {
31
+ "revenue",
32
+ "operations",
33
+ "supply_chain",
34
+ "regulation",
35
+ "geopolitical",
36
+ }
37
+
38
+ _EXPOSURE_TYPE_ALIASES: dict[str, str] = {
39
+ "regulatory": "regulation",
40
+ "legal": "regulation",
41
+ "compliance": "regulation",
42
+ "sales": "revenue",
43
+ "manufacturing": "operations",
44
+ "production": "operations",
45
+ "sourcing": "supply_chain",
46
+ "suppliers": "supply_chain",
47
+ "political": "geopolitical",
48
+ "geo_political": "geopolitical",
49
+ "trade": "geopolitical",
50
+ "tariffs": "geopolitical",
51
+ }
52
+
53
+
54
  class CompanyIdentity(BaseModel):
55
  model_config = ConfigDict(extra="ignore")
56
 
 
73
  revenue_share_pct: Optional[float] = Field(default=None, ge=0, le=100)
74
  share_period: Optional[str] = None
75
 
76
+ @field_validator("trend", mode="before")
77
+ @classmethod
78
+ def _normalize_trend(cls, v: object) -> str:
79
+ if isinstance(v, str):
80
+ normalized = "_".join(v.strip().lower().replace("-", " ").split())
81
+ if normalized in _CANONICAL_TRENDS:
82
+ return normalized
83
+ if normalized in _TREND_ALIASES:
84
+ return _TREND_ALIASES[normalized]
85
+ print(
86
+ f"[business-trend] coercing unknown trend '{v}' to not_disclosed",
87
+ file=sys.stderr,
88
+ )
89
+ return "not_disclosed"
90
+
91
 
92
  class GeographicExposure(BaseModel):
93
  model_config = ConfigDict(extra="ignore")
94
 
95
  name: str
96
  exposure_types: list[
97
+ Literal[
98
+ "revenue", "operations", "supply_chain", "regulation", "geopolitical"
99
+ ]
100
  ] = Field(default_factory=list)
101
  description: SourcedFact
102
  revenue_share_pct: Optional[float] = Field(default=None, ge=0, le=100)
103
  period: Optional[str] = None
104
 
105
+ @field_validator("exposure_types", mode="before")
106
+ @classmethod
107
+ def _normalize_exposure_types(cls, v: object) -> object:
108
+ if not isinstance(v, list):
109
+ return v
110
+ normalized_items = []
111
+ seen = set()
112
+ for item in v:
113
+ if not isinstance(item, str):
114
+ normalized_items.append(item)
115
+ continue
116
+ normalized = "_".join(item.strip().lower().replace("-", " ").split())
117
+ if normalized in _EXPOSURE_TYPE_ALIASES:
118
+ normalized = _EXPOSURE_TYPE_ALIASES[normalized]
119
+ elif normalized not in _CANONICAL_EXPOSURE_TYPES:
120
+ print(
121
+ f"[exposure-type] dropping unknown exposure_type '{item}'",
122
+ file=sys.stderr,
123
+ )
124
+ continue
125
+ if normalized not in seen:
126
+ normalized_items.append(normalized)
127
+ seen.add(normalized)
128
+ return normalized_items
129
+
130
 
131
  class StrategicChange(BaseModel):
132
  model_config = ConfigDict(extra="ignore")
agent/prompts.py CHANGED
@@ -491,8 +491,8 @@ Required JSON structure:
491
  - risks_categorized: 3-6 items; category must be exactly one of: Regulatory, Operational, Competitive, Financial, Macro, Demand, Geopolitical — do not invent new buckets
492
  - management_commentary: 3-5 items (prefer MD&A sources; use transcript for tone/Q&A color not in filings)
493
  - 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)
494
- - company_profile.business_lines: 3-5 items
495
- - company_profile.geographic_exposures: at most 8 items
496
  - company_profile.strategic_changes: at most 3 items
497
  - company_profile.attention_themes: exactly 3 items
498
  - company_profile.watch_variables: 3-4 items
 
491
  - risks_categorized: 3-6 items; category must be exactly one of: Regulatory, Operational, Competitive, Financial, Macro, Demand, Geopolitical — do not invent new buckets
492
  - management_commentary: 3-5 items (prefer MD&A sources; use transcript for tone/Q&A color not in filings)
493
  - 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)
494
+ - company_profile.business_lines: 3-5 items; trend must be exactly one of: growing, stable, declining, mixed, not_disclosed — do not invent new labels
495
+ - company_profile.geographic_exposures: at most 8 items; each exposure_types value must be exactly one of: revenue, operations, supply_chain, regulation, geopolitical — do not invent new labels (e.g. 'regulatory' is invalid, use 'regulation')
496
  - company_profile.strategic_changes: at most 3 items
497
  - company_profile.attention_themes: exactly 3 items
498
  - company_profile.watch_variables: 3-4 items
tests/test_company_profile.py CHANGED
@@ -16,7 +16,8 @@ from agent.company_profile import (
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 (
22
  _parse_published_date,
@@ -52,6 +53,94 @@ def _fact(status="VERIFIED"):
52
  }
53
 
54
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  def _metric_row(
56
  period,
57
  report_date,
 
16
  source_fingerprint,
17
  )
18
  from agent.evidence import evidence_envelope, make_evidence_record
19
+ from agent.company_profile_schemas import CompanyProfile, CompanyProfileSection
20
+ from agent.prompts import SYNTHESIS_STRUCTURED_PROMPT
21
  from analysis.company_attention import attach_attention_stats, cluster_questions
22
  from analytics.company_market import (
23
  _parse_published_date,
 
53
  }
54
 
55
 
56
+ def test_geographic_exposure_normalizes_synonym_and_new_exposure_types():
57
+ section = CompanyProfileSection.model_validate(
58
+ {
59
+ "geographic_exposures": [
60
+ {
61
+ "name": "Americas",
62
+ "exposure_types": ["revenue"],
63
+ "description": _fact(),
64
+ },
65
+ {
66
+ "name": "Europe",
67
+ "exposure_types": ["revenue", "regulatory"],
68
+ "description": _fact(),
69
+ },
70
+ {
71
+ "name": "Greater China",
72
+ "exposure_types": ["operations", "geopolitical"],
73
+ "description": _fact(),
74
+ },
75
+ ]
76
+ }
77
+ )
78
+
79
+ assert section.geographic_exposures[1].exposure_types == [
80
+ "revenue",
81
+ "regulation",
82
+ ]
83
+ assert section.geographic_exposures[2].exposure_types == [
84
+ "operations",
85
+ "geopolitical",
86
+ ]
87
+
88
+
89
+ def test_geographic_exposure_drops_unknown_exposure_type():
90
+ section = CompanyProfileSection.model_validate(
91
+ {
92
+ "geographic_exposures": [
93
+ {
94
+ "name": "Americas",
95
+ "exposure_types": ["revenue", "interplanetary"],
96
+ "description": _fact(),
97
+ },
98
+ {
99
+ "name": "Asia",
100
+ "exposure_types": ["Supply Chain"],
101
+ "description": _fact(),
102
+ },
103
+ ]
104
+ }
105
+ )
106
+
107
+ assert section.geographic_exposures[0].exposure_types == ["revenue"]
108
+ assert section.geographic_exposures[1].exposure_types == ["supply_chain"]
109
+
110
+
111
+ def test_business_line_trend_normalizes_synonyms_and_unknowns():
112
+ section = CompanyProfileSection.model_validate(
113
+ {
114
+ "business_lines": [
115
+ {"name": "Services", "description": _fact(), "trend": "Increasing"},
116
+ {"name": "Devices", "description": _fact(), "trend": "flat"},
117
+ {
118
+ "name": "Other",
119
+ "description": _fact(),
120
+ "trend": "something_else",
121
+ },
122
+ ]
123
+ }
124
+ )
125
+
126
+ assert [line.trend for line in section.business_lines] == [
127
+ "growing",
128
+ "stable",
129
+ "not_disclosed",
130
+ ]
131
+
132
+
133
+ def test_synthesis_prompt_enumerates_profile_literals():
134
+ assert (
135
+ "revenue, operations, supply_chain, regulation, geopolitical"
136
+ in SYNTHESIS_STRUCTURED_PROMPT
137
+ )
138
+ assert (
139
+ "growing, stable, declining, mixed, not_disclosed"
140
+ in SYNTHESIS_STRUCTURED_PROMPT
141
+ )
142
+
143
+
144
  def _metric_row(
145
  period,
146
  report_date,