Tostino/civic-watch

Searchable, speaker-attributed archive of local government meetings, with an agent that cites the recording. First instance: pasco.watch

0

stars

177

commits

Python

primary language

Sep 4, 2026

updated

README

civic-watch - Searchable archive of local government meetings

civic-watch mirrors what a county publishes about its meetings, transcribes the recordings, and lets you ask questions against both. The first instance is Pasco Watch, covering Pasco County, Florida, at pasco.watch. civic-watch stays the name of the code and of the container image; Pasco Watch is what the site calls itself.

Two different things go into the archive. The county publishes agendas and minutes, and that's the authority on what was decided. It covers every meeting whether or not anyone filmed it. Then there's the transcript, which is the authority on what was said, and it only exists where there's a recording, which reaches a minority of the decided items.

The transcript side is machine made from end to end. Audio comes down as 16 kHz mono FLAC, pyannote works out who spoke when without having any idea who anyone is, and Parakeet transcribes the voiced windows. Getting names onto those anonymous voices is the hard part, and it happens in stages: cluster the voices across the whole archive, anchor a few clusters using the published chair roster, match the rest against whoever the roster says was in the room that day, then run an LLM pass over the text that has to find a verbatim quote before it's allowed to claim a name. Anything a person fixes by hand outranks all of that and survives a full rebuild.

None of it is exact, and the archive tries not to pretend otherwise. Every passage carries how well its name is known, which is a different question from whether it has one, and the three levels aren't close to equal: a person stated this name, or a voice was matched at that meeting, or all we have is the name that voice goes by across the whole archive. Before tools.speaker_sure() existed every surface printed all three identically, which meant an answer could say "Oakley moved" on the strength of the weakest one.

This has only ever run against one county, and Pasco is baked in deeper than it should be. The catalog stage knows about a YouTube channel, the portal stage speaks CivicClerk, and the roster and naming code carry local assumptions. So moving it somewhere else is a real port and not a config change. It's the kind of port a model is good at, though. Hand an LLM this repo as the worked example, along with your own county's documents and its video archive, and most of the job is rewriting the catalog and portal stages to match what your municipality actually publishes. The schema, the retrieval and the agent should carry over.

Table of Contents

How this was built

I built this with Claude Code over about two weeks. Almost all of the code was written by the model. My own work went in elsewhere: steering the design, spot checking the data coming out of each stage, labelling speakers by hand, and testing the site the way a reader would use it. I haven't gone through the whole codebase line by line, and mostly opened the code when the model was making obviously bad choices.

Layout

bin/      the pipeline, plus schema.sql and bm25.sql
web/      the reader API: retrieval, the tool surface, the agent, admin, routing
ui/       the front end (Next 16, React 19, TypeScript)
deploy/   Dockerfile, compose, and by-hand notes for Postgres and the edge
data/     per-video audio, diarization and embeddings, all gitignored
eval/     eval fixtures; the labelled ground truth is gitignored, see Redaction

Everything a reader touches comes out of Postgres 18 with pgvector. Retrieval is a SQL implementation of BM25, an HNSW vector index, and curated thread keys, all of it over passages that never cross a speaker boundary. The site doesn't serve any media itself, since playback is just a YouTube iframe, which is nice because it means the audio and video sitting on disk never have to be reachable from the internet.

There are three virtualenvs, which looks silly until you try it with one. NeMo and pyannote don't agree on which torch to pin, so asr-venv handles ASR, diar-venv handles diarization, and emb-venv handles embeddings, the agent and the web server.

Pipeline

stagescriptwhat it does
catalogcatalog.pyfinds the county's published recordings
downloaddownload_worker.py16 kHz mono FLAC plus silence points
diarizediarize_worker.pypyannote, saving speaker centroids
ASRasr_worker.pyParakeet over VAD windows, then audit and repair
speakersspeaker_id.pyclustering, anchors, per-meeting matching
namingname_speakers.pyan LLM pass with verbatim-quote verification
correctionscorrect.pya human over an utterance range, outranking every derived layer
segmentssegment.pyphase and subject boundaries, one LLM call per meeting-day
portalcivicclerk.pymirrors the county's agendas and minutes
agendasparse_agenda.pyagenda text into items, codes and case numbers
minutesparse_minutes.pyminutes text into the outcome for each item
rostersroster.pywho sat on the board, in what office, by date
domainland_agenda.pymeetings, items and cases; binds transcript spans to items
chairchair_anchor.pyanchors clusters to commissioners from the chair roster
affinityaffinity.pywhether a voice really sounds like the person its cluster names
passagesindex_passages.pyretrieval units and their embeddings
agentask.pyplan, retrieve, read through several lenses, answer
checkeval_agent.pywhether the answer reached the evidence it needed
auditaudit.pydata invariants, in bulk, repairing nothing

One nice surprise: the county publishes its agendas and minutes through an unauthenticated CivicClerk OData endpoint, and it'll hand back its own extraction of the PDF text if you ask for it. So there's no PDF parsing in here at all, which is usually the grim part of a project like this.

Running it

source ./env.local.sh                  # CIVIC_DSN and LLM_API_KEY, gitignored, mode 600

bash bin/run.sh                        # ingest fleet: download, diarize, ASR
./emb-venv/bin/python bin/status.py    # progress

bin/serve.sh                           # reader API on :8765
npm --prefix ui run dev                # UI on :3000, proxying /api to :8765

The UI wants Node 24, which is what deploy/Dockerfile builds it with and what ui/.nvmrc says, so nvm use in ui/ lands on the right one.

Start the API through bin/serve.sh instead of calling server.py yourself. If you call it directly it comes up fine, serves the whole archive correctly, and then refuses every question, because the inference key only reaches the process through bin/_env.sh. It ran that way here for weeks before anyone noticed.

CIVIC_DSN is what decides which database is real, and bin/db.py won't read anything else, so a script run without it raises instead of quietly finding some local cluster and doing the work there. It was called PASCO_DSN until 2026-08-21, as was CIVIC_EMBED_DEVICE; both old names are still honoured, in bin/db.py and bin/_env.sh, so a running deployment survives an image pull it has not been reconfigured for. Nothing else reads them.

Pages

/              browse: the collection, a year by month time axis, ways in
/meeting/:id   the meeting: agenda spine, roster, transcript, player
/item/:id      one agenda item: the record, the county's PDF, what was said
/case/:id      one application across every meeting that took it up
/search        both sources at once
/ask           the agent, streaming its real tool calls
/ask/:id       one kept answer, so a run can be sent to somebody
/about         what the archive holds and where it falls short, counted live
/mcp           the same archive as tools, for a client that speaks MCP
/admin         curation console: queues, corrections, redactions, ops

/mcp is public and unauthenticated the same way the pages are. What it calls itself in the handshake comes from MCP_NAME and MCP_TITLE, because that name belongs to the archive being served and not to this code; unset, it answers to civic-watch and "Civic Watch". /about prints whatever MCP_NAME says in the commands it hands a reader, so the name they register under is the name the handshake announces. Setting one and not the other is the way to get those two out of step.

The admin console isn't reachable from the internet, and what does that is which port answered rather than anything in the request itself. Curation binds its own loopback listener, the public listener 404s every /api/admin path, and the UI only proxies to the curation port when ADMIN_API is set, which the production image doesn't set. There's also a peer check in web/admin.py on top of that, and the comment above it goes on at some length about why you can't trust a header for this. The version before this one locked the console out of its own front end, and the obvious repair after that turned out to be forgeable, so read the comment before you touch any of it. Reach the console over an SSH tunnel.

Keeping it current

./emb-venv/bin/python bin/civicclerk.py --events --text
./emb-venv/bin/python bin/catalog.py && bash bin/run.sh
bash bin/refresh.sh roster speakers names chair affinity segment land index eval
./emb-venv/bin/python bin/audit.py

The order refresh.sh runs in matters, and the script explains each dependency right where it relies on it. Every stage is idempotent, so re-running one after a failure just resumes. If you want to be sure of which code produced which rows, bin/rebuild.sh drops all the derived tables and builds them again from the same inputs in about twenty minutes, without re-downloading a video or re-running ASR.

What it gets wrong

/about has the current numbers and bin/audit.py says which invariants are failing today. Neither of them catches what's below.

  • The audit checks consistency, not correctness. It catches an orphaned span, or an outcome that disagrees with the sentence it came from. It has no opinion on whether a boundary is in the right place, or whether a voice belongs to the person named on it.
  • I haven't measured speaker precision recently, and it's hard to measure properly. The hand labels and the published rosters both feed the assignment, so scoring against either one is circular. I measure coverage instead: how many utterances cluster, how many get a name, and how many land outside the named person's term of office.
  • Plenty of utterances get no name at all and show up as a group label. That's the first thing most people notice.
  • Some of the older agendas are image-only scans the portal can't pull text from. Those meetings have no published agenda and run entirely on items derived from the transcript.
  • Binding transcript spans to agenda items works on public hearings and resolutions, is patchier on the consent agenda, and is worst on the regular agenda. Board reports carry no agenda code, so they never bind at all.
  • Roll calls are the worst case for getting a name wrong. They're fast, one person reads the names while the votes come from several others, and people talk over each other. It's also where being wrong does the most damage, so the tool instructions don't allow reporting a named person's vote from a transcript at all.

Everything the agent says sits inside that list. That's why every page says what it's showing and where it came from.

Who is asking

Two tables come out of /ask. answers holds a run so that a shared link still resolves years later, and it holds nothing that could tell two people apart. asks is the operator's, and no route serves it: one row per arrival at the endpoint, including the ones that were turned away, because a ceiling set too low used to look exactly like nobody asking.

The only identity in it is an HMAC of the caller's address and the local date. That counts people within a day and cannot be joined across two, so it says how many asked on Tuesday and can't tell you that Tuesday's visitor came back on Friday. The key is what keeps it a token rather than a thin disguise: an IPv4 address is 32 bits, so a bare hash of one is undone by trying all four billion. With ASK_ASKER_KEY unset, the arrivals are still counted and that column stays empty. Nothing else is kept per visitor anywhere: no cookie, no session, no access log.

./emb-venv/bin/python bin/asks.py               # a line a day
./emb-venv/bin/python bin/asks.py --questions   # what was actually asked

A count of tokens is a count of addresses, so a household behind one router is one person and somebody on a phone and a laptop is two. It is a floor with a wobble, not a headcount, and it is as far as a server that keeps no accounts can honestly go.

Redaction

People read their home address into public comment at the podium. That's public record, but an address that turns up as the top search result is a different kind of exposure from the same address sitting in a PDF nobody indexes. bin/redact.py proposes removals and a person approves or rejects each one at /admin/redactions.

An approved removal layers rather than deletes. utterances.text_raw is what the recognizer produced and never gets indexed, and utterances.text is what the archive publishes. There's a family of checks in audit.py for the rest of it: that a removal actually reached the transcript, the passages, the search index and any kept answers, that the text is genuinely unfindable afterwards, that the raw column still holds what was said, and that the span taken out wasn't wider than it needed to be.

No real name or address appears anywhere in this repository. The examples in the docs are made up, and each one was checked for zero occurrences in the corpus before it got used. The labelled ground-truth files stay out of git.

Documents

  • COPY.md has the copy conventions. The numbered list at the top is mine; the rest is a model inferring the rules from copy a model wrote, so check it against the pages, not the pages against it.
  • deploy/postgres-unraid.md and deploy/nginx-proxy-manager.md cover the database and the edge, both of which are set up by hand.
  • AUDIT.md is a front-end audit from 2026-08-23: every page template at both form factors, what is wrong, and in what order to fix it. The numbers in it come from a local Lighthouse and are more generous than PageSpeed Insights; it says so, and says why.

Not the official record

Pasco County publishes the authoritative agendas and minutes. This archive mirrors those documents and adds a transcript layer the county didn't publish and doesn't vouch for, and every page that makes a claim tells you which of the two it came from.

So the standing rule is that an outcome comes from the record and an argument comes from the transcript. Answering "did the board approve this?" out of the transcript is the obvious thing to build, and I think it's wrong often enough that you shouldn't: the minutes were written by the county and approved by the board, and they say who voted which way, while a name in the transcript is something this project attached to a voice. A count the transcript states out loud, something like "four nays, three ayes", is fine to repeat, because that's a thing that was said rather than a name anybody attached.

Contributors

Tostino

177 commits

Tostino/civic-watch

Searchable, speaker-attributed archive of local government meetings, with an agent that cites the recording. First instance: pasco.watch

0

stars

177

commits

Python

primary language

Sep 4, 2026

updated

README

civic-watch - Searchable archive of local government meetings

civic-watch mirrors what a county publishes about its meetings, transcribes the recordings, and lets you ask questions against both. The first instance is Pasco Watch, covering Pasco County, Florida, at pasco.watch. civic-watch stays the name of the code and of the container image; Pasco Watch is what the site calls itself.

Two different things go into the archive. The county publishes agendas and minutes, and that's the authority on what was decided. It covers every meeting whether or not anyone filmed it. Then there's the transcript, which is the authority on what was said, and it only exists where there's a recording, which reaches a minority of the decided items.

The transcript side is machine made from end to end. Audio comes down as 16 kHz mono FLAC, pyannote works out who spoke when without having any idea who anyone is, and Parakeet transcribes the voiced windows. Getting names onto those anonymous voices is the hard part, and it happens in stages: cluster the voices across the whole archive, anchor a few clusters using the published chair roster, match the rest against whoever the roster says was in the room that day, then run an LLM pass over the text that has to find a verbatim quote before it's allowed to claim a name. Anything a person fixes by hand outranks all of that and survives a full rebuild.

None of it is exact, and the archive tries not to pretend otherwise. Every passage carries how well its name is known, which is a different question from whether it has one, and the three levels aren't close to equal: a person stated this name, or a voice was matched at that meeting, or all we have is the name that voice goes by across the whole archive. Before tools.speaker_sure() existed every surface printed all three identically, which meant an answer could say "Oakley moved" on the strength of the weakest one.

This has only ever run against one county, and Pasco is baked in deeper than it should be. The catalog stage knows about a YouTube channel, the portal stage speaks CivicClerk, and the roster and naming code carry local assumptions. So moving it somewhere else is a real port and not a config change. It's the kind of port a model is good at, though. Hand an LLM this repo as the worked example, along with your own county's documents and its video archive, and most of the job is rewriting the catalog and portal stages to match what your municipality actually publishes. The schema, the retrieval and the agent should carry over.

Table of Contents

How this was built

I built this with Claude Code over about two weeks. Almost all of the code was written by the model. My own work went in elsewhere: steering the design, spot checking the data coming out of each stage, labelling speakers by hand, and testing the site the way a reader would use it. I haven't gone through the whole codebase line by line, and mostly opened the code when the model was making obviously bad choices.

Layout

bin/      the pipeline, plus schema.sql and bm25.sql
web/      the reader API: retrieval, the tool surface, the agent, admin, routing
ui/       the front end (Next 16, React 19, TypeScript)
deploy/   Dockerfile, compose, and by-hand notes for Postgres and the edge
data/     per-video audio, diarization and embeddings, all gitignored
eval/     eval fixtures; the labelled ground truth is gitignored, see Redaction

Everything a reader touches comes out of Postgres 18 with pgvector. Retrieval is a SQL implementation of BM25, an HNSW vector index, and curated thread keys, all of it over passages that never cross a speaker boundary. The site doesn't serve any media itself, since playback is just a YouTube iframe, which is nice because it means the audio and video sitting on disk never have to be reachable from the internet.

There are three virtualenvs, which looks silly until you try it with one. NeMo and pyannote don't agree on which torch to pin, so asr-venv handles ASR, diar-venv handles diarization, and emb-venv handles embeddings, the agent and the web server.

Pipeline

stagescriptwhat it does
catalogcatalog.pyfinds the county's published recordings
downloaddownload_worker.py16 kHz mono FLAC plus silence points
diarizediarize_worker.pypyannote, saving speaker centroids
ASRasr_worker.pyParakeet over VAD windows, then audit and repair
speakersspeaker_id.pyclustering, anchors, per-meeting matching
namingname_speakers.pyan LLM pass with verbatim-quote verification
correctionscorrect.pya human over an utterance range, outranking every derived layer
segmentssegment.pyphase and subject boundaries, one LLM call per meeting-day
portalcivicclerk.pymirrors the county's agendas and minutes
agendasparse_agenda.pyagenda text into items, codes and case numbers
minutesparse_minutes.pyminutes text into the outcome for each item
rostersroster.pywho sat on the board, in what office, by date
domainland_agenda.pymeetings, items and cases; binds transcript spans to items
chairchair_anchor.pyanchors clusters to commissioners from the chair roster
affinityaffinity.pywhether a voice really sounds like the person its cluster names
passagesindex_passages.pyretrieval units and their embeddings
agentask.pyplan, retrieve, read through several lenses, answer
checkeval_agent.pywhether the answer reached the evidence it needed
auditaudit.pydata invariants, in bulk, repairing nothing

One nice surprise: the county publishes its agendas and minutes through an unauthenticated CivicClerk OData endpoint, and it'll hand back its own extraction of the PDF text if you ask for it. So there's no PDF parsing in here at all, which is usually the grim part of a project like this.

Running it

source ./env.local.sh                  # CIVIC_DSN and LLM_API_KEY, gitignored, mode 600

bash bin/run.sh                        # ingest fleet: download, diarize, ASR
./emb-venv/bin/python bin/status.py    # progress

bin/serve.sh                           # reader API on :8765
npm --prefix ui run dev                # UI on :3000, proxying /api to :8765

The UI wants Node 24, which is what deploy/Dockerfile builds it with and what ui/.nvmrc says, so nvm use in ui/ lands on the right one.

Start the API through bin/serve.sh instead of calling server.py yourself. If you call it directly it comes up fine, serves the whole archive correctly, and then refuses every question, because the inference key only reaches the process through bin/_env.sh. It ran that way here for weeks before anyone noticed.

CIVIC_DSN is what decides which database is real, and bin/db.py won't read anything else, so a script run without it raises instead of quietly finding some local cluster and doing the work there. It was called PASCO_DSN until 2026-08-21, as was CIVIC_EMBED_DEVICE; both old names are still honoured, in bin/db.py and bin/_env.sh, so a running deployment survives an image pull it has not been reconfigured for. Nothing else reads them.

Pages

/              browse: the collection, a year by month time axis, ways in
/meeting/:id   the meeting: agenda spine, roster, transcript, player
/item/:id      one agenda item: the record, the county's PDF, what was said
/case/:id      one application across every meeting that took it up
/search        both sources at once
/ask           the agent, streaming its real tool calls
/ask/:id       one kept answer, so a run can be sent to somebody
/about         what the archive holds and where it falls short, counted live
/mcp           the same archive as tools, for a client that speaks MCP
/admin         curation console: queues, corrections, redactions, ops

/mcp is public and unauthenticated the same way the pages are. What it calls itself in the handshake comes from MCP_NAME and MCP_TITLE, because that name belongs to the archive being served and not to this code; unset, it answers to civic-watch and "Civic Watch". /about prints whatever MCP_NAME says in the commands it hands a reader, so the name they register under is the name the handshake announces. Setting one and not the other is the way to get those two out of step.

The admin console isn't reachable from the internet, and what does that is which port answered rather than anything in the request itself. Curation binds its own loopback listener, the public listener 404s every /api/admin path, and the UI only proxies to the curation port when ADMIN_API is set, which the production image doesn't set. There's also a peer check in web/admin.py on top of that, and the comment above it goes on at some length about why you can't trust a header for this. The version before this one locked the console out of its own front end, and the obvious repair after that turned out to be forgeable, so read the comment before you touch any of it. Reach the console over an SSH tunnel.

Keeping it current

./emb-venv/bin/python bin/civicclerk.py --events --text
./emb-venv/bin/python bin/catalog.py && bash bin/run.sh
bash bin/refresh.sh roster speakers names chair affinity segment land index eval
./emb-venv/bin/python bin/audit.py

The order refresh.sh runs in matters, and the script explains each dependency right where it relies on it. Every stage is idempotent, so re-running one after a failure just resumes. If you want to be sure of which code produced which rows, bin/rebuild.sh drops all the derived tables and builds them again from the same inputs in about twenty minutes, without re-downloading a video or re-running ASR.

What it gets wrong

/about has the current numbers and bin/audit.py says which invariants are failing today. Neither of them catches what's below.

  • The audit checks consistency, not correctness. It catches an orphaned span, or an outcome that disagrees with the sentence it came from. It has no opinion on whether a boundary is in the right place, or whether a voice belongs to the person named on it.
  • I haven't measured speaker precision recently, and it's hard to measure properly. The hand labels and the published rosters both feed the assignment, so scoring against either one is circular. I measure coverage instead: how many utterances cluster, how many get a name, and how many land outside the named person's term of office.
  • Plenty of utterances get no name at all and show up as a group label. That's the first thing most people notice.
  • Some of the older agendas are image-only scans the portal can't pull text from. Those meetings have no published agenda and run entirely on items derived from the transcript.
  • Binding transcript spans to agenda items works on public hearings and resolutions, is patchier on the consent agenda, and is worst on the regular agenda. Board reports carry no agenda code, so they never bind at all.
  • Roll calls are the worst case for getting a name wrong. They're fast, one person reads the names while the votes come from several others, and people talk over each other. It's also where being wrong does the most damage, so the tool instructions don't allow reporting a named person's vote from a transcript at all.

Everything the agent says sits inside that list. That's why every page says what it's showing and where it came from.

Who is asking

Two tables come out of /ask. answers holds a run so that a shared link still resolves years later, and it holds nothing that could tell two people apart. asks is the operator's, and no route serves it: one row per arrival at the endpoint, including the ones that were turned away, because a ceiling set too low used to look exactly like nobody asking.

The only identity in it is an HMAC of the caller's address and the local date. That counts people within a day and cannot be joined across two, so it says how many asked on Tuesday and can't tell you that Tuesday's visitor came back on Friday. The key is what keeps it a token rather than a thin disguise: an IPv4 address is 32 bits, so a bare hash of one is undone by trying all four billion. With ASK_ASKER_KEY unset, the arrivals are still counted and that column stays empty. Nothing else is kept per visitor anywhere: no cookie, no session, no access log.

./emb-venv/bin/python bin/asks.py               # a line a day
./emb-venv/bin/python bin/asks.py --questions   # what was actually asked

A count of tokens is a count of addresses, so a household behind one router is one person and somebody on a phone and a laptop is two. It is a floor with a wobble, not a headcount, and it is as far as a server that keeps no accounts can honestly go.

Redaction

People read their home address into public comment at the podium. That's public record, but an address that turns up as the top search result is a different kind of exposure from the same address sitting in a PDF nobody indexes. bin/redact.py proposes removals and a person approves or rejects each one at /admin/redactions.

An approved removal layers rather than deletes. utterances.text_raw is what the recognizer produced and never gets indexed, and utterances.text is what the archive publishes. There's a family of checks in audit.py for the rest of it: that a removal actually reached the transcript, the passages, the search index and any kept answers, that the text is genuinely unfindable afterwards, that the raw column still holds what was said, and that the span taken out wasn't wider than it needed to be.

No real name or address appears anywhere in this repository. The examples in the docs are made up, and each one was checked for zero occurrences in the corpus before it got used. The labelled ground-truth files stay out of git.

Documents

  • COPY.md has the copy conventions. The numbered list at the top is mine; the rest is a model inferring the rules from copy a model wrote, so check it against the pages, not the pages against it.
  • deploy/postgres-unraid.md and deploy/nginx-proxy-manager.md cover the database and the edge, both of which are set up by hand.
  • AUDIT.md is a front-end audit from 2026-08-23: every page template at both form factors, what is wrong, and in what order to fix it. The numbers in it come from a local Lighthouse and are more generous than PageSpeed Insights; it says so, and says why.

Not the official record

Pasco County publishes the authoritative agendas and minutes. This archive mirrors those documents and adds a transcript layer the county didn't publish and doesn't vouch for, and every page that makes a claim tells you which of the two it came from.

So the standing rule is that an outcome comes from the record and an argument comes from the transcript. Answering "did the board approve this?" out of the transcript is the obvious thing to build, and I think it's wrong often enough that you shouldn't: the minutes were written by the county and approved by the board, and they say who voted which way, while a name in the transcript is something this project attached to a voice. A count the transcript states out loud, something like "four nays, three ayes", is fine to repeat, because that's a thing that was said rather than a name anybody attached.

Contributors

Tostino

177 commits

Languages

Python

50.2%

TypeScript

32.3%

CSS

12.7%

PLpgSQL

3.0%

Shell

1.6%