fix: normalize off-list enum values in company profile schemas
Browse filesGeographicExposure.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 +87 -2
- agent/prompts.py +2 -2
- tests/test_company_profile.py +90 -1
|
@@ -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[
|
|
|
|
|
|
|
| 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")
|
|
@@ -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
|
|
@@ -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,
|