Skip to content

Repository files navigation

ExportGuard 🛃

LLM-powered export-control screening for chemical trade — deterministic screen, LLM explanation, human decision.

ExportGuard screens outbound chemical sales orders against real regulatory control lists (EU dual-use, embargo, drug-precursor and PIC lists). It is a portfolio project demonstrating production-grade LLM application engineering on the Anthropic API: structured document ingestion, citation-backed explanations, local RAG, a tool-using copilot, and AI regulation-change summaries — all wrapped around a screening core that deliberately contains no model at all.

The architectural thesis: in compliance, decisions must be reproducible and auditable. So the LLM extracts, explains and summarizes — exact matching flags, and a human decides every outcome.

The five LLM features

Feature What it does API technique
Smart ingestion Parses regulatory PDFs/XLSX/CSV (mostly German) into structured control entries — CAS numbers, HS codes, embargoed countries messages.parse() + Pydantic schema; PDF document blocks; every proposed entry re-verified by deterministic validators (CAS checksum, HS format, ISO country) before it can enter the database
Hit explanations Streams why a transaction matched, quoting the exact regulatory passage with page numbers Citations API (citations: {enabled: true} on document blocks) + SSE streaming + prompt caching on the document; persisted, never billed twice
RAG Q&A "Is thiodiglycol controlled for export?" answered from the document library with source chips deep-linking into the PDFs Local embeddings (fastembed / bge-small, ONNX) + sqlite-vec in the same SQLite file — no vector DB service, no second paid API
Compliance copilot Chat over the app's own data: "show open hits to Russia" SDK tool runner (@beta_tool) with five read-only tools, streamed tool activity, capped iterations
Change summaries When a list's source URL yields changed content (SHA-256), explains what changed Deterministic set-diff of validated entries + messages.parse() narration with screening-impact notes — demoed against real published revisions of the seeded lists

Human-in-the-loop by design

The pipeline is ingest → screen → review, and the model's authority shrinks at every step:

flowchart LR
    subgraph AI["LLM (proposes & explains)"]
        A[Document ingestion<br/>Claude extracts entries]
        E[Hit explanations<br/>with citations]
    end
    subgraph DET["Deterministic core"]
        V[Validators<br/>CAS checksum · HS format · ISO]
        S[Screening engine<br/>exact matching]
    end
    subgraph HUMAN["Human"]
        R[Review queue<br/>confirm / ignore]
        T[Audit trail]
    end
    A -->|proposed entries| V -->|accepted entries| S -->|hits| R --> T
    E -.explains.-> R
Loading
  • The LLM proposes entries; deterministic validators accept them (a CAS number with a bad checksum never enters the database, whoever wrote it).
  • Screening is exact matching against those validated entries — reproducible, auditable, no model in the loop, matched_entry_ids recorded for provenance.
  • Every hit is decided by a person in the review queue; decisions append to an audit trail and roll up the transaction status (open hit → awaiting review, confirmed hit → withheld, all clear/ignored → cleared).
  • The explanation prompt explicitly forbids verdicts; the copilot is read-only and points to the review queue.

A tour

The review queue — a hit explained by Claude with citations anchored into the German regulation (chips deep-link to the PDF page):

Review queue with AI explanation

Regulation change summaries, generated against real published revisions (this one caught Guatemala being added to the EU embargo overview):

Change summaries

The compliance copilot — visible tool calls, and it knows it doesn't decide:

Copilot

Transactions after a screening run:

Transactions

Quickstart

uv sync                                   # install everything
cp .env.example .env                      # add your ANTHROPIC_API_KEY
uv run python scripts/seed.py             # demo dataset + 6 real regulatory lists (11 versions)
uv run python scripts/ingest.py           # LLM-extract entries from the current lists
uv run python scripts/generate_diffs.py   # optional: pre-generate change summaries
uv run uvicorn exportguard.main:app --reload

Then open http://127.0.0.1:8000 — press Screen all pending, open the review queue, and hit ✨ Explain this hit.

Without an API key the app still runs: screening, review, versioning and deterministic diffs all work; the LLM features show a friendly hint.

Docker: docker compose up --build (uses the same .env).

Demo data

  • Vantiro Specialty Chemicals GmbH — a fictional trading company. The seed generates ~60 orders of real generic chemicals (public CAS/CN numbers) to a believable destination mix, several of them genuinely listed: thiodiglycol (1C350), triethanolamine, acetic anhydride, potassium permanganate…
  • Real regulatory documents (all public: BAFA, BfArM, EUR-Lex/ECHA) are committed under fixtures/control_lists/ including their genuine published revisions — the embargo overview (v1→v2), the precursor list (v1→v2→v3) and the PIC annex (v1→v2→v3) — so change summaries demo against real regulation changes. Bonus: the documents are German, so ingestion demonstrates multilingual structured extraction.

Engineering notes

  • Stack: FastAPI · SQLAlchemy 2.0 · Alembic · Pydantic v2 · HTMX + SSE · Tailwind (standalone CLI, no Node) · SQLite (+ sqlite-vec) · uv · ruff · pytest · GitHub Actions.
  • LLM boundary: every feature depends on a small LlmService surface (src/exportguard/llm/client.py); tests inject fakes — the whole suite (80+ tests) runs hermetically with zero network. One live smoke test is gated behind EXPORTGUARD_LIVE_TESTS=1.
  • Failure handling: stop_reason == "refusal" checked before reading content; rate-limit/API errors surface as friendly HTMX fragments; ingestion failures mark the version failed with the error, never half-imported.
  • Cost awareness: regulation documents carry cache_control so repeated explanations hit the prompt cache; explanations and diffs are persisted (never re-billed); embeddings are local; the model is configurable via EXPORTGUARD_MODEL (default claude-opus-4-8).
  • Why SQLite (+sqlite-vec): single-file, zero-ops demo; vectors live next to the relational data with SQL-visible provenance. A Postgres swap is a documented non-goal.

Provenance

This project is a from-scratch public rebuild of an internal compliance tool I built professionally (Flask/Jinja/raw-SQL at the time). No code, data or configuration was carried over — only the domain knowledge, plus the public regulatory documents listed above. The rebuild adds everything the original never had: the LLM layer, structured extraction, RAG, and the copilot.

License

MIT — see LICENSE.

About

LLM-powered export-control screening: deterministic screen, LLM explanation, human decision. FastAPI + Claude + HTMX.

Topics

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages