Skip to content

Repository files navigation

The Media Customs: one asset, every market, before it ships

Live instance Tests Jurisdictions Rules Grafana License

Open the live console →  ·  Watch the walkthrough →

Judges, the word at the door is DEVPOST.


Watch a fix land

Three violations, found and repaired by the system itself. Left is what was uploaded; right is what came back. Every clip below is footage this project generated and then fixed with its own methods: no stock, no borrowed IP.

Wine becomes juice

France · Loi Évin · alcohol. French law bans alcohol advertising on television outright. The wine becomes pale juice and the bottle becomes a carafe; the toast, the light and the laugh survive untouched.

Cigarette becomes espresso

EU · AVMSD art. 9 · tobacco. A lit cigarette becomes an espresso cup, and every wisp of smoke leaves the frame. This one took two passes: the first edit held everywhere except the drag itself, which is exactly why the product re-watches every fix instead of trusting the model's word.

Short skirt becomes knee-length

UAE · Federal Decree-Law 55/2023 · modesty. The hemline drops to the knee in the same grey fabric, mid-stride, same street, same woman.

Note

These are the marketing clips, and they were held to the product's own standard. The first take of the skirt pair came back with legible Chanel storefronts in the background (third-party branding, on the homepage of a rights-clearance tool), so it was thrown away and regenerated with brands banned.


Four moves, end to end

Every frame below is the running console, shot from the deployed instance rather than drawn.

Upload and processing Hand it a master. Pick your markets, and the crew narrates itself stage by stage while it works: shots, transcript, observations, then one adjudicator per market in parallel.
Frames and findings Every frame, as the analyst saw it. One neutral timecoded sentence each, verdicts forbidden. A finding is that observation joined to one market's rule and a live citation.
The fix picker Five ways to fix it, priced in euro before you press. What should change, and how much of the footage to disturb. The two generative methods will also take your own words, added to the instruction the model is given. The localized master lands beside the original in the cutting room.
A click in Grafana A click on a Grafana panel starts a generative fix. The grid is the crew's own dashboard; clicking a square resolves the scene under it and starts a priced edit on that span.
Agent mode answering Or ask for the whole console in sentences. Every screen above is one of this agent's eleven tools, so what it tells you is what you would have found by clicking -- and it opens that thing beside its answer. The turn in this recording is a real one, posted to the deployed service.

The problem

A global brand ships one commercial into forty markets and checks maybe six of them.

The regulated half of the risk already carries an invoice: France bans TV alcohol advertising under Loi Évin, Quebec bans advertising aimed at children under 13, the UK pre-clears every TV ad through Clearcast, Nigeria requires ARCON vetting before air. The unregulated half costs more: Pepsi and Kendall Jenner, Dolce & Gabbana in China, H&M's hoodie. Every one of those was caught by the public rather than by a process.

Pilots get a pre-flight checklist and an instrument panel. Ad launches get a consultant, a focus group, and luck.

This is the instrument.


What it does

Hand it a commercial and a market list, then watch it clear customs in real time.

The Media Customs front door

The output is two things: a decision (can this air here, and on what evidence) and a localized master (the version that can).

Jurisdictions 21 market packs resolving to a global baseline, the EU, 16 countries and 80 broadcasters, 1,063 market-rule pairings once inheritance resolves
Rules every one naming a real statute, broadcaster code or cultural norm, with a live citation
Dimensions one fixed taxonomy the analyst emits and every rule is written against
Repair methods from a €0.04 single-frame patch to a €3.68 Veo-generated bridge, priced in euro before you press, and the generative two take an instruction of your own
Agents a Google Cloud Agent Builder (ADK) SequentialAgent named customs_crew, with the adjudicators fanned out in parallel
The refusal when a rule targets a protected characteristic, a rule-layer Guard refuses to auto-edit and hands it to a human

The core idea: observe once, judge many

The expensive part of this problem is looking at the film. The cheap part is opinions about what was seen.

So the system separates them, and never lets them mix:

flowchart LR
    A["🎬 one commercial"] --> B["Analyst<br/>watches each shot ONCE<br/>Gemini vision"]
    B --> C["neutral observations<br/>timecoded · boxed · no verdicts<br/>18-dimension taxonomy"]
    C --> D1["Adjudicator · FR"]
    C --> D2["Adjudicator · EU"]
    C --> D3["Adjudicator · AE"]
    C --> D4["Adjudicator · …98"]
    D1 --> E["findings<br/>observation × rule × citation"]
    D2 --> E
    D3 --> E
    D4 --> E
    E --> F["🚦 decision + localized master"]

    style A fill:#e8f0fe,stroke:#4285F4,color:#17222c
    style B fill:#fef7e0,stroke:#FBBC05,color:#17222c
    style C fill:#e6f4ea,stroke:#34A853,color:#17222c
    style E fill:#fce8e6,stroke:#EA4335,color:#17222c
    style F fill:#e8f0fe,stroke:#4285F4,color:#17222c
Loading
The 18 dimensions, and what each watches for

One fixed vocabulary. The analyst may only emit these, and every one of the 128 rules is written against one of them, which is what makes the join between a fact and a market's opinion a lookup rather than an argument.

Dimension What it watches for
alcohol tobacco drugs drink, smoke and substances on screen
religious symbols practices faith, ritual and sacred imagery
modesty dress body how much skin a market allows
gesture body language a gesture that means something else here
food and animals what may be eaten, and what may not be shown
gender portrayal roles, stereotypes and objectification
sexual orientation gender id who may be shown together
children and minors advertising to, and with, children
national symbols politics flags, anthems, leaders, borders
health claims pharma medicinal and nutritional promises
gambling and finance betting, credit and financial promotion
violence and weapons force, threat and weaponry
language profanity idiom words that do not travel
humour irony satire jokes that land differently
superstition number colour numbers, colours and omens
photosensitivity sensory flashing, strobing and sensory risk
text legibility on-screen text, language and readability
comparative claims superiority, firsts and head-to-heads

A finding is always a join: this observation × that market's rule × a citation that resolves. Which makes "is this fact wrong?" and "is this rule wrong?" separable questions, and that is the whole reason findings are cheap, parallel and defensible.

The analyst is forbidden from expressing an opinion. It writes "a woman raises a glass of red wine", never "this violates French law". Ninety-eight adjudicators then argue about that one sentence, simultaneously, each holding a different rulebook.


How a run actually works

Five stages, in order, from src/customs/crew.py. The stage names below are the ones the code emits.

1 · ingest: IngestAgent

ffmpeg cuts the film into its own shots, one Gemini audio call per shot writes the transcript, and a measured flash sweep counts luminance edges: a strobe is a property of the sequence, not of any frame, so it is counted rather than asked about.

Writes: shots, transcripts, photosensitivity observations

2 · analyst: AnalystAgent

One Gemini vision call per shot: keyframes as image parts, the 18-dimension taxonomy in the prompt, and neutral sentences out. No verdicts allowed: it writes “a woman raises a glass of red wine”, never “this violates French law”.

Writes: observations, bounding boxes, evidence frames

3 · adjudicators: AdjudicatorAgent × N in an ADK ParallelAgent

A pure dimension join against each market's YAML pack, then one batched Gemini call and one grounded Google Search citation per triggered rule. N markets cost one market's latency, not N.

Writes: a verdict for every candidate pairing, cleared ones included

4 · guard: GuardAgent

Reads the matched rule's metadata and nothing else: never the rationale, never a model-authored field, never a model. Which is what makes it un-promptable. It runs after the fan-out joins, so it is also the single findings write.

Writes: findings (the one write, on one thread)

5 · publisher: PublisherAgent, the only LlmAgent

Issues five tool calls itself, three of them live Grafana MCP. It reads every result, decides what to do when one fails, and composes the prose it writes into the overview dashboard's description: a real MCP write, every run, in words no template produced.

Writes: Mimir series, Loki lines, annotations, dashboards, alert rules

And two the crew meets only when something has to change:

remediator

Woken by a Grafana alert. Works out the cheapest edit that would satisfy the rule, prices it in euro, and changes only the seconds that were objected to, sweeping every other open finding on the same shot into one combined edit.

verifier

Re-runs the real analyst pass over the changed shots (the same instrument that found the problem) to confirm the violation is gone and that nothing new broke.

Stage by stage, with the file that does it

1 · ingest: crew.IngestAgent. media.detect_shots runs an ffmpeg scene-score ladder, analyst.merge_micro_shots folds away sub-second fragments. Then one Gemini audio call per shot for a transcript. Then media.detect_flashes, which is the one observation that is measured rather than asked of a model: it reads full-frame luminance and every window over 3.0 flashes/second becomes a photosensitivity observation. A vision model cannot see a strobe (a strobe is a property of the sequence, not of any frame), and this is exactly how the milestone gate once missed a planted 6 Hz strobe.

2 · analyst: crew.AnalystAgent. One Gemini vision call per shot, keyframes as image parts, the 18-dimension taxonomy interpolated into the prompt. Emits observations with bounding boxes and evidence frames. No verdicts allowed.

3 · adjudicators: crew.AdjudicatorAgent inside an ADK ParallelAgent, one per market, each on its own thread. N markets cost one market's latency, not N. Each does a pure dimension-equality join against its YAML pack, then one batched Gemini call, then one grounded Google Search citation per triggered rule. Severity, sourced, the citation, remediability and the finding id are all decided in code: the model may only adjust severity downward, clamped to [−20, 0]. An unresolvable citation caps severity at 40 and can never trigger remediation.

4 · guard: crew.GuardAgent. Runs after the parallel branch joins, which makes it the single place findings are written to SQLite, on one thread. See The Guard.

5 · publisher is crew.PublisherAgent, the genuinely agentic stage: a BaseAgent wrapping an LlmAgent with five tools, three of which are live Grafana MCP calls. It issues each call itself, reads every result, decides what to do when one fails, and composes the prose it writes into the overview dashboard's description. That last step is a real MCP write, on every run, in words no template produced.


Grafana is a participant, not a picture

This is the Grafana Labs track, and the requirement is that the project actively use the Grafana stack at runtime, primarily through the Grafana Cloud MCP server. It does, in three directions, and you can watch all three happen on the live instance.

Important

The MCP server is the official grafana/mcp-grafana v1.1.0, run as a stdio subprocess with a service-account token. Grafana's own documentation notes the hosted Cloud MCP endpoint "authenticates users interactively — there is no service-account or machine-token option." This crew is unattended, so the open-source server is the documented correct choice, not a workaround.

1 · The crew writes into Grafana

The Publisher agent calls MCP tools to build its own instrument panel: 9 dashboards / 40 panels, an annotation per finding, and the alert rules that will later wake the Remediator. Seven distinct mcp-grafana tools are called at runtime: create_folder, update_dashboard, alerting_manage_rules, get_panel_image, query_loki_logs, search_dashboards, get_dashboard_by_uid.

What lands in the stack:

Store What
Mimir 5 metric series: customs_risk (on the film's own clock), customs_market_status, customs_blocking, customs_stage_error
Loki 3 line kinds: kind=finding, kind=observation, kind=verdict, each with its own stream labels
Annotations one per finding, tagged ["customs", asset, market, rule_id, finding_id]

The clock trick that makes it read as a timeline: Prometheus rejects backdated samples, so each run maps the film's timecode onto the wall clock at run start. Video second n is written at wall clock t₀ + n. The panel's x-axis reads as the timecode because it is the timecode.

2 · Grafana triggers the crew

Two alert rules evaluate every 30 seconds. When customs_blocking crosses 70 for an {asset, market, rule_id} triple, Grafana posts to the customs-webhook contact point, which is POST /webhook/alert on this service. The webhook reads labels only, names the finding, and hands off to a worker.

An alert in Grafana is what starts a Veo render. Not a cron, not a queue: a threshold in a dashboard the agent built itself.

3 · A click on a Grafana panel launches a generative workflow

The merged grid: console axes, Grafana body

That grid is one visual drawn by two systems. The taxonomy icons down the side and each scene's opening frame across the top are the console's: no Grafana panel can put an image on an axis, which was verified by reading the renderers, not by guessing. The squares between them are Grafana's own status-history panel, live, with its row labels and time axis hidden because the console is those axes. Columns are as wide as their scenes are long, so a column's share of the width is its share of the film, which is exactly what the panel's hidden time axis measures.

Click a square and a data link fires /launch/remediate with the click's coordinate. The console resolves which open finding lives there and starts the Omni rewrite for that scene.

Why the panels are embedded from a self-hosted viewer

Grafana Cloud will not be framed. Fourteen URL forms were probed with real browser and iframe headers (/d/, every ?kiosk variant, /d-solo/, public dashboards, snapshots), and every one returns an enforcing Content-Security-Policy: frame-ancestors 'none'. PUT /api/admin/settings answers 403, because on Cloud that switch belongs to Grafana: their embedding guide grants it per-tenant, through the account team, as an origin allowlist.

So grafana-viewer/ is a stock grafana-oss with allow_embedding=true that holds no data of its own: its Loki and Mimir datasources read the same Grafana Cloud stores the crew writes to, at the same datasource UIDs the dashboards name, on a purpose-minted access policy scoped to logs:read + metrics:read and pinned by label to {app="customs"}. It answers frame-ancestors 'self' <the console>, so only the console may frame it.

The crew still writes to Grafana Cloud over MCP. The viewer is a second pair of eyes on the same data.


What is actually in Loki and Mimir

Not a claim, four renders. Every panel below is windowed to the run's own mapped clock, because that is where this system's telemetry lives: wall second t0+n is video second n. Point the stock dashboards at now-6h and they say No data, which is the first thing anyone gets wrong here.

Loki, kind="observation": one line per keyframe. Labels carry app, asset, dimension, flagged and kind; the body carries the analyst's own sentence, its confidence, the shot and the window, and which markets objected.

Loki observation lines with their labels and bodies

Loki, kind="finding": the join, with the statute in it. market and rule_id are labels, so a market's findings are a selector rather than a scan. The body holds the class, the severity, the rationale, and the grounded citation_url that was resolved at adjudication time.

Loki finding lines carrying statute and citation

Loki, by label Mimir, on the film's clock
Observation lines per dimension customs_risk per market across the timecode
Counted from the stream labels alone, no body parsing. customs_risk, one sample per video second, which is what makes the timecode an x axis.

The full inventory of what this system keeps in Grafana (9 dashboards, 40 panels, 9 metric series, 3 log streams, 3 alert rules, and which of the 8 write operations goes over MCP versus the provisioning API) is a screen in the console itself: /grafana.

The console

Fourteen screens, each one a way of asking the same question at a different distance -- and a guided tour, for anyone who would rather be shown.

The tour

A first-time reader met two doors and a paragraph. There is a third door now: fourteen slides that say what the thing is, and then a walk across the live console -- twelve stops, each one dimming a real screen and spotlighting the thing worth looking at. Every number on a slide is read from the instance at render time, and nothing in the walk is a screenshot.

The tour

The front door

Hand it a file, pick the markets, and watch it clear customs live. No password: the visitor door asks for nothing, and what bounds a stranger is the generation cap behind it.

The front door

Archive

Every clearance this instance has performed. Hover a card and the whole film plays as a five-second timelapse. The squares beneath it are the console's own drawing by default, with a switch on every card to Grafana's live panel of the same answer -- and in the drawn one, hovering a dot plays that second of the film, because the card's clip is the whole film in five seconds and the chart knows the mapping.

Archive

Launch board

The verdict, market by market, flipping in place as each adjudicator returns.

Launch board

And on the same page, the crew's own lanes dashboard, live, framed, and clickable:

The lanes dashboard, live from Grafana

Timeline

One grid drawn by two systems: our icons down the side and each scene's opening frame across the top, Grafana's live status history as the body between them. Click a square to start the Omni rewrite for that scene.

The merged grid

Mission feed

Every move the crew made, in the order it made them, each stage saying in plain words what it is doing, with its own shorthand underneath.

Mission feed

Frame board

Every scene the crew looked at, how it opens and closes, and the neutral sentences it wrote before any market saw them.

Frame board

Market room

One panel per scene. Closed, a scene is its first and last frame and a count; opened, every finding written against it, each with the statute behind it and a priced fix.

Market room, scenes closed

Market room, a scene opened

Cutting room

The original and the localized master, playing in lockstep on the second that changed.

Cutting room

Generated content

What the models actually made for one run, next door to the cutting room rather than behind a tab on the event log: every change record with the stills a patch wrote, the seconds Gemini Omni or Veo rendered, and for a bridge the two anchor frames it was handed. Those two frames are the entire brief, which makes them the only way to tell whether it invented something or was given it.

Generated content

My edits

The cross-run cutting room, and the answer to a different question: not "what happened to this film" but what has this system actually changed. One card per scene, before beside after, played rather than photographed -- each side is the span the finding names, cut from a master on the server and cached, with the kept still as its poster. Hovering runs both clips together.

One card per scene and not per market, because three jurisdictions objecting to the same two seconds is one shot with three objections and only one of them paid for the render: every market that objected is named on the card, and so is the one the fix on show was made for. A revoice is a sound edit rather than a picture edit -- as a still it looks like a frame that did not change -- so those live on the other side of a picture/sound toggle, with their soundtrack and real controls.

Every card names its fix type in words, because whether a model re-rendered the span or a still was patched over it is exactly the difference between the fixes that hold and the fixes the verifier sends back. And an edit can be removed: the one control in this console that destroys evidence, so it asks for a word first, says what it takes, and takes it everywhere at once.

My edits

Agent mode

A second ADK surface: one LlmAgent with eleven tools. Ask in sentences; it answers by opening the evidence beside you and pricing any fix before it runs.

Agent mode

Ask it for Grafana and it builds Grafana. build_dashboard composes a dashboard from the run's own labels; chart takes any of fourteen panel types with the LogQL or PromQL behind each. Either way the result is a real dashboard on the stack, opened beside the answer as Grafana's own render, with the link to the live one under it.

The agent builds a findings dashboard

The agent composes a three-panel chart across every run

Rule library

Every rule, filed under the observation that can trigger it.

Rule library

Frame search

Every caption the analyst ever wrote is a Loki line, so "which frames have a rabbit in them" is a question rather than a feature somebody anticipated, and what comes back is the frames themselves across every run. The match is semantic: the question and the candidate captions go to Gemini together, so bunnies finds "an animated rabbit character" without anybody guessing which word a vision model chose months ago, and each card says in the model's own words why it is there. A carrot is not a rabbit. mode=literal switches to a regex over the caption alone for when you mean the characters, a rule id or a brand. The agent has it as search_frames, which returns the counts first, so how many frames show a short skirt is answered with a number and the pictures beside it.

Intelligence

Every other screen answers a question about one commercial. This one reads across all of them, out of the same two stores the crew wrote during those runs: 17 panels and 8 kinds of chart over every clearance this instance has performed. Which subject your creative keeps tripping over, which jurisdictions are actually hard, the dimension-against-market matrix, the severity distribution, worst severity per market against the line where a finding starts to block, market status over time out of Mimir, and the raw finding stream underneath it all.

The division of labour is the design. Grafana charts the data because it holds it; the console draws the axis because Grafana has never heard of an eighteen-part taxonomy or a broadcaster channel's mark. The hero is one LCD block per commercial, lit to the highest severity any market ever recorded against it, with that film's own first frame directly underneath it in the same order: green cleared, blue noted, red past the 70 where a finding begins to block. Both halves sort by value and then by name, because the store promises neither: sort_desc says nothing about how a tie is broken, seven of these films sit at 95, and five executions of that one query came back in five different arrangements of them. The panel does it in a Grafana transformation, the console does it in Python, and the sixteen posters line up with the sixteen blocks position for position.

The budget is a series, and one alert asks the loop to stop

Two of the three alert rules ask Grafana to wake the Remediator. The third asks it to stop: the loop that fixes a finding by generating video is the loop that can empty a day's allowance while nobody is watching. Every charge writes customs_spend_eur_total and customs_budget_remaining_eur to Mimir on the real clock, and customs_budget_low fires when what is left crosses a floor set above the most expensive single fix. The webhook then holds automatic remediation for the rest of the UTC day: findings still block, alerts still arrive and are recorded in the feed with the reason, and a person at the console can still spend what is left one fix at a time. The pause is on the path nobody is watching, which is the only one that needed one.

Grafana resources

The whole Grafana surface on one page: 9 dashboards and every panel on them, the 9 metric series and 3 log streams the crew writes and which clock each sits on, the annotations, all three alert rules with the expression that fires them, and every write operation with the MCP tool it uses or the REST call it falls back to when mcp-grafana 1.1.0 has no tool for it. Read from the definitions the Publisher provisions from, so it cannot drift into describing a stack nobody has.

Everything that is working wears one mark: a rotating ring of the four brand colours. An uploading master, a run mid-analysis, a market tile whose fix is landing, the exact frame being repaired, the stage narrating it, and a beacon in the topbar that says what is running wherever you are.


Remediation, and the loop that stops it wasting money

Five methods are offered in the console picker, each one a different amount of footage to disturb. The mark beside each is the gesture it performs.

MethodWhat it touchesPrice
omni Gemini Omni rewrites the whole span as video-to-video €0.10 × span (≤ 10 s)
bridge both ends of the span edited, and Veo 3.1 generates the motion between them €1.88 to €3.68
overlay one Gemini image edit of one keyframe, held over the span €0.04
track the same edit, its lighting divided out and multiplied into every live frame €0.04
per_frame a repaint of every frame in the span €0.04 × ⌈span × 12⌉

Automatic fixes name themselves by what they change rather than by how much they disturb: relettering for on-screen text, prop_swap for an object, revoice for a spoken line. The first two land through the picker's own two patch paths (a relight propagated into every live frame when the finding's box can be located, a freeze over the span when it cannot), and revoice replaces the audio span with TTS. plan() chooses among them by the observation's dimension, and it is a pure function: no model call.

bridge is never chosen automatically. It regenerates pixels and costs real money, so it only ever runs because an operator picked it (or clicked a data link on a Grafana panel) and the day's budget allowed it. The budget is €45/day, system-wide, which buys somewhere between 12 and 23 bridges.

One shot, one edit. Before touching a pixel, the Remediator sweeps every other open finding this market holds on the same shot into one combined instruction. Fixing them one at a time regenerates the same seconds repeatedly and pays for each, and the second fix would undo the first.

The safety loop

flowchart LR
    G[("<b>Grafana</b><br/>alert rule<br/>every 30s")] -- "customs_blocking ≥ 70<br/>{asset, market, rule_id}" --> WH["POST<br/>/webhook/alert"]
    WH --> REM["<b>Remediator</b><br/>plan · price<br/>edit the span"]
    REM --> S["staged file"]
    S --> C{"craft gate<br/>length · resolution<br/>audio · drift"}
    C -- "fails" --> X["discarded<br/>master untouched<br/>finding back to open"]
    C -- "passes" --> M["new master"]
    M --> V{"<b>Verifier</b><br/>re-runs the<br/>REAL analyst"}
    V -- "violation survived<br/>or something new broke" --> X
    V -- "gone, and nothing<br/>else broke" --> R["<b>resolved</b><br/>Grafana clears<br/>its own alert"]
    R -.-> G

    style G fill:#fff4e5,stroke:#F46800,color:#17222c
    style C fill:#fef7e0,stroke:#FBBC05,color:#17222c
    style V fill:#fef7e0,stroke:#FBBC05,color:#17222c
    style X fill:#fce8e6,stroke:#EA4335,color:#17222c
    style R fill:#e6f4ea,stroke:#34A853,color:#17222c
Loading

The Verifier does not inspect the edit and does not ask the model "did that work?". It re-runs the real analyst pass over the changed shots and asks the same instrument that found the problem whether it still sees it. Then it answers the second half of the question (did anything new break?) because an edit that removes a bottle can also remove the finding next to it, or introduce one.


The Guard

When a rule is written on a protected characteristic, the honest answer is not an edit.

guard.apply is a pure function that reads exactly two things: the pack rule matched by rule_id, and the finding's own class. It never reads the rationale, the severity, or any other model-authored field. It never calls a model. Which means it is un-promptable: a crafted finding cannot argue its way past it.

Two rules in the corpus carry protected_basis: true. They are AE-LGBT-01 and SA-LGBT-01. A finding matching either gets remediation_blocked and the verbatim reason "rule basis targets a protected characteristic; human decision required", and the console shows the statute alongside a human decision.

The refusal is enforced a second time at the point of action: remediate._refuse_if_blocked raises before a frame is touched.

Tip

Guardrails belong in rule layers, not prompts. Anything a prompt grants, a prompt can take away.


The jurisdiction ladder

A market is a YAML file, not code.

GLOBAL  ── the baseline nobody escapes
  └─ EU  ── continental: AVMSD and friends
      └─ FR, DE, BE …  ── national statute
          └─ RTL, TF1, VRT …  ── broadcaster codes, stricter than the law

21 pack files, 128 authored rules, resolving to 98 selectable jurisdictions (1 global, 1 continental, 16 national, 80 channel) and 1,063 market-rule pairings. Every rule names its basis in prose and carries a citation. The class split is 94 legal / 25 policy / 9 offence, and class matters: an offence-class finding never blocks a market on its own, and never triggers an automatic edit.

Adding a market

Drop a YAML file in markets/. Declare a parent to inherit everything above you on the ladder, and add only what your jurisdiction says differently. Rules are matched to observations by dimension, which must be one of the 18 in markets/_taxonomy.yaml.

market: PT
name: Portugal
parent: EU
regulators: [ERC, ASAE]
pre_clearance: none
rules:
  - id: PT-ALC-01
    dimension: alcohol_tobacco_drugs
    klass: legal
    severity: 70
    basis: >
      Decreto-Lei 106/2015 restricts alcohol advertising on television
      between 07:00 and 22:30.
    remedy: Replace the alcoholic drink with a non-alcoholic one.

Quickstart

# 1. Environment
python3.12 -m venv .venv && .venv/bin/pip install -r requirements.txt
cp .env.example .env          # then fill in the Google Cloud + Grafana values

# 2. Provision the Grafana surface: dashboards, all three alert rules,
#    the webhook contact point, the share links. Idempotent.
.venv/bin/python scripts/provision_grafana.py

# 3. Clear an ad from the terminal
.venv/bin/python scripts/run_pipeline.py docs/samples/test_ad.mp4 --markets FR,EU,AE

# 4. Or run the console and use a browser
.venv/bin/uvicorn customs.app:app --reload
# http://127.0.0.1:8000

Needs ffmpeg on PATH, a Google Cloud project with Vertex AI enabled, and a Grafana Cloud stack. scripts/deploy.sh ships it to Cloud Run; scripts/deploy_viewer.sh ships the embeddable Grafana viewer beside it.

The test ad

docs/samples/test_ad.mp4 was generated with Veo and deliberately loaded with seven documented landmines plus a clean control shot, so Google's own tools made the ad, and then failed it.


Tests

.venv/bin/python -m pytest -q
# 624 passed, 8 deselected

624 offline tests across 24 files, no network, no API keys, no Grafana. The eight deselected ones are live-API probes you opt into explicitly.

The suite exists because most of this system's failure modes are silent: a guard that stops refusing, a citation that stops resolving, a craft gate that accepts a master that lost its soundtrack, a green can appearing in a modesty fix because a default leaked across dimensions. Every one of those has a test, and several of them have a test because it happened.


Honest limits

Warning

This is a hackathon build. The following are known, deliberate, and load-bearing to say out loud.

  • The ladder is wired, and one rung of it is thin. Every pack now declares its parent, so a German commercial is judged against German law, the AVMSD and the global baseline, and the 80 channel nodes inherit through their country (this was the honest limit here until 2026-09-07: fifteen national packs declared no parent and inherited nothing). What remains thin is the channel rung itself: three Belgian broadcasters have pack files of their own and the other 77 are names that inherit their country and add nothing.
  • The corpus is deliberately small. The archive shows the four commercials this project generated with Veo, because they are the ones it holds the rights to. Everything borrowed during development is hidden rather than deleted (config.WITHHELD_ASSETS, twenty-four films): the rows, frames and log lines are all still there, every read path refuses them, and emptying that tuple brings them back.
  • Omni refuses third-party IP. Gemini Omni declines to edit footage containing recognisable third-party content, which is one of the reasons the corpus above is our own. The refusal is quoted verbatim in the mission feed and nothing is charged. Patch methods remain the path for that footage.
  • Veo has a celebrity filter. A bridge over a shot it reads as depicting a public figure is refused with support code 15236754. Never charged, and a retry cannot help: the footage is the refusal.
  • The Omni model id is a deliberately old alias. gemini-omni-flash-preview deprecates 2026-09-30; the newer 1.1 preview is access-gated behind a quota error that granting quota does not clear. A preflight asks the model whether it still exists on the way up, so when that date passes the picker greys the method out with a sentence instead of offering a button that can only fail; OMNI_MODEL in the environment re-probes on the next cold start.
  • Image-generation quota is 2/min on this project pending a support case, which is why per_frame is slow.
  • Grafana Cloud cannot be framed, so live panels come from the self-hosted viewer described above. Annotation markers are blank in the framed panels: they live in the Cloud instance's own database, not in Loki.
  • A single Cloud Run instance. SQLite plus one writer thread is the concurrency model. It survives the parallel fan-out because every stage opens its own connection and only the guard writes findings.

Architecture

src/customs/
  crew.py         the ADK SequentialAgent: ingest → analyst → adjudicators → guard → publisher
  analyst.py      one Gemini vision call per shot, 18-dimension taxonomy, no verdicts
  adjudicate.py   the join: observation × rule × grounded citation, severity decided in code
  guard.py        un-promptable refusal, reads rule metadata only
  remediate.py    six methods, priced, group-aware, guarded twice
  verify.py       re-runs the real analyst on the changed shots, rules on bystanders
  media.py        ffmpeg: shots, flashes, spans, craft gate, thumbnails, previews
  grafana_ops.py  MCP first, REST where mcp-grafana 1.1.0 has no write tool
  telemetry.py    Mimir over OTLP on the film's own clock, Loki lines, annotations
  costs.py        what each method costs before you press
  app.py          FastAPI console: SSE, 21 templates, no build step
markets/          21 packs → 98 jurisdictions → 128 rules
grafana/dashboards/  9 dashboards, provisioned as JSON
grafana-viewer/   stock grafana-oss that is allowed to be framed
tests/            24 files, 624 tests

Built with: Python · FastAPI · Google Cloud Agent Builder (ADK) · Gemini (vision, text, TTS, image) · Veo 3.1 · Gemini Omni · Grafana Cloud (Mimir, Loki, MCP) · ffmpeg · SQLite · Cloud Run


License

Apache 2.0.

Grafana is upstream of the work, not a report produced afterwards.

About

An AI crew watches your commercial once, judges it against different geographical jurisdictions in parallel, builds its own Grafana instrument panel, and re-renders the shots that fail.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages