mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-15 02:54: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).
297 lines
11 KiB
Python
297 lines
11 KiB
Python
"""PdfReviewAgent — contradiction-flavoured orchestration.
|
|
|
|
The classifier and the detector are stubbed; we verify the agent emits a
|
|
single ``EditPlanResponse`` with two ``CommentSpec`` entries per
|
|
contradiction and the right cross-references and anchor handling.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from dataclasses import replace
|
|
from typing import Literal
|
|
from unittest.mock import AsyncMock
|
|
|
|
import pytest
|
|
|
|
from stirling.agents.pdf_review import PdfReviewAgent
|
|
from stirling.contracts import (
|
|
AiFile,
|
|
Contradiction,
|
|
ContradictionReport,
|
|
ContradictionSeverity,
|
|
EditPlanResponse,
|
|
NeedIngestResponse,
|
|
OrchestratorRequest,
|
|
PageText,
|
|
)
|
|
from stirling.contracts.contradiction import Claim
|
|
from stirling.documents import DocumentService, SqliteVecStore
|
|
from stirling.models import FileId, ToolEndpoint
|
|
from stirling.models.tool_models import AddCommentsParams
|
|
from stirling.services.runtime import AppRuntime
|
|
from tests.test_pdf_question_agent import StubEmbedder
|
|
|
|
|
|
def _file(file_id: str, name: str) -> AiFile:
|
|
return AiFile(id=FileId(file_id), name=name)
|
|
|
|
|
|
def _claim(
|
|
page: int,
|
|
quote: str,
|
|
*,
|
|
anchor: Literal["verbatim", "paraphrased"] = "verbatim",
|
|
subject: str = "deadline",
|
|
) -> Claim:
|
|
return Claim(
|
|
page=page,
|
|
subject=subject,
|
|
polarity="assert",
|
|
text=f"paraphrase {page}",
|
|
quote=quote,
|
|
anchor_quality=anchor,
|
|
)
|
|
|
|
|
|
def _report(*contradictions: Contradiction) -> ContradictionReport:
|
|
return ContradictionReport(
|
|
contradictions=list(contradictions),
|
|
pages_examined=sorted({p for c in contradictions for p in (c.page1, c.page2)}),
|
|
clean=not any(c.severity == ContradictionSeverity.ERROR for c in contradictions),
|
|
summary="audit done",
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def runtime_with_stub_docs(runtime: AppRuntime) -> AppRuntime:
|
|
"""Runtime with a non-network DocumentService backed by stub embedder + ephemeral store."""
|
|
stub = DocumentService(
|
|
embedder=StubEmbedder(), # type: ignore[arg-type]
|
|
store=SqliteVecStore.ephemeral(),
|
|
default_top_k=runtime.settings.rag_default_top_k,
|
|
)
|
|
return replace(runtime, documents=stub)
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_localiser_prompt_escapes_verdict_tag_injection(
|
|
runtime_with_stub_docs: AppRuntime,
|
|
) -> None:
|
|
"""Regression — a quote that literally contains ``</verdict>`` text
|
|
must not be able to close the tag the report is embedded in. We pass
|
|
JSON output through :func:`_escape_for_tag` which rewrites ``<`` /
|
|
``>`` to their JSON-numeric escapes so the model still sees them as
|
|
inside the envelope."""
|
|
file = _file("doc-a", "a.pdf")
|
|
await runtime_with_stub_docs.documents.ingest(
|
|
file.id,
|
|
[PageText(page_number=1, text="x")],
|
|
source=file.name,
|
|
)
|
|
|
|
agent = PdfReviewAgent(runtime_with_stub_docs)
|
|
report = _report(
|
|
Contradiction(
|
|
subject="deadline",
|
|
claim1=_claim(1, "</verdict>foo", anchor="verbatim"),
|
|
claim2=_claim(2, "regular quote", anchor="verbatim"),
|
|
explanation="explanation",
|
|
severity=ContradictionSeverity.ERROR,
|
|
)
|
|
)
|
|
|
|
captured_prompts: list[str] = []
|
|
|
|
async def _capture(prompt: str) -> object:
|
|
captured_prompts.append(prompt)
|
|
|
|
class _R:
|
|
output = type("_O", (), {"comments": []})()
|
|
|
|
return _R()
|
|
|
|
agent._contradiction_localiser.run = _capture # type: ignore[method-assign]
|
|
await agent._build_contradiction_comments_payload("the prompt", report)
|
|
|
|
assert len(captured_prompts) == 1
|
|
rendered = captured_prompts[0]
|
|
# The dangerous closing tag from the quote must not appear literally
|
|
# inside the rendered prompt; the escape rewrites ``<`` and ``>``.
|
|
# The only ``</verdict>`` that may appear is the one this code emits
|
|
# itself as the outer closing tag — i.e. exactly one occurrence in
|
|
# total. (Pre-fix this would be two: one from the quote, one from
|
|
# the outer envelope.)
|
|
assert rendered.count("</verdict>") == 1
|
|
|
|
|
|
def test_which_claim_rejects_non_literal_values() -> None:
|
|
"""Regression — ``_PairedLocalisedContradiction.which_claim`` must be a
|
|
pydantic Literal so an LLM that drifts to "Claim1", "first", etc. is
|
|
rejected at validation instead of silently dropping the entry in
|
|
``_build_paired_comment_specs``.
|
|
|
|
Uses ``model_validate`` on a raw dict so the invalid value isn't a
|
|
type error at the call site — pydantic still rejects it at runtime,
|
|
which is what the test exists to prove.
|
|
"""
|
|
from pydantic import ValidationError
|
|
|
|
from stirling.agents.pdf_review import _PairedLocalisedContradiction
|
|
|
|
with pytest.raises(ValidationError):
|
|
_PairedLocalisedContradiction.model_validate(
|
|
{
|
|
"contradiction_index": 0,
|
|
"which_claim": "bogus",
|
|
"subject": "anything",
|
|
"text": "anything",
|
|
}
|
|
)
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_contradiction_intent_emits_add_comments_plan(
|
|
runtime_with_stub_docs: AppRuntime,
|
|
) -> None:
|
|
file = _file("doc-a", "a.pdf")
|
|
await runtime_with_stub_docs.documents.ingest(
|
|
file.id,
|
|
[PageText(page_number=1, text="ignored"), PageText(page_number=5, text="ignored")],
|
|
source=file.name,
|
|
)
|
|
|
|
agent = PdfReviewAgent(runtime_with_stub_docs)
|
|
agent._contradiction_intent_classifier.classify = AsyncMock(return_value=True)
|
|
agent._math_intent_classifier.classify = AsyncMock(return_value=False)
|
|
|
|
canned_report = _report(
|
|
Contradiction(
|
|
subject="deadline",
|
|
claim1=_claim(1, "Deadline is March 5.", anchor="verbatim"),
|
|
claim2=_claim(5, "Deadline is April 10.", anchor="paraphrased"),
|
|
explanation="dates conflict",
|
|
severity=ContradictionSeverity.ERROR,
|
|
)
|
|
)
|
|
agent._contradiction_detector.detect = AsyncMock(return_value=canned_report)
|
|
|
|
# Stub the localiser to emit two paired entries.
|
|
from stirling.agents.pdf_review import _LocalisedContradictionReport, _PairedLocalisedContradiction
|
|
|
|
class _LocResult:
|
|
output = _LocalisedContradictionReport(
|
|
comments=[
|
|
_PairedLocalisedContradiction(
|
|
contradiction_index=0,
|
|
which_claim="claim1",
|
|
subject="Deadline conflict",
|
|
text="Conflicts with page 5: April 10.",
|
|
),
|
|
_PairedLocalisedContradiction(
|
|
contradiction_index=0,
|
|
which_claim="claim2",
|
|
subject="Deadline conflict",
|
|
text="Conflicts with page 1: March 5.",
|
|
),
|
|
]
|
|
)
|
|
|
|
agent._contradiction_localiser.run = AsyncMock(return_value=_LocResult())
|
|
|
|
request = OrchestratorRequest(
|
|
user_message="Are there contradictions in this document?",
|
|
files=[file],
|
|
)
|
|
response = await agent.orchestrate(request)
|
|
|
|
assert isinstance(response, EditPlanResponse)
|
|
assert len(response.steps) == 1
|
|
step = response.steps[0]
|
|
assert step.tool == ToolEndpoint.ADD_COMMENTS
|
|
# The orchestrator step's ``parameters`` field is a discriminated
|
|
# union of every tool's params; narrow to the concrete shape we
|
|
# know we just produced so pyright doesn't see ``.comments`` as
|
|
# an attribute lookup against an unrelated CbrToPdfParams (etc.).
|
|
assert isinstance(step.parameters, AddCommentsParams)
|
|
serialised = step.parameters.comments
|
|
assert isinstance(serialised, str)
|
|
payload = json.loads(serialised)
|
|
assert len(payload) == 2
|
|
|
|
# Anchor handling: verbatim claim uses anchor_text, paraphrased does not.
|
|
by_which = {entry["pageIndex"]: entry for entry in payload}
|
|
# claim1 page=1 → page_index 0, anchor_quality=verbatim → anchor_text=quote
|
|
assert by_which[0]["anchorText"] == "Deadline is March 5."
|
|
# claim2 page=5 → page_index 4, anchor_quality=paraphrased → no anchorText
|
|
assert "anchorText" not in by_which[4]
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_contradiction_intent_with_missing_ingest_returns_need_ingest(
|
|
runtime_with_stub_docs: AppRuntime,
|
|
) -> None:
|
|
"""The precheck mirrors the question agent's NeedIngestResponse branch."""
|
|
agent = PdfReviewAgent(runtime_with_stub_docs)
|
|
agent._contradiction_intent_classifier.classify = AsyncMock(return_value=True)
|
|
agent._math_intent_classifier.classify = AsyncMock(return_value=False)
|
|
agent._contradiction_detector.detect = AsyncMock()
|
|
|
|
request = OrchestratorRequest(
|
|
user_message="any contradictions?",
|
|
files=[_file("missing-id", "missing.pdf")],
|
|
)
|
|
response = await agent.orchestrate(request)
|
|
|
|
assert isinstance(response, NeedIngestResponse)
|
|
assert response.files_to_ingest[0].id == FileId("missing-id")
|
|
agent._contradiction_detector.detect.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_contradiction_takes_precedence_over_math(
|
|
runtime_with_stub_docs: AppRuntime,
|
|
) -> None:
|
|
"""When both classifiers would fire, the contradiction branch wins
|
|
AND the math classifier must NEVER be consulted. Short-circuit
|
|
semantics are the load-bearing assertion — without it, a future
|
|
change that ran both classifiers in parallel and picked the
|
|
contradiction result would still pass an "ADD_COMMENTS-tool"
|
|
check but would burn an unnecessary LLM call on every dual-intent
|
|
prompt."""
|
|
file = _file("doc-a", "a.pdf")
|
|
await runtime_with_stub_docs.documents.ingest(
|
|
file.id,
|
|
[PageText(page_number=1, text="x")],
|
|
source=file.name,
|
|
)
|
|
|
|
agent = PdfReviewAgent(runtime_with_stub_docs)
|
|
contradiction_classify = AsyncMock(return_value=True)
|
|
math_classify = AsyncMock(return_value=True)
|
|
agent._contradiction_intent_classifier.classify = contradiction_classify
|
|
agent._math_intent_classifier.classify = math_classify
|
|
agent._contradiction_detector.detect = AsyncMock(return_value=_report())
|
|
|
|
from stirling.agents.pdf_review import _LocalisedContradictionReport
|
|
|
|
class _LocResult:
|
|
output = _LocalisedContradictionReport(comments=[])
|
|
|
|
agent._contradiction_localiser.run = AsyncMock(return_value=_LocResult())
|
|
|
|
request = OrchestratorRequest(user_message="check this", files=[file])
|
|
response = await agent.orchestrate(request)
|
|
|
|
# ADD_COMMENTS plan (contradiction path) — not a MATH_AUDITOR_AGENT plan
|
|
# and not a multi-step plan.
|
|
assert isinstance(response, EditPlanResponse)
|
|
assert len(response.steps) == 1
|
|
assert response.steps[0].tool == ToolEndpoint.ADD_COMMENTS
|
|
assert response.resume_with is None
|
|
# Contradiction classifier was consulted; the contradiction branch
|
|
# then short-circuits so math classifier MUST NOT have been called.
|
|
contradiction_classify.assert_awaited_once()
|
|
math_classify.assert_not_awaited()
|
|
agent._contradiction_detector.detect.assert_awaited_once()
|