From 4afb0ce480f6e7bd75b25741df3736976f90f7c4 Mon Sep 17 00:00:00 2001 From: chetanr25 Date: Sat, 15 Aug 2026 14:00:32 +0530 Subject: [PATCH 1/4] feat(contract): validate returns 409 when not completed --- contracts/path/extraction.yaml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/contracts/path/extraction.yaml b/contracts/path/extraction.yaml index b2e4e8c..e6cdc04 100644 --- a/contracts/path/extraction.yaml +++ b/contracts/path/extraction.yaml @@ -419,3 +419,12 @@ validate: example: error_code: "TEMPLATE_NOT_FOUND" message: "Template with ID 550e8400-e29b-41d4-a716-446655440099 not found" + "409": + description: Extraction not yet completed + content: + application/json: + schema: + $ref: "../schemas/common.yaml#/ErrorResponse" + example: + error_code: "EXTRACT_NOT_COMPLETED" + message: "Extraction is still processing. Wait until status is 'completed'." From e6c7011fc95a4154ba6fd305b429001dbced90a7 Mon Sep 17 00:00:00 2001 From: chetanr25 Date: Sat, 15 Aug 2026 14:00:59 +0530 Subject: [PATCH 2/4] feat(extraction): readiness engine over template field lists --- app/api/schemas/extraction.py | 6 + app/services/extraction_readiness.py | 209 +++++++++++++++++++++++++++ 2 files changed, 215 insertions(+) create mode 100644 app/services/extraction_readiness.py diff --git a/app/api/schemas/extraction.py b/app/api/schemas/extraction.py index 621d8e7..80a4bb2 100644 --- a/app/api/schemas/extraction.py +++ b/app/api/schemas/extraction.py @@ -112,6 +112,12 @@ class FieldGap(BaseModel): description: str | None = None +class ValidationRequest(BaseModel): + """Which registered template to check the extraction against.""" + + template_id: UUID + + class ValidationResult(BaseModel): valid: bool template_id: UUID diff --git a/app/services/extraction_readiness.py b/app/services/extraction_readiness.py new file mode 100644 index 0000000..9f7d4bb --- /dev/null +++ b/app/services/extraction_readiness.py @@ -0,0 +1,209 @@ +"""Read-side check of an extraction against the templates that could be filled. + +Once extraction finishes, the responder still has to pick a form. That choice +only makes sense if the screen can say which forms are fillable right now and +what is missing on the ones that are not. Both endpoints answer that from +stored data: one template at a time (validate) or every active template at once +(readiness). No LLM, so it stays cheap to refetch after every correction. + +Plain dicts and repository calls only, no FastAPI. The route stays thin. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Any + +from app.api.schemas.enums import FieldSource, TemplateStatus +from app.api.schemas.extraction import ( + FieldGap, + ReadinessMatrix, + TemplateReadiness, + ValidationResult, +) +from app.api.schemas.templates import TemplateField +from app.models import Extraction, FormTemplate, Incident +from app.services.extraction_review import value_at + +# Open mapping on the contract. Manual and open template fields live here, and +# the extractor writes them under a single flat key, "{form_type}.{field_name}", +# rather than as nested objects. +CUSTOM_FIELDS = "custom_fields" + + +def is_filled(value: Any) -> bool: + """True when the value would actually print something on the form. + + A null, a blank string and an empty container are all a blank box to the + responder, so none of them close a gap. + """ + if value is None: + return False + if isinstance(value, str): + return bool(value.strip()) + if isinstance(value, (list, dict, tuple, set)): + return bool(value) + return True + + +def custom_key(field: TemplateField, form_type: str) -> str: + """The custom_fields key a manual or open field is stored under.""" + return f"{form_type}.{field.field_name}" + + +def resolve(contract: dict, field: TemplateField, form_type: str) -> Any: + """The value a template field would be filled with, or None. + + Where to look depends on the field's source. A schema field reads the + contract path it declares. A static field carries its own text. Manual and + open fields sit under custom_fields, which is a flat mapping: the dotted + key is one key, not a path, so it is read directly instead of walked. + """ + if field.source is FieldSource.static: + return field.static_text + if field.source is FieldSource.schema: + return value_at(contract, field.incident_mapping or "") + + custom = contract.get(CUSTOM_FIELDS) + if not isinstance(custom, dict): + return None + return custom.get(custom_key(field, form_type)) + + +def mapping_of(field: TemplateField, form_type: str) -> str | None: + """What to show the UI as the origin of a missing value. + + A schema field points at the contract path to correct. A manual or open + field points at the custom_fields key the typed value goes into. + """ + if field.source is FieldSource.schema: + return field.incident_mapping + if field.source is FieldSource.static: + return None + return f"{CUSTOM_FIELDS}.{custom_key(field, form_type)}" + + +def _gap(field: TemplateField, form_type: str) -> FieldGap: + return FieldGap( + field_name=field.field_name, + source=field.source, + incident_mapping=mapping_of(field, form_type), + description=field.description, + ) + + +def _parse_fields(template: FormTemplate) -> list[TemplateField]: + """The template's stored field definitions as models.""" + return [TemplateField.model_validate(entry) for entry in template.fields or []] + + +def warnings_for(gaps: list[FieldGap]) -> list[str]: + """One readable line per recommended field that has no value. + + Built from the gaps themselves rather than a rule table per form type, so + a newly registered template gets useful warnings without anyone adding to + a list first. + """ + lines = [] + for gap in gaps: + where = gap.incident_mapping or gap.field_name + line = f"{where} has no value. '{gap.field_name}' is optional on this form" + if gap.description: + line = f"{line}: {gap.description}" + lines.append(line) + return lines + + +@dataclass(frozen=True) +class Gaps: + """What one template is missing, and how much of it is filled.""" + + missing_required: list[FieldGap] + missing_recommended: list[FieldGap] + coverage_percent: float + + @property + def ready(self) -> bool: + return not self.missing_required + + +def gaps_for(contract: dict, template: FormTemplate) -> Gaps: + """Compare a contract document against one template's field list.""" + fields = _parse_fields(template) + missing_required: list[FieldGap] = [] + missing_recommended: list[FieldGap] = [] + filled = 0 + + for field in fields: + if is_filled(resolve(contract, field, template.form_type)): + filled += 1 + continue + gap = _gap(field, template.form_type) + if field.required: + missing_required.append(gap) + else: + missing_recommended.append(gap) + + coverage = round(filled / len(fields) * 100, 1) if fields else 0.0 + return Gaps(missing_required, missing_recommended, coverage) + + +def validate_template( + extraction: Extraction, incident: Incident, template: FormTemplate +) -> ValidationResult: + """The single-template answer: can this form be generated right now.""" + gaps = gaps_for(incident.incident_contract or {}, template) + return ValidationResult( + valid=gaps.ready, + template_id=template.template_id, + extract_id=extraction.extract_id, + form_type=template.form_type, + missing_required=gaps.missing_required, + missing_recommended=gaps.missing_recommended, + warnings=warnings_for(gaps.missing_recommended), + field_coverage_percent=gaps.coverage_percent, + ) + + +def readiness_matrix( + extraction: Extraction, incident: Incident, templates: list[FormTemplate] +) -> ReadinessMatrix: + """The same check across every active template, for the selection screen. + + Drafts and legacy templates are left out: nothing on the selection screen + should offer a form that is not in service. + """ + contract = incident.incident_contract or {} + rows = [] + for template in templates: + if template.status != TemplateStatus.active: + continue + gaps = gaps_for(contract, template) + rows.append( + TemplateReadiness( + template_id=template.template_id, + form_type=template.form_type, + display_name=template.display_name, + ready=gaps.ready, + missing_required=gaps.missing_required, + missing_recommended=gaps.missing_recommended, + field_coverage_percent=gaps.coverage_percent, + ) + ) + return ReadinessMatrix( + extract_id=extraction.extract_id, + templates=rows, + computed_at=datetime.now(timezone.utc), + ) + + +__all__ = [ + "Gaps", + "gaps_for", + "is_filled", + "readiness_matrix", + "resolve", + "validate_template", + "warnings_for", +] From 627115474f78342a50c33f44fef2027c6183a5c6 Mon Sep 17 00:00:00 2001 From: chetanr25 Date: Sat, 15 Aug 2026 14:01:27 +0530 Subject: [PATCH 3/4] feat(extraction): serve readiness and validate for an extraction --- app/api/routes/extraction.py | 26 ++++++++++++++++++++++++++ app/services/form_templates.py | 10 +++++----- 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/app/api/routes/extraction.py b/app/api/routes/extraction.py index e57ee75..bf6dc29 100644 --- a/app/api/routes/extraction.py +++ b/app/api/routes/extraction.py @@ -10,6 +10,9 @@ ExtractionJobResponse, ExtractionProcessing, ExtractionRequest, + ReadinessMatrix, + ValidationRequest, + ValidationResult, ) from app.api.schemas.incident_contract import IncidentContract from app.core.config import ( @@ -22,10 +25,13 @@ get_extraction_by_input, get_incident_by_extract, get_input, + list_form_templates, ) from app.models import Extraction, Incident from app.services.extraction.service import ExtractionService +from app.services.extraction_readiness import readiness_matrix, validate_template from app.services.extraction_review import ExtractionReviewService, load_for_review +from app.services.form_templates import require_template from app.services import llm router = APIRouter(prefix="/extract", tags=["extraction"]) @@ -169,3 +175,23 @@ def update_extraction( db, extraction, incident, patch ) return _completed_response(extraction, incident) + + +@router.get("/{extract_id}/readiness", response_model=ReadinessMatrix) +def get_readiness(extract_id: UUID, db: Session = Depends(get_db)): + extraction = _load_extraction(db, extract_id) + incident = load_for_review(extraction, get_incident_by_extract(db, extract_id)) + + return readiness_matrix(extraction, incident, list_form_templates(db)) + + +@router.post("/{extract_id}/validate", response_model=ValidationResult) +def validate_extraction( + extract_id: UUID, + body: ValidationRequest, + db: Session = Depends(get_db), +): + extraction = _load_extraction(db, extract_id) + incident = load_for_review(extraction, get_incident_by_extract(db, extract_id)) + + return validate_template(extraction, incident, require_template(db, body.template_id)) diff --git a/app/services/form_templates.py b/app/services/form_templates.py index ff6af5f..613c388 100644 --- a/app/services/form_templates.py +++ b/app/services/form_templates.py @@ -87,7 +87,7 @@ def _to_detail(template: FormTemplate) -> TemplateDetail: ) -def _require_template(db: Session, template_id: UUID) -> FormTemplate: +def require_template(db: Session, template_id: UUID) -> FormTemplate: template = get_form_template(db, template_id) if not template: raise AppError( @@ -126,13 +126,13 @@ def create_template(db: Session, body: CreateTemplateRequest) -> TemplateDetail: def get_template(db: Session, template_id: UUID) -> TemplateDetail: - return _to_detail(_require_template(db, template_id)) + return _to_detail(require_template(db, template_id)) def replace_template( db: Session, template_id: UUID, body: CreateTemplateRequest ) -> TemplateDetail: - template = _require_template(db, template_id) + template = require_template(db, template_id) # form_type is unique in the DB, so a rename onto a form_type another # template already holds has to be answered here. Without this the insert @@ -168,7 +168,7 @@ def resolve_template_pdf(db: Session, template_id: UUID) -> Path: `pdf_template_ref` is client-supplied, so the resolved path is checked to still sit under the data directory before anything is served from it. """ - template = _require_template(db, template_id) + template = require_template(db, template_id) if not template.pdf_template_ref: raise AppError( f"Template {template_id} has no source PDF", @@ -294,7 +294,7 @@ def draft_response(upload: TemplateUpload, job: Job | None) -> TemplateDraftAcce def get_template_fields( db: Session, template_id: UUID, required_only: bool ) -> TemplateFieldsResponse: - template = _require_template(db, template_id) + template = require_template(db, template_id) fields = [TemplateField(**f) for f in template.fields] required = [f for f in fields if f.required] From 9764207c91b4181faa6423f5713a77228799aec8 Mon Sep 17 00:00:00 2001 From: chetanr25 Date: Sat, 15 Aug 2026 14:01:49 +0530 Subject: [PATCH 4/4] test(extraction): cover the gap rules and both endpoints --- tests/test_v1_extraction_readiness.py | 441 ++++++++++++++++++++++++++ 1 file changed, 441 insertions(+) create mode 100644 tests/test_v1_extraction_readiness.py diff --git a/tests/test_v1_extraction_readiness.py b/tests/test_v1_extraction_readiness.py new file mode 100644 index 0000000..8365e31 --- /dev/null +++ b/tests/test_v1_extraction_readiness.py @@ -0,0 +1,441 @@ +"""Tests for the read side of the review screen. + +GET /api/v1/extract/{extract_id}/readiness answers which registered forms can +be generated from what was extracted, and POST /api/v1/extract/{extract_id}/validate +answers the same question for one template. Both run the same engine, so the +gap rules are tested once on the engine and then confirmed through the routes. +""" + +from datetime import datetime, timezone + +from app.api.schemas.enums import ( + ExtractionStatus, + InputStatus, + InputType, + ReportStatus, + TemplateStatus, +) +from app.api.schemas.templates import TemplateField +from app.db.repositories import ( + create_extraction, + create_form_template, + create_incident, + create_input, +) +from app.models import Extraction, FormTemplate, Incident, Input +from app.services.extraction_readiness import ( + gaps_for, + is_filled, + resolve, + warnings_for, +) + +URL = "/api/v1/extract" + +_CONTRACT = { + "schema_version": "1.1.0", + "schema_name": "fireform_incident_contract", + "incident": {"name": "Bear Creek Wildfire"}, + "location": {"city": "Reno", "state": "NV", "country": "US"}, + "fire": {"cause_certainty": "suspected"}, + "custom_fields": {"state_texas.marshal_signature_name": "A. Ruiz"}, +} + + +# --------------------------------------------------------------------------- +# Seed helpers +# --------------------------------------------------------------------------- + +def _field(name, source="schema", required=True, **extra) -> dict: + field = { + "field_name": name, + "field_type": "string", + "source": source, + "required": required, + } + if source == "schema": + field.setdefault("incident_mapping", "incident.name") + if source == "static": + field.setdefault("static_text", "Reno Fire Department") + if source == "open": + field.setdefault("description", "Anything the narrative says about it") + field.update(extra) + return field + + +def _template( + db, + form_type="state_texas", + display_name="Texas SFM", + fields=None, + status=TemplateStatus.active, +) -> FormTemplate: + return create_form_template( + db, + FormTemplate( + form_type=form_type, + display_name=display_name, + fields=fields if fields is not None else [_field("incident_name")], + status=status, + ), + ) + + +def _seed( + db, + contract=None, + extraction_status=ExtractionStatus.completed, +) -> tuple[Extraction, Incident]: + now = datetime.now(timezone.utc) + inp = create_input( + db, + Input( + input_type=InputType.text, + status=InputStatus.ready, + transcript="Wildfire off Bear Creek, no injuries.", + created_at=now, + updated_at=now, + ), + ) + extraction = create_extraction( + db, + Extraction( + input_id=inp.input_id, + status=extraction_status, + started_at=now, + completed_at=now if extraction_status == ExtractionStatus.completed else None, + model_used="qwen2.5:1.5b", + ), + ) + incident = create_incident( + db, + Incident( + extract_id=extraction.extract_id, + status=ReportStatus.draft, + incident_contract=_CONTRACT if contract is None else contract, + ), + ) + return extraction, incident + + +def _validate(client, extract_id, template_id): + return client.post(f"{URL}/{extract_id}/validate", json={"template_id": str(template_id)}) + + +# --------------------------------------------------------------------------- +# When a field counts as filled +# --------------------------------------------------------------------------- + +class TestIsFilled: + + def test_null_is_a_gap(self): + assert is_filled(None) is False + + def test_blank_string_is_a_gap(self): + assert is_filled("") is False + assert is_filled(" ") is False + + def test_empty_container_is_a_gap(self): + assert is_filled([]) is False + assert is_filled({}) is False + + def test_zero_and_false_are_values(self): + assert is_filled(0) is True + assert is_filled(False) is True + + def test_text_and_numbers_are_values(self): + assert is_filled("Reno") is True + assert is_filled(10000) is True + + +# --------------------------------------------------------------------------- +# Where a value comes from +# --------------------------------------------------------------------------- + +class TestResolve: + + def test_schema_field_reads_its_contract_path(self): + field = TemplateField.model_validate( + _field("city", incident_mapping="location.city") + ) + assert resolve(_CONTRACT, field, "state_texas") == "Reno" + + def test_missing_path_resolves_to_none(self): + field = TemplateField.model_validate( + _field("loss", incident_mapping="losses.property_loss.amount") + ) + assert resolve(_CONTRACT, field, "state_texas") is None + + def test_static_field_carries_its_own_text(self): + field = TemplateField.model_validate(_field("agency", source="static")) + assert resolve({}, field, "state_texas") == "Reno Fire Department" + + def test_manual_field_reads_the_flat_custom_fields_key(self): + field = TemplateField.model_validate( + _field("marshal_signature_name", source="manual") + ) + assert resolve(_CONTRACT, field, "state_texas") == "A. Ruiz" + + def test_manual_field_of_another_form_type_does_not_match(self): + field = TemplateField.model_validate( + _field("marshal_signature_name", source="manual") + ) + assert resolve(_CONTRACT, field, "neris") is None + + def test_contract_without_custom_fields_resolves_to_none(self): + field = TemplateField.model_validate(_field("notes", source="open")) + assert resolve({"incident": {"name": "x"}}, field, "state_texas") is None + + +# --------------------------------------------------------------------------- +# Gaps and coverage +# --------------------------------------------------------------------------- + +class TestGaps: + + def test_required_gap_blocks_and_optional_gap_does_not(self, db): + template = _template( + db, + fields=[ + _field("city", incident_mapping="location.city"), + _field("loss", required=True, incident_mapping="losses.property_loss.amount"), + _field("alarm", required=False, incident_mapping="risk_reduction.smoke_alarm"), + ], + ) + gaps = gaps_for(_CONTRACT, template) + + assert gaps.ready is False + assert [g.field_name for g in gaps.missing_required] == ["loss"] + assert [g.field_name for g in gaps.missing_recommended] == ["alarm"] + + def test_ready_when_every_required_field_resolves(self, db): + template = _template( + db, + fields=[ + _field("city", incident_mapping="location.city"), + _field("alarm", required=False, incident_mapping="risk_reduction.smoke_alarm"), + ], + ) + gaps = gaps_for(_CONTRACT, template) + + assert gaps.ready is True + assert gaps.missing_required == [] + + def test_coverage_counts_filled_over_total(self, db): + template = _template( + db, + fields=[ + _field("city", incident_mapping="location.city"), + _field("agency", source="static"), + _field("loss", incident_mapping="losses.property_loss.amount"), + _field("cause", incident_mapping="fire.cause_certainty"), + ], + ) + assert gaps_for(_CONTRACT, template).coverage_percent == 75.0 + + def test_template_without_fields_is_ready(self, db): + template = _template(db, fields=[]) + gaps = gaps_for(_CONTRACT, template) + + assert gaps.ready is True + assert gaps.coverage_percent == 0.0 + + def test_gap_points_a_schema_field_at_its_contract_path(self, db): + template = _template( + db, fields=[_field("loss", incident_mapping="losses.property_loss.amount")] + ) + gap = gaps_for(_CONTRACT, template).missing_required[0] + + assert gap.source == "schema" + assert gap.incident_mapping == "losses.property_loss.amount" + + def test_gap_points_a_manual_field_at_its_custom_fields_key(self, db): + template = _template(db, fields=[_field("chief_name", source="manual")]) + gap = gaps_for(_CONTRACT, template).missing_required[0] + + assert gap.source == "manual" + assert gap.incident_mapping == "custom_fields.state_texas.chief_name" + + def test_warnings_describe_the_recommended_gaps(self, db): + template = _template( + db, + fields=[ + _field( + "alarm", + required=False, + incident_mapping="risk_reduction.smoke_alarm", + description="NERIS recommends damage estimates", + ) + ], + ) + warnings = warnings_for(gaps_for(_CONTRACT, template).missing_recommended) + + assert len(warnings) == 1 + assert "risk_reduction.smoke_alarm" in warnings[0] + assert "NERIS recommends damage estimates" in warnings[0] + + +# --------------------------------------------------------------------------- +# POST /api/v1/extract/{extract_id}/validate +# --------------------------------------------------------------------------- + +class TestValidateExtraction: + + def test_200_valid_when_nothing_required_is_missing(self, client, db): + extraction, _ = _seed(db) + template = _template(db, fields=[_field("city", incident_mapping="location.city")]) + + resp = _validate(client, extraction.extract_id, template.template_id) + body = resp.json() + + assert resp.status_code == 200 + assert body["valid"] is True + assert body["form_type"] == "state_texas" + assert body["extract_id"] == str(extraction.extract_id) + assert body["missing_required"] == [] + assert body["field_coverage_percent"] == 100.0 + + def test_200_invalid_lists_the_blocking_fields(self, client, db): + extraction, _ = _seed(db) + template = _template( + db, + fields=[ + _field("city", incident_mapping="location.city"), + _field("loss", incident_mapping="losses.property_loss.amount"), + ], + ) + + body = _validate(client, extraction.extract_id, template.template_id).json() + + assert body["valid"] is False + assert [g["field_name"] for g in body["missing_required"]] == ["loss"] + + def test_correcting_the_contract_flips_it_to_valid(self, client, db): + extraction, _ = _seed(db) + template = _template( + db, fields=[_field("loss", incident_mapping="losses.property_loss.amount")] + ) + assert _validate(client, extraction.extract_id, template.template_id).json()["valid"] is False + + client.patch( + f"{URL}/{extraction.extract_id}", + json={"losses": {"property_loss": {"amount": 250000}}}, + headers={"Content-Type": "application/merge-patch+json"}, + ) + + assert _validate(client, extraction.extract_id, template.template_id).json()["valid"] is True + + def test_validate_checks_a_legacy_template_too(self, client, db): + extraction, _ = _seed(db) + template = _template( + db, + fields=[_field("city", incident_mapping="location.city")], + status=TemplateStatus.legacy, + ) + + resp = _validate(client, extraction.extract_id, template.template_id) + + assert resp.status_code == 200 + assert resp.json()["valid"] is True + + def test_404_when_the_extraction_is_unknown(self, client, db): + template = _template(db) + resp = _validate(client, "550e8400-e29b-41d4-a716-446655440099", template.template_id) + + assert resp.status_code == 404 + assert resp.json()["error_code"] == "EXTRACT_NOT_FOUND" + + def test_404_when_the_template_is_unknown(self, client, db): + extraction, _ = _seed(db) + resp = _validate( + client, extraction.extract_id, "550e8400-e29b-41d4-a716-446655440099" + ) + + assert resp.status_code == 404 + assert resp.json()["error_code"] == "TEMPLATE_NOT_FOUND" + + def test_409_while_the_extraction_is_still_running(self, client, db): + extraction, _ = _seed(db, extraction_status=ExtractionStatus.processing) + template = _template(db) + + resp = _validate(client, extraction.extract_id, template.template_id) + + assert resp.status_code == 409 + assert resp.json()["error_code"] == "EXTRACT_NOT_COMPLETED" + + +# --------------------------------------------------------------------------- +# GET /api/v1/extract/{extract_id}/readiness +# --------------------------------------------------------------------------- + +class TestReadiness: + + def test_200_reports_every_active_template(self, client, db): + extraction, _ = _seed(db) + _template(db, form_type="neris", display_name="NERIS Incident Report") + _template( + db, + form_type="state_texas", + fields=[_field("loss", incident_mapping="losses.property_loss.amount")], + ) + + resp = client.get(f"{URL}/{extraction.extract_id}/readiness") + body = resp.json() + + assert resp.status_code == 200 + assert body["extract_id"] == str(extraction.extract_id) + assert body["computed_at"] + + rows = {row["form_type"]: row for row in body["templates"]} + assert rows["neris"]["ready"] is True + assert rows["neris"]["display_name"] == "NERIS Incident Report" + assert rows["state_texas"]["ready"] is False + assert rows["state_texas"]["missing_required"][0]["field_name"] == "loss" + + def test_drafts_and_legacy_templates_stay_out(self, client, db): + extraction, _ = _seed(db) + _template(db, form_type="neris") + _template(db, form_type="old_form", status=TemplateStatus.legacy) + _template(db, form_type="wip_form", status=TemplateStatus.draft) + + body = client.get(f"{URL}/{extraction.extract_id}/readiness").json() + + assert [row["form_type"] for row in body["templates"]] == ["neris"] + + def test_empty_registry_gives_an_empty_matrix(self, client, db): + extraction, _ = _seed(db) + + body = client.get(f"{URL}/{extraction.extract_id}/readiness").json() + + assert body["templates"] == [] + + def test_readiness_agrees_with_validate(self, client, db): + extraction, _ = _seed(db) + template = _template( + db, + fields=[ + _field("city", incident_mapping="location.city"), + _field("loss", incident_mapping="losses.property_loss.amount"), + ], + ) + + row = client.get(f"{URL}/{extraction.extract_id}/readiness").json()["templates"][0] + single = _validate(client, extraction.extract_id, template.template_id).json() + + assert row["ready"] == single["valid"] + assert row["missing_required"] == single["missing_required"] + assert row["field_coverage_percent"] == single["field_coverage_percent"] + + def test_404_when_the_extraction_is_unknown(self, client, db): + resp = client.get(f"{URL}/550e8400-e29b-41d4-a716-446655440099/readiness") + + assert resp.status_code == 404 + assert resp.json()["error_code"] == "EXTRACT_NOT_FOUND" + + def test_409_while_the_extraction_is_still_running(self, client, db): + extraction, _ = _seed(db, extraction_status=ExtractionStatus.processing) + + resp = client.get(f"{URL}/{extraction.extract_id}/readiness") + + assert resp.status_code == 409 + assert resp.json()["error_code"] == "EXTRACT_NOT_COMPLETED"