Two long PDFs, read clause by clause.
In public-sector and infrastructure procurement, every draft tender must be checked against an authoritative standard contract (GCC 2023, CPWD, MoRTH, PSU conditions). Legal teams do this by hand: reading two long documents side by side, logging every deviation — added, removed, weakened, or risky — and responding to dozens of bidder pre-bid queries, each citing the exact governing clause.
It's slow, inconsistent between reviewers, and error-prone — and a single missed deviation can mean real contractual and financial exposure. Goal: compress this into an AI-assisted workflow that produces a structured, auditable deviation register and draft responses, while keeping a human firmly in the loop for approval.
A two-module platform.
Upload a standard document + a draft tender. The system extracts text from both, prompts Gemini to compare them, and returns a structured JSON deviation register — each entry classifying the deviation, its severity, and the governing clause.
Upload a bidder's query document. The system:
- Extracts each discrete query (Gemini parse step)
- Drafts a clause-anchored response citing both standard and draft
- Flags risk — auto-tags legal_risk / needs_review / low_confidence
- Lets a reviewer edit / approve / redraft
- Exports the approved register to a formatted .docx
Supporting: a Master-Document library (platform standards + private reusable user uploads), a unified "recent work" dashboard across both modules, and JWT auth shared across the Next.js frontend and FastAPI backend.
The build.
| Frontend | Next.js (React), edge-compatible JWT auth |
| Backend | FastAPI (Python 3.11), async request handling |
| AI | Google Gemini (gemini-2.5-flash) via REST, structured-JSON prompting |
| Database | PostgreSQL (local UAT) / Supabase (production) |
| File storage | Local filesystem (UAT) / AWS S3 with presigned uploads (prod) |
| Auth | JWT (python-jose), shared secret between frontend & backend |
| Document I/O | PyMuPDF (PDF extraction), python-docx (Word export) |
| Infra | Render (CI/CD on push to main), environment-based config |
One env var drives the whole split.
The core pattern: a single variable, APP_ENV (uat | prod), drives the entire environment split. Three thin dispatcher modules — ai_service, db_service, storage_service — each inspect APP_ENV and delegate to the right implementation:
| Concern | UAT (local dev) | PROD (cloud) |
|---|---|---|
| Database | Local PostgreSQL | Supabase |
| File storage | ./uploads/ | AWS S3 (presigned URLs) |
| CORS origin | localhost:3000 | production frontend domain |
Business logic stayed identical across environments, local development was fully offline-capable (no cloud dependency to write a feature), and production parity became a config change, not a code branch.
The problems that took thought.
Silent, invalid JSON on long documents
gemini-2.5-flash shares one token budget between internal "thinking" tokens and visible output. On long docs this silently ate the output budget and returned truncated, invalid JSON (finish_reason=MAX_TOKENS) — the deviation register simply failed to parse.
Fix: explicitly set thinkingBudget: 0 and request the full maxOutputTokens (65,536), plus a verification step (assert finish_reason=STOP) in the test loop so truncation regressions are caught before deploy.
Surviving free-tier caps with multi-key rotation
Gemini's free tier caps requests per project. Under real use this throttled analyses mid-job. Fix: _call_gemini() tries up to three API keys, each in a separate Google project (each with its own fresh quota). On a 429/503 it fails over immediately, then applies tiered backoff (20/40/60s for quota, 2/4/6s for transient overload). One shared call path means all three modules benefit at once.
A "CORS error" that wasn't a CORS error
A prod bug surfaced as a browser CORS failure. The real cause: prebid_* tables use a text PRIMARY KEY with no DB default; the Supabase insert path wasn't generating an ID, so the insert hit a not-null violation, the unhandled 500 returned without CORS headers, and the browser reported it as a CORS block.
Fix & lesson: generate the UUID in app code on both DB paths, and wrap the endpoint so failures return a clean, CORS-bearing error. Captured as a convention: a missing-CORS-header symptom usually means an unhandled 500 underneath.
The rest of the iceberg
Long-running AI (M2's two sequential Gemini calls) runs as background tasks; blocking httpx calls wrapped in asyncio.to_thread() to avoid stalling the event loop. Small bugs, outsized impact: .docx downloads failed on tender names with em-dashes (Content-Disposition must be latin-1) — fixed by sanitising to ASCII; the drafter prompt initially flagged nearly everything for review — tuned to flag only genuine legal risk. Secrets hygiene: env files git-ignored, a codified read-only-by-default rule for production, and a documented Render env-var foot-gun (PUT replaces all variables).
AI accelerates; humans decide.
A manual side-by-side tender review compressed from a multi-day task into a minutes-long, reviewable workflow.
A consistent, auditable deviation register with human approval gating every AI-drafted response — risk flags, citations, and an edit/approve loop before anything is exported.
Backend, AI integration, cloud infra, and the deployment pipeline — all built and deployed by one person.
Multi-key rotation, structured-output validation, and background processing keep it working under free-tier limits and long documents.
What it shows.
| AI / LLM | Structured-output prompting, token-budget management, prompt tuning, resilient multi-key rate-limit handling, human-in-the-loop review design. |
| Backend | FastAPI, async patterns, background jobs, JWT auth, clean environment-abstraction (dispatcher pattern), migrations. |
| Cloud / DevOps | AWS S3 presigned uploads, Supabase, Render CI/CD, environment parity, production-safety discipline. |
| Product | Scoped a real legal/procurement workflow into two shippable modules; prioritised reviewer trust over full automation. |
| Debugging | Diagnosed misleading symptoms (CORS-masked 500s, silent LLM truncation) to their true root causes. |
The roadmap.
Move from free tier to Tier 1 for higher limits and to disable prompt-data training — required before handling real client tenders at scale.
Anchor each deviation to its source text spans, with inline highlighting in the document viewer.
Precision/recall on a labelled set of known deviations, to catch prompt/model regressions before they ship.
Replace demo credentials with proper user management and role-based access control.
