From dbcbe958cc37916f046eb62e511dd7c896da71f7 Mon Sep 17 00:00:00 2001 From: Abhishek Kumar Date: Fri, 26 Jun 2026 12:23:42 +0530 Subject: [PATCH 1/2] Add text input layer: POST /input/text and GET /input/{input_id} (#546) --- app/api/router.py | 6 +- app/api/routes/input.py | 105 +++++++++++++++++++++++++ app/api/schemas/input.py | 45 +++++++++++ app/core/config.py | 4 + tests/test_v1_input.py | 165 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 323 insertions(+), 2 deletions(-) create mode 100644 app/api/routes/input.py create mode 100644 app/api/schemas/input.py create mode 100644 tests/test_v1_input.py diff --git a/app/api/router.py b/app/api/router.py index 962a26c..b9dd62f 100644 --- a/app/api/router.py +++ b/app/api/router.py @@ -1,6 +1,7 @@ from fastapi import APIRouter -from app.api.routes import forms, templates, weather, zipcode, jobs, system +from app.api.routes import forms, jobs, system, templates, weather, zipcode +from app.api.routes import input as input_routes from app.core.config import API_PREFIX api_router = APIRouter() @@ -9,4 +10,5 @@ api_router.include_router(system.router, prefix=API_PREFIX) api_router.include_router(jobs.router, prefix=API_PREFIX) api_router.include_router(weather.router, prefix=API_PREFIX) -api_router.include_router(zipcode.router, prefix=API_PREFIX) \ No newline at end of file +api_router.include_router(zipcode.router, prefix=API_PREFIX) +api_router.include_router(input_routes.router, prefix=API_PREFIX) \ No newline at end of file diff --git a/app/api/routes/input.py b/app/api/routes/input.py new file mode 100644 index 0000000..108121b --- /dev/null +++ b/app/api/routes/input.py @@ -0,0 +1,105 @@ +from datetime import datetime, timezone +from uuid import UUID + +from fastapi import APIRouter, Depends +from fastapi.responses import JSONResponse +from sqlmodel import Session, select + +from app.api.deps import get_db +from app.api.schemas.enums import InputStatus, InputType +from app.api.schemas.input import InputRecordResponse, TextInputRequest, TextInputResponse +from app.core.config import INPUT_POLL_INTERVAL_SECONDS +from app.core.errors.base import AppError +from app.models import Input + +router = APIRouter(prefix="/input", tags=["input"]) + + +@router.post("/text", response_model=TextInputResponse, status_code=201) +def submit_text_input(body: TextInputRequest, db: Session = Depends(get_db)): + narrative = body.narrative + + if len(narrative) > 50_000: + raise AppError( + "Narrative exceeds maximum length of 50,000 characters", + status_code=413, + error_code="NARRATIVE_TOO_LONG", + detail={"max_characters": 50_000, "received_characters": len(narrative)}, + ) + + words = narrative.split() + if len(words) < 10: + return JSONResponse( + status_code=422, + content={ + "error_code": "VALIDATION_ERROR", + "message": "Request validation failed", + "validation_errors": [ + { + "field": "narrative", + "issue": "Must contain at least 10 words", + "value": narrative, + } + ], + }, + ) + + now = datetime.now(timezone.utc) + record = Input( + input_type=InputType.text, + status=InputStatus.ready, + transcript=narrative, + character_count=len(narrative), + word_count=len(words), + station_id=body.station_id, + responder_badge=body.responder_badge, + incident_date_hint=body.incident_date_hint, + created_at=now, + updated_at=now, + ) + db.add(record) + db.commit() + db.refresh(record) + + return TextInputResponse( + input_id=record.input_id, + status=record.status, + input_type=record.input_type, + character_count=record.character_count, + word_count=record.word_count, + created_at=record.created_at, + ) + + +@router.get("/{input_id}", response_model=InputRecordResponse) +def get_input(input_id: UUID, db: Session = Depends(get_db)): + record = db.exec(select(Input).where(Input.input_id == input_id)).first() + if record is None: + raise AppError( + f"Input with ID {input_id} not found", + status_code=404, + error_code="INPUT_NOT_FOUND", + ) + + retry_after = ( + INPUT_POLL_INTERVAL_SECONDS + if record.status in (InputStatus.queued, InputStatus.transcribing) + else None + ) + return InputRecordResponse( + input_id=record.input_id, + input_type=record.input_type, + status=record.status, + transcript=record.transcript, + original_filename=record.original_filename, + audio_duration_seconds=record.audio_duration_seconds, + character_count=record.character_count, + word_count=record.word_count, + station_id=record.station_id, + responder_badge=record.responder_badge, + incident_date_hint=record.incident_date_hint, + error_detail=record.error_detail, + retry_after_seconds=retry_after, + created_at=record.created_at, + updated_at=record.updated_at, + ) diff --git a/app/api/schemas/input.py b/app/api/schemas/input.py new file mode 100644 index 0000000..ab705b5 --- /dev/null +++ b/app/api/schemas/input.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from datetime import date, datetime +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + +from app.api.schemas.enums import InputStatus, InputType + + +class TextInputRequest(BaseModel): + model_config = ConfigDict() + narrative: str = Field(min_length=20) + station_id: str | None = None + responder_badge: str | None = None + incident_date_hint: date | None = None + + +class TextInputResponse(BaseModel): + model_config = ConfigDict() + input_id: UUID + status: InputStatus + input_type: InputType + character_count: int | None = None + word_count: int | None = None + created_at: datetime | None = None + + +class InputRecordResponse(BaseModel): + model_config = ConfigDict() + input_id: UUID + input_type: InputType + status: InputStatus + transcript: str | None = None + original_filename: str | None = None + audio_duration_seconds: float | None = None + character_count: int | None = None + word_count: int | None = None + station_id: str | None = None + responder_badge: str | None = None + incident_date_hint: date | None = None + error_detail: str | None = None + retry_after_seconds: int | None = None + created_at: datetime | None = None + updated_at: datetime | None = None diff --git a/app/core/config.py b/app/core/config.py index 38db481..e0f41f8 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -54,6 +54,10 @@ # hint, not a measured backpressure value — the app has no queue-depth signal. RETRY_AFTER_SECONDS = 30 +# Polling hint returned by GET /input/{id} when a voice input is still queued +# or transcribing. Value matches the contract example (contracts/path/input.yaml). +INPUT_POLL_INTERVAL_SECONDS = 5 + # --- API Versioning ------------------------------------------------------- API_PREFIX = "/api/v1" diff --git a/tests/test_v1_input.py b/tests/test_v1_input.py new file mode 100644 index 0000000..1a00b1c --- /dev/null +++ b/tests/test_v1_input.py @@ -0,0 +1,165 @@ +"""Tests for POST /api/v1/input/text and GET /api/v1/input/{input_id}. + +Uses the shared in-memory SQLite engine from conftest.py. No Ollama or +Whisper involvement — text input is fully synchronous. +""" + +import pytest + +TEXT_URL = "/api/v1/input/text" +INPUT_URL = "/api/v1/input" + +# Narrative that passes all validation: > 20 chars, >= 10 words, << 50,000 chars. +VALID_NARRATIVE = ( + "Responded to a wildfire at Bear Creek Trailhead around 1:45 PM on July 10th. " + "Lightning strike ignited timber litter in mixed conifer and chaparral." +) + + +# --------------------------------------------------------------------------- +# POST /api/v1/input/text +# --------------------------------------------------------------------------- + +class TestSubmitTextInput: + + def test_201_returns_required_fields(self, client): + resp = client.post(TEXT_URL, json={"narrative": VALID_NARRATIVE}) + assert resp.status_code == 201 + body = resp.json() + assert body["status"] == "ready" + assert body["input_type"] == "text" + assert "input_id" in body + assert "created_at" in body + + def test_201_character_and_word_counts_match_narrative(self, client): + resp = client.post(TEXT_URL, json={"narrative": VALID_NARRATIVE}) + assert resp.status_code == 201 + body = resp.json() + assert body["character_count"] == len(VALID_NARRATIVE) + assert body["word_count"] == len(VALID_NARRATIVE.split()) + + def test_201_optional_fields_accepted_and_visible_via_get(self, client): + resp = client.post(TEXT_URL, json={ + "narrative": VALID_NARRATIVE, + "station_id": "STA-045", + "responder_badge": "FD-7842", + "incident_date_hint": "2024-07-10", + }) + assert resp.status_code == 201 + input_id = resp.json()["input_id"] + + get_resp = client.get(f"{INPUT_URL}/{input_id}") + assert get_resp.status_code == 200 + body = get_resp.json() + assert body["station_id"] == "STA-045" + assert body["responder_badge"] == "FD-7842" + assert body["incident_date_hint"] == "2024-07-10" + + def test_narrative_stored_in_transcript_field(self, client): + resp = client.post(TEXT_URL, json={"narrative": VALID_NARRATIVE}) + input_id = resp.json()["input_id"] + get_resp = client.get(f"{INPUT_URL}/{input_id}") + assert get_resp.json()["transcript"] == VALID_NARRATIVE + + def test_413_narrative_over_50000_chars(self, client): + too_long = "x" * 50_001 + resp = client.post(TEXT_URL, json={"narrative": too_long}) + assert resp.status_code == 413 + body = resp.json() + assert body["error_code"] == "NARRATIVE_TOO_LONG" + assert body["detail"]["max_characters"] == 50_000 + assert body["detail"]["received_characters"] == 50_001 + + def test_413_boundary_exactly_50000_chars_is_accepted(self, client): + # Exactly at the limit: should succeed (> 50_000 is rejected, not >=). + # Build a narrative at exactly 50,000 chars that has >= 10 words. + phrase = "fire incident response " # 23 chars + narrative = (phrase * 2175)[:50_000] # 2175*23=50025, sliced to 50000 + resp = client.post(TEXT_URL, json={"narrative": narrative}) + assert resp.status_code == 201 + + def test_422_fewer_than_10_words_matches_request_validation_shape(self, client): + # >= 20 chars so Pydantic minLength passes, but only 6 words. + short = "Fire happened at the scene today here" # 7 words, > 20 chars + resp = client.post(TEXT_URL, json={"narrative": short}) + assert resp.status_code == 422 + body = resp.json() + assert body["error_code"] == "VALIDATION_ERROR" + assert body["message"] == "Request validation failed" + errs = body["validation_errors"] + assert len(errs) == 1 + assert errs[0]["field"] == "narrative" + assert errs[0]["issue"] == "Must contain at least 10 words" + assert errs[0]["value"] == short + + def test_422_narrative_under_20_chars_triggers_pydantic_validation(self, client): + resp = client.post(TEXT_URL, json={"narrative": "Too short"}) + assert resp.status_code == 422 + body = resp.json() + assert body["error_code"] == "VALIDATION_ERROR" + assert "validation_errors" in body + + def test_422_missing_narrative_field(self, client): + resp = client.post(TEXT_URL, json={}) + assert resp.status_code == 422 + body = resp.json() + assert body["error_code"] == "VALIDATION_ERROR" + + def test_422_empty_string_narrative(self, client): + resp = client.post(TEXT_URL, json={"narrative": ""}) + assert resp.status_code == 422 + body = resp.json() + assert body["error_code"] == "VALIDATION_ERROR" + + +# --------------------------------------------------------------------------- +# GET /api/v1/input/{input_id} +# --------------------------------------------------------------------------- + +class TestGetInput: + + def _create(self, client, **kwargs): + payload = {"narrative": VALID_NARRATIVE, **kwargs} + resp = client.post(TEXT_URL, json=payload) + assert resp.status_code == 201 + return resp.json()["input_id"] + + def test_200_returns_full_record(self, client): + input_id = self._create(client) + resp = client.get(f"{INPUT_URL}/{input_id}") + assert resp.status_code == 200 + body = resp.json() + assert body["input_id"] == input_id + assert body["input_type"] == "text" + assert body["status"] == "ready" + assert body["transcript"] == VALID_NARRATIVE + assert body["character_count"] == len(VALID_NARRATIVE) + assert body["word_count"] == len(VALID_NARRATIVE.split()) + assert body["created_at"] is not None + assert body["updated_at"] is not None + + def test_200_voice_only_fields_are_null_for_text_input(self, client): + input_id = self._create(client) + body = client.get(f"{INPUT_URL}/{input_id}").json() + assert body["original_filename"] is None + assert body["audio_duration_seconds"] is None + assert body["error_detail"] is None + + def test_200_retry_after_is_null_for_ready_input(self, client): + input_id = self._create(client) + body = client.get(f"{INPUT_URL}/{input_id}").json() + assert body.get("retry_after_seconds") is None + + def test_404_unknown_uuid_returns_app_error_envelope(self, client): + fake_id = "00000000-0000-0000-0000-000000000000" + resp = client.get(f"{INPUT_URL}/{fake_id}") + assert resp.status_code == 404 + body = resp.json() + assert body["error_code"] == "INPUT_NOT_FOUND" + assert fake_id in body["message"] + + def test_404_message_contains_the_requested_id(self, client): + target = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + resp = client.get(f"{INPUT_URL}/{target}") + assert resp.status_code == 404 + assert target in resp.json()["message"] From 3a79a0d6618ed9bafe91b5bd3b7a91561dfecd76 Mon Sep 17 00:00:00 2001 From: Abhishek Kumar Date: Sat, 27 Jun 2026 01:01:22 +0530 Subject: [PATCH 2/2] Refactor text input: service layer, repository, reuse validation handler --- app/api/router.py | 5 ++- app/api/routes/input.py | 68 ++++++++++++++++------------------------ app/api/schemas/input.py | 5 +-- app/db/repositories.py | 17 +++++++++- app/services/input.py | 30 ++++++++++++++++++ 5 files changed, 76 insertions(+), 49 deletions(-) create mode 100644 app/services/input.py diff --git a/app/api/router.py b/app/api/router.py index b9dd62f..cc59847 100644 --- a/app/api/router.py +++ b/app/api/router.py @@ -1,7 +1,6 @@ from fastapi import APIRouter -from app.api.routes import forms, jobs, system, templates, weather, zipcode -from app.api.routes import input as input_routes +from app.api.routes import forms, input, jobs, system, templates, weather, zipcode from app.core.config import API_PREFIX api_router = APIRouter() @@ -11,4 +10,4 @@ api_router.include_router(jobs.router, prefix=API_PREFIX) api_router.include_router(weather.router, prefix=API_PREFIX) api_router.include_router(zipcode.router, prefix=API_PREFIX) -api_router.include_router(input_routes.router, prefix=API_PREFIX) \ No newline at end of file +api_router.include_router(input.router, prefix=API_PREFIX) \ No newline at end of file diff --git a/app/api/routes/input.py b/app/api/routes/input.py index 108121b..009bdf0 100644 --- a/app/api/routes/input.py +++ b/app/api/routes/input.py @@ -1,65 +1,51 @@ -from datetime import datetime, timezone from uuid import UUID from fastapi import APIRouter, Depends -from fastapi.responses import JSONResponse -from sqlmodel import Session, select +from fastapi.exceptions import RequestValidationError +from sqlmodel import Session from app.api.deps import get_db -from app.api.schemas.enums import InputStatus, InputType +from app.api.schemas.enums import InputStatus from app.api.schemas.input import InputRecordResponse, TextInputRequest, TextInputResponse from app.core.config import INPUT_POLL_INTERVAL_SECONDS from app.core.errors.base import AppError -from app.models import Input +from app.db.repositories import create_input, get_input as repo_get_input +from app.services.input import InputService router = APIRouter(prefix="/input", tags=["input"]) @router.post("/text", response_model=TextInputResponse, status_code=201) def submit_text_input(body: TextInputRequest, db: Session = Depends(get_db)): - narrative = body.narrative - - if len(narrative) > 50_000: + if len(body.narrative) > 50_000: raise AppError( "Narrative exceeds maximum length of 50,000 characters", status_code=413, error_code="NARRATIVE_TOO_LONG", - detail={"max_characters": 50_000, "received_characters": len(narrative)}, + detail={"max_characters": 50_000, "received_characters": len(body.narrative)}, ) - words = narrative.split() - if len(words) < 10: - return JSONResponse( - status_code=422, - content={ - "error_code": "VALIDATION_ERROR", - "message": "Request validation failed", - "validation_errors": [ - { - "field": "narrative", - "issue": "Must contain at least 10 words", - "value": narrative, - } - ], - }, + svc = InputService() + try: + record = svc.build_text_input( + narrative=body.narrative, + station_id=body.station_id, + responder_badge=body.responder_badge, + incident_date_hint=body.incident_date_hint, + ) + except ValueError as exc: + raise RequestValidationError( + errors=[ + { + "loc": ("body", "narrative"), + "msg": str(exc), + "input": body.narrative, + "type": "value_error", + } + ] ) - now = datetime.now(timezone.utc) - record = Input( - input_type=InputType.text, - status=InputStatus.ready, - transcript=narrative, - character_count=len(narrative), - word_count=len(words), - station_id=body.station_id, - responder_badge=body.responder_badge, - incident_date_hint=body.incident_date_hint, - created_at=now, - updated_at=now, - ) - db.add(record) - db.commit() - db.refresh(record) + record = create_input(db, record) return TextInputResponse( input_id=record.input_id, @@ -73,7 +59,7 @@ def submit_text_input(body: TextInputRequest, db: Session = Depends(get_db)): @router.get("/{input_id}", response_model=InputRecordResponse) def get_input(input_id: UUID, db: Session = Depends(get_db)): - record = db.exec(select(Input).where(Input.input_id == input_id)).first() + record = repo_get_input(db, input_id) if record is None: raise AppError( f"Input with ID {input_id} not found", diff --git a/app/api/schemas/input.py b/app/api/schemas/input.py index ab705b5..cc37daf 100644 --- a/app/api/schemas/input.py +++ b/app/api/schemas/input.py @@ -3,13 +3,12 @@ from datetime import date, datetime from uuid import UUID -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, Field from app.api.schemas.enums import InputStatus, InputType class TextInputRequest(BaseModel): - model_config = ConfigDict() narrative: str = Field(min_length=20) station_id: str | None = None responder_badge: str | None = None @@ -17,7 +16,6 @@ class TextInputRequest(BaseModel): class TextInputResponse(BaseModel): - model_config = ConfigDict() input_id: UUID status: InputStatus input_type: InputType @@ -27,7 +25,6 @@ class TextInputResponse(BaseModel): class InputRecordResponse(BaseModel): - model_config = ConfigDict() input_id: UUID input_type: InputType status: InputStatus diff --git a/app/db/repositories.py b/app/db/repositories.py index d7445ff..9186d61 100644 --- a/app/db/repositories.py +++ b/app/db/repositories.py @@ -1,5 +1,8 @@ +from uuid import UUID + from sqlmodel import Session, select -from app.models import Template, FormSubmission, Job + +from app.models import Template, FormSubmission, Job, Input # Templates def create_template(session: Session, template: Template) -> Template: @@ -66,3 +69,15 @@ def delete_form_submission(session: Session, submission: FormSubmission) -> None session.delete(submission) session.commit() + +# Inputs +def create_input(session: Session, input_obj: Input) -> Input: + session.add(input_obj) + session.commit() + session.refresh(input_obj) + return input_obj + + +def get_input(session: Session, input_id: UUID) -> Input | None: + return session.get(Input, input_id) + diff --git a/app/services/input.py b/app/services/input.py new file mode 100644 index 0000000..b04868c --- /dev/null +++ b/app/services/input.py @@ -0,0 +1,30 @@ +from datetime import date, datetime, timezone + +from app.api.schemas.enums import InputStatus, InputType +from app.models import Input + + +class InputService: + def build_text_input( + self, + narrative: str, + station_id: str | None = None, + responder_badge: str | None = None, + incident_date_hint: date | None = None, + ) -> Input: + words = narrative.split() + if len(words) < 10: + raise ValueError("Must contain at least 10 words") + now = datetime.now(timezone.utc) + return Input( + input_type=InputType.text, + status=InputStatus.ready, + transcript=narrative, + character_count=len(narrative), + word_count=len(words), + station_id=station_id, + responder_badge=responder_badge, + incident_date_hint=incident_date_hint, + created_at=now, + updated_at=now, + )