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:
@@ -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)
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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."))
|
||||
|
||||
@@ -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"],
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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"}
|
||||
@@ -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),
|
||||
}
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user