Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,12 @@
.idea
venv
.venv
*.db
*.dbsrc/inputs/*.pdf
src/outputs/*.pdf
src/inputs/*.pdf
src/outputs/*.pdf
fireform.db
*.bak
ngrok.exe
out.txt
benchmark_proof.py
19 changes: 15 additions & 4 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,15 @@ FROM python:3.11-slim
WORKDIR /app

# Install system dependencies
# Fixes #275 #191 #184 — libGL and libglib2 required by faster-whisper / OpenCV
# Fixes #53 — libxcb1 missing from python:3.11-slim base image
# ffmpeg required by faster-whisper for audio processing
RUN apt-get update && apt-get install -y \
curl \
ffmpeg \
libgl1 \
libglib2.0-0 \
libxcb1 \
&& rm -rf /var/lib/apt/lists/*

# Copy and install Python dependencies
Expand All @@ -15,8 +22,12 @@ RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY . .

# Set Python path so imports work correctly
ENV PYTHONPATH=/app/src
# Fix #118 #116 — PYTHONPATH must be /app (project root), not /app/src
# All imports use api.*, src.* which require the root to be on the path
ENV PYTHONPATH=/app

# Keep container running for interactive use
CMD ["tail", "-f", "/dev/null"]
# 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"]
20 changes: 16 additions & 4 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,16 @@ help:
@echo "make clean - Remove containers"
@echo "make super-clean - [CAUTION] Use carefully. Cleans up ALL stopped containers, networks, build cache..."

fireform: build up
@echo "Launching interactive shell in the app container..."
docker compose exec app /bin/bash
# Fix #382 — pull-model is now part of the main setup flow
# Mistral is pulled automatically before you need it
fireform: build up pull-model
@echo ""
@echo "✅ FireForm is ready!"
@echo " API: http://localhost:8000"
@echo " API Docs: http://localhost:8000/docs"
@echo " PWA: http://localhost:8000/mobile"
@echo ""
@echo "Run 'make logs' to view live logs, 'make down' to stop."

build:
docker compose build
Expand All @@ -48,14 +55,19 @@ logs-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

pull-model:
docker compose exec ollama ollama pull mistral

# Fix — correct test directory (was src/test/ which doesn't exist)
test:
docker compose exec app python3 -m pytest src/test/
docker compose exec app python3 -m pytest tests/ -v

clean:
docker compose down -v
Expand Down
26 changes: 25 additions & 1 deletion api/db/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,28 @@ class FormSubmission(SQLModel, table=True):
template_id: int
input_text: str
output_pdf_path: str
created_at: datetime = Field(default_factory=datetime.utcnow)
created_at: datetime = Field(default_factory=datetime.utcnow)

# ADD THIS TO api/db/models.py
# (append to existing file — don't replace)

from sqlmodel import SQLModel, Field
from typing import Optional
from datetime import datetime


class IncidentMasterData(SQLModel, table=True):
"""
The Incident Data Lake.
Stores all extracted data from one incident as a master JSON blob.
Any agency can generate their PDF from this single record — zero new LLM calls.
"""
id: Optional[int] = Field(default=None, primary_key=True)
incident_id: str = Field(index=True) # INC-2026-0321-4821
master_json: str # JSON string — all extracted fields
transcript_text: str # original transcript
location_lat: Optional[float] = None # from PWA GPS
location_lng: Optional[float] = None # from PWA GPS
officer_notes: Optional[str] = None # additional context
created_at: datetime = Field(default_factory=datetime.utcnow)
updated_at: datetime = Field(default_factory=datetime.utcnow)
104 changes: 101 additions & 3 deletions api/db/repositories.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,117 @@
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
return form


def get_form(session: Session, submission_id: int) -> FormSubmission | None:
return session.get(FormSubmission, submission_id)


# ADD THESE FUNCTIONS TO api/db/repositories.py
# (append to existing file — don't replace)

import json
from api.db.models import IncidentMasterData
from datetime import datetime


def create_incident(db, incident: IncidentMasterData) -> IncidentMasterData:
db.add(incident)
db.commit()
db.refresh(incident)
return incident


def get_incident(db, incident_id: str) -> IncidentMasterData:
from sqlmodel import select
return db.exec(
select(IncidentMasterData).where(
IncidentMasterData.incident_id == incident_id
)
).first()


def get_all_incidents(db) -> list:
from sqlmodel import select
return db.exec(select(IncidentMasterData)).all()


def update_incident_json(db, incident_id: str, new_data: dict, new_transcript: str = None) -> IncidentMasterData:
"""
Smart Merge new extracted data into existing master JSON to enable
Collaborative Incident Consensus. Protects existing data from being
wiped by LLM `null` hallucinations, and appends long-form text.
"""
incident = get_incident(db, incident_id)
if not incident:
return None

existing = json.loads(incident.master_json)

for key, value in new_data.items():
# 1. Ignore empty/null values to protect existing data
if value is None or str(value).strip().lower() in ("null", "none", "", "n/a"):
continue

# 2. If the field exists, handle smart merging vs overwriting
if key in existing and existing[key]:
old_value = existing[key]

# Use string representation for safe comparison
old_str = str(old_value).strip() if not isinstance(old_value, list) else "\n".join(str(i) for i in old_value)
new_str = str(value).strip() if not isinstance(value, list) else "\n".join(str(i) for i in value)

# If the value is identical, do nothing
if old_str.lower() == new_str.lower():
continue

# If it's a long-form text field (Notes, Description, Narrative, Summary, etc)
long_fields = ("note", "desc", "narrative", "summary", "remark", "detail", "comment")
if any(lf in key.lower() for lf in long_fields):
# Prevent recursive appending
if new_str not in old_str:
existing[key] = f"{old_str}\n\n[UPDATE]: {new_str}"
else:
# Standard Field Correction (e.g. ID, City) - overwrite the old value
existing[key] = value
else:
# 3. Brand new field
existing[key] = value

incident.master_json = json.dumps(existing)

# Safely append the new transcript segment for true consensus history
if new_transcript and new_transcript.strip() not in incident.transcript_text:
incident.transcript_text = f"{incident.transcript_text}\n\n---\n[UPDATE]: {new_transcript.strip()}"

incident.updated_at = datetime.utcnow()
db.add(incident)
db.commit()
db.refresh(incident)
return incident
31 changes: 28 additions & 3 deletions api/main.py
Original file line number Diff line number Diff line change
@@ -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, incidents
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)
app.include_router(forms.router)
app.include_router(transcribe.router)
app.include_router(incidents.router)

if os.path.exists("mobile"):
app.mount("/mobile", StaticFiles(directory="mobile", html=True), name="mobile")
Loading