Stage 2: intake

Stage 1 handed you claims already in JSON. From stage 2 onwards, contacts arrive as human-generated text, so somebody has to read the sentence and turn it into something understandable. That somebody is intake, a stateless model call that takes one transcript in and produces one report out. It has no memory between contacts and no access to your stores.

For now, each incident is reported only once. In Stage 3, you’ll start reconciling duplicates.

From raw contacts to structured reports

What needs to be built

extract_report in intake.py is ready. It picks the model, sends the system prompt with the contact text, and budgets the completion. Smaller models occasionally struggle with structured output, so _parse helps with that. You need to:

  1. prompts/intake_system.md - five TODO sections: output structure, incident_type, severity, headcount, and event time with confidence. This governs how the model turns text into a structure.
  2. _finalize(fields, contact) in intake.py - The TODO (stage 2) at the end of extract_report needs to check and validate the model output. Flip that line to return _finalize(fields, contact), usage.

As you build the prompt, you’ll need to grade it against the fixtures your team developed on day 1.

Envelope authority

_finalize is where the report structure is enforced. You can design your own report schema, but maintain these four invariants:

  • contact_id and reported_location are copied from the contact object, never taken from the model. Never give an LLM the opportunity to invent something when you can just copy the ground truth.
  • incident_type is exactly one of fire, none, unknown. Anything else coerces to unknown.
  • notes is "" when absent.
  • A none report carries no fire severity and no headcount: force both to null.

Everything else - severity, headcount and its qualifier, event time, and how confident your intake bot is in each field - comes from the model.

The model is small

INTAKE_MODEL defaults to mimo-v2.5. It follows explicit rules well and guesses unstated ones badly, so a clearly-stated decision ladder beats a pile of few-shot examples: say what you mean rather than hoping it infers the convention. If you have the examples and want to build the ladder, use Opus to do that; it is a much more powerful model and excels at such tasks.

MAX_TOKENS is 1400 and that is deliberate. If you enable reasoning (or swap to a reasoning model later), it needs enough token output to complete its reasoning trace before it ever emits the JSON. A tight budget truncates the response to empty content and your accuracy collapses to zero.

Activity: Extract the claim

  1. Write the prompt. Fill the five sections of prompts/intake_system.md. incident_type is the one that moves the score: explicit flame or fire words are fire; an explicit denial or a clearly non-fire request is none; smoke, a smell, an alarm, or plain ambiguity is unknown. Absence of the word “fire” is not a denial.

  2. Implement _finalize to the envelope-authority rules above, then flip the TODO (stage 2) line so raw model output stops escaping unvalidated.

  3. Keep the free paths free. Typed truck observations and Stage 1 structured claims must not reach the model - extract_report already routes structured_report to _project_claim at zero tokens, and runner._process_tick already splits truck observations out before intake sees them. Neither should cost you a model call.

  4. Grade it against the fixtures in your repository - the accepted labels Day 1’s evaluation session produced over datasets/contacts_unlabeled.jsonl, which the driver’s pull request merged into every teammate’s repo. Score against the claim the caller expressed, never against hidden simulator truth. Give severity, headcount, and event time a tolerance band, because the model is reading prose and a defensible reading can differ from your labeller’s. Treat per-field confidence as advisory and directional rather than a number to match: it carries generation jitter and is not worth chasing. The instructor’s own labeled corpus is a different, unseen set of contacts and stays sealed until model comparison, where you unseal it as a held-out test set - so do not tune against it, because you cannot.

  5. Add the scenarios. features/intake.feature ships only INT-2 and INT-3, both on the structured path. Add human_text scenarios of your own for the cases you just argued about in the prompt - a denial, ambiguous smoke, a red herring, a caller who hedges - and record the new baseline.

  6. Run it live. Point the runner at a stage-2 scenario, where every contact is a panicked sentence rather than a structured claim:

    uv run hadr-runner stage2-language@londone
    

    Your Stage 1 dispatcher is unchanged, so any drop against Stage 1 is intake losing information the dispatcher used to get for free.

Boundaries

  • Intake has no incident-store access and no memory across contacts.
  • Ambiguous smoke is unknown, not a confirmed fire or an explicit false alarm.
  • A clearly non-fire red herring such as a cat rescue or a neighbour dispute is none, not unknown.
  • Every human contact supplies exact coordinates or an exact l... building ID. It may still be wrong relative to the real fire. The runner converts buildings to locations for you. The intake helper-prompt stub still asks you to say why, not to pick freely.
  • Do not add duplicate reconciliation yet.

When you’re done

  1. Open a pull request with the prompt and _finalize, and have a teammate review the prompt as prose, not as code. It is the part of your system a reviewer can actually read end to end, and a rule that is ambiguous to your teammate is ambiguous to mimo-v2.5.
  2. Post your extraction accuracy on the Padlet: the per-field breakdown, not one aggregate number, plus tokens per contact. Your team graded against the same fixtures, so the field where your numbers diverge is the field where your prompts disagree - and one of you is wrong. a. If you spot your accuracy as unusually high, ask yourself if that is because your intake bot is good, or because your fixtures are missing details, and too easy to predict.