fix: tolerate malformed LLM-copied EvidenceRef fields
Browse filesEvidenceRef enforced its own format constraints (64-hex pattern on
content_hash, min_length on evidence_id/document_id, a closed source
Literal, extra="forbid") on fields the LLM copies verbatim from a
retrieved evidence.v1 record. A single mis-copied field (e.g. a
truncated SHA-256 hash) crashed BriefOutput.model_validate entirely,
wiping the whole brief -- even though agent/evidence.py::verify_fact
already re-checks every one of these fields deterministically against
the real record and fails just that one fact cleanly (FAILED /
content_hash_mismatch, reliability=LOW) without touching the rest.
Confirmed on a live AAPL run: the model cited the same real evidence
record twice with a hash truncated to 47 of 64 hex characters, which
crashed synthesis outright under the old constraints.
EvidenceRef fields are now plain strings (source normalized via the
existing _normalize_source instead of a closed Literal), extra
fields are ignored rather than rejected, and verify_fact remains the
single source of truth for whether a citation is trustworthy.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- agent/schemas.py +11 -6
- tests/test_evidence.py +35 -6
- tests/test_schemas.py +32 -0
|
@@ -8,18 +8,23 @@ VerificationStatus = Literal["VERIFIED", "UNVERIFIED", "FAILED"]
|
|
| 8 |
|
| 9 |
|
| 10 |
class EvidenceRef(BaseModel):
|
| 11 |
-
"""
|
| 12 |
|
| 13 |
-
model_config = ConfigDict(extra="
|
| 14 |
|
| 15 |
-
evidence_id: str =
|
| 16 |
-
source:
|
| 17 |
-
content_hash: str =
|
| 18 |
-
document_id: str =
|
| 19 |
chunk_id: Optional[str] = None
|
| 20 |
source_url: Optional[str] = None
|
| 21 |
as_of: Optional[str] = None
|
| 22 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
|
| 24 |
class EvidenceRecord(BaseModel):
|
| 25 |
"""Retrieved content plus its content-addressed reference."""
|
|
|
|
| 8 |
|
| 9 |
|
| 10 |
class EvidenceRef(BaseModel):
|
| 11 |
+
"""LLM-copied locator whose canonical form is enforced only by ``verify_fact``."""
|
| 12 |
|
| 13 |
+
model_config = ConfigDict(extra="ignore")
|
| 14 |
|
| 15 |
+
evidence_id: str = ""
|
| 16 |
+
source: str = ""
|
| 17 |
+
content_hash: str = ""
|
| 18 |
+
document_id: str = ""
|
| 19 |
chunk_id: Optional[str] = None
|
| 20 |
source_url: Optional[str] = None
|
| 21 |
as_of: Optional[str] = None
|
| 22 |
|
| 23 |
+
@field_validator("source", mode="before")
|
| 24 |
+
@classmethod
|
| 25 |
+
def _coerce_source(cls, v: object) -> object:
|
| 26 |
+
return _normalize_source(v)
|
| 27 |
+
|
| 28 |
|
| 29 |
class EvidenceRecord(BaseModel):
|
| 30 |
"""Retrieved content plus its content-addressed reference."""
|
|
@@ -1,8 +1,5 @@
|
|
| 1 |
import json
|
| 2 |
|
| 3 |
-
import pytest
|
| 4 |
-
from pydantic import ValidationError
|
| 5 |
-
|
| 6 |
from agent.evidence import (
|
| 7 |
NO_VERIFIED_SYNTHESIS_MESSAGE,
|
| 8 |
content_hash,
|
|
@@ -67,12 +64,25 @@ def test_ids_and_hashes_are_stable_across_whitespace_and_metadata_order():
|
|
| 67 |
assert first.ref.content_hash == content_hash(second.content)
|
| 68 |
|
| 69 |
|
| 70 |
-
def
|
| 71 |
record = _filing_record()
|
| 72 |
raw = record.ref.model_dump()
|
| 73 |
raw["source"] = "10-Q, transcript"
|
| 74 |
-
|
| 75 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
|
| 77 |
|
| 78 |
def test_sourced_fact_supports_structured_sources_and_defaults_unverified():
|
|
@@ -118,6 +128,25 @@ def test_verified_fact_requires_matching_id_source_hash_snippet_and_numbers():
|
|
| 118 |
assert fact["verification_status"] == "VERIFIED"
|
| 119 |
|
| 120 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
def test_unsupported_number_fails_closed_to_low():
|
| 122 |
record = _filing_record()
|
| 123 |
fact = {
|
|
|
|
| 1 |
import json
|
| 2 |
|
|
|
|
|
|
|
|
|
|
| 3 |
from agent.evidence import (
|
| 4 |
NO_VERIFIED_SYNTHESIS_MESSAGE,
|
| 5 |
content_hash,
|
|
|
|
| 64 |
assert first.ref.content_hash == content_hash(second.content)
|
| 65 |
|
| 66 |
|
| 67 |
+
def test_noncanonical_ref_source_fails_verification_not_validation():
|
| 68 |
record = _filing_record()
|
| 69 |
raw = record.ref.model_dump()
|
| 70 |
raw["source"] = "10-Q, transcript"
|
| 71 |
+
supplied = EvidenceRef.model_validate(raw)
|
| 72 |
+
fact = {
|
| 73 |
+
"text": "Revenue grew 5%.",
|
| 74 |
+
"source": "10-Q",
|
| 75 |
+
"reliability": "HIGH",
|
| 76 |
+
"evidence_snippet": "Revenue grew 5% year over year to $12.0 billion.",
|
| 77 |
+
"evidence_ref": supplied.model_dump(mode="json"),
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
verify_fact(fact, [record])
|
| 81 |
+
|
| 82 |
+
assert supplied.source == "10-Q, transcript"
|
| 83 |
+
assert fact["verification_status"] == "FAILED"
|
| 84 |
+
assert fact["verification_reason"] == "source_mismatch"
|
| 85 |
+
assert fact["reliability"] == "LOW"
|
| 86 |
|
| 87 |
|
| 88 |
def test_sourced_fact_supports_structured_sources_and_defaults_unverified():
|
|
|
|
| 128 |
assert fact["verification_status"] == "VERIFIED"
|
| 129 |
|
| 130 |
|
| 131 |
+
def test_truncated_content_hash_fails_verification_per_fact():
|
| 132 |
+
record = _filing_record()
|
| 133 |
+
truncated_ref = record.ref.model_dump(mode="json")
|
| 134 |
+
truncated_ref["content_hash"] = truncated_ref["content_hash"][:47]
|
| 135 |
+
fact = {
|
| 136 |
+
"text": "Revenue increased 5% to $12 billion.",
|
| 137 |
+
"source": "10-Q",
|
| 138 |
+
"reliability": "HIGH",
|
| 139 |
+
"evidence_snippet": "Revenue grew 5% year over year to $12.0 billion.",
|
| 140 |
+
"evidence_ref": truncated_ref,
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
verify_fact(fact, [record])
|
| 144 |
+
|
| 145 |
+
assert fact["verification_status"] == "FAILED"
|
| 146 |
+
assert fact["verification_reason"] == "content_hash_mismatch"
|
| 147 |
+
assert fact["reliability"] == "LOW"
|
| 148 |
+
|
| 149 |
+
|
| 150 |
def test_unsupported_number_fails_closed_to_low():
|
| 151 |
record = _filing_record()
|
| 152 |
fact = {
|
|
@@ -99,6 +99,38 @@ def test_brief_output_valid():
|
|
| 99 |
assert brief.what_to_watch == ["Q2 iPhone shipments", "AI feature adoption"]
|
| 100 |
|
| 101 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
def test_brief_output_accepts_metrics_sources_for_risk_and_guidance():
|
| 103 |
brief = _minimal_brief(
|
| 104 |
risks_categorized=[dict(
|
|
|
|
| 99 |
assert brief.what_to_watch == ["Q2 iPhone shipments", "AI feature adoption"]
|
| 100 |
|
| 101 |
|
| 102 |
+
def test_brief_output_tolerates_truncated_content_hash_in_evidence_ref():
|
| 103 |
+
truncated_hash = "496fb91760e2df7fe4e59bb606127879006da861385c98"
|
| 104 |
+
evidence_ref = {
|
| 105 |
+
"evidence_id": "ev_abc123",
|
| 106 |
+
"source": "10-Q",
|
| 107 |
+
"content_hash": truncated_hash,
|
| 108 |
+
"document_id": "sec:AAPL:0001",
|
| 109 |
+
}
|
| 110 |
+
payload = _minimal_brief().model_dump(mode="json")
|
| 111 |
+
payload["bear_points"] = [{
|
| 112 |
+
"text": "China headwinds persist.",
|
| 113 |
+
"source": "10-Q",
|
| 114 |
+
"reliability": "HIGH",
|
| 115 |
+
"evidence_snippet": "China headwinds persist.",
|
| 116 |
+
"evidence_ref": evidence_ref,
|
| 117 |
+
}]
|
| 118 |
+
payload["risks_categorized"] = [{
|
| 119 |
+
"category": "Demand",
|
| 120 |
+
"text": "China demand may weaken.",
|
| 121 |
+
"source": "10-Q",
|
| 122 |
+
"reliability": "HIGH",
|
| 123 |
+
"is_new_this_filing": False,
|
| 124 |
+
"evidence_snippet": "China demand may weaken.",
|
| 125 |
+
"evidence_ref": evidence_ref,
|
| 126 |
+
}]
|
| 127 |
+
|
| 128 |
+
brief = BriefOutput.model_validate(payload)
|
| 129 |
+
|
| 130 |
+
assert brief.bear_points[0].evidence_ref.content_hash == truncated_hash
|
| 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(
|