diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
index 22edb610..644e5a8e 100644
--- a/.github/workflows/lint.yml
+++ b/.github/workflows/lint.yml
@@ -4,7 +4,7 @@ on:
push:
branches: [main,development]
pull_request:
- branches: [main,development]
+ branches: [main, development, 'development-*']
jobs:
lint:
@@ -23,7 +23,7 @@ jobs:
uses: astral-sh/setup-uv@v6
- name: Install linter
- run: uv pip install --system ruff
+ run: uv pip install --system ruff==0.16.1
- name: Run linter
run: |
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 281b6b16..af033e0f 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -9,7 +9,7 @@ on:
- 'alembic.ini'
- '.github/workflows/tests.yml'
pull_request:
- branches: [main,development]
+ branches: [main, development, 'development-*']
paths:
- '**.py'
- 'requirements.txt'
diff --git a/Makefile b/Makefile
index fd8a6947..6f45c119 100644
--- a/Makefile
+++ b/Makefile
@@ -34,6 +34,7 @@ help:
@echo "make docs - Serve interactive API docs from the OpenAPI contract"
@echo "make migrate - Run pending Alembic migrations"
@echo "make migration - Generate new migration (msg='description')"
+ @echo "make generate-contract-models - Regenerate Pydantic models from the incident contract"
@echo "make clean - Stop containers (preserves volumes)"
@echo "make super-clean - [CAUTION] Stop containers, delete volumes, prune Docker"
@@ -114,7 +115,11 @@ migrate:
@$(COMPOSE) exec -T app alembic upgrade head
migration:
- @$(COMPOSE) exec -T app alembic revision --autogenerate -m "$(msg)"
+ @test -n "$(rev)" || { echo "rev is required, e.g. make migration rev=003 msg=\"...\""; exit 1; }
+ @$(COMPOSE) exec -T app alembic revision --autogenerate --rev-id "$(rev)" -m "$(msg)"
+
+generate-contract-models:
+ @$(COMPOSE) exec -T app sh -c "uv pip install --system -r requirements-dev.txt && python3 scripts/generate_contract_models.py"
clean:
@$(COMPOSE) down
diff --git a/alembic/versions/003_move_incident_contract_onto_incident_row.py b/alembic/versions/003_move_incident_contract_onto_incident_row.py
new file mode 100644
index 00000000..f2793670
--- /dev/null
+++ b/alembic/versions/003_move_incident_contract_onto_incident_row.py
@@ -0,0 +1,73 @@
+"""move incident contract onto incident row
+
+Revision ID: 003
+Revises: 002
+Create Date: 2026-07-30 09:26:53.879663
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+import sqlmodel
+from sqlalchemy.dialects import postgresql
+
+# revision identifiers, used by Alembic.
+revision: str = '003'
+down_revision: Union[str, None] = '002'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.add_column('extractions', sa.Column('partial_result', sa.JSON(), nullable=True))
+ op.drop_column('extractions', 'incident_contract')
+ op.add_column('incidents', sa.Column('incident_contract', sa.JSON(), nullable=True))
+ op.add_column('incidents', sa.Column('incident_category', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
+ op.add_column('incidents', sa.Column('incident_datetime', sa.DateTime(), nullable=True))
+ op.add_column('incidents', sa.Column('city', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
+ op.add_column('incidents', sa.Column('state', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
+ op.add_column('incidents', sa.Column('country', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
+ op.add_column('incidents', sa.Column('civilian_injuries', sa.Integer(), nullable=True))
+ op.add_column('incidents', sa.Column('civilian_fatalities', sa.Integer(), nullable=True))
+ op.add_column('incidents', sa.Column('responder_injuries', sa.Integer(), nullable=True))
+ op.add_column('incidents', sa.Column('responder_fatalities', sa.Integer(), nullable=True))
+ op.add_column('incidents', sa.Column('people_rescued', sa.Integer(), nullable=True))
+ op.add_column('incidents', sa.Column('people_evacuated', sa.Integer(), nullable=True))
+ op.add_column('incidents', sa.Column('structures_destroyed', sa.Integer(), nullable=True))
+ op.add_column('incidents', sa.Column('area_burned_ha', sa.Float(), nullable=True))
+ op.add_column('incidents', sa.Column('total_loss_amount', sa.Float(), nullable=True))
+ op.add_column('incidents', sa.Column('total_loss_currency', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
+ op.add_column('incidents', sa.Column('call_to_arrival_seconds', sa.Integer(), nullable=True))
+ op.add_column('incidents', sa.Column('turnout_seconds_first_unit', sa.Integer(), nullable=True))
+ op.add_column('incidents', sa.Column('travel_seconds_first_unit', sa.Integer(), nullable=True))
+ op.add_column('incidents', sa.Column('on_scene_duration_seconds', sa.Integer(), nullable=True))
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_column('incidents', 'on_scene_duration_seconds')
+ op.drop_column('incidents', 'travel_seconds_first_unit')
+ op.drop_column('incidents', 'turnout_seconds_first_unit')
+ op.drop_column('incidents', 'call_to_arrival_seconds')
+ op.drop_column('incidents', 'total_loss_currency')
+ op.drop_column('incidents', 'total_loss_amount')
+ op.drop_column('incidents', 'area_burned_ha')
+ op.drop_column('incidents', 'structures_destroyed')
+ op.drop_column('incidents', 'people_evacuated')
+ op.drop_column('incidents', 'people_rescued')
+ op.drop_column('incidents', 'responder_fatalities')
+ op.drop_column('incidents', 'responder_injuries')
+ op.drop_column('incidents', 'civilian_fatalities')
+ op.drop_column('incidents', 'civilian_injuries')
+ op.drop_column('incidents', 'country')
+ op.drop_column('incidents', 'state')
+ op.drop_column('incidents', 'city')
+ op.drop_column('incidents', 'incident_datetime')
+ op.drop_column('incidents', 'incident_category')
+ op.drop_column('incidents', 'incident_contract')
+ op.add_column('extractions', sa.Column('incident_contract', postgresql.JSON(astext_type=sa.Text()), autoincrement=False, nullable=True))
+ op.drop_column('extractions', 'partial_result')
+ # ### end Alembic commands ###
diff --git a/alembic/versions/004_form_templates.py b/alembic/versions/004_form_templates.py
new file mode 100644
index 00000000..ef23be52
--- /dev/null
+++ b/alembic/versions/004_form_templates.py
@@ -0,0 +1,78 @@
+"""form templates registry and pdf upload drafts
+
+Revision ID: 004
+Revises: 003
+Create Date: 2026-08-10
+
+Two tables, both new in the contract Layer 6 template work.
+
+`form_templates` is the registry, keyed by `form_type` and distinct from the
+legacy `template` table (int PK + uploaded PDF). Its `fields` JSON column holds
+the TemplateField list, each with a nested `layout`. `form_type` stays a plain
+VARCHAR rather than a Postgres enum, because jurisdictions register their own
+form types and those are not part of the built-in FormType enum.
+
+`template_uploads` holds the drafts behind the PDF authoring flow: the stored
+blank PDF, its page geometry, and the fields commonforms detected. Rows here
+are working state, not templates. Registering a template copies the edited
+fields across and keeps only the `pdf_template_ref` pointing back.
+
+Both JSON columns use sa.JSON for consistency with migrations 001-003 and for
+the SQLite test harness.
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+import sqlmodel
+
+
+# revision identifiers, used by Alembic.
+revision: str = '004'
+down_revision: Union[str, None] = '003'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.create_table('form_templates',
+ sa.Column('template_id', sa.Uuid(), nullable=False),
+ sa.Column('form_type', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
+ sa.Column('display_name', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
+ sa.Column('jurisdiction', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
+ sa.Column('agency_type', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
+ sa.Column('fields', sa.JSON(), nullable=False),
+ sa.Column('source_standard', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
+ sa.Column('pdf_template_ref', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
+ sa.Column('version', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
+ sa.Column('status', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
+ sa.Column('created_at', sa.DateTime(), nullable=False),
+ sa.Column('updated_at', sa.DateTime(), nullable=False),
+ sa.PrimaryKeyConstraint('template_id')
+ )
+ op.create_index(op.f('ix_form_templates_form_type'), 'form_templates', ['form_type'], unique=True)
+ op.create_table('template_uploads',
+ sa.Column('upload_id', sa.Uuid(), nullable=False),
+ sa.Column('status', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
+ sa.Column('pdf_path', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
+ sa.Column('pdf_template_ref', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
+ sa.Column('original_filename', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
+ sa.Column('page_count', sa.Integer(), nullable=False),
+ sa.Column('pages', sa.JSON(), nullable=False),
+ sa.Column('detected_fields', sa.JSON(), nullable=True),
+ sa.Column('detection_error', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
+ sa.Column('job_id', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
+ sa.Column('created_at', sa.DateTime(), nullable=False),
+ sa.Column('updated_at', sa.DateTime(), nullable=False),
+ sa.PrimaryKeyConstraint('upload_id')
+ )
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_table('template_uploads')
+ op.drop_index(op.f('ix_form_templates_form_type'), table_name='form_templates')
+ op.drop_table('form_templates')
+ # ### end Alembic commands ###
diff --git a/alembic/versions/005_form_incident_template_batch.py b/alembic/versions/005_form_incident_template_batch.py
new file mode 100644
index 00000000..24603214
--- /dev/null
+++ b/alembic/versions/005_form_incident_template_batch.py
@@ -0,0 +1,59 @@
+"""forms: key by template, require incident, group by batch, drop extract_id
+
+Revision ID: 005
+Revises: 004
+Create Date: 2026-08-17
+
+Form generation (contract Layer 3, #552) keys a form by which template filled
+it and reaches the extraction through the incident rather than a direct link
+(incidents already FK the extraction, so extract_id was a redundant hop).
+Batching is a grouping key only — batch_id has no Batch table, batch status is
+derived on the fly from the Form rows that share an id.
+
+No route or repository writes a v1 Form row yet (form generation itself is
+still unimplemented), so the forms table is empty in every environment and
+the NOT NULL adds below are safe — there is no existing data to backfill.
+
+FK constraints are named explicitly, matching Postgres' own default
+`
__fkey` pattern, so downgrade can drop them by name. The same
+naming_convention is handed to batch_alter_table so SQLite's reflect-and-
+recreate path (needed for the incident_id nullable flip and the extract_id
+drop, neither of which SQLite can ALTER in place) assigns identical names.
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision: str = '005'
+down_revision: Union[str, None] = '004'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+NAMING_CONVENTION = {"fk": "%(table_name)s_%(column_0_name)s_fkey"}
+
+
+def upgrade() -> None:
+ with op.batch_alter_table('forms', naming_convention=NAMING_CONVENTION) as batch_op:
+ batch_op.add_column(sa.Column('template_id', sa.Uuid(), nullable=False))
+ batch_op.create_foreign_key(
+ 'forms_template_id_fkey', 'form_templates', ['template_id'], ['template_id']
+ )
+ batch_op.add_column(sa.Column('batch_id', sa.Uuid(), nullable=True))
+ batch_op.drop_constraint('forms_extract_id_fkey', type_='foreignkey')
+ batch_op.drop_column('extract_id')
+ batch_op.alter_column('incident_id', existing_type=sa.Uuid(), nullable=False)
+
+
+def downgrade() -> None:
+ with op.batch_alter_table('forms', naming_convention=NAMING_CONVENTION) as batch_op:
+ batch_op.alter_column('incident_id', existing_type=sa.Uuid(), nullable=True)
+ batch_op.add_column(sa.Column('extract_id', sa.Uuid(), nullable=False))
+ batch_op.create_foreign_key(
+ 'forms_extract_id_fkey', 'extractions', ['extract_id'], ['extract_id']
+ )
+ batch_op.drop_constraint('forms_template_id_fkey', type_='foreignkey')
+ batch_op.drop_column('template_id')
+ batch_op.drop_column('batch_id')
diff --git a/alembic/versions/006_incidents_crud_indexes.py b/alembic/versions/006_incidents_crud_indexes.py
new file mode 100644
index 00000000..53b58fe6
--- /dev/null
+++ b/alembic/versions/006_incidents_crud_indexes.py
@@ -0,0 +1,35 @@
+"""incidents crud indexes
+
+Revision ID: 006
+Revises: 005
+Create Date: 2026-08-22 06:49:34.422095
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+import sqlmodel
+
+
+# revision identifiers, used by Alembic.
+revision: str = '006'
+down_revision: Union[str, None] = '005'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.create_index('ix_incidents_live_datetime', 'incidents', ['deleted_at', 'incident_datetime'], unique=False)
+ op.create_index('ix_incidents_number_live', 'incidents', ['incident_number'], unique=True, postgresql_where=sa.text('incident_number IS NOT NULL AND deleted_at IS NULL'), sqlite_where=sa.text('incident_number IS NOT NULL AND deleted_at IS NULL'))
+ op.drop_column('incidents', 'incident_date')
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.add_column('incidents', sa.Column('incident_date', sa.DATE(), autoincrement=False, nullable=True))
+ op.drop_index('ix_incidents_number_live', table_name='incidents', postgresql_where=sa.text('incident_number IS NOT NULL AND deleted_at IS NULL'), sqlite_where=sa.text('incident_number IS NOT NULL AND deleted_at IS NULL'))
+ op.drop_index('ix_incidents_live_datetime', table_name='incidents')
+ # ### end Alembic commands ###
diff --git a/app/api/router.py b/app/api/router.py
index cc59847a..d19767f9 100644
--- a/app/api/router.py
+++ b/app/api/router.py
@@ -1,13 +1,33 @@
from fastapi import APIRouter
-from app.api.routes import forms, input, jobs, system, templates, weather, zipcode
+from app.api.routes import (
+ extraction,
+ form_generation,
+ forms,
+ form_templates,
+ incidents,
+ input,
+ jobs,
+ system,
+ weather,
+ zipcode,
+)
from app.core.config import API_PREFIX
api_router = APIRouter()
-api_router.include_router(templates.router, prefix=API_PREFIX)
+api_router.include_router(form_templates.router, prefix=API_PREFIX)
api_router.include_router(forms.router, prefix=API_PREFIX)
+# v1 form generation — same "/forms" prefix as the legacy router above, kept
+# in a separate file/router rather than added to forms.py. Included AFTER
+# `forms` on purpose: the legacy router's literal GET paths (/forms/models,
+# /forms/submissions, ...) must be matched before this router's catch-all
+# GET /forms/{form_id}, same reasoning form_templates.py uses for /pdf vs
+# /{template_id} — otherwise "models"/"submissions" would be read as a form_id.
+api_router.include_router(form_generation.router, prefix=API_PREFIX)
api_router.include_router(system.router, prefix=API_PREFIX)
api_router.include_router(jobs.router, prefix=API_PREFIX)
api_router.include_router(weather.router, prefix=API_PREFIX)
api_router.include_router(zipcode.router, prefix=API_PREFIX)
-api_router.include_router(input.router, prefix=API_PREFIX)
\ No newline at end of file
+api_router.include_router(input.router, prefix=API_PREFIX)
+api_router.include_router(extraction.router, prefix=API_PREFIX)
+api_router.include_router(incidents.router, prefix=API_PREFIX)
\ No newline at end of file
diff --git a/app/api/routes/__init__.py b/app/api/routes/__init__.py
index 2264aee0..17c68a8e 100644
--- a/app/api/routes/__init__.py
+++ b/app/api/routes/__init__.py
@@ -1,3 +1,3 @@
-from . import templates, forms
+from . import form_templates, forms
-__all__ = ["templates", "forms"]
+__all__ = ["form_templates", "forms"]
diff --git a/app/api/routes/extraction.py b/app/api/routes/extraction.py
new file mode 100644
index 00000000..28f26eab
--- /dev/null
+++ b/app/api/routes/extraction.py
@@ -0,0 +1,198 @@
+from uuid import UUID
+
+from fastapi import APIRouter, Body, Depends
+from sqlmodel import Session
+
+from app.api.deps import get_db
+from app.api.schemas.enums import ExtractionStatus, InputStatus
+from app.api.schemas.extraction import (
+ ExtractionCompleted,
+ ExtractionJobResponse,
+ ExtractionProcessing,
+ ExtractionRequest,
+ ReadinessMatrix,
+ ValidationRequest,
+ ValidationResult,
+)
+from app.api.schemas.incident_contract import IncidentContract
+from app.core.config import (
+ ESTIMATED_EXTRACTION_SECONDS,
+ EXTRACTION_ALLOW_RERUN,
+ EXTRACTION_POLL_INTERVAL_SECONDS,
+)
+from app.core.errors.base import AppError
+from app.db.repositories import (
+ get_extraction,
+ get_extraction_by_input,
+ get_incident_by_extract,
+ get_input,
+ list_form_templates,
+)
+from app.models import Extraction, Incident
+from app.services.extraction.service import ExtractionService
+from app.services.extraction_readiness import readiness_matrix, validate_template
+from app.services.extraction_review import ExtractionReviewService, load_for_review
+from app.services.form_templates import require_template
+from app.services import llm
+
+router = APIRouter(prefix="/extract", tags=["extraction"])
+
+MERGE_PATCH_MEDIA_TYPE = "application/merge-patch+json"
+
+
+def _completed_response(extraction: Extraction, incident: Incident | None) -> ExtractionCompleted:
+ """The completed shape, with the contract read from the incident row."""
+ contract = IncidentContract.model_validate(
+ (incident.incident_contract if incident else None) or {}
+ )
+ return ExtractionCompleted(
+ extract_id=extraction.extract_id,
+ input_id=extraction.input_id,
+ incident_id=incident.incident_id if incident else None,
+ status="completed",
+ incident_contract=contract,
+ completed_at=extraction.completed_at,
+ model_used=extraction.model_used,
+ processing_time_seconds=extraction.processing_time_seconds,
+ corrections=extraction.corrections,
+ )
+
+
+def _load_extraction(db: Session, extract_id: UUID) -> Extraction:
+ extraction = get_extraction(db, extract_id)
+ if extraction is None:
+ raise AppError(
+ f"Extraction with ID {extract_id} not found",
+ status_code=404,
+ error_code="EXTRACT_NOT_FOUND",
+ )
+ return extraction
+
+
+@router.post("/{input_id}", response_model=ExtractionJobResponse, status_code=202)
+def create_extraction(
+ input_id: UUID,
+ body: ExtractionRequest | None = None,
+ db: Session = Depends(get_db),
+):
+ record = get_input(db, input_id)
+ if record is None:
+ raise AppError(
+ f"Input with ID {input_id} not found",
+ status_code=404,
+ error_code="INPUT_NOT_FOUND",
+ )
+
+ if record.status != InputStatus.ready:
+ raise AppError(
+ f"Input is in '{record.status}' state. Wait until status is 'ready'.",
+ status_code=409,
+ error_code="INPUT_NOT_READY",
+ detail={"current_status": record.status},
+ )
+
+ existing = get_extraction_by_input(db, input_id)
+ if existing is not None and not EXTRACTION_ALLOW_RERUN:
+ raise AppError(
+ "An extraction already exists for this input",
+ status_code=409,
+ error_code="EXTRACTION_EXISTS",
+ detail={"existing_extract_id": str(existing.extract_id)},
+ )
+
+ provider = llm.health()
+ if provider.status == "unhealthy":
+ raise AppError(
+ f"The {provider.label} LLM service is not available",
+ status_code=503,
+ error_code="LLM_UNAVAILABLE",
+ detail={"provider": provider.provider, "reason": provider.detail},
+ )
+
+ svc = ExtractionService()
+ extraction, job = svc.start_extraction(
+ db,
+ input_id,
+ model_override=body.model_override if body else None,
+ defaults=body.defaults if body else None,
+ hints=body.extraction_hints if body else None,
+ )
+
+ return ExtractionJobResponse(
+ extract_id=extraction.extract_id,
+ input_id=input_id,
+ job_id=job.job_id,
+ status=extraction.status,
+ queued_at=extraction.created_at,
+ estimated_seconds=ESTIMATED_EXTRACTION_SECONDS,
+ poll_url=f"/api/v1/extract/{extraction.extract_id}",
+ )
+
+
+@router.get("/{extract_id}", response_model=ExtractionCompleted | ExtractionProcessing)
+def get_extraction_result(extract_id: UUID, db: Session = Depends(get_db)):
+ extraction = _load_extraction(db, extract_id)
+
+ if extraction.status == ExtractionStatus.completed:
+ return _completed_response(extraction, get_incident_by_extract(db, extract_id))
+
+ retry_after = (
+ EXTRACTION_POLL_INTERVAL_SECONDS
+ if extraction.status == ExtractionStatus.processing
+ else None
+ )
+ partial = (
+ IncidentContract.model_validate(extraction.partial_result)
+ if extraction.partial_result
+ else None
+ )
+ return ExtractionProcessing(
+ extract_id=extraction.extract_id,
+ input_id=extraction.input_id,
+ status=extraction.status,
+ started_at=extraction.started_at,
+ retry_after_seconds=retry_after,
+ error_type=extraction.error_type,
+ error_detail=extraction.error_detail,
+ partial_result=partial,
+ )
+
+
+@router.patch("/{extract_id}", response_model=ExtractionCompleted)
+def update_extraction(
+ extract_id: UUID,
+ patch: dict = Body(
+ ...,
+ media_type=MERGE_PATCH_MEDIA_TYPE,
+ description="JSON Merge Patch (RFC 7396) shaped like the incident contract. "
+ "Only send the fields that changed; a null deletes a field.",
+ ),
+ db: Session = Depends(get_db),
+):
+ extraction = _load_extraction(db, extract_id)
+ incident = load_for_review(extraction, get_incident_by_extract(db, extract_id))
+
+ extraction, incident = ExtractionReviewService().apply_patch(
+ db, extraction, incident, patch
+ )
+ return _completed_response(extraction, incident)
+
+
+@router.get("/{extract_id}/readiness", response_model=ReadinessMatrix)
+def get_readiness(extract_id: UUID, db: Session = Depends(get_db)):
+ extraction = _load_extraction(db, extract_id)
+ incident = load_for_review(extraction, get_incident_by_extract(db, extract_id))
+
+ return readiness_matrix(extraction, incident, list_form_templates(db))
+
+
+@router.post("/{extract_id}/validate", response_model=ValidationResult)
+def validate_extraction(
+ extract_id: UUID,
+ body: ValidationRequest,
+ db: Session = Depends(get_db),
+):
+ extraction = _load_extraction(db, extract_id)
+ incident = load_for_review(extraction, get_incident_by_extract(db, extract_id))
+
+ return validate_template(extraction, incident, require_template(db, body.template_id))
diff --git a/app/api/routes/form_generation.py b/app/api/routes/form_generation.py
new file mode 100644
index 00000000..fcfe1a91
--- /dev/null
+++ b/app/api/routes/form_generation.py
@@ -0,0 +1,226 @@
+"""Contract Layer 3 form generation endpoints (contracts/path/forms.yaml).
+
+Serves POST /forms/generate and the retrieval endpoints at /api/v1/forms,
+backed by the v1 Form model. Handlers are thin; business logic lives in
+app/services/form_generation.py (write path) and app/services/form_fill_worker.py
+(the Celery-dispatched fill). Distinct from the legacy prototype routes in
+app/api/routes/forms.py (int template_id, no incident/batch concept), which
+stay mounted at the same "/forms" prefix unchanged.
+
+/batch/{batch_id} is declared before /{form_id} for the same reason
+form_templates.py declares /pdf before /{template_id}: FastAPI matches paths
+in declaration order, so the literal segment has to come first or "batch"
+gets read as a form_id.
+"""
+
+from uuid import UUID
+
+from fastapi import APIRouter, Depends, Response
+from fastapi.responses import FileResponse, JSONResponse
+from sqlmodel import Session
+
+from app.api.deps import get_db
+from app.api.schemas.enums import FormStatus
+from app.api.schemas.form_generation import (
+ BatchFormEntry,
+ BatchGenerateResponse,
+ BatchStatus,
+ FormMappedJson,
+ FormRecord,
+ GenerateFormsRequest,
+ QueuedForm,
+ SkippedForm,
+)
+from app.core.config import (
+ ESTIMATED_FORM_GENERATION_SECONDS,
+ FORM_GENERATION_POLL_INTERVAL_SECONDS,
+)
+from app.core.errors.base import AppError
+from app.db.repositories import get_form, list_forms_by_batch
+from app.services.form_generation import (
+ FormGenerationService,
+ batch_state,
+ batch_zip,
+ download_filename,
+ form_version,
+ resolve_form_pdf,
+)
+
+router = APIRouter(prefix="/forms", tags=["forms"])
+
+
+@router.post("/generate", response_model=BatchGenerateResponse, status_code=202)
+def generate_forms(body: GenerateFormsRequest, db: Session = Depends(get_db)):
+ result = FormGenerationService().start_generation(db, body)
+ return BatchGenerateResponse(
+ batch_id=result.batch_id,
+ incident_id=result.incident_id,
+ forms_queued=[
+ QueuedForm(form_id=f.form_id, template_id=f.template_id, form_type=f.form_type)
+ for f in result.queued
+ ],
+ forms_skipped=[
+ SkippedForm(template_id=s.template_id, form_type=s.form_type, reason=s.reason)
+ for s in result.skipped
+ ],
+ estimated_seconds=ESTIMATED_FORM_GENERATION_SECONDS,
+ poll_url=f"/api/v1/forms/batch/{result.batch_id}",
+ )
+
+
+@router.get("/batch/{batch_id}", response_model=BatchStatus)
+def get_batch_status(batch_id: UUID, db: Session = Depends(get_db)):
+ forms = list_forms_by_batch(db, batch_id)
+ if not forms:
+ raise AppError(f"Batch {batch_id} not found", status_code=404, error_code="BATCH_NOT_FOUND")
+
+ completed = sum(1 for f in forms if f.status == FormStatus.completed)
+ failed = sum(1 for f in forms if f.status == FormStatus.failed)
+ total = len(forms)
+ status = batch_state(forms)
+
+ return BatchStatus(
+ batch_id=batch_id,
+ status=status,
+ total=total,
+ completed=completed,
+ failed=failed,
+ forms=[
+ BatchFormEntry(
+ form_id=f.form_id,
+ template_id=f.template_id,
+ form_type=f.form_type,
+ status=f.status,
+ )
+ for f in forms
+ ],
+ # Only offered once there is something to bundle: a batch where every
+ # form failed has no PDFs behind the link.
+ download_url=(
+ f"/api/v1/forms/batch/{batch_id}/download" if status == "completed" else None
+ ),
+ )
+
+
+@router.get("/batch/{batch_id}/download")
+def download_batch_zip(batch_id: UUID, db: Session = Depends(get_db)):
+ forms = list_forms_by_batch(db, batch_id)
+ if not forms:
+ raise AppError(f"Batch {batch_id} not found", status_code=404, error_code="BATCH_NOT_FOUND")
+
+ status = batch_state(forms)
+ if status == "processing":
+ return JSONResponse(
+ status_code=202,
+ content={
+ "message": "Batch is still generating",
+ "status": status,
+ "retry_after_seconds": FORM_GENERATION_POLL_INTERVAL_SECONDS,
+ },
+ )
+
+ if status == "failed":
+ raise AppError(
+ f"Every form in batch {batch_id} failed to generate",
+ status_code=500,
+ error_code="FORM_GENERATION_FAILED",
+ detail={"reason": "No PDFs were produced for this batch"},
+ )
+
+ archive, filename = batch_zip(db, batch_id, forms)
+ return Response(
+ content=archive,
+ media_type="application/zip",
+ headers={"Content-Disposition": f'attachment; filename="{filename}"'},
+ )
+
+
+@router.get("/{form_id}", response_model=FormRecord)
+def get_form_record(form_id: UUID, db: Session = Depends(get_db)):
+ form = get_form(db, form_id)
+ if not form:
+ raise AppError(f"Form {form_id} not found", status_code=404, error_code="FORM_NOT_FOUND")
+
+ return FormRecord(
+ form_id=form.form_id,
+ template_id=form.template_id,
+ form_type=form.form_type,
+ status=form.status,
+ incident_id=form.incident_id,
+ batch_id=form.batch_id,
+ created_at=form.created_at,
+ completed_at=form.completed_at,
+ pdf_ready=form.pdf_ready,
+ json_ready=form.json_ready,
+ field_mapping_summary=form.field_mapping_summary,
+ )
+
+
+@router.get("/{form_id}/pdf", response_class=FileResponse)
+def download_form_pdf(form_id: UUID, db: Session = Depends(get_db)):
+ form = get_form(db, form_id)
+ if not form:
+ raise AppError(f"Form {form_id} not found", status_code=404, error_code="FORM_NOT_FOUND")
+
+ if form.status == FormStatus.failed:
+ raise AppError(
+ f"Form {form_id} failed to generate",
+ status_code=500,
+ error_code="PDF_GENERATION_FAILED",
+ detail={"reason": "Form generation failed"},
+ )
+
+ if not form.pdf_ready or not form.pdf_path:
+ return JSONResponse(
+ status_code=202,
+ content={
+ "message": "Form generation is still in progress",
+ "status": form.status,
+ "retry_after_seconds": FORM_GENERATION_POLL_INTERVAL_SECONDS,
+ },
+ )
+
+ path = resolve_form_pdf(form)
+ if path is None:
+ raise AppError(f"Form {form_id} not found", status_code=404, error_code="FORM_NOT_FOUND")
+
+ return FileResponse(
+ path, media_type="application/pdf", filename=download_filename(db, form)
+ )
+
+
+@router.get("/{form_id}/json", response_model=FormMappedJson)
+def get_form_json(form_id: UUID, db: Session = Depends(get_db)):
+ form = get_form(db, form_id)
+ if not form:
+ raise AppError(f"Form {form_id} not found", status_code=404, error_code="FORM_NOT_FOUND")
+
+ # Same three answers as /pdf, so a client polling both after one generate
+ # call reads them the same way: 500 once the fill failed, 202 while it is
+ # still running, the payload once it is there.
+ if form.status == FormStatus.failed:
+ raise AppError(
+ f"Form {form_id} failed to generate",
+ status_code=500,
+ error_code="FORM_GENERATION_FAILED",
+ detail={"reason": "Form generation failed"},
+ )
+
+ if not form.json_ready or form.json_data is None:
+ return JSONResponse(
+ status_code=202,
+ content={
+ "message": "Form generation is still in progress",
+ "status": form.status,
+ "retry_after_seconds": FORM_GENERATION_POLL_INTERVAL_SECONDS,
+ },
+ )
+
+ return FormMappedJson(
+ form_type=form.form_type,
+ form_version=form_version(db, form),
+ form_id=form.form_id,
+ template_id=form.template_id,
+ incident_id=form.incident_id,
+ agency_fields=form.json_data,
+ )
diff --git a/app/api/routes/form_templates.py b/app/api/routes/form_templates.py
new file mode 100644
index 00000000..eec93076
--- /dev/null
+++ b/app/api/routes/form_templates.py
@@ -0,0 +1,127 @@
+"""Contract Layer 6 template registry endpoints (contracts/path/templates.yaml).
+
+Serves the form-template registry at /api/v1/templates, backed by the
+UUID-keyed `FormTemplate` model, plus the PDF-authoring flow that feeds it:
+upload a blank PDF, poll the detection draft, register the edited fields.
+Handlers are thin, business logic lives in app/services/form_templates.py. The
+legacy prototype template routes (upload / create / make-fillable / preview /
+delete) were removed in the contract migration; the legacy int-PK `Template`
+model survives only as the lookup target of the fill pipeline (forms.py /
+jobs.py / tasks/fill.py).
+"""
+
+from pathlib import Path
+from uuid import UUID
+
+from fastapi import APIRouter, Depends, File, Form, Query, UploadFile
+from fastapi.responses import FileResponse
+from sqlmodel import Session
+
+from app.api.deps import get_db
+from app.api.schemas.templates import (
+ CreateTemplateRequest,
+ TemplateDetail,
+ TemplateDraft,
+ TemplateDraftAccepted,
+ TemplateFieldsResponse,
+ TemplateSummary,
+)
+from app.core.config import MAX_TEMPLATE_PDF_BYTES
+from app.core.errors.base import AppError
+from app.services import form_templates as service
+
+router = APIRouter(prefix="/templates", tags=["templates"])
+
+_PDF_MAGIC = b"%PDF-"
+
+
+def _reject_if_too_large(size: int | None) -> None:
+ """Guard the 50MB cap. Checked once on the declared size before the body is
+ read into memory, and again on what actually arrived."""
+ if size is None or size <= MAX_TEMPLATE_PDF_BYTES:
+ return
+ raise AppError(
+ "PDF exceeds maximum size of 50MB",
+ status_code=413,
+ error_code="FILE_TOO_LARGE",
+ detail={
+ "max_size_bytes": MAX_TEMPLATE_PDF_BYTES,
+ "received_size_bytes": size,
+ },
+ )
+
+
+@router.get("", response_model=list[TemplateSummary])
+def list_templates(db: Session = Depends(get_db)):
+ return service.list_templates(db)
+
+
+@router.post("", response_model=TemplateDetail, status_code=201)
+def create_template(body: CreateTemplateRequest, db: Session = Depends(get_db)):
+ return service.create_template(db, body)
+
+
+# The two /pdf routes are declared before /{template_id} on purpose. FastAPI
+# matches in declaration order, so the literal path has to come first or "pdf"
+# gets read as a template id.
+@router.post("/pdf", response_model=TemplateDraftAccepted, status_code=202)
+def upload_template_pdf(
+ pdf_file: UploadFile = File(...),
+ detect_fields: bool = Form(default=True),
+ db: Session = Depends(get_db),
+):
+ filename = pdf_file.filename or ""
+
+ _reject_if_too_large(pdf_file.size)
+
+ content = pdf_file.file.read()
+ if not content:
+ raise AppError(
+ "No PDF file was uploaded",
+ status_code=400,
+ error_code="MISSING_FILE",
+ )
+ _reject_if_too_large(len(content))
+ # Trust the bytes, not the extension or the declared content type.
+ if not content.startswith(_PDF_MAGIC):
+ raise AppError(
+ "Uploaded file is not a PDF",
+ status_code=415,
+ error_code="UNSUPPORTED_FORMAT",
+ detail={"accepted_formats": ["pdf"]},
+ )
+
+ upload, job = service.store_upload(db, content, filename or None, detect_fields)
+ return service.draft_response(upload, job)
+
+
+@router.get("/pdf/{upload_id}", response_model=TemplateDraft)
+def get_template_draft(upload_id: UUID, db: Session = Depends(get_db)):
+ return service.get_draft(db, upload_id)
+
+
+@router.get("/{template_id}", response_model=TemplateDetail)
+def get_template(template_id: UUID, db: Session = Depends(get_db)):
+ return service.get_template(db, template_id)
+
+
+@router.put("/{template_id}", response_model=TemplateDetail)
+def replace_template(
+ template_id: UUID, body: CreateTemplateRequest, db: Session = Depends(get_db)
+):
+ return service.replace_template(db, template_id, body)
+
+
+@router.get("/{template_id}/fields", response_model=TemplateFieldsResponse)
+def get_template_fields(
+ template_id: UUID,
+ required_only: bool = Query(False, description="Return only required fields"),
+ db: Session = Depends(get_db),
+):
+ return service.get_template_fields(db, template_id, required_only)
+
+
+@router.get("/{template_id}/pdf", response_class=FileResponse)
+def download_template_pdf(template_id: UUID, db: Session = Depends(get_db)):
+ path: Path = service.resolve_template_pdf(db, template_id)
+ return FileResponse(path, media_type="application/pdf", filename=path.name)
diff --git a/app/api/routes/forms.py b/app/api/routes/forms.py
index be160291..ef6e8910 100644
--- a/app/api/routes/forms.py
+++ b/app/api/routes/forms.py
@@ -1,6 +1,5 @@
from datetime import datetime, timedelta, timezone
from pathlib import Path
-import requests
from fastapi import APIRouter, Depends, File, UploadFile, Query
from sqlmodel import Session, select
@@ -11,11 +10,12 @@
ModelsResponse,
TranscriptionResponse,
)
-from app.core.config import OLLAMA_HOST, OLLAMA_MODEL, BASE_DIR, RETENTION_PERIOD_DAYS
+from app.core.config import BASE_DIR, RETENTION_PERIOD_DAYS
from app.services.whisper import call_whisper_asr
from app.core.errors.base import AppError
from app.db.repositories import create_form, get_template, get_form_submission, delete_form_submission
from app.models import FormSubmission, Template
+from app.services import llm
from app.services.controller import Controller
PROJECT_ROOT = BASE_DIR
@@ -66,24 +66,14 @@ def fill_form(form: FormFill, db: Session = Depends(get_db)):
@router.get("/models", response_model=ModelsResponse)
def list_models():
- """List the Whisper-independent extraction models available in the local
- Ollama instance, plus the configured default. Used by the Fill Form UI's
- model picker. Falls back to just the default if Ollama is unreachable."""
- default_model = OLLAMA_MODEL
+ """Models the configured provider will serve, for the Fill Form model picker.
- models: list[str] = []
- try:
- response = requests.get(f"{OLLAMA_HOST}/api/tags", timeout=10)
- response.raise_for_status()
- models = [m["name"] for m in response.json().get("models", []) if m.get("name")]
- except requests.exceptions.RequestException:
- models = []
-
- # Always surface the configured default, even if Ollama hasn't pulled it yet.
- if default_model not in models:
- models.insert(0, default_model)
-
- return ModelsResponse(models=models, default=default_model)
+ Falls back to the configured default alone when the provider will not list
+ them.
+ """
+ available = llm.list_models()
+ default = next((m.name for m in available if m.default), llm.get_settings().model)
+ return ModelsResponse(models=[m.name for m in available], default=default)
@router.post("/transcribe", response_model=TranscriptionResponse)
diff --git a/app/api/routes/incidents.py b/app/api/routes/incidents.py
new file mode 100644
index 00000000..15403080
--- /dev/null
+++ b/app/api/routes/incidents.py
@@ -0,0 +1,165 @@
+"""Contract Layer 4 incident endpoints (contracts/path/incidents.yaml).
+
+Handlers are thin; the logic lives in app/services/incident_crud.py. The one
+job kept here is assembling the response shapes, since the DB stores the
+promoted analytics as flat columns while the contract nests them under
+`analytics`.
+"""
+
+from datetime import date
+from math import ceil
+from uuid import UUID
+
+from fastapi import APIRouter, Depends, Query
+from sqlmodel import Session
+
+from app.api.deps import get_db
+from app.api.schemas.common import Pagination
+from app.api.schemas.enums import IncidentCategory, ReportStatus
+from app.api.schemas.form_generation import FormRecord
+from app.api.schemas.incidents import (
+ CreateIncidentRequest,
+ DeleteIncidentResponse,
+ GeneratedForm,
+ IncidentAnalytics,
+ IncidentListItem,
+ IncidentListResponse,
+ IncidentRecord,
+ IncidentRecordFull,
+ SubmissionLogEntry,
+ UpdateIncidentRequest,
+)
+from app.api.schemas.incident_contract import IncidentContract
+from app.models import Form, Incident
+from app.services.incident_crud import IncidentService
+
+router = APIRouter(prefix="/incidents", tags=["incidents"])
+
+
+def _form_summary(form: Form) -> GeneratedForm:
+ return GeneratedForm(form_id=form.form_id, form_type=form.form_type, status=form.status)
+
+
+def _record(incident: Incident, forms: list[Form]) -> IncidentRecord:
+ """The incident row as the contract's IncidentRecord."""
+ return IncidentRecord(
+ incident_id=incident.incident_id,
+ extract_id=incident.extract_id,
+ incident_number=incident.incident_number,
+ status=incident.status,
+ incident_name=incident.incident_name,
+ incident_type=incident.incident_type,
+ incident_category=incident.incident_category,
+ incident_datetime=incident.incident_datetime,
+ analytics=IncidentAnalytics.model_validate(incident),
+ forms_generated=[_form_summary(f) for f in forms],
+ tags=incident.tags or [],
+ notes=incident.notes,
+ created_at=incident.created_at,
+ updated_at=incident.updated_at,
+ deleted_at=incident.deleted_at,
+ )
+
+
+def _submission_log(contract: dict | None) -> list[SubmissionLogEntry]:
+ """Submissions read out of the contract document.
+
+ Empty until the submission layer exists; nothing writes it today.
+ """
+ entries = (contract or {}).get("submission_log")
+ if not isinstance(entries, list):
+ return []
+ return [SubmissionLogEntry.model_validate(e) for e in entries if isinstance(e, dict)]
+
+
+@router.post("", response_model=IncidentRecord, status_code=201)
+def create_incident(body: CreateIncidentRequest, db: Session = Depends(get_db)):
+ service = IncidentService()
+ incident = service.finalize(db, body)
+ return _record(incident, service.forms(db, incident.incident_id))
+
+
+@router.get("", response_model=IncidentListResponse)
+def list_incidents(
+ db: Session = Depends(get_db),
+ date_from: date | None = Query(default=None),
+ date_to: date | None = Query(default=None),
+ incident_category: IncidentCategory | None = Query(default=None),
+ status: ReportStatus | None = Query(default=None),
+ page: int = Query(default=1, ge=1),
+ per_page: int = Query(default=20, ge=1, le=100),
+ sort: str = Query(default="date_desc", pattern="^(date_asc|date_desc)$"),
+):
+ rows, counts, total = IncidentService().list_page(
+ db,
+ date_from=date_from,
+ date_to=date_to,
+ incident_category=incident_category,
+ status=status,
+ page=page,
+ per_page=per_page,
+ sort=sort,
+ )
+ total_pages = ceil(total / per_page) if total else 0
+ return IncidentListResponse(
+ data=[
+ IncidentListItem(
+ incident_id=row.incident_id,
+ incident_number=row.incident_number,
+ status=row.status,
+ incident_name=row.incident_name,
+ incident_type=row.incident_type,
+ incident_category=row.incident_category,
+ incident_datetime=row.incident_datetime,
+ city=row.city,
+ country=row.country,
+ forms_count=counts.get(row.incident_id, 0),
+ created_at=row.created_at,
+ )
+ for row in rows
+ ],
+ pagination=Pagination(
+ total=total,
+ page=page,
+ per_page=per_page,
+ total_pages=total_pages,
+ has_next=page < total_pages,
+ has_prev=page > 1,
+ ),
+ )
+
+
+@router.get("/{incident_id}", response_model=IncidentRecordFull)
+def get_incident(incident_id: UUID, db: Session = Depends(get_db)):
+ service = IncidentService()
+ incident = service.get(db, incident_id)
+ forms = service.forms(db, incident_id)
+ base = _record(incident, forms)
+ return IncidentRecordFull(
+ **base.model_dump(),
+ incident_contract=(
+ IncidentContract.model_validate(incident.incident_contract)
+ if incident.incident_contract
+ else None
+ ),
+ forms=[FormRecord.model_validate(f, from_attributes=True) for f in forms],
+ submission_log=_submission_log(incident.incident_contract),
+ )
+
+
+@router.patch("/{incident_id}", response_model=IncidentRecord)
+def update_incident(
+ incident_id: UUID, body: UpdateIncidentRequest, db: Session = Depends(get_db)
+):
+ service = IncidentService()
+ incident = service.update(db, incident_id, body)
+ return _record(incident, service.forms(db, incident_id))
+
+
+@router.delete("/{incident_id}", response_model=DeleteIncidentResponse)
+def delete_incident(incident_id: UUID, db: Session = Depends(get_db)):
+ incident = IncidentService().soft_delete(db, incident_id)
+ return DeleteIncidentResponse(
+ incident_id=incident.incident_id,
+ deleted_at=incident.deleted_at,
+ )
diff --git a/app/api/routes/system.py b/app/api/routes/system.py
index eadd1d9f..174f5f7b 100644
--- a/app/api/routes/system.py
+++ b/app/api/routes/system.py
@@ -7,7 +7,7 @@
import time
import requests
-from fastapi import APIRouter
+from fastapi import APIRouter, Query
from fastapi.responses import JSONResponse
from sqlalchemy import text
@@ -15,9 +15,11 @@
ComponentHealth,
HealthComponents,
HealthStatus,
- ModelInfo,
+ SchemaFieldEntry,
+ SchemaFieldSearchResponse,
)
-from app.core.config import APP_VERSION, DATA_DIR, OLLAMA_HOST, WHISPER_HOST
+from app.services import field_catalog, llm
+from app.core.config import APP_VERSION, DATA_DIR, WHISPER_HOST
from app.db.database import engine
router = APIRouter(tags=["system"])
@@ -41,59 +43,32 @@ def _check_database() -> ComponentHealth:
return ComponentHealth(status="unhealthy", detail=str(exc))
-def _check_ollama() -> ComponentHealth:
- t0 = time.monotonic()
- try:
- tags_resp = requests.get(f"{OLLAMA_HOST}/api/tags", timeout=_PROBE_TIMEOUT)
- tags_resp.raise_for_status()
- elapsed = int((time.monotonic() - t0) * 1000)
-
- tags_data = tags_resp.json()
-
- ollama_version: str | None = None
- try:
- ver_resp = requests.get(f"{OLLAMA_HOST}/api/version", timeout=_PROBE_TIMEOUT)
- ver_resp.raise_for_status()
- ollama_version = ver_resp.json().get("version")
- except Exception:
- pass
-
- running_names: set[str] = set()
- model_loaded: str | None = None
- try:
- ps_resp = requests.get(f"{OLLAMA_HOST}/api/ps", timeout=_PROBE_TIMEOUT)
- ps_resp.raise_for_status()
- running = ps_resp.json().get("models", [])
- running_names = {m.get("name", "") for m in running}
- if running:
- model_loaded = running[0].get("name")
- except Exception:
- pass
-
- raw_models = tags_data.get("models", [])
- models_available: list[ModelInfo] = []
- for m in raw_models:
- name = m.get("name", "")
- size_gb = round(m.get("size", 0) / (1024 ** 3), 2)
- quantization = m.get("details", {}).get("quantization_level")
- models_available.append(
- ModelInfo(name=name, size_gb=size_gb, quantization=quantization, loaded=name in running_names)
- )
-
- status = "degraded" if elapsed > _SLOW_MS else "healthy"
-
- return ComponentHealth(
- status=status,
- response_time_ms=elapsed,
- model_loaded=model_loaded,
- ollama_version=ollama_version,
- models_available=models_available if models_available else None,
- # current_load is always None — Ollama exposes no queue-depth API;
- # populating it would require fabricating data.
- current_load=None,
- )
- except requests.exceptions.RequestException as exc:
- return ComponentHealth(status="unhealthy", detail=str(exc))
+def _check_llm() -> ComponentHealth:
+ """Health for whichever provider this deployment is configured for.
+
+ A local provider is probed. A hosted one is not, so the model list is left
+ out there too: both cost quota to answer a question the configuration
+ already answers.
+ """
+ report = llm.health()
+ status = report.status
+ if status == "healthy" and report.response_time_ms and report.response_time_ms > _SLOW_MS:
+ status = "degraded"
+
+ models: list[str] | None = None
+ if report.probed and status != "unhealthy":
+ models = [model.name for model in llm.list_models()]
+
+ return ComponentHealth(
+ status=status,
+ response_time_ms=report.response_time_ms,
+ detail=report.detail,
+ provider=report.provider,
+ model=report.model,
+ external=report.external,
+ probed=report.probed,
+ models_available=models,
+ )
def _check_whisper() -> ComponentHealth:
@@ -128,15 +103,15 @@ def _check_storage() -> ComponentHealth:
)
def get_health():
database = _check_database()
- ollama = _check_ollama()
+ provider = _check_llm()
whisper = _check_whisper()
storage = _check_storage()
components = HealthComponents(
- database=database, ollama=ollama, whisper=whisper, storage=storage
+ database=database, llm=provider, whisper=whisper, storage=storage
)
- statuses = {database.status, ollama.status, whisper.status, storage.status}
+ statuses = {database.status, provider.status, whisper.status, storage.status}
if database.status == "unhealthy":
overall = "unhealthy"
@@ -179,3 +154,35 @@ def get_schema_versions():
"message": "Schema version history not yet available — see issue #555",
},
)
+
+
+@router.get(
+ "/schema/fields",
+ response_model=SchemaFieldSearchResponse,
+ summary="Search or list the incident-contract field catalog",
+)
+def search_schema_fields(
+ q: str | None = Query(None, description="Search text, matched against names, aliases and descriptions"),
+ section: str | None = Query(None, description="Restrict to one top-level contract section"),
+ limit: int = Query(20, ge=1, le=100, description="Caps search results, ignored when q is omitted"),
+):
+ hits = field_catalog.search(q, section, limit)
+ return SchemaFieldSearchResponse(
+ query=q,
+ total=len(hits),
+ schema_version=field_catalog.schema_version(),
+ fields=[
+ SchemaFieldEntry(
+ path=entry.path,
+ label=entry.label,
+ field_type=entry.field_type,
+ section=entry.section,
+ description=entry.description,
+ enum_values=list(entry.enum_values) if entry.enum_values else None,
+ pii=entry.pii,
+ aliases=list(entry.aliases),
+ score=score,
+ )
+ for entry, score in hits
+ ],
+ )
diff --git a/app/api/routes/templates.py b/app/api/routes/templates.py
deleted file mode 100644
index ee7189aa..00000000
--- a/app/api/routes/templates.py
+++ /dev/null
@@ -1,240 +0,0 @@
-import re
-from datetime import datetime, timezone
-from pathlib import Path
-
-from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile
-from fastapi.responses import FileResponse
-from sqlmodel import Session
-
-from app.api.deps import get_db, verify_api_key
-from app.api.schemas.templates import (
- TemplateCreate,
- TemplateResponse,
- TemplateUploadResponse,
- MakeFillableRequest,
- MakeFillableResponse,
-)
-from app.core.config import BASE_DIR, DEFAULT_TEMPLATE_DIR
-from app.db.repositories import create_template, list_templates, get_template, delete_template
-from app.models import Template, FormSubmission, Job
-from app.services.controller import Controller
-from sqlmodel import select
-
-router = APIRouter(prefix="/templates", tags=["templates"])
-PROJECT_ROOT = BASE_DIR
-
-
-def _resolve_target_directory(directory: str) -> Path:
- dir_value = (directory or DEFAULT_TEMPLATE_DIR).strip()
- if not dir_value:
- raise HTTPException(status_code=400, detail="Directory is required.")
-
- candidate = Path(dir_value)
- if not candidate.is_absolute():
- candidate = (PROJECT_ROOT / candidate).resolve()
- else:
- candidate = candidate.resolve()
-
- if candidate != PROJECT_ROOT and PROJECT_ROOT not in candidate.parents:
- raise HTTPException(status_code=400, detail="Directory must be inside the project.")
-
- return candidate
-
-
-def _resolve_project_file(file_path: str) -> Path:
- raw_path = (file_path or "").strip()
- if not raw_path:
- raise HTTPException(status_code=400, detail="Path is required.")
-
- candidate = Path(raw_path)
- if not candidate.is_absolute():
- candidate = (PROJECT_ROOT / candidate).resolve()
- else:
- candidate = candidate.resolve()
-
- if candidate != PROJECT_ROOT and PROJECT_ROOT not in candidate.parents:
- raise HTTPException(status_code=400, detail="Path must be inside the project.")
-
- return candidate
-
-
-@router.post("/upload", response_model=TemplateUploadResponse)
-async def upload_template_pdf(
- file: UploadFile = File(...),
- directory: str = Form(DEFAULT_TEMPLATE_DIR),
-):
- filename = Path(file.filename or "").name
- if not filename:
- raise HTTPException(status_code=400, detail="A PDF filename is required.")
-
- if not filename.lower().endswith(".pdf"):
- raise HTTPException(status_code=400, detail="Only PDF files are supported.")
-
- target_dir = _resolve_target_directory(directory)
- target_dir.mkdir(parents=True, exist_ok=True)
-
- target_path = target_dir / filename
- if target_path.exists():
- timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
- target_path = target_dir / f"{target_path.stem}_{timestamp}{target_path.suffix}"
-
- content = await file.read()
- with target_path.open("wb") as output_file:
- output_file.write(content)
-
- relative_path = target_path.relative_to(PROJECT_ROOT).as_posix()
- extracted = _extract_pdf_fields(relative_path)
- return TemplateUploadResponse(
- filename=target_path.name,
- pdf_path=relative_path,
- field_count=None if extracted is None else len(extracted),
- fields=extracted or [],
- )
-
-
-# PDF field-type codes -> the type values the frontend field builder uses.
-_FIELD_TYPE_BY_FT = {"/Tx": "string", "/Btn": "checkbox", "/Ch": "list", "/Sig": "signature"}
-
-
-def _pdf_text(value) -> str:
- """Decode a pdfrw string (field name / tooltip) to plain text."""
- if value is None:
- return ""
- if hasattr(value, "to_unicode"):
- return value.to_unicode().strip()
- return str(value).strip()
-
-
-def _humanize(name: str) -> str:
- """Turn a raw field name into a readable description (JobTitle -> Job Title)."""
- text = re.sub(r"_+", " ", name)
- text = re.sub(r"(?<=[a-z])(?=[A-Z])", " ", text)
- return re.sub(r"\s+", " ", text).strip()
-
-
-def _extract_pdf_fields(pdf_path: str) -> list[dict] | None:
- """Fillable widgets in the same order Filler.fill_form writes them
- (top-to-bottom, left-to-right per page), so seeded rows line up with the
- fill order. Returns None if the PDF can't be read."""
- try:
- from pdfrw import PdfReader
- candidate = Path(pdf_path)
- if not candidate.is_absolute():
- candidate = (PROJECT_ROOT / candidate).resolve()
- pdf = PdfReader(str(candidate))
- fields: list[dict] = []
- for page in pdf.pages:
- widgets = [a for a in (page.Annots or []) if a.Subtype == "/Widget" and a.T]
- widgets.sort(key=lambda a: (-float(a.Rect[1]), float(a.Rect[0])))
- for annot in widgets:
- name = _pdf_text(annot.T)
- fields.append({
- "name": name,
- "description": _pdf_text(annot.TU) or _humanize(name),
- "type": _FIELD_TYPE_BY_FT.get(str(annot.FT), "string"),
- })
- return fields
- except Exception:
- return None
-
-
-def _count_pdf_widgets(pdf_path: str) -> int | None:
- """Number of fillable widgets in a PDF, or None if unreadable."""
- fields = _extract_pdf_fields(pdf_path)
- return None if fields is None else len(fields)
-
-
-@router.get("", response_model=list[TemplateResponse])
-def get_templates(db: Session = Depends(get_db)):
- return list_templates(db)
-
-
-@router.get("/preview")
-def preview_template_pdf(path: str = Query(..., description="Project-relative PDF path")):
- resolved_path = _resolve_project_file(path)
-
- if not resolved_path.exists() or not resolved_path.is_file():
- raise HTTPException(status_code=404, detail="PDF file not found.")
-
- if resolved_path.suffix.lower() != ".pdf":
- raise HTTPException(status_code=400, detail="Only PDF files can be previewed.")
-
- return FileResponse(
- resolved_path,
- media_type="application/pdf",
- filename=resolved_path.name,
- content_disposition_type="inline",
- )
-
-
-@router.post("/create", response_model=TemplateResponse)
-def create(template: TemplateCreate, db: Session = Depends(get_db)):
- tpl = Template(**template.model_dump())
- created = create_template(db, tpl)
- return TemplateResponse(
- id=created.id,
- name=created.name,
- pdf_path=created.pdf_path,
- fields=created.fields,
- field_count=_count_pdf_widgets(created.pdf_path),
- )
-
-
-@router.post("/make-fillable", response_model=MakeFillableResponse)
-def make_fillable(req: MakeFillableRequest):
- # Validate the path stays inside the project root.
- resolved = _resolve_project_file(req.pdf_path)
- if not resolved.exists() or not resolved.is_file():
- raise HTTPException(status_code=404, detail="PDF file not found.")
-
- controller = Controller()
- new_absolute = controller.prepare_fillable(str(resolved))
- new_path = Path(new_absolute)
- if not new_path.is_absolute():
- new_path = (PROJECT_ROOT / new_path).resolve()
- relative_path = new_path.relative_to(PROJECT_ROOT).as_posix()
-
- return MakeFillableResponse(
- pdf_path=relative_path,
- field_count=_count_pdf_widgets(relative_path),
- )
-
-
-@router.delete("/{template_id}", dependencies=[Depends(verify_api_key)])
-def delete_template_endpoint(template_id: int, db: Session = Depends(get_db)):
- template = get_template(db, template_id)
- if not template:
- raise HTTPException(status_code=404, detail="Template not found")
-
- # 1. Clean up associated submissions and their generated PDFs
- sub_stmt = select(FormSubmission).where(FormSubmission.template_id == template_id)
- submissions = list(db.exec(sub_stmt))
- for sub in submissions:
- if sub.output_pdf_path:
- try:
- resolved_out = _resolve_project_file(sub.output_pdf_path)
- if resolved_out.exists() and resolved_out.is_file():
- resolved_out.unlink()
- except Exception:
- pass
- db.delete(sub)
-
- # 2. Clean up associated jobs
- job_stmt = select(Job).where(Job.template_id == template_id)
- jobs = list(db.exec(job_stmt))
- for job in jobs:
- db.delete(job)
-
- # 3. Delete template PDF file
- if template.pdf_path:
- try:
- resolved_pdf = _resolve_project_file(template.pdf_path)
- if resolved_pdf.exists() and resolved_pdf.is_file():
- resolved_pdf.unlink()
- except Exception:
- pass
-
- # 4. Delete the template itself
- delete_template(db, template)
- return {"status": "success", "message": "Template and all associated data deleted"}
-
diff --git a/app/api/schemas/enums.py b/app/api/schemas/enums.py
index b6335389..4a2a3438 100644
--- a/app/api/schemas/enums.py
+++ b/app/api/schemas/enums.py
@@ -47,6 +47,7 @@ class JobType(str, Enum):
form_generation = "form_generation"
batch_form_generation = "batch_form_generation"
report_generation = "report_generation"
+ template_field_detection = "template_field_detection"
class FormType(str, Enum):
@@ -72,15 +73,52 @@ class FormType(str, Enum):
state_new_york = "state_new_york"
+class DetectionStatus(str, Enum):
+ """Field-detection state of an uploaded template PDF. The PDF itself is
+ stored before any of this runs, so a failed detection is recoverable."""
+
+ processing = "processing"
+ completed = "completed"
+ failed = "failed"
+
+
+class TemplateStatus(str, Enum):
+ active = "active"
+ legacy = "legacy"
+ draft = "draft"
+
+
+class TextAlign(str, Enum):
+ left = "left"
+ center = "center"
+ right = "right"
+
+
+class TemplateFieldType(str, Enum):
+ string = "string"
+ integer = "integer"
+ number = "number"
+ boolean = "boolean"
+ date = "date"
+ datetime = "datetime"
+ time = "time"
+ enum = "enum"
+ text = "text"
+ array = "array"
+
+
class IncidentCategory(str, Enum):
fire = "fire"
+ overpressure_explosion = "overpressure_explosion"
ems = "ems"
rescue = "rescue"
hazardous_conditions = "hazardous_conditions"
service_call = "service_call"
good_intent = "good_intent"
false_alarm = "false_alarm"
+ natural_disaster = "natural_disaster"
law_enforcement = "law_enforcement"
+ special_incident = "special_incident"
class CauseCertainty(str, Enum):
@@ -94,7 +132,9 @@ class InjurySeverity(str, Enum):
minor = "minor"
moderate = "moderate"
severe = "severe"
+ life_threatening = "life_threatening"
fatal = "fatal"
+ undetermined = "undetermined"
class RateOfSpread(str, Enum):
@@ -114,3 +154,10 @@ class OutputFormat(str, Enum):
pdf = "pdf"
json = "json"
both = "both"
+
+
+class FieldSource(str, Enum):
+ schema = "schema"
+ static = "static"
+ manual = "manual"
+ open = "open"
diff --git a/app/api/schemas/extraction.py b/app/api/schemas/extraction.py
new file mode 100644
index 00000000..80a4bb22
--- /dev/null
+++ b/app/api/schemas/extraction.py
@@ -0,0 +1,150 @@
+from __future__ import annotations
+
+from datetime import datetime
+from typing import Any, Literal
+from uuid import UUID
+
+from pydantic import BaseModel, ConfigDict
+
+from app.api.schemas.enums import FieldSource
+from app.api.schemas.incident_contract import IncidentContract
+
+
+# ---------------------------------------------------------------------------
+# Request
+# ---------------------------------------------------------------------------
+
+class ExtractionHints(BaseModel):
+ """Optional hints to improve extraction accuracy.
+
+ Extra keys are allowed; the contract marks this object
+ additionalProperties: true.
+ """
+
+ model_config = ConfigDict(extra="allow")
+
+ incident_type: str | None = None
+ state: str | None = None
+ agency_type: str | None = None
+
+
+class ExtractionDefaults(BaseModel):
+ """Deployment context the extractor falls back on when the narrative is
+ silent: timezone for relative dates, country, and currency for Money."""
+
+ country: str | None = None
+ timezone: str | None = None
+ currency: str | None = None
+
+
+class ExtractionRequest(BaseModel):
+ model_override: str | None = None
+ extraction_hints: ExtractionHints | None = None
+ defaults: ExtractionDefaults | None = None
+
+
+# ---------------------------------------------------------------------------
+# Responses
+# ---------------------------------------------------------------------------
+
+class ExtractionJobResponse(BaseModel):
+ """202 body for POST /extract/{input_id}. Carries the ids the client needs
+ to poll the extraction plus the underlying async job."""
+
+ extract_id: UUID
+ input_id: UUID
+ job_id: str
+ job_type: str = "extraction"
+ status: str
+ queued_at: datetime | None = None
+ estimated_seconds: int | None = None
+ poll_url: str
+
+
+class Correction(BaseModel):
+ """One manual correction applied to the contract via PATCH."""
+
+ field_path: str | None = None
+ original_value: Any = None
+ corrected_value: Any = None
+ corrected_at: datetime | None = None
+ corrected_by: str | None = None
+
+
+class ExtractionCompleted(BaseModel):
+ """A completed extraction. The contract document is embedded here, read
+ from the linked incident row; the extraction itself keeps only job
+ metadata and the corrections audit trail."""
+
+ extract_id: UUID
+ input_id: UUID
+ incident_id: UUID
+ status: Literal["completed"]
+ incident_contract: IncidentContract
+ completed_at: datetime | None = None
+ model_used: str | None = None
+ processing_time_seconds: float | None = None
+ corrections: list[Correction] | None = None
+
+
+class ExtractionProcessing(BaseModel):
+ extract_id: UUID
+ input_id: UUID
+ status: Literal["processing", "failed"]
+ started_at: datetime | None = None
+ retry_after_seconds: int | None = None
+ error_type: str | None = None
+ error_detail: str | None = None
+ partial_result: IncidentContract | None = None
+
+
+# ---------------------------------------------------------------------------
+# Validation and readiness
+# ---------------------------------------------------------------------------
+
+class FieldGap(BaseModel):
+ """One template field with no value yet, with enough context for the UI to
+ explain it and offer the right fix."""
+
+ field_name: str
+ source: FieldSource
+ incident_mapping: str | None = None
+ description: str | None = None
+
+
+class ValidationRequest(BaseModel):
+ """Which registered template to check the extraction against."""
+
+ template_id: UUID
+
+
+class ValidationResult(BaseModel):
+ valid: bool
+ template_id: UUID
+ extract_id: UUID
+ # form_type is an open string, not a closed enum: users register their own
+ # templates and each carries its own label.
+ form_type: str | None = None
+ missing_required: list[FieldGap] | None = None
+ missing_recommended: list[FieldGap] | None = None
+ warnings: list[str] | None = None
+ field_coverage_percent: float | None = None
+
+
+class TemplateReadiness(BaseModel):
+ template_id: UUID
+ form_type: str
+ display_name: str
+ ready: bool
+ missing_required: list[FieldGap] | None = None
+ missing_recommended: list[FieldGap] | None = None
+ field_coverage_percent: float | None = None
+
+
+class ReadinessMatrix(BaseModel):
+ """Per-template fill readiness for one extraction, computed by comparing
+ the contract against every registered template's field list."""
+
+ extract_id: UUID
+ templates: list[TemplateReadiness]
+ computed_at: datetime | None = None
diff --git a/app/api/schemas/form_generation.py b/app/api/schemas/form_generation.py
new file mode 100644
index 00000000..b82cc748
--- /dev/null
+++ b/app/api/schemas/form_generation.py
@@ -0,0 +1,141 @@
+"""Contract Layer 3 form generation schemas (contracts/schemas/form-record.yaml).
+
+Separate from app/api/schemas/forms.py, which holds the legacy prototype
+fill-pipeline shapes (int template_id, no incident/batch concept) still served
+by the old routes in app/api/routes/forms.py. This file is the v1 contract
+shape only — mirrors the extraction.py / templates.py split, one file per
+contract domain.
+"""
+
+from __future__ import annotations
+
+from datetime import datetime
+from typing import Literal
+from uuid import UUID
+
+from pydantic import BaseModel, Field
+
+from app.api.schemas.enums import FormStatus, OutputFormat
+
+
+# ---------------------------------------------------------------------------
+# Request
+# ---------------------------------------------------------------------------
+
+class GenerateFormsOptions(BaseModel):
+ output_format: OutputFormat | None = None
+ force_partial: bool = False
+
+
+class GenerateFormsRequest(BaseModel):
+ """POST /forms/generate body.
+
+ Omitting template_ids generates every template the readiness matrix
+ reports as ready. An empty list is a different thing from an absent one
+ and is rejected: it reads as a selection screen that sent nothing.
+ """
+
+ incident_id: UUID
+ template_ids: list[UUID] | None = Field(default=None, min_length=1)
+ options: GenerateFormsOptions | None = None
+
+
+# ---------------------------------------------------------------------------
+# Responses
+# ---------------------------------------------------------------------------
+
+class QueuedForm(BaseModel):
+ form_id: UUID
+ template_id: UUID
+ form_type: str
+
+
+class SkippedForm(BaseModel):
+ template_id: UUID
+ form_type: str
+ reason: str
+
+
+class BatchGenerateResponse(BaseModel):
+ """202 body for POST /forms/generate."""
+
+ batch_id: UUID
+ status: Literal["processing"] = "processing"
+ incident_id: UUID
+ forms_queued: list[QueuedForm] = Field(default_factory=list)
+ forms_skipped: list[SkippedForm] = Field(default_factory=list)
+ estimated_seconds: int | None = None
+ poll_url: str
+
+
+class FieldMappingSummary(BaseModel):
+ total_form_fields: int
+ fields_filled: int
+ fields_blank: int
+ coverage_percent: float
+
+
+class FormRecord(BaseModel):
+ """GET /forms/{form_id} response."""
+
+ form_id: UUID
+ template_id: UUID
+ # form_type is an open string on the wire: registries can add form types
+ # the closed FormType enum does not know about yet (see FormTemplate.form_type).
+ form_type: str
+ status: FormStatus
+ incident_id: UUID
+ batch_id: UUID | None = None
+ created_at: datetime
+ completed_at: datetime | None = None
+ pdf_ready: bool
+ json_ready: bool
+ field_mapping_summary: FieldMappingSummary | None = None
+
+
+class FormMappedJson(BaseModel):
+ """GET /forms/{form_id}/json response."""
+
+ form_type: str
+ # The template's version, read at request time. Null when the template has
+ # since been deleted out of the registry.
+ form_version: str | None = None
+ form_id: UUID
+ template_id: UUID
+ incident_id: UUID
+ agency_fields: dict = Field(default_factory=dict)
+
+
+class BatchFormEntry(BaseModel):
+ form_id: UUID
+ template_id: UUID
+ form_type: str
+ status: FormStatus
+
+
+class BatchStatus(BaseModel):
+ """GET /forms/batch/{batch_id} response, derived on the fly from the
+ batch's Form rows — there is no Batch table."""
+
+ batch_id: UUID
+ status: Literal["processing", "completed", "failed"]
+ total: int
+ completed: int
+ failed: int
+ forms: list[BatchFormEntry] = Field(default_factory=list)
+ # Set once the batch has finished with at least one PDF to bundle.
+ download_url: str | None = None
+
+
+__all__ = [
+ "GenerateFormsOptions",
+ "GenerateFormsRequest",
+ "QueuedForm",
+ "SkippedForm",
+ "BatchGenerateResponse",
+ "FieldMappingSummary",
+ "FormRecord",
+ "FormMappedJson",
+ "BatchFormEntry",
+ "BatchStatus",
+]
diff --git a/app/api/schemas/incident_contract.py b/app/api/schemas/incident_contract.py
new file mode 100644
index 00000000..ce1094b4
--- /dev/null
+++ b/app/api/schemas/incident_contract.py
@@ -0,0 +1,2425 @@
+# This file is generated from contracts/schemas/incident-contract.yaml.
+# DO NOT EDIT BY HAND. Run `make generate-contract-models` to regenerate.
+
+from __future__ import annotations
+
+from datetime import date as date_type, time as time_type
+from enum import Enum
+from typing import Annotated, Any, Optional
+from uuid import UUID
+
+from pydantic import AwareDatetime, BaseModel, Field, RootModel
+from app.api.schemas.enums import (
+ CauseCertainty,
+ IncidentCategory,
+ InjurySeverity,
+ RateOfSpread,
+ ReportStatus,
+)
+
+
+class SchemaName(Enum):
+ """
+ Schema name identifier
+ """
+
+ fireform_incident_contract = "fireform_incident_contract"
+
+
+class Money(BaseModel):
+ """
+ Monetary amount with ISO 4217 currency code
+ """
+
+ amount: Optional[float] = None
+ currency: Annotated[Optional[str], Field(None, examples=["USD"])]
+ """
+ ISO 4217 code
+ """
+
+
+class Scheme(Enum):
+ """
+ Coding scheme identifier
+ """
+
+ neris = "neris"
+ nfirs = "nfirs"
+ uk_irs = "uk_irs"
+ airs = "airs"
+ ontario_sir = "ontario_sir"
+ nemsis = "nemsis"
+ nibrs = "nibrs"
+ un_ssirs = "un_ssirs"
+ local = "local"
+ other = "other"
+
+
+class CodeRef(BaseModel):
+ """
+ A code from a named external coding scheme
+ """
+
+ scheme: Optional[Scheme] = None
+ """
+ Coding scheme identifier
+ """
+ code: Optional[str] = None
+ label: Optional[str] = None
+
+
+class Quantity(BaseModel):
+ """
+ Value with explicit reported unit, used where the unit itself is data (hazmat)
+ """
+
+ value: Optional[float] = None
+ unit: Optional[str] = None
+ """
+ Unit as reported (e.g. l, kg, gal, lb, m3)
+ """
+
+
+class PresenceStatus(Enum):
+ present = "present"
+ absent = "absent"
+ undetermined = "undetermined"
+
+
+class OperationStatus(Enum):
+ """
+ Whether a protection system operated when exposed to the incident
+ """
+
+ operated = "operated"
+ failed_to_operate = "failed_to_operate"
+ fire_too_small_to_activate = "fire_too_small_to_activate"
+ not_reached_by_fire = "not_reached_by_fire"
+ undetermined = "undetermined"
+
+
+class Coordinates(BaseModel):
+ latitude: Optional[float] = None
+ longitude: Optional[float] = None
+ accuracy_meters: Optional[float] = None
+
+
+class InputType(Enum):
+ voice = "voice"
+ text = "text"
+
+
+class Completeness(BaseModel):
+ """
+ Extraction-quality summary, recalculated server-side after every
+ PATCH /extract. Per-template readiness is not stored here; it is
+ computed live by GET /extract/{extract_id}/readiness against the
+ registered templates.
+
+ """
+
+ overall_percent: Annotated[Optional[int], Field(None, ge=0, le=100)]
+ missing_fields: Optional[list[str]] = None
+ """
+ JSON paths of fields that have no value
+ """
+ low_confidence_fields: Optional[list[str]] = None
+ """
+ JSON paths of fields where LLM confidence is low
+ """
+ inferred_fields: Optional[list[str]] = None
+ """
+ JSON paths of fields that were inferred (not explicitly stated)
+ """
+
+
+class ExternalId(BaseModel):
+ scheme: Annotated[Optional[str], Field(None, examples=["irwin"])]
+ value: Optional[str] = None
+
+
+class FormType(RootModel[Optional[str]]):
+ root: Annotated[Optional[str], Field(None, examples=["neris"])] = None
+ """
+ Stable string identifier for a form type. An open string, not a closed
+ enum, because users register their own templates (any jurisdiction, any
+ agency) and each carries its own form_type label. Generation and
+ validation are keyed by template_id; form_type is a human-friendly label
+ and grouping key.
+
+ Well-known built-in values: neris, nemsis_epcr, nibrs, nfirs_basic,
+ nfirs_fire, nfirs_structure, nfirs_wildland, nfirs_ems, nfirs_hazmat,
+ nfirs_apparatus, nfirs_personnel, nfirs_arson, nfirs_casualty_civilian,
+ nfirs_casualty_responder, cal_fire_ics209, osha_301, un_ssirs,
+ state_georgia, state_california, state_new_york.
+
+ """
+
+
+class ReportingUnit(BaseModel):
+ station_name: Optional[str] = None
+ station_id: Optional[str] = None
+ agency_name: Optional[str] = None
+ agency_id: Optional[UUID] = None
+ agency_type: Optional[str] = None
+
+
+class Personnel(BaseModel):
+ name: Optional[str] = None
+ badge_number: Optional[str] = None
+ rank: Optional[str] = None
+ role: Optional[str] = None
+ assignment: Optional[str] = None
+ contact_number: Optional[str] = None
+ signature_captured: Optional[bool] = None
+
+
+class Reviewer(BaseModel):
+ name: Optional[str] = None
+ badge_number: Optional[str] = None
+ rank: Optional[str] = None
+ role: Optional[str] = None
+ reviewed_at: Optional[AwareDatetime] = None
+ approved: Optional[bool] = None
+
+
+class Reason(Enum):
+ malicious = "malicious"
+ good_intent = "good_intent"
+ automatic_system_fault = "automatic_system_fault"
+ automatic_system_accidental = "automatic_system_accidental"
+ human_error = "human_error"
+ undetermined = "undetermined"
+ other = "other"
+
+
+class FalseAlarm(BaseModel):
+ """
+ Populated when the final type is a false alarm
+ """
+
+ reason: Optional[Reason] = None
+ reason_description: Optional[str] = None
+
+
+class DelayIgnitionToDiscovery(Enum):
+ immediate = "immediate"
+ under_5_min = "under_5_min"
+ field_5_to_30_min = "5_to_30_min"
+ over_30_min = "over_30_min"
+ undetermined = "undetermined"
+
+
+class DelayDiscoveryToCall(Enum):
+ immediate = "immediate"
+ under_5_min = "under_5_min"
+ field_5_to_30_min = "5_to_30_min"
+ over_30_min = "over_30_min"
+ undetermined = "undetermined"
+
+
+class CallOrigin(Enum):
+ person_landline = "person_landline"
+ person_mobile = "person_mobile"
+ person_in_person = "person_in_person"
+ automatic_alarm_originator = "automatic_alarm_originator"
+ automatic_alarm_monitoring_center = "automatic_alarm_monitoring_center"
+ other_agency = "other_agency"
+ police = "police"
+ ambulance = "ambulance"
+ coastguard = "coastguard"
+ other_fire_service = "other_fire_service"
+ other = "other"
+ undetermined = "undetermined"
+
+
+class DispatcherComment(BaseModel):
+ comment: Optional[str] = None
+ timestamp: Optional[AwareDatetime] = None
+
+
+class Dispatch(BaseModel):
+ """
+ Call handling data, usually pre-populated from CAD/PSAP when available
+ """
+
+ psap_id: Optional[str] = None
+ """
+ Dispatch center / PSAP identifier
+ """
+ dispatch_center: Optional[str] = None
+ """
+ Dispatch center name
+ """
+ cad_event_id: Optional[str] = None
+ call_received_datetime: Optional[AwareDatetime] = None
+ """
+ Call arrived at PSAP or department dispatch center
+ """
+ call_answered_datetime: Optional[AwareDatetime] = None
+ call_created_datetime: Optional[AwareDatetime] = None
+ """
+ CAD event created
+ """
+ first_unit_dispatched_datetime: Optional[AwareDatetime] = None
+ call_origin: Optional[CallOrigin] = None
+ automatic_alarm: Optional[bool] = None
+ """
+ Call originated from an automatic alarm system
+ """
+ incident_type_at_dispatch: Optional[str] = None
+ """
+ Incident type as received by the control room; may differ from final type
+ """
+ determinate_code: Optional[str] = None
+ """
+ Output code from the dispatch protocol (e.g. EMD/ProQA)
+ """
+ priority_at_call: Annotated[Optional[int], Field(None, ge=1, le=5)]
+ dispatcher_comments: Optional[list[DispatcherComment]] = None
+
+
+class LocationType(Enum):
+ street_address = "street_address"
+ intersection = "intersection"
+ milepost_or_highway = "milepost_or_highway"
+ coordinates_only = "coordinates_only"
+ unaddressable_area = "unaddressable_area"
+ water_body = "water_body"
+ other = "other"
+
+
+class Scheme1(Enum):
+ usng = "usng"
+ mgrs = "mgrs"
+ utm = "utm"
+ osgb = "osgb"
+ other = "other"
+
+
+class GridReference(BaseModel):
+ """
+ National grid reference where used instead of lat/long
+ """
+
+ scheme: Optional[Scheme1] = None
+ value: Optional[str] = None
+
+
+class Jurisdiction(BaseModel):
+ federal: Optional[bool] = None
+ state: Optional[bool] = None
+ private: Optional[bool] = None
+ tribal: Optional[bool] = None
+
+
+class OwnershipAtOrigin(Enum):
+ """
+ Ownership of the property at the point of origin
+ """
+
+ private = "private"
+ city_or_local = "city_or_local"
+ county = "county"
+ state_or_province = "state_or_province"
+ federal = "federal"
+ tribal = "tribal"
+ military = "military"
+ foreign = "foreign"
+ other = "other"
+ undetermined = "undetermined"
+
+
+class PopulationDensity(Enum):
+ urban = "urban"
+ suburban = "suburban"
+ rural = "rural"
+ wilderness = "wilderness"
+
+
+class OverBorder(BaseModel):
+ """
+ Incident on another service's ground (UK IRS 1.5-1.7)
+ """
+
+ is_over_border: Optional[bool] = None
+ other_service_name: Optional[str] = None
+ other_service_incident_number: Optional[str] = None
+
+
+class Location(BaseModel):
+ location_type: Optional[LocationType] = None
+ address: Optional[str] = None
+ """
+ Full street address as one line
+ """
+ cross_streets: Optional[list[str]] = None
+ """
+ Nearest cross street(s)
+ """
+ nearest_landmark: Optional[str] = None
+ city: Optional[str] = None
+ """
+ City, town or nearest settlement
+ """
+ district_or_zone: Optional[str] = None
+ """
+ Administrative district, borough or fire zone
+ """
+ county: Optional[str] = None
+ state: Optional[str] = None
+ """
+ State, province or region
+ """
+ country: Annotated[Optional[str], Field(None, examples=["US"])]
+ """
+ ISO 3166-1 alpha-2 code preferred
+ """
+ postal_code: Optional[str] = None
+ census_area: Optional[str] = None
+ """
+ Census tract or national statistical area code
+ """
+ coordinates: Optional[Coordinates] = None
+ ignition_point_coordinates: Optional[Coordinates] = None
+ grid_reference: Optional[GridReference] = None
+ """
+ National grid reference where used instead of lat/long
+ """
+ elevation_m: Optional[float] = None
+ legal_description: Optional[str] = None
+ """
+ Township / section / range or equivalent cadastral reference
+ """
+ jurisdiction: Optional[Jurisdiction] = None
+ ownership_at_origin: Optional[OwnershipAtOrigin] = None
+ """
+ Ownership of the property at the point of origin
+ """
+ population_density: Optional[PopulationDensity] = None
+ property_type: Optional[str] = None
+ property_use: Optional[str] = None
+ """
+ Use of the property at the time (residential, commercial, school...)
+ """
+ property_use_codes: Optional[list[CodeRef]] = None
+ """
+ Property use in external coding schemes (NFIRS 3-digit, NERIS location use)
+ """
+ mixed_use: Optional[str] = None
+ """
+ Mixed-use classification when the property has multiple uses
+ """
+ over_border: Optional[OverBorder] = None
+ """
+ Incident on another service's ground (UK IRS 1.5-1.7)
+ """
+
+
+class Category(Enum):
+ fire_suppression = "fire_suppression"
+ search = "search"
+ rescue = "rescue"
+ ems_care = "ems_care"
+ extrication = "extrication"
+ hazmat_mitigation = "hazmat_mitigation"
+ ventilation = "ventilation"
+ forcible_entry = "forcible_entry"
+ salvage_overhaul = "salvage_overhaul"
+ water_supply = "water_supply"
+ exposure_protection = "exposure_protection"
+ evacuation = "evacuation"
+ command_control = "command_control"
+ investigation = "investigation"
+ public_assist = "public_assist"
+ standby = "standby"
+ information_referral = "information_referral"
+ systems_restoration = "systems_restoration"
+ other = "other"
+
+
+class Action(BaseModel):
+ category: Optional[Category] = None
+ description: Optional[str] = None
+ codes: Optional[list[CodeRef]] = None
+
+
+class ActionsTaken(BaseModel):
+ """
+ What responders did on scene. Every reporting standard requires this.
+ """
+
+ actions: Optional[list[Action]] = None
+ no_action_reason: Optional[str] = None
+ """
+ Why no action was taken (canceled enroute, no hazard found...)
+ """
+
+
+class AidDirection(Enum):
+ given = "given"
+ received = "received"
+ both = "both"
+ none = "none"
+
+
+class AidType(Enum):
+ automatic = "automatic"
+ mutual = "mutual"
+ other = "other"
+ none = "none"
+
+
+class IncidentCommander(BaseModel):
+ name: Optional[str] = None
+ agency: Optional[str] = None
+ position: Optional[str] = None
+
+
+class RespondingAgency(BaseModel):
+ agency_name: Optional[str] = None
+ agency_type: Optional[str] = None
+ """
+ fire, ems, police, forestry, military, utility, ngo, other
+ """
+ role: Optional[str] = None
+ personnel_count: Optional[int] = None
+ incident_number_at_agency: Optional[str] = None
+ """
+ That agency's own incident number for cross-referencing
+ """
+
+
+class ApparatusType(Enum):
+ engine_pumper = "engine_pumper"
+ ladder_aerial = "ladder_aerial"
+ quint = "quint"
+ tanker_tender = "tanker_tender"
+ brush_wildland = "brush_wildland"
+ arff = "arff"
+ dozer_plow = "dozer_plow"
+ heavy_equipment = "heavy_equipment"
+ aircraft_fixed_wing = "aircraft_fixed_wing"
+ helicopter = "helicopter"
+ boat = "boat"
+ rescue_unit = "rescue_unit"
+ usar_unit = "usar_unit"
+ hazmat_unit = "hazmat_unit"
+ ambulance_bls = "ambulance_bls"
+ ambulance_als = "ambulance_als"
+ command_vehicle = "command_vehicle"
+ support_unit = "support_unit"
+ hand_crew = "hand_crew"
+ privately_owned = "privately_owned"
+ other = "other"
+
+
+class Use(Enum):
+ suppression = "suppression"
+ ems = "ems"
+ rescue = "rescue"
+ hazmat = "hazmat"
+ command = "command"
+ support = "support"
+ other = "other"
+
+
+class PersonnelItem(BaseModel):
+ personnel_id: Optional[str] = None
+ name: Optional[str] = None
+ rank: Optional[str] = None
+ role: Optional[str] = None
+
+
+class ResponseMode(Enum):
+ emergency_lights_siren = "emergency_lights_siren"
+ non_emergency = "non_emergency"
+ undetermined = "undetermined"
+
+
+class TransportMode(Enum):
+ emergency_lights_siren = "emergency_lights_siren"
+ non_emergency = "non_emergency"
+ undetermined = "undetermined"
+
+
+class UnitResponse(BaseModel):
+ """
+ One responding unit (apparatus or resource) and its timeline
+ """
+
+ unit_id: Optional[str] = None
+ """
+ Callsign or unit identifier
+ """
+ unit_name: Optional[str] = None
+ agency_name: Optional[str] = None
+ apparatus_type: Optional[ApparatusType] = None
+ use: Optional[Use] = None
+ personnel_count: Optional[int] = None
+ personnel: Optional[list[PersonnelItem]] = None
+ response_mode: Optional[ResponseMode] = None
+ canceled_enroute: Optional[bool] = None
+ dispatched_datetime: Optional[AwareDatetime] = None
+ enroute_datetime: Optional[AwareDatetime] = None
+ arrived_datetime: Optional[AwareDatetime] = None
+ staged_datetime: Optional[AwareDatetime] = None
+ at_patient_datetime: Optional[AwareDatetime] = None
+ enroute_hospital_datetime: Optional[AwareDatetime] = None
+ arrived_hospital_datetime: Optional[AwareDatetime] = None
+ transfer_of_care_datetime: Optional[AwareDatetime] = None
+ cleared_datetime: Optional[AwareDatetime] = None
+ in_service_datetime: Optional[AwareDatetime] = None
+ """
+ Back available for calls
+ """
+ turnout_seconds: Optional[int] = None
+ """
+ Computed, dispatched to enroute
+ """
+ travel_seconds: Optional[int] = None
+ """
+ Computed, enroute to arrived
+ """
+ transport_mode: Optional[TransportMode] = None
+ hospital_destination: Optional[str] = None
+ actions_taken: Optional[list[str]] = None
+ """
+ Actions by this unit, same categories as incident actions
+ """
+
+
+class PersonnelBreakdown(BaseModel):
+ firefighters: Optional[int] = None
+ officers: Optional[int] = None
+ engineers_operators: Optional[int] = None
+ ems_personnel: Optional[int] = None
+ incident_command: Optional[int] = None
+ support_staff: Optional[int] = None
+
+
+class ApparatusCounts(BaseModel):
+ """
+ Unit counts by primary use (NFIRS Basic G1)
+ """
+
+ suppression: Optional[int] = None
+ ems: Optional[int] = None
+ other: Optional[int] = None
+
+
+class ResourcesSummary(BaseModel):
+ """
+ Aggregate counts; per-unit detail lives in units[]
+ """
+
+ total_personnel: Optional[int] = None
+ personnel_breakdown: Optional[PersonnelBreakdown] = None
+ apparatus_counts: Optional[ApparatusCounts] = None
+ """
+ Unit counts by primary use (NFIRS Basic G1)
+ """
+ crew_types: Optional[list[str]] = None
+ """
+ Wildland crew types deployed (hand crew type 1/2, engine crew...)
+ """
+ counts_include_aid_received: Optional[bool] = None
+
+
+class CauseCategory(Enum):
+ intentional = "intentional"
+ unintentional = "unintentional"
+ equipment_failure = "equipment_failure"
+ act_of_nature = "act_of_nature"
+ cause_under_investigation = "cause_under_investigation"
+ undetermined = "undetermined"
+ other = "other"
+
+
+class HumanFactor(Enum):
+ asleep = "asleep"
+ impaired_by_alcohol_or_drugs = "impaired_by_alcohol_or_drugs"
+ unattended_person = "unattended_person"
+ mentally_disabled = "mentally_disabled"
+ physically_disabled = "physically_disabled"
+ multiple_persons_involved = "multiple_persons_involved"
+ age_was_factor = "age_was_factor"
+ other = "other"
+
+
+class Portability(Enum):
+ portable = "portable"
+ stationary = "stationary"
+
+
+class EquipmentInvolved(BaseModel):
+ """
+ Equipment involved in ignition, if any
+ """
+
+ involved: Optional[bool] = None
+ equipment_type: Optional[str] = None
+ brand: Optional[str] = None
+ model: Optional[str] = None
+ serial_number: Optional[str] = None
+ year: Optional[int] = None
+ power_source: Optional[str] = None
+ portability: Optional[Portability] = None
+
+
+class WaterSupplyType(Enum):
+ pressurized_hydrant = "pressurized_hydrant"
+ rural_water_supply = "rural_water_supply"
+ tanker_shuttle = "tanker_shuttle"
+ drafting_static_source = "drafting_static_source"
+ onboard_water_only = "onboard_water_only"
+ none_needed = "none_needed"
+ other = "other"
+ undetermined = "undetermined"
+
+
+class ExtinguishingAgent(Enum):
+ water = "water"
+ foam = "foam"
+ co2 = "co2"
+ dry_chemical = "dry_chemical"
+ wet_chemical = "wet_chemical"
+ halon_clean_agent = "halon_clean_agent"
+ sand_earth = "sand_earth"
+ blanket_smothering = "blanket_smothering"
+ other = "other"
+
+
+class EquipmentUsedItem(BaseModel):
+ equipment_type: Optional[str] = None
+ count: Optional[int] = None
+
+
+class FirefightingDelay(BaseModel):
+ occurred: Optional[bool] = None
+ reason: Optional[str] = None
+
+
+class SuppressionOperations(BaseModel):
+ water_supply_type: Optional[WaterSupplyType] = None
+ water_used_l: Optional[float] = None
+ extinguishing_agents: Optional[list[ExtinguishingAgent]] = None
+ suppression_appliances: Optional[list[str]] = None
+ """
+ Appliances used for suppression (jets, hose reels, monitors, extinguishers)
+ """
+ equipment_used: Optional[list[EquipmentUsedItem]] = None
+ """
+ Equipment used at the incident with counts (UK IRS 6.16-6.17)
+ """
+ ba_wearers_count: Optional[int] = None
+ """
+ Breathing apparatus wearers
+ """
+ firefighting_delay: Optional[FirefightingDelay] = None
+ public_action_before_arrival: Optional[str] = None
+ """
+ Main action taken by the public before responders arrived
+ """
+
+
+class Stage(Enum):
+ before_fire = "before_fire"
+ during_fire = "during_fire"
+ after_fire = "after_fire"
+ no_fire = "no_fire"
+
+
+class Explosion(BaseModel):
+ """
+ Explosion or overpressure event, with or without fire (UK IRS 8.10-8.13)
+ """
+
+ occurred: Optional[bool] = None
+ cause: Optional[str] = None
+ stage: Optional[Stage] = None
+ containers_involved: Optional[list[str]] = None
+
+
+class AlarmType(Enum):
+ smoke = "smoke"
+ heat = "heat"
+ combination = "combination"
+ sprinkler_waterflow = "sprinkler_waterflow"
+ multiple_types = "multiple_types"
+ other = "other"
+ undetermined = "undetermined"
+
+
+class PowerSupply(Enum):
+ battery_only = "battery_only"
+ hardwire_only = "hardwire_only"
+ hardwire_with_battery = "hardwire_with_battery"
+ plug_in = "plug_in"
+ plug_in_with_battery = "plug_in_with_battery"
+ mechanical = "mechanical"
+ multiple = "multiple"
+ other = "other"
+ undetermined = "undetermined"
+
+
+class Effectiveness(Enum):
+ alerted_occupants_responded = "alerted_occupants_responded"
+ alerted_occupants_no_response = "alerted_occupants_no_response"
+ no_occupants = "no_occupants"
+ failed_to_alert = "failed_to_alert"
+ undetermined = "undetermined"
+
+
+class FailureReason(Enum):
+ power_failure_or_disconnect = "power_failure_or_disconnect"
+ improper_installation = "improper_installation"
+ defective = "defective"
+ lack_of_maintenance = "lack_of_maintenance"
+ battery_missing = "battery_missing"
+ battery_dead = "battery_dead"
+ other = "other"
+ undetermined = "undetermined"
+
+
+class SmokeAlarm(BaseModel):
+ presence: Optional[PresenceStatus] = None
+ alarm_type: Optional[AlarmType] = None
+ power_supply: Optional[PowerSupply] = None
+ working: Optional[bool] = None
+ operation: Optional[OperationStatus] = None
+ effectiveness: Optional[Effectiveness] = None
+ failure_reason: Optional[FailureReason] = None
+ occupant_response: Optional[str] = None
+
+
+class FireAlarm(BaseModel):
+ """
+ Building fire alarm system
+ """
+
+ presence: Optional[PresenceStatus] = None
+ alarm_type: Optional[str] = None
+ monitored: Optional[bool] = None
+ operation: Optional[OperationStatus] = None
+ failure_reason: Optional[str] = None
+
+
+class OtherAlarm(BaseModel):
+ """
+ CO, gas, security or other alarm
+ """
+
+ presence: Optional[PresenceStatus] = None
+ alarm_type: Optional[str] = None
+
+
+class SystemType(Enum):
+ wet_pipe_sprinkler = "wet_pipe_sprinkler"
+ dry_pipe_sprinkler = "dry_pipe_sprinkler"
+ other_sprinkler = "other_sprinkler"
+ dry_chemical = "dry_chemical"
+ foam = "foam"
+ halon_clean_agent = "halon_clean_agent"
+ co2 = "co2"
+ water_mist = "water_mist"
+ other_special_hazard = "other_special_hazard"
+ other = "other"
+ undetermined = "undetermined"
+
+
+class Coverage(Enum):
+ full = "full"
+ partial = "partial"
+ undetermined = "undetermined"
+
+
+class FailureReason1(Enum):
+ system_shut_off = "system_shut_off"
+ not_enough_agent = "not_enough_agent"
+ agent_did_not_reach_fire = "agent_did_not_reach_fire"
+ wrong_system_type = "wrong_system_type"
+ fire_outside_protected_area = "fire_outside_protected_area"
+ components_damaged = "components_damaged"
+ lack_of_maintenance = "lack_of_maintenance"
+ manual_intervention = "manual_intervention"
+ other = "other"
+ undetermined = "undetermined"
+
+
+class SuppressionSystem(BaseModel):
+ """
+ Automatic extinguishing system
+ """
+
+ presence: Optional[PresenceStatus] = None
+ system_type: Optional[SystemType] = None
+ coverage: Optional[Coverage] = None
+ operation: Optional[OperationStatus] = None
+ sprinkler_heads_activated: Optional[int] = None
+ effective: Optional[bool] = None
+ failure_reason: Optional[FailureReason1] = None
+
+
+class CookingSuppression(BaseModel):
+ presence: Optional[PresenceStatus] = None
+ system_type: Optional[str] = None
+
+
+class FixedFirefightingFacility(BaseModel):
+ facility_type: Optional[str] = None
+ used: Optional[bool] = None
+ worked: Optional[bool] = None
+ failure_reason: Optional[str] = None
+
+
+class RiskReduction(BaseModel):
+ """
+ Alarms, detectors and suppression systems and how they performed
+ """
+
+ smoke_alarm: Optional[SmokeAlarm] = None
+ fire_alarm: Optional[FireAlarm] = None
+ """
+ Building fire alarm system
+ """
+ other_alarm: Optional[OtherAlarm] = None
+ """
+ CO, gas, security or other alarm
+ """
+ suppression_system: Optional[SuppressionSystem] = None
+ """
+ Automatic extinguishing system
+ """
+ cooking_suppression: Optional[CookingSuppression] = None
+ fixed_firefighting_facilities: Optional[list[FixedFirefightingFacility]] = None
+ """
+ Built-in firefighting facilities (risers, hose reels, smoke control, fire lift)
+ """
+
+
+class BuildingStatus(Enum):
+ occupied_in_use = "occupied_in_use"
+ vacant_secured = "vacant_secured"
+ vacant_unsecured = "vacant_unsecured"
+ under_construction = "under_construction"
+ under_renovation = "under_renovation"
+ under_demolition = "under_demolition"
+ derelict = "derelict"
+ undetermined = "undetermined"
+
+
+class FireSpreadExtent(Enum):
+ confined_to_object = "confined_to_object"
+ confined_to_room = "confined_to_room"
+ confined_to_floor = "confined_to_floor"
+ confined_to_building = "confined_to_building"
+ beyond_building = "beyond_building"
+ no_flame_damage = "no_flame_damage"
+ undetermined = "undetermined"
+
+
+class ArrivalConditions(Enum):
+ no_visible_smoke_or_fire = "no_visible_smoke_or_fire"
+ smoke_showing = "smoke_showing"
+ fire_showing = "fire_showing"
+ fully_involved = "fully_involved"
+ collapsed = "collapsed"
+ undetermined = "undetermined"
+
+
+class StoriesDamaged(BaseModel):
+ """
+ Count of stories by flame damage band (NFIRS-3 J3)
+ """
+
+ minor: Optional[int] = None
+ """
+ 1-24% flame damage
+ """
+ significant: Optional[int] = None
+ """
+ 25-49% flame damage
+ """
+ heavy: Optional[int] = None
+ """
+ 50-74% flame damage
+ """
+ extreme: Optional[int] = None
+ """
+ 75-100% flame damage
+ """
+
+
+class Structure(BaseModel):
+ is_structure_involved: Optional[bool] = None
+ structures_threatened: Optional[int] = None
+ structures_damaged: Optional[int] = None
+ structures_destroyed: Optional[int] = None
+ structures_protected: Optional[int] = None
+ buildings_involved: Optional[int] = None
+ """
+ Number of buildings involved at the origin property
+ """
+ building_status: Optional[BuildingStatus] = None
+ construction_type: Optional[str] = None
+ construction_codes: Optional[list[CodeRef]] = None
+ special_construction_method: Optional[str] = None
+ """
+ Notable construction method involved (timber frame, sandwich panel, cladding system)
+ """
+ stories_above_grade: Optional[int] = None
+ stories_below_grade: Optional[int] = None
+ total_floor_area_m2: Optional[float] = None
+ main_floor_area_m2: Optional[float] = None
+ residential_units: Optional[int] = None
+ """
+ Residential living units in the building of origin
+ """
+ occupancy_at_time: Optional[int] = None
+ """
+ Estimated number of people in the building at the time
+ """
+ fire_safety_regulations_apply: Optional[bool] = None
+ means_of_escape_condition: Optional[str] = None
+ compartmentation_effective: Optional[bool] = None
+ story_of_origin: Optional[int] = None
+ """
+ Negative below grade, 1 is ground floor
+ """
+ room_of_origin: Optional[str] = None
+ room_of_origin_area_m2: Optional[float] = None
+ floor_of_origin_area_m2: Optional[float] = None
+ fire_spread_extent: Optional[FireSpreadExtent] = None
+ item_contributing_most_to_spread: Optional[str] = None
+ material_contributing_most_to_spread: Optional[str] = None
+ arrival_conditions: Optional[ArrivalConditions] = None
+ progressed_beyond_arrival: Optional[bool] = None
+ """
+ Fire extended beyond the conditions found on arrival
+ """
+ smoke_damage_only: Optional[bool] = None
+ """
+ Heat/smoke damage with no flame damage (UK IRS 8.19)
+ """
+ stories_damaged: Optional[StoriesDamaged] = None
+ """
+ Count of stories by flame damage band (NFIRS-3 J3)
+ """
+ damage_area_on_arrival_m2: Optional[float] = None
+ damage_area_at_stop_m2: Optional[float] = None
+ """
+ Horizontal area damaged by flame/heat when fire was stopped
+ """
+
+
+class AreaType(Enum):
+ urban = "urban"
+ suburban = "suburban"
+ rural = "rural"
+ wildland_urban_interface = "wildland_urban_interface"
+ remote_wilderness = "remote_wilderness"
+
+
+class LandOwnershipBreakdown(BaseModel):
+ federal_ha: Optional[float] = None
+ state_ha: Optional[float] = None
+ private_ha: Optional[float] = None
+ tribal_ha: Optional[float] = None
+ other_ha: Optional[float] = None
+
+
+class FireDangerRating(Enum):
+ """
+ Fire danger rating in effect at the time
+ """
+
+ low = "low"
+ moderate = "moderate"
+ high = "high"
+ very_high = "very_high"
+ severe = "severe"
+ extreme = "extreme"
+ catastrophic = "catastrophic"
+
+
+class ComplexityLevel(Enum):
+ """
+ Incident complexity (type 5 lowest, type 1 highest)
+ """
+
+ type_5 = "type_5"
+ type_4 = "type_4"
+ type_3 = "type_3"
+ type_2 = "type_2"
+ type_1 = "type_1"
+
+
+class Status(Enum):
+ identified = "identified"
+ unidentified = "unidentified"
+ fire_not_caused_by_person = "fire_not_caused_by_person"
+
+
+class PersonResponsible(BaseModel):
+ """
+ Person who caused the wildland fire, if any (NFIRS-8 L)
+ """
+
+ status: Optional[Status] = None
+ age: Optional[int] = None
+ sex: Optional[str] = None
+ activity: Optional[str] = None
+
+
+class RightOfWay(BaseModel):
+ """
+ Nearby road/rail/power right-of-way (NFIRS-8 M)
+ """
+
+ row_type: Optional[str] = None
+ distance_m: Optional[float] = None
+
+
+class FireLines(BaseModel):
+ primary_line_km: Optional[float] = None
+ secondary_line_km: Optional[float] = None
+ dozer_line_km: Optional[float] = None
+ hand_line_km: Optional[float] = None
+
+
+class AerialOperations(BaseModel):
+ water_dropped_l: Optional[float] = None
+ retardant_dropped_l: Optional[float] = None
+ total_flight_hours: Optional[float] = None
+
+
+class Wildland(BaseModel):
+ is_wildland_incident: Optional[bool] = None
+ discovery_datetime: Optional[AwareDatetime] = None
+ area_type: Optional[AreaType] = None
+ area_burned_ha: Optional[float] = None
+ """
+ Total area burned in hectares
+ """
+ land_ownership_breakdown: Optional[LandOwnershipBreakdown] = None
+ percent_contained: Annotated[Optional[int], Field(None, ge=0, le=100)]
+ fire_danger_rating: Optional[FireDangerRating] = None
+ """
+ Fire danger rating in effect at the time
+ """
+ fuel_model: Optional[str] = None
+ """
+ NFDRS or local fuel model at origin
+ """
+ fuel_moisture_percent: Optional[float] = None
+ complexity_level: Optional[ComplexityLevel] = None
+ """
+ Incident complexity (type 5 lowest, type 1 highest)
+ """
+ slope_position: Optional[str] = None
+ """
+ Relative position on slope at origin
+ """
+ aspect: Optional[str] = None
+ person_responsible: Optional[PersonResponsible] = None
+ """
+ Person who caused the wildland fire, if any (NFIRS-8 L)
+ """
+ right_of_way: Optional[RightOfWay] = None
+ """
+ Nearby road/rail/power right-of-way (NFIRS-8 M)
+ """
+ crops_burned: Optional[list[str]] = None
+ fire_lines: Optional[FireLines] = None
+ aerial_operations: Optional[AerialOperations] = None
+ containment_strategies: Optional[list[str]] = None
+
+
+class DamageRating(Enum):
+ none = "none"
+ minor = "minor"
+ significant = "significant"
+ heavy = "heavy"
+ destroyed = "destroyed"
+ undetermined = "undetermined"
+
+
+class Exposure(BaseModel):
+ """
+ A property beyond the origin damaged or threatened by spread
+ """
+
+ exposure_number: Optional[int] = None
+ """
+ 0 is the origin; exposures count up from 1 (NFIRS convention)
+ """
+ exposure_type: Optional[str] = None
+ item_damaged: Optional[str] = None
+ address: Optional[str] = None
+ coordinates: Optional[Coordinates] = None
+ property_use: Optional[str] = None
+ people_present: Optional[bool] = None
+ damage_rating: Optional[DamageRating] = None
+ people_displaced: Optional[int] = None
+
+
+class Affiliation(Enum):
+ civilian = "civilian"
+ ems_non_fd = "ems_non_fd"
+ police = "police"
+ other_responder = "other_responder"
+ undetermined = "undetermined"
+
+
+class HumanFactor1(Enum):
+ asleep = "asleep"
+ unconscious = "unconscious"
+ impaired_by_alcohol = "impaired_by_alcohol"
+ impaired_by_drugs = "impaired_by_drugs"
+ mentally_disabled = "mentally_disabled"
+ physically_disabled = "physically_disabled"
+ physically_restrained = "physically_restrained"
+ unattended_person = "unattended_person"
+ other = "other"
+
+
+class ActivityWhenInjured(Enum):
+ escaping = "escaping"
+ rescue_attempt = "rescue_attempt"
+ fire_control = "fire_control"
+ returning_before_control = "returning_before_control"
+ returning_after_control = "returning_after_control"
+ sleeping = "sleeping"
+ unable_to_act = "unable_to_act"
+ irrational_act = "irrational_act"
+ other = "other"
+ undetermined = "undetermined"
+
+
+class LocationAtIgnition(Enum):
+ in_area_of_origin = "in_area_of_origin"
+ in_building_not_in_area = "in_building_not_in_area"
+ outside_building = "outside_building"
+ not_on_property = "not_on_property"
+ undetermined = "undetermined"
+
+
+class CareerOrVolunteer(Enum):
+ career = "career"
+ volunteer = "volunteer"
+ undetermined = "undetermined"
+
+
+class PhysicalConditionPrior(Enum):
+ rested = "rested"
+ fatigued = "fatigued"
+ ill_or_injured = "ill_or_injured"
+ other = "other"
+ undetermined = "undetermined"
+
+
+class WhereOccurred(Enum):
+ enroute_to_scene = "enroute_to_scene"
+ at_scene_inside = "at_scene_inside"
+ at_scene_outside = "at_scene_outside"
+ enroute_to_facility = "enroute_to_facility"
+ at_facility = "at_facility"
+ returning = "returning"
+ at_station = "at_station"
+ other = "other"
+ undetermined = "undetermined"
+
+
+class ProtectiveEquipmentFailure(BaseModel):
+ failed: Optional[bool] = None
+ item: Optional[str] = None
+ problem: Optional[str] = None
+
+
+class DutyStatus(Enum):
+ on_duty = "on_duty"
+ off_duty_responding = "off_duty_responding"
+ off_duty = "off_duty"
+ undetermined = "undetermined"
+
+
+class TakenTo(Enum):
+ hospital = "hospital"
+ doctors_office = "doctors_office"
+ morgue = "morgue"
+ residence = "residence"
+ station = "station"
+ not_transported = "not_transported"
+ other = "other"
+
+
+class ResponderCasualty(BaseModel):
+ personnel_id: Optional[str] = None
+ name: Optional[str] = None
+ age: Optional[int] = None
+ sex: Optional[str] = None
+ agency: Optional[str] = None
+ role: Optional[str] = None
+ rank: Optional[str] = None
+ career_or_volunteer: Optional[CareerOrVolunteer] = None
+ years_of_service: Optional[float] = None
+ usual_assignment: Optional[str] = None
+ physical_condition_prior: Optional[PhysicalConditionPrior] = None
+ prior_responses_24h: Optional[int] = None
+ injury_datetime: Optional[AwareDatetime] = None
+ injury_type: Optional[str] = None
+ primary_symptom: Optional[str] = None
+ primary_body_part: Optional[str] = None
+ severity: Optional[InjurySeverity] = None
+ cause: Optional[str] = None
+ contributing_factor: Optional[str] = None
+ object_involved: Optional[str] = None
+ activity_at_injury: Optional[str] = None
+ where_occurred: Optional[WhereOccurred] = None
+ story_where_injured: Optional[int] = None
+ protective_equipment_failure: Optional[ProtectiveEquipmentFailure] = None
+ duty_status: Optional[DutyStatus] = None
+ treatment: Optional[str] = None
+ taken_to: Optional[TakenTo] = None
+ transported: Optional[bool] = None
+ hospital: Optional[str] = None
+ hospitalized_overnight: Optional[bool] = None
+ return_to_duty_date: Optional[date_type] = None
+ osha_recordable: Optional[bool] = None
+ exposure_only: Optional[bool] = None
+ """
+ Chemical/biological exposure without immediate symptoms
+ """
+
+
+class PersonType(Enum):
+ civilian = "civilian"
+ firefighter = "firefighter"
+ other_responder = "other_responder"
+
+
+class RescueType(Enum):
+ rescue = "rescue"
+ assist = "assist"
+ self_evacuation = "self_evacuation"
+ body_recovery = "body_recovery"
+ no_rescue_needed = "no_rescue_needed"
+
+
+class RelativeTimeToSuppression(Enum):
+ before_suppression = "before_suppression"
+ during_suppression = "during_suppression"
+ after_suppression = "after_suppression"
+ undetermined = "undetermined"
+
+
+class Mayday(BaseModel):
+ """
+ Firefighter emergencies only
+ """
+
+ called: Optional[bool] = None
+ relative_time: Optional[str] = None
+ rit_activated: Optional[bool] = None
+
+
+class Rescue(BaseModel):
+ """
+ One person rescued, assisted or self-evacuated (NERIS rescue modules)
+ """
+
+ person_type: Optional[PersonType] = None
+ rescue_type: Optional[RescueType] = None
+ presence_known_beforehand: Optional[bool] = None
+ age: Optional[int] = None
+ sex: Optional[str] = None
+ primary_mode: Optional[str] = None
+ """
+ Primary rescue mode (interior search, ladder, water, rope, extrication)
+ """
+ actions: Optional[list[str]] = None
+ impediments: Optional[list[str]] = None
+ room_type: Optional[str] = None
+ elevation: Optional[str] = None
+ """
+ Elevation at which the person was found (below grade, ground, upper story, roof)
+ """
+ removal_path: Optional[str] = None
+ """
+ Route used to remove the person (internal stairs, window, aerial)
+ """
+ relative_time_to_suppression: Optional[RelativeTimeToSuppression] = None
+ gas_isolation: Optional[bool] = None
+ """
+ Space was isolated from heat/toxic gas flow
+ """
+ mayday: Optional[Mayday] = None
+ """
+ Firefighter emergencies only
+ """
+ resulting_casualty: Optional[bool] = None
+ """
+ True if this person also appears in casualties
+ """
+
+
+class EvacuationStatus(Enum):
+ none = "none"
+ planned = "planned"
+ in_progress = "in_progress"
+ completed = "completed"
+ repopulation_in_progress = "repopulation_in_progress"
+ shelter_in_place = "shelter_in_place"
+
+
+class EvacuationDisplacement(BaseModel):
+ evacuation_occurred: Optional[bool] = None
+ evacuation_status: Optional[EvacuationStatus] = None
+ people_evacuated_without_assistance: Optional[int] = None
+ people_evacuated_with_assistance: Optional[int] = None
+ people_assisted_by_fd: Optional[int] = None
+ total_people_evacuated: Optional[int] = None
+ buildings_evacuated: Optional[int] = None
+ evacuation_delay_reason: Optional[str] = None
+ evacuation_completion_minutes: Optional[int] = None
+ people_sheltering_in_place: Optional[int] = None
+ people_in_temporary_shelters: Optional[int] = None
+ people_trapped: Optional[int] = None
+ people_missing: Optional[int] = None
+ people_displaced: Optional[int] = None
+ """
+ People who cannot return to the property
+ """
+ displacement_causes: Optional[list[str]] = None
+
+
+class InjuryIntent(Enum):
+ accidental = "accidental"
+ self_inflicted = "self_inflicted"
+ inflicted_by_other = "inflicted_by_other"
+ undetermined = "undetermined"
+
+
+class BodySite(BaseModel):
+ site: Optional[str] = None
+ injury_type: Optional[str] = None
+
+
+class CardiacArrest(BaseModel):
+ occurred: Optional[bool] = None
+ pre_arrival: Optional[bool] = None
+ witnessed: Optional[bool] = None
+ bystander_cpr: Optional[bool] = None
+ initial_rhythm: Optional[str] = None
+
+
+class HighestCareLevelOnScene(Enum):
+ first_responder = "first_responder"
+ emt_basic = "emt_basic"
+ emt_intermediate = "emt_intermediate"
+ paramedic = "paramedic"
+ physician = "physician"
+ other = "other"
+
+
+class PatientStatus(Enum):
+ improved = "improved"
+ unchanged = "unchanged"
+ worsened = "worsened"
+
+
+class Disposition(Enum):
+ treated_and_transported_by_fd = "treated_and_transported_by_fd"
+ transported_by_other_agency = "transported_by_other_agency"
+ treated_no_transport = "treated_no_transport"
+ refused_care = "refused_care"
+ dead_at_scene = "dead_at_scene"
+ transferred_care = "transferred_care"
+ other = "other"
+
+
+class EMSPatient(BaseModel):
+ """
+ Summary-level patient record; the ePCR is the clinical record
+ """
+
+ patient_ref_id: Optional[str] = None
+ nemsis_report_id: Optional[str] = None
+ age_approx: Optional[int] = None
+ date_of_birth: Optional[date_type] = None
+ sex: Optional[str] = None
+ chief_complaint: Optional[str] = None
+ provider_impression: Optional[str] = None
+ """
+ Provider's primary impression/assessment
+ """
+ injury_intent: Optional[InjuryIntent] = None
+ body_sites: Optional[list[BodySite]] = None
+ """
+ Injured body sites with injury type per site
+ """
+ procedures: Optional[list[str]] = None
+ """
+ Procedures performed on scene (CPR, oxygen, splinting, defibrillation)
+ """
+ cardiac_arrest: Optional[CardiacArrest] = None
+ safety_equipment_used: Optional[list[str]] = None
+ """
+ Safety equipment used by the patient (seat belt, airbag, helmet)
+ """
+ highest_care_level_on_scene: Optional[HighestCareLevelOnScene] = None
+ patient_status: Optional[PatientStatus] = None
+ at_patient_datetime: Optional[AwareDatetime] = None
+ transfer_of_care_datetime: Optional[AwareDatetime] = None
+ disposition: Optional[Disposition] = None
+ transported: Optional[bool] = None
+ hospital_destination: Optional[str] = None
+
+
+class IgnitionOrReleaseFirst(Enum):
+ ignition_first = "ignition_first"
+ release_first = "release_first"
+ no_fire = "no_fire"
+ undetermined = "undetermined"
+
+
+class ReleaseCause(Enum):
+ intentional = "intentional"
+ unintentional = "unintentional"
+ container_failure = "container_failure"
+ act_of_nature = "act_of_nature"
+ cause_under_investigation = "cause_under_investigation"
+ undetermined = "undetermined"
+
+
+class EquipmentInvolvedInRelease(BaseModel):
+ involved: Optional[bool] = None
+ equipment_type: Optional[str] = None
+ brand: Optional[str] = None
+ model: Optional[str] = None
+ year: Optional[int] = None
+
+
+class Disposition1(Enum):
+ """
+ Who the cleanup/scene was released to. Evacuee counts live in evacuation_displacement.
+ """
+
+ completed_by_fire_service = "completed_by_fire_service"
+ completed_with_fire_service_present = "completed_with_fire_service_present"
+ released_to_local_agency = "released_to_local_agency"
+ released_to_state_agency = "released_to_state_agency"
+ released_to_federal_agency = "released_to_federal_agency"
+ released_to_private_contractor = "released_to_private_contractor"
+ released_to_owner = "released_to_owner"
+ undetermined = "undetermined"
+
+
+class PhysicalState(Enum):
+ solid = "solid"
+ liquid = "liquid"
+ gas = "gas"
+ undetermined = "undetermined"
+
+
+class ReleasedInto(Enum):
+ air = "air"
+ water = "water"
+ soil = "soil"
+ contained_on_site = "contained_on_site"
+ sewer_drain = "sewer_drain"
+ other = "other"
+ undetermined = "undetermined"
+
+
+class HazmatMaterial(BaseModel):
+ name: Optional[str] = None
+ un_number: Optional[str] = None
+ dot_hazard_class: Optional[str] = None
+ """
+ DOT/UN hazard class and division, e.g. "3" or "2.1"
+ """
+ cas_number: Optional[str] = None
+ physical_state: Optional[PhysicalState] = None
+ container_type: Optional[str] = None
+ container_capacity: Optional[Quantity] = None
+ released: Optional[bool] = None
+ amount_released: Optional[Quantity] = None
+ released_into: Optional[ReleasedInto] = None
+ released_from_story: Optional[int] = None
+ released_inside_structure: Optional[bool] = None
+
+
+class Category1(Enum):
+ battery_energy_storage = "battery_energy_storage"
+ electric_vehicle = "electric_vehicle"
+ micromobility_device = "micromobility_device"
+ consumer_electronics = "consumer_electronics"
+ photovoltaic_system = "photovoltaic_system"
+ power_generation = "power_generation"
+ csst_gas_tubing = "csst_gas_tubing"
+ other = "other"
+
+
+class SourceOrTarget(Enum):
+ ignition_source = "ignition_source"
+ target_only = "target_only"
+ both = "both"
+ undetermined = "undetermined"
+
+
+class EmergingHazard(BaseModel):
+ """
+ Stored-energy and similar emerging hazards (NERIS emerging_hazard module)
+ """
+
+ category: Optional[Category1] = None
+ subtype: Optional[str] = None
+ source_or_target: Optional[SourceOrTarget] = None
+ suppression_approach: Optional[str] = None
+ reignition_occurred: Optional[bool] = None
+ ev_crash_involved: Optional[bool] = None
+ """
+ Electric vehicle was involved in a crash
+ """
+ lightning_suspected: Optional[bool] = None
+ """
+ CSST cases, lightning as suspected cause
+ """
+ notes: Optional[str] = None
+
+
+class CaseStatus(Enum):
+ open = "open"
+ closed_with_arrest = "closed_with_arrest"
+ closed_exceptional = "closed_exceptional"
+ closed = "closed"
+ inactive = "inactive"
+
+
+class AgencyReferredTo(BaseModel):
+ name: Optional[str] = None
+ case_number: Optional[str] = None
+
+
+class IncendiaryDevice(BaseModel):
+ container: Optional[str] = None
+ ignition_delay_mechanism: Optional[str] = None
+ fuel: Optional[str] = None
+
+
+class MaterialAvailability(Enum):
+ transported_to_scene = "transported_to_scene"
+ available_at_scene = "available_at_scene"
+ undetermined = "undetermined"
+
+
+class Subject(BaseModel):
+ age: Optional[int] = None
+ sex: Optional[str] = None
+ family_type: Optional[str] = None
+ risk_factors: Optional[list[str]] = None
+ disposition: Optional[str] = None
+
+
+class JuvenileFiresetter(BaseModel):
+ involved: Optional[bool] = None
+ subjects: Optional[list[Subject]] = None
+
+
+class Arson(BaseModel):
+ suspected: Optional[bool] = None
+ confirmed: Optional[bool] = None
+ motivation_factors: Optional[list[str]] = None
+ """
+ Suspected motivations (fraud, intimidation, concealment, thrill, protest)
+ """
+ group_involvement: Optional[str] = None
+ entry_method: Optional[str] = None
+ extent_of_involvement_on_arrival: Optional[str] = None
+ incendiary_device: Optional[IncendiaryDevice] = None
+ material_availability: Optional[MaterialAvailability] = None
+ initial_observations: Optional[list[str]] = None
+ """
+ Scene observations (forced entry, doors locked, security system state)
+ """
+ other_indicators: Optional[list[str]] = None
+ """
+ Contextual indicators (vacancy, for sale, insurance change, financial problems)
+ """
+ juvenile_firesetter: Optional[JuvenileFiresetter] = None
+
+
+class Role(Enum):
+ owner = "owner"
+ occupant = "occupant"
+ tenant = "tenant"
+ reporting_party = "reporting_party"
+ responsible_party = "responsible_party"
+ witness = "witness"
+ business_representative = "business_representative"
+ insurance_holder = "insurance_holder"
+ other = "other"
+
+
+class Insurance(BaseModel):
+ insured: Optional[bool] = None
+ company: Optional[str] = None
+ policy_number: Optional[str] = None
+
+
+class PersonInvolved(BaseModel):
+ """
+ Owner, occupant or other party connected to the incident (not casualties)
+ """
+
+ role: Optional[Role] = None
+ name: Optional[str] = None
+ business_name: Optional[str] = None
+ address: Optional[str] = None
+ same_address_as_incident: Optional[bool] = None
+ phone: Optional[str] = None
+ email: Optional[str] = None
+ insurance: Optional[Insurance] = None
+
+
+class Involvement(Enum):
+ ignition_source_and_burned = "ignition_source_and_burned"
+ ignition_source_not_burned = "ignition_source_not_burned"
+ burned_not_ignition_source = "burned_not_ignition_source"
+ collision = "collision"
+ hazmat_release = "hazmat_release"
+ rescued_from = "rescued_from"
+ threatened_only = "threatened_only"
+ other = "other"
+
+
+class PropertyType(Enum):
+ passenger_car = "passenger_car"
+ motorcycle = "motorcycle"
+ bus = "bus"
+ heavy_goods_vehicle = "heavy_goods_vehicle"
+ agricultural_vehicle = "agricultural_vehicle"
+ construction_vehicle = "construction_vehicle"
+ recreational_vehicle = "recreational_vehicle"
+ train_rail = "train_rail"
+ boat_vessel = "boat_vessel"
+ aircraft = "aircraft"
+ trailer = "trailer"
+ mobile_home = "mobile_home"
+ other = "other"
+
+
+class FuelType(Enum):
+ petrol_gasoline = "petrol_gasoline"
+ diesel = "diesel"
+ electric = "electric"
+ hybrid = "hybrid"
+ hydrogen = "hydrogen"
+ cng_lpg = "cng_lpg"
+ other = "other"
+ undetermined = "undetermined"
+
+
+class Extrication(BaseModel):
+ """
+ Extrication from this vehicle (UK IRS RTC block)
+ """
+
+ performed: Optional[bool] = None
+ method: Optional[str] = None
+ vehicle_position: Optional[str] = None
+ time_taken_minutes: Optional[int] = None
+
+
+class MobileProperty(BaseModel):
+ involvement: Optional[Involvement] = None
+ property_type: Optional[PropertyType] = None
+ make: Optional[str] = None
+ model: Optional[str] = None
+ year: Optional[int] = None
+ fuel_type: Optional[FuelType] = None
+ license_plate: Optional[str] = None
+ license_region: Optional[str] = None
+ """
+ Registering state/province/country
+ """
+ vin: Optional[str] = None
+ dot_icc_number: Optional[str] = None
+ reported_stolen: Optional[bool] = None
+ appeared_abandoned: Optional[bool] = None
+ occupants: Optional[int] = None
+ extrication: Optional[Extrication] = None
+ """
+ Extrication from this vehicle (UK IRS RTC block)
+ """
+
+
+class EstimateMethod(Enum):
+ rough_estimate = "rough_estimate"
+ owner_estimate = "owner_estimate"
+ insurance_assessment = "insurance_assessment"
+ investigator_assessment = "investigator_assessment"
+ official_valuation = "official_valuation"
+ other = "other"
+
+
+class Losses(BaseModel):
+ """
+ Monetary values. Currency follows the reporting agency.
+ """
+
+ no_loss: Optional[bool] = None
+ property_loss: Optional[Money] = None
+ contents_loss: Optional[Money] = None
+ pre_incident_property_value: Optional[Money] = None
+ pre_incident_contents_value: Optional[Money] = None
+ property_saved: Optional[Money] = None
+ other_costs: Optional[Money] = None
+ estimate_method: Optional[EstimateMethod] = None
+
+
+class WeatherType(Enum):
+ clear = "clear"
+ cloudy = "cloudy"
+ rain = "rain"
+ snow_ice = "snow_ice"
+ fog = "fog"
+ high_winds = "high_winds"
+ thunderstorm_lightning = "thunderstorm_lightning"
+ extreme_heat = "extreme_heat"
+ extreme_cold = "extreme_cold"
+ other = "other"
+
+
+class WeatherReading(BaseModel):
+ datetime: Optional[AwareDatetime] = None
+ temperature_c: Optional[float] = None
+ relative_humidity_percent: Optional[float] = None
+ wind_speed_kph: Optional[float] = None
+ wind_gusts_kph: Optional[float] = None
+ wind_direction: Optional[str] = None
+ haines_index: Optional[int] = None
+
+
+class EnvironmentalImpact(BaseModel):
+ habitat_affected_ha: Optional[float] = None
+ watershed_impact: Optional[str] = None
+ soil_erosion_risk: Optional[str] = None
+ sensitive_species_affected: Optional[list[str]] = None
+ air_quality_impact: Optional[str] = None
+ water_body_affected: Optional[bool] = None
+
+
+class InfrastructureItem(BaseModel):
+ infrastructure_type: Optional[str] = None
+ unit: Optional[str] = None
+ quantity: Optional[float] = None
+ severity: Optional[str] = None
+
+
+class NearMissEvent(BaseModel):
+ description: Optional[str] = None
+ date: Optional[date_type] = None
+ contributing_factors: Optional[list[str]] = None
+ lessons_learned: Optional[str] = None
+ corrective_action: Optional[str] = None
+
+
+class AttackType(Enum):
+ verbal_abuse = "verbal_abuse"
+ physical_no_weapon = "physical_no_weapon"
+ weapon = "weapon"
+ objects_thrown = "objects_thrown"
+ vehicle_used = "vehicle_used"
+ other = "other"
+
+
+class AttacksOnPersonnel(BaseModel):
+ """
+ Attacks on responders travelling to, at, or from the incident (UK IRS 3.10-3.13)
+ """
+
+ occurred: Optional[bool] = None
+ attack_type: Optional[AttackType] = None
+ serious_injuries: Optional[int] = None
+ slight_injuries: Optional[int] = None
+
+
+class NearMissAndSafety(BaseModel):
+ near_miss_events: Optional[list[NearMissEvent]] = None
+ safety_breaches: Optional[int] = None
+ maydays_count: Optional[int] = None
+ attacks_on_personnel: Optional[AttacksOnPersonnel] = None
+ """
+ Attacks on responders travelling to, at, or from the incident (UK IRS 3.10-3.13)
+ """
+ weather_related_risks: Optional[list[str]] = None
+
+
+class ReportVersion(Enum):
+ initial = "initial"
+ update = "update"
+ final = "final"
+
+
+class ComplexityLevel1(Enum):
+ type_5 = "type_5"
+ type_4 = "type_4"
+ type_3 = "type_3"
+ type_2 = "type_2"
+ type_1 = "type_1"
+
+
+class ThreatManagementEnum(Enum):
+ no_likely_threat = "no_likely_threat"
+ potential_future_threat = "potential_future_threat"
+ mass_notifications_in_progress = "mass_notifications_in_progress"
+ mass_notifications_completed = "mass_notifications_completed"
+ no_evacuations_imminent = "no_evacuations_imminent"
+ planning_for_evacuation = "planning_for_evacuation"
+ planning_for_shelter_in_place = "planning_for_shelter_in_place"
+ evacuations_in_progress = "evacuations_in_progress"
+ shelter_in_place_in_progress = "shelter_in_place_in_progress"
+ repopulation_in_progress = "repopulation_in_progress"
+ area_restriction_in_effect = "area_restriction_in_effect"
+ other = "other"
+
+
+class ProjectedActivity(BaseModel):
+ """
+ Projected incident activity by timeframe
+ """
+
+ next_12_hours: Optional[str] = None
+ next_24_hours: Optional[str] = None
+ next_48_hours: Optional[str] = None
+ next_72_hours: Optional[str] = None
+ beyond_72_hours: Optional[str] = None
+
+
+class SituationStatus(BaseModel):
+ """
+ Evolving large-incident status for sitreps (ICS-209 shape). Person counts
+ live in casualties and evacuation_displacement; structure counts live in
+ structure. Mappers compose the ICS-209 matrices from those sections.
+
+ """
+
+ report_version: Optional[ReportVersion] = None
+ report_number: Optional[int] = None
+ period_from: Optional[AwareDatetime] = None
+ period_to: Optional[AwareDatetime] = None
+ complexity_level: Optional[ComplexityLevel1] = None
+ imt_type: Optional[str] = None
+ """
+ Incident management organization (single resource, type 3 IMT, unified command)
+ """
+ significant_events: Optional[str] = None
+ primary_hazards: Optional[str] = None
+ """
+ Primary materials or hazards involved
+ """
+ threat_management: Optional[list[ThreatManagementEnum]] = None
+ """
+ Active protective actions
+ """
+ projected_activity: Optional[ProjectedActivity] = None
+ """
+ Projected incident activity by timeframe
+ """
+ strategic_objectives: Optional[str] = None
+ threat_summary: Optional[str] = None
+ critical_resource_needs: Optional[list[str]] = None
+ planned_actions: Optional[str] = None
+ projected_final_size_ha: Optional[float] = None
+ anticipated_completion_date: Optional[date_type] = None
+ demobilization_start_date: Optional[date_type] = None
+ costs_to_date: Optional[Money] = None
+ projected_final_cost: Optional[Money] = None
+
+
+class LessonsLearned(BaseModel):
+ successful_tactics: Optional[list[str]] = None
+ areas_for_improvement: Optional[list[str]] = None
+ recommendations: Optional[list[str]] = None
+
+
+class MopUp(BaseModel):
+ percent_complete: Optional[int] = None
+ estimated_completion_date: Optional[date_type] = None
+ personnel_assigned: Optional[int] = None
+
+
+class Rehabilitation(BaseModel):
+ erosion_control_ha: Optional[float] = None
+ reseeding_ha: Optional[float] = None
+ hazard_tree_removal_required: Optional[bool] = None
+
+
+class FollowUp(BaseModel):
+ mop_up: Optional[MopUp] = None
+ rehabilitation: Optional[Rehabilitation] = None
+ next_inspection_date: Optional[date_type] = None
+
+
+class PeriodicReporting(BaseModel):
+ contributes_to_monthly_report: Optional[bool] = None
+ contributes_to_quarterly_report: Optional[bool] = None
+ contributes_to_annual_report: Optional[bool] = None
+ neris_submitted: Optional[bool] = None
+ neris_submitted_at: Optional[AwareDatetime] = None
+ state_submitted: Optional[bool] = None
+ state_submitted_at: Optional[AwareDatetime] = None
+
+
+class AttachmentRef(BaseModel):
+ ref_id: Optional[UUID] = None
+ filename: Optional[str] = None
+ content_type: Optional[str] = None
+ size_bytes: Optional[int] = None
+
+
+class Attachments(BaseModel):
+ maps: Optional[bool] = None
+ photos_count: Optional[int] = None
+ weather_charts: Optional[bool] = None
+ resource_tracking_logs: Optional[bool] = None
+ incident_action_plans: Optional[bool] = None
+ attachment_refs: Optional[list[AttachmentRef]] = None
+
+
+class ExtractionMetadata(BaseModel):
+ extract_id: Optional[UUID] = None
+ input_id: Optional[UUID] = None
+ input_type: Optional[InputType] = None
+ extracted_at: Optional[AwareDatetime] = None
+ llm_model: Annotated[Optional[str], Field(None, examples=["llama3:8b"])]
+ confidence_score: Annotated[Optional[float], Field(None, ge=0.0, le=1.0)]
+ """
+ Overall confidence score from the LLM extraction (0.0-1.0)
+ """
+ completeness: Optional[Completeness] = None
+
+
+class SubmissionLogItem(BaseModel):
+ form_type: Optional[FormType] = None
+ submitted_at: Optional[AwareDatetime] = None
+ submitted_to: Optional[str] = None
+
+
+class ReportMetadata(BaseModel):
+ report_id: Annotated[Optional[str], Field(None, examples=["FF-2024-CA-0157"])]
+ incident_number: Annotated[Optional[str], Field(None, examples=["CA-SQF-2024-0421"])]
+ """
+ Department's own incident number
+ """
+ external_ids: Optional[list[ExternalId]] = None
+ """
+ Identifiers for this incident in external systems (CAD event, IRWIN, state registry, partner agency)
+ """
+ report_date: Optional[date_type] = None
+ report_time: Optional[time_type] = None
+ report_status: Optional[ReportStatus] = None
+ reporting_unit: Optional[ReportingUnit] = None
+ prepared_by: Optional[list[Personnel]] = None
+ """
+ Member(s) making the report
+ """
+ officer_in_charge: Optional[Personnel] = None
+ reviewed_by: Optional[list[Reviewer]] = None
+ submission_log: Optional[list[SubmissionLogItem]] = None
+
+
+class IncidentType(BaseModel):
+ primary: Optional[bool] = None
+ """
+ Only one entry may be primary
+ """
+ category: Optional[IncidentCategory] = None
+ subcategory: Optional[str] = None
+ """
+ Free-form specific type within the category
+ """
+ codes: Optional[list[CodeRef]] = None
+ """
+ This type expressed in external coding schemes
+ """
+
+
+class RespondingAgencies(BaseModel):
+ primary_agency: Optional[str] = None
+ all_agencies: Optional[list[RespondingAgency]] = None
+ mutual_aid_activated: Optional[bool] = None
+ aid_direction: Optional[AidDirection] = None
+ aid_type: Optional[AidType] = None
+ non_fd_entities: Optional[list[str]] = None
+ """
+ Non fire department entities that assisted (utility, red cross, public works)
+ """
+ unified_command: Optional[bool] = None
+ incident_commander: Optional[IncidentCommander] = None
+
+
+class Fire(BaseModel):
+ cause_category: Optional[CauseCategory] = None
+ cause_specific: Optional[str] = None
+ cause_codes: Optional[list[CodeRef]] = None
+ cause_certainty: Optional[CauseCertainty] = None
+ arson_suspected: Optional[bool] = None
+ area_of_origin: Optional[str] = None
+ """
+ Room or area where the fire began
+ """
+ heat_source: Optional[str] = None
+ """
+ What provided the heat that started the fire
+ """
+ ignition_power_source: Optional[str] = None
+ """
+ What powered the ignition source (mains, battery, gas, open flame)
+ """
+ item_first_ignited: Optional[str] = None
+ material_first_ignited: Optional[str] = None
+ multiple_seats_of_fire: Optional[bool] = None
+ """
+ More than one independent point of origin (arson indicator)
+ """
+ human_factors: Optional[list[HumanFactor]] = None
+ """
+ Human factors contributing to ignition
+ """
+ person_involved_age: Optional[int] = None
+ """
+ Estimated age of person whose age was a factor
+ """
+ person_involved_sex: Optional[str] = None
+ contributing_factors: Optional[list[str]] = None
+ """
+ Non-human factors contributing to ignition
+ """
+ equipment_involved: Optional[EquipmentInvolved] = None
+ """
+ Equipment involved in ignition, if any
+ """
+ on_site_materials: Optional[list[str]] = None
+ """
+ Significant commercial/industrial/agricultural materials on the property
+ """
+ fuel_types: Optional[list[str]] = None
+ fire_spread_directions: Optional[list[str]] = None
+ rate_of_spread: Optional[RateOfSpread] = None
+ rate_of_spread_m_per_min: Optional[float] = None
+ flame_length_m: Optional[float] = None
+ spotting_distance_km: Optional[float] = None
+ unusual_behaviors: Optional[list[str]] = None
+ rapid_growth_cause: Optional[str] = None
+ """
+ Cause of any rapid fire growth (UK IRS 8.8)
+ """
+ fire_suppression_factors: Optional[list[str]] = None
+ """
+ Factors that helped or hindered suppression
+ """
+ suppression_operations: Optional[SuppressionOperations] = None
+
+
+class CivilianCasualty(BaseModel):
+ name: Optional[str] = None
+ age: Optional[int] = None
+ date_of_birth: Optional[date_type] = None
+ sex: Optional[str] = None
+ race_ethnicity: Optional[str] = None
+ """
+ Only where the target jurisdiction collects it (US, UK)
+ """
+ affiliation: Optional[Affiliation] = None
+ injury_datetime: Optional[AwareDatetime] = None
+ injury_type: Optional[str] = None
+ """
+ Nature of the injury (burns, smoke inhalation, trauma)
+ """
+ primary_symptom: Optional[str] = None
+ primary_body_part: Optional[str] = None
+ severity: Optional[InjurySeverity] = None
+ cause: Optional[str] = None
+ human_factors: Optional[list[HumanFactor1]] = None
+ contributing_factors: Optional[list[str]] = None
+ activity_when_injured: Optional[ActivityWhenInjured] = None
+ location_at_ignition: Optional[LocationAtIgnition] = None
+ story_at_start: Optional[int] = None
+ story_where_injured: Optional[int] = None
+ location_where_found: Optional[str] = None
+ cause_of_failure_to_escape: Optional[str] = None
+ """
+ Why the person could not escape (Canada NFID casualty file)
+ """
+ disposition: Optional[str] = None
+ transported: Optional[bool] = None
+ hospital: Optional[str] = None
+ fatal_circumstances: Optional[str] = None
+ death_certificate_reconciled: Optional[bool] = None
+
+
+class EMS(BaseModel):
+ ems_response_required: Optional[bool] = None
+ patients: Optional[list[EMSPatient]] = None
+ total_patients: Optional[int] = None
+ ems_agency_responded: Optional[str] = None
+ nemsis_report_required: Optional[bool] = None
+ nemsis_report_ids: Optional[list[str]] = None
+ """
+ Linked ePCR ids (NEMSIS eRecord.01); the full ePCR stays in the EMS system
+ """
+
+
+class Hazmat(BaseModel):
+ involved: Optional[bool] = None
+ materials: Optional[list[HazmatMaterial]] = None
+ ignition_or_release_first: Optional[IgnitionOrReleaseFirst] = None
+ release_cause: Optional[ReleaseCause] = None
+ release_factors: Optional[list[str]] = None
+ mitigation_factors: Optional[list[str]] = None
+ """
+ Factors or impediments that affected mitigation
+ """
+ actions_taken: Optional[list[str]] = None
+ """
+ Hazmat-specific actions (identification, containment, decontamination, neutralization)
+ """
+ equipment_involved_in_release: Optional[EquipmentInvolvedInRelease] = None
+ area_affected: Optional[Quantity] = None
+ area_evacuated: Optional[Quantity] = None
+ epa_reportable_quantity_exceeded: Optional[bool] = None
+ disposition: Optional[Disposition1] = None
+ """
+ Who the cleanup/scene was released to. Evacuee counts live in evacuation_displacement.
+ """
+
+
+class Investigation(BaseModel):
+ investigation_needed: Optional[bool] = None
+ """
+ Incident commander's assessment that formal investigation is required
+ """
+ investigation_types: Optional[list[str]] = None
+ """
+ Types of investigation completed (origin_and_cause, arson, insurance, forensic)
+ """
+ investigation_ongoing: Optional[bool] = None
+ case_status: Optional[CaseStatus] = None
+ agency_referred_to: Optional[AgencyReferredTo] = None
+ law_enforcement_notified: Optional[bool] = None
+ evidence_collected: Optional[bool] = None
+ laboratory_used: Optional[str] = None
+ nibrs_report_required: Optional[bool] = None
+ arson: Optional[Arson] = None
+ notes: Optional[str] = None
+
+
+class Weather(BaseModel):
+ on_arrival: Optional[WeatherReading] = None
+ worst_conditions: Optional[WeatherReading] = None
+ weather_type: Optional[WeatherType] = None
+ factors_influencing_fire: Optional[list[str]] = None
+
+
+class InfrastructureImpact(BaseModel):
+ items: Optional[list[InfrastructureItem]] = None
+
+
+class Incident(BaseModel):
+ name: Optional[str] = None
+ """
+ Human-readable incident name
+ """
+ types: Optional[list[IncidentType]] = None
+ special_modifiers: Annotated[
+ Optional[list[str]], Field(None, examples=[["mass_casualty", "major_incident"]])
+ ]
+ """
+ Magnitude or class tags qualifying the incident (NERIS special modifiers)
+ """
+ false_alarm: Optional[FalseAlarm] = None
+ """
+ Populated when the final type is a false alarm
+ """
+ special_service_type: Optional[str] = None
+ """
+ Non-fire service call subtype (lift release, lock-in, flooding, animal assist, co-response)
+ """
+ chimney_fire: Optional[bool] = None
+ """
+ Flame confined to a chimney (UK IRS 3.9)
+ """
+ timezone: Annotated[Optional[str], Field(None, examples=["America/Los_Angeles"])]
+ """
+ IANA timezone of the incident, for local wall-clock rendering
+ """
+ discovered_datetime: Optional[AwareDatetime] = None
+ how_discovered: Optional[str] = None
+ """
+ How the fire or emergency was first discovered
+ """
+ delay_ignition_to_discovery: Optional[DelayIgnitionToDiscovery] = None
+ delay_discovery_to_call: Optional[DelayDiscoveryToCall] = None
+ start_datetime: Optional[AwareDatetime] = None
+ """
+ Estimated ignition or emergency start
+ """
+ alarm_datetime: Optional[AwareDatetime] = None
+ """
+ Department alerted / alarm time
+ """
+ first_arrival_datetime: Optional[AwareDatetime] = None
+ """
+ First unit on scene
+ """
+ command_established_datetime: Optional[AwareDatetime] = None
+ sizeup_complete_datetime: Optional[AwareDatetime] = None
+ water_on_fire_datetime: Optional[AwareDatetime] = None
+ primary_search_begin_datetime: Optional[AwareDatetime] = None
+ primary_search_complete_datetime: Optional[AwareDatetime] = None
+ knocked_down_datetime: Optional[AwareDatetime] = None
+ containment_datetime: Optional[AwareDatetime] = None
+ controlled_datetime: Optional[AwareDatetime] = None
+ extrication_complete_datetime: Optional[AwareDatetime] = None
+ suppression_complete_datetime: Optional[AwareDatetime] = None
+ loss_stopped_datetime: Optional[AwareDatetime] = None
+ stop_message_datetime: Optional[AwareDatetime] = None
+ """
+ Stop / situation-under-control message to control room (UK IRS 2.5)
+ """
+ cleared_datetime: Optional[AwareDatetime] = None
+ """
+ Last unit cleared the scene
+ """
+ closed_datetime: Optional[AwareDatetime] = None
+ """
+ Incident administratively closed
+ """
+ total_duration_hours: Optional[float] = None
+ alarm_level: Optional[int] = None
+ """
+ Number of alarms / escalation level
+ """
+ shift_or_platoon: Optional[str] = None
+ district: Optional[str] = None
+ """
+ Response district or box area
+ """
+ people_present: Optional[bool] = None
+ """
+ Whether people were present at the location at the time
+ """
+ animals_rescued: Optional[int] = None
+ animals_deceased: Optional[int] = None
+ narrative: Optional[str] = None
+ """
+ Free text summary of the incident
+ """
+ narrative_impediment: Optional[str] = None
+ """
+ Obstacles that impacted the response (NERIS)
+ """
+ narrative_outcome: Optional[str] = None
+ """
+ Final disposition of the incident (NERIS)
+ """
+ raw_transcript: Optional[str] = None
+ """
+ Original voice or text input verbatim
+ """
+
+
+class Casualties(BaseModel):
+ """
+ Injuries and deaths. Uninjured rescues live in rescues[].
+ """
+
+ civilian: Optional[list[CivilianCasualty]] = None
+ responder: Optional[list[ResponderCasualty]] = None
+ total_civilian_injuries: Optional[int] = None
+ total_civilian_fatalities: Optional[int] = None
+ total_responder_injuries: Optional[int] = None
+ total_responder_fatalities: Optional[int] = None
+
+
+class IncidentContract(BaseModel):
+ """
+ The FireForm incident contract. This is the superset schema
+ containing every field any downstream form could need. Form-specific
+ mappers select only the relevant fields for each agency template.
+ Stored as a single JSONB document; queryable stats are promoted to
+ IncidentRecord columns server-side.
+
+ """
+
+ schema_version: Annotated[Optional[str], Field(None, examples=["1.1.0"])]
+ """
+ Schema version identifier
+ """
+ schema_name: Optional[SchemaName] = None
+ """
+ Schema name identifier
+ """
+ extraction_metadata: Optional[ExtractionMetadata] = None
+ report_metadata: Optional[ReportMetadata] = None
+ incident: Optional[Incident] = None
+ dispatch: Optional[Dispatch] = None
+ location: Optional[Location] = None
+ actions_taken: Optional[ActionsTaken] = None
+ responding_agencies: Optional[RespondingAgencies] = None
+ units: Optional[list[UnitResponse]] = None
+ """
+ Per-unit (apparatus/resource) response records with timestamps
+ """
+ resources_summary: Optional[ResourcesSummary] = None
+ fire: Optional[Fire] = None
+ explosion: Optional[Explosion] = None
+ risk_reduction: Optional[RiskReduction] = None
+ structure: Optional[Structure] = None
+ wildland: Optional[Wildland] = None
+ exposures: Optional[list[Exposure]] = None
+ """
+ Properties beyond the origin affected by spread of the incident
+ """
+ casualties: Optional[Casualties] = None
+ rescues: Optional[list[Rescue]] = None
+ """
+ Rescues and assisted evacuations, with or without injury
+ """
+ evacuation_displacement: Optional[EvacuationDisplacement] = None
+ ems: Optional[EMS] = None
+ hazmat: Optional[Hazmat] = None
+ emerging_hazards: Optional[list[EmergingHazard]] = None
+ """
+ Battery, EV, solar and other stored-energy hazards (NERIS)
+ """
+ investigation: Optional[Investigation] = None
+ persons_involved: Optional[list[PersonInvolved]] = None
+ """
+ Owners, occupants and other parties connected to the incident
+ """
+ mobile_property: Optional[list[MobileProperty]] = None
+ """
+ Vehicles, vessels and other mobile property involved
+ """
+ losses: Optional[Losses] = None
+ weather: Optional[Weather] = None
+ environmental_impact: Optional[EnvironmentalImpact] = None
+ infrastructure_impact: Optional[InfrastructureImpact] = None
+ near_miss_and_safety: Optional[NearMissAndSafety] = None
+ situation_status: Optional[SituationStatus] = None
+ lessons_learned: Optional[LessonsLearned] = None
+ follow_up: Optional[FollowUp] = None
+ periodic_reporting: Optional[PeriodicReporting] = None
+ attachments: Optional[Attachments] = None
+ custom_fields: Optional[dict[str, Any]] = None
+ """
+ Agency-local fields with no contract home (NFIRS special studies,
+ IRS local options and similar). Keys are agency-defined strings,
+ values are scalars. Passed through to mappers untouched.
+
+ """
diff --git a/app/api/schemas/incidents.py b/app/api/schemas/incidents.py
new file mode 100644
index 00000000..4319f867
--- /dev/null
+++ b/app/api/schemas/incidents.py
@@ -0,0 +1,166 @@
+"""Contract Layer 4 incident schemas (contracts/schemas/incident-record.yaml).
+
+The DB stores the promoted analytics as flat columns on the incident row, but
+the contract nests them under an `analytics` object. `IncidentAnalytics` owns
+that reshaping so routes and services never assemble the block by hand.
+"""
+
+from __future__ import annotations
+
+from datetime import datetime
+from uuid import UUID
+
+from pydantic import BaseModel, ConfigDict, Field
+
+from app.api.schemas.common import Pagination
+from app.api.schemas.enums import FormStatus, IncidentCategory, ReportStatus
+from app.api.schemas.form_generation import FormRecord
+from app.api.schemas.incident_contract import IncidentContract
+
+
+# ---------------------------------------------------------------------------
+# Requests
+# ---------------------------------------------------------------------------
+
+class CreateIncidentRequest(BaseModel):
+ """POST /incidents body.
+
+ Finalizes the draft incident that was created when the extraction
+ completed. `extract_id` resolves to that existing row; a second row is
+ never created for the same extraction.
+ """
+
+ extract_id: UUID
+ incident_number: str | None = None
+ tags: list[str] | None = None
+
+
+class UpdateIncidentRequest(BaseModel):
+ """PATCH /incidents/{id} body.
+
+ A partial update: only the fields actually present in the request body are
+ applied, so omitting a field leaves it untouched rather than nulling it.
+ Callers read that distinction off `model_fields_set`, which is why no
+ field here carries a meaningful default.
+ """
+
+ status: ReportStatus | None = None
+ tags: list[str] | None = None
+ incident_number: str | None = None
+ notes: str | None = None
+
+
+# ---------------------------------------------------------------------------
+# Responses
+# ---------------------------------------------------------------------------
+
+class IncidentAnalytics(BaseModel):
+ """Read-only stats promoted out of the incident contract.
+
+ Recomputed server-side by `app.services.incidents.promote` on every change
+ to the document. Clients never write these.
+ """
+
+ model_config = ConfigDict(from_attributes=True)
+
+ city: str | None = None
+ state: str | None = None
+ country: str | None = None
+ civilian_injuries: int | None = None
+ civilian_fatalities: int | None = None
+ responder_injuries: int | None = None
+ responder_fatalities: int | None = None
+ people_rescued: int | None = None
+ people_evacuated: int | None = None
+ structures_destroyed: int | None = None
+ area_burned_ha: float | None = None
+ total_loss_amount: float | None = None
+ total_loss_currency: str | None = None
+ call_to_arrival_seconds: int | None = None
+ turnout_seconds_first_unit: int | None = None
+ travel_seconds_first_unit: int | None = None
+ on_scene_duration_seconds: int | None = None
+
+
+class GeneratedForm(BaseModel):
+ """One entry in `forms_generated`, the at-a-glance form summary."""
+
+ form_id: UUID
+ # Open string rather than the FormType enum, matching FormRecord: a
+ # registry can hold form types the closed enum does not know about yet.
+ form_type: str
+ status: FormStatus
+
+
+class IncidentRecord(BaseModel):
+ """The incident record without its contract document or full form rows."""
+
+ incident_id: UUID
+ extract_id: UUID
+ incident_number: str | None = None
+ status: ReportStatus
+ incident_name: str | None = None
+ incident_type: str | None = None
+ incident_category: IncidentCategory | None = None
+ incident_datetime: datetime | None = None
+ analytics: IncidentAnalytics | None = None
+ forms_generated: list[GeneratedForm] = Field(default_factory=list)
+ tags: list[str] = Field(default_factory=list)
+ notes: str | None = None
+ created_at: datetime
+ updated_at: datetime
+ deleted_at: datetime | None = None
+
+
+class SubmissionLogEntry(BaseModel):
+ """One agency submission, read out of the contract document.
+
+ Stays empty until the submission layer exists; nothing writes it today.
+ """
+
+ form_type: str | None = None
+ submitted_at: datetime | None = None
+ submitted_to: str | None = None
+ status: str | None = None
+
+
+class IncidentRecordFull(IncidentRecord):
+ """GET /incidents/{id}: the record plus everything hanging off it."""
+
+ incident_contract: IncidentContract | None = None
+ forms: list[FormRecord] = Field(default_factory=list)
+ submission_log: list[SubmissionLogEntry] = Field(default_factory=list)
+
+
+class IncidentListItem(BaseModel):
+ """One row of GET /incidents.
+
+ Deliberately flatter than IncidentRecord: a list view needs the location
+ columns but not the whole analytics block, and a form count rather than
+ the forms themselves.
+ """
+
+ incident_id: UUID
+ incident_number: str | None = None
+ status: ReportStatus
+ incident_name: str | None = None
+ incident_type: str | None = None
+ incident_category: IncidentCategory | None = None
+ incident_datetime: datetime | None = None
+ city: str | None = None
+ country: str | None = None
+ forms_count: int = 0
+ created_at: datetime
+
+
+class IncidentListResponse(BaseModel):
+ data: list[IncidentListItem] = Field(default_factory=list)
+ pagination: Pagination
+
+
+class DeleteIncidentResponse(BaseModel):
+ """200 body for DELETE /incidents/{id}. The row is never removed."""
+
+ incident_id: UUID
+ deleted_at: datetime
+ recoverable: bool = True
diff --git a/app/api/schemas/system.py b/app/api/schemas/system.py
index bafa5cfc..7c0db95d 100644
--- a/app/api/schemas/system.py
+++ b/app/api/schemas/system.py
@@ -1,32 +1,21 @@
from pydantic import BaseModel
-class ModelInfo(BaseModel):
- name: str
- size_gb: float
- quantization: str | None = None
- loaded: bool
-
-
-class CurrentLoad(BaseModel):
- active_requests: int
- queued_requests: int
-
-
class ComponentHealth(BaseModel):
status: str
response_time_ms: int | None = None
detail: str | None = None
- model_loaded: str | None = None
- ollama_version: str | None = None
- models_available: list[ModelInfo] | None = None
- current_load: CurrentLoad | None = None
disk_free_gb: float | None = None
+ provider: str | None = None
+ model: str | None = None
+ external: bool | None = None
+ probed: bool | None = None
+ models_available: list[str] | None = None
class HealthComponents(BaseModel):
database: ComponentHealth
- ollama: ComponentHealth
+ llm: ComponentHealth
whisper: ComponentHealth
storage: ComponentHealth
@@ -43,3 +32,32 @@ class SchemaVersion(BaseModel):
released_at: str
changelog: str | None = None
breaking_changes: bool | None = None
+
+
+class SchemaFieldEntry(BaseModel):
+ """One leaf field of the incident contract, as the catalog exposes it
+ (contracts/schemas/template-record.yaml#/SchemaFieldEntry).
+
+ Array hops show as `[]`, so a person's address reads
+ `persons_involved[].address`.
+ """
+
+ path: str
+ label: str | None = None
+ field_type: str
+ section: str
+ description: str | None = None
+ enum_values: list[str] | None = None
+ pii: bool = False
+ aliases: list[str] = []
+ # Only set on search results, absent when the whole catalog is listed.
+ score: float | None = None
+
+
+class SchemaFieldSearchResponse(BaseModel):
+ """GET /schema/fields response (path/system.yaml#/schema_fields)."""
+
+ query: str | None = None
+ total: int
+ schema_version: str | None = None
+ fields: list[SchemaFieldEntry]
diff --git a/app/api/schemas/templates.py b/app/api/schemas/templates.py
index df39832b..a192aaf4 100644
--- a/app/api/schemas/templates.py
+++ b/app/api/schemas/templates.py
@@ -1,38 +1,266 @@
-from pydantic import BaseModel
+import re
+from datetime import date, datetime
+from uuid import UUID
-class TemplateCreate(BaseModel):
- name: str
- pdf_path: str
- fields: dict
+from pydantic import BaseModel, Field, field_validator, model_validator
+from app.api.schemas.enums import (
+ DetectionStatus,
+ FieldSource,
+ TemplateFieldType,
+ TemplateStatus,
+ TextAlign,
+)
-class MakeFillableRequest(BaseModel):
- pdf_path: str
+_HEX_COLOR = re.compile(r"^#[0-9A-Fa-f]{6}$")
+_FORM_TYPE = re.compile(r"^[a-z0-9_-]+$")
-class MakeFillableResponse(BaseModel):
- pdf_path: str
- field_count: int | None = None
+# ---------------------------------------------------------------------------
+# Contract Layer 6 schemas (contracts/schemas/template-record.yaml)
+# ---------------------------------------------------------------------------
+class TemplateFieldLayout(BaseModel):
+ """Visual placement of a field on the PDF (schemas/template-record.yaml#/TemplateFieldLayout).
-class TemplateResponse(BaseModel):
- id: int
- name: str
- pdf_path: str
- fields: dict
- field_count: int | None = None
+ Coordinates are PDF points with the origin at the bottom-left of the page:
+ box = start (x, y) .. end (x + width, y + height).
+ """
- class Config:
- from_attributes = True
+ page: int = Field(ge=0, description="Zero-based page index")
+ x: float = Field(ge=0, description="Lower-left X in PDF points")
+ y: float = Field(ge=0, description="Lower-left Y in PDF points (origin bottom-left)")
+ width: float = Field(gt=0)
+ height: float = Field(gt=0)
+ font: str = "Helvetica"
+ font_size: float = Field(default=10, gt=0)
+ color: str = "#000000"
+ align: TextAlign = TextAlign.left
+ @field_validator("font")
+ @classmethod
+ def _font_not_blank(cls, v: str) -> str:
+ if not v.strip():
+ raise ValueError("font must not be blank")
+ return v
-class ExtractedField(BaseModel):
- name: str
- description: str
- type: str
+ @field_validator("color")
+ @classmethod
+ def _color_is_hex(cls, v: str) -> str:
+ if not _HEX_COLOR.match(v):
+ raise ValueError('color must be a hex string like "#000000"')
+ return v
-class TemplateUploadResponse(BaseModel):
- filename: str
- pdf_path: str
- field_count: int | None = None
- fields: list[ExtractedField] = []
+class TemplateField(BaseModel):
+ """One field definition within a template (schemas/template-record.yaml#/TemplateField).
+
+ `source` decides where the value comes from, and each source brings its own
+ requirement: schema needs an `incident_mapping`, static needs `static_text`,
+ open needs a `description` (it is the instruction the extractor is given),
+ and manual needs neither. `layout` places the box on the PDF.
+ """
+
+ field_name: str
+ field_type: TemplateFieldType
+ source: FieldSource
+ required: bool
+ description: str | None = None
+ max_length: int | None = Field(default=None, gt=0)
+ # No bound on min/max themselves: contract values legitimately go negative
+ # (temperatures, elevations). Only their order is checked below.
+ min_value: float | None = None
+ max_value: float | None = None
+ allowed_values: list[str] | None = None
+ incident_mapping: str | None = None
+ static_text: str | None = None
+ default_value: object | None = None
+ unit: str | None = None
+ layout: TemplateFieldLayout | None = None
+
+ @field_validator("field_name")
+ @classmethod
+ def _name_not_blank(cls, v: str) -> str:
+ v = v.strip()
+ if not v:
+ raise ValueError("field_name must not be empty")
+ return v
+
+ @model_validator(mode="after")
+ def _check_field(self) -> "TemplateField":
+ has_mapping = bool(self.incident_mapping and self.incident_mapping.strip())
+ has_static = self.static_text is not None
+ has_description = bool(self.description and self.description.strip())
+
+ if self.source is FieldSource.schema and not has_mapping:
+ raise ValueError(
+ f"field '{self.field_name}': incident_mapping is required when source is 'schema'"
+ )
+ if self.source is not FieldSource.schema and has_mapping:
+ raise ValueError(
+ f"field '{self.field_name}': incident_mapping is only allowed when source is 'schema'"
+ )
+ if self.source is FieldSource.static and not has_static:
+ raise ValueError(
+ f"field '{self.field_name}': static_text is required when source is 'static'"
+ )
+ if self.source is not FieldSource.static and has_static:
+ raise ValueError(
+ f"field '{self.field_name}': static_text is only allowed when source is 'static'"
+ )
+ if self.source is FieldSource.open and not has_description:
+ raise ValueError(
+ f"field '{self.field_name}': description is required when source is 'open'. "
+ "It is the instruction the extractor is given."
+ )
+ if self.field_type == TemplateFieldType.enum and not self.allowed_values:
+ raise ValueError(
+ f"field '{self.field_name}': allowed_values is required when field_type is 'enum'"
+ )
+ if (
+ self.min_value is not None
+ and self.max_value is not None
+ and self.min_value > self.max_value
+ ):
+ raise ValueError(
+ f"field '{self.field_name}': min_value must be <= max_value"
+ )
+ return self
+
+
+class CreateTemplateRequest(BaseModel):
+ """POST/PUT request body (schemas/template-record.yaml#/CreateTemplateRequest)."""
+
+ form_type: str
+ display_name: str
+ jurisdiction: str | None = None
+ agency_type: str | None = None
+ fields: list[TemplateField] = Field(min_length=1)
+ source_standard: str | None = None
+ pdf_template_ref: str | None = None
+
+ @field_validator("form_type")
+ @classmethod
+ def _form_type_slug(cls, v: str) -> str:
+ v = v.strip()
+ if not _FORM_TYPE.match(v):
+ raise ValueError(
+ "form_type must contain only lowercase letters, digits, underscores or hyphens"
+ )
+ return v
+
+ @field_validator("display_name")
+ @classmethod
+ def _display_not_blank(cls, v: str) -> str:
+ if not v.strip():
+ raise ValueError("display_name must not be empty")
+ return v
+
+ @model_validator(mode="after")
+ def _unique_field_names(self) -> "CreateTemplateRequest":
+ names = [f.field_name for f in self.fields]
+ dupes = sorted({n for n in names if names.count(n) > 1})
+ if dupes:
+ raise ValueError(f"duplicate field_name(s): {', '.join(dupes)}")
+ return self
+
+
+class TemplateSummary(BaseModel):
+ """List item (schemas/template-record.yaml#/TemplateSummary)."""
+
+ template_id: UUID
+ form_type: str
+ display_name: str
+ jurisdiction: str | None = None
+ agency_type: str | None = None
+ version: str
+ last_updated: date
+ field_count: int
+ status: TemplateStatus
+
+
+class TemplateDetail(CreateTemplateRequest):
+ """Full template definition (schemas/template-record.yaml#/Template).
+
+ Server-generated fields layered on top of CreateTemplateRequest.
+ """
+
+ template_id: UUID
+ version: str
+ last_updated: date
+ field_count: int
+ status: TemplateStatus
+ created_at: datetime
+ updated_at: datetime
+
+
+class TemplateFieldsResponse(BaseModel):
+ """GET /templates/{id}/fields response (path/templates.yaml#/template_fields)."""
+
+ template_id: UUID
+ form_type: str
+ total_fields: int
+ required_fields: int
+ optional_fields: int
+ fields: list[TemplateField]
+
+
+# ---------------------------------------------------------------------------
+# PDF upload and field detection (path/templates.yaml#/templates_pdf)
+# ---------------------------------------------------------------------------
+class PageGeometry(BaseModel):
+ """One page's size in PDF points, the unit every layout box uses."""
+
+ page: int = Field(ge=0, description="Zero-based page index")
+ width: float
+ height: float
+
+
+class MappingSuggestion(BaseModel):
+ """One ranked incident-contract mapping guess for a detected box."""
+
+ path: str
+ label: str | None = None
+ field_type: str | None = None
+ section: str | None = None
+ description: str | None = None
+ score: float
+
+
+class DraftField(BaseModel):
+ """A detected box as an editable TemplateField, plus what it was guessed from."""
+
+ field: TemplateField
+ # Kept verbatim, not normalized, so the editor can show the user what the
+ # suggestion was based on.
+ detected_label: str | None = None
+ suggestions: list[MappingSuggestion] = Field(default_factory=list)
+
+
+class TemplateDraft(BaseModel):
+ """Everything the visual editor needs after a PDF upload.
+
+ The PDF is stored the moment the upload returns; only detection is async,
+ so `status` describes detection alone. A failed detection still leaves a
+ usable upload, the user just draws every box by hand.
+ """
+
+ upload_id: UUID
+ status: DetectionStatus
+ pdf_template_ref: str
+ original_filename: str | None = None
+ page_count: int
+ pages: list[PageGeometry]
+ detected_fields: list[DraftField] | None = None
+ detection_error: str | None = None
+ retry_after_seconds: int | None = None
+
+
+class TemplateDraftAccepted(TemplateDraft):
+ """202 body of POST /templates/pdf: the draft plus where to poll it.
+
+ `job_id` is absent when detection was skipped, since there is no background
+ work to follow. `poll_url` still resolves, it just answers straight away.
+ """
+
+ job_id: str | None = None
+ poll_url: str
diff --git a/app/core/celery.py b/app/core/celery.py
index a91146f7..b498e8e7 100644
--- a/app/core/celery.py
+++ b/app/core/celery.py
@@ -1,4 +1,7 @@
+import sys
+
from celery import Celery
+from celery.signals import celeryd_init
from app.core.config import CELERY_BROKER_URL, CELERY_RESULT_BACKEND
@@ -16,7 +19,32 @@
result_expires=86400,
)
-celery_app.conf.include = ["app.tasks.fill", "app.tasks.purge", "app.tasks.transcribe"]
+@celeryd_init.connect
+def _check_llm_config(**_kwargs):
+ """Stop the worker on a bad LLM configuration, before it accepts any task.
+
+ SystemExit rather than letting LLMConfigError travel: Celery catches
+ anything deriving from Exception in a signal handler, logs it and carries
+ on, so a raised config error would leave the worker running and picking up
+ extractions it cannot serve. SystemExit is not an Exception, so it lands.
+ """
+ from app.services import llm
+
+ try:
+ llm.check_config()
+ except llm.LLMConfigError as exc:
+ print(f"FATAL: {exc}", file=sys.stderr, flush=True)
+ raise SystemExit(1) from exc
+
+
+celery_app.conf.include = [
+ "app.tasks.fill",
+ "app.tasks.purge",
+ "app.tasks.transcribe",
+ "app.tasks.extract",
+ "app.tasks.detect_fields",
+ "app.tasks.generate_forms",
+]
# Optional Celery Beat schedule — runs purge_old_submissions once a day.
# Enable by running: celery -A app.core.celery beat
diff --git a/app/core/config.py b/app/core/config.py
index 4a7ca897..183e6828 100644
--- a/app/core/config.py
+++ b/app/core/config.py
@@ -35,8 +35,53 @@
# --- External services ----------------------------------------------------
OLLAMA_HOST = os.getenv("OLLAMA_HOST", "http://localhost:11434").rstrip("/")
OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "qwen2.5:1.5b")
+# Seconds to wait on a single Ollama generate call. One chunk prompt on a small
+# local model is slow, so this is generous by design.
+OLLAMA_TIMEOUT = int(os.getenv("OLLAMA_TIMEOUT", "600"))
+# Ceiling on tokens generated per chunk answer. It stops a small model from
+# echoing the whole field skeleton back and spending the timeout on it.
+#
+# Keep these two in step. A section that runs past OLLAMA_TIMEOUT is lost
+# outright, while one that hits this ceiling still keeps every field it
+# completed before the cut, so the ceiling should be reachable well inside the
+# timeout on the slowest model you run. A small model on CPU manages roughly
+# three tokens a second, which is where these defaults come from.
+OLLAMA_MAX_TOKENS = int(os.getenv("OLLAMA_MAX_TOKENS", "1200"))
WHISPER_HOST = os.getenv("WHISPER_HOST", "http://localhost:9000").rstrip("/")
+# --- LLM provider ---------------------------------------------------------
+# Which backend answers prompts, one per deployment. "custom" points at any
+# endpoint speaking the OpenAI chat completions API and needs LLM_BASE_URL.
+# The OLLAMA_* settings above stay the Ollama provider's defaults, so an
+# existing .env keeps working untouched. Meanings are documented in
+# docker/.env.example.
+LLM_PROVIDER = os.getenv("LLM_PROVIDER", "ollama").strip().lower()
+LLM_MODEL = os.getenv("LLM_MODEL", "").strip()
+LLM_BASE_URL = os.getenv("LLM_BASE_URL", "").strip().rstrip("/")
+
+OPENAI_API_KEY = os.getenv("OPENAI_API_KEY", "").strip()
+GEMINI_API_KEY = os.getenv("GEMINI_API_KEY", "").strip()
+ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY", "").strip()
+LLM_API_KEY = os.getenv("LLM_API_KEY", "").strip()
+
+LLM_EXTRA_HEADERS = os.getenv("LLM_EXTRA_HEADERS", "").strip()
+LLM_TIMEOUT = int(os.getenv("LLM_TIMEOUT", str(OLLAMA_TIMEOUT)))
+LLM_MAX_TOKENS = int(os.getenv("LLM_MAX_TOKENS", str(OLLAMA_MAX_TOKENS)))
+
+# Hosted providers mean incident narratives leave the department's hardware,
+# which is the one thing FireForm promises will not happen. They refuse to
+# start until this is switched on deliberately.
+LLM_ALLOW_EXTERNAL = os.getenv("LLM_ALLOW_EXTERNAL", "false").strip().lower() == "true"
+
+# A 429 is the provider asking us to wait, so it is worth waiting out. Anything
+# still limited after all of these tries is a quota problem no retry fixes.
+LLM_RATE_LIMIT_RETRIES = int(os.getenv("LLM_RATE_LIMIT_RETRIES", "10"))
+LLM_RATE_LIMIT_WAIT_SECONDS = float(os.getenv("LLM_RATE_LIMIT_WAIT_SECONDS", "10"))
+LLM_RATE_LIMIT_MAX_WAIT_SECONDS = float(os.getenv("LLM_RATE_LIMIT_MAX_WAIT_SECONDS", "60"))
+LLM_RESPECT_RETRY_AFTER = os.getenv("LLM_RESPECT_RETRY_AFTER", "true").strip().lower() == "true"
+LLM_SERVER_RETRIES = int(os.getenv("LLM_SERVER_RETRIES", "2"))
+LLM_SERVER_RETRY_WAIT_SECONDS = float(os.getenv("LLM_SERVER_RETRY_WAIT_SECONDS", "2"))
+
# --- Celery / Redis -------------------------------------------------------
CELERY_BROKER_URL = os.getenv("CELERY_BROKER_URL", "redis://localhost:6379/0")
CELERY_RESULT_BACKEND = os.getenv("CELERY_RESULT_BACKEND", "redis://localhost:6379/0")
@@ -58,6 +103,44 @@
# or transcribing. Value matches the contract example (contracts/path/input.yaml).
INPUT_POLL_INTERVAL_SECONDS = 5
+# Polling hint returned by GET /extract/{id} while an extraction is still
+# processing. Matches the contract example (contracts/path/extraction.yaml).
+EXTRACTION_POLL_INTERVAL_SECONDS = 5
+
+# Advisory estimate returned in the 202 body of POST /extract/{input_id}.
+ESTIMATED_EXTRACTION_SECONDS = int(os.getenv("ESTIMATED_EXTRACTION_SECONDS", "60"))
+
+# --- Extraction worker ----------------------------------------------------
+# How many chunk prompts run at once. A local server queues whatever it cannot
+# serve concurrently, so going wider than it buys nothing but memory pressure.
+# A hosted provider will rate limit instead, which the retry policy absorbs.
+EXTRACTION_MAX_PARALLEL = int(
+ os.getenv("LLM_MAX_PARALLEL", os.getenv("OLLAMA_NUM_PARALLEL", "4"))
+)
+
+# Extra attempts after a chunk's first result fails validation. One retry, with
+# the rejected value named in the prompt; a chunk that misses twice is left for
+# manual entry rather than guessed at.
+EXTRACTION_CHUNK_RETRIES = int(os.getenv("EXTRACTION_CHUNK_RETRIES", "1"))
+
+# One input gets one extraction, which makes re-testing the same narrative mean
+# re-uploading it. Switching this on lets a repeat run through; every run still
+# gets its own extraction and its own draft incident, so the old ones stay put.
+# Development only, leave it off anywhere real.
+EXTRACTION_ALLOW_RERUN = os.getenv("EXTRACTION_ALLOW_RERUN", "false").strip().lower() == "true"
+
+# The contract file the chunk registry reads its tiers and triggers from.
+INCIDENT_CONTRACT_PATH = Path(
+ os.getenv("INCIDENT_CONTRACT_PATH", BASE_DIR / "contracts" / "schemas" / "incident-contract.yaml")
+)
+
+# Deployment context the extractor falls back on when the narrative is silent:
+# timezone for resolving relative dates, country, currency for Money amounts.
+# A request can override any of them through ExtractionRequest.defaults.
+DEFAULT_COUNTRY = os.getenv("FIREFORM_DEFAULT_COUNTRY", "US")
+DEFAULT_TIMEZONE = os.getenv("FIREFORM_DEFAULT_TIMEZONE", "UTC")
+DEFAULT_CURRENCY = os.getenv("FIREFORM_DEFAULT_CURRENCY", "USD")
+
# --- API Versioning -------------------------------------------------------
API_PREFIX = "/api/v1"
@@ -67,6 +150,26 @@
# --- Data Retention --------------------------------------------------------
RETENTION_PERIOD_DAYS = int(os.getenv("RETENTION_PERIOD_DAYS", "30"))
+# --- Template PDF storage and field detection -----------------------------
+# Blank agency PDFs uploaded for template authoring land here as
+# {TEMPLATE_UPLOAD_DIR}/{upload_id}.pdf. The reference handed back to clients
+# is that path taken relative to DATA_DIR, so the two can never drift apart.
+TEMPLATE_UPLOAD_DIR = DATA_DIR / "templates" / "uploads"
+
+MAX_TEMPLATE_PDF_BYTES = 50 * 1024 * 1024
+
+# Polling hint returned by the draft endpoints while detection is running.
+TEMPLATE_DETECTION_POLL_INTERVAL_SECONDS = 5
+
+# Two thresholds govern the mapping suggester. Below the floor nothing is
+# offered at all: an empty list next to a good search box beats a wrong guess,
+# because a pre-filled mapping is trusted far more than it deserves. At or above
+# the auto-apply mark the top hit is written straight into the field as a
+# schema mapping; between the two the suggestions are listed and the user picks.
+MAPPING_SUGGESTION_FLOOR = float(os.getenv("MAPPING_SUGGESTION_FLOOR", "0.5"))
+MAPPING_AUTO_APPLY_SCORE = float(os.getenv("MAPPING_AUTO_APPLY_SCORE", "0.85"))
+MAX_MAPPING_SUGGESTIONS = 5
+
# --- Audio storage --------------------------------------------------------
# Voice input audio files land here: {AUDIO_DIR}/{input_id}.{ext}
AUDIO_DIR = DATA_DIR / "audio"
@@ -84,4 +187,17 @@
"ogg": "audio/ogg",
"webm": "audio/webm",
}
-ALLOWED_AUDIO_EXTENSIONS: frozenset[str] = frozenset(AUDIO_CONTENT_TYPES)
\ No newline at end of file
+ALLOWED_AUDIO_EXTENSIONS: frozenset[str] = frozenset(AUDIO_CONTENT_TYPES)
+
+# --- Generated form storage -------------------------------------------------
+# Filled form PDFs land here: {FORMS_OUTPUT_DIR}/{form_id}.pdf. Form.pdf_path
+# stores this DATA_DIR-relative, same convention as FormTemplate.pdf_template_ref.
+FORMS_OUTPUT_DIR = DATA_DIR / "forms" / "generated"
+
+# Advisory estimate returned in the 202 body of POST /forms/generate. Filling
+# is pure lookup-and-draw (no LLM), so this is far below the extraction estimate.
+ESTIMATED_FORM_GENERATION_SECONDS = int(os.getenv("ESTIMATED_FORM_GENERATION_SECONDS", "10"))
+
+# Polling hint returned by GET /forms/{id}/pdf while generation is still in
+# progress. Matches the contract example (contracts/path/forms.yaml).
+FORM_GENERATION_POLL_INTERVAL_SECONDS = 5
\ No newline at end of file
diff --git a/app/core/errors/base.py b/app/core/errors/base.py
index d317736a..ad323d51 100644
--- a/app/core/errors/base.py
+++ b/app/core/errors/base.py
@@ -21,3 +21,23 @@ def __init__(
self.status_code = status_code
self.error_code = error_code or _default_error_code(status_code)
self.detail = detail
+
+
+class ValidationAppError(AppError):
+ """A 422 raised by a service with field-level issues attached.
+
+ Request-shape problems are caught by FastAPI and rendered from
+ RequestValidationError. This is for the ones only the service can see, such
+ as a correction whose merged document no longer matches the contract, and
+ it renders the same validation_errors list so clients read one shape.
+ """
+
+ def __init__(
+ self,
+ message: str,
+ validation_errors: list[dict] | None = None,
+ error_code: str = "VALIDATION_ERROR",
+ detail: dict | None = None,
+ ):
+ super().__init__(message, status_code=422, error_code=error_code, detail=detail)
+ self.validation_errors = validation_errors or []
diff --git a/app/core/errors/handlers.py b/app/core/errors/handlers.py
index ce8988ad..c57378ed 100644
--- a/app/core/errors/handlers.py
+++ b/app/core/errors/handlers.py
@@ -1,9 +1,28 @@
+import json
+
from fastapi import Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from app.core.config import RETRY_AFTER_SECONDS
-from app.core.errors.base import AppError
+from app.core.errors.base import AppError, ValidationAppError
+
+
+def _jsonable(value):
+ """Return value untouched if JSON-serializable, else a string form.
+
+ FastAPI puts the offending input on each validation error. When a JSON-body
+ endpoint is called with the wrong content-type, that input is the raw
+ request body as bytes, which JSONResponse cannot encode. Coerce anything
+ non-serializable so the 422 renders instead of turning into a 500.
+ """
+ try:
+ json.dumps(value)
+ return value
+ except (TypeError, ValueError):
+ if isinstance(value, bytes):
+ return value.decode("utf-8", "replace")
+ return str(value)
def register_exception_handlers(app):
@@ -16,6 +35,18 @@ async def app_error_handler(request: Request, exc: AppError):
body["retry_after_seconds"] = RETRY_AFTER_SECONDS
return JSONResponse(status_code=exc.status_code, content=body)
+ @app.exception_handler(ValidationAppError)
+ async def validation_app_error_handler(request: Request, exc: ValidationAppError):
+ body: dict = {"error_code": exc.error_code, "message": exc.message}
+ if exc.detail is not None:
+ body["detail"] = exc.detail
+ if exc.validation_errors:
+ body["validation_errors"] = [
+ {**error, "value": _jsonable(error.get("value"))}
+ for error in exc.validation_errors
+ ]
+ return JSONResponse(status_code=exc.status_code, content=body)
+
@app.exception_handler(RequestValidationError)
async def validation_error_handler(request: Request, exc: RequestValidationError):
validation_errors = []
@@ -25,7 +56,7 @@ async def validation_error_handler(request: Request, exc: RequestValidationError
validation_errors.append({
"field": field or None,
"issue": error.get("msg"),
- "value": error.get("input"),
+ "value": _jsonable(error.get("input")),
})
return JSONResponse(
status_code=422,
diff --git a/app/core/lifespan.py b/app/core/lifespan.py
index 7f842739..a61cc5b9 100644
--- a/app/core/lifespan.py
+++ b/app/core/lifespan.py
@@ -6,12 +6,17 @@
from fastapi import FastAPI
from app.db.init_db import init_db
+from app.services import llm
logger = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI):
+ # A missing key or an unnamed model is a deployment mistake. Better to stop
+ # here than halfway through someone's incident report.
+ llm.check_config()
+
logger.info("Initializing database...")
init_db()
yield
diff --git a/app/db/repositories.py b/app/db/repositories.py
index 0935c133..d03df596 100644
--- a/app/db/repositories.py
+++ b/app/db/repositories.py
@@ -1,24 +1,76 @@
+from datetime import date, datetime, time, timedelta
from uuid import UUID
+from sqlalchemy import func, nullslast
from sqlmodel import Session, select
-from app.models import Template, FormSubmission, Job, Input
+from app.models import (
+ Template,
+ FormSubmission,
+ FormTemplate,
+ Form,
+ Job,
+ Input,
+ Extraction,
+ Incident,
+ TemplateUpload,
+)
+from app.api.schemas.enums import IncidentCategory, ReportStatus
-# Templates
-def create_template(session: Session, template: Template) -> Template:
+# Templates (legacy fill pipeline - read-only lookup, consumed by forms/jobs/tasks)
+def get_template(session: Session, template_id: int) -> Template | None:
+ return session.get(Template, template_id)
+
+# Form templates (contract Layer 6 registry)
+def create_form_template(session: Session, template: FormTemplate) -> FormTemplate:
session.add(template)
session.commit()
session.refresh(template)
return template
-def get_template(session: Session, template_id: int) -> Template | None:
- return session.get(Template, template_id)
+
+def get_form_template(session: Session, template_id: UUID) -> FormTemplate | None:
+ return session.get(FormTemplate, template_id)
-def list_templates(session: Session) -> list[Template]:
- statement = select(Template).order_by(Template.created_at.desc(), Template.id.desc())
+def get_form_template_by_form_type(session: Session, form_type: str) -> FormTemplate | None:
+ statement = select(FormTemplate).where(FormTemplate.form_type == form_type)
+ return session.exec(statement).first()
+
+
+def list_form_templates(session: Session) -> list[FormTemplate]:
+ statement = select(FormTemplate).order_by(
+ FormTemplate.created_at.desc(), FormTemplate.template_id
+ )
return list(session.exec(statement))
+
+def update_form_template(session: Session, template: FormTemplate) -> FormTemplate:
+ session.add(template)
+ session.commit()
+ session.refresh(template)
+ return template
+
+
+# Template PDF uploads (field-detection drafts)
+def create_template_upload(session: Session, upload: TemplateUpload) -> TemplateUpload:
+ session.add(upload)
+ session.commit()
+ session.refresh(upload)
+ return upload
+
+
+def get_template_upload(session: Session, upload_id: UUID) -> TemplateUpload | None:
+ return session.get(TemplateUpload, upload_id)
+
+
+def update_template_upload(session: Session, upload: TemplateUpload) -> TemplateUpload:
+ session.add(upload)
+ session.commit()
+ session.refresh(upload)
+ return upload
+
+
# Forms
def create_form(session: Session, form: FormSubmission) -> FormSubmission:
session.add(form)
@@ -56,11 +108,6 @@ def update_job(session: Session, job: Job) -> Job:
return job
-def delete_template(session: Session, template: Template) -> None:
- session.delete(template)
- session.commit()
-
-
def get_form_submission(session: Session, submission_id: int) -> FormSubmission | None:
return session.get(FormSubmission, submission_id)
@@ -70,6 +117,32 @@ def delete_form_submission(session: Session, submission: FormSubmission) -> None
session.commit()
+# Forms (contract Layer 3 — v1 Form model, distinct from the legacy FormSubmission)
+def create_generated_form(session: Session, form: Form) -> Form:
+ session.add(form)
+ session.commit()
+ session.refresh(form)
+ return form
+
+
+def get_form(session: Session, form_id: UUID) -> Form | None:
+ return session.get(Form, form_id)
+
+
+def list_forms_by_batch(session: Session, batch_id: UUID) -> list[Form]:
+ statement = select(Form).where(Form.batch_id == batch_id).order_by(
+ Form.created_at, Form.form_id
+ )
+ return list(session.exec(statement))
+
+
+def update_form(session: Session, form: Form) -> Form:
+ session.add(form)
+ session.commit()
+ session.refresh(form)
+ return form
+
+
# Inputs
def create_input(session: Session, input_obj: Input) -> Input:
session.add(input_obj)
@@ -88,3 +161,155 @@ def update_input(session: Session, input_obj: Input) -> Input:
session.refresh(input_obj)
return input_obj
+
+# Extractions
+def create_extraction(session: Session, extraction: Extraction) -> Extraction:
+ session.add(extraction)
+ session.commit()
+ session.refresh(extraction)
+ return extraction
+
+
+def get_extraction(session: Session, extract_id: UUID) -> Extraction | None:
+ return session.get(Extraction, extract_id)
+
+
+def get_extraction_by_input(session: Session, input_id: UUID) -> Extraction | None:
+ statement = select(Extraction).where(Extraction.input_id == input_id)
+ return session.exec(statement).first()
+
+
+def update_extraction(session: Session, extraction: Extraction) -> Extraction:
+ session.add(extraction)
+ session.commit()
+ session.refresh(extraction)
+ return extraction
+
+
+# Incidents
+def _day_start(value: date) -> datetime:
+ """Midnight on the given day, naive.
+
+ incident_datetime is a naive DateTime column and the offset on the value
+ promoted from the contract is dropped on write, so what is stored is local
+ wall-clock time. Date bounds are built the same way to match.
+ """
+ return datetime.combine(value, time.min)
+
+
+def create_incident(session: Session, incident: Incident) -> Incident:
+ session.add(incident)
+ session.commit()
+ session.refresh(incident)
+ return incident
+
+
+def get_incident(session: Session, incident_id: UUID) -> Incident | None:
+ return session.get(Incident, incident_id)
+
+
+def get_incident_by_extract(session: Session, extract_id: UUID) -> Incident | None:
+ statement = select(Incident).where(Incident.extract_id == extract_id)
+ return session.exec(statement).first()
+
+
+def update_incident(session: Session, incident: Incident) -> Incident:
+ session.add(incident)
+ session.commit()
+ session.refresh(incident)
+ return incident
+
+
+def create_draft_incident(session: Session, extract_id: UUID) -> Incident:
+ """Create the draft incident row linked to a completed extraction.
+
+ Called when an extraction completes: the new row owns the contract document
+ and starts in draft status. POST /incidents later finalizes this same row.
+ """
+ incident = Incident(extract_id=extract_id, status=ReportStatus.draft)
+ return create_incident(session, incident)
+
+
+def get_incident_by_number(session: Session, incident_number: str) -> Incident | None:
+ """Look up a live incident by its department-assigned number.
+
+ Soft-deleted rows are skipped so a deleted incident does not block its
+ number from being reused.
+ """
+ statement = select(Incident).where(
+ Incident.incident_number == incident_number,
+ Incident.deleted_at.is_(None),
+ )
+ return session.exec(statement).first()
+
+
+def list_incidents(
+ session: Session,
+ date_from: date | None = None,
+ date_to: date | None = None,
+ incident_category: IncidentCategory | None = None,
+ status: ReportStatus | None = None,
+ page: int = 1,
+ per_page: int = 20,
+ sort: str = "date_desc",
+) -> tuple[list[Incident], int]:
+ """One page of live incidents plus the total matching the filters.
+
+ Date filters are inclusive and apply to incident_datetime, which is
+ nullable, so rows without one are excluded whenever a date bound is given
+ and sort last otherwise. created_at breaks ties, keeping paging stable
+ across rows that share an incident_datetime.
+ """
+ conditions = [Incident.deleted_at.is_(None)]
+ if date_from is not None:
+ conditions.append(Incident.incident_datetime >= _day_start(date_from))
+ if date_to is not None:
+ conditions.append(Incident.incident_datetime < _day_start(date_to) + timedelta(days=1))
+ if incident_category is not None:
+ conditions.append(Incident.incident_category == incident_category)
+ if status is not None:
+ conditions.append(Incident.status == status)
+
+ total = session.exec(
+ select(func.count()).select_from(Incident).where(*conditions)
+ ).one()
+
+ ascending = sort == "date_asc"
+ ordering = (
+ nullslast(Incident.incident_datetime.asc()) if ascending
+ else nullslast(Incident.incident_datetime.desc())
+ )
+ tiebreak = Incident.created_at.asc() if ascending else Incident.created_at.desc()
+
+ statement = (
+ select(Incident)
+ .where(*conditions)
+ .order_by(ordering, tiebreak, Incident.incident_id)
+ .offset((page - 1) * per_page)
+ .limit(per_page)
+ )
+ return list(session.exec(statement)), total
+
+
+def list_forms_by_incident(session: Session, incident_id: UUID) -> list[Form]:
+ statement = select(Form).where(Form.incident_id == incident_id).order_by(
+ Form.created_at, Form.form_id
+ )
+ return list(session.exec(statement))
+
+
+def count_forms_by_incident(session: Session, incident_ids: list[UUID]) -> dict[UUID, int]:
+ """Form counts for a page of incidents, as one grouped query.
+
+ Counting per row would issue a query per incident on every list request.
+ Incidents with no forms are absent from the result; callers default to 0.
+ """
+ if not incident_ids:
+ return {}
+ statement = (
+ select(Form.incident_id, func.count())
+ .where(Form.incident_id.in_(incident_ids))
+ .group_by(Form.incident_id)
+ )
+ return {incident_id: count for incident_id, count in session.exec(statement)}
+
diff --git a/app/models/__init__.py b/app/models/__init__.py
index bba2eecb..af645112 100644
--- a/app/models/__init__.py
+++ b/app/models/__init__.py
@@ -4,15 +4,19 @@
Extraction,
Form,
FormSubmission,
+ FormTemplate,
Incident,
Input,
Job,
Report,
Template,
+ TemplateUpload,
)
__all__ = [
"Template",
+ "FormTemplate",
+ "TemplateUpload",
"FormSubmission",
"Job",
"Input",
diff --git a/app/models/models.py b/app/models/models.py
index cb9a5ff7..49954db2 100644
--- a/app/models/models.py
+++ b/app/models/models.py
@@ -2,20 +2,23 @@
from uuid import UUID, uuid4
from datetime import date, datetime, timezone
-from sqlalchemy import Column, JSON
+from sqlalchemy import Column, Index, JSON, text
from sqlmodel import SQLModel, Field
from sqlmodel.sql.sqltypes import AutoString
from app.api.schemas.enums import (
+ DetectionStatus,
ExtractionStatus,
FormStatus,
FormType,
+ IncidentCategory,
InputStatus,
InputType,
JobStatus,
OutputFormat,
PeriodType,
ReportStatus,
+ TemplateStatus,
)
@@ -91,9 +94,10 @@ class Extraction(SQLModel, table=True):
completed_at: datetime | None = None
model_used: str | None = None
processing_time_seconds: float | None = None
- # Full IncidentContract superset blob; stores partial result while processing,
- # final canonical JSON when status=completed.
- incident_contract: dict | None = Field(default=None, sa_column=Column(JSON))
+ # Transient contract blob held only while the job runs. Cleared once the
+ # extraction completes and the contract is written to the incident row,
+ # which is the single store. Extractions keep no copy of the final contract.
+ partial_result: dict | None = Field(default=None, sa_column=Column(JSON))
# Audit trail of manual corrections applied via PATCH /extract/{id}.
corrections: list | None = Field(default=None, sa_column=Column(JSON))
error_type: str | None = None
@@ -104,6 +108,23 @@ class Extraction(SQLModel, table=True):
class Incident(SQLModel, table=True):
__tablename__ = "incidents"
+ __table_args__ = (
+ # A department's incident number identifies one live incident. Partial
+ # so a soft-deleted row does not keep its number reserved forever, and
+ # so the many rows still awaiting a number do not collide on NULL.
+ Index(
+ "ix_incidents_number_live",
+ "incident_number",
+ unique=True,
+ postgresql_where=text("incident_number IS NOT NULL AND deleted_at IS NULL"),
+ sqlite_where=text("incident_number IS NOT NULL AND deleted_at IS NULL"),
+ ),
+ # Covers GET /incidents: every query excludes soft-deleted rows and
+ # then sorts on incident_datetime. The status and incident_category
+ # filters are left unindexed on purpose, they are low cardinality and
+ # a department-sized table does not need them.
+ Index("ix_incidents_live_datetime", "deleted_at", "incident_datetime"),
+ )
incident_id: UUID = Field(default_factory=uuid4, primary_key=True)
extract_id: UUID = Field(foreign_key="extractions.extract_id")
@@ -113,9 +134,35 @@ class Incident(SQLModel, table=True):
)
incident_name: str | None = None
incident_type: str | None = None
- incident_date: date | None = None
tags: list | None = Field(default=None, sa_column=Column(JSON))
notes: str | None = None
+ # The single store of the incident contract. Created as a draft when
+ # extraction completes; PATCH /extract writes here, form generation reads
+ # here. Nothing else keeps a copy.
+ incident_contract: dict | None = Field(default=None, sa_column=Column(JSON))
+ # Promoted scalars, recomputed server-side from the contract on every
+ # document change. All nullable; clients never write them directly.
+ incident_category: IncidentCategory | None = Field(
+ default=None, sa_column=Column(AutoString, nullable=True)
+ )
+ incident_datetime: datetime | None = None
+ city: str | None = None
+ state: str | None = None
+ country: str | None = None
+ civilian_injuries: int | None = None
+ civilian_fatalities: int | None = None
+ responder_injuries: int | None = None
+ responder_fatalities: int | None = None
+ people_rescued: int | None = None
+ people_evacuated: int | None = None
+ structures_destroyed: int | None = None
+ area_burned_ha: float | None = None
+ total_loss_amount: float | None = None
+ total_loss_currency: str | None = None
+ call_to_arrival_seconds: int | None = None
+ turnout_seconds_first_unit: int | None = None
+ travel_seconds_first_unit: int | None = None
+ on_scene_duration_seconds: int | None = None
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
deleted_at: datetime | None = None
@@ -129,8 +176,11 @@ class Form(SQLModel, table=True):
status: FormStatus = Field(
default=FormStatus.queued, sa_column=Column(AutoString, nullable=False)
)
- extract_id: UUID = Field(foreign_key="extractions.extract_id")
- incident_id: UUID | None = Field(default=None, foreign_key="incidents.incident_id")
+ template_id: UUID = Field(foreign_key="form_templates.template_id")
+ incident_id: UUID = Field(foreign_key="incidents.incident_id")
+ # Grouping key for a batch generate request. No Batch table — batch status
+ # is derived on the fly from the Form rows sharing this id.
+ batch_id: UUID | None = None
# Plain UUID, no FK constraint — pending contract Job model resolution (#544 decision A).
job_id: UUID | None = None
completed_at: datetime | None = None
@@ -143,6 +193,66 @@ class Form(SQLModel, table=True):
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
+class TemplateUpload(SQLModel, table=True):
+ """A blank PDF uploaded for template authoring, plus its detection draft.
+
+ The PDF is stored and its page geometry read synchronously, so a row exists
+ with `page_count`/`pages` filled before detection starts. `status` tracks
+ detection alone: a failed detection still leaves a usable upload, the user
+ just draws every box by hand. Rows are drafts, not templates. Registering a
+ template copies the edited fields into `form_templates` and keeps only the
+ `pdf_template_ref` pointing back here.
+ """
+
+ __tablename__ = "template_uploads"
+
+ upload_id: UUID = Field(default_factory=uuid4, primary_key=True)
+ status: DetectionStatus = Field(
+ default=DetectionStatus.processing, sa_column=Column(AutoString, nullable=False)
+ )
+ # Path on disk, and the DATA_DIR-relative reference handed to clients.
+ pdf_path: str
+ pdf_template_ref: str
+ original_filename: str | None = None
+ page_count: int = Field(default=0)
+ # List of {page, width, height} in PDF points.
+ pages: list = Field(default_factory=list, sa_column=Column(JSON, nullable=False))
+ # List of DraftField objects (see app/api/schemas/templates.py).
+ detected_fields: list | None = Field(default=None, sa_column=Column(JSON))
+ detection_error: str | None = None
+ job_id: str | None = None
+ created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
+ updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
+
+
+class FormTemplate(SQLModel, table=True):
+ """Contract Layer 6 form template registry (path/templates.yaml).
+
+ Distinct from the legacy prototype `Template` (int PK + uploaded PDF): this
+ is the standards registry keyed by `form_type`, holding incident-schema field
+ definitions plus their visual `layout`. `field_count` and `last_updated` are
+ derived in the response schemas (len(fields) / updated_at.date()), not stored.
+ """
+
+ __tablename__ = "form_templates"
+
+ template_id: UUID = Field(default_factory=uuid4, primary_key=True)
+ form_type: str = Field(sa_column=Column(AutoString, nullable=False, unique=True, index=True))
+ display_name: str
+ jurisdiction: str | None = None
+ agency_type: str | None = None
+ # List of TemplateField objects (see app/api/schemas/templates.py).
+ fields: list = Field(sa_column=Column(JSON, nullable=False))
+ source_standard: str | None = None
+ pdf_template_ref: str | None = None
+ version: str = Field(default="1.0")
+ status: TemplateStatus = Field(
+ default=TemplateStatus.active, sa_column=Column(AutoString, nullable=False)
+ )
+ created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
+ updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
+
+
class Report(SQLModel, table=True):
__tablename__ = "reports"
diff --git a/app/services/extraction/__init__.py b/app/services/extraction/__init__.py
new file mode 100644
index 00000000..c1c5103d
--- /dev/null
+++ b/app/services/extraction/__init__.py
@@ -0,0 +1,11 @@
+"""The extraction layer.
+
+Split by job: `service` queues a run, `registry` and `router` decide what to ask
+the model, `prompts` and `client` do the asking, `runner` validates and retries,
+`defaults` covers everything computable without a model, and `worker` stitches
+it all into a contract and a draft incident.
+
+Nothing is re-exported here on purpose. The celery task imports the worker and
+the service imports the task, so a package-level import of either would close
+that loop at import time. Import the submodule you need.
+"""
diff --git a/app/services/extraction/defaults.py b/app/services/extraction/defaults.py
new file mode 100644
index 00000000..fdd5d034
--- /dev/null
+++ b/app/services/extraction/defaults.py
@@ -0,0 +1,181 @@
+"""The parts of extraction that need no model at all.
+
+Deployment context (timezone, country, currency) and anything arithmetic. The
+model is told the context so it can resolve "yesterday evening" itself, and
+these functions then fill what it left blank and compute what is derivable:
+default country and currency, a timezone offset on naive timestamps, and the
+per-unit turnout and travel seconds. Plain code beats a prompt every time.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from datetime import datetime
+from typing import Any
+from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
+
+from app.api.schemas.extraction import ExtractionDefaults, ExtractionHints
+from app.core.config import DEFAULT_COUNTRY, DEFAULT_CURRENCY, DEFAULT_TIMEZONE
+from app.core.logging import get_logger
+
+logger = get_logger(__name__)
+
+
+@dataclass(frozen=True)
+class ExtractionContext:
+ """Deployment context for one extraction run."""
+
+ country: str
+ timezone: str
+ currency: str
+ now: datetime
+
+ @property
+ def zone(self) -> ZoneInfo:
+ try:
+ return ZoneInfo(self.timezone)
+ except (ZoneInfoNotFoundError, ValueError):
+ logger.warning("unknown timezone %s, falling back to UTC", self.timezone)
+ return ZoneInfo("UTC")
+
+
+def resolve_context(defaults: ExtractionDefaults | dict | None) -> ExtractionContext:
+ """Merge the request's defaults over the server's configured ones."""
+ if isinstance(defaults, ExtractionDefaults):
+ defaults = defaults.model_dump(exclude_none=True)
+ values = defaults or {}
+ timezone_name = values.get("timezone") or DEFAULT_TIMEZONE
+ context = ExtractionContext(
+ country=values.get("country") or DEFAULT_COUNTRY,
+ timezone=timezone_name,
+ currency=values.get("currency") or DEFAULT_CURRENCY,
+ now=datetime.now(),
+ )
+ return ExtractionContext(
+ country=context.country,
+ timezone=context.timezone,
+ currency=context.currency,
+ now=datetime.now(context.zone),
+ )
+
+
+def context_lines(context: ExtractionContext, hints: ExtractionHints | dict | None = None) -> list[str]:
+ """The context block every chunk prompt carries in its dynamic tail."""
+ lines = [
+ f"Right now it is {context.now.isoformat(timespec='seconds')} "
+ f"({context.now.strftime('%A')}), timezone {context.timezone}. "
+ "Resolve relative times like 'yesterday evening' against it.",
+ f"Country when the narrative names none: {context.country}.",
+ f"Currency for money amounts when the narrative names none: {context.currency}.",
+ ]
+ if isinstance(hints, ExtractionHints):
+ hints = hints.model_dump(exclude_none=True)
+ for key, value in (hints or {}).items():
+ if value:
+ lines.append(f"Hint from the responder, {key.replace('_', ' ')}: {value}.")
+ return lines
+
+
+def _localize_datetimes(node: Any, context: ExtractionContext) -> Any:
+ """Attach the deployment offset to any timestamp the model left naive."""
+ if isinstance(node, dict):
+ return {key: _localize_datetimes(value, context) for key, value in node.items()}
+ if isinstance(node, list):
+ return [_localize_datetimes(item, context) for item in node]
+ if isinstance(node, str) and len(node) >= 16 and "T" in node:
+ try:
+ parsed = datetime.fromisoformat(node.replace("Z", "+00:00"))
+ except ValueError:
+ return node
+ if parsed.tzinfo is None:
+ return parsed.replace(tzinfo=context.zone).isoformat()
+ return node
+
+
+def _apply_currency(node: Any, currency: str) -> Any:
+ """Stamp the default currency on Money objects that came back without one."""
+ if isinstance(node, dict):
+ filled = {key: _apply_currency(value, currency) for key, value in node.items()}
+ if isinstance(filled.get("amount"), (int, float)) and not filled.get("currency"):
+ filled["currency"] = currency
+ return filled
+ if isinstance(node, list):
+ return [_apply_currency(item, currency) for item in node]
+ return node
+
+
+def _prune_empty(node: Any) -> Any:
+ """Drop empty strings and empty containers from a contract document.
+
+ Models answer a field they know nothing about with "" or [] rather than
+ leaving it out. In the contract an absent field means unknown, while an
+ empty string is a claim that the value is blank, so these are dropped.
+ Zero is kept: a count of zero is a real answer.
+ """
+ if isinstance(node, dict):
+ cleaned = {}
+ for key, value in node.items():
+ pruned = _prune_empty(value)
+ if pruned is None or pruned == "" or pruned == [] or pruned == {}:
+ continue
+ cleaned[key] = pruned
+ return cleaned
+ if isinstance(node, list):
+ items = [_prune_empty(item) for item in node]
+ return [item for item in items if item not in (None, "", [], {})]
+ return node
+
+
+def _seconds_between(start: Any, end: Any) -> int | None:
+ """Whole seconds between two RFC 3339 strings, or None if that makes no sense."""
+ try:
+ first = datetime.fromisoformat(str(start).replace("Z", "+00:00"))
+ second = datetime.fromisoformat(str(end).replace("Z", "+00:00"))
+ except (TypeError, ValueError):
+ return None
+ if first.tzinfo is None or second.tzinfo is None:
+ return None
+ delta = (second - first).total_seconds()
+ return int(delta) if delta >= 0 else None
+
+
+def _derive_unit_timings(contract: dict) -> None:
+ """Compute each unit's turnout and travel seconds from its own timestamps.
+
+ Arithmetic is not the model's job. When both timestamps are there the
+ computed value wins, because models do state a duration that contradicts
+ the times they just gave. The model's number is only kept when the
+ timestamps are missing and there is nothing to compute from.
+ """
+ units = contract.get("units")
+ if not isinstance(units, list):
+ return
+ for unit in units:
+ if not isinstance(unit, dict):
+ continue
+ turnout = _seconds_between(unit.get("dispatched_datetime"), unit.get("enroute_datetime"))
+ if turnout is not None:
+ unit["turnout_seconds"] = turnout
+ travel = _seconds_between(unit.get("enroute_datetime"), unit.get("arrived_datetime"))
+ if travel is not None:
+ unit["travel_seconds"] = travel
+
+
+def apply_context(contract: dict, context: ExtractionContext) -> dict:
+ """Run every deterministic post-step over a stitched contract."""
+ filled = _prune_empty(contract)
+ filled = _localize_datetimes(filled, context)
+ filled = _apply_currency(filled, context.currency)
+
+ # Country is deployment truth, not a guess, so it is stamped even when the
+ # narrative said nothing about where it happened.
+ location = filled.setdefault("location", {})
+ if isinstance(location, dict) and not location.get("country"):
+ location["country"] = context.country
+
+ incident = filled.get("incident")
+ if isinstance(incident, dict) and not incident.get("timezone"):
+ incident["timezone"] = context.timezone
+
+ _derive_unit_timings(filled)
+ return filled
diff --git a/app/services/extraction/prompts.py b/app/services/extraction/prompts.py
new file mode 100644
index 00000000..36ee502d
--- /dev/null
+++ b/app/services/extraction/prompts.py
@@ -0,0 +1,154 @@
+"""Chunk prompts.
+
+Every chunk prompt is a static prefix plus a dynamic suffix. The prefix (the
+instructions and the chunk's field skeleton) never changes between incidents,
+so Ollama reuses its KV cache for it; only the tail, which carries the
+deployment context and the narrative, is new each time. Interpolating the
+narrative anywhere but the end would throw that away.
+
+The field skeleton is rendered from the generated Pydantic model rather than
+hand-written, so it cannot drift from the contract. Enum members are spelled
+out because small models invent enum values otherwise.
+"""
+
+from __future__ import annotations
+
+import json
+from datetime import date, datetime, time
+from enum import Enum
+from functools import lru_cache
+from typing import Any, get_args, get_origin
+from uuid import UUID
+
+from pydantic import AwareDatetime, BaseModel, NaiveDatetime, RootModel
+
+from app.services.extraction.registry import ChunkSpec
+
+# How deep the skeleton goes before it stops describing nested shape. Three
+# levels covers every chunk in the contract without bloating the prompt.
+MAX_DEPTH = 3
+
+# Longest enum spelled out in full. Past this the prompt lists the first few
+# and tells the model to use exactly one of the contract's values.
+MAX_ENUM_MEMBERS = 25
+
+INSTRUCTIONS = """You extract fire and emergency incident data from a responder's narrative.
+
+Rules:
+- Return one JSON object and nothing else. No prose, no markdown, no code fence.
+- Use only facts the narrative states or clearly implies. Never invent a value.
+- Leave out any field the narrative does not support. An absent field is correct; a guess is not.
+- Use exactly the enum values listed. If none fits, leave the field out.
+- Date-times are RFC 3339 with an offset, for example 2026-04-18T21:14:00-07:00.
+- Physical quantities are SI: metres, square metres, hectares, litres, kilometres, Celsius.
+"""
+
+
+def _enum_hint(enum_cls: type[Enum]) -> str:
+ members = [str(member.value) for member in enum_cls]
+ if len(members) > MAX_ENUM_MEMBERS:
+ shown = " | ".join(members[:MAX_ENUM_MEMBERS])
+ return f""
+ return f""
+
+
+# Pydantic's date-time markers are plain classes at runtime, not datetime
+# subclasses, so they need naming before the subclass checks below.
+_ALIAS_HINTS: dict[Any, str] = {
+ AwareDatetime: "",
+ NaiveDatetime: "",
+}
+
+_SCALAR_HINTS: dict[type, str] = {
+ str: "",
+ bool: "",
+ int: "",
+ float: "",
+ datetime: "",
+ date: "",
+ time: "