-
Notifications
You must be signed in to change notification settings - Fork 152
Add text input layer: POST /input/text and GET /input/{input_id} (#546) #595
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
marcvergees
merged 2 commits into
fireform-core:development
from
abhishek-8081:issue-546-text-input
Jun 27, 2026
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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, | ||
| ) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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, | ||
| ) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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"] |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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
servicesand routes files would be responsible only to call those functions and expose it as API.Right now
app/api/routes/input.pyis serving as: