Viney Claude Opus 5 commited on
Commit
b6b4740
·
1 Parent(s): 8e72414

fix: per-item drop of invalid brief facts; relax LLM-produced singleton fields

Browse files

Generalizes 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 CHANGED
@@ -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")
agent/schemas.py CHANGED
@@ -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(description="Structured MD&A analysis from the 10-K/10-Q.")
 
 
 
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:
tests/test_company_profile.py CHANGED
@@ -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"
tests/test_schemas.py CHANGED
@@ -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(