diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 928602fdd..527954752 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -16,7 +16,7 @@ repos: hooks: - id: codespell args: - - --ignore-words-list=thirdParty,tabEl,tabEls,Sie,ist + - --ignore-words-list=thirdParty,tabEl,tabEls,Sie,ist,fulfilment - --skip="./.*,*.csv,*.json,*.ambr" - --quiet-level=2 files: \.(html|css|js|py|md)$ diff --git a/app/proprietary/build.gradle b/app/proprietary/build.gradle index 141734a90..1c03a435f 100644 --- a/app/proprietary/build.gradle +++ b/app/proprietary/build.gradle @@ -59,6 +59,14 @@ dependencies { // https://mvnrepository.com/artifact/com.bucket4j/bucket4j_jdk17 implementation "org.bouncycastle:bcprov-jdk18on:$bouncycastleVersion" + // Tabula table extraction — used by MathAuditorOrchestrator + implementation ('technology.tabula:tabula:1.0.5') { + exclude group: 'org.slf4j', module: 'slf4j-simple' + exclude group: 'org.bouncycastle', module: 'bcprov-jdk15on' + exclude group: 'com.google.code.gson', module: 'gson' + } + implementation 'com.google.code.gson:gson:2.13.2' + api 'io.micrometer:micrometer-registry-prometheus' api "io.jsonwebtoken:jjwt-api:$jwtVersion" diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/MathAuditorAgentController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/MathAuditorAgentController.java new file mode 100644 index 000000000..3691266b7 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/MathAuditorAgentController.java @@ -0,0 +1,97 @@ +package stirling.software.proprietary.controller.api; + +import java.io.IOException; +import java.math.BigDecimal; + +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.proprietary.model.api.ai.Verdict; +import stirling.software.proprietary.service.MathAuditorOrchestrator; + +/** + * Public entry point for the Math Auditor Agent (mathAuditorAgent). + * + *

Accepts a PDF from the client, hands it to the {@link MathAuditorOrchestrator} which runs the + * multi-round Java-Python negotiation, and returns the Auditor's {@link Verdict}. + * + *

The raw PDF never leaves Java. Python receives only structured text and CSV data. + */ +@Slf4j +@RestController +@RequestMapping("/api/v1/ai") +@RequiredArgsConstructor +@Tag(name = "AI Engine", description = "AI-powered document analysis endpoints.") +public class MathAuditorAgentController { + + private final MathAuditorOrchestrator orchestrator; + + @PostMapping(value = "/math-auditor-agent", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @Operation( + summary = "Validate mathematical calculations in a PDF", + description = + """ + Analyses a PDF document for mathematical errors using the Math Auditor Agent. + + The auditor checks: + - Table row and column totals (tally errors) + - Inline arithmetic expressions (e.g. "100 + 200 = 300") + - Cross-page figure consistency (same figure cited differently on different pages) + - Prose claims about percentages, growth rates, and comparisons + + The PDF is processed entirely on the Java side; only extracted text and table data + are sent to the AI engine. + + Input: PDF Output: JSON Type: SISO + """) + public ResponseEntity mathAuditorAgent( + @Parameter(description = "The PDF document to audit", required = true) + @RequestParam("fileInput") + MultipartFile fileInput, + @Parameter( + description = + "Arithmetic tolerance — differences smaller than this are" + + " ignored (default: 0.01)") + @RequestParam(value = "tolerance", defaultValue = "0.01") + BigDecimal tolerance) { + + String contentType = fileInput.getContentType(); + if (contentType == null || !contentType.equals("application/pdf")) { + return ResponseEntity.badRequest().build(); + } + + if (tolerance.compareTo(BigDecimal.ZERO) < 0) { + return ResponseEntity.badRequest().build(); + } + + String safeName = + fileInput.getOriginalFilename() != null + ? fileInput.getOriginalFilename().replaceAll("[\\r\\n]", "_") + : ""; + log.info("[math-auditor-agent] request file={} tolerance={}", safeName, tolerance); + + try { + Verdict verdict = orchestrator.audit(fileInput, tolerance); + return ResponseEntity.ok(verdict); + } catch (IOException e) { + log.error("[math-auditor-agent] IO error during audit", e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); + } catch (Exception e) { + log.error("[math-auditor-agent] unexpected error during audit", e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); + } + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/AuditDiscrepancy.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/AuditDiscrepancy.java new file mode 100644 index 000000000..ba7cd596d --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/AuditDiscrepancy.java @@ -0,0 +1,21 @@ +package stirling.software.proprietary.model.api.ai; + +/** + * A single mathematical error found by the Python Auditor. + * + * @param page 0-indexed page number where the discrepancy appears. + * @param kind Category of the discrepancy. + * @param severity Whether this is a definite mistake or a possible ambiguity. + * @param description Human-readable explanation of the error. + * @param stated The value as it appears in the document. + * @param expected The value the Auditor calculated. + * @param context Surrounding text or table fragment for traceability. + */ +public record AuditDiscrepancy( + int page, + DiscrepancyKind kind, + AuditSeverity severity, + String description, + String stated, + String expected, + String context) {} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/AuditSeverity.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/AuditSeverity.java new file mode 100644 index 000000000..1bef46a44 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/AuditSeverity.java @@ -0,0 +1,17 @@ +package stirling.software.proprietary.model.api.ai; + +import com.fasterxml.jackson.annotation.JsonValue; + +/** + * Severity of a mathematical discrepancy. Mirrors the Python {@code Severity} enum in {@code + * contracts/ledger.py}. + */ +public enum AuditSeverity { + ERROR, + WARNING; + + @JsonValue + public String toJson() { + return name().toLowerCase(); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/DiscrepancyKind.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/DiscrepancyKind.java new file mode 100644 index 000000000..54c7ac66f --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/DiscrepancyKind.java @@ -0,0 +1,19 @@ +package stirling.software.proprietary.model.api.ai; + +import com.fasterxml.jackson.annotation.JsonValue; + +/** + * Category of a mathematical discrepancy found by the auditor. Mirrors the Python {@code + * DiscrepancyKind} enum in {@code contracts/ledger.py}. + */ +public enum DiscrepancyKind { + TALLY, + ARITHMETIC, + CONSISTENCY, + STATEMENT; + + @JsonValue + public String toJson() { + return name().toLowerCase(); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/Evidence.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/Evidence.java new file mode 100644 index 000000000..98b3a0945 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/Evidence.java @@ -0,0 +1,24 @@ +package stirling.software.proprietary.model.api.ai; + +import java.util.List; + +/** + * Java's fulfilment package: the extracted content the Python Auditor asked for. + * + *

Sent after Java has fulfilled a {@link Requisition}. When {@code finalRound} is {@code true}, + * the Auditor must return a {@link Verdict} — Java will not honour further Requisitions. + * + * @param sessionId Matches the session opened by the original client request. + * @param folios The extracted page content for each page in the Requisition. + * @param round Which negotiation round this Evidence belongs to (1–3). + * @param finalRound When {@code true}, the Auditor must commit to a Verdict this round. + * @param unauditablePages Pages that were requested but could not be fulfilled — e.g. OCR was asked + * for but is not yet wired. The Auditor echoes these into {@link Verdict#unauditablePages()} so + * the client knows coverage is incomplete. + */ +public record Evidence( + String sessionId, + List folios, + int round, + boolean finalRound, + List unauditablePages) {} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/Folio.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/Folio.java new file mode 100644 index 000000000..7126e5935 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/Folio.java @@ -0,0 +1,17 @@ +package stirling.software.proprietary.model.api.ai; + +import java.util.List; + +/** + * One page's worth of extracted content, assembled by Java in response to a {@link Requisition}. + * + *

Only the fields explicitly requested will be populated; unused fields are {@code null}. + * + * @param page 0-indexed page number. + * @param text PDFBox plain-text extraction result (null if not requested). + * @param tables Tabula CSV strings, one per table found on the page (null if not requested). + * @param ocrText OCRmyPDF output text (null if not requested or OCR not available). + * @param ocrConfidence Mean character confidence from OCRmyPDF, 0.0–1.0 (null if OCR not run). + */ +public record Folio( + int page, String text, List tables, String ocrText, Double ocrConfidence) {} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/FolioManifest.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/FolioManifest.java new file mode 100644 index 000000000..49ba60fac --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/FolioManifest.java @@ -0,0 +1,18 @@ +package stirling.software.proprietary.model.api.ai; + +import java.util.List; + +/** + * Java's opening move in the audit negotiation. + * + *

Built from a cheap PDFBox scan (character count + image detection) with no OCR or Tabula + * involved. Sent to the Python Examiner, which replies with a {@link Requisition}. + * + * @param sessionId Opaque handle Java uses to locate the PDF on disk during this audit session. + * @param pageCount Total number of pages in the document. + * @param folioTypes One {@link FolioType} per page (0-indexed). {@code folioTypes.size() == + * pageCount}. + * @param round Which negotiation round this manifest belongs to (1–3). + */ +public record FolioManifest( + String sessionId, int pageCount, List folioTypes, int round) {} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/FolioType.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/FolioType.java new file mode 100644 index 000000000..a10625deb --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/FolioType.java @@ -0,0 +1,21 @@ +package stirling.software.proprietary.model.api.ai; + +import com.fasterxml.jackson.annotation.JsonValue; + +/** + * Java's classification of a single PDF page after a cheap PDFBox character-count scan. Mirrors the + * Python {@code FolioType} enum in {@code ledger/models.py}. + */ +public enum FolioType { + /** Selectable text layer is present — PDFBox can extract text directly. */ + TEXT, + /** Image-only page — OCRmyPDF is required before any text is available. */ + IMAGE, + /** Partial text layer plus embedded images — both PDFBox and OCRmyPDF may be useful. */ + MIXED; + + @JsonValue + public String toJson() { + return name().toLowerCase(); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/Requisition.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/Requisition.java new file mode 100644 index 000000000..05de6df97 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/Requisition.java @@ -0,0 +1,30 @@ +package stirling.software.proprietary.model.api.ai; + +import java.util.List; + +/** + * The Python Examiner's shopping list: which pages Java must extract before the Auditor can form an + * opinion. + * + *

Java parses this from the Examiner's response, fulfils it (text / tables / OCR), and sends the + * results back as an {@link Evidence} payload. + * + * @param type Discriminator — always {@code "requisition"}. + * @param needText 0-indexed page numbers requiring PDFBox plain-text extraction. + * @param needTables 0-indexed page numbers requiring Tabula CSV extraction. + * @param needOcr 0-indexed page numbers requiring OCRmyPDF. + * @param rationale Human-readable reason logged for observability. + */ +public record Requisition( + String type, + List needText, + List needTables, + List needOcr, + String rationale) { + + public boolean isEmpty() { + return (needText == null || needText.isEmpty()) + && (needTables == null || needTables.isEmpty()) + && (needOcr == null || needOcr.isEmpty()); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/Verdict.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/Verdict.java new file mode 100644 index 000000000..4d859f76c --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/Verdict.java @@ -0,0 +1,43 @@ +package stirling.software.proprietary.model.api.ai; + +import java.util.List; + +/** + * The Auditor's final opinion on the document's mathematical integrity. + * + *

This is the terminal message in the audit negotiation; Java returns it to the client once + * received from Python. + * + * @param type Discriminator — always {@code "verdict"}. + * @param sessionId Matches the session opened by the original client request. + * @param discrepancies Every mathematical error found, sorted by page. + * @param pagesExamined 0-indexed page numbers the Auditor actually inspected. + * @param roundsTaken How many negotiation rounds were needed (1–3). + * @param summary One or two sentences suitable for the end user. + * @param clean {@code true} iff no errors were found (warnings are tolerated). + * @param unauditablePages Pages that could not be audited — typically image-only pages for which + * OCR was requested but is not yet wired. The client should indicate that these pages were not + * checked. + */ +public record Verdict( + String type, + String sessionId, + List discrepancies, + List pagesExamined, + int roundsTaken, + String summary, + boolean clean, + List unauditablePages) { + + public long errorCount() { + return discrepancies == null + ? 0 + : discrepancies.stream().filter(d -> d.severity() == AuditSeverity.ERROR).count(); + } + + public long warningCount() { + return discrepancies == null + ? 0 + : discrepancies.stream().filter(d -> d.severity() == AuditSeverity.WARNING).count(); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/pdf/FlexibleCSVWriter.java b/app/proprietary/src/main/java/stirling/software/proprietary/pdf/FlexibleCSVWriter.java new file mode 100644 index 000000000..a22357a7e --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/pdf/FlexibleCSVWriter.java @@ -0,0 +1,17 @@ +package stirling.software.proprietary.pdf; + +import org.apache.commons.csv.CSVFormat; + +import technology.tabula.writers.CSVWriter; + +/** Exposes Tabula's protected {@link CSVWriter#CSVWriter(CSVFormat)} constructor. */ +public class FlexibleCSVWriter extends CSVWriter { + + public FlexibleCSVWriter() { + super(); + } + + public FlexibleCSVWriter(CSVFormat csvFormat) { + super(csvFormat); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/MathAuditorOrchestrator.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/MathAuditorOrchestrator.java new file mode 100644 index 000000000..92c595b43 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/MathAuditorOrchestrator.java @@ -0,0 +1,226 @@ +package stirling.software.proprietary.service; + +import java.io.IOException; +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.UUID; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.service.CustomPDFDocumentFactory; +import stirling.software.proprietary.model.api.ai.Evidence; +import stirling.software.proprietary.model.api.ai.Folio; +import stirling.software.proprietary.model.api.ai.FolioManifest; +import stirling.software.proprietary.model.api.ai.FolioType; +import stirling.software.proprietary.model.api.ai.Requisition; +import stirling.software.proprietary.model.api.ai.Verdict; + +import tools.jackson.databind.ObjectMapper; + +/** + * Orchestrator for the Math Auditor Agent (mathAuditorAgent). + * + *

Manages a four-step Java-Python protocol: + * + *

    + *
  1. Classify all pages cheaply with PDFBox (no OCR or Tabula yet). + *
  2. Send the {@link FolioManifest} to the Python Examiner; receive a {@link Requisition}. + *
  3. Fulfil the Requisition (text / tables / OCR) for only the requested pages. + *
  4. Send the {@link Evidence} to the Python Auditor; receive a {@link Verdict}. + *
+ * + *

The raw PDF never leaves Java. Python only receives structured text and CSV data. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class MathAuditorOrchestrator { + + private static final String EXAMINE_PATH = "/api/v1/ai/math-auditor-agent/examine"; + private static final String DELIBERATE_PATH = "/api/v1/ai/math-auditor-agent/deliberate"; + + private final AiEngineClient aiEngineClient; + private final CustomPDFDocumentFactory pdfDocumentFactory; + private final PdfContentExtractor pdfContentExtractor; + private final ObjectMapper objectMapper; + + /** + * Run a full math audit against the supplied PDF file. + * + * @param pdfFile The uploaded PDF to audit. + * @param tolerance Arithmetic tolerance — differences smaller than this are ignored. + * @return The Auditor's final Verdict. + */ + public Verdict audit(MultipartFile pdfFile, BigDecimal tolerance) throws IOException { + String sessionId = UUID.randomUUID().toString(); + log.info( + "[math-auditor-agent] audit started session={} file={} tolerance={}", + sessionId, + pdfFile.getOriginalFilename(), + tolerance); + + try (PDDocument document = pdfDocumentFactory.load(pdfFile)) { + // Round 1: classify pages cheaply; send manifest; get requisition + List folioTypes = classifyPages(document); + FolioManifest manifest = + new FolioManifest(sessionId, document.getNumberOfPages(), folioTypes, 1); + + Requisition requisition = callExamine(manifest); + log.info( + "[math-auditor-agent] session={} requisition received: {}", + sessionId, + requisition.rationale()); + + // Round 2: fulfil the requisition and get verdict + Evidence evidence = fulfil(document, sessionId, requisition, 2, true); + Verdict verdict = callDeliberate(evidence, tolerance); + + if (verdict == null) { + log.error( + "[math-auditor-agent] session={} null Verdict from deliberate", sessionId); + throw new IllegalStateException("Math Auditor Agent returned null Verdict"); + } + + log.info( + "[math-auditor-agent] session={} verdict: {} errors, {} warnings," + + " clean={}", + sessionId, + verdict.errorCount(), + verdict.warningCount(), + verdict.clean()); + return verdict; + } + } + + // ----------------------------------------------------------------------- + // Python engine calls + // ----------------------------------------------------------------------- + + private Requisition callExamine(FolioManifest manifest) throws IOException { + String requestBody = objectMapper.writeValueAsString(manifest); + log.info( + "[math-auditor-agent] POST {} session={} round={}", + EXAMINE_PATH, + manifest.sessionId(), + manifest.round()); + String responseBody = aiEngineClient.post(EXAMINE_PATH, requestBody); + return objectMapper.readValue(responseBody, Requisition.class); + } + + private Verdict callDeliberate(Evidence evidence, BigDecimal tolerance) throws IOException { + String path = DELIBERATE_PATH + "?tolerance=" + tolerance.toPlainString(); + String requestBody = objectMapper.writeValueAsString(evidence); + log.info( + "[math-auditor-agent] POST {} session={} round={} final={}", + path, + evidence.sessionId(), + evidence.round(), + evidence.finalRound()); + String responseBody = aiEngineClient.post(path, requestBody); + return objectMapper.readValue(responseBody, Verdict.class); + } + + // ----------------------------------------------------------------------- + // Page classification + // ----------------------------------------------------------------------- + + private List classifyPages(PDDocument document) throws IOException { + List types = new ArrayList<>(); + for (int page = 1; page <= document.getNumberOfPages(); page++) { + types.add(pdfContentExtractor.classifyPage(document, page)); + } + return types; + } + + // ----------------------------------------------------------------------- + // Requisition fulfilment + // ----------------------------------------------------------------------- + + private Evidence fulfil( + PDDocument document, + String sessionId, + Requisition requisition, + int round, + boolean finalRound) + throws IOException { + + List allPages = + union(requisition.needText(), requisition.needTables(), requisition.needOcr()); + int totalPages = document.getNumberOfPages(); + allPages.removeIf(page -> page < 0 || page >= totalPages); + if (allPages.isEmpty()) { + log.warn( + "[math-auditor-agent] session={} all requested pages are out of bounds", + sessionId); + } + List folios = new ArrayList<>(); + List unauditablePages = new ArrayList<>(); + + for (int page : allPages) { + // Page indices from Python are 0-based; PdfContentExtractor uses 1-based + int pageNumber = page + 1; + String text = null; + List tables = null; + String ocrText = null; + + if (contains(requisition.needText(), page)) { + text = pdfContentExtractor.extractPageTextRaw(document, pageNumber); + } + if (contains(requisition.needTables(), page)) { + tables = pdfContentExtractor.extractTablesAsCsv(document, pageNumber); + } + if (contains(requisition.needOcr(), page)) { + log.warn( + "[math-auditor-agent] session={} OCR requested for page {} but not yet" + + " wired - marking unauditable", + sessionId, + page); + unauditablePages.add(page); + } + + if (text != null || tables != null) { + folios.add(new Folio(page, text, tables, ocrText, null)); + } + } + + log.info( + "[math-auditor-agent] session={} fulfilled round {} with {} folios, {}" + + " unauditable pages", + sessionId, + round, + folios.size(), + unauditablePages.size()); + return new Evidence(sessionId, folios, round, finalRound, unauditablePages); + } + + // ----------------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------------- + + @SafeVarargs + private static List union(List... lists) { + List result = new ArrayList<>(); + for (List list : lists) { + if (list != null) { + for (int page : list) { + if (!result.contains(page)) { + result.add(page); + } + } + } + } + Collections.sort(result); + return result; + } + + private static boolean contains(List list, int value) { + return list != null && list.contains(value); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/PdfContentExtractor.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/PdfContentExtractor.java index be3f86a5a..0716b2ca8 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/service/PdfContentExtractor.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/PdfContentExtractor.java @@ -1,7 +1,9 @@ package stirling.software.proprietary.service; import java.io.IOException; +import java.io.StringWriter; import java.util.ArrayList; +import java.util.Collections; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; @@ -9,6 +11,8 @@ import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; +import org.apache.commons.csv.CSVFormat; +import org.apache.commons.csv.QuoteMode; import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.text.PDFTextStripper; import org.springframework.stereotype.Service; @@ -20,9 +24,17 @@ import lombok.Data; import lombok.extern.slf4j.Slf4j; import stirling.software.common.util.ExceptionUtils; +import stirling.software.common.util.PdfUtils; import stirling.software.proprietary.model.api.ai.AiPdfContentType; import stirling.software.proprietary.model.api.ai.AiWorkflowFileRequest; import stirling.software.proprietary.model.api.ai.AiWorkflowTextSelection; +import stirling.software.proprietary.model.api.ai.FolioType; +import stirling.software.proprietary.pdf.FlexibleCSVWriter; + +import technology.tabula.ObjectExtractor; +import technology.tabula.Page; +import technology.tabula.Table; +import technology.tabula.extractors.SpreadsheetExtractionAlgorithm; @Slf4j @Service @@ -30,8 +42,84 @@ public class PdfContentExtractor { private static final int MAX_CHARACTERS_PER_PAGE = 4_000; + private static final int TEXT_PRESENCE_THRESHOLD = 20; + record LoadedFile(String fileName, PDDocument document) {} + // ----------------------------------------------------------------------- + // Low-level extraction methods (usable by any agent) + // ----------------------------------------------------------------------- + + /** + * Classify a single page as TEXT, IMAGE, or MIXED. + * + * @param document the open PDF + * @param pageNumber 1-based page number + */ + public FolioType classifyPage(PDDocument document, int pageNumber) throws IOException { + PDFTextStripper stripper = new PDFTextStripper(); + stripper.setStartPage(pageNumber); + stripper.setEndPage(pageNumber); + String text = stripper.getText(document).trim(); + + boolean hasText = text.length() > TEXT_PRESENCE_THRESHOLD; + boolean hasImages = PdfUtils.hasImagesOnPage(document.getPage(pageNumber - 1)); + + if (hasText && hasImages) { + return FolioType.MIXED; + } else if (hasText) { + return FolioType.TEXT; + } else { + return FolioType.IMAGE; + } + } + + /** + * Extract plain text from a single page, clipped to {@link #MAX_CHARACTERS_PER_PAGE}. + * + * @param document the open PDF + * @param pageNumber 1-based page number + * @return trimmed text, or empty string if the page has no extractable text + */ + public String extractPageTextRaw(PDDocument document, int pageNumber) throws IOException { + PDFTextStripper stripper = new PDFTextStripper(); + stripper.setStartPage(pageNumber); + stripper.setEndPage(pageNumber); + String text = stripper.getText(document).trim(); + return clip(text, MAX_CHARACTERS_PER_PAGE); + } + + /** + * Extract all tables from a single page as CSV strings. + * + * @param document the open PDF + * @param pageNumber 1-based page number + * @return list of CSV strings (one per table), empty if no tables found + */ + public List extractTablesAsCsv(PDDocument document, int pageNumber) throws IOException { + SpreadsheetExtractionAlgorithm sea = new SpreadsheetExtractionAlgorithm(); + CSVFormat format = + CSVFormat.EXCEL.builder().setEscape('"').setQuoteMode(QuoteMode.ALL).build(); + List csvStrings = new ArrayList<>(); + + try (ObjectExtractor extractor = new ObjectExtractor(document)) { + Page tabulaPage = extractor.extract(pageNumber); + List tables = sea.extract(tabulaPage); + + for (Table table : tables) { + StringWriter sw = new StringWriter(); + FlexibleCSVWriter csvWriter = new FlexibleCSVWriter(format); + csvWriter.write(sw, Collections.singletonList(table)); + csvStrings.add(sw.toString()); + } + } + return csvStrings; + } + + // ----------------------------------------------------------------------- + // Workflow extraction (used by AiWorkflowService) + // ----------------------------------------------------------------------- + /** * Extracts content from the loaded files according to the requested content types and budget * constraints. diff --git a/engine/config/.env.example b/engine/config/.env.example index 55e2cfc30..bfcbe3f57 100644 --- a/engine/config/.env.example +++ b/engine/config/.env.example @@ -12,3 +12,9 @@ STIRLING_FAST_MODEL_MAX_TOKENS=2048 STIRLING_POSTHOG_ENABLED=false STIRLING_POSTHOG_API_KEY=phc_VOdeYnlevc2T63m3myFGjeBlRcIusRgmhfx6XL5a1iz STIRLING_POSTHOG_HOST=https://eu.i.posthog.com + +# Log level for the stirling logger hierarchy (DEBUG, INFO, WARNING, ERROR) +STIRLING_LOG_LEVEL=INFO + +# Path to log file. Rolls daily, keeps 1 backup. Leave empty for console only. +STIRLING_LOG_FILE= diff --git a/engine/pyproject.toml b/engine/pyproject.toml index 17da20b1b..b410cc6d2 100644 --- a/engine/pyproject.toml +++ b/engine/pyproject.toml @@ -46,6 +46,7 @@ select = [ "UP", "PYI", # flake8-pyi: flags deprecated typing constructs "FA", # flake8-future-annotations: flags missing future annotations imports + "BLE", # flake8-blind-except: flags bare `except Exception` ] [tool.pyright] diff --git a/engine/src/stirling/agents/ledger/__init__.py b/engine/src/stirling/agents/ledger/__init__.py new file mode 100644 index 000000000..a6b4831f4 --- /dev/null +++ b/engine/src/stirling/agents/ledger/__init__.py @@ -0,0 +1,5 @@ +"""Math Auditor Agent (mathAuditorAgent) — AI-powered math validation for PDF documents.""" + +from .agent import MathAuditorAgent + +__all__ = ["MathAuditorAgent"] diff --git a/engine/src/stirling/agents/ledger/agent.py b/engine/src/stirling/agents/ledger/agent.py new file mode 100644 index 000000000..f000b604e --- /dev/null +++ b/engine/src/stirling/agents/ledger/agent.py @@ -0,0 +1,550 @@ +""" +Math Auditor Agent (mathAuditorAgent) — pydantic-ai agents for PDF math validation. + +Examiner (Round 1, /api/v1/ai/math-auditor-agent/examine) + Receives a FolioManifest and returns a Requisition declaring what + Java must extract before validation can begin. + +Audit pipeline (Round 2, /api/v1/ai/math-auditor-agent/deliberate) + Processes Evidence per-page: + 1. Deterministic pass — ArithmeticScanner on every folio + 2. Fast-model pass — extract named figures from each page + 3. FigureTracker — cross-page consistency check + 4. Fast-model call — generate human-readable summary + 5. Assemble Verdict programmatically + +Neither agent ever touches a PDF file. All content arrives pre-extracted +by Java, which owns the PDF from start to finish. +""" + +from __future__ import annotations + +import asyncio +import logging +from collections.abc import Coroutine +from decimal import Decimal, InvalidOperation +from typing import Any + +from pydantic import BaseModel, Field +from pydantic_ai import Agent +from pydantic_ai.exceptions import AgentRunError + +from stirling.contracts.ledger import ( + Discrepancy, + DiscrepancyKind, + Evidence, + Folio, + FolioManifest, + Requisition, + Severity, + Verdict, +) +from stirling.logging import Pretty +from stirling.services import AppRuntime + +from .prompts import ( + EXAMINER_SYSTEM_PROMPT, + FIGURE_EXTRACTOR_PROMPT, + STATEMENT_VERIFIER_PROMPT, + SUMMARY_PROMPT, + TABLE_FORMULA_PROMPT, +) +from .validators import ArithmeticScanner, FigureTracker, FormulaEvaluator + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Structured output models for the per-page figure extractor +# --------------------------------------------------------------------------- + + +class ExtractedFigure(BaseModel): + """A single named figure found on a page.""" + + label: str = Field(description="Normalised name, e.g. 'Total Revenue', 'VAT'.") + value: str = Field(description="Numeric value as a string, e.g. '1200.00'.") + raw: str = Field(description="Original text from the document, e.g. '£1,200.00'.") + + +class FigureExtractionResult(BaseModel): + """All named figures found on a single page.""" + + figures: list[ExtractedFigure] = Field(default_factory=list) + + +class FormulaCheck(BaseModel): + """One verifiable mathematical relationship in a table.""" + + description: str = Field(description="Human-readable, e.g. 'Line Total = Qty × Unit Price'") + formula: str = Field(description="Expression: 'col3 = col1 * col2' or 'cell(4,3) = sum(col3, 1-3)'") + scope: str = Field(description="'each_row' | 'column_total' | 'single_cell'") + row_range: list[int] | None = Field(default=None, description="Data rows to check (for each_row scope)") + target_row: int | None = Field(default=None, description="Row index of total (for column_total/single_cell)") + target_col: int | None = Field(default=None, description="Column index (for column_total/single_cell)") + + +class TableFormulas(BaseModel): + """All verifiable formulas found in one table.""" + + formulas: list[FormulaCheck] = Field(default_factory=list) + + +class StatementCheck(BaseModel): + """One prose claim and its verification result.""" + + claim: str = Field(description="The exact text of the claim") + verification: str = Field(description="Type: percentage_change, comparison, ratio, trend, average, other") + values_referenced: list[str] = Field(default_factory=list, description="Numbers used in the check") + expected_result: str = Field(description="What the calculation actually yields") + actual_claim: str = Field(description="What the text claims") + is_valid: bool = Field(description="True if the claim is correct within tolerance") + explanation: str = Field(description="One-line working showing the calculation") + + +class StatementsResult(BaseModel): + """All verifiable prose claims found on a page.""" + + statements: list[StatementCheck] = Field(default_factory=list) + + +# --------------------------------------------------------------------------- +# MathAuditorAgent — main entry point, instantiated once at startup +# --------------------------------------------------------------------------- + + +class MathAuditorAgent: + """ + Encapsulates the Ledger Auditor pipeline. + + Instantiated once at app startup with an AppRuntime, which provides + pre-built Model objects and ModelSettings. + """ + + def __init__(self, runtime: AppRuntime) -> None: + fast_model = runtime.fast_model + model_settings = runtime.fast_model_settings + self._runtime = runtime + self._examiner = Agent( + model=fast_model, + deps_type=FolioManifest, + output_type=Requisition, + system_prompt=EXAMINER_SYSTEM_PROMPT, + model_settings=model_settings, + ) + self._figure_extractor = Agent( + model=fast_model, + output_type=FigureExtractionResult, + system_prompt=FIGURE_EXTRACTOR_PROMPT, + model_settings=model_settings, + ) + self._table_analyser = Agent( + model=fast_model, + output_type=TableFormulas, + system_prompt=TABLE_FORMULA_PROMPT, + model_settings=model_settings, + ) + self._statement_verifier = Agent( + model=fast_model, + output_type=StatementsResult, + system_prompt=STATEMENT_VERIFIER_PROMPT, + model_settings=model_settings, + ) + self._summary_agent = Agent( + model=fast_model, + output_type=str, + system_prompt=SUMMARY_PROMPT, + model_settings=model_settings, + ) + self._llm_semaphore = asyncio.Semaphore(10) + + # ------------------------------------------------------------------ + # Round 1: Examine + # ------------------------------------------------------------------ + + async def examine(self, manifest: FolioManifest) -> Requisition: + """Inspect a FolioManifest and declare the Requisition.""" + logger.info( + "[math-auditor-agent] session=%s round=%d examining %d folios", + manifest.session_id, + manifest.round, + manifest.page_count, + ) + + user_prompt = "Examine this folio manifest and declare your requisition:\n" + manifest.model_dump_json() + logger.debug("REQUEST (examine)\n%s", Pretty({"user_prompt": user_prompt})) + + result = await self._examiner.run(user_prompt, deps=manifest) + req = result.output + + logger.debug("RESPONSE (examine)\n%s", Pretty(req.model_dump())) + logger.info( + "[math-auditor-agent] session=%s requisition: text=%s tables=%s ocr=%s", + manifest.session_id, + req.need_text, + req.need_tables, + req.need_ocr, + ) + return req + + # ------------------------------------------------------------------ + # Round 2: Deliberate (deterministic-first pipeline) + # ------------------------------------------------------------------ + + async def audit(self, evidence: Evidence, tolerance: Decimal = Decimal("0.01")) -> Verdict: + """ + Audit the evidence using a deterministic-first pipeline: + + 1. Run ArithmeticScanner on every folio (no LLM) + 2. Extract named figures per-page with fast model + 3. Run FigureTracker cross-page consistency check (no LLM) + 4. Generate human summary with fast model + 5. Assemble Verdict + """ + return await self._audit_inner(evidence, tolerance) + + async def _audit_inner( + self, + evidence: Evidence, + tolerance: Decimal, + ) -> Verdict: + logger.info( + "[math-auditor-agent] session=%s round=%d auditing %d folios (final=%s)", + evidence.session_id, + evidence.round, + len(evidence.folios), + evidence.final_round, + ) + + all_discrepancies: list[Discrepancy] = [] + pages_examined: list[int] = [] + figure_tracker = FigureTracker(tolerance=tolerance) + + # Step 1: Arithmetic scanning (deterministic, instant) + arithmetic_scanner = ArithmeticScanner(tolerance=tolerance) + for folio in evidence.folios: + pages_examined.append(folio.page) + text = folio.readable_text + if text and text.strip(): + results = arithmetic_scanner.scan(folio.page, text) + all_discrepancies.extend(results) + logger.debug( + "TOOL (scan_arithmetic)\nArgs: %s\nResult: %s", + Pretty({"page": folio.page, "text_length": len(text)}), + Pretty([d.model_dump() for d in results]), + ) + + # Step 2: Parallel LLM calls — formula inference + figure extraction + # These are independent per-page so we fire them all concurrently. + formula_evaluator = FormulaEvaluator(tolerance=tolerance) + folios_with_text = [f for f in evidence.folios if f.readable_text.strip()] + + # Collect all tables as (page, csv) pairs for formula inference + table_tasks: list[tuple[int, str]] = [] + for folio in evidence.folios: + if folio.tables: + for table_csv in folio.tables: + table_tasks.append((folio.page, table_csv)) + + logger.info( + "[math-auditor-agent] session=%s step 2: %d formula + %d figure LLM calls (parallel)", + evidence.session_id, + len(table_tasks), + len(folios_with_text), + ) + + # Fire all LLM calls concurrently (bounded by _llm_semaphore) + formula_coros = [self._throttled(self._infer_formulas(csv)) for _, csv in table_tasks] + figure_coros = [self._throttled(self._extract_figures_for_page(f)) for f in folios_with_text] + statement_coros = [self._throttled(self._verify_statements(f)) for f in folios_with_text] + all_results = await asyncio.gather( + *formula_coros, + *figure_coros, + *statement_coros, + return_exceptions=True, + ) + + n_formulas = len(table_tasks) + n_figures = len(folios_with_text) + + # Process formula results + for i, (page, table_csv) in enumerate(table_tasks): + result = all_results[i] + if isinstance(result, BaseException): + logger.warning("[math-auditor-agent] formula inference failed for page %d: %s", page, result) + continue + assert isinstance(result, TableFormulas) + formulas = result + if not formulas.formulas: + logger.info("[math-auditor-agent] page %d: no verifiable formulas found", page) + continue + for fc in formulas.formulas: + checked = formula_evaluator.evaluate( + page=page, + table_csv=table_csv, + formula=fc.formula, + scope=fc.scope, + description=fc.description, + row_range=fc.row_range, + target_row=fc.target_row, + target_col=fc.target_col, + ) + all_discrepancies.extend(checked) + logger.debug( + "TOOL (check_formula)\nArgs: %s\nResult: %s", + Pretty({"page": page, "formula": fc.formula, "scope": fc.scope, "description": fc.description}), + Pretty([d.model_dump() for d in checked]), + ) + + # Process figure results + for i, folio in enumerate(folios_with_text): + result = all_results[n_formulas + i] + if isinstance(result, BaseException): + logger.warning("[math-auditor-agent] figure extraction failed for page %d: %s", folio.page, result) + continue + assert isinstance(result, list) + for fig, page in result: + try: + decimal_value = Decimal(fig.value.replace(",", "").strip()) + except (InvalidOperation, ValueError): + logger.warning( + "[math-auditor-agent] skipping figure %r on page %d: non-numeric value %r", + fig.label, + page, + fig.value, + ) + continue + figure_tracker.record( + label=fig.label, + value=decimal_value, + page=page, + raw=fig.raw, + ) + + # Process statement verification results + for i, folio in enumerate(folios_with_text): + result = all_results[n_formulas + n_figures + i] + if isinstance(result, BaseException): + logger.warning("[math-auditor-agent] statement verification failed for page %d: %s", folio.page, result) + continue + assert isinstance(result, StatementsResult) + stmts = result + for sc in stmts.statements: + if not sc.is_valid: + all_discrepancies.append( + Discrepancy( + page=folio.page, + kind=DiscrepancyKind.STATEMENT, + severity=Severity.ERROR, + description=f"{sc.claim}: {sc.explanation}", + stated=sc.actual_claim, + expected=sc.expected_result, + context=sc.claim, + ) + ) + logger.debug( + "TOOL (verify_statement)\nArgs: %s\nResult: %s", + Pretty({"page": folio.page, "claim": sc.claim}), + Pretty(sc.model_dump()), + ) + + logger.info( + "[math-auditor-agent] session=%s step 2 complete: %d figures registered", + evidence.session_id, + figure_tracker.entry_count, + ) + + # Step 3: Cross-page consistency — deterministic + consistency_discrepancies = figure_tracker.conflicts() + all_discrepancies.extend(consistency_discrepancies) + if consistency_discrepancies: + logger.debug( + "TOOL (check_figure_consistency)\nResult: %s", + Pretty([d.model_dump() for d in consistency_discrepancies]), + ) + + # Step 4: Summary — fast model, small payload + # Collect verification stats for the summary + total_tables = sum(len(f.tables) for f in evidence.folios if f.tables) + total_formulas_checked = sum(len(r.formulas) for r in all_results[:n_formulas] if isinstance(r, TableFormulas)) + total_statements_checked = sum( + len(r.statements) for r in all_results[n_formulas + n_figures :] if isinstance(r, StatementsResult) + ) + verification_stats = ( + f"Verified: {len(pages_examined)} pages, {total_tables} tables " + f"({total_formulas_checked} formulas), " + f"{figure_tracker.entry_count} figures tracked, " + f"{total_statements_checked} prose claims checked." + ) + + logger.info( + "[math-auditor-agent] session=%s step 4: generating summary (%d discrepancies)", + evidence.session_id, + len(all_discrepancies), + ) + pages_examined.sort() + summary = await self._generate_summary( + all_discrepancies, + pages_examined, + evidence.unauditable_pages, + verification_stats, + ) + + # Step 5: Assemble Verdict + error_count = sum(1 for d in all_discrepancies if d.severity == Severity.ERROR) + verdict = Verdict( + session_id=evidence.session_id, + discrepancies=all_discrepancies, + pages_examined=pages_examined, + rounds_taken=evidence.round, + summary=summary, + clean=error_count == 0, + unauditable_pages=evidence.unauditable_pages, + ) + + logger.debug("RESPONSE (deliberate)\n%s", Pretty(verdict.model_dump())) + logger.info( + "[math-auditor-agent] session=%s verdict: %d errors, %d warnings, clean=%s", + evidence.session_id, + verdict.error_count, + verdict.warning_count, + verdict.clean, + ) + return verdict + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + async def _throttled[T](self, coro: Coroutine[Any, Any, T]) -> T: + """Wrap a coroutine with the LLM concurrency semaphore.""" + async with self._llm_semaphore: + return await coro + + async def _infer_formulas(self, table_csv: str) -> TableFormulas: + """Ask the fast model to infer verifiable formulas from a CSV table.""" + try: + result = await self._table_analyser.run(f"CSV table:\n{table_csv}") + formulas = result.output + except AgentRunError: + logger.warning("[math-auditor-agent] formula inference failed, skipping table", exc_info=True) + formulas = TableFormulas(formulas=[]) + + logger.debug( + "TOOL (infer_formulas)\nArgs: %s\nResult: %s", + Pretty({"table_csv": table_csv[:300]}), + Pretty(formulas.model_dump()), + ) + return formulas + + async def _verify_statements( + self, + folio: Folio, + ) -> StatementsResult: + """Ask the fast model to find and verify prose claims on a page.""" + text = folio.readable_text + if not text or not text.strip(): + return StatementsResult(statements=[]) + + # Build context: page text + any table CSVs + prompt = f"Page {folio.page + 1} text:\n{text}" + if folio.tables: + prompt += "\n\nTable data on this page:\n" + for i, csv in enumerate(folio.tables): + prompt += f"\nTable {i + 1}:\n{csv}" + + try: + result = await self._statement_verifier.run(prompt) + stmts = result.output + except AgentRunError: + logger.warning("[math-auditor-agent] statement verification failed for page %d", folio.page, exc_info=True) + stmts = StatementsResult(statements=[]) + + if stmts.statements: + logger.debug( + "TOOL (verify_statements)\nArgs: %s\nResult: %s", + Pretty({"page": folio.page, "text_length": len(text), "n_tables": len(folio.tables or [])}), + Pretty([s.model_dump() for s in stmts.statements]), + ) + return stmts + + async def _extract_figures_for_page( + self, + folio: Folio, + ) -> list[tuple[ExtractedFigure, int]]: + text = folio.readable_text + if not text or not text.strip(): + return [] + + logger.info("[math-auditor-agent] extracting figures from page %d (%d chars)", folio.page, len(text)) + prompt = f"Page {folio.page + 1} text:\n{text}" + try: + result = await self._figure_extractor.run(prompt) + figures = result.output.figures + except AgentRunError: + logger.warning( + "[math-auditor-agent] figure extraction failed for page %d, skipping", + folio.page, + exc_info=True, + ) + figures = [] + + logger.debug( + "TOOL (extract_figures)\nArgs: %s\nResult: %s", + Pretty({"page": folio.page, "text_length": len(text)}), + Pretty([f.model_dump() for f in figures]), + ) + + return [(fig, folio.page) for fig in figures] + + async def _generate_summary( + self, + discrepancies: list[Discrepancy], + pages_examined: list[int], + unauditable_pages: list[int], + verification_stats: str, + ) -> str: + error_count = sum(1 for d in discrepancies if d.severity == Severity.ERROR) + warning_count = sum(1 for d in discrepancies if d.severity == Severity.WARNING) + + prompt = ( + f"{verification_stats}\n" + f"Errors: {error_count}, Warnings: {warning_count}, " + f"Pages examined: {len(pages_examined)}, " + f"Unauditable pages: {unauditable_pages or 'none'}.\n" + ) + if discrepancies: + prompt += "Discrepancies:\n" + for d in discrepancies: + prompt += f" - [{d.severity}] p{d.page + 1}: {d.description}\n" + + try: + result = await self._summary_agent.run(prompt) + summary = result.output + except AgentRunError: + logger.warning("[math-auditor-agent] summary generation failed, using fallback", exc_info=True) + summary = self._fallback_summary(error_count, warning_count, pages_examined, unauditable_pages) + + logger.debug("RESPONSE (summary)\n%s", Pretty({"summary": summary})) + return summary + + @staticmethod + def _fallback_summary( + error_count: int, + warning_count: int, + pages_examined: list[int], + unauditable_pages: list[int], + ) -> str: + parts = [] + if error_count == 0 and warning_count == 0: + parts.append(f"No mathematical errors found across {len(pages_examined)} pages.") + else: + if error_count: + parts.append(f"Found {error_count} error{'s' if error_count != 1 else ''}.") + if warning_count: + parts.append(f"Found {warning_count} warning{'s' if warning_count != 1 else ''}.") + if unauditable_pages: + parts.append( + f"Pages {', '.join(str(p + 1) for p in unauditable_pages)} could not be audited (OCR unavailable)." + ) + return " ".join(parts) diff --git a/engine/src/stirling/agents/ledger/prompts.py b/engine/src/stirling/agents/ledger/prompts.py new file mode 100644 index 000000000..78e97c4f9 --- /dev/null +++ b/engine/src/stirling/agents/ledger/prompts.py @@ -0,0 +1,147 @@ +""" +Ledger Auditor — system prompts. + +One prompt per role; keep them short and directive. Each agent is a +specialist with a narrow remit, not a general assistant. +""" + +EXAMINER_SYSTEM_PROMPT = """\ +You are the Examiner, the first stage of the Ledger Auditor pipeline. + +You receive a FolioManifest: a list of page types (text / image / mixed) \ +for a PDF document. Your sole task is to declare exactly which pages you \ +need Java to extract content from so that the Auditor can verify the \ +document's mathematics. + +Rules: +- Request BOTH text AND table extraction for every 'text' or 'mixed' page. \ + Tables are critical — the Auditor cannot verify totals without them. \ + Tabula extraction is cheap; missing a table is not. +- Request OCR for any page classified as 'image' or 'mixed' (PDFBox cannot \ + read image-only content). +- Be conservative — if in doubt, request the page. False negatives \ + (missed errors) are worse than false positives (wasted extraction). +- Do not request pages that are clearly decorative (cover pages, blank pages) \ + unless you cannot tell from the manifest alone. +- Return a Requisition with your page lists and a plain-English rationale \ + that will appear in server logs. +""" + +FIGURE_EXTRACTOR_PROMPT = """\ +You are a figure extractor for financial document auditing. + +You receive the text content of a single PDF page. Your task is to \ +identify every significant named numeric figure on the page. + +A "named figure" is a labelled number that could appear elsewhere in \ +the document under the same name — for example: + "Total Revenue: £1,200,000" + "Net Profit $45,000" + "VAT (20%): 240.00" + "Subtotal ......... 3,500" + +For each figure, return: +- label: a normalised name (e.g. "Total Revenue", "Net Profit", "VAT") +- value: the numeric value as a plain decimal string (e.g. "1200000") +- raw: the original text as it appears in the document + +Rules: +- Only extract figures that have a clear label/name attached. +- Do not extract bare numbers without context. +- Strip currency symbols and thousands separators from value. +- If a figure appears multiple times on the same page, extract each. +- Return an empty list if no named figures are found. +- Be precise — do not invent figures that are not in the text. +""" + +TABLE_FORMULA_PROMPT = """\ +You are a table formula analyser for financial document auditing. + +You receive a CSV table extracted from a PDF. Your task is to identify \ +every verifiable mathematical relationship between cells. + +Relationships fall into three scopes: + +1. "each_row" — a formula that should hold for every data row. + Example: "col3 = col1 * col2" (Line Total = Qty × Unit Price) + +2. "column_total" — a total row where cells = sum of the column above. + Example: a Subtotal row where each cell sums the column. + +3. "single_cell" — one specific cell computed from others. + Example: "cell(5,3) = cell(4,3) * 0.1" (Tax = Subtotal × 10%) + +Formula syntax (use exactly this): + - Column references: col0, col1, col2 ... (0-indexed) + - Cell references: cell(row, col) — 0-indexed, header is row 0 + - Operators: + - * / + - sum(colN, start-end) — sum of colN from row start to row end inclusive + - Decimal numbers: 0.1, 100, etc. + +Rules: +- Row 0 is the header. First data row is row 1. +- Include the left-hand side: "col3 = col1 * col2" not just "col1 * col2" +- For column_total scope, set target_row to the total row index. \ + Set target_col to a specific column or null to check all numeric columns. +- For each_row scope, set row_range to the data rows (exclude header \ + and total rows). +- Only return formulas you are confident about. Skip columns/rows \ + where the relationship is unclear. +- Return an empty list if the table has no verifiable math. +""" + +STATEMENT_VERIFIER_PROMPT = """\ +You are a statement verifier for financial document auditing. + +You receive the text of a single PDF page, plus any table data from \ +that page. Your task is to find prose claims that make mathematical \ +assertions, and verify whether each claim is correct. + +A "verifiable claim" is a sentence that states a mathematical fact \ +about numbers present on the page or derivable from the data. Examples: + - "Revenue grew 15% year-over-year" + - "Costs decreased month on month" + - "Department A represents 40% of total spend" + - "Net margin improved to 12.4%" + - "Average transaction value was $250" + +For each claim you find: +1. Identify the numbers referenced in the claim +2. Perform the calculation yourself using the data on the page +3. Compare your result to what the claim states +4. Determine if the claim is valid (within reasonable rounding) + +Return: +- claim: the exact text of the claim +- verification: the type — "percentage_change", "comparison", \ + "ratio", "trend", "average", or "other" +- values_referenced: the specific numbers used in your check +- expected_result: what the calculation actually yields +- actual_claim: what the text claims +- is_valid: true if the claim is correct within 1% tolerance +- explanation: show your working, one line + +Rules: +- Only check claims that can be verified from data on this page. +- If a claim references data not on the page, skip it. +- "Decreased month on month" means EVERY consecutive pair decreased. +- Percentage claims allow 1% absolute tolerance (14.8% ≈ 15%). +- Return an empty list if there are no verifiable claims. +- Do not fabricate claims that are not in the text. +""" + +SUMMARY_PROMPT = """\ +You are a summary writer for a PDF math audit tool. + +You receive a list of discrepancies (errors and warnings) found in a \ +document, plus coverage statistics and a breakdown of what was verified. \ +Write a two to three sentence summary suitable for an end user. + +Rules: +- Start with what was verified: e.g. "Audited 6 pages: checked 4 tables \ + (12 formulas), scanned 6 pages for arithmetic, extracted 20 figures \ + for cross-page consistency, and verified 3 prose claims." +- Then state the outcome: errors found or clean. +- Mention unauditable pages if any exist. +- Be concise and factual. Do not repeat individual discrepancy details. +""" diff --git a/engine/src/stirling/agents/ledger/validators/__init__.py b/engine/src/stirling/agents/ledger/validators/__init__.py new file mode 100644 index 000000000..d4ba9dc1c --- /dev/null +++ b/engine/src/stirling/agents/ledger/validators/__init__.py @@ -0,0 +1,5 @@ +from .arithmetic import ArithmeticScanner +from .figures import FigureTracker +from .formula import FormulaEvaluator + +__all__ = ["ArithmeticScanner", "FigureTracker", "FormulaEvaluator"] diff --git a/engine/src/stirling/agents/ledger/validators/_parsing.py b/engine/src/stirling/agents/ledger/validators/_parsing.py new file mode 100644 index 000000000..52a06271c --- /dev/null +++ b/engine/src/stirling/agents/ledger/validators/_parsing.py @@ -0,0 +1,31 @@ +"""Shared parsing helpers for ledger validators.""" + +from __future__ import annotations + +import csv +import io +import re +from decimal import Decimal, InvalidOperation + +# Strip common currency symbols and thousands separators before parsing. +STRIP_PATTERN = re.compile(r"[£$€¥,\s]") + + +def to_decimal(raw: str) -> Decimal | None: + """Parse a cell value to Decimal, returning None for non-numeric cells.""" + cleaned = STRIP_PATTERN.sub("", raw.strip()) + if not cleaned or cleaned in {"-", "—", "n/a", "N/A", "na", "NA"}: + return None + # Handle parenthesised negatives: (123.45) → -123.45 + if cleaned.startswith("(") and cleaned.endswith(")"): + cleaned = "-" + cleaned[1:-1] + try: + return Decimal(cleaned) + except InvalidOperation: + return None + + +def parse_csv(table_csv: str) -> list[list[str]]: + """Parse a CSV string into rows, dropping completely empty rows.""" + reader = csv.reader(io.StringIO(table_csv.strip())) + return [row for row in reader if any(cell.strip() for cell in row)] diff --git a/engine/src/stirling/agents/ledger/validators/arithmetic.py b/engine/src/stirling/agents/ledger/validators/arithmetic.py new file mode 100644 index 000000000..31856ffa8 --- /dev/null +++ b/engine/src/stirling/agents/ledger/validators/arithmetic.py @@ -0,0 +1,152 @@ +""" +ArithmeticScanner — finds and verifies inline arithmetic expressions in text. + +Targets patterns commonly found in financial documents: + "100 + 200 + 150 = 450" + "Total: 1,250 (500 + 400 + 350)" + "Net profit of £1,200 (£2,000 revenue less £800 costs)" + +All arithmetic is performed in Decimal. The scanner does not use an LLM — +it is a deterministic regex-and-eval pipeline. +""" + +from __future__ import annotations + +import logging +import re +from decimal import Decimal + +from stirling.contracts.ledger import Discrepancy, DiscrepancyKind, Severity + +from ._parsing import STRIP_PATTERN as _STRIP +from ._parsing import to_decimal as _to_decimal + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Regex patterns +# --------------------------------------------------------------------------- + +# Currency / number token: optional sign, optional currency symbol, +# digits with optional thousands separator and decimal point. +_NUM = r"[£$€¥]?-?[\d,]+(?:\.\d+)?" + +# "A + B + C = D" or "A + B + C = D" with arbitrary spacing +_EQUALS_EXPR = re.compile( + rf"({_NUM}(?:\s*[+\-]\s*{_NUM})+)\s*=\s*({_NUM})", + re.IGNORECASE, +) + +# "Total: X (A + B + C)" — the total comes before the addends +_TOTAL_THEN_ADDENDS = re.compile( + rf"(?:total|sum|grand total|subtotal)\s*[:\-]?\s*({_NUM})\s*\(({_NUM}(?:\s*[+\-]\s*{_NUM})+)\)", + re.IGNORECASE, +) + + +def _parse(token: str) -> Decimal | None: + """Parse a regex-matched token to Decimal.""" + return _to_decimal(token) + + +def _eval_expression(expr: str) -> Decimal | None: + """ + Evaluate a simple additive expression of the form A +/- B +/- C ... + Returns None if the expression cannot be parsed. + """ + # Tokenise: split on + or -, keep the operator. + tokens = re.split(r"([+\-])", _STRIP.sub("", expr.strip())) + result = Decimal(0) + operator = "+" + for token in tokens: + token = token.strip() + if not token: + continue # skip empty tokens (e.g. from leading negative) + if token in ("+", "-"): + operator = token + continue + val = _parse(token) + if val is None: + return None + result = result + val if operator == "+" else result - val + return result + + +class ArithmeticScanner: + """ + Scans a block of text for arithmetic expressions and checks them. + + Parameters + ---------- + tolerance: + Maximum absolute difference before an expression is flagged as wrong. + """ + + def __init__(self, tolerance: Decimal = Decimal("0.01")) -> None: + self.tolerance = tolerance + + def scan(self, page: int, text: str) -> list[Discrepancy]: + """ + Find all verifiable arithmetic expressions in *text* and return + a Discrepancy for each one that does not balance within tolerance. + """ + discrepancies: list[Discrepancy] = [] + discrepancies.extend(self._check_equals_expressions(page, text)) + discrepancies.extend(self._check_total_then_addends(page, text)) + return discrepancies + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _check_equals_expressions(self, page: int, text: str) -> list[Discrepancy]: + """Handle patterns like '100 + 200 = 300'.""" + found: list[Discrepancy] = [] + for match in _EQUALS_EXPR.finditer(text): + expr_str = match.group(1) + stated_str = match.group(2) + + computed = _eval_expression(expr_str) + stated = _parse(stated_str) + if computed is None or stated is None: + continue + + if abs(computed - stated) > self.tolerance: + found.append( + Discrepancy( + page=page, + kind=DiscrepancyKind.ARITHMETIC, + severity=Severity.ERROR, + description=f"Arithmetic error: {expr_str.strip()} should equal {computed}, not {stated}", + stated=str(stated), + expected=str(computed), + context=match.group(0), + ) + ) + return found + + def _check_total_then_addends(self, page: int, text: str) -> list[Discrepancy]: + """Handle patterns like 'Total: 450 (100 + 200 + 150)'.""" + found: list[Discrepancy] = [] + for match in _TOTAL_THEN_ADDENDS.finditer(text): + stated_str = match.group(1) + expr_str = match.group(2) + + stated = _parse(stated_str) + computed = _eval_expression(expr_str) + if stated is None or computed is None: + continue + + if abs(computed - stated) > self.tolerance: + found.append( + Discrepancy( + page=page, + kind=DiscrepancyKind.ARITHMETIC, + severity=Severity.ERROR, + description=f"Stated total {stated} does not match addends ({expr_str.strip()} = {computed})", + stated=str(stated), + expected=str(computed), + context=match.group(0), + ) + ) + return found diff --git a/engine/src/stirling/agents/ledger/validators/figures.py b/engine/src/stirling/agents/ledger/validators/figures.py new file mode 100644 index 000000000..7790c8dfb --- /dev/null +++ b/engine/src/stirling/agents/ledger/validators/figures.py @@ -0,0 +1,98 @@ +""" +FigureTracker — cross-page consistency checker for named figures. + +Collects named numeric figures as the auditor encounters them (e.g. +"Total Revenue: £1,200,000") and surfaces any that appear under the same +label but with a different value on another page — a classic symptom of +copy-paste errors or stale data in executive summaries. + +The tracker is intentionally simple: normalise labels, compare values +within tolerance, emit Discrepancy for each conflict. +""" + +from __future__ import annotations + +import logging +import re +from decimal import Decimal + +from pydantic import BaseModel + +from stirling.contracts.ledger import Discrepancy, DiscrepancyKind, Severity + +logger = logging.getLogger(__name__) + + +class FigureRecord(BaseModel): + """A named numeric figure seen on a specific page.""" + + label: str + value: Decimal + page: int + raw: str + + +# Strip punctuation that varies between contexts ("revenue:" vs "revenue —") +_LABEL_NOISE = re.compile(r"[:\-—\s]+") + + +def _normalise_label(label: str) -> str: + return _LABEL_NOISE.sub(" ", label.lower()).strip() + + +class FigureTracker: + """ + Accumulates named figures during an audit and checks them for consistency. + + Typical usage: + tracker = FigureTracker() + tracker.record("Net Profit", Decimal("1200.00"), page=3, raw="£1,200.00") + tracker.record("Net Profit", Decimal("1250.00"), page=7, raw="£1,250.00") + discrepancies = tracker.conflicts() # returns one Discrepancy + """ + + def __init__(self, tolerance: Decimal = Decimal("0.01")) -> None: + self.tolerance = tolerance + self._ledger: dict[str, list[FigureRecord]] = {} + + def record(self, label: str, value: Decimal, page: int, raw: str) -> None: + """Register a named figure sighting.""" + key = _normalise_label(label) + self._ledger.setdefault(key, []).append(FigureRecord(label=key, value=value, page=page, raw=raw)) + + def conflicts(self) -> list[Discrepancy]: + """ + Return a Discrepancy for every label that has sightings whose value + differs from the first-seen (canonical) value by more than tolerance. + + O(n) per label — each record is compared against the canonical only. + """ + discrepancies: list[Discrepancy] = [] + + for label, records in self._ledger.items(): + if len(records) < 2: + continue + canonical = records[0] + for other in records[1:]: + if abs(canonical.value - other.value) > self.tolerance: + discrepancies.append( + Discrepancy( + page=other.page, + kind=DiscrepancyKind.CONSISTENCY, + severity=Severity.WARNING, + description=( + f'"{label}" stated as {canonical.raw} on page' + f" {canonical.page + 1}" + f" but {other.raw} on page {other.page + 1}" + ), + stated=other.raw, + expected=canonical.raw, + context=(f"First seen: page {canonical.page + 1} | Later: page {other.page + 1}"), + ) + ) + + return discrepancies + + @property + def entry_count(self) -> int: + return sum(len(v) for v in self._ledger.values()) diff --git a/engine/src/stirling/agents/ledger/validators/formula.py b/engine/src/stirling/agents/ledger/validators/formula.py new file mode 100644 index 000000000..fe9ae81c5 --- /dev/null +++ b/engine/src/stirling/agents/ledger/validators/formula.py @@ -0,0 +1,375 @@ +""" +FormulaEvaluator — verifies LLM-inferred formulas against CSV table data. + +Supports a safe expression syntax: + - Column refs: col0, col1, col2 ... + - Cell refs: cell(row, col) + - Operators: + - * / + - Functions: sum(colN, rows start-end) + +All arithmetic is Decimal. No eval(), no arbitrary code execution. +""" + +from __future__ import annotations + +import logging +import re +from decimal import Decimal, InvalidOperation + +from stirling.contracts.ledger import Discrepancy, DiscrepancyKind, Severity + +from ._parsing import parse_csv as _parse_csv +from ._parsing import to_decimal as _to_decimal + +logger = logging.getLogger(__name__) + + +class FormulaEvaluator: + """ + Evaluates formula expressions against parsed CSV rows. + + Formulas use a simple syntax: + "col3 = col1 * col2" — per-row check + "cell(4,3) = sum(col3, 1-3)" — single cell check + """ + + def __init__(self, tolerance: Decimal = Decimal("0.01")) -> None: + self.tolerance = tolerance + + def evaluate( + self, + page: int, + table_csv: str, + formula: str, + scope: str, + description: str, + row_range: list[int] | None = None, + target_row: int | None = None, + target_col: int | None = None, + ) -> list[Discrepancy]: + """ + Evaluate a formula against table data. + + scope: "each_row" | "column_total" | "single_cell" + """ + rows = _parse_csv(table_csv) + if len(rows) < 2: + return [] + + if scope == "each_row": + return self._check_each_row(page, rows, formula, description, row_range) + elif scope == "column_total": + return self._check_column_total(page, rows, formula, description, target_row, target_col) + elif scope == "single_cell": + return self._check_single_cell(page, rows, formula, description, target_row, target_col) + else: + logger.warning("[formula] unknown scope %r, skipping", scope) + return [] + + def _check_each_row( + self, + page: int, + rows: list[list[str]], + formula: str, + description: str, + row_range: list[int] | None, + ) -> list[Discrepancy]: + """Verify formula holds for each data row.""" + discrepancies: list[Discrepancy] = [] + + # Parse "colX = expr" format + parts = formula.split("=", 1) + if len(parts) != 2: + return [] + lhs = parts[0].strip() + rhs = parts[1].strip() + + lhs_col = self._parse_col_ref(lhs) + if lhs_col is None: + return [] + + check_rows = row_range if row_range else list(range(1, len(rows))) + + for row_idx in check_rows: + if row_idx >= len(rows): + continue + row = rows[row_idx] + + stated = self._get_cell(row, lhs_col) + if stated is None: + continue + + computed = self._eval_row_expr(rhs, row, rows) + if computed is None: + continue + + if abs(stated - computed) > self.tolerance: + discrepancies.append( + Discrepancy( + page=page, + kind=DiscrepancyKind.TALLY, + severity=Severity.ERROR, + description=f"{description}: row {row_idx} — stated {stated}, expected {computed}", + stated=str(stated), + expected=str(computed), + context=f"row {row_idx}, {formula}", + ) + ) + + return discrepancies + + def _check_column_total( + self, + page: int, + rows: list[list[str]], + formula: str, + description: str, + target_row: int | None, + target_col: int | None, + ) -> list[Discrepancy]: + """Verify that a total row contains correct column sums.""" + if target_row is None or target_row >= len(rows): + return [] + + discrepancies: list[Discrepancy] = [] + total_row = rows[target_row] + + # Determine which columns to check + cols_to_check: list[int] = [] + if target_col is not None: + cols_to_check = [target_col] + else: + # Check all numeric columns in the total row + cols_to_check = list(range(len(total_row))) + + # Determine addend rows (all rows between header and total row) + addend_rows = list(range(1, target_row)) + + for col in cols_to_check: + stated = self._get_cell(total_row, col) + if stated is None: + continue + + computed = Decimal(0) + has_addends = False + for r_idx in addend_rows: + if r_idx >= len(rows): + continue + val = self._get_cell(rows[r_idx], col) + if val is not None: + computed += val + has_addends = True + + if not has_addends: + continue + + if abs(stated - computed) > self.tolerance: + discrepancies.append( + Discrepancy( + page=page, + kind=DiscrepancyKind.TALLY, + severity=Severity.ERROR, + description=f"{description}: column {col} — stated {stated}, expected {computed}", + stated=str(stated), + expected=str(computed), + context=f"column {col}, total row {target_row}", + ) + ) + + return discrepancies + + def _check_single_cell( + self, + page: int, + rows: list[list[str]], + formula: str, + description: str, + target_row: int | None, + target_col: int | None, + ) -> list[Discrepancy]: + """Verify a single cell formula (e.g. Grand Total = Subtotal + Tax).""" + parts = formula.split("=", 1) + if len(parts) != 2: + return [] + + # Parse target from LHS cell(r,c) if not provided explicitly + if target_row is None or target_col is None: + lhs_match = re.match(r"cell\(\s*(\d+)\s*,\s*(\d+)\s*\)", parts[0].strip()) + if lhs_match: + target_row = int(lhs_match.group(1)) + target_col = int(lhs_match.group(2)) + else: + return [] + + if target_row >= len(rows): + return [] + + rhs = parts[1].strip() + + stated = self._get_cell(rows[target_row], target_col) + if stated is None: + return [] + + computed = self._eval_row_expr(rhs, rows[target_row], rows) + if computed is None: + return [] + + if abs(stated - computed) > self.tolerance: + return [ + Discrepancy( + page=page, + kind=DiscrepancyKind.TALLY, + severity=Severity.ERROR, + description=f"{description}: stated {stated}, expected {computed}", + stated=str(stated), + expected=str(computed), + context=f"cell({target_row},{target_col}), {formula}", + ) + ] + return [] + + # ------------------------------------------------------------------ + # Expression evaluation + # ------------------------------------------------------------------ + + def _eval_row_expr(self, expr: str, row: list[str], all_rows: list[list[str]]) -> Decimal | None: + """ + Evaluate an expression in the context of a specific row. + Supports: colN refs, cell(r,c) refs, +, -, *, / + Also supports: sum(colN, start-end) + """ + # Handle sum() function first + sum_pattern = re.compile(r"sum\(\s*col(\d+)\s*,\s*(\d+)\s*-\s*(\d+)\s*\)") + resolved = expr + for match in sum_pattern.finditer(expr): + col = int(match.group(1)) + start = int(match.group(2)) + end = int(match.group(3)) + total = Decimal(0) + for r_idx in range(start, end + 1): + if r_idx < len(all_rows): + val = self._get_cell(all_rows[r_idx], col) + if val is not None: + total += val + resolved = resolved.replace(match.group(0), str(total)) + + # Handle cell(r, c) references + cell_pattern = re.compile(r"cell\(\s*(\d+)\s*,\s*(\d+)\s*\)") + for match in cell_pattern.finditer(resolved): + r = int(match.group(1)) + c = int(match.group(2)) + if r < len(all_rows): + val = self._get_cell(all_rows[r], c) + if val is not None: + resolved = resolved.replace(match.group(0), str(val)) + else: + return None + else: + return None + + # Replace colN references with values from the current row. + # Use re.sub with word boundaries to avoid col1 corrupting col12. + _failed = False + + def _col_replacer(m: re.Match[str]) -> str: + nonlocal _failed + col_idx = int(m.group(1)) + val = self._get_cell(row, col_idx) + if val is None: + _failed = True + return m.group(0) + return str(val) + + resolved = re.sub(r"\bcol(\d+)\b", _col_replacer, resolved) + if _failed: + return None + + # Evaluate the resulting arithmetic expression safely + return self._safe_eval(resolved) + + def _safe_eval(self, expr: str) -> Decimal | None: + """ + Evaluate a simple arithmetic expression containing only + numbers and +, -, *, / operators. Respects standard operator + precedence (* and / bind tighter than + and -). No eval(). + """ + try: + raw = re.findall(r"\d+(?:\.\d+)?|[+\-*/]", expr.strip()) + if not raw: + return None + + # Build (values, ops) lists, merging a leading '-' or an + # operator-adjacent '-' into the next number token. + values: list[Decimal] = [] + ops: list[str] = [] + i = 0 + while i < len(raw): + tok = raw[i] + if tok in "+-*/" and not values and tok == "-": + # Leading negative: merge with next number + i += 1 + if i >= len(raw): + return None + values.append(Decimal("-" + raw[i])) + elif tok in "+-*/": + # Operator followed by '-' → negative operand + if ( + tok in "+-*/" + and i + 1 < len(raw) + and raw[i + 1] == "-" + and i + 2 < len(raw) + and raw[i + 2] not in "+-*/" + ): + ops.append(tok) + values.append(Decimal("-" + raw[i + 2])) + i += 2 # skip the '-' and the number + else: + ops.append(tok) + else: + values.append(Decimal(tok)) + i += 1 + + if not values: + return None + + # Pass 1: evaluate * and / + j = 0 + while j < len(ops): + if ops[j] in ("*", "/"): + if ops[j] == "*": + values[j] = values[j] * values[j + 1] + else: + if values[j + 1] == 0: + return None + values[j] = values[j] / values[j + 1] + values.pop(j + 1) + ops.pop(j) + else: + j += 1 + + # Pass 2: evaluate + and - + result = values[0] + for j, op in enumerate(ops): + if op == "+": + result += values[j + 1] + elif op == "-": + result -= values[j + 1] + + return result + except (InvalidOperation, IndexError, ValueError): + return None + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + @staticmethod + def _parse_col_ref(ref: str) -> int | None: + match = re.match(r"col(\d+)", ref.strip()) + return int(match.group(1)) if match else None + + @staticmethod + def _get_cell(row: list[str], col: int) -> Decimal | None: + if col >= len(row): + return None + return _to_decimal(row[col]) diff --git a/engine/src/stirling/agents/orchestrator.py b/engine/src/stirling/agents/orchestrator.py index 9b58b4540..3ddefcf43 100644 --- a/engine/src/stirling/agents/orchestrator.py +++ b/engine/src/stirling/agents/orchestrator.py @@ -21,8 +21,11 @@ from stirling.contracts import ( PdfQuestionRequest, PdfQuestionResponse, SupportedCapability, + ToolOperationStep, UnsupportedCapabilityResponse, ) +from stirling.contracts.pdf_edit import EditPlanResponse +from stirling.models.agent_tool_models import AgentToolId, MathAuditorAgentParams from stirling.services import AppRuntime @@ -53,6 +56,14 @@ class OrchestratorAgent: name="delegate_user_spec", description="Delegate requests to create or revise a user agent spec and return the draft result.", ), + ToolOutput( + self.math_auditor_agent, + name="math_auditor_agent", + description=( + "Delegate requests to check arithmetic, validate table totals, " + "audit financial calculations, or verify mathematical accuracy in PDFs." + ), + ), ToolOutput( self.unsupported_capability, name="unsupported_capability", @@ -66,6 +77,8 @@ class OrchestratorAgent: "Use delegate_pdf_edit for requested PDF modifications. " "Use delegate_pdf_question for questions about PDF contents. " "Use delegate_user_spec for requests to create or define an agent spec. " + "Use math_auditor_agent for requests to check arithmetic, validate " + "table totals, audit financial calculations, or verify math in PDFs. " "Use unsupported_capability only when none of the other outputs fit." ), model_settings=runtime.fast_model_settings, @@ -93,6 +106,7 @@ class OrchestratorAgent: SupportedCapability.ORCHESTRATE | SupportedCapability.AGENT_REVISE | SupportedCapability.AGENT_NEXT_ACTION + | SupportedCapability.MATH_AUDITOR_AGENT ): raise ValueError(f"Cannot resume orchestrator with capability: {capability}") case _ as unreachable: @@ -123,6 +137,17 @@ class OrchestratorAgent: async def _run_agent_draft(self, request: OrchestratorRequest) -> AgentDraftWorkflowResponse: return await UserSpecAgent(self.runtime).draft(AgentDraftRequest(user_message=request.user_message)) + async def math_auditor_agent(self, ctx: RunContext[OrchestratorDeps]) -> EditPlanResponse: + return EditPlanResponse( + summary="Validate mathematical calculations in the document.", + steps=[ + ToolOperationStep( + tool=AgentToolId.MATH_AUDITOR_AGENT, + parameters=MathAuditorAgentParams(), + ) + ], + ) + async def unsupported_capability( self, ctx: RunContext[OrchestratorDeps], diff --git a/engine/src/stirling/api/app.py b/engine/src/stirling/api/app.py index 76a915001..2afef6208 100644 --- a/engine/src/stirling/api/app.py +++ b/engine/src/stirling/api/app.py @@ -8,10 +8,12 @@ from pydantic_ai import Agent from pydantic_ai.models.instrumented import InstrumentationSettings from stirling.agents import ExecutionPlanningAgent, OrchestratorAgent, PdfEditAgent, PdfQuestionAgent, UserSpecAgent +from stirling.agents.ledger import MathAuditorAgent from stirling.api.middleware import UserIdMiddleware from stirling.api.routes import ( agent_draft_router, execution_router, + ledger_router, orchestrator_router, pdf_edit_router, pdf_question_router, @@ -40,6 +42,7 @@ async def lifespan(fast_api: FastAPI): fast_api.state.pdf_question_agent = PdfQuestionAgent(runtime) fast_api.state.user_spec_agent = UserSpecAgent(runtime) fast_api.state.execution_planning_agent = ExecutionPlanningAgent(runtime) + fast_api.state.math_auditor_agent = MathAuditorAgent(runtime) tracer_provider = setup_posthog_tracking(settings) if tracer_provider: Agent.instrument_all(InstrumentationSettings(tracer_provider=tracer_provider)) @@ -55,6 +58,7 @@ app.include_router(pdf_edit_router) app.include_router(pdf_question_router) app.include_router(agent_draft_router) app.include_router(execution_router) +app.include_router(ledger_router) @app.get("/health", response_model=HealthResponse) diff --git a/engine/src/stirling/api/dependencies.py b/engine/src/stirling/api/dependencies.py index c25e488d1..7101f4274 100644 --- a/engine/src/stirling/api/dependencies.py +++ b/engine/src/stirling/api/dependencies.py @@ -3,6 +3,7 @@ from __future__ import annotations from fastapi import Request from stirling.agents import ExecutionPlanningAgent, OrchestratorAgent, PdfEditAgent, PdfQuestionAgent, UserSpecAgent +from stirling.agents.ledger import MathAuditorAgent from stirling.services import AppRuntime @@ -28,3 +29,7 @@ def get_user_spec_agent(request: Request) -> UserSpecAgent: def get_execution_planning_agent(request: Request) -> ExecutionPlanningAgent: return request.app.state.execution_planning_agent + + +def get_math_auditor_agent(request: Request) -> MathAuditorAgent: + return request.app.state.math_auditor_agent diff --git a/engine/src/stirling/api/routes/__init__.py b/engine/src/stirling/api/routes/__init__.py index 3a920ab5e..a4a0acdb9 100644 --- a/engine/src/stirling/api/routes/__init__.py +++ b/engine/src/stirling/api/routes/__init__.py @@ -1,5 +1,6 @@ from .agent_drafts import router as agent_draft_router from .execution import router as execution_router +from .ledger import router as ledger_router from .orchestrator import router as orchestrator_router from .pdf_edit import router as pdf_edit_router from .pdf_questions import router as pdf_question_router @@ -7,6 +8,7 @@ from .pdf_questions import router as pdf_question_router __all__ = [ "agent_draft_router", "execution_router", + "ledger_router", "orchestrator_router", "pdf_edit_router", "pdf_question_router", diff --git a/engine/src/stirling/api/routes/ledger.py b/engine/src/stirling/api/routes/ledger.py new file mode 100644 index 000000000..82e220fbf --- /dev/null +++ b/engine/src/stirling/api/routes/ledger.py @@ -0,0 +1,60 @@ +""" +Math Auditor Agent (mathAuditorAgent) — FastAPI routes. + +Two internal endpoints, called only by the Java MathAuditorOrchestrator: + + POST /api/v1/ai/math-auditor-agent/examine + Java sends a FolioManifest (cheap page classification). + Python returns a Requisition (what Java must extract). + + POST /api/v1/ai/math-auditor-agent/deliberate + Java sends Evidence (fulfilled extraction results). + Python returns a Verdict directly. +""" + +from __future__ import annotations + +import logging +from decimal import Decimal, InvalidOperation +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, Query + +from stirling.agents.ledger import MathAuditorAgent +from stirling.api.dependencies import get_math_auditor_agent +from stirling.contracts.ledger import ( + Evidence, + FolioManifest, + Requisition, + Verdict, +) + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/v1/ai/math-auditor-agent", tags=["math-auditor-agent"]) + + +@router.post("/examine", response_model=Requisition) +async def examine_endpoint( + manifest: FolioManifest, + agent: Annotated[MathAuditorAgent, Depends(get_math_auditor_agent)], +) -> Requisition: + """Round 1: Java presents a FolioManifest; Python declares its Requisition.""" + return await agent.examine(manifest) + + +@router.post("/deliberate", response_model=Verdict) +async def deliberate_endpoint( + evidence: Evidence, + agent: Annotated[MathAuditorAgent, Depends(get_math_auditor_agent)], + tolerance: str = Query(default="0.01"), +) -> Verdict: + """Round 2: Java presents fulfilled Evidence; Python returns a Verdict.""" + try: + tol = Decimal(tolerance) + if tol < 0: + raise HTTPException(status_code=400, detail="tolerance must be non-negative") + except InvalidOperation: + raise HTTPException(status_code=400, detail=f"Invalid tolerance value: {tolerance!r}") + + return await agent.audit(evidence, tol) diff --git a/engine/src/stirling/config/settings.py b/engine/src/stirling/config/settings.py index 92fcf4795..0844c3ff4 100644 --- a/engine/src/stirling/config/settings.py +++ b/engine/src/stirling/config/settings.py @@ -1,5 +1,7 @@ from __future__ import annotations +import logging +import logging.handlers from functools import lru_cache from pathlib import Path @@ -19,12 +21,44 @@ class AppSettings(BaseSettings): smart_model_max_tokens: int = Field(validation_alias="STIRLING_SMART_MODEL_MAX_TOKENS") fast_model_max_tokens: int = Field(validation_alias="STIRLING_FAST_MODEL_MAX_TOKENS") + log_level: str = Field(default="INFO", validation_alias="STIRLING_LOG_LEVEL") + log_file: str = Field(default="", validation_alias="STIRLING_LOG_FILE") + posthog_enabled: bool = Field(validation_alias="STIRLING_POSTHOG_ENABLED") posthog_api_key: str = Field(validation_alias="STIRLING_POSTHOG_API_KEY") posthog_host: str = Field(validation_alias="STIRLING_POSTHOG_HOST") +def _configure_logging(level_name: str, log_file: str) -> None: + """Configure the ``stirling`` logger hierarchy.""" + level = logging.getLevelNamesMapping().get(level_name.upper()) + if level is None: + logging.getLogger("stirling").warning( + "Unknown STIRLING_LOG_LEVEL %r, defaulting to INFO", + level_name, + ) + level = logging.INFO + + root = logging.getLogger("stirling") + root.setLevel(level) + + if log_file: + log_path = Path(log_file) + log_path.parent.mkdir(parents=True, exist_ok=True) + fh = logging.handlers.TimedRotatingFileHandler( + log_path, + when="midnight", + backupCount=1, + encoding="utf-8", + ) + fh.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s [%(funcName)s] %(message)s")) + fh.setLevel(level) + root.addHandler(fh) + + @lru_cache(maxsize=1) def load_settings() -> AppSettings: load_dotenv(ENV_FILE) - return AppSettings.model_validate({}) + settings = AppSettings.model_validate({}) + _configure_logging(settings.log_level, settings.log_file) + return settings diff --git a/engine/src/stirling/contracts/__init__.py b/engine/src/stirling/contracts/__init__.py index 0593f4dd2..3f0b1fb87 100644 --- a/engine/src/stirling/contracts/__init__.py +++ b/engine/src/stirling/contracts/__init__.py @@ -29,6 +29,17 @@ from .execution import ( ToolCallExecutionAction, ) from .health import HealthResponse +from .ledger import ( + Discrepancy, + DiscrepancyKind, + Evidence, + Folio, + FolioManifest, + FolioType, + Requisition, + Severity, + Verdict, +) from .orchestrator import ( ExtractedTextArtifact, OrchestratorRequest, @@ -53,7 +64,6 @@ from .pdf_questions import ( ) __all__ = [ - "ArtifactKind", "AgentDraft", "AgentDraftRequest", "AgentDraftResponse", @@ -65,35 +75,45 @@ __all__ = [ "AgentSpec", "AgentSpecStep", "AiToolAgentStep", + "ArtifactKind", "CannotContinueExecutionAction", - "ConversationMessage", - "ExtractedFileText", "CompletedExecutionAction", + "ConversationMessage", + "Discrepancy", + "DiscrepancyKind", "EditCannotDoResponse", "EditClarificationRequest", "EditPlanResponse", + "Evidence", "ExecutionContext", "ExecutionStepResult", + "ExtractedFileText", + "ExtractedTextArtifact", + "Folio", + "FolioManifest", + "FolioType", "HealthResponse", "NeedContentFileRequest", "NextExecutionAction", - "ExtractedTextArtifact", "OrchestratorRequest", "OrchestratorResponse", + "PdfContentType", "PdfEditRequest", "PdfEditResponse", "PdfQuestionAnswerResponse", - "PdfQuestionNotFoundResponse", - "PdfContentType", "PdfQuestionNeedContentResponse", + "PdfQuestionNotFoundResponse", "PdfQuestionRequest", "PdfQuestionResponse", "PdfTextSelection", + "Requisition", + "Severity", "StepKind", "SupportedCapability", - "ToolOperationStep", "ToolCallExecutionAction", - "WorkflowOutcome", + "ToolOperationStep", "UnsupportedCapabilityResponse", + "Verdict", "WorkflowArtifact", + "WorkflowOutcome", ] diff --git a/engine/src/stirling/contracts/common.py b/engine/src/stirling/contracts/common.py index 7fd2af9aa..20c467002 100644 --- a/engine/src/stirling/contracts/common.py +++ b/engine/src/stirling/contracts/common.py @@ -1,11 +1,12 @@ from __future__ import annotations from enum import StrEnum -from typing import Literal +from typing import Literal, assert_never from pydantic import Field, model_validator -from stirling.models import OPERATIONS, ApiModel, OperationId, ParamToolModel +from stirling.models import OPERATIONS, ApiModel, OperationId +from stirling.models.agent_tool_models import AGENT_OPERATIONS, AgentToolId, AnyParamModel, AnyToolId class PdfContentType(StrEnum): @@ -82,6 +83,7 @@ class SupportedCapability(StrEnum): AGENT_DRAFT = "agent_draft" AGENT_REVISE = "agent_revise" AGENT_NEXT_ACTION = "agent_next_action" + MATH_AUDITOR_AGENT = "math_auditor_agent" class ConversationMessage(ApiModel): @@ -101,14 +103,19 @@ class ExtractedFileText(ApiModel): class ToolOperationStep(ApiModel): kind: Literal[StepKind.TOOL] = StepKind.TOOL - tool: OperationId - parameters: ParamToolModel + tool: AnyToolId + parameters: AnyParamModel @model_validator(mode="after") def validate_tool_parameter_pairing(self) -> ToolOperationStep: - expected_type = OPERATIONS[self.tool] + if isinstance(self.tool, AgentToolId): + expected_type = AGENT_OPERATIONS[self.tool] + elif isinstance(self.tool, OperationId): + expected_type = OPERATIONS[self.tool] + else: + assert_never(self.tool) + if not isinstance(self.parameters, expected_type): actual_type = type(self.parameters).__name__ - expected_type_name = expected_type.__name__ - raise ValueError(f"Parameters for tool {self.tool.value} must be {expected_type_name}, got {actual_type}.") + raise ValueError(f"Parameters for tool {self.tool} must be {expected_type.__name__}, got {actual_type}.") return self diff --git a/engine/src/stirling/contracts/ledger.py b/engine/src/stirling/contracts/ledger.py new file mode 100644 index 000000000..bc31b960e --- /dev/null +++ b/engine/src/stirling/contracts/ledger.py @@ -0,0 +1,196 @@ +""" +Ledger Auditor — shared models for the Java-Python protocol. + +Every struct that crosses the wire lives here so the contract is +impossible to miss or partially implement. +""" + +from __future__ import annotations + +from enum import StrEnum +from typing import Literal + +from pydantic import Field + +from stirling.models import ApiModel + +# --------------------------------------------------------------------------- +# Page classification — Java's side of the conversation +# --------------------------------------------------------------------------- + + +class FolioType(StrEnum): + """How Java classifies each page after a cheap PDFBox scan. + + Java counterpart: FolioType.java - values must stay in sync. + """ + + TEXT = "text" # selectable text layer present + IMAGE = "image" # image-only, will need OCR + MIXED = "mixed" # partial text layer + embedded images + + +class FolioManifest(ApiModel): + """ + Java's opening move: a fast, cheap page classification with no OCR or + table extraction — just PDFBox character counts and image detection. + + Python inspects this and returns a Requisition declaring what it needs. + """ + + session_id: str = Field(description="Opaque handle Java uses to find the PDF on disk.") + page_count: int = Field(ge=1) + folio_types: list[FolioType] = Field(description="One entry per page (0-indexed). len(folio_types) == page_count.") + round: int = Field(default=1, ge=1, le=3, description="Which negotiation round this is.") + + +# --------------------------------------------------------------------------- +# Requisition — Python's declaration of what it needs +# --------------------------------------------------------------------------- + + +class Requisition(ApiModel): + """ + Python's reply to a FolioManifest: a precise shopping list of what Java + must extract before the auditor can form an opinion. + + Java fulfils this and sends back an Evidence payload. + """ + + type: Literal["requisition"] = "requisition" + need_text: list[int] = Field( + default_factory=list, + description="0-indexed page numbers. Java runs PDFBox text extraction on these.", + ) + need_tables: list[int] = Field( + default_factory=list, + description="0-indexed page numbers. Java runs Tabula CSV extraction on these.", + ) + need_ocr: list[int] = Field( + default_factory=list, + description="0-indexed page numbers. Java runs OCRmyPDF on these.", + ) + rationale: str = Field(description="Plain-language reason, written for log readability, not the client.") + + +# --------------------------------------------------------------------------- +# Evidence — Java's fulfilment of a Requisition +# --------------------------------------------------------------------------- + + +class Folio(ApiModel): + """ + One page's worth of extracted content — whatever Java was able to provide + in response to the Requisition for that page. + """ + + page: int = Field(ge=0, description="0-indexed page number.") + text: str | None = Field(default=None, description="PDFBox plain-text extraction.") + tables: list[str] | None = Field(default=None, description="Tabula CSV strings, one per table found on the page.") + ocr_text: str | None = Field(default=None, description="OCRmyPDF output text.") + ocr_confidence: float | None = Field( + default=None, ge=0.0, le=1.0, description="Mean character confidence from OCRmyPDF." + ) + + @property + def readable_text(self) -> str: + """Best available text for this folio — OCR wins over digital when present.""" + return self.ocr_text or self.text or "" + + +class Evidence(ApiModel): + """ + Java's fulfilment package: the extracted content Python asked for. + Java may also set final_round=True on the last allowable round to signal + that the auditor must return a Verdict regardless of remaining questions. + """ + + session_id: str + folios: list[Folio] + round: int = Field(ge=1, le=3) + final_round: bool = Field( + default=False, + description="When True, Java will not honour further Requisitions. " + "The auditor must return a Verdict this round.", + ) + unauditable_pages: list[int] = Field( + default_factory=list, + description=( + "Pages that were requested in the Requisition but could not be fulfilled — " + "e.g. OCR was asked for but is not wired. The Auditor echoes these into " + "Verdict.unauditable_pages so the client knows coverage is incomplete." + ), + ) + + +# --------------------------------------------------------------------------- +# Findings — what the auditor discovers +# --------------------------------------------------------------------------- + + +class DiscrepancyKind(StrEnum): + """Java counterpart: DiscrepancyKind.java - values must stay in sync.""" + + TALLY = "tally" # a row/column sum is wrong + ARITHMETIC = "arithmetic" # an inline calculation is wrong + CONSISTENCY = "consistency" # the same figure is stated differently elsewhere + STATEMENT = "statement" # a prose claim contradicts the numbers + + +class Severity(StrEnum): + """Java counterpart: AuditSeverity.java - values must stay in sync.""" + + ERROR = "error" # definite arithmetic mistake + WARNING = "warning" # possible rounding or ambiguity + + +class Discrepancy(ApiModel): + """A single mathematical error found in the document.""" + + page: int = Field(ge=0) + kind: DiscrepancyKind + severity: Severity + description: str = Field(description="Human-readable explanation of the error.") + stated: str = Field(description="The value as it appears in the document.") + expected: str = Field(description="The value the auditor calculated.") + context: str = Field( + default="", + description="Surrounding text or table fragment for traceability.", + ) + + +# --------------------------------------------------------------------------- +# Verdict — the final report +# --------------------------------------------------------------------------- + + +class Verdict(ApiModel): + """ + The auditor's final opinion on the document's mathematical integrity. + Returned to Java as the terminal message in the negotiation. + """ + + type: Literal["verdict"] = "verdict" + session_id: str + discrepancies: list[Discrepancy] = Field(default_factory=list) + pages_examined: list[int] = Field(description="0-indexed page numbers the auditor actually inspected.") + rounds_taken: int = Field(ge=1, le=3) + summary: str = Field(description="One or two sentences summarising the audit outcome for the client.") + clean: bool = Field(description="True iff no errors were found (warnings are tolerated).") + unauditable_pages: list[int] = Field( + default_factory=list, + description=( + "0-indexed pages that could not be audited — typically because OCR was " + "requested but is not yet wired. Java populates this by omitting the folio " + "and the Auditor echoes the page number here so the client knows coverage " + "is incomplete." + ), + ) + + @property + def error_count(self) -> int: + return sum(1 for d in self.discrepancies if d.severity == Severity.ERROR) + + @property + def warning_count(self) -> int: + return sum(1 for d in self.discrepancies if d.severity == Severity.WARNING) diff --git a/engine/src/stirling/logging.py b/engine/src/stirling/logging.py new file mode 100644 index 000000000..a0a6fec20 --- /dev/null +++ b/engine/src/stirling/logging.py @@ -0,0 +1,22 @@ +"""Shared logging utilities for the Stirling AI engine.""" + +from __future__ import annotations + +import json + + +class Pretty: + """Lazy JSON formatter — only serialises when ``str()`` is called. + + Designed for use with ``logging``'s ``%s`` formatting so that the + JSON serialisation is skipped entirely when the log message is + never emitted. + """ + + __slots__ = ("_obj",) + + def __init__(self, obj: object) -> None: + self._obj = obj + + def __str__(self) -> str: + return json.dumps(self._obj, indent=2, default=str, ensure_ascii=True) diff --git a/engine/src/stirling/models/agent_tool_models.py b/engine/src/stirling/models/agent_tool_models.py new file mode 100644 index 000000000..fcef18ad4 --- /dev/null +++ b/engine/src/stirling/models/agent_tool_models.py @@ -0,0 +1,30 @@ +"""Agent tool IDs, parameter models, and registry. + +tool_models.py is auto-generated from the frontend. This file is its +manually-maintained counterpart for tools backed by AI agent pipelines. +""" + +from __future__ import annotations + +from enum import StrEnum + +from stirling.models.base import ApiModel +from stirling.models.tool_models import OperationId, ParamToolModel + + +class AgentToolId(StrEnum): + MATH_AUDITOR_AGENT = "mathAuditorAgent" + + +class MathAuditorAgentParams(ApiModel): + tolerance: str = "0.01" + + +type AgentParamModel = MathAuditorAgentParams + +type AnyToolId = OperationId | AgentToolId +type AnyParamModel = ParamToolModel | AgentParamModel + +AGENT_OPERATIONS: dict[AgentToolId, type[AgentParamModel]] = { + AgentToolId.MATH_AUDITOR_AGENT: MathAuditorAgentParams, +} diff --git a/engine/tests/ledger/__init__.py b/engine/tests/ledger/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/engine/tests/ledger/test_arithmetic_scanner.py b/engine/tests/ledger/test_arithmetic_scanner.py new file mode 100644 index 000000000..00fe4c68f --- /dev/null +++ b/engine/tests/ledger/test_arithmetic_scanner.py @@ -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" diff --git a/engine/tests/ledger/test_figure_tracker.py b/engine/tests/ledger/test_figure_tracker.py new file mode 100644 index 000000000..fec305062 --- /dev/null +++ b/engine/tests/ledger/test_figure_tracker.py @@ -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 diff --git a/engine/tests/ledger/test_formula_evaluator.py b/engine/tests/ledger/test_formula_evaluator.py new file mode 100644 index 000000000..1cf5a254d --- /dev/null +++ b/engine/tests/ledger/test_formula_evaluator.py @@ -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" diff --git a/engine/tests/ledger/test_models.py b/engine/tests/ledger/test_models.py new file mode 100644 index 000000000..98b0ef9c8 --- /dev/null +++ b/engine/tests/ledger/test_models.py @@ -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 diff --git a/engine/tests/ledger/test_routes.py b/engine/tests/ledger/test_routes.py new file mode 100644 index 000000000..6e36dd471 --- /dev/null +++ b/engine/tests/ledger/test_routes.py @@ -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 diff --git a/testing/ledger/arithmetic_error.pdf b/testing/ledger/arithmetic_error.pdf new file mode 100644 index 000000000..36d73ebca Binary files /dev/null and b/testing/ledger/arithmetic_error.pdf differ diff --git a/testing/ledger/clean_invoice.pdf b/testing/ledger/clean_invoice.pdf new file mode 100644 index 000000000..c54d7d45b --- /dev/null +++ b/testing/ledger/clean_invoice.pdf @@ -0,0 +1,86 @@ +%PDF-1.3 +% +1 0 obj +<< +/Count 1 +/Kids [3 0 R] +/MediaBox [0 0 595.28 841.89] +/Type /Pages +>> +endobj +2 0 obj +<< +/OpenAction [3 0 R /FitH null] +/PageLayout /OneColumn +/Pages 1 0 R +/Type /Catalog +>> +endobj +3 0 obj +<< +/Contents 4 0 R +/Parent 1 0 R +/Resources 7 0 R +/Type /Page +>> +endobj +4 0 obj +<< +/Filter /FlateDecode +/Length 562 +>> +stream +xMo@#J +o +%-Um/;2&ccd˞ٙwf%Rl,8#̀h|OgQ`Dw+8-}\&c@jDɖ|ՙt(t0UYP!me|wH]J`z&-|ϖa8=e!r+ls˓7hZBڕƺ+| z%ur@v {|z2UwJSU'|FTU./DPzxXB}oM_׷ch!{?qn#o+!JcR$E\|#߫<-%G +endstream +endobj +5 0 obj +<< +/BaseFont /Helvetica-Bold +/Encoding /WinAnsiEncoding +/Subtype /Type1 +/Type /Font +>> +endobj +6 0 obj +<< +/BaseFont /Helvetica +/Encoding /WinAnsiEncoding +/Subtype /Type1 +/Type /Font +>> +endobj +7 0 obj +<< +/Font <> +/ProcSet [/PDF /Text /ImageB /ImageC /ImageI] +>> +endobj +8 0 obj +<< +/CreationDate (D:20260402165856Z) +>> +endobj +xref +0 9 +0000000000 65535 f +0000000015 00000 n +0000000102 00000 n +0000000205 00000 n +0000000285 00000 n +0000000919 00000 n +0000001021 00000 n +0000001118 00000 n +0000001215 00000 n +trailer +<< +/Size 9 +/Root 2 0 R +/Info 8 0 R +/ID [<126348ECB4E9AAB0626EE0EA6D52254A><126348ECB4E9AAB0626EE0EA6D52254A>] +>> +startxref +1270 +%%EOF diff --git a/testing/ledger/consistency_error.pdf b/testing/ledger/consistency_error.pdf new file mode 100644 index 000000000..0526e86e3 Binary files /dev/null and b/testing/ledger/consistency_error.pdf differ diff --git a/testing/ledger/generate_test_pdfs.py b/testing/ledger/generate_test_pdfs.py new file mode 100644 index 000000000..8712f5d4d --- /dev/null +++ b/testing/ledger/generate_test_pdfs.py @@ -0,0 +1,285 @@ +""" +Generate test PDFs for the Ledger Auditor math validation agent. + +Run: uv run python testing/ledger/generate_test_pdfs.py +Outputs PDFs into testing/ledger/ +""" + +from fpdf import FPDF + + +def _new_pdf() -> FPDF: + pdf = FPDF() + pdf.set_auto_page_break(auto=True, margin=15) + return pdf + + +def _heading(pdf: FPDF, text: str) -> None: + pdf.set_font("Helvetica", "B", 16) + pdf.cell(0, 12, text, new_x="LMARGIN", new_y="NEXT") + pdf.ln(2) + + +def _body(pdf: FPDF) -> None: + pdf.set_font("Helvetica", "", 11) + + +def _table_row(pdf: FPDF, cells: list[str], bold: bool = False) -> None: + style = "B" if bold else "" + pdf.set_font("Helvetica", style, 10) + col_w = (pdf.w - 2 * pdf.l_margin) / len(cells) + for c in cells: + pdf.cell(col_w, 8, c, border=1, align="C") + pdf.ln() + + +# ───────────────────────────────────────────────────────────────────────────── +# 1. Clean invoice — all math is correct +# ───────────────────────────────────────────────────────────────────────────── +def create_clean_invoice(): + pdf = _new_pdf() + pdf.add_page() + + _heading(pdf, "INVOICE #1001 - Acme Corp") + _body(pdf) + pdf.cell(0, 8, "Date: 2026-03-15", new_x="LMARGIN", new_y="NEXT") + pdf.cell(0, 8, "Bill To: Widget Industries", new_x="LMARGIN", new_y="NEXT") + pdf.ln(5) + + _table_row(pdf, ["Item", "Qty", "Unit Price", "Line Total"], bold=True) + _table_row(pdf, ["Consulting Hours", "40", "$150.00", "$6,000.00"]) + _table_row(pdf, ["Software License", "5", "$200.00", "$1,000.00"]) + _table_row(pdf, ["Travel Expenses", "1", "$450.00", "$450.00"]) + _table_row(pdf, ["", "", "Subtotal", "$7,450.00"], bold=True) + + pdf.ln(5) + pdf.cell(0, 8, "Tax (10%): $745.00", new_x="LMARGIN", new_y="NEXT") + pdf.cell(0, 8, "Grand Total: $8,195.00", new_x="LMARGIN", new_y="NEXT") + pdf.ln(3) + pdf.cell( + 0, 8, + "Breakdown: $6,000.00 + $1,000.00 + $450.00 = $7,450.00", + new_x="LMARGIN", new_y="NEXT", + ) + + pdf.output("testing/ledger/clean_invoice.pdf") + print(" clean_invoice.pdf") + + +# ───────────────────────────────────────────────────────────────────────────── +# 2. Tally error — column total is wrong +# ───────────────────────────────────────────────────────────────────────────── +def create_tally_error(): + pdf = _new_pdf() + pdf.add_page() + + _heading(pdf, "Q1 2026 Expense Report") + _body(pdf) + pdf.cell(0, 8, "Department: Engineering", new_x="LMARGIN", new_y="NEXT") + pdf.ln(5) + + _table_row(pdf, ["Category", "Jan", "Feb", "Mar", "Total"], bold=True) + _table_row(pdf, ["Salaries", "$50,000", "$50,000", "$52,000", "$152,000"]) + _table_row(pdf, ["Cloud Infra", "$12,000", "$13,500", "$14,200", "$39,700"]) + _table_row(pdf, ["Equipment", "$8,000", "$2,500", "$5,000", "$15,500"]) + # BUG: column totals are wrong — Jan should be 70,000, Grand should be 207,200 + _table_row(pdf, ["Total", "$68,000", "$66,000", "$71,200", "$205,200"], bold=True) + + pdf.ln(5) + pdf.cell( + 0, 8, + "Total Q1 spend: $68,000 + $66,000 + $71,200 = $205,200", + new_x="LMARGIN", new_y="NEXT", + ) + + pdf.output("testing/ledger/tally_error.pdf") + print(" tally_error.pdf") + + +# ───────────────────────────────────────────────────────────────────────────── +# 3. Arithmetic error — inline expression is wrong +# ───────────────────────────────────────────────────────────────────────────── +def create_arithmetic_error(): + pdf = _new_pdf() + pdf.add_page() + + _heading(pdf, "Project Budget Summary") + _body(pdf) + + lines = [ + "Phase 1 (Design): $45,000", + "Phase 2 (Development): $120,000", + "Phase 3 (Testing): $35,000", + "Phase 4 (Deployment): $18,000", + "", + # BUG: 45000 + 120000 + 35000 + 18000 = 218,000, NOT 215,000 + "Total project cost: $45,000 + $120,000 + $35,000 + $18,000 = $215,000", + "", + "Contingency (15%): $32,250", + # This one is also wrong: 215000 + 32250 = 247250, but the real total + # should be 218000 + 32700 = 250700. Either way 247,250 is stated. + "Grand total with contingency: $215,000 + $32,250 = $247,250", + ] + for line in lines: + pdf.cell(0, 8, line, new_x="LMARGIN", new_y="NEXT") + + pdf.output("testing/ledger/arithmetic_error.pdf") + print(" arithmetic_error.pdf") + + +# ───────────────────────────────────────────────────────────────────────────── +# 4. Cross-page consistency error — same figure, different values +# ───────────────────────────────────────────────────────────────────────────── +def create_consistency_error(): + pdf = _new_pdf() + + # Page 1: Executive Summary + pdf.add_page() + _heading(pdf, "Annual Report 2025 - Executive Summary") + _body(pdf) + lines_p1 = [ + "FY2025 was a strong year for GlobalTech Inc.", + "", + "Total Revenue: $24,500,000", + "Total Expenses: $18,200,000", + "Net Profit: $6,300,000", + "", + "Headcount grew from 142 to 187 employees.", + "Customer acquisition cost fell to $1,250 per customer.", + ] + for line in lines_p1: + pdf.cell(0, 8, line, new_x="LMARGIN", new_y="NEXT") + + # Page 2: Financial Detail + pdf.add_page() + _heading(pdf, "Financial Detail") + _body(pdf) + + _table_row(pdf, ["Metric", "Q1", "Q2", "Q3", "Q4", "FY2025"], bold=True) + _table_row(pdf, ["Revenue", "$5,100,000", "$5,800,000", "$6,200,000", + "$7,200,000", "$24,300,000"]) + _table_row(pdf, ["Expenses", "$4,300,000", "$4,400,000", "$4,600,000", + "$4,900,000", "$18,200,000"]) + _table_row(pdf, ["Profit", "$800,000", "$1,400,000", "$1,600,000", + "$2,300,000", "$6,100,000"]) + + pdf.ln(5) + # BUG: Page 1 says Total Revenue = $24,500,000 + # Page 2 table says Revenue FY2025 = $24,300,000 + # Page 1 says Net Profit = $6,300,000 + # Page 2 table says Profit FY2025 = $6,100,000 + pdf.cell( + 0, 8, + "Full-year revenue of $24,300,000 exceeded targets by 8%.", + new_x="LMARGIN", new_y="NEXT", + ) + + pdf.output("testing/ledger/consistency_error.pdf") + print(" consistency_error.pdf") + + +# ───────────────────────────────────────────────────────────────────────────── +# 5. Mixed errors — has both tally and arithmetic problems +# ───────────────────────────────────────────────────────────────────────────── +def create_mixed_errors(): + pdf = _new_pdf() + pdf.add_page() + + _heading(pdf, "Monthly Sales Report - March 2026") + _body(pdf) + + _table_row(pdf, ["Region", "Units Sold", "Revenue", "Commission"], bold=True) + _table_row(pdf, ["North", "340", "$51,000", "$5,100"]) + _table_row(pdf, ["South", "280", "$42,000", "$4,200"]) + _table_row(pdf, ["East", "195", "$29,250", "$2,925"]) + _table_row(pdf, ["West", "410", "$61,500", "$6,150"]) + # BUG: Units should be 1225 not 1220, Revenue should be $183,750 not $182,750 + _table_row(pdf, ["Total", "1,220", "$182,750", "$18,375"], bold=True) + + pdf.ln(5) + # BUG: 51000 + 42000 + 29250 + 61500 = 183,750, NOT 182,750 + pdf.cell( + 0, 8, + "Total revenue: $51,000 + $42,000 + $29,250 + $61,500 = $182,750", + new_x="LMARGIN", new_y="NEXT", + ) + pdf.ln(3) + pdf.cell( + 0, 8, + "Commission rate: 10% across all regions.", + new_x="LMARGIN", new_y="NEXT", + ) + + pdf.output("testing/ledger/mixed_errors.pdf") + print(" mixed_errors.pdf") + + +# ───────────────────────────────────────────────────────────────────────────── +# 6. Statement errors — prose claims that contradict the numbers +# ───────────────────────────────────────────────────────────────────────────── +def create_statement_errors(): + pdf = _new_pdf() + pdf.add_page() + + _heading(pdf, "FY2025 Annual Review - Statement Errors") + _body(pdf) + + # Table with correct numbers + _table_row(pdf, ["Metric", "FY2024", "FY2025"], bold=True) + _table_row(pdf, ["Revenue", "$10,000,000", "$11,200,000"]) + _table_row(pdf, ["Expenses", "$7,500,000", "$8,100,000"]) + _table_row(pdf, ["Profit", "$2,500,000", "$3,100,000"]) + _table_row(pdf, ["Headcount", "142", "187"]) + + pdf.ln(5) + + # Correct claim: profit grew from 2.5M to 3.1M = 24% growth + pdf.cell( + 0, 8, + "Profit grew 24% year-over-year, from $2,500,000 to $3,100,000.", + new_x="LMARGIN", new_y="NEXT", + ) + + # BUG: Revenue grew from 10M to 11.2M = 12% growth, NOT 15% + pdf.cell( + 0, 8, + "Revenue increased 15% compared to the prior year.", + new_x="LMARGIN", new_y="NEXT", + ) + + # BUG: Expenses went UP from 7.5M to 8.1M, NOT decreased + pdf.cell( + 0, 8, + "Operating expenses decreased year-over-year.", + new_x="LMARGIN", new_y="NEXT", + ) + + # BUG: Headcount grew from 142 to 187 = 31.7%, NOT 25% + pdf.cell( + 0, 8, + "The team expanded by 25%, growing from 142 to 187 employees.", + new_x="LMARGIN", new_y="NEXT", + ) + + # Correct claim: profit margin = 3.1M / 11.2M = 27.68% + pdf.cell( + 0, 8, + "Net profit margin reached approximately 28%.", + new_x="LMARGIN", new_y="NEXT", + ) + + pdf.output("testing/ledger/statement_errors.pdf") + print(" statement_errors.pdf") + + +if __name__ == "__main__": + import os + os.makedirs("testing/ledger", exist_ok=True) + print("Generating test PDFs:") + create_clean_invoice() + create_tally_error() + create_arithmetic_error() + create_consistency_error() + create_mixed_errors() + create_statement_errors() + print("Done!") diff --git a/testing/ledger/mixed_errors.pdf b/testing/ledger/mixed_errors.pdf new file mode 100644 index 000000000..0d5e44ed5 Binary files /dev/null and b/testing/ledger/mixed_errors.pdf differ diff --git a/testing/ledger/statement_errors.pdf b/testing/ledger/statement_errors.pdf new file mode 100644 index 000000000..6d6d9e13e --- /dev/null +++ b/testing/ledger/statement_errors.pdf @@ -0,0 +1,86 @@ +%PDF-1.3 +% +1 0 obj +<< +/Count 1 +/Kids [3 0 R] +/MediaBox [0 0 595.28 841.89] +/Type /Pages +>> +endobj +2 0 obj +<< +/OpenAction [3 0 R /FitH null] +/PageLayout /OneColumn +/Pages 1 0 R +/Type /Catalog +>> +endobj +3 0 obj +<< +/Contents 4 0 R +/Parent 1 0 R +/Resources 7 0 R +/Type /Page +>> +endobj +4 0 obj +<< +/Filter /FlateDecode +/Length 552 +>> +stream +x}K0~9,R+vĪԇaSLXoI% !o;큡px' ca4$Gd&4|.}5s>E ʰɿ3Ѫ +RncBq<< LjQe:dˋ*E r5ک!EŦ E1t/K۾CNAw)u{?L7e7;#gcgq +R*FiD:fB)ۖYU0",݊h#=jQZ =E|4mhP)0U2Ba)c<{s##'Y&c:*_}ZbjR6 2\AI@5#{vުӱcNajGyü:9\; u bAV,Ow~\`YU膠C2Uo1 Si+qsqL8PڧyäŊ=г:P L]o&HWnUn(m+4lV$]V-R)7{wgtN +endstream +endobj +5 0 obj +<< +/BaseFont /Helvetica-Bold +/Encoding /WinAnsiEncoding +/Subtype /Type1 +/Type /Font +>> +endobj +6 0 obj +<< +/BaseFont /Helvetica +/Encoding /WinAnsiEncoding +/Subtype /Type1 +/Type /Font +>> +endobj +7 0 obj +<< +/Font <> +/ProcSet [/PDF /Text /ImageB /ImageC /ImageI] +>> +endobj +8 0 obj +<< +/CreationDate (D:20260402165856Z) +>> +endobj +xref +0 9 +0000000000 65535 f +0000000015 00000 n +0000000102 00000 n +0000000205 00000 n +0000000285 00000 n +0000000909 00000 n +0000001011 00000 n +0000001108 00000 n +0000001205 00000 n +trailer +<< +/Size 9 +/Root 2 0 R +/Info 8 0 R +/ID [] +>> +startxref +1260 +%%EOF diff --git a/testing/ledger/tally_error.pdf b/testing/ledger/tally_error.pdf new file mode 100644 index 000000000..507f4f5ce Binary files /dev/null and b/testing/ledger/tally_error.pdf differ