Merge origin/main into AI-Form-Fill

Resolution favours main for all conflicts (new orchestrator shape with
artifacts/file_names/conversation_history/resume_with, WorkflowOutcome
enum, RAG, ledger agent, deleted engine/config/.env.example).

Re-wires our three form-fill agents (FormAnalyserAgent, FormFillerAgent,
DocumentExtractorAgent) into the new app layout:
- contracts/__init__.py: adds form-fill exports to __all__
- api/dependencies.py: adds get_form_analyser_agent / get_form_filler_agent / get_document_extractor_agent
- api/app.py: instantiates the three agents in lifespan and registers form_fill_router
- api/routes/__init__.py: exports form_fill_router
- tests/test_stirling_api.py: imports and registers form-fill stubs
- tests/test_stirling_contracts.py: adds back KnowledgeUpdateResponse discriminator test

Form fill remains accessible via its own endpoints (/api/v1/form/ai/analyse,
/fill-batch, /extract). Not wired as an orchestrator delegate — following
main's pattern where orchestrator only routes pdf_edit/pdf_question/user_spec
/math_auditor.

Follow-ups still needed:
- Thread conversation_history into form-fill agent prompts
- Align form-fill response outcomes with WorkflowOutcome enum
- Decide whether engine should return ToolOperationStep plans (main's
  new pattern per #6116) or keep returning fill values directly

All 127 engine tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Reece
2026-04-22 13:13:12 +01:00
co-authored by Claude Opus 4.7
1770 changed files with 145755 additions and 64948 deletions
+46
View File
@@ -0,0 +1,46 @@
from __future__ import annotations
from collections.abc import Iterator
from pathlib import Path
import pytest
from stirling.config import AppSettings, RagBackend, load_settings
from stirling.services import build_runtime
from stirling.services.runtime import AppRuntime
@pytest.fixture(autouse=True)
def clear_settings_cache() -> Iterator[None]:
load_settings.cache_clear()
yield
load_settings.cache_clear()
def build_app_settings() -> AppSettings:
return AppSettings(
smart_model_name="test",
fast_model_name="test",
smart_model_max_tokens=8192,
fast_model_max_tokens=2048,
rag_backend=RagBackend.SQLITE,
rag_embedding_model="voyageai:voyage-4",
rag_store_path=Path(":memory:"),
rag_pgvector_dsn="",
rag_chunk_size=512,
rag_chunk_overlap=64,
rag_default_top_k=5,
posthog_enabled=False,
posthog_api_key="",
posthog_host="https://eu.i.posthog.com",
)
@pytest.fixture
def app_settings() -> AppSettings:
return build_app_settings()
@pytest.fixture
def runtime(app_settings: AppSettings) -> AppRuntime:
return build_runtime(app_settings)
View File
@@ -0,0 +1,133 @@
"""
ArithmeticScanner — unit tests.
Tests cover the two inline arithmetic patterns the scanner targets:
1. Equals expressions: A + B = C
2. Total-then-addends: Total: C (A + B)
"""
from decimal import Decimal
import pytest
from stirling.agents.ledger.validators.arithmetic import ArithmeticScanner
@pytest.fixture
def scanner() -> ArithmeticScanner:
return ArithmeticScanner(tolerance=Decimal("0.01"))
# ---------------------------------------------------------------------------
# Equals expressions: A + B + C = D
# ---------------------------------------------------------------------------
def test_correct_equals_expression(scanner: ArithmeticScanner) -> None:
"""A correct sum should produce no findings."""
text = "The total cost is 100 + 200 + 150 = 450."
assert scanner.scan(page=0, text=text) == []
def test_wrong_equals_expression(scanner: ArithmeticScanner) -> None:
"""An incorrect sum should produce one error discrepancy."""
text = "Revenue: 500 + 300 = 900" # should be 800
discrepancies = scanner.scan(page=3, text=text)
assert len(discrepancies) == 1
d = discrepancies[0]
assert d.page == 3
assert d.kind == "arithmetic"
assert d.severity == "error"
assert d.stated == "900"
assert d.expected == "800"
def test_subtraction_expression(scanner: ArithmeticScanner) -> None:
"""Subtraction in expressions should be evaluated correctly."""
text = "Net: 1000 - 250 = 750"
assert scanner.scan(page=0, text=text) == []
def test_wrong_subtraction(scanner: ArithmeticScanner) -> None:
text = "Net: 1000 - 250 = 800" # should be 750
discrepancies = scanner.scan(page=0, text=text)
assert len(discrepancies) == 1
assert discrepancies[0].expected == "750"
def test_currency_symbols_stripped(scanner: ArithmeticScanner) -> None:
"""Currency symbols and thousand separators must not break parsing."""
text = "Total: £1,000 + £500 = £1,500"
assert scanner.scan(page=0, text=text) == []
def test_multiple_expressions_in_text(scanner: ArithmeticScanner) -> None:
"""Multiple expressions in the same text should each be evaluated."""
text = (
"Q1 revenue: 100 + 200 = 300. "
"Q2 revenue: 150 + 100 = 350. " # wrong: should be 250
)
discrepancies = scanner.scan(page=0, text=text)
assert len(discrepancies) == 1
assert discrepancies[0].expected == "250"
# ---------------------------------------------------------------------------
# Total-then-addends: "Total: X (A + B + C)"
# ---------------------------------------------------------------------------
def test_correct_total_then_addends(scanner: ArithmeticScanner) -> None:
text = "Grand Total: 750 (300 + 250 + 200)"
assert scanner.scan(page=0, text=text) == []
def test_wrong_total_then_addends(scanner: ArithmeticScanner) -> None:
text = "Grand Total: 900 (300 + 250 + 200)" # addends sum to 750
discrepancies = scanner.scan(page=0, text=text)
assert len(discrepancies) == 1
d = discrepancies[0]
assert d.stated == "900"
assert d.expected == "750"
def test_total_keyword_variations(scanner: ArithmeticScanner) -> None:
"""The pattern must work for 'Sum', 'Subtotal', 'Grand Total' etc."""
cases = [
("Sum: 600 (200 + 200 + 200)", True),
("Subtotal: 600 (200 + 200 + 200)", True),
("Total: 999 (200 + 200 + 200)", False), # wrong
]
for text, should_be_clean in cases:
result = scanner.scan(page=0, text=text)
if should_be_clean:
assert result == [], f"Expected clean for: {text!r}"
else:
assert len(result) == 1, f"Expected error for: {text!r}"
# ---------------------------------------------------------------------------
# Edge cases
# ---------------------------------------------------------------------------
def test_no_expressions_in_text(scanner: ArithmeticScanner) -> None:
text = "This paragraph discusses revenue trends but contains no arithmetic."
assert scanner.scan(page=0, text=text) == []
def test_empty_text(scanner: ArithmeticScanner) -> None:
assert scanner.scan(page=0, text="") == []
def test_leading_negative_expression(scanner: ArithmeticScanner) -> None:
"""Expressions starting with a negative number should evaluate correctly."""
text = "Adjustment: -100 + 250 = 150"
assert scanner.scan(page=0, text=text) == []
def test_leading_negative_wrong(scanner: ArithmeticScanner) -> None:
text = "Adjustment: -100 + 250 = 200" # should be 150
discrepancies = scanner.scan(page=0, text=text)
assert len(discrepancies) == 1
assert discrepancies[0].expected == "150"
+100
View File
@@ -0,0 +1,100 @@
"""
FigureTracker — unit tests.
Tests that named figures are correctly accumulated and that conflicting
sightings (same label, different value) are surfaced as consistency warnings.
"""
from decimal import Decimal
import pytest
from stirling.agents.ledger.validators.figures import FigureTracker
@pytest.fixture
def tracker() -> FigureTracker:
return FigureTracker(tolerance=Decimal("0.01"))
# ---------------------------------------------------------------------------
# No conflicts
# ---------------------------------------------------------------------------
def test_no_conflicts_single_figure(tracker: FigureTracker) -> None:
tracker.record("Net Profit", Decimal("1200.00"), page=3, raw="£1,200.00")
assert tracker.conflicts() == []
def test_no_conflicts_consistent_figure(tracker: FigureTracker) -> None:
"""The same figure cited identically on two pages must not raise a conflict."""
tracker.record("Total Revenue", Decimal("5000.00"), page=1, raw="£5,000")
tracker.record("Total Revenue", Decimal("5000.00"), page=8, raw="£5,000")
assert tracker.conflicts() == []
def test_no_conflicts_within_tolerance(tracker: FigureTracker) -> None:
"""A difference within tolerance must not be flagged."""
tracker.record("VAT", Decimal("100.00"), page=2, raw="£100.00")
tracker.record("VAT", Decimal("100.005"), page=5, raw="£100.005")
assert tracker.conflicts() == []
# ---------------------------------------------------------------------------
# Conflicts
# ---------------------------------------------------------------------------
def test_conflict_different_values(tracker: FigureTracker) -> None:
"""Same label, different value on two pages → one consistency warning."""
tracker.record("Net Profit", Decimal("1200.00"), page=3, raw="£1,200")
tracker.record("Net Profit", Decimal("1250.00"), page=7, raw="£1,250")
conflicts = tracker.conflicts()
assert len(conflicts) == 1
d = conflicts[0]
assert d.kind == "consistency"
assert d.severity == "warning"
assert d.page == 7 # later occurrence is flagged
def test_conflict_three_sightings_two_values(tracker: FigureTracker) -> None:
"""Three sightings where one differs from canonical → 1 conflict."""
tracker.record("Revenue", Decimal("1000"), page=1, raw="£1,000")
tracker.record("Revenue", Decimal("1000"), page=3, raw="£1,000")
tracker.record("Revenue", Decimal("999"), page=5, raw="£999")
conflicts = tracker.conflicts()
# Canonical=p1 (1000). p3 matches, p5 differs → 1 conflict
assert len(conflicts) == 1
assert conflicts[0].page == 5
# ---------------------------------------------------------------------------
# Label normalisation
# ---------------------------------------------------------------------------
def test_label_normalisation_case_insensitive(tracker: FigureTracker) -> None:
"""Labels must be compared case-insensitively."""
tracker.record("Net Profit", Decimal("1200"), page=2, raw="1200")
tracker.record("net profit", Decimal("1100"), page=4, raw="1100")
assert len(tracker.conflicts()) == 1
def test_label_normalisation_punctuation(tracker: FigureTracker) -> None:
"""Colons and dashes in labels must be normalised before comparison."""
tracker.record("Total Revenue:", Decimal("5000"), page=1, raw="5000")
tracker.record("Total Revenue —", Decimal("4000"), page=9, raw="4000")
assert len(tracker.conflicts()) == 1
# ---------------------------------------------------------------------------
# Entry count
# ---------------------------------------------------------------------------
def test_entry_count(tracker: FigureTracker) -> None:
tracker.record("A", Decimal("1"), page=0, raw="1")
tracker.record("A", Decimal("1"), page=1, raw="1")
tracker.record("B", Decimal("2"), page=2, raw="2")
assert tracker.entry_count == 3
@@ -0,0 +1,205 @@
"""
FormulaEvaluator — unit tests.
Tests cover:
- Operator precedence (* / before + -)
- Column reference replacement (colN with word boundaries)
- Negative number handling
- each_row, column_total, and single_cell scopes
"""
from decimal import Decimal
import pytest
from stirling.agents.ledger.validators.formula import FormulaEvaluator
@pytest.fixture
def evaluator() -> FormulaEvaluator:
return FormulaEvaluator(tolerance=Decimal("0.01"))
# ---------------------------------------------------------------------------
# _safe_eval — operator precedence
# ---------------------------------------------------------------------------
def test_safe_eval_addition(evaluator: FormulaEvaluator) -> None:
assert evaluator._safe_eval("2 + 3") == Decimal("5")
def test_safe_eval_multiplication_before_addition(evaluator: FormulaEvaluator) -> None:
"""2 + 3 * 4 should be 14, not 20."""
assert evaluator._safe_eval("2 + 3 * 4") == Decimal("14")
def test_safe_eval_division_before_subtraction(evaluator: FormulaEvaluator) -> None:
"""10 - 6 / 2 should be 7, not 2."""
assert evaluator._safe_eval("10 - 6 / 2") == Decimal("7")
def test_safe_eval_mixed_precedence(evaluator: FormulaEvaluator) -> None:
"""1 + 2 * 3 - 4 / 2 should be 1 + 6 - 2 = 5."""
assert evaluator._safe_eval("1 + 2 * 3 - 4 / 2") == Decimal("5")
def test_safe_eval_all_multiplication(evaluator: FormulaEvaluator) -> None:
assert evaluator._safe_eval("2 * 3 * 4") == Decimal("24")
def test_safe_eval_division_by_zero(evaluator: FormulaEvaluator) -> None:
assert evaluator._safe_eval("10 / 0") is None
def test_safe_eval_negative_result(evaluator: FormulaEvaluator) -> None:
assert evaluator._safe_eval("3 - 5") == Decimal("-2")
def test_safe_eval_leading_negative(evaluator: FormulaEvaluator) -> None:
"""Expressions starting with a negative number should work."""
result = evaluator._safe_eval("-100 + 200")
assert result == Decimal("100")
def test_safe_eval_empty(evaluator: FormulaEvaluator) -> None:
assert evaluator._safe_eval("") is None
def test_safe_eval_single_number(evaluator: FormulaEvaluator) -> None:
assert evaluator._safe_eval("42") == Decimal("42")
def test_safe_eval_decimal_numbers(evaluator: FormulaEvaluator) -> None:
result = evaluator._safe_eval("1.5 * 2 + 0.5")
assert result == Decimal("3.5")
# ---------------------------------------------------------------------------
# colN replacement — word boundary safety
# ---------------------------------------------------------------------------
def test_col1_does_not_corrupt_col12(evaluator: FormulaEvaluator) -> None:
"""col1 replacement must not alter col12."""
csv = "a,b,c,d,e,f,g,h,i,j,k,l,m\n0,10,0,0,0,0,0,0,0,0,0,0,120\n"
# col1=10, col12=120 → col12 - col1 should be 110
result = evaluator.evaluate(
page=0,
table_csv=csv,
formula="col0 = col12 - col1",
scope="each_row",
description="test",
)
# row 1: col0=0, expected=120-10=110 → discrepancy
assert len(result) == 1
assert result[0].expected == "110"
def test_col_replacement_adjacent_columns(evaluator: FormulaEvaluator) -> None:
"""col1 and col10 should both be replaced correctly."""
csv = "a,b,c,d,e,f,g,h,i,j,k\n55,5,0,0,0,0,0,0,0,0,50\n"
# col0=55, col1=5, col10=50 → col1 + col10 = 55
result = evaluator.evaluate(
page=0,
table_csv=csv,
formula="col0 = col1 + col10",
scope="each_row",
description="test",
)
assert result == [] # 5 + 50 = 55, matches col0
# ---------------------------------------------------------------------------
# each_row scope
# ---------------------------------------------------------------------------
def test_each_row_correct(evaluator: FormulaEvaluator) -> None:
csv = "Item,Qty,Price,Total\nWidget,10,5,50\nGadget,3,20,60\n"
result = evaluator.evaluate(
page=0,
table_csv=csv,
formula="col3 = col1 * col2",
scope="each_row",
description="unit price check",
)
assert result == []
def test_each_row_error(evaluator: FormulaEvaluator) -> None:
csv = "Item,Qty,Price,Total\nWidget,10,5,50\nGadget,3,20,99\n"
result = evaluator.evaluate(
page=0,
table_csv=csv,
formula="col3 = col1 * col2",
scope="each_row",
description="unit price check",
)
assert len(result) == 1
assert result[0].expected == "60"
assert result[0].stated == "99"
# ---------------------------------------------------------------------------
# column_total scope
# ---------------------------------------------------------------------------
def test_column_total_correct(evaluator: FormulaEvaluator) -> None:
csv = "Name,Amount\nA,100\nB,200\nTotal,300\n"
result = evaluator.evaluate(
page=0,
table_csv=csv,
formula="sum",
scope="column_total",
description="total check",
target_row=3,
target_col=1,
)
assert result == []
def test_column_total_error(evaluator: FormulaEvaluator) -> None:
csv = "Name,Amount\nA,100\nB,200\nTotal,400\n"
result = evaluator.evaluate(
page=0,
table_csv=csv,
formula="sum",
scope="column_total",
description="total check",
target_row=3,
target_col=1,
)
assert len(result) == 1
assert result[0].expected == "300"
# ---------------------------------------------------------------------------
# single_cell scope
# ---------------------------------------------------------------------------
def test_single_cell_correct(evaluator: FormulaEvaluator) -> None:
csv = "A,B,C\n10,20,30\n5,15,20\n15,35,50\n"
result = evaluator.evaluate(
page=0,
table_csv=csv,
formula="cell(3,2) = cell(1,2) + cell(2,2)",
scope="single_cell",
description="grand total",
)
assert result == []
def test_single_cell_error(evaluator: FormulaEvaluator) -> None:
csv = "A,B,C\n10,20,30\n5,15,20\n15,35,99\n"
result = evaluator.evaluate(
page=0,
table_csv=csv,
formula="cell(3,2) = cell(1,2) + cell(2,2)",
scope="single_cell",
description="grand total",
)
assert len(result) == 1
assert result[0].expected == "50"
+144
View File
@@ -0,0 +1,144 @@
"""
Ledger models — unit tests for serialisation and business logic.
These tests confirm the wire contract: models round-trip through JSON
correctly and their helper properties behave as documented.
"""
import pytest
from pydantic import ValidationError
from stirling.contracts.ledger import (
Discrepancy,
DiscrepancyKind,
Evidence,
Folio,
FolioManifest,
FolioType,
Requisition,
Severity,
Verdict,
)
# ---------------------------------------------------------------------------
# FolioManifest
# ---------------------------------------------------------------------------
def test_folio_manifest_round_trip() -> None:
manifest = FolioManifest(
session_id="abc-123",
page_count=3,
folio_types=[FolioType.TEXT, FolioType.IMAGE, FolioType.MIXED],
)
reloaded = FolioManifest.model_validate_json(manifest.model_dump_json())
assert reloaded == manifest
def test_folio_manifest_round_bounds() -> None:
with pytest.raises(ValidationError):
FolioManifest(session_id="x", page_count=1, folio_types=[FolioType.TEXT], round=0)
with pytest.raises(ValidationError):
FolioManifest(session_id="x", page_count=1, folio_types=[FolioType.TEXT], round=4)
# ---------------------------------------------------------------------------
# Requisition
# ---------------------------------------------------------------------------
def test_requisition_empty() -> None:
req = Requisition(rationale="nothing needed")
assert req.need_text == []
assert req.need_tables == []
assert req.need_ocr == []
def test_requisition_type_discriminator() -> None:
req = Requisition(need_text=[0, 1], rationale="needs text")
assert req.type == "requisition"
# ---------------------------------------------------------------------------
# Folio.readable_text
# ---------------------------------------------------------------------------
def test_folio_readable_text_prefers_ocr() -> None:
folio = Folio(page=0, text="digital text", ocr_text="ocr text")
assert folio.readable_text == "ocr text"
def test_folio_readable_text_falls_back_to_text() -> None:
folio = Folio(page=0, text="digital text")
assert folio.readable_text == "digital text"
def test_folio_readable_text_empty_when_none() -> None:
folio = Folio(page=0)
assert folio.readable_text == ""
# ---------------------------------------------------------------------------
# Verdict
# ---------------------------------------------------------------------------
def test_verdict_clean_flag() -> None:
verdict = Verdict(
session_id="s1",
discrepancies=[],
pages_examined=[0, 1],
rounds_taken=2,
summary="All figures balance.",
clean=True,
)
assert verdict.error_count == 0
assert verdict.warning_count == 0
assert verdict.clean is True
def test_verdict_error_and_warning_counts() -> None:
discrepancies = [
Discrepancy(
page=0,
kind=DiscrepancyKind.TALLY,
severity=Severity.ERROR,
description="bad sum",
stated="100",
expected="110",
),
Discrepancy(
page=1,
kind=DiscrepancyKind.CONSISTENCY,
severity=Severity.WARNING,
description="mismatched figure",
stated="500",
expected="550",
),
]
verdict = Verdict(
session_id="s1",
discrepancies=discrepancies,
pages_examined=[0, 1],
rounds_taken=1,
summary="Issues found.",
clean=False,
)
assert verdict.error_count == 1
assert verdict.warning_count == 1
# ---------------------------------------------------------------------------
# Evidence.final_round
# ---------------------------------------------------------------------------
def test_evidence_final_round() -> None:
evidence = Evidence(
session_id="s",
folios=[Folio(page=0, text="hello")],
round=3,
final_round=True,
)
assert evidence.final_round is True
+243
View File
@@ -0,0 +1,243 @@
"""
Ledger Auditor — FastAPI route tests.
Uses FastAPI's TestClient with dependency overrides. All LLM calls are
mocked out; these tests exercise HTTP parsing, serialisation, and response
enveloping only — not the agent's reasoning.
"""
from __future__ import annotations
from collections.abc import Iterator
from decimal import Decimal
import pytest
from fastapi.testclient import TestClient
from stirling.api import app
from stirling.api.dependencies import get_math_auditor_agent
from stirling.config import AppSettings, load_settings
from stirling.contracts.ledger import (
Discrepancy,
DiscrepancyKind,
Evidence,
FolioManifest,
Requisition,
Severity,
Verdict,
)
# ---------------------------------------------------------------------------
# Stubs
# ---------------------------------------------------------------------------
class StubSettingsProvider:
def __call__(self) -> AppSettings:
from conftest import build_app_settings
return build_app_settings()
class StubLedgerAgent:
"""Stub that returns canned responses without touching any model."""
def __init__(
self,
requisition: Requisition | None = None,
verdict: Verdict | None = None,
) -> None:
self._requisition = requisition or _stub_requisition()
self._verdict = verdict or _stub_verdict()
self.examine_calls: list[FolioManifest] = []
self.audit_calls: list[tuple[Evidence, Decimal]] = []
async def examine(self, manifest: FolioManifest) -> Requisition:
self.examine_calls.append(manifest)
return self._requisition
async def audit(self, evidence: Evidence, tolerance: Decimal = Decimal("0.01")) -> Verdict:
self.audit_calls.append((evidence, tolerance))
return self._verdict
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _stub_requisition() -> Requisition:
return Requisition(
need_text=[0, 2],
need_tables=[0],
need_ocr=[1],
rationale="Page 1 is image-only; pages 0 and 2 have financial text.",
)
def _stub_verdict(
clean: bool = True,
discrepancies: list[Discrepancy] | None = None,
) -> Verdict:
return Verdict(
session_id="test-session",
discrepancies=discrepancies or [],
pages_examined=[0, 2],
rounds_taken=2,
summary="No errors found." if clean else "1 tally error found.",
clean=clean,
)
def _manifest_body(**overrides: object) -> dict[str, object]:
base: dict[str, object] = {
"sessionId": "test-session",
"pageCount": 3,
"folioTypes": ["text", "image", "mixed"],
"round": 1,
}
return {**base, **overrides}
def _evidence_body(**overrides: object) -> dict[str, object]:
base: dict[str, object] = {
"sessionId": "test-session",
"folios": [
{"page": 0, "text": "Fee: £100\nTax: £20\nTotal: £120"},
{"page": 2, "text": "Summary: all tallies correct"},
],
"round": 2,
"finalRound": False,
}
return {**base, **overrides}
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def stub_agent() -> StubLedgerAgent:
return StubLedgerAgent()
@pytest.fixture
def client(stub_agent: StubLedgerAgent) -> Iterator[TestClient]:
app.dependency_overrides[load_settings] = StubSettingsProvider()
app.dependency_overrides[get_math_auditor_agent] = lambda: stub_agent
yield TestClient(app, raise_server_exceptions=False)
app.dependency_overrides.pop(load_settings, None)
app.dependency_overrides.pop(get_math_auditor_agent, None)
# ---------------------------------------------------------------------------
# POST /api/v1/ai/math-auditor-agent/examine
# ---------------------------------------------------------------------------
class TestExamineEndpoint:
"""Tests for POST /api/v1/ai/math-auditor-agent/examine."""
def test_returns_200(self, client: TestClient) -> None:
resp = client.post("/api/v1/ai/math-auditor-agent/examine", json=_manifest_body())
assert resp.status_code == 200
def test_response_is_requisition(self, client: TestClient) -> None:
resp = client.post("/api/v1/ai/math-auditor-agent/examine", json=_manifest_body())
body = resp.json()
assert body["type"] == "requisition"
assert body["needText"] == [0, 2]
assert body["needTables"] == [0]
assert body["needOcr"] == [1]
assert "rationale" in body
def test_examine_called_with_parsed_manifest(
self,
client: TestClient,
stub_agent: StubLedgerAgent,
) -> None:
client.post("/api/v1/ai/math-auditor-agent/examine", json=_manifest_body(sessionId="my-session", pageCount=3))
assert len(stub_agent.examine_calls) == 1
manifest = stub_agent.examine_calls[0]
assert manifest.session_id == "my-session"
assert manifest.page_count == 3
def test_content_type_is_json(self, client: TestClient) -> None:
resp = client.post("/api/v1/ai/math-auditor-agent/examine", json=_manifest_body())
assert "application/json" in resp.headers["content-type"]
# ---------------------------------------------------------------------------
# POST /api/v1/ai/math-auditor-agent/deliberate
# ---------------------------------------------------------------------------
class TestDeliberateEndpoint:
"""Tests for POST /api/v1/ai/math-auditor-agent/deliberate."""
def test_returns_200_clean(self, client: TestClient) -> None:
resp = client.post("/api/v1/ai/math-auditor-agent/deliberate", json=_evidence_body())
assert resp.status_code == 200
def test_response_is_verdict(self, client: TestClient) -> None:
resp = client.post("/api/v1/ai/math-auditor-agent/deliberate", json=_evidence_body())
body = resp.json()
assert body["type"] == "verdict"
assert body["clean"] is True
def test_discrepancies_serialised(self, client: TestClient) -> None:
d = Discrepancy(
page=0,
kind=DiscrepancyKind.TALLY,
severity=Severity.ERROR,
description="Column total wrong",
stated="250",
expected="300",
)
stub = StubLedgerAgent(verdict=_stub_verdict(clean=False, discrepancies=[d]))
app.dependency_overrides[get_math_auditor_agent] = lambda: stub
resp = client.post("/api/v1/ai/math-auditor-agent/deliberate", json=_evidence_body())
body = resp.json()
discrepancies = body["discrepancies"]
assert len(discrepancies) == 1
assert discrepancies[0]["kind"] == "tally"
assert discrepancies[0]["severity"] == "error"
assert discrepancies[0]["stated"] == "250"
assert discrepancies[0]["expected"] == "300"
def test_tolerance_query_param_forwarded(
self,
client: TestClient,
stub_agent: StubLedgerAgent,
) -> None:
client.post("/api/v1/ai/math-auditor-agent/deliberate?tolerance=0.05", json=_evidence_body())
assert len(stub_agent.audit_calls) == 1
_, tolerance = stub_agent.audit_calls[0]
assert tolerance == Decimal("0.05")
def test_default_tolerance_when_omitted(
self,
client: TestClient,
stub_agent: StubLedgerAgent,
) -> None:
client.post("/api/v1/ai/math-auditor-agent/deliberate", json=_evidence_body())
_, tolerance = stub_agent.audit_calls[0]
assert tolerance == Decimal("0.01")
def test_invalid_tolerance_returns_400(
self,
client: TestClient,
stub_agent: StubLedgerAgent,
) -> None:
resp = client.post("/api/v1/ai/math-auditor-agent/deliberate?tolerance=notanumber", json=_evidence_body())
assert resp.status_code == 400
def test_final_round_flag_parsed(
self,
client: TestClient,
stub_agent: StubLedgerAgent,
) -> None:
client.post("/api/v1/ai/math-auditor-agent/deliberate", json=_evidence_body(finalRound=True))
evidence, _ = stub_agent.audit_calls[0]
assert evidence.final_round is True
+26 -31
View File
@@ -5,7 +5,6 @@ from dataclasses import dataclass
import pytest
from stirling.agents import PdfEditAgent, PdfEditParameterSelector, PdfEditPlanSelection
from stirling.config import AppSettings
from stirling.contracts import (
EditCannotDoResponse,
EditClarificationRequest,
@@ -13,23 +12,14 @@ from stirling.contracts import (
PdfEditRequest,
ToolOperationStep,
)
from stirling.models.tool_models import CompressParams, OperationId, RotateParams
from stirling.services import build_runtime
def build_test_settings() -> AppSettings:
return AppSettings(
smart_model_name="test",
fast_model_name="test",
smart_model_max_tokens=8192,
fast_model_max_tokens=2048,
)
from stirling.models.tool_models import Angle, FlattenParams, RotatePdfParams, ToolEndpoint
from stirling.services.runtime import AppRuntime
@dataclass(frozen=True)
class ParameterSelectorCall:
request: PdfEditRequest
operation_plan: list[OperationId]
operation_plan: list[ToolEndpoint]
operation_index: int
generated_steps: list[ToolOperationStep]
@@ -41,10 +31,10 @@ class RecordingParameterSelector:
async def select(
self,
request: PdfEditRequest,
operation_plan: list[OperationId],
operation_plan: list[ToolEndpoint],
operation_index: int,
generated_steps: list[ToolOperationStep],
) -> RotateParams | CompressParams:
) -> RotatePdfParams | FlattenParams:
self.calls.append(
ParameterSelectorCall(
request=request,
@@ -54,17 +44,18 @@ class RecordingParameterSelector:
)
)
if operation_index == 0:
return RotateParams(angle=90)
return CompressParams(compression_level=5)
return RotatePdfParams(angle=Angle(90))
return FlattenParams(flatten_only_forms=False, render_dpi=None)
class StubPdfEditAgent(PdfEditAgent):
def __init__(
self,
runtime: AppRuntime,
selection: PdfEditPlanSelection | EditClarificationRequest | EditCannotDoResponse,
parameter_selector: RecordingParameterSelector | PdfEditParameterSelector | None = None,
) -> None:
super().__init__(build_runtime(build_test_settings()))
super().__init__(runtime)
self.selection = selection
if parameter_selector is not None:
self.parameter_selector = parameter_selector
@@ -77,11 +68,12 @@ class StubPdfEditAgent(PdfEditAgent):
@pytest.mark.anyio
async def test_pdf_edit_agent_builds_multi_step_plan() -> None:
async def test_pdf_edit_agent_builds_multi_step_plan(runtime: AppRuntime) -> None:
parameter_selector = RecordingParameterSelector()
agent = StubPdfEditAgent(
runtime,
PdfEditPlanSelection(
operations=[OperationId.ROTATE, OperationId.COMPRESS],
operations=[ToolEndpoint.ROTATE_PDF, ToolEndpoint.FLATTEN],
summary="Rotate the PDF, then compress it.",
rationale="The pages need reorientation before reducing file size.",
),
@@ -98,17 +90,18 @@ async def test_pdf_edit_agent_builds_multi_step_plan() -> None:
assert isinstance(response, EditPlanResponse)
assert response.summary == "Rotate the PDF, then compress it."
assert response.rationale == "The pages need reorientation before reducing file size."
assert [step.tool for step in response.steps] == [OperationId.ROTATE, OperationId.COMPRESS]
assert isinstance(response.steps[0].parameters, RotateParams)
assert isinstance(response.steps[1].parameters, CompressParams)
assert [step.tool for step in response.steps] == [ToolEndpoint.ROTATE_PDF, ToolEndpoint.FLATTEN]
assert isinstance(response.steps[0].parameters, RotatePdfParams)
assert isinstance(response.steps[1].parameters, FlattenParams)
@pytest.mark.anyio
async def test_pdf_edit_agent_passes_previous_steps_to_parameter_selector() -> None:
async def test_pdf_edit_agent_passes_previous_steps_to_parameter_selector(runtime: AppRuntime) -> None:
parameter_selector = RecordingParameterSelector()
agent = StubPdfEditAgent(
runtime,
PdfEditPlanSelection(
operations=[OperationId.ROTATE, OperationId.COMPRESS],
operations=[ToolEndpoint.ROTATE_PDF, ToolEndpoint.FLATTEN],
summary="Rotate the PDF, then compress it.",
),
parameter_selector=parameter_selector,
@@ -127,19 +120,20 @@ async def test_pdf_edit_agent_passes_previous_steps_to_parameter_selector() -> N
assert parameter_selector.calls[1].operation_index == 1
assert parameter_selector.calls[1].generated_steps == [
ToolOperationStep(
tool=OperationId.ROTATE,
parameters=RotateParams(angle=90),
tool=ToolEndpoint.ROTATE_PDF,
parameters=RotatePdfParams(angle=Angle(90)),
)
]
@pytest.mark.anyio
async def test_pdf_edit_agent_returns_clarification_without_partial_plan() -> None:
async def test_pdf_edit_agent_returns_clarification_without_partial_plan(runtime: AppRuntime) -> None:
agent = StubPdfEditAgent(
runtime,
EditClarificationRequest(
question="Which pages should be rotated?",
reason="The request does not say which pages to change.",
)
),
)
response = await agent.handle(PdfEditRequest(user_message="Rotate some pages."))
@@ -148,11 +142,12 @@ async def test_pdf_edit_agent_returns_clarification_without_partial_plan() -> No
@pytest.mark.anyio
async def test_pdf_edit_agent_returns_cannot_do_without_partial_plan() -> None:
async def test_pdf_edit_agent_returns_cannot_do_without_partial_plan(runtime: AppRuntime) -> None:
agent = StubPdfEditAgent(
runtime,
EditCannotDoResponse(
reason="This request requires OCR, which is not part of PDF edit planning.",
)
),
)
response = await agent.handle(PdfEditRequest(user_message="Read this scan and summarize it."))
+31 -24
View File
@@ -3,19 +3,20 @@ from __future__ import annotations
import pytest
from stirling.agents import PdfQuestionAgent
from stirling.config import AppSettings
from stirling.contracts import (
ExtractedFileText,
PdfQuestionAnswerResponse,
PdfQuestionNeedTextResponse,
PdfQuestionNeedContentResponse,
PdfQuestionNotFoundResponse,
PdfQuestionRequest,
PdfTextSelection,
)
from stirling.services import build_runtime
from stirling.services.runtime import AppRuntime
class StubPdfQuestionAgent(PdfQuestionAgent):
def __init__(self, response: PdfQuestionAnswerResponse | PdfQuestionNotFoundResponse) -> None:
super().__init__(build_runtime(build_test_settings()))
def __init__(self, runtime: AppRuntime, response: PdfQuestionAnswerResponse | PdfQuestionNotFoundResponse) -> None:
super().__init__(runtime)
self.response = response
async def _run_answer_agent(
@@ -25,38 +26,39 @@ class StubPdfQuestionAgent(PdfQuestionAgent):
return self.response
def build_test_settings() -> AppSettings:
return AppSettings(
smart_model_name="test",
fast_model_name="test",
smart_model_max_tokens=8192,
fast_model_max_tokens=2048,
def invoice_page() -> ExtractedFileText:
return ExtractedFileText(
file_name="invoice.pdf",
pages=[PdfTextSelection(page_number=1, text="Invoice total: 120.00")],
)
@pytest.mark.anyio
async def test_pdf_question_agent_requires_extracted_text() -> None:
agent = PdfQuestionAgent(build_runtime(build_test_settings()))
async def test_pdf_question_agent_requires_extracted_text(runtime: AppRuntime) -> None:
agent = PdfQuestionAgent(runtime)
response = await agent.handle(PdfQuestionRequest(question="What is the total?", extracted_text=""))
response = await agent.handle(
PdfQuestionRequest(question="What is the total?", page_text=[], file_names=["test.pdf"])
)
assert isinstance(response, PdfQuestionNeedTextResponse)
assert isinstance(response, PdfQuestionNeedContentResponse)
@pytest.mark.anyio
async def test_pdf_question_agent_returns_grounded_answer() -> None:
async def test_pdf_question_agent_returns_grounded_answer(runtime: AppRuntime) -> None:
agent = StubPdfQuestionAgent(
runtime,
PdfQuestionAnswerResponse(
answer="The invoice total is 120.00.",
evidence=["Invoice total: 120.00"],
)
evidence=[invoice_page()],
),
)
response = await agent.handle(
PdfQuestionRequest(
question="What is the total?",
extracted_text="Invoice total: 120.00",
file_name="invoice.pdf",
page_text=[invoice_page()],
file_names=["invoice.pdf"],
)
)
@@ -65,14 +67,19 @@ async def test_pdf_question_agent_returns_grounded_answer() -> None:
@pytest.mark.anyio
async def test_pdf_question_agent_returns_not_found_when_text_is_insufficient() -> None:
agent = StubPdfQuestionAgent(PdfQuestionNotFoundResponse(reason="The answer is not present in the text."))
async def test_pdf_question_agent_returns_not_found_when_text_is_insufficient(runtime: AppRuntime) -> None:
agent = StubPdfQuestionAgent(runtime, PdfQuestionNotFoundResponse(reason="The answer is not present in the text."))
response = await agent.handle(
PdfQuestionRequest(
question="What is the total?",
extracted_text="This page contains only a shipping address.",
file_name="invoice.pdf",
page_text=[
ExtractedFileText(
file_name="invoice.pdf",
pages=[PdfTextSelection(page_number=1, text="This page contains only a shipping address.")],
)
],
file_names=["invoice.pdf"],
)
)
+283
View File
@@ -0,0 +1,283 @@
from __future__ import annotations
import pytest
from stirling.rag.capability import RagCapability
from stirling.rag.chunker import chunk_text
from stirling.rag.service import RagService
from stirling.rag.sqlite_vec_store import SqliteVecStore
from stirling.rag.store import Document, SearchResult
# ── chunk_text ──────────────────────────────────────────────────────────
class TestChunkText:
def test_empty_input_returns_empty(self) -> None:
assert chunk_text("") == []
assert chunk_text(" ") == []
def test_short_text_returns_single_chunk(self) -> None:
text = "Hello world."
chunks = chunk_text(text, chunk_size=100)
assert len(chunks) == 1
assert chunks[0] == "Hello world."
def test_splits_on_paragraph_boundaries(self) -> None:
text = "First paragraph.\n\nSecond paragraph.\n\nThird paragraph."
chunks = chunk_text(text, chunk_size=30, overlap=0)
# Each paragraph fits in 30 chars, so they should be split
assert len(chunks) >= 2
assert "First paragraph." in chunks[0]
def test_long_text_produces_multiple_chunks(self) -> None:
text = " ".join(["word"] * 200)
chunks = chunk_text(text, chunk_size=100, overlap=10)
assert len(chunks) > 1
for chunk in chunks:
# Chunks may slightly exceed due to sentence boundary snapping
assert len(chunk) <= 200 # generous upper bound
def test_overlap_produces_shared_content(self) -> None:
sentences = [f"Sentence number {i}." for i in range(20)]
text = " ".join(sentences)
chunks = chunk_text(text, chunk_size=100, overlap=30)
if len(chunks) >= 2:
# After word-boundary snapping, the second chunk should share
# some content with the tail of the first chunk
words_in_first_tail = chunks[0].split()[-3:] # last 3 words
overlap_text = " ".join(words_in_first_tail)
assert overlap_text in chunks[1], f"Expected overlap '{overlap_text}' in chunk[1]: '{chunks[1][:80]}...'"
# ── SqliteVecStore ──────────────────────────────────────────────────────
class TestSqliteVecStore:
"""Each test gets its own ephemeral store to avoid cross-test dimension conflicts."""
@pytest.mark.anyio
async def test_add_and_search(self) -> None:
store = SqliteVecStore.ephemeral()
docs = [
Document(id="1", text="Python is a programming language", metadata={"source": "test"}),
Document(id="2", text="Java is another programming language", metadata={"source": "test"}),
Document(id="3", text="The weather today is sunny", metadata={"source": "test"}),
]
# Simple 3-dimensional embeddings for testing
embeddings = [
[1.0, 0.0, 0.0],
[0.9, 0.1, 0.0],
[0.0, 0.0, 1.0],
]
await store.add_documents("test-col", docs, embeddings)
# Search with a query close to the programming-related docs
results = await store.search("test-col", [1.0, 0.05, 0.0], top_k=2)
assert len(results) == 2
assert isinstance(results[0], SearchResult)
# The closest should be doc "1" (exact match on first dimension)
assert results[0].document.id == "1"
assert results[0].score > 0.5
@pytest.mark.anyio
async def test_list_and_has_collection(self) -> None:
store = SqliteVecStore.ephemeral()
docs = [Document(id="1", text="test", metadata={})]
await store.add_documents("my-collection", docs, [[1.0, 0.0]])
collections = await store.list_collections()
assert "my-collection" in collections
assert await store.has_collection("my-collection") is True
assert await store.has_collection("nonexistent") is False
@pytest.mark.anyio
async def test_delete_collection(self) -> None:
store = SqliteVecStore.ephemeral()
docs = [Document(id="1", text="test", metadata={})]
await store.add_documents("to-delete", docs, [[1.0]])
assert await store.has_collection("to-delete") is True
await store.delete_collection("to-delete")
assert await store.has_collection("to-delete") is False
@pytest.mark.anyio
async def test_search_empty_collection(self) -> None:
store = SqliteVecStore.ephemeral()
docs = [Document(id="1", text="test", metadata={})]
await store.add_documents("empty-test", docs, [[1.0, 0.0]])
results = await store.search("empty-test", [1.0, 0.0], top_k=5)
assert len(results) == 1
@pytest.mark.anyio
async def test_mismatched_docs_embeddings_raises(self) -> None:
store = SqliteVecStore.ephemeral()
docs = [Document(id="1", text="test", metadata={})]
with pytest.raises(ValueError, match="documents.*embeddings"):
await store.add_documents("bad", docs, [[1.0], [2.0]])
# ── RagService (with stub embedder) ────────────────────────────────────
class StubEmbeddingService:
"""A minimal stub that returns fixed-dimension embeddings for testing."""
def __init__(self, dim: int = 8) -> None:
self._dim = dim
async def embed_query(self, text: str) -> list[float]:
# Deterministic embedding based on hash of text
h = hash(text) % 1000
return [(h + i) / 1000.0 for i in range(self._dim)]
async def embed_documents(self, texts: list[str]) -> list[list[float]]:
return [await self.embed_query(t) for t in texts]
def chunk_and_prepare(
self,
text: str,
source: str = "",
base_metadata: dict[str, str] | None = None,
) -> list[Document]:
from stirling.rag.chunker import chunk_text
chunks = chunk_text(text, 100, 10)
docs = []
for i, chunk in enumerate(chunks):
meta = dict(base_metadata) if base_metadata else {}
meta["source"] = source
meta["chunk_index"] = str(i)
doc_id = f"{source}:chunk:{i}" if source else f"chunk:{i}"
docs.append(Document(id=doc_id, text=chunk, metadata=meta))
return docs
@pytest.fixture
def rag_service() -> RagService:
"""Each RagService test gets its own fresh ephemeral store to avoid dimension conflicts."""
store = SqliteVecStore.ephemeral()
return RagService(embedder=StubEmbeddingService(), store=store, default_top_k=3) # type: ignore[arg-type]
class TestRagService:
@pytest.mark.anyio
async def test_index_and_search(self, rag_service: RagService) -> None:
text = "Python is great for data science. It has many libraries like pandas and numpy."
count = await rag_service.index_text("docs", text, source="guide.pdf")
assert count > 0
results = await rag_service.search("Python libraries", collection="docs")
assert len(results) > 0
assert results[0].document.text # non-empty text
@pytest.mark.anyio
async def test_index_empty_text_returns_zero(self, rag_service: RagService) -> None:
count = await rag_service.index_text("docs", "", source="empty.pdf")
assert count == 0
@pytest.mark.anyio
async def test_search_nonexistent_collection_returns_empty(self, rag_service: RagService) -> None:
results = await rag_service.search("anything", collection="nonexistent")
assert results == []
@pytest.mark.anyio
async def test_search_all_collections(self, rag_service: RagService) -> None:
await rag_service.index_text("col-a", "Machine learning overview.", source="ml.pdf")
await rag_service.index_text("col-b", "Deep learning with neural networks.", source="dl.pdf")
results = await rag_service.search("neural networks")
assert len(results) > 0
@pytest.mark.anyio
async def test_delete_collection(self, rag_service: RagService) -> None:
await rag_service.index_text("temp", "Temporary data.", source="tmp.pdf")
collections = await rag_service.list_collections()
assert "temp" in collections
await rag_service.delete_collection("temp")
collections = await rag_service.list_collections()
assert "temp" not in collections
# ── RagCapability ──────────────────────────────────────────────────────
async def _invoke_search_knowledge(capability: RagCapability, query: str, max_results: int = 5) -> str:
"""Extract and call the search_knowledge tool function from a RagCapability's toolset."""
from pydantic_ai import FunctionToolset
toolset = capability.toolset
assert isinstance(toolset, FunctionToolset)
tool = toolset.tools["search_knowledge"]
return await tool.function(query=query, max_results=max_results) # type: ignore[call-arg] — pyright can't infer the generic tool function's kwargs
class TestRagCapability:
def test_instructions_static_when_collections_pinned(self, rag_service: RagService) -> None:
cap = RagCapability(rag_service, collections=["docs", "manuals"])
instructions = cap.instructions
assert isinstance(instructions, str)
assert "docs, manuals" in instructions
assert "search_knowledge" in instructions
def test_instructions_dynamic_when_no_collections(self, rag_service: RagService) -> None:
cap = RagCapability(rag_service)
instructions = cap.instructions
assert callable(instructions)
@pytest.mark.anyio
async def test_dynamic_instructions_list_available_collections(self, rag_service: RagService) -> None:
await rag_service.index_text("col-a", "Alpha content.", source="a.pdf")
await rag_service.index_text("col-b", "Beta content.", source="b.pdf")
cap = RagCapability(rag_service)
instructions_fn = cap.instructions
assert callable(instructions_fn)
text = await instructions_fn()
assert "col-a" in text
assert "col-b" in text
@pytest.mark.anyio
async def test_dynamic_instructions_when_store_empty(self, rag_service: RagService) -> None:
cap = RagCapability(rag_service)
instructions_fn = cap.instructions
assert callable(instructions_fn)
text = await instructions_fn()
assert "empty" in text.lower()
@pytest.mark.anyio
async def test_search_knowledge_returns_no_results_message_when_empty(self, rag_service: RagService) -> None:
cap = RagCapability(rag_service)
output = await _invoke_search_knowledge(cap, "anything")
assert output == "No relevant results found in the knowledge base."
@pytest.mark.anyio
async def test_search_knowledge_formats_results_with_source_and_score(self, rag_service: RagService) -> None:
await rag_service.index_text("docs", "Python is a programming language.", source="guide.pdf")
cap = RagCapability(rag_service)
output = await _invoke_search_knowledge(cap, "Python")
assert "[Result 1" in output
assert "source: guide.pdf" in output
assert "chunk:" in output
assert "relevance:" in output
@pytest.mark.anyio
async def test_search_knowledge_restricts_to_pinned_collections(self, rag_service: RagService) -> None:
await rag_service.index_text("pinned", "Pinned collection content.", source="pinned.pdf")
await rag_service.index_text("other", "Content in another collection.", source="other.pdf")
cap = RagCapability(rag_service, collections=["pinned"])
output = await _invoke_search_knowledge(cap, "content")
assert "pinned.pdf" in output
assert "other.pdf" not in output
@pytest.mark.anyio
async def test_search_knowledge_respects_max_results(self, rag_service: RagService) -> None:
paragraphs = "\n\n".join(f"Paragraph {i} about topic." for i in range(10))
await rag_service.index_text("bulk", paragraphs, source="bulk.pdf")
cap = RagCapability(rag_service)
output = await _invoke_search_knowledge(cap, "topic", max_results=2)
# Only two results requested, so only Result 1 and Result 2 should appear
assert "[Result 1" in output
assert "[Result 2" in output
assert "[Result 3" not in output
+205
View File
@@ -0,0 +1,205 @@
from __future__ import annotations
from collections.abc import Iterator
import pytest
from fastapi.testclient import TestClient
from stirling.api import app
from stirling.api.dependencies import get_rag_embedding_model, get_rag_service
from stirling.rag import Document, RagService, SqliteVecStore
TEST_EMBEDDING_MODEL = "test-embedder"
class StubEmbedder:
"""Deterministic embeddings for route tests — no network, no provider needed."""
def __init__(self, dim: int = 8) -> None:
self._dim = dim
async def embed_query(self, text: str) -> list[float]:
h = hash(text) % 1000
return [(h + i) / 1000.0 for i in range(self._dim)]
async def embed_documents(self, texts: list[str]) -> list[list[float]]:
return [await self.embed_query(t) for t in texts]
def chunk_and_prepare(
self,
text: str,
source: str = "",
base_metadata: dict[str, str] | None = None,
) -> list[Document]:
from stirling.rag.chunker import chunk_text
chunks = chunk_text(text, 100, 10)
docs = []
for i, chunk in enumerate(chunks):
meta = dict(base_metadata) if base_metadata else {}
meta["source"] = source
meta["chunk_index"] = str(i)
doc_id = f"{source}:chunk:{i}" if source else f"chunk:{i}"
docs.append(Document(id=doc_id, text=chunk, metadata=meta))
return docs
def _build_service() -> RagService:
return RagService(
embedder=StubEmbedder(), # type: ignore[arg-type]
store=SqliteVecStore.ephemeral(),
default_top_k=3,
)
@pytest.fixture
def client() -> Iterator[TestClient]:
service = _build_service()
app.dependency_overrides[get_rag_service] = lambda: service
app.dependency_overrides[get_rag_embedding_model] = lambda: TEST_EMBEDDING_MODEL
try:
yield TestClient(app)
finally:
app.dependency_overrides.pop(get_rag_service, None)
app.dependency_overrides.pop(get_rag_embedding_model, None)
# ── /status ─────────────────────────────────────────────────────────────
def test_status_reports_embedding_model_and_collections(client: TestClient) -> None:
client.post(
"/api/v1/rag/index",
json={"collection": "my-docs", "text": "Hello world.", "source": "a.pdf"},
)
response = client.get("/api/v1/rag/status")
assert response.status_code == 200
body = response.json()
assert body["embeddingModel"] == TEST_EMBEDDING_MODEL
assert "my-docs" in body["collections"]
def test_status_when_empty(client: TestClient) -> None:
response = client.get("/api/v1/rag/status")
assert response.status_code == 200
body = response.json()
assert body == {"embeddingModel": TEST_EMBEDDING_MODEL, "collections": []}
# ── /index ──────────────────────────────────────────────────────────────
def test_index_returns_chunk_count(client: TestClient) -> None:
response = client.post(
"/api/v1/rag/index",
json={"collection": "indexed", "text": "Short text.", "source": "doc.pdf"},
)
assert response.status_code == 200
body = response.json()
assert body["collection"] == "indexed"
assert body["chunksIndexed"] >= 1
def test_index_rejects_empty_collection_name(client: TestClient) -> None:
response = client.post(
"/api/v1/rag/index",
json={"collection": "", "text": "Text.", "source": "x.pdf"},
)
assert response.status_code == 422
def test_index_rejects_oversized_text(client: TestClient) -> None:
huge = "x" * 1_000_001 # Just over the 1MB cap
response = client.post(
"/api/v1/rag/index",
json={"collection": "toobig", "text": huge},
)
assert response.status_code == 422
# ── /search ─────────────────────────────────────────────────────────────
def test_search_returns_results(client: TestClient) -> None:
client.post(
"/api/v1/rag/index",
json={"collection": "search-test", "text": "Python is fun.", "source": "guide.pdf"},
)
response = client.post(
"/api/v1/rag/search",
json={"query": "Python", "collection": "search-test", "topK": 3},
)
assert response.status_code == 200
body = response.json()
assert body["query"] == "Python"
assert len(body["results"]) >= 1
first = body["results"][0]
assert first["source"] == "guide.pdf"
assert "score" in first
def test_search_rejects_empty_collection_name(client: TestClient) -> None:
response = client.post(
"/api/v1/rag/search",
json={"query": "anything", "collection": ""},
)
assert response.status_code == 422
def test_search_without_collection_searches_all(client: TestClient) -> None:
client.post(
"/api/v1/rag/index",
json={"collection": "col-one", "text": "Alpha content.", "source": "one.pdf"},
)
client.post(
"/api/v1/rag/index",
json={"collection": "col-two", "text": "Beta content.", "source": "two.pdf"},
)
response = client.post(
"/api/v1/rag/search",
json={"query": "content"},
)
assert response.status_code == 200
body = response.json()
assert len(body["results"]) >= 1
# ── /collections ────────────────────────────────────────────────────────
def test_collections_empty_when_no_data(client: TestClient) -> None:
response = client.get("/api/v1/rag/collections")
assert response.status_code == 200
assert response.json() == {"collections": []}
def test_collections_lists_indexed(client: TestClient) -> None:
client.post(
"/api/v1/rag/index",
json={"collection": "list-me", "text": "Text.", "source": "x.pdf"},
)
response = client.get("/api/v1/rag/collections")
assert response.status_code == 200
assert "list-me" in response.json()["collections"]
# ── DELETE /collections/{name} ──────────────────────────────────────────
def test_delete_collection_removes_it(client: TestClient) -> None:
client.post(
"/api/v1/rag/index",
json={"collection": "to-delete", "text": "Text.", "source": "x.pdf"},
)
response = client.delete("/api/v1/rag/collections/to-delete")
assert response.status_code == 200
assert response.json() == {"status": "deleted", "collection": "to-delete"}
listing = client.get("/api/v1/rag/collections").json()
assert "to-delete" not in listing["collections"]
def test_delete_nonexistent_collection_is_idempotent(client: TestClient) -> None:
response = client.delete("/api/v1/rag/collections/never-existed")
assert response.status_code == 200
assert response.json() == {"status": "deleted", "collection": "never-existed"}
+29 -38
View File
@@ -1,3 +1,4 @@
from conftest import build_app_settings
from fastapi.testclient import TestClient
from stirling.api import app
@@ -11,7 +12,7 @@ from stirling.api.dependencies import (
get_pdf_question_agent,
get_user_spec_agent,
)
from stirling.config import AppSettings, load_settings
from stirling.config import load_settings
from stirling.contracts import (
AgentDraft,
AgentDraftRequest,
@@ -19,42 +20,30 @@ from stirling.contracts import (
AgentExecutionRequest,
AgentRevisionRequest,
AgentRevisionResponse,
CannotContinueExecutionAction,
EditCannotDoResponse,
OrchestratorRequest,
PdfEditRequest,
PdfQuestionNotFoundResponse,
PdfQuestionRequest,
UnsupportedCapabilityResponse,
)
from stirling.contracts.form_fill import (
AnalysedFileResult,
CannotContinueExecutionAction,
CrossFileRole,
DetectedRole,
DocumentExtractionRequest,
EditCannotDoResponse,
FileFillResult,
FormAnalysisRequest,
FormAnalysisResponse,
FormFillBatchRequest,
FormFillBatchResponse,
KnowledgeUpdateResponse,
OrchestratorRequest,
PdfEditRequest,
PdfQuestionNeedContentResponse,
PdfQuestionNotFoundResponse,
PdfQuestionRequest,
)
from stirling.models.tool_models import RotateParams
class StubSettingsProvider:
def __call__(self) -> AppSettings:
return AppSettings(
smart_model_name="test",
fast_model_name="test",
smart_model_max_tokens=8192,
fast_model_max_tokens=2048,
)
from stirling.models.tool_models import Angle, RotatePdfParams
class StubOrchestratorAgent:
async def handle(self, request: OrchestratorRequest) -> UnsupportedCapabilityResponse:
return UnsupportedCapabilityResponse(capability="pdf_edit", message=request.user_message)
async def handle(self, request: OrchestratorRequest) -> PdfQuestionNeedContentResponse:
return PdfQuestionNeedContentResponse(reason=request.user_message, files=[], max_pages=1, max_characters=1000)
class StubPdfEditAgent:
@@ -127,14 +116,7 @@ class StubExecutionPlanningAgent:
return CannotContinueExecutionAction(reason=str(request.current_step_index))
client: TestClient = TestClient(app)
def override_settings() -> AppSettings:
return StubSettingsProvider()()
app.dependency_overrides[load_settings] = override_settings
app.dependency_overrides[load_settings] = build_app_settings
app.dependency_overrides[get_orchestrator_agent] = lambda: StubOrchestratorAgent()
app.dependency_overrides[get_pdf_edit_agent] = lambda: StubPdfEditAgent()
app.dependency_overrides[get_pdf_question_agent] = lambda: StubPdfQuestionAgent()
@@ -144,6 +126,8 @@ app.dependency_overrides[get_form_analyser_agent] = lambda: StubFormAnalyserAgen
app.dependency_overrides[get_form_filler_agent] = lambda: StubFormFillerAgent()
app.dependency_overrides[get_document_extractor_agent] = lambda: StubDocumentExtractorAgent()
client: TestClient = TestClient(app)
def test_health_route() -> None:
response = client.get("/health")
@@ -153,10 +137,10 @@ def test_health_route() -> None:
def test_orchestrator_route() -> None:
response = client.post("/api/v1/orchestrator", json={"userMessage": "route this"})
response = client.post("/api/v1/orchestrator", json={"userMessage": "route this", "fileNames": ["test.pdf"]})
assert response.status_code == 200
assert response.json()["outcome"] == "unsupported_capability"
assert response.json()["outcome"] == "need_content"
def test_pdf_edit_route() -> None:
@@ -167,7 +151,14 @@ def test_pdf_edit_route() -> None:
def test_pdf_questions_route() -> None:
response = client.post("/api/v1/pdf/questions", json={"question": "what is this?"})
response = client.post(
"/api/v1/pdf/questions",
json={
"question": "what is this?",
"fileNames": ["test.pdf"],
"pageText": [{"fileName": "test.pdf", "pages": [{"pageNumber": 1, "text": "Example"}]}],
},
)
assert response.status_code == 200
assert response.json()["outcome"] == "not_found"
@@ -192,8 +183,8 @@ def test_agent_revise_route() -> None:
"steps": [
{
"kind": "tool",
"tool": "rotate",
"parameters": RotateParams(angle=90).model_dump(by_alias=True),
"tool": "/api/v1/general/rotate-pdf",
"parameters": RotatePdfParams(angle=Angle(90)).model_dump(by_alias=True),
}
],
},
@@ -252,8 +243,8 @@ def test_next_action_route() -> None:
"steps": [
{
"kind": "tool",
"tool": "rotate",
"parameters": RotateParams(angle=90).model_dump(by_alias=True),
"tool": "/api/v1/general/rotate-pdf",
"parameters": RotatePdfParams(angle=Angle(90)).model_dump(by_alias=True),
}
],
},
+41 -23
View File
@@ -1,36 +1,47 @@
from collections.abc import Iterator
import pytest
from stirling.config import AppSettings, load_settings
from stirling.config import AppSettings
from stirling.contracts import (
AgentExecutionRequest,
AgentSpec,
AgentSpecStep,
EditPlanResponse,
ExecutionContext,
OrchestratorRequest,
PdfQuestionAnswerResponse,
ToolOperationStep,
)
from stirling.contracts.form_fill import (
ExtractedFileText,
ExtractedTextArtifact,
KnowledgeEntry,
KnowledgeUpdateResponse,
OrchestratorRequest,
PdfQuestionAnswerResponse,
PdfTextSelection,
ToolOperationStep,
)
from stirling.models.tool_models import OperationId, RotateParams
from stirling.models.tool_models import Angle, RotatePdfParams, ToolEndpoint
def test_orchestrator_request_accepts_user_message() -> None:
request = OrchestratorRequest(user_message="Rotate the PDF")
request = OrchestratorRequest(
user_message="Rotate the PDF",
file_names=["test.pdf"],
artifacts=[
ExtractedTextArtifact(
files=[
ExtractedFileText(
file_name="test.pdf",
pages=[PdfTextSelection(page_number=1, text="Hello")],
)
]
)
],
)
assert request.user_message == "Rotate the PDF"
assert len(request.artifacts) == 1
def test_agent_execution_request_uses_typed_agent_spec() -> None:
steps: list[AgentSpecStep] = [
ToolOperationStep(
tool=OperationId.ROTATE,
parameters=RotateParams(angle=90),
tool=ToolEndpoint.ROTATE_PDF,
parameters=RotatePdfParams(angle=Angle(90)),
)
]
request = AgentExecutionRequest(
@@ -48,13 +59,13 @@ def test_agent_execution_request_uses_typed_agent_spec() -> None:
def test_edit_plan_response_has_typed_steps() -> None:
steps = [ToolOperationStep(tool=OperationId.ROTATE, parameters=RotateParams(angle=90))]
steps = [ToolOperationStep(tool=ToolEndpoint.ROTATE_PDF, parameters=RotatePdfParams(angle=Angle(90)))]
response = EditPlanResponse(
summary="Rotate the input PDF by 90 degrees.",
steps=steps,
)
assert response.steps[0].tool == OperationId.ROTATE
assert response.steps[0].tool == ToolEndpoint.ROTATE_PDF
def test_pdf_question_answer_defaults_evidence_list() -> None:
@@ -63,13 +74,6 @@ def test_pdf_question_answer_defaults_evidence_list() -> None:
assert response.evidence == []
@pytest.fixture(autouse=True)
def clear_settings_cache() -> Iterator[None]:
load_settings.cache_clear()
yield
load_settings.cache_clear()
def test_knowledge_update_response_discriminator() -> None:
update = KnowledgeUpdateResponse(
proposed_entries=[KnowledgeEntry(key="name", value="John", source="CV")],
@@ -79,11 +83,25 @@ def test_knowledge_update_response_discriminator() -> None:
def test_app_settings_accepts_model_configuration() -> None:
from pathlib import Path
from stirling.config import RagBackend
settings = AppSettings(
smart_model_name="claude-sonnet-4-5-20250929",
fast_model_name="claude-haiku-4-5-20251001",
smart_model_max_tokens=8192,
fast_model_max_tokens=2048,
rag_backend=RagBackend.SQLITE,
rag_embedding_model="voyageai:voyage-4",
rag_store_path=Path(":memory:"),
rag_pgvector_dsn="",
rag_chunk_size=512,
rag_chunk_overlap=64,
rag_default_top_k=5,
posthog_enabled=False,
posthog_api_key="",
posthog_host="https://eu.i.posthog.com",
)
assert settings.smart_model_name
+30 -34
View File
@@ -4,7 +4,6 @@ import pytest
from pydantic import ValidationError
from stirling.agents import UserSpecAgent
from stirling.config import AppSettings
from stirling.contracts import (
AgentDraft,
AgentDraftRequest,
@@ -14,35 +13,28 @@ from stirling.contracts import (
EditPlanResponse,
ToolOperationStep,
)
from stirling.models.tool_models import CompressParams, OperationId, RotateParams
from stirling.services import build_runtime
def build_test_settings() -> AppSettings:
return AppSettings(
smart_model_name="test",
fast_model_name="test",
smart_model_max_tokens=8192,
fast_model_max_tokens=2048,
)
from stirling.models.tool_models import Angle, FlattenParams, RotatePdfParams, ToolEndpoint
from stirling.services.runtime import AppRuntime
class StubUserSpecAgent(UserSpecAgent):
def __init__(self, draft_result: AgentDraft, revision_result: AgentDraft) -> None:
super().__init__(build_runtime(build_test_settings()))
def __init__(self, runtime: AppRuntime, draft_result: AgentDraft, revision_result: AgentDraft) -> None:
super().__init__(runtime)
self.draft_result = draft_result
self.revision_result = revision_result
self.edit_plan = EditPlanResponse(
summary="Rotate the document.",
steps=[
ToolOperationStep(
tool=OperationId.ROTATE,
parameters=RotateParams(angle=90),
tool=ToolEndpoint.ROTATE_PDF,
parameters=RotatePdfParams(angle=Angle(90)),
)
],
)
async def _build_edit_plan(self, user_message: str) -> EditPlanResponse:
async def _build_edit_plan(
self, user_message: str, conversation_history: list[ConversationMessage]
) -> EditPlanResponse:
return self.edit_plan
async def _run_draft_agent(self, request: AgentDraftRequest, edit_plan: EditPlanResponse) -> AgentDraft:
@@ -53,10 +45,12 @@ class StubUserSpecAgent(UserSpecAgent):
class ClarifyingUserSpecAgent(UserSpecAgent):
def __init__(self) -> None:
super().__init__(build_runtime(build_test_settings()))
def __init__(self, runtime: AppRuntime) -> None:
super().__init__(runtime)
async def _build_edit_plan(self, user_message: str) -> EditClarificationRequest:
async def _build_edit_plan(
self, user_message: str, conversation_history: list[ConversationMessage]
) -> EditClarificationRequest:
return EditClarificationRequest(
question="Which pages should be changed?",
reason="The request does not specify the target pages.",
@@ -64,16 +58,17 @@ class ClarifyingUserSpecAgent(UserSpecAgent):
@pytest.mark.anyio
async def test_user_spec_agent_drafts_agent_spec() -> None:
async def test_user_spec_agent_drafts_agent_spec(runtime: AppRuntime) -> None:
agent = StubUserSpecAgent(
runtime,
AgentDraft(
name="Invoice Cleanup",
description="Prepare invoices for review.",
objective="Normalize invoices before accounting review.",
steps=[
ToolOperationStep(
tool=OperationId.ROTATE,
parameters=RotateParams(angle=90),
tool=ToolEndpoint.ROTATE_PDF,
parameters=RotatePdfParams(angle=Angle(90)),
)
],
),
@@ -100,19 +95,20 @@ async def test_user_spec_agent_drafts_agent_spec() -> None:
@pytest.mark.anyio
async def test_user_spec_agent_revises_existing_draft() -> None:
async def test_user_spec_agent_revises_existing_draft(runtime: AppRuntime) -> None:
current_draft = AgentDraft(
name="Invoice Cleanup",
description="Prepare invoices for review.",
objective="Normalize invoices before accounting review.",
steps=[
ToolOperationStep(
tool=OperationId.ROTATE,
parameters=RotateParams(angle=90),
tool=ToolEndpoint.ROTATE_PDF,
parameters=RotatePdfParams(angle=Angle(90)),
)
],
)
agent = StubUserSpecAgent(
runtime,
draft_result=current_draft,
revision_result=AgentDraft(
name="Invoice Cleanup",
@@ -120,12 +116,12 @@ async def test_user_spec_agent_revises_existing_draft() -> None:
objective="Normalize invoices before accounting review.",
steps=[
ToolOperationStep(
tool=OperationId.ROTATE,
parameters=RotateParams(angle=90),
tool=ToolEndpoint.ROTATE_PDF,
parameters=RotatePdfParams(angle=Angle(90)),
),
ToolOperationStep(
tool=OperationId.COMPRESS,
parameters=CompressParams(compression_level=5),
tool=ToolEndpoint.FLATTEN,
parameters=FlattenParams(flatten_only_forms=False, render_dpi=None),
),
],
),
@@ -146,14 +142,14 @@ async def test_user_spec_agent_revises_existing_draft() -> None:
def test_tool_operation_step_rejects_mismatched_parameters() -> None:
with pytest.raises(ValidationError):
ToolOperationStep(
tool=OperationId.ROTATE,
parameters=CompressParams(compression_level=5),
tool=ToolEndpoint.ROTATE_PDF,
parameters=FlattenParams(flatten_only_forms=False, render_dpi=None),
)
@pytest.mark.anyio
async def test_user_spec_agent_propagates_edit_clarification() -> None:
agent = ClarifyingUserSpecAgent()
async def test_user_spec_agent_propagates_edit_clarification(runtime: AppRuntime) -> None:
agent = ClarifyingUserSpecAgent(runtime)
response = await agent.draft(AgentDraftRequest(user_message="Build an agent to rotate some pages."))