ledgerlens / apps /api /scripts /seed.py
Abdr007's picture
LedgerLens — deployed tree
6741fc6
Raw
History Blame Contribute Delete
6.46 kB
"""Seed the ledger with realistic vendor history (spec §8).
Loads 30 historical invoices across 6 vendors so the vendor-spend chart and the
per-vendor z-scores are meaningful the first time the dashboard is opened, and so
the planted near-duplicate pair guarantees the anomaly demo fires.
Documents are rendered as **real PDFs and real degraded scans** and pushed through
the **real pipeline** in the same process — the same hashing, routing, extraction,
validation, screening and persistence a browser upload takes. Nothing is inserted
straight into the tables.
Order matters: a duplicate can only be found against something already in the
ledger, so the corpus is processed oldest-first.
python scripts/seed.py # add to whatever is already there
python scripts/seed.py --reset # wipe the ledger first
python scripts/seed.py --scans # render a subset as photographed scans
"""
from __future__ import annotations
import argparse
import asyncio
import sys
import time
from pathlib import Path
# Allow `python scripts/seed.py` from the apps/api directory.
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from sqlalchemy import text
from app.core.bootstrap import init_schema
from app.core.db import dispose_engine, init_engine, transaction
from app.core.logging import configure_logging
from app.core.settings import get_settings
from app.core.tracing import get_tracer
from app.deps import get_claude_client
from app.devtools.corpus import build_seed_corpus
from app.devtools.documents import degrade_to_scan, render_invoice_pdf
from app.models.enums import DocumentStatus
from app.pipeline.orchestrator import PipelineOrchestrator
_TABLES = ("anomalies", "extractions", "llm_traces", "audit_log", "failed_jobs", "documents")
async def reset_ledger() -> None:
"""Empty every table. The audit-log trigger blocks DELETE, so use TRUNCATE."""
async with transaction() as session:
await session.execute(text(f"TRUNCATE {', '.join(_TABLES)} RESTART IDENTITY CASCADE"))
async def seed(*, reset: bool, with_scans: bool) -> int:
settings = get_settings()
configure_logging("WARNING") # the progress table below is the useful output
engine = init_engine(settings)
await init_schema(engine)
if reset:
await reset_ledger()
print("Ledger reset.\n")
client = get_claude_client()
orchestrator = PipelineOrchestrator(client=client, settings=settings, tracer=get_tracer())
corpus = build_seed_corpus()
print(f"Seeding {len(corpus)} invoices across 6 vendors (mode: {client.mode})\n")
print(f"{'#':>3} {'invoice':16} {'vendor':30} {'total':>12} {'status':13} {'ms':>5} flags")
print("-" * 96)
failures = 0
started = time.perf_counter()
for index, item in enumerate(corpus, start=1):
pdf = render_invoice_pdf(item.spec)
# A slice of the corpus is photographed so the vision lane has real input.
as_scan = with_scans and index % 7 == 0
payload = degrade_to_scan(pdf) if as_scan else pdf
filename = f"{item.stem}{'.jpg' if as_scan else '.pdf'}"
content_type = "image/jpeg" if as_scan else "application/pdf"
outcome = await orchestrator.ingest(
data=payload, filename=filename, declared_content_type=content_type
)
if outcome.duplicate:
print(f"{index:>3} {item.spec.invoice_number:16} {'(already ingested)':30}")
continue
await orchestrator.process(
document_id=outcome.document_id,
data=payload,
filename=filename,
media_type=outcome.media_type,
)
async with transaction() as session:
row = (
await session.execute(
text(
"SELECT d.status, d.latency_ms, "
" (SELECT count(*) FROM anomalies a WHERE a.document_id = d.id) AS flags "
"FROM documents d WHERE d.id = :id"
),
{"id": outcome.document_id},
)
).one()
status = DocumentStatus(row.status)
if status is DocumentStatus.FAILED:
failures += 1
marker = " <-- ANOMALY" if row.flags else ""
print(
f"{index:>3} {item.spec.invoice_number:16} {item.spec.vendor:30} "
f"{item.spec.total:>12,.2f} {status.value:13} {row.latency_ms or 0:>5} "
f"{row.flags}{marker}"
)
elapsed = time.perf_counter() - started
async with transaction() as session:
summary = (
await session.execute(
text(
"SELECT "
" (SELECT count(*) FROM documents) AS docs, "
" (SELECT count(*) FROM documents WHERE status='DONE') AS done, "
" (SELECT count(*) FROM documents WHERE status='NEEDS_REVIEW') AS review, "
" (SELECT count(*) FROM documents WHERE status='FAILED') AS failed, "
" (SELECT count(*) FROM anomalies) AS anomalies, "
" (SELECT count(*) FROM audit_log) AS audit, "
" (SELECT coalesce(sum(cost_usd),0) FROM documents) AS cost, "
" (SELECT coalesce(avg(latency_ms),0) FROM documents "
" WHERE latency_ms IS NOT NULL) AS avg_ms"
)
)
).one()
print("-" * 96)
print(
f"\n{summary.docs} documents · {summary.done} DONE · {summary.review} NEEDS_REVIEW · "
f"{summary.failed} FAILED"
)
print(
f"{summary.anomalies} anomaly flag(s) · {summary.audit} audit events · "
f"avg {float(summary.avg_ms):.0f} ms/doc · ${float(summary.cost):.4f} total"
)
print(f"Seeded in {elapsed:.1f}s.\n")
await dispose_engine()
return 1 if failures else 0
def main() -> int:
parser = argparse.ArgumentParser(description="Seed LedgerLens with vendor history.")
parser.add_argument("--reset", action="store_true", help="Empty the ledger first.")
parser.add_argument(
"--scans",
action="store_true",
help="Render part of the corpus as photographed scans (needs a Claude key to read).",
)
args = parser.parse_args()
return asyncio.run(seed(reset=args.reset, with_scans=args.scans))
if __name__ == "__main__":
raise SystemExit(main())