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
30 changes: 30 additions & 0 deletions app/api/routes/forms.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
from datetime import datetime, timedelta, timezone
from pathlib import Path
import json
import requests
from fastapi import APIRouter, Depends, File, UploadFile, Query
from fastapi.responses import StreamingResponse
from sqlmodel import Session, select

from app.api.deps import get_db, verify_api_key
Expand All @@ -10,6 +12,7 @@
FormFillResponse,
ModelsResponse,
TranscriptionResponse,
ModelPullRequest,
)
from app.core.config import OLLAMA_HOST, OLLAMA_MODEL, BASE_DIR, RETENTION_PERIOD_DAYS
from app.services.whisper import call_whisper_asr
Expand Down Expand Up @@ -86,6 +89,33 @@ def list_models():
return ModelsResponse(models=models, default=default_model)


@router.post("/pull")
def pull_model(req: ModelPullRequest):
"""Stream pull progress from Ollama as NDJSON.
Each line is a JSON object with Ollama's progress fields
(status, completed, total, digest). The last line will
have status='success' when the pull completes."""

def generate():
try:
with requests.post(
f"{OLLAMA_HOST}/api/pull",
json={"name": req.model, "stream": True},
stream=True,
timeout=600,
) as r:
r.raise_for_status()
for chunk in r.iter_content(chunk_size=None):
if chunk:
yield chunk
# Signal clean completion
yield (json.dumps({"status": "success"}) + "\n").encode("utf-8")
except requests.exceptions.RequestException as e:
yield (json.dumps({"error": str(e)}) + "\n").encode("utf-8")

return StreamingResponse(generate(), media_type="application/x-ndjson")


@router.post("/transcribe", response_model=TranscriptionResponse)
def transcribe(audio: UploadFile = File(...)):
"""Forward recorded audio to the local Whisper ASR sidecar and return text.
Expand Down
6 changes: 5 additions & 1 deletion app/api/schemas/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,4 +74,8 @@ class Config:


class AsyncFormFillResponse(BaseModel):
jobs: list[AsyncJobSubmitResponse]
jobs: list[AsyncJobSubmitResponse]


class ModelPullRequest(BaseModel):
model: str
13 changes: 13 additions & 0 deletions docs/1. SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,19 @@ Check `make logs-app` for the actual error. The entrypoint runs database migrati
**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.

## AI Model Selection

FireForm allows you to choose which AI model to run during form extraction directly from the dropdown in the frontend "Fill Form" UI.

The supported recommended models are:
- `qwen2.5:1.5b` (default, lightweight)
- `qwen2.5:3b`
- `qwen2.5:7b`
- `llama3.2:3b`
- `mistral:7b`

If you select a model that is not yet installed (pulled) in your local Ollama instance, the app will automatically request the backend to download it via the `POST /api/v1/forms/pull` endpoint. During installation, form submission is temporarily disabled, and status progress is displayed. Once downloaded, the model is cached in Ollama's Docker volume for future use.

## Where to go next

- **Join our [Discord](https://discord.gg/nBv5b6kF68)** — ask questions and coordinate with other contributors
Expand Down
28 changes: 28 additions & 0 deletions tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,34 @@ def boom(*a, **k):
assert resp.status_code == 200
assert resp.json()["models"] == ["qwen2.5:1.5b"]

def test_pull_model_success(self, client, monkeypatch):
from unittest.mock import MagicMock
fake_response = MagicMock()
fake_response.raise_for_status.return_value = None
monkeypatch.setattr("app.api.routes.forms.requests.post", lambda *a, **k: fake_response)

resp = client.post(f"{API_PREFIX}/forms/pull", json={"model": "llama3.2:3b"})
assert resp.status_code == 200
assert resp.json()["status"] == "success"

def test_pull_model_failure(self, client, monkeypatch):
"""When Ollama is unreachable the stream returns 200 with an error line in the body."""
import requests
import json

def boom(*a, **k):
raise requests.exceptions.RequestException("pull failed")

monkeypatch.setattr("app.api.routes.forms.requests.post", boom)

resp = client.post(f"{API_PREFIX}/forms/pull", json={"model": "llama3.2:3b"})
assert resp.status_code == 200
# The streamed body contains an error JSON line
lines = [l for l in resp.text.splitlines() if l.strip()]
assert lines, "expected at least one NDJSON line"
last = json.loads(lines[-1])
assert "error" in last

def test_fill_form_passes_model_override(self, client, mock_controller):
"""A `model` in the request reaches Controller.fill_form but isn't persisted."""
tpl_id = self._seed_template(client, mock_controller)
Expand Down
Loading