09-mini-projects

Mini Project 2: Contract & Invoice Compliance Extractor

A policy violation slipped through a 200-invoice month-end pile because a human reviewer missed it. Extraction, policy cross-checking, vision for scanned documents, and an Embabel workflow that knows when to ask for help instead of guessing.

August 14, 2026
spring-aistructured-outputragmultimodalembabelcompliancemini-project

The Invoice Nobody Caught

Month-end at Acme Fintech: two hundred vendor invoices land in the procurement inbox in four days, and one reviewer works through them by hand — vendor, amount, payment terms, required clauses, all checked against policy from memory and a PDF nobody has time to re-read closely. One invoice specifies 60-day payment terms; policy caps that vendor's tier at 30. It gets approved anyway, buried in the pile, and the mismatch isn't caught until finance reconciliation flags it weeks later.

The instinct is to automate the whole thing — extract, check, auto-approve. That instinct is exactly wrong on its own: an LLM auto-approving invoices with no human in the loop is a bigger risk than the manual process it replaces, not a smaller one. This project builds the version that's actually worth deploying — extraction with a real contract, policy cross-checking against real rules, and a workflow that routes anything ambiguous to a human instead of guessing, using Embabel specifically because this is exactly the kind of process whose branching outgrows a hand-written if/else tree fast.


Setup

groovy
// build.gradle
dependencies {
    // Already present if you're continuing the same project as earlier guides:
    implementation 'org.springframework.ai:spring-ai-ollama-spring-boot-starter'
    implementation 'org.springframework.ai:spring-ai-rag'
    implementation 'org.springframework.ai:spring-ai-pgvector-store-spring-boot-starter'
 
    // New for this project:
    implementation 'org.springframework.ai:spring-ai-pdf-document-reader' // PagePdfDocumentReader
    implementation 'com.embabel.agent:embabel-agent-starter'              // @Agent workflow — see Phase 5 for setup notes
}

No new dependency for the vision fallback in section 2 — Media and .media() are part of the core chat starter already on the classpath, the same as every other multimodal example in this roadmap's eighth phase.


1. Extraction: A Digital PDF, Structured

java
public record InvoiceExtraction(
    String vendorName,
    String vendorTier,
    double amount,
    int paymentTermsDays,
    LocalDate dueDate,
    boolean hasRequiredIndemnityClause,
    double extractionConfidence
) {}
java
PagePdfDocumentReader pdfReader = new PagePdfDocumentReader(invoiceResource,
    PdfDocumentReaderConfig.builder().withPagesPerDocument(1).build());
List<Document> pages = pdfReader.read();
String fullText = pages.stream().map(Document::getText).collect(Collectors.joining("\n"));
 
InvoiceExtraction extraction = chatClient.prompt()
    .user(u -> u.text("""
            Extract the invoice details below. If a field genuinely can't be
            determined from the text, use a clearly invalid sentinel value
            (paymentTermsDays: -1) rather than guessing, and reflect your
            overall confidence in extractionConfidence (0.0-1.0).
 
            Invoice text:
            %s
            """.formatted(fullText)))
    .call()
    .entity(InvoiceExtraction.class);

extractionConfidence is doing real work here, the same "give the model permission to admit uncertainty" lesson from this roadmap's multimodal-vision guide — an invoice with unusual formatting the model is genuinely unsure about should say so, not produce a confident-looking wrong number.


2. Vision Fallback: When There's No Text Layer at All

A meaningful fraction of real vendor invoices are scanned faxes or photographed paper — PagePdfDocumentReader returns almost nothing useful for those. Detect it, and fall back to vision instead of failing:

java
public InvoiceExtraction extract(Resource invoiceResource) throws IOException {
    List<Document> pages = new PagePdfDocumentReader(invoiceResource,
        PdfDocumentReaderConfig.builder().withPagesPerDocument(1).build()).read();
    String extractedText = pages.stream().map(Document::getText).collect(Collectors.joining());
 
    if (extractedText.trim().length() < 50) { // no meaningful text layer — likely a scan
        return extractFromImage(invoiceResource);
    }
    return extractFromText(extractedText);
}
 
private InvoiceExtraction extractFromImage(Resource invoiceResource) {
    return chatClient.prompt()
        .user(u -> u.text("Extract the invoice details from this scanned document image.")
            .media(MimeTypeUtils.APPLICATION_PDF, invoiceResource))
        .call()
        .entity(InvoiceExtraction.class);
}

Both paths converge on the exact same InvoiceExtraction record — everything downstream (policy checking, the Embabel workflow, the approval queue) never needs to know or care whether a given invoice arrived as clean text or a scanned photo. That convergence is the actual point: multimodal input is an ingestion-time decision, not a fork in your business logic.


3. Policy Cross-Check via RAG

java
Advisor policyRagAdvisor = RetrievalAugmentationAdvisor.builder()
    .documentRetriever(VectorStoreDocumentRetriever.builder()
        .vectorStore(policyVectorStore) // ingested the same way as Mini Project 1
        .similarityThreshold(0.65)
        .build())
    .build();
 
record ComplianceCheck(boolean compliant, String violatedPolicy, String explanation) {}
 
ComplianceCheck check = chatClient.prompt()
    .advisors(policyRagAdvisor)
    .user(u -> u.text("""
            Vendor tier: %s. Payment terms: %d days. Amount: $%.2f.
            Is this within our payment policy for this vendor tier?
            """.formatted(extraction.vendorTier(), extraction.paymentTermsDays(), extraction.amount())))
    .call()
    .entity(ComplianceCheck.class);

Same RAG pattern as every policy-grounded call in this roadmap — the payment-terms policy corpus is ingested exactly like Mini Project 1's engineering docs, just a different document set behind the same VectorStore.


4. The Embabel Workflow: Extraction Doesn't Always Go Cleanly

A hand-written pipeline handles the happy path fine. What it handles badly is everything this process actually needs: low-confidence extractions that need a different path than clean ones, and policy violations that need to reach a human instead of silently blocking. This is the exact "branching logic outgrowing hand-written control flow" case this roadmap's Embabel phase built for:

java
record InvoiceProcessingResult(String status, String reason) {}
 
@Agent(description = "Extract, verify, and route a vendor invoice for approval")
class InvoiceComplianceAgent {
 
    @Action
    InvoiceExtraction extractInvoice(InvoiceDocument doc, Ai ai) {
        return invoiceExtractionService.extract(doc.resource());
    }
 
    @Action
    ComplianceCheck checkCompliance(InvoiceExtraction extraction, Ai ai) {
        return policyCheckService.check(extraction);
    }
 
    @AchievesGoal(description = "Auto-approve a clean, compliant invoice")
    @Action
    InvoiceProcessingResult autoApprove(InvoiceExtraction extraction, ComplianceCheck check) {
        if (extraction.extractionConfidence() < 0.85 || !check.compliant()) {
            return approvalQueue.enqueue(extraction, check); // routes to section 5, not a dead end
        }
        return new InvoiceProcessingResult("APPROVED", "Compliant, high-confidence extraction");
    }
}

This is deliberately one @Action making an internal decision rather than two branching actions gated by a @Condition — a reasonable, honest choice given this roadmap's Embabel guide already noted every official Embabel example gets by on type-driven chaining, and explicit condition-branching syntax wasn't confidently verifiable at the time of writing. If your Embabel version documents @Condition clearly by the time you build this, splitting autoApprove and escalate into two separately gated actions is the more idiomatic version of the same logic.


5. Human Approval: A Queue, Not a Black Box

"Route to a human" needs to be a real, inspectable state, not a vague promise:

java
public record ApprovalQueueEntry(
    UUID id, InvoiceExtraction extraction, ComplianceCheck check,
    Instant queuedAt, String status
) {}
 
@Service
public class ApprovalQueueService {
 
    public InvoiceProcessingResult enqueue(InvoiceExtraction extraction, ComplianceCheck check) {
        ApprovalQueueEntry entry = new ApprovalQueueEntry(
            UUID.randomUUID(), extraction, check, Instant.now(), "PENDING");
        repository.save(entry);
        notificationService.notifyApprovers(entry);
        return new InvoiceProcessingResult("PENDING_REVIEW",
            check.compliant() ? "Low extraction confidence" : check.explanation());
    }
}
java
@PostMapping("/approvals/{id}/decision")
public void resolveApproval(@PathVariable UUID id, @RequestParam boolean approved, @RequestParam String reviewerNotes) {
    approvalQueueService.resolve(id, approved, reviewerNotes); // human's decision is the actual authority, always
}

This is the same output-validation discipline from this roadmap's AI security guide, applied at the workflow level instead of a single field: the model's compliance judgment is input to a human decision for anything below the confidence bar, never the decision itself.


6. Audit Trail

Every extraction, every policy check, and every routing decision gets persisted alongside the invoice — not just the final outcome:

java
auditLogService.record(new AuditEntry(
    invoiceId, extraction, check, decision, chatResponse.getMetadata().getUsage(), Instant.now()
));

When finance reconciliation (the process that caught this guide's opening incident, three weeks too late) asks "why was this approved," the answer is a stored record — the exact extraction, the exact policy check, and whether a human or the auto-approval path made the call — not a reconstruction from memory.


What's Next

This project combined structured output, RAG, multimodal vision, and Embabel — four separate phases of this roadmap in one pipeline, which is exactly the point of this phase's mini-projects over any single guide. Mini Project 5 (Fraud Investigation Pipeline) is a natural next pick if the Embabel branching-and-audit-trail angle here was the interesting part; Mini Project 1 covers the RAG-ingestion side in more depth if that's what you want to go deeper on instead.

Frequently asked questions

Why not just always use vision instead of PagePdfDocumentReader, to handle every invoice the same way?

Cost and reliability, mainly — text extraction from a clean digital PDF is cheaper and more literal than asking a vision model to read the same text out of a rendered image, and most vendor invoices do have a real text layer. Reach for vision specifically as the fallback for the subset that doesn't, as this guide does, rather than the default path for everything.

What confidence threshold should route to human review — is 0.85 the right number?

Treat it as a starting point to tune against real data, not a fixed rule — run the extraction pipeline against a batch of already-known-correct invoices, see what confidence scores correct extractions actually produce versus incorrect ones, and set the threshold where it separates the two well. This is the same golden-set instinct as this roadmap's Agent Evaluation guide, applied to a threshold instead of a pass/fail test.

Should the ComplianceCheck RAG call and the InvoiceExtraction call use the same ChatClient?

They can share a ChatClient bean, but consider different temperature settings — extraction benefits from temperature 0.0 for consistency, and policy compliance judgment often does too, so in this case sharing is fine. Where it wouldn't be is if one of the two needed a creative, high-temperature model and the other didn't.

Does this project need Embabel, or would the hand-rolled patterns from Phase 4 work just as well?

For exactly this workflow's current complexity, either would work — this project is as much a chance to practice Embabel's @Agent/@Action shape on a real multi-step business process as it is a claim that hand-rolling would fail here. Rebuild the section 4 workflow with Phase 4's Plan-and-Execute pattern instead as a genuinely useful comparison exercise.