Skip to content
Merged
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
120 changes: 10 additions & 110 deletions app/api/routes/forms.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
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 @@ -14,10 +13,8 @@
from app.core.config import OLLAMA_HOST, OLLAMA_MODEL, BASE_DIR, 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
from app.services.input import InputService
from app.db.repositories import get_template, get_form_submission, delete_form_submission
from app.services.form import FormService

PROJECT_ROOT = BASE_DIR

Expand Down Expand Up @@ -47,24 +44,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")

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

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

submission = FormSubmission(
template_id=form.template_id,
input_id=form.input_id,
input_text=transcript,
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 @@ -135,99 +119,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)
22 changes: 22 additions & 0 deletions app/db/repositories.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from datetime import datetime
from uuid import UUID

from sqlmodel import Session, select
Expand Down Expand Up @@ -27,6 +28,27 @@ def create_form(session: Session, form: FormSubmission) -> FormSubmission:
return form


def get_submissions(session: Session) -> list[tuple[FormSubmission, str | None]]:
statement = (
select(FormSubmission, Template.name)
.join(Template, FormSubmission.template_id == Template.id, isouter=True)
.order_by(FormSubmission.created_at.desc(), FormSubmission.id.desc())
)
return list(session.exec(statement).all())


def get_submissions_with_template(session: Session) -> list[tuple[FormSubmission, str | None]]:
statement = select(FormSubmission, Template.name).join(
Template, FormSubmission.template_id == Template.id, isouter=True
)
return list(session.exec(statement).all())


def get_submissions_before(session: Session, cutoff: datetime) -> list[FormSubmission]:
statement = select(FormSubmission).where(FormSubmission.created_at < cutoff)
return list(session.exec(statement))


# Jobs
def create_job(session: Session, job: Job) -> Job:
session.add(job)
Expand Down
142 changes: 142 additions & 0 deletions app/services/form.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import re
from collections import Counter
from datetime import datetime, timedelta, timezone
from pathlib import Path
from uuid import UUID

from sqlmodel import Session

from app.core.config import BASE_DIR
from app.core.errors.base import AppError
from app.db.repositories import (
create_form,
delete_form_submission,
get_submissions,
get_submissions_before,
get_submissions_with_template,
)
from app.models import FormSubmission, Template
from app.services.controller import Controller
from app.services.input import InputService

PROJECT_ROOT = BASE_DIR

_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"
}


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


class FormService:
def __init__(self):
self.controller = Controller()
self.input_service = InputService()

def fill_form(
self, session: Session, template: Template, input_id: UUID, model: str | None = None
) -> FormSubmission:
transcript = self.input_service.resolve_transcript(session, input_id)

path = self.controller.fill_form(
user_input=transcript,
fields=template.fields,
pdf_form_path=template.pdf_path,
model=model,
)

submission = FormSubmission(
template_id=template.id,
input_id=input_id,
input_text=transcript,
output_pdf_path=path,
)
return create_form(session, submission)

def list_submissions(self, session: Session) -> list[dict]:
results = get_submissions(session)
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
]

def get_analytics(self, session: Session) -> dict:
results = get_submissions_with_template(session)

total_submissions = len(results)

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

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,
}

def purge_submissions(self, session: Session, retention_days: int) -> int:
cutoff_date = datetime.now(timezone.utc) - timedelta(days=retention_days)
submissions = get_submissions_before(session, cutoff_date)

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(session, sub)
purged_count += 1

return purged_count
2 changes: 1 addition & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ def pdf_upload(pdf_bytes):
def mock_controller():
"""Patch Controller so create_template / fill_form don't touch the FS or LLM."""
with patch("app.api.routes.templates.Controller") as tpl_cls, \
patch("app.api.routes.forms.Controller") as form_cls:
patch("app.services.form.Controller") as form_cls:
tpl_instance = MagicMock()
tpl_instance.create_template.return_value = "src/inputs/test_template.pdf"
tpl_cls.return_value = tpl_instance
Expand Down
2 changes: 1 addition & 1 deletion tests/test_deletion.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ def test_purge_nothing_to_remove(self, client, db):
assert resp.json()["purged_count"] == 0

def test_purge_removes_output_pdf_file(self, client, db, tmp_path, monkeypatch):
monkeypatch.setattr("app.api.routes.forms.PROJECT_ROOT", tmp_path)
monkeypatch.setattr("app.services.form.PROJECT_ROOT", tmp_path)
out_pdf = tmp_path / "old_filled.pdf"
out_pdf.write_bytes(b"%PDF-1.4")

Expand Down
Loading