diff --git a/app/api/routes/forms.py b/app/api/routes/forms.py index be16029..caab2a7 100644 --- a/app/api/routes/forms.py +++ b/app/api/routes/forms.py @@ -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 @@ -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 @@ -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. diff --git a/app/api/schemas/forms.py b/app/api/schemas/forms.py index 155b14f..5acdadc 100644 --- a/app/api/schemas/forms.py +++ b/app/api/schemas/forms.py @@ -74,4 +74,8 @@ class Config: class AsyncFormFillResponse(BaseModel): - jobs: list[AsyncJobSubmitResponse] \ No newline at end of file + jobs: list[AsyncJobSubmitResponse] + + +class ModelPullRequest(BaseModel): + model: str \ No newline at end of file diff --git a/docs/1. SETUP.md b/docs/1. SETUP.md index 30d2aee..23d3ecc 100644 --- a/docs/1. SETUP.md +++ b/docs/1. SETUP.md @@ -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 diff --git a/tests/test_api.py b/tests/test_api.py index 32104ae..0b28e79 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -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)