Viney Claude Sonnet 5 commited on
Commit
8e72414
·
1 Parent(s): 6ddda49

fix: tolerate malformed LLM-copied EvidenceRef fields

Browse files

EvidenceRef 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>

Files changed (3) hide show
  1. agent/schemas.py +11 -6
  2. tests/test_evidence.py +35 -6
  3. tests/test_schemas.py +32 -0
agent/schemas.py CHANGED
@@ -8,18 +8,23 @@ VerificationStatus = Literal["VERIFIED", "UNVERIFIED", "FAILED"]
8
 
9
 
10
  class EvidenceRef(BaseModel):
11
- """Immutable locator copied from a retrieved ``evidence.v1`` record."""
12
 
13
- model_config = ConfigDict(extra="forbid")
14
 
15
- evidence_id: str = Field(min_length=4)
16
- source: EvidenceSource
17
- content_hash: str = Field(pattern=r"^[0-9a-f]{64}$")
18
- document_id: str = Field(min_length=1)
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."""
tests/test_evidence.py CHANGED
@@ -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 test_evidence_ref_rejects_noncanonical_source():
71
  record = _filing_record()
72
  raw = record.ref.model_dump()
73
  raw["source"] = "10-Q, transcript"
74
- with pytest.raises(ValidationError):
75
- EvidenceRef.model_validate(raw)
 
 
 
 
 
 
 
 
 
 
 
 
 
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 = {
tests/test_schemas.py CHANGED
@@ -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(