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..e3b51b6
--- /dev/null
+++ b/docs/SETUP.md
@@ -0,0 +1,303 @@
+# 🔥 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
+```
+
+---
+
+## 🐳 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..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...