Viney Claude Opus 4.8 commited on
Commit
c6e8aec
Β·
1 Parent(s): 947b6dd

fix: coerce compound LLM source strings to prevent BriefOutput validation failures

Browse files

The synthesis model was echoing compound source placeholders like '10-Q, transcript'
from the prompt template, causing Pydantic to reject the entire brief with up to 14
validation errors. Adds a _normalize_source() before-validator on all four source-
bearing models (SourcedFact, CategorizedRisk, ManagementCommentaryTopic, GuidancePoint)
that resolves compound strings to a single canonical value using filing > transcript >
news precedence. Also tightens ManagementCommentaryTopic.source to exclude 'news'
(matching prompt docs) and removes the five compound placeholders from the prompt
template that caused the model to emit the bad values in the first place.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Files changed (3) hide show
  1. agent/prompts.py +5 -5
  2. agent/schemas.py +52 -2
  3. tests/test_schemas.py +102 -0
agent/prompts.py CHANGED
@@ -186,8 +186,8 @@ Required JSON structure:
186
  "bullish_reading": "What the optimistic surface reading says.",
187
  "bearish_reading": "What cross-referencing the data reveals as a concern or caveat.",
188
  "weight": "material or watch or minor",
189
- "bullish_evidence": { "text": "...", "source": "10-K, 10-Q, transcript, or news", "reliability": "HIGH or MEDIUM or LOW", "impact": "HIGH or MEDIUM or LOW", "evidence_snippet": "verbatim quote <=30 words" },
190
- "bearish_evidence": { "text": "...", "source": "10-K, 10-Q, transcript, or news", "reliability": "HIGH or MEDIUM or LOW", "impact": "HIGH or MEDIUM or LOW", "evidence_snippet": "verbatim quote <=30 words" }
191
  }
192
  ],
193
 
@@ -196,7 +196,7 @@ Required JSON structure:
196
  "dimension": "consensus_beat_mix or guidance_dynamics or narrative_vs_numbers or segment_mix or capital_allocation",
197
  "assessment": "positive or neutral or concerning",
198
  "rationale": "One sentence grounded in retrieved evidence.",
199
- "evidence": { "text": "...", "source": "10-K, 10-Q, transcript, or news", "reliability": "HIGH or MEDIUM or LOW", "impact": "HIGH or MEDIUM or LOW", "evidence_snippet": "verbatim quote <=30 words" }
200
  }
201
  ],
202
 
@@ -254,7 +254,7 @@ Required JSON structure:
254
  "language_shift": "1-2 sentences: how has management language changed vs prior periods? More confident, more cautious, more defensive? Reference specific wording changes if available.",
255
  "key_quote": {
256
  "text": "The single most revealing management statement this period.",
257
- "source": "10-Q, 10-K, or transcript",
258
  "reliability": "HIGH or MEDIUM",
259
  "impact": "HIGH or MEDIUM or LOW",
260
  "evidence_snippet": "The verbatim quote <=30 words"
@@ -287,7 +287,7 @@ Required JSON structure:
287
  {
288
  "period": "Q2 2025",
289
  "text": "The guidance statement, 1-2 sentences.",
290
- "source": "10-Q, 10-K, or transcript",
291
  "reliability": "HIGH or MEDIUM",
292
  "impact": "HIGH or MEDIUM or LOW",
293
  "metric_focus": "Revenue or EPS or Operating margin or Capex or null",
 
186
  "bullish_reading": "What the optimistic surface reading says.",
187
  "bearish_reading": "What cross-referencing the data reveals as a concern or caveat.",
188
  "weight": "material or watch or minor",
189
+ "bullish_evidence": { "text": "...", "source": "10-Q", "reliability": "HIGH or MEDIUM or LOW", "impact": "HIGH or MEDIUM or LOW", "evidence_snippet": "verbatim quote <=30 words" },
190
+ "bearish_evidence": { "text": "...", "source": "10-Q", "reliability": "HIGH or MEDIUM or LOW", "impact": "HIGH or MEDIUM or LOW", "evidence_snippet": "verbatim quote <=30 words" }
191
  }
192
  ],
193
 
 
196
  "dimension": "consensus_beat_mix or guidance_dynamics or narrative_vs_numbers or segment_mix or capital_allocation",
197
  "assessment": "positive or neutral or concerning",
198
  "rationale": "One sentence grounded in retrieved evidence.",
199
+ "evidence": { "text": "...", "source": "10-Q", "reliability": "HIGH or MEDIUM or LOW", "impact": "HIGH or MEDIUM or LOW", "evidence_snippet": "verbatim quote <=30 words" }
200
  }
201
  ],
202
 
 
254
  "language_shift": "1-2 sentences: how has management language changed vs prior periods? More confident, more cautious, more defensive? Reference specific wording changes if available.",
255
  "key_quote": {
256
  "text": "The single most revealing management statement this period.",
257
+ "source": "10-Q",
258
  "reliability": "HIGH or MEDIUM",
259
  "impact": "HIGH or MEDIUM or LOW",
260
  "evidence_snippet": "The verbatim quote <=30 words"
 
287
  {
288
  "period": "Q2 2025",
289
  "text": "The guidance statement, 1-2 sentences.",
290
+ "source": "10-Q",
291
  "reliability": "HIGH or MEDIUM",
292
  "impact": "HIGH or MEDIUM or LOW",
293
  "metric_focus": "Revenue or EPS or Operating margin or Capex or null",
agent/schemas.py CHANGED
@@ -7,6 +7,35 @@ _CANONICAL_CATEGORIES = {
7
  "Regulatory", "Operational", "Competitive", "Financial", "Macro", "Demand", "Geopolitical"
8
  }
9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  _CATEGORY_ALIASES: dict[str, str] = {
11
  "Legal": "Regulatory",
12
  "Compliance": "Regulatory",
@@ -54,6 +83,11 @@ class SourcedFact(BaseModel):
54
  description="A literal quote (≀30 words) from the cited source that directly supports the claim. Must appear verbatim in retrieved tool output."
55
  )
56
 
 
 
 
 
 
57
  @field_validator('evidence_snippet')
58
  @classmethod
59
  def validate_snippet_length(cls, v: str) -> str:
@@ -111,6 +145,11 @@ class CategorizedRisk(BaseModel):
111
  description="True if this risk appears new or materially escalated vs prior filing."
112
  )
113
 
 
 
 
 
 
114
  @field_validator("category", mode="before")
115
  @classmethod
116
  def _normalize_category(cls, v: object) -> str:
@@ -128,8 +167,8 @@ class ManagementCommentaryTopic(BaseModel):
128
 
129
  topic: str = Field(description="Topic label, 2-5 words (e.g. 'iPhone demand', 'AI capex', 'margin guidance').")
130
  summary: str = Field(description="1-2 sentence summary of what management said about this topic.")
131
- source: Literal["10-K", "10-Q", "transcript", "news"] = Field(
132
- description="Source document for this commentary item."
133
  )
134
  reliability: Literal["HIGH", "MEDIUM", "LOW"] = Field(
135
  description="HIGH for SEC filings, MEDIUM for transcripts."
@@ -140,6 +179,11 @@ class ManagementCommentaryTopic(BaseModel):
140
  )
141
  evidence_snippet: str = Field(description="Verbatim quote ≀30 words supporting this topic.")
142
 
 
 
 
 
 
143
  @field_validator('evidence_snippet')
144
  @classmethod
145
  def _trim(cls, v: str) -> str:
@@ -155,6 +199,12 @@ class GuidancePoint(BaseModel):
155
  source: Literal["10-K", "10-Q", "transcript", "news"] = Field(
156
  description="Document type where this guidance appeared."
157
  )
 
 
 
 
 
 
158
  reliability: Optional[Literal["HIGH", "MEDIUM", "LOW"]] = Field(
159
  default=None,
160
  description="HIGH for SEC filings, MEDIUM for transcripts."
 
7
  "Regulatory", "Operational", "Competitive", "Financial", "Macro", "Demand", "Geopolitical"
8
  }
9
 
10
+ def _normalize_source(v: object) -> object:
11
+ """Coerce a compound/dirty LLM source string to a single canonical value.
12
+
13
+ The LLM occasionally emits combined sources like '10-Q, transcript' (often echoing
14
+ the prompt's placeholder). Resolve to one value using the documented precedence
15
+ 'filing beats transcript beats news' (see prompts.py: 'always set source to the
16
+ filing'). Between two filings, keep whichever appears first in the string.
17
+ Unknown strings pass through unchanged so the Literal still rejects them.
18
+ """
19
+ if not isinstance(v, str):
20
+ return v
21
+ s = v.strip()
22
+ if s in ("10-K", "10-Q", "transcript", "news"):
23
+ return s
24
+ low = s.lower()
25
+ if "10-k" in low or "10-q" in low:
26
+ k, q = low.find("10-k"), low.find("10-q")
27
+ if k == -1:
28
+ return "10-Q"
29
+ if q == -1:
30
+ return "10-K"
31
+ return "10-K" if k < q else "10-Q"
32
+ if "transcript" in low:
33
+ return "transcript"
34
+ if "news" in low:
35
+ return "news"
36
+ return v # let Literal validation reject genuinely-invalid sources
37
+
38
+
39
  _CATEGORY_ALIASES: dict[str, str] = {
40
  "Legal": "Regulatory",
41
  "Compliance": "Regulatory",
 
83
  description="A literal quote (≀30 words) from the cited source that directly supports the claim. Must appear verbatim in retrieved tool output."
84
  )
85
 
86
+ @field_validator("source", mode="before")
87
+ @classmethod
88
+ def _coerce_source(cls, v: object) -> object:
89
+ return _normalize_source(v)
90
+
91
  @field_validator('evidence_snippet')
92
  @classmethod
93
  def validate_snippet_length(cls, v: str) -> str:
 
145
  description="True if this risk appears new or materially escalated vs prior filing."
146
  )
147
 
148
+ @field_validator("source", mode="before")
149
+ @classmethod
150
+ def _coerce_source(cls, v: object) -> object:
151
+ return _normalize_source(v)
152
+
153
  @field_validator("category", mode="before")
154
  @classmethod
155
  def _normalize_category(cls, v: object) -> str:
 
167
 
168
  topic: str = Field(description="Topic label, 2-5 words (e.g. 'iPhone demand', 'AI capex', 'margin guidance').")
169
  summary: str = Field(description="1-2 sentence summary of what management said about this topic.")
170
+ source: Literal["10-K", "10-Q", "transcript"] = Field(
171
+ description="Source document for this commentary item. Management commentary must come from filings or transcripts, not news."
172
  )
173
  reliability: Literal["HIGH", "MEDIUM", "LOW"] = Field(
174
  description="HIGH for SEC filings, MEDIUM for transcripts."
 
179
  )
180
  evidence_snippet: str = Field(description="Verbatim quote ≀30 words supporting this topic.")
181
 
182
+ @field_validator("source", mode="before")
183
+ @classmethod
184
+ def _coerce_source(cls, v: object) -> object:
185
+ return _normalize_source(v)
186
+
187
  @field_validator('evidence_snippet')
188
  @classmethod
189
  def _trim(cls, v: str) -> str:
 
199
  source: Literal["10-K", "10-Q", "transcript", "news"] = Field(
200
  description="Document type where this guidance appeared."
201
  )
202
+
203
+ @field_validator("source", mode="before")
204
+ @classmethod
205
+ def _coerce_source(cls, v: object) -> object:
206
+ return _normalize_source(v)
207
+
208
  reliability: Optional[Literal["HIGH", "MEDIUM", "LOW"]] = Field(
209
  default=None,
210
  description="HIGH for SEC filings, MEDIUM for transcripts."
tests/test_schemas.py CHANGED
@@ -208,3 +208,105 @@ def test_brief_output_validates_with_geopolitical_risk():
208
  )]
209
  )
210
  assert brief.risks_categorized[0].category == "Geopolitical"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
208
  )]
209
  )
210
  assert brief.risks_categorized[0].category == "Geopolitical"
211
+
212
+
213
+ # ── _normalize_source β€” SourcedFact ─────────────────────────────────────────
214
+
215
+ def _sf(source: str) -> SourcedFact:
216
+ return SourcedFact(text="x", source=source, reliability="HIGH", evidence_snippet="x")
217
+
218
+
219
+ def test_sourced_fact_coerces_compound_10q_transcript():
220
+ assert _sf("10-Q, transcript").source == "10-Q"
221
+
222
+
223
+ def test_sourced_fact_coerces_compound_transcript_10q():
224
+ # filing takes precedence regardless of order
225
+ assert _sf("transcript, 10-Q").source == "10-Q"
226
+
227
+
228
+ def test_sourced_fact_coerces_compound_10k_transcript():
229
+ assert _sf("10-K, transcript").source == "10-K"
230
+
231
+
232
+ def test_sourced_fact_coerces_compound_10q_news():
233
+ assert _sf("10-Q, news").source == "10-Q"
234
+
235
+
236
+ def test_sourced_fact_coerces_compound_10q_10k_first_wins():
237
+ # '10-Q' appears before '10-K' in this string β†’ 10-Q
238
+ assert _sf("10-Q, 10-K").source == "10-Q"
239
+
240
+
241
+ def test_sourced_fact_coerces_compound_10k_10q_first_wins():
242
+ # '10-K' appears before '10-Q' in this string β†’ 10-K
243
+ assert _sf("10-K, 10-Q").source == "10-K"
244
+
245
+
246
+ def test_sourced_fact_coerces_compound_transcript_news():
247
+ assert _sf("transcript, news").source == "transcript"
248
+
249
+
250
+ def test_sourced_fact_coerces_lowercase_10q():
251
+ # case-folding: "10-q" β†’ "10-Q"
252
+ assert _sf("10-q").source == "10-Q"
253
+
254
+
255
+ def test_sourced_fact_regression_invalid_source_still_rejected():
256
+ # _normalize_source passes unknown strings through β†’ Literal still rejects them
257
+ with pytest.raises(ValidationError):
258
+ _sf("bloomberg")
259
+
260
+
261
+ # ── _normalize_source β€” other models ────────────────────────────────────────
262
+
263
+ def test_categorized_risk_coerces_compound_source():
264
+ r = CategorizedRisk(
265
+ category="Macro", text="Macro risk.", source="10-Q, 10-K",
266
+ reliability="HIGH", is_new_this_filing=False,
267
+ )
268
+ assert r.source == "10-Q"
269
+
270
+
271
+ def test_management_commentary_coerces_compound_source():
272
+ t = ManagementCommentaryTopic(
273
+ topic="AI capex", summary="Capex rising.", source="transcript, 10-Q",
274
+ reliability="HIGH", evidence_snippet="Capex rising.",
275
+ )
276
+ assert t.source == "10-Q"
277
+
278
+
279
+ def test_guidance_point_coerces_compound_source():
280
+ g = GuidancePoint(period="Q3 2025", text="Revenue guided flat.", source="10-K, transcript")
281
+ assert g.source == "10-K"
282
+
283
+
284
+ # ── BriefOutput end-to-end: reproduces the original NVDA failure ─────────────
285
+
286
+ def test_brief_output_tolerates_compound_source_in_analytical_tension():
287
+ """Reproduce the NVDA 14-error failure: compound source inside a nested SourcedFact."""
288
+ from agent.schemas import AnalyticalTension
289
+ tension = AnalyticalTension(
290
+ headline="Data center growth strong but sequential deceleration notable.",
291
+ bullish_reading="Revenue beat consensus by 4%.",
292
+ bearish_reading="Sequential growth slowing despite beat.",
293
+ weight="watch",
294
+ bullish_evidence=SourcedFact(
295
+ text="Revenue grew 78% YoY.",
296
+ source="10-Q, transcript", # ← the form that caused the original failure
297
+ reliability="HIGH",
298
+ evidence_snippet="Revenue grew 78% YoY driven by data center.",
299
+ ),
300
+ bearish_evidence=SourcedFact(
301
+ text="Sequential growth decelerated.",
302
+ source="10-Q, news",
303
+ reliability="HIGH",
304
+ evidence_snippet="Sequential revenue growth slowed to 8%.",
305
+ ),
306
+ )
307
+ assert tension.bullish_evidence.source == "10-Q"
308
+ assert tension.bearish_evidence.source == "10-Q"
309
+
310
+ brief = _minimal_brief(analytical_tensions=[tension])
311
+ assert brief.analytical_tensions[0].bullish_evidence.source == "10-Q"
312
+ assert brief.analytical_tensions[0].bearish_evidence.source == "10-Q"