Skip to content

Commit e55bf1f

Browse files
committed
merging development to chetanr25:template_registry
1 parent 8136064 commit e55bf1f

7 files changed

Lines changed: 348 additions & 4 deletions

File tree

app/api/router.py

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

3-
from app.api.routes import forms, form_templates, weather, zipcode, jobs, system
3+
from app.api.routes import forms, input, jobs, system, weather, zipcode, form_templates
44
from app.core.config import API_PREFIX
55

66
api_router = APIRouter()
@@ -9,4 +9,5 @@
99
api_router.include_router(system.router, prefix=API_PREFIX)
1010
api_router.include_router(jobs.router, prefix=API_PREFIX)
1111
api_router.include_router(weather.router, prefix=API_PREFIX)
12-
api_router.include_router(zipcode.router, prefix=API_PREFIX)
12+
api_router.include_router(zipcode.router, prefix=API_PREFIX)
13+
api_router.include_router(input.router, prefix=API_PREFIX)

app/api/routes/input.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
from uuid import UUID
2+
3+
from fastapi import APIRouter, Depends
4+
from fastapi.exceptions import RequestValidationError
5+
from sqlmodel import Session
6+
7+
from app.api.deps import get_db
8+
from app.api.schemas.enums import InputStatus
9+
from app.api.schemas.input import InputRecordResponse, TextInputRequest, TextInputResponse
10+
from app.core.config import INPUT_POLL_INTERVAL_SECONDS
11+
from app.core.errors.base import AppError
12+
from app.db.repositories import create_input, get_input as repo_get_input
13+
from app.services.input import InputService
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+
if len(body.narrative) > 50_000:
21+
raise AppError(
22+
"Narrative exceeds maximum length of 50,000 characters",
23+
status_code=413,
24+
error_code="NARRATIVE_TOO_LONG",
25+
detail={"max_characters": 50_000, "received_characters": len(body.narrative)},
26+
)
27+
28+
svc = InputService()
29+
try:
30+
record = svc.build_text_input(
31+
narrative=body.narrative,
32+
station_id=body.station_id,
33+
responder_badge=body.responder_badge,
34+
incident_date_hint=body.incident_date_hint,
35+
)
36+
except ValueError as exc:
37+
raise RequestValidationError(
38+
errors=[
39+
{
40+
"loc": ("body", "narrative"),
41+
"msg": str(exc),
42+
"input": body.narrative,
43+
"type": "value_error",
44+
}
45+
]
46+
)
47+
48+
record = create_input(db, record)
49+
50+
return TextInputResponse(
51+
input_id=record.input_id,
52+
status=record.status,
53+
input_type=record.input_type,
54+
character_count=record.character_count,
55+
word_count=record.word_count,
56+
created_at=record.created_at,
57+
)
58+
59+
60+
@router.get("/{input_id}", response_model=InputRecordResponse)
61+
def get_input(input_id: UUID, db: Session = Depends(get_db)):
62+
record = repo_get_input(db, input_id)
63+
if record is None:
64+
raise AppError(
65+
f"Input with ID {input_id} not found",
66+
status_code=404,
67+
error_code="INPUT_NOT_FOUND",
68+
)
69+
70+
retry_after = (
71+
INPUT_POLL_INTERVAL_SECONDS
72+
if record.status in (InputStatus.queued, InputStatus.transcribing)
73+
else None
74+
)
75+
return InputRecordResponse(
76+
input_id=record.input_id,
77+
input_type=record.input_type,
78+
status=record.status,
79+
transcript=record.transcript,
80+
original_filename=record.original_filename,
81+
audio_duration_seconds=record.audio_duration_seconds,
82+
character_count=record.character_count,
83+
word_count=record.word_count,
84+
station_id=record.station_id,
85+
responder_badge=record.responder_badge,
86+
incident_date_hint=record.incident_date_hint,
87+
error_detail=record.error_detail,
88+
retry_after_seconds=retry_after,
89+
created_at=record.created_at,
90+
updated_at=record.updated_at,
91+
)

app/api/schemas/input.py

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

app/db/repositories.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
11
from uuid import UUID
22

33
from sqlmodel import Session, select
4-
from app.models import Template, FormSubmission, FormTemplate, Job
4+
from app.models import Template, FormSubmission, FormTemplate, Job, Input
55

66
# Templates (legacy fill pipeline - read-only lookup, consumed by forms/jobs/tasks)
77
def get_template(session: Session, template_id: int) -> Template | None:
88
return session.get(Template, template_id)
99

10-
1110
# Form templates (contract Layer 6 registry)
1211
def create_form_template(session: Session, template: FormTemplate) -> FormTemplate:
1312
session.add(template)
@@ -84,3 +83,15 @@ def delete_form_submission(session: Session, submission: FormSubmission) -> None
8483
session.delete(submission)
8584
session.commit()
8685

86+
87+
# Inputs
88+
def create_input(session: Session, input_obj: Input) -> Input:
89+
session.add(input_obj)
90+
session.commit()
91+
session.refresh(input_obj)
92+
return input_obj
93+
94+
95+
def get_input(session: Session, input_id: UUID) -> Input | None:
96+
return session.get(Input, input_id)
97+

app/services/input.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
from datetime import date, datetime, timezone
2+
3+
from app.api.schemas.enums import InputStatus, InputType
4+
from app.models import Input
5+
6+
7+
class InputService:
8+
def build_text_input(
9+
self,
10+
narrative: str,
11+
station_id: str | None = None,
12+
responder_badge: str | None = None,
13+
incident_date_hint: date | None = None,
14+
) -> Input:
15+
words = narrative.split()
16+
if len(words) < 10:
17+
raise ValueError("Must contain at least 10 words")
18+
now = datetime.now(timezone.utc)
19+
return Input(
20+
input_type=InputType.text,
21+
status=InputStatus.ready,
22+
transcript=narrative,
23+
character_count=len(narrative),
24+
word_count=len(words),
25+
station_id=station_id,
26+
responder_badge=responder_badge,
27+
incident_date_hint=incident_date_hint,
28+
created_at=now,
29+
updated_at=now,
30+
)

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)