mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 10:34:06 +02:00
## Summary Adds a new AI specialist that finds **textual contradictions** across one or more PDFs — conflicting claims, recommendations, points of view, contested facts — built entirely in Python on top of the new `DocumentService` + `ChunkedReasoner` stack from #6314. Replaces the closed #6304, which was started before #6314 landed and therefore over-engineered (Java orchestrator, two-round handshake, resume artifact, discriminated-union lift). Two commits: 1. **`refactor(engine): extract ChunkedMapper[T] from ChunkedReasoner`** — pure refactor, public API of ChunkedReasoner unchanged. New `ChunkedMapper[T: BaseModel]` is a generic parallel-chunk primitive (slicing, semaphore, time-bounded extraction, cancellation drain, progress events) that's now a peer to ChunkedReasoner rather than locked inside it. The compression loop stays on ChunkedReasoner where it belongs. 2. **`feat(ai): add Contradiction Agent on ChunkedMapper`** — the agent itself, plus integrations into `PdfReviewAgent` and `PdfQuestionAgent`. ## Architecture - **Python-only.** No Java code. No `AgentToolId.CONTRADICTION_AGENT`. No dedicated HTTP endpoint. No resume artifact, no discriminated-union lift in `contracts/common.py`. Detector runs inside the Python engine and the Python engine alone. - **Review path** (`PdfReviewAgent`): a new `ContradictionIntentClassifier` fires on contradiction-flavoured prompts; agent runs detection synchronously and emits a single `EditPlanResponse(steps=[ADD_COMMENTS])`. Single-turn flow — no resume. - **Question path** (`PdfQuestionAgent`): a new `ContradictionCapability` joins `RagCapability` and `WholeDocReaderCapability` in the smart-model toolset, exposing `find_contradictions(query)`. The smart model picks it from the toolset alongside `search_knowledge` and `read_full_document`. ## Inside `ContradictionDetector.detect()` 1. `DocumentService.read_pages(file_id)` → ordered `list[Page]`. 2. `ChunkedMapper[_ExtractedClaims].map_pages(...)` — char-budgeted multi-page slicing; each slice runs the claim-extractor LLM in parallel under a semaphore. 3. Page-traceability: the extractor returns `_ExtractedClaim.page` (which `[Page N]` marker the claim came from). The wrapper validates `page ∈ chunk.pages`; if not, mechanical fallback searches the chunk's page text for the verbatim quote and reassigns. If still no match, drop the claim. 4. `Claim.anchor_quality: Literal[\"verbatim\", \"paraphrased\"]` is set by a substring check against the declared page's text. Verbatim quotes feed `anchor_text` for snap-to-quote add-comments placement; paraphrased ones fall back to margin geometry. 5. Subject canonicalisation: ONE fast-model LLM call collapses synonyms across the document. Fails open to lexical bucketing. 6. Pre-filters: drop identical-quote pairs; drop same-page same-polarity paraphrases. 7. Per-bucket pair detection in parallel (separate semaphore, cap 5). Buckets > 12 claims chunk into windows of 12 with overlap 2; pairs deduped across overlapping windows by frozen `(i, j)` index pair. 8. Summary fast-model call with fallback string on error. ## Prompt-injection hardening Every prompt that interpolates user-supplied or PDF-extracted text wraps content in `<user_message>` / `<verdict>` / `<content>` tags with an explicit SECURITY preamble instructing the model to treat tagged content as data only. ## Limitations - **Combined math + contradiction intent**: when both intent classifiers fire on the same prompt, contradiction takes precedence and the math intent is silently dropped. Documented in the Review module docstring and pinned by `test_review_integration.py::test_contradiction_precedence_over_math`. - **Cross-window contradiction reach**: within a subject bucket, pairs more than ~10 claim indices apart in the same chunked window may be missed by the overlap-2 strategy. Documented in `test_detector.py`. Acceptable for v1. ## Settings (engine/src/stirling/config/settings.py) ```python contradiction_detect_concurrency = 5 # per-bucket detector semaphore contradiction_bucket_chunk_size = 12 # max claims per detector call contradiction_bucket_chunk_overlap = 2 # overlap for >threshold buckets ``` `chars_per_slice` and extraction concurrency are reused from the existing `chunked_reasoner_*` settings. ## Test plan - [x] `uv run pytest tests/ -v` — **245/245 pass** (210 pre-existing + 35 new) - [x] `uv run ruff check src/ tests/` — clean - [x] `uv run pyright src/stirling/agents/contradiction/ src/stirling/contracts/contradiction.py` — 0 errors - [x] `./gradlew :proprietary:test` — green; no Java was touched, but verified untouched - [x] Page-traceability tests cover: valid page kept, hallucinated page dropped, mechanical-reassign on misattribution, anchor-quality verbatim vs paraphrased - [x] Review integration: ADD_COMMENTS plan with two paired CommentSpecs per contradiction; NeedIngestResponse precheck; precedence vs math intent pinned - [x] Question integration: all three capabilities wired into smart-model toolset; `find_contradictions` returns formatted report text - [x] ChunkedMapper standalone: slicing, multi-chunk ordering, worker failures, timeouts, cancellation drain, semaphore saturation - [x] ChunkedReasoner regression: all pre-existing tests pass unchanged after the internal split ## Relationship to closed #6304 #6304 was closed in favour of this PR. The closed PR predated #6314 and modelled the agent as a Java-orchestrated two-round examine/deliberate flow with its own HTTP endpoint and a discriminated-union resume artifact. With #6314 making full ordered page text available to the engine via `DocumentService.read_pages`, none of that is needed. Net effect: drop ~600 lines of Java, drop the two-round handshake, drop the `ToolReportArtifact` lift, while ending up with a more scalable agent (chunk-based instead of page-based extraction; tested to ChunkedReasoner-equivalent scale).
151 lines
5.1 KiB
Python
151 lines
5.1 KiB
Python
"""Page-traceability validation for extracted claims.
|
|
|
|
Covers the wrapper logic that maps an LLM-emitted ``_ExtractedClaim`` to
|
|
the public ``Claim`` after sanity-checking its declared page against
|
|
the chunk's covered pages, and assigns ``anchor_quality`` based on
|
|
whether the quote is a verbatim substring of the page's text.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from stirling.agents.contradiction.detector import (
|
|
ContradictionDetector,
|
|
_ExtractedClaim,
|
|
_ExtractedClaims,
|
|
)
|
|
from stirling.agents.shared.chunked_mapper import ChunkOutput
|
|
from stirling.contracts.contradiction import ClaimPolarity
|
|
from stirling.contracts.documents import Page
|
|
|
|
|
|
def _page(n: int, text: str) -> Page:
|
|
return Page(page_number=n, text=text, char_count=len(text))
|
|
|
|
|
|
def _chunk_output(pages: list[Page]) -> ChunkOutput[_ExtractedClaims]:
|
|
page_nums = [p.page_number for p in pages]
|
|
label = f"pages={page_nums[0]}" if len(page_nums) == 1 else f"pages={page_nums[0]}-{page_nums[-1]}"
|
|
return ChunkOutput(pages=page_nums, output=_ExtractedClaims(claims=[]), label=label)
|
|
|
|
|
|
def _raw(
|
|
*,
|
|
page: int,
|
|
quote: str,
|
|
subject: str = "deadline",
|
|
polarity: ClaimPolarity = "assert",
|
|
text: str = "Claim about the deadline.",
|
|
) -> _ExtractedClaim:
|
|
return _ExtractedClaim(
|
|
page=page,
|
|
subject=subject,
|
|
polarity=polarity,
|
|
text=text,
|
|
quote=quote,
|
|
)
|
|
|
|
|
|
# Valid page → kept
|
|
|
|
|
|
def test_valid_page_in_chunk_is_kept_verbatim() -> None:
|
|
pages = [_page(1, "The deadline is March 5."), _page(2, "Other content.")]
|
|
chunk = _chunk_output(pages)
|
|
pages_by_num = {p.page_number: p for p in pages}
|
|
raw = _raw(page=1, quote="The deadline is March 5.")
|
|
|
|
claim = ContradictionDetector._validate_extracted_claim(raw, chunk, pages_by_num)
|
|
|
|
assert claim is not None
|
|
assert claim.page == 1
|
|
assert claim.anchor_quality == "verbatim"
|
|
|
|
|
|
def test_quote_present_in_page_text_yields_verbatim_anchor() -> None:
|
|
pages = [_page(1, "Sentence A. The deadline is March 5. Sentence C.")]
|
|
chunk = _chunk_output(pages)
|
|
pages_by_num = {p.page_number: p for p in pages}
|
|
raw = _raw(page=1, quote="The deadline is March 5.")
|
|
|
|
claim = ContradictionDetector._validate_extracted_claim(raw, chunk, pages_by_num)
|
|
|
|
assert claim is not None
|
|
assert claim.anchor_quality == "verbatim"
|
|
|
|
|
|
def test_quote_absent_from_page_text_yields_paraphrased_anchor() -> None:
|
|
"""A claim whose quote isn't a substring of the declared page must
|
|
still survive (the LLM may have paraphrased), but it's marked
|
|
paraphrased so the comment placer falls back to margin geometry."""
|
|
pages = [_page(1, "March 5 was named as the deadline.")]
|
|
chunk = _chunk_output(pages)
|
|
pages_by_num = {p.page_number: p for p in pages}
|
|
raw = _raw(page=1, quote="The deadline is March 5.")
|
|
|
|
claim = ContradictionDetector._validate_extracted_claim(raw, chunk, pages_by_num)
|
|
|
|
assert claim is not None
|
|
assert claim.page == 1
|
|
assert claim.anchor_quality == "paraphrased"
|
|
|
|
|
|
# Page outside chunk + mechanical fallback
|
|
|
|
|
|
def test_page_outside_chunk_but_quote_uniquely_in_another_page_is_reassigned() -> None:
|
|
"""LLM declared page 3, but the quote literally appears on page 2 (which
|
|
is in the chunk). The wrapper reassigns and keeps the claim."""
|
|
pages = [
|
|
_page(1, "Nothing relevant here."),
|
|
_page(2, "The deadline is March 5."),
|
|
]
|
|
chunk = _chunk_output(pages)
|
|
pages_by_num = {p.page_number: p for p in pages}
|
|
raw = _raw(page=3, quote="The deadline is March 5.")
|
|
|
|
claim = ContradictionDetector._validate_extracted_claim(raw, chunk, pages_by_num)
|
|
|
|
assert claim is not None
|
|
assert claim.page == 2 # reassigned mechanically
|
|
assert claim.anchor_quality == "verbatim"
|
|
|
|
|
|
def test_page_outside_chunk_and_quote_not_in_any_chunk_page_is_dropped() -> None:
|
|
pages = [_page(1, "Unrelated."), _page(2, "Also unrelated.")]
|
|
chunk = _chunk_output(pages)
|
|
pages_by_num = {p.page_number: p for p in pages}
|
|
raw = _raw(page=3, quote="The deadline is March 5.")
|
|
|
|
claim = ContradictionDetector._validate_extracted_claim(raw, chunk, pages_by_num)
|
|
|
|
assert claim is None
|
|
|
|
|
|
def test_quote_matching_multiple_chunk_pages_is_dropped() -> None:
|
|
"""Ambiguous reassignment: if more than one chunk page contains the quote,
|
|
we have no way to pick — drop with a warning instead of guessing."""
|
|
pages = [
|
|
_page(1, "The deadline is March 5."),
|
|
_page(2, "The deadline is March 5."),
|
|
]
|
|
chunk = _chunk_output(pages)
|
|
pages_by_num = {p.page_number: p for p in pages}
|
|
raw = _raw(page=99, quote="The deadline is March 5.")
|
|
|
|
claim = ContradictionDetector._validate_extracted_claim(raw, chunk, pages_by_num)
|
|
|
|
assert claim is None
|
|
|
|
|
|
# Defensive drops
|
|
|
|
|
|
def test_empty_subject_drops_claim() -> None:
|
|
pages = [_page(1, "anything")]
|
|
chunk = _chunk_output(pages)
|
|
pages_by_num = {p.page_number: p for p in pages}
|
|
raw = _ExtractedClaim(page=1, subject=" ", polarity="assert", text="real text", quote="real quote")
|
|
|
|
claim = ContradictionDetector._validate_extracted_claim(raw, chunk, pages_by_num)
|
|
assert claim is None
|