mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 10:34:06 +02:00
Feat/math validation agent (#6012)
Co-authored-by: James Brunton <[email protected]> Co-authored-by: EthanHealy01 <[email protected]>
This commit is contained in:
co-authored by
James Brunton
EthanHealy01
parent
688f7f2013
commit
de8c483054
@@ -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)$
|
||||
|
||||
@@ -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"
|
||||
|
||||
+97
@@ -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).
|
||||
*
|
||||
* <p>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}.
|
||||
*
|
||||
* <p>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<Verdict> 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]", "_")
|
||||
: "<unnamed>";
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+21
@@ -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) {}
|
||||
+17
@@ -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();
|
||||
}
|
||||
}
|
||||
+19
@@ -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();
|
||||
}
|
||||
}
|
||||
+24
@@ -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.
|
||||
*
|
||||
* <p>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<Folio> folios,
|
||||
int round,
|
||||
boolean finalRound,
|
||||
List<Integer> unauditablePages) {}
|
||||
@@ -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}.
|
||||
*
|
||||
* <p>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<String> tables, String ocrText, Double ocrConfidence) {}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package stirling.software.proprietary.model.api.ai;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Java's opening move in the audit negotiation.
|
||||
*
|
||||
* <p>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<FolioType> folioTypes, int round) {}
|
||||
+21
@@ -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();
|
||||
}
|
||||
}
|
||||
+30
@@ -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.
|
||||
*
|
||||
* <p>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<Integer> needText,
|
||||
List<Integer> needTables,
|
||||
List<Integer> needOcr,
|
||||
String rationale) {
|
||||
|
||||
public boolean isEmpty() {
|
||||
return (needText == null || needText.isEmpty())
|
||||
&& (needTables == null || needTables.isEmpty())
|
||||
&& (needOcr == null || needOcr.isEmpty());
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
* <p>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<AuditDiscrepancy> discrepancies,
|
||||
List<Integer> pagesExamined,
|
||||
int roundsTaken,
|
||||
String summary,
|
||||
boolean clean,
|
||||
List<Integer> 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();
|
||||
}
|
||||
}
|
||||
+17
@@ -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);
|
||||
}
|
||||
}
|
||||
+226
@@ -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).
|
||||
*
|
||||
* <p>Manages a four-step Java-Python protocol:
|
||||
*
|
||||
* <ol>
|
||||
* <li>Classify all pages cheaply with PDFBox (no OCR or Tabula yet).
|
||||
* <li>Send the {@link FolioManifest} to the Python Examiner; receive a {@link Requisition}.
|
||||
* <li>Fulfil the Requisition (text / tables / OCR) for only the requested pages.
|
||||
* <li>Send the {@link Evidence} to the Python Auditor; receive a {@link Verdict}.
|
||||
* </ol>
|
||||
*
|
||||
* <p>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<FolioType> 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<FolioType> classifyPages(PDDocument document) throws IOException {
|
||||
List<FolioType> 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<Integer> 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<Folio> folios = new ArrayList<>();
|
||||
List<Integer> 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<String> 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<Integer> union(List<Integer>... lists) {
|
||||
List<Integer> result = new ArrayList<>();
|
||||
for (List<Integer> 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<Integer> list, int value) {
|
||||
return list != null && list.contains(value);
|
||||
}
|
||||
}
|
||||
+88
@@ -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<String> extractTablesAsCsv(PDDocument document, int pageNumber) throws IOException {
|
||||
SpreadsheetExtractionAlgorithm sea = new SpreadsheetExtractionAlgorithm();
|
||||
CSVFormat format =
|
||||
CSVFormat.EXCEL.builder().setEscape('"').setQuoteMode(QuoteMode.ALL).build();
|
||||
List<String> csvStrings = new ArrayList<>();
|
||||
|
||||
try (ObjectExtractor extractor = new ObjectExtractor(document)) {
|
||||
Page tabulaPage = extractor.extract(pageNumber);
|
||||
List<Table> 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.
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Math Auditor Agent (mathAuditorAgent) — AI-powered math validation for PDF documents."""
|
||||
|
||||
from .agent import MathAuditorAgent
|
||||
|
||||
__all__ = ["MathAuditorAgent"]
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
"""
|
||||
@@ -0,0 +1,5 @@
|
||||
from .arithmetic import ArithmeticScanner
|
||||
from .figures import FigureTracker
|
||||
from .formula import FormulaEvaluator
|
||||
|
||||
__all__ = ["ArithmeticScanner", "FigureTracker", "FormulaEvaluator"]
|
||||
@@ -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)]
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -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])
|
||||
@@ -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],
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
"""
|
||||
ArithmeticScanner — unit tests.
|
||||
|
||||
Tests cover the two inline arithmetic patterns the scanner targets:
|
||||
1. Equals expressions: A + B = C
|
||||
2. Total-then-addends: Total: C (A + B)
|
||||
"""
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
from stirling.agents.ledger.validators.arithmetic import ArithmeticScanner
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def scanner() -> ArithmeticScanner:
|
||||
return ArithmeticScanner(tolerance=Decimal("0.01"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Equals expressions: A + B + C = D
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_correct_equals_expression(scanner: ArithmeticScanner) -> None:
|
||||
"""A correct sum should produce no findings."""
|
||||
text = "The total cost is 100 + 200 + 150 = 450."
|
||||
assert scanner.scan(page=0, text=text) == []
|
||||
|
||||
|
||||
def test_wrong_equals_expression(scanner: ArithmeticScanner) -> None:
|
||||
"""An incorrect sum should produce one error discrepancy."""
|
||||
text = "Revenue: 500 + 300 = 900" # should be 800
|
||||
discrepancies = scanner.scan(page=3, text=text)
|
||||
assert len(discrepancies) == 1
|
||||
d = discrepancies[0]
|
||||
assert d.page == 3
|
||||
assert d.kind == "arithmetic"
|
||||
assert d.severity == "error"
|
||||
assert d.stated == "900"
|
||||
assert d.expected == "800"
|
||||
|
||||
|
||||
def test_subtraction_expression(scanner: ArithmeticScanner) -> None:
|
||||
"""Subtraction in expressions should be evaluated correctly."""
|
||||
text = "Net: 1000 - 250 = 750"
|
||||
assert scanner.scan(page=0, text=text) == []
|
||||
|
||||
|
||||
def test_wrong_subtraction(scanner: ArithmeticScanner) -> None:
|
||||
text = "Net: 1000 - 250 = 800" # should be 750
|
||||
discrepancies = scanner.scan(page=0, text=text)
|
||||
assert len(discrepancies) == 1
|
||||
assert discrepancies[0].expected == "750"
|
||||
|
||||
|
||||
def test_currency_symbols_stripped(scanner: ArithmeticScanner) -> None:
|
||||
"""Currency symbols and thousand separators must not break parsing."""
|
||||
text = "Total: £1,000 + £500 = £1,500"
|
||||
assert scanner.scan(page=0, text=text) == []
|
||||
|
||||
|
||||
def test_multiple_expressions_in_text(scanner: ArithmeticScanner) -> None:
|
||||
"""Multiple expressions in the same text should each be evaluated."""
|
||||
text = (
|
||||
"Q1 revenue: 100 + 200 = 300. "
|
||||
"Q2 revenue: 150 + 100 = 350. " # wrong: should be 250
|
||||
)
|
||||
discrepancies = scanner.scan(page=0, text=text)
|
||||
assert len(discrepancies) == 1
|
||||
assert discrepancies[0].expected == "250"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Total-then-addends: "Total: X (A + B + C)"
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_correct_total_then_addends(scanner: ArithmeticScanner) -> None:
|
||||
text = "Grand Total: 750 (300 + 250 + 200)"
|
||||
assert scanner.scan(page=0, text=text) == []
|
||||
|
||||
|
||||
def test_wrong_total_then_addends(scanner: ArithmeticScanner) -> None:
|
||||
text = "Grand Total: 900 (300 + 250 + 200)" # addends sum to 750
|
||||
discrepancies = scanner.scan(page=0, text=text)
|
||||
assert len(discrepancies) == 1
|
||||
d = discrepancies[0]
|
||||
assert d.stated == "900"
|
||||
assert d.expected == "750"
|
||||
|
||||
|
||||
def test_total_keyword_variations(scanner: ArithmeticScanner) -> None:
|
||||
"""The pattern must work for 'Sum', 'Subtotal', 'Grand Total' etc."""
|
||||
cases = [
|
||||
("Sum: 600 (200 + 200 + 200)", True),
|
||||
("Subtotal: 600 (200 + 200 + 200)", True),
|
||||
("Total: 999 (200 + 200 + 200)", False), # wrong
|
||||
]
|
||||
for text, should_be_clean in cases:
|
||||
result = scanner.scan(page=0, text=text)
|
||||
if should_be_clean:
|
||||
assert result == [], f"Expected clean for: {text!r}"
|
||||
else:
|
||||
assert len(result) == 1, f"Expected error for: {text!r}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Edge cases
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_no_expressions_in_text(scanner: ArithmeticScanner) -> None:
|
||||
text = "This paragraph discusses revenue trends but contains no arithmetic."
|
||||
assert scanner.scan(page=0, text=text) == []
|
||||
|
||||
|
||||
def test_empty_text(scanner: ArithmeticScanner) -> None:
|
||||
assert scanner.scan(page=0, text="") == []
|
||||
|
||||
|
||||
def test_leading_negative_expression(scanner: ArithmeticScanner) -> None:
|
||||
"""Expressions starting with a negative number should evaluate correctly."""
|
||||
text = "Adjustment: -100 + 250 = 150"
|
||||
assert scanner.scan(page=0, text=text) == []
|
||||
|
||||
|
||||
def test_leading_negative_wrong(scanner: ArithmeticScanner) -> None:
|
||||
text = "Adjustment: -100 + 250 = 200" # should be 150
|
||||
discrepancies = scanner.scan(page=0, text=text)
|
||||
assert len(discrepancies) == 1
|
||||
assert discrepancies[0].expected == "150"
|
||||
@@ -0,0 +1,100 @@
|
||||
"""
|
||||
FigureTracker — unit tests.
|
||||
|
||||
Tests that named figures are correctly accumulated and that conflicting
|
||||
sightings (same label, different value) are surfaced as consistency warnings.
|
||||
"""
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
from stirling.agents.ledger.validators.figures import FigureTracker
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tracker() -> FigureTracker:
|
||||
return FigureTracker(tolerance=Decimal("0.01"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# No conflicts
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_no_conflicts_single_figure(tracker: FigureTracker) -> None:
|
||||
tracker.record("Net Profit", Decimal("1200.00"), page=3, raw="£1,200.00")
|
||||
assert tracker.conflicts() == []
|
||||
|
||||
|
||||
def test_no_conflicts_consistent_figure(tracker: FigureTracker) -> None:
|
||||
"""The same figure cited identically on two pages must not raise a conflict."""
|
||||
tracker.record("Total Revenue", Decimal("5000.00"), page=1, raw="£5,000")
|
||||
tracker.record("Total Revenue", Decimal("5000.00"), page=8, raw="£5,000")
|
||||
assert tracker.conflicts() == []
|
||||
|
||||
|
||||
def test_no_conflicts_within_tolerance(tracker: FigureTracker) -> None:
|
||||
"""A difference within tolerance must not be flagged."""
|
||||
tracker.record("VAT", Decimal("100.00"), page=2, raw="£100.00")
|
||||
tracker.record("VAT", Decimal("100.005"), page=5, raw="£100.005")
|
||||
assert tracker.conflicts() == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Conflicts
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_conflict_different_values(tracker: FigureTracker) -> None:
|
||||
"""Same label, different value on two pages → one consistency warning."""
|
||||
tracker.record("Net Profit", Decimal("1200.00"), page=3, raw="£1,200")
|
||||
tracker.record("Net Profit", Decimal("1250.00"), page=7, raw="£1,250")
|
||||
conflicts = tracker.conflicts()
|
||||
assert len(conflicts) == 1
|
||||
d = conflicts[0]
|
||||
assert d.kind == "consistency"
|
||||
assert d.severity == "warning"
|
||||
assert d.page == 7 # later occurrence is flagged
|
||||
|
||||
|
||||
def test_conflict_three_sightings_two_values(tracker: FigureTracker) -> None:
|
||||
"""Three sightings where one differs from canonical → 1 conflict."""
|
||||
tracker.record("Revenue", Decimal("1000"), page=1, raw="£1,000")
|
||||
tracker.record("Revenue", Decimal("1000"), page=3, raw="£1,000")
|
||||
tracker.record("Revenue", Decimal("999"), page=5, raw="£999")
|
||||
conflicts = tracker.conflicts()
|
||||
# Canonical=p1 (1000). p3 matches, p5 differs → 1 conflict
|
||||
assert len(conflicts) == 1
|
||||
assert conflicts[0].page == 5
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Label normalisation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_label_normalisation_case_insensitive(tracker: FigureTracker) -> None:
|
||||
"""Labels must be compared case-insensitively."""
|
||||
tracker.record("Net Profit", Decimal("1200"), page=2, raw="1200")
|
||||
tracker.record("net profit", Decimal("1100"), page=4, raw="1100")
|
||||
assert len(tracker.conflicts()) == 1
|
||||
|
||||
|
||||
def test_label_normalisation_punctuation(tracker: FigureTracker) -> None:
|
||||
"""Colons and dashes in labels must be normalised before comparison."""
|
||||
tracker.record("Total Revenue:", Decimal("5000"), page=1, raw="5000")
|
||||
tracker.record("Total Revenue —", Decimal("4000"), page=9, raw="4000")
|
||||
assert len(tracker.conflicts()) == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Entry count
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_entry_count(tracker: FigureTracker) -> None:
|
||||
tracker.record("A", Decimal("1"), page=0, raw="1")
|
||||
tracker.record("A", Decimal("1"), page=1, raw="1")
|
||||
tracker.record("B", Decimal("2"), page=2, raw="2")
|
||||
assert tracker.entry_count == 3
|
||||
@@ -0,0 +1,205 @@
|
||||
"""
|
||||
FormulaEvaluator — unit tests.
|
||||
|
||||
Tests cover:
|
||||
- Operator precedence (* / before + -)
|
||||
- Column reference replacement (colN with word boundaries)
|
||||
- Negative number handling
|
||||
- each_row, column_total, and single_cell scopes
|
||||
"""
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
from stirling.agents.ledger.validators.formula import FormulaEvaluator
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def evaluator() -> FormulaEvaluator:
|
||||
return FormulaEvaluator(tolerance=Decimal("0.01"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _safe_eval — operator precedence
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_safe_eval_addition(evaluator: FormulaEvaluator) -> None:
|
||||
assert evaluator._safe_eval("2 + 3") == Decimal("5")
|
||||
|
||||
|
||||
def test_safe_eval_multiplication_before_addition(evaluator: FormulaEvaluator) -> None:
|
||||
"""2 + 3 * 4 should be 14, not 20."""
|
||||
assert evaluator._safe_eval("2 + 3 * 4") == Decimal("14")
|
||||
|
||||
|
||||
def test_safe_eval_division_before_subtraction(evaluator: FormulaEvaluator) -> None:
|
||||
"""10 - 6 / 2 should be 7, not 2."""
|
||||
assert evaluator._safe_eval("10 - 6 / 2") == Decimal("7")
|
||||
|
||||
|
||||
def test_safe_eval_mixed_precedence(evaluator: FormulaEvaluator) -> None:
|
||||
"""1 + 2 * 3 - 4 / 2 should be 1 + 6 - 2 = 5."""
|
||||
assert evaluator._safe_eval("1 + 2 * 3 - 4 / 2") == Decimal("5")
|
||||
|
||||
|
||||
def test_safe_eval_all_multiplication(evaluator: FormulaEvaluator) -> None:
|
||||
assert evaluator._safe_eval("2 * 3 * 4") == Decimal("24")
|
||||
|
||||
|
||||
def test_safe_eval_division_by_zero(evaluator: FormulaEvaluator) -> None:
|
||||
assert evaluator._safe_eval("10 / 0") is None
|
||||
|
||||
|
||||
def test_safe_eval_negative_result(evaluator: FormulaEvaluator) -> None:
|
||||
assert evaluator._safe_eval("3 - 5") == Decimal("-2")
|
||||
|
||||
|
||||
def test_safe_eval_leading_negative(evaluator: FormulaEvaluator) -> None:
|
||||
"""Expressions starting with a negative number should work."""
|
||||
result = evaluator._safe_eval("-100 + 200")
|
||||
assert result == Decimal("100")
|
||||
|
||||
|
||||
def test_safe_eval_empty(evaluator: FormulaEvaluator) -> None:
|
||||
assert evaluator._safe_eval("") is None
|
||||
|
||||
|
||||
def test_safe_eval_single_number(evaluator: FormulaEvaluator) -> None:
|
||||
assert evaluator._safe_eval("42") == Decimal("42")
|
||||
|
||||
|
||||
def test_safe_eval_decimal_numbers(evaluator: FormulaEvaluator) -> None:
|
||||
result = evaluator._safe_eval("1.5 * 2 + 0.5")
|
||||
assert result == Decimal("3.5")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# colN replacement — word boundary safety
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_col1_does_not_corrupt_col12(evaluator: FormulaEvaluator) -> None:
|
||||
"""col1 replacement must not alter col12."""
|
||||
csv = "a,b,c,d,e,f,g,h,i,j,k,l,m\n0,10,0,0,0,0,0,0,0,0,0,0,120\n"
|
||||
# col1=10, col12=120 → col12 - col1 should be 110
|
||||
result = evaluator.evaluate(
|
||||
page=0,
|
||||
table_csv=csv,
|
||||
formula="col0 = col12 - col1",
|
||||
scope="each_row",
|
||||
description="test",
|
||||
)
|
||||
# row 1: col0=0, expected=120-10=110 → discrepancy
|
||||
assert len(result) == 1
|
||||
assert result[0].expected == "110"
|
||||
|
||||
|
||||
def test_col_replacement_adjacent_columns(evaluator: FormulaEvaluator) -> None:
|
||||
"""col1 and col10 should both be replaced correctly."""
|
||||
csv = "a,b,c,d,e,f,g,h,i,j,k\n55,5,0,0,0,0,0,0,0,0,50\n"
|
||||
# col0=55, col1=5, col10=50 → col1 + col10 = 55
|
||||
result = evaluator.evaluate(
|
||||
page=0,
|
||||
table_csv=csv,
|
||||
formula="col0 = col1 + col10",
|
||||
scope="each_row",
|
||||
description="test",
|
||||
)
|
||||
assert result == [] # 5 + 50 = 55, matches col0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# each_row scope
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_each_row_correct(evaluator: FormulaEvaluator) -> None:
|
||||
csv = "Item,Qty,Price,Total\nWidget,10,5,50\nGadget,3,20,60\n"
|
||||
result = evaluator.evaluate(
|
||||
page=0,
|
||||
table_csv=csv,
|
||||
formula="col3 = col1 * col2",
|
||||
scope="each_row",
|
||||
description="unit price check",
|
||||
)
|
||||
assert result == []
|
||||
|
||||
|
||||
def test_each_row_error(evaluator: FormulaEvaluator) -> None:
|
||||
csv = "Item,Qty,Price,Total\nWidget,10,5,50\nGadget,3,20,99\n"
|
||||
result = evaluator.evaluate(
|
||||
page=0,
|
||||
table_csv=csv,
|
||||
formula="col3 = col1 * col2",
|
||||
scope="each_row",
|
||||
description="unit price check",
|
||||
)
|
||||
assert len(result) == 1
|
||||
assert result[0].expected == "60"
|
||||
assert result[0].stated == "99"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# column_total scope
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_column_total_correct(evaluator: FormulaEvaluator) -> None:
|
||||
csv = "Name,Amount\nA,100\nB,200\nTotal,300\n"
|
||||
result = evaluator.evaluate(
|
||||
page=0,
|
||||
table_csv=csv,
|
||||
formula="sum",
|
||||
scope="column_total",
|
||||
description="total check",
|
||||
target_row=3,
|
||||
target_col=1,
|
||||
)
|
||||
assert result == []
|
||||
|
||||
|
||||
def test_column_total_error(evaluator: FormulaEvaluator) -> None:
|
||||
csv = "Name,Amount\nA,100\nB,200\nTotal,400\n"
|
||||
result = evaluator.evaluate(
|
||||
page=0,
|
||||
table_csv=csv,
|
||||
formula="sum",
|
||||
scope="column_total",
|
||||
description="total check",
|
||||
target_row=3,
|
||||
target_col=1,
|
||||
)
|
||||
assert len(result) == 1
|
||||
assert result[0].expected == "300"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# single_cell scope
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_single_cell_correct(evaluator: FormulaEvaluator) -> None:
|
||||
csv = "A,B,C\n10,20,30\n5,15,20\n15,35,50\n"
|
||||
result = evaluator.evaluate(
|
||||
page=0,
|
||||
table_csv=csv,
|
||||
formula="cell(3,2) = cell(1,2) + cell(2,2)",
|
||||
scope="single_cell",
|
||||
description="grand total",
|
||||
)
|
||||
assert result == []
|
||||
|
||||
|
||||
def test_single_cell_error(evaluator: FormulaEvaluator) -> None:
|
||||
csv = "A,B,C\n10,20,30\n5,15,20\n15,35,99\n"
|
||||
result = evaluator.evaluate(
|
||||
page=0,
|
||||
table_csv=csv,
|
||||
formula="cell(3,2) = cell(1,2) + cell(2,2)",
|
||||
scope="single_cell",
|
||||
description="grand total",
|
||||
)
|
||||
assert len(result) == 1
|
||||
assert result[0].expected == "50"
|
||||
@@ -0,0 +1,144 @@
|
||||
"""
|
||||
Ledger models — unit tests for serialisation and business logic.
|
||||
|
||||
These tests confirm the wire contract: models round-trip through JSON
|
||||
correctly and their helper properties behave as documented.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from stirling.contracts.ledger import (
|
||||
Discrepancy,
|
||||
DiscrepancyKind,
|
||||
Evidence,
|
||||
Folio,
|
||||
FolioManifest,
|
||||
FolioType,
|
||||
Requisition,
|
||||
Severity,
|
||||
Verdict,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FolioManifest
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_folio_manifest_round_trip() -> None:
|
||||
manifest = FolioManifest(
|
||||
session_id="abc-123",
|
||||
page_count=3,
|
||||
folio_types=[FolioType.TEXT, FolioType.IMAGE, FolioType.MIXED],
|
||||
)
|
||||
reloaded = FolioManifest.model_validate_json(manifest.model_dump_json())
|
||||
assert reloaded == manifest
|
||||
|
||||
|
||||
def test_folio_manifest_round_bounds() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
FolioManifest(session_id="x", page_count=1, folio_types=[FolioType.TEXT], round=0)
|
||||
with pytest.raises(ValidationError):
|
||||
FolioManifest(session_id="x", page_count=1, folio_types=[FolioType.TEXT], round=4)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Requisition
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_requisition_empty() -> None:
|
||||
req = Requisition(rationale="nothing needed")
|
||||
assert req.need_text == []
|
||||
assert req.need_tables == []
|
||||
assert req.need_ocr == []
|
||||
|
||||
|
||||
def test_requisition_type_discriminator() -> None:
|
||||
req = Requisition(need_text=[0, 1], rationale="needs text")
|
||||
assert req.type == "requisition"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Folio.readable_text
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_folio_readable_text_prefers_ocr() -> None:
|
||||
folio = Folio(page=0, text="digital text", ocr_text="ocr text")
|
||||
assert folio.readable_text == "ocr text"
|
||||
|
||||
|
||||
def test_folio_readable_text_falls_back_to_text() -> None:
|
||||
folio = Folio(page=0, text="digital text")
|
||||
assert folio.readable_text == "digital text"
|
||||
|
||||
|
||||
def test_folio_readable_text_empty_when_none() -> None:
|
||||
folio = Folio(page=0)
|
||||
assert folio.readable_text == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Verdict
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_verdict_clean_flag() -> None:
|
||||
verdict = Verdict(
|
||||
session_id="s1",
|
||||
discrepancies=[],
|
||||
pages_examined=[0, 1],
|
||||
rounds_taken=2,
|
||||
summary="All figures balance.",
|
||||
clean=True,
|
||||
)
|
||||
assert verdict.error_count == 0
|
||||
assert verdict.warning_count == 0
|
||||
assert verdict.clean is True
|
||||
|
||||
|
||||
def test_verdict_error_and_warning_counts() -> None:
|
||||
discrepancies = [
|
||||
Discrepancy(
|
||||
page=0,
|
||||
kind=DiscrepancyKind.TALLY,
|
||||
severity=Severity.ERROR,
|
||||
description="bad sum",
|
||||
stated="100",
|
||||
expected="110",
|
||||
),
|
||||
Discrepancy(
|
||||
page=1,
|
||||
kind=DiscrepancyKind.CONSISTENCY,
|
||||
severity=Severity.WARNING,
|
||||
description="mismatched figure",
|
||||
stated="500",
|
||||
expected="550",
|
||||
),
|
||||
]
|
||||
verdict = Verdict(
|
||||
session_id="s1",
|
||||
discrepancies=discrepancies,
|
||||
pages_examined=[0, 1],
|
||||
rounds_taken=1,
|
||||
summary="Issues found.",
|
||||
clean=False,
|
||||
)
|
||||
assert verdict.error_count == 1
|
||||
assert verdict.warning_count == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Evidence.final_round
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_evidence_final_round() -> None:
|
||||
evidence = Evidence(
|
||||
session_id="s",
|
||||
folios=[Folio(page=0, text="hello")],
|
||||
round=3,
|
||||
final_round=True,
|
||||
)
|
||||
assert evidence.final_round is True
|
||||
@@ -0,0 +1,249 @@
|
||||
"""
|
||||
Ledger Auditor — FastAPI route tests.
|
||||
|
||||
Uses FastAPI's TestClient with dependency overrides. All LLM calls are
|
||||
mocked out; these tests exercise HTTP parsing, serialisation, and response
|
||||
enveloping only — not the agent's reasoning.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from stirling.api import app
|
||||
from stirling.api.dependencies import get_math_auditor_agent
|
||||
from stirling.config import AppSettings, load_settings
|
||||
from stirling.contracts.ledger import (
|
||||
Discrepancy,
|
||||
DiscrepancyKind,
|
||||
Evidence,
|
||||
FolioManifest,
|
||||
Requisition,
|
||||
Severity,
|
||||
Verdict,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stubs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class StubSettingsProvider:
|
||||
def __call__(self) -> AppSettings:
|
||||
return AppSettings(
|
||||
smart_model_name="test",
|
||||
fast_model_name="test",
|
||||
smart_model_max_tokens=8192,
|
||||
fast_model_max_tokens=2048,
|
||||
posthog_enabled=False,
|
||||
posthog_api_key="",
|
||||
posthog_host="https://eu.i.posthog.com",
|
||||
)
|
||||
|
||||
|
||||
class StubLedgerAgent:
|
||||
"""Stub that returns canned responses without touching any model."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
requisition: Requisition | None = None,
|
||||
verdict: Verdict | None = None,
|
||||
) -> None:
|
||||
self._requisition = requisition or _stub_requisition()
|
||||
self._verdict = verdict or _stub_verdict()
|
||||
self.examine_calls: list[FolioManifest] = []
|
||||
self.audit_calls: list[tuple[Evidence, Decimal]] = []
|
||||
|
||||
async def examine(self, manifest: FolioManifest) -> Requisition:
|
||||
self.examine_calls.append(manifest)
|
||||
return self._requisition
|
||||
|
||||
async def audit(self, evidence: Evidence, tolerance: Decimal = Decimal("0.01")) -> Verdict:
|
||||
self.audit_calls.append((evidence, tolerance))
|
||||
return self._verdict
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _stub_requisition() -> Requisition:
|
||||
return Requisition(
|
||||
need_text=[0, 2],
|
||||
need_tables=[0],
|
||||
need_ocr=[1],
|
||||
rationale="Page 1 is image-only; pages 0 and 2 have financial text.",
|
||||
)
|
||||
|
||||
|
||||
def _stub_verdict(
|
||||
clean: bool = True,
|
||||
discrepancies: list[Discrepancy] | None = None,
|
||||
) -> Verdict:
|
||||
return Verdict(
|
||||
session_id="test-session",
|
||||
discrepancies=discrepancies or [],
|
||||
pages_examined=[0, 2],
|
||||
rounds_taken=2,
|
||||
summary="No errors found." if clean else "1 tally error found.",
|
||||
clean=clean,
|
||||
)
|
||||
|
||||
|
||||
def _manifest_body(**overrides: object) -> dict[str, object]:
|
||||
base: dict[str, object] = {
|
||||
"sessionId": "test-session",
|
||||
"pageCount": 3,
|
||||
"folioTypes": ["text", "image", "mixed"],
|
||||
"round": 1,
|
||||
}
|
||||
return {**base, **overrides}
|
||||
|
||||
|
||||
def _evidence_body(**overrides: object) -> dict[str, object]:
|
||||
base: dict[str, object] = {
|
||||
"sessionId": "test-session",
|
||||
"folios": [
|
||||
{"page": 0, "text": "Fee: £100\nTax: £20\nTotal: £120"},
|
||||
{"page": 2, "text": "Summary: all tallies correct"},
|
||||
],
|
||||
"round": 2,
|
||||
"finalRound": False,
|
||||
}
|
||||
return {**base, **overrides}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def stub_agent() -> StubLedgerAgent:
|
||||
return StubLedgerAgent()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(stub_agent: StubLedgerAgent) -> Iterator[TestClient]:
|
||||
app.dependency_overrides[load_settings] = StubSettingsProvider()
|
||||
app.dependency_overrides[get_math_auditor_agent] = lambda: stub_agent
|
||||
yield TestClient(app, raise_server_exceptions=False)
|
||||
app.dependency_overrides.pop(load_settings, None)
|
||||
app.dependency_overrides.pop(get_math_auditor_agent, None)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /api/v1/ai/math-auditor-agent/examine
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestExamineEndpoint:
|
||||
"""Tests for POST /api/v1/ai/math-auditor-agent/examine."""
|
||||
|
||||
def test_returns_200(self, client: TestClient) -> None:
|
||||
resp = client.post("/api/v1/ai/math-auditor-agent/examine", json=_manifest_body())
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_response_is_requisition(self, client: TestClient) -> None:
|
||||
resp = client.post("/api/v1/ai/math-auditor-agent/examine", json=_manifest_body())
|
||||
body = resp.json()
|
||||
assert body["type"] == "requisition"
|
||||
assert body["needText"] == [0, 2]
|
||||
assert body["needTables"] == [0]
|
||||
assert body["needOcr"] == [1]
|
||||
assert "rationale" in body
|
||||
|
||||
def test_examine_called_with_parsed_manifest(
|
||||
self,
|
||||
client: TestClient,
|
||||
stub_agent: StubLedgerAgent,
|
||||
) -> None:
|
||||
client.post("/api/v1/ai/math-auditor-agent/examine", json=_manifest_body(sessionId="my-session", pageCount=3))
|
||||
assert len(stub_agent.examine_calls) == 1
|
||||
manifest = stub_agent.examine_calls[0]
|
||||
assert manifest.session_id == "my-session"
|
||||
assert manifest.page_count == 3
|
||||
|
||||
def test_content_type_is_json(self, client: TestClient) -> None:
|
||||
resp = client.post("/api/v1/ai/math-auditor-agent/examine", json=_manifest_body())
|
||||
assert "application/json" in resp.headers["content-type"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /api/v1/ai/math-auditor-agent/deliberate
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDeliberateEndpoint:
|
||||
"""Tests for POST /api/v1/ai/math-auditor-agent/deliberate."""
|
||||
|
||||
def test_returns_200_clean(self, client: TestClient) -> None:
|
||||
resp = client.post("/api/v1/ai/math-auditor-agent/deliberate", json=_evidence_body())
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_response_is_verdict(self, client: TestClient) -> None:
|
||||
resp = client.post("/api/v1/ai/math-auditor-agent/deliberate", json=_evidence_body())
|
||||
body = resp.json()
|
||||
assert body["type"] == "verdict"
|
||||
assert body["clean"] is True
|
||||
|
||||
def test_discrepancies_serialised(self, client: TestClient) -> None:
|
||||
d = Discrepancy(
|
||||
page=0,
|
||||
kind=DiscrepancyKind.TALLY,
|
||||
severity=Severity.ERROR,
|
||||
description="Column total wrong",
|
||||
stated="250",
|
||||
expected="300",
|
||||
)
|
||||
stub = StubLedgerAgent(verdict=_stub_verdict(clean=False, discrepancies=[d]))
|
||||
app.dependency_overrides[get_math_auditor_agent] = lambda: stub
|
||||
resp = client.post("/api/v1/ai/math-auditor-agent/deliberate", json=_evidence_body())
|
||||
body = resp.json()
|
||||
discrepancies = body["discrepancies"]
|
||||
assert len(discrepancies) == 1
|
||||
assert discrepancies[0]["kind"] == "tally"
|
||||
assert discrepancies[0]["severity"] == "error"
|
||||
assert discrepancies[0]["stated"] == "250"
|
||||
assert discrepancies[0]["expected"] == "300"
|
||||
|
||||
def test_tolerance_query_param_forwarded(
|
||||
self,
|
||||
client: TestClient,
|
||||
stub_agent: StubLedgerAgent,
|
||||
) -> None:
|
||||
client.post("/api/v1/ai/math-auditor-agent/deliberate?tolerance=0.05", json=_evidence_body())
|
||||
assert len(stub_agent.audit_calls) == 1
|
||||
_, tolerance = stub_agent.audit_calls[0]
|
||||
assert tolerance == Decimal("0.05")
|
||||
|
||||
def test_default_tolerance_when_omitted(
|
||||
self,
|
||||
client: TestClient,
|
||||
stub_agent: StubLedgerAgent,
|
||||
) -> None:
|
||||
client.post("/api/v1/ai/math-auditor-agent/deliberate", json=_evidence_body())
|
||||
_, tolerance = stub_agent.audit_calls[0]
|
||||
assert tolerance == Decimal("0.01")
|
||||
|
||||
def test_invalid_tolerance_returns_400(
|
||||
self,
|
||||
client: TestClient,
|
||||
stub_agent: StubLedgerAgent,
|
||||
) -> None:
|
||||
resp = client.post("/api/v1/ai/math-auditor-agent/deliberate?tolerance=notanumber", json=_evidence_body())
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_final_round_flag_parsed(
|
||||
self,
|
||||
client: TestClient,
|
||||
stub_agent: StubLedgerAgent,
|
||||
) -> None:
|
||||
client.post("/api/v1/ai/math-auditor-agent/deliberate", json=_evidence_body(finalRound=True))
|
||||
evidence, _ = stub_agent.audit_calls[0]
|
||||
assert evidence.final_round is True
|
||||
Binary file not shown.
@@ -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
|
||||
xœ…”MoÚ@†ïù#•J‰
|
||||
›ÝÙo¤
|
||||
%-UÔÅm/¹¸Á‰Ü;2&¤ÿ¾»ccÌödËž÷Ù™wfáÓ%RÃælÁ妥ÝÃ,òŸ8#Ì€¶ŒhÑÎçŸ|™OgðŠQÊ`ïî˜æÅÓD¿w¢Ë+Æú8ÒÄ-ç}\&c@ŠjDùˆÉ–|ˑՙ“t¹„(ÃÏtñ�”0ÏëUY¤ÉêàP—<mEC¸ÍáDI!e HàœÄHÂ-(k‰µUqeòX#™Bÿ9@dÄš6á[ù· ÕD‰0€F¶ß³´„¯Ez—ÔÁ‘àzÛ¶ç:ÍçY/»�9òH¹î:d?YjÂ]‚zûðàižÖË2Íàc¾.V¿‚0DA´jÓíØÔs¡ˆ±mý€I_OÇ« Dxµ>€¨!¥mÌÎe|wƒ¾Há“ö�›ü¾ÜÄî÷µë]¶Jº¾„`ˆÚÏz‹&»¶„äµ-�|€´Ï–¤¶¥aý¶¸Ó8=e‹!‚ƒr+lÕñs²„ÙË“7åhZB¬Ú•ƺ®„äµ+�| z‡%†urè@vž ÷{|ÌØz2 ïêÚUwJSU'|FŠºÕT¸þU¶÷½./D©›ÞPzxXàþB–}o·M�_àöœÑ×·ch!{ã…õËîã?q¶¨n#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 <</F1 5 0 R
|
||||
/F2 6 0 R>>
|
||||
/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
|
||||
Binary file not shown.
@@ -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!")
|
||||
Binary file not shown.
@@ -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œ}ÔK�Ú0ðû~Š9,R+…©ßvŽÄªªÔ‡º¹ô˜‚aS‘‡LX–oßI€% !ðoü;ðí�¡¶pxø’À§'Ü c�¬a‘4�$GîÀÆ‚džþ&4|.Š}º…ßþ5ó˜Ãs�Ö>÷E
|
||||
‹Ê°ûÉ¿3Ѫìª
|
||||
‡Rƒµ�ncÔæB q<<
|
||||
áL¢ŒÁjúQ´e¿û:dË‹*˜Eç¦
|
||||
árÙ5Ú©«‹!�EŦ
|
||||
Eƒ�¹1t/�K§Û¾�«±CN…¥Awê)uÑ{?L7e7£;È#gcígq
|
||||
R*Fiû�D:‡¢‹f÷BäÔ)®�Û–Y¼U¾ØùÝ0Õ",ÝŠ®òh#=jÊQZ ×=ÇE|4ÓèäÝmhP)0´ûUÜ2¿B¹Îêa¤)ã©c<Š{‘¦œs¤®#Ç#'›ÞY&‰±c:Þ*_}ºZ–ûbjRŽ6
|
||||
ë2\‰AšI@5#{óàÎvÞªÓ±cè›Nãaè´jGŸ†yùêüùÁ:”9\;u ×þàÌb®ûòAV,ƒOw~\Ï`YæUè† úÅC²2´Uo1íšSŠŸ•i�ðçí+qûsqL»8�“PÉÚ§yäŊÿ=‚гˆ:P¿
|
||||
L]o&H½ŸWÛòèýnÄUéÈnܾ¦(m+ó4l²‚V$]¾�ŸVôü-ËéßR)7{wþºgtN
|
||||
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 <</F1 5 0 R
|
||||
/F2 6 0 R>>
|
||||
/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
|
||||
Binary file not shown.
Reference in New Issue
Block a user