Brings the JSON variant back as a third ANSWER_MODE, distinct from the
pointer variant. The substrate exposes both: pointer for prose-
distribution / small-model paths (Hermes-3 8B), JSON for grammar-
constrained / large-reasoning-model paths (vLLM guided_json,
Claude/GPT-4 native JSON, Qwen 3.6 reasoner). Agents pick by setting
`policy["answer_mode"]`; both fold into governance_policy_hash so
records under different modes never alias.
Components:
- aborist/qa/verify.py:
* ANSWER_MODES = ("quote", "claim_lattice_pointer", "claim_lattice")
* `_lenient_json_parse(raw)` — defensive pre-parser. Strips markdown
fences, trims preamble/suffix to {/}, normalizes curly quotes,
fixes trailing commas. Returns (parsed, fixups[]) so the verifier
logs which drift had to be peeled. Lenient on syntax, strict on
semantics: parsed JSON still has to schema-check.
* CLAIM_LATTICE_JSON_SCHEMA — JSON Schema for the {"claims":[...]}
shape. Used by vLLM guided_json sampling-time constraint.
* `verify_claim_lattice_json(answer_json_text, evidence_map, ...)` —
runs the same hard checks as verify_claim_lattice (evidence_id
resolves, source_role allowed, no manual quotes, claim text non-
empty, claim textually overlaps evidence) but on content-
addressed evidence_ids directly. Returns the same verdict shape
plus a `json_fixups` list.
- aborist/qa/client.py: ChatClient Protocol & OpenAICompatibleClient
gain optional `extra_body` kwarg. Forwarded as additional fields in
the JSON request payload — opaque pass-through for vLLM-specific
knobs like `guided_json`. Endpoints that don't recognize a key
silently drop it. StubClient ignores; tests inspect via self.calls.
- aborist/qa/evidence.py: `render_evidence_block_for_json` and
`render_evidence_map_for_json` — JSON-mode prompts label blocks with
the content-addressed evidence_id (long hex) since that's what the
model cites in its JSON. Pointer mode keeps using the short
pointer_id.
- aborist/qa/query.py:
* Imports the JSON verifier + schema + JSON-mode evidence renderer.
* Message-build branch: `elif answer_mode == "claim_lattice"`
builds the same per-chunk evidence map as pointer mode, but
uses `claim_lattice_json_system_prompt` and labels blocks with
evidence_id.
* LLM call: when answer_mode=claim_lattice and policy
`claim_lattice_use_guided_json` is on, passes
`extra_body={"guided_json": SCHEMA}` so vLLM constrains output.
* Verifier dispatch: new `elif answer_mode == "claim_lattice"`
branch calls verify_claim_lattice_json.
* DAG persistence: lattice-mode raw_answer / parsed_lattice /
rendered_text threading now applies to both pointer and JSON.
* DEFAULT_QUERY_POLICY adds `claim_lattice_json_system_prompt`,
`claim_lattice_json_grounding_reminder`, and
`claim_lattice_use_guided_json` (default True).
- tests/test_verify_json.py: 14 tests covering the lenient parser
(strict pass-through, fence strip, preamble trim, curly-quote
normalize, trailing-comma fix, multi-fixup, hard-fail) and the
JSON verifier (STRICT on resolved claims, HYBRID on partial,
UNGROUNDED on schema invalid, fence recovery, manual-quote
violation, source-role block).
The whitepaper rewrite to 13.9.1 (substrate exposes both modalities,
both first-class) becomes accurate post-ship — JSON mode now exists
in code as it always existed in the architecture's intent.
554 tests pass.
186 lines
6.1 KiB
Python
186 lines
6.1 KiB
Python
"""JSON-mode claim-lattice verifier (`answer_mode="claim_lattice"`).
|
|
|
|
Pairs with grammar-constrained inference (vLLM guided_json, Claude/GPT-4
|
|
native JSON, Qwen 3.6 reasoner). The lenient pre-parser keeps the path
|
|
survivable on inference paths where the model emits non-strict JSON
|
|
(markdown fences, prose preamble, curly quotes, trailing commas).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
|
|
import pytest
|
|
|
|
from aborist.qa.evidence import EvidenceObject
|
|
from aborist.qa.verify import _lenient_json_parse, verify_claim_lattice_json
|
|
|
|
|
|
# ---------------------------------------------------------------- lenient parser
|
|
|
|
|
|
def test_lenient_strict_passes_through():
|
|
obj, fixups = _lenient_json_parse('{"a": 1}')
|
|
assert obj == {"a": 1}
|
|
assert fixups == []
|
|
|
|
|
|
def test_lenient_strips_markdown_fence():
|
|
raw = '```json\n{"claims": []}\n```'
|
|
obj, fixups = _lenient_json_parse(raw)
|
|
assert obj == {"claims": []}
|
|
assert "fence" in fixups
|
|
|
|
|
|
def test_lenient_strips_unlabeled_fence():
|
|
raw = '```\n{"claims": []}\n```'
|
|
obj, fixups = _lenient_json_parse(raw)
|
|
assert obj == {"claims": []}
|
|
assert "fence" in fixups
|
|
|
|
|
|
def test_lenient_trims_preamble_and_suffix():
|
|
raw = 'Here is the JSON:\n{"claims": []}\nLet me know if you need more.'
|
|
obj, fixups = _lenient_json_parse(raw)
|
|
assert obj == {"claims": []}
|
|
assert "prose_trim" in fixups
|
|
|
|
|
|
def test_lenient_normalizes_curly_quotes():
|
|
raw = '{“claims”: []}'
|
|
obj, fixups = _lenient_json_parse(raw)
|
|
assert obj == {"claims": []}
|
|
assert "curly_quotes" in fixups
|
|
|
|
|
|
def test_lenient_fixes_trailing_comma():
|
|
raw = '{"claims": [],}'
|
|
obj, fixups = _lenient_json_parse(raw)
|
|
assert obj == {"claims": []}
|
|
assert "trailing_comma" in fixups
|
|
|
|
|
|
def test_lenient_combines_multiple_fixups():
|
|
raw = '```json\nHere:\n{“claims”: [],}\n```'
|
|
obj, fixups = _lenient_json_parse(raw)
|
|
assert obj == {"claims": []}
|
|
assert "fence" in fixups
|
|
# At least one of the inner fixups also fired.
|
|
assert any(f in fixups for f in ("curly_quotes", "trailing_comma", "prose_trim"))
|
|
|
|
|
|
def test_lenient_raises_on_truly_broken():
|
|
with pytest.raises(json.JSONDecodeError):
|
|
_lenient_json_parse("not json at all { ] [")
|
|
|
|
|
|
# ---------------------------------------------------------------- JSON verifier
|
|
|
|
|
|
def _ev(eid: str, span: str, role: str = "primary_answer_source") -> EvidenceObject:
|
|
"""Stub evidence object with deterministic eid for the test."""
|
|
return EvidenceObject(
|
|
evidence_id=eid,
|
|
source_root="00" * 32,
|
|
document_uri="test://doc",
|
|
title="Test Doc",
|
|
chunk_idx=0,
|
|
chunk_root="11" * 32,
|
|
offset_start=0,
|
|
offset_end=len(span),
|
|
source_role=role,
|
|
text_hash="22" * 32,
|
|
span=span,
|
|
pointer_id=None,
|
|
)
|
|
|
|
|
|
def test_verify_json_strict_when_all_claims_resolve():
|
|
evidence = [
|
|
_ev("E1f8e4c2a", "Brachiosaurus appears in the Jurassic Park film as a herbivore."),
|
|
_ev("E2c9d7b3f", "Velociraptor is featured prominently throughout Jurassic Park."),
|
|
]
|
|
answer = json.dumps({
|
|
"claims": [
|
|
{"text": "Brachiosaurus appears in the film", "evidence_ids": ["E1f8e4c2a"]},
|
|
{"text": "Velociraptor is featured", "evidence_ids": ["E2c9d7b3f"]},
|
|
]
|
|
})
|
|
v = verify_claim_lattice_json(answer, evidence)
|
|
assert v["audit_mode"] == "STRICT"
|
|
assert v["verifier_method"] == "claim_lattice_json"
|
|
assert v["n_verified"] == 2
|
|
assert v["violations"] == []
|
|
|
|
|
|
def test_verify_json_hybrid_when_some_unknown_evidence_id():
|
|
evidence = [
|
|
_ev("E1f8e4c2a", "Brachiosaurus appears in the Jurassic Park film as a herbivore."),
|
|
]
|
|
answer = json.dumps({
|
|
"claims": [
|
|
{"text": "Brachiosaurus appears in the film", "evidence_ids": ["E1f8e4c2a"]},
|
|
{"text": "Made-up claim", "evidence_ids": ["EFAKEFAKE"]},
|
|
]
|
|
})
|
|
v = verify_claim_lattice_json(answer, evidence)
|
|
assert v["audit_mode"] == "HYBRID"
|
|
assert any(vio["kind"] == "UNKNOWN_EVIDENCE_ID" for vio in v["violations"])
|
|
|
|
|
|
def test_verify_json_ungrounded_on_schema_invalid():
|
|
"""Lenient parser fails too → SCHEMA_INVALID → UNGROUNDED."""
|
|
v = verify_claim_lattice_json("not json {[", [])
|
|
assert v["audit_mode"] == "UNGROUNDED"
|
|
assert any(vio["kind"] == "SCHEMA_INVALID" for vio in v["violations"])
|
|
|
|
|
|
def test_verify_json_recovers_from_markdown_fence():
|
|
"""JSON-fenced output still parses & verifies; fence fixup logged."""
|
|
evidence = [
|
|
_ev("E1f8e4c2a", "Brachiosaurus appears in the Jurassic Park film as a herbivore."),
|
|
]
|
|
answer = (
|
|
'```json\n'
|
|
'{"claims": [{"text": "Brachiosaurus appears in the film", '
|
|
'"evidence_ids": ["E1f8e4c2a"]}]}\n'
|
|
'```'
|
|
)
|
|
v = verify_claim_lattice_json(answer, evidence)
|
|
assert v["audit_mode"] == "STRICT"
|
|
assert "fence" in v["json_fixups"]
|
|
|
|
|
|
def test_verify_json_manual_quote_violation():
|
|
"""Strict no-double-quote rule — even valid JSON with double quotes
|
|
inside a claim's text field fails MANUAL_QUOTE_VIOLATION."""
|
|
evidence = [
|
|
_ev("E1f8e4c2a", "Brachiosaurus appears in the Jurassic Park film as a herbivore."),
|
|
]
|
|
answer = json.dumps({
|
|
"claims": [
|
|
{
|
|
"text": 'Brachiosaurus is "a herbivore" appears in the film',
|
|
"evidence_ids": ["E1f8e4c2a"],
|
|
}
|
|
]
|
|
})
|
|
v = verify_claim_lattice_json(answer, evidence)
|
|
assert any(vio["kind"] == "MANUAL_QUOTE_VIOLATION" for vio in v["violations"])
|
|
assert v["audit_mode"] == "UNGROUNDED"
|
|
|
|
|
|
def test_verify_json_blocks_disallowed_source_role():
|
|
"""Evidence resolved but source_role outside the allowlist fails
|
|
SOURCE_ROLE_BLOCKED."""
|
|
evidence = [
|
|
_ev("E1f8e4c2a", "Brachiosaurus content here.", role="noisy_background_source"),
|
|
]
|
|
answer = json.dumps({
|
|
"claims": [
|
|
{"text": "Brachiosaurus appears", "evidence_ids": ["E1f8e4c2a"]},
|
|
]
|
|
})
|
|
v = verify_claim_lattice_json(answer, evidence)
|
|
assert any(vio["kind"] == "SOURCE_ROLE_BLOCKED" for vio in v["violations"])
|
|
assert v["audit_mode"] == "UNGROUNDED"
|