Stage 2 reference solution

Two code holes (_finalize and enabling the human-text path) plus the intake prompt and your own eval. _project_claim and the runner already work, and Stage 1 left you a minimal _finalize to extend.

_finalize: envelope authority + schema

The model reads the free text; the envelope owns identity and location. Copy contact_id and reported_location from the contact, never from the model. Coerce incident_type to the three legal values, default notes, and null out fire fields on a none report.

def _finalize(fields: dict, contact: dict) -> dict:
    it = fields.get("incident_type")
    if it not in ("fire", "none", "unknown"):
        it = "unknown"
    report = {
        "contact_id": contact["contact_id"],
        "reported_location": contact["reported_location"],
        "incident_type": it,
        "severity": fields.get("severity"),
        "headcount": fields.get("headcount"),
        "event_time": fields.get("event_time"),
        "notes": fields.get("notes") or "",
    }
    if it == "none":
        report["severity"] = None
        report["headcount"] = None
    return report

Because reported_location is copied verbatim and the report schema has no coordinate field, a building_to_coords call inside intake would have nowhere to put its result: the location conversion belongs to deterministic reconciliation (Stage 3), and intake stays pure text-to-JSON.

Enable the human-text path

In extract_report, the raw model output is returned unvalidated until you finalize it. Replace the # TODO (stage 2) return with:

    return _finalize(fields, contact), usage

The prompt

mimo-v2.5 is a small model in JSON mode. A single crisp decision ladder outperforms few-shots (few-shots measurably hurt it). The load-bearing section is incident_type, decided by the words present:

  • fire/flame words (“on fire”, “flames”, “well alight”, “it’s burning”) -> fire; panic or vagueness does not downgrade a stated fire.
  • an explicit denial or a clearly non-fire matter (BBQ, steam, cat, dispute) -> none.
  • smoke alone, a burning smell alone, a bare alarm, “might be nothing” -> unknown.

Absence of the word “fire” is not a denial. Severity drops one notch for panicked callers; headcount carries a qualifier (confirmed_trapped / possibly_trapped / all_out / unknown); firm first-hand claims get higher confidence than hedged or second-hand ones.

event_time is the field that most rewards an explicit rule. Left to its own judgement the model reads any present-tense report of an ongoing fire as a past event and sets a tick on all of them; naming the convention per source_type moved that field from 57% to 100%.

The completed prompt:

You classify ONE emergency contact (a transcribed call or an automatic alarm) into a structured report. Output ONLY a JSON object, no prose, no code fence.

Keys:
- `incident_type`: "fire" | "unknown" | "none"
- `severity`: null, or {"value": "low"|"medium"|"high", "confidence": 0-1}
- `headcount`: null, or {"value": int>=0, "qualifier": "possibly_trapped"|"confirmed_trapped"|"all_out"|"unknown", "confidence": 0-1}
- `event_time`: null, or {"tick": int>=0, "confidence": 0-1}
- `notes`: short string ("" if none)

Do NOT output contact_id or reported_location; those come from the envelope.

## incident_type (most important - decide by the words present)

- `fire`: the text states a fire, flames, or a building burning/ablaze - "there's a fire", "on fire", "flames", "open flames", "flames out the window", "it's burning", "well alight", an explosion with flames. Panic, vagueness, or few details do NOT downgrade a stated fire; a panicked caller who mentions flames is still reporting `fire`.
- `none`: the caller DENIES a fire - "no fire", "not a fire", "nothing on fire", "nothing serious" - or reports a non-fire matter: BBQ/steam/toast/vent smoke, a party, noise, a leak, a stuck cat, a lockout, a dispute.
- `unknown`: a possible fire NOT confirmed by any fire/flame word - smoke alone, a burning SMELL alone, an alarm sounding with no flames, "might be nothing", "hard to say", "can't tell".

Rule of thumb: flame/fire words -> fire; an explicit denial or clearly non-fire request -> none; otherwise smoke/smell/alarm/ambiguity -> unknown. Absence of the word "fire" is not a denial.

## severity (fire/unknown only; null for none and for bare alarms)

- high: "serious", "spreading fast", "getting worse quickly", "really bad", "it looks bad", huge, explosion, or people trapped.
- medium: a real but limited-sounding fire, mixed cues.
- low: "small", "minor", "just a bit".
Panicked callers exaggerate: drop their severity one notch and lower confidence.

## headcount (null unless people are mentioned)

Set value (0 for "empty"/"everyone out") and qualifier: confirmed_trapped (definitely inside), possibly_trapped (maybe inside/unaccounted), all_out (everyone evacuated, value 0), unknown (a count given but status unclear). Vague counts -> best integer estimate, low confidence.

## event_time

When you set it, tick is ALWAYS the occurred_at you were given, copied verbatim. Never do arithmetic: "started 6 ticks back" at occurred_at 14 is still tick 14, not 8.

Decide by source_type:

- `automatic_alarm`: always set it. An alarm has been sounding since before it reached you.
- `responder_update`: always null. A responder is talking to you live from the scene.
- `public_call`: set it only if the text carries a time reference - "earlier", "a while ago", "a moment ago", "not long ago", "just now", "as I speak", "started N ticks back". Otherwise null.

Describing a fire in the present tense ("it's burning", "there's a fire", "spreading fast", "smoke's coming out") is NOT a time reference. Vaguer wording lowers confidence, it does not change whether you set the field.

## confidence

Firm first-hand claims high (0.7-0.9); hedged, panicked, or second-hand claims lower (0.3-0.5).

Output valid JSON only.

Eval

No harness ships in starter - build a small grader over your Day 1 accepted-label fixture (the evaluation session manufactured it from contacts_unlabeled.jsonl); the instructor contacts_labeled.jsonl stays sealed until model comparisons. Grade the field values with a tolerance band (severity ±1 band, headcount ±2, event_time tick ±2); the per-field confidence is advisory and carries fixture jitter, so do not match it exactly. Target incident_type >=85%.

Verified: INT-2/INT-3 green against the solution (cd starter && uv run --project ../solution behave features/intake.feature); the reference eval (solution/evals/extraction.py --n 42) scores incident_type 100%, reported_location 100%, severity(±1) 95.2%, headcount(±2) 100%, event_time 100%; live stage2-language@londone episodes resolve at casualties 5 / buildings_lost 1 (intake ~5 K tokens) on all four runs, improving on the greedy baseline of 40 / 1.