fix: per-item drop of invalid brief facts; relax LLM-produced singleton fields
Browse filesGeneralizes the fail-closed-per-fact principle to Pydantic validation
itself, closing the whole class of bugs behind this incident: a single
malformed item produced by the LLM (mis-cased source, malformed risk,
business line missing a description) used to crash
BriefOutput.model_validate or CompanyProfileSection.model_validate
outright, discarding the entire brief or the entire overview section.
A _drop_invalid_list_items helper (generalizing the existing
_sanitize_quality_signals pattern) validates each list item on its own
in a model_validator(mode="before") and quarantines only the failures,
with a stderr log. Invalid singletons fall back instead of raising:
standout_number / sentiment / market_expectations -> None, mda_summary
-> defaults, status -> PARTIAL. Same treatment on the company profile
side, plus revenue_share_pct normalization (non-numeric or out of
[0,100] -> None) and a direction fallback to "insufficient".
Deliberately excluded: management_commentary and
earnings_quality_signals, which already have dedicated field
sanitizers with their own log-tag tests. EvidenceRecord stays strict —
it is built by the tools, not copied by the model.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- agent/company_profile_schemas.py +91 -2
- agent/schemas.py +103 -3
- tests/test_company_profile.py +58 -0
- tests/test_schemas.py +75 -0
|
@@ -4,9 +4,9 @@ from __future__ import annotations
|
|
| 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"}
|
|
@@ -35,6 +35,14 @@ _CANONICAL_EXPOSURE_TYPES = {
|
|
| 35 |
"geopolitical",
|
| 36 |
}
|
| 37 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
_EXPOSURE_TYPE_ALIASES: dict[str, str] = {
|
| 39 |
"regulatory": "regulation",
|
| 40 |
"legal": "regulation",
|
|
@@ -88,6 +96,23 @@ class BusinessLine(BaseModel):
|
|
| 88 |
)
|
| 89 |
return "not_disclosed"
|
| 90 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
|
| 92 |
class GeographicExposure(BaseModel):
|
| 93 |
model_config = ConfigDict(extra="ignore")
|
|
@@ -127,6 +152,23 @@ class GeographicExposure(BaseModel):
|
|
| 127 |
seen.add(normalized)
|
| 128 |
return normalized_items
|
| 129 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
|
| 131 |
class StrategicChange(BaseModel):
|
| 132 |
model_config = ConfigDict(extra="ignore")
|
|
@@ -157,6 +199,19 @@ class AttentionTheme(BaseModel):
|
|
| 157 |
retrieved_news_count: int = Field(default=0, ge=0)
|
| 158 |
direction: Literal["rising", "stable", "falling", "new", "insufficient"] = "insufficient"
|
| 159 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
|
| 161 |
class WatchVariable(BaseModel):
|
| 162 |
model_config = ConfigDict(extra="ignore")
|
|
@@ -180,6 +235,40 @@ class CompanyProfileSection(BaseModel):
|
|
| 180 |
attention_themes: list[AttentionTheme] = Field(default_factory=list)
|
| 181 |
watch_variables: list[WatchVariable] = Field(default_factory=list)
|
| 182 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 183 |
|
| 184 |
class AnnualTrend(BaseModel):
|
| 185 |
model_config = ConfigDict(extra="ignore")
|
|
|
|
| 4 |
import sys
|
| 5 |
from typing import Any, Literal, Optional
|
| 6 |
|
| 7 |
+
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
| 8 |
|
| 9 |
+
from agent.schemas import SourcedFact, _drop_invalid_list_items
|
| 10 |
|
| 11 |
|
| 12 |
_CANONICAL_TRENDS = {"growing", "stable", "declining", "mixed", "not_disclosed"}
|
|
|
|
| 35 |
"geopolitical",
|
| 36 |
}
|
| 37 |
|
| 38 |
+
_CANONICAL_ATTENTION_DIRECTIONS = {
|
| 39 |
+
"rising",
|
| 40 |
+
"stable",
|
| 41 |
+
"falling",
|
| 42 |
+
"new",
|
| 43 |
+
"insufficient",
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
_EXPOSURE_TYPE_ALIASES: dict[str, str] = {
|
| 47 |
"regulatory": "regulation",
|
| 48 |
"legal": "regulation",
|
|
|
|
| 96 |
)
|
| 97 |
return "not_disclosed"
|
| 98 |
|
| 99 |
+
@field_validator("revenue_share_pct", mode="before")
|
| 100 |
+
@classmethod
|
| 101 |
+
def _normalize_revenue_share_pct(cls, v: object) -> object:
|
| 102 |
+
if v is None:
|
| 103 |
+
return None
|
| 104 |
+
try:
|
| 105 |
+
numeric = float(v)
|
| 106 |
+
except (TypeError, ValueError):
|
| 107 |
+
numeric = None
|
| 108 |
+
if numeric is not None and 0 <= numeric <= 100:
|
| 109 |
+
return numeric
|
| 110 |
+
print(
|
| 111 |
+
f"[revenue-share] invalid {cls.__name__} revenue_share_pct '{v}', falling back to None",
|
| 112 |
+
file=sys.stderr,
|
| 113 |
+
)
|
| 114 |
+
return None
|
| 115 |
+
|
| 116 |
|
| 117 |
class GeographicExposure(BaseModel):
|
| 118 |
model_config = ConfigDict(extra="ignore")
|
|
|
|
| 152 |
seen.add(normalized)
|
| 153 |
return normalized_items
|
| 154 |
|
| 155 |
+
@field_validator("revenue_share_pct", mode="before")
|
| 156 |
+
@classmethod
|
| 157 |
+
def _normalize_revenue_share_pct(cls, v: object) -> object:
|
| 158 |
+
if v is None:
|
| 159 |
+
return None
|
| 160 |
+
try:
|
| 161 |
+
numeric = float(v)
|
| 162 |
+
except (TypeError, ValueError):
|
| 163 |
+
numeric = None
|
| 164 |
+
if numeric is not None and 0 <= numeric <= 100:
|
| 165 |
+
return numeric
|
| 166 |
+
print(
|
| 167 |
+
f"[revenue-share] invalid {cls.__name__} revenue_share_pct '{v}', falling back to None",
|
| 168 |
+
file=sys.stderr,
|
| 169 |
+
)
|
| 170 |
+
return None
|
| 171 |
+
|
| 172 |
|
| 173 |
class StrategicChange(BaseModel):
|
| 174 |
model_config = ConfigDict(extra="ignore")
|
|
|
|
| 199 |
retrieved_news_count: int = Field(default=0, ge=0)
|
| 200 |
direction: Literal["rising", "stable", "falling", "new", "insufficient"] = "insufficient"
|
| 201 |
|
| 202 |
+
@field_validator("direction", mode="before")
|
| 203 |
+
@classmethod
|
| 204 |
+
def _normalize_direction(cls, v: object) -> str:
|
| 205 |
+
if isinstance(v, str):
|
| 206 |
+
normalized = "_".join(v.strip().lower().replace("-", " ").split())
|
| 207 |
+
if normalized in _CANONICAL_ATTENTION_DIRECTIONS:
|
| 208 |
+
return normalized
|
| 209 |
+
print(
|
| 210 |
+
f"[attention-direction] unknown direction '{v}', falling back to 'insufficient'",
|
| 211 |
+
file=sys.stderr,
|
| 212 |
+
)
|
| 213 |
+
return "insufficient"
|
| 214 |
+
|
| 215 |
|
| 216 |
class WatchVariable(BaseModel):
|
| 217 |
model_config = ConfigDict(extra="ignore")
|
|
|
|
| 235 |
attention_themes: list[AttentionTheme] = Field(default_factory=list)
|
| 236 |
watch_variables: list[WatchVariable] = Field(default_factory=list)
|
| 237 |
|
| 238 |
+
@model_validator(mode="before")
|
| 239 |
+
@classmethod
|
| 240 |
+
def _sanitize_llm_fields(cls, data: object) -> object:
|
| 241 |
+
if not isinstance(data, dict):
|
| 242 |
+
return data
|
| 243 |
+
|
| 244 |
+
sanitized = dict(data)
|
| 245 |
+
list_fields: dict[str, type[BaseModel]] = {
|
| 246 |
+
"business_lines": BusinessLine,
|
| 247 |
+
"geographic_exposures": GeographicExposure,
|
| 248 |
+
"strategic_changes": StrategicChange,
|
| 249 |
+
"attention_themes": AttentionTheme,
|
| 250 |
+
"watch_variables": WatchVariable,
|
| 251 |
+
}
|
| 252 |
+
for field_name, model_cls in list_fields.items():
|
| 253 |
+
if field_name in sanitized:
|
| 254 |
+
sanitized[field_name] = _drop_invalid_list_items(
|
| 255 |
+
sanitized[field_name], model_cls, field_name
|
| 256 |
+
)
|
| 257 |
+
|
| 258 |
+
if "identity" in sanitized:
|
| 259 |
+
try:
|
| 260 |
+
sanitized["identity"] = CompanyIdentity.model_validate(
|
| 261 |
+
sanitized["identity"]
|
| 262 |
+
)
|
| 263 |
+
except Exception as exc:
|
| 264 |
+
first_line = str(exc).splitlines()[0] if str(exc) else type(exc).__name__
|
| 265 |
+
print(
|
| 266 |
+
f"[brief-sanitizer] replacing invalid identity: {first_line}",
|
| 267 |
+
file=sys.stderr,
|
| 268 |
+
)
|
| 269 |
+
sanitized["identity"] = {}
|
| 270 |
+
return sanitized
|
| 271 |
+
|
| 272 |
|
| 273 |
class AnnualTrend(BaseModel):
|
| 274 |
model_config = ConfigDict(extra="ignore")
|
|
@@ -407,6 +407,27 @@ def _normalize_signal_type(v: object) -> object:
|
|
| 407 |
return "language_drift"
|
| 408 |
|
| 409 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 410 |
class SourcedFact(BaseModel):
|
| 411 |
model_config = ConfigDict(extra="ignore")
|
| 412 |
|
|
@@ -463,18 +484,35 @@ class MDASection(BaseModel):
|
|
| 463 |
model_config = ConfigDict(extra="ignore")
|
| 464 |
|
| 465 |
drivers: list[SourcedFact] = Field(
|
|
|
|
| 466 |
description="2-4 key revenue or margin drivers cited in the MD&A or transcript."
|
| 467 |
)
|
| 468 |
headwinds: list[SourcedFact] = Field(
|
|
|
|
| 469 |
description="1-3 headwinds or drags on performance cited in the MD&A or transcript."
|
| 470 |
)
|
| 471 |
language_shift: str = Field(
|
|
|
|
| 472 |
description="1-2 sentences on how management language evolved vs prior periods: more confident, more cautious, more defensive? If cross-period data is unavailable, state so explicitly."
|
| 473 |
)
|
| 474 |
-
key_quote: SourcedFact = Field(
|
|
|
|
| 475 |
description="The single most revealing management statement from the filing or transcript this period."
|
| 476 |
)
|
| 477 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 478 |
|
| 479 |
class CategorizedRisk(BaseModel):
|
| 480 |
model_config = ConfigDict(extra="ignore")
|
|
@@ -819,7 +857,8 @@ class BriefOutput(BaseModel):
|
|
| 819 |
description="2-5 earnings quality signals across distinct dimensions. Use evidence already retrieved."
|
| 820 |
)
|
| 821 |
|
| 822 |
-
standout_number: SourcedFact = Field(
|
|
|
|
| 823 |
description="The single most remarkable quantitative fact this quarter — the number a journalist would lead with."
|
| 824 |
)
|
| 825 |
|
|
@@ -843,7 +882,10 @@ class BriefOutput(BaseModel):
|
|
| 843 |
description="Deterministic verification coverage populated after synthesis.",
|
| 844 |
)
|
| 845 |
|
| 846 |
-
mda_summary: MDASection = Field(
|
|
|
|
|
|
|
|
|
|
| 847 |
risks_categorized: list[CategorizedRisk] = Field(description="3-6 categorized risks from the filing.")
|
| 848 |
management_commentary: list[ManagementCommentaryTopic] = Field(
|
| 849 |
description="3-5 key management themes drawn from MD&A (preferred) or earnings call transcript."
|
|
@@ -868,6 +910,64 @@ class BriefOutput(BaseModel):
|
|
| 868 |
description="Verbatim text deltas computed deterministically across consecutive filing periods. Populated by code, not LLM.",
|
| 869 |
)
|
| 870 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 871 |
@field_validator("earnings_quality_signals", mode="before")
|
| 872 |
@classmethod
|
| 873 |
def _sanitize_quality_signals(cls, v: object) -> object:
|
|
|
|
| 407 |
return "language_drift"
|
| 408 |
|
| 409 |
|
| 410 |
+
def _drop_invalid_list_items(
|
| 411 |
+
items: object,
|
| 412 |
+
model_cls: type[BaseModel],
|
| 413 |
+
field_name: str,
|
| 414 |
+
) -> object:
|
| 415 |
+
"""Validate LLM-produced list items independently and quarantine failures."""
|
| 416 |
+
if not isinstance(items, list):
|
| 417 |
+
return items
|
| 418 |
+
kept = []
|
| 419 |
+
for item in items:
|
| 420 |
+
try:
|
| 421 |
+
kept.append(model_cls.model_validate(item))
|
| 422 |
+
except Exception as exc:
|
| 423 |
+
first_line = str(exc).splitlines()[0] if str(exc) else type(exc).__name__
|
| 424 |
+
print(
|
| 425 |
+
f"[brief-sanitizer] dropping invalid {field_name} item: {first_line}",
|
| 426 |
+
file=sys.stderr,
|
| 427 |
+
)
|
| 428 |
+
return kept
|
| 429 |
+
|
| 430 |
+
|
| 431 |
class SourcedFact(BaseModel):
|
| 432 |
model_config = ConfigDict(extra="ignore")
|
| 433 |
|
|
|
|
| 484 |
model_config = ConfigDict(extra="ignore")
|
| 485 |
|
| 486 |
drivers: list[SourcedFact] = Field(
|
| 487 |
+
default_factory=list,
|
| 488 |
description="2-4 key revenue or margin drivers cited in the MD&A or transcript."
|
| 489 |
)
|
| 490 |
headwinds: list[SourcedFact] = Field(
|
| 491 |
+
default_factory=list,
|
| 492 |
description="1-3 headwinds or drags on performance cited in the MD&A or transcript."
|
| 493 |
)
|
| 494 |
language_shift: str = Field(
|
| 495 |
+
default="",
|
| 496 |
description="1-2 sentences on how management language evolved vs prior periods: more confident, more cautious, more defensive? If cross-period data is unavailable, state so explicitly."
|
| 497 |
)
|
| 498 |
+
key_quote: Optional[SourcedFact] = Field(
|
| 499 |
+
default=None,
|
| 500 |
description="The single most revealing management statement from the filing or transcript this period."
|
| 501 |
)
|
| 502 |
|
| 503 |
+
@model_validator(mode="before")
|
| 504 |
+
@classmethod
|
| 505 |
+
def _drop_invalid_fact_items(cls, data: object) -> object:
|
| 506 |
+
if not isinstance(data, dict):
|
| 507 |
+
return data
|
| 508 |
+
sanitized = dict(data)
|
| 509 |
+
for field_name in ("drivers", "headwinds"):
|
| 510 |
+
if field_name in sanitized:
|
| 511 |
+
sanitized[field_name] = _drop_invalid_list_items(
|
| 512 |
+
sanitized[field_name], SourcedFact, field_name
|
| 513 |
+
)
|
| 514 |
+
return sanitized
|
| 515 |
+
|
| 516 |
|
| 517 |
class CategorizedRisk(BaseModel):
|
| 518 |
model_config = ConfigDict(extra="ignore")
|
|
|
|
| 857 |
description="2-5 earnings quality signals across distinct dimensions. Use evidence already retrieved."
|
| 858 |
)
|
| 859 |
|
| 860 |
+
standout_number: Optional[SourcedFact] = Field(
|
| 861 |
+
default=None,
|
| 862 |
description="The single most remarkable quantitative fact this quarter — the number a journalist would lead with."
|
| 863 |
)
|
| 864 |
|
|
|
|
| 882 |
description="Deterministic verification coverage populated after synthesis.",
|
| 883 |
)
|
| 884 |
|
| 885 |
+
mda_summary: MDASection = Field(
|
| 886 |
+
default_factory=MDASection,
|
| 887 |
+
description="Structured MD&A analysis from the 10-K/10-Q.",
|
| 888 |
+
)
|
| 889 |
risks_categorized: list[CategorizedRisk] = Field(description="3-6 categorized risks from the filing.")
|
| 890 |
management_commentary: list[ManagementCommentaryTopic] = Field(
|
| 891 |
description="3-5 key management themes drawn from MD&A (preferred) or earnings call transcript."
|
|
|
|
| 910 |
description="Verbatim text deltas computed deterministically across consecutive filing periods. Populated by code, not LLM.",
|
| 911 |
)
|
| 912 |
|
| 913 |
+
@model_validator(mode="before")
|
| 914 |
+
@classmethod
|
| 915 |
+
def _sanitize_llm_fields(cls, data: object) -> object:
|
| 916 |
+
if not isinstance(data, dict):
|
| 917 |
+
return data
|
| 918 |
+
|
| 919 |
+
sanitized = dict(data)
|
| 920 |
+
list_fields: dict[str, type[BaseModel]] = {
|
| 921 |
+
"what_changed": SourcedFact,
|
| 922 |
+
"bull_points": SourcedFact,
|
| 923 |
+
"bear_points": SourcedFact,
|
| 924 |
+
"risks_categorized": CategorizedRisk,
|
| 925 |
+
"guidance_history": GuidancePoint,
|
| 926 |
+
"analytical_tensions": AnalyticalTension,
|
| 927 |
+
"between_the_lines": SubtextRead,
|
| 928 |
+
"trends": TrendPoint,
|
| 929 |
+
"quarter_deltas": QuarterDelta,
|
| 930 |
+
}
|
| 931 |
+
for field_name, model_cls in list_fields.items():
|
| 932 |
+
if field_name in sanitized:
|
| 933 |
+
sanitized[field_name] = _drop_invalid_list_items(
|
| 934 |
+
sanitized[field_name], model_cls, field_name
|
| 935 |
+
)
|
| 936 |
+
|
| 937 |
+
singleton_fields: tuple[tuple[str, type[BaseModel], object], ...] = (
|
| 938 |
+
("standout_number", SourcedFact, None),
|
| 939 |
+
("sentiment", SentimentScores, None),
|
| 940 |
+
("market_expectations", MarketExpectations, None),
|
| 941 |
+
("mda_summary", MDASection, {}),
|
| 942 |
+
)
|
| 943 |
+
for field_name, model_cls, fallback in singleton_fields:
|
| 944 |
+
if field_name not in sanitized:
|
| 945 |
+
continue
|
| 946 |
+
value = sanitized[field_name]
|
| 947 |
+
if value is None and fallback is None:
|
| 948 |
+
continue
|
| 949 |
+
try:
|
| 950 |
+
sanitized[field_name] = model_cls.model_validate(value)
|
| 951 |
+
except Exception as exc:
|
| 952 |
+
first_line = str(exc).splitlines()[0] if str(exc) else type(exc).__name__
|
| 953 |
+
print(
|
| 954 |
+
f"[brief-sanitizer] replacing invalid {field_name}: {first_line}",
|
| 955 |
+
file=sys.stderr,
|
| 956 |
+
)
|
| 957 |
+
sanitized[field_name] = fallback
|
| 958 |
+
return sanitized
|
| 959 |
+
|
| 960 |
+
@field_validator("status", mode="before")
|
| 961 |
+
@classmethod
|
| 962 |
+
def _normalize_status(cls, v: object) -> object:
|
| 963 |
+
if isinstance(v, str) and v in {"COMPLETE", "PARTIAL"}:
|
| 964 |
+
return v
|
| 965 |
+
print(
|
| 966 |
+
f"[brief-status] unknown status '{v}', falling back to 'PARTIAL'",
|
| 967 |
+
file=sys.stderr,
|
| 968 |
+
)
|
| 969 |
+
return "PARTIAL"
|
| 970 |
+
|
| 971 |
@field_validator("earnings_quality_signals", mode="before")
|
| 972 |
@classmethod
|
| 973 |
def _sanitize_quality_signals(cls, v: object) -> object:
|
|
@@ -130,6 +130,64 @@ def test_business_line_trend_normalizes_synonyms_and_unknowns():
|
|
| 130 |
]
|
| 131 |
|
| 132 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
def test_synthesis_prompt_enumerates_profile_literals():
|
| 134 |
assert (
|
| 135 |
"revenue, operations, supply_chain, regulation, geopolitical"
|
|
|
|
| 130 |
]
|
| 131 |
|
| 132 |
|
| 133 |
+
def test_profile_out_of_range_revenue_shares_become_none(capsys):
|
| 134 |
+
section = CompanyProfileSection.model_validate(
|
| 135 |
+
{
|
| 136 |
+
"business_lines": [
|
| 137 |
+
{
|
| 138 |
+
"name": "Services",
|
| 139 |
+
"description": _fact(),
|
| 140 |
+
"revenue_share_pct": 150,
|
| 141 |
+
}
|
| 142 |
+
],
|
| 143 |
+
"geographic_exposures": [
|
| 144 |
+
{
|
| 145 |
+
"name": "Americas",
|
| 146 |
+
"description": _fact(),
|
| 147 |
+
"revenue_share_pct": -1,
|
| 148 |
+
}
|
| 149 |
+
],
|
| 150 |
+
}
|
| 151 |
+
)
|
| 152 |
+
|
| 153 |
+
assert section.business_lines[0].revenue_share_pct is None
|
| 154 |
+
assert section.geographic_exposures[0].revenue_share_pct is None
|
| 155 |
+
assert "[revenue-share]" in capsys.readouterr().err
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def test_profile_unknown_attention_direction_falls_back(capsys):
|
| 159 |
+
section = CompanyProfileSection.model_validate(
|
| 160 |
+
{
|
| 161 |
+
"attention_themes": [
|
| 162 |
+
{
|
| 163 |
+
"theme": "AI",
|
| 164 |
+
"why_it_matters": "Material investment cycle.",
|
| 165 |
+
"evidence": _fact(),
|
| 166 |
+
"direction": "unknown_direction",
|
| 167 |
+
}
|
| 168 |
+
]
|
| 169 |
+
}
|
| 170 |
+
)
|
| 171 |
+
|
| 172 |
+
assert section.attention_themes[0].direction == "insufficient"
|
| 173 |
+
assert "[attention-direction]" in capsys.readouterr().err
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def test_profile_drops_only_invalid_business_line(capsys):
|
| 177 |
+
section = CompanyProfileSection.model_validate(
|
| 178 |
+
{
|
| 179 |
+
"business_lines": [
|
| 180 |
+
{"name": "Services", "description": _fact()},
|
| 181 |
+
{"name": "Malformed line without description"},
|
| 182 |
+
{"name": "Products", "description": _fact()},
|
| 183 |
+
]
|
| 184 |
+
}
|
| 185 |
+
)
|
| 186 |
+
|
| 187 |
+
assert [line.name for line in section.business_lines] == ["Services", "Products"]
|
| 188 |
+
assert "[brief-sanitizer] dropping invalid business_lines item:" in capsys.readouterr().err
|
| 189 |
+
|
| 190 |
+
|
| 191 |
def test_synthesis_prompt_enumerates_profile_literals():
|
| 192 |
assert (
|
| 193 |
"revenue, operations, supply_chain, regulation, geopolitical"
|
|
@@ -131,6 +131,81 @@ def test_brief_output_tolerates_truncated_content_hash_in_evidence_ref():
|
|
| 131 |
assert brief.risks_categorized[0].evidence_ref.content_hash == truncated_hash
|
| 132 |
|
| 133 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
def test_brief_output_accepts_metrics_sources_for_risk_and_guidance():
|
| 135 |
brief = _minimal_brief(
|
| 136 |
risks_categorized=[dict(
|
|
|
|
| 131 |
assert brief.risks_categorized[0].evidence_ref.content_hash == truncated_hash
|
| 132 |
|
| 133 |
|
| 134 |
+
def test_brief_output_drops_only_invalid_bull_point(capsys):
|
| 135 |
+
payload = _minimal_brief().model_dump(mode="json")
|
| 136 |
+
payload["bull_points"] = [
|
| 137 |
+
{
|
| 138 |
+
"text": "Services revenue grew.",
|
| 139 |
+
"source": "10-Q",
|
| 140 |
+
"reliability": "HIGH",
|
| 141 |
+
"evidence_snippet": "Services revenue grew.",
|
| 142 |
+
},
|
| 143 |
+
{
|
| 144 |
+
"text": "Unusable composite source.",
|
| 145 |
+
"source": "bloomberg",
|
| 146 |
+
"reliability": "LOW",
|
| 147 |
+
"evidence_snippet": "Unusable composite source.",
|
| 148 |
+
},
|
| 149 |
+
{
|
| 150 |
+
"text": "Margins expanded.",
|
| 151 |
+
"source": "transcript",
|
| 152 |
+
"reliability": "MEDIUM",
|
| 153 |
+
"evidence_snippet": "Margins expanded.",
|
| 154 |
+
},
|
| 155 |
+
]
|
| 156 |
+
|
| 157 |
+
brief = BriefOutput.model_validate(payload)
|
| 158 |
+
|
| 159 |
+
assert [item.text for item in brief.bull_points] == [
|
| 160 |
+
"Services revenue grew.",
|
| 161 |
+
"Margins expanded.",
|
| 162 |
+
]
|
| 163 |
+
assert "[brief-sanitizer] dropping invalid bull_points item:" in capsys.readouterr().err
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
def test_brief_output_invalid_standout_number_becomes_none():
|
| 167 |
+
payload = _minimal_brief().model_dump(mode="json")
|
| 168 |
+
payload["standout_number"] = {
|
| 169 |
+
"source": "10-Q",
|
| 170 |
+
"reliability": "HIGH",
|
| 171 |
+
"evidence_snippet": "Revenue grew.",
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
brief = BriefOutput.model_validate(payload)
|
| 175 |
+
|
| 176 |
+
assert brief.standout_number is None
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
def test_brief_output_evidence_ref_ignores_extra_content_key():
|
| 180 |
+
payload = _minimal_brief().model_dump(mode="json")
|
| 181 |
+
payload["bull_points"][0]["evidence_ref"] = {
|
| 182 |
+
"evidence_id": "ev_abc123",
|
| 183 |
+
"source": "10-Q",
|
| 184 |
+
"content_hash": "short-hash-is-tolerated",
|
| 185 |
+
"document_id": "sec:AAPL:0001",
|
| 186 |
+
"content": "This belongs to an evidence record, not an evidence ref.",
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
brief = BriefOutput.model_validate(payload)
|
| 190 |
+
|
| 191 |
+
assert brief.bull_points[0].evidence_ref.evidence_id == "ev_abc123"
|
| 192 |
+
assert not hasattr(brief.bull_points[0].evidence_ref, "content")
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
def test_brief_output_defaults_llm_singletons_and_status(capsys):
|
| 196 |
+
payload = _minimal_brief().model_dump(mode="json")
|
| 197 |
+
payload.pop("standout_number")
|
| 198 |
+
payload.pop("mda_summary")
|
| 199 |
+
payload["status"] = {"unexpected": "shape"}
|
| 200 |
+
|
| 201 |
+
brief = BriefOutput.model_validate(payload)
|
| 202 |
+
|
| 203 |
+
assert brief.standout_number is None
|
| 204 |
+
assert brief.mda_summary == MDASection()
|
| 205 |
+
assert brief.status == "PARTIAL"
|
| 206 |
+
assert "[brief-status]" in capsys.readouterr().err
|
| 207 |
+
|
| 208 |
+
|
| 209 |
def test_brief_output_accepts_metrics_sources_for_risk_and_guidance():
|
| 210 |
brief = _minimal_brief(
|
| 211 |
risks_categorized=[dict(
|