Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
77304f2
Remove Pipeline C-only contract (extraction, incidents, reporting) fr…
abhishek-8081 Aug 2, 2026
1dfc9fa
Merge pull request #645 from abhishek-8081/issue-b-contract-reshape
marcvergees Aug 2, 2026
edb754a
Reshape templates & forms contract to Pipeline B
abhishek-8081 Aug 3, 2026
08fba68
Merge pull request #646 from abhishek-8081/issue-b-forms-templates-re…
marcvergees Aug 3, 2026
dd9b88d
Wire fill flow to accept input_id and use stored transcript (#638)
abhishek-8081 Aug 5, 2026
8441182
Merge pull request #649 from abhishek-8081/issue-638-fill-input-id
marcvergees Aug 5, 2026
a3e0601
Updated and pinned ruff latest version. Created ruff.toml
chetanr25 Aug 5, 2026
39ee47e
ci: run lint and tests on development-* PR bases
chetanr25 Aug 5, 2026
5557f03
Link FormSubmission to Input by foreign key (#639)
abhishek-8081 Aug 7, 2026
e7b63a3
Merge pull request #651 from abhishek-8081/issue-639-formsubmission-fk
marcvergees Aug 7, 2026
6f1a6b7
Apply routes->services->repositories layering to forms (#640, part 1)
abhishek-8081 Aug 8, 2026
27c94f9
Merge pull request #652 from abhishek-8081/issue-640-forms-templates-…
marcvergees Aug 8, 2026
56ccc0f
Apply routes->services->repositories layering to templates (#640, par…
abhishek-8081 Aug 8, 2026
c07b7c7
Merge pull request #653 from abhishek-8081/issue-640-templates-layering
marcvergees Aug 8, 2026
0735df5
Route Controller through FormService in the async fill task (#641)
abhishek-8081 Aug 9, 2026
8b7a49b
Merge pull request #655 from abhishek-8081/issue-641-controller-throu…
marcvergees Aug 9, 2026
7d79007
Match PDF fields by name instead of position in Filler (#642)
abhishek-8081 Aug 9, 2026
2923f11
Merge pull request #656 from abhishek-8081/issue-642-filler-name-matc…
marcvergees Aug 9, 2026
a419d96
Consolidate duplicated path/PDF helpers into shared leaf modules (#654)
abhishek-8081 Aug 15, 2026
491aa49
Merge pull request #670 from abhishek-8081/issue-654-consolidate-path…
marcvergees Aug 15, 2026
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
4 changes: 2 additions & 2 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ on:
push:
branches: [main,development]
pull_request:
branches: [main,development]
branches: [main, development, 'development-*']

jobs:
lint:
Expand All @@ -23,7 +23,7 @@ jobs:
uses: astral-sh/setup-uv@v6

- name: Install linter
run: uv pip install --system ruff
run: uv pip install --system ruff==0.16.1

- name: Run linter
run: |
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ on:
- 'alembic.ini'
- '.github/workflows/tests.yml'
pull_request:
branches: [main,development]
branches: [main, development, 'development-*']
paths:
- '**.py'
- 'requirements.txt'
Expand Down
42 changes: 42 additions & 0 deletions alembic/versions/003_formsubmission_input_id_fk.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
"""formsubmission.input_id FK to inputs.

Revision ID: 003
Revises: 002
Create Date: 2026-08-07

Adds a nullable input_id FK on formsubmission -> inputs.input_id so submissions
can link to the Input they were filled from, instead of only duplicating the
transcript into input_text. input_text is kept for now (read by the
/forms/submissions and analytics endpoints); dropping it is a deferred
follow-up once those readers move to the FK.
"""

from collections.abc import Sequence

from alembic import op
import sqlalchemy as sa

revision: str = "003"
down_revision: str | None = "002"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None


def upgrade() -> None:
# batch_alter_table: SQLite can't ALTER a table to add a FK constraint in
# place (no ALTER-constraint support), so this goes through its
# copy-and-move strategy. On Postgres it emits a plain ALTER TABLE.
with op.batch_alter_table("formsubmission") as batch_op:
batch_op.add_column(sa.Column("input_id", sa.Uuid(), nullable=True))
batch_op.create_foreign_key(
"fk_formsubmission_input_id_inputs",
"inputs",
["input_id"],
["input_id"],
)


def downgrade() -> None:
with op.batch_alter_table("formsubmission") as batch_op:
batch_op.drop_constraint("fk_formsubmission_input_id_inputs", type_="foreignkey")
batch_op.drop_column("input_id")
139 changes: 13 additions & 126 deletions app/api/routes/forms.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
from datetime import datetime, timedelta, timezone
from pathlib import Path
import requests
from fastapi import APIRouter, Depends, File, UploadFile, Query
from sqlmodel import Session, select
from sqlmodel import Session

from app.api.deps import get_db, verify_api_key
from app.api.schemas.forms import (
Expand All @@ -11,30 +9,12 @@
ModelsResponse,
TranscriptionResponse,
)
from app.core.config import OLLAMA_HOST, OLLAMA_MODEL, BASE_DIR, RETENTION_PERIOD_DAYS
from app.core import paths
from app.core.config import OLLAMA_HOST, OLLAMA_MODEL, RETENTION_PERIOD_DAYS
from app.services.whisper import call_whisper_asr
from app.core.errors.base import AppError
from app.db.repositories import create_form, get_template, get_form_submission, delete_form_submission
from app.models import FormSubmission, Template
from app.services.controller import Controller

PROJECT_ROOT = BASE_DIR

def _resolve_project_file(file_path: str) -> Path:
raw_path = (file_path or "").strip()
if not raw_path:
raise AppError("Path is required", status_code=400)

candidate = Path(raw_path)
if not candidate.is_absolute():
candidate = (PROJECT_ROOT / candidate).resolve()
else:
candidate = candidate.resolve()

if candidate != PROJECT_ROOT and PROJECT_ROOT not in candidate.parents:
raise AppError("Path must be inside the project", status_code=400)

return candidate
from app.db.repositories import get_template, get_form_submission, delete_form_submission
from app.services.form import FormService

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

Expand All @@ -46,20 +26,11 @@ def fill_form(form: FormFill, db: Session = Depends(get_db)):
if not fetched_template:
raise AppError("Template not found", status_code=404, error_code="TEMPLATE_NOT_FOUND")

controller = Controller()
svc = FormService()
try:
path = controller.fill_form(
user_input=form.input_text,
fields=fetched_template.fields,
pdf_form_path=fetched_template.pdf_path,
model=form.model,
)

# `model` is a runtime override, not a column — keep it out of the DB row.
submission = FormSubmission(
**form.model_dump(exclude={"model"}), output_pdf_path=path
)
return create_form(db, submission)
return svc.fill_form(db, template=fetched_template, input_id=form.input_id, model=form.model)
except AppError:
raise
except Exception as e:
raise AppError(str(e), status_code=500, error_code="FORM_FILL_ERROR")

Expand Down Expand Up @@ -117,7 +88,7 @@ def delete_submission_endpoint(submission_id: int, db: Session = Depends(get_db)

if sub.output_pdf_path:
try:
resolved_out = _resolve_project_file(sub.output_pdf_path)
resolved_out = paths._resolve_project_file(sub.output_pdf_path)
if resolved_out.exists() and resolved_out.is_file():
resolved_out.unlink()
except Exception:
Expand All @@ -130,99 +101,15 @@ def delete_submission_endpoint(submission_id: int, db: Session = Depends(get_db)
@router.post("/purge", dependencies=[Depends(verify_api_key)])
def purge_submissions_endpoint(days: int = Query(default=None), db: Session = Depends(get_db)):
retention_days = days if days is not None else RETENTION_PERIOD_DAYS
cutoff_date = datetime.now(timezone.utc) - timedelta(days=retention_days)

statement = select(FormSubmission).where(FormSubmission.created_at < cutoff_date)
submissions = list(db.exec(statement))

purged_count = 0
for sub in submissions:
if sub.output_pdf_path:
try:
resolved_out = _resolve_project_file(sub.output_pdf_path)
if resolved_out.exists() and resolved_out.is_file():
resolved_out.unlink()
except Exception:
pass
delete_form_submission(db, sub)
purged_count += 1

purged_count = FormService().purge_submissions(db, retention_days)
return {"status": "success", "purged_count": purged_count, "retention_days_used": retention_days}



@router.get("/submissions")
def get_submissions(db: Session = Depends(get_db)):
from sqlmodel import select
statement = (
select(FormSubmission, Template.name)
.join(Template, FormSubmission.template_id == Template.id, isouter=True)
.order_by(FormSubmission.created_at.desc(), FormSubmission.id.desc())
)
results = db.exec(statement).all()
return [
{
"id": sub.id,
"template_id": sub.template_id,
"template_name": name or "Unknown Template",
"input_text": sub.input_text,
"output_pdf_path": sub.output_pdf_path,
"created_at": sub.created_at.isoformat() if sub.created_at else None,
}
for sub, name in results
]
return FormService().list_submissions(db)


@router.get("/submissions/analytics")
def get_submissions_analytics(db: Session = Depends(get_db)):
from collections import Counter
import re
from sqlmodel import select

statement = select(FormSubmission, Template.name).join(
Template, FormSubmission.template_id == Template.id, isouter=True
)
results = db.exec(statement).all()

total_submissions = len(results)

template_counts = Counter()
daily_counts = Counter()
words = []

stopwords = {
"the", "and", "a", "of", "to", "in", "is", "that", "it", "was", "for", "on",
"as", "with", "by", "at", "an", "be", "this", "are", "from", "or", "have",
"has", "had", "but", "not", "he", "she", "they", "we", "i", "you", "my", "his",
"her", "their", "our", "me", "him", "them", "us", "about", "there", "their",
"were", "been", "would", "could", "should", "will", "can", "no", "yes", "any",
"so", "very", "patient", "presents", "with", "reported", "history", "shows",
"left", "right", "pain", "due", "after", "before", "emergency", "department",
"medical", "clinical"
}

for sub, name in results:
template_name = name or "Unknown Template"
template_counts[template_name] += 1

if sub.created_at:
date_str = sub.created_at.strftime("%Y-%m-%d")
daily_counts[date_str] += 1

if sub.input_text:
found_words = re.findall(r"\b[a-zA-Z]{3,15}\b", sub.input_text.lower())
for w in found_words:
if w not in stopwords:
words.append(w)

sorted_daily = [{"date": k, "count": v} for k, v in sorted(daily_counts.items())]
sorted_templates = [{"template_name": k, "count": v} for k, v in template_counts.most_common()]
common_terms = [{"word": k, "count": v} for k, v in Counter(words).most_common(12)]

return {
"total_submissions": total_submissions,
"by_template": sorted_templates,
"by_date": sorted_daily,
"common_terms": common_terms,
}

return FormService().get_analytics(db)
7 changes: 5 additions & 2 deletions app/api/routes/jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from app.core.errors.base import AppError
from app.db.repositories import create_job, get_job_by_uuid, get_template
from app.models import Job
from app.services.input import InputService
from app.tasks.fill import fill_form_task

router = APIRouter(tags=["jobs"])
Expand Down Expand Up @@ -39,14 +40,16 @@ def submit_async_form_fill(form: AsyncFormFill, db: Session = Depends(get_db)):
if not get_template(db, tid):
raise AppError(f"Template {tid} not found", status_code=404)

transcript = InputService().resolve_transcript(db, form.input_id)

jobs: list[AsyncJobSubmitResponse] = []
for tid in form.template_ids:
result = fill_form_task.delay(tid, form.input_text, form.model)
result = fill_form_task.delay(tid, transcript, str(form.input_id), form.model)
job = Job(
celery_task_id=result.id,
job_type="form_generation",
template_id=tid,
input_text=form.input_text,
input_text=transcript,
status="queued",
model=form.model,
)
Expand Down
Loading