diff --git a/.gitignore b/.gitignore
index 7fa2022..e81a1db 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,4 +2,5 @@
.idea
venv
.venv
-*.db
\ No newline at end of file
+*.db
+src/inputs/*.pdf
\ No newline at end of file
diff --git a/api/db/repositories.py b/api/db/repositories.py
index 6608718..7686510 100644
--- a/api/db/repositories.py
+++ b/api/db/repositories.py
@@ -16,4 +16,10 @@ 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_all_templates(session: Session, limit: int = 100, offset: int = 0) -> list[Template]:
+ return session.exec(select(Template).offset(offset).limit(limit)).all()
+
+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..0a7d8e7 100644
--- a/api/main.py
+++ b/api/main.py
@@ -1,7 +1,25 @@
-from fastapi import FastAPI
+from fastapi import FastAPI, Request
+from fastapi.middleware.cors import CORSMiddleware
+from fastapi.responses import JSONResponse
from api.routes import templates, forms
+from api.errors.base import AppError
+from typing import Union
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
diff --git a/api/routes/forms.py b/api/routes/forms.py
index f3430ed..3491d4e 100644
--- a/api/routes/forms.py
+++ b/api/routes/forms.py
@@ -1,25 +1,82 @@
+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.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
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):
+ # Single DB query (fixes issue #149 - redundant query)
+ 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)
+ try:
+ controller = Controller()
+ # FileManipulator.fill_form expects fields as a list of key strings
+ fields_list = list(template.fields.keys()) if isinstance(template.fields, dict) else template.fields
+ path = controller.fill_form(
+ user_input=form.input_text,
+ fields=fields_list,
+ pdf_form_path=template.pdf_path
+ )
+ except ConnectionError:
+ raise AppError(
+ "Could not connect to Ollama. Make sure ollama serve is running.",
+ status_code=503
+ )
+ except Exception as e:
+ raise AppError(f"PDF filling failed: {str(e)}", status_code=500)
+
+ # Guard: controller returned None instead of a file path
+ if not path:
+ raise AppError(
+ "PDF generation failed — no output file was produced. "
+ "Check that the PDF template is a valid fillable form and Ollama is running.",
+ status_code=500
+ )
- controller = Controller()
- path = controller.fill_form(user_input=form.input_text, fields=fetched_template.fields, pdf_form_path=fetched_template.pdf_path)
+ if not os.path.exists(path):
+ raise AppError(
+ f"PDF was generated but file not found at: {path}",
+ status_code=500
+ )
- submission = FormSubmission(**form.model_dump(), output_pdf_path=path)
+ submission = FormSubmission(
+ **form.model_dump(),
+ output_pdf_path=path
+ )
return create_form(db, submission)
+@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/docs/SETUP.md b/docs/SETUP.md
new file mode 100644
index 0000000..cf47642
--- /dev/null
+++ b/docs/SETUP.md
@@ -0,0 +1,228 @@
+# 🔥 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.
+
+---
+
+
+## 🍎 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.
+
+### Step 3 — Download the filled PDF
+- Click **"Download PDF"** to save the completed form
+
+---
+
+## 🤖 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: **52 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
+```
+
+---
+
+## 🐳 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/docs/TESTING.md b/docs/TESTING.md
new file mode 100644
index 0000000..386763d
--- /dev/null
+++ b/docs/TESTING.md
@@ -0,0 +1,64 @@
+# 🧪 Testing
+
+This document describes how to run the FireForm test suite locally.
+
+## Prerequisites
+
+Make sure you have installed all dependencies:
+
+```bash
+pip install -r requirements.txt
+```
+
+## Running Tests
+
+From the project root directory:
+
+```bash
+python -m pytest tests/ -v
+```
+
+> **Note:** Use `python -m pytest` instead of `pytest` directly to ensure the project root is on the Python path.
+
+## Test Coverage
+
+| File | Tests | What it covers |
+|------|-------|----------------|
+| `tests/test_llm.py` | 40 | LLM class — batch prompt, field extraction, plural handling, schema validation |
+| `tests/test_templates.py` | 10 | `POST /templates/create`, `GET /templates`, `GET /templates/{id}` |
+| `tests/test_forms.py` | 7 | `POST /forms/fill`, `GET /forms/{id}`, `GET /forms/download/{id}` |
+
+**Total: 57 tests**
+
+## Test Design
+
+- All tests use an **in-memory SQLite database** — your local `fireform.db` is never touched
+- Each test gets a **fresh empty database** — no data leaks between tests
+- Ollama is **never called** during tests — all LLM calls are mocked
+
+## Key Test Cases
+
+**LLM extraction (`test_llm.py`)**
+- Batch prompt contains all field keys and human-readable labels
+- `main_loop()` makes exactly **1 Ollama call** regardless of field count (O(1) assertion)
+- Graceful fallback when Mistral returns invalid JSON
+- `-1` responses stored as `None`, not as the string `"-1"`
+
+**Template endpoints (`test_templates.py`)**
+- Valid PDF upload returns 200 with field data
+- Non-PDF upload returns 400
+- Missing file returns 422
+- Non-existent template returns 404
+
+**Form endpoints (`test_forms.py`)**
+- Non-existent template returns 404
+- Ollama connection failure returns 503
+- Missing filled PDF on disk returns 404
+- Non-existent submission returns 404
+
+**Schema validation (`test_llm.py::TestSchemaValidation`)**
+- Valid extraction returns no warnings
+- Invalid email (missing @) is flagged
+- Same value in 3+ fields flagged as hallucination
+- None values are skipped (no false positives)
+- Warnings accessible via `get_validation_warnings()`
\ No newline at end of file
diff --git a/docs/demo/filled_form_output.pdf b/docs/demo/filled_form_output.pdf
new file mode 100644
index 0000000..6587e43
Binary files /dev/null and b/docs/demo/filled_form_output.pdf differ
diff --git a/docs/demo/frontend_ui.png b/docs/demo/frontend_ui.png
new file mode 100644
index 0000000..856c696
Binary files /dev/null and b/docs/demo/frontend_ui.png differ
diff --git a/docs/demo/frontend_ui02.png b/docs/demo/frontend_ui02.png
new file mode 100644
index 0000000..ca84a72
Binary files /dev/null and b/docs/demo/frontend_ui02.png differ
diff --git a/docs/frontend.md b/docs/frontend.md
new file mode 100644
index 0000000..22d2b55
--- /dev/null
+++ b/docs/frontend.md
@@ -0,0 +1,218 @@
+# Frontend UI Guide
+
+This guide explains how to set up and use the FireForm browser-based frontend interface.
+
+## Overview
+
+The FireForm frontend is a single-page web application (`frontend/index.html`) that provides a user-friendly interface for non-technical first responders to:
+
+- Upload and save fillable PDF form templates
+- Describe incidents in plain language
+- Auto-fill forms using local AI (Mistral via Ollama)
+- Download completed PDF forms instantly
+
+> [!IMPORTANT]
+> The frontend communicates with the FastAPI backend at `http://127.0.0.1:8000`. Make sure both Ollama and the API server are running before opening the frontend.
+
+---
+
+## Prerequisites
+
+Before running the frontend, ensure the following are set up:
+
+> [!IMPORTANT]
+> Complete the database setup described in [db.md](db.md) first.
+
+1. **Ollama** installed and running — [https://ollama.com/download](https://ollama.com/download)
+2. **Mistral model** pulled:
+ ```bash
+ ollama pull mistral
+ ```
+3. **Dependencies** installed:
+ ```bash
+ pip install -r requirements.txt
+ ```
+
+---
+
+## Running the Frontend
+
+### Step 1 — Start Ollama
+
+In a terminal, run:
+
+```bash
+ollama serve
+```
+
+> [!TIP]
+> Leave this terminal open. Ollama must stay running for AI extraction to work.
+
+### Step 2 — Initialize the Database
+
+```bash
+python -m api.db.init_db
+```
+
+### Step 3 — Start the API Server
+
+In a new terminal, from the project root:
+
+```bash
+uvicorn api.main:app --reload
+```
+
+If successful, you will see:
+`INFO: Uvicorn running on http://127.0.0.1:8000`
+
+### Step 4 — Open the Frontend
+
+Open `frontend/index.html` directly in your browser by double-clicking it, or navigate to it in your file explorer.
+
+> [!NOTE]
+> No additional server is required for the frontend. It is a static HTML file that communicates directly with the FastAPI backend.
+
+---
+
+## Using the Frontend
+
+The interface guides you through 4 steps:
+
+### Step 1 — Upload a Template
+
+1. Click **"Click to upload"** or drag and drop a fillable PDF form
+2. Enter a name for the template (e.g. `Cal Fire Incident Report`)
+3. Click **"SAVE TEMPLATE →"**
+
+The template is saved to the database and will appear in the **Saved Templates** list.
+
+> [!TIP]
+> Any fillable PDF form works. The system automatically detects all form fields.
+
+### Step 2 — Select a Template
+
+Click any saved template from the **Saved Templates** list in the sidebar. The selected template will be highlighted in red.
+
+### Step 3 — Describe the Incident
+
+Type or paste a plain-language description of the incident in the text area. For best results, include all relevant details that match your form's fields.
+
+**Example for an employee form:**
+```
+The employee's name is John Smith. His employee ID is EMP-2024-789.
+His job title is Firefighter Paramedic. His location is Station 12,
+Sacramento. His department is Emergency Medical Services. His supervisor
+is Captain Jane Rodriguez. His phone number is 916-555-0147.
+His email is jsmith@calfire.ca.gov.
+```
+
+**Example for an incident report form:**
+```
+Officer Hernandez responding to a structure fire at 742 Evergreen Terrace.
+Two occupants evacuated safely. Minor smoke inhalation treated on scene
+by EMS. Unit 7 on scene at 14:32, cleared at 16:45.
+Handed off to Deputy Martinez.
+```
+
+### Step 4 — Fill and Download
+
+Click **"⚡ FILL FORM"**. The system will:
+
+1. Send the description to Mistral (running locally via Ollama)
+2. Extract all relevant field values
+3. Fill the PDF template automatically
+4. Provide a **"⬇ Download PDF"** button
+
+> [!NOTE]
+> Processing time depends on your hardware. Typically 10–30 seconds with Mistral on a standard machine.
+
+---
+
+## API Endpoints
+
+The frontend uses the following API endpoints:
+
+| Method | Endpoint | Description |
+|--------|----------|-------------|
+| `POST` | `/templates/create` | Upload a new PDF template |
+| `GET` | `/templates` | List all saved templates |
+| `GET` | `/templates/{id}` | Get a specific template |
+| `POST` | `/forms/fill` | Fill a form with incident text |
+| `GET` | `/forms/{id}` | Get a submission record |
+| `GET` | `/forms/download/{id}` | Download a filled PDF |
+
+For full API documentation, visit [http://127.0.0.1:8000/docs](http://127.0.0.1:8000/docs) while the server is running.
+
+---
+
+## API Status Indicator
+
+The top-right corner of the frontend shows the API connection status:
+
+- 🟢 **api online** — Backend is reachable, ready to use
+- 🔴 **api offline** — Backend is not running, check uvicorn
+
+---
+
+## Troubleshooting
+
+### "api offline" shown in the top bar
+
+The FastAPI server is not running. Start it with:
+```bash
+uvicorn api.main:app --reload
+```
+
+### Form fills with null or incorrect values
+
+This happens when the incident description does not contain information matching the PDF form fields. Ensure your description includes the specific data your form requires (names, dates, locations, etc.).
+
+See [Issue #113](https://github.com/fireform-core/FireForm/issues/113) for context on matching input to templates.
+
+### "Could not connect to Ollama" error
+
+Ollama is not running. Start it with:
+```bash
+ollama serve
+```
+
+Then verify Mistral is available:
+```bash
+ollama list
+```
+
+If Mistral is not listed, pull it:
+```bash
+ollama pull mistral
+```
+
+### Port conflict on 11434
+
+Something else is using Ollama's port. On Linux/Mac:
+```bash
+sudo lsof -i :11434
+```
+On Windows:
+```cmd
+netstat -ano | findstr :11434
+```
+
+---
+
+## Privacy
+
+> [!IMPORTANT]
+> FireForm is designed to be fully private. All AI processing happens locally via Ollama. No incident data, form content, or personal information is ever sent to external servers.
+
+---
+
+## Docker Usage
+
+To run the full stack including the frontend API via Docker:
+
+```bash
+chmod +x container-init.sh
+./container-init.sh
+```
+
+See [docker.md](docker.md) for full Docker setup instructions.
diff --git a/frontend/index.html b/frontend/index.html
new file mode 100644
index 0000000..a3b0083
--- /dev/null
+++ b/frontend/index.html
@@ -0,0 +1,467 @@
+
+
+
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
Choose from saved forms
+
+
+
3
+
Describe Incident
Plain language report
+
+
+
4
+
Download PDF
All fields auto-filled
+
+
+
+
+
← Select a template from the sidebar
+
+ Incident Description *
+ 0 chars
+
+
+
+
+
Runs via Ollama locally. No data leaves your machine.
+
+
+
+
+
Mistral is extracting data and filling your form...