-
-
Overview: every product checked against every market it sells into. One breaking a rule, 13 disagreements waiting on a human
-
Herbal Drink Powder in Germany: 300 mg/kg benzoic acid against a 150 limit. It names the ingredient, the rule and the gap
-
Side by side: EU 150 mg/kg against BPOM 350 mg/kg from 2026-12-01. The strictest wins, and the source text sits under it
-
Clauses pulled from a BPOM document, each with why we are sure: text quality, two independent reads agreeing, source authority
-
A fix plan, not an action: one number that clears both markets, each limit linked to the exact wording it came from
-
Ask in plain English. Every answer quotes the exact clauses it came from, with a confidence figure — or says no rule covers it
-
Every rule extracted from the documents you added — 130 of 200 counted in verdicts right now, and the rest say why they are not
-
Watched addresses re-read every 24h. Name a country and a Gemma model finds its regulator's pages — Saudi Arabia, 2 addresses added
-
Add rules: drop in a PDF or paste text. ReguLens finds the country, source weight and limits; you confirm only what it could not read
-
Rules that disagree: BPOM allows 1000 mg/kg benzoate, the EU 150. ReguLens marks the stricter one and says what to do
Inspiration
Sell a drink powder into Germany and Indonesia and you are obeying two additive rulebooks that disagree with each other, and neither of them emails you when a number moves. The EU caps benzoates in flavoured drinks at 150 mg/kg; Indonesia's BPOM allows 400–900 mg/kg for the same category. The current answer is a consultant reading gazettes or a compliance officer with a spreadsheet and a calendar reminder — people doing lookup work, and finding out late.
We wanted the opposite failure mode: a system that notices before anyone thinks to look, and that refuses to guess when it cannot tell. The bar we set was not "can it answer a question" — it was can a verdict change while nobody is watching, and can the system prove that is what happened.
What it does
ReguLens keeps a structured model of a physical product — ingredients with real amounts, packaging, origin, target markets — and holds an opinion about it in every market at once.
The chain that matters runs end to end on the deployed stack. Commission Regulation (EU) 2023/2108 was published at the EU Publications Office. Nobody uploaded it. The 06:00 sweep found it at CELLAR, read it into 88 verbatim numeric limits, reconciled them against the rules already held — and moved Traditional Cured Beef Sausage from "needs a look" to breaks a rule in Germany, against the nitrite row that entered into force on 9 October 2025:
Your product has 120 mg/kg Allowed up to 30 mg/kg
From COMMISSION REGULATION (EU) 2023/2108 · E 249-250 Nitrites,
"only traditionally cured products", Period of application: from 9 October 2025
The alert says the one thing that separates a monitor from a checker: nobody
uploaded this. GET /stats/autonomy counts that claim from stored records
rather than asserting it — regulations found unprompted, clauses read out of
them, verdicts moved. A quiet week reports zeros.
- Finds regulations nobody uploaded. A daily Cloud Scheduler sweep re-reads four regulator addresses — the EU Publications Office SPARQL catalogue, one specific EU act, the Commission's food-safety RSS feed, and BPOM's legal portal. Three of the four discover: they surface acts at addresses the system has never seen.
- Finds the addresses too.
POST /countries/discovertakes a country nobody seeded and produces a watched index page. Gemma is asked only for the two things a model gets right — the regulator's name and its root domain — and every path is read off pages actually fetched. Measured over six countries: regulator names 6/6, root domains 6/6, and every model-written path wrong, 0 of 14 — which is why the model is never asked for one. - Extracts rules, not summaries. Clauses come out verbatim with a substance, a limit, a unit, a jurisdiction, an effective date and a citation, each carrying a computed confidence score.
- Refuses to compare things that should not be compared. A deterministic guardrail — plain typed code, no model — decides whether two clauses are even comparable, and routes anything ambiguous or low-authority to a human review queue instead of quietly moving a limit.
- Changes its mind without being asked, and names the strictest rule in force the product actually fails — not whichever clause happened to trigger the run.
How we built it
One container image, four Cloud Run services, and everything slow behind Pub/Sub.
The API hashes, stores, publishes, and returns 202.
A. a person uploads a PDF or pastes text
B. Cloud Scheduler (06:00 daily) re-reads every watched address
nothing changed → conditional GET + text hash. No model call, no cost.
something changed → create_document — the SAME call an upload makes
both paths publish → Pub/Sub: document.uploaded (push, OIDC)
/internal/document-uploaded → extract (ADK Extraction Agent ×2) → clause.extracted
/internal/clause-extracted → reconcile (guardrail first; agent only if ambiguous)
/internal/graph-changed → impact — pure arithmetic, no model — → verdict + audit event
/internal/dead-letter ← any topic after max delivery attempts
There is no third way into the graph. A regulation the scheduler discovers is hashed, stored, extracted, guardrailed and reviewed exactly as an upload is.
Three rules held the build together:
- Deterministic code owns every mutation. A model response never reaches Firestore without passing a Pydantic validator and the guardrail. Agents propose; typed code decides.
- The engine does not know it is a web app.
api/app/core/imports neither FastAPI nor ADK — one grep proves it. The ADK agents only register tools; every tool body is a plain function that imports and tests without an agent framework or a web server. - Honest labels on the agents. The Query agent is genuinely agentic — it picks its own retrieval tools. Impact contains no model call at all: comparing $120$ to $30$ is arithmetic, and a model there would be strictly worse.
Confidence is computed, not self-reported:
$$\text{confidence} = 0.3 \cdot \text{parse_quality} + 0.4 \cdot \text{self_consistency} + 0.3 \cdot \text{authority_tier}$$
Low-authority sources are capped by construction and routed to review. Every
state change writes an immutable graph_events record in the same Firestore
batch as the change itself — there is no raw update method to reach around it.
Challenges we ran into
verdicts_changed: 0, and it was the honest number. For two days the scheduler found regulations, read them, and stored clauses that could never bind anything. Five things sat in the last inch of the pipeline — a substance name the dictionary learned two days too late, one refusal recorded twice under two names, an EU annex that prints no category code, an amendment written as two undated-looking rows, and a purity ceiling that names no food but would have been the strictest nitrite limit in the graph. Each was found by simulating the fix against the live queue before running it.- A change is a change of wording, not of bytes. EUR-Lex stamps a fresh session id into every response, so a byte hash reported a change every night — and would have billed a model run for it nightly, forever. The signal is a hash of the extracted text.
- What works from a laptop is not evidence it works from Cloud Run. The
EUR-Lex HTML URL was verified locally three times. Deployed, it answered our
datacentre address with
202and a challenge page. The EU source now points at CELLAR, which serves the same regulation and sends anETag. - A model cannot tell you a URL that exists. Six countries, 14 model-written paths, 0 that resolved — while regulator names and root domains were 6 for 6. The whole architecture of country discovery is that measurement.
- Own your HTTP transport.
google-genai'sBaseApiClientcloses the httpx client it created when the owning object is collected. Our direct-extraction fallback kept dying on a transport a finished ADK runner had already shut, and a document with a working path left was recordedfailed— three times in production before we handed both clients a transport the process owns.
Accomplishments we're proud of
- The verdict moves with nobody watching, and the count is a query over the
same
graph_eventsthe timeline renders — the number and the audit trail cannot disagree. - 618 tests, and
make test-allruns lint, unit tests, a type check, a production web build and a full local emulator drill from a clean checkout — no Google Cloud account, no API key, no cost. - Idempotency proven, not assumed: extract, reconcile and impact were each redelivery-tested against the deployed stack; a concurrency probe caught and closed a double-write race.
- Every limitation is written down in the README rather than discovered in a demo.
What we learned
The interesting decisions were all about what not to let the model do. A language model is excellent at pulling a verbatim clause out of 177 pages of annex, and it is the wrong tool for deciding whether $120$ exceeds $30$. Once we drew that line, the architecture fell out of it.
The second lesson took longer: a pipeline that works stage by stage is not a
pipeline that works. Every stage of ours was green while the thing the product
exists to do had never once happened end to end — and verdicts_changed: 0 was
sitting on the dashboard the whole time. Four defects were only findable after a
verdict finally moved.
The third: a filter that hides something has to say how much and why. The review queue states its held-back count; a source that cannot be read renders its error. Silence is what turns a monitoring claim into a lie — and for a compliance product, that is the whole product.
What's next
- OCR, so a scanned gazette is ingestible.
- Auto-evaluation of non-numeric clauses (labelling, certification,
documentation), currently extracted and surfaced as
needs_review. - Multi-workspace and user auth — the
/internal/*routes are already OIDC-gated; user identity is the missing half.
Data sources
Real, citable documents — nothing synthetic. Checksums and provenance in
data/regulations/SOURCES.md.
- Commission Regulation (EU) 2023/2108 (CELEX
32023R2108) — nitrites/nitrates amendment, found and ingested by the scheduled sweep, not by us. - Regulation (EC) No 1333/2008 on food additives (CELEX
32008R1333), plus the consolidated versions in force 18 Feb 2026 and 18 Aug 2026. - Commission Regulation (EU) No 1129/2011 — the Annex II Union list (CELEX
32011R1129). - Peraturan Badan POM No. 11 Tahun 2019 tentang Bahan Tambahan Pangan — 1,156 pages from JDIH BPOM, the Indonesian regulator's own legal portal.
Built With
- artifact-registry
- docker
- fastapi
- firestore
- gemini
- gemma
- google-adk
- google-cloud
- google-cloud-build
- google-cloud-firestore
- google-cloud-pubsub
- google-cloud-run
- google-cloud-scheduler
- next.js
- oidc
- pub-sub
- pydantic
- python
- react
- secret-manager
- sparql
- tailwindcss
- typescript
- vertex-ai
Log in or sign up for Devpost to join the conversation.