mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 11:23:10 +02:00
Feat/math validation agent (#6012)
Co-authored-by: James Brunton <[email protected]> Co-authored-by: EthanHealy01 <[email protected]>
This commit is contained in:
co-authored by
James Brunton
EthanHealy01
parent
688f7f2013
commit
de8c483054
@@ -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,249 @@
|
||||
"""
|
||||
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:
|
||||
return AppSettings(
|
||||
smart_model_name="test",
|
||||
fast_model_name="test",
|
||||
smart_model_max_tokens=8192,
|
||||
fast_model_max_tokens=2048,
|
||||
posthog_enabled=False,
|
||||
posthog_api_key="",
|
||||
posthog_host="https://eu.i.posthog.com",
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
Reference in New Issue
Block a user