# Melrose Council Search — guide for research agents

This site — https://melrose-council.chmrr.net — is an unofficial, public
search engine over City of Melrose, Massachusetts government records. Use it
to research what city bodies discussed, decided, and voted on, and to cite
primary sources (official PDFs and meeting-video timestamps).

## What's in the corpus

- **Minutes and agendas, 2001–present**, for ~74 city bodies (City Council
  and its committees, School Committee, Planning Board, ZBA, and every other
  board/commission), from the city's CivicClerk portal and the legacy IQM2
  portal.
- **Meeting videos with timestamped transcripts, 2014–present**, from
  MMTV's Vimeo account: Vimeo auto-captions for ~2023 onward, locally
  produced Whisper transcripts for the earlier era (same correction
  and alignment pipeline either way).
- **Structured agenda items and vote records covering 2012–present**
  (don't stop at the platform migration: ~Sept 2025 onward is native,
  everything earlier is backfilled from the legacy portal — items
  with titles and order; votes with results, mover/seconder, and
  AYES rosters where the portal recorded them). Item↔video
  alignment reaches back to the earliest recordings (~2014).
  Items are aligned to the moment in the video where they were taken
  up and to the minutes passages that narrate them. The legacy
  backfills are complete: an older meeting with no items or votes has
  none on record (no published outline, no recorded roll call), so
  treat such gaps as permanent rather than pending.

Caveats to carry into your analysis:

- This is an **unofficial index**. Verify anything load-bearing against the
  linked original PDF or video before asserting it.
- Transcripts are **auto-generated**: names and technical terms get
  mis-heard (councillor surnames and street names are auto-corrected,
  other names are not).
  Treat transcript quotes as approximate unless verified against the video.
- Item↔video alignment is machine-derived: timestamps are reliable to
  within seconds, span edges approximate.
- A vote's `passed` field can be null ("result not recorded"): tallies were
  recorded but no outcome — do not infer passage.

## JSON API

No auth. All URLs relative to https://melrose-council.chmrr.net. `page_url`
fields in responses are site-relative paths.

### `GET /api/search?q=<query>[&scope=…][&sort=…][&year=YYYY]`

| Param | Values | Notes |
|---|---|---|
| `q` | free text | required. Loose terms are OR'd; ranking rewards matching more of them. Double-quote a segment to require it as an exact phrase — the way to find identifiers: `q="APPRO-2024-10"`. Hybrid keyword + semantic: paraphrases work, exact terms rank best. |
| `scope` | `council` (default) \| `all` \| `body:<id>` | `council` = City Council + its committees; `all` = every board; `body:<id>` = one board (ids from `/api/bodies`). |
| `sort` | `relevance` (default) \| `newest` | relevance already blends recency. |
| `year` | `YYYY` | filter to one year (the `years` facet is computed pre-filter). |

Returns at most **25 result groups**, no pagination — narrow with `year`,
`scope`, or more specific terms. Top level: `years`, the per-year match
histogram (`pct` relative to the busiest year) — answers "when was this
topic active?" — and `results`, the groups. Each group is one rolled-up
topic:

- `key` gives the group type: `["item", <id>]` (one agenda item — fetch
  `/api/item/<id>` for votes and detail), `["minutes", <meeting_id>]`
  (fetch `/api/meeting/<meeting_id>`), or `["video", <video_id>]`
  (transcript hits in one recording — fetch `/api/video/<video_id>`).
  A recording can cover several meetings held the same evening. Where
  the recording has been segmented by meeting, the key is
  `["video", <video_id>, <meeting_id>]` and `meeting_id` names the
  meeting in session during those hits; otherwise `meeting_id` is null
  unless exactly one meeting is linked — cite the video timestamp when
  no meeting is claimed.
- `hits` holds at most 3 representative hits per group — fetch the
  item, meeting, or video endpoint for the rest. Each hit carries a
  `snippet`, and for transcript hits a `vimeo_id` + `start_sec`;
  `https://vimeo.com/<vimeo_id>#t=<int>s` deep-links the exact moment.

### `GET /api/bodies`

Every body with meetings on record: `id`, `name`, `meetings` (count),
`first_meeting`/`last_meeting` (ISO dates), `is_council` (in the default
search scope), `current` (met within ~18 months of the newest record), and
the ready-made `scope` value for `/api/search`.

### `GET /api/item/<id>`

One agenda item: `title`, `content`, `number` (the clerk file number,
when assigned — feed it to `/api/order/` for the item's whole
lifecycle), meeting/body/date, `votes` (motion,
mover/seconder, `passed` = 1/0/null, `yes`/`no`/`abstain` as arrays
of member names as the clerk printed them (resolve them to people keys
through `/api/people`), and `absent` — who was recorded missing for that
motion, an array the legacy portal kept through mid-2025 and the
current one does not, so `[]` means "not recorded", never "everyone
present"). CAUTION: `passed` refers to the MOTION
named — "HOLD [9 TO 0]" with passed=1 means the hold carried, not
that the underlying matter passed; read motion and `passed`
together, never `passed` alone. Also: `minutes` (the passages narrating this item, stitched free
of indexing overlap), `video`
(vimeo id, span start/end seconds, timestamped URL), `transcript` (the
aligned passages, each with its own timestamped URL and a `speaker` —
the voice-identified member who spoke most of the passage, as
`{key, display_name}`, or null; see `/api/utterances` for how that
attribution works and errs), `dialogue` (the same span as
speaker-labeled paragraphs once the recording has been diarized —
each with `start_sec`, `end_sec`, `url`, `speaker` (a person or null),
`label` (the person's name, or "Speaker N" for an unidentified voice,
numbered by first appearance in the recording), and the auto-caption
`text`; `[]` for recordings not yet diarized, when `transcript` is
what there is), `documents`
(original agenda/minutes PDF links), and `portal_url` (the meeting on the
official city portal).

### `GET /api/meetings?body=<id>&from=YYYY-MM-DD&to=YYYY-MM-DD`

Enumerate meetings (all filters optional): id, name, date, body,
`item_count`, `has_video`, `has_minutes`. Use this to iterate a
body's meetings exhaustively — don't reconstruct the list from
search results.

### `GET /api/meeting/<id>`

One meeting: name/body/date, `items` (agenda in order, each with
aligned `start_sec`/`end_sec` where known — duration needs no extra
per-item call — and `parent_id` linking sub-items to their section
header item, e.g. everything under "Resolutions"; clerk-pasted
Zoom-invitation boilerplate is omitted, so the list can run shorter
than the official portal agenda. Items are NOT guaranteed
chronological by `start_sec`: chairs take things out of order),
`videos` (each with a `start_sec` and
timestamped `url` for where this meeting's session begins, when its
recording covers several meetings), `minutes` (full text in
document-order passages, indexing overlap already stitched out),
`transcript` (this meeting's speech with timestamped URLs and the
same per-passage `speaker` as `/api/item` — its segment of a shared
recording, the whole transcript of a dedicated one, or empty when the
recording is shared but not yet segmented),
`attendance` (the clerk's attendance table resolved to people keys:
`person`, `display_name`, `status`, `title`, `seat`, `role`, `arrived`,
`page_url`; empty when the minutes carry none), `documents`,
`portal_url`.

### `GET /api/video/<id>`

One recording: title, date, Vimeo URL, and the meetings it is linked to
(`is_primary` marks title-matched ones; `start_sec`, where present, is
when that meeting's session starts in the recording).

### `GET /api/order/<identifier>`

The lifecycle of a clerk file number (e.g. `ORDER-2024-21`,
`APPRO-2024-10`; bare numbers like `2026-1376` — the CivicClerk-era
style minutes write as "(ID # 2026-1376)" — and old-style `2012-113`
also work) or of a plain-number IQM2 LegiFile id (e.g. `12209`, the
form older minutes write as "(ID # 12209)" — the only identifier
petitions and other unprefixed files get): chronological
`stops`, one per meeting the identifier appears in, each with the
involved agenda items and their full vote records, minutes-mention
counts, and timestamped video clips. This is the fastest way to trace
committee referral → hearings → final council action.

### `GET /api/people[?at=YYYY-MM-DD][&all=1][&body=<id>]`

The membership roster, **derived from participation** — the city
publishes no machine-readable member list, so a person is here because
they appear in vote rosters, as a mover or seconder, or in the
attendance table the clerk prints at the top of full minutes (2013
onward). Default: people with a seat on the City Council or one of its
committees who are `active` at `at` (default today); `all=1` lists
everyone who ever held one; `body=<id>` scopes both to one body (ids
from `/api/bodies` — this is how to list the School Committee). Clerks,
solicitors, and other staff printed in the table are filtered out and
never become people. Top level: `at`, `all`, `body` (echoed), and
`people`.

Each person: `key` (stable slug, surname first: `zwirko-michael-p` —
the join key for every people endpoint), `display_name`, `aliases`
(the distinct full-name forms vote rosters and attendance tables use
for them, seat/role suffixes stripped and curated misspellings folded
— not every literal string in the record), `first_seen`/`last_seen`
(their first and last date in a vote roster or a member row of an
attendance table — minutes prose and transcripts do not count:
structured votes begin 2012 and attendance tables 2013, so a
long-serving member's `first_seen` is the record's start, not their
term's), `active` (an inference: a membership span on an in-scope body
reaches within 120 days of `at`), `service` (dated membership spans,
each `body_id`/`body_name`, `seat` ("Ward 6", "At-Large", or null),
`role` ("member", "president", "chair", "vice-chair", "ex-officio",
…), `first_seen`/`last_seen`, and `meetings`, the number of meetings
evidencing it — one span per run of consecutive meetings on one body
with the same seat and role; members change wards, take offices, and
move between bodies, so read this, never a single seat), `votes`
(counts: `cast` = yes+no+abstain, plus `yes`, `no`, `abstain`,
`absent`, `dissents`, `contested` (contested motions this person cast
on), `moved`, `seconded`), `attendance` (counts by
`present`/`absent`/`remote`/`late`/`excused` from the attendance
tables), `page_url`.

### `GET /api/person/<key>[?at=YYYY-MM-DD]`

One person's record, same shape plus `recent_votes` (their 25 newest
rows in the `/api/votes` shape); `at` moves the `active` inference as
on `/api/people`. An unknown key 404s with `detail.near`, the closest
known keys (surname-only and given-name-first guesses are matched).

Errors on every people endpoint follow the same rules: an unknown
`person` 404s with `detail.near`; a `from`, `to`, or `at` that is not
`YYYY-MM-DD`, a `role` other than `mover`/`seconder`, or a `status`
outside the five values 400s; a `limit` outside 1–500 or `min_shared`
below 1 is a 422; a malformed cursor, or one minted by a different
endpoint, returns an empty page rather than an error.

### `GET /api/votes?person=<key>[&q=…][&body=<id>][&from=YYYY-MM-DD][&to=…][&dissent=1][&contested=1][&role=mover|seconder][&limit=N][&cursor=…]`

Flat per-person vote rows — the workhorse for "how has X voted on Y".
Every filter is optional and they combine: `person` (a people key),
`q` (a keyword filter over the item's title and agenda text — the
`/api/search` syntax, quoted phrases required and loose terms OR'd,
but keyword-only with no semantic expansion, and applied to EVERY
matching item rather than a ranked top-25), `body`, `from`/`to`,
`dissent=1` (only rows where this person voted against the majority),
`contested=1` (only motions that drew both yes and no votes),
`role=mover|seconder` (only motions this person moved or seconded).
Rows come newest first, `limit` 100 by default and 500 at most; when
`next_cursor` is non-null, pass it back as `cursor` for the next page
(opaque; stable as votes are appended — a recent meeting re-ingested
by the daily refresh can shift its own rows). Top level: `votes` (the
rows) and `next_cursor`.

Each row: `person`, `vote_id`, `item_id`, `item_title`, `number` (clerk
file number or null), `meeting_id`, `body_id`, `body_name`, `date`,
`motion`, `passed` (1/0/null as on `/api/item`), `side`
(`yes`/`no`/`abstain`/`absent`, or null when the person moved or
seconded a motion whose roster went unrecorded), `dissent`,
`contested`, `mover`/`seconder` (booleans: did THIS person move or
second it — the names are on `/api/item`), `tally`
(`yes`/`no`/`abstain`/`absent` counts for the motion), `page_url`.

Definitions, applied uniformly:
- **dissent**: the person's side differs from the side with more votes
  cast on that motion. Ties: nobody dissents. Abstaining or being
  absent is never dissent.
- **contested**: the motion drew at least one yes AND one no. Abstentions
  and absences do not make a motion contested.
- The `/api/item` caveat applies unchanged: `passed` and `dissent` are
  relative to the MOTION as worded — voting no on "HOLD" is dissent
  from the hold, not from the underlying matter.

### `GET /api/attendance?person=<key>[&body=<id>][&from=…][&to=…][&status=…][&limit=N][&cursor=…]`

Per-person attendance rows from the clerk's attendance tables (full
minutes, 2013 onward — a meeting whose minutes lack the table has no
rows, which is absence of record, not absence). Filters as for
`/api/votes`, plus `status` = `present`/`absent`/`remote`/`late`/
`excused`; same `limit`/`cursor` pagination. Top level: `attendance`
and `next_cursor`. Each row: `person`,
`meeting_id`, `meeting_name`, `body_id`, `body_name`, `date`, `status`,
`title` (as the clerk printed it), `seat`, `role`, `arrived` (a time or
note when recorded), `page_url`. `/api/meeting/<id>` also returns that
meeting's whole table as `attendance`.

### `GET /api/statements?person=<key>[&q=…][&body=<id>][&from=…][&to=…][&limit=N][&cursor=…]`

What the minutes say a member said. Older minutes narrate the floor
("Alderman Conn asks if the abutters were notified…"), and each such
sentence is one row, attributed by the surname the clerk wrote when it
is unique on record or exactly one namesake's service window covers
the date — never guessed. Rows are **the clerk's paraphrase, not
quotes**, and the narration style is dense in the 2007–2011 minutes and
sparse after 2012, when action minutes took over; nothing here comes
from video transcripts (they carry no speaker labels). Filters:
`person`, `q` (every term must appear in the statement text,
case-insensitive), `body`, `from`/`to`; same `limit`/`cursor`
pagination. Top level: `statements` and `next_cursor`. Each row:
`person`, `date`, `meeting_id`, `meeting_name`, `body_id`, `body_name`,
`item_id` (the agenda item the passage is attributed to, or null),
`match_name` (the title and name as printed), `text`, `page_url`.
For what a member actually said on video, use `/api/utterances`
(voice-attributed turns), or search the transcript with `/api/search`
and read the timestamped hits.

### `GET /api/utterances?person=<key>[&q=…][&video=<id>][&from=…][&to=…][&limit=N][&cursor=…]`

What a member said on video, attributed by **voice**: each recording is
segmented into speaker turns and every turn's voice is matched against
the member's enrolled voice (built from moments the chair recognizes
them by name in the captions). A turn is attributed only above a
similarity threshold; the rest of the meeting's speech — public
comment, staff, unmatched voices — is not here at all. Expect a few
percent of attributions to be wrong (the pilot measured 98% precision
on a held-out sample) and the text to be the auto-caption text of that
turn, so treat rows as leads to the timestamp, not as quotes. Filters:
`person`, `q` (every term must appear in the turn text), `video`,
`from`/`to`; same `limit`/`cursor` paging. Top level: `utterances` and
`next_cursor`. Each row: `person`, `date`, `video_id`, `video_title`,
`meeting_id` (the recording's primary meeting), `start_sec`,
`end_sec`, `similarity`, `text`, `url` (the timestamped Vimeo link),
`page_url`. `/api/person/<key>` carries `speech` (identified hours,
turns, recordings), its ten newest rows as `recent_utterances`, and
`voice` — what the person's voice profile rests on (`labeled_turns`,
`recordings`, `seconds`, `enrolled`), or null when no recognized turn
has ever labeled their voice. A member whose `voice.enrolled` is
false (newly elected, typically) has no attributed speech yet; the
profile is promoted once three labeled turns across two recordings
exist, and their earlier meetings are then re-identified.

### `GET /api/votes/agreement[?from=…][&to=…][&body=<id>][&min_shared=N]`

Pairwise voting agreement, a one-fetch coalition prior. Only
**contested** motions count (unanimous ones carry no pairwise
information and would push every rate toward 1.0), and only yes/no
sides — an abstention is not a position. Default scope is the council
and its committees; `body=<id>` narrows to one body. Returns `window`
(your `from`/`to` where given, else the first and last dates with a
contested ballot), `body` and `min_shared` (echoed),
`contested_motions`, and `pairs`, each
`{a, b, shared, agreed, rate}` with keys ordered a < b; pairs sharing
fewer than `min_shared` (default 10) contested motions are dropped as
noise. Remember that committees are small and their rosters change
yearly: a pair's `shared` count says how much evidence the rate rests
on.

## Human pages (for citation links)

`/item/<id>`, `/meeting/<id>`, `/video/<id>`, and `/order/<identifier>`
render the same data for human readers; `/people` and `/person/<key>`
are the roster pages; `/search` is the interactive UI and `/` lists
recent and upcoming meetings. Cite the page URL for navigation
plus the original PDF link and/or the `vimeo.com/...#t=` timestamp as the
primary source.

## Effective research patterns

- Start broad (`scope=all` when a topic may span boards), read the `years`
  histogram, then drill into specific years.
- Try 2–3 phrasings (official term, colloquial term, related concept):
  minutes use formal language ("accessory dwelling unit", not "in-law
  apartment").
- For one motion's roster and result, fetch `/api/item/<id>` — they
  live there, not in search snippets.
- For anything person-centric, start at `/api/people` for the key,
  then `/api/votes?person=…&q=…` (one call, every matching motion),
  `/api/attendance?person=…`, `/api/statements?person=…`, and
  `/api/votes/agreement` for coalitions — never reconstruct a member's
  record from `/api/search`.
- Committee recommendation ≠ final action. When you have a file number,
  `/api/order/<identifier>` gives the whole lifecycle in one call; the
  final disposition is authoritative.
- Bodies rename over the decades (Board of Aldermen → City Council, 2018).
  The index unifies them: searching the current name covers old records.

## Etiquette

This is a small public service on modest hardware: keep to a few requests
per second and cache what you fetch.
