Skip to content

Commit dbcbe95

Browse files
committed
Add text input layer: POST /input/text and GET /input/{input_id} (#546)
1 parent 7224f68 commit dbcbe95

5 files changed

Lines changed: 323 additions & 2 deletions

File tree

app/api/router.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from fastapi import APIRouter
22

3-
from app.api.routes import forms, templates, weather, zipcode, jobs, system
3+
from app.api.routes import forms, jobs, system, templates, weather, zipcode
4+
from app.api.routes import input as input_routes
45
from app.core.config import API_PREFIX
56

67
api_router = APIRouter()
@@ -9,4 +10,5 @@
910
api_router.include_router(system.router, prefix=API_PREFIX)
1011
api_router.include_router(jobs.router, prefix=API_PREFIX)
1112
api_router.include_router(weather.router, prefix=API_PREFIX)
12-
api_router.include_router(zipcode.router, prefix=API_PREFIX)
13+
api_router.include_router(zipcode.router, prefix=API_PREFIX)
14+
api_router.include_router(input_routes.router, prefix=API_PREFIX)

app/api/routes/input.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
from datetime import datetime, timezone
2+
from uuid import UUID
3+
4+
from fastapi import APIRouter, Depends
5+
from fastapi.responses import JSONResponse
6+
from sqlmodel import Session, select
7+
8+
from app.api.deps import get_db
9+
from app.api.schemas.enums import InputStatus, InputType
10+
from app.api.schemas.input import InputRecordResponse, TextInputRequest, TextInputResponse
11+
from app.core.config import INPUT_POLL_INTERVAL_SECONDS
12+
from app.core.errors.base import AppError
13+
from app.models import Input
14+
15+
router = APIRouter(prefix="/input", tags=["input"])
16+
17+
18+
@router.post("/text", response_model=TextInputResponse, status_code=201)
19+
def submit_text_input(body: TextInputRequest, db: Session = Depends(get_db)):
20+
narrative = body.narrative
21+
22+
if len(narrative) > 50_000:
23+
raise AppError(
24+
"Narrative exceeds maximum length of 50,000 characters",
25+
status_code=413,
26+
error_code="NARRATIVE_TOO_LONG",
27+
detail={"max_characters": 50_000, "received_characters": len(narrative)},
28+
)
29+
30+
words = narrative.split()
31+
if len(words) < 10:
32+
return JSONResponse(
33+
status_code=422,
34+
content={
35+
"error_code": "VALIDATION_ERROR",
36+
"message": "Request validation failed",
37+
"validation_errors": [
38+
{
39+
"field": "narrative",
40+
"issue": "Must contain at least 10 words",
41+
"value": narrative,
42+
}
43+
],
44+
},
45+
)
46+
47+
now = datetime.now(timezone.utc)
48+
record = Input(
49+
input_type=InputType.text,
50+
status=InputStatus.ready,
51+
transcript=narrative,
52+
character_count=len(narrative),
53+
word_count=len(words),
54+
station_id=body.station_id,
55+
responder_badge=body.responder_badge,
56+
incident_date_hint=body.incident_date_hint,
57+
created_at=now,
58+
updated_at=now,
59+
)
60+
db.add(record)
61+
db.commit()
62+
db.refresh(record)
63+
64+
return TextInputResponse(
65+
input_id=record.input_id,
66+
status=record.status,
67+
input_type=record.input_type,
68+
character_count=record.character_count,
69+
word_count=record.word_count,
70+
created_at=record.created_at,
71+
)
72+
73+
74+
@router.get("/{input_id}", response_model=InputRecordResponse)
75+
def get_input(input_id: UUID, db: Session = Depends(get_db)):
76+
record = db.exec(select(Input).where(Input.input_id == input_id)).first()
77+
if record is None:
78+
raise AppError(
79+
f"Input with ID {input_id} not found",
80+
status_code=404,
81+
error_code="INPUT_NOT_FOUND",
82+
)
83+
84+
retry_after = (
85+
INPUT_POLL_INTERVAL_SECONDS
86+
if record.status in (InputStatus.queued, InputStatus.transcribing)
87+
else None
88+
)
89+
return InputRecordResponse(
90+
input_id=record.input_id,
91+
input_type=record.input_type,
92+
status=record.status,
93+
transcript=record.transcript,
94+
original_filename=record.original_filename,
95+
audio_duration_seconds=record.audio_duration_seconds,
96+
character_count=record.character_count,
97+
word_count=record.word_count,
98+
station_id=record.station_id,
99+
responder_badge=record.responder_badge,
100+
incident_date_hint=record.incident_date_hint,
101+
error_detail=record.error_detail,
102+
retry_after_seconds=retry_after,
103+
created_at=record.created_at,
104+
updated_at=record.updated_at,
105+
)

app/api/schemas/input.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
from __future__ import annotations
2+
3+
from datetime import date, datetime
4+
from uuid import UUID
5+
6+
from pydantic import BaseModel, ConfigDict, Field
7+
8+
from app.api.schemas.enums import InputStatus, InputType
9+
10+
11+
class TextInputRequest(BaseModel):
12+
model_config = ConfigDict()
13+
narrative: str = Field(min_length=20)
14+
station_id: str | None = None
15+
responder_badge: str | None = None
16+
incident_date_hint: date | None = None
17+
18+
19+
class TextInputResponse(BaseModel):
20+
model_config = ConfigDict()
21+
input_id: UUID
22+
status: InputStatus
23+
input_type: InputType
24+
character_count: int | None = None
25+
word_count: int | None = None
26+
created_at: datetime | None = None
27+
28+
29+
class InputRecordResponse(BaseModel):
30+
model_config = ConfigDict()
31+
input_id: UUID
32+
input_type: InputType
33+
status: InputStatus
34+
transcript: str | None = None
35+
original_filename: str | None = None
36+
audio_duration_seconds: float | None = None
37+
character_count: int | None = None
38+
word_count: int | None = None
39+
station_id: str | None = None
40+
responder_badge: str | None = None
41+
incident_date_hint: date | None = None
42+
error_detail: str | None = None
43+
retry_after_seconds: int | None = None
44+
created_at: datetime | None = None
45+
updated_at: datetime | None = None

app/core/config.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,10 @@
5454
# hint, not a measured backpressure value — the app has no queue-depth signal.
5555
RETRY_AFTER_SECONDS = 30
5656

57+
# Polling hint returned by GET /input/{id} when a voice input is still queued
58+
# or transcribing. Value matches the contract example (contracts/path/input.yaml).
59+
INPUT_POLL_INTERVAL_SECONDS = 5
60+
5761
# --- API Versioning -------------------------------------------------------
5862
API_PREFIX = "/api/v1"
5963

tests/test_v1_input.py

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
"""Tests for POST /api/v1/input/text and GET /api/v1/input/{input_id}.
2+
3+
Uses the shared in-memory SQLite engine from conftest.py. No Ollama or
4+
Whisper involvement — text input is fully synchronous.
5+
"""
6+
7+
import pytest
8+
9+
TEXT_URL = "/api/v1/input/text"
10+
INPUT_URL = "/api/v1/input"
11+
12+
# Narrative that passes all validation: > 20 chars, >= 10 words, << 50,000 chars.
13+
VALID_NARRATIVE = (
14+
"Responded to a wildfire at Bear Creek Trailhead around 1:45 PM on July 10th. "
15+
"Lightning strike ignited timber litter in mixed conifer and chaparral."
16+
)
17+
18+
19+
# ---------------------------------------------------------------------------
20+
# POST /api/v1/input/text
21+
# ---------------------------------------------------------------------------
22+
23+
class TestSubmitTextInput:
24+
25+
def test_201_returns_required_fields(self, client):
26+
resp = client.post(TEXT_URL, json={"narrative": VALID_NARRATIVE})
27+
assert resp.status_code == 201
28+
body = resp.json()
29+
assert body["status"] == "ready"
30+
assert body["input_type"] == "text"
31+
assert "input_id" in body
32+
assert "created_at" in body
33+
34+
def test_201_character_and_word_counts_match_narrative(self, client):
35+
resp = client.post(TEXT_URL, json={"narrative": VALID_NARRATIVE})
36+
assert resp.status_code == 201
37+
body = resp.json()
38+
assert body["character_count"] == len(VALID_NARRATIVE)
39+
assert body["word_count"] == len(VALID_NARRATIVE.split())
40+
41+
def test_201_optional_fields_accepted_and_visible_via_get(self, client):
42+
resp = client.post(TEXT_URL, json={
43+
"narrative": VALID_NARRATIVE,
44+
"station_id": "STA-045",
45+
"responder_badge": "FD-7842",
46+
"incident_date_hint": "2024-07-10",
47+
})
48+
assert resp.status_code == 201
49+
input_id = resp.json()["input_id"]
50+
51+
get_resp = client.get(f"{INPUT_URL}/{input_id}")
52+
assert get_resp.status_code == 200
53+
body = get_resp.json()
54+
assert body["station_id"] == "STA-045"
55+
assert body["responder_badge"] == "FD-7842"
56+
assert body["incident_date_hint"] == "2024-07-10"
57+
58+
def test_narrative_stored_in_transcript_field(self, client):
59+
resp = client.post(TEXT_URL, json={"narrative": VALID_NARRATIVE})
60+
input_id = resp.json()["input_id"]
61+
get_resp = client.get(f"{INPUT_URL}/{input_id}")
62+
assert get_resp.json()["transcript"] == VALID_NARRATIVE
63+
64+
def test_413_narrative_over_50000_chars(self, client):
65+
too_long = "x" * 50_001
66+
resp = client.post(TEXT_URL, json={"narrative": too_long})
67+
assert resp.status_code == 413
68+
body = resp.json()
69+
assert body["error_code"] == "NARRATIVE_TOO_LONG"
70+
assert body["detail"]["max_characters"] == 50_000
71+
assert body["detail"]["received_characters"] == 50_001
72+
73+
def test_413_boundary_exactly_50000_chars_is_accepted(self, client):
74+
# Exactly at the limit: should succeed (> 50_000 is rejected, not >=).
75+
# Build a narrative at exactly 50,000 chars that has >= 10 words.
76+
phrase = "fire incident response " # 23 chars
77+
narrative = (phrase * 2175)[:50_000] # 2175*23=50025, sliced to 50000
78+
resp = client.post(TEXT_URL, json={"narrative": narrative})
79+
assert resp.status_code == 201
80+
81+
def test_422_fewer_than_10_words_matches_request_validation_shape(self, client):
82+
# >= 20 chars so Pydantic minLength passes, but only 6 words.
83+
short = "Fire happened at the scene today here" # 7 words, > 20 chars
84+
resp = client.post(TEXT_URL, json={"narrative": short})
85+
assert resp.status_code == 422
86+
body = resp.json()
87+
assert body["error_code"] == "VALIDATION_ERROR"
88+
assert body["message"] == "Request validation failed"
89+
errs = body["validation_errors"]
90+
assert len(errs) == 1
91+
assert errs[0]["field"] == "narrative"
92+
assert errs[0]["issue"] == "Must contain at least 10 words"
93+
assert errs[0]["value"] == short
94+
95+
def test_422_narrative_under_20_chars_triggers_pydantic_validation(self, client):
96+
resp = client.post(TEXT_URL, json={"narrative": "Too short"})
97+
assert resp.status_code == 422
98+
body = resp.json()
99+
assert body["error_code"] == "VALIDATION_ERROR"
100+
assert "validation_errors" in body
101+
102+
def test_422_missing_narrative_field(self, client):
103+
resp = client.post(TEXT_URL, json={})
104+
assert resp.status_code == 422
105+
body = resp.json()
106+
assert body["error_code"] == "VALIDATION_ERROR"
107+
108+
def test_422_empty_string_narrative(self, client):
109+
resp = client.post(TEXT_URL, json={"narrative": ""})
110+
assert resp.status_code == 422
111+
body = resp.json()
112+
assert body["error_code"] == "VALIDATION_ERROR"
113+
114+
115+
# ---------------------------------------------------------------------------
116+
# GET /api/v1/input/{input_id}
117+
# ---------------------------------------------------------------------------
118+
119+
class TestGetInput:
120+
121+
def _create(self, client, **kwargs):
122+
payload = {"narrative": VALID_NARRATIVE, **kwargs}
123+
resp = client.post(TEXT_URL, json=payload)
124+
assert resp.status_code == 201
125+
return resp.json()["input_id"]
126+
127+
def test_200_returns_full_record(self, client):
128+
input_id = self._create(client)
129+
resp = client.get(f"{INPUT_URL}/{input_id}")
130+
assert resp.status_code == 200
131+
body = resp.json()
132+
assert body["input_id"] == input_id
133+
assert body["input_type"] == "text"
134+
assert body["status"] == "ready"
135+
assert body["transcript"] == VALID_NARRATIVE
136+
assert body["character_count"] == len(VALID_NARRATIVE)
137+
assert body["word_count"] == len(VALID_NARRATIVE.split())
138+
assert body["created_at"] is not None
139+
assert body["updated_at"] is not None
140+
141+
def test_200_voice_only_fields_are_null_for_text_input(self, client):
142+
input_id = self._create(client)
143+
body = client.get(f"{INPUT_URL}/{input_id}").json()
144+
assert body["original_filename"] is None
145+
assert body["audio_duration_seconds"] is None
146+
assert body["error_detail"] is None
147+
148+
def test_200_retry_after_is_null_for_ready_input(self, client):
149+
input_id = self._create(client)
150+
body = client.get(f"{INPUT_URL}/{input_id}").json()
151+
assert body.get("retry_after_seconds") is None
152+
153+
def test_404_unknown_uuid_returns_app_error_envelope(self, client):
154+
fake_id = "00000000-0000-0000-0000-000000000000"
155+
resp = client.get(f"{INPUT_URL}/{fake_id}")
156+
assert resp.status_code == 404
157+
body = resp.json()
158+
assert body["error_code"] == "INPUT_NOT_FOUND"
159+
assert fake_id in body["message"]
160+
161+
def test_404_message_contains_the_requested_id(self, client):
162+
target = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
163+
resp = client.get(f"{INPUT_URL}/{target}")
164+
assert resp.status_code == 404
165+
assert target in resp.json()["message"]

0 commit comments

Comments
 (0)