Skip to content

Commit c156224

Browse files
committed
Merge remote-tracking branch 'upstream/development' into optimisations
2 parents 501d837 + 42b141f commit c156224

16 files changed

Lines changed: 422 additions & 11 deletions

File tree

Makefile

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ help:
2727
@echo "make logs - Stream all container logs"
2828
@echo "make logs-app - Stream app container logs"
2929
@echo "make logs-ollama - Stream Ollama container logs"
30+
@echo "make logs-worker - Stream Celery worker logs"
3031
@echo "make shell - Open shell in running app container"
3132
@echo "make pull-model - Pull Ollama model from .env.dev ($(OLLAMA_MODEL))"
3233
@echo "make test - Run test suite"
@@ -90,6 +91,9 @@ logs-app:
9091
logs-ollama:
9192
@$(COMPOSE) logs -f ollama
9293

94+
logs-worker:
95+
@$(COMPOSE) logs -f celery-worker
96+
9397
shell:
9498
@$(COMPOSE) exec app /bin/sh
9599

app/api/router.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,15 @@
99

1010
from fastapi import APIRouter
1111

12-
from app.api.routes import forms, templates
12+
from app.api.routes import forms, jobs, templates
1313
from app.api.v1.router import v1_router
1414
from app.api.routes import forms, templates, weather, zipcode
1515

1616
api_router = APIRouter()
1717
api_router.include_router(templates.router)
1818
api_router.include_router(forms.router)
1919
api_router.include_router(v1_router)
20+
api_router.include_router(jobs.router)
2021

2122
api_router.include_router(weather.router)
22-
api_router.include_router(zipcode.router)
23+
api_router.include_router(zipcode.router)

app/api/routes/jobs.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
from fastapi import APIRouter, Depends
2+
from sqlmodel import Session
3+
4+
from app.api.deps import get_db
5+
from app.api.schemas.forms import (
6+
AsyncFormFill,
7+
AsyncFormFillResponse,
8+
AsyncJobSubmitResponse,
9+
JobResponse,
10+
)
11+
from app.core.errors.base import AppError
12+
from app.db.repositories import create_job, get_job_by_uuid, get_template
13+
from app.models import Job
14+
from app.tasks.fill import fill_form_task
15+
16+
router = APIRouter(tags=["jobs"])
17+
18+
19+
@router.get("/jobs/{job_id}", response_model=JobResponse)
20+
def get_job_status(job_id: str, db: Session = Depends(get_db)):
21+
job = get_job_by_uuid(db, job_id)
22+
if not job:
23+
raise AppError("Job not found", status_code=404)
24+
return JobResponse(
25+
job_id=job.job_id,
26+
job_type=job.job_type,
27+
status=job.status,
28+
progress_percent=job.progress_percent,
29+
result_url=job.result_url,
30+
error=job.error,
31+
created_at=job.created_at.isoformat() if job.created_at else None,
32+
updated_at=job.updated_at.isoformat() if job.updated_at else None,
33+
)
34+
35+
36+
@router.post("/forms/jobs", response_model=AsyncFormFillResponse)
37+
def submit_async_form_fill(form: AsyncFormFill, db: Session = Depends(get_db)):
38+
for tid in form.template_ids:
39+
if not get_template(db, tid):
40+
raise AppError(f"Template {tid} not found", status_code=404)
41+
42+
jobs: list[AsyncJobSubmitResponse] = []
43+
for tid in form.template_ids:
44+
result = fill_form_task.delay(tid, form.input_text, form.model)
45+
job = Job(
46+
celery_task_id=result.id,
47+
job_type="form_generation",
48+
template_id=tid,
49+
input_text=form.input_text,
50+
status="queued",
51+
model=form.model,
52+
)
53+
job = create_job(db, job)
54+
jobs.append(AsyncJobSubmitResponse(
55+
job_id=job.job_id,
56+
status=job.status,
57+
poll_url=f"/api/v1/jobs/{job.job_id}",
58+
))
59+
60+
return AsyncFormFillResponse(jobs=jobs)

app/api/schemas/forms.py

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
from pydantic import BaseModel, field_validator
22

3+
34
class FormFill(BaseModel):
45
template_id: int
56
input_text: str
6-
# Optional Ollama model override for this fill; falls back to OLLAMA_MODEL.
77
model: str | None = None
88

99
@field_validator("input_text")
@@ -29,4 +29,49 @@ class TranscriptionResponse(BaseModel):
2929

3030
class ModelsResponse(BaseModel):
3131
models: list[str]
32-
default: str
32+
default: str
33+
34+
35+
class AsyncFormFill(BaseModel):
36+
template_ids: list[int]
37+
input_text: str
38+
model: str | None = None
39+
40+
@field_validator("input_text")
41+
def validate_input_text(cls, value):
42+
if not value or not value.strip():
43+
raise ValueError("Input text cannot be empty")
44+
return value
45+
46+
@field_validator("template_ids")
47+
def validate_template_ids(cls, value):
48+
if not value:
49+
raise ValueError("template_ids cannot be empty")
50+
return value
51+
52+
53+
class JobResponse(BaseModel):
54+
job_id: str
55+
job_type: str
56+
status: str
57+
progress_percent: int = 0
58+
result_url: str | None = None
59+
error: dict | None = None
60+
created_at: str | None = None
61+
updated_at: str | None = None
62+
63+
class Config:
64+
from_attributes = True
65+
66+
67+
class AsyncJobSubmitResponse(BaseModel):
68+
job_id: str
69+
status: str
70+
poll_url: str
71+
72+
class Config:
73+
from_attributes = True
74+
75+
76+
class AsyncFormFillResponse(BaseModel):
77+
jobs: list[AsyncJobSubmitResponse]

app/core/celery.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
from celery import Celery
2+
3+
from app.core.config import CELERY_BROKER_URL, CELERY_RESULT_BACKEND
4+
5+
celery_app = Celery(
6+
"fireform",
7+
broker=CELERY_BROKER_URL,
8+
backend=CELERY_RESULT_BACKEND,
9+
)
10+
11+
celery_app.conf.update(
12+
task_serializer="json",
13+
result_serializer="json",
14+
accept_content=["json"],
15+
task_track_started=True,
16+
result_expires=86400,
17+
)
18+
19+
celery_app.conf.include = ["app.tasks.fill"]

app/core/config.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@
3737
OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "qwen2.5:1.5b")
3838
WHISPER_HOST = os.getenv("WHISPER_HOST", "http://localhost:9000").rstrip("/")
3939

40+
# --- Celery / Redis -------------------------------------------------------
41+
CELERY_BROKER_URL = os.getenv("CELERY_BROKER_URL", "redis://localhost:6379/0")
42+
CELERY_RESULT_BACKEND = os.getenv("CELERY_RESULT_BACKEND", "redis://localhost:6379/0")
43+
4044
# --- CORS -----------------------------------------------------------------
4145
_DEFAULT_ORIGINS = "http://127.0.0.1:5173,http://localhost:5173"
4246
ALLOWED_ORIGINS = [

app/db/repositories.py

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from sqlmodel import Session, select
2-
from app.models import Template, FormSubmission
2+
from app.models import Template, FormSubmission, Job
33

44
# Templates
55
def create_template(session: Session, template: Template) -> Template:
@@ -22,3 +22,32 @@ def create_form(session: Session, form: FormSubmission) -> FormSubmission:
2222
session.commit()
2323
session.refresh(form)
2424
return form
25+
26+
27+
# Jobs
28+
def create_job(session: Session, job: Job) -> Job:
29+
session.add(job)
30+
session.commit()
31+
session.refresh(job)
32+
return job
33+
34+
35+
def get_job(session: Session, job_id: int) -> Job | None:
36+
return session.get(Job, job_id)
37+
38+
39+
def get_job_by_uuid(session: Session, job_uuid: str) -> Job | None:
40+
statement = select(Job).where(Job.job_id == job_uuid)
41+
return session.exec(statement).first()
42+
43+
44+
def get_job_by_celery_id(session: Session, celery_task_id: str) -> Job | None:
45+
statement = select(Job).where(Job.celery_task_id == celery_task_id)
46+
return session.exec(statement).first()
47+
48+
49+
def update_job(session: Session, job: Job) -> Job:
50+
session.add(job)
51+
session.commit()
52+
session.refresh(job)
53+
return job

app/models/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
"""ORM models. Import from here: `from app.models import Template`."""
22

3-
from app.models.models import FormSubmission, Template
3+
from app.models.models import FormSubmission, Job, Template
44

5-
__all__ = ["Template", "FormSubmission"]
5+
__all__ = ["Template", "FormSubmission", "Job"]

app/models/models.py

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1-
from sqlmodel import SQLModel, Field
1+
import uuid as uuid_mod
2+
23
from sqlalchemy import Column, JSON
4+
from sqlmodel import SQLModel, Field
35
from datetime import datetime, timezone
46

57

@@ -16,4 +18,20 @@ class FormSubmission(SQLModel, table=True):
1618
template_id: int = Field(foreign_key="template.id")
1719
input_text: str
1820
output_pdf_path: str
19-
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
21+
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
22+
23+
24+
class Job(SQLModel, table=True):
25+
id: int | None = Field(default=None, primary_key=True)
26+
job_id: str = Field(default_factory=lambda: str(uuid_mod.uuid4()), index=True, unique=True)
27+
celery_task_id: str = Field(index=True)
28+
job_type: str = Field(default="form_generation")
29+
template_id: int | None = Field(default=None, foreign_key="template.id")
30+
input_text: str | None = None
31+
status: str = Field(default="queued")
32+
progress_percent: int = Field(default=0)
33+
result_url: str | None = None
34+
error: dict | None = Field(default=None, sa_column=Column(JSON))
35+
model: str | None = None
36+
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
37+
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))

app/tasks/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)