diff --git a/.gitignore b/.gitignore
index 7fa2022..0f5657d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,4 +2,12 @@
.idea
venv
.venv
-*.db
\ No newline at end of file
+*.dbsrc/inputs/*.pdf
+src/outputs/*.pdf
+src/inputs/*.pdf
+src/outputs/*.pdf
+fireform.db
+*.bak
+ngrok.exe
+out.txt
+benchmark_proof.py
diff --git a/api/db/repositories.py b/api/db/repositories.py
index 6608718..4bc8a00 100644
--- a/api/db/repositories.py
+++ b/api/db/repositories.py
@@ -1,19 +1,33 @@
from sqlmodel import Session, select
from api.db.models import Template, FormSubmission
-# Templates
+
+# ── Templates ─────────────────────────────────────────────────
+
def create_template(session: Session, template: Template) -> Template:
session.add(template)
session.commit()
session.refresh(template)
return template
+
def get_template(session: Session, template_id: int) -> Template | None:
return session.get(Template, template_id)
-# Forms
+
+def get_all_templates(session: Session, limit: int = 100, offset: int = 0) -> list[Template]:
+ statement = select(Template).offset(offset).limit(limit)
+ return session.exec(statement).all()
+
+
+# ── Forms ─────────────────────────────────────────────────────
+
def create_form(session: Session, form: FormSubmission) -> FormSubmission:
session.add(form)
session.commit()
session.refresh(form)
- return form
\ No newline at end of file
+ return form
+
+
+def get_form(session: Session, submission_id: int) -> FormSubmission | None:
+ return session.get(FormSubmission, submission_id)
\ No newline at end of file
diff --git a/api/main.py b/api/main.py
index d0b8c79..612a1a0 100644
--- a/api/main.py
+++ b/api/main.py
@@ -1,7 +1,32 @@
-from fastapi import FastAPI
-from api.routes import templates, forms
+from fastapi import FastAPI, Request
+from fastapi.middleware.cors import CORSMiddleware
+from fastapi.responses import JSONResponse
+from fastapi.staticfiles import StaticFiles
+from api.routes import templates, forms, transcribe
+from api.errors.base import AppError
+from typing import Union
+import os
app = FastAPI()
+app.add_middleware(
+ CORSMiddleware,
+ allow_origins=["*"],
+ allow_methods=["*"],
+ allow_headers=["*"],
+)
+
+@app.exception_handler(AppError)
+def app_error_handler(request: Request, exc: AppError):
+ return JSONResponse(
+ status_code=exc.status_code,
+ content={"detail": exc.message}
+ )
+
app.include_router(templates.router)
-app.include_router(forms.router)
\ No newline at end of file
+app.include_router(forms.router)
+app.include_router(transcribe.router)
+
+# Serve mobile PWA at /mobile
+if os.path.exists("mobile"):
+ app.mount("/mobile", StaticFiles(directory="mobile", html=True), name="mobile")
\ No newline at end of file
diff --git a/api/routes/forms.py b/api/routes/forms.py
index f3430ed..9df6a5c 100644
--- a/api/routes/forms.py
+++ b/api/routes/forms.py
@@ -1,25 +1,140 @@
+import os
from fastapi import APIRouter, Depends
+from fastapi.responses import FileResponse
from sqlmodel import Session
from api.deps import get_db
-from api.schemas.forms import FormFill, FormFillResponse
-from api.db.repositories import create_form, get_template
+from api.schemas.forms import FormFill, FormFillResponse, BatchFormFill, BatchFormFillResponse, BatchResultItem
+from api.db.repositories import create_form, get_template, get_form
from api.db.models import FormSubmission
from api.errors.base import AppError
from src.controller import Controller
+from src.llm import LLM
+from src.filler import Filler
router = APIRouter(prefix="/forms", tags=["forms"])
+
@router.post("/fill", response_model=FormFillResponse)
-def fill_form(form: FormFill, db: Session = Depends(get_db)):
- if not get_template(db, form.template_id):
+async def fill_form(form: FormFill, db: Session = Depends(get_db)):
+ template = get_template(db, form.template_id)
+ if not template:
raise AppError("Template not found", status_code=404)
- fetched_template = get_template(db, form.template_id)
+ if not os.path.exists(template.pdf_path):
+ raise AppError(f"Template PDF not found: {template.pdf_path}", status_code=404)
+
+ try:
+ # Step 1: LLM Extraction (Async)
+ llm = LLM(transcript_text=form.input_text, target_fields=template.fields)
+ await llm.async_main_loop()
+ extracted_data = llm.get_data()
- controller = Controller()
- path = controller.fill_form(user_input=form.input_text, fields=fetched_template.fields, pdf_form_path=fetched_template.pdf_path)
+ # Step 2: PDF Filling (Sync)
+ # Using filler directly to avoid redundant extraction in controller
+ filler = Filler()
+ path = filler.fill_form_with_data(
+ pdf_form=template.pdf_path,
+ data=extracted_data
+ )
+ except Exception as e:
+ raise AppError(f"Processing failed: {str(e)}", status_code=500)
+
+ if not path or not os.path.exists(path):
+ raise AppError("PDF generation failed.", status_code=500)
submission = FormSubmission(**form.model_dump(), output_pdf_path=path)
return create_form(db, submission)
+@router.post("/fill/batch", response_model=BatchFormFillResponse)
+async def fill_batch(batch: BatchFormFill, db: Session = Depends(get_db)):
+ if not batch.template_ids:
+ raise AppError("template_ids must not be empty", status_code=400)
+
+ templates = []
+ for tid in batch.template_ids:
+ tpl = get_template(db, tid)
+ if not tpl or not os.path.exists(tpl.pdf_path):
+ raise AppError(f"Template {tid} invalid or PDF missing", status_code=404)
+ templates.append(tpl)
+
+ # Step 1: LLM Extraction (Async - ONE call for all templates)
+ merged_fields = {}
+ for tpl in templates:
+ if isinstance(tpl.fields, dict): merged_fields.update(tpl.fields)
+ else:
+ for f in tpl.fields: merged_fields[f] = f
+
+ try:
+ llm = LLM(transcript_text=batch.input_text, target_fields=merged_fields)
+ await llm.async_main_loop()
+ extracted_json = llm.get_data()
+ except Exception as e:
+ raise AppError(f"Extraction failed: {str(e)}", status_code=500)
+
+ # Step 2: PDF Filling (Sync - per template)
+ results = []
+ success_count = 0
+ filler = Filler()
+
+ for tpl in templates:
+ try:
+ tpl_field_keys = list(tpl.fields.keys()) if isinstance(tpl.fields, dict) else tpl.fields
+ tpl_data = {k: extracted_json.get(k) for k in tpl_field_keys}
+
+ output_path = filler.fill_form_with_data(pdf_form=tpl.pdf_path, data=tpl_data)
+
+ submission = FormSubmission(
+ template_id=tpl.id,
+ input_text=batch.input_text,
+ output_pdf_path=output_path
+ )
+ saved = create_form(db, submission)
+
+ results.append(BatchResultItem(
+ template_id=tpl.id,
+ template_name=tpl.name,
+ success=True,
+ submission_id=saved.id,
+ download_url=f"/forms/download/{saved.id}"
+ ))
+ success_count += 1
+ except Exception as e:
+ results.append(BatchResultItem(
+ template_id=tpl.id,
+ template_name=tpl.name,
+ success=False,
+ error=str(e)
+ ))
+
+ return BatchFormFillResponse(
+ total=len(templates),
+ succeeded=success_count,
+ failed=len(templates)-success_count,
+ results=results
+ )
+
+
+@router.get("/{submission_id}", response_model=FormFillResponse)
+def get_submission(submission_id: int, db: Session = Depends(get_db)):
+ submission = get_form(db, submission_id)
+ if not submission:
+ raise AppError("Submission not found", status_code=404)
+ return submission
+
+
+@router.get("/download/{submission_id}")
+def download_filled_pdf(submission_id: int, db: Session = Depends(get_db)):
+ submission = get_form(db, submission_id)
+ if not submission:
+ raise AppError("Submission not found", status_code=404)
+
+ file_path = submission.output_pdf_path
+ if not os.path.exists(file_path):
+ raise AppError("PDF file not found on server", status_code=404)
+
+ return FileResponse(
+ path=file_path,
+ media_type="application/pdf",
+ filename=os.path.basename(file_path)
+ )
\ No newline at end of file
diff --git a/api/routes/templates.py b/api/routes/templates.py
index 5c2281b..9419ae6 100644
--- a/api/routes/templates.py
+++ b/api/routes/templates.py
@@ -1,16 +1,89 @@
-from fastapi import APIRouter, Depends
+import os
+import shutil
+import uuid
+from fastapi import APIRouter, Depends, UploadFile, File, Form
from sqlmodel import Session
from api.deps import get_db
-from api.schemas.templates import TemplateCreate, TemplateResponse
-from api.db.repositories import create_template
+from api.schemas.templates import TemplateResponse
+from api.db.repositories import create_template, get_all_templates
from api.db.models import Template
-from src.controller import Controller
+from api.errors.base import AppError
router = APIRouter(prefix="/templates", tags=["templates"])
+# Save directly into src/inputs/ — stable location, won't get wiped
+TEMPLATES_DIR = os.path.join("src", "inputs")
+os.makedirs(TEMPLATES_DIR, exist_ok=True)
+
+
@router.post("/create", response_model=TemplateResponse)
-def create(template: TemplateCreate, db: Session = Depends(get_db)):
- controller = Controller()
- template_path = controller.create_template(template.pdf_path)
- tpl = Template(**template.model_dump(exclude={"pdf_path"}), pdf_path=template_path)
- return create_template(db, tpl)
\ No newline at end of file
+async def create(
+ name: str = Form(...),
+ file: UploadFile = File(...),
+ db: Session = Depends(get_db)
+):
+ # Validate PDF
+ if not file.filename.endswith(".pdf"):
+ raise AppError("Only PDF files are allowed", status_code=400)
+
+ # Save uploaded file with unique name into src/inputs/
+ unique_name = f"{uuid.uuid4().hex}_{file.filename}"
+ save_path = os.path.join(TEMPLATES_DIR, unique_name)
+
+ with open(save_path, "wb") as f:
+ shutil.copyfileobj(file.file, f)
+
+ # Extract fields using commonforms + pypdf
+ # Store as simple list of field name strings — what Filler expects
+ try:
+ from commonforms import prepare_form
+ from pypdf import PdfReader
+
+ # Read real field names directly from original PDF
+ # Use /T (internal name) as both key and label
+ # Real names like "JobTitle", "Phone Number" are already human-readable
+ reader = PdfReader(save_path)
+ raw_fields = reader.get_fields() or {}
+
+ fields = {}
+ for internal_name, field_data in raw_fields.items():
+ # Use /TU tooltip if available, otherwise prettify /T name
+ label = None
+ if isinstance(field_data, dict):
+ label = field_data.get("/TU")
+ if not label:
+ # Prettify: "JobTitle" → "Job Title", "DATE7_af_date" → "Date"
+ import re
+ label = re.sub(r'([a-z])([A-Z])', r'\1 \2', internal_name)
+ label = re.sub(r'_af_.*$', '', label) # strip "_af_date" suffix
+ label = label.replace('_', ' ').strip().title()
+ fields[internal_name] = label
+
+ except Exception as e:
+ print(f"Field extraction failed: {e}")
+ fields = []
+
+ # Save to DB
+ tpl = Template(name=name, pdf_path=save_path, fields=fields)
+ return create_template(db, tpl)
+
+
+@router.get("", response_model=list[TemplateResponse])
+def list_templates(
+ limit: int = 100,
+ offset: int = 0,
+ db: Session = Depends(get_db)
+):
+ return get_all_templates(db, limit=limit, offset=offset)
+
+
+@router.get("/{template_id}", response_model=TemplateResponse)
+def get_template_by_id(
+ template_id: int,
+ db: Session = Depends(get_db)
+):
+ from api.db.repositories import get_template
+ tpl = get_template(db, template_id)
+ if not tpl:
+ raise AppError("Template not found", status_code=404)
+ return tpl
\ No newline at end of file
diff --git a/api/routes/transcribe.py b/api/routes/transcribe.py
new file mode 100644
index 0000000..41783fa
--- /dev/null
+++ b/api/routes/transcribe.py
@@ -0,0 +1,77 @@
+
+from fastapi import APIRouter, UploadFile, File, Query
+from fastapi.responses import JSONResponse
+from api.errors.base import AppError
+from src.transcriber import transcribe_audio
+
+router = APIRouter(prefix="/transcribe", tags=["transcription"])
+
+ALLOWED_EXTENSIONS = {".mp3", ".mp4", ".wav", ".m4a", ".ogg", ".webm", ".flac"}
+MAX_FILE_SIZE = 50 * 1024 * 1024 # 50MB
+
+
+@router.post("")
+async def transcribe(
+ file: UploadFile = File(...),
+ language: str = Query(
+ default=None,
+ description="Optional language code e.g. 'en', 'fr', 'es'. "
+ "Leave empty for auto-detection."
+ )
+):
+ """
+ Transcribe an audio file to text using faster-whisper.
+
+ Upload any audio file (wav, mp3, m4a, webm, ogg).
+ Returns transcript text ready to pass directly into POST /forms/fill.
+
+ Works CPU-only — no GPU required.
+ Typical transcription time: 2-5s for a 1-minute recording.
+
+ Example workflow:
+ 1. POST /transcribe → get transcript
+ 2. POST /forms/fill → fill PDF from transcript
+ """
+ # Validate file extension
+ from pathlib import Path
+ ext = Path(file.filename or "").suffix.lower()
+ if ext not in ALLOWED_EXTENSIONS:
+ raise AppError(
+ f"Unsupported file type '{ext}'. "
+ f"Supported: {', '.join(sorted(ALLOWED_EXTENSIONS))}",
+ status_code=422
+ )
+
+ # Read and validate file size
+ file_bytes = await file.read()
+ if len(file_bytes) > MAX_FILE_SIZE:
+ raise AppError(
+ f"File too large ({len(file_bytes) // (1024*1024)}MB). "
+ "Maximum allowed size is 50MB.",
+ status_code=413
+ )
+
+ if len(file_bytes) == 0:
+ raise AppError("Uploaded file is empty.", status_code=422)
+
+ try:
+ result = transcribe_audio(
+ file_bytes=file_bytes,
+ filename=file.filename or "audio.wav",
+ language=language or None
+ )
+ except RuntimeError as e:
+ raise AppError(str(e), status_code=503)
+ except Exception as e:
+ raise AppError(
+ f"Transcription failed: {str(e)}",
+ status_code=500
+ )
+
+ return {
+ "transcript": result["transcript"],
+ "language": result["language"],
+ "language_probability": result["language_probability"],
+ "duration_seconds": result["duration"],
+ "hint": "Pass 'transcript' directly as 'input_text' to POST /forms/fill"
+ }
\ No newline at end of file
diff --git a/api/schemas/forms.py b/api/schemas/forms.py
index 3cce650..ae8c38b 100644
--- a/api/schemas/forms.py
+++ b/api/schemas/forms.py
@@ -1,15 +1,65 @@
from pydantic import BaseModel
+from typing import Optional
+from datetime import datetime
+
class FormFill(BaseModel):
template_id: int
input_text: str
+ class Config:
+ from_attributes = True
+
class FormFillResponse(BaseModel):
id: int
template_id: int
input_text: str
output_pdf_path: str
+ created_at: datetime
+
+ class Config:
+ from_attributes = True
+
+
+# ── Batch schemas — closes #156 ───────────────────────────────
+
+class BatchFormFill(BaseModel):
+ """
+ Request body for POST /forms/fill/batch.
+ One transcript + multiple template IDs → fills all PDFs in one request.
+ """
+ input_text: str
+ template_ids: list[int]
+
+ class Config:
+ from_attributes = True
+
+
+class BatchResultItem(BaseModel):
+ """
+ Per-template result in a batch fill response.
+ """
+ template_id: int
+ template_name: str
+ success: bool
+ submission_id: Optional[int] = None
+ download_url: Optional[str] = None
+ error: Optional[str] = None
+
+ class Config:
+ from_attributes = True
+
+
+class BatchFormFillResponse(BaseModel):
+ """
+ Response body for POST /forms/fill/batch.
+ Partial failures preserved — one failure never aborts the batch.
+ """
+ total: int
+ succeeded: int
+ failed: int
+ results: list[BatchResultItem]
class Config:
from_attributes = True
\ No newline at end of file
diff --git a/docs/SETUP.md b/docs/SETUP.md
new file mode 100644
index 0000000..861b855
--- /dev/null
+++ b/docs/SETUP.md
@@ -0,0 +1,438 @@
+# 🔥 FireForm — Setup & Usage Guide
+
+This guide covers how to install, run, and use FireForm locally on Windows, Linux, and macOS.
+
+---
+
+## 📋 Prerequisites
+
+| Tool | Version | Purpose |
+|------|---------|---------|
+| Python | 3.11+ | Backend runtime |
+| Ollama | 0.17.7+ | Local LLM server |
+| Mistral 7B | latest | AI extraction model |
+| Git | any | Clone the repository |
+
+---
+
+## 🪟 Windows
+
+### 1. Clone the repository
+```cmd
+git clone https://github.com/fireform-core/FireForm.git
+cd FireForm
+```
+
+### 2. Create and activate virtual environment
+```cmd
+python -m venv venv
+venv\Scripts\activate
+```
+
+### 3. Install dependencies
+```cmd
+pip install -r requirements.txt
+```
+
+### 4. Install and start Ollama
+Download Ollama from https://ollama.com/download/windows
+
+Then pull the Mistral model:
+```cmd
+ollama pull mistral
+ollama serve
+```
+
+> Ollama runs on `http://localhost:11434` by default. Keep this terminal open.
+
+### 5. Initialize the database
+```cmd
+python -m api.db.init_db
+```
+
+### 6. Start the API server
+```cmd
+uvicorn api.main:app --reload
+```
+
+API is now running at `http://127.0.0.1:8000`
+
+### 7. Start the frontend
+Open a new terminal:
+```cmd
+cd frontend
+python -m http.server 3000
+```
+
+Open `http://localhost:3000` in your browser.
+
+---
+
+## 🐧 Linux (Ubuntu/Debian)
+
+### 1. Clone and enter the repository
+```bash
+git clone https://github.com/fireform-core/FireForm.git
+cd FireForm
+```
+
+### 2. Create and activate virtual environment
+```bash
+python3 -m venv venv
+source venv/bin/activate
+```
+
+### 3. Install dependencies
+```bash
+pip install -r requirements.txt
+```
+
+### 4. Install and start Ollama
+```bash
+curl -fsSL https://ollama.com/install.sh | sh
+ollama pull mistral
+ollama serve &
+```
+
+### 5. Initialize the database
+```bash
+python -m api.db.init_db
+```
+
+### 6. Start the API server
+```bash
+uvicorn api.main:app --reload
+```
+
+### 7. Start the frontend
+```bash
+cd frontend
+python3 -m http.server 3000
+```
+
+---
+
+## 🍎 macOS
+
+### 1. Clone and enter the repository
+```bash
+git clone https://github.com/fireform-core/FireForm.git
+cd FireForm
+```
+
+### 2. Create and activate virtual environment
+```bash
+python3 -m venv venv
+source venv/bin/activate
+```
+
+### 3. Install dependencies
+```bash
+pip install -r requirements.txt
+```
+
+### 4. Install and start Ollama
+Download from https://ollama.com/download/mac or:
+```bash
+brew install ollama
+ollama pull mistral
+ollama serve &
+```
+
+### 5. Initialize the database
+```bash
+python -m api.db.init_db
+```
+
+### 6. Start the API server
+```bash
+uvicorn api.main:app --reload
+```
+
+### 7. Start the frontend
+```bash
+cd frontend
+python3 -m http.server 3000
+```
+
+---
+
+## 🖥️ Using the Frontend
+
+Once everything is running, open `http://localhost:3000` in your browser.
+
+### Step 1 — Upload a PDF template
+- Click **"Choose File"** and select any fillable PDF form
+- Enter a name for the template
+- Click **"Upload Template"**
+
+FireForm will automatically extract all form field names and their human-readable labels.
+
+### Step 2 — Fill the form
+- Select your uploaded template from the dropdown
+- In the text box, describe the incident or enter the information in natural language:
+
+```
+Employee name is John Smith. Employee ID is EMP-2024-789.
+Job title is Firefighter Paramedic. Location is Station 12 Sacramento.
+Department is Emergency Medical Services. Supervisor is Captain Rodriguez.
+Phone number is 916-555-0147.
+```
+
+- Click **"Fill Form"**
+
+FireForm sends one request to Ollama (Mistral) which extracts all fields at once and returns structured JSON.
+
+
+### Batch fill — multiple agency forms at once
+
+Switch to **BATCH** mode in the sidebar to fill multiple templates simultaneously from one transcript:
+
+1. Click **BATCH** toggle in the sidebar
+2. Check all agency templates you want to fill
+3. Enter one incident description
+4. Click **⚡ FILL N FORMS**
+
+FireForm runs a single LLM call for the entire batch and returns individual download links for each filled PDF. One failed template never aborts the rest.
+
+---
+### Step 3 — Download the filled PDF
+- Click **"Download PDF"** to save the completed form
+
+---
+
+## ✅ Supported PDF Field Types
+
+FireForm supports all common fillable PDF field types:
+
+| Field Type | Description | Example |
+|------------|-------------|---------|
+| Text | Plain text input | Name, ID, Notes |
+| Checkbox | Boolean tick box | Married ✓ |
+| Radio button | Single selection from options | Gender: Male / Female |
+| Dropdown | Single select list | City |
+| Multi-select | Multiple select list | Language |
+
+**Checkbox and radio button filling:**
+FireForm automatically detects the field type from the PDF annotation flags (`FT` and `Ff`) and writes the correct PDF value format. PDF checkboxes require named values like `/Yes` or `/Off` — not plain strings. FireForm reads the PDF's own appearance stream (`AP.N`) to find the exact on-state name used by each form, so it works correctly with any PDF regardless of internal naming conventions.
+
+LLM outputs like `"yes"`, `"true"`, `"x"`, `"1"`, `"checked"` all resolve to the correct checked state. Outputs like `"no"`, `"false"`, `"0"`, `""` resolve to unchecked.
+
+---
+
+## 🤖 How AI Extraction Works
+
+FireForm uses a **batch extraction** approach:
+
+```
+Traditional approach (slow): FireForm approach (fast):
+ Field 1 → Ollama call All fields → 1 Ollama call
+ Field 2 → Ollama call Mistral returns JSON with all values
+ Field 3 → Ollama call Parse → fill PDF
+ ...N calls total 1 call total (O(1))
+```
+
+Field names are automatically read from the PDF's annotations and converted to human-readable labels before being sent to Mistral — so the model understands what each field means regardless of internal PDF naming conventions like `textbox_0_0`.
+
+**Example extraction:**
+```json
+{
+ "NAME/SID": "John Smith",
+ "JobTitle": "Firefighter Paramedic",
+ "Department": "Emergency Medical Services",
+ "Phone Number": "916-555-0147",
+ "email": null
+}
+```
+
+---
+
+## 🧪 Running Tests
+
+```bash
+python -m pytest tests/ -v
+```
+
+Expected output: **70 passed**
+
+See [TESTING.md](TESTING.md) for full test coverage details.
+
+---
+
+## 🔧 Environment Variables
+
+| Variable | Default | Description |
+|----------|---------|-------------|
+| `OLLAMA_HOST` | `http://localhost:11434` | Ollama server URL |
+
+To use a remote Ollama instance:
+```bash
+export OLLAMA_HOST=http://your-server:11434 # Linux/Mac
+set OLLAMA_HOST=http://your-server:11434 # Windows
+```
+
+---
+
+
+---
+
+## 📱 PWA Mobile Companion (Field Capture)
+
+FireForm includes an offline-first Progressive Web App at `/mobile` that lets first responders capture incident data in the field — no internet required.
+
+> **Current Status:** The PWA is a working proof of concept. The long-term goal is a fully native mobile app. The PWA approach is the current implementation while the native app is in development.
+
+---
+
+### How It Works
+
+```
+At the scene (no WiFi):
+ Officer opens PWA on phone
+ Records voice, types notes, captures GPS
+ Saves draft — stored on device
+
+Back at station (WiFi restored):
+ PWA detects connection automatically
+ Draft syncs to station server
+ Audio transcribed, PDF filled
+ Officer downloads completed forms
+```
+
+---
+
+### Setup — Running PWA on Your Network
+
+**Step 1 — Start the API server exposed to your network:**
+```cmd
+uvicorn api.main:app --host 0.0.0.0 --reload
+```
+
+**Step 2 — Find your PC's local IP:**
+```cmd
+ipconfig
+```
+Look for `IPv4 Address` under your WiFi adapter — e.g. `192.168.1.105`
+
+**Step 3 — Open on mobile (same WiFi):**
+```
+http://192.168.1.105:8000/mobile
+```
+
+This works for basic text capture and drafts. However, **microphone and GPS require HTTPS** — this is a browser security requirement, not a FireForm limitation.
+
+---
+
+### Enabling Microphone and GPS — ngrok (Recommended for Demo)
+
+[ngrok](https://ngrok.com) creates a secure HTTPS tunnel to your local server. This is the recommended approach for demo and testing purposes.
+
+**Install ngrok:**
+1. Download from https://ngrok.com/download
+2. Create a free account at https://dashboard.ngrok.com/signup
+3. Copy your authtoken from the dashboard
+4. Run: `ngrok config add-authtoken YOUR_TOKEN`
+
+**Start the tunnel:**
+```cmd
+# Terminal 1 — API server
+uvicorn api.main:app --host 0.0.0.0 --reload
+
+# Terminal 2 — ngrok tunnel
+ngrok http 8000
+```
+
+ngrok will show a URL like:
+```
+Forwarding https://abc123.ngrok-free.app -> http://localhost:8000
+```
+
+**Open on mobile:**
+```
+https://abc123.ngrok-free.app/mobile
+```
+
+Now microphone ✅, GPS ✅, and PWA install ✅ all work.
+
+> **Note:** Free ngrok sessions expire after a few hours and the URL changes on restart. For persistent access during a demo session, keep both terminals running.
+
+---
+
+### Installing as an App on Android
+
+1. Open the ngrok HTTPS URL in Chrome on your Android device
+2. Tap the three-dot menu → **Add to Home Screen**
+3. Tap **Install**
+
+The app now appears as an icon on your home screen and opens in standalone mode (no browser bar).
+
+---
+
+### Installing as an App on iOS
+
+1. Open the ngrok HTTPS URL in Safari on your iPhone/iPad
+2. Tap the Share button → **Add to Home Screen**
+3. Tap **Add**
+
+---
+
+### Offline Behaviour After Install
+
+Once installed via HTTPS, the Service Worker caches the app shell. After the first visit:
+- The app opens even with zero internet
+- Drafts save to IndexedDB on the device
+- GPS works (satellite-based, no internet needed)
+- Voice records and stores locally
+- Everything syncs automatically when WiFi is restored
+
+---
+
+### Production Deployment — Station Network
+
+For real station deployment, replace ngrok with a self-signed SSL certificate on the station PC:
+
+```bash
+# Generate self-signed certificate (Linux/Mac)
+openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -days 365 -nodes
+
+# Start uvicorn with SSL
+uvicorn api.main:app --host 0.0.0.0 --ssl-keyfile key.pem --ssl-certfile cert.pem
+```
+
+Officers connect via station WiFi:
+```
+https://STATION_PC_IP:8000/mobile
+```
+
+All data stays on-premise. No external services. No cloud.
+
+---
+
+## 🐳 Docker (Coming Soon)
+
+Docker support is in progress. See [docker.md](docker.md) for current status.
+
+---
+
+## ❓ Troubleshooting
+
+**`Form data requires python-multipart`**
+```bash
+pip install python-multipart
+```
+
+**`ModuleNotFoundError: No module named 'pypdf'`**
+```bash
+pip install pypdf
+```
+
+**`Could not connect to Ollama`**
+- Make sure `ollama serve` is running
+- Check Ollama is on port 11434: `curl http://localhost:11434`
+
+**`NameError: name 'Union' is not defined`**
+- Pull latest changes: `git pull origin main`
+- This bug is fixed in the current version
+
+**Tests fail with `ModuleNotFoundError: No module named 'api'`**
+- Use `python -m pytest` instead of `pytest`
\ No newline at end of file
diff --git a/frontend/index.html b/frontend/index.html
new file mode 100644
index 0000000..2c18797
--- /dev/null
+++ b/frontend/index.html
@@ -0,0 +1,657 @@
+
+
+
Describe any incident in plain language. FireForm uses a locally-running AI to extract every relevant detail and auto-fill all required agency forms — instantly and privately.
+
+
+
+
+
1
+
Upload Template
Any fillable PDF form
+
+
+
2
+
Select Template(s)
Single or multi-agency batch
+
+
+
3
+
Describe Incident
Plain language report
+
+
+
4
+
Download PDF
All fields auto-filled
+
+
+
+
+
← Select a template from the sidebar
+
+ Incident Description *
+ 0 chars
+
+
+
+
+
Click mic to record incident report
+
Transcribing audio...
+
+
+
+
+
+
Runs via Ollama locally. No data leaves your machine.
+
+
+
+
+
Mistral is extracting data and filling your form...