diff --git a/app/api/routes/forms.py b/app/api/routes/forms.py index 601f39d..0444165 100644 --- a/app/api/routes/forms.py +++ b/app/api/routes/forms.py @@ -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 ( @@ -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 @@ -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") @@ -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) diff --git a/app/db/repositories.py b/app/db/repositories.py index 0935c13..feed61f 100644 --- a/app/db/repositories.py +++ b/app/db/repositories.py @@ -1,3 +1,4 @@ +from datetime import datetime from uuid import UUID from sqlmodel import Session, select @@ -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) diff --git a/app/services/form.py b/app/services/form.py new file mode 100644 index 0000000..e0f56b8 --- /dev/null +++ b/app/services/form.py @@ -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 diff --git a/tests/conftest.py b/tests/conftest.py index 62fae51..12e8668 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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 diff --git a/tests/test_deletion.py b/tests/test_deletion.py index 0d036dc..51fb920 100644 --- a/tests/test_deletion.py +++ b/tests/test_deletion.py @@ -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")