diff --git a/contracts/openapi.yaml b/contracts/openapi.yaml index e86c308..17d2b75 100644 --- a/contracts/openapi.yaml +++ b/contracts/openapi.yaml @@ -11,7 +11,7 @@ info: email: chetan250204@gmail.com servers: - - url: http://localhost:8080 + - url: http://localhost:8000 description: Local FireForm instance (default) tags: @@ -29,6 +29,8 @@ tags: description: Weather forecast lookup via external API - name: zipcode description: Address and postal code geocoding via external API + - name: profiles + description: Profile of the person filling forms paths: # ── Layer 1: Input ────────────────────────────────────────────── @@ -44,16 +46,32 @@ paths: $ref: "path/forms.yaml#/fill" /api/v1/forms/jobs: $ref: "path/forms.yaml#/async_fill" + /api/v1/forms/models: + $ref: "path/forms.yaml#/models" + /api/v1/forms/transcribe: + $ref: "path/forms.yaml#/transcribe" + /api/v1/forms/submissions: + $ref: "path/forms.yaml#/submissions" + /api/v1/forms/submissions/analytics: + $ref: "path/forms.yaml#/submissions_analytics" + /api/v1/forms/purge: + $ref: "path/forms.yaml#/purge" + /api/v1/forms/{submission_id}: + $ref: "path/forms.yaml#/submission_by_id" # ── Layer 6: Templates & Configuration ───────────────────────── /api/v1/templates: $ref: "path/templates.yaml#/templates" + /api/v1/templates/upload: + $ref: "path/templates.yaml#/templates_upload" + /api/v1/templates/preview: + $ref: "path/templates.yaml#/templates_preview" + /api/v1/templates/create: + $ref: "path/templates.yaml#/templates_create" + /api/v1/templates/make-fillable: + $ref: "path/templates.yaml#/templates_make_fillable" /api/v1/templates/{template_id}: $ref: "path/templates.yaml#/template_by_id" - /api/v1/templates/{template_id}/fields: - $ref: "path/templates.yaml#/template_fields" - /api/v1/templates/pdf: - $ref: "path/templates.yaml#/templates_pdf" # ── Layer 7: System ──────────────────────────────────────────── /api/v1/health: @@ -88,3 +106,20 @@ paths: $ref: "path/weather.yaml#/forecast" /api/v1/zipcode/lookup-address: $ref: "path/zipcode.yaml#/lookup_address" + + # ── Profiles ─────────────────────────────────────────────────── + /api/v1/profiles/: + $ref: "path/profiles.yaml#/profiles" + /api/v1/profiles/{profile_id}: + $ref: "path/profiles.yaml#/profile_by_id" + +components: + securitySchemes: + # Only enforced when FIREFORM_API_KEY is set; either scheme is accepted. + ApiKeyHeader: + type: apiKey + in: header + name: X-API-Key + BearerAuth: + type: http + scheme: bearer diff --git a/contracts/path/forms.yaml b/contracts/path/forms.yaml index a0e203a..898ae7d 100644 --- a/contracts/path/forms.yaml +++ b/contracts/path/forms.yaml @@ -1,6 +1,12 @@ # Layer 3 Form Fill Endpoints -# POST /api/v1/forms/fill -# POST /api/v1/forms/jobs +# POST /api/v1/forms/fill +# POST /api/v1/forms/jobs +# GET /api/v1/forms/models +# POST /api/v1/forms/transcribe +# GET /api/v1/forms/submissions +# GET /api/v1/forms/submissions/analytics +# POST /api/v1/forms/purge +# DELETE /api/v1/forms/{submission_id} fill: post: @@ -12,6 +18,10 @@ fill: the chosen LLM to get values back field-by-field, then draws those values into the template's PDF boxes. Runs synchronously: one LLM pass per call. The referenced input must have status "ready". + + Instead of `input_id`, the narrative can be sent directly as + `input_text`; without an `input_id` it is stored as a new text input + first. tags: - forms requestBody: @@ -58,7 +68,9 @@ fill: detail: status: "transcribing" "422": - description: The provider or model is not one GET /system/llm/models lists + description: Neither input_id nor input_text was sent (VALIDATION_ERROR), + or the provider or model is not one GET /system/llm/models lists + (LLM_NOT_AVAILABLE) content: application/json: schema: @@ -163,3 +175,270 @@ async_fill: example: error_code: "LLM_NOT_AVAILABLE" message: "gemini does not offer gemini-x. Pick one GET /system/llm/models lists." + "503": + description: The task queue (broker) could not be reached, so no job was queued + content: + application/json: + schema: + $ref: "../schemas/common.yaml#/ErrorResponse" + example: + error_code: "QUEUE_UNAVAILABLE" + message: "Could not queue fill job for template 3: Error 111 connecting to redis:6379." + retry_after_seconds: 30 + "207": + description: Some jobs were queued before the broker became unreachable. + `jobs` lists the queued ones and `error` says which template failed. + content: + application/json: + schema: + $ref: "../schemas/form-record.yaml#/AsyncFormFillResponse" + +models: + get: + operationId: listFillModels + summary: List Ollama models for the Fill Form model picker + description: | + Lists the models pulled in the local Ollama instance, with the + configured default first if it isn't pulled yet. If Ollama can't be + reached, returns just the default. For every provider, see + GET /api/v1/system/llm/models. + tags: + - forms + responses: + "200": + description: Available models and the default + content: + application/json: + schema: + $ref: "../schemas/form-record.yaml#/ModelsResponse" + example: + models: ["qwen2.5:3b", "llama3.1:latest"] + default: "qwen2.5:3b" + +transcribe: + post: + operationId: transcribeAudio + summary: Transcribe a short recording to text + description: | + Forwards recorded audio to the local Whisper service and returns the + text. Used by the Fill Form microphone button. The audio is not stored. + For stored voice inputs with async transcription, use POST /input/voice. + tags: + - forms + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: + - audio + properties: + audio: + type: string + format: binary + description: Recorded audio (e.g. webm, wav). Max 500 MB. + responses: + "200": + description: Transcribed text + content: + application/json: + schema: + $ref: "../schemas/form-record.yaml#/TranscriptionResponse" + example: + text: "Structure fire on Main Street, two engines on scene." + "413": + description: Audio exceeds the 500 MB limit + content: + application/json: + schema: + $ref: "../schemas/common.yaml#/ErrorResponse" + example: + error_code: "FILE_TOO_LARGE" + message: "Audio file exceeds maximum size of 500MB" + detail: + max_size_bytes: 524288000 + received_size_bytes: 524288001 + "502": + description: Whisper returned an error + content: + application/json: + schema: + $ref: "../schemas/common.yaml#/ErrorResponse" + example: + error_code: "TRANSCRIPTION_FAILED" + message: "Transcription failed: 500 Server Error: Internal Server Error for url: http://whisper:9000/asr" + "503": + description: Whisper can't be reached + content: + application/json: + schema: + $ref: "../schemas/common.yaml#/ErrorResponse" + example: + error_code: "STT_UNAVAILABLE" + message: "Could not connect to the speech-to-text service at http://whisper:9000/asr. Please ensure the whisper service is running." + retry_after_seconds: 30 + +submissions: + get: + operationId: listSubmissions + summary: List filled form submissions + description: Returns every form submission with the name of its template. + tags: + - forms + responses: + "200": + description: List of submissions + content: + application/json: + schema: + type: array + items: + $ref: "../schemas/form-record.yaml#/Submission" + example: + - id: 12 + template_id: 3 + template_name: "ICS 209 Incident Status Summary" + input_text: "Structure fire on Main Street, two engines on scene." + output_pdf_path: "data/inputs/ics_209_20261030_101500_filled.pdf" + created_at: "2026-10-30T10:15:00" + +submissions_analytics: + get: + operationId: getSubmissionAnalytics + summary: Summary statistics over all submissions + description: | + Counts submissions in total, per template and per day, and lists the 12 + most common words in the narratives (stop words removed). + tags: + - forms + responses: + "200": + description: Submission analytics + content: + application/json: + schema: + $ref: "../schemas/form-record.yaml#/SubmissionAnalytics" + example: + total_submissions: 3 + by_template: + - template_name: "ICS 209 Incident Status Summary" + count: 2 + - template_name: "Manual Test Template" + count: 1 + by_date: + - date: "2026-10-29" + count: 1 + - date: "2026-10-30" + count: 2 + common_terms: + - word: "fire" + count: 3 + - word: "engine" + count: 2 + +purge: + post: + operationId: purgeSubmissions + summary: Delete submissions older than a retention window + description: | + Deletes every submission created before now minus `days` (defaults to + the configured retention period), together with its output PDF. + + Requires the API key when `FIREFORM_API_KEY` is set. + tags: + - forms + security: + - ApiKeyHeader: [] + - BearerAuth: [] + parameters: + - name: days + in: query + required: false + description: Retention window in days. Defaults to the configured retention period. + schema: + type: integer + minimum: 1 + maximum: 3650 + responses: + "200": + description: Old submissions deleted + content: + application/json: + schema: + type: object + required: + - status + - purged_count + - retention_days_used + properties: + status: + type: string + purged_count: + type: integer + retention_days_used: + type: integer + example: + status: "success" + purged_count: 4 + retention_days_used: 30 + "401": + description: Missing or wrong API key + content: + application/json: + schema: + $ref: "../schemas/common.yaml#/ErrorResponse" + example: + error_code: "UNAUTHORIZED" + message: "Unauthorized" + "422": + description: days is outside 1-3650 + content: + application/json: + schema: + $ref: "../schemas/common.yaml#/ErrorResponse" + +submission_by_id: + delete: + operationId: deleteSubmission + summary: Delete one submission and its output PDF + description: Requires the API key when `FIREFORM_API_KEY` is set. + tags: + - forms + security: + - ApiKeyHeader: [] + - BearerAuth: [] + parameters: + - name: submission_id + in: path + required: true + schema: + type: integer + responses: + "200": + description: Submission deleted + content: + application/json: + schema: + $ref: "../schemas/common.yaml#/StatusMessage" + example: + status: "success" + message: "Submission and associated output file deleted" + "401": + description: Missing or wrong API key + content: + application/json: + schema: + $ref: "../schemas/common.yaml#/ErrorResponse" + example: + error_code: "UNAUTHORIZED" + message: "Unauthorized" + "404": + description: Submission not found + content: + application/json: + schema: + $ref: "../schemas/common.yaml#/ErrorResponse" + example: + error_code: "SUBMISSION_NOT_FOUND" + message: "Submission not found" diff --git a/contracts/path/jobs.yaml b/contracts/path/jobs.yaml index 2ac042f..e229c07 100644 --- a/contracts/path/jobs.yaml +++ b/contracts/path/jobs.yaml @@ -75,5 +75,5 @@ job_by_id: schema: $ref: "../schemas/common.yaml#/ErrorResponse" example: - error_code: "JOB_NOT_FOUND" - message: "Job with ID 550e8400-e29b-41d4-a716-446655440099 not found" + error_code: "NOT_FOUND" + message: "Job not found" diff --git a/contracts/path/profiles.yaml b/contracts/path/profiles.yaml new file mode 100644 index 0000000..ffee9d3 --- /dev/null +++ b/contracts/path/profiles.yaml @@ -0,0 +1,132 @@ +# Form Filler Profiles +# GET /api/v1/profiles/ +# POST /api/v1/profiles/ +# GET /api/v1/profiles/{profile_id} +# PATCH /api/v1/profiles/{profile_id} + +profiles: + get: + operationId: getCurrentProfile + summary: Get the current form filler profile + description: | + Returns the most recently updated profile, or `null` if none has been + created yet. + tags: + - profiles + responses: + "200": + description: The current profile, or null + content: + application/json: + schema: + allOf: + - $ref: "../schemas/profile.yaml#/Profile" + nullable: true + example: + profile_id: "550e8400-e29b-41d4-a716-446655440077" + name: "Jane Smith" + profession: "Firefighter" + role: "Captain" + description: "Station 12, B shift" + custom_fields: + badge_number: "4471" + created_at: "2026-10-20T09:00:00Z" + updated_at: "2026-10-28T16:30:00Z" + + post: + operationId: createProfile + summary: Create a form filler profile + tags: + - profiles + requestBody: + required: true + content: + application/json: + schema: + $ref: "../schemas/profile.yaml#/CreateProfileRequest" + example: + name: "Jane Smith" + profession: "Firefighter" + role: "Captain" + description: "Station 12, B shift" + custom_fields: + badge_number: "4471" + responses: + "201": + description: Profile created + content: + application/json: + schema: + $ref: "../schemas/profile.yaml#/Profile" + "422": + description: Request body failed validation + content: + application/json: + schema: + $ref: "../schemas/common.yaml#/ErrorResponse" + +profile_by_id: + get: + operationId: getProfile + summary: Get a profile by ID + tags: + - profiles + parameters: + - name: profile_id + in: path + required: true + schema: + type: string + format: uuid + responses: + "200": + description: The profile + content: + application/json: + schema: + $ref: "../schemas/profile.yaml#/Profile" + "404": + description: Profile not found + content: + application/json: + schema: + $ref: "../schemas/common.yaml#/HTTPErrorDetail" + example: + detail: "Profile not found" + + patch: + operationId: updateProfile + summary: Update a profile + description: Partial update. Only the keys sent are changed. + tags: + - profiles + parameters: + - name: profile_id + in: path + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: "../schemas/profile.yaml#/UpdateProfileRequest" + example: + role: "Battalion Chief" + responses: + "200": + description: Profile updated + content: + application/json: + schema: + $ref: "../schemas/profile.yaml#/Profile" + "404": + description: Profile not found + content: + application/json: + schema: + $ref: "../schemas/common.yaml#/HTTPErrorDetail" + example: + detail: "Profile not found" diff --git a/contracts/path/templates.yaml b/contracts/path/templates.yaml index 9064471..679d529 100644 --- a/contracts/path/templates.yaml +++ b/contracts/path/templates.yaml @@ -1,21 +1,20 @@ # Layer 6 Template & Configuration Endpoints -# GET /api/v1/templates -# GET /api/v1/templates/{template_id} -# POST /api/v1/templates -# PUT /api/v1/templates/{template_id} -# GET /api/v1/templates/{template_id}/fields -# POST /api/v1/templates/pdf +# GET /api/v1/templates +# POST /api/v1/templates/upload +# GET /api/v1/templates/preview +# POST /api/v1/templates/create +# POST /api/v1/templates/make-fillable +# PUT /api/v1/templates/{template_id} +# DELETE /api/v1/templates/{template_id} templates: get: operationId: listTemplates - summary: List all available form templates + summary: List all registered form templates description: | - Returns all registered form templates including built-in standard templates - (NERIS, NEMSIS, NIBRS, NFIRS modules, OSHA, etc.) and any custom templates - added for specific jurisdictions. Each template defines its own field - dictionary - the field names, types, and validation rules used to fill - that template's PDF at fill time. + Returns every registered template, newest first. `field_count` is worked + out from the stored field dictionary, or by counting the PDF's fillable + widgets when the template has no fields stored. tags: - templates responses: @@ -26,375 +25,313 @@ templates: schema: type: array items: - $ref: "../schemas/template.yaml#/TemplateSummary" + $ref: "../schemas/template.yaml#/Template" example: - - template_id: 1 - form_type: "neris" - display_name: "NERIS Incident Report" - jurisdiction: "US-Federal" - agency_type: "fire_department" - version: "2.0" - last_updated: "2026-02-01" - field_count: 85 - status: "active" - - template_id: 2 - form_type: "nemsis_epcr" - display_name: "NEMSIS Electronic Patient Care Report" - jurisdiction: "US-Federal" - agency_type: "ems" - version: "3.5" - last_updated: "2024-01-15" - field_count: 142 - status: "active" - - template_id: 3 - form_type: "nfirs_basic" - display_name: "NFIRS Basic Module (Legacy)" - jurisdiction: "US-Federal" - agency_type: "fire_department" - version: "5.0" - last_updated: "2015-01-01" - field_count: 65 - status: "legacy" + - id: 2 + name: "Manual Test Template" + description: "Employee contact and sign-off form used to manually verify that FireForm fills a PDF correctly." + pdf_path: "data/inputs/file_template_manual.pdf" + fields: + "Employee's name": "string" + "Employee's job title": "string" + "Date": "string" + field_count: 3 +templates_upload: post: - operationId: createTemplate - summary: Register a new custom form template + operationId: uploadTemplatePdf + summary: Upload a blank PDF to build a template from description: | - Registers a new form template for a jurisdiction or agency not yet supported. - This is how FireForm extends to new states, countries, or custom agency forms - without code changes. The template defines its own field dictionary - each - field's name, type, and validation rules - used to fill that template's PDF - at fill time. + Stores a blank agency PDF in the project and extracts its fillable + fields, so the editor can show them before the template is registered. + This is the first step of the template-authoring flow: the returned + `pdf_path` is then sent to `POST /api/v1/templates/create` (optionally + after `POST /api/v1/templates/make-fillable` for a flat PDF). + + If a file with the same name already exists in `directory`, a timestamp + is added to the new file's name instead of overwriting it. tags: - templates requestBody: required: true content: - application/json: + multipart/form-data: schema: - $ref: "../schemas/template.yaml#/CreateTemplateRequest" - example: - form_type: "state_texas" - display_name: "Texas State Fire Marshal Incident Report" - jurisdiction: "US-TX" - agency_type: "fire_department" - pdf_template_ref: "templates/state_texas.pdf" - fields: - - field_name: "incident_number" - field_type: "string" - required: true - max_length: 20 - description: "State-assigned incident number" - layout: - page: 0 - x: 188.33 - y: 621.33 - width: 127.33 - height: 28.67 - font: "Helvetica" - font_size: 10 - color: "#000000" - align: "left" - - field_name: "fire_cause" - field_type: "enum" - required: true - allowed_values: - - "accidental" - - "natural" - - "intentional" - - "undetermined" - layout: - page: 0 - x: 188.33 - y: 560.0 - width: 200.0 - height: 18.0 - - field_name: "report_footer" - field_type: "string" - required: false - static_text: "Generated by FireForm" - layout: - page: 0 - x: 72.0 - y: 40.0 - width: 300.0 - height: 12.0 - font_size: 8 - align: "center" + type: object + required: + - file + properties: + file: + type: string + format: binary + description: Blank fillable or flat PDF form. Must end in `.pdf`. Max 10 MB. + directory: + type: string + default: "data/inputs" + description: Project-relative directory to store the PDF in + (defaults to FIREFORM_TEMPLATE_DIR). Must be inside the project. responses: - "201": - description: Template created + "200": + description: PDF stored and its fields extracted content: application/json: schema: - $ref: "../schemas/template.yaml#/Template" - "409": - description: Template with this form_type already exists + $ref: "../schemas/template.yaml#/TemplateUploadResponse" + example: + filename: "ics_209.pdf" + pdf_path: "data/inputs/ics_209.pdf" + field_count: 2 + fields: + - name: "incident_name" + description: "Incident name" + type: "string" + - name: "incident_number" + description: "Incident number" + type: "string" + schema_data: null + tables: null + "400": + description: Missing filename, not a `.pdf` file, or `directory` outside the project content: application/json: schema: - $ref: "../schemas/common.yaml#/ErrorResponse" - "422": - description: Invalid template definition + $ref: "../schemas/common.yaml#/HTTPErrorDetail" + example: + detail: "Only PDF files are supported." + "413": + description: PDF exceeds the 10 MB limit content: application/json: schema: $ref: "../schemas/common.yaml#/ErrorResponse" + example: + error_code: "FILE_TOO_LARGE" + message: "PDF template exceeds maximum size of 10MB" + detail: + max_size_bytes: 10485760 + received_size_bytes: 12582912 -template_by_id: +templates_preview: get: - operationId: getTemplate - summary: Get full template schema + operationId: previewTemplatePdf + summary: Stream a stored PDF for preview description: | - Returns the complete template definition including all fields, their types, - required/optional status, and validation rules that this template's PDF is - filled against at fill time. Includes the source standard reference where - applicable. + Returns a PDF stored in the project (a template or a filled output) with + `Content-Disposition: inline`, so the frontend can show it in its viewer. tags: - templates parameters: - - name: template_id - in: path + - name: path + in: query required: true - description: Unique identifier of the template + description: Project-relative PDF path, as returned in `pdf_path` or + `output_pdf_path`. Must be inside the project. schema: - type: integer + type: string + example: "data/inputs/ics_209.pdf" responses: "200": - description: Full template definition + description: The PDF file + content: + application/pdf: + schema: + type: string + format: binary + "400": + description: Path is empty, outside the project, or not a `.pdf` file content: application/json: schema: - $ref: "../schemas/template.yaml#/Template" + $ref: "../schemas/common.yaml#/HTTPErrorDetail" + example: + detail: "Only PDF files can be previewed." "404": - description: Template not found + description: No file at that path content: application/json: schema: - $ref: "../schemas/common.yaml#/ErrorResponse" + $ref: "../schemas/common.yaml#/HTTPErrorDetail" + example: + detail: "PDF file not found." - put: - operationId: updateTemplate - summary: Update an existing template +templates_create: + post: + operationId: createTemplate + summary: Register a new form template description: | - Replaces an existing template definition. Used when an upstream standard - changes (e.g., NERIS schema update). Templates used by submitted incidents - cannot be modified create a new version instead. + Registers a template for a PDF already stored in the project (see + `POST /api/v1/templates/upload`). When `fields` is empty, the field + dictionary is extracted from the PDF itself. tags: - templates - parameters: - - name: template_id - in: path - required: true - description: Unique identifier of the template to update - schema: - type: integer requestBody: required: true content: application/json: schema: $ref: "../schemas/template.yaml#/CreateTemplateRequest" + example: + name: "ICS 209 Incident Status Summary" + description: "Incident status summary sent to the coordination center." + pdf_path: "data/inputs/ics_209.pdf" + fields: {} responses: "200": - description: Template updated + description: Template created content: application/json: schema: $ref: "../schemas/template.yaml#/Template" - "404": - description: Template not found + "400": + description: "`pdf_path` is empty or outside the project" + content: + application/json: + schema: + $ref: "../schemas/common.yaml#/HTTPErrorDetail" + "422": + description: Request body failed validation content: application/json: schema: $ref: "../schemas/common.yaml#/ErrorResponse" - "409": - description: Template in use by submitted incidents cannot modify + +templates_make_fillable: + post: + operationId: makeTemplateFillable + summary: Detect form fields in a flat PDF and save a fillable copy + description: | + Runs field detection (commonforms) on a stored PDF and writes a fillable + copy next to it as `_template.pdf`. The returned `pdf_path` can be + sent to `POST /api/v1/templates/create`. This can take a minute on CPU. + tags: + - templates + requestBody: + required: true + content: + application/json: + schema: + $ref: "../schemas/template.yaml#/MakeFillableRequest" + example: + pdf_path: "data/inputs/ics_209.pdf" + responses: + "200": + description: Fillable copy created + content: + application/json: + schema: + $ref: "../schemas/template.yaml#/MakeFillableResponse" + example: + pdf_path: "data/inputs/ics_209_template.pdf" + field_count: 48 + "400": + description: "`pdf_path` is empty or outside the project" + content: + application/json: + schema: + $ref: "../schemas/common.yaml#/HTTPErrorDetail" + "404": + description: No file at `pdf_path` + content: + application/json: + schema: + $ref: "../schemas/common.yaml#/HTTPErrorDetail" + example: + detail: "PDF file not found." + "500": + description: Field detection failed content: application/json: schema: $ref: "../schemas/common.yaml#/ErrorResponse" example: - error_code: "TEMPLATE_IN_USE" - message: "Template is referenced by submitted incidents. Create a new version instead." - detail: - submitted_incident_count: 15 - recommendation: "POST /api/v1/templates to create a new version" + error_code: "MAKE_FILLABLE_FAILED" + message: "Could not make the PDF fillable: RuntimeError" -template_fields: - get: - operationId: getTemplateFields - summary: Get template field definitions +template_by_id: + put: + operationId: updateTemplate + summary: Update a template's name, description or fields description: | - Returns just the fields list for a template with type, required/optional - status, and validation rules. Useful for building validation checklists and - for the validate endpoint to determine requirements. + Partial update: only the keys sent are changed. `pdf_path` cannot be + changed; register a new template for a different PDF. tags: - templates parameters: - name: template_id in: path required: true - description: Unique identifier of the template + description: Unique identifier of the template to update schema: type: integer - - name: required_only - in: query - description: If true, return only required fields - schema: - type: boolean - default: false + requestBody: + required: true + content: + application/json: + schema: + $ref: "../schemas/template.yaml#/UpdateTemplateRequest" + example: + description: "Updated ICS 209 description used as extra context when filling" responses: "200": - description: List of template fields + description: Template updated content: application/json: schema: - type: object - properties: - template_id: - type: integer - form_type: - type: string - total_fields: - type: integer - required_fields: - type: integer - optional_fields: - type: integer - fields: - type: array - items: - $ref: "../schemas/template.yaml#/TemplateField" - example: - template_id: 1 - form_type: "neris" - total_fields: 85 - required_fields: 32 - optional_fields: 53 - fields: - - field_name: "incident_type" - field_type: "enum" - required: true - description: "Primary incident type code" - layout: - page: 0 - x: 120.0 - y: 680.0 - width: 160.0 - height: 18.0 - - field_name: "incident_date" - field_type: "date" - required: true - description: "Date of incident" - layout: - page: 0 - x: 120.0 - y: 655.0 - width: 120.0 - height: 18.0 + $ref: "../schemas/template.yaml#/Template" "404": description: Template not found content: application/json: schema: - $ref: "../schemas/common.yaml#/ErrorResponse" + $ref: "../schemas/common.yaml#/HTTPErrorDetail" + example: + detail: "Template not found" -templates_pdf: - post: - operationId: uploadTemplatePdf - summary: Upload a blank PDF for a template before defining its fields + delete: + operationId: deleteTemplate + summary: Delete a template and everything filled from it description: | - Uploads the blank agency PDF that a template's field coordinates will be - written onto, and returns a `pdf_template_ref` plus the page geometry. + Deletes the template, its form submissions and their output PDFs, and + its jobs. The template's PDF is deleted too, unless another template + still uses the same file or it is the bundled seed PDF. - This is the first step of the template-authoring flow. The visual editor - renders the returned pages, the user draws a box per field, and the box - coordinates become each field's `layout`. The resulting `pdf_template_ref` - is then sent in the `POST /api/v1/templates` body. Upload precedes create - because the layout coordinates only have meaning relative to this PDF. - - Page dimensions are returned in PDF points (1/72 inch, origin bottom-left) - so the editor can map canvas positions to the same coordinate system the - `layout` fields use. + Requires the API key when `FIREFORM_API_KEY` is set. tags: - templates - requestBody: - required: true - content: - multipart/form-data: - schema: - type: object - required: - - pdf_file - properties: - pdf_file: - type: string - format: binary - description: Blank fillable or flat PDF form. Max 50MB. + security: + - ApiKeyHeader: [] + - BearerAuth: [] + parameters: + - name: template_id + in: path + required: true + description: Unique identifier of the template to delete + schema: + type: integer responses: - "201": - description: PDF stored and ready to be referenced by a template + "200": + description: Template deleted content: application/json: schema: - type: object - required: - - pdf_template_ref - - original_filename - - page_count - - pages - properties: - pdf_template_ref: - type: string - description: Opaque reference to the stored PDF, passed back in - the template body as pdf_template_ref - original_filename: - type: string - page_count: - type: integer - pages: - type: array - description: Per-page geometry in PDF points, index 0 = first page - items: - type: object - required: - - page - - width - - height - properties: - page: - type: integer - width: - type: number - height: - type: number + $ref: "../schemas/common.yaml#/StatusMessage" example: - pdf_template_ref: "templates/uploads/550e8400-e29b-41d4-a716-446655440080.pdf" - original_filename: "texas_sfm_incident.pdf" - page_count: 2 - pages: - - page: 0 - width: 612.0 - height: 792.0 - - page: 1 - width: 612.0 - height: 792.0 - "400": - description: Missing file or malformed multipart request + status: "success" + message: "Template and all associated data deleted" + "401": + description: Missing or wrong API key content: application/json: schema: $ref: "../schemas/common.yaml#/ErrorResponse" - "413": - description: PDF exceeds the maximum allowed size - content: - application/json: - schema: - $ref: "../schemas/common.yaml#/ErrorResponse" - "415": - description: Uploaded file is not a valid PDF + example: + error_code: "UNAUTHORIZED" + message: "Unauthorized" + "404": + description: Template not found content: application/json: schema: - $ref: "../schemas/common.yaml#/ErrorResponse" + $ref: "../schemas/common.yaml#/HTTPErrorDetail" + example: + detail: "Template not found" diff --git a/contracts/path/weather.yaml b/contracts/path/weather.yaml index 601911b..1cbb3f8 100644 --- a/contracts/path/weather.yaml +++ b/contracts/path/weather.yaml @@ -103,6 +103,32 @@ forecast: items: type: number '422': - description: Validation error (e.g. coordinates out of range, invalid date format) + description: > + Validation error (coordinates out of range, a date not in YYYY-MM-DD + format, or end_date before start_date). Open-Meteo is not called. + content: + application/json: + schema: + $ref: "../schemas/common.yaml#/ErrorResponse" + example: + error_code: "VALIDATION_ERROR" + message: "Request validation failed" + validation_errors: + - field: "query.latitude" + issue: "Input should be less than or equal to 90" + value: "200" + '502': + description: Open-Meteo returned an error or could not be reached + content: + application/json: + schema: + $ref: "../schemas/common.yaml#/ErrorResponse" + example: + error_code: "WEATHER_FETCH_FAILED" + message: "failed to request 'https://api.open-meteo.com/v1/forecast': ..." '500': description: Internal server error + content: + application/json: + schema: + $ref: "../schemas/common.yaml#/ErrorResponse" diff --git a/contracts/path/zipcode.yaml b/contracts/path/zipcode.yaml index a348017..1401489 100644 --- a/contracts/path/zipcode.yaml +++ b/contracts/path/zipcode.yaml @@ -70,7 +70,40 @@ lookup_address: nullable: true description: County or district name. example: "Santa Clara County" + '422': + description: The address is empty or only whitespace. Nominatim is not called. + content: + application/json: + schema: + $ref: "../schemas/common.yaml#/ErrorResponse" + example: + error_code: "VALIDATION_ERROR" + message: "Request validation failed" + validation_errors: + - field: "query.address" + issue: "address must not be blank" + value: " " + '502': + description: The geocoding service (Nominatim) returned an error + content: + application/json: + schema: + $ref: "../schemas/common.yaml#/ErrorResponse" + example: + error_code: "GEOCODING_FAILED" + message: "Geocoding service error: HTTP Error 503: Service Unavailable" '504': description: Geocoding service timed out + content: + application/json: + schema: + $ref: "../schemas/common.yaml#/ErrorResponse" + example: + error_code: "GEOCODING_TIMEOUT" + message: "Geocoding service timed out for address: '100 Main St, Santa Cruz, CA'" '500': description: Internal server error + content: + application/json: + schema: + $ref: "../schemas/common.yaml#/ErrorResponse" diff --git a/contracts/schemas/common.yaml b/contracts/schemas/common.yaml index 0798f85..b1ae525 100644 --- a/contracts/schemas/common.yaml +++ b/contracts/schemas/common.yaml @@ -27,6 +27,30 @@ ErrorResponse: items: $ref: "#/ValidationError" +HTTPErrorDetail: + type: object + description: | + Error body of routes that raise FastAPI's HTTPException instead of an + AppError (some template and profile routes). + required: + - detail + properties: + detail: + type: string + example: "Template not found" + +StatusMessage: + type: object + required: + - status + - message + properties: + status: + type: string + example: "success" + message: + type: string + ValidationError: type: object properties: diff --git a/contracts/schemas/form-record.yaml b/contracts/schemas/form-record.yaml index 33cea06..edbe0c3 100644 --- a/contracts/schemas/form-record.yaml +++ b/contracts/schemas/form-record.yaml @@ -2,9 +2,10 @@ FormFillRequest: type: object + description: Send either input_id or input_text. When both are sent, + input_text is used. required: - template_id - - input_id properties: template_id: type: integer @@ -12,9 +13,15 @@ FormFillRequest: input_id: type: string format: uuid + nullable: true description: ID of a stored input (see POST /input/text, POST /input/voice) whose transcript is used to fill this template. The input must have status "ready". + input_text: + type: string + nullable: true + description: Narrative to fill from directly. If input_id is not sent, it + is stored as a new text input before filling. provider: allOf: - $ref: "enums.yaml#/LLMProvider" @@ -44,6 +51,11 @@ FormFillResponse: output_pdf_path: type: string description: Path to the filled PDF output file + values: + type: object + nullable: true + additionalProperties: true + description: Extracted field values, when returned AsyncFormFillRequest: type: object @@ -97,3 +109,98 @@ AsyncFormFillResponse: poll_url: type: string description: Path to poll via GET /api/v1/jobs/{job_id} + error: + type: string + nullable: true + description: Present only on a partial failure (HTTP 207), saying which + template could not be queued + +ModelsResponse: + type: object + required: + - models + - default + properties: + models: + type: array + items: + type: string + description: Models available in the local Ollama instance + default: + type: string + description: The configured default model + +TranscriptionResponse: + type: object + required: + - text + properties: + text: + type: string + +Submission: + type: object + required: + - id + - template_id + - template_name + - output_pdf_path + properties: + id: + type: integer + template_id: + type: integer + template_name: + type: string + description: Name of the template, or "Unknown Template" if it no longer exists + input_text: + type: string + nullable: true + output_pdf_path: + type: string + created_at: + type: string + format: date-time + nullable: true + +SubmissionAnalytics: + type: object + required: + - total_submissions + - by_template + - by_date + - common_terms + properties: + total_submissions: + type: integer + by_template: + type: array + description: Submission count per template, most used first + items: + type: object + properties: + template_name: + type: string + count: + type: integer + by_date: + type: array + description: Submission count per day, oldest first + items: + type: object + properties: + date: + type: string + format: date + count: + type: integer + common_terms: + type: array + description: The 12 most common words in the narratives, stop words removed + items: + type: object + properties: + word: + type: string + count: + type: integer diff --git a/contracts/schemas/profile.yaml b/contracts/schemas/profile.yaml new file mode 100644 index 0000000..c9acdfd --- /dev/null +++ b/contracts/schemas/profile.yaml @@ -0,0 +1,68 @@ +# Form filler profile schemas + +CreateProfileRequest: + type: object + required: + - name + - profession + - role + properties: + name: + type: string + description: Name of the person filling the form + profession: + type: string + description: Profession of the person + role: + type: string + description: Role of the person + description: + type: string + nullable: true + description: Optional profile description + custom_fields: + type: object + nullable: true + additionalProperties: true + default: {} + description: Extensible fields + +UpdateProfileRequest: + type: object + description: Only the keys sent are changed. + properties: + name: + type: string + nullable: true + profession: + type: string + nullable: true + role: + type: string + nullable: true + description: + type: string + nullable: true + custom_fields: + type: object + nullable: true + additionalProperties: true + +Profile: + allOf: + - $ref: "#/CreateProfileRequest" + - type: object + required: + - profile_id + - created_at + - updated_at + properties: + profile_id: + type: string + format: uuid + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time diff --git a/contracts/schemas/template.yaml b/contracts/schemas/template.yaml index 0127e5b..f6ee9e2 100644 --- a/contracts/schemas/template.yaml +++ b/contracts/schemas/template.yaml @@ -1,191 +1,144 @@ # Template & Configuration schemas -TemplateSummary: +Template: type: object + required: + - id + - name + - description + - pdf_path + - fields properties: - template_id: + id: type: integer - form_type: - type: string - description: Unique form type identifier (built-in or custom jurisdiction) - display_name: + name: type: string - jurisdiction: - type: string - nullable: true - description: Jurisdiction code (e.g. "US-Federal", "US-CA", "US-GA") - agency_type: - type: string - nullable: true - version: + description: type: string - last_updated: + description: Free-text description, also passed to the LLM as context when filling + pdf_path: type: string - format: date + description: Project-relative path of the template PDF + example: "data/inputs/ics_209.pdf" + fields: + $ref: "#/TemplateFields" field_count: type: integer - status: - type: string - enum: - - active - - legacy - - draft + nullable: true + description: Number of fields in `fields`, or of fillable widgets in the PDF + when `fields` is empty. Null if the PDF could not be read. + +TemplateFields: + type: object + description: | + The template's field dictionary. Either a flat map of field name to field + type (e.g. `{"incident_name": "string"}`), or, when extracted from the PDF, + `{"schema": , "tables": [...], "groups": [...]}`. Empty when + nothing has been defined or extracted. + additionalProperties: true CreateTemplateRequest: type: object required: - - form_type - - display_name - - fields + - name + - pdf_path properties: - form_type: - type: string - description: Unique form type identifier - display_name: + name: type: string - jurisdiction: + description: type: string - nullable: true - description: Jurisdiction code. Optional — the visual editor may register a - template before jurisdiction is assigned. - agency_type: + default: "" + pdf_path: type: string - nullable: true + description: Project-relative path of a PDF already stored in the project, + usually the `pdf_path` returned by POST /api/v1/templates/upload fields: - type: array - items: - $ref: "#/TemplateField" - source_standard: + allOf: + - $ref: "#/TemplateFields" + default: {} + description: Field dictionary. Leave empty to extract it from the PDF. + +UpdateTemplateRequest: + type: object + description: Only the keys sent are changed. + properties: + name: type: string nullable: true - description: Reference to the source standard (e.g. "NERIS v2.0", "NFIRS 5.0") - pdf_template_ref: + description: type: string nullable: true - description: Reference to the PDF template file the layout coordinates apply to - -Template: - allOf: - - type: object - properties: - template_id: - type: integer - version: - type: string - last_updated: - type: string - format: date - field_count: - type: integer - status: - type: string - enum: [active, legacy, draft] - created_at: - type: string - format: date-time - updated_at: - type: string - format: date-time - - $ref: "#/CreateTemplateRequest" + fields: + allOf: + - $ref: "#/TemplateFields" + nullable: true -TemplateField: +ExtractedField: type: object required: - - field_name - - field_type - - required + - name properties: - field_name: + name: type: string - description: Field identifier within this template - field_type: - type: string - enum: - - string - - integer - - number - - boolean - - date - - datetime - - time - - enum - - text - - array - description: Data type of the field - required: - type: boolean + description: Field name; table columns are named `.` description: type: string - nullable: true - max_length: + default: "" + type: + type: string + default: "string" + +TemplateUploadResponse: + type: object + required: + - filename + - pdf_path + properties: + filename: + type: string + description: Name the PDF was stored under (a timestamp is added on a name clash) + pdf_path: + type: string + description: Project-relative path to send to POST /api/v1/templates/create + field_count: type: integer nullable: true - min_value: - type: number - nullable: true - max_value: - type: number - nullable: true - allowed_values: + description: Number of fields found. Null if the PDF could not be read. + fields: type: array items: - type: string + $ref: "#/ExtractedField" + schema_data: + type: object nullable: true - description: Valid values for enum fields - static_text: - type: string - nullable: true - description: | - Fixed text drawn into the field instead of an LLM-extracted value. Null - for a field whose value comes from the transcript at fill time. - layout: + additionalProperties: true + description: JSON Schema extracted from the PDF, when extraction succeeded + tables: + type: array nullable: true - description: Visual placement of the field on the PDF. Null for fields with - no fixed position. - allOf: - - $ref: "#/TemplateFieldLayout" + items: + type: object + additionalProperties: true + description: Tables detected in the PDF, when extraction succeeded -TemplateFieldLayout: +MakeFillableRequest: type: object - description: | - Visual placement of a field on the PDF page. Coordinates are in PDF points - with the origin at the bottom-left of the page, so the field box runs from - start = (x, y) to end = (x + width, y + height). The editor is responsible - for converting rendered-canvas pixels to PDF points before saving. required: - - page - - x - - y - - width - - height + - pdf_path properties: - page: - type: integer - description: Zero-based page index - x: - type: number - description: Lower-left X of the box, in PDF points - y: - type: number - description: Lower-left Y of the box, in PDF points (origin bottom-left) - width: - type: number - height: - type: number - font: - type: string - default: Helvetica - font_size: - type: number - default: 10 - color: + pdf_path: type: string - default: "#000000" - description: Hex color, e.g. "#000000" - align: + description: Project-relative path of the stored PDF to make fillable + +MakeFillableResponse: + type: object + required: + - pdf_path + properties: + pdf_path: type: string - enum: - - left - - center - - right - default: left + description: Project-relative path of the new fillable copy + field_count: + type: integer + nullable: true + description: Number of fillable widgets in the new copy diff --git a/tests/test_contracts.py b/tests/test_contracts.py new file mode 100644 index 0000000..a2fbd7d --- /dev/null +++ b/tests/test_contracts.py @@ -0,0 +1,74 @@ +"""Keep contracts/ in sync with the API the app actually serves. + +Every endpoint under API_PREFIX must have a contract in contracts/openapi.yaml, +every documented endpoint must still exist, and every $ref must resolve. +""" + +from pathlib import Path + +import yaml + +from app.core.config import API_PREFIX +from app.main import app + +CONTRACTS_DIR = Path(__file__).resolve().parents[1] / "contracts" +HTTP_METHODS = {"get", "post", "put", "patch", "delete"} + + +def _load(path: Path) -> dict: + return yaml.safe_load(path.read_text(encoding="utf-8")) + + +def _resolve(ref: str, base: Path) -> tuple[object, Path]: + file_part, _, pointer = ref.partition("#") + target = (base.parent / file_part).resolve() if file_part else base + node = _load(target) + for key in [k for k in pointer.split("/") if k]: + node = node[key] + return node, target + + +def _documented_operations() -> set[tuple[str, str]]: + root = CONTRACTS_DIR / "openapi.yaml" + operations = set() + for path, item in _load(root)["paths"].items(): + if "$ref" in item: + item, _ = _resolve(item["$ref"], root) + operations |= {(method.upper(), path) for method in item if method in HTTP_METHODS} + return operations + + +def _served_operations() -> set[tuple[str, str]]: + return { + (method.upper(), path) + for path, item in app.openapi()["paths"].items() + if path.startswith(API_PREFIX) + for method in item + if method in HTTP_METHODS + } + + +def test_every_served_endpoint_has_a_contract(): + missing = _served_operations() - _documented_operations() + assert not missing, f"Endpoints with no contract in contracts/: {sorted(missing)}" + + +def test_every_contract_matches_a_served_endpoint(): + stale = _documented_operations() - _served_operations() + assert not stale, f"Contracts for endpoints that no longer exist: {sorted(stale)}" + + +def test_every_ref_resolves(): + def walk(node, source: Path): + if isinstance(node, dict): + for key, value in node.items(): + if key == "$ref": + _resolve(value, source) # raises KeyError / FileNotFoundError if broken + else: + walk(value, source) + elif isinstance(node, list): + for value in node: + walk(value, source) + + for path in CONTRACTS_DIR.rglob("*.yaml"): + walk(_load(path), path)