diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
index 93d41fb..1652a2b 100644
--- a/.github/workflows/lint.yml
+++ b/.github/workflows/lint.yml
@@ -26,4 +26,4 @@ jobs:
- name: Run linter
run: |
- ruff check src/ --output-format=github
\ No newline at end of file
+ ruff check app/ --output-format=github
\ No newline at end of file
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
deleted file mode 100644
index d6d1f83..0000000
--- a/.github/workflows/release.yml
+++ /dev/null
@@ -1,59 +0,0 @@
-name: Release Electron App
-
-on:
- push:
- tags: ["v*"]
-
-jobs:
- build:
- strategy:
- matrix:
- os: [macos-latest, ubuntu-latest, windows-latest]
- runs-on: ${{ matrix.os }}
- defaults:
- run:
- working-directory: frontend
-
- permissions:
- contents: write
-
- steps:
- - uses: actions/checkout@v4
-
- - name: Setup Python
- uses: actions/setup-python@v5
- with:
- python-version: '3.11'
-
- - name: Install Python dependencies
- working-directory: .
- shell: bash
- run: |
- python -m pip install --upgrade pip
- if [ "$RUNNER_OS" == "macOS" ]; then
- pip install pyinstaller
- pip install -r requirements.txt
- else
- pip install torch --index-url https://download.pytorch.org/whl/cpu
- pip install pyinstaller
- pip install -r requirements.txt
- fi
-
- - name: Build Python backend with PyInstaller
- working-directory: .
- shell: bash
- run: |
- pyinstaller --name api-backend --onefile api/main.py
- mkdir -p frontend/bin
- cp dist/api-backend* frontend/bin/
-
- - uses: actions/setup-node@v4
- with:
- node-version: 20
-
- - run: npm ci
-
- - name: Build & publish
- run: npx electron-builder --publish always
- env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.gitignore b/.gitignore
index b5a9a90..1515bf3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -26,4 +26,7 @@ src/inputs/*.pdf
frontend/release/
# Local Claude Code instructions
-CLAUDE.md
\ No newline at end of file
+CLAUDE.md
+
+*temp/
+*.env*
\ No newline at end of file
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 154daf8..d30e9b9 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -39,25 +39,14 @@ If you have a great idea for FireForm, we'd love to hear it! Please open an issu
4. Make sure your code lints.
5. Issue that pull request!
-## 🛠️ Local Development Setup
-
-FireForm uses Docker and Docker Compose for the backend to ensure a consistent environment.
+**Issues are not formally assigned.** You are free to pick any open issue, work on it, and raise a PR directly. If multiple PRs address the same issue, the first one that actually fixes it generally gets preference. To avoid duplicating someone else's work, coordinate with other contributors on our [Discord](https://discord.gg/nBv5b6kF68) before starting.
-### Prerequisites
+## 💬 Community
-- [Docker](https://docs.docker.com/get-docker/)
-- [Docker Compose](https://docs.docker.com/compose/install/)
-- `make` (optional, but recommended)
-- [Node.js](https://nodejs.org/) 20+ (only needed for the desktop app)
+Join our Discord server to ask questions, discuss issues, and coordinate work with other contributors: https://discord.gg/nBv5b6kF68
-### Desktop App Development
-
-The frontend is a vanilla HTML/CSS/JS app wrapped in Electron. To run it locally:
+## 🛠️ Local Development Setup
-```bash
-cd frontend
-npm install # one-time setup
-npm start # launches the Electron desktop window
-```
+See the [Setup Guide](docs/1.%20SETUP.md) for the full walkthrough: prerequisites, running the backend with Docker, testing endpoints via Swagger UI, day-to-day commands, and troubleshooting.
-The backend (API + Ollama) must be running separately via Docker — see `make fireform`.
+Before writing code, read the [Project Structure](docs/2.%20PROJECT_STRUCTURE.md) guide — it explains how the codebase is organized and where new code should go.
diff --git a/Dockerfile b/Dockerfile
index 282eb25..b7b00ce 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -18,11 +18,11 @@ RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY . .
-# All imports use api.*, src.* which require the root to be on the path
+# All imports use the app.* package, which requires the root on the path
ENV PYTHONPATH=/app
# Expose FastAPI port
EXPOSE 8000
# Start the FastAPI server (not tail -f /dev/null which does nothing)
-CMD ["uvicorn", "api.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
+CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
diff --git a/Makefile b/Makefile
index 026a721..13ea366 100644
--- a/Makefile
+++ b/Makefile
@@ -1,8 +1,10 @@
-.PHONY: help build up down logs shell exec pull-model test clean fireform logs-app logs-ollama logs-frontend super-clean
+.PHONY: help init fireform build up down logs logs-app logs-ollama shell pull-model test clean super-clean
-# The extraction model pulled into Ollama and used by src/llm.py. Override with
-# `make pull-model OLLAMA_MODEL=...`. A 1.5B model keeps per-field fills fast.
-OLLAMA_MODEL ?= qwen2.5:1.5b
+COMPOSE = docker compose -f docker/dev/compose.yml --env-file docker/.env.dev
+ENV_DEV = docker/.env.dev
+
+# Read OLLAMA_MODEL from .env.dev at runtime; fall back to default if file absent.
+OLLAMA_MODEL = $(shell grep -E '^OLLAMA_MODEL=' $(ENV_DEV) 2>/dev/null | cut -d= -f2 | tr -d '[:space:]' || echo qwen2.5:1.5b)
help:
@printf '%s\n' \
@@ -13,74 +15,84 @@ help:
'/_/ /_//_/ \___/ /_/ \____/_/ /_/ /_/ /_/ ' \
''
@echo ""
- @echo "Fireform Development Commands"
+ @echo "FireForm Development Commands"
@echo "=============================="
- @echo "make fireform - Build and start containers, then open a shell"
+ @echo "make init - First-time setup: check deps, create .env.dev, pick model"
+ @echo "make fireform - Build images, start containers, pull Ollama model"
@echo "make build - Build Docker images"
- @echo "make up - Start all containers"
+ @echo "make up - Start all containers (detached)"
@echo "make down - Stop all containers"
- @echo "make logs - View container logs"
- @echo "make logs-app - View API container logs"
- @echo "make logs-frontend - View frontend container logs"
- @echo "make logs-ollama - View Ollama container logs"
- @echo "make shell - Open Python shell in app container"
- @echo "make exec - Execute Python script in container"
- @echo "make pull-model - Pull the extraction model ($(OLLAMA_MODEL)) into Ollama"
- @echo "make test - Run tests"
- @echo "make clean - Remove containers"
- @echo "make super-clean - [CAUTION] Use carefully. Cleans up ALL stopped containers, networks, build cache..."
-
-# Fix #382 — pull-model is now part of the main setup flow
-# The extraction model is pulled automatically before you need it
-fireform: build up pull-model
+ @echo "make logs - Stream all container logs"
+ @echo "make logs-app - Stream app container logs"
+ @echo "make logs-ollama - Stream Ollama container logs"
+ @echo "make shell - Open shell in running app container"
+ @echo "make pull-model - Pull Ollama model from .env.dev ($(OLLAMA_MODEL))"
+ @echo "make test - Run test suite"
+ @echo "make clean - Stop containers (preserves volumes)"
+ @echo "make super-clean - [CAUTION] Stop containers, delete volumes, prune Docker"
+
+init:
+ @chmod +x scripts/check-deps.sh scripts/init-env.sh scripts/select-model.sh
+ @sh scripts/check-deps.sh
+ @sh scripts/init-env.sh
+ @sh scripts/select-model.sh
+ @printf "Build containers and pull model now? [y/N] "; \
+ read answer; \
+ case "$$answer" in \
+ [yY]*) $(MAKE) fireform ;; \
+ *) echo "Run 'make fireform' when ready." ;; \
+ esac
+
+fireform: build up
+ @printf "Waiting for Ollama to be ready..."
+ @until $(COMPOSE) exec -T ollama ollama list > /dev/null 2>&1; do \
+ printf '.'; sleep 2; \
+ done
+ @echo " ready."
+ @if $(COMPOSE) exec -T ollama ollama list 2>/dev/null | grep -q "^$(OLLAMA_MODEL)"; then \
+ echo " Model $(OLLAMA_MODEL) already pulled."; \
+ else \
+ echo " Pulling $(OLLAMA_MODEL)..."; \
+ $(COMPOSE) exec -T ollama ollama pull $(OLLAMA_MODEL); \
+ fi
@echo ""
- @echo "✅ FireForm is ready!"
- @echo " Frontend: http://localhost:5173"
+ @echo "FireForm is ready!"
@echo " API: http://localhost:8000"
@echo " API Docs: http://localhost:8000/docs"
@echo ""
@echo "Run 'make logs' to view live logs, 'make down' to stop."
build:
- docker compose build
+ @$(COMPOSE) build --progress=quiet
up:
- docker compose up -d
+ @$(COMPOSE) up -d
down:
- docker compose down
+ @$(COMPOSE) down --remove-orphans
logs:
- docker compose logs -f
+ @$(COMPOSE) logs -f
logs-app:
- docker compose logs -f app
+ @$(COMPOSE) logs -f app
logs-ollama:
- docker compose logs -f ollama
-
-logs-frontend:
- docker compose logs -f frontend
+ @$(COMPOSE) logs -f ollama
shell:
- docker compose exec app /bin/bash
-
-# Start the FastAPI server inside the running container
-run:
- docker compose exec app uvicorn api.main:app --host 0.0.0.0 --port 8000 --reload
-
-exec:
- docker compose exec app python3 src/main.py
+ @$(COMPOSE) exec app /bin/sh
pull-model:
- docker compose exec ollama ollama pull $(OLLAMA_MODEL)
+ @$(COMPOSE) exec -T ollama ollama pull $(OLLAMA_MODEL)
-# Fix — correct test directory (was src/test/ which doesn't exist)
test:
- docker compose exec app python3 -m pytest tests/ -v
+ @$(COMPOSE) exec -T app python3 -m pytest tests/ -v
clean:
- docker compose down -v
+ @$(COMPOSE) down
+
super-clean:
- docker compose down -v
- docker system prune
+ @echo "WARNING: this will delete all volumes (database, uploads, model weights)."
+ @$(COMPOSE) down -v
+ @docker system prune -f
diff --git a/api/db/database.py b/api/db/database.py
deleted file mode 100644
index 97e6e62..0000000
--- a/api/db/database.py
+++ /dev/null
@@ -1,20 +0,0 @@
-import os
-from sqlmodel import create_engine, Session
-
-# Define path to the database in the user's home directory
-HOME_DIR = os.path.expanduser("~")
-APP_DIR = os.path.join(HOME_DIR, ".fireform")
-os.makedirs(APP_DIR, exist_ok=True)
-DB_PATH = os.path.join(APP_DIR, "fireform.db")
-
-DATABASE_URL = f"sqlite:///{DB_PATH}"
-
-engine = create_engine(
- DATABASE_URL,
- echo=True,
- connect_args={"check_same_thread": False},
-)
-
-def get_session():
- with Session(engine) as session:
- yield session
\ No newline at end of file
diff --git a/api/main.py b/api/main.py
deleted file mode 100644
index 0c30bc5..0000000
--- a/api/main.py
+++ /dev/null
@@ -1,42 +0,0 @@
-from contextlib import asynccontextmanager
-import os
-
-# Disable CUDA to prevent PyTorch from trying to find NVIDIA drivers on Mac Silicon / Docker
-os.environ["CUDA_VISIBLE_DEVICES"] = ""
-
-from fastapi import FastAPI
-from api.routes import templates, forms
-from api.db.init_db import init_db
-from api.errors.handlers import register_exception_handlers
-from fastapi.middleware.cors import CORSMiddleware
-from api.routes import forms, templates
-
-@asynccontextmanager
-async def lifespan(app: FastAPI):
- # Startup: Initialize the database and seed it if necessary
- print("Initializing database...")
- init_db()
- yield
- # Shutdown logic goes here if needed
-
-app = FastAPI(lifespan=lifespan)
-
-register_exception_handlers(app)
-
-default_origins = "http://127.0.0.1:5173,http://localhost:5173"
-allowed_origins = [
- origin.strip()
- for origin in os.getenv("FRONTEND_ORIGINS", default_origins).split(",")
- if origin.strip()
-]
-
-app.add_middleware(
- CORSMiddleware,
- allow_origins=allowed_origins,
- allow_credentials=False,
- allow_methods=["*"],
- allow_headers=["*"],
-)
-
-app.include_router(templates.router)
-app.include_router(forms.router)
diff --git a/api/routes/__init__.py b/api/routes/__init__.py
deleted file mode 100644
index e8fe8f4..0000000
--- a/api/routes/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-from . import templates, forms
diff --git a/app/__init__.py b/app/__init__.py
new file mode 100644
index 0000000..de9379b
--- /dev/null
+++ b/app/__init__.py
@@ -0,0 +1,8 @@
+"""FireForm backend application package."""
+
+import os
+
+# Force CPU before any service module imports torch / rfdetr. Prevents PyTorch
+# from probing for NVIDIA drivers on Mac Silicon and inside Docker. Runs here so
+# it is guaranteed to execute before `app.main` imports the service layer.
+os.environ["CUDA_VISIBLE_DEVICES"] = ""
diff --git a/api/__init__.py b/app/api/__init__.py
similarity index 100%
rename from api/__init__.py
rename to app/api/__init__.py
diff --git a/api/deps.py b/app/api/deps.py
similarity index 51%
rename from api/deps.py
rename to app/api/deps.py
index c2a0b74..4aa9614 100644
--- a/api/deps.py
+++ b/app/api/deps.py
@@ -1,4 +1,4 @@
-from api.db.database import get_session
+from app.db.database import get_session
def get_db():
yield from get_session()
\ No newline at end of file
diff --git a/app/api/router.py b/app/api/router.py
new file mode 100644
index 0000000..ba9c0b5
--- /dev/null
+++ b/app/api/router.py
@@ -0,0 +1,12 @@
+"""Aggregates every route module into a single API router.
+
+Add new feature routers here; main.py only mounts this one router.
+"""
+
+from fastapi import APIRouter
+
+from app.api.routes import forms, templates
+
+api_router = APIRouter()
+api_router.include_router(templates.router)
+api_router.include_router(forms.router)
diff --git a/app/api/routes/__init__.py b/app/api/routes/__init__.py
new file mode 100644
index 0000000..2264aee
--- /dev/null
+++ b/app/api/routes/__init__.py
@@ -0,0 +1,3 @@
+from . import templates, forms
+
+__all__ = ["templates", "forms"]
diff --git a/api/routes/forms.py b/app/api/routes/forms.py
similarity index 85%
rename from api/routes/forms.py
rename to app/api/routes/forms.py
index 74069ea..7af5da6 100644
--- a/api/routes/forms.py
+++ b/app/api/routes/forms.py
@@ -1,19 +1,19 @@
-import os
-
import requests
from fastapi import APIRouter, Depends, File, UploadFile
from sqlmodel import Session
-from api.deps import get_db
-from api.schemas.forms import (
+
+from app.api.deps import get_db
+from app.api.schemas.forms import (
FormFill,
FormFillResponse,
ModelsResponse,
TranscriptionResponse,
)
-from api.db.repositories import create_form, get_template
-from api.db.models import FormSubmission
-from api.errors.base import AppError
-from src.controller import Controller
+from app.core.config import OLLAMA_HOST, OLLAMA_MODEL, WHISPER_HOST
+from app.core.errors.base import AppError
+from app.db.repositories import create_form, get_template
+from app.models import FormSubmission
+from app.services.controller import Controller
router = APIRouter(prefix="/forms", tags=["forms"])
@@ -48,12 +48,11 @@ 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 = os.getenv("OLLAMA_MODEL", "qwen2.5:1.5b")
- ollama_host = os.getenv("OLLAMA_HOST", "http://localhost:11434").rstrip("/")
+ default_model = OLLAMA_MODEL
models: list[str] = []
try:
- response = requests.get(f"{ollama_host}/api/tags", timeout=10)
+ 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:
@@ -75,8 +74,7 @@ def transcribe(audio: UploadFile = File(...)):
audio is streamed straight through to the local STT service and never
persisted — no PII leaves the machine.
"""
- whisper_host = os.getenv("WHISPER_HOST", "http://localhost:9000").rstrip("/")
- whisper_url = f"{whisper_host}/asr"
+ whisper_url = f"{WHISPER_HOST}/asr"
files = {
"audio_file": (
diff --git a/api/routes/templates.py b/app/api/routes/templates.py
similarity index 95%
rename from api/routes/templates.py
rename to app/api/routes/templates.py
index 64db2e0..cc98370 100644
--- a/api/routes/templates.py
+++ b/app/api/routes/templates.py
@@ -5,21 +5,22 @@
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile
from fastapi.responses import FileResponse
from sqlmodel import Session
-from api.deps import get_db
-from api.schemas.templates import (
+
+from app.api.deps import get_db
+from app.api.schemas.templates import (
TemplateCreate,
TemplateResponse,
TemplateUploadResponse,
MakeFillableRequest,
MakeFillableResponse,
)
-from api.db.repositories import create_template, list_templates
-from api.db.models import Template
-from src.controller import Controller
+from app.core.config import BASE_DIR, DEFAULT_TEMPLATE_DIR
+from app.db.repositories import create_template, list_templates
+from app.models import Template
+from app.services.controller import Controller
router = APIRouter(prefix="/templates", tags=["templates"])
-PROJECT_ROOT = Path(__file__).resolve().parents[2]
-DEFAULT_TEMPLATE_DIR = "src/inputs"
+PROJECT_ROOT = BASE_DIR
def _resolve_target_directory(directory: str) -> Path:
diff --git a/api/db/__init__.py b/app/api/schemas/__init__.py
similarity index 100%
rename from api/db/__init__.py
rename to app/api/schemas/__init__.py
diff --git a/api/schemas/common.py b/app/api/schemas/common.py
similarity index 88%
rename from api/schemas/common.py
rename to app/api/schemas/common.py
index 578cd2c..8d20a24 100644
--- a/api/schemas/common.py
+++ b/app/api/schemas/common.py
@@ -1,5 +1,5 @@
from pydantic import BaseModel
-from typing import Any, Optional
+from typing import Any
class SuccessResponse(BaseModel):
success: bool = True
diff --git a/api/schemas/forms.py b/app/api/schemas/forms.py
similarity index 90%
rename from api/schemas/forms.py
rename to app/api/schemas/forms.py
index ca99651..6a833c8 100644
--- a/api/schemas/forms.py
+++ b/app/api/schemas/forms.py
@@ -4,7 +4,6 @@ class FormFill(BaseModel):
template_id: int
input_text: str
# Optional Ollama model override for this fill; falls back to OLLAMA_MODEL.
- # Not persisted (no DB column) — excluded before building FormSubmission.
model: str | None = None
@field_validator("input_text")
diff --git a/api/schemas/templates.py b/app/api/schemas/templates.py
similarity index 100%
rename from api/schemas/templates.py
rename to app/api/schemas/templates.py
diff --git a/api/errors/__init__.py b/app/core/__init__.py
similarity index 100%
rename from api/errors/__init__.py
rename to app/core/__init__.py
diff --git a/app/core/config.py b/app/core/config.py
new file mode 100644
index 0000000..055bbad
--- /dev/null
+++ b/app/core/config.py
@@ -0,0 +1,47 @@
+"""Central configuration.
+
+Single source of truth for paths, the database URL, external service hosts and
+CORS. Read environment once here so the rest of the app imports settings instead
+of calling os.getenv() in scattered places.
+"""
+
+import os
+from pathlib import Path
+
+# Repo root. config.py lives at app/core/config.py -> parents[2] is the repo root.
+BASE_DIR = Path(__file__).resolve().parents[2]
+
+# --- App metadata ---------------------------------------------------------
+APP_TITLE = "FireForm API"
+APP_VERSION = "1.1.0"
+
+# --- Runtime data paths ---------------------------------------------------
+# Uploaded templates and generated PDFs. Project-relative paths the API echoes
+# back to the client are resolved against BASE_DIR (the "inside the project"
+# guard in the templates routes). Override the data dir with FIREFORM_DATA_DIR.
+DATA_DIR = Path(os.getenv("FIREFORM_DATA_DIR", BASE_DIR / "data")).resolve()
+
+# Directory new uploads land in, as a project-relative string (was "src/inputs"
+# before the restructure). Override with FIREFORM_TEMPLATE_DIR.
+DEFAULT_TEMPLATE_DIR = os.getenv("FIREFORM_TEMPLATE_DIR", "data/inputs")
+
+# --- Database -------------------------------------------------------------
+# Keep the SQLite file in the user's home so it survives container rebuilds.
+_APP_HOME = Path(os.path.expanduser("~")) / ".fireform"
+_APP_HOME.mkdir(parents=True, exist_ok=True)
+DB_PATH = Path(os.getenv("FIREFORM_DB_PATH", _APP_HOME / "fireform.db"))
+DATABASE_URL = f"sqlite:///{DB_PATH}"
+DB_ECHO = os.getenv("FIREFORM_DB_ECHO", "true").lower() == "true"
+
+# --- External services ----------------------------------------------------
+OLLAMA_HOST = os.getenv("OLLAMA_HOST", "http://localhost:11434").rstrip("/")
+OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "qwen2.5:1.5b")
+WHISPER_HOST = os.getenv("WHISPER_HOST", "http://localhost:9000").rstrip("/")
+
+# --- CORS -----------------------------------------------------------------
+_DEFAULT_ORIGINS = "http://127.0.0.1:5173,http://localhost:5173"
+ALLOWED_ORIGINS = [
+ origin.strip()
+ for origin in os.getenv("FRONTEND_ORIGINS", _DEFAULT_ORIGINS).split(",")
+ if origin.strip()
+]
diff --git a/api/schemas/__init__.py b/app/core/errors/__init__.py
similarity index 100%
rename from api/schemas/__init__.py
rename to app/core/errors/__init__.py
diff --git a/api/errors/base.py b/app/core/errors/base.py
similarity index 100%
rename from api/errors/base.py
rename to app/core/errors/base.py
diff --git a/api/errors/handlers.py b/app/core/errors/handlers.py
similarity index 88%
rename from api/errors/handlers.py
rename to app/core/errors/handlers.py
index 903e744..0285ddb 100644
--- a/api/errors/handlers.py
+++ b/app/core/errors/handlers.py
@@ -1,6 +1,6 @@
from fastapi import Request
from fastapi.responses import JSONResponse
-from api.errors.base import AppError
+from app.core.errors.base import AppError
def register_exception_handlers(app):
@app.exception_handler(AppError)
diff --git a/app/core/lifespan.py b/app/core/lifespan.py
new file mode 100644
index 0000000..7f84273
--- /dev/null
+++ b/app/core/lifespan.py
@@ -0,0 +1,17 @@
+"""Application lifespan: startup and shutdown hooks."""
+
+import logging
+from contextlib import asynccontextmanager
+
+from fastapi import FastAPI
+
+from app.db.init_db import init_db
+
+logger = logging.getLogger(__name__)
+
+
+@asynccontextmanager
+async def lifespan(app: FastAPI):
+ logger.info("Initializing database...")
+ init_db()
+ yield
diff --git a/app/core/logging.py b/app/core/logging.py
new file mode 100644
index 0000000..48d28a8
--- /dev/null
+++ b/app/core/logging.py
@@ -0,0 +1,14 @@
+"""Logging setup. Call setup_logging() once at app startup."""
+
+import logging
+
+
+def setup_logging(level: str = "INFO") -> None:
+ logging.basicConfig(
+ level=level,
+ format="%(asctime)s %(levelname)-8s %(name)s: %(message)s",
+ )
+
+
+def get_logger(name: str) -> logging.Logger:
+ return logging.getLogger(name)
diff --git a/src/__init__.py b/app/db/__init__.py
similarity index 100%
rename from src/__init__.py
rename to app/db/__init__.py
diff --git a/app/db/database.py b/app/db/database.py
new file mode 100644
index 0000000..0437afd
--- /dev/null
+++ b/app/db/database.py
@@ -0,0 +1,14 @@
+from sqlmodel import Session, create_engine
+
+from app.core.config import DATABASE_URL, DB_ECHO
+
+engine = create_engine(
+ DATABASE_URL,
+ echo=DB_ECHO,
+ connect_args={"check_same_thread": False},
+)
+
+
+def get_session():
+ with Session(engine) as session:
+ yield session
diff --git a/api/db/init_db.py b/app/db/init_db.py
similarity index 65%
rename from api/db/init_db.py
rename to app/db/init_db.py
index ea77cb6..fc17147 100644
--- a/api/db/init_db.py
+++ b/app/db/init_db.py
@@ -1,9 +1,15 @@
-import json
import datetime
-from sqlmodel import SQLModel, Session, select
-from api.db.database import engine
-from api.db import models
-from api.db.models import Template
+import logging
+
+from sqlmodel import Session, SQLModel, select
+
+from app.core.config import DEFAULT_TEMPLATE_DIR
+from app.db.database import engine
+
+from app.models import FormSubmission, Template # noqa: F401
+
+logger = logging.getLogger(__name__)
+
def seed_db():
with Session(engine) as session:
@@ -14,9 +20,9 @@ def seed_db():
except Exception:
# Table might not exist yet if called at a weird time
results = None
-
+
if not results:
- print("Seeding database with default template...")
+ logger.info("Seeding database with default template...")
fields = {
"Employee's name": "string",
"Employee's job title": "string",
@@ -24,24 +30,26 @@ def seed_db():
"Employee's phone number": "string",
"Employee's email": "string",
"Signature": "string",
- "Date": "string"
+ "Date": "string",
}
-
+
# Using ID 2 as agreed to avoid any ID 1 corruption
default_template = Template(
id=2,
name="Manual Test Template",
fields=fields,
- pdf_path="src/inputs/file_template_manual.pdf",
- created_at=datetime.datetime.now()
+ pdf_path=f"{DEFAULT_TEMPLATE_DIR}/file_template_manual.pdf",
+ created_at=datetime.datetime.now(),
)
session.add(default_template)
session.commit()
- print("Database seeded successfully.")
+ logger.info("Database seeded successfully.")
+
def init_db():
SQLModel.metadata.create_all(engine)
seed_db()
+
if __name__ == "__main__":
- init_db()
\ No newline at end of file
+ init_db()
diff --git a/api/db/repositories.py b/app/db/repositories.py
similarity index 93%
rename from api/db/repositories.py
rename to app/db/repositories.py
index a9ac3cf..ebb80de 100644
--- a/api/db/repositories.py
+++ b/app/db/repositories.py
@@ -1,5 +1,5 @@
from sqlmodel import Session, select
-from api.db.models import Template, FormSubmission
+from app.models import Template, FormSubmission
# Templates
def create_template(session: Session, template: Template) -> Template:
diff --git a/app/main.py b/app/main.py
new file mode 100644
index 0000000..3d3bdad
--- /dev/null
+++ b/app/main.py
@@ -0,0 +1,34 @@
+from fastapi import FastAPI
+from fastapi.middleware.cors import CORSMiddleware
+
+from app.api.router import api_router
+from app.core import config
+from app.core.errors.handlers import register_exception_handlers
+from app.core.lifespan import lifespan
+from app.core.logging import setup_logging
+
+
+def create_app() -> FastAPI:
+ setup_logging()
+
+ app = FastAPI(
+ title=config.APP_TITLE,
+ version=config.APP_VERSION,
+ lifespan=lifespan,
+ )
+
+ app.add_middleware(
+ CORSMiddleware,
+ allow_origins=config.ALLOWED_ORIGINS,
+ allow_credentials=False,
+ allow_methods=["*"],
+ allow_headers=["*"],
+ )
+
+ register_exception_handlers(app)
+ app.include_router(api_router)
+
+ return app
+
+
+app = create_app()
diff --git a/app/models/__init__.py b/app/models/__init__.py
new file mode 100644
index 0000000..f46e17d
--- /dev/null
+++ b/app/models/__init__.py
@@ -0,0 +1,5 @@
+"""ORM models. Import from here: `from app.models import Template`."""
+
+from app.models.models import FormSubmission, Template
+
+__all__ = ["Template", "FormSubmission"]
diff --git a/api/db/models.py b/app/models/models.py
similarity index 100%
rename from api/db/models.py
rename to app/models/models.py
diff --git a/app/services/__init__.py b/app/services/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/controller.py b/app/services/controller.py
similarity index 87%
rename from src/controller.py
rename to app/services/controller.py
index 0e19290..49c10e4 100644
--- a/src/controller.py
+++ b/app/services/controller.py
@@ -1,4 +1,4 @@
-from src.file_manipulator import FileManipulator
+from app.services.file_manipulator import FileManipulator
class Controller:
def __init__(self):
diff --git a/src/file_manipulator.py b/app/services/file_manipulator.py
similarity index 96%
rename from src/file_manipulator.py
rename to app/services/file_manipulator.py
index 8d1f3a0..357356e 100644
--- a/src/file_manipulator.py
+++ b/app/services/file_manipulator.py
@@ -1,6 +1,6 @@
import os
-from src.filler import Filler
-from src.llm import LLM
+from app.services.filler import Filler
+from app.services.llm import LLM
class FileManipulator:
diff --git a/src/filler.py b/app/services/filler.py
similarity index 97%
rename from src/filler.py
rename to app/services/filler.py
index 7f738c2..aec9756 100644
--- a/src/filler.py
+++ b/app/services/filler.py
@@ -1,5 +1,5 @@
from pdfrw import PdfReader, PdfWriter
-from src.llm import LLM
+from app.services.llm import LLM
from datetime import datetime
diff --git a/src/inputs/input.txt b/app/services/inputs/input.txt
similarity index 100%
rename from src/inputs/input.txt
rename to app/services/inputs/input.txt
diff --git a/src/llm.py b/app/services/llm.py
similarity index 94%
rename from src/llm.py
rename to app/services/llm.py
index 053b883..2998318 100644
--- a/src/llm.py
+++ b/app/services/llm.py
@@ -3,6 +3,8 @@
import requests
from requests.exceptions import Timeout, RequestException
+from app.core.config import OLLAMA_HOST, OLLAMA_MODEL
+
class LLM:
def __init__(self, transcript_text: str=None, target_fields: list=None, json_dict: dict=None, model: str=None):
@@ -31,9 +33,8 @@ def main_loop(self):
total_fields = len(self._target_fields)
for i, (field, field_type) in enumerate(self._target_fields.items(), 1):
prompt = self.build_prompt(field, field_type if isinstance(field_type, str) else "string")
- ollama_host = os.getenv("OLLAMA_HOST", "http://localhost:11434").rstrip("/")
- ollama_url = f"{ollama_host}/api/generate"
- ollama_model = self._model or os.getenv("OLLAMA_MODEL", "qwen2.5:1.5b")
+ ollama_url = f"{OLLAMA_HOST}/api/generate"
+ ollama_model = self._model or OLLAMA_MODEL
payload = {
"model": ollama_model,
diff --git a/src/outputs/test_output_1.json b/app/services/outputs/test_output_1.json
similarity index 100%
rename from src/outputs/test_output_1.json
rename to app/services/outputs/test_output_1.json
diff --git a/src/prompt.txt b/app/services/prompt.txt
similarity index 100%
rename from src/prompt.txt
rename to app/services/prompt.txt
diff --git a/src/test/example_template.json b/app/services/test/example_template.json
similarity index 100%
rename from src/test/example_template.json
rename to app/services/test/example_template.json
diff --git a/src/test/test_output_1.json b/app/services/test/test_output_1.json
similarity index 100%
rename from src/test/test_output_1.json
rename to app/services/test/test_output_1.json
diff --git a/contracts/openapi.yaml b/contracts/openapi.yaml
new file mode 100644
index 0000000..3b19ea3
--- /dev/null
+++ b/contracts/openapi.yaml
@@ -0,0 +1,98 @@
+openapi: 3.0.0
+
+info:
+ title: FireForm API
+ version: 1.0.0
+ description: |
+ Proposed API contract for FireForm as GSoC project 2026 by [chetanr25.in](https://chetanr25.in)
+ contact:
+ name: Chetan R
+ url: https://github.com/chetanr25
+ email: chetan250204@gmail.com
+
+servers:
+ - url: http://localhost:8080
+ description: Local FireForm instance (default)
+
+tags:
+ - name: input
+ description: Submit voice or text incident narratives
+ - name: extraction
+ description: AI-powered data extraction from narratives into canonical JSON
+ - name: forms
+ description: Generate agency-specific PDF forms from extracted data
+ - name: incidents
+ description: Manage incident records linking inputs, extractions, and forms
+ - name: reporting
+ description: Aggregate statistics and periodic report generation
+ - name: templates
+ description: Form template configuration and management
+ - name: system
+ description: Health checks, schema introspection, and system status
+ - name: jobs
+ description: Cross-cutting async job status polling
+
+paths:
+ # ── Layer 1: Input ──────────────────────────────────────────────
+ /api/v1/input/voice:
+ $ref: "path/input.yaml#/voice"
+ /api/v1/input/text:
+ $ref: "path/input.yaml#/text"
+ /api/v1/input/{input_id}:
+ $ref: "path/input.yaml#/input_by_id"
+
+ # ── Layer 2: AI Extraction ─────────────────────────────────────
+ /api/v1/extract/{input_id}:
+ $ref: "path/extraction.yaml#/extract_by_input"
+ /api/v1/extract/{extract_id}:
+ $ref: "path/extraction.yaml#/extract_by_id"
+ /api/v1/extract/{extract_id}/validate:
+ $ref: "path/extraction.yaml#/validate"
+
+ # ── Layer 3: Form Generation ───────────────────────────────────
+ /api/v1/forms/generate/all:
+ $ref: "path/forms.yaml#/generate_all"
+ /api/v1/forms/generate/{form_type}:
+ $ref: "path/forms.yaml#/generate_single"
+ /api/v1/forms/{form_id}:
+ $ref: "path/forms.yaml#/form_by_id"
+ /api/v1/forms/{form_id}/pdf:
+ $ref: "path/forms.yaml#/form_pdf"
+ /api/v1/forms/{form_id}/json:
+ $ref: "path/forms.yaml#/form_json"
+ /api/v1/forms/batch/{batch_id}:
+ $ref: "path/forms.yaml#/batch_by_id"
+
+ # ── Layer 4: Incident Management ───────────────────────────────
+ /api/v1/incidents:
+ $ref: "path/incidents.yaml#/incidents"
+ /api/v1/incidents/{incident_id}:
+ $ref: "path/incidents.yaml#/incident_by_id"
+
+ # ── Layer 5: Reporting & Analytics ─────────────────────────────
+ /api/v1/reports/summary:
+ $ref: "path/reporting.yaml#/summary"
+ /api/v1/reports/generate:
+ $ref: "path/reporting.yaml#/generate"
+ /api/v1/reports/{report_id}:
+ $ref: "path/reporting.yaml#/report_by_id"
+
+ # ── Layer 6: Templates & Configuration ─────────────────────────
+ /api/v1/templates:
+ $ref: "path/templates.yaml#/templates"
+ /api/v1/templates/{template_id}:
+ $ref: "path/templates.yaml#/template_by_id"
+ /api/v1/templates/{template_id}/fields:
+ $ref: "path/templates.yaml#/template_fields"
+
+ # ── Layer 7: System ────────────────────────────────────────────
+ /api/v1/health:
+ $ref: "path/system.yaml#/health"
+ /api/v1/schema/incident:
+ $ref: "path/system.yaml#/schema_incident"
+ /api/v1/schema/incident/versions:
+ $ref: "path/system.yaml#/schema_versions"
+
+ # ── Layer 8: Async Jobs ────────────────────────────────────────
+ /api/v1/jobs/{job_id}:
+ $ref: "path/jobs.yaml#/job_by_id"
diff --git a/contracts/path/extraction.yaml b/contracts/path/extraction.yaml
new file mode 100644
index 0000000..865e20c
--- /dev/null
+++ b/contracts/path/extraction.yaml
@@ -0,0 +1,313 @@
+# Layer 2 AI Extraction Endpoints
+# POST /api/v1/extract/{input_id}
+# GET /api/v1/extract/{extract_id}
+# PATCH /api/v1/extract/{extract_id}
+# POST /api/v1/extract/{extract_id}/validate
+
+extract_by_input:
+ post:
+ operationId: createExtraction
+ summary: Start AI extraction from input narrative
+ description: |
+ Sends the narrative (from a previously submitted input) to the local Ollama
+ LLM with a structured prompt to extract all incident fields into the canonical
+ FireForm JSON schema. This is an asynchronous operation the LLM may take
+ 30–120 seconds. Returns an extract_id and job_id for polling.
+ tags:
+ - extraction
+ parameters:
+ - name: input_id
+ in: path
+ required: true
+ description: ID of the input record to extract from
+ schema:
+ type: string
+ format: uuid
+ requestBody:
+ required: false
+ content:
+ application/json:
+ schema:
+ $ref: "../schemas/extraction-record.yaml#/ExtractionRequest"
+ example:
+ model_override: "llama3:8b"
+ extraction_hints:
+ incident_type: "wildland_fire"
+ state: "CA"
+ responses:
+ "202":
+ description: Extraction job queued successfully
+ content:
+ application/json:
+ schema:
+ $ref: "../schemas/common.yaml#/AsyncJobResponse"
+ example:
+ extract_id: "550e8400-e29b-41d4-a716-446655440020"
+ job_id: "550e8400-e29b-41d4-a716-446655440098"
+ job_type: "extraction"
+ status: "processing"
+ input_id: "550e8400-e29b-41d4-a716-446655440001"
+ queued_at: "2024-07-15T14:30:00Z"
+ estimated_seconds: 60
+ poll_url: "/api/v1/extract/550e8400-e29b-41d4-a716-446655440020"
+ "404":
+ description: Input ID not found
+ content:
+ application/json:
+ schema:
+ $ref: "../schemas/common.yaml#/ErrorResponse"
+ example:
+ error_code: "INPUT_NOT_FOUND"
+ message: "Input with ID 550e8400-e29b-41d4-a716-446655440099 not found"
+ "409":
+ description: Conflict input not ready or extraction already exists
+ content:
+ application/json:
+ schema:
+ $ref: "../schemas/common.yaml#/ErrorResponse"
+ examples:
+ not_ready:
+ summary: Input still transcribing
+ value:
+ error_code: "INPUT_NOT_READY"
+ message: "Input is in 'transcribing' state. Wait until status is 'ready'."
+ detail:
+ current_status: "transcribing"
+ already_exists:
+ summary: Extraction already initiated for this input
+ value:
+ error_code: "EXTRACTION_EXISTS"
+ message: "An extraction already exists for this input"
+ detail:
+ existing_extract_id: "550e8400-e29b-41d4-a716-446655440020"
+ "503":
+ description: Ollama LLM service unavailable
+ content:
+ application/json:
+ schema:
+ $ref: "../schemas/common.yaml#/ErrorResponse"
+ example:
+ error_code: "LLM_UNAVAILABLE"
+ message: "Ollama LLM service is not available"
+ retry_after_seconds: 30
+ detail:
+ ollama_status: "connection_refused"
+
+extract_by_id:
+ get:
+ operationId: getExtraction
+ summary: Get extraction result by ID
+ description: |
+ Returns the full canonical FireForm JSON when extraction is complete, or
+ the current job status while still processing. When status is "completed",
+ the response body contains the entire canonical incident schema. When still
+ processing, includes a retry_after_seconds hint for polling.
+ tags:
+ - extraction
+ parameters:
+ - name: extract_id
+ in: path
+ required: true
+ description: Unique identifier of the extraction
+ schema:
+ type: string
+ format: uuid
+ responses:
+ "200":
+ description: Extraction record (completed or in-progress)
+ content:
+ application/json:
+ schema:
+ oneOf:
+ - $ref: "../schemas/extraction-record.yaml#/ExtractionCompleted"
+ - $ref: "../schemas/extraction-record.yaml#/ExtractionProcessing"
+ examples:
+ completed:
+ summary: Extraction completed with canonical JSON
+ value:
+ extract_id: "550e8400-e29b-41d4-a716-446655440020"
+ input_id: "550e8400-e29b-41d4-a716-446655440001"
+ status: "completed"
+ completed_at: "2024-07-15T14:31:05Z"
+ incident_contract:
+ schema_version: "1.1.0"
+ schema_name: "fireform_incident_contract"
+ extraction_metadata:
+ extract_id: "550e8400-e29b-41d4-a716-446655440020"
+ confidence_score: 0.91
+ incident:
+ name: "Bear Creek Wildfire"
+ processing:
+ summary: Extraction still in progress
+ value:
+ extract_id: "550e8400-e29b-41d4-a716-446655440020"
+ input_id: "550e8400-e29b-41d4-a716-446655440001"
+ status: "processing"
+ started_at: "2024-07-15T14:30:00Z"
+ retry_after_seconds: 5
+ "404":
+ description: Extraction not found
+ content:
+ application/json:
+ schema:
+ $ref: "../schemas/common.yaml#/ErrorResponse"
+ example:
+ error_code: "EXTRACT_NOT_FOUND"
+ message: "Extraction with ID 550e8400-e29b-41d4-a716-446655440099 not found"
+
+ patch:
+ operationId: updateExtraction
+ summary: Manually correct extracted fields
+ description: |
+ Allows a responder to correct any field in the canonical JSON after LLM
+ extraction. Uses JSON Merge Patch (RFC 7396) only send the fields that
+ changed. The server records an audit trail of all changes vs the original
+ LLM output and recalculates completeness scores and applicable_forms.
+ tags:
+ - extraction
+ parameters:
+ - name: extract_id
+ in: path
+ required: true
+ description: Unique identifier of the extraction to update
+ schema:
+ type: string
+ format: uuid
+ requestBody:
+ required: true
+ description: JSON Merge Patch (RFC 7396) only include changed fields
+ content:
+ application/merge-patch+json:
+ schema:
+ $ref: "../schemas/incident-contract.yaml#/IncidentContract"
+ example:
+ fire:
+ estimated_damage_usd: 250000
+ casualties:
+ total_responder_injuries: 2
+ responses:
+ "200":
+ description: Extraction updated returns full updated canonical JSON
+ content:
+ application/json:
+ schema:
+ $ref: "../schemas/extraction-record.yaml#/ExtractionCompleted"
+ "404":
+ description: Extraction not found
+ content:
+ application/json:
+ schema:
+ $ref: "../schemas/common.yaml#/ErrorResponse"
+ "409":
+ description: Extraction is locked (already submitted)
+ content:
+ application/json:
+ schema:
+ $ref: "../schemas/common.yaml#/ErrorResponse"
+ example:
+ error_code: "EXTRACT_LOCKED"
+ message: "Cannot modify extraction incident report has been submitted"
+ detail:
+ report_status: "submitted"
+ submitted_at: "2024-07-15T18:00:00Z"
+ "422":
+ description: Invalid field path or value
+ content:
+ application/json:
+ schema:
+ $ref: "../schemas/common.yaml#/ErrorResponse"
+ example:
+ error_code: "VALIDATION_ERROR"
+ message: "Invalid field path or value in patch"
+ validation_errors:
+ - field: "fire.cause_certainty"
+ issue: "Must be one of: confirmed, probable, suspected, undetermined"
+ value: "maybe"
+
+validate:
+ post:
+ operationId: validateExtraction
+ summary: Validate extraction against a form's requirements
+ description: |
+ Validates the canonical JSON against a specific form type's field requirements.
+ Returns whether the extraction has all required fields, which recommended
+ fields are missing, and any warnings. Useful for checking "can I generate
+ a NERIS report with what I have?" before triggering form generation.
+ tags:
+ - extraction
+ parameters:
+ - name: extract_id
+ in: path
+ required: true
+ description: Unique identifier of the extraction to validate
+ schema:
+ type: string
+ format: uuid
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ required:
+ - form_type
+ properties:
+ form_type:
+ $ref: "../schemas/enums.yaml#/FormType"
+ example:
+ form_type: "neris"
+ responses:
+ "200":
+ description: Validation result
+ content:
+ application/json:
+ schema:
+ $ref: "../schemas/extraction-record.yaml#/ValidationResult"
+ example:
+ valid: true
+ form_type: "neris"
+ extract_id: "550e8400-e29b-41d4-a716-446655440020"
+ missing_required: []
+ missing_recommended:
+ - "fire.detector_present"
+ - "fire.detector_operated"
+ warnings:
+ - "fire.estimated_damage_usd is null NERIS recommends providing damage estimates"
+ field_coverage_percent: 94
+ "404":
+ description: Extraction not found
+ content:
+ application/json:
+ schema:
+ $ref: "../schemas/common.yaml#/ErrorResponse"
+ "422":
+ description: Unknown form type
+ content:
+ application/json:
+ schema:
+ $ref: "../schemas/common.yaml#/ErrorResponse"
+ example:
+ error_code: "UNKNOWN_FORM_TYPE"
+ message: "Form type 'xyz' is not recognized"
+ detail:
+ valid_form_types:
+ - 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
diff --git a/contracts/path/forms.yaml b/contracts/path/forms.yaml
new file mode 100644
index 0000000..b920b41
--- /dev/null
+++ b/contracts/path/forms.yaml
@@ -0,0 +1,348 @@
+# Layer 3 Form Generation Endpoints
+# POST /api/v1/forms/generate/all
+# POST /api/v1/forms/generate/{form_type}
+# GET /api/v1/forms/{form_id}
+# GET /api/v1/forms/{form_id}/pdf
+# GET /api/v1/forms/{form_id}/json
+# GET /api/v1/forms/batch/{batch_id}
+
+generate_all:
+ post:
+ operationId: generateAllForms
+ summary: Generate all applicable forms from an extraction
+ description: |
+ Triggers batch generation of ALL forms listed in the extraction's
+ extraction_metadata.applicable_forms. This is an async batch job.
+ Use skip_incomplete to skip forms that fail validation, or force_partial
+ to generate forms with blank fields where data is missing.
+ tags:
+ - forms
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "../schemas/form-record.yaml#/GenerateAllRequest"
+ example:
+ extract_id: "550e8400-e29b-41d4-a716-446655440020"
+ options:
+ skip_incomplete: true
+ force_partial: false
+ responses:
+ "202":
+ description: Batch form generation job accepted
+ content:
+ application/json:
+ schema:
+ $ref: "../schemas/form-record.yaml#/BatchGenerateResponse"
+ example:
+ batch_id: "550e8400-e29b-41d4-a716-446655440030"
+ status: "processing"
+ extract_id: "550e8400-e29b-41d4-a716-446655440020"
+ forms_queued:
+ - "neris"
+ - "nfirs_basic"
+ - "nfirs_wildland"
+ forms_skipped:
+ - form_type: "nemsis_epcr"
+ reason: "Missing required fields: ems.patients[0].date_of_birth"
+ estimated_seconds: 45
+ poll_url: "/api/v1/forms/batch/550e8400-e29b-41d4-a716-446655440030"
+ "404":
+ description: Extract ID not found
+ content:
+ application/json:
+ schema:
+ $ref: "../schemas/common.yaml#/ErrorResponse"
+ "409":
+ description: Extraction not yet completed
+ content:
+ application/json:
+ schema:
+ $ref: "../schemas/common.yaml#/ErrorResponse"
+ example:
+ error_code: "EXTRACT_NOT_COMPLETED"
+ message: "Extraction is still processing. Wait until status is 'completed'."
+ detail:
+ current_status: "processing"
+ "422":
+ description: No applicable forms found
+ content:
+ application/json:
+ schema:
+ $ref: "../schemas/common.yaml#/ErrorResponse"
+ example:
+ error_code: "NO_APPLICABLE_FORMS"
+ message: "No forms are applicable for this extraction's incident type"
+
+generate_single:
+ post:
+ operationId: generateSingleForm
+ summary: Generate one specific form type
+ description: |
+ Generates a single agency-specific form from the canonical extraction data.
+ The form_type path parameter specifies which form to generate. If the extraction
+ is missing required fields for this form, returns 422 unless force_partial is true.
+ tags:
+ - forms
+ parameters:
+ - name: form_type
+ in: path
+ required: true
+ description: Type of form to generate
+ schema:
+ $ref: "../schemas/enums.yaml#/FormType"
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "../schemas/form-record.yaml#/GenerateSingleRequest"
+ example:
+ extract_id: "550e8400-e29b-41d4-a716-446655440020"
+ options:
+ output_format: "pdf"
+ force_partial: false
+ responses:
+ "202":
+ description: Form generation job accepted
+ content:
+ application/json:
+ schema:
+ $ref: "../schemas/form-record.yaml#/FormGenerateResponse"
+ example:
+ form_id: "550e8400-e29b-41d4-a716-446655440040"
+ form_type: "neris"
+ status: "processing"
+ extract_id: "550e8400-e29b-41d4-a716-446655440020"
+ job_id: "550e8400-e29b-41d4-a716-446655440097"
+ estimated_seconds: 15
+ poll_url: "/api/v1/forms/550e8400-e29b-41d4-a716-446655440040"
+ "404":
+ description: Extract ID not found
+ content:
+ application/json:
+ schema:
+ $ref: "../schemas/common.yaml#/ErrorResponse"
+ "422":
+ description: Validation failure or form not in applicable list
+ content:
+ application/json:
+ schema:
+ $ref: "../schemas/common.yaml#/ErrorResponse"
+ example:
+ error_code: "FORM_VALIDATION_FAILED"
+ message: "Extraction is missing required fields for NERIS form"
+ detail:
+ form_type: "neris"
+ missing_fields:
+ - "incident.types[0].neris_code"
+ - "location.coordinates"
+ validation_errors:
+ - field: "incident.types[0].neris_code"
+ issue: "Required field is null"
+
+form_by_id:
+ get:
+ operationId: getForm
+ summary: Get form metadata and status
+ description: |
+ Returns the form record including generation status, associated extract and
+ incident IDs, and a field_mapping_summary showing how canonical fields were
+ mapped to the form's agency-specific fields (for audit/transparency).
+ tags:
+ - forms
+ parameters:
+ - name: form_id
+ in: path
+ required: true
+ description: Unique identifier of the generated form
+ schema:
+ type: string
+ format: uuid
+ responses:
+ "200":
+ description: Form record found
+ content:
+ application/json:
+ schema:
+ $ref: "../schemas/form-record.yaml#/FormRecord"
+ example:
+ form_id: "550e8400-e29b-41d4-a716-446655440040"
+ form_type: "neris"
+ status: "completed"
+ extract_id: "550e8400-e29b-41d4-a716-446655440020"
+ incident_id: "550e8400-e29b-41d4-a716-446655440050"
+ created_at: "2024-07-15T14:32:00Z"
+ completed_at: "2024-07-15T14:32:12Z"
+ pdf_ready: true
+ json_ready: true
+ field_mapping_summary:
+ total_form_fields: 85
+ fields_filled: 72
+ fields_blank: 13
+ coverage_percent: 84.7
+ "404":
+ description: Form not found
+ content:
+ application/json:
+ schema:
+ $ref: "../schemas/common.yaml#/ErrorResponse"
+
+form_pdf:
+ get:
+ operationId: downloadFormPdf
+ summary: Download filled PDF form
+ description: |
+ Returns the filled PDF binary file for the specified form. If the form
+ generation is still in progress, returns 202 Accepted with a retry_after
+ hint. The Content-Disposition header is set for browser download.
+ tags:
+ - forms
+ parameters:
+ - name: form_id
+ in: path
+ required: true
+ description: Unique identifier of the form to download
+ schema:
+ type: string
+ format: uuid
+ responses:
+ "200":
+ description: Filled PDF file
+ content:
+ application/pdf:
+ schema:
+ type: string
+ format: binary
+ headers:
+ Content-Disposition:
+ description: Attachment filename for download
+ schema:
+ type: string
+ example: 'attachment; filename="NERIS_FF-2024-CA-0157.pdf"'
+ "202":
+ description: Form generation still in progress
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ message:
+ type: string
+ status:
+ type: string
+ retry_after_seconds:
+ type: integer
+ example:
+ message: "Form generation is still in progress"
+ status: "processing"
+ retry_after_seconds: 5
+ "404":
+ description: Form not found
+ content:
+ application/json:
+ schema:
+ $ref: "../schemas/common.yaml#/ErrorResponse"
+ "500":
+ description: PDF generation failed
+ content:
+ application/json:
+ schema:
+ $ref: "../schemas/common.yaml#/ErrorResponse"
+ example:
+ error_code: "PDF_GENERATION_FAILED"
+ message: "Failed to generate PDF for form"
+ detail:
+ reason: "Template file corrupted or missing"
+
+form_json:
+ get:
+ operationId: getFormJson
+ summary: Get form-specific field-mapped JSON
+ description: |
+ Returns the form-specific JSON with fields mapped to the agency's expected
+ format not the canonical FireForm schema, but the actual field names and
+ structure that the target agency system expects. Useful for future direct
+ API submission to agency systems.
+ tags:
+ - forms
+ parameters:
+ - name: form_id
+ in: path
+ required: true
+ description: Unique identifier of the form
+ schema:
+ type: string
+ format: uuid
+ responses:
+ "200":
+ description: Form-specific mapped JSON
+ content:
+ application/json:
+ schema:
+ $ref: "../schemas/form-record.yaml#/FormMappedJson"
+ example:
+ form_type: "neris"
+ form_version: "2.0"
+ form_id: "550e8400-e29b-41d4-a716-446655440040"
+ extract_id: "550e8400-e29b-41d4-a716-446655440020"
+ agency_fields:
+ incident_type: "wildland-fire"
+ incident_date: "2024-07-10"
+ alarm_time: "13:52"
+ acres_burned: 1247
+ cause: "natural"
+ "404":
+ description: Form not found
+ content:
+ application/json:
+ schema:
+ $ref: "../schemas/common.yaml#/ErrorResponse"
+
+batch_by_id:
+ get:
+ operationId: getBatchStatus
+ summary: Get batch form generation status
+ description: |
+ Returns the status of a batch form generation job including progress
+ for each individual form. Poll this endpoint after POST /forms/generate/all.
+ tags:
+ - forms
+ parameters:
+ - name: batch_id
+ in: path
+ required: true
+ description: Unique identifier of the batch job
+ schema:
+ type: string
+ format: uuid
+ responses:
+ "200":
+ description: Batch job status
+ content:
+ application/json:
+ schema:
+ $ref: "../schemas/form-record.yaml#/BatchStatus"
+ example:
+ batch_id: "550e8400-e29b-41d4-a716-446655440030"
+ status: "completed"
+ total: 3
+ completed: 3
+ failed: 0
+ forms:
+ - form_id: "550e8400-e29b-41d4-a716-446655440040"
+ form_type: "neris"
+ status: "completed"
+ - form_id: "550e8400-e29b-41d4-a716-446655440041"
+ form_type: "nfirs_basic"
+ status: "completed"
+ - form_id: "550e8400-e29b-41d4-a716-446655440042"
+ form_type: "nfirs_wildland"
+ status: "completed"
+ "404":
+ description: Batch job not found
+ content:
+ application/json:
+ schema:
+ $ref: "../schemas/common.yaml#/ErrorResponse"
diff --git a/contracts/path/incidents.yaml b/contracts/path/incidents.yaml
new file mode 100644
index 0000000..810a311
--- /dev/null
+++ b/contracts/path/incidents.yaml
@@ -0,0 +1,313 @@
+# Layer 4 Incident Management Endpoints
+# POST /api/v1/incidents
+# GET /api/v1/incidents
+# GET /api/v1/incidents/{incident_id}
+# PATCH /api/v1/incidents/{incident_id}
+# DELETE /api/v1/incidents/{incident_id}
+
+incidents:
+ post:
+ operationId: createIncident
+ summary: Create a full incident record
+ description: |
+ Creates a permanent incident record that links input, extraction, and
+ generated forms into a single coherent unit. Assigns a permanent incident_id
+ and stores the complete incident for future retrieval and reporting.
+ tags:
+ - incidents
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "../schemas/incident-record.yaml#/CreateIncidentRequest"
+ example:
+ extract_id: "550e8400-e29b-41d4-a716-446655440020"
+ incident_number: "CA-SQF-2024-0421"
+ tags:
+ - "wildland"
+ - "mutual_aid"
+ - "lightning"
+ responses:
+ "201":
+ description: Incident record created
+ content:
+ application/json:
+ schema:
+ $ref: "../schemas/incident-record.yaml#/IncidentRecord"
+ example:
+ incident_id: "550e8400-e29b-41d4-a716-446655440050"
+ extract_id: "550e8400-e29b-41d4-a716-446655440020"
+ incident_number: "CA-SQF-2024-0421"
+ status: "draft"
+ forms_generated:
+ - form_id: "550e8400-e29b-41d4-a716-446655440040"
+ form_type: "neris"
+ status: "completed"
+ tags:
+ - "wildland"
+ - "mutual_aid"
+ - "lightning"
+ created_at: "2024-07-15T14:35:00Z"
+ "404":
+ description: Extract ID not found
+ content:
+ application/json:
+ schema:
+ $ref: "../schemas/common.yaml#/ErrorResponse"
+ "409":
+ description: Duplicate incident number
+ content:
+ application/json:
+ schema:
+ $ref: "../schemas/common.yaml#/ErrorResponse"
+ example:
+ error_code: "DUPLICATE_INCIDENT_NUMBER"
+ message: "Incident number CA-SQF-2024-0421 already exists"
+ detail:
+ existing_incident_id: "550e8400-e29b-41d4-a716-446655440049"
+
+ get:
+ operationId: listIncidents
+ summary: List incidents with filtering
+ description: |
+ Returns a paginated list of incident records with optional filtering by
+ date range, incident type, and status. Supports sorting by date.
+ Maximum 100 results per page.
+ tags:
+ - incidents
+ parameters:
+ - name: date_from
+ in: query
+ description: Start date filter (inclusive, ISO 8601)
+ schema:
+ type: string
+ format: date
+ - name: date_to
+ in: query
+ description: End date filter (inclusive, ISO 8601)
+ schema:
+ type: string
+ format: date
+ - name: incident_type
+ in: query
+ description: Filter by incident category
+ schema:
+ $ref: "../schemas/enums.yaml#/IncidentCategory"
+ - name: status
+ in: query
+ description: Filter by report status
+ schema:
+ $ref: "../schemas/enums.yaml#/ReportStatus"
+ - name: page
+ in: query
+ description: Page number (1-based)
+ schema:
+ type: integer
+ minimum: 1
+ default: 1
+ - name: per_page
+ in: query
+ description: Items per page (max 100)
+ schema:
+ type: integer
+ minimum: 1
+ maximum: 100
+ default: 20
+ - name: sort
+ in: query
+ description: Sort order
+ schema:
+ type: string
+ enum:
+ - date_asc
+ - date_desc
+ default: date_desc
+ responses:
+ "200":
+ description: Paginated list of incidents
+ content:
+ application/json:
+ schema:
+ $ref: "../schemas/incident-record.yaml#/IncidentListResponse"
+ example:
+ data:
+ - incident_id: "550e8400-e29b-41d4-a716-446655440050"
+ incident_number: "CA-SQF-2024-0421"
+ status: "draft"
+ incident_name: "Bear Creek Wildfire"
+ incident_type: "fire"
+ incident_date: "2024-07-10"
+ forms_count: 3
+ created_at: "2024-07-15T14:35:00Z"
+ pagination:
+ total: 42
+ page: 1
+ per_page: 20
+ total_pages: 3
+ has_next: true
+ has_prev: false
+ "422":
+ description: Invalid query parameter format
+ content:
+ application/json:
+ schema:
+ $ref: "../schemas/common.yaml#/ErrorResponse"
+ example:
+ error_code: "VALIDATION_ERROR"
+ message: "Invalid date format for date_from"
+ validation_errors:
+ - field: "date_from"
+ issue: "Must be ISO 8601 date format (YYYY-MM-DD)"
+ value: "15/07/2024"
+
+incident_by_id:
+ get:
+ operationId: getIncident
+ summary: Get full incident record
+ description: |
+ Returns the complete incident record including the linked canonical
+ extraction, all generated forms and their statuses, submission log,
+ and audit trail.
+ tags:
+ - incidents
+ parameters:
+ - name: incident_id
+ in: path
+ required: true
+ description: Unique identifier of the incident
+ schema:
+ type: string
+ format: uuid
+ responses:
+ "200":
+ description: Full incident record
+ content:
+ application/json:
+ schema:
+ $ref: "../schemas/incident-record.yaml#/IncidentRecordFull"
+ "404":
+ description: Incident not found
+ content:
+ application/json:
+ schema:
+ $ref: "../schemas/common.yaml#/ErrorResponse"
+
+ patch:
+ operationId: updateIncident
+ summary: Update incident metadata
+ description: |
+ Updates incident metadata such as status, tags, incident number, and notes.
+ Does NOT allow changing the underlying extraction data use PATCH /extract
+ for that. Submitted incidents cannot be modified.
+ tags:
+ - incidents
+ parameters:
+ - name: incident_id
+ in: path
+ required: true
+ description: Unique identifier of the incident to update
+ schema:
+ type: string
+ format: uuid
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "../schemas/incident-record.yaml#/UpdateIncidentRequest"
+ example:
+ status: "approved"
+ tags:
+ - "wildland"
+ - "mutual_aid"
+ - "lightning"
+ - "reviewed"
+ notes: "Reviewed by BC Wilson. Ready for submission."
+ responses:
+ "200":
+ description: Incident updated
+ content:
+ application/json:
+ schema:
+ $ref: "../schemas/incident-record.yaml#/IncidentRecord"
+ "404":
+ description: Incident not found
+ content:
+ application/json:
+ schema:
+ $ref: "../schemas/common.yaml#/ErrorResponse"
+ "409":
+ description: Cannot modify a submitted incident
+ content:
+ application/json:
+ schema:
+ $ref: "../schemas/common.yaml#/ErrorResponse"
+ example:
+ error_code: "INCIDENT_SUBMITTED"
+ message: "Cannot modify an incident that has been submitted"
+ detail:
+ submitted_at: "2024-07-15T18:00:00Z"
+
+ delete:
+ operationId: deleteIncident
+ summary: Soft-delete an incident
+ description: |
+ Performs a soft delete sets a deleted_at timestamp but never removes data.
+ Submitted incidents cannot be deleted. Soft-deleted incidents are excluded
+ from list queries by default but can be recovered.
+ tags:
+ - incidents
+ parameters:
+ - name: incident_id
+ in: path
+ required: true
+ description: Unique identifier of the incident to delete
+ schema:
+ type: string
+ format: uuid
+ responses:
+ "200":
+ description: Incident soft-deleted
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ incident_id:
+ type: string
+ format: uuid
+ deleted_at:
+ type: string
+ format: date-time
+ recoverable:
+ type: boolean
+ example:
+ incident_id: "550e8400-e29b-41d4-a716-446655440050"
+ deleted_at: "2024-07-16T10:00:00Z"
+ recoverable: true
+ "404":
+ description: Incident not found
+ content:
+ application/json:
+ schema:
+ $ref: "../schemas/common.yaml#/ErrorResponse"
+ "409":
+ description: Cannot delete already deleted or submitted
+ content:
+ application/json:
+ schema:
+ $ref: "../schemas/common.yaml#/ErrorResponse"
+ examples:
+ already_deleted:
+ summary: Incident already soft-deleted
+ value:
+ error_code: "ALREADY_DELETED"
+ message: "Incident has already been deleted"
+ detail:
+ deleted_at: "2024-07-16T09:00:00Z"
+ submitted:
+ summary: Submitted incidents cannot be deleted
+ value:
+ error_code: "INCIDENT_SUBMITTED"
+ message: "Submitted incidents cannot be deleted"
diff --git a/contracts/path/input.yaml b/contracts/path/input.yaml
new file mode 100644
index 0000000..a2d6a57
--- /dev/null
+++ b/contracts/path/input.yaml
@@ -0,0 +1,247 @@
+# Layer 1 Input Endpoints
+# POST /api/v1/input/voice, POST /api/v1/input/text, GET /api/v1/input/{input_id}
+
+voice:
+ post:
+ operationId: submitVoiceInput
+ summary: Submit a voice recording for transcription
+ description: |
+ Accepts an audio file (voice memo) from a first responder along with optional
+ incident metadata. The audio is saved and queued for transcription via Whisper
+ running on the local Ollama instance. Returns an input_id immediately the
+ transcription runs asynchronously. Poll GET /api/v1/input/{input_id} for status.
+ tags:
+ - input
+ requestBody:
+ required: true
+ content:
+ multipart/form-data:
+ schema:
+ type: object
+ required:
+ - audio_file
+ properties:
+ audio_file:
+ type: string
+ format: binary
+ description: Audio file in wav, mp3, m4a, ogg, or webm format. Max 500MB.
+ station_id:
+ type: string
+ description: Station identifier (e.g. "STA-045")
+ responder_badge:
+ type: string
+ description: Badge number of the responder submitting the report
+ incident_date_hint:
+ type: string
+ format: date
+ description: Approximate date of the incident to aid extraction
+ example:
+ station_id: "STA-045"
+ responder_badge: "FD-7842"
+ incident_date_hint: "2024-07-15"
+ responses:
+ "201":
+ description: Voice input accepted and queued for transcription
+ content:
+ application/json:
+ schema:
+ $ref: "../schemas/input-record.yaml#/VoiceInputResponse"
+ example:
+ input_id: "550e8400-e29b-41d4-a716-446655440001"
+ status: "queued"
+ input_type: "voice"
+ estimated_processing_seconds: 30
+ created_at: "2024-07-15T14:25:00Z"
+ job_id: "550e8400-e29b-41d4-a716-446655440099"
+ poll_url: "/api/v1/input/550e8400-e29b-41d4-a716-446655440001"
+ "400":
+ description: Malformed request (missing audio file or invalid form data)
+ content:
+ application/json:
+ schema:
+ $ref: "../schemas/common.yaml#/ErrorResponse"
+ "413":
+ description: Audio file exceeds 500MB limit
+ content:
+ application/json:
+ schema:
+ $ref: "../schemas/common.yaml#/ErrorResponse"
+ example:
+ error_code: "FILE_TOO_LARGE"
+ message: "Audio file exceeds maximum size of 500MB"
+ detail:
+ max_size_bytes: 524288000
+ received_size_bytes: 600000000
+ "415":
+ description: Unsupported audio format
+ content:
+ application/json:
+ schema:
+ $ref: "../schemas/common.yaml#/ErrorResponse"
+ example:
+ error_code: "UNSUPPORTED_FORMAT"
+ message: "Audio format not supported. Accepted formats: wav, mp3, m4a, ogg, webm"
+ detail:
+ accepted_formats:
+ - wav
+ - mp3
+ - m4a
+ - ogg
+ - webm
+ "503":
+ description: Ollama/Whisper service unavailable
+ content:
+ application/json:
+ schema:
+ $ref: "../schemas/common.yaml#/ErrorResponse"
+ example:
+ error_code: "LLM_UNAVAILABLE"
+ message: "Ollama transcription service is not available"
+ retry_after_seconds: 30
+
+text:
+ post:
+ operationId: submitTextInput
+ summary: Submit a text narrative for processing
+ description: |
+ Accepts a free-text incident narrative from a first responder. This is
+ synchronous the text is stored immediately and the input is marked as
+ "ready" for extraction. No transcription step needed.
+ tags:
+ - input
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "../schemas/input-record.yaml#/TextInputRequest"
+ example:
+ narrative: >
+ Responded to a wildfire at Bear Creek Trailhead around 1:45 PM on July 10th.
+ Lightning strike ignited timber litter in mixed conifer and chaparral. Fire
+ spread rapidly northeast driven by 15-25 mph SW winds. We deployed 18 engines,
+ 6 water tenders, and 3 helicopters. 247 personnel total. One firefighter
+ treated for heat exhaustion, returned to duty July 12. 1247 acres burned across
+ federal, state, and private land. 47 structures threatened, 3 damaged, none
+ destroyed. Fire contained July 14, controlled July 15.
+ station_id: "STA-045"
+ responder_badge: "FD-7842"
+ incident_date_hint: "2024-07-10"
+ responses:
+ "201":
+ description: Text input accepted and ready for extraction
+ content:
+ application/json:
+ schema:
+ $ref: "../schemas/input-record.yaml#/TextInputResponse"
+ example:
+ input_id: "550e8400-e29b-41d4-a716-446655440001"
+ status: "ready"
+ input_type: "text"
+ character_count: 612
+ word_count: 98
+ created_at: "2024-07-15T14:25:00Z"
+ "400":
+ description: Malformed JSON request body
+ content:
+ application/json:
+ schema:
+ $ref: "../schemas/common.yaml#/ErrorResponse"
+ "413":
+ description: Narrative exceeds 50,000 character limit
+ content:
+ application/json:
+ schema:
+ $ref: "../schemas/common.yaml#/ErrorResponse"
+ example:
+ error_code: "NARRATIVE_TOO_LONG"
+ message: "Narrative exceeds maximum length of 50,000 characters"
+ detail:
+ max_characters: 50000
+ received_characters: 52000
+ "422":
+ description: Validation error (empty or too-short narrative)
+ content:
+ application/json:
+ schema:
+ $ref: "../schemas/common.yaml#/ErrorResponse"
+ example:
+ error_code: "VALIDATION_ERROR"
+ message: "Narrative is too short to extract meaningful incident data"
+ validation_errors:
+ - field: "narrative"
+ issue: "Must contain at least 10 words"
+ value: "Fire happened"
+
+input_by_id:
+ get:
+ operationId: getInput
+ summary: Get input record by ID
+ description: |
+ Returns the full input record including the original text or transcript,
+ processing status for voice inputs, and metadata. For voice inputs, poll
+ this endpoint until status transitions from "queued"/"transcribing" to "ready".
+ tags:
+ - input
+ parameters:
+ - name: input_id
+ in: path
+ required: true
+ description: Unique identifier of the input record
+ schema:
+ type: string
+ format: uuid
+ responses:
+ "200":
+ description: Input record found
+ content:
+ application/json:
+ schema:
+ $ref: "../schemas/input-record.yaml#/InputRecord"
+ examples:
+ voice_ready:
+ summary: Voice input with completed transcription
+ value:
+ input_id: "550e8400-e29b-41d4-a716-446655440001"
+ input_type: "voice"
+ status: "ready"
+ transcript: "Responded to a wildfire at Bear Creek..."
+ original_filename: "incident_memo.m4a"
+ audio_duration_seconds: 180
+ character_count: 612
+ word_count: 98
+ station_id: "STA-045"
+ responder_badge: "FD-7842"
+ incident_date_hint: "2024-07-10"
+ created_at: "2024-07-15T14:25:00Z"
+ updated_at: "2024-07-15T14:25:30Z"
+ voice_processing:
+ summary: Voice input still transcribing
+ value:
+ input_id: "550e8400-e29b-41d4-a716-446655440001"
+ input_type: "voice"
+ status: "transcribing"
+ transcript: null
+ created_at: "2024-07-15T14:25:00Z"
+ updated_at: "2024-07-15T14:25:10Z"
+ retry_after_seconds: 5
+ text_ready:
+ summary: Text input ready for extraction
+ value:
+ input_id: "550e8400-e29b-41d4-a716-446655440002"
+ input_type: "text"
+ status: "ready"
+ transcript: "Responded to a wildfire at Bear Creek..."
+ character_count: 612
+ word_count: 98
+ created_at: "2024-07-15T14:25:00Z"
+ updated_at: "2024-07-15T14:25:00Z"
+ "404":
+ description: Input record not found
+ content:
+ application/json:
+ schema:
+ $ref: "../schemas/common.yaml#/ErrorResponse"
+ example:
+ error_code: "INPUT_NOT_FOUND"
+ message: "Input with ID 550e8400-e29b-41d4-a716-446655440099 not found"
diff --git a/contracts/path/jobs.yaml b/contracts/path/jobs.yaml
new file mode 100644
index 0000000..2ac042f
--- /dev/null
+++ b/contracts/path/jobs.yaml
@@ -0,0 +1,79 @@
+# Layer 8 Async Job Status (Cross-cutting)
+# GET /api/v1/jobs/{job_id}
+
+job_by_id:
+ get:
+ operationId: getJobStatus
+ summary: Get async job status
+ description: |
+ Universal job status endpoint for any asynchronous operation in FireForm —
+ transcription, LLM extraction, form generation, and report generation.
+ Returns the current status, progress percentage, and a result URL when
+ the job completes. Poll this endpoint for long-running operations.
+ tags:
+ - jobs
+ parameters:
+ - name: job_id
+ in: path
+ required: true
+ description: Unique identifier of the async job
+ schema:
+ type: string
+ format: uuid
+ responses:
+ "200":
+ description: Job status
+ content:
+ application/json:
+ schema:
+ $ref: "../schemas/common.yaml#/Job"
+ examples:
+ queued:
+ summary: Job waiting in queue
+ value:
+ job_id: "550e8400-e29b-41d4-a716-446655440098"
+ job_type: "extraction"
+ status: "queued"
+ progress_percent: 0
+ created_at: "2024-07-15T14:30:00Z"
+ updated_at: "2024-07-15T14:30:00Z"
+ processing:
+ summary: Job currently processing
+ value:
+ job_id: "550e8400-e29b-41d4-a716-446655440098"
+ job_type: "extraction"
+ status: "processing"
+ progress_percent: 45
+ created_at: "2024-07-15T14:30:00Z"
+ updated_at: "2024-07-15T14:30:30Z"
+ completed:
+ summary: Job completed successfully
+ value:
+ job_id: "550e8400-e29b-41d4-a716-446655440098"
+ job_type: "extraction"
+ status: "completed"
+ progress_percent: 100
+ result_url: "/api/v1/extract/550e8400-e29b-41d4-a716-446655440020"
+ created_at: "2024-07-15T14:30:00Z"
+ updated_at: "2024-07-15T14:31:05Z"
+ failed:
+ summary: Job failed with error
+ value:
+ job_id: "550e8400-e29b-41d4-a716-446655440098"
+ job_type: "extraction"
+ status: "failed"
+ progress_percent: 23
+ error:
+ error_code: "LLM_TIMEOUT"
+ message: "Ollama did not respond within 120 seconds"
+ created_at: "2024-07-15T14:30:00Z"
+ updated_at: "2024-07-15T14:32:00Z"
+ "404":
+ description: Job not found
+ content:
+ application/json:
+ schema:
+ $ref: "../schemas/common.yaml#/ErrorResponse"
+ example:
+ error_code: "JOB_NOT_FOUND"
+ message: "Job with ID 550e8400-e29b-41d4-a716-446655440099 not found"
diff --git a/contracts/path/reporting.yaml b/contracts/path/reporting.yaml
new file mode 100644
index 0000000..ad10172
--- /dev/null
+++ b/contracts/path/reporting.yaml
@@ -0,0 +1,191 @@
+# Layer 5 Reporting & Analytics Endpoints
+# GET /api/v1/reports/summary
+# POST /api/v1/reports/generate
+# GET /api/v1/reports/{report_id}
+
+summary:
+ get:
+ operationId: getReportSummary
+ summary: Get aggregate incident statistics
+ description: |
+ Returns aggregate statistics for a dashboard view total incidents,
+ breakdown by type and status, forms generated, average completeness
+ scores, and average processing times. Supports filtering by date range
+ and grouping by time period or incident type.
+ tags:
+ - reporting
+ parameters:
+ - name: date_from
+ in: query
+ description: Start date (inclusive, ISO 8601)
+ schema:
+ type: string
+ format: date
+ - name: date_to
+ in: query
+ description: End date (inclusive, ISO 8601)
+ schema:
+ type: string
+ format: date
+ - name: group_by
+ in: query
+ description: Group results by time period or incident type
+ schema:
+ type: string
+ enum:
+ - day
+ - week
+ - month
+ - incident_type
+ default: month
+ responses:
+ "200":
+ description: Aggregate statistics
+ content:
+ application/json:
+ schema:
+ $ref: "../schemas/reporting.yaml#/ReportSummary"
+ example:
+ date_from: "2024-01-01"
+ date_to: "2024-07-15"
+ total_incidents: 157
+ by_type:
+ fire: 42
+ ems: 68
+ rescue: 12
+ hazardous_conditions: 8
+ service_call: 15
+ good_intent: 7
+ false_alarm: 5
+ by_status:
+ draft: 3
+ under_review: 2
+ approved: 12
+ submitted: 140
+ forms_generated: 489
+ avg_completeness_score: 88.3
+ avg_processing_time_seconds: 47.2
+ groups:
+ - period: "2024-07"
+ incident_count: 23
+ forms_generated: 71
+
+generate:
+ post:
+ operationId: generateReport
+ summary: Generate a periodic report
+ description: |
+ Generates a periodic (monthly, quarterly, or annual) aggregate report
+ as PDF or JSON. This includes statistics, incident summaries, and
+ compliance metrics required by agencies like USFA/NERIS. This is an
+ async operation returns a report_id for polling.
+
+ Note: USFA encourages monthly submission but requires quarterly at minimum.
+ This endpoint supports generating reports aligned with those cadences.
+ tags:
+ - reporting
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "../schemas/reporting.yaml#/GenerateReportRequest"
+ example:
+ period_type: "monthly"
+ year: 2024
+ month: 7
+ format: "pdf"
+ responses:
+ "202":
+ description: Report generation job accepted
+ content:
+ application/json:
+ schema:
+ $ref: "../schemas/common.yaml#/AsyncJobResponse"
+ example:
+ job_id: "550e8400-e29b-41d4-a716-446655440096"
+ job_type: "report_generation"
+ status: "processing"
+ estimated_seconds: 30
+ poll_url: "/api/v1/reports/550e8400-e29b-41d4-a716-446655440060"
+ report_id: "550e8400-e29b-41d4-a716-446655440060"
+ "422":
+ description: Invalid period or future date
+ content:
+ application/json:
+ schema:
+ $ref: "../schemas/common.yaml#/ErrorResponse"
+ examples:
+ future_period:
+ summary: Future period requested
+ value:
+ error_code: "FUTURE_PERIOD"
+ message: "Cannot generate a report for a future period"
+ invalid_quarter:
+ summary: Invalid quarter value
+ value:
+ error_code: "VALIDATION_ERROR"
+ message: "Invalid quarter value"
+ validation_errors:
+ - field: "quarter"
+ issue: "Must be 1, 2, 3, or 4"
+ value: 5
+
+report_by_id:
+ get:
+ operationId: getReport
+ summary: Retrieve a generated report
+ description: |
+ Returns a previously generated periodic report. If the report is still
+ being generated, returns 202 Accepted with a retry hint. When complete,
+ returns the report in the originally requested format (PDF binary or JSON).
+ tags:
+ - reporting
+ parameters:
+ - name: report_id
+ in: path
+ required: true
+ description: Unique identifier of the generated report
+ schema:
+ type: string
+ format: uuid
+ responses:
+ "200":
+ description: Generated report (PDF or JSON depending on original request)
+ content:
+ application/pdf:
+ schema:
+ type: string
+ format: binary
+ application/json:
+ schema:
+ $ref: "../schemas/reporting.yaml#/PeriodicReport"
+ headers:
+ Content-Disposition:
+ description: Attachment filename for PDF downloads
+ schema:
+ type: string
+ example: 'attachment; filename="FireForm_Monthly_2024-07.pdf"'
+ "202":
+ description: Report still being generated
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ message:
+ type: string
+ status:
+ type: string
+ retry_after_seconds:
+ type: integer
+ example:
+ message: "Report generation is still in progress"
+ status: "processing"
+ retry_after_seconds: 10
+ "404":
+ description: Report not found
+ content:
+ application/json:
+ schema:
+ $ref: "../schemas/common.yaml#/ErrorResponse"
diff --git a/contracts/path/system.yaml b/contracts/path/system.yaml
new file mode 100644
index 0000000..cd00901
--- /dev/null
+++ b/contracts/path/system.yaml
@@ -0,0 +1,152 @@
+# Layer 7 System Endpoints
+# GET /api/v1/health
+# GET /api/v1/schema/incident
+# GET /api/v1/schema/incident/versions
+
+health:
+ get:
+ operationId: getHealth
+ summary: System health check
+ description: |
+ Returns the health status of all FireForm components — database, Ollama LLM
+ (including loaded models, GPU status, and current load), Whisper transcription,
+ and file storage. Returns 200 even when degraded (so load balancers don't kill
+ it), 503 only if the system is truly unhealthy and cannot serve any requests.
+ tags:
+ - system
+ responses:
+ "200":
+ description: System is healthy or degraded
+ content:
+ application/json:
+ schema:
+ $ref: "../schemas/system.yaml#/HealthStatus"
+ examples:
+ healthy:
+ summary: All systems operational
+ value:
+ status: "healthy"
+ version: "1.0.0"
+ uptime_seconds: 86400
+ components:
+ database:
+ status: "healthy"
+ response_time_ms: 2
+ ollama:
+ status: "healthy"
+ response_time_ms: 15
+ model_loaded: "llama3:8b"
+ ollama_version: "0.3.0"
+ models_available:
+ - name: "llama3:8b"
+ size_gb: 4.7
+ quantization: "Q4_K_M"
+ loaded: true
+ - name: "mistral:7b"
+ size_gb: 4.1
+ quantization: "Q4_0"
+ loaded: false
+ current_load:
+ active_requests: 1
+ queued_requests: 0
+ whisper:
+ status: "healthy"
+ response_time_ms: 10
+ storage:
+ status: "healthy"
+ disk_free_gb: 120.5
+ degraded:
+ summary: Some components have issues
+ value:
+ status: "degraded"
+ version: "1.0.0"
+ uptime_seconds: 3600
+ components:
+ database:
+ status: "healthy"
+ response_time_ms: 2
+ ollama:
+ status: "degraded"
+ response_time_ms: 5000
+ detail: "High latency model may be loading"
+ current_load:
+ active_requests: 3
+ queued_requests: 2
+ whisper:
+ status: "unhealthy"
+ detail: "Whisper model not loaded"
+ storage:
+ status: "healthy"
+ disk_free_gb: 120.5
+ "503":
+ description: System is unhealthy cannot serve requests
+ content:
+ application/json:
+ schema:
+ $ref: "../schemas/system.yaml#/HealthStatus"
+ example:
+ status: "unhealthy"
+ version: "1.0.0"
+ uptime_seconds: 10
+ components:
+ database:
+ status: "unhealthy"
+ detail: "Connection refused"
+ ollama:
+ status: "unhealthy"
+ detail: "Connection refused"
+
+schema_incident:
+ get:
+ operationId: getIncidentSchema
+ summary: Get the canonical FireForm JSON Schema
+ description: |
+ Returns the full canonical FireForm incident JSON Schema (JSON Schema
+ draft-07). Clients can use this to validate incident data locally before
+ submitting. This is a schema-as-API pattern for interoperability.
+ tags:
+ - system
+ responses:
+ "200":
+ description: Full JSON Schema document
+ content:
+ application/json:
+ schema:
+ type: object
+ description: JSON Schema draft-07 document for the canonical incident model
+ example:
+ $schema: "http://json-schema.org/draft-07/schema#"
+ title: "FireForm Canonical Incident"
+ type: "object"
+ properties:
+ schema_version:
+ type: "string"
+
+schema_versions:
+ get:
+ operationId: getSchemaVersions
+ summary: Get schema version history
+ description: |
+ Returns the version history of the canonical FireForm incident schema,
+ including changelogs and whether each version introduced breaking changes.
+ Useful for clients tracking schema evolution across FireForm updates.
+ tags:
+ - system
+ responses:
+ "200":
+ description: Schema version history
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: "../schemas/system.yaml#/SchemaVersion"
+ example:
+ - version: "1.1.0"
+ released_at: "2026-02-01T00:00:00Z"
+ changelog: "Added NERIS fields replacing legacy NFIRS. Added wildland.aerial_operations. Updated incident.types to include neris_code."
+ breaking_changes: true
+ - version: "1.0.0"
+ released_at: "2024-01-01T00:00:00Z"
+ changelog: "Initial canonical schema with NFIRS support."
+ breaking_changes: false
diff --git a/contracts/path/templates.yaml b/contracts/path/templates.yaml
new file mode 100644
index 0000000..dab9dca
--- /dev/null
+++ b/contracts/path/templates.yaml
@@ -0,0 +1,267 @@
+# Layer 6 Template & Configuration Endpoints
+# GET /api/v1/templates
+# GET /api/v1/templates/{template_id}
+# POST /api/v1/templates
+# PUT /api/v1/templates/{template_id}
+# GET /api/v1/templates/{template_id}/fields
+
+templates:
+ get:
+ operationId: listTemplates
+ summary: List all available form templates
+ description: |
+ Returns all registered form templates including built-in standard templates
+ (NERIS, NEMSIS, NIBRS, NFIRS modules, OSHA, etc.) and any custom templates
+ added for specific jurisdictions. Each template defines the fields, validation
+ rules, and mapping from the canonical FireForm schema.
+ tags:
+ - templates
+ responses:
+ "200":
+ description: List of all templates
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: "../schemas/template.yaml#/TemplateSummary"
+ example:
+ - template_id: "550e8400-e29b-41d4-a716-446655440070"
+ form_type: "neris"
+ display_name: "NERIS Incident Report"
+ jurisdiction: "US-Federal"
+ agency_type: "fire_department"
+ version: "2.0"
+ last_updated: "2026-02-01"
+ field_count: 85
+ status: "active"
+ - template_id: "550e8400-e29b-41d4-a716-446655440071"
+ form_type: "nemsis_epcr"
+ display_name: "NEMSIS Electronic Patient Care Report"
+ jurisdiction: "US-Federal"
+ agency_type: "ems"
+ version: "3.5"
+ last_updated: "2024-01-15"
+ field_count: 142
+ status: "active"
+ - template_id: "550e8400-e29b-41d4-a716-446655440072"
+ form_type: "nfirs_basic"
+ display_name: "NFIRS Basic Module (Legacy)"
+ jurisdiction: "US-Federal"
+ agency_type: "fire_department"
+ version: "5.0"
+ last_updated: "2015-01-01"
+ field_count: 65
+ status: "legacy"
+
+ post:
+ operationId: createTemplate
+ summary: Register a new custom form template
+ description: |
+ Registers a new form template for a jurisdiction or agency not yet supported.
+ This is how FireForm extends to new states, countries, or custom agency forms
+ without code changes. The template defines all fields, their types, validation
+ rules, and how each maps from the canonical FireForm schema.
+ tags:
+ - templates
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "../schemas/template.yaml#/CreateTemplateRequest"
+ example:
+ form_type: "state_texas"
+ display_name: "Texas State Fire Marshal Incident Report"
+ jurisdiction: "US-TX"
+ agency_type: "fire_department"
+ fields:
+ - field_name: "incident_number"
+ field_type: "string"
+ required: true
+ max_length: 20
+ description: "State-assigned incident number"
+ canonical_mapping: "report_metadata.incident_number"
+ - field_name: "fire_cause"
+ field_type: "enum"
+ required: true
+ allowed_values:
+ - "accidental"
+ - "natural"
+ - "intentional"
+ - "undetermined"
+ canonical_mapping: "fire.cause_category"
+ field_mappings_from_canonical:
+ "report_metadata.incident_number": "incident_number"
+ "fire.cause_category": "fire_cause"
+ responses:
+ "201":
+ description: Template created
+ content:
+ application/json:
+ schema:
+ $ref: "../schemas/template.yaml#/Template"
+ "409":
+ description: Template with this form_type already exists
+ content:
+ application/json:
+ schema:
+ $ref: "../schemas/common.yaml#/ErrorResponse"
+ "422":
+ description: Invalid template definition
+ content:
+ application/json:
+ schema:
+ $ref: "../schemas/common.yaml#/ErrorResponse"
+
+template_by_id:
+ get:
+ operationId: getTemplate
+ summary: Get full template schema
+ description: |
+ Returns the complete template definition including all fields, their types,
+ required/optional status, validation rules, and the mapping from canonical
+ FireForm schema fields. Includes the source standard reference where applicable.
+ tags:
+ - templates
+ parameters:
+ - name: template_id
+ in: path
+ required: true
+ description: Unique identifier of the template
+ schema:
+ type: string
+ format: uuid
+ responses:
+ "200":
+ description: Full template definition
+ content:
+ application/json:
+ schema:
+ $ref: "../schemas/template.yaml#/Template"
+ "404":
+ description: Template not found
+ content:
+ application/json:
+ schema:
+ $ref: "../schemas/common.yaml#/ErrorResponse"
+
+ put:
+ operationId: updateTemplate
+ summary: Update an existing template
+ description: |
+ Replaces an existing template definition. Used when an upstream standard
+ changes (e.g., NERIS schema update). Templates used by submitted incidents
+ cannot be modified create a new version instead.
+ tags:
+ - templates
+ parameters:
+ - name: template_id
+ in: path
+ required: true
+ description: Unique identifier of the template to update
+ schema:
+ type: string
+ format: uuid
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "../schemas/template.yaml#/CreateTemplateRequest"
+ responses:
+ "200":
+ description: Template updated
+ content:
+ application/json:
+ schema:
+ $ref: "../schemas/template.yaml#/Template"
+ "404":
+ description: Template not found
+ content:
+ application/json:
+ schema:
+ $ref: "../schemas/common.yaml#/ErrorResponse"
+ "409":
+ description: Template in use by submitted incidents cannot modify
+ content:
+ application/json:
+ schema:
+ $ref: "../schemas/common.yaml#/ErrorResponse"
+ example:
+ error_code: "TEMPLATE_IN_USE"
+ message: "Template is referenced by submitted incidents. Create a new version instead."
+ detail:
+ submitted_incident_count: 15
+ recommendation: "POST /api/v1/templates to create a new version"
+
+template_fields:
+ get:
+ operationId: getTemplateFields
+ summary: Get template field definitions
+ description: |
+ Returns just the fields list for a template with type, required/optional
+ status, validation rules, and canonical schema mapping. Useful for building
+ validation checklists and for the validate endpoint to determine requirements.
+ tags:
+ - templates
+ parameters:
+ - name: template_id
+ in: path
+ required: true
+ description: Unique identifier of the template
+ schema:
+ type: string
+ format: uuid
+ - name: required_only
+ in: query
+ description: If true, return only required fields
+ schema:
+ type: boolean
+ default: false
+ responses:
+ "200":
+ description: List of template fields
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ template_id:
+ type: string
+ format: uuid
+ form_type:
+ $ref: "../schemas/enums.yaml#/FormType"
+ total_fields:
+ type: integer
+ required_fields:
+ type: integer
+ optional_fields:
+ type: integer
+ fields:
+ type: array
+ items:
+ $ref: "../schemas/template.yaml#/TemplateField"
+ example:
+ template_id: "550e8400-e29b-41d4-a716-446655440070"
+ form_type: "neris"
+ total_fields: 85
+ required_fields: 32
+ optional_fields: 53
+ fields:
+ - field_name: "incident_type"
+ field_type: "enum"
+ required: true
+ description: "Primary incident type code"
+ canonical_mapping: "incident.types[0].neris_code"
+ - field_name: "incident_date"
+ field_type: "date"
+ required: true
+ description: "Date of incident"
+ canonical_mapping: "incident.start_datetime"
+ "404":
+ description: Template not found
+ content:
+ application/json:
+ schema:
+ $ref: "../schemas/common.yaml#/ErrorResponse"
diff --git a/contracts/schemas/common.yaml b/contracts/schemas/common.yaml
new file mode 100644
index 0000000..0798f85
--- /dev/null
+++ b/contracts/schemas/common.yaml
@@ -0,0 +1,129 @@
+# Common schemas used across multiple endpoints
+
+ErrorResponse:
+ type: object
+ required:
+ - error_code
+ - message
+ properties:
+ error_code:
+ type: string
+ description: Machine-readable error code
+ example: "EXTRACT_NOT_FOUND"
+ message:
+ type: string
+ description: Human-readable error message
+ example: "Extraction with ID xyz not found"
+ detail:
+ type: object
+ description: Additional context specific to the error type
+ additionalProperties: true
+ retry_after_seconds:
+ type: integer
+ description: Present on 503 errors seconds to wait before retrying
+ validation_errors:
+ type: array
+ description: Present on 422 errors list of field-level validation issues
+ items:
+ $ref: "#/ValidationError"
+
+ValidationError:
+ type: object
+ properties:
+ field:
+ type: string
+ description: JSON path of the invalid field
+ example: "fire.cause_certainty"
+ issue:
+ type: string
+ description: Description of the validation issue
+ example: "Must be one of: confirmed, probable, suspected, undetermined"
+ value:
+ description: The invalid value that was provided
+
+Pagination:
+ type: object
+ properties:
+ total:
+ type: integer
+ description: Total number of items across all pages
+ page:
+ type: integer
+ description: Current page number (1-based)
+ per_page:
+ type: integer
+ description: Items per page
+ total_pages:
+ type: integer
+ description: Total number of pages
+ has_next:
+ type: boolean
+ description: Whether there is a next page
+ has_prev:
+ type: boolean
+ description: Whether there is a previous page
+
+AsyncJobResponse:
+ type: object
+ required:
+ - job_id
+ - status
+ properties:
+ job_id:
+ type: string
+ format: uuid
+ description: Unique job identifier for polling
+ job_type:
+ type: string
+ description: Type of async operation
+ enum:
+ - transcription
+ - extraction
+ - form_generation
+ - batch_form_generation
+ - report_generation
+ status:
+ $ref: "enums.yaml#/JobStatus"
+ estimated_seconds:
+ type: integer
+ description: Estimated time to completion in seconds
+ poll_url:
+ type: string
+ description: Full URL to poll for job status
+
+Job:
+ type: object
+ required:
+ - job_id
+ - job_type
+ - status
+ properties:
+ job_id:
+ type: string
+ format: uuid
+ job_type:
+ type: string
+ enum:
+ - transcription
+ - extraction
+ - form_generation
+ - batch_form_generation
+ - report_generation
+ status:
+ $ref: "enums.yaml#/JobStatus"
+ progress_percent:
+ type: integer
+ minimum: 0
+ maximum: 100
+ description: Progress percentage (0–100)
+ result_url:
+ type: string
+ description: URL of the completed result (present when status is "completed")
+ error:
+ $ref: "#/ErrorResponse"
+ created_at:
+ type: string
+ format: date-time
+ updated_at:
+ type: string
+ format: date-time
diff --git a/contracts/schemas/enums.yaml b/contracts/schemas/enums.yaml
new file mode 100644
index 0000000..b79beda
--- /dev/null
+++ b/contracts/schemas/enums.yaml
@@ -0,0 +1,130 @@
+# Shared enums used across the FireForm API
+
+InputStatus:
+ type: string
+ enum:
+ - queued
+ - transcribing
+ - ready
+ - failed
+ description: Status of an input record
+
+ExtractionStatus:
+ type: string
+ enum:
+ - processing
+ - completed
+ - failed
+ - needs_review
+ description: Status of an AI extraction job
+
+FormStatus:
+ type: string
+ enum:
+ - queued
+ - generating
+ - completed
+ - failed
+ description: Status of a form generation job
+
+ReportStatus:
+ type: string
+ enum:
+ - draft
+ - under_review
+ - approved
+ - submitted
+ description: Status of an incident report in the approval workflow
+
+JobStatus:
+ type: string
+ enum:
+ - queued
+ - processing
+ - completed
+ - failed
+ description: Status of any async job
+
+FormType:
+ type: string
+ enum:
+ - 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
+ description: |
+ Stable string identifier for a form type. Includes the new NERIS standard
+ (replacing NFIRS as of Feb 2026), legacy NFIRS modules, NEMSIS, NIBRS,
+ OSHA, state-specific, and international (UN SSIRS) forms.
+
+IncidentCategory:
+ type: string
+ enum:
+ - fire
+ - ems
+ - rescue
+ - hazardous_conditions
+ - service_call
+ - good_intent
+ - false_alarm
+ - law_enforcement
+ description: High-level incident category
+
+CauseCertainty:
+ type: string
+ enum:
+ - confirmed
+ - probable
+ - suspected
+ - undetermined
+ description: Certainty level of fire cause determination
+
+InjurySeverity:
+ type: string
+ enum:
+ - minor
+ - moderate
+ - severe
+ - fatal
+ description: Severity classification for injuries
+
+RateOfSpread:
+ type: string
+ enum:
+ - slow
+ - moderate
+ - rapid
+ - extreme
+ description: Rate of fire spread classification
+
+PeriodType:
+ type: string
+ enum:
+ - monthly
+ - quarterly
+ - annual
+ description: Reporting period type
+
+OutputFormat:
+ type: string
+ enum:
+ - pdf
+ - json
+ - both
+ description: Output format for generated forms
diff --git a/contracts/schemas/extraction-record.yaml b/contracts/schemas/extraction-record.yaml
new file mode 100644
index 0000000..ae0fd48
--- /dev/null
+++ b/contracts/schemas/extraction-record.yaml
@@ -0,0 +1,134 @@
+# Extraction-related schemas
+
+ExtractionRequest:
+ type: object
+ properties:
+ model_override:
+ type: string
+ description: Override the default LLM model (e.g. "llama3:70b")
+ extraction_hints:
+ type: object
+ description: Optional hints to improve extraction accuracy
+ properties:
+ incident_type:
+ type: string
+ description: Hint about the incident type (e.g. "wildland_fire", "structure_fire")
+ state:
+ type: string
+ description: US state code to apply state-specific extraction rules
+ agency_type:
+ type: string
+ description: Agency type hint for form selection
+ additionalProperties: true
+
+ExtractionCompleted:
+ type: object
+ required:
+ - extract_id
+ - input_id
+ - status
+ - incident_contract
+ properties:
+ extract_id:
+ type: string
+ format: uuid
+ input_id:
+ type: string
+ format: uuid
+ status:
+ type: string
+ enum:
+ - completed
+ completed_at:
+ type: string
+ format: date-time
+ model_used:
+ type: string
+ description: LLM model that performed the extraction
+ processing_time_seconds:
+ type: number
+ incident_contract:
+ $ref: "incident-contract.yaml#/IncidentContract"
+ corrections:
+ type: array
+ description: Audit trail of manual corrections applied via PATCH
+ items:
+ type: object
+ properties:
+ field_path:
+ type: string
+ original_value: {}
+ corrected_value: {}
+ corrected_at:
+ type: string
+ format: date-time
+ corrected_by:
+ type: string
+
+ExtractionProcessing:
+ type: object
+ required:
+ - extract_id
+ - input_id
+ - status
+ properties:
+ extract_id:
+ type: string
+ format: uuid
+ input_id:
+ type: string
+ format: uuid
+ status:
+ type: string
+ enum:
+ - processing
+ - failed
+ started_at:
+ type: string
+ format: date-time
+ retry_after_seconds:
+ type: integer
+ description: Polling hint for clients
+ error_type:
+ type: string
+ nullable: true
+ description: Present when status is "failed"
+ error_detail:
+ type: string
+ nullable: true
+ partial_result:
+ $ref: "incident-contract.yaml#/IncidentContract"
+
+ValidationResult:
+ type: object
+ required:
+ - valid
+ - form_type
+ - extract_id
+ properties:
+ valid:
+ type: boolean
+ description: Whether all required fields for this form type are present
+ form_type:
+ $ref: "enums.yaml#/FormType"
+ extract_id:
+ type: string
+ format: uuid
+ missing_required:
+ type: array
+ items:
+ type: string
+ description: JSON paths of required fields that are missing
+ missing_recommended:
+ type: array
+ items:
+ type: string
+ description: JSON paths of recommended fields that are missing
+ warnings:
+ type: array
+ items:
+ type: string
+ description: Human-readable warnings about data quality
+ field_coverage_percent:
+ type: number
+ description: Percentage of form fields that have values
diff --git a/contracts/schemas/form-record.yaml b/contracts/schemas/form-record.yaml
new file mode 100644
index 0000000..a83ec41
--- /dev/null
+++ b/contracts/schemas/form-record.yaml
@@ -0,0 +1,198 @@
+# Form generation related schemas
+
+GenerateAllRequest:
+ type: object
+ required:
+ - extract_id
+ properties:
+ extract_id:
+ type: string
+ format: uuid
+ options:
+ type: object
+ properties:
+ skip_incomplete:
+ type: boolean
+ default: true
+ description: Skip forms that fail validation
+ force_partial:
+ type: boolean
+ default: false
+ description: Generate forms even with missing fields (leaving blanks)
+
+GenerateSingleRequest:
+ type: object
+ required:
+ - extract_id
+ properties:
+ extract_id:
+ type: string
+ format: uuid
+ options:
+ type: object
+ properties:
+ output_format:
+ $ref: "../schemas/enums.yaml#/OutputFormat"
+ force_partial:
+ type: boolean
+ default: false
+ force:
+ type: boolean
+ default: false
+ description: Allow generation even if form_type is not in applicable_forms
+
+BatchGenerateResponse:
+ type: object
+ required:
+ - batch_id
+ - status
+ - extract_id
+ properties:
+ batch_id:
+ type: string
+ format: uuid
+ status:
+ type: string
+ enum: [processing]
+ extract_id:
+ type: string
+ format: uuid
+ forms_queued:
+ type: array
+ items:
+ $ref: "../schemas/enums.yaml#/FormType"
+ forms_skipped:
+ type: array
+ items:
+ type: object
+ properties:
+ form_type:
+ $ref: "../schemas/enums.yaml#/FormType"
+ reason:
+ type: string
+ estimated_seconds:
+ type: integer
+ poll_url:
+ type: string
+
+FormGenerateResponse:
+ type: object
+ required:
+ - form_id
+ - form_type
+ - status
+ properties:
+ form_id:
+ type: string
+ format: uuid
+ form_type:
+ $ref: "../schemas/enums.yaml#/FormType"
+ status:
+ type: string
+ enum: [processing, completed]
+ extract_id:
+ type: string
+ format: uuid
+ job_id:
+ type: string
+ format: uuid
+ estimated_seconds:
+ type: integer
+ poll_url:
+ type: string
+
+FormRecord:
+ type: object
+ required:
+ - form_id
+ - form_type
+ - status
+ properties:
+ form_id:
+ type: string
+ format: uuid
+ form_type:
+ $ref: "../schemas/enums.yaml#/FormType"
+ status:
+ $ref: "../schemas/enums.yaml#/FormStatus"
+ extract_id:
+ type: string
+ format: uuid
+ incident_id:
+ type: string
+ format: uuid
+ nullable: true
+ created_at:
+ type: string
+ format: date-time
+ completed_at:
+ type: string
+ format: date-time
+ nullable: true
+ pdf_ready:
+ type: boolean
+ json_ready:
+ type: boolean
+ field_mapping_summary:
+ type: object
+ properties:
+ total_form_fields:
+ type: integer
+ fields_filled:
+ type: integer
+ fields_blank:
+ type: integer
+ coverage_percent:
+ type: number
+
+FormMappedJson:
+ type: object
+ required:
+ - form_type
+ - form_id
+ properties:
+ form_type:
+ $ref: "../schemas/enums.yaml#/FormType"
+ form_version:
+ type: string
+ form_id:
+ type: string
+ format: uuid
+ extract_id:
+ type: string
+ format: uuid
+ agency_fields:
+ type: object
+ additionalProperties: true
+ description: Agency-specific field names and values as the target system expects
+
+BatchStatus:
+ type: object
+ required:
+ - batch_id
+ - status
+ properties:
+ batch_id:
+ type: string
+ format: uuid
+ status:
+ type: string
+ enum: [processing, completed, failed]
+ total:
+ type: integer
+ completed:
+ type: integer
+ failed:
+ type: integer
+ forms:
+ type: array
+ items:
+ type: object
+ properties:
+ form_id:
+ type: string
+ format: uuid
+ form_type:
+ $ref: "../schemas/enums.yaml#/FormType"
+ status:
+ $ref: "../schemas/enums.yaml#/FormStatus"
diff --git a/contracts/schemas/incident-contract.yaml b/contracts/schemas/incident-contract.yaml
new file mode 100644
index 0000000..1a129df
--- /dev/null
+++ b/contracts/schemas/incident-contract.yaml
@@ -0,0 +1,930 @@
+# Canonical FireForm Incident Schema
+# This is the master superset schema single source of truth for all downstream forms
+
+IncidentContract:
+ type: object
+ description: |
+ The canonical FireForm incident data model. 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.
+ properties:
+ schema_version:
+ type: string
+ description: Schema version identifier
+ example: "1.1.0"
+ schema_name:
+ type: string
+ description: Schema name identifier
+ enum:
+ - fireform_incident_contract
+
+ extraction_metadata:
+ $ref: "#/ExtractionMetadata"
+ report_metadata:
+ $ref: "#/ReportMetadata"
+ incident:
+ $ref: "#/Incident"
+ location:
+ $ref: "#/Location"
+ fire:
+ $ref: "#/Fire"
+ wildland:
+ $ref: "#/Wildland"
+ structure:
+ $ref: "#/Structure"
+ casualties:
+ $ref: "#/Casualties"
+ ems:
+ $ref: "#/EMS"
+ hazmat:
+ $ref: "#/Hazmat"
+ arson:
+ $ref: "#/Arson"
+ responding_agencies:
+ $ref: "#/RespondingAgencies"
+ resources_deployed:
+ $ref: "#/ResourcesDeployed"
+ weather:
+ $ref: "#/Weather"
+ environmental_impact:
+ $ref: "#/EnvironmentalImpact"
+ infrastructure_impact:
+ $ref: "#/InfrastructureImpact"
+ near_miss_and_safety:
+ $ref: "#/NearMissAndSafety"
+ lessons_learned:
+ $ref: "#/LessonsLearned"
+ follow_up:
+ $ref: "#/FollowUp"
+ periodic_reporting:
+ $ref: "#/PeriodicReporting"
+ attachments:
+ $ref: "#/Attachments"
+
+# --- Sub-schemas ---
+
+ExtractionMetadata:
+ type: object
+ properties:
+ extract_id:
+ type: string
+ format: uuid
+ input_id:
+ type: string
+ format: uuid
+ input_type:
+ type: string
+ enum: [voice, text]
+ extracted_at:
+ type: string
+ format: date-time
+ llm_model:
+ type: string
+ example: "llama3:8b"
+ confidence_score:
+ type: number
+ minimum: 0
+ maximum: 1
+ description: Overall confidence score from the LLM extraction (0.0–1.0)
+ completeness:
+ $ref: "#/Completeness"
+ applicable_forms:
+ type: array
+ items:
+ $ref: "../schemas/enums.yaml#/FormType"
+
+Completeness:
+ type: object
+ description: |
+ Tells the system which forms can be fully auto-generated vs which need
+ manual review. Recalculated server-side after every PATCH /extract.
+ properties:
+ overall_percent:
+ type: integer
+ minimum: 0
+ maximum: 100
+ missing_fields:
+ type: array
+ items:
+ type: string
+ description: JSON paths of fields that have no value
+ low_confidence_fields:
+ type: array
+ items:
+ type: string
+ description: JSON paths of fields where LLM confidence is low
+ inferred_fields:
+ type: array
+ items:
+ type: string
+ description: JSON paths of fields that were inferred (not explicitly stated)
+ forms_fully_generatable:
+ type: array
+ items:
+ $ref: "../schemas/enums.yaml#/FormType"
+ forms_needing_review:
+ type: array
+ items:
+ $ref: "../schemas/enums.yaml#/FormType"
+ forms_missing_data:
+ type: array
+ items:
+ $ref: "../schemas/enums.yaml#/FormType"
+
+ReportMetadata:
+ type: object
+ properties:
+ report_id:
+ type: string
+ example: "FF-2024-CA-0157"
+ incident_number:
+ type: string
+ example: "CA-SQF-2024-0421"
+ report_date:
+ type: string
+ format: date
+ report_time:
+ type: string
+ format: time
+ report_status:
+ $ref: "../schemas/enums.yaml#/ReportStatus"
+ reporting_unit:
+ $ref: "#/ReportingUnit"
+ prepared_by:
+ type: array
+ items:
+ $ref: "#/Personnel"
+ reviewed_by:
+ type: array
+ items:
+ $ref: "#/Reviewer"
+ submission_log:
+ type: array
+ items:
+ type: object
+ properties:
+ form_type:
+ $ref: "../schemas/enums.yaml#/FormType"
+ submitted_at:
+ type: string
+ format: date-time
+ submitted_to:
+ type: string
+
+ReportingUnit:
+ type: object
+ properties:
+ station_name:
+ type: string
+ station_id:
+ type: string
+ agency_name:
+ type: string
+ agency_id:
+ type: string
+ format: uuid
+ agency_type:
+ type: string
+
+Personnel:
+ type: object
+ properties:
+ name:
+ type: string
+ badge_number:
+ type: string
+ rank:
+ type: string
+ role:
+ type: string
+ contact_number:
+ type: string
+ signature_captured:
+ type: boolean
+
+Reviewer:
+ type: object
+ properties:
+ name:
+ type: string
+ badge_number:
+ type: string
+ rank:
+ type: string
+ role:
+ type: string
+ reviewed_at:
+ type: string
+ format: date-time
+ approved:
+ type: boolean
+
+Incident:
+ type: object
+ properties:
+ name:
+ type: string
+ description: Human-readable incident name
+ types:
+ type: array
+ items:
+ $ref: "#/IncidentType"
+ start_datetime:
+ type: string
+ format: date-time
+ alarm_datetime:
+ type: string
+ format: date-time
+ first_arrival_datetime:
+ type: string
+ format: date-time
+ containment_datetime:
+ type: string
+ format: date-time
+ nullable: true
+ controlled_datetime:
+ type: string
+ format: date-time
+ nullable: true
+ cleared_datetime:
+ type: string
+ format: date-time
+ nullable: true
+ total_duration_hours:
+ type: number
+ nullable: true
+ narrative:
+ type: string
+ description: Free text summary of the incident
+ raw_transcript:
+ type: string
+ description: Original voice or text input verbatim
+
+IncidentType:
+ type: object
+ properties:
+ primary:
+ type: boolean
+ category:
+ $ref: "../schemas/enums.yaml#/IncidentCategory"
+ subcategory:
+ type: string
+ neris_code:
+ type: string
+ description: NERIS incident type code (replaces NFIRS as of Feb 2026)
+ nfirs_code:
+ type: string
+ description: Legacy NFIRS incident type code
+
+Location:
+ type: object
+ properties:
+ address:
+ type: string
+ nullable: true
+ nearest_landmark:
+ type: string
+ nullable: true
+ nearest_town:
+ type: string
+ nullable: true
+ county:
+ type: string
+ nullable: true
+ state:
+ type: string
+ nullable: true
+ country:
+ type: string
+ nullable: true
+ postal_code:
+ type: string
+ nullable: true
+ coordinates:
+ $ref: "#/Coordinates"
+ ignition_point_coordinates:
+ $ref: "#/CoordinatesBasic"
+ elevation_range_ft:
+ type: string
+ nullable: true
+ legal_description:
+ type: string
+ nullable: true
+ jurisdiction:
+ type: object
+ properties:
+ federal:
+ type: boolean
+ state:
+ type: boolean
+ private:
+ type: boolean
+ tribal:
+ type: boolean
+ property_type:
+ type: string
+ nullable: true
+ property_use:
+ type: string
+ nullable: true
+ dispatch_center:
+ type: string
+ nullable: true
+
+Coordinates:
+ type: object
+ properties:
+ latitude:
+ type: number
+ longitude:
+ type: number
+ accuracy_meters:
+ type: number
+
+CoordinatesBasic:
+ type: object
+ properties:
+ latitude:
+ type: number
+ longitude:
+ type: number
+
+Fire:
+ type: object
+ properties:
+ cause_category:
+ type: string
+ nullable: true
+ cause_specific:
+ type: string
+ nullable: true
+ cause_certainty:
+ $ref: "../schemas/enums.yaml#/CauseCertainty"
+ arson_suspected:
+ type: boolean
+ material_first_ignited:
+ type: string
+ nullable: true
+ fuel_types:
+ type: array
+ items:
+ type: string
+ fire_spread_directions:
+ type: array
+ items:
+ type: string
+ rate_of_spread:
+ $ref: "../schemas/enums.yaml#/RateOfSpread"
+ flame_lengths_ft:
+ type: string
+ nullable: true
+ spotting_distance_miles:
+ type: number
+ nullable: true
+ unusual_behaviors:
+ type: array
+ items:
+ type: string
+ detector_present:
+ type: boolean
+ nullable: true
+ detector_operated:
+ type: boolean
+ nullable: true
+ suppression_system_present:
+ type: boolean
+ nullable: true
+ suppression_system_operated:
+ type: boolean
+ nullable: true
+ estimated_damage_usd:
+ type: number
+ nullable: true
+ contents_loss_usd:
+ type: number
+ nullable: true
+
+Wildland:
+ type: object
+ properties:
+ is_wildland_incident:
+ type: boolean
+ total_acres_burned:
+ type: number
+ nullable: true
+ land_ownership_breakdown:
+ type: object
+ properties:
+ federal_acres:
+ type: number
+ state_acres:
+ type: number
+ private_acres:
+ type: number
+ tribal_acres:
+ type: number
+ percent_contained:
+ type: integer
+ minimum: 0
+ maximum: 100
+ fire_lines:
+ type: object
+ properties:
+ primary_line_miles:
+ type: number
+ secondary_line_miles:
+ type: number
+ dozer_line_miles:
+ type: number
+ hand_line_miles:
+ type: number
+ aerial_operations:
+ type: object
+ properties:
+ water_drops_gallons:
+ type: integer
+ retardant_drops_gallons:
+ type: integer
+ total_flight_hours:
+ type: number
+ containment_strategies:
+ type: array
+ items:
+ type: string
+
+Structure:
+ type: object
+ properties:
+ is_structure_involved:
+ type: boolean
+ structures_threatened:
+ type: integer
+ nullable: true
+ structures_damaged:
+ type: integer
+ nullable: true
+ structures_destroyed:
+ type: integer
+ nullable: true
+ structures_protected:
+ type: integer
+ nullable: true
+ construction_type:
+ type: string
+ nullable: true
+ stories:
+ type: integer
+ nullable: true
+ area_sqft:
+ type: number
+ nullable: true
+ occupancy_at_time:
+ type: integer
+ nullable: true
+
+Casualties:
+ type: object
+ properties:
+ civilian:
+ type: array
+ items:
+ $ref: "#/CivilianCasualty"
+ responder:
+ type: array
+ items:
+ $ref: "#/ResponderCasualty"
+ total_civilian_injuries:
+ type: integer
+ total_civilian_fatalities:
+ type: integer
+ total_responder_injuries:
+ type: integer
+ total_responder_fatalities:
+ type: integer
+
+CivilianCasualty:
+ type: object
+ properties:
+ age:
+ type: integer
+ nullable: true
+ sex:
+ type: string
+ nullable: true
+ injury_type:
+ type: string
+ severity:
+ $ref: "../schemas/enums.yaml#/InjurySeverity"
+ cause:
+ type: string
+ nullable: true
+ location_at_time:
+ type: string
+ nullable: true
+ transported:
+ type: boolean
+ hospital:
+ type: string
+ nullable: true
+
+ResponderCasualty:
+ type: object
+ properties:
+ personnel_id:
+ type: string
+ agency:
+ type: string
+ role:
+ type: string
+ injury_type:
+ type: string
+ severity:
+ $ref: "../schemas/enums.yaml#/InjurySeverity"
+ treatment:
+ type: string
+ nullable: true
+ transported:
+ type: boolean
+ hospital:
+ type: string
+ nullable: true
+ return_to_duty_date:
+ type: string
+ format: date
+ nullable: true
+ osha_recordable:
+ type: boolean
+ nfirs_5_required:
+ type: boolean
+
+EMS:
+ type: object
+ properties:
+ ems_response_required:
+ type: boolean
+ patients:
+ type: array
+ items:
+ $ref: "#/EMSPatient"
+ total_patients:
+ type: integer
+ ems_agency_responded:
+ type: string
+ nullable: true
+ nemsis_report_required:
+ type: boolean
+ nemsis_report_ids:
+ type: array
+ items:
+ type: string
+
+EMSPatient:
+ type: object
+ properties:
+ patient_ref_id:
+ type: string
+ age_approx:
+ type: integer
+ nullable: true
+ sex:
+ type: string
+ nullable: true
+ chief_complaint:
+ type: string
+ nullable: true
+ disposition:
+ type: string
+ nullable: true
+ transported:
+ type: boolean
+ date_of_birth:
+ type: string
+ format: date
+ nullable: true
+ nemsis_data_captured:
+ type: boolean
+
+Hazmat:
+ type: object
+ properties:
+ involved:
+ type: boolean
+ materials:
+ type: array
+ items:
+ type: object
+ properties:
+ name:
+ type: string
+ un_number:
+ type: string
+ nullable: true
+ quantity:
+ type: string
+ nullable: true
+ epa_reportable_quantity_exceeded:
+ type: boolean
+ spill_size_gallons:
+ type: number
+ nullable: true
+
+Arson:
+ type: object
+ properties:
+ suspected:
+ type: boolean
+ confirmed:
+ type: boolean
+ law_enforcement_notified:
+ type: boolean
+ investigation_required:
+ type: boolean
+ investigation_agency:
+ type: string
+ nullable: true
+ evidence_collected:
+ type: boolean
+ nibrs_report_required:
+ type: boolean
+ notes:
+ type: string
+ nullable: true
+
+RespondingAgencies:
+ type: object
+ properties:
+ primary_agency:
+ type: string
+ all_agencies:
+ type: array
+ items:
+ $ref: "#/RespondingAgency"
+ mutual_aid_activated:
+ type: boolean
+ mutual_aid_agencies:
+ type: array
+ items:
+ type: string
+ unified_command:
+ type: boolean
+
+RespondingAgency:
+ type: object
+ properties:
+ agency_name:
+ type: string
+ agency_type:
+ type: string
+ role:
+ type: string
+ personnel_count:
+ type: integer
+
+ResourcesDeployed:
+ type: object
+ properties:
+ total_personnel:
+ type: integer
+ personnel_breakdown:
+ type: object
+ properties:
+ firefighters:
+ type: integer
+ crew_supervisors:
+ type: integer
+ engineers:
+ type: integer
+ incident_command:
+ type: integer
+ support_staff:
+ type: integer
+ apparatus:
+ type: array
+ items:
+ $ref: "#/Apparatus"
+ crew_types:
+ type: array
+ items:
+ type: string
+
+Apparatus:
+ type: object
+ properties:
+ type:
+ type: string
+ count:
+ type: integer
+
+Weather:
+ type: object
+ properties:
+ on_arrival:
+ $ref: "#/WeatherReading"
+ worst_conditions:
+ $ref: "#/WeatherReadingExtended"
+ factors_influencing_fire:
+ type: array
+ items:
+ type: string
+
+WeatherReading:
+ type: object
+ properties:
+ datetime:
+ type: string
+ format: date-time
+ temperature_f:
+ type: number
+ relative_humidity_percent:
+ type: number
+ wind_speed_mph:
+ type: number
+ wind_direction:
+ type: string
+ haines_index:
+ type: integer
+ nullable: true
+
+WeatherReadingExtended:
+ type: object
+ properties:
+ datetime:
+ type: string
+ format: date-time
+ temperature_f:
+ type: number
+ relative_humidity_percent:
+ type: number
+ wind_speed_mph:
+ type: number
+ wind_gusts_mph:
+ type: number
+ nullable: true
+
+EnvironmentalImpact:
+ type: object
+ properties:
+ wildlife_habitat_affected_acres:
+ type: number
+ nullable: true
+ watershed_impact:
+ type: string
+ nullable: true
+ soil_erosion_risk:
+ type: string
+ nullable: true
+ sensitive_species_affected:
+ type: array
+ items:
+ type: string
+ air_quality_impact:
+ type: string
+ nullable: true
+ water_body_affected:
+ type: boolean
+ nullable: true
+
+InfrastructureImpact:
+ type: object
+ properties:
+ items:
+ type: array
+ items:
+ $ref: "#/InfrastructureItem"
+
+InfrastructureItem:
+ type: object
+ properties:
+ type:
+ type: string
+ unit:
+ type: string
+ quantity:
+ type: number
+ severity:
+ type: string
+
+NearMissAndSafety:
+ type: object
+ properties:
+ near_miss_events:
+ type: array
+ items:
+ type: object
+ properties:
+ description:
+ type: string
+ date:
+ type: string
+ format: date
+ contributing_factors:
+ type: array
+ items:
+ type: string
+ lessons_learned:
+ type: string
+ nullable: true
+ corrective_action:
+ type: string
+ nullable: true
+ safety_breaches:
+ type: integer
+ weather_related_risks:
+ type: array
+ items:
+ type: string
+
+LessonsLearned:
+ type: object
+ properties:
+ successful_tactics:
+ type: array
+ items:
+ type: string
+ areas_for_improvement:
+ type: array
+ items:
+ type: string
+ recommendations:
+ type: array
+ items:
+ type: string
+
+FollowUp:
+ type: object
+ properties:
+ mop_up:
+ type: object
+ properties:
+ percent_complete:
+ type: integer
+ estimated_completion_date:
+ type: string
+ format: date
+ personnel_assigned:
+ type: integer
+ rehabilitation:
+ type: object
+ properties:
+ erosion_control_acres:
+ type: number
+ reseeding_acres:
+ type: number
+ hazard_tree_removal_required:
+ type: boolean
+ next_inspection_date:
+ type: string
+ format: date
+ nullable: true
+ investigation_ongoing:
+ type: boolean
+
+PeriodicReporting:
+ type: object
+ properties:
+ contributes_to_monthly_report:
+ type: boolean
+ contributes_to_quarterly_report:
+ type: boolean
+ contributes_to_annual_report:
+ type: boolean
+ neris_submitted:
+ type: boolean
+ neris_submitted_at:
+ type: string
+ format: date-time
+ nullable: true
+ state_submitted:
+ type: boolean
+ state_submitted_at:
+ type: string
+ format: date-time
+ nullable: true
+
+Attachments:
+ type: object
+ properties:
+ maps:
+ type: boolean
+ photos_count:
+ type: integer
+ weather_charts:
+ type: boolean
+ resource_tracking_logs:
+ type: boolean
+ incident_action_plans:
+ type: boolean
+ attachment_refs:
+ type: array
+ items:
+ type: object
+ properties:
+ ref_id:
+ type: string
+ format: uuid
+ filename:
+ type: string
+ content_type:
+ type: string
+ size_bytes:
+ type: integer
diff --git a/contracts/schemas/incident-record.yaml b/contracts/schemas/incident-record.yaml
new file mode 100644
index 0000000..686e7ce
--- /dev/null
+++ b/contracts/schemas/incident-record.yaml
@@ -0,0 +1,150 @@
+# Incident management schemas
+
+CreateIncidentRequest:
+ type: object
+ required:
+ - extract_id
+ properties:
+ extract_id:
+ type: string
+ format: uuid
+ incident_number:
+ type: string
+ description: Optional org-assigned incident number
+ tags:
+ type: array
+ items:
+ type: string
+
+UpdateIncidentRequest:
+ type: object
+ properties:
+ status:
+ $ref: "../schemas/enums.yaml#/ReportStatus"
+ tags:
+ type: array
+ items:
+ type: string
+ incident_number:
+ type: string
+ notes:
+ type: string
+
+IncidentRecord:
+ type: object
+ required:
+ - incident_id
+ - extract_id
+ - status
+ properties:
+ incident_id:
+ type: string
+ format: uuid
+ extract_id:
+ type: string
+ format: uuid
+ incident_number:
+ type: string
+ nullable: true
+ status:
+ $ref: "../schemas/enums.yaml#/ReportStatus"
+ incident_name:
+ type: string
+ nullable: true
+ incident_type:
+ type: string
+ nullable: true
+ incident_date:
+ type: string
+ format: date
+ nullable: true
+ forms_generated:
+ type: array
+ items:
+ type: object
+ properties:
+ form_id:
+ type: string
+ format: uuid
+ form_type:
+ $ref: "../schemas/enums.yaml#/FormType"
+ status:
+ $ref: "../schemas/enums.yaml#/FormStatus"
+ tags:
+ type: array
+ items:
+ type: string
+ notes:
+ type: string
+ nullable: true
+ created_at:
+ type: string
+ format: date-time
+ updated_at:
+ type: string
+ format: date-time
+ deleted_at:
+ type: string
+ format: date-time
+ nullable: true
+
+IncidentRecordFull:
+ description: Full incident record with linked extraction and forms
+ allOf:
+ - $ref: "#/IncidentRecord"
+ - type: object
+ properties:
+ incident_contract:
+ $ref: "incident-contract.yaml#/IncidentContract"
+ forms:
+ type: array
+ items:
+ $ref: "form-record.yaml#/FormRecord"
+ submission_log:
+ type: array
+ items:
+ type: object
+ properties:
+ form_type:
+ $ref: "../schemas/enums.yaml#/FormType"
+ submitted_at:
+ type: string
+ format: date-time
+ submitted_to:
+ type: string
+ status:
+ type: string
+
+IncidentListResponse:
+ type: object
+ properties:
+ data:
+ type: array
+ items:
+ type: object
+ properties:
+ incident_id:
+ type: string
+ format: uuid
+ incident_number:
+ type: string
+ nullable: true
+ status:
+ $ref: "../schemas/enums.yaml#/ReportStatus"
+ incident_name:
+ type: string
+ nullable: true
+ incident_type:
+ type: string
+ nullable: true
+ incident_date:
+ type: string
+ format: date
+ nullable: true
+ forms_count:
+ type: integer
+ created_at:
+ type: string
+ format: date-time
+ pagination:
+ $ref: "common.yaml#/Pagination"
diff --git a/contracts/schemas/input-record.yaml b/contracts/schemas/input-record.yaml
new file mode 100644
index 0000000..65dd205
--- /dev/null
+++ b/contracts/schemas/input-record.yaml
@@ -0,0 +1,138 @@
+# Input-related schemas
+
+TextInputRequest:
+ type: object
+ required:
+ - narrative
+ properties:
+ narrative:
+ type: string
+ description: Free-text incident narrative (10+ words, max 50,000 characters)
+ minLength: 20
+ maxLength: 50000
+ station_id:
+ type: string
+ description: Station identifier
+ responder_badge:
+ type: string
+ description: Badge number of the reporting responder
+ incident_date_hint:
+ type: string
+ format: date
+ description: Approximate date of the incident
+
+VoiceInputResponse:
+ type: object
+ required:
+ - input_id
+ - status
+ - input_type
+ properties:
+ input_id:
+ type: string
+ format: uuid
+ status:
+ type: string
+ enum:
+ - queued
+ input_type:
+ type: string
+ enum:
+ - voice
+ estimated_processing_seconds:
+ type: integer
+ created_at:
+ type: string
+ format: date-time
+ job_id:
+ type: string
+ format: uuid
+ description: Job ID for polling transcription status
+ poll_url:
+ type: string
+ description: URL to poll for input status
+
+TextInputResponse:
+ type: object
+ required:
+ - input_id
+ - status
+ - input_type
+ properties:
+ input_id:
+ type: string
+ format: uuid
+ status:
+ type: string
+ enum:
+ - ready
+ input_type:
+ type: string
+ enum:
+ - text
+ character_count:
+ type: integer
+ word_count:
+ type: integer
+ created_at:
+ type: string
+ format: date-time
+
+InputRecord:
+ type: object
+ required:
+ - input_id
+ - input_type
+ - status
+ properties:
+ input_id:
+ type: string
+ format: uuid
+ input_type:
+ type: string
+ enum:
+ - voice
+ - text
+ status:
+ $ref: "enums.yaml#/InputStatus"
+ transcript:
+ type: string
+ nullable: true
+ description: Transcribed text (for voice) or original narrative (for text). Null while transcribing.
+ original_filename:
+ type: string
+ nullable: true
+ description: Original audio filename (voice inputs only)
+ audio_duration_seconds:
+ type: number
+ nullable: true
+ description: Duration of audio in seconds (voice inputs only)
+ character_count:
+ type: integer
+ nullable: true
+ word_count:
+ type: integer
+ nullable: true
+ station_id:
+ type: string
+ nullable: true
+ responder_badge:
+ type: string
+ nullable: true
+ incident_date_hint:
+ type: string
+ format: date
+ nullable: true
+ error_detail:
+ type: string
+ nullable: true
+ description: Error message if transcription failed
+ retry_after_seconds:
+ type: integer
+ description: Polling hint present when status is queued or transcribing
+ created_at:
+ type: string
+ format: date-time
+ updated_at:
+ type: string
+ format: date-time
diff --git a/contracts/schemas/reporting.yaml b/contracts/schemas/reporting.yaml
new file mode 100644
index 0000000..f352e70
--- /dev/null
+++ b/contracts/schemas/reporting.yaml
@@ -0,0 +1,112 @@
+# Reporting & Analytics schemas
+
+ReportSummary:
+ type: object
+ properties:
+ date_from:
+ type: string
+ format: date
+ date_to:
+ type: string
+ format: date
+ total_incidents:
+ type: integer
+ by_type:
+ type: object
+ additionalProperties:
+ type: integer
+ description: Incident counts grouped by category
+ by_status:
+ type: object
+ additionalProperties:
+ type: integer
+ description: Incident counts grouped by report status
+ forms_generated:
+ type: integer
+ avg_completeness_score:
+ type: number
+ avg_processing_time_seconds:
+ type: number
+ groups:
+ type: array
+ items:
+ type: object
+ properties:
+ period:
+ type: string
+ description: Period label (e.g. "2024-07" for monthly, "fire" for by type)
+ incident_count:
+ type: integer
+ forms_generated:
+ type: integer
+
+GenerateReportRequest:
+ type: object
+ required:
+ - period_type
+ - year
+ properties:
+ period_type:
+ $ref: "../schemas/enums.yaml#/PeriodType"
+ year:
+ type: integer
+ minimum: 2000
+ maximum: 2100
+ month:
+ type: integer
+ minimum: 1
+ maximum: 12
+ description: Required when period_type is "monthly"
+ quarter:
+ type: integer
+ minimum: 1
+ maximum: 4
+ description: Required when period_type is "quarterly"
+ format:
+ $ref: "../schemas/enums.yaml#/OutputFormat"
+
+PeriodicReport:
+ type: object
+ properties:
+ report_id:
+ type: string
+ format: uuid
+ period_type:
+ $ref: "../schemas/enums.yaml#/PeriodType"
+ period_label:
+ type: string
+ description: Human-readable period (e.g. "July 2024", "Q3 2024", "2024")
+ generated_at:
+ type: string
+ format: date-time
+ summary:
+ $ref: "#/ReportSummary"
+ incidents:
+ type: array
+ items:
+ type: object
+ properties:
+ incident_id:
+ type: string
+ format: uuid
+ incident_number:
+ type: string
+ incident_date:
+ type: string
+ format: date
+ incident_type:
+ type: string
+ status:
+ type: string
+ forms_generated:
+ type: integer
+ compliance:
+ type: object
+ properties:
+ neris_submission_rate:
+ type: number
+ description: Percentage of incidents with NERIS submitted
+ average_submission_delay_days:
+ type: number
+ overdue_incidents:
+ type: integer
diff --git a/contracts/schemas/system.yaml b/contracts/schemas/system.yaml
new file mode 100644
index 0000000..8c25693
--- /dev/null
+++ b/contracts/schemas/system.yaml
@@ -0,0 +1,101 @@
+# System & health check schemas
+
+HealthStatus:
+ type: object
+ required:
+ - status
+ - version
+ properties:
+ status:
+ type: string
+ enum:
+ - healthy
+ - degraded
+ - unhealthy
+ version:
+ type: string
+ description: FireForm API version
+ uptime_seconds:
+ type: integer
+ components:
+ type: object
+ properties:
+ database:
+ $ref: "#/ComponentHealth"
+ ollama:
+ $ref: "#/ComponentHealth"
+ whisper:
+ $ref: "#/ComponentHealth"
+ storage:
+ $ref: "#/ComponentHealth"
+
+ComponentHealth:
+ type: object
+ required:
+ - status
+ properties:
+ status:
+ type: string
+ enum:
+ - healthy
+ - degraded
+ - unhealthy
+ response_time_ms:
+ type: integer
+ nullable: true
+ detail:
+ type: string
+ nullable: true
+ model_loaded:
+ type: string
+ nullable: true
+ description: Currently loaded model (ollama component)
+ disk_free_gb:
+ type: number
+ nullable: true
+ description: Free disk space (storage component)
+ ollama_version:
+ type: string
+ nullable: true
+ description: Ollama server version (ollama component)
+ models_available:
+ type: array
+ nullable: true
+ description: All models pulled and available on the Ollama server (ollama component)
+ items:
+ type: object
+ properties:
+ name:
+ type: string
+ size_gb:
+ type: number
+ quantization:
+ type: string
+ nullable: true
+ loaded:
+ type: boolean
+ current_load:
+ type: object
+ nullable: true
+ description: Current processing load (ollama component)
+ properties:
+ active_requests:
+ type: integer
+ queued_requests:
+ type: integer
+
+SchemaVersion:
+ type: object
+ required:
+ - version
+ - released_at
+ properties:
+ version:
+ type: string
+ released_at:
+ type: string
+ format: date-time
+ changelog:
+ type: string
+ breaking_changes:
+ type: boolean
diff --git a/contracts/schemas/template.yaml b/contracts/schemas/template.yaml
new file mode 100644
index 0000000..45c71f7
--- /dev/null
+++ b/contracts/schemas/template.yaml
@@ -0,0 +1,144 @@
+# Template & Configuration schemas
+
+TemplateSummary:
+ type: object
+ properties:
+ template_id:
+ type: string
+ format: uuid
+ form_type:
+ $ref: "../schemas/enums.yaml#/FormType"
+ display_name:
+ type: string
+ jurisdiction:
+ type: string
+ description: Jurisdiction code (e.g. "US-Federal", "US-CA", "US-GA")
+ agency_type:
+ type: string
+ version:
+ type: string
+ last_updated:
+ type: string
+ format: date
+ field_count:
+ type: integer
+ status:
+ type: string
+ enum:
+ - active
+ - legacy
+ - draft
+
+CreateTemplateRequest:
+ type: object
+ required:
+ - form_type
+ - display_name
+ - jurisdiction
+ - fields
+ - field_mappings_from_canonical
+ properties:
+ form_type:
+ type: string
+ description: Unique form type identifier
+ display_name:
+ type: string
+ jurisdiction:
+ type: string
+ agency_type:
+ type: string
+ fields:
+ type: array
+ items:
+ $ref: "#/TemplateField"
+ field_mappings_from_canonical:
+ type: object
+ additionalProperties:
+ type: string
+ description: |
+ Mapping from canonical FireForm JSON paths to this template's field names.
+ Key = canonical path (e.g. "fire.cause_category"), value = template field name.
+ source_standard:
+ type: string
+ nullable: true
+ description: Reference to the source standard (e.g. "NERIS v2.0", "NFIRS 5.0")
+ pdf_template_ref:
+ type: string
+ nullable: true
+ description: Reference to the PDF template file
+
+Template:
+ allOf:
+ - type: object
+ properties:
+ template_id:
+ type: string
+ format: uuid
+ version:
+ type: string
+ last_updated:
+ type: string
+ format: date
+ field_count:
+ type: integer
+ status:
+ type: string
+ enum: [active, legacy, draft]
+ created_at:
+ type: string
+ format: date-time
+ updated_at:
+ type: string
+ format: date-time
+ - $ref: "#/CreateTemplateRequest"
+
+TemplateField:
+ type: object
+ required:
+ - field_name
+ - field_type
+ - required
+ properties:
+ field_name:
+ type: string
+ description: Field identifier within this template
+ field_type:
+ type: string
+ enum:
+ - string
+ - integer
+ - number
+ - boolean
+ - date
+ - datetime
+ - time
+ - enum
+ - text
+ - array
+ description: Data type of the field
+ required:
+ type: boolean
+ description:
+ type: string
+ nullable: true
+ max_length:
+ type: integer
+ nullable: true
+ min_value:
+ type: number
+ nullable: true
+ max_value:
+ type: number
+ nullable: true
+ allowed_values:
+ type: array
+ items:
+ type: string
+ nullable: true
+ description: Valid values for enum fields
+ canonical_mapping:
+ type: string
+ description: JSON path in canonical FireForm schema this field maps from
+ default_value:
+ nullable: true
+ description: Default value if canonical field is null
diff --git a/docker-compose.yml b/docker-compose.yml
index a9e8e6e..45ad034 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -49,7 +49,7 @@ services:
condition: service_healthy
whisper:
condition: service_started
- command: /bin/sh -c "python3 -m api.db.init_db && python3 -m uvicorn api.main:app --host 0.0.0.0 --port 8000"
+ command: /bin/sh -c "python3 -m app.db.init_db && python3 -m uvicorn app.main:app --host 0.0.0.0 --port 8000"
volumes:
- .:/app
# Persist the SQLite DB (~/.fireform) across container rebuilds so created
diff --git a/docker/.env.example b/docker/.env.example
new file mode 100644
index 0000000..c4321ac
--- /dev/null
+++ b/docker/.env.example
@@ -0,0 +1,27 @@
+# Copy this file to .env.dev or .env.prod and fill in values.
+# Never commit .env.dev or .env.prod — they are gitignored.
+
+# --- App ------------------------------------------------------------------
+APP_PORT=8000
+
+# --- Ollama ---------------------------------------------------------------
+# Ollama runs in Docker with port mapped to host. App runs on host.
+# Override to point at an external Ollama instance (e.g. a GPU server).
+OLLAMA_HOST=http://localhost:11434
+OLLAMA_MODEL=qwen2.5:1.5b
+OLLAMA_TIMEOUT=300
+
+# --- Whisper --------------------------------------------------------------
+# Whisper runs in Docker with port mapped to host. App runs on host.
+# Override to point at an external Whisper endpoint.
+WHISPER_HOST=http://localhost:9000
+WHISPER_MODEL=small.en
+
+# --- Gunicorn (prod only) -------------------------------------------------
+GUNICORN_WORKERS=2
+
+# --- CORS -----------------------------------------------------------------
+# Comma-separated list of allowed frontend origins.
+# Dev: http://localhost:5173,http://127.0.0.1:5173
+# Prod: https://your-domain.com
+FRONTEND_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
diff --git a/docker/README.md b/docker/README.md
new file mode 100644
index 0000000..e238c64
--- /dev/null
+++ b/docker/README.md
@@ -0,0 +1,31 @@
+# Docker
+
+```
+docker/
+ dev/
+ Dockerfile # uvicorn --reload, source-mounted for hot reload
+ compose.yml
+ prod/
+ Dockerfile # multi-stage build, gunicorn, no source mount
+ compose.yml
+ .env.example # template — copy to .env.dev or .env.prod
+ .env.dev # gitignored, dev values
+ .env.prod # gitignored, prod values
+ entrypoint.sh # runs DB migrations then exec's the server command
+ README.md
+```
+
+## Env vars
+
+See `.env.example` for the full list with descriptions.
+
+## Volumes
+
+`docker compose down` never removes volumes. Use `docker compose down -v` only to intentionally wipe all data.
+
+| Volume | Path in container | Purpose |
+| ------------------ | ---------------------- | ----------------------------------- |
+| `fireform_db` | `/data/db/fireform.db` | SQLite database |
+| `fireform_uploads` | `/data/uploads` | Uploaded templates + generated PDFs |
+| `ollama_data` | `/root/.ollama` | Ollama model weights |
+| `whisper_models` | `/data/whisper` | Whisper model cache |
diff --git a/docker/dev/Dockerfile b/docker/dev/Dockerfile
new file mode 100644
index 0000000..ad4bfd4
--- /dev/null
+++ b/docker/dev/Dockerfile
@@ -0,0 +1,26 @@
+# syntax=docker/dockerfile:1
+FROM python:3.11-slim
+
+WORKDIR /app
+
+# Use apt cache mount to speed up system package installation across builds
+RUN rm -f /etc/apt/apt.conf.d/docker-clean; echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' > /etc/apt/apt.conf.d/keep-cache
+RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
+ --mount=type=cache,target=/var/lib/apt \
+ apt-get update && apt-get install -y \
+ curl \
+ libgl1 \
+ libglib2.0-0 \
+ libxcb1
+
+COPY requirements.txt .
+
+# Use pip cache mount so it remembers downloaded wheels
+RUN --mount=type=cache,target=/root/.cache/pip \
+ pip install -r requirements.txt
+
+ENV PYTHONPATH=/app
+
+EXPOSE 8000
+
+CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
diff --git a/docker/dev/compose.yml b/docker/dev/compose.yml
new file mode 100644
index 0000000..cdb94cc
--- /dev/null
+++ b/docker/dev/compose.yml
@@ -0,0 +1,84 @@
+services:
+ ollama:
+ image: ollama/ollama:latest
+ container_name: fireform-ollama
+ ports:
+ - "127.0.0.1:11434:11434"
+ volumes:
+ - ollama_data:/root/.ollama
+ networks:
+ - fireform-network
+ healthcheck:
+ test: ["CMD-SHELL", "ollama list || exit 1"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ start_period: 30s
+
+ whisper:
+ image: onerahmet/openai-whisper-asr-webservice:latest
+ container_name: fireform-whisper
+ environment:
+ - ASR_ENGINE=faster_whisper
+ - ASR_MODEL=${WHISPER_MODEL:-small.en}
+ - ASR_MODEL_PATH=/data/whisper
+ volumes:
+ - whisper_models:/data/whisper
+ ports:
+ - "127.0.0.1:9000:9000"
+ networks:
+ - fireform-network
+ healthcheck:
+ test: ["CMD-SHELL", "python3 -c \"import urllib.request; urllib.request.urlopen('http://localhost:9000/docs')\" || exit 1"]
+ interval: 15s
+ timeout: 5s
+ retries: 5
+ start_period: 60s
+
+ app:
+ build:
+ context: ../..
+ dockerfile: docker/dev/Dockerfile
+ container_name: fireform-app
+ depends_on:
+ ollama:
+ condition: service_healthy
+ whisper:
+ condition: service_started
+ entrypoint: ["sh", "docker/entrypoint.sh"]
+ command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
+ volumes:
+ - ../..:/app
+ - fireform_db:/data/db
+ - fireform_uploads:/data/uploads
+ ports:
+ - "${APP_PORT:-8000}:8000"
+ environment:
+ - PYTHONUNBUFFERED=1
+ - CUDA_VISIBLE_DEVICES=
+ - PYTHONPATH=/app
+ - OLLAMA_HOST=${OLLAMA_HOST:-http://ollama:11434}
+ - OLLAMA_TIMEOUT=${OLLAMA_TIMEOUT:-300}
+ - OLLAMA_MODEL=${OLLAMA_MODEL:-qwen2.5:1.5b}
+ - WHISPER_HOST=${WHISPER_HOST:-http://whisper:9000}
+ - FIREFORM_DB_PATH=/data/db/fireform.db
+ - FIREFORM_DATA_DIR=/data/uploads
+ - FIREFORM_TEMPLATE_DIR=/data/uploads
+ - FIREFORM_DB_ECHO=${FIREFORM_DB_ECHO:-true}
+ - FRONTEND_ORIGINS=${FRONTEND_ORIGINS:-http://localhost:5173,http://127.0.0.1:5173}
+ networks:
+ - fireform-network
+
+volumes:
+ ollama_data:
+ driver: local
+ whisper_models:
+ driver: local
+ fireform_db:
+ driver: local
+ fireform_uploads:
+ driver: local
+
+networks:
+ fireform-network:
+ driver: bridge
diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh
new file mode 100755
index 0000000..84a8142
--- /dev/null
+++ b/docker/entrypoint.sh
@@ -0,0 +1,10 @@
+#!/bin/sh
+set -e
+
+# Ensure data directories exist (volumes may be empty on first run)
+mkdir -p /data/db /data/uploads
+
+# Run DB migrations / init before starting the server
+python3 -m app.db.init_db
+
+exec "$@"
diff --git a/docker/prod/Dockerfile b/docker/prod/Dockerfile
new file mode 100644
index 0000000..f97d6dd
--- /dev/null
+++ b/docker/prod/Dockerfile
@@ -0,0 +1,39 @@
+FROM python:3.11-slim AS builder
+
+WORKDIR /build
+
+COPY requirements.txt .
+RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
+
+
+FROM python:3.11-slim
+
+WORKDIR /app
+
+RUN apt-get update && apt-get install -y \
+ curl \
+ libgl1 \
+ libglib2.0-0 \
+ libxcb1 \
+ && rm -rf /var/lib/apt/lists/*
+
+COPY --from=builder /install /usr/local
+
+# Copy only app code, not data/ temp/ tests/ docs/ etc.
+COPY app/ ./app/
+COPY requirements.txt .
+COPY docker/entrypoint.sh /entrypoint.sh
+
+ENV PYTHONPATH=/app
+
+# Data dirs created here; actual storage comes from mounted volumes at runtime.
+RUN mkdir -p /data/db /data/uploads && chmod +x /entrypoint.sh
+
+EXPOSE 8000
+
+ENTRYPOINT ["/entrypoint.sh"]
+CMD ["gunicorn", "app.main:app", \
+ "--worker-class", "uvicorn.workers.UvicornWorker", \
+ "--bind", "0.0.0.0:8000", \
+ "--access-logfile", "-", \
+ "--error-logfile", "-"]
diff --git a/docker/prod/compose.yml b/docker/prod/compose.yml
new file mode 100644
index 0000000..edd2fa4
--- /dev/null
+++ b/docker/prod/compose.yml
@@ -0,0 +1,90 @@
+services:
+ ollama:
+ image: ollama/ollama:latest
+ container_name: fireform-ollama
+ ports:
+ - "127.0.0.1:11434:11434"
+ volumes:
+ - ollama_data:/root/.ollama
+ networks:
+ - fireform-network
+ restart: unless-stopped
+ healthcheck:
+ test: ["CMD-SHELL", "ollama list || exit 1"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ start_period: 30s
+
+ whisper:
+ image: onerahmet/openai-whisper-asr-webservice:latest
+ container_name: fireform-whisper
+ environment:
+ - ASR_ENGINE=faster_whisper
+ - ASR_MODEL=${WHISPER_MODEL}
+ - ASR_MODEL_PATH=/data/whisper
+ volumes:
+ - whisper_models:/data/whisper
+ ports:
+ - "127.0.0.1:9000:9000"
+ networks:
+ - fireform-network
+ restart: unless-stopped
+ healthcheck:
+ test: ["CMD-SHELL", "python3 -c \"import urllib.request; urllib.request.urlopen('http://localhost:9000/docs')\" || exit 1"]
+ interval: 15s
+ timeout: 5s
+ retries: 5
+ start_period: 60s
+
+ app:
+ build:
+ context: ../..
+ dockerfile: docker/prod/Dockerfile
+ container_name: fireform-app
+ depends_on:
+ ollama:
+ condition: service_healthy
+ whisper:
+ condition: service_started
+ command: ["gunicorn", "app.main:app",
+ "--worker-class", "uvicorn.workers.UvicornWorker",
+ "--bind", "0.0.0.0:8000",
+ "--access-logfile", "-",
+ "--error-logfile", "-"]
+ volumes:
+ - fireform_db:/data/db
+ - fireform_uploads:/data/uploads
+ ports:
+ - "${APP_PORT}:8000"
+ environment:
+ - PYTHONUNBUFFERED=1
+ - CUDA_VISIBLE_DEVICES=
+ - PYTHONPATH=/app
+ - OLLAMA_HOST=${OLLAMA_HOST}
+ - OLLAMA_TIMEOUT=${OLLAMA_TIMEOUT}
+ - OLLAMA_MODEL=${OLLAMA_MODEL}
+ - WHISPER_HOST=${WHISPER_HOST}
+ - FIREFORM_DB_PATH=/data/db/fireform.db
+ - FIREFORM_DATA_DIR=/data/uploads
+ - FIREFORM_TEMPLATE_DIR=/data/uploads
+ - FIREFORM_DB_ECHO=false
+ - FRONTEND_ORIGINS=${FRONTEND_ORIGINS}
+ - WEB_CONCURRENCY=${GUNICORN_WORKERS}
+ networks:
+ - fireform-network
+ restart: unless-stopped
+
+volumes:
+ ollama_data:
+ driver: local
+ whisper_models:
+ driver: local
+ fireform_db:
+ driver: local
+ fireform_uploads:
+ driver: local
+
+networks:
+ fireform-network:
+ driver: bridge
diff --git a/docs/1. SETUP.md b/docs/1. SETUP.md
new file mode 100644
index 0000000..30d2aee
--- /dev/null
+++ b/docs/1. SETUP.md
@@ -0,0 +1,99 @@
+# Setup Guide
+
+This guide gets the FireForm backend running locally with Docker. It assumes you are comfortable with git and a terminal, but new to this project.
+
+## Prerequisites
+
+- [Docker](https://docs.docker.com/get-docker/) **24 or newer**, with the Docker daemon running
+- Docker Compose v2 (bundled with Docker Desktop; verify with `docker compose version`)
+- `make`
+- ~3 GB of free disk space (Docker images + LLM model weights)
+
+## Setup
+
+### 1. Clone the repository
+
+```bash
+git clone https://github.com/fireform-core/FireForm.git
+cd FireForm
+```
+
+### 2. Run first-time setup
+
+```bash
+make init
+```
+
+This will:
+
+1. Check that Docker meets the requirements above
+2. Create `docker/.env.dev` from `docker/.env.example` (gitignored; defaults work out of the box)
+3. Prompt you to pick an Ollama model (the default, `qwen2.5:1.5b`, is the smallest and fine for development)
+4. Offer to build and start everything answer `y`, or run `make fireform` later
+
+### 3. Build and start (if you skipped it in step 2)
+
+```bash
+make fireform
+```
+
+This builds the Docker images, starts the containers, waits for Ollama, and pulls the LLM model. The first run takes several minutes (image build + model download); later runs are fast.
+
+When it finishes you'll see:
+
+```
+FireForm is ready!
+ API: http://localhost:8000
+ API Docs: http://localhost:8000/docs
+```
+
+### 4. Verify it works
+
+Open **http://localhost:8000/docs** in your browser. This is the interactive Swagger UI you can explore and test every API endpoint directly from there (expand an endpoint, click _Try it out_, then _Execute_).
+
+## Day-to-day commands
+
+| Command | What it does |
+| ------------ | ------------------------------------------------------------------------ |
+| `make up` | Start containers |
+| `make down` | Stop containers (data is preserved) |
+| `make logs` | Stream all container logs (`make logs-app` / `make logs-ollama` for one) |
+| `make shell` | Open a shell inside the app container |
+| `make test` | Run the test suite |
+| `make help` | List all commands |
+
+The dev container mounts the source code, so code changes reload automatically — no rebuild needed. Rebuild (`make build`) only when dependencies in `requirements.txt` or the Dockerfile change.
+
+## Frontend (optional)
+
+The desktop/web frontend lives in a separate repository:
+
+```bash
+git clone https://github.com/fireform-core/fireform-frontend.git
+```
+
+Follow the README in that repository to run it. The backend from this guide must be running for the frontend to work.
+
+## Troubleshooting
+
+**`make init` fails dependency checks**
+Docker isn't running or is too old. Start Docker Desktop (or the Docker daemon) and confirm `docker version` reports 24+.
+
+**Port 8000 already in use**
+Another process is bound to the port. Either stop it, or change `APP_PORT` in `docker/.env.dev` and run `make down && make up`.
+
+**Model pull is slow or times out**
+The first `make fireform` downloads the LLM weights (~1 GB for the default model). On a slow connection just wait, or re-run `make pull-model` it resumes safely.
+
+**Containers start but the API doesn't respond**
+Check `make logs-app` for the actual error. The entrypoint runs database migrations on startup, so the API takes a few seconds after the container starts.
+
+**Want a clean slate**
+`make super-clean` stops everything and **deletes all volumes** database, uploads, and downloaded model weights. Only use it when you intend to wipe all local data.
+
+## Where to go next
+
+- **Join our [Discord](https://discord.gg/nBv5b6kF68)** — ask questions and coordinate with other contributors
+- [CONTRIBUTING.md](../CONTRIBUTING.md) - how to contribute
+- [PROJECT_STRUCTURE.md](2.%20PROJECT_STRUCTURE.md) - how the codebase is organized and where new code goes
+- [docker/README.md](../docker/README.md) - Docker layout, env vars, and volumes in detail
diff --git a/docs/2. PROJECT_STRUCTURE.md b/docs/2. PROJECT_STRUCTURE.md
new file mode 100644
index 0000000..cee9c40
--- /dev/null
+++ b/docs/2. PROJECT_STRUCTURE.md
@@ -0,0 +1,87 @@
+# Project Structure
+
+This document explains how the FireForm repository is organized and, when you add new code, where it should go.
+
+## Top-level layout
+
+```
+FireForm/
+├── app/ # Backend source code (FastAPI application)
+├── tests/ # Test suite (pytest)
+├── docker/ # Dockerfiles, compose files, env templates
+├── scripts/ # Shell scripts
+├── docs/ # Project documentation
+├── examples/ # Standalone demo scripts
+├── data/ # Local runtime data (gitignored contents)
+├── Makefile # Entry point for all dev commands (`make help`)
+└── requirements.txt # Python dependencies
+```
+
+| You want to… | Go to |
+| -------------------------------- | ------------------------------------------------------- |
+| Change backend behavior | `app/` |
+| Add or fix tests | `tests/` |
+| Change container setup, env vars | `docker/` (see [docker/README.md](../docker/README.md)) |
+| Change setup/bootstrap scripts | `scripts/` |
+| Add documentation | `docs/` |
+| Add dev commands | `Makefile` |
+
+## Inside `app/`
+
+The backend follows a layered structure: **routes → services → repositories → database**. Requests enter through the API layer, business logic lives in services, and all database access goes through repositories.
+
+```
+app/
+├── main.py # App factory: creates FastAPI app, wires middleware and routers
+├── api/ # HTTP layer - request/response handling only, no business logic
+│ ├── router.py # Aggregates all route modules into one router; main.py mounts this
+│ ├── deps.py # Shared FastAPI dependencies (e.g. DB session injection)
+│ ├── routes/ # One module per feature (forms.py, templates.py, …)
+│ └── schemas/ # Pydantic request/response models, one module per feature
+├── core/ # App-wide infrastructure
+│ ├── config.py # All settings and env var reading nothing else reads os.environ
+│ ├── lifespan.py # Startup/shutdown logic (DB init, etc.)
+│ ├── logging.py # Logging configuration
+│ └── errors/ # Custom exception classes (base.py) and handlers (handlers.py)
+├── services/ # Business logic — LLM extraction, PDF filling, orchestration
+├── db/ # Database engine/session (database.py) and repositories.py
+├── models/ # SQLAlchemy ORM models
+└── utils/ # Small generic helpers with no business logic
+```
+
+### Where does my new code go?
+
+**Adding a new API endpoint:**
+
+1. `app/api/schemas/<...>.py` - Pydantic models for the request and response bodies
+2. `app/api/routes/<...>.py` - the route handlers; keep them thin, delegate to a service
+3. `app/api/router.py` - register the new router (one `include_router` line)
+4. Business logic goes in `app/services/`, not in the route handler
+
+**Adding business logic:** `app/services/`. A service should not know about HTTP (no FastAPI imports) it takes plain data in and returns plain data, so it can be tested and reused independently.
+
+**Adding a database table:** define the ORM model in `app/models/models.py`, and add its query/persistence functions to `app/db/repositories.py`. Services call repositories; routes never touch the database directly.
+
+**Adding a setting or env var:** declare it in `app/core/config.py` and document it in `docker/.env.example`. Code elsewhere imports from `config`, never reads `os.environ` itself.
+
+**Adding a custom error:** subclass in `app/core/errors/base.py`; map it to an HTTP response in `app/core/errors/handlers.py`.
+
+## Tests
+
+Tests live in `tests/`, run with `make test` (pytest inside the app container). `conftest.py` holds shared fixtures. Name files `test_.py` and mirror what you're testing: API endpoint tests alongside `test_api.py`, model/DB tests alongside `test_model.py`.
+Note: Tests will be undergoing many changes hence the docs can be outdated, Please raise issue if tests or docs are outdated.
+
+## Docker & scripts
+
+- `docker/dev/` - development image (hot reload, source mounted) and its compose file. This is what `make up` runs.
+- `docker/prod/` - production image (multi-stage build, gunicorn, no source mount).
+- `docker/.env.example` - template for env vars; copied to `.env.dev` by `make init`.
+- `scripts/` - shell scripts invoked by `make init` (dependency checks, env file creation, model selection) and by container startup. Not meant to be run directly.
+
+Full details: [docker/README.md](../docker/README.md).
+
+## Everything else
+
+- `examples/` - runnable demo scripts showing the pipeline end to end; not imported by the app.
+- `data/` - runtime working data on your machine; contents are not part of the codebase.
+- `src/`, `temp/` - scratch/legacy directories slated for cleanup; don't add new code here.
diff --git a/docs/dpg.html b/docs/dpg.html
deleted file mode 100644
index 47371bc..0000000
--- a/docs/dpg.html
+++ /dev/null
@@ -1,203 +0,0 @@
-
-
-
-
-
- FireForm - Digital Public Good & SDG Relevance
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Digital Public Good
-
SDG Relevance
-
- FireForm is proud to be an open-source Digital Public Good, dedicated to advancing the United Nations' Sustainable Development Goals (SDGs). By drastically reducing the administrative burden on first responders, we enable emergency services to operate more efficiently, safely, and transparently.
-
-
-
-
-
-
-
-
Relevant Sustainable Development Goals
-
-
-
-
-
🏢
-
SDG 16: Peace, Justice and Strong Institutions
-
Target 16.6: Develop effective, accountable and transparent institutions at all levels.
-
How FireForm contributes: By unifying and digitizing the reporting structure for firefighters and other emergency responders, FireForm builds stronger, more accountable local institutions. Emergency services can seamlessly share critical incident data across county lines, sheriff departments, and EMS, ensuring transparency and highly effective public service administration without the overhead of repetitive paperwork.
-
-
-
-
🏙️
-
SDG 11: Sustainable Cities and Communities
-
Target 11.b: By 2020, substantially increase the number of cities and human settlements adopting and implementing integrated policies and plans towards inclusion, resource efficiency, mitigation and adaptation to climate change, resilience to disasters...
-
How FireForm contributes: FireForm directly enhances a city's resilience to disasters by giving first responders hours of their time back. Instead of doing administrative work, firefighters can focus on training, disaster mitigation, and responding to emergencies, ultimately creating safer and more resilient communities.
-
-
-
-
💡
-
SDG 9: Industry, Innovation and Infrastructure
-
Target 9.4: By 2030, upgrade infrastructure and retrofit industries to make them sustainable, with increased resource-use efficiency and greater adoption of clean and environmentally sound technologies and industrial processes...
-
How FireForm contributes: FireForm modernizes the outdated infrastructure of first responder reporting. By leveraging open-source AI locally, it serves as an innovative digital infrastructure that makes emergency management more resource-efficient and environmentally sound (eliminating physical paperwork and reducing digital redundancies).
-
-
-
-
-
-
Clear Ownership & Accountability
-
-
- In accordance with the Digital Public Goods Alliance requirements for Open Software, the ownership and accountability of the FireForm project and all its produced assets are clearly defined.
-
-
- Accountable Entity: The intellectual property and copyright of the software code and content are owned by the original core creators: Juan Álvarez Sánchez, Manuel Carriedo Garrido, Vincent Harkins, Marc Vergés, and Jan Sans.
-
-
- This ownership is publicly documented and verifiable across our project's assets:
-
-
-
Software License: Detailed in our public MIT License.
Public Website: Documented here on our official project website.
-
-
-
-
-
-
Platform Independence
-
-
- In accordance with the Digital Public Goods Alliance requirements for Open Software and Open AI Systems, FireForm guarantees platform independence. We do not rely on mandatory proprietary dependencies or closed components that would restrict our MIT License.
-
-
- Core Dependencies:
-
-
-
Frontend: React, Electron, Node.js. (Declared in frontend/package.json)
-
Backend: Python, FastAPI, SQLite. (Declared in requirements.txt)
-
AI System (Optional):Ollama running open-weight models like Mistral. All inference runs locally. Note: The AI features are optional and not core to the main functionality of FireForm (which operates as a digital form and template manager). The AI extraction can be disabled in the application settings. Furthermore, this local Ollama dependency can be swapped with any other LLM service.
-
-
- Dependency Graph (SBOM): All components and their versions in our software supply chain are automatically tracked by our source repository. You can view our full dependency graph and SBOM directly on our GitHub repository. If any proprietary components are ever integrated, they will be strictly optional and replaceable with open alternatives without modifying the core solution.
-
-
-
-
-
-
Mechanism for Extracting Data
-
-
- Digital public goods must ensure that data and content can be extracted or imported in a non-proprietary format. FireForm embraces this requirement at the core of its architecture:
-
-
-
Standard Format (JSON): All data extracted by the LLM—whether non-PII or PII—is formatted and exported natively as a non-proprietary JSON object. This makes it instantly compatible with any modern software or data pipeline without vendor lock-in.
-
Open Storage (SQLite): Metadata, templates, and local configuration are stored using SQLite, an open, serverless database engine. The data is saved locally in a fireform.db file, which can be easily extracted, backed up, and read by hundreds of open-source tools.
-
API Access: Our local Python FastAPI backend exposes these JSON objects and database entries, allowing automated systems to securely extract the data.
-
-
- By standardizing on JSON and SQLite, FireForm guarantees that emergency departments retain full ownership and accessibility of their data at all times.
-
-
-
-
-
-
Privacy & Applicable Laws
-
-
- FireForm is designed from the ground up with a privacy-first, local-only architecture. Because emergency incident reports frequently contain sensitive Personally Identifiable Information (PII), we guarantee that no data ever leaves the user's device.
-
-
- By eliminating cloud servers and third-party APIs, FireForm enables first responder organizations to easily comply with the world's most stringent data protection laws, including:
-
-
-
HIPAA (Health Insurance Portability and Accountability Act): Ensures EMS medical data remains entirely offline and secure.
-
CCPA (California Consumer Privacy Act): Ensures no consumer data is shared or sold.
-
GDPR (General Data Protection Regulation): Enforces strict data minimization and localizes data subjects' rights.
-
-
- For full details on our consent management procedures, data minimization practices, and how the solution was designed to comply with HIPAA, CCPA, and GDPR, please read our official Privacy Policy.
-
-
-
-
-
-
Do No Harm by Design
-
-
- FireForm is committed to anticipating and preventing harm by design, strictly adhering to the DPGA's framework:
-
-
-
9A. Data Privacy & Security: Because our solution handles sensitive incident data (PII), we mitigate risk by strictly operating offline. Data is never exposed to public networks, avoiding data breaches. (See our Privacy Policy).
-
9B. Inappropriate & Illegal Content: FireForm is an enterprise productivity tool for emergency responders, not a social platform. It does not host public user-generated content, completely mitigating the risk of distributing illegal or misleading public content.
-
9C. Protection from Harassment: There is no public user-to-user interaction within the app. For our open-source contributor community, we strictly enforce our Code of Conduct to protect against harassment and abuse.
-
-
-
-
-
-
Standards & Best Practices
-
-
- FireForm strictly aligns with globally recognized standards and best practices curated by the DPGA to ensure high-quality, interoperable, and sustainable software:
-
-
Adhered Standards:
-
-
JSON & UTF-8 (Data Interchange): All incident data extracted by our AI system is structured purely as JSON with UTF-8 encoding.
-
OpenAPI & REST (Data Exchange): Our Python backend is built with FastAPI, which automatically generates interactive OpenAPI specifications and follows RESTful architectural patterns.
Lifecycle Management: All codebase modifications use Git for Change Management. We track progress with Tagged Releases and strictly adhere to Semantic Versioning.
-
Interoperability & Architecture: We prioritize Open Standards and File Formats, modularized Programmatic APIs, and strict Dependency Management.
-
-
-
-
-
-
Public Communications & Mission
-
- Our mission is to build software that protects the people who protect us. FireForm was originally conceived and won 1st place at the Reboot the Earth hackathon, hosted by the UN and UC Santa Cruz, specifically targeting solutions for a better, more sustainable future.
-
-
- We believe that modern technology like Local LLMs should be leveraged as public goods, accessible to any department regardless of budget, to help them better serve their communities.
-
- An open-source, AI-powered system built to solve administrative overhead for first responders. Save hundreds of hours by eliminating redundant paperwork.
-
Automatically turn any non-fillable PDF into a fillable, standardized template.
-
-
-
💾
-
Local Template Database
-
Store your templates in a local DB. Recover past templates and quickly generate new forms.
-
-
-
🎙️
-
Voice or Text Input
-
Provide input via text or voice transcription. A single input drives everything.
-
-
-
🤖
-
Automated LLM Filling
-
Our local LLM extracts relevant information and automatically populates the documents.
-
-
-
-
-
-
Future Roadmap
-
-
-
-
-
🌍
-
External Data APIs
-
Connect to APIs to automatically fetch climate, location, and other external data.
-
-
-
-
-
-
📊
-
Insightful Dashboard
-
Visualize your reports with comprehensive graphics and statistics.
-
-
-
-
-
-
🧠
-
ML Field Detection
-
Machine learning models for advanced, automatic form field detection.
-
-
-
-
-
-
📑
-
Advanced PDF Features
-
More powerful PDF manipulation, merging, and management tools.
-
-
-
-
-
-
-
About the Project
-
-
-
FireForm was born at a Silicon Valley hackathon organized by the United Nations, University of California, and CalFire.
-
After winning first place, the project was awarded a 6-month mentorship by Salesforce. We are also proud to be part of Google Summer of Code, welcoming new collaborators to scale our impact.
- FireForm is built on the principle of privacy by design. Our architecture guarantees that your data, including Personally Identifiable Information (PII), never leaves your device.
-
-
-
-
-
-
-
-
-
-
1. Local-First Architecture
-
- FireForm is a completely offline, local-first application. All processing, including voice transcription and AI-based information extraction, is performed on your local hardware using local Large Language Models (LLMs) via Ollama.
-
-
- We do not use cloud APIs, we do not have central servers that collect your data, and we do not track telemetry. Therefore, we do not collect, process, or share any PII with third parties.
-
-
-
-
-
2. Data Collection and Usage
-
- The only data collected by FireForm is the information you explicitly input (voice memos, text, and PDF templates) to generate incident reports. This data is stored locally on your device in a local SQLite database and standard JSON files. You maintain full ownership and control over this data, and can delete it or export it at any time.
-
-
- Because all data remains on your device, consent management and data subject requests are fully within the control of the user or the deploying organization (e.g., the fire department).
-
-
-
-
-
3. Compliance with Applicable Laws
-
- By guaranteeing that data never leaves the host machine, FireForm enables deploying organizations to easily comply with the strictest data protection and privacy laws worldwide. We specifically support compliance with:
-
-
-
Health Insurance Portability and Accountability Act (HIPAA): By ensuring EMS and healthcare-related incident data remains entirely offline and secure on the local device.
-
California Consumer Privacy Act (CCPA) / CPRA: No consumer data is shared or sold. The local architecture defaults to maximum privacy.
-
General Data Protection Regulation (GDPR): Complete data minimization and localized processing ensures that no unauthorized cross-border data transfers occur, and data subjects' rights are easily managed locally.
-
-
-
-
-
4. Contact Us
-
- If you have any questions about this Privacy Policy or how FireForm handles data, please contact the core maintainers via our GitHub Repository.
-
- By downloading, installing, or using FireForm you agree to the following terms. Please read them carefully. If you do not agree, do not use the software.
-
-
- Last updated: May 2026
-
-
-
-
-
-
-
-
-
-
1. Acceptance of Terms
-
- These Terms of Service ("Terms") govern your access to and use of the FireForm software application, source code, documentation, and related materials (collectively, the "Software"). By using the Software you confirm that you are at least 18 years old (or the age of majority in your jurisdiction) and have the legal authority to accept these Terms on behalf of yourself or the organization you represent.
-
-
-
-
-
2. Open-Source License
-
- FireForm is released under the MIT License. You are free to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software subject to the conditions stated in the LICENSE file included with every distribution and available in our GitHub repository.
-
-
- Nothing in these Terms restricts rights granted to you by the MIT License; they are intended solely to clarify responsibilities and limitations not addressed by that license.
-
-
-
-
-
3. Permitted Use
-
You may use FireForm for any lawful purpose, including:
-
-
Internal use by emergency services, public safety agencies, and fire departments.
-
Research, academic, and non-commercial projects.
-
Commercial deployments, provided you comply with the MIT License attribution requirements.
-
Developing derivative works or integrations, subject to the MIT License terms.
-
-
-
-
-
4. Prohibited Use
-
You must not use FireForm to:
-
-
Violate any applicable law or regulation, including but not limited to laws governing data protection, privacy, and public safety reporting.
-
Intentionally generate false, fraudulent, or misleading incident reports.
-
Infringe the intellectual property rights of any third party.
-
Introduce malware, exploits, or other harmful code into the Software or its dependencies.
-
Use the Software in a manner that endangers the health or safety of any person.
-
-
-
-
-
5. No Warranty
-
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT. THE FIREFORM CONTRIBUTORS DO NOT WARRANT THAT THE SOFTWARE WILL BE ERROR-FREE, UNINTERRUPTED, SECURE, OR THAT ANY DEFECTS WILL BE CORRECTED. USE OF THE SOFTWARE FOR CRITICAL PUBLIC-SAFETY OPERATIONS IS ENTIRELY AT YOUR OWN RISK AND YOUR ORGANIZATION'S DISCRETION.
-
-
-
-
-
6. Limitation of Liability
-
- TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW, IN NO EVENT SHALL THE FIREFORM CONTRIBUTORS, MAINTAINERS, SPONSORS, OR AFFILIATED ORGANIZATIONS BE LIABLE FOR ANY INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL, OR PUNITIVE DAMAGES — INCLUDING LOSS OF DATA, REVENUE, GOODWILL, OR LIFE — ARISING OUT OF OR IN CONNECTION WITH THE USE OR INABILITY TO USE THE SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. IN JURISDICTIONS THAT DO NOT ALLOW THE EXCLUSION OF CERTAIN WARRANTIES OR LIMITATIONS OF LIABILITY, OUR LIABILITY IS LIMITED TO THE FULLEST EXTENT PERMITTED BY LAW.
-
-
-
-
-
7. User-Generated Content and Data
-
- FireForm processes data exclusively on your local device. You retain full ownership of all incident reports, templates, voice recordings, and any other data you create or import. We do not access, store, or transmit your data.
-
-
- You are solely responsible for the accuracy and legality of the data you enter into FireForm and for complying with any reporting obligations imposed by your jurisdiction or regulatory authority.
-
-
-
-
-
8. Third-Party Components
-
- FireForm integrates with third-party software, including Ollama for local LLM inference and Whisper for voice transcription. Your use of those components is governed by their respective licenses and terms. We make no representations or warranties regarding third-party software and are not responsible for any issues arising from their use.
-
-
-
-
-
9. Contributions
-
- Contributions to FireForm (pull requests, issues, documentation, etc.) are subject to our Contributing Guidelines and Code of Conduct. By submitting a contribution you confirm that you have the right to license it under the MIT License and that you grant the FireForm project a perpetual, worldwide, royalty-free license to use, reproduce, and distribute your contribution.
-
-
-
-
-
10. Changes to These Terms
-
- We may update these Terms from time to time. When we do, we will revise the "Last updated" date at the top of this page and, for material changes, post a notice in our GitHub repository. Continued use of FireForm after changes are posted constitutes your acceptance of the revised Terms.
-
-
-
-
-
11. Contact
-
- If you have questions about these Terms, please reach out to the core maintainers via our GitHub Repository. We welcome feedback from deploying organizations and community members.
-
-
-
-
-
-
-
-
-
diff --git a/examples/pipeline_demo.py b/examples/pipeline_demo.py
new file mode 100644
index 0000000..dbad305
--- /dev/null
+++ b/examples/pipeline_demo.py
@@ -0,0 +1,27 @@
+"""Manual, end-to-end demo of the fill pipeline (not part of the package).
+
+Run from the repo root with a real PDF path. This is a developer convenience
+for exercising the services layer without the API; it is not imported anywhere.
+"""
+
+from commonforms import prepare_form
+from pypdf import PdfReader
+
+from app.services.controller import Controller
+
+if __name__ == "__main__":
+ file = "./data/inputs/file.pdf" # update to a real input PDF
+ user_input = (
+ "Hi. The employee's name is John Doe. His job title is managing director. "
+ "His department supervisor is Jane Doe. His phone number is 123456. "
+ "His email is jdoe@ucsc.edu. The signature is , and the date is 01/02/2005"
+ )
+ prepared_pdf = "temp_outfile.pdf"
+ prepare_form(file, prepared_pdf)
+
+ reader = PdfReader(prepared_pdf)
+ fields = reader.get_fields()
+ num_fields = len(fields) if fields else 0
+
+ controller = Controller()
+ controller.fill_form(user_input, fields, file)
diff --git a/frontend/app.js b/frontend/app.js
deleted file mode 100644
index 4fd15ad..0000000
--- a/frontend/app.js
+++ /dev/null
@@ -1,1362 +0,0 @@
-const STORAGE_TEMPLATES_KEY = "fireform.templates.v1";
-const STORAGE_LAST_OUTPUT_KEY = "fireform.lastOutputPath.v1";
-// Where uploaded template PDFs are copied. Fixed for now; longer term this
-// should be user-configurable behind a Settings button (see note below).
-const DEFAULT_TEMPLATE_DIRECTORY = "src/inputs";
-const API_BASE_URL = "http://127.0.0.1:8000";
-
-// UI label <-> stored type-string mapping. The stored values stay backward
-// compatible with the existing default "string" type.
-const FIELD_TYPES = [
- { label: "Text", value: "string" },
- { label: "Long Text", value: "long_text" },
- { label: "Number", value: "number" },
- { label: "Date", value: "date" },
- { label: "Time", value: "time" },
- { label: "Email", value: "email" },
- { label: "Phone", value: "phone" },
- { label: "Signature", value: "signature" },
- { label: "Checkbox", value: "checkbox" },
- { label: "List", value: "list" },
-];
-const TYPE_VALUE_TO_LABEL = Object.fromEntries(FIELD_TYPES.map((t) => [t.value, t.label]));
-const DEFAULT_FIELD_ROWS = [{ name: "", type: "string" }];
-
-const elements = {
- tabs: Array.from(document.querySelectorAll(".tab")),
- panels: Array.from(document.querySelectorAll(".panel")),
- templateForm: document.getElementById("templateForm"),
- templateName: document.getElementById("templateName"),
- templatePdfFile: document.getElementById("templatePdfFile"),
- pdfDropZone: document.getElementById("pdfDropZone"),
- selectedFileMeta: document.getElementById("selectedFileMeta"),
- changePdfBtn: document.getElementById("changePdfBtn"),
- makeFillableBtn: document.getElementById("makeFillableBtn"),
- makeFillableHelpBtn: document.getElementById("makeFillableHelpBtn"),
- makeFillableHelp: document.getElementById("makeFillableHelp"),
- fieldsBuilder: document.getElementById("fieldsBuilder"),
- fieldCountBadge: document.getElementById("fieldCountBadge"),
- addFieldBtn: document.getElementById("addFieldBtn"),
- templateFormMessage: document.getElementById("templateFormMessage"),
- templateFormResponse: document.getElementById("templateFormResponse"),
- fillForm: document.getElementById("fillForm"),
- fillModel: document.getElementById("fillModel"),
- fillTemplateTiles: document.getElementById("fillTemplateTiles"),
- fillSelectionHint: document.getElementById("fillSelectionHint"),
- fillSubmitBtn: document.getElementById("fillSubmitBtn"),
- inputText: document.getElementById("inputText"),
- sttControls: document.getElementById("sttControls"),
- sttRecordBtn: document.getElementById("sttRecordBtn"),
- sttPauseBtn: document.getElementById("sttPauseBtn"),
- sttStopBtn: document.getElementById("sttStopBtn"),
- sttStatus: document.getElementById("sttStatus"),
- fillFormMessage: document.getElementById("fillFormMessage"),
- fillFormResponse: document.getElementById("fillFormResponse"),
- templatesEmpty: document.getElementById("templatesEmpty"),
- templatesList: document.getElementById("templatesList"),
- localPdfFile: document.getElementById("localPdfFile"),
- serverPdfPath: document.getElementById("serverPdfPath"),
- previewPathBtn: document.getElementById("previewPathBtn"),
- previewStatus: document.getElementById("previewStatus"),
- pdfFrame: document.getElementById("pdfFrame"),
-};
-
-let templates = loadTemplates();
-let activeObjectUrl = null;
-let selectedTemplateFile = null;
-// Field rows are scratch state for building one template — they start empty
-// each session and are not persisted.
-let fieldRows = DEFAULT_FIELD_ROWS.map((row) => ({ ...row }));
-let dragSourceIndex = null;
-let uploadedPath = null;
-let uploadedFieldCount = null;
-// Template ids currently selected in the Fill Form tab (multi-select).
-let selectedFillIds = new Set();
-
-// Speech-to-text recording state. The MediaRecorder captures compressed audio
-// in the renderer; on stop we POST it straight to /forms/transcribe (the local
-// Whisper service handles decoding).
-let mediaRecorder = null;
-let recordedChunks = [];
-let recordingStream = null;
-
-waitForBackend().then(initialize);
-
-async function waitForBackend() {
- const loadingScreen = document.getElementById("loadingScreen");
- let isReady = false;
-
- while (!isReady) {
- try {
- const response = await fetch(`${API_BASE_URL}/templates`);
- if (response.ok) {
- isReady = true;
- }
- } catch (e) {
- // Ignore error and try again
- }
-
- if (!isReady) {
- await new Promise(r => setTimeout(r, 500));
- }
- }
-
- if (loadingScreen) {
- loadingScreen.classList.add("hidden");
- }
-}
-
-async function initialize() {
- bindEvents();
- renderFieldRows();
- renderTemplates();
- renderFillTemplates();
- restorePreviewState();
- updateSelectedFileMeta();
- loadModels();
- await refreshTemplatesFromApi();
-}
-
-function bindEvents() {
- elements.tabs.forEach((tab) => {
- tab.addEventListener("click", () => activateSection(tab.dataset.target));
- });
-
- elements.templateForm.addEventListener("submit", handleTemplateSubmit);
- elements.templatePdfFile.addEventListener("change", handleTemplateFileInput);
- elements.pdfDropZone.addEventListener("click", () => elements.templatePdfFile.click());
- elements.pdfDropZone.addEventListener("keydown", handleDropZoneKeyDown);
- elements.changePdfBtn.addEventListener("click", () => elements.templatePdfFile.click());
- elements.addFieldBtn.addEventListener("click", handleAddFieldClick);
- elements.makeFillableBtn.addEventListener("click", handleMakeFillableClick);
- elements.makeFillableHelpBtn.addEventListener("click", toggleMakeFillableHelp);
- bindDropZoneDragEvents();
- elements.fillForm.addEventListener("submit", handleFillSubmit);
- elements.fillTemplateTiles.addEventListener("click", handleTileClick);
- elements.fillTemplateTiles.addEventListener("keydown", handleTileKeydown);
- elements.sttRecordBtn.addEventListener("click", startRecording);
- elements.sttPauseBtn.addEventListener("click", togglePauseRecording);
- elements.sttStopBtn.addEventListener("click", stopRecording);
- elements.templatesList.addEventListener("click", handleTemplateActionClick);
- elements.localPdfFile.addEventListener("change", handleLocalFilePreview);
- elements.previewPathBtn.addEventListener("click", () =>
- previewFromPath(elements.serverPdfPath.value, { switchToPreview: true })
- );
-}
-
-function activateSection(targetId) {
- switchSection(targetId);
-}
-
-async function refreshTemplatesFromApi() {
- try {
- const response = await fetch(`${API_BASE_URL}/templates`);
- const body = await parseJsonResponse(response);
- if (!response.ok) {
- throw new Error(extractErrorMessage(body, response.status));
- }
-
- if (Array.isArray(body)) {
- templates = body.map((template) => ({
- id: template.id,
- name: template.name || "",
- pdf_path: template.pdf_path || "",
- fields: template.fields || {},
- }));
- saveTemplates();
- // Drop selections for templates that no longer exist.
- const liveIds = new Set(templates.map((t) => Number(t.id)));
- selectedFillIds.forEach((id) => { if (!liveIds.has(id)) selectedFillIds.delete(id); });
- renderTemplates();
- renderFillTemplates();
- }
- } catch (error) {
- setStatus(
- elements.templateFormMessage,
- `Could not refresh templates from API: ${error.message}`,
- "error"
- );
- }
-}
-
-function bindDropZoneDragEvents() {
- ["dragenter", "dragover"].forEach((eventName) => {
- elements.pdfDropZone.addEventListener(eventName, (event) => {
- event.preventDefault();
- event.stopPropagation();
- elements.pdfDropZone.classList.add("active");
- });
- });
-
- ["dragleave", "dragend", "drop"].forEach((eventName) => {
- elements.pdfDropZone.addEventListener(eventName, (event) => {
- event.preventDefault();
- event.stopPropagation();
- elements.pdfDropZone.classList.remove("active");
- });
- });
-
- elements.pdfDropZone.addEventListener("drop", (event) => {
- const file = event.dataTransfer?.files?.[0];
- setSelectedTemplateFile(file);
- });
-}
-
-function handleDropZoneKeyDown(event) {
- if (event.key === "Enter" || event.key === " ") {
- event.preventDefault();
- elements.templatePdfFile.click();
- }
-}
-
-function handleTemplateFileInput(event) {
- const file = event.target.files && event.target.files[0];
- setSelectedTemplateFile(file);
-}
-
-function setSelectedTemplateFile(file) {
- if (!file) {
- return;
- }
-
- if (!isPdfFile(file)) {
- selectedTemplateFile = null;
- uploadedPath = null;
- uploadedFieldCount = null;
- setMakeFillableButtonState();
- renderFieldCountBadge();
- setStatus(elements.templateFormMessage, "Please select a PDF file.", "error");
- updateSelectedFileMeta();
- return;
- }
-
- selectedTemplateFile = file;
- uploadedPath = null;
- uploadedFieldCount = null;
- setMakeFillableButtonState();
- renderFieldCountBadge();
- clearJson(elements.templateFormResponse);
- setStatus(elements.templateFormMessage, "");
- updateSelectedFileMeta();
- // Eager upload so the user gets a live field-count comparison while building rows.
- uploadSelectedFileSilently();
-}
-
-async function uploadSelectedFileSilently() {
- if (!selectedTemplateFile) return;
- const directory = DEFAULT_TEMPLATE_DIRECTORY;
-
- const fileAtUploadStart = selectedTemplateFile;
- try {
- const upload = await uploadTemplatePdf(fileAtUploadStart, directory);
- // Guard against the user picking a different file mid-upload.
- if (fileAtUploadStart !== selectedTemplateFile) return;
- uploadedPath = upload.pdf_path;
- uploadedFieldCount =
- typeof upload.field_count === "number" ? upload.field_count : null;
- maybeSeedFieldRows(upload.fields);
- renderFieldCountBadge();
- } catch (_error) {
- // Silent failure — the explicit Create / Make Fillable paths surface errors.
- }
-}
-
-// Auto-add a row per field the PDF already defines — same as clicking "+ Add
-// Field" for each — filling in its description and type so the user can edit.
-// If the list already has rows the user typed, warn before replacing them.
-function maybeSeedFieldRows(fields) {
- if (!Array.isArray(fields) || !fields.length) return;
- syncFieldRowsFromDom();
-
- if (fieldRows.some((row) => row.name.trim())) {
- const replace = window.confirm(
- `This PDF has ${fields.length} fillable field${fields.length === 1 ? "" : "s"}.\n\n` +
- "Replace your current form fields with them? Your existing entries will be lost."
- );
- if (!replace) {
- setStatus(elements.templateFormMessage, "Kept your existing form fields.", "info");
- return;
- }
- }
-
- fieldRows = fields.map((f) => ({
- name: f.description || f.name || "",
- type: normalizeFieldType(f.type),
- }));
- renderFieldRows();
- setStatus(
- elements.templateFormMessage,
- `Loaded ${fieldRows.length} field${fieldRows.length === 1 ? "" : "s"} from the PDF — edit the descriptions as needed.`,
- "info"
- );
-}
-
-function setMakeFillableButtonState() {
- if (!elements.makeFillableBtn) return;
- elements.makeFillableBtn.disabled = !selectedTemplateFile;
- elements.makeFillableBtn.textContent = "Make this PDF fillable";
-}
-
-function renderFieldCountBadge() {
- const badge = elements.fieldCountBadge;
- if (!badge) return;
-
- if (!selectedTemplateFile || uploadedFieldCount === null) {
- badge.classList.add("hidden");
- badge.classList.remove("match", "mismatch");
- badge.textContent = "";
- return;
- }
-
- const expected = uploadedFieldCount;
- const actual = fieldRows.length;
- const noun = (n) => `${n} fillable field${n === 1 ? "" : "s"}`;
- const rowNoun = (n) => `${n} row${n === 1 ? "" : "s"}`;
-
- badge.classList.remove("hidden", "match", "mismatch");
- if (expected === actual) {
- badge.classList.add("match");
- badge.textContent = `PDF has ${noun(expected)} — your ${rowNoun(actual)} match.`;
- } else {
- badge.classList.add("mismatch");
- badge.textContent = `PDF has ${noun(expected)} — you have ${rowNoun(actual)}.`;
- }
-}
-
-function isPdfFile(file) {
- const name = String(file?.name || "").toLowerCase();
- return name.endsWith(".pdf");
-}
-
-function updateSelectedFileMeta() {
- // Once a file is chosen, swap the drop zone for a compact "change" control.
- const hasFile = !!selectedTemplateFile;
- elements.pdfDropZone.classList.toggle("hidden", hasFile);
- elements.changePdfBtn.classList.toggle("hidden", !hasFile);
-
- if (!hasFile) {
- elements.selectedFileMeta.textContent = "No PDF selected.";
- return;
- }
-
- const destinationPath = `${DEFAULT_TEMPLATE_DIRECTORY}/${selectedTemplateFile.name}`;
-
- elements.selectedFileMeta.textContent = `Selected: ${selectedTemplateFile.name} (${formatBytes(
- selectedTemplateFile.size
- )}) - destination: ${destinationPath}`;
-}
-
-function formatBytes(bytes) {
- if (!Number.isFinite(bytes) || bytes <= 0) {
- return "0 B";
- }
-
- const units = ["B", "KB", "MB", "GB"];
- let value = bytes;
- let unitIndex = 0;
-
- while (value >= 1024 && unitIndex < units.length - 1) {
- value /= 1024;
- unitIndex += 1;
- }
-
- return `${value.toFixed(value >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`;
-}
-
-function switchSection(targetId) {
- elements.panels.forEach((panel) => {
- panel.classList.toggle("hidden", panel.id !== targetId);
- });
- elements.tabs.forEach((tab) => {
- tab.classList.toggle("active", tab.dataset.target === targetId);
- });
-}
-
-function setStatus(target, message, type = "info") {
- target.textContent = message || "";
- target.className = "status";
- if (type) {
- target.classList.add(type);
- }
-}
-
-function showJson(preElement, payload) {
- preElement.textContent = JSON.stringify(payload, null, 2);
- preElement.classList.remove("hidden");
-}
-
-function clearJson(preElement) {
- preElement.textContent = "";
- preElement.classList.add("hidden");
-}
-
-function collectFieldRows() {
- syncFieldRowsFromDom();
-
- if (fieldRows.length === 0) {
- return { error: "Add at least one field before creating the template." };
- }
-
- const dict = {};
- const seen = new Set();
- for (const row of fieldRows) {
- const name = row.name.trim();
- if (!name) {
- return { error: "Every field needs a name." };
- }
- const key = name.toLowerCase();
- if (seen.has(key)) {
- return { error: `Field names must be unique ("${name}" appears more than once).` };
- }
- seen.add(key);
- dict[name] = row.type || "string";
- }
- return { value: dict };
-}
-
-async function handleTemplateSubmit(event) {
- event.preventDefault();
- clearJson(elements.templateFormResponse);
- setStatus(elements.templateFormMessage, "");
-
- const name = elements.templateName.value.trim();
- const templateDirectory = DEFAULT_TEMPLATE_DIRECTORY;
- const collected = collectFieldRows();
-
- if (!name || !selectedTemplateFile) {
- setStatus(
- elements.templateFormMessage,
- "Name and PDF file are required.",
- "error"
- );
- return;
- }
-
- if (collected.error) {
- setStatus(elements.templateFormMessage, collected.error, "error");
- return;
- }
-
- try {
- let activePdfPath = uploadedPath;
- if (!activePdfPath) {
- setStatus(elements.templateFormMessage, "Copying PDF into project directory...", "info");
- const upload = await uploadTemplatePdf(selectedTemplateFile, templateDirectory);
- activePdfPath = upload.pdf_path;
- uploadedPath = upload.pdf_path;
- }
-
- const payload = {
- name,
- pdf_path: activePdfPath,
- fields: collected.value,
- };
-
- setStatus(elements.templateFormMessage, "Creating template...", "info");
- const response = await fetch(`${API_BASE_URL}/templates/create`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(payload),
- });
-
- const body = await parseJsonResponse(response);
- if (!response.ok) {
- throw new Error(extractErrorMessage(body, response.status));
- }
-
- upsertTemplate(body);
- if (body.id != null) {
- selectedFillIds.add(Number(body.id));
- }
- await refreshTemplatesFromApi();
- elements.serverPdfPath.value = body.pdf_path || "";
-
- const expected = body.field_count;
- const actual = Object.keys(collected.value).length;
- let mismatchNote = "";
- let statusLevel = "success";
- if (typeof expected === "number" && expected !== actual) {
- mismatchNote = ` Heads up — the PDF has ${expected} fillable field${expected === 1 ? "" : "s"}, but you added ${actual} row${actual === 1 ? "" : "s"}. Fills may be incomplete or misaligned.`;
- statusLevel = "error";
- }
-
- setStatus(
- elements.templateFormMessage,
- `Template created (id: ${body.id}). PDF saved at ${activePdfPath}.${mismatchNote}`,
- statusLevel
- );
- showJson(elements.templateFormResponse, body);
- uploadedPath = null;
- uploadedFieldCount = null;
- setMakeFillableButtonState();
- renderFieldCountBadge();
- } catch (error) {
- setStatus(elements.templateFormMessage, error.message, "error");
- }
-}
-
-async function uploadTemplatePdf(file, directory) {
- const formData = new FormData();
- formData.append("file", file, file.name);
- formData.append("directory", directory);
-
- const response = await fetch(`${API_BASE_URL}/templates/upload`, {
- method: "POST",
- body: formData,
- });
-
- const body = await parseJsonResponse(response);
- if (!response.ok) {
- throw new Error(extractErrorMessage(body, response.status));
- }
-
- return body;
-}
-
-// ───────────────────────── Fill Form: model + template tiles ──────────────
-
-// "1 field" / "3 forms" — keeps the count-and-label logic in one place.
-function pluralize(count, noun) {
- return `${count} ${noun}${count === 1 ? "" : "s"}`;
-}
-
-// Look up a template by id (ids may arrive as strings from dataset attributes).
-function findTemplate(id) {
- return templates.find((template) => Number(template.id) === Number(id));
-}
-
-// Populate the model picker from the local Ollama models the API reports.
-async function loadModels() {
- const select = elements.fillModel;
- try {
- const response = await fetch(`${API_BASE_URL}/forms/models`);
- const body = await parseJsonResponse(response);
- if (!response.ok) {
- throw new Error(extractErrorMessage(body, response.status));
- }
-
- select.innerHTML = "";
- const models = body.models || [];
- models.forEach((name) => {
- const isDefault = name === body.default;
- const option = document.createElement("option");
- option.value = name;
- option.textContent = isDefault ? `${name} (default)` : name;
- option.selected = isDefault;
- select.append(option);
- });
- } catch (_error) {
- // Ollama unreachable — leave one placeholder so the picker isn't empty.
- if (!select.options.length) {
- const option = document.createElement("option");
- option.value = "";
- option.textContent = "(default model)";
- select.append(option);
- }
- }
-}
-
-// Build one selectable tile. Whether it's selected is shown purely through the
-// tile's highlighted styling (.selected) — there's no separate checkbox.
-function createTemplateTile(template) {
- const id = Number(template.id);
- const selected = selectedFillIds.has(id);
-
- const tile = document.createElement("div");
- tile.className = selected ? "template-tile selected" : "template-tile";
- tile.dataset.templateId = String(id);
- // Behaves like a toggle button for keyboard and screen-reader users.
- tile.setAttribute("role", "button");
- tile.setAttribute("tabindex", "0");
- tile.setAttribute("aria-pressed", String(selected));
-
- const title = document.createElement("span");
- title.className = "tile-title";
- title.textContent = template.name || "Untitled";
-
- const fieldCount = template.fields ? Object.keys(template.fields).length : 0;
- const meta = document.createElement("span");
- meta.className = "tile-meta";
- meta.textContent = pluralize(fieldCount, "field");
-
- const body = document.createElement("div");
- body.className = "tile-body";
- body.append(title, meta);
-
- // Preview must not toggle selection, so it carries its own id and the click
- // handler stops the event from bubbling up to the tile.
- const previewButton = document.createElement("button");
- previewButton.type = "button";
- previewButton.className = "tile-preview-btn";
- previewButton.dataset.previewId = String(id);
- previewButton.textContent = "Preview";
-
- tile.append(body, previewButton);
- return tile;
-}
-
-function renderFillTemplates() {
- const container = elements.fillTemplateTiles;
- container.innerHTML = "";
-
- if (!templates.length) {
- const empty = document.createElement("p");
- empty.className = "empty-state";
- empty.textContent = "No templates yet — create one in the Create Template tab.";
- container.append(empty);
- updateFillButtonState();
- return;
- }
-
- templates.forEach((template) => container.append(createTemplateTile(template)));
- updateFillButtonState();
-}
-
-function handleTileClick(event) {
- // A click on the Preview button previews the PDF without toggling selection.
- const previewButton = event.target.closest(".tile-preview-btn");
- if (previewButton) {
- event.stopPropagation();
- const template = findTemplate(previewButton.dataset.previewId);
- if (template) {
- elements.serverPdfPath.value = template.pdf_path || "";
- previewFromPath(template.pdf_path || "", { switchToPreview: true });
- }
- return;
- }
-
- // A click anywhere else on the tile toggles it on/off for filling.
- const tile = event.target.closest(".template-tile");
- if (tile) {
- toggleFillSelection(Number(tile.dataset.templateId));
- }
-}
-
-function handleTileKeydown(event) {
- // Enter/Space activate the focused tile, matching its role="button".
- if (event.key !== "Enter" && event.key !== " ") {
- return;
- }
- const tile = event.target.closest(".template-tile");
- if (tile) {
- event.preventDefault();
- toggleFillSelection(Number(tile.dataset.templateId));
- }
-}
-
-function toggleFillSelection(id) {
- if (selectedFillIds.has(id)) {
- selectedFillIds.delete(id);
- } else {
- selectedFillIds.add(id);
- }
- renderFillTemplates();
-}
-
-function updateFillButtonState() {
- const count = selectedFillIds.size;
- const nothingSelected = count === 0;
-
- // Greyed out (but still clickable) until at least one form is chosen.
- elements.fillSubmitBtn.classList.toggle("is-disabled", nothingSelected);
- elements.fillSubmitBtn.textContent = count > 1 ? `Fill ${count} Forms` : "Fill Form";
-
- elements.fillSelectionHint.classList.remove("error");
- elements.fillSelectionHint.textContent = nothingSelected
- ? "Select one or more forms to fill."
- : `${pluralize(count, "form")} selected.`;
-}
-
-// A human-readable label for a template, used in the success/error summary.
-function templateLabel(id) {
- const template = findTemplate(id);
- return template && template.name ? template.name : `id ${id}`;
-}
-
-// Fill a single template and return its submission. Throws on failure so the
-// caller can note which form failed and still continue with the others.
-async function fillOneTemplate(id, inputText, model) {
- const response = await fetch(`${API_BASE_URL}/forms/fill`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ template_id: id, input_text: inputText, model }),
- });
- const body = await parseJsonResponse(response);
- if (!response.ok) {
- throw new Error(extractErrorMessage(body, response.status));
- }
- return body;
-}
-
-// Summarize "N filled, M failed" into the status line, choosing the right tone:
-// all-good = success, some failed but some worked = info, nothing worked = error.
-function reportFillOutcome(results, errors) {
- const parts = [];
- if (results.length) parts.push(`${results.length} filled`);
- if (errors.length) parts.push(`${errors.length} failed`);
-
- let level = "success";
- if (errors.length) {
- level = results.length ? "info" : "error";
- }
-
- const detail = errors.length ? ` ${errors.join("; ")}` : "";
- setStatus(elements.fillFormMessage, `${parts.join(", ")}.${detail}`, level);
-}
-
-async function handleFillSubmit(event) {
- event.preventDefault();
- clearJson(elements.fillFormResponse);
- setStatus(elements.fillFormMessage, "");
-
- const ids = Array.from(selectedFillIds);
- if (!ids.length) {
- // The button looks disabled but stays clickable, so prompt the user here.
- elements.fillSelectionHint.classList.add("error");
- elements.fillSelectionHint.textContent = "Select at least one form to fill.";
- setStatus(elements.fillFormMessage, "Select at least one form to fill.", "error");
- return;
- }
-
- const inputText = elements.inputText.value.trim();
- if (!inputText) {
- setStatus(elements.fillFormMessage, "Input text is required.", "error");
- return;
- }
-
- // An empty picker value means "let the server use its default model".
- const model = elements.fillModel.value || undefined;
- setStatus(elements.fillFormMessage, `Filling ${pluralize(ids.length, "form")}…`, "info");
-
- // Fill each selected form independently so one failure doesn't stop the rest.
- const results = [];
- const errors = [];
- for (const id of ids) {
- try {
- results.push(await fillOneTemplate(id, inputText, model));
- } catch (error) {
- errors.push(`${templateLabel(id)}: ${error.message}`);
- }
- }
-
- const lastResult = results[results.length - 1];
- if (lastResult) {
- showJson(elements.fillFormResponse, results.length === 1 ? lastResult : results);
- if (lastResult.output_pdf_path) {
- localStorage.setItem(STORAGE_LAST_OUTPUT_KEY, lastResult.output_pdf_path);
- elements.serverPdfPath.value = lastResult.output_pdf_path;
- }
- }
-
- reportFillOutcome(results, errors);
-
- // Preview the most recently filled PDF.
- if (lastResult && lastResult.output_pdf_path) {
- await previewFromPath(lastResult.output_pdf_path, { switchToPreview: true });
- }
-}
-
-// ───────────────────────── Speech-to-text (local Whisper) ─────────────────
-
-function setSttStatus(message) {
- if (elements.sttStatus) {
- elements.sttStatus.textContent = message || "";
- }
-}
-
-async function startRecording() {
- if (mediaRecorder) {
- return;
- }
- if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
- setSttStatus("Microphone capture is not available in this environment.");
- return;
- }
-
- try {
- recordingStream = await navigator.mediaDevices.getUserMedia({ audio: true });
- } catch (error) {
- setSttStatus("Microphone permission denied.");
- return;
- }
-
- recordedChunks = [];
- mediaRecorder = new MediaRecorder(recordingStream);
- mediaRecorder.addEventListener("dataavailable", (event) => {
- if (event.data && event.data.size > 0) {
- recordedChunks.push(event.data);
- }
- });
- mediaRecorder.addEventListener("stop", handleRecordingStop);
- mediaRecorder.start();
-
- elements.sttControls.classList.add("is-recording");
- elements.sttControls.classList.remove("is-paused");
- elements.sttRecordBtn.disabled = true;
- elements.sttPauseBtn.disabled = false;
- elements.sttStopBtn.disabled = false;
- elements.sttPauseBtn.textContent = "Pause";
- setSttStatus("Recording…");
-}
-
-function togglePauseRecording() {
- if (!mediaRecorder) {
- return;
- }
- if (mediaRecorder.state === "recording") {
- mediaRecorder.pause();
- elements.sttControls.classList.add("is-paused");
- elements.sttControls.classList.remove("is-recording");
- elements.sttPauseBtn.textContent = "Resume";
- setSttStatus("Paused.");
- } else if (mediaRecorder.state === "paused") {
- mediaRecorder.resume();
- elements.sttControls.classList.add("is-recording");
- elements.sttControls.classList.remove("is-paused");
- elements.sttPauseBtn.textContent = "Pause";
- setSttStatus("Recording…");
- }
-}
-
-function stopRecording() {
- if (!mediaRecorder) {
- return;
- }
- // Lock the controls while we finalize capture and transcribe.
- elements.sttPauseBtn.disabled = true;
- elements.sttStopBtn.disabled = true;
- setSttStatus("Finishing capture…");
- mediaRecorder.stop();
-}
-
-async function handleRecordingStop() {
- elements.sttControls.classList.remove("is-recording", "is-paused");
- stopRecordingStream();
-
- const chunks = recordedChunks;
- const recorder = mediaRecorder;
- recordedChunks = [];
- mediaRecorder = null;
-
- const blob = new Blob(chunks, { type: (recorder && recorder.mimeType) || "audio/webm" });
- if (!blob.size) {
- resetSttControls();
- setSttStatus("Nothing was recorded.");
- return;
- }
-
- try {
- setSttStatus("Transcribing…");
- const text = await transcribeAudio(blob);
- appendTranscribedText(text);
- setSttStatus(text ? "Transcription added." : "No speech detected.");
- } catch (error) {
- setSttStatus(`Transcription failed: ${error.message}`);
- } finally {
- resetSttControls();
- }
-}
-
-function resetSttControls() {
- elements.sttRecordBtn.disabled = false;
- elements.sttPauseBtn.disabled = true;
- elements.sttStopBtn.disabled = true;
- elements.sttPauseBtn.textContent = "Pause";
- elements.sttControls.classList.remove("is-recording", "is-paused");
-}
-
-function stopRecordingStream() {
- if (recordingStream) {
- recordingStream.getTracks().forEach((track) => track.stop());
- recordingStream = null;
- }
-}
-
-function appendTranscribedText(text) {
- if (!text) {
- return;
- }
- const existing = elements.inputText.value.trim();
- elements.inputText.value = existing ? `${existing} ${text}` : text;
- // Let any listeners (and the required-field check) see the new value.
- elements.inputText.dispatchEvent(new Event("input"));
-}
-
-// "audio/webm;codecs=opus" -> "webm". Just gives the upload a sensible filename;
-// the server decodes by content, not extension.
-function audioExtension(mimeType) {
- const subtype = (mimeType || "").split("/")[1] || "";
- const withoutCodecs = subtype.split(";")[0].trim();
- return withoutCodecs || "webm";
-}
-
-// The Whisper ASR service decodes audio with ffmpeg, so we post the recording
-// as-is (typically webm/opus) — no client-side transcoding needed.
-async function transcribeAudio(blob) {
- const formData = new FormData();
- formData.append("audio", blob, `recording.${audioExtension(blob.type)}`);
-
- const response = await fetch(`${API_BASE_URL}/forms/transcribe`, {
- method: "POST",
- body: formData,
- });
- const body = await parseJsonResponse(response);
- if (!response.ok) {
- throw new Error(extractErrorMessage(body, response.status));
- }
- return (body.text || "").trim();
-}
-
-function handleTemplateActionClick(event) {
- const button = event.target.closest("button[data-action]");
- if (!button) {
- return;
- }
-
- const id = Number(button.dataset.templateId);
- const template = templates.find((item) => Number(item.id) === id);
- if (!template) {
- return;
- }
-
- if (button.dataset.action === "preview") {
- elements.serverPdfPath.value = template.pdf_path || "";
- previewFromPath(template.pdf_path || "", { switchToPreview: true });
- return;
- }
-
- if (button.dataset.action === "use-fill") {
- selectedFillIds.add(Number(template.id));
- renderFillTemplates();
- activateSection("fillFormSection");
- setStatus(
- elements.fillFormMessage,
- `"${template.name || "Template"}" selected for filling.`,
- "info"
- );
- }
-}
-
-function handleLocalFilePreview(event) {
- const file = event.target.files && event.target.files[0];
- if (!file) {
- return;
- }
-
- if (activeObjectUrl) {
- URL.revokeObjectURL(activeObjectUrl);
- }
-
- activeObjectUrl = URL.createObjectURL(file);
- elements.pdfFrame.src = activeObjectUrl;
- switchSection("pdfPreviewerSection");
- setStatus(elements.previewStatus, `Previewing local file: ${file.name}`, "success");
-}
-
-function resolvePreviewCandidates(pathInput) {
- const raw = String(pathInput || "").trim();
- if (!raw) {
- return [];
- }
-
- if (/^https?:\/\//i.test(raw)) {
- return [raw];
- }
-
- return [`${API_BASE_URL}/templates/preview?path=${encodeURIComponent(raw)}`];
-}
-
-async function previewFromPath(pathInput, options = {}) {
- if (options.switchToPreview) {
- switchSection("pdfPreviewerSection");
- }
-
- const raw = String(pathInput || "").trim();
- if (!raw) {
- setStatus(elements.previewStatus, "Enter a PDF path or URL first.", "error");
- return false;
- }
-
- const candidates = resolvePreviewCandidates(raw);
- if (!candidates.length) {
- setStatus(elements.previewStatus, "Unable to parse preview path.", "error");
- return false;
- }
-
- setStatus(elements.previewStatus, "Attempting to preview path...", "info");
- let lastReason = "unknown error";
-
- for (const candidate of candidates) {
- try {
- const response = await fetch(candidate, { method: "HEAD" });
- if (response.ok || response.status === 405) {
- elements.pdfFrame.src = candidate;
- setStatus(elements.previewStatus, `Previewing path: ${candidate}`, "success");
- return true;
- }
- lastReason = `${response.status} ${response.statusText}`.trim();
- } catch (error) {
- lastReason = error.message;
- }
- }
-
- const likelyServerLocal =
- !/^https?:\/\//i.test(raw) && !raw.startsWith("/");
-
- if (likelyServerLocal) {
- setStatus(
- elements.previewStatus,
- `Could not preview "${raw}". It looks like a server-local path and may not be web-accessible.`,
- "error"
- );
- } else {
- setStatus(
- elements.previewStatus,
- `Could not preview path. Last error: ${lastReason}`,
- "error"
- );
- }
-
- return false;
-}
-
-function renderTemplates() {
- elements.templatesList.innerHTML = "";
-
- if (!templates.length) {
- elements.templatesEmpty.classList.remove("hidden");
- return;
- }
-
- elements.templatesEmpty.classList.add("hidden");
- templates.forEach((template) => {
- const card = document.createElement("article");
- card.className = "template-card";
-
- const title = document.createElement("h3");
- title.textContent = `${template.name || "Untitled"} (id: ${template.id ?? "n/a"})`;
-
- const path = document.createElement("p");
- path.className = "template-meta";
- path.textContent = `pdf_path: ${template.pdf_path || ""}`;
-
- const fields = buildFieldsTable(template.fields || {});
-
- const actions = document.createElement("div");
- actions.className = "card-actions";
-
- const previewButton = document.createElement("button");
- previewButton.type = "button";
- previewButton.dataset.action = "preview";
- previewButton.dataset.templateId = String(template.id);
- previewButton.textContent = "Preview This Template";
-
- const useFillButton = document.createElement("button");
- useFillButton.type = "button";
- useFillButton.dataset.action = "use-fill";
- useFillButton.dataset.templateId = String(template.id);
- useFillButton.textContent = "Use in Fill Form";
-
- actions.append(previewButton, useFillButton);
- card.append(title, path, fields, actions);
- elements.templatesList.append(card);
- });
-}
-
-function buildFieldsTable(fieldsDict) {
- const table = document.createElement("table");
- table.className = "fields-table";
-
- const thead = document.createElement("thead");
- thead.innerHTML = "