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
103 changes: 88 additions & 15 deletions app/api/routes/forms.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,31 @@
import json

import requests
from fastapi import APIRouter, Depends, File, UploadFile, Query
from fastapi.responses import StreamingResponse
from sqlmodel import Session

from app.api.deps import get_db, verify_api_key
from app.api.schemas.forms import (
FormFill,
FormFillResponse,
ModelPullRequest,
ModelsResponse,
TranscriptionResponse,
)
from app.core import paths
from app.core.config import OLLAMA_HOST, OLLAMA_MODEL, RETENTION_PERIOD_DAYS
from app.core.config import (
OLLAMA_HOST,
OLLAMA_MODEL,
OLLAMA_PULL_TIMEOUT,
RECOMMENDED_MODELS,
RETENTION_PERIOD_DAYS,
)
from app.services.whisper import call_whisper_asr
from app.core.errors.base import AppError
from app.db.repositories import get_template, get_form_submission, delete_form_submission
from app.services.form import FormService
from app.services.form_filler.exceptions import ModelNotInstalledError

router = APIRouter(prefix="/forms", tags=["forms"])

Expand All @@ -40,6 +51,12 @@ def fill_form(form: FormFill, db: Session = Depends(get_db)):
input_text=submission.input_text,
output_pdf_path=submission.output_pdf_path,
)
except ModelNotInstalledError as exc:
raise AppError(
f"{exc} Pull it first via POST /forms/pull.",
status_code=422,
error_code="MODEL_NOT_INSTALLED",
) from exc
except AppError:
raise
except Exception as e:
Expand All @@ -49,24 +66,80 @@ def fill_form(form: FormFill, db: Session = Depends(get_db)):

@router.get("/models", response_model=ModelsResponse)
def list_models():
"""List the Whisper-independent extraction models available in the local
Ollama instance, plus the configured default. Used by the Fill Form UI's
model picker. Falls back to just the default if Ollama is unreachable."""
default_model = OLLAMA_MODEL

models: list[str] = []
"""List recommended and installed models for the Fill Form model picker."""
installed_names: set[str] = set()
try:
response = requests.get(f"{OLLAMA_HOST}/api/tags", timeout=10)
response.raise_for_status()
models = [m["name"] for m in response.json().get("models", []) if m.get("name")]
except requests.exceptions.RequestException:
models = []

# Always surface the configured default, even if Ollama hasn't pulled it yet.
if default_model not in models:
models.insert(0, default_model)
payload = response.json()
if not isinstance(payload, dict) or not isinstance(payload.get("models"), list):
raise ValueError("Unexpected Ollama tags response")
for model in payload["models"]:
if not isinstance(model, dict) or not isinstance(model.get("name"), str):
raise ValueError("Unexpected Ollama tags response")
installed_names.add(model["name"])
except (requests.exceptions.RequestException, ValueError, TypeError):
pass

all_names = sorted(set(RECOMMENDED_MODELS) | installed_names)
models = [
{
"name": name,
"installed": name in installed_names,
"recommended": name in RECOMMENDED_MODELS,
}
for name in all_names
]
return ModelsResponse(current_model=OLLAMA_MODEL, models=models)


@router.post(
"/pull",
response_class=StreamingResponse,
responses={200: {"content": {"application/x-ndjson": {}}}},
)
def pull_model(req: ModelPullRequest):
"""Stream pull progress from Ollama as newline-delimited JSON."""

def generate():
saw_error = False
saw_success = False
buffer = ""

def inspect_lines(chunk: bytes) -> None:
nonlocal buffer, saw_error, saw_success
buffer += chunk.decode("utf-8")
lines = buffer.split("\n")
buffer = lines.pop() or ""
for line in lines:
if not line.strip():
continue
event = json.loads(line)
saw_error = saw_error or "error" in event
saw_success = saw_success or event.get("status") == "success"

return ModelsResponse(models=models, default=default_model)
try:
with requests.post(
f"{OLLAMA_HOST}/api/pull",
json={"name": req.model, "stream": True},
stream=True,
timeout=(10, OLLAMA_PULL_TIMEOUT),
) as response:
response.raise_for_status()
for chunk in response.iter_content(chunk_size=None):
if chunk:
inspect_lines(chunk)
yield chunk
if buffer.strip():
event = json.loads(buffer)
saw_error = saw_error or "error" in event
saw_success = saw_success or event.get("status") == "success"
if not saw_error and not saw_success:
yield (json.dumps({"status": "success"}) + "\n").encode("utf-8")
except requests.exceptions.RequestException as exc:
yield (json.dumps({"error": str(exc)}) + "\n").encode("utf-8")

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


@router.post("/transcribe", response_model=TranscriptionResponse)
Expand Down
40 changes: 38 additions & 2 deletions app/api/schemas/forms.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,37 @@
import re
from uuid import UUID

from pydantic import BaseModel, ConfigDict, field_validator

from app.api.schemas.enums import JobStatus, JobType

_MODEL_NAME_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._\-/:]*$")


def validate_model_name(value: str | None) -> str | None:
if value is None:
return None
value = value.strip()
if not value:
raise ValueError("model name cannot be empty")
if len(value) > 128:
raise ValueError("model name must be at most 128 characters")
if not _MODEL_NAME_PATTERN.fullmatch(value):
raise ValueError("model name contains invalid characters")
return value


class FormFill(BaseModel):
template_id: int
input_text: str | None = None
input_id: UUID | None = None
model: str | None = None

@field_validator("model")
@classmethod
def _validate_model_name(cls, value: str | None) -> str | None:
return validate_model_name(value)


class FormFillResponse(BaseModel):
id: int
Expand All @@ -26,9 +47,24 @@ class TranscriptionResponse(BaseModel):
text: str


class ModelInfo(BaseModel):
name: str
installed: bool
recommended: bool


class ModelsResponse(BaseModel):
models: list[str]
default: str
current_model: str
models: list[ModelInfo]


class ModelPullRequest(BaseModel):
model: str

@field_validator("model")
@classmethod
def _validate_model_name(cls, value: str) -> str:
return validate_model_name(value) or ""


class AsyncFormFill(BaseModel):
Expand Down
7 changes: 7 additions & 0 deletions app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,14 @@
OLLAMA_HOST = os.getenv("OLLAMA_HOST", "http://localhost:11434").rstrip("/")
OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "qwen2.5:1.5b")
OLLAMA_TIMEOUT = int(os.getenv("OLLAMA_TIMEOUT", "300"))
OLLAMA_PULL_TIMEOUT = int(os.getenv("OLLAMA_PULL_TIMEOUT", "120"))
WHISPER_HOST = os.getenv("WHISPER_HOST", "http://localhost:9000").rstrip("/")
RECOMMENDED_MODELS: list[str] = [
"qwen2.5:1.5b",
"qwen2.5:3b",
"qwen2.5:7b",
"llama3.2:3b",
]

# --- Celery / Redis -------------------------------------------------------
CELERY_BROKER_URL = os.getenv("CELERY_BROKER_URL", "redis://localhost:6379/0")
Expand Down
4 changes: 4 additions & 0 deletions app/services/form_filler/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
class ModelNotInstalledError(Exception):
def __init__(self, model: str):
self.model = model
super().__init__(f"Model '{model}' is not installed in Ollama.")
18 changes: 12 additions & 6 deletions app/services/form_filler/filler.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from app.core.config import OLLAMA_HOST, OLLAMA_MODEL, OLLAMA_TIMEOUT

from . import template
from .exceptions import ModelNotInstalledError
from .geometry import CHECKBOX, RADIOBUTTON, SIGNATURE, drop_null_parents, on_state, page_widgets

OPTIONS = {
Expand Down Expand Up @@ -97,12 +98,17 @@ def call_model(prompt, schema, model):
"think": False,
"options": OPTIONS,
}
response = requests.post(
OLLAMA_HOST + "/api/generate",
json=payload,
timeout=OLLAMA_TIMEOUT,
)
response.raise_for_status()
try:
response = requests.post(
OLLAMA_HOST + "/api/generate",
json=payload,
timeout=OLLAMA_TIMEOUT,
)
response.raise_for_status()
except requests.exceptions.HTTPError as exc:
if exc.response is not None and exc.response.status_code == 404:
raise ModelNotInstalledError(model) from exc
raise
return response.json().get("response", "")


Expand Down
10 changes: 10 additions & 0 deletions app/tasks/fill.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from app.db.database import get_session
from app.db.repositories import get_job_by_celery_id, get_template, update_job
from app.services.form import FormService
from app.services.form_filler.exceptions import ModelNotInstalledError

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -43,6 +44,15 @@ def fill_form_task(self, template_id: int, input_text: str, input_id_str: str, m

return {"job_id": job.job_id, "result_url": job.result_url}

except ModelNotInstalledError as e:
logger.exception("fill_form_task failed")
job = get_job_by_celery_id(session, self.request.id)
if job:
job.status = JobStatus.failed
job.error = {"error_code": "MODEL_NOT_INSTALLED", "message": str(e)}
job.updated_at = datetime.now(timezone.utc)
update_job(session, job)
raise
except Exception as e:
logger.exception("fill_form_task failed")
job = get_job_by_celery_id(session, self.request.id)
Expand Down
4 changes: 4 additions & 0 deletions contracts/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ paths:
# ── Layer 3: Form Fill ──────────────────────────────────────────
/api/v1/forms/fill:
$ref: "path/forms.yaml#/fill"
/api/v1/forms/models:
$ref: "path/forms.yaml#/models"
/api/v1/forms/pull:
$ref: "path/forms.yaml#/pull"
/api/v1/forms/jobs:
$ref: "path/forms.yaml#/async_fill"

Expand Down
87 changes: 87 additions & 0 deletions contracts/path/forms.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# Layer 3 Form Fill Endpoints
# POST /api/v1/forms/fill
# GET /api/v1/forms/models
# POST /api/v1/forms/pull
# POST /api/v1/forms/jobs

fill:
Expand Down Expand Up @@ -56,6 +58,15 @@ fill:
message: "Input 550e8400-e29b-41d4-a716-446655440001 is not ready (status: transcribing)"
detail:
status: "transcribing"
"422":
description: The requested model is not installed in Ollama
content:
application/json:
schema:
$ref: "../schemas/common.yaml#/ErrorResponse"
example:
error_code: "MODEL_NOT_INSTALLED"
message: "Model 'qwen2.5:7b' is not installed in Ollama. Pull it first via POST /forms/pull."
"500":
description: Form fill failed (LLM or PDF generation error)
content:
Expand All @@ -66,6 +77,82 @@ fill:
error_code: "FORM_FILL_ERROR"
message: "Ollama did not respond within 120 seconds"

models:
get:
operationId: listModels
summary: List available AI models
description: |
Returns the configured current model together with recommended models and
all models currently installed in Ollama. Models that are not installed
are included so clients can offer them for download.
tags:
- forms
responses:
"200":
description: Current, recommended, and installed AI models
content:
application/json:
schema:
$ref: "../schemas/form-record.yaml#/ModelsResponse"
example:
current_model: "qwen2.5:1.5b"
models:
- name: "qwen2.5:1.5b"
installed: true
recommended: true
- name: "llama3.2:3b"
installed: false
recommended: true

pull:
post:
operationId: pullModel
summary: Pull an AI model from Ollama
description: |
Starts downloading the requested Ollama model and streams progress as
newline-delimited JSON. The final line has status "success" when the
download completes. Pull failures are returned in the stream as an
object with an "error" field because the HTTP status is already committed.
tags:
- forms
requestBody:
required: true
content:
application/json:
schema:
$ref: "../schemas/form-record.yaml#/ModelPullRequest"
example:
model: "llama3.2:3b"
responses:
"200":
description: Streaming pull progress or an error object
content:
application/x-ndjson:
schema:
type: object
additionalProperties: true
examples:
progress:
summary: Pull progress event
value:
status: "pulling manifest"
completed: 1048576
total: 4194304
success:
summary: Pull completed
value:
status: "success"
error:
summary: Pull failed
value:
error: "Ollama connection refused"
"422":
description: Invalid pull request
content:
application/json:
schema:
$ref: "../schemas/common.yaml#/ErrorResponse"

async_fill:
post:
operationId: submitAsyncFormFill
Expand Down
Loading
Loading