From 61f579290fca5e555bb601b377131336134315e9 Mon Sep 17 00:00:00 2001 From: Shrvan Benke Date: Thu, 6 Aug 2026 18:20:20 +0530 Subject: [PATCH 1/9] test: add corrective plan coverage verifier --- .../restaurant-corrective-plan/.gitignore | 8 ++ .../backend/app/__init__.py | 1 + .../backend/app/models.py | 112 +++++++++++++++++ .../backend/app/verifier.py | 116 ++++++++++++++++++ .../backend/pyproject.toml | 18 +++ .../backend/tests/fixtures/blended_plan.json | 26 ++++ .../tests/fixtures/boilerplate_plan.json | 26 ++++ .../backend/tests/fixtures/good_plan.json | 32 +++++ .../backend/tests/fixtures/violations.json | 23 ++++ .../backend/tests/test_verifier.py | 80 ++++++++++++ 10 files changed, 442 insertions(+) create mode 100644 use-cases/01shrvan/restaurant-corrective-plan/.gitignore create mode 100644 use-cases/01shrvan/restaurant-corrective-plan/backend/app/__init__.py create mode 100644 use-cases/01shrvan/restaurant-corrective-plan/backend/app/models.py create mode 100644 use-cases/01shrvan/restaurant-corrective-plan/backend/app/verifier.py create mode 100644 use-cases/01shrvan/restaurant-corrective-plan/backend/pyproject.toml create mode 100644 use-cases/01shrvan/restaurant-corrective-plan/backend/tests/fixtures/blended_plan.json create mode 100644 use-cases/01shrvan/restaurant-corrective-plan/backend/tests/fixtures/boilerplate_plan.json create mode 100644 use-cases/01shrvan/restaurant-corrective-plan/backend/tests/fixtures/good_plan.json create mode 100644 use-cases/01shrvan/restaurant-corrective-plan/backend/tests/fixtures/violations.json create mode 100644 use-cases/01shrvan/restaurant-corrective-plan/backend/tests/test_verifier.py diff --git a/use-cases/01shrvan/restaurant-corrective-plan/.gitignore b/use-cases/01shrvan/restaurant-corrective-plan/.gitignore new file mode 100644 index 00000000..55a21cba --- /dev/null +++ b/use-cases/01shrvan/restaurant-corrective-plan/.gitignore @@ -0,0 +1,8 @@ +.env +.venv/ +__pycache__/ +.pytest_cache/ +*.pyc +*.egg-info/ +dist/ +node_modules/ diff --git a/use-cases/01shrvan/restaurant-corrective-plan/backend/app/__init__.py b/use-cases/01shrvan/restaurant-corrective-plan/backend/app/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/use-cases/01shrvan/restaurant-corrective-plan/backend/app/__init__.py @@ -0,0 +1 @@ + diff --git a/use-cases/01shrvan/restaurant-corrective-plan/backend/app/models.py b/use-cases/01shrvan/restaurant-corrective-plan/backend/app/models.py new file mode 100644 index 00000000..d4f9c6dd --- /dev/null +++ b/use-cases/01shrvan/restaurant-corrective-plan/backend/app/models.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +from datetime import date +from typing import Literal + +from pydantic import BaseModel, ConfigDict, field_validator + + +def _require_non_empty(value: str, field_name: str) -> str: + if not value or not value.strip(): + raise ValueError(f"{field_name} is required and cannot be empty") + return value.strip() + + +class Violation(BaseModel): + model_config = ConfigDict(frozen=True) + + code: str + description: str + location: str | None = None + observed_on: date | None = None + + @field_validator("code") + @classmethod + def code_required(cls, value: str) -> str: + return _require_non_empty(value, "code") + + @field_validator("description") + @classmethod + def description_required(cls, value: str) -> str: + return _require_non_empty(value, "description") + + +class CorrectiveItem(BaseModel): + model_config = ConfigDict(frozen=True) + + code: str + finding: str + corrective_action: str + responsible_party: str + completion_target: str | None = None + evidence: str | None = None + + @field_validator("code") + @classmethod + def code_required(cls, value: str) -> str: + return _require_non_empty(value, "code") + + @field_validator("finding") + @classmethod + def finding_required(cls, value: str) -> str: + return _require_non_empty(value, "finding") + + @field_validator("corrective_action") + @classmethod + def corrective_action_required(cls, value: str) -> str: + return _require_non_empty(value, "corrective_action") + + @field_validator("responsible_party") + @classmethod + def responsible_party_required(cls, value: str) -> str: + return _require_non_empty(value, "responsible_party") + + +class CorrectivePlan(BaseModel): + model_config = ConfigDict(frozen=True) + + restaurant: str + inspection_date: date + health_department: str + items: tuple[CorrectiveItem, ...] + + @field_validator("restaurant") + @classmethod + def restaurant_required(cls, value: str) -> str: + return _require_non_empty(value, "restaurant") + + @field_validator("health_department") + @classmethod + def department_required(cls, value: str) -> str: + return _require_non_empty(value, "health_department") + + +class CodeCoverage(BaseModel): + model_config = ConfigDict(frozen=True) + + code: str + status: Literal["covered", "incomplete"] + reasons: tuple[str, ...] = () + + @property + def is_complete(self) -> bool: + return self.status == "covered" + + +class CoverageReport(BaseModel): + model_config = ConfigDict(frozen=True) + + codes: tuple[CodeCoverage, ...] + + @property + def is_complete(self) -> bool: + return all(code.is_complete for code in self.codes) + + @property + def missing_codes(self) -> tuple[str, ...]: + return tuple( + code.code + for code in self.codes + if any(reason.startswith("code not present") for reason in code.reasons) + ) + diff --git a/use-cases/01shrvan/restaurant-corrective-plan/backend/app/verifier.py b/use-cases/01shrvan/restaurant-corrective-plan/backend/app/verifier.py new file mode 100644 index 00000000..e78155c7 --- /dev/null +++ b/use-cases/01shrvan/restaurant-corrective-plan/backend/app/verifier.py @@ -0,0 +1,116 @@ +from __future__ import annotations + +import re +from collections import Counter +from difflib import SequenceMatcher + +from .models import CodeCoverage, CorrectivePlan, CoverageReport, Violation + + +_BOILERPLATE_PATTERNS = ( + re.compile(r"\b(correct|corrective|address|resolve)\s+(the\s+)?violation\b", re.I), + re.compile(r"\b(per|according to|in accordance with)\s+(code|requirements?)\b", re.I), + re.compile(r"\b(code|food code)\s+requirements?\b", re.I), +) + + +def verify_coverage( + violations: tuple[Violation, ...] | list[Violation], + plan: CorrectivePlan, +) -> CoverageReport: + items_by_code = {item.code: item for item in plan.items} + action_counts = Counter(_fingerprint(item.corrective_action) for item in plan.items) + owner_counts = Counter(_fingerprint(item.responsible_party) for item in plan.items) + + code_reports: list[CodeCoverage] = [] + for violation in violations: + item = items_by_code.get(violation.code) + reasons: list[str] = [] + if item is None: + code_reports.append( + CodeCoverage( + code=violation.code, + status="incomplete", + reasons=("code not present in corrective plan",), + ) + ) + continue + + action = item.corrective_action.strip() + owner = item.responsible_party.strip() + finding = item.finding.strip() + + if _fingerprint(action) == _fingerprint(finding): + reasons.append("corrective action repeats the finding instead of stating a fix") + if action_counts[_fingerprint(action)] > 1: + reasons.append("corrective action is copied from another violation") + if owner_counts[_fingerprint(owner)] > 1: + reasons.append("responsible party is copied from another violation") + if _is_boilerplate(action, violation): + reasons.append("corrective action is boilerplate and not specific to the violation") + if _near_duplicate_action(action, item.code, plan): + reasons.append("corrective action is near-identical to another violation") + + code_reports.append( + CodeCoverage( + code=violation.code, + status="incomplete" if reasons else "covered", + reasons=tuple(reasons), + ) + ) + + return CoverageReport(codes=tuple(code_reports)) + + +def _fingerprint(value: str) -> str: + return re.sub(r"[^a-z0-9]+", " ", value.lower()).strip() + + +def _without_code(value: str, code: str) -> str: + return _fingerprint(value.replace(code, " ")) + + +def _is_boilerplate(action: str, violation: Violation) -> bool: + normalized = _without_code(action, violation.code) + has_boilerplate = sum(1 for pattern in _BOILERPLATE_PATTERNS if pattern.search(normalized)) >= 2 + has_domain_detail = any( + word in normalized + for word in _meaningful_terms(violation.description, violation.location) + ) + return has_boilerplate and not has_domain_detail + + +def _near_duplicate_action(action: str, code: str, plan: CorrectivePlan) -> bool: + candidate = _without_code(action, code) + for other in plan.items: + if other.code == code: + continue + other_action = _without_code(other.corrective_action, other.code) + if SequenceMatcher(None, candidate, other_action).ratio() >= 0.82: + return True + return False + + +def _meaningful_terms(description: str, location: str | None) -> set[str]: + text = f"{description} {location or ''}".lower() + words = { + word + for word in re.findall(r"[a-z][a-z0-9-]{3,}", text) + if word + not in { + "area", + "code", + "with", + "that", + "this", + "from", + "requirement", + "requirements", + "minimum", + "violation", + "violations", + "supplied", + } + } + return words + diff --git a/use-cases/01shrvan/restaurant-corrective-plan/backend/pyproject.toml b/use-cases/01shrvan/restaurant-corrective-plan/backend/pyproject.toml new file mode 100644 index 00000000..949416ba --- /dev/null +++ b/use-cases/01shrvan/restaurant-corrective-plan/backend/pyproject.toml @@ -0,0 +1,18 @@ +[project] +name = "restaurant-corrective-plan" +version = "0.1.0" +description = "SuperDocs restaurant inspection corrective-plan build" +requires-python = ">=3.12" +dependencies = [ + "pydantic>=2.8,<3", +] + +[project.optional-dependencies] +test = [ + "pytest>=8.3,<9", +] + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["."] + diff --git a/use-cases/01shrvan/restaurant-corrective-plan/backend/tests/fixtures/blended_plan.json b/use-cases/01shrvan/restaurant-corrective-plan/backend/tests/fixtures/blended_plan.json new file mode 100644 index 00000000..c58574a9 --- /dev/null +++ b/use-cases/01shrvan/restaurant-corrective-plan/backend/tests/fixtures/blended_plan.json @@ -0,0 +1,26 @@ +{ + "restaurant": "The Copper Kettle", + "inspection_date": "2026-08-03", + "health_department": "Riverside County Environmental Health", + "items": [ + { + "code": "5-202.11", + "finding": "Handwashing sink in the prep area is not supplied with hot water at 100 F minimum.", + "corrective_action": "Management will correct the hot water, hot holding, and cleaning violations, retrain staff, monitor the restaurant, and keep records for the health department.", + "responsible_party": "General Manager" + }, + { + "code": "3-501.16", + "finding": "Cooked rice held at 118 F in the steam well, below the 135 F hot-holding requirement.", + "corrective_action": "Management will correct the hot water, hot holding, and cleaning violations, retrain staff, monitor the restaurant, and keep records for the health department.", + "responsible_party": "General Manager" + }, + { + "code": "6-501.12", + "finding": "Accumulated grease and debris beneath the fryer line.", + "corrective_action": "Management will correct the hot water, hot holding, and cleaning violations, retrain staff, monitor the restaurant, and keep records for the health department.", + "responsible_party": "General Manager" + } + ] +} + diff --git a/use-cases/01shrvan/restaurant-corrective-plan/backend/tests/fixtures/boilerplate_plan.json b/use-cases/01shrvan/restaurant-corrective-plan/backend/tests/fixtures/boilerplate_plan.json new file mode 100644 index 00000000..3598e8dc --- /dev/null +++ b/use-cases/01shrvan/restaurant-corrective-plan/backend/tests/fixtures/boilerplate_plan.json @@ -0,0 +1,26 @@ +{ + "restaurant": "The Copper Kettle", + "inspection_date": "2026-08-03", + "health_department": "Riverside County Environmental Health", + "items": [ + { + "code": "5-202.11", + "finding": "Handwashing sink in the prep area is not supplied with hot water at 100 F minimum.", + "corrective_action": "Correct violation 5-202.11 per code requirements and document completion for the inspector.", + "responsible_party": "Facilities Lead" + }, + { + "code": "3-501.16", + "finding": "Cooked rice held at 118 F in the steam well, below the 135 F hot-holding requirement.", + "corrective_action": "Correct violation 3-501.16 per code requirements and document completion for the inspector.", + "responsible_party": "Kitchen Manager" + }, + { + "code": "6-501.12", + "finding": "Accumulated grease and debris beneath the fryer line.", + "corrective_action": "Correct violation 6-501.12 per code requirements and document completion for the inspector.", + "responsible_party": "Closing Supervisor" + } + ] +} + diff --git a/use-cases/01shrvan/restaurant-corrective-plan/backend/tests/fixtures/good_plan.json b/use-cases/01shrvan/restaurant-corrective-plan/backend/tests/fixtures/good_plan.json new file mode 100644 index 00000000..961d8ccc --- /dev/null +++ b/use-cases/01shrvan/restaurant-corrective-plan/backend/tests/fixtures/good_plan.json @@ -0,0 +1,32 @@ +{ + "restaurant": "The Copper Kettle", + "inspection_date": "2026-08-03", + "health_department": "Riverside County Environmental Health", + "items": [ + { + "code": "5-202.11", + "finding": "Handwashing sink in the prep area is not supplied with hot water at 100 F minimum.", + "corrective_action": "Restore the prep-area handwashing sink hot-water supply to at least 100 F, verify the faucet temperature at opening and mid-shift, and keep a signed temperature log at the prep station.", + "responsible_party": "Facilities Lead", + "completion_target": "2026-08-05", + "evidence": "Plumber invoice and two days of sink temperature logs" + }, + { + "code": "3-501.16", + "finding": "Cooked rice held at 118 F in the steam well, below the 135 F hot-holding requirement.", + "corrective_action": "Discard rice held below temperature, recalibrate the line steam well to hold cooked rice at 135 F or above, and require the line cook to record hot-holding temperatures every two hours.", + "responsible_party": "Kitchen Manager", + "completion_target": "2026-08-03", + "evidence": "Discard log, calibration record, and hot-holding temperature sheet" + }, + { + "code": "6-501.12", + "finding": "Accumulated grease and debris beneath the fryer line.", + "corrective_action": "Pull the fryer bank out of service for deep cleaning beneath and behind the line, add the fry station floor area to the nightly closing checklist, and photograph the cleaned area after each closing shift for one week.", + "responsible_party": "Closing Supervisor", + "completion_target": "2026-08-04", + "evidence": "Updated closing checklist and dated cleaning photos" + } + ] +} + diff --git a/use-cases/01shrvan/restaurant-corrective-plan/backend/tests/fixtures/violations.json b/use-cases/01shrvan/restaurant-corrective-plan/backend/tests/fixtures/violations.json new file mode 100644 index 00000000..8b96c0fb --- /dev/null +++ b/use-cases/01shrvan/restaurant-corrective-plan/backend/tests/fixtures/violations.json @@ -0,0 +1,23 @@ +{ + "restaurant": "The Copper Kettle", + "inspection_date": "2026-08-03", + "health_department": "Riverside County Environmental Health", + "violations": [ + { + "code": "5-202.11", + "description": "Handwashing sink in the prep area is not supplied with hot water at 100 F minimum.", + "location": "Prep area" + }, + { + "code": "3-501.16", + "description": "Cooked rice held at 118 F in the steam well, below the 135 F hot-holding requirement.", + "location": "Line steam well" + }, + { + "code": "6-501.12", + "description": "Accumulated grease and debris beneath the fryer line.", + "location": "Fry station" + } + ] +} + diff --git a/use-cases/01shrvan/restaurant-corrective-plan/backend/tests/test_verifier.py b/use-cases/01shrvan/restaurant-corrective-plan/backend/tests/test_verifier.py new file mode 100644 index 00000000..6b8758f1 --- /dev/null +++ b/use-cases/01shrvan/restaurant-corrective-plan/backend/tests/test_verifier.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +from pydantic import ValidationError + +from app.models import CorrectivePlan, Violation +from app.verifier import verify_coverage + + +FIXTURES = Path(__file__).parent / "fixtures" + + +def load_violations() -> tuple[Violation, ...]: + payload = json.loads((FIXTURES / "violations.json").read_text(encoding="utf-8")) + return tuple(Violation(**violation) for violation in payload["violations"]) + + +def load_plan(name: str) -> CorrectivePlan: + return CorrectivePlan(**json.loads((FIXTURES / name).read_text(encoding="utf-8"))) + + +def reasons(report) -> dict[str, tuple[str, ...]]: + return {code.code: code.reasons for code in report.codes} + + +def test_good_plan_covers_each_violation_code_individually() -> None: + report = verify_coverage(load_violations(), load_plan("good_plan.json")) + + assert report.is_complete + assert [code.code for code in report.codes] == ["5-202.11", "3-501.16", "6-501.12"] + assert all(not code.reasons for code in report.codes) + + +def test_blended_one_paragraph_plan_is_rejected() -> None: + report = verify_coverage(load_violations(), load_plan("blended_plan.json")) + + assert not report.is_complete + by_code = reasons(report) + assert "corrective action is copied from another violation" in by_code["5-202.11"] + assert "responsible party is copied from another violation" in by_code["3-501.16"] + assert "corrective action is copied from another violation" in by_code["6-501.12"] + + +def test_individually_coded_boilerplate_plan_is_rejected() -> None: + report = verify_coverage(load_violations(), load_plan("boilerplate_plan.json")) + + assert not report.is_complete + for code_report in report.codes: + assert ( + "corrective action is boilerplate and not specific to the violation" + in code_report.reasons + ) + assert "corrective action is near-identical to another violation" in code_report.reasons + + +def test_missing_violation_code_or_description_names_field() -> None: + with pytest.raises(ValidationError) as missing_code: + Violation(code="", description="A valid finding") + assert "code" in str(missing_code.value) + + with pytest.raises(ValidationError) as missing_description: + Violation(code="5-202.11", description=" ") + assert "description" in str(missing_description.value) + + +def test_corrective_item_without_action_or_owner_cannot_construct() -> None: + payload = json.loads((FIXTURES / "good_plan.json").read_text(encoding="utf-8")) + payload["items"][0]["corrective_action"] = "" + payload["items"][1]["responsible_party"] = " " + + with pytest.raises(ValidationError) as error: + CorrectivePlan(**payload) + + message = str(error.value) + assert "corrective_action" in message + assert "responsible_party" in message + From 6cb4c1b6b39ad52351bdfc83fd962893b61533f9 Mon Sep 17 00:00:00 2001 From: Shrvan Benke Date: Thu, 6 Aug 2026 18:24:47 +0530 Subject: [PATCH 2/9] fix: reject empty and invented corrective coverage --- .../backend/app/models.py | 6 ++- .../backend/app/verifier.py | 50 ++++++++++++++++--- .../tests/fixtures/invented_code_plan.json | 32 ++++++++++++ .../location_only_boilerplate_plan.json | 26 ++++++++++ .../backend/tests/test_verifier.py | 27 +++++++++- 5 files changed, 132 insertions(+), 9 deletions(-) create mode 100644 use-cases/01shrvan/restaurant-corrective-plan/backend/tests/fixtures/invented_code_plan.json create mode 100644 use-cases/01shrvan/restaurant-corrective-plan/backend/tests/fixtures/location_only_boilerplate_plan.json diff --git a/use-cases/01shrvan/restaurant-corrective-plan/backend/app/models.py b/use-cases/01shrvan/restaurant-corrective-plan/backend/app/models.py index d4f9c6dd..ce242b9e 100644 --- a/use-cases/01shrvan/restaurant-corrective-plan/backend/app/models.py +++ b/use-cases/01shrvan/restaurant-corrective-plan/backend/app/models.py @@ -97,10 +97,13 @@ class CoverageReport(BaseModel): model_config = ConfigDict(frozen=True) codes: tuple[CodeCoverage, ...] + uninvited_codes: tuple[str, ...] = () @property def is_complete(self) -> bool: - return all(code.is_complete for code in self.codes) + return bool(self.codes) and not self.uninvited_codes and all( + code.is_complete for code in self.codes + ) @property def missing_codes(self) -> tuple[str, ...]: @@ -109,4 +112,3 @@ def missing_codes(self) -> tuple[str, ...]: for code in self.codes if any(reason.startswith("code not present") for reason in code.reasons) ) - diff --git a/use-cases/01shrvan/restaurant-corrective-plan/backend/app/verifier.py b/use-cases/01shrvan/restaurant-corrective-plan/backend/app/verifier.py index e78155c7..33b972c8 100644 --- a/use-cases/01shrvan/restaurant-corrective-plan/backend/app/verifier.py +++ b/use-cases/01shrvan/restaurant-corrective-plan/backend/app/verifier.py @@ -19,6 +19,8 @@ def verify_coverage( plan: CorrectivePlan, ) -> CoverageReport: items_by_code = {item.code: item for item in plan.items} + cited_codes = {violation.code for violation in violations} + uninvited_codes = tuple(item.code for item in plan.items if item.code not in cited_codes) action_counts = Counter(_fingerprint(item.corrective_action) for item in plan.items) owner_counts = Counter(_fingerprint(item.responsible_party) for item in plan.items) @@ -59,7 +61,7 @@ def verify_coverage( ) ) - return CoverageReport(codes=tuple(code_reports)) + return CoverageReport(codes=tuple(code_reports), uninvited_codes=uninvited_codes) def _fingerprint(value: str) -> str: @@ -73,11 +75,7 @@ def _without_code(value: str, code: str) -> str: def _is_boilerplate(action: str, violation: Violation) -> bool: normalized = _without_code(action, violation.code) has_boilerplate = sum(1 for pattern in _BOILERPLATE_PATTERNS if pattern.search(normalized)) >= 2 - has_domain_detail = any( - word in normalized - for word in _meaningful_terms(violation.description, violation.location) - ) - return has_boilerplate and not has_domain_detail + return has_boilerplate and not _has_remedial_detail(normalized, violation) def _near_duplicate_action(action: str, code: str, plan: CorrectivePlan) -> bool: @@ -114,3 +112,43 @@ def _meaningful_terms(description: str, location: str | None) -> set[str]: } return words + +def _has_remedial_detail(action: str, violation: Violation) -> bool: + removable = _meaningful_terms(violation.description, violation.location) + remaining_terms = [ + word + for word in re.findall(r"[a-z][a-z0-9-]{3,}", action.lower()) + if word + not in removable + and word + not in { + "code", + "correct", + "corrective", + "completion", + "document", + "food", + "inspector", + "requirements", + "violation", + } + ] + return any( + term + in { + "calibrate", + "clean", + "discard", + "install", + "log", + "monitor", + "record", + "repair", + "replace", + "restore", + "retrain", + "sanitize", + "verify", + } + for term in remaining_terms + ) diff --git a/use-cases/01shrvan/restaurant-corrective-plan/backend/tests/fixtures/invented_code_plan.json b/use-cases/01shrvan/restaurant-corrective-plan/backend/tests/fixtures/invented_code_plan.json new file mode 100644 index 00000000..2310b3cf --- /dev/null +++ b/use-cases/01shrvan/restaurant-corrective-plan/backend/tests/fixtures/invented_code_plan.json @@ -0,0 +1,32 @@ +{ + "restaurant": "The Copper Kettle", + "inspection_date": "2026-08-03", + "health_department": "Riverside County Environmental Health", + "items": [ + { + "code": "5-202.11", + "finding": "Handwashing sink in the prep area is not supplied with hot water at 100 F minimum.", + "corrective_action": "Restore the prep-area handwashing sink hot-water supply to at least 100 F, verify the faucet temperature at opening and mid-shift, and keep a signed temperature log at the prep station.", + "responsible_party": "Facilities Lead" + }, + { + "code": "3-501.16", + "finding": "Cooked rice held at 118 F in the steam well, below the 135 F hot-holding requirement.", + "corrective_action": "Discard rice held below temperature, recalibrate the line steam well to hold cooked rice at 135 F or above, and require the line cook to record hot-holding temperatures every two hours.", + "responsible_party": "Kitchen Manager" + }, + { + "code": "6-501.12", + "finding": "Accumulated grease and debris beneath the fryer line.", + "corrective_action": "Pull the fryer bank out of service for deep cleaning beneath and behind the line, add the fry station floor area to the nightly closing checklist, and photograph the cleaned area after each closing shift for one week.", + "responsible_party": "Closing Supervisor" + }, + { + "code": "9-999.99", + "finding": "Invented violation not present in the citation list.", + "corrective_action": "Create a new corrective action for an uncited inspection item.", + "responsible_party": "General Manager" + } + ] +} + diff --git a/use-cases/01shrvan/restaurant-corrective-plan/backend/tests/fixtures/location_only_boilerplate_plan.json b/use-cases/01shrvan/restaurant-corrective-plan/backend/tests/fixtures/location_only_boilerplate_plan.json new file mode 100644 index 00000000..bf289095 --- /dev/null +++ b/use-cases/01shrvan/restaurant-corrective-plan/backend/tests/fixtures/location_only_boilerplate_plan.json @@ -0,0 +1,26 @@ +{ + "restaurant": "The Copper Kettle", + "inspection_date": "2026-08-03", + "health_department": "Riverside County Environmental Health", + "items": [ + { + "code": "5-202.11", + "finding": "Handwashing sink in the prep area is not supplied with hot water at 100 F minimum.", + "corrective_action": "Correct violation 5-202.11 per code requirements at the handwashing sink.", + "responsible_party": "Facilities Lead" + }, + { + "code": "3-501.16", + "finding": "Cooked rice held at 118 F in the steam well, below the 135 F hot-holding requirement.", + "corrective_action": "Correct violation 3-501.16 per code requirements at the steam well.", + "responsible_party": "Kitchen Manager" + }, + { + "code": "6-501.12", + "finding": "Accumulated grease and debris beneath the fryer line.", + "corrective_action": "Correct violation 6-501.12 per code requirements at the fryer line.", + "responsible_party": "Closing Supervisor" + } + ] +} + diff --git a/use-cases/01shrvan/restaurant-corrective-plan/backend/tests/test_verifier.py b/use-cases/01shrvan/restaurant-corrective-plan/backend/tests/test_verifier.py index 6b8758f1..8db5c604 100644 --- a/use-cases/01shrvan/restaurant-corrective-plan/backend/tests/test_verifier.py +++ b/use-cases/01shrvan/restaurant-corrective-plan/backend/tests/test_verifier.py @@ -56,6 +56,32 @@ def test_individually_coded_boilerplate_plan_is_rejected() -> None: assert "corrective action is near-identical to another violation" in code_report.reasons +def test_empty_violation_list_is_not_complete() -> None: + report = verify_coverage((), load_plan("good_plan.json")) + + assert not report.is_complete + + +def test_uncited_plan_items_make_report_incomplete() -> None: + report = verify_coverage(load_violations(), load_plan("invented_code_plan.json")) + + assert not report.is_complete + assert report.uninvited_codes == ("9-999.99",) + + +def test_location_only_boilerplate_plan_is_rejected() -> None: + report = verify_coverage( + load_violations(), load_plan("location_only_boilerplate_plan.json") + ) + + assert not report.is_complete + for code_report in report.codes: + assert ( + "corrective action is boilerplate and not specific to the violation" + in code_report.reasons + ) + + def test_missing_violation_code_or_description_names_field() -> None: with pytest.raises(ValidationError) as missing_code: Violation(code="", description="A valid finding") @@ -77,4 +103,3 @@ def test_corrective_item_without_action_or_owner_cannot_construct() -> None: message = str(error.value) assert "corrective_action" in message assert "responsible_party" in message - From 5de20b592342cc161324313441b55d30c8075784 Mon Sep 17 00:00:00 2001 From: Shrvan Benke Date: Thu, 6 Aug 2026 18:31:28 +0530 Subject: [PATCH 3/9] feat: add offline corrective plan draft --- .../backend/app/offline.py | 85 +++++++++++++++++++ .../backend/app/template.py | 45 ++++++++++ .../backend/tests/test_offline.py | 63 ++++++++++++++ 3 files changed, 193 insertions(+) create mode 100644 use-cases/01shrvan/restaurant-corrective-plan/backend/app/offline.py create mode 100644 use-cases/01shrvan/restaurant-corrective-plan/backend/app/template.py create mode 100644 use-cases/01shrvan/restaurant-corrective-plan/backend/tests/test_offline.py diff --git a/use-cases/01shrvan/restaurant-corrective-plan/backend/app/offline.py b/use-cases/01shrvan/restaurant-corrective-plan/backend/app/offline.py new file mode 100644 index 00000000..35e9c57f --- /dev/null +++ b/use-cases/01shrvan/restaurant-corrective-plan/backend/app/offline.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +from datetime import date, timedelta + +from .models import CorrectiveItem, CorrectivePlan, Violation + + +_OWNER_BY_CODE_PREFIX = { + "5-": "Facilities Lead", + "3-": "Kitchen Manager", + "6-": "Closing Supervisor", +} + + +def generate_offline_plan( + *, + restaurant: str, + inspection_date: date, + health_department: str, + violations: tuple[Violation, ...] | list[Violation], +) -> CorrectivePlan: + items = tuple( + CorrectiveItem( + code=violation.code, + finding=violation.description, + corrective_action=_action_for(violation), + responsible_party=_owner_for(violation.code), + completion_target=(inspection_date + timedelta(days=index)).isoformat(), + evidence=_evidence_for(violation), + ) + for index, violation in enumerate(violations, start=1) + ) + return CorrectivePlan( + restaurant=restaurant, + inspection_date=inspection_date, + health_department=health_department, + items=items, + ) + + +def _owner_for(code: str) -> str: + for prefix, owner in _OWNER_BY_CODE_PREFIX.items(): + if code.startswith(prefix): + return owner + return "General Manager" + + +def _action_for(violation: Violation) -> str: + text = f"{violation.description} {violation.location or ''}".lower() + location = violation.location or "the cited area" + + if "handwashing" in text or "hot water" in text: + return ( + f"Restore hot water at {location} to at least 100 F, verify the faucet " + "temperature at opening and mid-shift, and keep a signed sink-temperature " + "log at the station." + ) + if "hot-holding" in text or "hot holding" in text or "steam well" in text: + return ( + f"Discard food held below 135 F at {location}, recalibrate the holding " + "equipment before service, and record line temperatures every two hours " + "until seven consecutive compliant readings are logged." + ) + if "grease" in text or "debris" in text or "fryer" in text or "clean" in text: + return ( + f"Deep clean beneath and behind {location}, add that floor area to the " + "nightly closing checklist, and require dated photos after each closing " + "shift for one week." + ) + return ( + f"Assign the cited condition at {location} to the responsible manager, correct " + "the physical condition described in the finding, document the completed work, " + "and verify it during the next pre-service inspection." + ) + + +def _evidence_for(violation: Violation) -> str: + text = f"{violation.description} {violation.location or ''}".lower() + if "handwashing" in text or "hot water" in text: + return "Repair invoice and sink-temperature log" + if "hot-holding" in text or "hot holding" in text or "steam well" in text: + return "Discard log, equipment calibration record, and hot-holding log" + if "grease" in text or "debris" in text or "fryer" in text: + return "Updated closing checklist and dated cleaning photos" + return "Manager sign-off and dated corrective-action photo" diff --git a/use-cases/01shrvan/restaurant-corrective-plan/backend/app/template.py b/use-cases/01shrvan/restaurant-corrective-plan/backend/app/template.py new file mode 100644 index 00000000..11ebba37 --- /dev/null +++ b/use-cases/01shrvan/restaurant-corrective-plan/backend/app/template.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from datetime import date +from html import escape + +from .models import Violation + + +def build_plan_template_html( + *, + restaurant: str, + inspection_date: date, + health_department: str, + violations: tuple[Violation, ...] | list[Violation], +) -> str: + sections = "\n".join(_violation_section(violation) for violation in violations) + return f""" + + + + Corrective Action Plan - {escape(restaurant)} + + +

Corrective Action Plan

+

Restaurant: {escape(restaurant)}

+

Inspection date: {inspection_date.isoformat()}

+

Health department: {escape(health_department)}

+

Cited Violations

+{sections} + + +""" + + +def _violation_section(violation: Violation) -> str: + location = violation.location or "Not specified" + return f"""
+

Violation {escape(violation.code)}

+

Finding: {escape(violation.description)}

+

Location: {escape(location)}

+

Corrective action: [pending]

+

Responsible party: [pending]

+

Evidence: [pending]

+
""" + diff --git a/use-cases/01shrvan/restaurant-corrective-plan/backend/tests/test_offline.py b/use-cases/01shrvan/restaurant-corrective-plan/backend/tests/test_offline.py new file mode 100644 index 00000000..9726ad04 --- /dev/null +++ b/use-cases/01shrvan/restaurant-corrective-plan/backend/tests/test_offline.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import json +from datetime import date +from pathlib import Path + +from app.models import Violation +from app.offline import generate_offline_plan +from app.template import build_plan_template_html +from app.verifier import verify_coverage + + +FIXTURES = Path(__file__).parent / "fixtures" + + +def load_case() -> tuple[str, date, str, tuple[Violation, ...]]: + payload = json.loads((FIXTURES / "violations.json").read_text(encoding="utf-8")) + return ( + payload["restaurant"], + date.fromisoformat(payload["inspection_date"]), + payload["health_department"], + tuple(Violation(**violation) for violation in payload["violations"]), + ) + + +def test_offline_generator_produces_one_verified_item_per_violation() -> None: + restaurant, inspection_date, department, violations = load_case() + + plan = generate_offline_plan( + restaurant=restaurant, + inspection_date=inspection_date, + health_department=department, + violations=violations, + ) + + report = verify_coverage(violations, plan) + assert report.is_complete + assert [item.code for item in plan.items] == ["5-202.11", "3-501.16", "6-501.12"] + assert len({item.corrective_action for item in plan.items}) == 3 + assert [item.responsible_party for item in plan.items] == [ + "Facilities Lead", + "Kitchen Manager", + "Closing Supervisor", + ] + + +def test_template_has_one_structural_section_per_violation_code() -> None: + restaurant, inspection_date, department, violations = load_case() + + html = build_plan_template_html( + restaurant=restaurant, + inspection_date=inspection_date, + health_department=department, + violations=violations, + ) + + for violation in violations: + assert html.count(f'data-violation-code="{violation.code}"') == 1 + assert f"Violation {violation.code}" in html + assert html.count('data-slot="corrective-action"') == 3 + assert html.count('data-slot="responsible-party"') == 3 + assert html.count('data-slot="evidence"') == 3 + From e34d23f8f74137d3c0d2f1057d88219cb9e6566c Mon Sep 17 00:00:00 2001 From: Shrvan Benke Date: Thu, 6 Aug 2026 18:34:14 +0530 Subject: [PATCH 4/9] feat: add async approval SuperDocs client --- .../backend/app/superdocs_client.py | 272 ++++++++++++++++++ .../backend/pyproject.toml | 2 +- .../backend/tests/test_superdocs_client.py | 168 +++++++++++ 3 files changed, 441 insertions(+), 1 deletion(-) create mode 100644 use-cases/01shrvan/restaurant-corrective-plan/backend/app/superdocs_client.py create mode 100644 use-cases/01shrvan/restaurant-corrective-plan/backend/tests/test_superdocs_client.py diff --git a/use-cases/01shrvan/restaurant-corrective-plan/backend/app/superdocs_client.py b/use-cases/01shrvan/restaurant-corrective-plan/backend/app/superdocs_client.py new file mode 100644 index 00000000..946f3536 --- /dev/null +++ b/use-cases/01shrvan/restaurant-corrective-plan/backend/app/superdocs_client.py @@ -0,0 +1,272 @@ +from __future__ import annotations + +import json +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import httpx + + +VALID_EXPORT_FORMATS = {"docx", "pdf", "html", "markdown", "txt", "doc"} + + +class SuperDocsError(RuntimeError): + pass + + +@dataclass(frozen=True) +class PendingChange: + change_id: str + chunk_id: str | None + document_id: str | None + old_html: str + new_html: str + ai_explanation: str | None + + +@dataclass(frozen=True) +class ApprovedDocument: + session_id: str + job_id: str + changes: tuple[PendingChange, ...] + + +class SuperDocsClient: + def __init__( + self, + *, + api_key: str, + base_url: str = "https://api.superdocs.app", + timeout_seconds: float = 60.0, + client: httpx.Client | None = None, + ) -> None: + if not api_key.strip(): + raise SuperDocsError( + "SuperDocs API key is missing. Set SUPERDOCS_API_KEY in the environment." + ) + self._client = client or httpx.Client(timeout=timeout_seconds) + self._base_url = base_url.rstrip("/") + self._headers = {"Authorization": f"Bearer {api_key}"} + + def draft_with_approval( + self, + *, + document_html: str, + message: str, + session_id: str, + poll_interval_seconds: float = 2.0, + max_polls: int = 90, + max_elapsed_seconds: float = 300.0, + ) -> ApprovedDocument: + if max_polls <= 0: + raise SuperDocsError("polling stopped: max_polls must be positive. Increase max_polls.") + uploaded_html = self._upload_html_with_warmup_retry(document_html, session_id) + job_id = self._start_async_chat( + document_html=uploaded_html, + message=message, + session_id=session_id, + ) + changes = self._poll_for_approval( + job_id=job_id, + poll_interval_seconds=poll_interval_seconds, + max_polls=max_polls, + max_elapsed_seconds=max_elapsed_seconds, + ) + for change in changes: + self._approve_change(session_id=session_id, job_id=job_id, change_id=change.change_id) + return ApprovedDocument(session_id=session_id, job_id=job_id, changes=changes) + + def export(self, *, session_id: str, fmt: str) -> bytes: + if fmt not in VALID_EXPORT_FORMATS: + valid = ", ".join(f"'{value}'" for value in sorted(VALID_EXPORT_FORMATS)) + raise SuperDocsError( + f"export failed: format '{fmt}' is not valid. Use {valid}." + ) + response = self._request( + "POST", + "/v1/documents/export", + json={"session_id": session_id, "format": fmt}, + ) + return response.content + + def _upload_html_with_warmup_retry(self, document_html: str, session_id: str) -> str: + last_error: Exception | None = None + for attempt in range(2): + try: + response = self._request( + "POST", + "/v1/documents/upload", + files={"file": ("corrective-plan.html", document_html, "text/html")}, + data={"session_id": session_id}, + ) + payload = _json_response(response, "upload") + html = payload.get("html") + if not isinstance(html, str) or not html: + raise SuperDocsError( + "upload failed: response did not include html. Retry the upload or check the API response shape." + ) + return html + except (httpx.HTTPError, SuperDocsError) as error: + last_error = error + if attempt == 1: + break + raise SuperDocsError( + f"upload failed after warm-up retry: {last_error}. Check network access and SUPERDOCS_API_KEY." + ) + + def _start_async_chat(self, *, document_html: str, message: str, session_id: str) -> str: + response = self._request( + "POST", + "/v1/chat/async", + json={ + "message": message, + "session_id": session_id, + "document_html": document_html, + "approval_mode": "ask_every_time", + }, + ) + payload = _json_response(response, "chat") + job_id = payload.get("job_id") + if not isinstance(job_id, str) or not job_id: + raise SuperDocsError( + "chat failed: async response did not include job_id. Use /v1/chat/async, not /v1/chat." + ) + return job_id + + def _poll_for_approval( + self, + *, + job_id: str, + poll_interval_seconds: float, + max_polls: int, + max_elapsed_seconds: float, + ) -> tuple[PendingChange, ...]: + started = time.monotonic() + last_status = "unknown" + for poll_count in range(1, max_polls + 1): + response = self._request("GET", f"/v1/jobs/{job_id}") + payload = _json_response(response, "job polling") + last_status = str(payload.get("status", "unknown")) + if last_status == "awaiting_approval": + return _pending_changes(payload) + if last_status in {"failed", "cancelled", "error"}: + raise SuperDocsError( + f"job failed: status is '{last_status}'. Inspect the job error and retry the request." + ) + if time.monotonic() - started >= max_elapsed_seconds: + raise SuperDocsError( + f"polling stopped after {poll_count} polls and {max_elapsed_seconds:.0f}s with status '{last_status}'. Increase max_elapsed_seconds or inspect job {job_id}." + ) + time.sleep(poll_interval_seconds) + raise SuperDocsError( + f"polling stopped after {max_polls} polls with status '{last_status}'. Increase max_polls or inspect job {job_id}." + ) + + def _approve_change(self, *, session_id: str, job_id: str, change_id: str) -> None: + payload = { + "job_id": job_id, + "change_id": change_id, + "approved": True, + } + response = self._request("POST", f"/v1/chat/{session_id}/approve", json=payload) + result = _json_response(response, "approval") + if result.get("status") != "ok": + raise SuperDocsError( + "approval failed: API did not return status 'ok'. Retry approval before exporting." + ) + + def _request(self, method: str, path: str, **kwargs: Any) -> httpx.Response: + response = self._client.request( + method, + f"{self._base_url}{path}", + headers=self._headers, + **kwargs, + ) + try: + response.raise_for_status() + except httpx.HTTPStatusError as error: + detail = _error_detail(response) + raise SuperDocsError( + f"{method} {path} failed with HTTP {response.status_code}: {detail}. Check request fields and retry." + ) from error + return response + + +def _json_response(response: httpx.Response, operation: str) -> dict[str, Any]: + try: + payload = response.json() + except json.JSONDecodeError as error: + raise SuperDocsError( + f"{operation} failed: response was not JSON. Check the SuperDocs API status and retry." + ) from error + if not isinstance(payload, dict): + raise SuperDocsError( + f"{operation} failed: response JSON was not an object. Check the API response shape." + ) + return payload + + +def _pending_changes(payload: dict[str, Any]) -> tuple[PendingChange, ...]: + metadata = payload.get("metadata") + if not isinstance(metadata, dict): + raise SuperDocsError( + "approval failed: job metadata is missing. Retry polling or inspect the job response." + ) + raw_changes = metadata.get("pending_changes") + if isinstance(raw_changes, str): + raw_changes = json.loads(raw_changes) + if not isinstance(raw_changes, list) or not raw_changes: + raise SuperDocsError( + "approval failed: pending_changes is empty. Ask SuperDocs to produce changes before approval." + ) + changes: list[PendingChange] = [] + for raw in raw_changes: + if not isinstance(raw, dict): + raise SuperDocsError( + "approval failed: pending_changes contained a non-object item. Inspect the job response." + ) + change_id = raw.get("change_id") + if not isinstance(change_id, str) or not change_id: + raise SuperDocsError( + "approval failed: pending change has no change_id. Retry the async chat request." + ) + changes.append( + PendingChange( + change_id=change_id, + chunk_id=_optional_str(raw.get("chunk_id")), + document_id=_optional_str(raw.get("document_id")), + old_html=str(raw.get("old_html", "")), + new_html=str(raw.get("new_html", "")), + ai_explanation=_optional_str(raw.get("ai_explanation")), + ) + ) + return tuple(changes) + + +def _optional_str(value: Any) -> str | None: + return value if isinstance(value, str) else None + + +def _error_detail(response: httpx.Response) -> str: + try: + payload = response.json() + except json.JSONDecodeError: + return response.text[:500] + if isinstance(payload, dict): + detail = payload.get("detail") + if isinstance(detail, str): + return detail + return json.dumps(payload, sort_keys=True)[:500] + return str(payload)[:500] + + +def load_api_key_from_env_file(path: Path) -> str | None: + if not path.exists(): + return None + for line in path.read_text(encoding="utf-8").splitlines(): + if line.startswith("SUPERDOCS_API_KEY="): + return line.split("=", 1)[1].strip() + return None + diff --git a/use-cases/01shrvan/restaurant-corrective-plan/backend/pyproject.toml b/use-cases/01shrvan/restaurant-corrective-plan/backend/pyproject.toml index 949416ba..0afd2138 100644 --- a/use-cases/01shrvan/restaurant-corrective-plan/backend/pyproject.toml +++ b/use-cases/01shrvan/restaurant-corrective-plan/backend/pyproject.toml @@ -4,6 +4,7 @@ version = "0.1.0" description = "SuperDocs restaurant inspection corrective-plan build" requires-python = ">=3.12" dependencies = [ + "httpx>=0.27,<1", "pydantic>=2.8,<3", ] @@ -15,4 +16,3 @@ test = [ [tool.pytest.ini_options] testpaths = ["tests"] pythonpath = ["."] - diff --git a/use-cases/01shrvan/restaurant-corrective-plan/backend/tests/test_superdocs_client.py b/use-cases/01shrvan/restaurant-corrective-plan/backend/tests/test_superdocs_client.py new file mode 100644 index 00000000..41155e42 --- /dev/null +++ b/use-cases/01shrvan/restaurant-corrective-plan/backend/tests/test_superdocs_client.py @@ -0,0 +1,168 @@ +from __future__ import annotations + +import json + +import httpx +import pytest + +from app.superdocs_client import SuperDocsClient, SuperDocsError + + +def test_async_approval_flow_uses_human_gate_and_parses_pending_changes() -> None: + requests: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + if request.url.path == "/v1/documents/upload": + return httpx.Response(200, json={"html": "uploaded"}) + if request.url.path == "/v1/chat/async": + payload = json.loads(request.content) + assert payload["approval_mode"] == "ask_every_time" + assert payload["document_html"] == "uploaded" + return httpx.Response(200, json={"job_id": "job-1", "status": "pending"}) + if request.url.path == "/v1/jobs/job-1": + return httpx.Response( + 200, + json={ + "status": "awaiting_approval", + "metadata": { + "pending_changes": json.dumps( + [ + { + "change_id": "change-1", + "chunk_id": "chunk-1", + "document_id": "doc-1", + "old_html": "

old

", + "new_html": "

new

", + "ai_explanation": "filled section", + } + ] + ) + }, + }, + ) + if request.url.path == "/v1/chat/session-1/approve": + payload = json.loads(request.content) + assert payload == { + "job_id": "job-1", + "change_id": "change-1", + "approved": True, + } + return httpx.Response(200, json={"status": "ok", "batch_complete": True}) + raise AssertionError(f"unexpected request: {request.method} {request.url}") + + client = SuperDocsClient( + api_key="test-key", + base_url="https://api.test", + client=httpx.Client(transport=httpx.MockTransport(handler)), + ) + + result = client.draft_with_approval( + document_html="template", + message="Fill each violation section.", + session_id="session-1", + poll_interval_seconds=0, + ) + + assert result.job_id == "job-1" + assert [request.url.path for request in requests] == [ + "/v1/documents/upload", + "/v1/chat/async", + "/v1/jobs/job-1", + "/v1/chat/session-1/approve", + ] + + +def test_upload_retries_once_for_warmup_failure() -> None: + upload_count = 0 + + def handler(request: httpx.Request) -> httpx.Response: + nonlocal upload_count + if request.url.path == "/v1/documents/upload": + upload_count += 1 + if upload_count == 1: + return httpx.Response(503, json={"detail": "warming up"}) + return httpx.Response(200, json={"html": "uploaded"}) + if request.url.path == "/v1/chat/async": + return httpx.Response(200, json={"job_id": "job-1", "status": "pending"}) + if request.url.path == "/v1/jobs/job-1": + return httpx.Response( + 200, + json={ + "status": "awaiting_approval", + "metadata": { + "pending_changes": [ + { + "change_id": "change-1", + "old_html": "", + "new_html": "", + } + ] + }, + }, + ) + if request.url.path == "/v1/chat/session-1/approve": + return httpx.Response(200, json={"status": "ok"}) + raise AssertionError(f"unexpected request: {request.method} {request.url}") + + client = SuperDocsClient( + api_key="test-key", + base_url="https://api.test", + client=httpx.Client(transport=httpx.MockTransport(handler)), + ) + + client.draft_with_approval( + document_html="template", + message="Fill each violation section.", + session_id="session-1", + poll_interval_seconds=0, + ) + + assert upload_count == 2 + + +def test_polling_stops_after_max_polls_with_fix_message() -> None: + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path == "/v1/documents/upload": + return httpx.Response(200, json={"html": "uploaded"}) + if request.url.path == "/v1/chat/async": + return httpx.Response(200, json={"job_id": "job-1", "status": "pending"}) + if request.url.path == "/v1/jobs/job-1": + return httpx.Response(200, json={"status": "pending"}) + raise AssertionError(f"unexpected request: {request.method} {request.url}") + + client = SuperDocsClient( + api_key="test-key", + base_url="https://api.test", + client=httpx.Client(transport=httpx.MockTransport(handler)), + ) + + with pytest.raises(SuperDocsError) as error: + client.draft_with_approval( + document_html="template", + message="Fill each violation section.", + session_id="session-1", + poll_interval_seconds=0, + max_polls=2, + ) + + message = str(error.value) + assert "polling stopped after 2 polls" in message + assert "Increase max_polls" in message + + +def test_invalid_export_format_names_cause_and_fix() -> None: + client = SuperDocsClient( + api_key="test-key", + base_url="https://api.test", + client=httpx.Client(transport=httpx.MockTransport(lambda request: httpx.Response(500))), + ) + + with pytest.raises(SuperDocsError) as error: + client.export(session_id="session-1", fmt="md") + + assert ( + str(error.value) + == "export failed: format 'md' is not valid. Use 'doc', 'docx', 'html', 'markdown', 'pdf', 'txt'." + ) + From fa15acf77266f0223138fa4550b9ff32381b60e0 Mon Sep 17 00:00:00 2001 From: Shrvan Benke Date: Thu, 6 Aug 2026 18:45:01 +0530 Subject: [PATCH 5/9] feat: add corrective plan review UI --- .../restaurant-corrective-plan/.gitignore | 1 + .../backend/app/api.py | 142 ++ .../backend/app/models.py | 5 +- .../backend/app/review.py | 36 + .../backend/pyproject.toml | 2 + .../backend/tests/test_api.py | 108 + .../frontend/index.html | 13 + .../frontend/package-lock.json | 1744 +++++++++++++++++ .../frontend/package.json | 24 + .../frontend/src/main.tsx | 275 +++ .../frontend/src/styles.css | 325 +++ .../frontend/tsconfig.json | 22 + .../frontend/vite.config.ts | 17 + 13 files changed, 2713 insertions(+), 1 deletion(-) create mode 100644 use-cases/01shrvan/restaurant-corrective-plan/backend/app/api.py create mode 100644 use-cases/01shrvan/restaurant-corrective-plan/backend/app/review.py create mode 100644 use-cases/01shrvan/restaurant-corrective-plan/backend/tests/test_api.py create mode 100644 use-cases/01shrvan/restaurant-corrective-plan/frontend/index.html create mode 100644 use-cases/01shrvan/restaurant-corrective-plan/frontend/package-lock.json create mode 100644 use-cases/01shrvan/restaurant-corrective-plan/frontend/package.json create mode 100644 use-cases/01shrvan/restaurant-corrective-plan/frontend/src/main.tsx create mode 100644 use-cases/01shrvan/restaurant-corrective-plan/frontend/src/styles.css create mode 100644 use-cases/01shrvan/restaurant-corrective-plan/frontend/tsconfig.json create mode 100644 use-cases/01shrvan/restaurant-corrective-plan/frontend/vite.config.ts diff --git a/use-cases/01shrvan/restaurant-corrective-plan/.gitignore b/use-cases/01shrvan/restaurant-corrective-plan/.gitignore index 55a21cba..8d4c40e6 100644 --- a/use-cases/01shrvan/restaurant-corrective-plan/.gitignore +++ b/use-cases/01shrvan/restaurant-corrective-plan/.gitignore @@ -4,5 +4,6 @@ __pycache__/ .pytest_cache/ *.pyc *.egg-info/ +*.tsbuildinfo dist/ node_modules/ diff --git a/use-cases/01shrvan/restaurant-corrective-plan/backend/app/api.py b/use-cases/01shrvan/restaurant-corrective-plan/backend/app/api.py new file mode 100644 index 00000000..2a21c09e --- /dev/null +++ b/use-cases/01shrvan/restaurant-corrective-plan/backend/app/api.py @@ -0,0 +1,142 @@ +from __future__ import annotations + +import json +from datetime import date +from pathlib import Path + +from fastapi import FastAPI, HTTPException, Response +from fastapi.staticfiles import StaticFiles +from pydantic import BaseModel, ConfigDict + +from .models import CorrectivePlan, CoverageReport, Violation +from .offline import generate_offline_plan +from .review import Decision, ReviewItem, undecided_codes +from .superdocs_client import VALID_EXPORT_FORMATS +from .template import build_plan_template_html +from .verifier import verify_coverage + + +class DraftRequest(BaseModel): + model_config = ConfigDict(frozen=True) + + restaurant: str + inspection_date: date + health_department: str + violations: tuple[Violation, ...] + + +class DraftResponse(BaseModel): + model_config = ConfigDict(frozen=True) + + plan: CorrectivePlan + coverage: CoverageReport + template_html: str + + +class ExportRequest(BaseModel): + model_config = ConfigDict(frozen=True) + + plan: CorrectivePlan + violations: tuple[Violation, ...] + decisions: dict[str, Decision] + format: str = "html" + + +def create_app() -> FastAPI: + app = FastAPI(title="Restaurant Corrective Plan") + + @app.get("/api/sample") + def sample() -> dict: + return _sample_payload() + + @app.post("/api/draft", response_model=DraftResponse) + def draft(request: DraftRequest) -> DraftResponse: + plan = generate_offline_plan( + restaurant=request.restaurant, + inspection_date=request.inspection_date, + health_department=request.health_department, + violations=request.violations, + ) + coverage = verify_coverage(request.violations, plan) + template_html = build_plan_template_html( + restaurant=request.restaurant, + inspection_date=request.inspection_date, + health_department=request.health_department, + violations=request.violations, + ) + return DraftResponse(plan=plan, coverage=coverage, template_html=template_html) + + @app.post("/api/export") + def export(request: ExportRequest) -> Response: + if request.format not in VALID_EXPORT_FORMATS: + valid = ", ".join(f"'{value}'" for value in sorted(VALID_EXPORT_FORMATS)) + raise HTTPException( + status_code=400, + detail=f"export failed: format '{request.format}' is not valid. Use {valid}.", + ) + review_items = tuple( + ReviewItem(item=item, decision=request.decisions.get(item.code, "pending")) + for item in request.plan.items + ) + pending = undecided_codes(review_items) + if pending: + raise HTTPException( + status_code=409, + detail=( + "export refused: these violation codes are undecided: " + f"{', '.join(pending)}. Approve or reject each item before exporting." + ), + ) + coverage = verify_coverage(request.violations, request.plan) + if not coverage.is_complete: + problems = list(coverage.missing_codes) + list(coverage.uninvited_codes) + named = ", ".join(problems) if problems else "see coverage report" + raise HTTPException( + status_code=409, + detail=( + f"export refused: coverage is incomplete for {named}. " + "Fix the cited items before exporting." + ), + ) + html = _plan_to_html(request.plan) + return Response(content=html, media_type="text/html") + + static_dir = Path(__file__).resolve().parents[2] / "frontend" / "dist" + if static_dir.exists(): + app.mount("/", StaticFiles(directory=static_dir, html=True), name="frontend") + + return app + + +app = create_app() + + +def _sample_payload() -> dict: + path = Path(__file__).resolve().parents[1] / "tests" / "fixtures" / "violations.json" + return json.loads(path.read_text(encoding="utf-8")) + + +def _plan_to_html(plan: CorrectivePlan) -> str: + sections = "\n".join( + f"""
+

Violation {item.code}

+

Finding: {item.finding}

+

Corrective action: {item.corrective_action}

+

Responsible party: {item.responsible_party}

+

Completion target: {item.completion_target or "Not specified"}

+

Evidence: {item.evidence or "Not specified"}

+
""" + for item in plan.items + ) + return f""" + + {plan.restaurant} corrective plan + +

Corrective Action Plan

+

Restaurant: {plan.restaurant}

+

Inspection date: {plan.inspection_date.isoformat()}

+

Health department: {plan.health_department}

+{sections} + + +""" diff --git a/use-cases/01shrvan/restaurant-corrective-plan/backend/app/models.py b/use-cases/01shrvan/restaurant-corrective-plan/backend/app/models.py index ce242b9e..9da18093 100644 --- a/use-cases/01shrvan/restaurant-corrective-plan/backend/app/models.py +++ b/use-cases/01shrvan/restaurant-corrective-plan/backend/app/models.py @@ -3,7 +3,7 @@ from datetime import date from typing import Literal -from pydantic import BaseModel, ConfigDict, field_validator +from pydantic import BaseModel, ConfigDict, computed_field, field_validator def _require_non_empty(value: str, field_name: str) -> str: @@ -88,6 +88,7 @@ class CodeCoverage(BaseModel): status: Literal["covered", "incomplete"] reasons: tuple[str, ...] = () + @computed_field @property def is_complete(self) -> bool: return self.status == "covered" @@ -99,12 +100,14 @@ class CoverageReport(BaseModel): codes: tuple[CodeCoverage, ...] uninvited_codes: tuple[str, ...] = () + @computed_field @property def is_complete(self) -> bool: return bool(self.codes) and not self.uninvited_codes and all( code.is_complete for code in self.codes ) + @computed_field @property def missing_codes(self) -> tuple[str, ...]: return tuple( diff --git a/use-cases/01shrvan/restaurant-corrective-plan/backend/app/review.py b/use-cases/01shrvan/restaurant-corrective-plan/backend/app/review.py new file mode 100644 index 00000000..b52d26ef --- /dev/null +++ b/use-cases/01shrvan/restaurant-corrective-plan/backend/app/review.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +from typing import Literal + +from pydantic import BaseModel, ConfigDict + +from .models import CorrectiveItem + + +Decision = Literal["pending", "approved", "rejected"] + + +class ReviewItem(BaseModel): + model_config = ConfigDict(frozen=True) + + item: CorrectiveItem + decision: Decision = "pending" + + +def apply_decision( + items: tuple[ReviewItem, ...], + *, + code: str, + decision: Decision, +) -> tuple[ReviewItem, ...]: + return tuple( + ReviewItem(item=review.item, decision=decision) + if review.item.code == code + else review + for review in items + ) + + +def undecided_codes(items: tuple[ReviewItem, ...]) -> tuple[str, ...]: + return tuple(review.item.code for review in items if review.decision == "pending") + diff --git a/use-cases/01shrvan/restaurant-corrective-plan/backend/pyproject.toml b/use-cases/01shrvan/restaurant-corrective-plan/backend/pyproject.toml index 0afd2138..17488d8e 100644 --- a/use-cases/01shrvan/restaurant-corrective-plan/backend/pyproject.toml +++ b/use-cases/01shrvan/restaurant-corrective-plan/backend/pyproject.toml @@ -4,8 +4,10 @@ version = "0.1.0" description = "SuperDocs restaurant inspection corrective-plan build" requires-python = ">=3.12" dependencies = [ + "fastapi>=0.115,<1", "httpx>=0.27,<1", "pydantic>=2.8,<3", + "uvicorn>=0.30,<1", ] [project.optional-dependencies] diff --git a/use-cases/01shrvan/restaurant-corrective-plan/backend/tests/test_api.py b/use-cases/01shrvan/restaurant-corrective-plan/backend/tests/test_api.py new file mode 100644 index 00000000..91981991 --- /dev/null +++ b/use-cases/01shrvan/restaurant-corrective-plan/backend/tests/test_api.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from fastapi.testclient import TestClient + +from app.api import create_app +from app.models import CorrectivePlan +from app.review import ReviewItem, apply_decision + + +FIXTURES = Path(__file__).parent / "fixtures" + + +def client() -> TestClient: + return TestClient(create_app()) + + +def sample_payload() -> dict: + return json.loads((FIXTURES / "violations.json").read_text(encoding="utf-8")) + + +def test_draft_endpoint_returns_verified_offline_plan() -> None: + response = client().post("/api/draft", json=sample_payload()) + + assert response.status_code == 200 + payload = response.json() + assert payload["coverage"]["is_complete"] is True + assert [item["code"] for item in payload["plan"]["items"]] == [ + "5-202.11", + "3-501.16", + "6-501.12", + ] + assert payload["template_html"].count("data-violation-code=") == 3 + + +def test_rejecting_one_item_leaves_other_review_items_untouched_by_identity() -> None: + plan = CorrectivePlan(**json.loads((FIXTURES / "good_plan.json").read_text())) + original = tuple(ReviewItem(item=item) for item in plan.items) + + updated = apply_decision(original, code="3-501.16", decision="rejected") + + assert updated[0] is original[0] + assert updated[1] is not original[1] + assert updated[1].decision == "rejected" + assert updated[2] is original[2] + + +def test_export_is_refused_while_any_item_is_undecided_and_names_code() -> None: + draft = client().post("/api/draft", json=sample_payload()).json() + decisions = {"5-202.11": "approved", "3-501.16": "approved"} + + response = client().post( + "/api/export", + json={ + "plan": draft["plan"], + "violations": sample_payload()["violations"], + "decisions": decisions, + "format": "html", + }, + ) + + assert response.status_code == 409 + assert "6-501.12" in response.json()["detail"] + assert "Approve or reject each item" in response.json()["detail"] + + +def test_export_is_refused_when_coverage_is_incomplete_and_names_missing_code() -> None: + plan = json.loads((FIXTURES / "good_plan.json").read_text(encoding="utf-8")) + plan["items"] = plan["items"][:2] + decisions = {"5-202.11": "approved", "3-501.16": "approved"} + + response = client().post( + "/api/export", + json={ + "plan": plan, + "violations": sample_payload()["violations"], + "decisions": decisions, + "format": "html", + }, + ) + + assert response.status_code == 409 + assert "6-501.12" in response.json()["detail"] + assert "coverage is incomplete" in response.json()["detail"] + + +def test_export_rejects_md_with_cause_and_fix() -> None: + draft = client().post("/api/draft", json=sample_payload()).json() + decisions = {item["code"]: "approved" for item in draft["plan"]["items"]} + + response = client().post( + "/api/export", + json={ + "plan": draft["plan"], + "violations": sample_payload()["violations"], + "decisions": decisions, + "format": "md", + }, + ) + + assert response.status_code == 400 + assert ( + response.json()["detail"] + == "export failed: format 'md' is not valid. Use 'doc', 'docx', 'html', 'markdown', 'pdf', 'txt'." + ) + diff --git a/use-cases/01shrvan/restaurant-corrective-plan/frontend/index.html b/use-cases/01shrvan/restaurant-corrective-plan/frontend/index.html new file mode 100644 index 00000000..f8e510a4 --- /dev/null +++ b/use-cases/01shrvan/restaurant-corrective-plan/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + Restaurant Corrective Plan + + +
+ + + + diff --git a/use-cases/01shrvan/restaurant-corrective-plan/frontend/package-lock.json b/use-cases/01shrvan/restaurant-corrective-plan/frontend/package-lock.json new file mode 100644 index 00000000..296ca01c --- /dev/null +++ b/use-cases/01shrvan/restaurant-corrective-plan/frontend/package-lock.json @@ -0,0 +1,1744 @@ +{ + "name": "restaurant-corrective-plan-ui", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "restaurant-corrective-plan-ui", + "version": "0.1.0", + "dependencies": { + "@vitejs/plugin-react": "^5.1.2", + "lucide-react": "^0.562.0", + "react": "^19.2.3", + "react-dom": "^19.2.3", + "typescript": "^5.9.3", + "vite": "^7.3.0" + }, + "devDependencies": { + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz", + "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz", + "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz", + "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz", + "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz", + "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz", + "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz", + "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz", + "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz", + "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz", + "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz", + "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz", + "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz", + "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz", + "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz", + "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz", + "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz", + "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz", + "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz", + "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz", + "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz", + "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz", + "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz", + "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz", + "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz", + "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", + "integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.29.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-rc.3", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.12", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.12.tgz", + "integrity": "sha512-r7WnVImvVCeFpf2DOXfy41aPWzeNg3H/A2X4dKmy1QL0MSyyk/e7z8ihJ3N6Nn2PsdhkVlqnEfnUE4a05P2aTA==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.7", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", + "integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "baseline-browser-mapping": "^2.10.44", + "caniuse-lite": "^1.0.30001806", + "electron-to-chromium": "^1.5.393", + "node-releases": "^2.0.51", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001807", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001807.tgz", + "integrity": "sha512-daRXJ9EB/rdRgu7kV+TTl1YUKtlsMWblPl2sLnpg9DZae16QCegol6A1SmCE31Lm9mXC1sRWGt/krouH+/dl7Q==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.402", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.402.tgz", + "integrity": "sha512-/oOpMaPT6Yg+6/1XQhyIPlzgj7Ye9zf+nNM2Uh6OcE2G2oNptWazFa+qB2Pdqqbsc9KnIDzgAntoYN0dbwOXwA==", + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.562.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.562.0.tgz", + "integrity": "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.17", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz", + "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.53", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz", + "integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz", + "integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==", + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.62.4", + "@rollup/rollup-android-arm64": "4.62.4", + "@rollup/rollup-darwin-arm64": "4.62.4", + "@rollup/rollup-darwin-x64": "4.62.4", + "@rollup/rollup-freebsd-arm64": "4.62.4", + "@rollup/rollup-freebsd-x64": "4.62.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.4", + "@rollup/rollup-linux-arm-musleabihf": "4.62.4", + "@rollup/rollup-linux-arm64-gnu": "4.62.4", + "@rollup/rollup-linux-arm64-musl": "4.62.4", + "@rollup/rollup-linux-loong64-gnu": "4.62.4", + "@rollup/rollup-linux-loong64-musl": "4.62.4", + "@rollup/rollup-linux-ppc64-gnu": "4.62.4", + "@rollup/rollup-linux-ppc64-musl": "4.62.4", + "@rollup/rollup-linux-riscv64-gnu": "4.62.4", + "@rollup/rollup-linux-riscv64-musl": "4.62.4", + "@rollup/rollup-linux-s390x-gnu": "4.62.4", + "@rollup/rollup-linux-x64-gnu": "4.62.4", + "@rollup/rollup-linux-x64-musl": "4.62.4", + "@rollup/rollup-openbsd-x64": "4.62.4", + "@rollup/rollup-openharmony-arm64": "4.62.4", + "@rollup/rollup-win32-arm64-msvc": "4.62.4", + "@rollup/rollup-win32-ia32-msvc": "4.62.4", + "@rollup/rollup-win32-x64-gnu": "4.62.4", + "@rollup/rollup-win32-x64-msvc": "4.62.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "license": "MIT", + "peer": true, + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "license": "ISC" + } + } +} diff --git a/use-cases/01shrvan/restaurant-corrective-plan/frontend/package.json b/use-cases/01shrvan/restaurant-corrective-plan/frontend/package.json new file mode 100644 index 00000000..fb422810 --- /dev/null +++ b/use-cases/01shrvan/restaurant-corrective-plan/frontend/package.json @@ -0,0 +1,24 @@ +{ + "name": "restaurant-corrective-plan-ui", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --host 127.0.0.1", + "build": "tsc -b && vite build", + "preview": "vite preview --host 127.0.0.1" + }, + "dependencies": { + "@vitejs/plugin-react": "^5.1.2", + "lucide-react": "^0.562.0", + "react": "^19.2.3", + "react-dom": "^19.2.3", + "typescript": "^5.9.3", + "vite": "^7.3.0" + }, + "devDependencies": { + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3" + } +} + diff --git a/use-cases/01shrvan/restaurant-corrective-plan/frontend/src/main.tsx b/use-cases/01shrvan/restaurant-corrective-plan/frontend/src/main.tsx new file mode 100644 index 00000000..481f9a0d --- /dev/null +++ b/use-cases/01shrvan/restaurant-corrective-plan/frontend/src/main.tsx @@ -0,0 +1,275 @@ +import * as React from 'react'; +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { Check, Download, FileText, Loader2, RotateCcw, X } from 'lucide-react'; +import './styles.css'; + +type Violation = { + code: string; + description: string; + location?: string | null; + observed_on?: string | null; +}; + +type CorrectiveItem = { + code: string; + finding: string; + corrective_action: string; + responsible_party: string; + completion_target?: string | null; + evidence?: string | null; +}; + +type CorrectivePlan = { + restaurant: string; + inspection_date: string; + health_department: string; + items: CorrectiveItem[]; +}; + +type CoverageReport = { + is_complete: boolean; + missing_codes: string[]; + uninvited_codes: string[]; +}; + +type DraftResponse = { + plan: CorrectivePlan; + coverage: CoverageReport; + template_html: string; +}; + +type Decision = 'pending' | 'approved' | 'rejected'; + +type FormState = { + restaurant: string; + inspection_date: string; + health_department: string; + violations: Violation[]; +}; + +const blankViolation = (): Violation => ({ + code: '', + description: '', + location: '', +}); + +function App() { + const [form, setForm] = React.useState({ + restaurant: '', + inspection_date: '', + health_department: '', + violations: [blankViolation(), blankViolation(), blankViolation()], + }); + const [draft, setDraft] = React.useState(null); + const [decisions, setDecisions] = React.useState>({}); + const [status, setStatus] = React.useState<'idle' | 'loading' | 'exporting'>('idle'); + const [message, setMessage] = React.useState(''); + + const allDecided = + draft !== null && + draft.plan.items.every((item) => decisions[item.code] !== undefined && decisions[item.code] !== 'pending'); + + async function loadSample() { + setStatus('loading'); + setMessage(''); + try { + const response = await fetch('/api/sample'); + if (!response.ok) throw new Error(await errorText(response)); + const payload = (await response.json()) as FormState; + setForm(payload); + setDraft(null); + setDecisions({}); + } catch (error) { + setMessage(error instanceof Error ? error.message : 'sample load failed'); + } finally { + setStatus('idle'); + } + } + + async function draftPlan(event: React.FormEvent) { + event.preventDefault(); + setStatus('loading'); + setMessage(''); + try { + const response = await fetch('/api/draft', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(form), + }); + if (!response.ok) throw new Error(await errorText(response)); + const payload = (await response.json()) as DraftResponse; + setDraft(payload); + setDecisions(Object.fromEntries(payload.plan.items.map((item) => [item.code, 'pending']))); + } catch (error) { + setMessage(error instanceof Error ? error.message : 'draft failed'); + } finally { + setStatus('idle'); + } + } + + async function exportPlan(format: 'html') { + if (!draft) return; + setStatus('exporting'); + setMessage(''); + try { + const response = await fetch('/api/export', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + plan: draft.plan, + violations: form.violations, + decisions, + format, + }), + }); + if (!response.ok) throw new Error(await errorText(response)); + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `corrective-plan.${format}`; + link.click(); + URL.revokeObjectURL(url); + } catch (error) { + setMessage(error instanceof Error ? error.message : 'export failed'); + } finally { + setStatus('idle'); + } + } + + function updateViolation(index: number, patch: Partial) { + setForm({ + ...form, + violations: form.violations.map((violation, current) => (current === index ? { ...violation, ...patch } : violation)), + }); + } + + return ( +
+
+