fix: coerce compound LLM source strings to prevent BriefOutput validation failures
Browse filesThe 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>
- agent/prompts.py +5 -5
- agent/schemas.py +52 -2
- 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-
|
| 190 |
-
"bearish_evidence": { "text": "...", "source": "10-
|
| 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-
|
| 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
|
| 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
|
| 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"
|
| 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"
|