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
5 changes: 3 additions & 2 deletions app/api/router.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from fastapi import APIRouter

from app.api.routes import forms, templates, weather, zipcode, jobs, system
from app.api.routes import forms, input, jobs, system, templates, weather, zipcode
from app.core.config import API_PREFIX

api_router = APIRouter()
Expand All @@ -9,4 +9,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)
api_router.include_router(zipcode.router, prefix=API_PREFIX)
api_router.include_router(input.router, prefix=API_PREFIX)
91 changes: 91 additions & 0 deletions app/api/routes/input.py

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think there is too much going on under this file (keeping in mind that it's a routes file)
We should keep all the service logic under services and routes files would be responsible only to call those functions and expose it as API.

Right now app/api/routes/input.py is serving as:

  • Logic and validation layer (which services/ should be handling)
  • Database operations (which repository.py is responsible for)

Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
from uuid import UUID

from fastapi import APIRouter, Depends
from fastapi.exceptions import RequestValidationError
from sqlmodel import Session

from app.api.deps import get_db
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.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)):
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(body.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",
}
]
)

record = create_input(db, 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 = repo_get_input(db, input_id)
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,
)
42 changes: 42 additions & 0 deletions app/api/schemas/input.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
from __future__ import annotations

from datetime import date, datetime
from uuid import UUID

from pydantic import BaseModel, Field

from app.api.schemas.enums import InputStatus, InputType


class TextInputRequest(BaseModel):
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):
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):
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
4 changes: 4 additions & 0 deletions app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
17 changes: 16 additions & 1 deletion app/db/repositories.py
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -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)

30 changes: 30 additions & 0 deletions app/services/input.py
Original file line number Diff line number Diff line change
@@ -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,
)
165 changes: 165 additions & 0 deletions tests/test_v1_input.py
Original file line number Diff line number Diff line change
@@ -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"]
Loading