From b276758ee181801d22dcdce40eba48e75ac5f1e5 Mon Sep 17 00:00:00 2001 From: Lars Date: Sun, 4 Oct 2026 13:04:59 +0200 Subject: [PATCH] Stelle den Frontend-MVP und die v0.3-API auf develop bereit, damit Dev API und PWA zusammen deployen. Co-authored-by: Cursor --- .env.example | 3 + .gitea/workflows/deploy-dev.yml | 8 +- .gitea/workflows/deploy-prod.yml | 8 +- .gitea/workflows/test.yml | 15 + .gitignore | 4 + .../buying-agent-architecture-addendum-v1.md | 377 + .specs/buying-agent-frontend-mvp-spec-v0.1.md | 987 ++ .specs/buying-agent-frontend-mvp-spec-v0.2.md | 1329 +++ .specs/buying-agent-frontend-mvp-spec-v0.3.md | 1708 ++++ ...buying-agent-source-discovery-spec-v0.1.md | 986 ++ README.md | 61 +- app/__init__.py | 1 - app/main.py | 10 - backend/.dockerignore | 10 + Dockerfile => backend/Dockerfile | 0 alembic.ini => backend/alembic.ini | 1 + backend/app/__init__.py | 0 backend/app/api/__init__.py | 0 backend/app/api/discovery.py | 125 + backend/app/api/evaluations.py | 20 + {app => backend/app}/api/health.py | 0 backend/app/api/offers.py | 42 + backend/app/api/products.py | 63 + backend/app/api/profiles.py | 100 + backend/app/api/projects.py | 125 + backend/app/api/schemas.py | 274 + backend/app/clock.py | 12 + backend/app/discovery/__init__.py | 38 + backend/app/discovery/criteria.py | 77 + backend/app/discovery/definitions.py | 51 + backend/app/discovery/diff.py | 74 + backend/app/discovery/enums.py | 78 + backend/app/discovery/identity.py | 81 + backend/app/discovery/mapping.py | 34 + backend/app/discovery/ranking.py | 100 + backend/app/discovery/urlgen.py | 82 + backend/app/domain/__init__.py | 0 backend/app/domain/attributes.py | 45 + backend/app/domain/constraints.py | 148 + backend/app/domain/enums.py | 81 + backend/app/domain/models.py | 275 + backend/app/domain/serialize.py | 26 + backend/app/domain/timeutil.py | 11 + backend/app/domain/validation.py | 307 + backend/app/errors.py | 20 + backend/app/main.py | 48 + backend/app/normalization/__init__.py | 0 backend/app/normalization/base.py | 11 + backend/app/normalization/manual.py | 61 + backend/app/persistence/__init__.py | 0 {app => backend/app}/persistence/db.py | 4 + backend/app/persistence/discovery_models.py | 321 + backend/app/persistence/mapping.py | 105 + backend/app/persistence/models.py | 249 + backend/app/persistence/repositories.py | 183 + backend/app/scoring/__init__.py | 0 backend/app/scoring/deal.py | 42 + backend/app/scoring/engine.py | 545 + backend/app/scoring/mathutil.py | 21 + backend/app/scoring/price.py | 148 + backend/app/scoring/product.py | 86 + backend/app/services/__init__.py | 0 backend/app/services/catalog_service.py | 224 + backend/app/services/discovery_service.py | 1431 +++ backend/app/services/evaluation_service.py | 210 + backend/app/services/import_service.py | 94 + backend/app/services/ingestion_service.py | 263 + backend/app/services/project_service.py | 346 + {app => backend/app}/settings.py | 7 + backend/app/sources/__init__.py | 0 backend/app/sources/base.py | 85 + backend/app/sources/geizhals/__init__.py | 3 + backend/app/sources/geizhals/adapter.py | 81 + backend/app/sources/manual.py | 110 + .../fixtures/geizhals/nb-search-page1.json | 38 + .../fixtures/geizhals/nb-search-page2.json | 23 + backend/fixtures/geizhals/nb-taxonomy.json | 27 + backend/fixtures/manual-demo.json | 80 + {migrations => backend/migrations}/env.py | 0 .../migrations}/script.py.mako | 0 .../versions/0001_deploy_skeleton.py | 2 +- .../migrations/versions/0002_domain_schema.py | 233 + .../versions/0003_discovery_schema.py | 229 + .../0004_dynamic_attributes_criteria.py | 127 + .../versions/0005_frontend_mvp_v03.py | 261 + .../profiles/mobile-development-notebook.json | 23 + pyproject.toml => backend/pyproject.toml | 4 +- backend/rules/generic-v1.json | 23 + backend/scripts/__init__.py | 0 backend/scripts/bootstrap.py | 71 + backend/scripts/demo.py | 138 + backend/scripts/seed_notebook_pilot.py | 81 + {scripts => backend/scripts}/startup.sh | 1 + backend/tests/__init__.py | 0 backend/tests/conftest.py | 150 + backend/tests/e2e/__init__.py | 0 .../tests/e2e/test_discovery_walkthrough.py | 199 + backend/tests/e2e/test_http_walkthrough.py | 204 + backend/tests/integration/__init__.py | 0 .../integration/test_discovery_lifecycle.py | 249 + .../tests/integration/test_manual_source.py | 59 + .../tests}/integration/test_ready.py | 0 .../integration/test_schema_and_bootstrap.py | 53 + backend/tests/unit/__init__.py | 0 backend/tests/unit/test_constraints.py | 59 + backend/tests/unit/test_discovery_diff.py | 74 + backend/tests/unit/test_discovery_identity.py | 83 + backend/tests/unit/test_discovery_ranking.py | 48 + backend/tests/unit/test_discovery_urlgen.py | 81 + backend/tests/unit/test_engine_cases.py | 165 + .../tests}/unit/test_health_live.py | 0 backend/tests/unit/test_price_score.py | 103 + backend/tests/unit/test_product_score.py | 91 + .../tests/unit/test_project_criteria_parse.py | 27 + .../tests}/unit/test_settings.py | 0 uv.lock => backend/uv.lock | 0 docker-compose.dev-env.yml | 21 +- docker-compose.test.yml | 3 +- docker-compose.yml | 19 +- docs/DEPLOYMENT.md | 5 +- docs/buying-agent-architecture-addendum-v1.md | 377 + frontend/Dockerfile | 11 + frontend/README.md | 12 + frontend/e2e/notebook-pilot.spec.ts | 7 + frontend/eslint.config.js | 6 + frontend/index.html | 15 + frontend/nginx.conf | 21 + frontend/package-lock.json | 8837 +++++++++++++++++ frontend/package.json | 36 + frontend/playwright.config.ts | 10 + frontend/public/icon.svg | 4 + frontend/public/manifest.webmanifest | 9 + frontend/public/offline.html | 11 + frontend/src/App.tsx | 44 + frontend/src/api.ts | 152 + .../src/components/CriteriaControl.test.tsx | 33 + frontend/src/components/CriteriaControl.tsx | 71 + frontend/src/components/State.tsx | 20 + frontend/src/main.tsx | 18 + frontend/src/pages/AdminPage.tsx | 63 + frontend/src/pages/CandidateDetailPage.tsx | 53 + frontend/src/pages/CandidateList.tsx | 45 + frontend/src/pages/ProjectDetailPage.tsx | 127 + frontend/src/pages/ProjectListPage.tsx | 53 + frontend/src/pages/WizardPage.tsx | 185 + frontend/src/styles.css | 41 + frontend/src/test-setup.ts | 1 + frontend/src/vite-env.d.ts | 1 + frontend/tsconfig.app.json | 21 + frontend/tsconfig.json | 4 + frontend/vite.config.ts | 46 + scripts/ci-pytest.sh | 17 +- 152 files changed, 25846 insertions(+), 34 deletions(-) create mode 100644 .specs/buying-agent-architecture-addendum-v1.md create mode 100644 .specs/buying-agent-frontend-mvp-spec-v0.1.md create mode 100644 .specs/buying-agent-frontend-mvp-spec-v0.2.md create mode 100644 .specs/buying-agent-frontend-mvp-spec-v0.3.md create mode 100644 .specs/buying-agent-source-discovery-spec-v0.1.md delete mode 100644 app/__init__.py delete mode 100644 app/main.py create mode 100644 backend/.dockerignore rename Dockerfile => backend/Dockerfile (100%) rename alembic.ini => backend/alembic.ini (96%) create mode 100644 backend/app/__init__.py create mode 100644 backend/app/api/__init__.py create mode 100644 backend/app/api/discovery.py create mode 100644 backend/app/api/evaluations.py rename {app => backend/app}/api/health.py (100%) create mode 100644 backend/app/api/offers.py create mode 100644 backend/app/api/products.py create mode 100644 backend/app/api/profiles.py create mode 100644 backend/app/api/projects.py create mode 100644 backend/app/api/schemas.py create mode 100644 backend/app/clock.py create mode 100644 backend/app/discovery/__init__.py create mode 100644 backend/app/discovery/criteria.py create mode 100644 backend/app/discovery/definitions.py create mode 100644 backend/app/discovery/diff.py create mode 100644 backend/app/discovery/enums.py create mode 100644 backend/app/discovery/identity.py create mode 100644 backend/app/discovery/mapping.py create mode 100644 backend/app/discovery/ranking.py create mode 100644 backend/app/discovery/urlgen.py create mode 100644 backend/app/domain/__init__.py create mode 100644 backend/app/domain/attributes.py create mode 100644 backend/app/domain/constraints.py create mode 100644 backend/app/domain/enums.py create mode 100644 backend/app/domain/models.py create mode 100644 backend/app/domain/serialize.py create mode 100644 backend/app/domain/timeutil.py create mode 100644 backend/app/domain/validation.py create mode 100644 backend/app/errors.py create mode 100644 backend/app/main.py create mode 100644 backend/app/normalization/__init__.py create mode 100644 backend/app/normalization/base.py create mode 100644 backend/app/normalization/manual.py create mode 100644 backend/app/persistence/__init__.py rename {app => backend/app}/persistence/db.py (84%) create mode 100644 backend/app/persistence/discovery_models.py create mode 100644 backend/app/persistence/mapping.py create mode 100644 backend/app/persistence/models.py create mode 100644 backend/app/persistence/repositories.py create mode 100644 backend/app/scoring/__init__.py create mode 100644 backend/app/scoring/deal.py create mode 100644 backend/app/scoring/engine.py create mode 100644 backend/app/scoring/mathutil.py create mode 100644 backend/app/scoring/price.py create mode 100644 backend/app/scoring/product.py create mode 100644 backend/app/services/__init__.py create mode 100644 backend/app/services/catalog_service.py create mode 100644 backend/app/services/discovery_service.py create mode 100644 backend/app/services/evaluation_service.py create mode 100644 backend/app/services/import_service.py create mode 100644 backend/app/services/ingestion_service.py create mode 100644 backend/app/services/project_service.py rename {app => backend/app}/settings.py (70%) create mode 100644 backend/app/sources/__init__.py create mode 100644 backend/app/sources/base.py create mode 100644 backend/app/sources/geizhals/__init__.py create mode 100644 backend/app/sources/geizhals/adapter.py create mode 100644 backend/app/sources/manual.py create mode 100644 backend/fixtures/geizhals/nb-search-page1.json create mode 100644 backend/fixtures/geizhals/nb-search-page2.json create mode 100644 backend/fixtures/geizhals/nb-taxonomy.json create mode 100644 backend/fixtures/manual-demo.json rename {migrations => backend/migrations}/env.py (100%) rename {migrations => backend/migrations}/script.py.mako (100%) rename {migrations => backend/migrations}/versions/0001_deploy_skeleton.py (84%) create mode 100644 backend/migrations/versions/0002_domain_schema.py create mode 100644 backend/migrations/versions/0003_discovery_schema.py create mode 100644 backend/migrations/versions/0004_dynamic_attributes_criteria.py create mode 100644 backend/migrations/versions/0005_frontend_mvp_v03.py create mode 100644 backend/profiles/mobile-development-notebook.json rename pyproject.toml => backend/pyproject.toml (89%) create mode 100644 backend/rules/generic-v1.json create mode 100644 backend/scripts/__init__.py create mode 100644 backend/scripts/bootstrap.py create mode 100644 backend/scripts/demo.py create mode 100644 backend/scripts/seed_notebook_pilot.py rename {scripts => backend/scripts}/startup.sh (85%) create mode 100644 backend/tests/__init__.py create mode 100644 backend/tests/conftest.py create mode 100644 backend/tests/e2e/__init__.py create mode 100644 backend/tests/e2e/test_discovery_walkthrough.py create mode 100644 backend/tests/e2e/test_http_walkthrough.py create mode 100644 backend/tests/integration/__init__.py create mode 100644 backend/tests/integration/test_discovery_lifecycle.py create mode 100644 backend/tests/integration/test_manual_source.py rename {tests => backend/tests}/integration/test_ready.py (100%) create mode 100644 backend/tests/integration/test_schema_and_bootstrap.py create mode 100644 backend/tests/unit/__init__.py create mode 100644 backend/tests/unit/test_constraints.py create mode 100644 backend/tests/unit/test_discovery_diff.py create mode 100644 backend/tests/unit/test_discovery_identity.py create mode 100644 backend/tests/unit/test_discovery_ranking.py create mode 100644 backend/tests/unit/test_discovery_urlgen.py create mode 100644 backend/tests/unit/test_engine_cases.py rename {tests => backend/tests}/unit/test_health_live.py (100%) create mode 100644 backend/tests/unit/test_price_score.py create mode 100644 backend/tests/unit/test_product_score.py create mode 100644 backend/tests/unit/test_project_criteria_parse.py rename {tests => backend/tests}/unit/test_settings.py (100%) rename uv.lock => backend/uv.lock (100%) create mode 100644 docs/buying-agent-architecture-addendum-v1.md create mode 100644 frontend/Dockerfile create mode 100644 frontend/README.md create mode 100644 frontend/e2e/notebook-pilot.spec.ts create mode 100644 frontend/eslint.config.js create mode 100644 frontend/index.html create mode 100644 frontend/nginx.conf create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/playwright.config.ts create mode 100644 frontend/public/icon.svg create mode 100644 frontend/public/manifest.webmanifest create mode 100644 frontend/public/offline.html create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/api.ts create mode 100644 frontend/src/components/CriteriaControl.test.tsx create mode 100644 frontend/src/components/CriteriaControl.tsx create mode 100644 frontend/src/components/State.tsx create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/pages/AdminPage.tsx create mode 100644 frontend/src/pages/CandidateDetailPage.tsx create mode 100644 frontend/src/pages/CandidateList.tsx create mode 100644 frontend/src/pages/ProjectDetailPage.tsx create mode 100644 frontend/src/pages/ProjectListPage.tsx create mode 100644 frontend/src/pages/WizardPage.tsx create mode 100644 frontend/src/styles.css create mode 100644 frontend/src/test-setup.ts create mode 100644 frontend/src/vite-env.d.ts create mode 100644 frontend/tsconfig.app.json create mode 100644 frontend/tsconfig.json create mode 100644 frontend/vite.config.ts diff --git a/.env.example b/.env.example index 94709c2..5b19c48 100644 --- a/.env.example +++ b/.env.example @@ -10,6 +10,9 @@ ENVIRONMENT=production ENGINE_BUILD=dev BUYING_AGENT_API_BIND=127.0.0.1 BUYING_AGENT_API_PORT=8000 +BUYING_AGENT_FRONTEND_BIND=127.0.0.1 +BUYING_AGENT_FRONTEND_PORT=4173 +CORS_ALLOWED_ORIGINS=http://localhost:4173,http://127.0.0.1:4173,http://localhost:5173,http://127.0.0.1:5173 # Production on the Raspberry Pi (192.168.2.49): # BUYING_AGENT_API_BIND=0.0.0.0 diff --git a/.gitea/workflows/deploy-dev.yml b/.gitea/workflows/deploy-dev.yml index 155f042..66c026e 100644 --- a/.gitea/workflows/deploy-dev.yml +++ b/.gitea/workflows/deploy-dev.yml @@ -41,7 +41,13 @@ jobs: export ENGINE_BUILD BUYING_AGENT_API_PORT ENGINE_BUILD="$(git rev-parse --short HEAD)" BUYING_AGENT_API_PORT=8095 - docker compose -f docker-compose.dev-env.yml build --pull=false api + if ! grep -q '^CORS_ALLOWED_ORIGINS=' .env; then + echo 'CORS_ALLOWED_ORIGINS=http://192.168.2.49:5174,http://localhost:5173' >> .env + fi + if ! grep -q '^BUYING_AGENT_FRONTEND_PORT=' .env; then + echo 'BUYING_AGENT_FRONTEND_PORT=5174' >> .env + fi + docker compose -f docker-compose.dev-env.yml build --pull=false api frontend if ! ENGINE_BUILD="$ENGINE_BUILD" BUYING_AGENT_API_PORT="$BUYING_AGENT_API_PORT" \ docker compose -f docker-compose.dev-env.yml up -d --wait; then echo "compose up --wait failed — api logs:" diff --git a/.gitea/workflows/deploy-prod.yml b/.gitea/workflows/deploy-prod.yml index 367953d..d9505b0 100644 --- a/.gitea/workflows/deploy-prod.yml +++ b/.gitea/workflows/deploy-prod.yml @@ -48,7 +48,13 @@ jobs: ENGINE_BUILD="$(git rev-parse --short HEAD)" BUYING_AGENT_API_BIND=0.0.0.0 BUYING_AGENT_API_PORT=8006 - docker compose build --pull=false api + if ! grep -q '^CORS_ALLOWED_ORIGINS=' .env; then + echo 'CORS_ALLOWED_ORIGINS=http://192.168.2.49:4173,http://localhost:4173' >> .env + fi + if ! grep -q '^BUYING_AGENT_FRONTEND_PORT=' .env; then + echo 'BUYING_AGENT_FRONTEND_PORT=4173' >> .env + fi + docker compose build --pull=false api frontend if ! ENGINE_BUILD="$ENGINE_BUILD" \ BUYING_AGENT_API_BIND="$BUYING_AGENT_API_BIND" \ BUYING_AGENT_API_PORT="$BUYING_AGENT_API_PORT" \ diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml index 76d73ca..f24301f 100644 --- a/.gitea/workflows/test.yml +++ b/.gitea/workflows/test.yml @@ -57,3 +57,18 @@ jobs: curl -sf http://localhost:8095/health/ready echo echo "LAN smoke OK: http://192.168.2.49:8095" + + frontend-checks: + if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }} + runs-on: ubuntu-latest + steps: + - name: Frontend typecheck, lint, test, build + run: | + set -e + APP_DIR="$HOME/docker/buyingagent-dev" + cd "$APP_DIR/frontend" + npm ci || npm install + npm run typecheck + npm run lint + npm test + npm run build diff --git a/.gitignore b/.gitignore index a6bdecb..25d6160 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,10 @@ dist/ build/ +# Frontend +node_modules/ +frontend/dev-dist/ + # OS / editor .DS_Store Thumbs.db diff --git a/.specs/buying-agent-architecture-addendum-v1.md b/.specs/buying-agent-architecture-addendum-v1.md new file mode 100644 index 0000000..6b473a7 --- /dev/null +++ b/.specs/buying-agent-architecture-addendum-v1.md @@ -0,0 +1,377 @@ +# Buying Agent – Architecture Addendum v1 + +**Status:** ergänzende Architekturvorgabe +**Gültig zusammen mit:** `buying-agent-v0.1-spec-v1.1.md` +**Stand:** 2026-10-03 + +## 1. Zweck + +Dieses Addendum ergänzt die bestehende Spezifikation um übergreifende Architekturentscheidungen für das spätere Zielsystem. + +Es ersetzt **nicht** die fachliche Spezifikation `buying-agent-v0.1-spec-v1.1.md`. + +Bei Konflikten zwischen beiden Dokumenten hat dieses Addendum für die hier behandelten Architekturthemen Vorrang. + +Ziel ist, den aktuellen v0.1-Slice schlank zu halten, ohne spätere Erweiterungen auf Frontend, mehrere Kaufprojekte und mehrere Benutzer unnötig zu erschweren. + +--- + +## 2. Zielstruktur des Repositories + +Das Projekt wird als Monorepo strukturiert: + +```text +buying-agent/ +├── README.md +├── docker-compose.yml +├── .env.example +├── .gitignore +├── docs/ +│ ├── buying-agent-v0.1-spec-v1.1.md +│ └── buying-agent-architecture-addendum-v1.md +├── backend/ +│ ├── Dockerfile +│ ├── pyproject.toml +│ ├── uv.lock +│ ├── alembic.ini +│ ├── migrations/ +│ ├── app/ +│ ├── rules/ +│ ├── profiles/ +│ ├── fixtures/ +│ ├── scripts/ +│ └── tests/ +└── frontend/ + └── README.md +``` + +### Regeln + +- `docker-compose.yml` bleibt im Repository-Root. +- Das Backend ist der aktuell implementierte v0.1-Scope. +- `frontend/` wird in v0.1 **nicht implementiert**. +- `frontend/README.md` dokumentiert lediglich, dass dort später die Benutzeroberfläche entsteht. +- Keine zusätzliche `infra/`-Struktur in v0.1, solange sie nicht konkret benötigt wird. + +--- + +## 3. Backend-/Frontend-Grenze + +Das Backend bleibt das **System of Record** und besitzt: + +- Domain-Modell +- Constraints +- Scoring +- Evaluation +- Persistenz +- Preis- und Produktlogik +- zukünftige Kaufentscheidungslogik + +Das spätere Frontend konsumiert ausschließlich die versionierte Backend-API, primär unter: + +```text +/api/v1 +``` + +Das Frontend enthält keine fachlich verbindliche Business-Logik. + +--- + +## 4. Zukünftige Multi-User-Architektur + +Das Zielsystem soll später von Familie und gegebenenfalls engen Freunden genutzt werden können. + +Die Architektur darf daher nicht von einem impliziten Single-User-Modell ausgehen. + +Zielmodell: + +```text +User + ↕ +WorkspaceMember + ↕ +Workspace + ↓ +BuyingProject + ↓ +ProjectCandidate + ↓ +ProductVariant +``` + +Ein Benutzer kann Mitglied in mehreren Workspaces sein. + +Beispiele: + +```text +Lars +├── Privat +├── Familie +└── Freunde +``` + +### Noch nicht Bestandteil von v0.1 + +Nicht implementieren: + +- Login +- Authentifizierung +- Passwortverwaltung +- Einladungen +- RBAC +- komplexe ACLs +- Sharing-UI +- Workspace-Administration + +Die aktuelle Architektur darf deren spätere Einführung aber nicht unnötig erschweren. + +--- + +## 5. Globale versus Workspace-bezogene Daten + +### Global wiederverwendbare Daten + +Diese Entitäten bleiben systemweit gemeinsam nutzbar: + +- Product +- ProductVariant +- Offer +- PriceObservation +- AttributeDefinition +- Source + +Sie erhalten **keine** `user_id` oder `workspace_id`. + +Ein Produkt oder Angebot darf nicht pro Benutzer oder Workspace dupliziert werden. + +### Workspace-bezogene Daten + +Spätere benutzerbezogene Beschaffungskontexte gehören zu einem Workspace: + +- BuyingProject +- ProjectCandidate +- projektspezifische Profil-/Anforderungsauswahl +- Notification Preferences +- Beschaffungsdeadline +- Zielpreis +- Kaufentscheidungsstatus +- gegebenenfalls spätere Warenkorb-/Beschaffungsinformationen + +Diese Objekte werden erst in späteren Slices implementiert. + +--- + +## 6. BuyingProject als zukünftiges Kernobjekt + +Ein `BuyingProject` repräsentiert ein konkretes Beschaffungsvorhaben. + +Beispiele: + +- Mobiler Entwicklungsrechner +- Lokaler LLM-Server +- Fernseher +- Kamera + +Ein Workspace kann mehrere BuyingProjects parallel besitzen. + +Ein BuyingProject kann mehrere ProjectCandidates beobachten. + +```text +BuyingProject +├── Candidate A → ProductVariant +├── Candidate B → ProductVariant +└── Candidate C → ProductVariant +``` + +### Wichtig für v0.1 + +`BuyingProject` und `ProjectCandidate` müssen in v0.1 noch nicht implementiert werden. + +Die aktuelle Implementierung darf jedoch nicht annehmen: + +- es existiert genau ein aktives Profil, +- es existiert genau ein Kaufvorhaben, +- es gibt globale Singleton-Konfiguration für einen aktuellen Kauf, +- alle Daten gehören automatisch demselben Benutzer, +- es existiert nur eine gleichzeitig relevante Evaluation. + +Mehrere unabhängige Profile und Evaluationen müssen bereits parallel koexistieren können. + +--- + +## 7. Rolle von n8n + +n8n bleibt eine externe Orchestrierungsschicht. + +Spätere mögliche Aufgaben: + +- Scheduler +- Preisabfragen +- Datenimport +- Benachrichtigungen +- externe Integrationen +- volatile oder häufig veränderte Workflows + +Kern-Business-Logik bleibt im Programmcode, insbesondere wenn sie: + +- deterministisch sein soll, +- versioniert werden muss, +- getestet werden muss, +- fachliche Entscheidungen reproduzierbar machen soll. + +n8n darf für veränderliche Prozesslogik eingesetzt werden, aber nicht zur Voraussetzung für das Funktionieren der Buying Engine werden. + +--- + +## 8. Zukünftiges Frontend + +Das Zielsystem soll später eine einfach bedienbare Oberfläche erhalten. + +Die Standardoberfläche soll auch für nichttechnische Familienmitglieder verständlich bleiben. + +Die Engine darf gleichzeitig deutlich leistungsfähiger sein als die Oberfläche. + +Voraussichtliche spätere Kernbereiche: + +- Übersicht laufender Kaufprojekte +- Kandidaten je Projekt +- aktueller bester Deal +- Preisverlauf +- Produkt-Fit +- Offer-Fit +- DealScore +- verständliche Gründe +- Beobachtungsstatus +- konkrete Kaufempfehlung +- ggf. spätere Benachrichtigungs- und Warenkorbfunktionen + +Keine dieser UI-Funktionen ist Bestandteil von v0.1. + +--- + +## 9. Kaufentscheidungslogik + +Langfristig soll das System nicht nur Scores liefern, sondern auch konfigurierbare Kaufentscheidungen unterstützen. + +Beispiele möglicher Einflussfaktoren: + +- Zielpreis +- Maximalpreis +- spätestes Beschaffungsdatum +- aktueller Preis +- Preisentwicklung der letzten Tage/Wochen/Monate +- Abstand zu historischem Median +- Produkt-Fit +- Offer-Fit +- Verfügbarkeit +- Lieferzeit +- Händlerqualität +- alternative Kandidaten +- Beschaffungsdringlichkeit + +Die Kaufentscheidung soll nachvollziehbar und möglichst deterministisch bleiben. + +LLMs dürfen später Erklärungen, Normalisierung oder qualitative Interpretation unterstützen, sollen aber keine nicht nachvollziehbare Blackbox-Entscheidung erzwingen. + +--- + +## 10. Datenquellen + +Das Zielsystem muss mehrere Produkt- und Preisquellen unterstützen können. + +Mögliche spätere Quellen: + +- Amazon / Keepa +- Preisvergleichsportale +- Hersteller +- Händler +- weitere APIs + +v0.1 implementiert weiterhin **keine reale externe Quelle**. + +Die bestehende Source-Adapter-Grenze bleibt die vorgesehene Erweiterungsstelle. + +--- + +## 11. Scope-Grenze für die laufende v0.1-Implementierung + +Dieses Addendum darf nicht als Anlass dienen, v0.1 unnötig zu vergrößern. + +Insbesondere **nicht jetzt implementieren**: + +- echtes Frontend +- Authentifizierung +- User Management +- Workspace Management +- RBAC +- Einladungen +- BuyingProject +- ProjectCandidate +- Notifications +- n8n +- externe Preisquellen +- automatische Produktsuche +- Warenkorbmanagement + +Ziel ist Architekturkompatibilität, nicht vorgezogene Feature-Entwicklung. + +--- + +## 12. Arbeitsauftrag für die laufende Implementierung + +Der Coding-Agent soll vor Fortsetzung des laufenden Builds einen kurzen Architecture-Alignment-Check durchführen. + +### Prüfen + +1. Entspricht die Repository-Struktur bereits der Monorepo-Struktur mit: + - `backend/` + - `frontend/` + - `docs/` + - Root-`docker-compose.yml` + +2. Gibt es Annahmen über: + - genau einen Benutzer, + - genau ein aktives Profil, + - genau ein Kaufprojekt, + - globale Singleton-Zustände? + +3. Sind Product, ProductVariant, Offer und PriceObservation weiterhin global und unabhängig von späteren Workspaces modelliert? + +4. Ist das Backend klar von zukünftiger Frontend- und n8n-Logik getrennt? + +### Änderungen + +Nur notwendige strukturelle Anpassungen durchführen. + +Keine vorgezogene Multi-User- oder Frontend-Funktionalität implementieren. + +Die fachlichen Anforderungen aus `buying-agent-v0.1-spec-v1.1.md` bleiben vollständig gültig. + +### Abschlussbericht + +Dokumentieren: + +- geänderte oder verschobene Dateien, +- notwendige Architekturänderungen, +- Änderungen an Datenbank/Migrationen, +- ausgeführte Tests, +- Testergebnisse, +- verbleibende Risiken für spätere Workspace-/BuyingProject-Erweiterung. + +--- + +## 13. Vorrang und Weiterentwicklung + +Dieses Dokument ist ein Architektur-Addendum zur v0.1-Spezifikation. + +Nach Abschluss von v0.1 sollen die Erkenntnisse aus: + +- `buying-agent-v0.1-spec-v1.1.md` +- diesem Addendum +- der realen Implementierung + +in eine konsolidierte Folgespezifikation für den nächsten Slice übernommen werden. + +Die Kernregel lautet: + +> Nicht vorzeitig bauen, aber bekannte Zielanforderungen nicht architektonisch verbauen. diff --git a/.specs/buying-agent-frontend-mvp-spec-v0.1.md b/.specs/buying-agent-frontend-mvp-spec-v0.1.md new file mode 100644 index 0000000..6e34e15 --- /dev/null +++ b/.specs/buying-agent-frontend-mvp-spec-v0.1.md @@ -0,0 +1,987 @@ +# Buying Agent – Frontend MVP Specification v0.1 + +**Status:** implementierungsbereit +**Spezifikationsversion:** 0.1 +**Stand:** 2026-10-04 + +**Ergänzt verbindlich:** +1. `buying-agent-v0.1-spec-v1.1.md` +2. `buying-agent-architecture-addendum-v1.md` +3. `buying-agent-source-discovery-spec-v0.1.md` + +Diese Frontend-Spezifikation hebt für den nutzbaren MVP die frühere Aussage „kein Frontend in diesem Slice“ auf. +Sie ändert keine fachliche Backend-Logik; sie definiert die Benutzeroberfläche und die dafür notwendigen API-Verträge. + +--- + +## 1. Ziel + +Der Buying Agent soll als **nutzbare PWA** einen vollständigen vertikalen Ablauf unterstützen: + +```text +BuyingProject anlegen + ↓ +Produktkategorie wählen + ↓ +dynamische Kriterien aus Source-Taxonomie auswählen + ↓ +Kriterien konfigurieren + ↓ +Geizhals-Suche generieren + ↓ +SearchRun starten / Status verfolgen + ↓ +Kandidaten sehen + ↓ +Ranking + Shortlist sehen + ↓ +Kandidat öffnen + ↓ +Status, Historie, bisherige Ergebnisse und Empfehlungen sehen + ↓ +Kriterien bei Bedarf anpassen +``` + +Der MVP muss diesen Flow über die Oberfläche vollständig nutzbar machen. + +--- + +## 2. Technologiestack + +Frontend: + +- React +- TypeScript +- Vite +- PWA +- TanStack Query für Server-State / Data Fetching +- React Router oder äquivalente schlanke Routing-Lösung +- schlanke komponentenbasierte UI +- kein Next.js +- kein SSR +- keine SEO-Anforderungen + +Das Frontend liegt im bestehenden Monorepo: + +```text +buying-agent/ +├── backend/ +├── frontend/ +├── docs/ +└── docker-compose.yml +``` + +Backend bleibt System of Record. + +--- + +## 3. Gestaltungsprinzipien + +1. **Einfach für Standardnutzer, leistungsfähig im Hintergrund.** +2. Keine technische Datenbank-/Engine-Sicht in der Standardoberfläche. +3. Dynamische Kriterien statt fest verdrahteter Notebook-Felder. +4. Responsive von Anfang an. +5. Zwei bewusst gestaltete Hauptlayouts: + - Desktop + - Mobile +6. Nüchternes, klares Design. +7. Bedienbarkeit vor visueller Komplexität. +8. Admin-/Source-Themen von der normalen Nutzung trennen. +9. Keine Businesslogik im Frontend. +10. Frontend konsumiert ausschließlich die versionierte Backend-API. + +--- + +## 4. PWA und Responsive Layout + +### Desktop + +Desktop verwendet primär: + +```text +Sidebar ++ Hauptinhalt ++ optionaler Detailbereich +``` + +Beispiel: + +```text +┌───────────────┬──────────────────────────────────────┐ +│ Buying Agent │ Projekt: Mobiler Entwicklungsrechner│ +│ │ │ +│ Projekte │ Status / Suche / Ranking / Shortlist│ +│ Admin │ │ +│ Einstellungen │ │ +└───────────────┴──────────────────────────────────────┘ +``` + +### Mobile + +Mobile verwendet primär: + +- Bottom Navigation oder kompakte Hauptnavigation +- Vollbild-Detailseiten +- Tabs innerhalb eines BuyingProjects +- keine verkleinerte Desktop-Sidebar + +Beispiel: + +```text +Projekt +──────────────── +Übersicht +Kandidaten +Shortlist +Kriterien +Historie + +[ Projekte ] [ Suche ] [ Admin ] +``` + +### Mitai-Bezug + +Falls im bestehenden Mitai-Code wiederverwendbare Layout-, PWA-, Responsive- oder Navigationsmuster vorhanden sind, sollen diese bevorzugt übernommen oder konsistent nachgebildet werden. + +Keine fachliche Kopplung an Mitai. + +--- + +## 5. Hauptnavigation + +Der MVP benötigt mindestens: + +```text +Projekte +Admin +``` + +Optional: + +```text +Systemstatus +``` + +Noch nicht erforderlich: + +- Benutzerverwaltung +- Warenkorb +- Benachrichtigungseinstellungen +- Account-/Profilverwaltung + +--- + +## 6. Screen 1 – BuyingProject-Liste + +Route: + +```text +/projects +``` + +Funktionen: + +- aktive BuyingProjects anzeigen +- Projektname +- Kategorie +- Status +- letzter erfolgreicher SearchRun +- Anzahl Kandidaten +- Anzahl Shortlist-Kandidaten +- aktueller Discovery-Status +- Button „Neues Projekt“ + +Mögliche Status: + +```text +ACTIVE +PURCHASED +ARCHIVED +OUT_OF_SYNC +BLOCKED +TOO_BROAD +``` + +Die Liste darf keine produktkategoriespezifischen Felder voraussetzen. + +--- + +## 7. Screen 2 – BuyingProject anlegen + +Route: + +```text +/projects/new +``` + +### Schritt 1 – Basisdaten + +Mindestens: + +```text +name +source +source_category +market +currency +shortlist_size +schedule_interval +``` + +Default: + +```text +shortlist_size = 5 +schedule_interval_minutes = 180 +``` + +### Schritt 2 – Kriterien + +Nach Auswahl der Source-Kategorie lädt das Frontend dynamisch: + +- Canonical Criteria +- Source Filter Definitions +- CriterionMappings +- Mapping-Status + +Das Frontend erzeugt daraus dynamisch die Eingabefelder. + +Es gibt **keine fest codierten Notebook-Felder**. + +--- + +## 8. Dynamische Kriterien + +Verfügbare Kriterien entstehen aus: + +```text +SourceCategory + ↓ +SourceFilterDefinitions + ↓ +CriterionDefinitions + ↓ +CriterionMappings +``` + +Das Frontend rendert Controls anhand des Typs. + +### Unterstützte Control-Typen + +```text +BOOLEAN +ENUM +MULTI_ENUM +NUMERIC_RANGE +NUMERIC_THRESHOLD +TEXT +``` + +Für ein Kriterium sollen mindestens konfigurierbar sein: + +```text +operator +value +mandatory / optional +``` + +Wenn vom Scoring unterstützt zusätzlich: + +```text +weight +preference strategy +acceptable +ideal +direction +``` + +### Sichtbarer Mapping-Status + +Pro Kriterium: + +```text +VERIFIED +LEARNED_UNVERIFIED +INTERNAL_ONLY +BROKEN +DISABLED +``` + +Standardnutzer sollen primär verständliche Hinweise sehen: + +- „Wird direkt bei Geizhals gefiltert“ +- „Wird erst intern bewertet“ +- „Filter derzeit nicht verfügbar“ + +Technische IDs bleiben im Admin-Bereich. + +--- + +## 9. Kriterienänderungen + +Wenn Kriterien geändert werden: + +```text +criteria_version++ +``` + +Die bestehende DiscoverySearch wird: + +```text +OUT_OF_SYNC +``` + +Danach muss das Backend eine neue Query aus den aktuellen: + +```text +Criteria ++ +VERIFIED Mappings +``` + +erzeugen. + +Das Frontend zeigt klar: + +```text +Suchkriterien wurden geändert. +Die externe Suche muss aktualisiert werden. +``` + +Aktion: + +```text +Suche aktualisieren +``` + +Danach: + +```text +generate-query +→ optional run +→ neuer SearchRun +→ neues Ranking +``` + +--- + +## 10. Screen 3 – BuyingProject-Detail + +Route: + +```text +/projects/:projectId +``` + +Desktop: strukturierte Detailansicht. + +Mobile: Tabs. + +Mindestens folgende Bereiche: + +### Übersicht + +- Projektstatus +- Kategorie +- letzter SearchRun +- Discovery-Status +- Trefferzahl +- Kandidatenzahl +- Shortlist-Größe +- Zeitpunkt nächste geplante Suche +- wichtige Warnungen + +### Kandidaten + +- aktuelle Kandidaten +- ProductScore +- product_eligibility +- aktueller best_price, sofern vorhanden +- Diff-Status +- Lifecycle-Status +- Shortlist-Markierung + +### Shortlist + +Top-N gemäß Projektkonfiguration. + +### Kriterien + +Dynamische Kriterien anzeigen und bearbeiten. + +### Historie + +- SearchRuns +- wichtige Statusänderungen +- bisherige Rankings +- soweit vorhanden frühere Bewertungen / Empfehlungen + +--- + +## 11. Screen 4 – Kandidatenliste + +Route: + +```text +/projects/:projectId/candidates +``` + +Darstellung: + +- sortierbare Liste/Tabelle auf Desktop +- Cards/List Items auf Mobile + +Mindestens: + +```text +Produktname +Hersteller +wichtige dynamische Attribute +best_price +ProductScore +product_eligibility +diff_status +lifecycle_status +shortlist_status +last_seen +``` + +### Dynamische Attribute + +Welche Eigenschaften sichtbar sind, darf nicht notebook-spezifisch hart codiert sein. + +Mögliche Strategie: + +1. Source-/Category-Definition liefert relevante display attributes. +2. Projekt kann später bevorzugte sichtbare Attribute konfigurieren. +3. MVP darf zunächst die wichtigsten vorhandenen normalisierten Attribute anzeigen. + +--- + +## 12. Screen 5 – Kandidatendetail + +Route: + +```text +/projects/:projectId/candidates/:candidateId +``` + +Der Nutzer soll in ein Produkt „hineinspringen“ können. + +Mindestens: + +### Aktueller Status + +- Name +- Hersteller +- Source-Link +- ProductScore +- product_eligibility +- Shortlist-Status +- aktueller best_price +- letzte Sichtung + +### Attribute + +Alle verfügbaren normalisierten dynamischen Produktattribute. + +Keine notebook-spezifische Feldliste. + +### Historie + +- Preisentwicklung aus ProductObservations +- relevante Attributänderungen +- NEW / CHANGED / MISSING / DISAPPEARED +- SearchRun-Bezug + +### Bewertungen / Ergebnisse + +- aktuelle Bewertung +- frühere gespeicherte Bewertungen, soweit Backend vorhanden +- Gründe / Constraint-Ergebnisse +- Coverage / fehlende Daten + +### Spätere Erweiterbarkeit + +Dieser Screen wird später Zielort für: + +- Deep Enrichment +- Tests +- Reviews +- Benchmarks +- Händlervergleich +- Kaufempfehlung + +Diese Funktionen sind noch nicht Bestandteil dieses MVP-Slices. + +--- + +## 13. Screen 6 – Shortlist + +Route: + +```text +/projects/:projectId/shortlist +``` + +Zweck: + +Nur die aktuell relevantesten Produkte zeigen. + +Mindestens: + +- Rang +- Produkt +- ProductScore +- Product Eligibility +- aktueller Preis +- relevante Stärken +- relevante Schwächen / fehlende Daten +- letzter Änderungsstatus + +Default: + +```text +Top 5 +``` + +konfigurierbar am BuyingProject. + +--- + +## 14. Screen 7 – Search Runs / Historie + +Route: + +```text +/projects/:projectId/history +``` + +Anzeige: + +```text +Zeitpunkt +Status +Trefferzahl +Page Count +NEW +CHANGED +MISSING +DISAPPEARED +TOO_BROAD +Fehler / BLOCKED +``` + +Ein SearchRun muss geöffnet werden können, um Detailinformationen zu sehen. + +Kein HTML anzeigen oder archivieren. + +--- + +## 15. Screen 8 – Admin: Taxonomie & Mapping + +Route: + +```text +/admin/sources +/admin/sources/:source/categories/:category +``` + +Dieser Bereich darf technischer sein. + +Funktionen: + +- gelernte Kategorien sehen +- SourceFilterDefinitions sehen +- SourceFilterOptions/Ranges sehen +- Canonical Criteria sehen +- CriterionMappings sehen +- Mapping-Status ändern +- fehlerhafte kleine Mapping-Änderungen direkt korrigieren +- BROKEN / DISABLED erkennen +- Taxonomie neu einlesen +- Query-Mapping nachvollziehen + +### Wichtig + +Das Frontend darf kein URL-Encoding selbst erfinden. + +Es editiert/pflegt nur persistierte Mapping-Daten. + +Backend bleibt für Validierung und Query-Generation verantwortlich. + +--- + +## 16. Server State und Data Fetching + +Empfehlung: + +```text +TanStack Query +``` + +für: + +- Projekte +- Kriterien +- Taxonomie +- Mappings +- SearchRuns +- Kandidaten +- Shortlist +- Produktdetails + +Client-State nur für: + +- UI-Zustände +- Formulare +- Tabs +- lokale Filter + +Keine fachlich relevante Server-Wahrheit ausschließlich im Browser halten. + +--- + +## 17. API-Verträge + +Das Frontend nutzt mindestens: + +### Projekte + +```text +GET /api/v1/buying-projects +POST /api/v1/buying-projects +GET /api/v1/buying-projects/{id} +PATCH /api/v1/buying-projects/{id} +``` + +### Projektkriterien + +```text +GET /api/v1/buying-projects/{id}/criteria +PUT /api/v1/buying-projects/{id}/criteria +``` + +Falls Backend intern versionierte Ressourcen bevorzugt, kann PUT eine neue Version erzeugen. + +### Taxonomie + +```text +POST /api/v1/sources/{source}/categories/{category}/discover-taxonomy +GET /api/v1/sources/{source}/categories/{category}/filters +``` + +### Criterion Definitions / Mappings + +```text +GET /api/v1/criterion-definitions +GET /api/v1/criterion-mappings +POST /api/v1/criterion-mappings +PATCH /api/v1/criterion-mappings/{id} +``` + +### Discovery Search + +```text +GET /api/v1/buying-projects/{id}/discovery-search +POST /api/v1/discovery-searches/{id}/generate-query +POST /api/v1/discovery-searches/{id}/run +``` + +### Search Runs + +```text +GET /api/v1/discovery-searches/{id}/runs +GET /api/v1/search-runs/{id} +``` + +### Candidates + +```text +GET /api/v1/buying-projects/{id}/candidates +GET /api/v1/buying-projects/{id}/shortlist +GET /api/v1/buying-projects/{id}/candidates/{candidateId} +``` + +--- + +## 18. Status- und Fehlerdarstellung + +Frontend muss mindestens folgende Zustände sichtbar machen: + +### Discovery + +```text +SUCCESS +PARTIAL +FAILED +BLOCKED +TOO_BROAD +OUT_OF_SYNC +``` + +### Candidate Diff + +```text +NEW +CHANGED +UNCHANGED +MISSING +DISAPPEARED +``` + +### Matching + +```text +MATCHED +MATCH_UNCERTAIN +``` + +### Mapping + +```text +DISCOVERED +LEARNED_UNVERIFIED +VERIFIED +BROKEN +DISABLED +INTERNAL_ONLY +``` + +Fehlertexte für Standardnutzer verständlich formulieren. + +Technische Details optional aufklappbar. + +--- + +## 19. Loading / Empty / Error States + +Jeder Screen braucht explizit: + +- Loading +- Empty +- Error +- Retry + +Beispiele: + +```text +Noch keine Kandidaten gefunden. +Noch kein SearchRun ausgeführt. +Taxonomie wurde noch nicht geladen. +Diese Suche ist derzeit BLOCKED. +Die Kriterien wurden geändert; Suche ist OUT_OF_SYNC. +``` + +--- + +## 20. PWA-Grundgerüst + +Mindestens: + +- installierbare PWA +- Manifest +- Icons / App-Metadaten +- responsive shell +- app-like navigation +- sinnvolle Offline-Fallback-Seite + +Keine Offline-Businesslogik erforderlich. + +Keine komplexe Synchronisationslogik in v0.1. + +--- + +## 21. Tests + +### Unit / Component + +- dynamischer Criterion Renderer je Filtertyp +- Mapping-Status-Darstellung +- Candidate Status-Darstellung +- responsive Komponenten +- Formvalidierung + +### API Integration + +- Projektliste +- Projektanlage +- Kriterien laden/speichern +- SearchRun starten +- Kandidaten laden +- Shortlist laden +- Admin-Mapping aktualisieren + +### E2E + +Mindestens ein vollständiger Notebook-Pilot: + +```text +Projekt anlegen +→ Kategorie Notebooks +→ dynamische Kriterien laden +→ Kriterien setzen +→ Geizhals Query generieren +→ SearchRun starten +→ Kandidaten erscheinen +→ Ranking sichtbar +→ Shortlist sichtbar +→ Kandidat öffnen +→ Historie sichtbar +→ Kriterium ändern +→ OUT_OF_SYNC sichtbar +→ Query neu erzeugen +``` + +### Responsive E2E + +Mindestens: + +- Desktop-Viewport +- Mobile-Viewport + +--- + +## 22. Nicht implementieren + +In diesem Slice weiterhin nicht: + +- Login/Auth +- RBAC +- Einladungen +- Multi-User-UI +- Keepa +- Idealo +- WhatsApp +- Warenkorb +- Kaufautomatisierung +- LLM-Automation +- Deep Enrichment +- finale Decision Engine + +Die Architektur darf diese Erweiterungen nicht verbauen. + +--- + +## 23. Definition of Done + +- [ ] React + TypeScript + Vite Frontend existiert im Monorepo. +- [ ] PWA ist installierbar. +- [ ] Desktop- und Mobile-Layouts sind bewusst getrennt gestaltet. +- [ ] BuyingProjects können über die UI angelegt und geöffnet werden. +- [ ] Produktkategorie kann ausgewählt werden. +- [ ] Kriterien werden dynamisch aus Taxonomie / CriterionDefinitions erzeugt. +- [ ] Keine notebook-spezifischen Criteria-Felder im Frontend-Core. +- [ ] Kriterien können bearbeitet und gespeichert werden. +- [ ] Mapping-Status ist sichtbar. +- [ ] INTERNAL_ONLY-Kriterien können gespeichert werden. +- [ ] Discovery Search kann generiert werden. +- [ ] SearchRun kann aus der UI gestartet werden. +- [ ] SearchRun-Status ist sichtbar. +- [ ] Kandidatenliste funktioniert. +- [ ] Ranking ist sichtbar. +- [ ] Shortlist funktioniert. +- [ ] Kandidatendetail zeigt dynamische Attribute und Historie. +- [ ] Projekt-Historie zeigt SearchRuns und relevante Änderungen. +- [ ] Kriterienänderung setzt/zeigt OUT_OF_SYNC und ermöglicht Regeneration. +- [ ] Admin-Bereich erlaubt Prüfung/Korrektur von Source-Taxonomie und Mappings. +- [ ] Loading-/Empty-/Error-Zustände existieren. +- [ ] E2E-Test des vollständigen Notebook-Piloten ist grün. +- [ ] Desktop- und Mobile-E2E sind grün. +- [ ] Backend bleibt alleinige Quelle fachlicher Wahrheit. + +--- + +# Coding-Agent-Arbeitsauftrag + +Verwende als verbindliche Anforderungen: + +1. `buying-agent-v0.1-spec-v1.1.md` +2. `buying-agent-architecture-addendum-v1.md` +3. `buying-agent-source-discovery-spec-v0.1.md` +4. `buying-agent-frontend-mvp-spec-v0.1.md` + +Die Frontend-MVP-Spec hebt für den nutzbaren MVP die frühere Scope-Aussage „kein Frontend“ auf. + +## 1. Vor Implementierung + +Prüfe den aktuellen Repository-Stand gegen alle vier Dokumente. + +Berichte kurz: + +- welche Backend-APIs für den Frontend-Flow bereits existieren, +- welche Endpunkte fehlen, +- ob aktuelle DTOs dynamische Kriterien und Produktattribute vollständig tragen, +- ob bestehender Code notebook-spezifische Felder enthält, +- welche Änderungen rein additiv möglich sind. + +Keine größere strukturelle Abweichung ohne Rückfrage. + +## 2. Vertikalen MVP implementieren + +Nicht Backend und Frontend getrennt in langen Phasen bauen. + +Stattdessen vertikal: + +```text +Projektliste +→ Projektanlage +→ dynamische Kriterien +→ Query Generation +→ SearchRun +→ Kandidaten +→ Ranking +→ Shortlist +→ Kandidatendetail +``` + +Jeder Schritt soll nach Implementierung unmittelbar über die UI prüfbar sein. + +## 3. Dynamik sicherstellen + +Keine festen Felder wie: + +```text +ram_gb +storage_gb +weight_kg +display_inches +``` + +in generischen Frontend-Komponenten hardcoden. + +Controls und sichtbare Produktattribute werden metadata-driven erzeugt. + +Notebook-spezifische Werte dürfen nur als gelernte/gespeicherte Daten vorkommen. + +## 4. UI + +- React + TypeScript + Vite +- PWA +- TanStack Query +- einfache, nüchterne Bedienung +- Desktop: Sidebar + Content/Detail +- Mobile: kompakte Navigation / Bottom Navigation + Vollbild-Details +- vorhandene Mitai-Muster wiederverwenden, falls im zugänglichen Repo vorhanden und passend + +## 5. Tests + +Führe aus: + +- Frontend Unit/Component Tests +- API Integration Tests +- E2E vollständiger Notebook-Pilot +- Desktop E2E +- Mobile E2E + +## 6. Abschlussbericht + +Dokumentiere: + +- implementierte Screens +- neue/angepasste Backend-Endpunkte +- verwendete Frontend-Bibliotheken +- responsive Verhalten +- ausgeführte Tests und Ergebnisse +- offene Abweichungen +- Risiken für spätere Multi-User-, Deep-Enrichment- und Decision-Erweiterungen diff --git a/.specs/buying-agent-frontend-mvp-spec-v0.2.md b/.specs/buying-agent-frontend-mvp-spec-v0.2.md new file mode 100644 index 0000000..f274fe2 --- /dev/null +++ b/.specs/buying-agent-frontend-mvp-spec-v0.2.md @@ -0,0 +1,1329 @@ +# Buying Agent – Frontend MVP Specification v0.2 + +**Status:** Review Candidate – vor Implementierung erneut durch den Coding-Agenten zu prüfen +**Spezifikationsversion:** 0.2 +**Stand:** 2026-10-04 +**Ersetzt vollständig:** `buying-agent-frontend-mvp-spec-v0.1.md` + +**Gilt zusammen mit:** +1. `buying-agent-v0.1-spec-v1.1.md` +2. `buying-agent-architecture-addendum-v1.md` +3. `buying-agent-source-discovery-spec-v0.1.md` + +Bei Widersprüchen innerhalb des Frontend-/MVP-Bereichs hat diese v0.2 Vorrang. +Sie präzisiert außerdem die für das Frontend notwendigen Backend-Verträge. + +--- + +# 1. Zielbild + +Der Buying Agent soll nicht nur ein Backend-Skeleton sein, sondern als **nutzbare PWA** einen vollständigen vertikalen Nutzerfluss abbilden. + +Der Nutzer soll ohne technische Kenntnisse: + +```text +BuyingProject anlegen + ↓ +Produktkategorie auswählen + ↓ +verfügbare Kriterien aus der Source-Taxonomie laden + ↓ +gewünschte Suchparameter auswählen und Werte setzen + ↓ +externe Suche generieren + ↓ +SearchRun starten / Status verfolgen + ↓ +Kandidaten sehen + ↓ +Shortlist / Ranking sehen, soweit konfiguriert + ↓ +Kandidat öffnen + ↓ +Attribute, Preise, Historie und bisherigen Status sehen + ↓ +Kriterien anpassen + ↓ +neue Suche ausführen +``` + +Die Oberfläche soll einfach bleiben. Die technische Komplexität verbleibt im Backend. + +--- + +# 2. Grundsätzliche Architektur + +## 2.1 Backend bleibt System of Record + +Das Frontend enthält keine fachliche Kernlogik. + +Es konsumiert ausschließlich die versionierte `/api/v1`-API. + +Fachliche Entscheidungen über: + +- Taxonomie +- Mapping +- Query-Generierung +- Produktidentität +- Diffing +- Ranking +- Historisierung +- Lifecycle + +liegen im Backend. + +## 2.2 Keine Canonical-Criteria-Schicht + +Es wird **keine separate globale Canonical-Criteria-Ontologie** eingeführt. + +Für eine unterstützte Source/Kategorie bildet deren gelernte Taxonomie zunächst den primären Kriterienkatalog. + +Jedes Source-Kriterium erhält lediglich eine stabile interne ID: + +```text +CriterionDefinition.id = interne UUID +``` + +Diese ID dient als Persistenz- und Referenzidentität. + +Sie ist keine Behauptung, dass das Kriterium global quellenunabhängig kanonisch sei. + +## 2.3 Source-Kriterien und Ergebnisattribute werden verbunden + +Ein Kriterium muss sowohl mit dem Suchfilter als auch mit den aus Suchergebnissen bzw. Produktdetailseiten extrahierten Attributen verbunden werden können. + +Beispiel: + +```text +CriterionDefinition +id = +origin = SOURCE +source = geizhals +category = notebook +label = "Arbeitsspeicher" + +SourceFilterMapping: +Geizhals Filter 17023 -> CriterionDefinition.id + +SourceAttributeMapping: +"RAM" -> CriterionDefinition.id +"Arbeitsspeicher" -> CriterionDefinition.id +ggf. source_attribute_id -> CriterionDefinition.id +``` + +Eine Produktbeobachtung soll dann auf dieselbe interne CriterionDefinition referenzieren: + +```text +ProductAttributeObservation +- source_product_reference_id +- criterion_definition_id +- raw_name +- raw_value +- normalized_value +- unit +- observed_at +``` + +## 2.4 INTERNAL-Kriterien + +Zusätzliche interne Kriterien werden nur angelegt, wenn für die Kaufentscheidung relevante Eigenschaften von der Source-Taxonomie nicht angeboten werden. + +Beispiele: + +- externer Akku-Benchmark +- Reparierbarkeit +- Testscore +- Lautstärke unter Last + +```text +CriterionDefinition.origin = INTERNAL +``` + +Diese besitzen naturgemäß keinen Source-Filter, können aber später von anderen Datenquellen befüllt werden. + +--- + +# 3. Produktkategorien + +Das BuyingProject referenziert eine interne `ProductCategory`. + +Beispiel: + +```text +ProductCategory +id = notebook +name = Notebooks +``` + +Die Source-spezifische Kategorie wird separat gemappt: + +```text +SourceCategory +source = geizhals +external_category_key = nb +product_category_id = notebook +``` + +Damit ist das BuyingProject nicht direkt an `geizhals/nb` gekoppelt. + +--- + +# 4. BuyingProject + +Ein BuyingProject ist die konkrete Instanz eines Kaufvorhabens. + +Es kann **ohne ProductProfile** angelegt werden. + +Mindestens: + +```text +id +workspace_id nullable +name +product_category_id +market +currency +budget_target nullable +budget_maximum nullable +shortlist_size +schedule_interval_minutes +status +created_at +updated_at +archived_at nullable +``` + +Status: + +```text +ACTIVE +PURCHASED +ARCHIVED +``` + +Default: + +```text +shortlist_size = 5 +schedule_interval_minutes = 180 +``` + +--- + +# 5. Rolle von ProductProfile / BuyingTemplate + +Das bisherige `ProductProfile` ist für neue BuyingProjects **nicht verpflichtend**. + +Fachlich wird es künftig als optionale Vorlage verstanden. + +Zielbild: + +```text +BuyingTemplate +"Mobiler Entwicklungsrechner" + ↓ optional beim Projektstart +BuyingProject +``` + +Eine Vorlage kann z. B. liefern: + +- vorgeschlagene Suchkriterien +- Ranking-Konfiguration +- Default-Budget +- Kategorie +- Markt / Währung + +Beim Erstellen eines Projekts werden relevante Werte in die Projektkonfiguration übernommen. + +Spätere Änderungen an der Vorlage verändern bestehende Projekte **nicht rückwirkend**. + +Zur Nachvollziehbarkeit darf gespeichert werden: + +```text +template_id +template_version +``` + +Die bestehende ProductProfile-Implementierung darf technisch weiterverwendet werden, solange sie nicht mehr als zwingende Voraussetzung für ein BuyingProject fungiert. + +--- + +# 6. ProjectCriteria + +## 6.1 Zweck + +ProjectCriteria beschreiben die vom Nutzer gewählten Suchparameter eines BuyingProjects. + +Sie referenzieren die dynamisch geladenen CriterionDefinitions. + +Beispiel: + +```text +ProjectCriterion +- id +- project_id +- criterion_definition_id +- operator +- value +- enabled_for_discovery +- created_at +- updated_at +``` + +## 6.2 Standard-UI + +Die Standardoberfläche zeigt **keine** technischen Ranking-Parameter wie: + +- weight +- acceptable +- ideal +- strategy +- scoring_version + +Der Nutzer wählt lediglich: + +```text +Kriterium +Operator +Wert / Wertebereich +für externe Suche aktiv ja/nein +``` + +Optional kann später eine einfache Kennzeichnung wie „Muss“ / „Wunsch“ ergänzt werden, sie ist für v0.2 nicht Voraussetzung. + +## 6.3 Beispiel + +```text +Arbeitsspeicher >= 24 GB +Preis <= 1700 EUR +Gewicht <= 1.5 kg +``` + +Die verfügbaren Operatoren und UI-Controls werden aus der SourceFilterDefinition bzw. CriterionDefinition abgeleitet. + +--- + +# 7. Ranking-Konfiguration + +Discovery und Ranking bleiben getrennte fachliche Bereiche. + +ProjectCriteria dürfen keine versteckten Scoring-Regeln erzwingen. + +Ein Projekt kann optional eine Ranking-Konfiguration besitzen. + +Mögliche Herkunft: + +1. aus einem BuyingTemplate/ProductProfile übernommen +2. später explizit durch Advanced UI +3. später von KI vorgeschlagen +4. später durch eine Default-Strategie erzeugt + +Für den MVP gilt: + +- Ein Projekt ohne Ranking-Konfiguration darf trotzdem Discovery ausführen. +- In diesem Zustand zeigt das Frontend Kandidaten, aber kennzeichnet Ranking als `NOT_CONFIGURED`. +- Für den Notebook-Pilot kann das vorhandene Profil `mobile-development-notebook` als optionale Vorlage genutzt werden, damit ProductScore/Shortlist end-to-end getestet werden können. +- Es dürfen keine stillen, nicht dokumentierten Scoring-Regeln erfunden werden. + +--- + +# 8. Criterion-Katalog vor Projektanlage + +Der Projekt-Wizard muss Kriterien laden können, bevor eine Project-ID existiert. + +Erforderliche Endpunkte: + +```text +GET /api/v1/sources +GET /api/v1/sources/{source}/categories +GET /api/v1/sources/{source}/categories/{category}/available-criteria +``` + +`available-criteria` liefert mindestens: + +```text +criterion_definition_id +label +description nullable +origin +source +category +filter_type +unit nullable +operators +options / range metadata +mapping_status +supports_discovery +``` + +Nur Source-Kriterien mit nutzbarer Filterdefinition werden als externe Suchparameter angeboten. + +INTERNAL-Kriterien dürfen separat sichtbar sein, wenn sie für die Kategorie bekannt sind. + +--- + +# 9. Taxonomie-Lernen + +Taxonomie wird on demand pro Kategorie gelernt. + +Initial: + +```text +Geizhals -> Notebooks +``` + +Die gelernte Taxonomie soll später im Admin-Bereich bearbeitbar sein. + +Mapping-Status: + +```text +DISCOVERED +LEARNED_UNVERIFIED +VERIFIED +BROKEN +DISABLED +``` + +Nur `VERIFIED` darf automatisch für produktive Source-Query-Generierung verwendet werden. + +Technisch eindeutige automatische Verifikation ist zulässig, wenn der Backend-Service einen reproduzierbaren Roundtrip sicher nachweisen kann. + +--- + +# 10. DiscoverySearch + +Persistenz bleibt **1:n**: + +```text +BuyingProject + └── DiscoverySearch[] +``` + +Für den ersten MVP gilt als fachliche Einschränkung: + +```text +maximal eine aktive DiscoverySearch +pro BuyingProject + Source + SourceCategory +``` + +Diese Einschränkung darf nicht als dauerhafte 1:1-Beziehung in das Datenmodell eingebaut werden. + +Mindestens: + +```text +id +buying_project_id +source_id +source_category_id +criteria_version +mapping_version +generated_url +status +schedule_interval_minutes +next_run_at nullable +last_successful_run_at nullable +created_at +updated_at +``` + +--- + +# 11. Statuswelten strikt trennen + +## 11.1 Project Lifecycle + +```text +ACTIVE +PURCHASED +ARCHIVED +``` + +## 11.2 DiscoverySearch / SearchRun Status + +Je nach fachlicher Ebene: + +```text +RUNNING +SUCCESS +PARTIAL +FAILED +BLOCKED +TOO_BROAD +OUT_OF_SYNC +``` + +`OUT_OF_SYNC` gehört fachlich zur Search-Konfiguration. + +`TOO_BROAD` beschreibt den Zustand bzw. das Ergebnis einer DiscoverySearch/SearchRun-Ausführung. + +## 11.3 Run Diff + +```text +NEW +CHANGED +UNCHANGED +MISSING +``` + +## 11.4 Product Lifecycle + +```text +ACTIVE +DISAPPEARED +ARCHIVED +``` + +## 11.5 Identity / Matching + +```text +MATCHED +MATCH_UNCERTAIN +``` + +Diese Status dürfen in DB, API und UI nicht zu einem gemeinsamen Statusfeld vermischt werden. + +--- + +# 12. Diff- und Lifecycle-Regeln + +Nur vollständige erfolgreiche Full Runs dürfen fehlende Produkte ableiten. + +```text +SUCCESS Full Run: +- NEW +- CHANGED +- UNCHANGED +- MISSING +``` + +`PARTIAL` darf **keine** MISSING-/DISAPPEARED-Transition auslösen. + +Lifecycle: + +```text +1 vollständiger Run fehlend -> MISSING im Run-Diff +3 aufeinanderfolgende vollständige Runs fehlend -> Product Lifecycle DISAPPEARED +``` + +Wiederauftauchen reaktiviert bestehende SourceProductReference, sofern Identity ausreichend sicher ist. + +--- + +# 13. Candidate Summary DTO + +Listen und Shortlist müssen genügend Daten liefern, damit das Frontend nicht für jede Zeile Einzelrequests benötigt. + +Mindestens: + +```text +candidate_id +source_product_reference_id +title +manufacturer nullable +model nullable +source_url +current_best_price nullable +currency nullable +current_dynamic_attributes +product_score nullable +product_eligibility nullable +ranking_status +shortlist_status +current_diff_status +product_lifecycle_status +match_status +first_seen_at +last_seen_at +``` + +`current_dynamic_attributes` ist metadata-driven und keine feste Notebook-Struktur. + +--- + +# 14. Candidate Detail DTO + +Erforderlicher Endpunkt: + +```text +GET /api/v1/buying-projects/{project_id}/candidates/{candidate_id} +``` + +Mindestens: + +```text +Candidate Summary ++ +alle aktuellen normalisierten Attribute ++ +Source-Metadaten ++ +aktuelle Observation ++ +Ranking-/Eligibility-Daten, soweit vorhanden +``` + +Das Frontend darf keine Gründe/Empfehlungen behaupten, die nicht tatsächlich persistiert sind. + +--- + +# 15. Observation History + +Erforderlicher Endpunkt: + +```text +GET /api/v1/buying-projects/{project_id}/candidates/{candidate_id}/observations +``` + +Mindestens: + +```text +observed_at +best_price +currency +availability nullable +seller_count nullable +dynamic_attributes +search_run_id +``` + +Diese Historie ermöglicht: + +- Preisentwicklung +- Attributänderungen +- Last Seen +- Verlauf über SearchRuns + +Kein HTML-Archiv. + +--- + +# 16. Project Summary DTO + +Die Projektliste soll nicht N+1-Requests erzeugen. + +Erforderlich: + +```text +GET /api/v1/buying-projects +``` + +Pro Projekt mindestens: + +```text +id +name +product_category +status +last_successful_run_at nullable +next_run_at nullable +discovery_status nullable +candidate_count +shortlist_count +new_count +changed_count +missing_count +current_search_result_count nullable +``` + +--- + +# 17. Projekt-Wizard + +Route: + +```text +/projects/new +``` + +## Schritt 1 – Basis + +```text +Projektname +Produktkategorie +Markt +Währung +Budget Ziel optional +Budget Maximum optional +Shortlist-Größe +Suchintervall +optional BuyingTemplate +``` + +## Schritt 2 – Kriterien laden + +Nach Kategorieauswahl: + +```text +GET available-criteria +``` + +Die UI zeigt die Source-Kriterien als verständliche Liste. + +Beispiel Notebooks: + +```text +[ ] Hersteller +[ ] Arbeitsspeicher +[ ] SSD +[ ] Gewicht +[ ] Displaygröße +[ ] CPU +[ ] Grafikkarte +... +``` + +## Schritt 3 – Parameter setzen + +Nach Aktivierung eines Kriteriums: + +```text +Arbeitsspeicher +Operator: mindestens +Wert: 24 GB +``` + +Control-Typen: + +```text +BOOLEAN +ENUM +MULTI_ENUM +NUMERIC_RANGE +NUMERIC_THRESHOLD +TEXT +``` + +Das UI rendert dynamisch. + +Keine hartcodierten Notebook-Felder. + +## Schritt 4 – Projekt speichern + +Erst jetzt wird das BuyingProject angelegt und die ProjectCriteria werden gespeichert. + +--- + +# 18. Kriterienänderung im bestehenden Projekt + +Route: + +```text +/projects/:projectId/criteria +``` + +Ändert der Nutzer relevante ProjectCriteria: + +```text +criteria_version++ +DiscoverySearch.status = OUT_OF_SYNC +``` + +Das Frontend zeigt: + +> Suchparameter wurden geändert. Die externe Suche muss aktualisiert werden. + +Aktion: + +```text +Suche aktualisieren +``` + +Backend: + +```text +generate-query +→ VERIFIED Mappings verwenden +→ neue URL speichern +→ optional SearchRun starten +``` + +--- + +# 19. Hauptscreens + +## 19.1 Projektliste + +```text +/projects +``` + +Anzeige: + +- Name +- Kategorie +- Project Lifecycle +- letzter erfolgreicher Run +- Discovery-Status +- Kandidatenzahl +- Shortlistzahl +- NEW / CHANGED Counts +- nächste Suche + +## 19.2 Projektdetail + +```text +/projects/:projectId +``` + +Bereiche: + +```text +Übersicht +Kandidaten +Shortlist +Kriterien +Historie +``` + +## 19.3 Kandidaten + +```text +/projects/:projectId/candidates +``` + +Desktop: + +- Tabelle / kompakte Liste + +Mobile: + +- Cards / List Items + +## 19.4 Kandidatendetail + +```text +/projects/:projectId/candidates/:candidateId +``` + +Zeigt nur tatsächlich vorhandene Daten: + +- Titel / Hersteller / Modell +- aktueller Preis +- dynamische Attribute +- Eligibility / ProductScore, soweit vorhanden +- Ranking Status +- Diff Status +- Product Lifecycle +- Match Status +- First Seen / Last Seen +- Observation History + +Nicht anzeigen, solange Backend es nicht erzeugt: + +- Deep-Enrichment-Ergebnisse +- Review-Zusammenfassungen +- finale Kaufentscheidung +- nicht persistierte Begründungen + +## 19.5 Shortlist + +```text +/projects/:projectId/shortlist +``` + +Zeigt Top-N, falls Ranking konfiguriert. + +Ohne Ranking-Konfiguration: + +```text +Ranking ist noch nicht konfiguriert. +``` + +## 19.6 Historie + +```text +/projects/:projectId/history +``` + +SearchRuns mit: + +```text +Zeitpunkt +Run Status +Trefferzahl +Page Count +NEW +CHANGED +MISSING +Fehler / BLOCKED / TOO_BROAD +``` + +--- + +# 20. Admin-Bereich + +Routen: + +```text +/admin/sources +/admin/sources/:source/categories +/admin/sources/:source/categories/:category +``` + +Funktionen: + +- Kategorien sehen +- Taxonomie laden/neu laden +- SourceFilterDefinitions sehen +- Optionen / Ranges sehen +- CriterionDefinitions sehen +- SearchFilter-Mapping sehen +- SourceAttributeMappings sehen +- Mapping-Status ändern +- kleine Mapping-Korrekturen durchführen +- BROKEN / DISABLED erkennen + +Technische IDs sind nur hier sichtbar. + +Das Frontend generiert keine Source-URL-Encodings selbst. + +--- + +# 21. Frontend-Technologie + +Verbindlich: + +- React +- TypeScript +- Vite +- PWA +- TanStack Query +- React Router oder vergleichbare schlanke Lösung + +Nicht erforderlich: + +- Next.js +- SSR +- SEO + +Begründung: + +Die Anwendung ist eine private/interne App und benötigt keine suchmaschinenoptimierte öffentliche Seitenauslieferung. + +--- + +# 22. Responsive Architektur + +Von Anfang an zwei bewusste Hauptlayouts. + +## Desktop + +```text +Sidebar + Hauptinhalt + optional Detailbereich +``` + +## Mobile + +```text +Bottom Navigation / kompakte Navigation ++ Vollbild-Detailseiten ++ projektinterne Tabs +``` + +Keine bloße Verkleinerung des Desktop-Layouts. + +--- + +# 23. Mitai-Bezug + +Wenn der Mitai-Quellcode dem Coding-Agenten im Repository oder über einen konkret angegebenen Pfad zugänglich ist, sollen passende Muster wiederverwendet werden: + +- PWA-Shell +- Responsive Breakpoints +- Navigation +- Layout +- ggf. Basiskomponenten + +Ohne zugänglichen Pfad darf der Agent keine Annahmen über konkrete Mitai-Komponenten treffen. + +Dann nur dieselben Architekturprinzipien umsetzen. + +--- + +# 24. CORS + +Backend muss den Frontend-Origin konfigurierbar zulassen. + +Keine harte Produktions-URL im Code. + +Beispiel: + +```text +CORS_ALLOWED_ORIGINS +``` + +Lokale Entwicklung: + +```text +http://localhost: +``` + +Produktive Origins über Environment. + +--- + +# 25. Docker Compose + +Der nutzbare MVP enthält mindestens: + +```text +api +postgres +frontend +``` + +Das Frontend muss über Compose startbar sein. + +Development und Production Build dürfen unterschiedliche Docker Targets verwenden. + +Root-Compose bleibt zentrale Startstelle. + +--- + +# 26. CI + +CI muss mindestens enthalten: + +Backend: + +- Unit Tests +- Integration Tests +- API E2E + +Frontend: + +- TypeScript typecheck +- lint +- unit/component tests +- production build + +E2E: + +- vollständiger Notebook-Pilot mit Fixture/Mock-Source +- Desktop viewport +- Mobile viewport + +Live-Geizhals-Abfragen gehören nicht in CI. + +--- + +# 27. PWA + +Mindestens: + +- Manifest +- installierbare App +- App Icons +- Responsive App Shell +- Offline-Fallback + +Keine komplexe Offline-Synchronisation im MVP. + +--- + +# 28. Loading / Empty / Error States + +Jeder wesentliche Screen braucht: + +```text +Loading +Empty +Error +Retry +``` + +Beispiele: + +- Noch keine Projekte vorhanden. +- Noch keine Kandidaten gefunden. +- Noch kein SearchRun ausgeführt. +- Taxonomie wurde noch nicht geladen. +- Suche ist OUT_OF_SYNC. +- Suche wurde BLOCKED. +- Ranking ist nicht konfiguriert. + +--- + +# 29. API-Verträge – Mindestumfang + +## Projekte + +```text +GET /api/v1/buying-projects +POST /api/v1/buying-projects +GET /api/v1/buying-projects/{id} +PATCH /api/v1/buying-projects/{id} +``` + +## Kriterien + +```text +GET /api/v1/buying-projects/{id}/criteria +PUT /api/v1/buying-projects/{id}/criteria +``` + +## Source / Kategorien + +```text +GET /api/v1/sources +GET /api/v1/sources/{source}/categories +POST /api/v1/sources/{source}/categories/{category}/discover-taxonomy +GET /api/v1/sources/{source}/categories/{category}/available-criteria +``` + +## Admin Mapping + +```text +GET /api/v1/sources/{source}/categories/{category}/filters +GET /api/v1/criterion-mappings +POST /api/v1/criterion-mappings +PATCH /api/v1/criterion-mappings/{id} +GET /api/v1/source-attribute-mappings +POST /api/v1/source-attribute-mappings +PATCH /api/v1/source-attribute-mappings/{id} +``` + +## Discovery Search + +```text +GET /api/v1/buying-projects/{id}/discovery-searches +POST /api/v1/buying-projects/{id}/discovery-searches +POST /api/v1/discovery-searches/{id}/generate-query +POST /api/v1/discovery-searches/{id}/run +``` + +## SearchRuns + +```text +GET /api/v1/discovery-searches/{id}/runs +GET /api/v1/search-runs/{id} +``` + +## Candidates + +```text +GET /api/v1/buying-projects/{id}/candidates +GET /api/v1/buying-projects/{id}/candidates/{candidateId} +GET /api/v1/buying-projects/{id}/candidates/{candidateId}/observations +GET /api/v1/buying-projects/{id}/shortlist +``` + +--- + +# 30. Nutzerfreundlichkeit + +Die Standardoberfläche zeigt keine Begriffe wie: + +```text +CriterionDefinition +SourceFilterDefinition +mapping_version +source_filter_id +normalized_spec_fingerprint +``` + +Stattdessen: + +```text +Arbeitsspeicher +mindestens +24 GB +``` + +Technische Details gehören in den Admin-Bereich. + +--- + +# 31. KI-Erweiterung – bewusst vorbereitet, nicht implementiert + +Später soll KI den heute manuellen Kriterien-Auswahlschritt übernehmen können. + +Ziel: + +```text +Nutzer beschreibt Wunsch in natürlicher Sprache + ↓ +KI erhält verfügbare Source-Kriterien + ↓ +KI schlägt konkrete Filterparameter vor + ↓ +Nutzer bestätigt / korrigiert + ↓ +ProjectCriteria werden gespeichert +``` + +Wichtig: + +Die KI soll Source-Kriterien möglichst aus dem tatsächlich verfügbaren Katalog auswählen. + +INTERNAL-Kriterien dürfen nur vorgeschlagen werden, wenn sie im System bekannt sind und eine Datenquelle bzw. Bewertungsstrategie existiert. + +Keine KI-Implementierung in v0.2. + +--- + +# 32. Tests + +## Frontend Unit / Component + +- dynamischer Criteria Renderer pro Filtertyp +- Projekt-Wizard +- Mapping-/Statusdarstellung +- Candidate List +- Candidate Detail +- Mobile Navigation +- Desktop Navigation +- Loading / Empty / Error States + +## API Integration + +- Kategorie-Katalog laden +- available-criteria vor Projektanlage laden +- Projekt ohne Profil anlegen +- Projekt optional aus Template anlegen +- Criteria speichern +- Search generieren +- SearchRun starten +- Candidate Summary laden +- Candidate Detail laden +- Observation History laden +- Shortlist laden + +## E2E Notebook-Pilot + +```text +App öffnen +→ neues Projekt +→ Notebooks wählen +→ Geizhals-Kriterien laden +→ RAM >= 24 GB auswählen +→ Preismaximum setzen +→ Projekt speichern +→ Query generieren +→ SearchRun ausführen +→ Kandidaten anzeigen +→ Kandidat öffnen +→ dynamische Attribute sehen +→ Preis / Observation History sehen +→ Kriterien ändern +→ OUT_OF_SYNC sehen +→ Query neu generieren +``` + +Wenn optional ein Ranking-Template verwendet wird: + +```text +→ ProductScore sichtbar +→ Shortlist sichtbar +``` + +## Responsive E2E + +Mindestens: + +- Desktop Viewport +- Mobile Viewport + +--- + +# 33. Nicht Bestandteil dieses Slices + +- Auth / Login +- RBAC +- Einladungen +- Multi-User UI +- Keepa +- Idealo +- LLM-Automation +- Deep Enrichment +- finale Decision Engine +- Kaufautomatisierung +- Warenkorb +- komplexe Benachrichtigungen + +Die Architektur darf diese Erweiterungen nicht verbauen. + +--- + +# 34. Definition of Done + +## Architektur + +- [ ] Keine separate Canonical-Criteria-Schicht. +- [ ] Source-Kriterien besitzen stabile interne CriterionDefinition-IDs. +- [ ] SearchFilter und Ergebnisattribute können auf dieselbe CriterionDefinition-ID gemappt werden. +- [ ] INTERNAL-Kriterien sind möglich. +- [ ] BuyingProject ist ohne ProductProfile anlegbar. +- [ ] ProductProfile kann optional als Vorlage dienen. +- [ ] DiscoverySearch-Persistenz bleibt 1:n-fähig. +- [ ] Statuswelten sind getrennt. + +## API + +- [ ] Source-Kategorie-Katalog existiert. +- [ ] available-criteria funktioniert vor Projektanlage. +- [ ] Candidate Summary DTO enthält Preis, Attribute, Diff, Lifecycle, last_seen und Ranking-Felder. +- [ ] Candidate Detail existiert. +- [ ] Observation History existiert. +- [ ] Project Summary DTO enthält Aggregate. +- [ ] CORS ist konfigurierbar. + +## Frontend + +- [ ] React + TypeScript + Vite. +- [ ] PWA installierbar. +- [ ] Desktop-Layout. +- [ ] Mobile-Layout. +- [ ] Projektliste. +- [ ] Projekt-Wizard. +- [ ] Kriterien dynamisch aus Source-Taxonomie. +- [ ] Keine notebook-spezifisch hartcodierten Criteria Controls. +- [ ] Projekt-Detail. +- [ ] Kandidatenliste. +- [ ] Kandidatendetail. +- [ ] Observation History. +- [ ] Shortlist, wenn Ranking verfügbar. +- [ ] Admin-Bereich für Taxonomie/Mappings. +- [ ] OUT_OF_SYNC sichtbar und korrigierbar. +- [ ] Loading / Empty / Error States. + +## Integration + +- [ ] Frontend in Docker Compose. +- [ ] Frontend Build/Tests in CI. +- [ ] Backend Tests grün. +- [ ] Frontend Tests grün. +- [ ] Desktop E2E grün. +- [ ] Mobile E2E grün. +- [ ] Notebook-Pilot über die UI erfolgreich. + +--- + +# 35. Arbeitsauftrag an den Coding-Agenten + +Diese Datei ersetzt `buying-agent-frontend-mvp-spec-v0.1.md` vollständig. + +Bitte **noch nicht implementieren**. + +Führe zunächst denselben Review-Prozess durch wie bei der v0.1: + +1. Prüfe die aktuelle Repository-Implementierung gegen: + - `buying-agent-v0.1-spec-v1.1.md` + - `buying-agent-architecture-addendum-v1.md` + - `buying-agent-source-discovery-spec-v0.1.md` + - `buying-agent-frontend-mvp-spec-v0.2.md` + +2. Prüfe insbesondere, ob die in deinem vorherigen Frontend-MVP-Review identifizierten Blocker F1–F6 und die weiteren Lücken mit v0.2 vollständig geschlossen sind. + +3. Berichte ausschließlich: + - verbleibende echte fachliche Widersprüche, + - noch fehlende Verträge, + - technisch riskante Annahmen, + - notwendige Backend-Änderungen, + - notwendige Frontend-Änderungen. + +4. Keine neue Fachlogik erfinden, um Lücken still zu schließen. + +5. Wenn keine blockierenden Punkte mehr bestehen, erstelle einen aktualisierten Implementierungsplan für einen **vertikalen End-to-End-MVP**, sodass jeder Schritt über das Frontend prüfbar wird. + +6. Implementierung erst nach ausdrücklicher Freigabe dieses Reviews. diff --git a/.specs/buying-agent-frontend-mvp-spec-v0.3.md b/.specs/buying-agent-frontend-mvp-spec-v0.3.md new file mode 100644 index 0000000..dbd23cb --- /dev/null +++ b/.specs/buying-agent-frontend-mvp-spec-v0.3.md @@ -0,0 +1,1708 @@ +# Buying Agent – Frontend MVP Specification v0.3 + +**Status:** Review Candidate – vor Implementierung erneut durch den Coding-Agenten zu prüfen +**Spezifikationsversion:** 0.3 +**Stand:** 2026-10-04 + +**Ersetzt vollständig:** `buying-agent-frontend-mvp-spec-v0.2.md` + +**Gilt zusammen mit:** +1. `buying-agent-v0.1-spec-v1.1.md` +2. `buying-agent-architecture-addendum-v1.md` +3. `buying-agent-source-discovery-spec-v0.1.md` + +Bei Widersprüchen innerhalb des Frontend-/MVP-Bereichs hat diese v0.3 Vorrang. +Sie präzisiert außerdem die für den Frontend-MVP notwendigen Backend-Verträge. + +--- + +# 1. Zielbild + +Der Buying Agent soll als **nutzbare PWA** einen vollständigen vertikalen Nutzerfluss abbilden. + +Der Nutzer soll ohne technische Kenntnisse: + +```text +BuyingProject anlegen + ↓ +Produktkategorie auswählen + ↓ +verfügbare Quellen für diese Kategorie bestimmen + ↓ +verfügbare Kriterien aus der Source-Taxonomie laden + ↓ +gewünschte Suchparameter auswählen und Werte setzen + ↓ +externe Suche generieren + ↓ +SearchRun starten / Status verfolgen + ↓ +Kandidaten sehen + ↓ +Shortlist / Ranking sehen, soweit konfiguriert + ↓ +Kandidat öffnen + ↓ +Attribute, Preise, Historie und bisherigen Status sehen + ↓ +Kriterien anpassen + ↓ +neue Suche ausführen +``` + +Die Oberfläche soll einfach bleiben. Die technische Komplexität verbleibt im Backend. + +--- + +# 2. Grundsätzliche Architektur + +## 2.1 Backend bleibt System of Record + +Das Frontend enthält keine fachliche Kernlogik. + +Es konsumiert ausschließlich die versionierte `/api/v1`-API. + +Fachliche Entscheidungen über: + +- Taxonomie +- Mapping +- Query-Generierung +- Produktidentität +- Diffing +- Ranking +- Historisierung +- Lifecycle + +liegen im Backend. + +## 2.2 Keine Canonical-Criteria-Schicht + +Es wird **keine separate globale Canonical-Criteria-Ontologie** eingeführt. + +Für eine unterstützte Source/Kategorie bildet deren gelernte Taxonomie zunächst den primären Kriterienkatalog. + +Jedes Source-Kriterium erhält lediglich eine stabile interne ID: + +```text +CriterionDefinition.id = interne UUID +``` + +Diese ID dient als Persistenz- und Referenzidentität. + +Sie ist keine Behauptung, dass das Kriterium global quellenunabhängig kanonisch sei. + +## 2.3 Source-Kriterien und Ergebnisattribute werden verbunden + +Ein Kriterium muss sowohl mit dem Suchfilter als auch mit den aus Suchergebnissen bzw. Produktdetailseiten extrahierten Attributen verbunden werden können. + +Beispiel: + +```text +CriterionDefinition +id = +origin = SOURCE +source = geizhals +category = notebook +label = "Arbeitsspeicher" + +SourceFilterMapping: +Geizhals Filter 17023 -> CriterionDefinition.id + +SourceAttributeMapping: +"RAM" -> CriterionDefinition.id +"Arbeitsspeicher" -> CriterionDefinition.id +ggf. source_attribute_id -> CriterionDefinition.id +``` + +Eine Produktbeobachtung soll dann auf dieselbe interne CriterionDefinition referenzieren: + +```text +ProductAttributeObservation +- source_product_reference_id +- criterion_definition_id +- raw_name +- raw_value +- normalized_value +- unit +- observed_at +- search_run_id +``` + +## 2.4 INTERNAL-Kriterien + +Zusätzliche interne Kriterien werden nur angelegt, wenn für die Kaufentscheidung relevante Eigenschaften von der Source-Taxonomie nicht angeboten werden. + +Beispiele: + +- externer Akku-Benchmark +- Reparierbarkeit +- Testscore +- Lautstärke unter Last + +```text +CriterionDefinition.origin = INTERNAL +``` + +Diese besitzen naturgemäß keinen Source-Filter, können aber später von anderen Datenquellen befüllt werden. + +--- + +# 3. Produktkategorien + +## 3.1 Interner Katalog + +Das BuyingProject referenziert eine interne `ProductCategory`. + +Beispiel: + +```text +ProductCategory +id = notebook +name = Notebooks +status = ACTIVE +``` + +Erforderlicher Endpunkt: + +```text +GET /api/v1/product-categories +``` + +Response mindestens: + +```text +id +name +status +``` + +Der initiale Pilot enthält mindestens: + +```text +notebook +``` + +## 3.2 Source-Zuordnung + +Die Source-spezifische Kategorie wird separat gemappt: + +```text +SourceCategory +source = geizhals +external_category_key = nb +product_category_id = notebook +``` + +Damit ist das BuyingProject nicht direkt an `geizhals/nb` gekoppelt. + +--- + +# 4. BuyingProject + +Ein BuyingProject ist die konkrete Instanz eines Kaufvorhabens. + +Es kann **ohne ProductProfile** angelegt werden. + +Mindestens: + +```text +id +workspace_id nullable +name +product_category_id +market +currency +target_budget nullable +shortlist_size +status +template_id nullable +template_version nullable +criteria_version +created_at +updated_at +archived_at nullable +``` + +Status: + +```text +ACTIVE +PURCHASED +ARCHIVED +``` + +Default: + +```text +shortlist_size = 5 +criteria_version = 1 +``` + +## 4.1 Kein doppelter Suchpreis + +Ein extern wirksames Preismaximum wird **nicht zusätzlich als BuyingProject-Feld** gehalten. + +Wenn die Source einen Preisfilter unterstützt, wird das maximale Suchbudget als normales ProjectCriterion modelliert. + +`target_budget` bleibt optional als spätere Ranking-/Decision-Information. + +Damit existieren nicht zwei konkurrierende Wahrheiten für denselben externen Preisfilter. + +--- + +# 5. Rolle von ProductProfile / BuyingTemplate + +Das bisherige `ProductProfile` ist für neue BuyingProjects **nicht verpflichtend**. + +Fachlich wird es als optionale Vorlage verstanden. + +Zielbild: + +```text +BuyingTemplate +"Mobiler Entwicklungsrechner" + ↓ optional beim Projektstart +BuyingProject +``` + +Eine Vorlage kann z. B. liefern: + +- vorgeschlagene Suchkriterien +- Ranking-Konfiguration +- Default-Zielbudget +- Kategorie +- Markt / Währung + +Beim Erstellen eines Projekts werden relevante Werte in die Projektkonfiguration übernommen. + +Spätere Änderungen an der Vorlage verändern bestehende Projekte **nicht rückwirkend**. + +Zur Nachvollziehbarkeit: + +```text +template_id +template_version +``` + +Erforderlicher Endpunkt: + +```text +GET /api/v1/buying-templates +``` + +Response mindestens: + +```text +id +version +name +product_category_id +market +currency +description nullable +``` + +Die bestehende ProductProfile-Implementierung darf technisch weiterverwendet werden, solange sie nicht mehr als zwingende Voraussetzung für ein BuyingProject fungiert. + +--- + +# 6. ProjectCriteria und Versionierung + +## 6.1 Zweck + +ProjectCriteria beschreiben die vom Nutzer gewählten Suchparameter eines BuyingProjects. + +Sie referenzieren die dynamisch geladenen CriterionDefinitions. + +## 6.2 Immutable Versions + +ProjectCriteria werden nicht still überschrieben. + +Ein fachlich relevanter Criteria-Wechsel erzeugt eine neue `criteria_version`. + +Schema: + +```text +ProjectCriterion +- id +- project_id +- criteria_version +- criterion_definition_id +- operator +- value +- enabled_for_discovery +- created_at +``` + +Für eine Project-/Criteria-Version gilt: + +```text +(project_id, criteria_version, criterion_definition_id) +``` + +als fachlich eindeutige Kombination. + +`BuyingProject.criteria_version` zeigt auf die aktuell aktive Version. + +Änderung: + +```text +aktuelle criteria_version = 3 + ↓ +Nutzer ändert Kriterien + ↓ +neue immutable Kriterienmenge version 4 + ↓ +BuyingProject.criteria_version = 4 +``` + +SearchRuns und DiscoverySearches müssen die verwendete `criteria_version` speichern. + +## 6.3 Standard-UI + +Die Standardoberfläche zeigt keine technischen Ranking-Parameter wie: + +- weight +- acceptable +- ideal +- strategy +- scoring_version + +Der Nutzer wählt lediglich: + +```text +Kriterium +Operator +Wert / Wertebereich +für externe Suche aktiv ja/nein +``` + +Optional kann später eine einfache Kennzeichnung wie „Muss“ / „Wunsch“ ergänzt werden, sie ist für v0.3 nicht Voraussetzung. + +--- + +# 7. Ranking-Konfiguration + +Discovery und Ranking bleiben getrennte fachliche Bereiche. + +ProjectCriteria dürfen keine versteckten Scoring-Regeln erzwingen. + +Ein Projekt kann optional eine Ranking-Konfiguration besitzen. + +Mögliche Herkunft: + +1. aus einem BuyingTemplate/ProductProfile übernommen +2. später explizit durch Advanced UI +3. später von KI vorgeschlagen +4. später durch eine dokumentierte Default-Strategie erzeugt + +Für den MVP gilt: + +- Ein Projekt ohne Ranking-Konfiguration darf trotzdem Discovery ausführen. +- In diesem Zustand zeigt das Frontend Kandidaten, aber kennzeichnet Ranking als `NOT_CONFIGURED`. +- Für den Notebook-Pilot kann das vorhandene Profil `mobile-development-notebook` als optionale Vorlage genutzt werden, damit ProductScore/Shortlist end-to-end getestet werden können. +- Es dürfen keine stillen, nicht dokumentierten Scoring-Regeln erfunden werden. + +--- + +# 8. Brücke CriterionDefinition -> bestehende Ranking-Engine + +Die bestehende Ranking-/ProductProfile-Engine arbeitet weiterhin mit Attribut-Keys wie: + +```text +ram_gb +storage_gb +weight_kg +``` + +Dafür erhält `CriterionDefinition` optional: + +```text +scoring_attribute_key nullable +``` + +Beispiel: + +```text +CriterionDefinition.id = +label = "Arbeitsspeicher" +scoring_attribute_key = "ram_gb" +``` + +Wichtig: + +- `scoring_attribute_key` ist **kein Canonical-Criterion-Layer**. +- Es ist ausschließlich ein technischer Adapter zur bestehenden Scoring-Engine. +- Source-Kriterium und interne UUID bleiben die primäre Persistenzidentität. +- Ist kein Ranking-Mapping vorhanden, bleibt `scoring_attribute_key = null`. +- Ein fehlender Mapping-Key darf keine Discovery verhindern. + +So kann ein aus Geizhals beobachteter Wert: + +```text +criterion_definition_id = +normalized_value = 32 +``` + +für einen Engine-Snapshot zusätzlich als: + +```text +ram_gb = 32 +``` + +bereitgestellt werden. + +--- + +# 9. Source-Auswahl im Wizard + +Der Nutzer soll die technische Source möglichst nicht auswählen müssen, solange nur eine geeignete Source existiert. + +Nach Auswahl einer ProductCategory fragt das Frontend: + +```text +GET /api/v1/product-categories/{product_category_id}/sources +``` + +Response mindestens: + +```text +source +source_category_id +display_name +status +``` + +Regeln: + +- genau eine aktive Source -> automatisch auswählen +- mehrere aktive Sources -> Source-Picker anzeigen +- keine aktive Source -> verständlicher Empty State + +Der Nutzer muss die externe SourceCategory-ID wie `nb` nicht kennen. + +--- + +# 10. Criterion-Katalog vor Projektanlage + +Der Projekt-Wizard muss Kriterien laden können, bevor eine Project-ID existiert. + +Erforderliche Endpunkte: + +```text +GET /api/v1/product-categories +GET /api/v1/product-categories/{product_category_id}/sources +GET /api/v1/product-categories/{product_category_id}/available-criteria?source=geizhals +``` + +Backend löst intern: + +```text +ProductCategory ++ Source + ↓ +SourceCategory + ↓ +CriterionDefinitions / SourceFilterDefinitions +``` + +auf. + +`available-criteria` liefert mindestens: + +```text +criterion_definition_id +label +description nullable +origin +source +product_category_id +filter_type +unit nullable +operators +options / range metadata +mapping_status +supports_discovery +scoring_attribute_key nullable +``` + +Nur Source-Kriterien mit nutzbarer Filterdefinition werden als externe Suchparameter angeboten. + +INTERNAL-Kriterien dürfen separat sichtbar sein, wenn sie für die Kategorie bekannt sind. + +--- + +# 11. Taxonomie-Lernen + +Taxonomie wird on demand pro SourceCategory gelernt. + +Initial: + +```text +Geizhals -> Notebooks +``` + +Die gelernte Taxonomie soll später im Admin-Bereich bearbeitbar sein. + +Mapping-Status: + +```text +DISCOVERED +LEARNED_UNVERIFIED +VERIFIED +BROKEN +DISABLED +``` + +Nur `VERIFIED` darf automatisch für produktive Source-Query-Generierung verwendet werden. + +Technisch eindeutige automatische Verifikation ist zulässig, wenn der Backend-Service einen reproduzierbaren Roundtrip sicher nachweisen kann. + +--- + +# 12. DiscoverySearch + +Persistenz bleibt **1:n**: + +```text +BuyingProject + └── DiscoverySearch[] +``` + +Für den ersten MVP gilt als fachliche Einschränkung: + +```text +maximal eine aktive DiscoverySearch +pro BuyingProject + Source + SourceCategory +``` + +Diese Einschränkung darf nicht als dauerhafte 1:1-Beziehung in das Datenmodell eingebaut werden. + +Mindestens: + +```text +id +buying_project_id +source_id +source_category_id +criteria_version +mapping_version +generated_url +status +schedule_interval_minutes +next_run_at nullable +last_successful_run_at nullable +created_at +updated_at +``` + +## 12.1 Scheduling + +Das Suchintervall gehört fachlich an die `DiscoverySearch`. + +`BuyingProject` hält **kein zweites maßgebliches schedule_interval_minutes**. + +Ein Template oder Projekt-Wizard darf einen Defaultwert vorgeben, dieser wird beim Anlegen der DiscoverySearch kopiert. + +Ab dann ist: + +```text +DiscoverySearch.schedule_interval_minutes +``` + +die maßgebliche Quelle. + +Default: + +```text +180 Minuten +``` + +--- + +# 13. Statuswelten strikt trennen + +## 13.1 Project Lifecycle + +```text +ACTIVE +PURCHASED +ARCHIVED +``` + +## 13.2 DiscoverySearch Status + +```text +ACTIVE +OUT_OF_SYNC +BLOCKED +DISABLED +``` + +## 13.3 SearchRun Status + +```text +RUNNING +SUCCESS +PARTIAL +FAILED +BLOCKED +TOO_BROAD +``` + +## 13.4 Run Diff + +```text +NEW +CHANGED +UNCHANGED +MISSING +``` + +## 13.5 Product Lifecycle + +```text +ACTIVE +DISAPPEARED +ARCHIVED +``` + +## 13.6 Identity / Matching + +```text +MATCHED +MATCH_UNCERTAIN +``` + +Diese Status dürfen in DB, API und UI nicht zu einem gemeinsamen Statusfeld vermischt werden. + +--- + +# 14. Diff- und Lifecycle-Regeln + +Nur vollständige erfolgreiche Full Runs dürfen fehlende Produkte ableiten. + +```text +SUCCESS Full Run: +- NEW +- CHANGED +- UNCHANGED +- MISSING +``` + +`PARTIAL` darf **keine** MISSING-/DISAPPEARED-Transition auslösen. + +Lifecycle: + +```text +1 vollständiger Run fehlend -> MISSING im Run-Diff +3 aufeinanderfolgende vollständige Runs fehlend -> Product Lifecycle DISAPPEARED +``` + +Wiederauftauchen reaktiviert bestehende SourceProductReference, sofern Identity ausreichend sicher ist. + +--- + +# 15. SearchRun Summary + +SearchRun-Responses und History-Listen müssen Aggregatzähler liefern. + +Mindestens: + +```text +id +discovery_search_id +started_at +finished_at nullable +status +result_count +page_count +new_count +changed_count +unchanged_count +missing_count +error_code nullable +``` + +`disappeared_count` darf optional zusätzlich als Lifecycle-Aggregat geliefert werden, gehört aber nicht zum Run-Diff-Enum. + +--- + +# 16. ProductObservation und Attribute + +Eine Observation gehört mindestens zu: + +```text +SourceProductReference ++ +SearchRun +``` + +Schema konzeptionell: + +```text +ProductObservation +- id +- source_product_reference_id +- search_run_id +- observed_at +- best_price +- currency +- availability nullable +- seller_count nullable +``` + +Dynamische Attribute: + +```text +ProductAttributeObservation +- product_observation_id +- criterion_definition_id +- raw_name +- raw_value +- normalized_value +- unit nullable +``` + +Damit ist jede Beobachtung eindeutig einem Produkt und einem konkreten SearchRun zugeordnet. + +--- + +# 17. Candidate Summary DTO + +Listen und Shortlist müssen genügend Daten liefern, damit das Frontend nicht für jede Zeile Einzelrequests benötigt. + +Mindestens: + +```text +candidate_id +source_product_reference_id +title +manufacturer nullable +model nullable +source_url +current_best_price nullable +currency nullable +current_dynamic_attributes +product_score nullable +product_eligibility nullable +ranking_status +shortlist_status +current_diff_status +product_lifecycle_status +match_status +first_seen_at +last_seen_at +``` + +`current_dynamic_attributes` ist metadata-driven und keine feste Notebook-Struktur. + +--- + +# 18. Candidate Detail DTO + +Erforderlicher Endpunkt: + +```text +GET /api/v1/buying-projects/{project_id}/candidates/{candidate_id} +``` + +Mindestens: + +```text +Candidate Summary ++ +alle aktuellen normalisierten Attribute ++ +Source-Metadaten ++ +aktuelle Observation ++ +Ranking-/Eligibility-Daten, soweit vorhanden +``` + +Das Frontend darf keine Gründe/Empfehlungen behaupten, die nicht tatsächlich persistiert sind. + +--- + +# 19. Observation History + +Erforderlicher Endpunkt: + +```text +GET /api/v1/buying-projects/{project_id}/candidates/{candidate_id}/observations +``` + +Mindestens: + +```text +observation_id +observed_at +best_price +currency +availability nullable +seller_count nullable +dynamic_attributes +search_run_id +``` + +Diese Historie ermöglicht: + +- Preisentwicklung +- Attributänderungen +- Last Seen +- Verlauf über SearchRuns + +Kein HTML-Archiv. + +--- + +# 20. Project Summary DTO + +Die Projektliste soll nicht N+1-Requests erzeugen. + +Erforderlich: + +```text +GET /api/v1/buying-projects +``` + +Pro Projekt mindestens: + +```text +id +name +product_category +status +last_successful_run_at nullable +next_run_at nullable +discovery_status nullable +candidate_count +shortlist_count +new_count +changed_count +missing_count +current_search_result_count nullable +``` + +--- + +# 21. Projekt-Wizard + +Route: + +```text +/projects/new +``` + +## Schritt 1 – Basis + +```text +Projektname +Produktkategorie +Markt +Währung +Zielbudget optional +Shortlist-Größe +optional BuyingTemplate +``` + +## Schritt 2 – Source + +Frontend lädt: + +```text +GET /product-categories/{id}/sources +``` + +Bei genau einer Source automatische Auswahl. + +Bei mehreren Sources Auswahl durch Nutzer. + +## Schritt 3 – Kriterien laden + +Frontend lädt: + +```text +GET /product-categories/{id}/available-criteria?source=... +``` + +Die UI zeigt Source-Kriterien als verständliche Liste. + +Beispiel Notebooks: + +```text +[ ] Hersteller +[ ] Arbeitsspeicher +[ ] SSD +[ ] Gewicht +[ ] Displaygröße +[ ] CPU +[ ] Grafikkarte +... +``` + +## Schritt 4 – Parameter setzen + +Nach Aktivierung eines Kriteriums: + +```text +Arbeitsspeicher +Operator: mindestens +Wert: 24 GB +``` + +Control-Typen: + +```text +BOOLEAN +ENUM +MULTI_ENUM +NUMERIC_RANGE +NUMERIC_THRESHOLD +TEXT +``` + +Das UI rendert dynamisch. + +Keine hartcodierten Notebook-Felder. + +## Schritt 5 – Projekt speichern + +Erst jetzt wird das BuyingProject angelegt und die ProjectCriteria-Version gespeichert. + +Danach wird die initiale DiscoverySearch für die ausgewählte Source erzeugt. + +--- + +# 22. Kriterienänderung im bestehenden Projekt + +Route: + +```text +/projects/:projectId/criteria +``` + +Ändert der Nutzer relevante ProjectCriteria: + +```text +neue immutable criteria_version +BuyingProject.criteria_version = neue Version +DiscoverySearch.status = OUT_OF_SYNC +``` + +Das Frontend zeigt: + +> Suchparameter wurden geändert. Die externe Suche muss aktualisiert werden. + +Aktion: + +```text +Suche aktualisieren +``` + +Backend: + +```text +generate-query +→ VERIFIED Mappings verwenden +→ neue URL speichern +→ optional SearchRun starten +``` + +--- + +# 23. PATCH BuyingProject + +Erforderlicher Endpunkt: + +```text +PATCH /api/v1/buying-projects/{id} +``` + +Im MVP explizit änderbar: + +```text +name +target_budget +shortlist_size +status +``` + +Nicht über diesen PATCH ändern: + +```text +product_category_id +market +currency +criteria +source +source_category +schedule_interval +``` + +Suchrelevante Änderungen laufen über die Criteria-/DiscoverySearch-Verträge. + +Falls Kategorie/Markt/Währung später änderbar werden, benötigt dies einen expliziten Migrations-/Reinitialisierungsflow und ist nicht Teil des MVP. + +--- + +# 24. Hauptscreens + +## 24.1 Projektliste + +```text +/projects +``` + +Anzeige: + +- Name +- Kategorie +- Project Lifecycle +- letzter erfolgreicher Run +- Discovery-Status +- Kandidatenzahl +- Shortlistzahl +- NEW / CHANGED Counts +- nächste Suche + +## 24.2 Projektdetail + +```text +/projects/:projectId +``` + +Bereiche: + +```text +Übersicht +Kandidaten +Shortlist +Kriterien +Historie +``` + +## 24.3 Kandidaten + +```text +/projects/:projectId/candidates +``` + +Desktop: + +- Tabelle / kompakte Liste + +Mobile: + +- Cards / List Items + +## 24.4 Kandidatendetail + +```text +/projects/:projectId/candidates/:candidateId +``` + +Zeigt nur tatsächlich vorhandene Daten: + +- Titel / Hersteller / Modell +- aktueller Preis +- dynamische Attribute +- Eligibility / ProductScore, soweit vorhanden +- Ranking Status +- Diff Status +- Product Lifecycle +- Match Status +- First Seen / Last Seen +- Observation History + +Nicht anzeigen, solange Backend es nicht erzeugt: + +- Deep-Enrichment-Ergebnisse +- Review-Zusammenfassungen +- finale Kaufentscheidung +- nicht persistierte Begründungen + +## 24.5 Shortlist + +```text +/projects/:projectId/shortlist +``` + +Zeigt Top-N, falls Ranking konfiguriert. + +Ohne Ranking-Konfiguration: + +```text +Ranking ist noch nicht konfiguriert. +``` + +## 24.6 Historie + +```text +/projects/:projectId/history +``` + +SearchRuns mit: + +```text +Zeitpunkt +Run Status +Trefferzahl +Page Count +NEW +CHANGED +UNCHANGED +MISSING +Fehler / BLOCKED / TOO_BROAD +``` + +--- + +# 25. Admin-Bereich + +Routen: + +```text +/admin/sources +/admin/sources/:source/categories +/admin/sources/:source/categories/:category +``` + +Funktionen: + +- Kategorien sehen +- Taxonomie laden/neu laden +- SourceFilterDefinitions sehen +- Optionen / Ranges sehen +- CriterionDefinitions sehen +- SearchFilter-Mapping sehen +- SourceAttributeMappings sehen +- scoring_attribute_key sehen/pflegen, soweit administrativ vorgesehen +- Mapping-Status ändern +- kleine Mapping-Korrekturen durchführen +- BROKEN / DISABLED erkennen + +Technische IDs sind nur hier sichtbar. + +Das Frontend generiert keine Source-URL-Encodings selbst. + +--- + +# 26. Frontend-Technologie + +Verbindlich: + +- React +- TypeScript +- Vite +- PWA +- TanStack Query +- React Router oder vergleichbare schlanke Lösung +- Playwright für E2E + +Nicht erforderlich: + +- Next.js +- SSR +- SEO + +--- + +# 27. Responsive Architektur + +Von Anfang an zwei bewusste Hauptlayouts. + +## Desktop + +```text +Sidebar + Hauptinhalt + optional Detailbereich +``` + +## Mobile + +```text +Bottom Navigation / kompakte Navigation ++ Vollbild-Detailseiten ++ projektinterne Tabs +``` + +Keine bloße Verkleinerung des Desktop-Layouts. + +--- + +# 28. Mitai-Bezug + +Wenn der Mitai-Quellcode dem Coding-Agenten im Repository oder über einen konkret angegebenen Pfad zugänglich ist, sollen passende Muster wiederverwendet werden: + +- PWA-Shell +- Responsive Breakpoints +- Navigation +- Layout +- ggf. Basiskomponenten + +Ohne zugänglichen Pfad darf der Agent keine Annahmen über konkrete Mitai-Komponenten treffen. + +Dann nur dieselben Architekturprinzipien umsetzen. + +--- + +# 29. CORS + +Backend muss den Frontend-Origin konfigurierbar zulassen. + +Keine harte Produktions-URL im Code. + +Beispiel: + +```text +CORS_ALLOWED_ORIGINS +``` + +Lokale und produktive Origins über Environment. + +--- + +# 30. Ports + +Ports sind Deployment-Konfiguration und keine Fachlogik. + +Sie werden über: + +- Docker Compose +- `.env` +- `.env.example` + +konfiguriert. + +Keine fachliche Spec darf einen unveränderlichen Frontend-Port voraussetzen. + +--- + +# 31. Docker Compose + +Der nutzbare MVP enthält mindestens: + +```text +api +postgres +frontend +``` + +Das Frontend muss über Root-Compose startbar sein. + +Development und Production Build dürfen unterschiedliche Docker Targets verwenden. + +--- + +# 32. CI + +CI muss mindestens enthalten: + +Backend: + +- Unit Tests +- Integration Tests +- API E2E + +Frontend: + +- TypeScript typecheck +- lint +- unit/component tests +- production build + +E2E: + +- Playwright +- vollständiger Notebook-Pilot mit Fixture/Mock-Source +- Desktop viewport +- Mobile viewport + +Live-Geizhals-Abfragen gehören nicht in CI. + +--- + +# 33. PWA + +Mindestens: + +- Manifest +- installierbare App +- App Icons +- Responsive App Shell +- Offline-Fallback + +Keine komplexe Offline-Synchronisation im MVP. + +--- + +# 34. Loading / Empty / Error States + +Jeder wesentliche Screen braucht: + +```text +Loading +Empty +Error +Retry +``` + +Beispiele: + +- Noch keine Projekte vorhanden. +- Noch keine Kandidaten gefunden. +- Noch kein SearchRun ausgeführt. +- Taxonomie wurde noch nicht geladen. +- Keine Source für diese Produktkategorie verfügbar. +- Suche ist OUT_OF_SYNC. +- Suche wurde BLOCKED. +- Ranking ist nicht konfiguriert. + +--- + +# 35. API-Verträge – Mindestumfang + +## Produktkategorien + +```text +GET /api/v1/product-categories +GET /api/v1/product-categories/{id}/sources +GET /api/v1/product-categories/{id}/available-criteria?source=... +``` + +## Templates + +```text +GET /api/v1/buying-templates +``` + +## Projekte + +```text +GET /api/v1/buying-projects +POST /api/v1/buying-projects +GET /api/v1/buying-projects/{id} +PATCH /api/v1/buying-projects/{id} +``` + +## Kriterien + +```text +GET /api/v1/buying-projects/{id}/criteria +PUT /api/v1/buying-projects/{id}/criteria +``` + +`PUT` erzeugt eine neue immutable criteria_version. + +## Source / Taxonomie + +```text +GET /api/v1/sources +GET /api/v1/sources/{source}/categories +POST /api/v1/sources/{source}/categories/{category}/discover-taxonomy +``` + +## Admin Mapping + +```text +GET /api/v1/sources/{source}/categories/{category}/filters +GET /api/v1/criterion-mappings +POST /api/v1/criterion-mappings +PATCH /api/v1/criterion-mappings/{id} +GET /api/v1/source-attribute-mappings +POST /api/v1/source-attribute-mappings +PATCH /api/v1/source-attribute-mappings/{id} +``` + +## Discovery Search + +```text +GET /api/v1/buying-projects/{id}/discovery-searches +POST /api/v1/buying-projects/{id}/discovery-searches +POST /api/v1/discovery-searches/{id}/generate-query +POST /api/v1/discovery-searches/{id}/run +PATCH /api/v1/discovery-searches/{id} +``` + +`PATCH DiscoverySearch` darf im MVP mindestens ändern: + +```text +schedule_interval_minutes +status ACTIVE/DISABLED +``` + +## SearchRuns + +```text +GET /api/v1/discovery-searches/{id}/runs +GET /api/v1/search-runs/{id} +``` + +## Candidates + +```text +GET /api/v1/buying-projects/{id}/candidates +GET /api/v1/buying-projects/{id}/candidates/{candidateId} +GET /api/v1/buying-projects/{id}/candidates/{candidateId}/observations +GET /api/v1/buying-projects/{id}/shortlist +``` + +--- + +# 36. Nutzerfreundlichkeit + +Die Standardoberfläche zeigt keine Begriffe wie: + +```text +CriterionDefinition +SourceFilterDefinition +mapping_version +source_filter_id +normalized_spec_fingerprint +scoring_attribute_key +``` + +Stattdessen: + +```text +Arbeitsspeicher +mindestens +24 GB +``` + +Technische Details gehören in den Admin-Bereich. + +--- + +# 37. KI-Erweiterung – bewusst vorbereitet, nicht implementiert + +Später soll KI den heute manuellen Kriterien-Auswahlschritt übernehmen können. + +Ziel: + +```text +Nutzer beschreibt Wunsch in natürlicher Sprache + ↓ +KI erhält verfügbare Source-Kriterien + ↓ +KI schlägt konkrete Filterparameter vor + ↓ +Nutzer bestätigt / korrigiert + ↓ +ProjectCriteria werden gespeichert +``` + +Wichtig: + +Die KI soll Source-Kriterien möglichst aus dem tatsächlich verfügbaren Katalog auswählen. + +INTERNAL-Kriterien dürfen nur vorgeschlagen werden, wenn sie im System bekannt sind und eine Datenquelle bzw. Bewertungsstrategie existiert. + +Keine KI-Implementierung in v0.3. + +--- + +# 38. Tests + +## Frontend Unit / Component + +- dynamischer Criteria Renderer pro Filtertyp +- ProductCategory Picker +- automatische Source-Auswahl bei genau einer Source +- Source Picker bei mehreren Sources +- Projekt-Wizard +- Mapping-/Statusdarstellung +- Candidate List +- Candidate Detail +- Mobile Navigation +- Desktop Navigation +- Loading / Empty / Error States + +## API Integration + +- ProductCategory-Katalog laden +- Sources pro ProductCategory laden +- available-criteria vor Projektanlage laden +- Projekt ohne Profil anlegen +- Projekt optional aus Template anlegen +- Criteria versioniert speichern +- DiscoverySearch anlegen +- Suchintervall an DiscoverySearch ändern +- Search generieren +- SearchRun starten +- SearchRun Summary laden +- Candidate Summary laden +- Candidate Detail laden +- Observation History laden +- Shortlist laden + +## E2E Notebook-Pilot + +```text +App öffnen +→ neues Projekt +→ Notebooks wählen +→ verfügbare Source automatisch bestimmen +→ Geizhals-Kriterien laden +→ RAM >= 24 GB auswählen +→ Preismaximum als Source-Kriterium setzen +→ Projekt speichern +→ DiscoverySearch erzeugen +→ Query generieren +→ SearchRun ausführen +→ Kandidaten anzeigen +→ Kandidat öffnen +→ dynamische Attribute sehen +→ Preis / Observation History sehen +→ Kriterien ändern +→ neue criteria_version +→ OUT_OF_SYNC sehen +→ Query neu generieren +``` + +Wenn optional ein Ranking-Template verwendet wird: + +```text +→ scoring_attribute_key Brücke verwenden +→ ProductScore sichtbar +→ Shortlist sichtbar +``` + +## Responsive E2E + +Mindestens: + +- Desktop Viewport +- Mobile Viewport + +--- + +# 39. Nicht Bestandteil dieses Slices + +- Auth / Login +- RBAC +- Einladungen +- Multi-User UI +- Keepa +- Idealo +- LLM-Automation +- Deep Enrichment +- finale Decision Engine +- Kaufautomatisierung +- Warenkorb +- komplexe Benachrichtigungen + +Die Architektur darf diese Erweiterungen nicht verbauen. + +--- + +# 40. Definition of Done + +## Architektur + +- [ ] Keine separate Canonical-Criteria-Schicht. +- [ ] ProductCategory-Katalog existiert. +- [ ] Source-Zuordnung pro ProductCategory existiert. +- [ ] Source-Kriterien besitzen stabile interne CriterionDefinition-IDs. +- [ ] SearchFilter und Ergebnisattribute können auf dieselbe CriterionDefinition-ID gemappt werden. +- [ ] Optionales `scoring_attribute_key` verbindet Kriterien mit bestehender Ranking-Engine. +- [ ] INTERNAL-Kriterien sind möglich. +- [ ] BuyingProject ist ohne ProductProfile anlegbar. +- [ ] ProductProfile kann optional als Vorlage dienen. +- [ ] ProjectCriteria sind immutable versioniert. +- [ ] SearchRun und DiscoverySearch referenzieren criteria_version. +- [ ] DiscoverySearch-Persistenz bleibt 1:n-fähig. +- [ ] Scheduling liegt fachlich an DiscoverySearch. +- [ ] Statuswelten sind getrennt. + +## API + +- [ ] `GET /product-categories` existiert. +- [ ] Sources pro ProductCategory existieren. +- [ ] available-criteria funktioniert vor Projektanlage. +- [ ] BuyingTemplate-Liste existiert. +- [ ] Candidate Summary DTO enthält Preis, Attribute, Diff, Lifecycle, last_seen und Ranking-Felder. +- [ ] Candidate Detail existiert. +- [ ] Observation History existiert. +- [ ] SearchRun Summary enthält Diff-Aggregate. +- [ ] Project Summary DTO enthält Aggregate. +- [ ] PATCH-Felder sind explizit definiert. +- [ ] CORS ist konfigurierbar. + +## Frontend + +- [ ] React + TypeScript + Vite. +- [ ] PWA installierbar. +- [ ] Desktop-Layout. +- [ ] Mobile-Layout. +- [ ] Projektliste. +- [ ] Projekt-Wizard. +- [ ] ProductCategory Picker. +- [ ] Source wird automatisch gewählt, wenn nur eine verfügbar ist. +- [ ] Kriterien dynamisch aus Source-Taxonomie. +- [ ] Keine notebook-spezifisch hartcodierten Criteria Controls. +- [ ] Projekt-Detail. +- [ ] Kandidatenliste. +- [ ] Kandidatendetail. +- [ ] Observation History. +- [ ] Shortlist, wenn Ranking verfügbar. +- [ ] Admin-Bereich für Taxonomie/Mappings. +- [ ] OUT_OF_SYNC sichtbar und korrigierbar. +- [ ] Loading / Empty / Error States. + +## Integration + +- [ ] Frontend in Docker Compose. +- [ ] Ports über Environment/Compose konfigurierbar. +- [ ] Frontend Build/Tests in CI. +- [ ] Backend Tests grün. +- [ ] Frontend Tests grün. +- [ ] Playwright Desktop E2E grün. +- [ ] Playwright Mobile E2E grün. +- [ ] Notebook-Pilot über die UI erfolgreich. + +--- + +# 41. Arbeitsauftrag an den Coding-Agenten + +Diese Datei ersetzt `buying-agent-frontend-mvp-spec-v0.2.md` vollständig. + +Bitte **noch nicht implementieren**. + +Führe erneut denselben Review-Prozess durch wie bei v0.1 und v0.2. + +Prüfe die aktuelle Repository-Implementierung gegen: + +1. `buying-agent-v0.1-spec-v1.1.md` +2. `buying-agent-architecture-addendum-v1.md` +3. `buying-agent-source-discovery-spec-v0.1.md` +4. `buying-agent-frontend-mvp-spec-v0.3.md` + +Prüfe insbesondere explizit, ob: + +- die bisherigen F1–F6 geschlossen sind, +- R1 ProductCategory-Katalog geschlossen ist, +- R2 Source-Auswahl vor Projektanlage geschlossen ist, +- R3 CriterionDefinition-UUID ↔ Scoring-Attribut-Key geschlossen ist, +- R4 ProjectCriteria-Versionierung geschlossen ist, +- die bisherigen kleineren Restlücken zu Template-API, PATCH-Semantik, + SearchRun-Zählern, Observation-Zuordnung, Scheduling, CORS, Ports, + Compose, CI und Teststack geschlossen sind. + +Berichte ausschließlich: + +- verbleibende echte fachliche Widersprüche, +- noch fehlende Verträge, +- technisch riskante Annahmen, +- notwendige Backend-Änderungen, +- notwendige Frontend-Änderungen. + +Keine neue Fachlogik erfinden, um Lücken still zu schließen. + +Wenn keine blockierenden Punkte mehr bestehen: + +1. erstelle einen aktualisierten Implementierungsplan, +2. plane **vertikal** statt „Backend komplett, danach Frontend“, +3. jeder Slice muss unmittelbar über die UI testbar sein, +4. beginne die Implementierung erst nach ausdrücklicher Freigabe dieses Reviews. diff --git a/.specs/buying-agent-source-discovery-spec-v0.1.md b/.specs/buying-agent-source-discovery-spec-v0.1.md new file mode 100644 index 0000000..73b6c21 --- /dev/null +++ b/.specs/buying-agent-source-discovery-spec-v0.1.md @@ -0,0 +1,986 @@ +# Buying Agent – Source & Discovery Specification v0.1 + +**Status:** implementierungsbereit +**Spezifikationsversion:** 0.1 +**Stand:** 2026-10-04 +**Ergänzt:** `buying-agent-v0.1-spec-v1.1.md` und `buying-agent-architecture-addendum-v1.md` + +## 1. Zweck + +Diese Spezifikation definiert den ersten real nutzbaren Discovery-Slice des Buying Agents. + +Ziel ist, externe Produktsuchen automatisiert aus den Kriterien eines BuyingProjects abzuleiten, regelmäßig auszuführen, Ergebnisse dauerhaft zu verwalten, Veränderungen zu erkennen und die Treffer anschließend durch die eigene Buying-Engine zu bewerten. + +Der erste Pilot erfolgt mit **Geizhals, Kategorie Notebooks**. Die Architektur darf jedoch nicht notebook-spezifisch sein. + +```text +BuyingProject + ↓ +Canonical Criteria + ↓ +Source Taxonomy + Mapping + ↓ +generierte Geizhals-Suche + ↓ +periodischer SearchRun + ↓ +persistierte Produkte + Beobachtungen + ↓ +Diff zum vorherigen Lauf + ↓ +NEW / CHANGED / MISSING / DISAPPEARED + ↓ +interne Constraints + Ranking + ↓ +Shortlist + ↓ +später Deep Enrichment + ↓ +später Decision +``` + +## 2. Fachliche Leitprinzipien + +1. Geizhals ist Discovery-Quelle, nicht Decision Engine. +2. Die eigenen BuyingProject-Kriterien bleiben die fachliche Quelle der Wahrheit. +3. Geizhals wird als möglichst starker Vorfilter genutzt. +4. Nicht auf Geizhals abbildbare Kriterien bleiben interne Kriterien. +5. Discovery, Ranking und Decision bleiben getrennte fachliche Stufen. +6. Externe Filtertaxonomien werden **on demand** gelernt, nicht vollständig vorab katalogisiert. +7. URL-Encoding und Filtersyntax dürfen nicht vom LLM erfunden werden. +8. Produktidentität wird zunächst konservativ und pragmatisch behandelt. +9. Bei Unsicherheit erfolgt keine automatische Zusammenführung. +10. Suchläufe speichern strukturierte Zustände, keine HTML-Archive. + +## 3. Scope v0.1 + +### Enthalten + +- Geizhals als erste Discovery-Quelle +- Pilotkategorie Notebooks +- On-Demand-Erfassung der Geizhals-Filtertaxonomie +- Speicherung von Source-Kategorien, Filterdefinitionen, Optionen und Wertebereichen +- Mapping zwischen Canonical Criteria und Source-Filtern +- reproduzierbare Generierung von Geizhals-Such-URLs +- periodische SearchRuns +- vollständige Ergebnisaufnahme einer aktiven Suche +- persistierte SourceProductReferences +- Historisierung sichtbarer Attribute und Preise +- Diffing zwischen Suchläufen +- Status `NEW`, `CHANGED`, `UNCHANGED`, `MISSING`, `DISAPPEARED` +- interne Bewertung der Kandidaten +- konfigurierbare Shortlist, Default 5 +- defensives Fehler-/Backoff-Verhalten +- Tests + +### Nicht enthalten + +- Frontend-Implementierung +- Keepa +- Idealo +- LLM-Automation +- automatische Kaufentscheidung +- Kaufautomatisierung +- Warenkorbmanagement +- Authentifizierung / RBAC +- Multi-User-UI +- automatische Deep-Enrichment-Quellen +- Umgehung von Schutzmechanismen externer Websites + +## 4. Source Taxonomy + +### 4.1 SourceCategory + +Repräsentiert eine Kategorie einer externen Quelle. + +```json +{ + "source": "geizhals", + "external_category_key": "nb", + "name": "Notebooks", + "source_url": "https://geizhals.de/?cat=nb", + "status": "ACTIVE", + "last_verified_at": "2026-10-04T10:00:00Z" +} +``` + +### 4.2 SourceFilterDefinition + +Beschreibt einen von Geizhals angebotenen Filter. + +```json +{ + "source": "geizhals", + "category_key": "nb", + "external_filter_key": "17023", + "label": "Arbeitsspeicher", + "filter_type": "NUMERIC_THRESHOLD", + "canonical_unit": "GB", + "status": "VERIFIED" +} +``` + +Mögliche `filter_type`-Werte: + +```text +BOOLEAN +ENUM +MULTI_ENUM +NUMERIC_RANGE +NUMERIC_THRESHOLD +TEXT +UNKNOWN +``` + +### 4.3 SourceFilterOption + +Für diskrete Filterwerte: + +```json +{ + "filter_key": "17023", + "source_value": "ab 24GB", + "normalized_value": 24, + "operator": ">=", + "unit": "GB" +} +``` + +### 4.4 SourceFilterRange + +Für Bereiche bzw. frei parametrisierbare Werte: + +```json +{ + "filter_key": "price", + "minimum": 0, + "maximum": null, + "step": 1, + "unit": "EUR" +} +``` + +## 5. On-Demand Taxonomy Learning + +Die Taxonomie wird nur für tatsächlich verwendete Kategorien gelernt. + +Initial: + +```text +Geizhals → Notebooks +``` + +Später z. B.: + +```text +Monitore +Fernseher +Kameras +Rasenmäher +... +``` + +### Ablauf + +```text +Kategorie öffnen + ↓ +verfügbare Filter erkennen + ↓ +Filterdefinitionen speichern + ↓ +Optionen / Bereiche erfassen + ↓ +URL-Verhalten beobachten + ↓ +Mapping erzeugen + ↓ +technisch verifizieren +``` + +### Mapping-Status + +```text +DISCOVERED +LEARNED_UNVERIFIED +VERIFIED +BROKEN +DISABLED +``` + +Nur `VERIFIED` darf für automatische produktive Suchläufe verwendet werden. + +### Änderbarkeit + +Die gelernten Mappings sollen später im Frontend administrierbar sein. + +v0.1 benötigt dafür noch keine UI, aber die Persistenz muss Änderungen ermöglichen, ohne Source-Code anzupassen. + +## 6. Canonical Criteria + +Canonical Criteria sind die interne fachliche Darstellung eines Suchkriteriums. + +Sie sollen sich stark an der Source-Taxonomie orientieren, aber nicht fest an Geizhals gekoppelt sein. + +```json +{ + "key": "ram_gb", + "value_type": "number", + "unit": "GB", + "semantic": "working_memory" +} +``` + +Ein BuyingProject verwendet daraus konkrete Anforderungen: + +```json +{ + "criterion": "ram_gb", + "operator": ">=", + "value": 24, + "mandatory": true +} +``` + +### Source Mapping + +```text +Canonical Criterion +ram_gb >= 24 + + ↓ + +Geizhals Mapping +filter_key = 17023 +source_value = "ab 24GB" + + ↓ + +URL fragment +xf=17023_ab+24GB +``` + +Nicht jedes Canonical Criterion muss ein Source Mapping besitzen. + +Fehlt ein Mapping: + +```text +Criterion → INTERNAL_ONLY +``` + +Das Kriterium wird dann erst nach Discovery intern bewertet. + +## 7. DiscoverySearch + +Eine `DiscoverySearch` gehört zu einem BuyingProject und einer Source. + +Sie enthält mindestens: + +```text +id +buying_project_id +source_id +source_category_id +criteria_version +mapping_version +generated_url +status +schedule_interval_minutes +broad_result_threshold +created_at +updated_at +last_successful_run_at +``` + +Defaults: + +```text +schedule_interval_minutes = 180 +shortlist_size = 5 +broad_result_threshold = 300 +``` + +## 8. Search URL Generation + +Die URL wird ausschließlich aus: + +```text +BuyingProject Criteria ++ +VERIFIED CriterionMappings ++ +SourceCategory ++ +konfigurierbaren Source-Optionen +``` + +generiert. + +Die URL ist abgeleitet, nicht die fachliche Quelle der Wahrheit. + +Eine Änderung der BuyingProject-Kriterien erzeugt eine neue `criteria_version`. + +Wenn die gespeicherte DiscoverySearch nicht mehr zur aktuellen Criteria-/Mapping-Version passt: + +```text +status = OUT_OF_SYNC +``` + +Danach muss die URL neu generiert werden. + +## 9. Ergebnisumfang + +Aktive Suchläufe werden grundsätzlich vollständig eingelesen. + +Keine stille Begrenzung auf Top-N. + +Nur vollständige Ergebnismengen erlauben belastbare Erkennung von: + +```text +NEW +CHANGED +MISSING +DISAPPEARED +``` + +### TOO_BROAD + +Überschreitet die Treffermenge den konfigurierten Schwellwert: + +```text +result_count > broad_result_threshold +``` + +wird die Suche markiert: + +```text +TOO_BROAD +``` + +Die Suche darf technisch weiter ausgeführt werden, erzeugt aber einen Hinweis, dass die Source-Kriterien geschärft werden sollten. + +Spätere Versionen dürfen breite Suchen differenziert behandeln, etwa seltenere Full-Reconciliation. + +## 10. SearchRun + +Jeder Abruf erzeugt einen unveränderlichen `SearchRun`. + +Felder: + +```text +id +discovery_search_id +criteria_version +mapping_version +generated_url +started_at +finished_at +status +result_count +page_count +http_status_summary +error_code +``` + +Status: + +```text +RUNNING +SUCCESS +PARTIAL +FAILED +BLOCKED +``` + +Es wird kein HTML-Snapshot gespeichert. + +## 11. SourceProductReference + +Ein Treffer einer externen Quelle wird als `SourceProductReference` dauerhaft angelegt. + +Mindestens verfügbare Felder: + +```text +id +source_id +source_category_id +source_url +source_title +manufacturer +model +manufacturer_sku +gtin_ean +normalized_spec_fingerprint +first_seen_at +last_seen_at +status +``` + +Keines dieser Felder außer der internen UUID wird pauschal als garantiert stabil betrachtet. + +## 12. Produktidentität – Pilotansatz + +v0.1 implementiert bewusst keine komplexe globale Identity Engine. + +Für den Notebook-Pilot werden folgende Signale verwendet: + +1. vorhandene stabile Source-ID, falls entdeckt +2. GTIN/EAN, falls vorhanden +3. Hersteller-SKU, falls vorhanden +4. kanonische Source-URL +5. normalisierter Titel +6. Hersteller + Modell +7. differenzierende technische Specs + +Für Notebooks kann der initiale Fingerprint beispielsweise enthalten: + +```text +manufacturer +model +cpu +ram +storage +display_size +gpu +``` + +### Matching-Regeln + +- Hohe Sicherheit → existierende SourceProductReference verwenden +- Unsicherheit → `MATCH_UNCERTAIN` +- Keine sichere Übereinstimmung → neue SourceProductReference +- niemals zwei Produkte allein aufgrund ähnlicher Namen automatisch verschmelzen + +Die tatsächliche Stabilität der Identitätsmerkmale wird im Notebook-Pilot beobachtet und danach verfeinert. + +## 13. Produktbeobachtungen und Historie + +Technische Eigenschaften und Preise werden nicht einfach überschrieben. + +Jeder SearchRun kann neue Beobachtungen erzeugen. + +### ProductObservation + +```text +source_product_reference_id +search_run_id +observed_at +raw_title +best_price +currency +raw_attributes +normalized_attributes +availability +seller_count +``` + +Veränderte Werte bleiben historisch nachvollziehbar. + +Beispiele: + +```text +Preis: 1499 → 1299 +RAM: 16 → 32 +Anbieterzahl: 8 → 12 +Verfügbarkeit geändert +``` + +Bei technischen Attributänderungen muss das System berücksichtigen, dass auch fehlerhaftes Matching vorliegen könnte. + +## 14. Diffing + +Jeder erfolgreiche Full SearchRun wird mit dem vorherigen erfolgreichen Full SearchRun derselben DiscoverySearch verglichen. + +### Status + +```text +NEW +CHANGED +UNCHANGED +MISSING +DISAPPEARED +``` + +### Regeln + +**NEW** +Produkt war im vorherigen vollständigen Lauf nicht vorhanden. + +**CHANGED** +Produkt vorhanden, aber mindestens ein relevantes beobachtetes Feld hat sich verändert. + +**UNCHANGED** +Produkt vorhanden und relevante Felder unverändert. + +**MISSING** +Produkt fehlt in einem vollständigen Lauf. + +**DISAPPEARED** +Produkt fehlt in drei aufeinanderfolgenden vollständigen Läufen. + +Produkte werden nicht gelöscht. + +Historie bleibt erhalten. + +Ein später erneut auftauchendes Produkt wird reaktiviert und nicht als neues Produkt dupliziert, sofern das Matching ausreichend sicher ist. + +## 15. Lifecycle eines BuyingProjects + +Während ein BuyingProject aktiv ist: + +```text +ACTIVE + ↓ +DiscoverySearches laufen + ↓ +Produkte + Historie wachsen + ↓ +Ranking + Shortlist +``` + +Nach finalem Kauf: + +```text +PURCHASED + ↓ +BuyingProject archivieren + ↓ +Discovery stoppen +``` + +Produkte, Beobachtungen und Evaluationen können mit dem Projekt archiviert bzw. aus aktiven Ansichten entfernt werden. + +Keine physische Löschung historischer Daten als Standardverhalten. + +## 16. Ranking + +Nach Discovery übernimmt ausschließlich die interne Buying Engine. + +```text +Source Results + ↓ +interne Hard Constraints + ↓ +ProductScore + ↓ +Ranking +``` + +Geizhals-Sortierung darf für die technische Ausgabe genutzt werden, ist aber keine fachliche Ranking-Entscheidung. + +## 17. Shortlist + +Nach internem Ranking wird eine Shortlist gebildet. + +Default: + +```text +shortlist_size = 5 +``` + +Der Wert ist pro BuyingProject konfigurierbar. + +Shortlist-Kandidaten erhalten: + +```text +SHORTLISTED +``` + +Andere gültige Kandidaten: + +```text +CANDIDATE +``` + +Die spätere Deep-Enrichment-Stufe arbeitet primär auf der Shortlist. + +## 18. Deep Enrichment + +Nicht Bestandteil dieses Implementierungsslices. + +Zielbild: + +```text +Shortlist + ↓ +Herstellerdaten +Tests +Benchmarks +Reviews +weitere Händler +Preisportale +Qualitätsinformationen + ↓ +erneute Bewertung +``` + +Discovery darf daher nicht voraussetzen, dass Geizhals alle für die finale Kaufentscheidung notwendigen Daten liefert. + +## 19. Discovery, Ranking und Decision + +Die drei fachlichen Stufen müssen getrennt bleiben. + +### Discovery + +```text +Welche Produkte kommen potenziell infrage? +``` + +### Ranking + +```text +Welche dieser Produkte passen am besten zum BuyingProject? +``` + +### Decision + +```text +Ist jetzt der richtige Zeitpunkt, Kandidat X zu kaufen? +``` + +Decision wird später u. a. berücksichtigen können: + +- Zielpreis +- Deadline +- historische Preisentwicklung +- aktuelle Verfügbarkeit +- Product Fit +- Offer Fit +- Alternativen +- Beschaffungsdringlichkeit + +Decision ist nicht Bestandteil dieses Slices. + +## 20. Scheduling und defensiver Abruf + +Default: + +```text +alle 180 Minuten +``` + +pro DiscoverySearch konfigurierbar. + +### Anforderungen + +- keine parallelen Massenabrufe +- Pagination sequenziell oder stark begrenzt parallel +- Caching, wo sinnvoll +- Timeout +- Retry mit exponentiellem Backoff +- Jitter +- Stop bei wiederholten Fehlern +- keine Umgehung von Captchas oder Bot-Schutz + +### Reaktion + +**HTTP 429** + +```text +BACKOFF +``` + +**HTTP 403** + +```text +BLOCKED +``` + +**Captcha / Challenge erkannt** + +```text +BLOCKED +``` + +Automatische weitere Abrufe der betroffenen DiscoverySearch werden pausiert. + +Keine Schutzmechanismen umgehen. + +## 21. Source Adapter Contract + +Ein Discovery-Adapter soll mindestens folgende Fähigkeiten kapseln: + +```python +class DiscoverySourceAdapter(Protocol): + def discover_taxonomy(category_ref): ... + def build_query(criteria, mappings): ... + def fetch_results(query, pagination): ... + def parse_results(response): ... + def normalize_result(result): ... +``` + +Taxonomy Learning und Result Retrieval dürfen intern getrennte Services sein. + +Der Rest des Systems darf keine Geizhals-spezifische URL- oder HTML-Logik kennen. + +## 22. Services + +Mindestens: + +```text +TaxonomyService +CriterionMappingService +DiscoverySearchService +SearchExecutionService +SourceProductIdentityService +SearchDiffService +CandidateRankingService +ShortlistService +``` + +Keine Businesslogik in n8n. + +n8n kann später den Scheduler bzw. externe Events orchestrieren. + +## 23. Persistenz + +Neue bzw. ergänzende Tabellen können sein: + +```text +source_categories +source_filter_definitions +source_filter_options +source_filter_ranges +criterion_definitions +criterion_mappings +discovery_searches +search_runs +source_product_references +product_observations +project_candidates +candidate_status_history +``` + +Bestehende globale Entitäten aus der Hauptspezifikation bleiben bestehen: + +```text +Product +ProductVariant +Offer +PriceObservation +``` + +Diese dürfen nicht unnötig dupliziert werden. + +Der Discovery-Slice kann zunächst mit SourceProductReference arbeiten und erst bei ausreichender Identität eine Verknüpfung zu canonical Product/ProductVariant herstellen. + +## 24. API – minimaler Vertrag + +### Taxonomie + +```text +POST /api/v1/sources/geizhals/categories/{category}/discover-taxonomy +GET /api/v1/sources/geizhals/categories/{category}/filters +``` + +### Mapping + +```text +GET /api/v1/criterion-mappings +POST /api/v1/criterion-mappings +PATCH /api/v1/criterion-mappings/{id} +``` + +### DiscoverySearch + +```text +POST /api/v1/discovery-searches +GET /api/v1/discovery-searches/{id} +POST /api/v1/discovery-searches/{id}/generate-query +POST /api/v1/discovery-searches/{id}/run +``` + +### SearchRuns + +```text +GET /api/v1/discovery-searches/{id}/runs +GET /api/v1/search-runs/{id} +``` + +### Candidates + +```text +GET /api/v1/buying-projects/{id}/candidates +GET /api/v1/buying-projects/{id}/shortlist +``` + +Falls BuyingProject im aktuellen Code noch nicht existiert, muss die Integration mit der im Architecture Addendum vorgesehenen nächsten Domain-Erweiterung abgestimmt werden. + +## 25. Tests + +### Taxonomy + +- Notebook-Kategorie kann on demand eingelesen werden +- Filterdefinitionen werden reproduzierbar gespeichert +- Optionen werden nicht dupliziert +- Filteränderungen werden erkannt +- BROKEN-Mapping wird nicht produktiv verwendet + +### Query Generation + +- identische Criteria + Mapping-Version erzeugen identische URL +- RAM >= 24 erzeugt erwartetes Geizhals-Fragment +- Preisbereich korrekt +- mehrere Filter kombinierbar +- INTERNAL_ONLY-Kriterien verändern die Source-URL nicht +- veraltete Mapping-Version markiert Search als OUT_OF_SYNC + +### Search Execution + +- Pagination vollständig +- Ergebniszahl korrekt +- TOO_BROAD bei Überschreitung +- kein stilles Top-N-Abschneiden +- 429 → Backoff +- 403/Captcha → BLOCKED + +### Identity + +- identische SKU wird wiedererkannt +- identische EAN wird wiedererkannt +- gleiche URL wird wiedererkannt +- leicht geänderter Titel bei stabilen Specs wird erkannt +- unsicheres Match erzeugt MATCH_UNCERTAIN +- ähnliche Produktnamen werden nicht automatisch verschmolzen + +### Diffing + +- neuer Treffer → NEW +- geänderter Preis → CHANGED +- unverändert → UNCHANGED +- einmal fehlend → MISSING +- dreimal fehlend → DISAPPEARED +- Wiederauftauchen reaktiviert bestehende Referenz + +### Historie + +- Preisänderungen bleiben historisiert +- technische Änderungen bleiben historisiert +- alte SearchRuns bleiben unverändert +- Projektarchivierung löscht keine History + +### Ranking + +- nur interne Engine bestimmt Ranking +- Source-Sortierung beeinflusst ProductScore nicht +- Shortlistgröße Default 5 +- Shortlistgröße konfigurierbar + +## 26. Implementierungsreihenfolge + +1. Bestehende Implementierung gegen diese Spec und das Architecture Addendum prüfen. +2. Notwendige BuyingProject-/ProjectCandidate-Minimalstruktur ergänzen, falls für Discovery erforderlich. +3. Source Taxonomy + Mapping persistieren. +4. Geizhals-Taxonomie-Pilot für Notebooks implementieren. +5. reproduzierbare Query Generation implementieren. +6. SearchRun + Pagination implementieren. +7. SourceProductReference + ProductObservation implementieren. +8. Identity Pilot implementieren. +9. Diffing implementieren. +10. interne Ranking-Engine anbinden. +11. Shortlist bilden. +12. Scheduling-Schnittstelle vorbereiten bzw. internen Scheduler minimal anbinden. +13. Tests vollständig ausführen. +14. Notebook-Pilot mit realer Suche validieren. + +## 27. Definition of Done + +- [ ] Geizhals-Notebook-Taxonomie wird on demand eingelesen und gespeichert. +- [ ] Source-Filter und Optionen werden persistiert. +- [ ] Filter-Mappings besitzen Lifecycle-Status. +- [ ] BuyingProject-Kriterien erzeugen reproduzierbar eine Geizhals-URL. +- [ ] Nicht abbildbare Kriterien bleiben INTERNAL_ONLY. +- [ ] Aktive Suche kann vollständig ausgeführt werden. +- [ ] Pagination wird vollständig verarbeitet. +- [ ] TOO_BROAD wird erkannt. +- [ ] SearchRuns werden gespeichert. +- [ ] Produkte werden dauerhaft als SourceProductReference angelegt. +- [ ] Preise und relevante Attribute werden historisiert. +- [ ] NEW / CHANGED / UNCHANGED / MISSING / DISAPPEARED funktionieren. +- [ ] DISAPPEARED erst nach drei aufeinanderfolgenden fehlenden Full Runs. +- [ ] Unsichere Produktidentität wird nicht automatisch verschmolzen. +- [ ] interne Engine bewertet Kandidaten. +- [ ] Shortlist Default 5 funktioniert. +- [ ] Suchfrequenz ist konfigurierbar, Default 180 Minuten. +- [ ] 403/429/Captcha führen zu defensivem Stop/Backoff. +- [ ] keine HTML-Archive. +- [ ] keine Notebook-spezifische Businesslogik außerhalb des Geizhals-Pilotmappings. +- [ ] Tests grün. +- [ ] Ergebnisse des Notebook-Piloten dokumentiert. + +# Coding-Agent-Arbeitsauftrag + +Verwende als verbindliche Anforderungen: + +1. `buying-agent-v0.1-spec-v1.1.md` +2. `buying-agent-architecture-addendum-v1.md` +3. `buying-agent-source-discovery-spec-v0.1.md` + +Die neue Source-/Discovery-Spec ergänzt die bestehenden Dokumente. Bei Konflikten in ihrem Fachbereich hat sie Vorrang. + +## Vorgehen + +### 1. Zuerst analysieren + +Prüfe den aktuellen Repository-Stand gegen alle drei Dokumente. + +Berichte vor größeren Änderungen: + +- welche Teile bereits kompatibel sind, +- welche Domain-/DB-/API-Erweiterungen erforderlich sind, +- ob bestehende Implementierung refaktoriert werden muss, +- welche Änderungen rein additiv möglich sind. + +### 2. Minimal integrieren + +Implementiere nur den beschriebenen Source-/Discovery-Slice. + +Nicht implementieren: + +- Frontend +- Keepa +- Idealo +- LLM-Automation +- Auth/RBAC +- Kaufautomatisierung +- Warenkorb + +### 3. Besonders beachten + +- nicht notebook-spezifisch modellieren +- Geizhals-spezifische Logik ausschließlich im Source Adapter / Mapping Layer +- URL-Encoding nur aus verifizierten Source-Mappings +- vollständige SearchRuns, kein stilles Top-N-Limit +- konservatives Product Matching +- keine automatische Zusammenführung bei Unsicherheit +- alle Änderungen historisieren +- Discovery, Ranking und Decision getrennt halten + +### 4. Tests + +Führe Unit-, Integration- und End-to-End-Tests aus. + +Mindestens: + +- Taxonomy Discovery +- Query Generation +- Pagination +- Identity Matching +- Diffing +- History +- Ranking +- Shortlist +- Backoff / BLOCKED + +### 5. Abschlussbericht + +Dokumentiere: + +- geänderte Dateien +- neue Migrationen +- neue API-Endpunkte +- Testergebnisse +- real durchgeführten Notebook-Pilot +- erkannte Geizhals-Eigenheiten +- verbleibende Risiken +- insbesondere Beobachtungen zur Stabilität der Produktidentität diff --git a/README.md b/README.md index fee6518..d698984 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,48 @@ # BuyingAgent -Backend für einen generischen Buying-/Deal-Agenten. Der erste fachliche Schnitt ist in `.specs/buying-agent-v0.1-spec.md` beschrieben. +Backend für einen generischen Buying-/Deal-Agenten. Das System bewertet, wie gut eine Produktvariante zu einem Profil passt und wie attraktiv ein konkretes Angebot preislich ist. Es kauft nicht. -Entwicklung auf `develop`. Push nach `develop` baut die Dev-Umgebung auf dem Raspberry Pi. Ein Merge-Commit nach `main` deployt Production. Details: `docs/DEPLOYMENT.md`. +v0.1 ist ein Walking Skeleton: Profil → Produkt → Variante → Angebot → Preisbeobachtungen → Evaluation. Notebooks sind der erste Anwendungsfall, nicht das Datenmodell. + +**Vorgaben:** `docs/buying-agent-v0.1-spec-v1.1.md` (fachlich) und `docs/buying-agent-architecture-addendum-v1.md` (Architektur, hat Vorrang bei Strukturfragen). + +## Monorepo + +```text +backend/ System of Record: Domain, Scoring, Persistenz, /api/v1 +frontend/ Platzhalter — keine UI in v0.1 +docs/ Spezifikationen und Deployment +docker-compose.yml +``` + +Entwicklung auf `develop`. Push nach `develop` baut Dev auf dem Raspberry Pi. Merge `--no-ff` nach `main` deployt Production. Details: `docs/DEPLOYMENT.md`. ## Stack -Python 3.13, FastAPI, Pydantic 2, SQLAlchemy 2, Alembic, PostgreSQL 17, pytest, Docker Compose. Build-Tool: [uv](https://docs.astral.sh/uv/) 0.10.11. +Python 3.13, FastAPI, Pydantic 2, SQLAlchemy 2 (sync, psycopg 3), Alembic, PostgreSQL 17, pytest, Docker Compose. Build-Tool: uv 0.10.11. + +Abhängigkeiten: HTTP → Services → Domain/Scoring → Repositories → PostgreSQL. `domain` und `scoring` importieren weder FastAPI noch SQLAlchemy. ## Lokal starten ```powershell Copy-Item .env.example .env docker compose up --build -d +docker compose exec api python -m scripts.demo ``` ```sh cp .env.example .env docker compose up --build -d +docker compose exec api python -m scripts.demo ``` API: `http://127.0.0.1:8000` +OpenAPI: `http://127.0.0.1:8000/docs` Live: `GET /health/live` -Ready: `GET /health/ready` (PostgreSQL erreichbar und Alembic auf Head) +Ready: `GET /health/ready` + +Die Demo legt Definitionen, Profil und die Fälle A–D über HTTP an und wertet sie mit `as_of=2026-10-03T12:00:00Z` aus. Wiederholter Lauf erzeugt keine Duplikate. ## Tests @@ -32,7 +52,38 @@ Lokal, isolierter Stack (tmpfs, kein Dev-/Prod-Volume): docker compose -f docker-compose.test.yml up --build --abort-on-container-exit --exit-code-from api ``` -Auf dem Server nach Dev-Deploy (Gitea `test.yml`, analog Kanshō): pytest gegen `buyingagent_test` auf dem Dev-Postgres plus read-only Smoke gegen `http://192.168.2.49:8095/health/ready`. +Nur Unit-Tests (ohne PostgreSQL), im Verzeichnis `backend/`: + +```sh +pytest tests/unit -ra --tb=short +``` + +Auf dem Server nach Dev-Deploy (Gitea `test.yml`): pytest gegen `buyingagent_test` auf dem Dev-Postgres plus Smoke gegen `http://192.168.2.49:8095/health/ready`. + +## Datenmodell (global) + +Product, ProductVariant, Offer, PriceObservation, AttributeDefinition und Source sind **systemweit** und haben keine `user_id`/`workspace_id`. Workspace, BuyingProject und Benutzer kommen später und dürfen diese Daten nicht duplizieren. + +Profile und Evaluationen können bereits parallel existieren; es gibt kein globales „aktuelles Kaufvorhaben“. + +## Scoring `generic-v1` + +- Preference-Strategie nur `numeric_linear`. Unbekannte Strategien werden beim Profil abgewiesen. +- `product_eligibility`, `offer_eligibility` und `overall_eligibility` sind getrennt. +- PriceScore ist eine reine Funktion über `current_price`, `reference_history` und `budget`. v0.1 setzt `price_reference_scope=offer`. +- DealScore nur bei `overall_eligibility=ELIGIBLE` und vorhandenem PriceScore. +- Klassifikation am **ungerundeten** DealScore; Anzeige auf zwei Dezimalen, `ROUND_HALF_UP`. +- Geld intern `Decimal`/`NUMERIC(12,2)`, in der API als Dezimalstring. + +Referenzfall A (synthetisch): ProductScore 90.21, PriceScore 98.24, DealScore 93.42, `EXCEPTIONAL`. Das ist keine Marktaussage. + +## Fehler + +Einheitliches Format `{error: {code, message, details}}`. 404 fehlende ID, 409 Konflikt/abweichender Wiederholungsimport, 422 ungültige Payload. Eine fachlich negative Evaluation ist HTTP 201. + +## Grenzen von v0.1 + +Keine Web-UI, keine Authentifizierung, keine Workspaces, kein BuyingProject, kein n8n, keine externen Quellen, keine Auto-Discovery, keine Wechselkurse, keine Variant-/Market-Level-Preisaggregation, keine Plugin-Infrastruktur. ## Server diff --git a/app/__init__.py b/app/__init__.py deleted file mode 100644 index 3dc1f76..0000000 --- a/app/__init__.py +++ /dev/null @@ -1 +0,0 @@ -__version__ = "0.1.0" diff --git a/app/main.py b/app/main.py deleted file mode 100644 index b499933..0000000 --- a/app/main.py +++ /dev/null @@ -1,10 +0,0 @@ -from fastapi import FastAPI - -from app.api.health import router as health_router - -app = FastAPI( - title="Buying Agent", - version="0.1.0", - description="Walking skeleton for the buying and deal evaluation API.", -) -app.include_router(health_router) diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..ea2656e --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,10 @@ +.git +.venv +.pytest_cache +.mypy_cache +.ruff_cache +__pycache__ +*.pyc +*.pyo +*.egg-info +.tests-tmp diff --git a/Dockerfile b/backend/Dockerfile similarity index 100% rename from Dockerfile rename to backend/Dockerfile diff --git a/alembic.ini b/backend/alembic.ini similarity index 96% rename from alembic.ini rename to backend/alembic.ini index 1dfc84d..3e8a0d6 100644 --- a/alembic.ini +++ b/backend/alembic.ini @@ -1,6 +1,7 @@ [alembic] script_location = migrations prepend_sys_path = . +path_separator = os sqlalchemy.url = driver://user:pass@localhost/dbname [loggers] diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/discovery.py b/backend/app/api/discovery.py new file mode 100644 index 0000000..1e669b1 --- /dev/null +++ b/backend/app/api/discovery.py @@ -0,0 +1,125 @@ +from uuid import UUID + +from fastapi import APIRouter, Depends, Query +from sqlalchemy.orm import Session + +from app.api.schemas import ( + CriterionDefinitionIn, + CriterionMappingIn, + CriterionMappingPatch, + DiscoverySearchIn, + DiscoverySearchPatch, + SourceAttributeMappingIn, + SourceAttributeMappingPatch, +) +from app.persistence.db import get_session +from app.services.catalog_service import CatalogService +from app.services.discovery_service import ( + CriterionDefinitionService, + CriterionMappingService, + DiscoverySearchService, + SourceAttributeMappingService, + TaxonomyService, +) + +router = APIRouter(prefix="/api/v1") + + +@router.get("/sources/{source_id}/categories") +def list_source_categories(source_id: str, session: Session = Depends(get_session)): + return {"items": CatalogService(session).source_categories(source_id)} + + +@router.post("/sources/{source_id}/categories/{category}/discover-taxonomy", status_code=201) +def discover_taxonomy(source_id: str, category: str, session: Session = Depends(get_session)): + return TaxonomyService(session).discover(source_id, category) + + +@router.get("/sources/{source_id}/categories/{category}/filters") +def list_filters(source_id: str, category: str, session: Session = Depends(get_session)): + return TaxonomyService(session).filters(source_id, category) + + +@router.post("/criterion-definitions", status_code=201) +def create_criterion_definition(payload: CriterionDefinitionIn, session: Session = Depends(get_session)): + return CriterionDefinitionService(session).create(payload.model_dump()) + + +@router.get("/criterion-definitions") +def list_criterion_definitions(category: str | None = None, session: Session = Depends(get_session)): + return {"items": CriterionDefinitionService(session).list(category)} + + +@router.get("/criterion-mappings") +def list_mappings(source_category_id: UUID | None = None, session: Session = Depends(get_session)): + return {"items": CriterionMappingService(session).list(source_category_id)} + + +@router.post("/criterion-mappings", status_code=201) +def create_mapping(payload: CriterionMappingIn, session: Session = Depends(get_session)): + return CriterionMappingService(session).create(payload.model_dump()) + + +@router.patch("/criterion-mappings/{mapping_id}") +def patch_mapping(mapping_id: UUID, payload: CriterionMappingPatch, session: Session = Depends(get_session)): + return CriterionMappingService(session).patch(mapping_id, payload.model_dump(exclude_unset=True)) + + +@router.get("/source-attribute-mappings") +def list_attribute_mappings(session: Session = Depends(get_session)): + return {"items": SourceAttributeMappingService(session).list()} + + +@router.post("/source-attribute-mappings", status_code=201) +def create_attribute_mapping(payload: SourceAttributeMappingIn, session: Session = Depends(get_session)): + return SourceAttributeMappingService(session).create(payload.model_dump()) + + +@router.patch("/source-attribute-mappings/{mapping_id}") +def patch_attribute_mapping( + mapping_id: UUID, + payload: SourceAttributeMappingPatch, + session: Session = Depends(get_session), +): + return SourceAttributeMappingService(session).patch(mapping_id, payload.model_dump(exclude_unset=True)) + + +@router.post("/discovery-searches", status_code=201) +def create_search(payload: DiscoverySearchIn, session: Session = Depends(get_session)): + return DiscoverySearchService(session).create(payload.model_dump()) + + +@router.get("/discovery-searches") +def list_searches(due: bool = Query(default=False), session: Session = Depends(get_session)): + service = DiscoverySearchService(session) + return {"items": service.list_due() if due else service.list_all()} + + +@router.get("/discovery-searches/{search_id}") +def get_search(search_id: UUID, session: Session = Depends(get_session)): + return DiscoverySearchService(session).get(search_id) + + +@router.patch("/discovery-searches/{search_id}") +def patch_search(search_id: UUID, payload: DiscoverySearchPatch, session: Session = Depends(get_session)): + return DiscoverySearchService(session).patch(search_id, payload.model_dump(exclude_unset=True)) + + +@router.post("/discovery-searches/{search_id}/generate-query") +def generate_query(search_id: UUID, session: Session = Depends(get_session)): + return DiscoverySearchService(session).generate_query(search_id) + + +@router.post("/discovery-searches/{search_id}/run", status_code=201) +def run_search(search_id: UUID, session: Session = Depends(get_session)): + return DiscoverySearchService(session).run(search_id) + + +@router.get("/discovery-searches/{search_id}/runs") +def list_runs(search_id: UUID, session: Session = Depends(get_session)): + return {"items": DiscoverySearchService(session).list_runs(search_id)} + + +@router.get("/search-runs/{run_id}") +def get_run(run_id: UUID, session: Session = Depends(get_session)): + return DiscoverySearchService(session).get_run(run_id) diff --git a/backend/app/api/evaluations.py b/backend/app/api/evaluations.py new file mode 100644 index 0000000..dc4b679 --- /dev/null +++ b/backend/app/api/evaluations.py @@ -0,0 +1,20 @@ +from uuid import UUID + +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session + +from app.api.schemas import EvaluateIn +from app.persistence.db import get_session +from app.services.evaluation_service import evaluate_and_store, load_evaluation + +router = APIRouter(prefix="/api/v1") + + +@router.post("/evaluate/{variant_id}", status_code=201) +def evaluate(variant_id: UUID, payload: EvaluateIn): + return evaluate_and_store(variant_id=variant_id, payload=payload.model_dump()) + + +@router.get("/evaluations/{evaluation_id}") +def get_evaluation(evaluation_id: UUID, session: Session = Depends(get_session)): + return load_evaluation(session, evaluation_id) diff --git a/app/api/health.py b/backend/app/api/health.py similarity index 100% rename from app/api/health.py rename to backend/app/api/health.py diff --git a/backend/app/api/offers.py b/backend/app/api/offers.py new file mode 100644 index 0000000..84ce6f3 --- /dev/null +++ b/backend/app/api/offers.py @@ -0,0 +1,42 @@ +from uuid import UUID + +from fastapi import APIRouter, Depends, Response +from sqlalchemy.orm import Session + +from app.api.schemas import OfferIn, OfferOut, PriceIn, PriceOut, page, parse_page +from app.errors import AppError +from app.persistence.db import get_session +from app.persistence.repositories import CatalogRepository +from app.services.ingestion_service import IngestionService + +router = APIRouter(prefix="/api/v1") + + +@router.post("/variants/{variant_id}/offers", status_code=201) +def create_offer(variant_id: UUID, payload: OfferIn, session: Session = Depends(get_session)) -> OfferOut: + offer = IngestionService(session).create_offer(variant_id, payload.model_dump()) + return OfferOut.model_validate(offer.__dict__) + + +@router.get("/offers/{offer_id}") +def get_offer(offer_id: UUID, session: Session = Depends(get_session)) -> OfferOut: + offer = CatalogRepository(session).get_offer(offer_id) + if offer is None: + raise AppError(404, "NOT_FOUND", "offer not found", {"id": str(offer_id)}) + return OfferOut.model_validate(offer.__dict__) + + +@router.post("/offers/{offer_id}/prices") +def add_price(offer_id: UUID, payload: PriceIn, response: Response, session: Session = Depends(get_session)) -> PriceOut: + observation, created = IngestionService(session).add_price(offer_id, payload.model_dump()) + response.status_code = 201 if created else 200 + return PriceOut.model_validate(observation.__dict__) + + +@router.get("/offers/{offer_id}/prices") +def list_prices(offer_id: UUID, limit: int = 50, offset: int = 0, session: Session = Depends(get_session)): + if CatalogRepository(session).get_offer(offer_id) is None: + raise AppError(404, "NOT_FOUND", "offer not found", {"id": str(offer_id)}) + limit, offset = parse_page(limit, offset) + items, total = CatalogRepository(session).list_observations(offer_id, limit, offset) + return page([PriceOut.model_validate(item.__dict__) for item in items], limit, offset, total) diff --git a/backend/app/api/products.py b/backend/app/api/products.py new file mode 100644 index 0000000..270ab29 --- /dev/null +++ b/backend/app/api/products.py @@ -0,0 +1,63 @@ +from uuid import UUID + +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session + +from app.api.schemas import AttributeIn, AttributeOut, ProductIn, ProductOut, VariantIn, VariantOut +from app.errors import AppError +from app.persistence.db import get_session +from app.persistence.repositories import CatalogRepository +from app.services.ingestion_service import IngestionService + +router = APIRouter(prefix="/api/v1") + + +@router.post("/products", status_code=201) +def create_product(payload: ProductIn, session: Session = Depends(get_session)) -> ProductOut: + product = IngestionService(session).create_product(payload.model_dump()) + return ProductOut.model_validate(product.__dict__) + + +@router.get("/products/{product_id}") +def get_product(product_id: UUID, session: Session = Depends(get_session)) -> ProductOut: + product = CatalogRepository(session).get_product(product_id) + if product is None: + raise AppError(404, "NOT_FOUND", "product not found", {"id": str(product_id)}) + return ProductOut.model_validate(product.__dict__) + + +@router.post("/products/{product_id}/variants", status_code=201) +def create_variant(product_id: UUID, payload: VariantIn, session: Session = Depends(get_session)): + variant = IngestionService(session).create_variant(product_id, payload.model_dump()) + return { + "id": variant.id, + "product_id": variant.product_id, + "name": variant.name, + "manufacturer_sku": variant.manufacturer_sku, + "identifiers": variant.identifiers, + "created_at": variant.created_at, + } + + +@router.get("/variants/{variant_id}") +def get_variant(variant_id: UUID, session: Session = Depends(get_session)) -> VariantOut: + repo = CatalogRepository(session) + variant = repo.get_variant(variant_id) + if variant is None: + raise AppError(404, "NOT_FOUND", "variant not found", {"id": str(variant_id)}) + attributes = [AttributeOut.model_validate(item.__dict__) for item in repo.list_attributes(variant_id)] + return VariantOut( + id=variant.id, + product_id=variant.product_id, + name=variant.name, + manufacturer_sku=variant.manufacturer_sku, + identifiers=variant.identifiers, + created_at=variant.created_at, + attributes=attributes, + ) + + +@router.post("/variants/{variant_id}/attributes", status_code=201) +def add_attribute(variant_id: UUID, payload: AttributeIn, session: Session = Depends(get_session)) -> AttributeOut: + statement = IngestionService(session).add_attribute(variant_id, payload.model_dump()) + return AttributeOut.model_validate(statement.__dict__) diff --git a/backend/app/api/profiles.py b/backend/app/api/profiles.py new file mode 100644 index 0000000..4957af4 --- /dev/null +++ b/backend/app/api/profiles.py @@ -0,0 +1,100 @@ +from typing import Any + +from fastapi import APIRouter, Body, Depends +from sqlalchemy.orm import Session + +from app.api.schemas import AttributeDefinitionIn, page, parse_page +from app.domain.serialize import jsonable +from app.errors import AppError +from app.persistence.db import get_session +from app.persistence.repositories import CatalogRepository +from app.services.ingestion_service import IngestionService + +router = APIRouter(prefix="/api/v1") + + +@router.post("/attribute-definitions", status_code=201) +def create_attribute_definition(payload: AttributeDefinitionIn, session: Session = Depends(get_session)): + definition = IngestionService(session).create_definition(payload.model_dump()) + return jsonable(definition.__dict__) + + +@router.get("/attribute-definitions") +def list_attribute_definitions(limit: int = 50, offset: int = 0, session: Session = Depends(get_session)): + limit, offset = parse_page(limit, offset) + items, total = CatalogRepository(session).list_definitions(limit, offset) + return page([jsonable(item.__dict__) for item in items], limit, offset, total) + + +@router.get("/sources") +def list_sources(session: Session = Depends(get_session)): + items = CatalogRepository(session).list_sources() + return { + "items": [{"id": item.id, "name": item.name, "adapter_type": item.adapter_type} for item in items], + "limit": len(items), + "offset": 0, + "total": len(items), + } + + +@router.get("/scoring-models/{version}") +def get_scoring_model(version: str, session: Session = Depends(get_session)): + model = CatalogRepository(session).get_scoring_model(version) + if model is None: + raise AppError(404, "NOT_FOUND", "scoring model not found", {"version": version}) + return { + "version": model.version, + "algorithm_id": model.algorithm_id, + "definition": model.definition, + "definition_hash": model.definition_hash, + } + + +@router.post("/profiles", status_code=201) +def create_profile(payload: dict[str, Any] = Body(...), session: Session = Depends(get_session)): + if not isinstance(payload, dict): + raise AppError(422, "PROFILE_INVALID", "profile must be an object") + row = IngestionService(session).create_profile(payload) + return { + "id": row.id, + "version": row.version, + "name": row.name, + "category": row.category, + "scoring_version": row.scoring_version, + "definition": row.definition, + "definition_hash": row.definition_hash, + "created_at": row.created_at, + } + + +@router.get("/profiles") +def list_profiles(limit: int = 50, offset: int = 0, session: Session = Depends(get_session)): + limit, offset = parse_page(limit, offset) + items, total = CatalogRepository(session).list_profiles(limit, offset) + return page( + [ + { + "id": row.id, + "version": row.version, + "name": row.name, + "category": row.category, + "scoring_version": row.scoring_version, + "definition_hash": row.definition_hash, + "created_at": row.created_at, + } + for row in items + ], + limit, + offset, + total, + ) + + +@router.get("/profiles/{profile_id}") +def get_profile(profile_id: str, version: int | None = None, session: Session = Depends(get_session)): + if version is None: + raise AppError(422, "VERSION_REQUIRED", "query parameter version is required") + profile = CatalogRepository(session).get_profile(profile_id, version) + if profile is None: + raise AppError(404, "NOT_FOUND", "profile version not found") + return profile.definition diff --git a/backend/app/api/projects.py b/backend/app/api/projects.py new file mode 100644 index 0000000..c89e101 --- /dev/null +++ b/backend/app/api/projects.py @@ -0,0 +1,125 @@ +from uuid import UUID + +from fastapi import APIRouter, Depends, Query +from sqlalchemy.orm import Session + +from app.api.schemas import ( + BuyingProjectIn, + BuyingProjectPatch, + DiscoverySearchIn, + ProjectCriteriaIn, + page, + parse_page, +) +from app.persistence.db import get_session +from app.services.catalog_service import CatalogService +from app.services.discovery_service import CandidateRankingService, DiscoverySearchService +from app.services.project_service import ProjectService + +router = APIRouter(prefix="/api/v1") + + +@router.get("/product-categories") +def list_product_categories(session: Session = Depends(get_session)): + return {"items": CatalogService(session).list_product_categories()} + + +@router.get("/product-categories/{product_category_id}/sources") +def list_category_sources(product_category_id: str, session: Session = Depends(get_session)): + return {"items": CatalogService(session).sources_for_category(product_category_id)} + + +@router.get("/product-categories/{product_category_id}/available-criteria") +def available_criteria_for_category( + product_category_id: str, + source: str = Query(...), + session: Session = Depends(get_session), +): + return CatalogService(session).available_criteria(product_category_id, source) + + +@router.get("/buying-templates") +def list_buying_templates(session: Session = Depends(get_session)): + return {"items": CatalogService(session).list_templates()} + + +@router.post("/buying-projects", status_code=201) +def create_buying_project(payload: BuyingProjectIn, session: Session = Depends(get_session)): + return ProjectService(session).create(payload.model_dump()) + + +@router.get("/buying-projects") +def list_buying_projects(limit: int = 50, offset: int = 0, session: Session = Depends(get_session)): + limit, offset = parse_page(limit, offset) + items, total = ProjectService(session).list(limit, offset) + return page(items, limit, offset, total) + + +@router.get("/buying-projects/{project_id}") +def get_buying_project(project_id: UUID, session: Session = Depends(get_session)): + return ProjectService(session).get(project_id) + + +@router.patch("/buying-projects/{project_id}") +def patch_buying_project(project_id: UUID, payload: BuyingProjectPatch, session: Session = Depends(get_session)): + return ProjectService(session).patch(project_id, payload.model_dump(exclude_unset=True)) + + +@router.post("/buying-projects/{project_id}/criteria", status_code=201) +def add_buying_project_criteria( + project_id: UUID, + payload: ProjectCriteriaIn, + session: Session = Depends(get_session), +): + return ProjectService(session).replace_criteria(project_id, payload.model_dump()) + + +@router.put("/buying-projects/{project_id}/criteria") +def put_buying_project_criteria( + project_id: UUID, + payload: ProjectCriteriaIn, + session: Session = Depends(get_session), +): + return ProjectService(session).replace_criteria(project_id, payload.model_dump()) + + +@router.get("/buying-projects/{project_id}/criteria") +def get_buying_project_criteria(project_id: UUID, session: Session = Depends(get_session)): + return ProjectService(session).get_criteria(project_id) + + +@router.get("/buying-projects/{project_id}/available-criteria") +def available_criteria(project_id: UUID, session: Session = Depends(get_session)): + return ProjectService(session).available_criteria(project_id) + + +@router.get("/buying-projects/{project_id}/discovery-searches") +def list_project_searches(project_id: UUID, session: Session = Depends(get_session)): + return {"items": DiscoverySearchService(session).list_for_project(project_id)} + + +@router.post("/buying-projects/{project_id}/discovery-searches", status_code=201) +def create_project_search(project_id: UUID, payload: DiscoverySearchIn, session: Session = Depends(get_session)): + body = payload.model_dump() + body["buying_project_id"] = project_id + return DiscoverySearchService(session).create(body) + + +@router.get("/buying-projects/{project_id}/candidates") +def list_candidates(project_id: UUID, session: Session = Depends(get_session)): + return {"items": CandidateRankingService(session).list_candidates(project_id)} + + +@router.get("/buying-projects/{project_id}/candidates/{candidate_id}") +def get_candidate(project_id: UUID, candidate_id: UUID, session: Session = Depends(get_session)): + return CandidateRankingService(session).get_candidate(project_id, candidate_id) + + +@router.get("/buying-projects/{project_id}/candidates/{candidate_id}/observations") +def list_candidate_observations(project_id: UUID, candidate_id: UUID, session: Session = Depends(get_session)): + return {"items": CandidateRankingService(session).list_observations(project_id, candidate_id)} + + +@router.get("/buying-projects/{project_id}/shortlist") +def list_shortlist(project_id: UUID, session: Session = Depends(get_session)): + return {"items": CandidateRankingService(session).list_candidates(project_id, shortlisted_only=True)} diff --git a/backend/app/api/schemas.py b/backend/app/api/schemas.py new file mode 100644 index 0000000..1fae67d --- /dev/null +++ b/backend/app/api/schemas.py @@ -0,0 +1,274 @@ +from datetime import datetime +from decimal import Decimal +from typing import Any +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field, field_serializer + + +class StrictModel(BaseModel): + model_config = ConfigDict(extra="forbid") + + +class AttributeDefinitionIn(StrictModel): + key: str + value_type: str + canonical_unit: str | None = None + minimum: Decimal | None = None + maximum: Decimal | None = None + description: str | None = None + + +class AttributeDefinitionOut(StrictModel): + key: str + value_type: str + canonical_unit: str | None + minimum: Decimal | None + maximum: Decimal | None + description: str | None + + +class ProductIn(StrictModel): + id: UUID | None = None + category: str + brand: str + name: str + model: str | None = None + + +class ProductOut(StrictModel): + id: UUID + category: str + brand: str + name: str + model: str | None + created_at: datetime + + +class VariantIn(StrictModel): + id: UUID | None = None + name: str + manufacturer_sku: str | None = None + identifiers: dict[str, Any] = Field(default_factory=dict) + + +class AttributeIn(StrictModel): + id: UUID | None = None + attribute: str + raw_value: Any + normalized_value: Any | None = None + unit: str | None = None + normalization_status: str + normalizer_version: str | None = None + confidence: Decimal + source_id: str + source_reference: str + observed_at: datetime + normalization_error: str | None = None + + +class AttributeOut(StrictModel): + id: UUID + variant_id: UUID + attribute: str + raw_value: Any + normalized_value: Any | None + unit: str | None + normalization_status: str + normalizer_version: str + confidence: Decimal + source_id: str + source_reference: str + observed_at: datetime + ingested_at: datetime + normalization_error: str | None + + +class VariantOut(StrictModel): + id: UUID + product_id: UUID + name: str + manufacturer_sku: str | None + identifiers: dict[str, Any] + created_at: datetime + attributes: list[AttributeOut] + + +class OfferIn(StrictModel): + id: UUID | None = None + source_id: str + external_id: str + seller: str + url: str | None = None + market: str + currency: str + condition: str + + +class OfferOut(StrictModel): + id: UUID + variant_id: UUID + source_id: str + external_id: str + seller: str + url: str | None + market: str + currency: str + condition: str + created_at: datetime + + +class PriceIn(StrictModel): + id: UUID | None = None + source_id: str + external_observation_id: str + observed_at: datetime + item_price: Decimal + shipping_price: Decimal | None = None + currency: str + availability: str + raw_payload: Any + confidence: Decimal + + @field_serializer("item_price", "shipping_price") + def serialize_money(self, value: Decimal | None) -> str | None: + return None if value is None else format(value, "f") + + +class PriceOut(StrictModel): + id: UUID + offer_id: UUID + source_id: str + external_observation_id: str + observed_at: datetime + ingested_at: datetime + item_price: Decimal + shipping_price: Decimal | None + currency: str + availability: str + raw_payload: Any + confidence: Decimal + + @field_serializer("item_price", "shipping_price") + def serialize_money(self, value: Decimal | None) -> str | None: + return None if value is None else format(Decimal(value).quantize(Decimal("0.01")), "f") + + +class EvaluateIn(StrictModel): + profile_id: str + profile_version: int + offer_id: UUID + as_of: datetime | None = None + + +class BuyingProjectIn(StrictModel): + id: UUID | None = None + name: str + product_category_id: str | None = None + market: str | None = None + currency: str | None = None + target_budget: Decimal | None = None + template_id: str | None = None + template_version: int | None = None + profile_id: str | None = None + profile_version: int | None = None + workspace_id: str | None = None + shortlist_size: int = 5 + schedule_interval_minutes: int | None = None + criteria: dict[str, Any] + + +class BuyingProjectPatch(StrictModel): + name: str | None = None + target_budget: Decimal | None = None + shortlist_size: int | None = None + status: str | None = None + + +class ProjectCriteriaIn(StrictModel): + items: list[dict[str, Any]] + + +class CriterionDefinitionIn(StrictModel): + key: str + value_type: str + unit: str | None = None + semantic: str | None = None + category: str | None = None + description: str | None = None + origin: str | None = None + source: str | None = None + product_category_id: str | None = None + label: str | None = None + scoring_attribute_key: str | None = None + + +class DiscoverySearchIn(StrictModel): + buying_project_id: UUID | None = None + source_id: str + source_category_id: UUID + schedule_interval_minutes: int = 180 + broad_result_threshold: int = 300 + + +class DiscoverySearchPatch(StrictModel): + schedule_interval_minutes: int | None = None + status: str | None = None + + +class SourceAttributeMappingIn(StrictModel): + source_id: str + source_category_id: UUID | None = None + raw_name: str + criterion_definition_id: UUID + status: str | None = None + + +class SourceAttributeMappingPatch(StrictModel): + raw_name: str | None = None + criterion_definition_id: UUID | None = None + status: str | None = None + + +class CriterionMappingIn(StrictModel): + criterion_definition_id: UUID | None = None + criterion_key: str | None = None + source_id: str + source_category_id: UUID + external_filter_key: str + source_value: str | None = None + encoding: dict[str, Any] = Field(default_factory=lambda: {"url_param": "xf"}) + status: str | None = None + value_type: str | None = None + unit: str | None = None + semantic: str | None = None + description: str | None = None + operator: str | None = None + probe_value: Any = None + + +class CriterionMappingPatch(StrictModel): + source_value: str | None = None + encoding: dict[str, Any] | None = None + status: str | None = None + probe_value: Any = None + operator: str | None = None + + +class Page(StrictModel): + items: list[Any] + limit: int + offset: int + total: int + + +def page(items: list[Any], limit: int, offset: int, total: int) -> dict[str, Any]: + return {"items": items, "limit": limit, "offset": offset, "total": total} + + +def parse_page(limit: int = 50, offset: int = 0) -> tuple[int, int]: + from app.errors import AppError + + if limit < 1 or limit > 200 or offset < 0: + raise AppError(422, "PAGINATION", "limit must be 1-200 and offset >= 0") + return limit, offset diff --git a/backend/app/clock.py b/backend/app/clock.py new file mode 100644 index 0000000..97b6e9b --- /dev/null +++ b/backend/app/clock.py @@ -0,0 +1,12 @@ +from datetime import datetime, timezone + +from app.settings import get_settings + + +def now_utc() -> datetime: + override = get_settings().clock_now + if override is not None: + if override.tzinfo is None: + return override.replace(tzinfo=timezone.utc) + return override.astimezone(timezone.utc) + return datetime.now(timezone.utc) diff --git a/backend/app/discovery/__init__.py b/backend/app/discovery/__init__.py new file mode 100644 index 0000000..9cbf0b6 --- /dev/null +++ b/backend/app/discovery/__init__.py @@ -0,0 +1,38 @@ +from app.discovery.criteria import parse_project_criteria +from app.discovery.diff import diff_run, next_lifecycle, observations_changed +from app.discovery.enums import ( + CandidateStatus, + DiffStatus, + LifecycleStatus, + MappingStatus, + MatchStatus, + ProjectStatus, + RunStatus, + SearchStatus, +) +from app.discovery.identity import IdentityDecision, IdentitySignals, decide_identity, spec_fingerprint +from app.discovery.mapping import verify_roundtrip +from app.discovery.ranking import rank_candidates +from app.discovery.urlgen import generate_search_url + +__all__ = [ + "CandidateStatus", + "DiffStatus", + "IdentityDecision", + "IdentitySignals", + "LifecycleStatus", + "MappingStatus", + "MatchStatus", + "ProjectStatus", + "RunStatus", + "SearchStatus", + "decide_identity", + "diff_run", + "generate_search_url", + "next_lifecycle", + "observations_changed", + "parse_project_criteria", + "rank_candidates", + "spec_fingerprint", + "verify_roundtrip", +] diff --git a/backend/app/discovery/criteria.py b/backend/app/discovery/criteria.py new file mode 100644 index 0000000..b8e00a4 --- /dev/null +++ b/backend/app/discovery/criteria.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +from typing import Any +from uuid import UUID + +from app.domain.validation import DomainValidationError, canonical_json, sha256_text + + +def parse_project_criteria(definition: dict[str, Any]) -> dict[str, Any]: + items = definition.get("items") + if not isinstance(items, list) or not items: + raise DomainValidationError("criteria.items must be a non-empty list") + seen: set[str] = set() + canonical_items: list[dict[str, Any]] = [] + for raw in items: + criterion_id = raw.get("criterion_definition_id") + key = raw.get("criterion") + item_id = str(raw.get("id") or criterion_id or key or "") + if not item_id: + raise DomainValidationError("each criterion needs id, criterion_definition_id or criterion") + if item_id in seen: + raise DomainValidationError("duplicate criterion id", {"id": item_id}) + seen.add(item_id) + if not criterion_id and not key: + raise DomainValidationError("criterion_definition_id or criterion is required", {"id": item_id}) + if not raw.get("operator"): + raise DomainValidationError("operator is required", {"id": item_id}) + enabled = raw.get("enabled_for_discovery") + if enabled is None: + enabled = raw.get("mandatory", True) + canonical_items.append( + { + "id": item_id, + "criterion_definition_id": None if criterion_id is None else UUID(str(criterion_id)), + "criterion": None if key is None else str(key), + "operator": str(raw["operator"]), + "value": raw.get("value"), + "enabled_for_discovery": bool(enabled), + "mandatory": bool(enabled), + "value_type": raw.get("value_type"), + "unit": raw.get("unit"), + "semantic": raw.get("semantic"), + "category": raw.get("category"), + } + ) + canonical = { + "items": [ + { + "id": item["id"], + "criterion_definition_id": None + if item["criterion_definition_id"] is None + else str(item["criterion_definition_id"]), + "criterion": item["criterion"], + "operator": item["operator"], + "value": item["value"], + "enabled_for_discovery": item["enabled_for_discovery"], + } + for item in canonical_items + ] + } + return { + "items": canonical_items, + "definition": canonical, + "definition_hash": sha256_text(canonical_json(canonical)), + } + + +def criterion_payload(row) -> dict[str, Any]: + return { + "id": row.item_id, + "criterion_definition_id": row.criterion_definition_id, + "criterion": row.criterion_key, + "operator": row.operator, + "value": row.value, + "enabled_for_discovery": row.enabled_for_discovery, + "mandatory": row.enabled_for_discovery, + } diff --git a/backend/app/discovery/definitions.py b/backend/app/discovery/definitions.py new file mode 100644 index 0000000..f80789e --- /dev/null +++ b/backend/app/discovery/definitions.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +from typing import Any +from uuid import UUID, uuid4 + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.persistence.discovery_models import CriterionDefinitionRecord + + +def get_definition_by_key(session: Session, key: str) -> CriterionDefinitionRecord | None: + return session.scalars(select(CriterionDefinitionRecord).where(CriterionDefinitionRecord.key == key)).first() + + +def get_definition(session: Session, definition_id: UUID) -> CriterionDefinitionRecord | None: + return session.get(CriterionDefinitionRecord, definition_id) + + +def upsert_definition(session: Session, payload: dict[str, Any]) -> CriterionDefinitionRecord: + key = str(payload["key"]) + existing = get_definition_by_key(session, key) + if existing is not None: + if payload.get("label"): + existing.label = str(payload["label"]) + if payload.get("scoring_attribute_key"): + existing.scoring_attribute_key = str(payload["scoring_attribute_key"]) + if payload.get("product_category_id"): + existing.product_category_id = str(payload["product_category_id"]) + if payload.get("origin"): + existing.origin = str(payload["origin"]) + if payload.get("source"): + existing.source = str(payload["source"]) + return existing + row = CriterionDefinitionRecord( + id=payload.get("id") or uuid4(), + key=key, + origin=str(payload.get("origin") or "SOURCE"), + source=payload.get("source"), + product_category_id=payload.get("product_category_id"), + label=str(payload.get("label") or key), + value_type=str(payload["value_type"]), + unit=payload.get("unit"), + semantic=payload.get("semantic"), + category=payload.get("category") or payload.get("product_category_id"), + description=payload.get("description"), + scoring_attribute_key=payload.get("scoring_attribute_key"), + ) + session.add(row) + session.flush() + return row diff --git a/backend/app/discovery/diff.py b/backend/app/discovery/diff.py new file mode 100644 index 0000000..ef4b8f0 --- /dev/null +++ b/backend/app/discovery/diff.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +from decimal import Decimal +from typing import Any + +from app.discovery.enums import DiffStatus, LifecycleStatus, RunStatus + + +RELEVANT_ATTR_IGNORE = {"title", "html", "presentation", "raw_title"} + + +def observations_changed(previous: dict[str, Any] | None, current: dict[str, Any] | None) -> bool: + if previous is None or current is None: + return previous != current + if _money(previous.get("best_price")) != _money(current.get("best_price")): + return True + if previous.get("availability") != current.get("availability"): + return True + if previous.get("seller_count") != current.get("seller_count"): + return True + prev_attrs = _technical(previous.get("normalized_attributes") or {}) + curr_attrs = _technical(current.get("normalized_attributes") or {}) + return prev_attrs != curr_attrs + + +def _money(value: Any) -> Decimal | None: + if value is None: + return None + return Decimal(str(value)) + + +def _technical(attrs: dict[str, Any]) -> dict[str, Any]: + return {key: attrs[key] for key in sorted(attrs) if key not in RELEVANT_ATTR_IGNORE} + + +def diff_run( + *, + previous_ids: set[Any], + current_ids: set[Any], + previous_obs: dict[Any, dict[str, Any]], + current_obs: dict[Any, dict[str, Any]], + run_status: str, +) -> dict[Any, DiffStatus]: + result: dict[Any, DiffStatus] = {} + for ref_id in current_ids: + if ref_id not in previous_ids: + result[ref_id] = DiffStatus.NEW + elif observations_changed(previous_obs.get(ref_id), current_obs.get(ref_id)): + result[ref_id] = DiffStatus.CHANGED + else: + result[ref_id] = DiffStatus.UNCHANGED + if run_status == RunStatus.SUCCESS.value: + for ref_id in previous_ids - current_ids: + result[ref_id] = DiffStatus.MISSING + return result + + +def next_lifecycle( + *, + current: str, + missing_in_success_run: bool, + consecutive_missing: int, + reappeared: bool, +) -> tuple[str, int]: + if current == LifecycleStatus.ARCHIVED.value: + return current, consecutive_missing + if reappeared: + return LifecycleStatus.ACTIVE.value, 0 + if missing_in_success_run: + streak = consecutive_missing + 1 + if streak >= 3: + return LifecycleStatus.DISAPPEARED.value, streak + return LifecycleStatus.ACTIVE.value, streak + return current, consecutive_missing diff --git a/backend/app/discovery/enums.py b/backend/app/discovery/enums.py new file mode 100644 index 0000000..be5dfb9 --- /dev/null +++ b/backend/app/discovery/enums.py @@ -0,0 +1,78 @@ +from enum import StrEnum + + +class ProjectStatus(StrEnum): + ACTIVE = "ACTIVE" + PURCHASED = "PURCHASED" + ARCHIVED = "ARCHIVED" + + +class MappingStatus(StrEnum): + DISCOVERED = "DISCOVERED" + LEARNED_UNVERIFIED = "LEARNED_UNVERIFIED" + VERIFIED = "VERIFIED" + BROKEN = "BROKEN" + DISABLED = "DISABLED" + + +class FilterType(StrEnum): + BOOLEAN = "BOOLEAN" + ENUM = "ENUM" + MULTI_ENUM = "MULTI_ENUM" + NUMERIC_RANGE = "NUMERIC_RANGE" + NUMERIC_THRESHOLD = "NUMERIC_THRESHOLD" + TEXT = "TEXT" + UNKNOWN = "UNKNOWN" + + +class CriterionOrigin(StrEnum): + SOURCE = "SOURCE" + INTERNAL = "INTERNAL" + + +class SearchStatus(StrEnum): + ACTIVE = "ACTIVE" + OUT_OF_SYNC = "OUT_OF_SYNC" + BLOCKED = "BLOCKED" + DISABLED = "DISABLED" + TOO_BROAD = "TOO_BROAD" + PAUSED = "PAUSED" + + +class RunStatus(StrEnum): + RUNNING = "RUNNING" + SUCCESS = "SUCCESS" + PARTIAL = "PARTIAL" + FAILED = "FAILED" + BLOCKED = "BLOCKED" + TOO_BROAD = "TOO_BROAD" + + +class RankingStatus(StrEnum): + NOT_CONFIGURED = "NOT_CONFIGURED" + CONFIGURED = "CONFIGURED" + + +class DiffStatus(StrEnum): + NEW = "NEW" + CHANGED = "CHANGED" + UNCHANGED = "UNCHANGED" + MISSING = "MISSING" + + +class LifecycleStatus(StrEnum): + ACTIVE = "ACTIVE" + DISAPPEARED = "DISAPPEARED" + ARCHIVED = "ARCHIVED" + + +class MatchStatus(StrEnum): + MATCHED = "MATCHED" + MATCH_UNCERTAIN = "MATCH_UNCERTAIN" + UNCERTAIN = "MATCH_UNCERTAIN" + NEW_REFERENCE = "NEW_REFERENCE" + + +class CandidateStatus(StrEnum): + CANDIDATE = "CANDIDATE" + SHORTLISTED = "SHORTLISTED" diff --git a/backend/app/discovery/identity.py b/backend/app/discovery/identity.py new file mode 100644 index 0000000..ab3824c --- /dev/null +++ b/backend/app/discovery/identity.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +import re +from dataclasses import dataclass +from typing import Any + +from app.discovery.enums import MatchStatus + +PRESENTATION_KEYS = {"title", "html", "presentation", "raw_title"} + + +def normalize_title(title: str) -> str: + return re.sub(r"\s+", " ", title.casefold().strip()) + + +def spec_fingerprint(normalized_attributes: dict[str, Any], keys: tuple[str, ...] | None = None) -> str: + """Fingerprint from whatever technical keys the observation actually has.""" + use = keys if keys is not None else tuple(sorted(key for key in normalized_attributes if key not in PRESENTATION_KEYS)) + parts = [] + for key in use: + value = normalized_attributes.get(key) + parts.append(f"{key}={'' if value is None else str(value).casefold().strip()}") + return "|".join(parts) + + +@dataclass(frozen=True) +class IdentitySignals: + source_external_id: str | None + gtin_ean: str | None + manufacturer_sku: str | None + source_url: str | None + title: str + manufacturer: str | None + model: str | None + fingerprint: str + + +@dataclass(frozen=True) +class IdentityDecision: + match_status: MatchStatus + existing_id: Any | None + reason: str + + +def decide_identity(incoming: IdentitySignals, known: list[tuple[Any, IdentitySignals]]) -> IdentityDecision: + """Conservative matching. Similar titles never merge by themselves.""" + for ref_id, existing in known: + if incoming.source_external_id and incoming.source_external_id == existing.source_external_id: + return IdentityDecision(MatchStatus.MATCHED, ref_id, "source_external_id") + if incoming.gtin_ean and incoming.gtin_ean == existing.gtin_ean: + return IdentityDecision(MatchStatus.MATCHED, ref_id, "gtin_ean") + if incoming.manufacturer_sku and incoming.manufacturer_sku == existing.manufacturer_sku: + return IdentityDecision(MatchStatus.MATCHED, ref_id, "manufacturer_sku") + if incoming.source_url and incoming.source_url == existing.source_url: + return IdentityDecision(MatchStatus.MATCHED, ref_id, "source_url") + + title_hits = [ + (ref_id, existing) + for ref_id, existing in known + if normalize_title(incoming.title) == normalize_title(existing.title) + ] + if len(title_hits) == 1 and incoming.fingerprint and incoming.fingerprint == title_hits[0][1].fingerprint: + return IdentityDecision(MatchStatus.MATCHED, title_hits[0][0], "title+fingerprint") + + manufacturer_model = [ + (ref_id, existing) + for ref_id, existing in known + if incoming.manufacturer + and incoming.model + and incoming.manufacturer.casefold() == (existing.manufacturer or "").casefold() + and incoming.model.casefold() == (existing.model or "").casefold() + ] + if len(manufacturer_model) == 1: + existing = manufacturer_model[0][1] + if incoming.fingerprint and existing.fingerprint and incoming.fingerprint != existing.fingerprint: + return IdentityDecision(MatchStatus.MATCH_UNCERTAIN, None, "manufacturer+model conflict with specs") + return IdentityDecision(MatchStatus.MATCHED, manufacturer_model[0][0], "manufacturer+model") + + if title_hits: + return IdentityDecision(MatchStatus.MATCH_UNCERTAIN, None, "title only") + return IdentityDecision(MatchStatus.NEW_REFERENCE, None, "no match") diff --git a/backend/app/discovery/mapping.py b/backend/app/discovery/mapping.py new file mode 100644 index 0000000..8f37024 --- /dev/null +++ b/backend/app/discovery/mapping.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from typing import Any +from urllib.parse import parse_qsl, urlsplit + +from app.discovery.enums import MappingStatus +from app.discovery.urlgen import decode_xf_roundtrip, generate_search_url + + +def verify_roundtrip( + *, + category_url: str, + criterion: dict[str, Any], + mapping: dict[str, Any], +) -> bool: + """True when encoding a single mapping round-trips to the same source parameter.""" + probe = {**mapping, "status": MappingStatus.VERIFIED.value} + url, internal = generate_search_url( + category_url=category_url, + criteria_items=[criterion], + mappings=[probe], + ) + if internal: + return False + encoding = mapping.get("encoding") or {} + param = str(encoding.get("url_param") or "xf") + if param == "xf": + expected_key = str(mapping["external_filter_key"]) + expected_value = str(mapping.get("source_value") or "") + decoded = decode_xf_roundtrip(url) + return any(key == expected_key and value == expected_value.replace("+", " ") for key, value in decoded) + expected = str(criterion.get("value")) + query = parse_qsl(urlsplit(url).query, keep_blank_values=True) + return any(name == param and value == expected for name, value in query) diff --git a/backend/app/discovery/ranking.py b/backend/app/discovery/ranking.py new file mode 100644 index 0000000..34f3ee3 --- /dev/null +++ b/backend/app/discovery/ranking.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +from datetime import datetime +from decimal import Decimal +from typing import Any +from uuid import UUID, uuid4 + +from app.clock import now_utc +from app.domain.attributes import select_attribute_snapshot +from app.domain.constraints import evaluate_constraint +from app.domain.enums import Eligibility, NormalizationStatus +from app.domain.models import AttributeStatement, Product +from app.scoring.engine import _product_eligibility +from app.scoring.mathutil import round_score +from app.scoring.product import score_product + + +def rank_candidates( + *, + profile, + scoring_model, + definitions, + candidates: list[dict[str, Any]], + shortlist_size: int, + as_of: datetime | None = None, +) -> list[dict[str, Any]]: + scored: list[dict[str, Any]] = [] + when = as_of or now_utc() + for candidate in candidates: + attrs = _statements(candidate, when) + snapshot = select_attribute_snapshot( + attrs, + as_of=when, + snapshot_cutoff=when, + ) + constraint_results = tuple( + evaluate_constraint( + constraint, + snapshot.get(constraint.attribute), + definitions[constraint.attribute], + scoring_model.minimum_confidence, + ) + for constraint in profile.constraints + if constraint.attribute in definitions + ) + product = Product( + id=uuid4(), + category=profile.category, + brand=str(candidate.get("manufacturer") or "unknown"), + name=str(candidate.get("source_title") or candidate.get("name") or "candidate"), + model=candidate.get("model"), + created_at=when, + ) + _gates, product_eligibility = _product_eligibility(profile, product, constraint_results) + product_score, coverage, _components = score_product( + profile.preferences, + snapshot, + constraint_results, + scoring_model, + ) + scored.append( + { + **candidate, + "product_eligibility": product_eligibility.value, + "product_score": round_score(product_score), + "product_coverage": coverage, + "eligible": product_eligibility == Eligibility.ELIGIBLE, + } + ) + scored.sort(key=lambda item: (not item["eligible"], -(item["product_score"] or Decimal("0")))) + for index, item in enumerate(scored): + item["candidate_status"] = "SHORTLISTED" if index < shortlist_size and item["eligible"] else "CANDIDATE" + item["rank"] = index + 1 + return scored + + +def _statements(candidate: dict[str, Any], when: datetime) -> tuple[AttributeStatement, ...]: + raw = candidate.get("normalized_attributes") or {} + now = when + items = [] + variant_id = candidate.get("variant_id") or uuid4() + for key, value in raw.items(): + items.append( + AttributeStatement( + id=uuid4(), + variant_id=variant_id if isinstance(variant_id, UUID) else uuid4(), + attribute=key, + raw_value=value, + normalized_value=value, + unit=None, + normalization_status=NormalizationStatus.NORMALIZED, + normalizer_version="discovery-v1", + confidence=Decimal("1"), + source_id="geizhals", + source_reference="discovery", + observed_at=now, + ingested_at=now, + ) + ) + return tuple(items) diff --git a/backend/app/discovery/urlgen.py b/backend/app/discovery/urlgen.py new file mode 100644 index 0000000..e7fa2d3 --- /dev/null +++ b/backend/app/discovery/urlgen.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +from typing import Any +from urllib.parse import parse_qsl, quote, urlencode, urljoin, urlsplit, urlunsplit + +from app.discovery.enums import MappingStatus + + +def generate_search_url( + *, + category_url: str, + criteria_items: list[dict[str, Any]], + mappings: list[dict[str, Any]], +) -> tuple[str, list[str]]: + """Build a source URL from VERIFIED mappings only. INTERNAL_ONLY keys are skipped.""" + by_criterion = {item["criterion_key"]: item for item in mappings if item.get("status") == MappingStatus.VERIFIED.value} + fragments: list[tuple[str, str]] = [] + internal_only: list[str] = [] + for criterion in criteria_items: + key = str(criterion["criterion"]) + mapping = by_criterion.get(key) + if mapping is None: + internal_only.append(key) + continue + fragments.extend(_encode_mapping(mapping, criterion)) + fragments.sort() + base = urlsplit(category_url) + query = dict(parse_qsl(base.query, keep_blank_values=True)) + extra: list[tuple[str, str]] = [] + for name, value in fragments: + if name in query and name != "xf": + query[name] = value + else: + extra.append((name, value)) + encoded = urlencode(list(query.items()) + extra, doseq=True, safe="+", quote_via=quote) + url = urlunsplit((base.scheme, base.netloc, base.path, encoded, base.fragment)) + return url, internal_only + + +def _encode_mapping(mapping: dict[str, Any], criterion: dict[str, Any]) -> list[tuple[str, str]]: + encoding = mapping.get("encoding") or {} + param = str(encoding.get("url_param") or "xf") + if param == "xf": + filter_key = str(mapping["external_filter_key"]) + source_value = str(mapping.get("source_value") or _threshold_value(mapping, criterion)) + return [("xf", f"{filter_key}_{_plus(source_value)}")] + if param in {"minp", "maxp", "prmin", "prmax"}: + return [(param, str(criterion.get("value")))] + template = encoding.get("value_template") + if template: + return [(param, str(template).format(value=criterion.get("value"), **mapping))] + raise ValueError(f"unsupported encoding for mapping {mapping.get('criterion_key')}") + + +def _threshold_value(mapping: dict[str, Any], criterion: dict[str, Any]) -> str: + option = mapping.get("source_value") + if option: + return str(option) + value = criterion.get("value") + unit = mapping.get("unit") or "" + operator = criterion.get("operator") + if operator in {">=", ">"} and unit: + return f"ab {value}{unit}" + return str(value) + + +def _plus(value: str) -> str: + return value.replace(" ", "+") + + +def decode_xf_roundtrip(url: str) -> list[tuple[str, str]]: + parts = urlsplit(url) + decoded: list[tuple[str, str]] = [] + for name, value in parse_qsl(parts.query, keep_blank_values=True): + if name == "xf" and "_" in value: + key, raw = value.split("_", 1) + decoded.append((key, raw.replace("+", " "))) + return decoded + + +def same_origin_url(base: str, maybe_relative: str) -> str: + return urljoin(base, maybe_relative) diff --git a/backend/app/domain/__init__.py b/backend/app/domain/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/domain/attributes.py b/backend/app/domain/attributes.py new file mode 100644 index 0000000..0a531fa --- /dev/null +++ b/backend/app/domain/attributes.py @@ -0,0 +1,45 @@ +from datetime import datetime +from decimal import Decimal +from typing import Iterable + +from app.domain.enums import NormalizationStatus +from app.domain.models import AttributeStatement + + +def statement_is_usable(statement: AttributeStatement | None, minimum_confidence: Decimal) -> bool: + if statement is None: + return False + if statement.normalization_status != NormalizationStatus.NORMALIZED: + return False + if statement.normalized_value is None: + return False + return statement.confidence >= minimum_confidence + + +def select_attribute_snapshot( + statements: Iterable[AttributeStatement], + *, + as_of: datetime, + snapshot_cutoff: datetime, +) -> dict[str, AttributeStatement]: + """Pick the newest statement per key at or before the evaluation cutoff.""" + chosen: dict[str, AttributeStatement] = {} + for statement in statements: + if statement.observed_at > as_of or statement.ingested_at > snapshot_cutoff: + continue + current = chosen.get(statement.attribute) + if current is None or _is_newer(statement, current): + chosen[statement.attribute] = statement + return chosen + + +def _is_newer(candidate: AttributeStatement, current: AttributeStatement) -> bool: + return ( + candidate.observed_at, + candidate.ingested_at, + candidate.id, + ) > ( + current.observed_at, + current.ingested_at, + current.id, + ) diff --git a/backend/app/domain/constraints.py b/backend/app/domain/constraints.py new file mode 100644 index 0000000..7439faf --- /dev/null +++ b/backend/app/domain/constraints.py @@ -0,0 +1,148 @@ +from decimal import Decimal +from typing import Any + +from app.domain.attributes import statement_is_usable +from app.domain.enums import ConstraintOperator, ConstraintStatus, ValueType +from app.domain.models import AttributeDefinition, AttributeStatement, Constraint, ConstraintResult + +_NUMBER_OPS = { + ConstraintOperator.GE, + ConstraintOperator.LE, + ConstraintOperator.GT, + ConstraintOperator.LT, +} +_EQUALITY_OPS = {ConstraintOperator.EQ, ConstraintOperator.NE} +_SCALAR_TYPES = {ValueType.NUMBER, ValueType.STRING, ValueType.BOOLEAN} + + +def operator_allowed_for(value_type: ValueType, operator: ConstraintOperator) -> bool: + if operator == ConstraintOperator.EXISTS: + return True + if operator in _NUMBER_OPS: + return value_type == ValueType.NUMBER + if operator in _EQUALITY_OPS: + return value_type in _SCALAR_TYPES + if operator == ConstraintOperator.IN: + return value_type in _SCALAR_TYPES + if operator == ConstraintOperator.CONTAINS: + return value_type == ValueType.STRING_LIST + return False + + +def evaluate_constraint( + constraint: Constraint, + statement: AttributeStatement | None, + definition: AttributeDefinition, + minimum_confidence: Decimal, +) -> ConstraintResult: + usable = statement_is_usable(statement, minimum_confidence) + actual = statement.normalized_value if usable else None + attribute_row_id = statement.id if statement is not None else None + + if not usable: + return ConstraintResult( + constraint_id=constraint.id, + attribute=constraint.attribute, + operator=constraint.operator.value, + expected=constraint.value, + actual=None, + status=ConstraintStatus.UNKNOWN.value, + mandatory=constraint.mandatory, + attribute_row_id=attribute_row_id, + reason_code="ATTRIBUTE_UNUSABLE", + ) + + passed = _compare(constraint.operator, actual, constraint.value, definition.value_type) + if passed is None: + status = ConstraintStatus.UNKNOWN + reason = "TYPE_MISMATCH" + elif passed: + status = ConstraintStatus.PASS + reason = "CONSTRAINT_PASS" + else: + status = ConstraintStatus.FAIL + reason = "CONSTRAINT_FAIL" + return ConstraintResult( + constraint_id=constraint.id, + attribute=constraint.attribute, + operator=constraint.operator.value, + expected=constraint.value, + actual=actual, + status=status.value, + mandatory=constraint.mandatory, + attribute_row_id=attribute_row_id, + reason_code=reason, + ) + + +def _as_number(value: Any) -> Decimal | None: + if isinstance(value, bool) or value is None: + return None + if isinstance(value, Decimal): + return value + if isinstance(value, int): + return Decimal(value) + if isinstance(value, float): + return Decimal(str(value)) + if isinstance(value, str): + return None + return None + + +def _same_scalar(left: Any, right: Any, value_type: ValueType) -> bool | None: + if value_type == ValueType.NUMBER: + left_n = _as_number(left) + right_n = _as_number(right) + if left_n is None or right_n is None: + return None + return left_n == right_n + if value_type == ValueType.STRING: + if not isinstance(left, str) or not isinstance(right, str): + return None + return left == right + if value_type == ValueType.BOOLEAN: + if not isinstance(left, bool) or not isinstance(right, bool): + return None + return left == right + return None + + +def _compare( + operator: ConstraintOperator, + actual: Any, + expected: Any, + value_type: ValueType, +) -> bool | None: + if operator == ConstraintOperator.EXISTS: + return True + if operator in _NUMBER_OPS: + actual_n = _as_number(actual) + expected_n = _as_number(expected) + if actual_n is None or expected_n is None: + return None + if operator == ConstraintOperator.GE: + return actual_n >= expected_n + if operator == ConstraintOperator.LE: + return actual_n <= expected_n + if operator == ConstraintOperator.GT: + return actual_n > expected_n + return actual_n < expected_n + if operator == ConstraintOperator.EQ: + return _same_scalar(actual, expected, value_type) + if operator == ConstraintOperator.NE: + equal = _same_scalar(actual, expected, value_type) + return None if equal is None else (not equal) + if operator == ConstraintOperator.IN: + if not isinstance(expected, list) or not expected: + return None + matches = [_same_scalar(actual, item, value_type) for item in expected] + if any(item is None for item in matches): + return None + return any(matches) + if operator == ConstraintOperator.CONTAINS: + if not isinstance(actual, list) or not isinstance(expected, str): + return None + if any(not isinstance(item, str) for item in actual): + return None + return expected in actual + return None diff --git a/backend/app/domain/enums.py b/backend/app/domain/enums.py new file mode 100644 index 0000000..544f277 --- /dev/null +++ b/backend/app/domain/enums.py @@ -0,0 +1,81 @@ +from enum import StrEnum + + +class ValueType(StrEnum): + NUMBER = "number" + STRING = "string" + BOOLEAN = "boolean" + STRING_LIST = "string_list" + + +class OfferCondition(StrEnum): + NEW = "new" + REFURBISHED = "refurbished" + USED = "used" + + +class Availability(StrEnum): + IN_STOCK = "in_stock" + OUT_OF_STOCK = "out_of_stock" + UNKNOWN = "unknown" + + +class NormalizationStatus(StrEnum): + NORMALIZED = "normalized" + UNKNOWN = "unknown" + + +class ConstraintStatus(StrEnum): + PASS = "PASS" + FAIL = "FAIL" + UNKNOWN = "UNKNOWN" + + +class Eligibility(StrEnum): + ELIGIBLE = "ELIGIBLE" + INELIGIBLE = "INELIGIBLE" + UNKNOWN = "UNKNOWN" + + +class PreferenceStrategy(StrEnum): + NUMERIC_LINEAR = "numeric_linear" + + +class PreferenceDirection(StrEnum): + HIGHER = "higher" + LOWER = "lower" + + +class ConstraintOperator(StrEnum): + GE = ">=" + LE = "<=" + GT = ">" + LT = "<" + EQ = "==" + NE = "!=" + IN = "in" + CONTAINS = "contains" + EXISTS = "exists" + + +class Classification(StrEnum): + IGNORE = "IGNORE" + WATCH = "WATCH" + GOOD = "GOOD" + VERY_GOOD = "VERY_GOOD" + EXCEPTIONAL = "EXCEPTIONAL" + INELIGIBLE = "INELIGIBLE" + INSUFFICIENT_DATA = "INSUFFICIENT_DATA" + + +class PriceBasis(StrEnum): + BUDGET_ONLY = "BUDGET_ONLY" + BUDGET_AND_HISTORY = "BUDGET_AND_HISTORY" + + +class PriceReferenceScope(StrEnum): + OFFER = "offer" + + +class AdapterType(StrEnum): + MANUAL = "manual" diff --git a/backend/app/domain/models.py b/backend/app/domain/models.py new file mode 100644 index 0000000..0301bf7 --- /dev/null +++ b/backend/app/domain/models.py @@ -0,0 +1,275 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime +from decimal import Decimal +from typing import Any +from uuid import UUID + +from app.domain.enums import ( + Availability, + ConstraintOperator, + Eligibility, + NormalizationStatus, + OfferCondition, + PreferenceDirection, + PreferenceStrategy, + PriceBasis, + PriceReferenceScope, + ValueType, +) + + +@dataclass(frozen=True) +class Budget: + target: Decimal + maximum: Decimal + + +@dataclass(frozen=True) +class Constraint: + id: str + attribute: str + operator: ConstraintOperator + value: Any = None + mandatory: bool = True + + +@dataclass(frozen=True) +class Preference: + id: str + attribute: str + strategy: PreferenceStrategy + direction: PreferenceDirection + acceptable: Decimal + ideal: Decimal + weight: Decimal + + +@dataclass(frozen=True) +class ClassificationBound: + upper: Decimal + inclusive: bool + label: str + + +@dataclass(frozen=True) +class ScoringModel: + version: str + algorithm_id: str + supported_preference_strategies: tuple[str, ...] + minimum_confidence: Decimal + maximum_price_age_hours: int + optional_constraint_penalty_points: Decimal + missing_preference_score: Decimal + product_weight: Decimal + price_weight: Decimal + budget_weight: Decimal + history_weight: Decimal + history_window_days: int + minimum_history_days: int + history_discount_for_max_score: Decimal + classification_upper_bounds: tuple[ClassificationBound, ...] + definition: dict[str, Any] + definition_hash: str + + +@dataclass(frozen=True) +class Profile: + id: str + version: int + name: str + category: str + currency: str + market: str + allowed_conditions: tuple[OfferCondition, ...] + budget: Budget + scoring_version: str + constraints: tuple[Constraint, ...] + preferences: tuple[Preference, ...] + definition: dict[str, Any] + definition_hash: str + + +@dataclass(frozen=True) +class AttributeDefinition: + key: str + value_type: ValueType + canonical_unit: str | None + minimum: Decimal | None + maximum: Decimal | None + description: str | None + + +@dataclass(frozen=True) +class Product: + id: UUID + category: str + brand: str + name: str + model: str | None + created_at: datetime + + +@dataclass(frozen=True) +class ProductVariant: + id: UUID + product_id: UUID + name: str + manufacturer_sku: str | None + identifiers: dict[str, Any] + created_at: datetime + + +@dataclass(frozen=True) +class AttributeStatement: + id: UUID + variant_id: UUID + attribute: str + raw_value: Any + normalized_value: Any + unit: str | None + normalization_status: NormalizationStatus + normalizer_version: str + confidence: Decimal + source_id: str + source_reference: str + observed_at: datetime + ingested_at: datetime + normalization_error: str | None = None + + +@dataclass(frozen=True) +class Offer: + id: UUID + variant_id: UUID + source_id: str + external_id: str + seller: str + url: str | None + market: str + currency: str + condition: OfferCondition + created_at: datetime + + +@dataclass(frozen=True) +class PriceObservation: + id: UUID + offer_id: UUID + source_id: str + external_observation_id: str + observed_at: datetime + ingested_at: datetime + item_price: Decimal + shipping_price: Decimal | None + currency: str + availability: Availability + raw_payload: Any + confidence: Decimal + + @property + def total_price(self) -> Decimal | None: + if self.shipping_price is None: + return None + return self.item_price + self.shipping_price + + +@dataclass(frozen=True) +class ConstraintResult: + constraint_id: str + attribute: str + operator: str + expected: Any + actual: Any + status: str + mandatory: bool + attribute_row_id: UUID | None + reason_code: str + + +@dataclass(frozen=True) +class GateResult: + key: str + status: str + reason_code: str + details: dict[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class ScoreComponent: + kind: str + component_key: str + raw_score: Decimal | None + weight: Decimal | None + contribution: Decimal | None + details: dict[str, Any] + + +@dataclass(frozen=True) +class HistoryWindow: + days: int + values: tuple[Decimal, ...] + observation_ids: tuple[UUID, ...] + + @property + def day_count(self) -> int: + return len(self.values) + + +@dataclass(frozen=True) +class PriceStatistics: + current_total: Decimal | None + median_30d: Decimal | None + median_30d_days: int + median_90d: Decimal | None + median_90d_days: int + lowest_365d: Decimal | None + lowest_365d_days: int + lowest_365d_is_full_year: bool + used_observation_ids: tuple[UUID, ...] + current_observation_id: UUID | None + price_reference_scope: PriceReferenceScope = PriceReferenceScope.OFFER + + +@dataclass(frozen=True) +class Reason: + code: str + message: str + + +@dataclass(frozen=True) +class EvaluationResult: + profile_id: str + profile_version: int + scoring_version: str + scoring_hash: str + engine_build: str + variant_id: UUID + offer_id: UUID + as_of: datetime + snapshot_cutoff: datetime + product_eligibility: Eligibility + offer_eligibility: Eligibility + overall_eligibility: Eligibility + eligible: bool | None + product_score: Decimal | None + product_score_raw: Decimal | None + price_score: Decimal | None + price_score_raw: Decimal | None + deal_score: Decimal | None + deal_score_raw: Decimal | None + classification: str + price_basis: PriceBasis | None + product_coverage: Decimal + constraint_results: tuple[ConstraintResult, ...] + product_gates: tuple[GateResult, ...] + offer_gates: tuple[GateResult, ...] + components: tuple[ScoreComponent, ...] + price_statistics: PriceStatistics + price_reference_scope: PriceReferenceScope + reasons: tuple[Reason, ...] + warnings: tuple[str, ...] + input_snapshot: dict[str, Any] + failed_constraints: tuple[ConstraintResult, ...] + unknown_constraints: tuple[ConstraintResult, ...] diff --git a/backend/app/domain/serialize.py b/backend/app/domain/serialize.py new file mode 100644 index 0000000..f4f894d --- /dev/null +++ b/backend/app/domain/serialize.py @@ -0,0 +1,26 @@ +from datetime import date, datetime +from decimal import Decimal +from typing import Any +from uuid import UUID + +from app.domain.timeutil import to_iso_z + + +def jsonable(value: Any) -> Any: + if value is None or isinstance(value, (str, int, bool)): + return value + if isinstance(value, Decimal): + return format(value, "f") + if isinstance(value, UUID): + return str(value) + if isinstance(value, datetime): + return to_iso_z(value) + if isinstance(value, date): + return value.isoformat() + if isinstance(value, dict): + return {str(key): jsonable(item) for key, item in value.items()} + if isinstance(value, (list, tuple)): + return [jsonable(item) for item in value] + if hasattr(value, "value"): + return jsonable(value.value) + return value diff --git a/backend/app/domain/timeutil.py b/backend/app/domain/timeutil.py new file mode 100644 index 0000000..31d1f60 --- /dev/null +++ b/backend/app/domain/timeutil.py @@ -0,0 +1,11 @@ +from datetime import datetime, timezone + + +def require_utc(value: datetime, field_name: str) -> datetime: + if value.tzinfo is None: + raise ValueError(f"{field_name} must include a timezone") + return value.astimezone(timezone.utc) + + +def to_iso_z(value: datetime) -> str: + return require_utc(value, "datetime").strftime("%Y-%m-%dT%H:%M:%SZ") diff --git a/backend/app/domain/validation.py b/backend/app/domain/validation.py new file mode 100644 index 0000000..fa16966 --- /dev/null +++ b/backend/app/domain/validation.py @@ -0,0 +1,307 @@ +from __future__ import annotations + +import hashlib +import json +from decimal import Decimal +from typing import Any + +from app.domain.constraints import operator_allowed_for +from app.domain.enums import ( + ConstraintOperator, + OfferCondition, + PreferenceDirection, + PreferenceStrategy, + ValueType, +) +from app.domain.models import ( + AttributeDefinition, + Budget, + ClassificationBound, + Constraint, + Preference, + Profile, + ScoringModel, +) + +SNAKE_CASE = r"^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$" +CURRENCY = r"^[A-Z]{3}$" +SUPPORTED_ALGORITHM = "linear-budget-history-v1" + + +class DomainValidationError(ValueError): + def __init__(self, message: str, details: dict[str, Any] | None = None) -> None: + super().__init__(message) + self.details = details or {} + + +def canonical_json(data: Any) -> str: + return json.dumps(data, sort_keys=True, separators=(",", ":"), ensure_ascii=False) + + +def sha256_text(text: str) -> str: + return hashlib.sha256(text.encode("utf-8")).hexdigest() + + +def _decimal(value: Any, field: str) -> Decimal: + if isinstance(value, bool) or value is None: + raise DomainValidationError(f"{field} must be a decimal", {"field": field}) + try: + return Decimal(str(value)) + except Exception as exc: + raise DomainValidationError(f"{field} must be a decimal", {"field": field}) from exc + + +def _require(mapping: dict[str, Any], key: str) -> Any: + if key not in mapping: + raise DomainValidationError(f"missing field {key}", {"field": key}) + return mapping[key] + + +def validate_attribute_key(key: str) -> None: + import re + + if not re.fullmatch(SNAKE_CASE, key): + raise DomainValidationError("attribute key must be snake_case", {"key": key}) + + +def validate_currency(value: str) -> str: + import re + + if not re.fullmatch(CURRENCY, value): + raise DomainValidationError("currency must be a 3-letter uppercase code", {"currency": value}) + return value + + +def parse_scoring_model(definition: dict[str, Any]) -> ScoringModel: + version = str(_require(definition, "version")) + algorithm_id = str(_require(definition, "algorithm_id")) + if algorithm_id != SUPPORTED_ALGORITHM: + raise DomainValidationError("unsupported algorithm_id", {"algorithm_id": algorithm_id}) + strategies = tuple(str(item) for item in _require(definition, "supported_preference_strategies")) + if not strategies: + raise DomainValidationError("supported_preference_strategies must not be empty") + if PreferenceStrategy.NUMERIC_LINEAR.value not in strategies: + raise DomainValidationError("generic-v1 must support numeric_linear") + + min_conf = _decimal(_require(definition, "minimum_confidence"), "minimum_confidence") + if not Decimal("0") <= min_conf <= Decimal("1"): + raise DomainValidationError("minimum_confidence must be between 0 and 1") + + max_age = int(_require(definition, "maximum_price_age_hours")) + history_window = int(_require(definition, "history_window_days")) + min_history = int(_require(definition, "minimum_history_days")) + if max_age <= 0 or history_window <= 0 or min_history <= 0: + raise DomainValidationError("time windows must be positive") + + product_weight = _decimal(_require(definition, "product_weight"), "product_weight") + price_weight = _decimal(_require(definition, "price_weight"), "price_weight") + budget_weight = _decimal(_require(definition, "budget_weight"), "budget_weight") + history_weight = _decimal(_require(definition, "history_weight"), "history_weight") + for name, weight in ( + ("product_weight", product_weight), + ("price_weight", price_weight), + ("budget_weight", budget_weight), + ("history_weight", history_weight), + ): + if weight < 0: + raise DomainValidationError(f"{name} must be non-negative") + if product_weight + price_weight != Decimal("1"): + raise DomainValidationError("product_weight + price_weight must equal 1") + if budget_weight + history_weight != Decimal("1"): + raise DomainValidationError("budget_weight + history_weight must equal 1") + + bounds_raw = _require(definition, "classification_upper_bounds") + bounds: list[ClassificationBound] = [] + previous = Decimal("0") + for index, raw in enumerate(bounds_raw): + upper = _decimal(raw["upper"], f"classification_upper_bounds[{index}].upper") + inclusive = bool(raw["inclusive"]) + label = str(raw["label"]) + if upper < previous or (index and upper == previous): + raise DomainValidationError("classification bounds must be strictly ascending") + if index == 0 and upper <= 0: + raise DomainValidationError("classification bounds must start above 0") + bounds.append(ClassificationBound(upper=upper, inclusive=inclusive, label=label)) + previous = upper + if not bounds or bounds[-1].upper != Decimal("100"): + raise DomainValidationError("classification bounds must end at 100") + + canonical = json.loads(canonical_json(definition)) + return ScoringModel( + version=version, + algorithm_id=algorithm_id, + supported_preference_strategies=strategies, + minimum_confidence=min_conf, + maximum_price_age_hours=max_age, + optional_constraint_penalty_points=_decimal( + _require(definition, "optional_constraint_penalty_points"), + "optional_constraint_penalty_points", + ), + missing_preference_score=_decimal( + _require(definition, "missing_preference_score"), + "missing_preference_score", + ), + product_weight=product_weight, + price_weight=price_weight, + budget_weight=budget_weight, + history_weight=history_weight, + history_window_days=history_window, + minimum_history_days=min_history, + history_discount_for_max_score=_decimal( + _require(definition, "history_discount_for_max_score"), + "history_discount_for_max_score", + ), + classification_upper_bounds=tuple(bounds), + definition=canonical, + definition_hash=sha256_text(canonical_json(canonical)), + ) + + +def parse_profile( + definition: dict[str, Any], + *, + attribute_definitions: dict[str, AttributeDefinition], + scoring_versions: set[str], + supported_strategies: set[str], +) -> Profile: + profile_id = str(_require(definition, "id")) + version = int(_require(definition, "version")) + if version <= 0: + raise DomainValidationError("profile version must be > 0") + name = str(_require(definition, "name")) + category = str(_require(definition, "category")) + if not category: + raise DomainValidationError("category is required") + currency = validate_currency(str(_require(definition, "currency"))) + market = str(_require(definition, "market")) + if not market: + raise DomainValidationError("market is required") + raw_conditions = _require(definition, "allowed_conditions") + if not raw_conditions: + raise DomainValidationError("allowed_conditions is required") + conditions = tuple(OfferCondition(item) for item in raw_conditions) + scoring_version = str(_require(definition, "scoring_version")) + if scoring_version not in scoring_versions: + raise DomainValidationError("unknown scoring_version", {"scoring_version": scoring_version}) + + budget_raw = _require(definition, "budget") + target = _decimal(_require(budget_raw, "target"), "budget.target") + maximum = _decimal(_require(budget_raw, "maximum"), "budget.maximum") + if not (Decimal("0") < target < maximum): + raise DomainValidationError("budget must satisfy 0 < target < maximum") + + constraints = _parse_constraints(_require(definition, "constraints"), attribute_definitions) + preferences = _parse_preferences( + _require(definition, "preferences"), + attribute_definitions, + supported_strategies, + ) + canonical = json.loads(canonical_json(definition)) + return Profile( + id=profile_id, + version=version, + name=name, + category=category, + currency=currency, + market=market, + allowed_conditions=conditions, + budget=Budget(target=target, maximum=maximum), + scoring_version=scoring_version, + constraints=constraints, + preferences=preferences, + definition=canonical, + definition_hash=sha256_text(canonical_json(canonical)), + ) + + +def _parse_constraints( + raw_items: list[dict[str, Any]], + attribute_definitions: dict[str, AttributeDefinition], +) -> tuple[Constraint, ...]: + seen: set[str] = set() + items: list[Constraint] = [] + for raw in raw_items: + constraint_id = str(_require(raw, "id")) + if constraint_id in seen: + raise DomainValidationError("duplicate constraint id", {"id": constraint_id}) + seen.add(constraint_id) + attribute = str(_require(raw, "attribute")) + definition = attribute_definitions.get(attribute) + if definition is None: + raise DomainValidationError("unknown attribute key", {"attribute": attribute}) + operator = ConstraintOperator(str(_require(raw, "operator"))) + if not operator_allowed_for(definition.value_type, operator): + raise DomainValidationError( + "operator is not valid for attribute type", + {"attribute": attribute, "operator": operator.value, "value_type": definition.value_type.value}, + ) + value = raw.get("value") + if operator == ConstraintOperator.EXISTS: + if "value" in raw and raw["value"] is not None: + raise DomainValidationError("exists must not have a value", {"id": constraint_id}) + value = None + elif operator == ConstraintOperator.IN: + if not isinstance(value, list) or not value: + raise DomainValidationError("in requires a non-empty list", {"id": constraint_id}) + elif operator == ConstraintOperator.CONTAINS: + if not isinstance(value, str): + raise DomainValidationError("contains requires a string", {"id": constraint_id}) + items.append( + Constraint( + id=constraint_id, + attribute=attribute, + operator=operator, + value=value, + mandatory=bool(raw.get("mandatory", True)), + ) + ) + return tuple(items) + + +def _parse_preferences( + raw_items: list[dict[str, Any]], + attribute_definitions: dict[str, AttributeDefinition], + supported_strategies: set[str], +) -> tuple[Preference, ...]: + if not raw_items: + raise DomainValidationError("at least one preference is required") + seen: set[str] = set() + items: list[Preference] = [] + for raw in raw_items: + preference_id = str(_require(raw, "id")) + if preference_id in seen: + raise DomainValidationError("duplicate preference id", {"id": preference_id}) + seen.add(preference_id) + attribute = str(_require(raw, "attribute")) + definition = attribute_definitions.get(attribute) + if definition is None: + raise DomainValidationError("unknown attribute key", {"attribute": attribute}) + strategy = str(_require(raw, "strategy")) + if strategy not in supported_strategies: + raise DomainValidationError("unsupported preference strategy", {"strategy": strategy}) + if strategy != PreferenceStrategy.NUMERIC_LINEAR.value: + raise DomainValidationError("v0.1 only supports numeric_linear", {"strategy": strategy}) + if definition.value_type != ValueType.NUMBER: + raise DomainValidationError("numeric_linear requires a number attribute", {"attribute": attribute}) + direction = PreferenceDirection(str(_require(raw, "direction"))) + acceptable = _decimal(_require(raw, "acceptable"), "acceptable") + ideal = _decimal(_require(raw, "ideal"), "ideal") + weight = _decimal(_require(raw, "weight"), "weight") + if weight <= 0: + raise DomainValidationError("preference weight must be positive", {"id": preference_id}) + if direction == PreferenceDirection.HIGHER and not ideal > acceptable: + raise DomainValidationError("higher requires ideal > acceptable", {"id": preference_id}) + if direction == PreferenceDirection.LOWER and not ideal < acceptable: + raise DomainValidationError("lower requires ideal < acceptable", {"id": preference_id}) + items.append( + Preference( + id=preference_id, + attribute=attribute, + strategy=PreferenceStrategy.NUMERIC_LINEAR, + direction=direction, + acceptable=acceptable, + ideal=ideal, + weight=weight, + ) + ) + return tuple(items) diff --git a/backend/app/errors.py b/backend/app/errors.py new file mode 100644 index 0000000..4bb0aa5 --- /dev/null +++ b/backend/app/errors.py @@ -0,0 +1,20 @@ +from typing import Any + + +class AppError(Exception): + def __init__( + self, + status_code: int, + code: str, + message: str, + details: dict[str, Any] | None = None, + ) -> None: + super().__init__(message) + self.status_code = status_code + self.code = code + self.message = message + self.details = details or {} + + +def error_body(code: str, message: str, details: dict[str, Any] | None = None) -> dict[str, Any]: + return {"error": {"code": code, "message": message, "details": details or {}}} diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..7daca4e --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,48 @@ +from fastapi import FastAPI, Request +from fastapi.exceptions import RequestValidationError +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse + +from app.api.discovery import router as discovery_router +from app.api.evaluations import router as evaluations_router +from app.api.health import router as health_router +from app.api.offers import router as offers_router +from app.api.products import router as products_router +from app.api.profiles import router as profiles_router +from app.api.projects import router as projects_router +from app.errors import AppError, error_body +from app.settings import get_settings + +settings = get_settings() +app = FastAPI( + title="Buying Agent", + version="0.1.0", + description="Generic buying and deal evaluation API. The backend is the system of record; no frontend or n8n is required.", +) +app.add_middleware( + CORSMiddleware, + allow_origins=settings.cors_origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) +app.include_router(health_router) +app.include_router(profiles_router) +app.include_router(products_router) +app.include_router(offers_router) +app.include_router(evaluations_router) +app.include_router(projects_router) +app.include_router(discovery_router) + + +@app.exception_handler(AppError) +def handle_app_error(_request: Request, exc: AppError) -> JSONResponse: + return JSONResponse(error_body(exc.code, exc.message, exc.details), status_code=exc.status_code) + + +@app.exception_handler(RequestValidationError) +def handle_validation_error(_request: Request, exc: RequestValidationError) -> JSONResponse: + return JSONResponse( + error_body("VALIDATION_ERROR", "request validation failed", {"errors": exc.errors()}), + status_code=422, + ) diff --git a/backend/app/normalization/__init__.py b/backend/app/normalization/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/normalization/base.py b/backend/app/normalization/base.py new file mode 100644 index 0000000..0970977 --- /dev/null +++ b/backend/app/normalization/base.py @@ -0,0 +1,11 @@ +from typing import Protocol + +from app.domain.models import AttributeDefinition, AttributeStatement + + +class Normalizer(Protocol): + version: str + + def validate(self, definition: AttributeDefinition, statement: AttributeStatement) -> AttributeStatement: + """Validate an already normalized statement. Must not parse free text.""" + ... diff --git a/backend/app/normalization/manual.py b/backend/app/normalization/manual.py new file mode 100644 index 0000000..f4eebf9 --- /dev/null +++ b/backend/app/normalization/manual.py @@ -0,0 +1,61 @@ +from decimal import Decimal +from typing import Any + +from app.domain.enums import NormalizationStatus, ValueType +from app.domain.models import AttributeDefinition, AttributeStatement +from app.errors import AppError + + +class ManualNormalizer: + version = "manual-v1" + + def validate(self, definition: AttributeDefinition, statement: AttributeStatement) -> AttributeStatement: + if statement.normalization_status == NormalizationStatus.UNKNOWN: + if statement.normalized_value is not None: + raise AppError( + 422, + "NORMALIZATION_INCONSISTENT", + "unknown statements must not have a normalized_value", + ) + return statement + if statement.normalization_status != NormalizationStatus.NORMALIZED: + raise AppError(422, "NORMALIZATION_STATUS", "unsupported normalization_status") + if statement.normalized_value is None: + raise AppError(422, "NORMALIZATION_MISSING", "normalized_value is required") + _assert_type(definition, statement.normalized_value) + if definition.value_type == ValueType.NUMBER: + number = Decimal(str(statement.normalized_value)) + if definition.key == "weight_kg" and number <= 0: + raise AppError(422, "VALUE_RANGE", "weight_kg must be > 0") + if definition.minimum is not None and number < definition.minimum: + raise AppError(422, "VALUE_RANGE", f"{definition.key} is below the minimum") + if definition.maximum is not None and number > definition.maximum: + raise AppError(422, "VALUE_RANGE", f"{definition.key} is above the maximum") + if definition.canonical_unit and statement.unit != definition.canonical_unit: + raise AppError( + 422, + "UNIT_MISMATCH", + "unit must match the canonical unit", + {"expected": definition.canonical_unit, "actual": statement.unit}, + ) + if definition.canonical_unit is None and statement.unit not in {None, ""}: + raise AppError(422, "UNIT_MISMATCH", "this attribute has no canonical unit") + return statement + + +def _assert_type(definition: AttributeDefinition, value: Any) -> None: + if definition.value_type == ValueType.NUMBER: + if isinstance(value, bool) or not isinstance(value, (int, float, Decimal)): + raise AppError(422, "TYPE_MISMATCH", "normalized_value must be a number") + return + if definition.value_type == ValueType.STRING: + if not isinstance(value, str): + raise AppError(422, "TYPE_MISMATCH", "normalized_value must be a string") + return + if definition.value_type == ValueType.BOOLEAN: + if not isinstance(value, bool): + raise AppError(422, "TYPE_MISMATCH", "normalized_value must be a boolean") + return + if definition.value_type == ValueType.STRING_LIST: + if not isinstance(value, list) or any(not isinstance(item, str) for item in value): + raise AppError(422, "TYPE_MISMATCH", "normalized_value must be a list of strings") diff --git a/backend/app/persistence/__init__.py b/backend/app/persistence/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/persistence/db.py b/backend/app/persistence/db.py similarity index 84% rename from app/persistence/db.py rename to backend/app/persistence/db.py index 05f09b0..c1bcb30 100644 --- a/app/persistence/db.py +++ b/backend/app/persistence/db.py @@ -14,5 +14,9 @@ def get_session() -> Generator[Session, None, None]: session = SessionLocal() try: yield session + session.commit() + except Exception: + session.rollback() + raise finally: session.close() diff --git a/backend/app/persistence/discovery_models.py b/backend/app/persistence/discovery_models.py new file mode 100644 index 0000000..06b03c8 --- /dev/null +++ b/backend/app/persistence/discovery_models.py @@ -0,0 +1,321 @@ +from datetime import datetime +from decimal import Decimal +from typing import Any +from uuid import UUID + +from sqlalchemy import Boolean, DateTime, ForeignKey, ForeignKeyConstraint, Integer, Numeric, String, Text, UniqueConstraint +from sqlalchemy.dialects.postgresql import JSONB, UUID as PGUUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.persistence.models import Base + + +class ProductCategoryRecord(Base): + __tablename__ = "product_categories" + + id: Mapped[str] = mapped_column(String(64), primary_key=True) + name: Mapped[str] = mapped_column(String(256), nullable=False) + status: Mapped[str] = mapped_column(String(32), nullable=False, default="ACTIVE") + + +class BuyingProjectRecord(Base): + __tablename__ = "buying_projects" + __table_args__ = ( + ForeignKeyConstraint( + ["template_id", "template_version"], + ["product_profiles.id", "product_profiles.version"], + name="fk_buying_projects_template", + ), + ) + + id: Mapped[UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True) + name: Mapped[str] = mapped_column(String(256), nullable=False) + status: Mapped[str] = mapped_column(String(32), nullable=False) + workspace_id: Mapped[str | None] = mapped_column(String(64)) + product_category_id: Mapped[str] = mapped_column(ForeignKey("product_categories.id"), nullable=False) + market: Mapped[str] = mapped_column(String(8), nullable=False) + currency: Mapped[str] = mapped_column(String(3), nullable=False) + target_budget: Mapped[Decimal | None] = mapped_column(Numeric(12, 2)) + template_id: Mapped[str | None] = mapped_column(String(128)) + template_version: Mapped[int | None] = mapped_column(Integer) + shortlist_size: Mapped[int] = mapped_column(Integer, nullable=False, default=5) + criteria_version: Mapped[int] = mapped_column(Integer, nullable=False, default=1) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + archived_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + + @property + def profile_id(self) -> str | None: + return self.template_id + + @property + def profile_version(self) -> int | None: + return self.template_version + + +class BuyingProjectCriteriaRecord(Base): + __tablename__ = "buying_project_criteria" + + project_id: Mapped[UUID] = mapped_column(ForeignKey("buying_projects.id"), primary_key=True) + version: Mapped[int] = mapped_column(Integer, primary_key=True) + definition_hash: Mapped[str] = mapped_column(String(64), nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + + +class ProjectCriterionRecord(Base): + __tablename__ = "project_criteria" + __table_args__ = ( + ForeignKeyConstraint( + ["project_id", "version"], + ["buying_project_criteria.project_id", "buying_project_criteria.version"], + name="fk_project_criteria_version", + ), + UniqueConstraint("project_id", "version", "item_id", name="uq_project_criterion_item"), + ) + + id: Mapped[UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True) + project_id: Mapped[UUID] = mapped_column(nullable=False) + version: Mapped[int] = mapped_column(Integer, nullable=False) + item_id: Mapped[str] = mapped_column(String(128), nullable=False) + criterion_definition_id: Mapped[UUID] = mapped_column(ForeignKey("criterion_definitions.id"), nullable=False) + criterion_key: Mapped[str | None] = mapped_column(String(128)) + operator: Mapped[str] = mapped_column(String(16), nullable=False) + value: Mapped[Any] = mapped_column(JSONB) + enabled_for_discovery: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + + @property + def mandatory(self) -> bool: + return self.enabled_for_discovery + + @property + def criteria_version(self) -> int: + return self.version + + +class SourceCategoryRecord(Base): + __tablename__ = "source_categories" + __table_args__ = (UniqueConstraint("source_id", "external_category_key", name="uq_source_category"),) + + id: Mapped[UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True) + source_id: Mapped[str] = mapped_column(ForeignKey("sources.id"), nullable=False) + external_category_key: Mapped[str] = mapped_column(String(64), nullable=False) + name: Mapped[str] = mapped_column(String(256), nullable=False) + source_url: Mapped[str] = mapped_column(Text, nullable=False) + product_category_id: Mapped[str | None] = mapped_column(ForeignKey("product_categories.id")) + canonical_category: Mapped[str | None] = mapped_column(String(128)) + status: Mapped[str] = mapped_column(String(32), nullable=False) + last_verified_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + + +class SourceFilterDefinitionRecord(Base): + __tablename__ = "source_filter_definitions" + __table_args__ = (UniqueConstraint("source_category_id", "external_filter_key", name="uq_source_filter"),) + + id: Mapped[UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True) + source_category_id: Mapped[UUID] = mapped_column(ForeignKey("source_categories.id"), nullable=False) + external_filter_key: Mapped[str] = mapped_column(String(64), nullable=False) + label: Mapped[str] = mapped_column(String(256), nullable=False) + filter_type: Mapped[str] = mapped_column(String(32), nullable=False) + canonical_unit: Mapped[str | None] = mapped_column(String(32)) + status: Mapped[str] = mapped_column(String(32), nullable=False) + + +class SourceFilterOptionRecord(Base): + __tablename__ = "source_filter_options" + __table_args__ = (UniqueConstraint("filter_id", "source_value", name="uq_filter_option"),) + + id: Mapped[UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True) + filter_id: Mapped[UUID] = mapped_column(ForeignKey("source_filter_definitions.id"), nullable=False) + source_value: Mapped[str] = mapped_column(String(256), nullable=False) + normalized_value: Mapped[Any] = mapped_column(JSONB) + operator: Mapped[str | None] = mapped_column(String(16)) + unit: Mapped[str | None] = mapped_column(String(32)) + + +class SourceFilterRangeRecord(Base): + __tablename__ = "source_filter_ranges" + + filter_id: Mapped[UUID] = mapped_column(ForeignKey("source_filter_definitions.id"), primary_key=True) + minimum: Mapped[Decimal | None] = mapped_column(Numeric(12, 2)) + maximum: Mapped[Decimal | None] = mapped_column(Numeric(12, 2)) + step: Mapped[Decimal | None] = mapped_column(Numeric(12, 2)) + unit: Mapped[str | None] = mapped_column(String(32)) + + +class CriterionDefinitionRecord(Base): + __tablename__ = "criterion_definitions" + __table_args__ = (UniqueConstraint("key", name="uq_criterion_definition_key"),) + + id: Mapped[UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True) + key: Mapped[str] = mapped_column(String(128), nullable=False) + origin: Mapped[str] = mapped_column(String(16), nullable=False, default="SOURCE") + source: Mapped[str | None] = mapped_column(String(64)) + product_category_id: Mapped[str | None] = mapped_column(ForeignKey("product_categories.id")) + label: Mapped[str] = mapped_column(String(256), nullable=False) + value_type: Mapped[str] = mapped_column(String(32), nullable=False) + unit: Mapped[str | None] = mapped_column(String(32)) + semantic: Mapped[str | None] = mapped_column(String(128)) + category: Mapped[str | None] = mapped_column(String(128)) + description: Mapped[str | None] = mapped_column(Text) + scoring_attribute_key: Mapped[str | None] = mapped_column(String(128)) + + +class SourceAttributeMappingRecord(Base): + __tablename__ = "source_attribute_mappings" + + id: Mapped[UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True) + source_id: Mapped[str] = mapped_column(ForeignKey("sources.id"), nullable=False) + source_category_id: Mapped[UUID | None] = mapped_column(ForeignKey("source_categories.id")) + raw_name: Mapped[str] = mapped_column(String(256), nullable=False) + criterion_definition_id: Mapped[UUID] = mapped_column(ForeignKey("criterion_definitions.id"), nullable=False) + status: Mapped[str] = mapped_column(String(32), nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + + +class CriterionMappingRecord(Base): + __tablename__ = "criterion_mappings" + + id: Mapped[UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True) + criterion_definition_id: Mapped[UUID] = mapped_column(ForeignKey("criterion_definitions.id"), nullable=False) + criterion_key: Mapped[str] = mapped_column(String(128), nullable=False) + source_id: Mapped[str] = mapped_column(ForeignKey("sources.id"), nullable=False) + source_category_id: Mapped[UUID] = mapped_column(ForeignKey("source_categories.id"), nullable=False) + external_filter_key: Mapped[str] = mapped_column(String(64), nullable=False) + source_value: Mapped[str | None] = mapped_column(String(256)) + encoding: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False) + status: Mapped[str] = mapped_column(String(32), nullable=False) + definition_hash: Mapped[str] = mapped_column(String(64), nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + + +class DiscoverySearchRecord(Base): + __tablename__ = "discovery_searches" + + id: Mapped[UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True) + buying_project_id: Mapped[UUID] = mapped_column(ForeignKey("buying_projects.id"), nullable=False) + source_id: Mapped[str] = mapped_column(ForeignKey("sources.id"), nullable=False) + source_category_id: Mapped[UUID] = mapped_column(ForeignKey("source_categories.id"), nullable=False) + criteria_version: Mapped[int] = mapped_column(Integer, nullable=False) + mapping_version: Mapped[str] = mapped_column(String(64), nullable=False) + generated_url: Mapped[str] = mapped_column(Text, nullable=False) + status: Mapped[str] = mapped_column(String(32), nullable=False) + schedule_interval_minutes: Mapped[int] = mapped_column(Integer, nullable=False, default=180) + broad_result_threshold: Mapped[int] = mapped_column(Integer, nullable=False, default=300) + next_run_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + last_successful_run_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + + +class SearchRunRecord(Base): + __tablename__ = "search_runs" + + id: Mapped[UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True) + discovery_search_id: Mapped[UUID] = mapped_column(ForeignKey("discovery_searches.id"), nullable=False) + criteria_version: Mapped[int] = mapped_column(Integer, nullable=False) + mapping_version: Mapped[str] = mapped_column(String(64), nullable=False) + generated_url: Mapped[str] = mapped_column(Text, nullable=False) + started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + status: Mapped[str] = mapped_column(String(32), nullable=False) + result_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + page_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + new_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + changed_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + unchanged_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + missing_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + http_status_summary: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict) + error_code: Mapped[str | None] = mapped_column(String(64)) + + +class SourceProductReferenceRecord(Base): + __tablename__ = "source_product_references" + + id: Mapped[UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True) + source_id: Mapped[str] = mapped_column(ForeignKey("sources.id"), nullable=False) + source_category_id: Mapped[UUID] = mapped_column(ForeignKey("source_categories.id"), nullable=False) + source_external_id: Mapped[str | None] = mapped_column(String(256)) + source_url: Mapped[str | None] = mapped_column(Text) + source_title: Mapped[str] = mapped_column(String(512), nullable=False) + manufacturer: Mapped[str | None] = mapped_column(String(256)) + model: Mapped[str | None] = mapped_column(String(256)) + manufacturer_sku: Mapped[str | None] = mapped_column(String(128)) + gtin_ean: Mapped[str | None] = mapped_column(String(32)) + normalized_spec_fingerprint: Mapped[str | None] = mapped_column(Text) + first_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + last_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + lifecycle_status: Mapped[str] = mapped_column(String(32), nullable=False) + match_status: Mapped[str] = mapped_column(String(32), nullable=False) + consecutive_missing_success_runs: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + product_variant_id: Mapped[UUID | None] = mapped_column(ForeignKey("product_variants.id")) + + +class ProductObservationRecord(Base): + __tablename__ = "product_observations" + + id: Mapped[UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True) + source_product_reference_id: Mapped[UUID] = mapped_column(ForeignKey("source_product_references.id"), nullable=False) + search_run_id: Mapped[UUID] = mapped_column(ForeignKey("search_runs.id"), nullable=False) + observed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + raw_title: Mapped[str] = mapped_column(String(512), nullable=False) + best_price: Mapped[Decimal | None] = mapped_column(Numeric(12, 2)) + currency: Mapped[str | None] = mapped_column(String(3)) + availability: Mapped[str | None] = mapped_column(String(32)) + seller_count: Mapped[int | None] = mapped_column(Integer) + + +class ProductObservationAttributeRecord(Base): + __tablename__ = "product_observation_attributes" + __table_args__ = (UniqueConstraint("observation_id", "attribute_key", name="uq_observation_attribute"),) + + id: Mapped[UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True) + observation_id: Mapped[UUID] = mapped_column(ForeignKey("product_observations.id"), nullable=False) + criterion_definition_id: Mapped[UUID | None] = mapped_column(ForeignKey("criterion_definitions.id")) + attribute_key: Mapped[str] = mapped_column(String(128), nullable=False) + raw_name: Mapped[str | None] = mapped_column(String(256)) + raw_value: Mapped[Any] = mapped_column(JSONB) + normalized_value: Mapped[Any] = mapped_column(JSONB) + unit: Mapped[str | None] = mapped_column(String(32)) + + +class SearchRunItemRecord(Base): + __tablename__ = "search_run_items" + __table_args__ = (UniqueConstraint("search_run_id", "source_product_reference_id", name="uq_run_item"),) + + id: Mapped[UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True) + search_run_id: Mapped[UUID] = mapped_column(ForeignKey("search_runs.id"), nullable=False) + source_product_reference_id: Mapped[UUID] = mapped_column(ForeignKey("source_product_references.id"), nullable=False) + observation_id: Mapped[UUID | None] = mapped_column(ForeignKey("product_observations.id")) + diff_status: Mapped[str] = mapped_column(String(32), nullable=False) + + +class ProjectCandidateRecord(Base): + __tablename__ = "project_candidates" + __table_args__ = ( + UniqueConstraint("buying_project_id", "source_product_reference_id", name="uq_project_candidate"), + ) + + id: Mapped[UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True) + buying_project_id: Mapped[UUID] = mapped_column(ForeignKey("buying_projects.id"), nullable=False) + source_product_reference_id: Mapped[UUID] = mapped_column(ForeignKey("source_product_references.id"), nullable=False) + product_variant_id: Mapped[UUID | None] = mapped_column(ForeignKey("product_variants.id")) + status: Mapped[str] = mapped_column(String(32), nullable=False) + match_status: Mapped[str] = mapped_column(String(32), nullable=False) + product_eligibility: Mapped[str | None] = mapped_column(String(16)) + product_score: Mapped[Decimal | None] = mapped_column(Numeric(6, 2)) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + + +class CandidateStatusHistoryRecord(Base): + __tablename__ = "candidate_status_history" + + id: Mapped[UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True) + candidate_id: Mapped[UUID] = mapped_column(ForeignKey("project_candidates.id"), nullable=False) + from_status: Mapped[str | None] = mapped_column(String(32)) + to_status: Mapped[str] = mapped_column(String(32), nullable=False) + reason: Mapped[str | None] = mapped_column(String(128)) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) diff --git a/backend/app/persistence/mapping.py b/backend/app/persistence/mapping.py new file mode 100644 index 0000000..b0b7f17 --- /dev/null +++ b/backend/app/persistence/mapping.py @@ -0,0 +1,105 @@ +from decimal import Decimal + +from app.domain.enums import ( + Availability, + NormalizationStatus, + OfferCondition, + ValueType, +) +from app.domain.models import ( + AttributeDefinition, + AttributeStatement, + Offer, + PriceObservation, + Product, + ProductVariant, +) +from app.persistence import models as orm + + +def decimal_or_none(value) -> Decimal | None: + return None if value is None else Decimal(str(value)) + + +def to_definition(row: orm.AttributeDefinitionRecord) -> AttributeDefinition: + return AttributeDefinition( + key=row.key, + value_type=ValueType(row.value_type), + canonical_unit=row.canonical_unit, + minimum=decimal_or_none(row.minimum), + maximum=decimal_or_none(row.maximum), + description=row.description, + ) + + +def to_product(row: orm.ProductRecord) -> Product: + return Product( + id=row.id, + category=row.category, + brand=row.brand, + name=row.name, + model=row.model, + created_at=row.created_at, + ) + + +def to_variant(row: orm.ProductVariantRecord) -> ProductVariant: + return ProductVariant( + id=row.id, + product_id=row.product_id, + name=row.name, + manufacturer_sku=row.manufacturer_sku, + identifiers=row.identifiers or {}, + created_at=row.created_at, + ) + + +def to_attribute(row: orm.VariantAttributeRecord) -> AttributeStatement: + return AttributeStatement( + id=row.id, + variant_id=row.variant_id, + attribute=row.attribute_key, + raw_value=row.raw_value, + normalized_value=row.normalized_value, + unit=row.unit, + normalization_status=NormalizationStatus(row.normalization_status), + normalizer_version=row.normalizer_version, + confidence=Decimal(str(row.confidence)), + source_id=row.source_id, + source_reference=row.source_reference, + observed_at=row.observed_at, + ingested_at=row.ingested_at, + normalization_error=row.normalization_error, + ) + + +def to_offer(row: orm.OfferRecord) -> Offer: + return Offer( + id=row.id, + variant_id=row.variant_id, + source_id=row.source_id, + external_id=row.external_id, + seller=row.seller, + url=row.url, + market=row.market, + currency=row.currency, + condition=OfferCondition(row.condition), + created_at=row.created_at, + ) + + +def to_observation(row: orm.PriceObservationRecord) -> PriceObservation: + return PriceObservation( + id=row.id, + offer_id=row.offer_id, + source_id=row.source_id, + external_observation_id=row.external_observation_id, + observed_at=row.observed_at, + ingested_at=row.ingested_at, + item_price=Decimal(str(row.item_price)), + shipping_price=decimal_or_none(row.shipping_price), + currency=row.currency, + availability=Availability(row.availability), + raw_payload=row.raw_payload, + confidence=Decimal(str(row.confidence)), + ) diff --git a/backend/app/persistence/models.py b/backend/app/persistence/models.py new file mode 100644 index 0000000..aeee5de --- /dev/null +++ b/backend/app/persistence/models.py @@ -0,0 +1,249 @@ +from datetime import datetime +from decimal import Decimal +from typing import Any +from uuid import UUID + +from sqlalchemy import ( + CheckConstraint, + DateTime, + ForeignKey, + ForeignKeyConstraint, + Numeric, + String, + Text, + UniqueConstraint, +) +from sqlalchemy.dialects.postgresql import JSONB, UUID as PGUUID +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship + + +class Base(DeclarativeBase): + pass + + +class AttributeDefinitionRecord(Base): + __tablename__ = "attribute_definitions" + __table_args__ = ( + CheckConstraint( + "value_type IN ('number', 'string', 'boolean', 'string_list')", + name="ck_attribute_definitions_value_type", + ), + ) + + key: Mapped[str] = mapped_column(String(128), primary_key=True) + value_type: Mapped[str] = mapped_column(String(32), nullable=False) + canonical_unit: Mapped[str | None] = mapped_column(String(32)) + minimum: Mapped[Decimal | None] = mapped_column(Numeric(12, 4)) + maximum: Mapped[Decimal | None] = mapped_column(Numeric(12, 4)) + description: Mapped[str | None] = mapped_column(Text) + + +class SourceRecord(Base): + __tablename__ = "sources" + + id: Mapped[str] = mapped_column(String(64), primary_key=True) + name: Mapped[str] = mapped_column(String(128), nullable=False) + adapter_type: Mapped[str] = mapped_column(String(64), nullable=False) + + +class ScoringModelRecord(Base): + __tablename__ = "scoring_models" + + version: Mapped[str] = mapped_column(String(64), primary_key=True) + algorithm_id: Mapped[str] = mapped_column(String(128), nullable=False) + definition: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False) + definition_hash: Mapped[str] = mapped_column(String(64), nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + + +class ProductProfileRecord(Base): + __tablename__ = "product_profiles" + __table_args__ = ( + CheckConstraint("version > 0", name="ck_product_profiles_version"), + ) + + id: Mapped[str] = mapped_column(String(128), primary_key=True) + version: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(256), nullable=False) + category: Mapped[str] = mapped_column(String(128), nullable=False) + scoring_version: Mapped[str] = mapped_column(ForeignKey("scoring_models.version"), nullable=False) + definition: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False) + definition_hash: Mapped[str] = mapped_column(String(64), nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + + +class ProductRecord(Base): + __tablename__ = "products" + + id: Mapped[UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True) + category: Mapped[str] = mapped_column(String(128), nullable=False) + brand: Mapped[str] = mapped_column(String(128), nullable=False) + name: Mapped[str] = mapped_column(String(256), nullable=False) + model: Mapped[str | None] = mapped_column(String(128)) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + + variants: Mapped[list["ProductVariantRecord"]] = relationship(back_populates="product") + + +class ProductVariantRecord(Base): + __tablename__ = "product_variants" + + id: Mapped[UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True) + product_id: Mapped[UUID] = mapped_column(ForeignKey("products.id"), nullable=False, index=True) + name: Mapped[str] = mapped_column(String(256), nullable=False) + manufacturer_sku: Mapped[str | None] = mapped_column(String(128)) + identifiers: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + + product: Mapped[ProductRecord] = relationship(back_populates="variants") + + +class VariantAttributeRecord(Base): + __tablename__ = "variant_attributes" + __table_args__ = ( + CheckConstraint("confidence >= 0 AND confidence <= 1", name="ck_variant_attributes_confidence"), + CheckConstraint( + "normalization_status IN ('normalized', 'unknown')", + name="ck_variant_attributes_status", + ), + ) + + id: Mapped[UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True) + variant_id: Mapped[UUID] = mapped_column(ForeignKey("product_variants.id"), nullable=False) + attribute_key: Mapped[str] = mapped_column(ForeignKey("attribute_definitions.key"), nullable=False) + source_id: Mapped[str] = mapped_column(ForeignKey("sources.id"), nullable=False) + raw_value: Mapped[Any] = mapped_column(JSONB, nullable=False) + normalized_value: Mapped[Any] = mapped_column(JSONB) + unit: Mapped[str | None] = mapped_column(String(32)) + normalization_status: Mapped[str] = mapped_column(String(32), nullable=False) + normalizer_version: Mapped[str] = mapped_column(String(64), nullable=False) + confidence: Mapped[Decimal] = mapped_column(Numeric(4, 3), nullable=False) + source_reference: Mapped[str] = mapped_column(String(256), nullable=False) + observed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + ingested_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + normalization_error: Mapped[str | None] = mapped_column(Text) + + +class OfferRecord(Base): + __tablename__ = "offers" + __table_args__ = ( + UniqueConstraint("source_id", "external_id", name="uq_offers_source_external"), + CheckConstraint("currency ~ '^[A-Z]{3}$'", name="ck_offers_currency"), + CheckConstraint("condition IN ('new', 'refurbished', 'used')", name="ck_offers_condition"), + ) + + id: Mapped[UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True) + variant_id: Mapped[UUID] = mapped_column(ForeignKey("product_variants.id"), nullable=False, index=True) + source_id: Mapped[str] = mapped_column(ForeignKey("sources.id"), nullable=False) + external_id: Mapped[str] = mapped_column(String(256), nullable=False) + seller: Mapped[str] = mapped_column(String(256), nullable=False) + url: Mapped[str | None] = mapped_column(Text) + market: Mapped[str] = mapped_column(String(16), nullable=False) + currency: Mapped[str] = mapped_column(String(3), nullable=False) + condition: Mapped[str] = mapped_column(String(32), nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + + +class PriceObservationRecord(Base): + __tablename__ = "price_observations" + __table_args__ = ( + UniqueConstraint("offer_id", "external_observation_id", name="uq_price_obs_offer_external"), + CheckConstraint("item_price > 0", name="ck_price_obs_item_price"), + CheckConstraint("shipping_price IS NULL OR shipping_price >= 0", name="ck_price_obs_shipping"), + CheckConstraint("confidence >= 0 AND confidence <= 1", name="ck_price_obs_confidence"), + CheckConstraint( + "availability IN ('in_stock', 'out_of_stock', 'unknown')", + name="ck_price_obs_availability", + ), + CheckConstraint("currency ~ '^[A-Z]{3}$'", name="ck_price_obs_currency"), + ) + + id: Mapped[UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True) + offer_id: Mapped[UUID] = mapped_column(ForeignKey("offers.id"), nullable=False) + source_id: Mapped[str] = mapped_column(ForeignKey("sources.id"), nullable=False) + external_observation_id: Mapped[str] = mapped_column(String(256), nullable=False) + observed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + ingested_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + item_price: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False) + shipping_price: Mapped[Decimal | None] = mapped_column(Numeric(12, 2)) + currency: Mapped[str] = mapped_column(String(3), nullable=False) + availability: Mapped[str] = mapped_column(String(32), nullable=False) + raw_payload: Mapped[Any] = mapped_column(JSONB, nullable=False) + confidence: Mapped[Decimal] = mapped_column(Numeric(4, 3), nullable=False) + + +class EvaluationRecord(Base): + __tablename__ = "evaluations" + __table_args__ = ( + ForeignKeyConstraint( + ["profile_id", "profile_version"], + ["product_profiles.id", "product_profiles.version"], + name="fk_evaluations_profile", + ), + CheckConstraint( + "product_eligibility IN ('ELIGIBLE', 'INELIGIBLE', 'UNKNOWN')", + name="ck_eval_product_eligibility", + ), + CheckConstraint( + "offer_eligibility IN ('ELIGIBLE', 'INELIGIBLE', 'UNKNOWN')", + name="ck_eval_offer_eligibility", + ), + CheckConstraint( + "overall_eligibility IN ('ELIGIBLE', 'INELIGIBLE', 'UNKNOWN')", + name="ck_eval_overall_eligibility", + ), + CheckConstraint( + "product_score IS NULL OR (product_score >= 0 AND product_score <= 100)", + name="ck_eval_product_score", + ), + CheckConstraint( + "price_score IS NULL OR (price_score >= 0 AND price_score <= 100)", + name="ck_eval_price_score", + ), + CheckConstraint( + "deal_score IS NULL OR (deal_score >= 0 AND deal_score <= 100)", + name="ck_eval_deal_score", + ), + ) + + id: Mapped[UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True) + variant_id: Mapped[UUID] = mapped_column(ForeignKey("product_variants.id"), nullable=False, index=True) + offer_id: Mapped[UUID] = mapped_column(ForeignKey("offers.id"), nullable=False) + profile_id: Mapped[str] = mapped_column(String(128), nullable=False) + profile_version: Mapped[int] = mapped_column(nullable=False) + scoring_version: Mapped[str] = mapped_column(ForeignKey("scoring_models.version"), nullable=False) + as_of: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + snapshot_cutoff: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + scoring_hash: Mapped[str] = mapped_column(String(64), nullable=False) + engine_build: Mapped[str] = mapped_column(String(64), nullable=False) + product_eligibility: Mapped[str] = mapped_column(String(16), nullable=False) + offer_eligibility: Mapped[str] = mapped_column(String(16), nullable=False) + overall_eligibility: Mapped[str] = mapped_column(String(16), nullable=False) + product_score: Mapped[Decimal | None] = mapped_column(Numeric(6, 2)) + price_score: Mapped[Decimal | None] = mapped_column(Numeric(6, 2)) + deal_score: Mapped[Decimal | None] = mapped_column(Numeric(6, 2)) + classification: Mapped[str] = mapped_column(String(32), nullable=False) + price_reference_scope: Mapped[str] = mapped_column(String(32), nullable=False) + input_snapshot: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False) + result_snapshot: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + + components: Mapped[list["EvaluationComponentRecord"]] = relationship(back_populates="evaluation") + + +class EvaluationComponentRecord(Base): + __tablename__ = "evaluation_components" + __table_args__ = ( + UniqueConstraint("evaluation_id", "kind", "component_key", name="uq_eval_component"), + ) + + id: Mapped[UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True) + evaluation_id: Mapped[UUID] = mapped_column(ForeignKey("evaluations.id"), nullable=False) + kind: Mapped[str] = mapped_column(String(64), nullable=False) + component_key: Mapped[str] = mapped_column(String(128), nullable=False) + raw_score: Mapped[Decimal | None] = mapped_column(Numeric(12, 6)) + weight: Mapped[Decimal | None] = mapped_column(Numeric(12, 6)) + contribution: Mapped[Decimal | None] = mapped_column(Numeric(12, 6)) + details: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False) + + evaluation: Mapped[EvaluationRecord] = relationship(back_populates="components") diff --git a/backend/app/persistence/repositories.py b/backend/app/persistence/repositories.py new file mode 100644 index 0000000..090aa1d --- /dev/null +++ b/backend/app/persistence/repositories.py @@ -0,0 +1,183 @@ +from datetime import datetime +from typing import Any +from uuid import UUID + +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from app.domain.models import AttributeDefinition, AttributeStatement, Offer, PriceObservation, Product, ProductVariant +from app.domain.validation import parse_profile, parse_scoring_model +from app.persistence import models as orm +from app.persistence.mapping import to_attribute, to_definition, to_observation, to_offer, to_product, to_variant + + +class CatalogRepository: + def __init__(self, session: Session) -> None: + self.session = session + + def get_definition(self, key: str) -> AttributeDefinition | None: + row = self.session.get(orm.AttributeDefinitionRecord, key) + return None if row is None else to_definition(row) + + def list_definitions(self, limit: int, offset: int) -> tuple[list[AttributeDefinition], int]: + total = self.session.scalar(select(func.count()).select_from(orm.AttributeDefinitionRecord)) or 0 + rows = self.session.scalars( + select(orm.AttributeDefinitionRecord).order_by(orm.AttributeDefinitionRecord.key).limit(limit).offset(offset) + ).all() + return [to_definition(row) for row in rows], total + + def add_definition(self, row: orm.AttributeDefinitionRecord) -> AttributeDefinition: + self.session.add(row) + self.session.flush() + return to_definition(row) + + def list_sources(self) -> list[orm.SourceRecord]: + return list(self.session.scalars(select(orm.SourceRecord).order_by(orm.SourceRecord.id)).all()) + + def get_source(self, source_id: str) -> orm.SourceRecord | None: + return self.session.get(orm.SourceRecord, source_id) + + def get_scoring_model(self, version: str): + row = self.session.get(orm.ScoringModelRecord, version) + return None if row is None else parse_scoring_model(row.definition) + + def scoring_versions(self) -> set[str]: + return set(self.session.scalars(select(orm.ScoringModelRecord.version)).all()) + + def all_definitions(self) -> dict[str, AttributeDefinition]: + rows = self.session.scalars(select(orm.AttributeDefinitionRecord)).all() + return {row.key: to_definition(row) for row in rows} + + def get_profile(self, profile_id: str, version: int): + row = self.session.get(orm.ProductProfileRecord, (profile_id, version)) + if row is None: + return None + model = self.get_scoring_model(row.scoring_version) + if model is None: + return None + return parse_profile( + row.definition, + attribute_definitions=self.all_definitions(), + scoring_versions=self.scoring_versions(), + supported_strategies=set(model.supported_preference_strategies), + ) + + def add_profile(self, row: orm.ProductProfileRecord): + self.session.add(row) + self.session.flush() + return row + + def list_profiles(self, limit: int, offset: int) -> tuple[list[orm.ProductProfileRecord], int]: + total = self.session.scalar(select(func.count()).select_from(orm.ProductProfileRecord)) or 0 + rows = self.session.scalars( + select(orm.ProductProfileRecord) + .order_by(orm.ProductProfileRecord.id, orm.ProductProfileRecord.version) + .limit(limit) + .offset(offset) + ).all() + return list(rows), total + + def get_product(self, product_id: UUID) -> Product | None: + row = self.session.get(orm.ProductRecord, product_id) + return None if row is None else to_product(row) + + def add_product(self, row: orm.ProductRecord) -> Product: + self.session.add(row) + self.session.flush() + return to_product(row) + + def get_variant(self, variant_id: UUID) -> ProductVariant | None: + row = self.session.get(orm.ProductVariantRecord, variant_id) + return None if row is None else to_variant(row) + + def add_variant(self, row: orm.ProductVariantRecord) -> ProductVariant: + self.session.add(row) + self.session.flush() + return to_variant(row) + + def list_attributes(self, variant_id: UUID) -> list[AttributeStatement]: + rows = self.session.scalars( + select(orm.VariantAttributeRecord) + .where(orm.VariantAttributeRecord.variant_id == variant_id) + .order_by( + orm.VariantAttributeRecord.observed_at, + orm.VariantAttributeRecord.ingested_at, + orm.VariantAttributeRecord.id, + ) + ).all() + return [to_attribute(row) for row in rows] + + def get_attribute(self, attribute_id: UUID) -> AttributeStatement | None: + row = self.session.get(orm.VariantAttributeRecord, attribute_id) + return None if row is None else to_attribute(row) + + def add_attribute(self, row: orm.VariantAttributeRecord) -> AttributeStatement: + self.session.add(row) + self.session.flush() + return to_attribute(row) + + def get_offer(self, offer_id: UUID) -> Offer | None: + row = self.session.get(orm.OfferRecord, offer_id) + return None if row is None else to_offer(row) + + def get_offer_by_external(self, source_id: str, external_id: str) -> Offer | None: + row = self.session.scalar( + select(orm.OfferRecord).where( + orm.OfferRecord.source_id == source_id, + orm.OfferRecord.external_id == external_id, + ) + ) + return None if row is None else to_offer(row) + + def add_offer(self, row: orm.OfferRecord) -> Offer: + self.session.add(row) + self.session.flush() + return to_offer(row) + + def list_observations(self, offer_id: UUID, limit: int | None = None, offset: int = 0) -> tuple[list[PriceObservation], int]: + total = ( + self.session.scalar( + select(func.count()) + .select_from(orm.PriceObservationRecord) + .where(orm.PriceObservationRecord.offer_id == offer_id) + ) + or 0 + ) + query = ( + select(orm.PriceObservationRecord) + .where(orm.PriceObservationRecord.offer_id == offer_id) + .order_by( + orm.PriceObservationRecord.observed_at, + orm.PriceObservationRecord.ingested_at, + orm.PriceObservationRecord.id, + ) + .offset(offset) + ) + if limit is not None: + query = query.limit(limit) + rows = self.session.scalars(query).all() + return [to_observation(row) for row in rows], total + + def get_observation_by_external(self, offer_id: UUID, external_id: str) -> orm.PriceObservationRecord | None: + return self.session.scalar( + select(orm.PriceObservationRecord).where( + orm.PriceObservationRecord.offer_id == offer_id, + orm.PriceObservationRecord.external_observation_id == external_id, + ) + ) + + def add_observation(self, row: orm.PriceObservationRecord) -> PriceObservation: + self.session.add(row) + self.session.flush() + return to_observation(row) + + def get_evaluation(self, evaluation_id: UUID) -> orm.EvaluationRecord | None: + return self.session.get(orm.EvaluationRecord, evaluation_id) + + def add_evaluation(self, row: orm.EvaluationRecord) -> orm.EvaluationRecord: + self.session.add(row) + self.session.flush() + return row + + def add_component(self, row: orm.EvaluationComponentRecord) -> None: + self.session.add(row) diff --git a/backend/app/scoring/__init__.py b/backend/app/scoring/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/scoring/deal.py b/backend/app/scoring/deal.py new file mode 100644 index 0000000..651763e --- /dev/null +++ b/backend/app/scoring/deal.py @@ -0,0 +1,42 @@ +from decimal import Decimal + +from app.domain.enums import Classification, Eligibility +from app.domain.models import ScoringModel +from app.scoring.mathutil import clamp + + +def classify_deal_score(score: Decimal, model: ScoringModel) -> str: + for bound in model.classification_upper_bounds: + if bound.inclusive and score <= bound.upper: + return bound.label + if not bound.inclusive and score < bound.upper: + return bound.label + return Classification.EXCEPTIONAL.value + + +def score_deal(product_score: Decimal, price_score: Decimal, model: ScoringModel) -> Decimal: + return clamp(model.product_weight * product_score + model.price_weight * price_score) + + +def eligibility_from_parts(product: Eligibility, offer: Eligibility) -> Eligibility: + if Eligibility.INELIGIBLE in {product, offer}: + return Eligibility.INELIGIBLE + if Eligibility.UNKNOWN in {product, offer}: + return Eligibility.UNKNOWN + return Eligibility.ELIGIBLE + + +def derive_eligible(overall: Eligibility) -> bool | None: + if overall == Eligibility.ELIGIBLE: + return True + if overall == Eligibility.INELIGIBLE: + return False + return None + + +def classification_for(overall: Eligibility, deal_score: Decimal | None, model: ScoringModel) -> str: + if overall == Eligibility.INELIGIBLE: + return Classification.INELIGIBLE.value + if overall == Eligibility.UNKNOWN or deal_score is None: + return Classification.INSUFFICIENT_DATA.value + return classify_deal_score(deal_score, model) diff --git a/backend/app/scoring/engine.py b/backend/app/scoring/engine.py new file mode 100644 index 0000000..de1fac9 --- /dev/null +++ b/backend/app/scoring/engine.py @@ -0,0 +1,545 @@ +from collections.abc import Sequence +from datetime import datetime, timedelta +from decimal import Decimal +from typing import Any + +from app.domain.attributes import select_attribute_snapshot +from app.domain.constraints import evaluate_constraint +from app.domain.enums import ( + Availability, + ConstraintStatus, + Eligibility, + PriceReferenceScope, +) +from app.domain.models import ( + AttributeDefinition, + AttributeStatement, + ConstraintResult, + EvaluationResult, + GateResult, + Offer, + PriceObservation, + Product, + ProductVariant, + Profile, + Reason, + ScoreComponent, + ScoringModel, +) +from app.domain.serialize import jsonable +from app.scoring.deal import classification_for, derive_eligible, eligibility_from_parts, score_deal +from app.scoring.mathutil import round_score +from app.scoring.price import ( + build_offer_reference_history, + price_statistics_from_windows, + score_price, + select_current_observation, +) +from app.scoring.product import score_product + + +def evaluate_deal( + *, + profile: Profile, + scoring_model: ScoringModel, + product: Product, + variant: ProductVariant, + offer: Offer, + attributes: tuple[AttributeStatement, ...], + observations: tuple[PriceObservation, ...], + definitions: dict[str, AttributeDefinition], + as_of: datetime, + snapshot_cutoff: datetime, + engine_build: str, +) -> EvaluationResult: + observations = tuple(item for item in observations if item.offer_id == offer.id) + snapshot = select_attribute_snapshot(attributes, as_of=as_of, snapshot_cutoff=snapshot_cutoff) + constraint_results = tuple( + evaluate_constraint( + constraint, + snapshot.get(constraint.attribute), + definitions[constraint.attribute], + scoring_model.minimum_confidence, + ) + for constraint in profile.constraints + ) + product_gates, product_eligibility = _product_eligibility(profile, product, constraint_results) + product_score_raw, coverage, preference_components = score_product( + profile.preferences, + snapshot, + constraint_results, + scoring_model, + ) + + current = select_current_observation(observations, as_of=as_of, snapshot_cutoff=snapshot_cutoff) + offer_gates, offer_eligibility = _offer_eligibility(profile, offer, current, scoring_model, as_of) + overall = eligibility_from_parts(product_eligibility, offer_eligibility) + + windows: dict[int, Any] = {} + price_score_raw: Decimal | None = None + budget_score: Decimal | None = None + history_score: Decimal | None = None + price_basis = None + history_warning: str | None = None + if _can_score_price(current, profile, scoring_model, as_of): + windows = build_offer_reference_history(observations, current, scoring_model) + price_score_raw, budget_score, history_score, price_basis, history_warning = score_price( + current_price=current.total_price, + reference_history=windows[90].values, + budget=profile.budget, + model=scoring_model, + ) + + stats = price_statistics_from_windows(current, windows) + deal_score_raw = None + if overall == Eligibility.ELIGIBLE and price_score_raw is not None: + deal_score_raw = score_deal(product_score_raw, price_score_raw, scoring_model) + + classification = classification_for(overall, deal_score_raw, scoring_model) + reasons, warnings = _reasons( + constraint_results, + product_gates, + offer_gates, + stats, + current, + history_warning, + price_score_raw, + ) + components = _all_components( + preference_components, + product_gates, + offer_gates, + budget_score, + history_score, + scoring_model, + ) + input_snapshot = { + "profile": profile.definition, + "scoring_model": scoring_model.definition, + "product": jsonable(_product_dict(product)), + "variant": jsonable(_variant_dict(variant)), + "offer": jsonable(_offer_dict(offer)), + "attribute_snapshot": { + key: jsonable(_attribute_dict(value)) + for key, value in snapshot.items() + }, + "current_observation": None if current is None else jsonable(_observation_dict(current)), + "history_observation_ids": [str(item) for item in stats.used_observation_ids], + "as_of": jsonable(as_of), + "snapshot_cutoff": jsonable(snapshot_cutoff), + "price_reference_scope": PriceReferenceScope.OFFER.value, + "unrounded": { + "product_score": jsonable(product_score_raw), + "price_score": jsonable(price_score_raw), + "deal_score": jsonable(deal_score_raw), + "budget_score": jsonable(budget_score), + "history_score": jsonable(history_score), + }, + } + return EvaluationResult( + profile_id=profile.id, + profile_version=profile.version, + scoring_version=scoring_model.version, + scoring_hash=scoring_model.definition_hash, + engine_build=engine_build, + variant_id=variant.id, + offer_id=offer.id, + as_of=as_of, + snapshot_cutoff=snapshot_cutoff, + product_eligibility=product_eligibility, + offer_eligibility=offer_eligibility, + overall_eligibility=overall, + eligible=derive_eligible(overall), + product_score=round_score(product_score_raw), + product_score_raw=product_score_raw, + price_score=None if price_score_raw is None else round_score(price_score_raw), + price_score_raw=price_score_raw, + deal_score=None if deal_score_raw is None else round_score(deal_score_raw), + deal_score_raw=deal_score_raw, + classification=classification, + price_basis=price_basis, + product_coverage=coverage, + constraint_results=constraint_results, + product_gates=product_gates, + offer_gates=offer_gates, + components=components, + price_statistics=stats, + price_reference_scope=PriceReferenceScope.OFFER, + reasons=reasons, + warnings=warnings, + input_snapshot=input_snapshot, + failed_constraints=tuple( + item for item in constraint_results if item.status == ConstraintStatus.FAIL.value + ), + unknown_constraints=tuple( + item for item in constraint_results if item.status == ConstraintStatus.UNKNOWN.value + ), + ) + + +def statement_price_confidence(observation: PriceObservation, model: ScoringModel) -> bool: + return observation.confidence >= model.minimum_confidence + + +def _fresh_enough(observation: PriceObservation, as_of: datetime, model: ScoringModel) -> bool: + return as_of - observation.observed_at <= timedelta(hours=model.maximum_price_age_hours) + + +def _product_eligibility( + profile: Profile, + product: Product, + constraint_results: tuple[ConstraintResult, ...], +) -> tuple[tuple[GateResult, ...], Eligibility]: + category_ok = product.category == profile.category + category_gate = GateResult( + key="category", + status="PASS" if category_ok else "FAIL", + reason_code="CATEGORY_MATCH" if category_ok else "CATEGORY_MISMATCH", + details={"expected": profile.category, "actual": product.category}, + ) + mandatory = [item for item in constraint_results if item.mandatory] + if not category_ok or any(item.status == ConstraintStatus.FAIL.value for item in mandatory): + eligibility = Eligibility.INELIGIBLE + elif any(item.status == ConstraintStatus.UNKNOWN.value for item in mandatory): + eligibility = Eligibility.UNKNOWN + else: + eligibility = Eligibility.ELIGIBLE + return (category_gate,), eligibility + + +def _offer_eligibility( + profile: Profile, + offer: Offer, + current: PriceObservation | None, + model: ScoringModel, + as_of: datetime, +) -> tuple[tuple[GateResult, ...], Eligibility]: + gates: list[GateResult] = [] + + market_ok = offer.market == profile.market + gates.append(_gate("market", market_ok, "MARKET_MATCH", "MARKET_MISMATCH", offer.market, profile.market)) + + condition_ok = offer.condition in profile.allowed_conditions + gates.append( + _gate( + "condition", + condition_ok, + "CONDITION_ALLOWED", + "CONDITION_NOT_ALLOWED", + offer.condition.value, + [item.value for item in profile.allowed_conditions], + ) + ) + + currency_ok = offer.currency == profile.currency + gates.append(_gate("currency", currency_ok, "CURRENCY_MATCH", "CURRENCY_MISMATCH", offer.currency, profile.currency)) + + if current is None: + gates.append(GateResult("price_present", "UNKNOWN", "PRICE_MISSING", {})) + gates.append(GateResult("price_fresh", "UNKNOWN", "PRICE_MISSING", {})) + gates.append(GateResult("price_confidence", "UNKNOWN", "PRICE_MISSING", {})) + gates.append(GateResult("shipping", "UNKNOWN", "SHIPPING_UNKNOWN", {})) + gates.append(GateResult("budget", "UNKNOWN", "PRICE_MISSING", {})) + gates.append(GateResult("availability", "UNKNOWN", "AVAILABILITY_UNKNOWN", {})) + return tuple(gates), _combine_offer_gates(gates) + + fresh = _fresh_enough(current, as_of, model) + if not fresh: + gates.append(GateResult("price_present", "UNKNOWN", "PRICE_STALE", {"observed_at": current.observed_at.isoformat()})) + gates.append(GateResult("price_fresh", "UNKNOWN", "PRICE_STALE", {})) + else: + gates.append(GateResult("price_present", "PASS", "PRICE_PRESENT", {"observation_id": str(current.id)})) + gates.append(GateResult("price_fresh", "PASS", "PRICE_FRESH", {})) + + confident = statement_price_confidence(current, model) + gates.append( + GateResult( + "price_confidence", + "PASS" if confident else "UNKNOWN", + "PRICE_CONFIDENCE_OK" if confident else "PRICE_LOW_CONFIDENCE", + {"confidence": str(current.confidence)}, + ) + ) + + shipping_known = current.shipping_price is not None + gates.append( + GateResult( + "shipping", + "PASS" if shipping_known else "UNKNOWN", + "SHIPPING_KNOWN" if shipping_known else "SHIPPING_UNKNOWN", + {}, + ) + ) + + total = current.total_price + if total is None or not currency_ok: + gates.append(GateResult("budget", "UNKNOWN", "PRICE_INCOMPLETE", {})) + elif total <= profile.budget.maximum: + gates.append(GateResult("budget", "PASS", "WITHIN_BUDGET", {"total": str(total)})) + else: + gates.append(GateResult("budget", "FAIL", "OVER_BUDGET", {"total": str(total)})) + + if current.availability == Availability.IN_STOCK: + gates.append(GateResult("availability", "PASS", "IN_STOCK", {})) + elif current.availability == Availability.OUT_OF_STOCK: + gates.append(GateResult("availability", "FAIL", "OUT_OF_STOCK", {})) + else: + gates.append(GateResult("availability", "UNKNOWN", "AVAILABILITY_UNKNOWN", {})) + + if current.currency != offer.currency: + gates.append(GateResult("observation_currency", "FAIL", "CURRENCY_MISMATCH", {})) + + if current.source_id != offer.source_id: + gates.append(GateResult("observation_source", "FAIL", "SOURCE_MISMATCH", {})) + + return tuple(gates), _combine_offer_gates(gates) + + +def _gate(key: str, ok: bool, pass_code: str, fail_code: str, actual: Any, expected: Any) -> GateResult: + return GateResult( + key=key, + status="PASS" if ok else "FAIL", + reason_code=pass_code if ok else fail_code, + details={"actual": actual, "expected": expected}, + ) + + +def _can_score_price( + current: PriceObservation | None, + profile: Profile, + model: ScoringModel, + as_of: datetime, +) -> bool: + if current is None: + return False + if current.currency != profile.currency: + return False + if current.total_price is None: + return False + if not _fresh_enough(current, as_of, model): + return False + return statement_price_confidence(current, model) + + +def _combine_offer_gates(gates: Sequence[GateResult]) -> Eligibility: + statuses = [gate.status for gate in gates] + if "FAIL" in statuses: + return Eligibility.INELIGIBLE + if "UNKNOWN" in statuses: + return Eligibility.UNKNOWN + return Eligibility.ELIGIBLE + + +def _reasons( + constraints: tuple[ConstraintResult, ...], + product_gates: tuple[GateResult, ...], + offer_gates: tuple[GateResult, ...], + stats, + current: PriceObservation | None, + history_warning: str | None, + price_score_raw: Decimal | None, +) -> tuple[tuple[Reason, ...], tuple[str, ...]]: + reasons: list[Reason] = [] + warnings: list[str] = [] + for gate in (*product_gates, *offer_gates): + if gate.status != "PASS": + reasons.append(Reason(gate.reason_code, _gate_message(gate))) + for item in constraints: + if item.status != ConstraintStatus.PASS.value: + reasons.append(Reason(item.reason_code, _constraint_message(item))) + if history_warning: + warnings.append(history_warning) + reasons.append(Reason(history_warning, "Zu wenige Historientage für den 90-Tage-Median.")) + if stats.lowest_365d is not None and not stats.lowest_365d_is_full_year: + warnings.append("LOWEST_365D_PARTIAL") + reasons.append( + Reason( + "LOWEST_365D_PARTIAL", + f"lowest_365d {stats.lowest_365d} ist das Minimum der vorhandenen {stats.lowest_365d_days} Tage, kein belegtes Jahrestief.", + ) + ) + if ( + price_score_raw is not None + and current is not None + and current.total_price is not None + and stats.median_90d is not None + and stats.median_90d != 0 + ): + delta = (stats.median_90d - current.total_price) / stats.median_90d * Decimal("100") + percent = round_score(delta) + if current.total_price < stats.median_90d: + reasons.append( + Reason( + "PRICE_BELOW_MEDIAN", + f"Gesamtpreis {_de_decimal(percent)} % unter dem Median der beobachteten 90-Tage-Historie.", + ) + ) + elif current.total_price > stats.median_90d: + reasons.append( + Reason( + "PRICE_ABOVE_MEDIAN", + f"Gesamtpreis {_de_decimal(percent.copy_abs())} % über dem Median der beobachteten 90-Tage-Historie.", + ) + ) + else: + reasons.append(Reason("PRICE_AT_MEDIAN", "Gesamtpreis entspricht dem Median der 90-Tage-Historie.")) + return tuple(reasons), tuple(warnings) + + +def _de_decimal(value: Decimal) -> str: + return format(value, "f").replace(".", ",") + + +def _gate_message(gate: GateResult) -> str: + messages = { + "CATEGORY_MISMATCH": "Die Produktkategorie entspricht nicht dem Profil.", + "MARKET_MISMATCH": "Der Angebotsmarkt entspricht nicht dem Profil.", + "CONDITION_NOT_ALLOWED": "Der Angebotszustand ist im Profil nicht erlaubt.", + "CURRENCY_MISMATCH": "Die Währung entspricht nicht dem Profil.", + "PRICE_MISSING": "Es liegt keine aktuelle Preisbeobachtung vor.", + "PRICE_STALE": "Die aktuelle Preisbeobachtung ist nicht frisch genug.", + "PRICE_LOW_CONFIDENCE": "Die Preis-Confidence liegt unter der Mindestschwelle.", + "SHIPPING_UNKNOWN": "Obligatorische Versandkosten sind unbekannt.", + "OVER_BUDGET": "Der Gesamtpreis liegt über dem Budgetmaximum.", + "OUT_OF_STOCK": "Das Angebot ist nicht lieferbar.", + "AVAILABILITY_UNKNOWN": "Die Verfügbarkeit ist unbekannt.", + "SOURCE_MISMATCH": "Die Beobachtungsquelle weicht vom Angebot ab.", + } + return messages.get(gate.reason_code, gate.reason_code) + + +def _constraint_message(item: ConstraintResult) -> str: + if item.status == ConstraintStatus.FAIL.value: + return f"Constraint {item.constraint_id} ist nicht erfüllt." + return f"Constraint {item.constraint_id} kann nicht bewertet werden." + + +def _all_components( + preference_components: tuple[ScoreComponent, ...], + product_gates: tuple[GateResult, ...], + offer_gates: tuple[GateResult, ...], + budget_score: Decimal | None, + history_score: Decimal | None, + model: ScoringModel, +) -> tuple[ScoreComponent, ...]: + extra: list[ScoreComponent] = list(preference_components) + if budget_score is not None: + extra.append( + ScoreComponent( + kind="budget", + component_key="budget", + raw_score=budget_score, + weight=model.budget_weight, + contribution=model.budget_weight * budget_score, + details={}, + ) + ) + if history_score is not None: + extra.append( + ScoreComponent( + kind="history", + component_key="history", + raw_score=history_score, + weight=model.history_weight, + contribution=model.history_weight * history_score, + details={}, + ) + ) + for gate in product_gates: + extra.append( + ScoreComponent( + kind="product_gate", + component_key=gate.key, + raw_score=None, + weight=None, + contribution=None, + details={"status": gate.status, "reason_code": gate.reason_code, **gate.details}, + ) + ) + for gate in offer_gates: + extra.append( + ScoreComponent( + kind="offer_gate", + component_key=gate.key, + raw_score=None, + weight=None, + contribution=None, + details={"status": gate.status, "reason_code": gate.reason_code, **gate.details}, + ) + ) + return tuple(extra) + + +def _product_dict(product: Product) -> dict[str, Any]: + return { + "id": product.id, + "category": product.category, + "brand": product.brand, + "name": product.name, + "model": product.model, + "created_at": product.created_at, + } + + +def _variant_dict(variant: ProductVariant) -> dict[str, Any]: + return { + "id": variant.id, + "product_id": variant.product_id, + "name": variant.name, + "manufacturer_sku": variant.manufacturer_sku, + "identifiers": variant.identifiers, + "created_at": variant.created_at, + } + + +def _offer_dict(offer: Offer) -> dict[str, Any]: + return { + "id": offer.id, + "variant_id": offer.variant_id, + "source_id": offer.source_id, + "external_id": offer.external_id, + "seller": offer.seller, + "url": offer.url, + "market": offer.market, + "currency": offer.currency, + "condition": offer.condition, + "created_at": offer.created_at, + } + + +def _attribute_dict(statement: AttributeStatement) -> dict[str, Any]: + return { + "id": statement.id, + "variant_id": statement.variant_id, + "attribute": statement.attribute, + "raw_value": statement.raw_value, + "normalized_value": statement.normalized_value, + "unit": statement.unit, + "normalization_status": statement.normalization_status, + "normalizer_version": statement.normalizer_version, + "confidence": statement.confidence, + "source_id": statement.source_id, + "source_reference": statement.source_reference, + "observed_at": statement.observed_at, + "ingested_at": statement.ingested_at, + "normalization_error": statement.normalization_error, + } + + +def _observation_dict(observation: PriceObservation) -> dict[str, Any]: + return { + "id": observation.id, + "offer_id": observation.offer_id, + "source_id": observation.source_id, + "external_observation_id": observation.external_observation_id, + "observed_at": observation.observed_at, + "ingested_at": observation.ingested_at, + "item_price": observation.item_price, + "shipping_price": observation.shipping_price, + "currency": observation.currency, + "availability": observation.availability, + "raw_payload": observation.raw_payload, + "confidence": observation.confidence, + "total_price": observation.total_price, + } + diff --git a/backend/app/scoring/mathutil.py b/backend/app/scoring/mathutil.py new file mode 100644 index 0000000..632847a --- /dev/null +++ b/backend/app/scoring/mathutil.py @@ -0,0 +1,21 @@ +from decimal import ROUND_HALF_UP, Decimal + +HUNDRED = Decimal("100") +ZERO = Decimal("0") +TWO_PLACES = Decimal("0.01") + + +def clamp(value: Decimal) -> Decimal: + return min(HUNDRED, max(ZERO, value)) + + +def round_score(value: Decimal) -> Decimal: + return value.quantize(TWO_PLACES, rounding=ROUND_HALF_UP) + + +def as_decimal(value: object) -> Decimal: + if isinstance(value, Decimal): + return value + if isinstance(value, bool) or value is None: + raise TypeError("value is not numeric") + return Decimal(str(value)) diff --git a/backend/app/scoring/price.py b/backend/app/scoring/price.py new file mode 100644 index 0000000..92a1237 --- /dev/null +++ b/backend/app/scoring/price.py @@ -0,0 +1,148 @@ +from collections.abc import Sequence +from datetime import date, datetime, timedelta +from decimal import Decimal +from uuid import UUID + +from app.domain.enums import Availability, PriceBasis +from app.domain.models import Budget, HistoryWindow, PriceObservation, PriceStatistics, ScoringModel +from app.scoring.mathutil import clamp + + +def total_or_none(observation: PriceObservation) -> Decimal | None: + return observation.total_price + + +def select_current_observation( + observations: Sequence[PriceObservation], + *, + as_of: datetime, + snapshot_cutoff: datetime, +) -> PriceObservation | None: + eligible = [ + item + for item in observations + if item.observed_at <= as_of and item.ingested_at <= snapshot_cutoff + ] + if not eligible: + return None + return max(eligible, key=lambda item: (item.observed_at, item.ingested_at, item.id)) + + +def median(values: Sequence[Decimal]) -> Decimal | None: + if not values: + return None + ordered = sorted(values) + mid = len(ordered) // 2 + if len(ordered) % 2: + return ordered[mid] + return (ordered[mid - 1] + ordered[mid]) / Decimal("2") + + +def _quality_ok(observation: PriceObservation, minimum_confidence: Decimal) -> bool: + return ( + observation.availability == Availability.IN_STOCK + and observation.total_price is not None + and observation.confidence >= minimum_confidence + ) + + +def daily_series( + observations: Sequence[PriceObservation], + *, + window_start: date, + window_end_exclusive: date, + minimum_confidence: Decimal, +) -> tuple[tuple[Decimal, ...], tuple[UUID, ...]]: + by_day: dict[date, PriceObservation] = {} + for item in observations: + day = item.observed_at.date() + if day < window_start or day >= window_end_exclusive: + continue + current = by_day.get(day) + if current is None or (item.observed_at, item.ingested_at, item.id) > ( + current.observed_at, + current.ingested_at, + current.id, + ): + by_day[day] = item + values: list[Decimal] = [] + ids: list[UUID] = [] + for day in sorted(by_day): + observation = by_day[day] + if _quality_ok(observation, minimum_confidence): + values.append(observation.total_price or Decimal("0")) + ids.append(observation.id) + return tuple(values), tuple(ids) + + +def build_offer_reference_history( + observations: Sequence[PriceObservation], + current: PriceObservation, + model: ScoringModel, +) -> dict[int, HistoryWindow]: + current_day = current.observed_at.date() + windows: dict[int, HistoryWindow] = {} + for days in (30, 90, 365): + values, ids = daily_series( + observations, + window_start=current_day - timedelta(days=days), + window_end_exclusive=current_day, + minimum_confidence=model.minimum_confidence, + ) + windows[days] = HistoryWindow(days=days, values=values, observation_ids=ids) + return windows + + +def score_price( + *, + current_price: Decimal, + reference_history: Sequence[Decimal], + budget: Budget, + model: ScoringModel, +) -> tuple[Decimal, Decimal, Decimal | None, PriceBasis, str | None]: + """Pure price score over current_price, reference_history and budget.""" + if current_price <= budget.target: + budget_score = Decimal("100") + else: + budget_score = clamp( + Decimal("100") * (budget.maximum - current_price) / (budget.maximum - budget.target) + ) + + if len(reference_history) < model.minimum_history_days: + return budget_score, budget_score, None, PriceBasis.BUDGET_ONLY, "INSUFFICIENT_HISTORY" + + reference = median(reference_history) + if reference is None or reference == 0: + return budget_score, budget_score, None, PriceBasis.BUDGET_ONLY, "INSUFFICIENT_HISTORY" + + discount = (reference - current_price) / reference + history_score = clamp( + Decimal("50") + Decimal("50") * discount / model.history_discount_for_max_score + ) + price_score = model.budget_weight * budget_score + model.history_weight * history_score + return price_score, budget_score, history_score, PriceBasis.BUDGET_AND_HISTORY, None + + +def price_statistics_from_windows( + current: PriceObservation | None, + windows: dict[int, HistoryWindow], +) -> PriceStatistics: + window_30 = windows.get(30, HistoryWindow(30, (), ())) + window_90 = windows.get(90, HistoryWindow(90, (), ())) + window_365 = windows.get(365, HistoryWindow(365, (), ())) + used = window_30.observation_ids + window_90.observation_ids + window_365.observation_ids + if current is not None: + used = (current.id,) + used + lowest = min(window_365.values) if window_365.values else None + return PriceStatistics( + current_total=None if current is None else current.total_price, + median_30d=median(window_30.values), + median_30d_days=window_30.day_count, + median_90d=median(window_90.values), + median_90d_days=window_90.day_count, + lowest_365d=lowest, + lowest_365d_days=window_365.day_count, + lowest_365d_is_full_year=window_365.day_count >= 365, + used_observation_ids=tuple(dict.fromkeys(used)), + current_observation_id=None if current is None else current.id, + ) diff --git a/backend/app/scoring/product.py b/backend/app/scoring/product.py new file mode 100644 index 0000000..d144c72 --- /dev/null +++ b/backend/app/scoring/product.py @@ -0,0 +1,86 @@ +from decimal import Decimal + +from app.domain.attributes import statement_is_usable +from app.domain.enums import ConstraintStatus, PreferenceDirection +from app.domain.models import ( + AttributeStatement, + ConstraintResult, + Preference, + ScoreComponent, + ScoringModel, +) +from app.scoring.mathutil import as_decimal, clamp + + +def score_numeric_linear(value: Decimal, acceptable: Decimal, ideal: Decimal, direction: PreferenceDirection) -> Decimal: + if direction == PreferenceDirection.HIGHER: + return clamp(Decimal("50") + Decimal("50") * (value - acceptable) / (ideal - acceptable)) + return clamp(Decimal("50") + Decimal("50") * (acceptable - value) / (acceptable - ideal)) + + +def score_product( + preferences: tuple[Preference, ...], + snapshot: dict[str, AttributeStatement], + constraint_results: tuple[ConstraintResult, ...], + model: ScoringModel, +) -> tuple[Decimal, Decimal, tuple[ScoreComponent, ...]]: + components: list[ScoreComponent] = [] + weighted = Decimal("0") + total_weight = sum((pref.weight for pref in preferences), Decimal("0")) + usable_weight = Decimal("0") + + for preference in preferences: + statement = snapshot.get(preference.attribute) + usable = statement_is_usable(statement, model.minimum_confidence) + if usable: + raw = score_numeric_linear( + as_decimal(statement.normalized_value), + preference.acceptable, + preference.ideal, + preference.direction, + ) + usable_weight += preference.weight + else: + raw = model.missing_preference_score + contribution = preference.weight * raw + weighted += contribution + components.append( + ScoreComponent( + kind="preference", + component_key=preference.id, + raw_score=raw, + weight=preference.weight, + contribution=contribution, + details={ + "attribute": preference.attribute, + "strategy": preference.strategy.value, + "direction": preference.direction.value, + "acceptable": preference.acceptable, + "ideal": preference.ideal, + "usable": usable, + "attribute_row_id": None if statement is None else str(statement.id), + }, + ) + ) + + base = weighted / total_weight if total_weight else Decimal("0") + optional_issues = [ + result + for result in constraint_results + if not result.mandatory and result.status in {ConstraintStatus.FAIL.value, ConstraintStatus.UNKNOWN.value} + ] + penalty = model.optional_constraint_penalty_points * Decimal(len(optional_issues)) + product_score = clamp(base - penalty) + if optional_issues: + components.append( + ScoreComponent( + kind="optional_penalty", + component_key="optional_constraints", + raw_score=penalty, + weight=None, + contribution=-penalty, + details={"count": len(optional_issues), "constraint_ids": [item.constraint_id for item in optional_issues]}, + ) + ) + coverage = (usable_weight / total_weight) if total_weight else Decimal("0") + return product_score, coverage, tuple(components) diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/services/catalog_service.py b/backend/app/services/catalog_service.py new file mode 100644 index 0000000..a9b008f --- /dev/null +++ b/backend/app/services/catalog_service.py @@ -0,0 +1,224 @@ +from __future__ import annotations + +from typing import Any + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.discovery.enums import FilterType, MappingStatus +from app.domain.serialize import jsonable +from app.errors import AppError +from app.persistence.discovery_models import ( + CriterionDefinitionRecord, + CriterionMappingRecord, + ProductCategoryRecord, + SourceCategoryRecord, + SourceFilterDefinitionRecord, + SourceFilterOptionRecord, + SourceFilterRangeRecord, +) +from app.discovery.definitions import upsert_definition +from app.persistence.repositories import CatalogRepository + + +FILTER_OPERATORS = { + FilterType.BOOLEAN.value: ["=", "!="], + FilterType.ENUM.value: ["=", "!="], + FilterType.MULTI_ENUM.value: ["in", "not_in"], + FilterType.NUMERIC_RANGE.value: [">=", "<=", "between"], + FilterType.NUMERIC_THRESHOLD.value: [">=", ">", "<=", "<"], + FilterType.TEXT.value: ["=", "contains"], +} + + +class CatalogService: + def __init__(self, session: Session) -> None: + self.session = session + self.catalog = CatalogRepository(session) + + def list_product_categories(self) -> list[dict[str, Any]]: + rows = self.session.scalars(select(ProductCategoryRecord).order_by(ProductCategoryRecord.name)).all() + return [{"id": row.id, "name": row.name, "status": row.status} for row in rows] + + def get_product_category(self, category_id: str) -> ProductCategoryRecord: + row = self.session.get(ProductCategoryRecord, category_id) + if row is None: + raise AppError(404, "NOT_FOUND", "product category not found", {"id": category_id}) + return row + + def sources_for_category(self, product_category_id: str) -> list[dict[str, Any]]: + self.get_product_category(product_category_id) + rows = self.session.scalars( + select(SourceCategoryRecord) + .where(SourceCategoryRecord.product_category_id == product_category_id) + .order_by(SourceCategoryRecord.source_id) + ).all() + sources = {item.id: item for item in self.catalog.list_sources()} + return [ + { + "source": row.source_id, + "source_category_id": row.id, + "display_name": sources[row.source_id].name if row.source_id in sources else row.name, + "external_category_key": row.external_category_key, + "status": row.status, + } + for row in rows + ] + + def source_categories(self, source_id: str) -> list[dict[str, Any]]: + if self.catalog.get_source(source_id) is None: + raise AppError(404, "NOT_FOUND", "source not found", {"source_id": source_id}) + rows = self.session.scalars( + select(SourceCategoryRecord) + .where(SourceCategoryRecord.source_id == source_id) + .order_by(SourceCategoryRecord.external_category_key) + ).all() + return [ + { + "id": row.id, + "source": row.source_id, + "source_category_id": row.id, + "external_category_key": row.external_category_key, + "name": row.name, + "product_category_id": row.product_category_id, + "status": row.status, + } + for row in rows + ] + + def available_criteria(self, product_category_id: str, source_id: str) -> dict[str, Any]: + self.get_product_category(product_category_id) + if self.catalog.get_source(source_id) is None: + raise AppError(404, "NOT_FOUND", "source not found", {"source_id": source_id}) + source_category = self.session.scalars( + select(SourceCategoryRecord).where( + SourceCategoryRecord.source_id == source_id, + SourceCategoryRecord.product_category_id == product_category_id, + ) + ).first() + criteria: list[dict[str, Any]] = [] + if source_category is not None: + filters = self.session.scalars( + select(SourceFilterDefinitionRecord) + .where(SourceFilterDefinitionRecord.source_category_id == source_category.id) + .order_by(SourceFilterDefinitionRecord.label) + ).all() + mappings = { + row.external_filter_key: row + for row in self.session.scalars( + select(CriterionMappingRecord).where( + CriterionMappingRecord.source_category_id == source_category.id + ) + ) + } + for filt in filters: + mapping = mappings.get(filt.external_filter_key) + definition = None if mapping is None else self.session.get(CriterionDefinitionRecord, mapping.criterion_definition_id) + if definition is None: + definition = upsert_definition( + self.session, + { + "key": f"{source_id}:{source_category.external_category_key}:{filt.external_filter_key}", + "origin": "SOURCE", + "source": source_id, + "product_category_id": product_category_id, + "label": filt.label, + "value_type": "number" + if filt.filter_type in {FilterType.NUMERIC_RANGE.value, FilterType.NUMERIC_THRESHOLD.value} + else "string", + "unit": filt.canonical_unit, + "category": product_category_id, + }, + ) + self.session.flush() + options = self.session.scalars( + select(SourceFilterOptionRecord).where(SourceFilterOptionRecord.filter_id == filt.id) + ).all() + rng = self.session.get(SourceFilterRangeRecord, filt.id) + criteria.append( + self._criterion_out( + definition, + filt, + mapping, + options, + rng, + ) + ) + internals = self.session.scalars( + select(CriterionDefinitionRecord) + .where( + CriterionDefinitionRecord.origin == "INTERNAL", + (CriterionDefinitionRecord.product_category_id == product_category_id) + | (CriterionDefinitionRecord.category == product_category_id), + ) + .order_by(CriterionDefinitionRecord.label) + ).all() + seen = {item["criterion_definition_id"] for item in criteria} + for definition in internals: + if definition.id in seen: + continue + criteria.append(self._criterion_out(definition, None, None, [], None)) + return jsonable({"product_category_id": product_category_id, "source": source_id, "criteria": criteria}) + + def list_templates(self) -> list[dict[str, Any]]: + rows, _total = self.catalog.list_profiles(200, 0) + return [ + { + "id": row.id, + "version": row.version, + "name": row.definition.get("name") or row.id, + "product_category_id": row.definition.get("category"), + "market": row.definition.get("market"), + "currency": row.definition.get("currency"), + "description": row.definition.get("description"), + } + for row in rows + ] + + def _criterion_out( + self, + definition: CriterionDefinitionRecord, + filt: SourceFilterDefinitionRecord | None, + mapping: CriterionMappingRecord | None, + options: list[SourceFilterOptionRecord], + rng: SourceFilterRangeRecord | None, + ) -> dict[str, Any]: + filter_type = None if filt is None else filt.filter_type + return { + "criterion_definition_id": definition.id, + "label": definition.label, + "description": definition.description, + "origin": definition.origin, + "source": definition.source, + "product_category_id": definition.product_category_id, + "filter_type": filter_type, + "unit": definition.unit or (None if filt is None else filt.canonical_unit), + "operators": FILTER_OPERATORS.get(filter_type or "", ["=", ">=", "<="]), + "options": [ + { + "source_value": option.source_value, + "normalized_value": option.normalized_value, + "operator": option.operator, + "unit": option.unit, + } + for option in options + ], + "range": None + if rng is None + else {"minimum": rng.minimum, "maximum": rng.maximum, "step": rng.step, "unit": rng.unit}, + "mapping_status": None if mapping is None else mapping.status, + "supports_discovery": mapping is not None + and mapping.status == MappingStatus.VERIFIED.value + and filt is not None, + "scoring_attribute_key": definition.scoring_attribute_key, + } + + +def ensure_product_category(session: Session, category_id: str, name: str | None = None) -> ProductCategoryRecord: + row = session.get(ProductCategoryRecord, category_id) + if row is not None: + return row + row = ProductCategoryRecord(id=category_id, name=name or category_id.title(), status="ACTIVE") + session.add(row) + session.flush() + return row diff --git a/backend/app/services/discovery_service.py b/backend/app/services/discovery_service.py new file mode 100644 index 0000000..22927ff --- /dev/null +++ b/backend/app/services/discovery_service.py @@ -0,0 +1,1431 @@ +from __future__ import annotations + +from datetime import timedelta +from decimal import Decimal +from typing import Any +from uuid import UUID, uuid4 + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.clock import now_utc +from app.discovery.definitions import get_definition, get_definition_by_key, upsert_definition +from app.discovery.diff import diff_run, next_lifecycle +from app.discovery.enums import ( + CandidateStatus, + DiffStatus, + LifecycleStatus, + MappingStatus, + MatchStatus, + ProjectStatus, + RankingStatus, + RunStatus, + SearchStatus, +) +from app.discovery.identity import IdentitySignals, decide_identity, spec_fingerprint +from app.discovery.mapping import verify_roundtrip +from app.discovery.ranking import rank_candidates +from app.discovery.urlgen import generate_search_url +from app.domain.serialize import jsonable +from app.domain.validation import canonical_json, sha256_text +from app.errors import AppError +from app.persistence.discovery_models import ( + BuyingProjectRecord, + CandidateStatusHistoryRecord, + CriterionDefinitionRecord, + CriterionMappingRecord, + DiscoverySearchRecord, + ProductCategoryRecord, + ProductObservationAttributeRecord, + ProductObservationRecord, + ProjectCandidateRecord, + SearchRunItemRecord, + SearchRunRecord, + SourceCategoryRecord, + SourceFilterDefinitionRecord, + SourceFilterOptionRecord, + SourceFilterRangeRecord, + SourceAttributeMappingRecord, + SourceProductReferenceRecord, +) +from app.persistence.repositories import CatalogRepository +from app.services.catalog_service import ensure_product_category +from app.services.project_service import ProjectService +from app.sources.base import DiscoverySourceAdapter +from app.sources.geizhals.adapter import GeizhalsAdapter + +MAX_PAGES = 50 +BACKOFF_BASE_MINUTES = 15 + + +def discovery_adapter(source_id: str) -> DiscoverySourceAdapter: + if source_id == "geizhals": + return GeizhalsAdapter() + raise AppError(422, "UNKNOWN_SOURCE", "no discovery adapter for source", {"source_id": source_id}) + + +def _linked_product_category(session: Session, canonical: str | None) -> str | None: + if not canonical: + return None + category = session.get(ProductCategoryRecord, canonical) + if category is None: + ensure_product_category(session, canonical) + return canonical + + +def mapping_version_hash(mappings: list[CriterionMappingRecord]) -> str: + payload = [ + { + "criterion_key": row.criterion_key, + "external_filter_key": row.external_filter_key, + "source_value": row.source_value, + "encoding": row.encoding, + } + for row in mappings + if row.status == MappingStatus.VERIFIED.value + ] + payload.sort(key=lambda item: (item["criterion_key"], item["external_filter_key"], str(item["source_value"]))) + return sha256_text(canonical_json(payload)) + + +class TaxonomyService: + def __init__(self, session: Session, adapter: DiscoverySourceAdapter | None = None) -> None: + self.session = session + self.adapter = adapter + + def discover(self, source_id: str, category_key: str) -> dict[str, Any]: + catalog = CatalogRepository(self.session) + if catalog.get_source(source_id) is None: + raise AppError(404, "NOT_FOUND", "source not found", {"source_id": source_id}) + adapter = self.adapter or discovery_adapter(source_id) + payload = adapter.discover_taxonomy(category_key) + now = now_utc() + category = self.session.scalars( + select(SourceCategoryRecord).where( + SourceCategoryRecord.source_id == source_id, + SourceCategoryRecord.external_category_key == payload.get("external_category_key", category_key), + ) + ).first() + if category is None: + category = SourceCategoryRecord( + id=uuid4(), + source_id=source_id, + external_category_key=str(payload.get("external_category_key") or category_key), + name=str(payload.get("name") or category_key), + source_url=str(payload.get("source_url") or ""), + canonical_category=payload.get("canonical_category"), + product_category_id=_linked_product_category(self.session, payload.get("canonical_category")), + status="ACTIVE", + last_verified_at=now, + ) + self.session.add(category) + self.session.flush() + else: + category.name = str(payload.get("name") or category.name) + category.source_url = str(payload.get("source_url") or category.source_url) + if payload.get("canonical_category"): + category.canonical_category = payload.get("canonical_category") + category.product_category_id = _linked_product_category(self.session, payload.get("canonical_category")) + category.last_verified_at = now + for raw in payload.get("filters") or []: + filt = self.session.scalars( + select(SourceFilterDefinitionRecord).where( + SourceFilterDefinitionRecord.source_category_id == category.id, + SourceFilterDefinitionRecord.external_filter_key == str(raw["external_filter_key"]), + ) + ).first() + filter_type = str(raw.get("filter_type") or "UNKNOWN") + if filt is None: + filt = SourceFilterDefinitionRecord( + id=uuid4(), + source_category_id=category.id, + external_filter_key=str(raw["external_filter_key"]), + label=str(raw.get("label") or raw["external_filter_key"]), + filter_type=filter_type, + canonical_unit=raw.get("canonical_unit"), + status=MappingStatus.VERIFIED.value, + ) + self.session.add(filt) + self.session.flush() + else: + if filt.filter_type != filter_type: + self._break_mappings(category.id, filt.external_filter_key) + filt.label = str(raw.get("label") or filt.label) + filt.filter_type = filter_type + filt.canonical_unit = raw.get("canonical_unit") + self._sync_options(filt, raw.get("options") or []) + if raw.get("range"): + self._sync_range(filt, raw["range"]) + self.session.flush() + return self.filters(source_id, category.external_category_key) + + def filters(self, source_id: str, category_key: str) -> dict[str, Any]: + category = self._category(source_id, category_key) + rows = self.session.scalars( + select(SourceFilterDefinitionRecord) + .where(SourceFilterDefinitionRecord.source_category_id == category.id) + .order_by(SourceFilterDefinitionRecord.external_filter_key) + ).all() + filters = [] + for filt in rows: + options = self.session.scalars( + select(SourceFilterOptionRecord).where(SourceFilterOptionRecord.filter_id == filt.id) + ).all() + rng = self.session.get(SourceFilterRangeRecord, filt.id) + filters.append( + { + "id": filt.id, + "external_filter_key": filt.external_filter_key, + "label": filt.label, + "filter_type": filt.filter_type, + "canonical_unit": filt.canonical_unit, + "status": filt.status, + "options": [ + { + "source_value": option.source_value, + "normalized_value": option.normalized_value, + "operator": option.operator, + "unit": option.unit, + } + for option in options + ], + "range": None + if rng is None + else { + "minimum": rng.minimum, + "maximum": rng.maximum, + "step": rng.step, + "unit": rng.unit, + }, + } + ) + return jsonable( + { + "id": category.id, + "source_id": category.source_id, + "external_category_key": category.external_category_key, + "name": category.name, + "source_url": category.source_url, + "canonical_category": category.canonical_category, + "status": category.status, + "last_verified_at": category.last_verified_at, + "filters": filters, + } + ) + + def _sync_options(self, filt: SourceFilterDefinitionRecord, options: list[dict[str, Any]]) -> None: + seen: set[str] = set() + existing = { + row.source_value: row + for row in self.session.scalars( + select(SourceFilterOptionRecord).where(SourceFilterOptionRecord.filter_id == filt.id) + ) + } + for raw in options: + value = str(raw["source_value"]) + seen.add(value) + row = existing.get(value) + if row is None: + self.session.add( + SourceFilterOptionRecord( + id=uuid4(), + filter_id=filt.id, + source_value=value, + normalized_value=raw.get("normalized_value"), + operator=raw.get("operator"), + unit=raw.get("unit"), + ) + ) + else: + row.normalized_value = raw.get("normalized_value") + row.operator = raw.get("operator") + row.unit = raw.get("unit") + removed = set(existing) - seen + if removed: + self._break_mappings(filt.source_category_id, filt.external_filter_key) + + def _sync_range(self, filt: SourceFilterDefinitionRecord, raw: dict[str, Any]) -> None: + row = self.session.get(SourceFilterRangeRecord, filt.id) + if row is None: + self.session.add( + SourceFilterRangeRecord( + filter_id=filt.id, + minimum=raw.get("minimum"), + maximum=raw.get("maximum"), + step=raw.get("step"), + unit=raw.get("unit"), + ) + ) + return + row.minimum = raw.get("minimum") + row.maximum = raw.get("maximum") + row.step = raw.get("step") + row.unit = raw.get("unit") + + def _break_mappings(self, category_id: UUID, filter_key: str) -> None: + now = now_utc() + for mapping in self.session.scalars( + select(CriterionMappingRecord).where( + CriterionMappingRecord.source_category_id == category_id, + CriterionMappingRecord.external_filter_key == filter_key, + CriterionMappingRecord.status == MappingStatus.VERIFIED.value, + ) + ): + mapping.status = MappingStatus.BROKEN.value + mapping.updated_at = now + self._mark_searches_out_of_sync(category_id) + + def _mark_searches_out_of_sync(self, category_id: UUID) -> None: + now = now_utc() + for search in self.session.scalars( + select(DiscoverySearchRecord).where(DiscoverySearchRecord.source_category_id == category_id) + ): + if search.status not in {SearchStatus.BLOCKED.value, SearchStatus.DISABLED.value, SearchStatus.PAUSED.value}: + search.status = SearchStatus.OUT_OF_SYNC.value + search.updated_at = now + + def _category(self, source_id: str, category_key: str) -> SourceCategoryRecord: + category = self.session.scalars( + select(SourceCategoryRecord).where( + SourceCategoryRecord.source_id == source_id, + SourceCategoryRecord.external_category_key == category_key, + ) + ).first() + if category is None: + raise AppError(404, "NOT_FOUND", "source category not found") + return category + + +class CriterionDefinitionService: + def __init__(self, session: Session) -> None: + self.session = session + + def create(self, payload: dict[str, Any]) -> dict[str, Any]: + key = str(payload["key"]) + if get_definition_by_key(self.session, key) is not None: + raise AppError(409, "CONFLICT", "criterion definition already exists", {"key": key}) + row = upsert_definition(self.session, payload) + return self._out(row) + + def list(self, category: str | None = None) -> list[dict[str, Any]]: + stmt = select(CriterionDefinitionRecord).order_by(CriterionDefinitionRecord.key) + if category is not None: + stmt = stmt.where( + (CriterionDefinitionRecord.category == category) + | (CriterionDefinitionRecord.product_category_id == category) + | (CriterionDefinitionRecord.category.is_(None)) + ) + return [self._out(row) for row in self.session.scalars(stmt)] + + def _out(self, row: CriterionDefinitionRecord) -> dict[str, Any]: + return jsonable( + { + "id": row.id, + "key": row.key, + "origin": row.origin, + "source": row.source, + "product_category_id": row.product_category_id, + "label": row.label, + "value_type": row.value_type, + "unit": row.unit, + "semantic": row.semantic, + "category": row.category, + "description": row.description, + "scoring_attribute_key": row.scoring_attribute_key, + } + ) + + +class CriterionMappingService: + def __init__(self, session: Session) -> None: + self.session = session + + def create(self, payload: dict[str, Any]) -> dict[str, Any]: + category = self.session.get(SourceCategoryRecord, payload["source_category_id"]) + if category is None: + raise AppError(404, "NOT_FOUND", "source category not found") + definition = self._ensure_definition(payload) + filt = self.session.scalars( + select(SourceFilterDefinitionRecord).where( + SourceFilterDefinitionRecord.source_category_id == category.id, + SourceFilterDefinitionRecord.external_filter_key == str(payload["external_filter_key"]), + ) + ).first() + if filt is None: + raise AppError(422, "UNKNOWN_FILTER", "filter is not in the learned taxonomy") + now = now_utc() + encoding = payload.get("encoding") or {"url_param": "xf"} + mapping = CriterionMappingRecord( + id=uuid4(), + criterion_definition_id=definition.id, + criterion_key=definition.key, + source_id=category.source_id, + source_category_id=category.id, + external_filter_key=str(payload["external_filter_key"]), + source_value=payload.get("source_value"), + encoding=encoding, + status=str(payload.get("status") or MappingStatus.LEARNED_UNVERIFIED.value), + definition_hash=sha256_text(canonical_json({"encoding": encoding, "source_value": payload.get("source_value")})), + created_at=now, + updated_at=now, + ) + if mapping.status != MappingStatus.DISABLED.value and self._auto_verify(category, mapping, payload): + mapping.status = MappingStatus.VERIFIED.value + self.session.add(mapping) + self.session.flush() + self._mark_out_of_sync(category.id) + return self._out(mapping) + + def patch(self, mapping_id: UUID, payload: dict[str, Any]) -> dict[str, Any]: + mapping = self.session.get(CriterionMappingRecord, mapping_id) + if mapping is None: + raise AppError(404, "NOT_FOUND", "criterion mapping not found") + if "source_value" in payload: + mapping.source_value = payload["source_value"] + if "encoding" in payload: + mapping.encoding = payload["encoding"] + if "status" in payload: + mapping.status = str(payload["status"]) + mapping.definition_hash = sha256_text( + canonical_json({"encoding": mapping.encoding, "source_value": mapping.source_value}) + ) + mapping.updated_at = now_utc() + category = self.session.get(SourceCategoryRecord, mapping.source_category_id) + if category is not None and mapping.status != MappingStatus.DISABLED.value: + if self._auto_verify(category, mapping, {"probe_value": payload.get("probe_value")}): + mapping.status = MappingStatus.VERIFIED.value + self.session.flush() + if category is not None: + self._mark_out_of_sync(category.id) + return self._out(mapping) + + def list(self, source_category_id: UUID | None = None) -> list[dict[str, Any]]: + stmt = select(CriterionMappingRecord).order_by(CriterionMappingRecord.created_at) + if source_category_id is not None: + stmt = stmt.where(CriterionMappingRecord.source_category_id == source_category_id) + return [self._out(row) for row in self.session.scalars(stmt)] + + def verified_for(self, category_id: UUID) -> list[CriterionMappingRecord]: + return list( + self.session.scalars( + select(CriterionMappingRecord).where( + CriterionMappingRecord.source_category_id == category_id, + CriterionMappingRecord.status == MappingStatus.VERIFIED.value, + ) + ) + ) + + def _ensure_definition(self, payload: dict[str, Any]) -> CriterionDefinitionRecord: + if payload.get("criterion_definition_id"): + existing = get_definition(self.session, payload["criterion_definition_id"]) + if existing is None: + raise AppError(422, "UNKNOWN_CRITERION", "criterion definition not found") + return existing + key = payload.get("criterion_key") + if not key: + raise AppError(422, "UNKNOWN_CRITERION", "criterion_definition_id or criterion_key is required") + existing = get_definition_by_key(self.session, str(key)) + if existing is not None: + return existing + value_type = payload.get("value_type") + if not value_type: + raise AppError(422, "UNKNOWN_CRITERION", "criterion definition missing; provide value_type") + return upsert_definition( + self.session, + { + "key": str(key), + "origin": "SOURCE", + "source": payload.get("source_id"), + "label": str(key), + "value_type": str(value_type), + "unit": payload.get("unit"), + "semantic": payload.get("semantic"), + "category": payload.get("category"), + "description": payload.get("description"), + "scoring_attribute_key": str(key), + }, + ) + + def _auto_verify( + self, + category: SourceCategoryRecord, + mapping: CriterionMappingRecord, + payload: dict[str, Any], + ) -> bool: + criterion = { + "criterion": mapping.criterion_key, + "operator": payload.get("operator") or ">=", + "value": payload.get("probe_value") if payload.get("probe_value") is not None else mapping.source_value or 0, + } + return verify_roundtrip( + category_url=category.source_url, + criterion=criterion, + mapping={ + "criterion_key": mapping.criterion_key, + "external_filter_key": mapping.external_filter_key, + "source_value": mapping.source_value, + "encoding": mapping.encoding, + }, + ) + + def _mark_out_of_sync(self, category_id: UUID) -> None: + now = now_utc() + current = mapping_version_hash(self.verified_for(category_id)) + for search in self.session.scalars( + select(DiscoverySearchRecord).where(DiscoverySearchRecord.source_category_id == category_id) + ): + if search.mapping_version != current and search.status not in { + SearchStatus.BLOCKED.value, + SearchStatus.DISABLED.value, + SearchStatus.PAUSED.value, + }: + search.status = SearchStatus.OUT_OF_SYNC.value + search.updated_at = now + + def _out(self, row: CriterionMappingRecord) -> dict[str, Any]: + return jsonable( + { + "id": row.id, + "criterion_definition_id": row.criterion_definition_id, + "criterion_key": row.criterion_key, + "source_id": row.source_id, + "source_category_id": row.source_category_id, + "external_filter_key": row.external_filter_key, + "source_value": row.source_value, + "encoding": row.encoding, + "status": row.status, + "definition_hash": row.definition_hash, + "created_at": row.created_at, + "updated_at": row.updated_at, + } + ) + + +class DiscoverySearchService: + def __init__(self, session: Session, adapter: DiscoverySourceAdapter | None = None) -> None: + self.session = session + self.adapter = adapter + self.projects = ProjectService(session) + self.mappings = CriterionMappingService(session) + + def create(self, payload: dict[str, Any]) -> dict[str, Any]: + project = self.session.get(BuyingProjectRecord, payload["buying_project_id"]) + if project is None: + raise AppError(404, "NOT_FOUND", "buying project not found") + category = self.session.get(SourceCategoryRecord, payload["source_category_id"]) + if category is None: + raise AppError(404, "NOT_FOUND", "source category not found") + criteria = self.projects.latest_criteria(project.id) + if criteria is None: + raise AppError(422, "CRITERIA_REQUIRED", "buying project has no criteria") + now = now_utc() + interval = int(payload.get("schedule_interval_minutes") or 180) + if interval < 1: + raise AppError(422, "VALIDATION_ERROR", "schedule_interval_minutes must be > 0") + existing_active = self.session.scalars( + select(DiscoverySearchRecord).where( + DiscoverySearchRecord.buying_project_id == project.id, + DiscoverySearchRecord.source_id == category.source_id, + DiscoverySearchRecord.source_category_id == category.id, + DiscoverySearchRecord.status.in_( + (SearchStatus.ACTIVE.value, SearchStatus.OUT_OF_SYNC.value, SearchStatus.TOO_BROAD.value) + ), + ) + ).first() + if existing_active is not None: + raise AppError(409, "SEARCH_EXISTS", "an active discovery search already exists for this source") + verified = self.mappings.verified_for(category.id) + url, _internal = generate_search_url( + category_url=category.source_url, + criteria_items=self.projects.criteria_items(project.id, criteria.version), + mappings=[self._mapping_dict(row) for row in verified], + ) + search = DiscoverySearchRecord( + id=uuid4(), + buying_project_id=project.id, + source_id=category.source_id, + source_category_id=category.id, + criteria_version=criteria.version, + mapping_version=mapping_version_hash(verified), + generated_url=url, + status=SearchStatus.ACTIVE.value, + schedule_interval_minutes=interval, + broad_result_threshold=int(payload.get("broad_result_threshold") or 300), + next_run_at=now, + last_successful_run_at=None, + created_at=now, + updated_at=now, + ) + self.session.add(search) + self.session.flush() + return self.get(search.id) + + def generate_query(self, search_id: UUID) -> dict[str, Any]: + search = self._require_search(search_id) + project = self.session.get(BuyingProjectRecord, search.buying_project_id) + criteria = self.projects.latest_criteria(search.buying_project_id) + category = self.session.get(SourceCategoryRecord, search.source_category_id) + if project is None or criteria is None or category is None: + raise AppError(404, "NOT_FOUND", "search dependencies missing") + verified = self.mappings.verified_for(category.id) + url, internal_only = generate_search_url( + category_url=category.source_url, + criteria_items=self.projects.criteria_items(search.buying_project_id, criteria.version), + mappings=[self._mapping_dict(row) for row in verified], + ) + search.criteria_version = criteria.version + search.mapping_version = mapping_version_hash(verified) + search.generated_url = url + if search.status == SearchStatus.OUT_OF_SYNC.value: + search.status = SearchStatus.ACTIVE.value + search.next_run_at = now_utc() + search.updated_at = now_utc() + self.session.flush() + body = self.get(search.id) + body["internal_only"] = internal_only + return body + + def get(self, search_id: UUID) -> dict[str, Any]: + search = self._require_search(search_id) + return jsonable( + { + "id": search.id, + "buying_project_id": search.buying_project_id, + "source_id": search.source_id, + "source_category_id": search.source_category_id, + "criteria_version": search.criteria_version, + "mapping_version": search.mapping_version, + "generated_url": search.generated_url, + "status": search.status, + "schedule_interval_minutes": search.schedule_interval_minutes, + "broad_result_threshold": search.broad_result_threshold, + "next_run_at": search.next_run_at, + "last_successful_run_at": search.last_successful_run_at, + "created_at": search.created_at, + "updated_at": search.updated_at, + } + ) + + def list_for_project(self, project_id: UUID) -> list[dict[str, Any]]: + if self.session.get(BuyingProjectRecord, project_id) is None: + raise AppError(404, "NOT_FOUND", "buying project not found") + rows = self.session.scalars( + select(DiscoverySearchRecord) + .where(DiscoverySearchRecord.buying_project_id == project_id) + .order_by(DiscoverySearchRecord.created_at) + ).all() + return [self.get(row.id) for row in rows] + + def patch(self, search_id: UUID, payload: dict[str, Any]) -> dict[str, Any]: + search = self._require_search(search_id) + if "schedule_interval_minutes" in payload: + interval = int(payload["schedule_interval_minutes"]) + if interval < 1: + raise AppError(422, "VALIDATION_ERROR", "schedule_interval_minutes must be > 0") + search.schedule_interval_minutes = interval + if "status" in payload: + status = str(payload["status"]) + if status not in {SearchStatus.ACTIVE.value, SearchStatus.DISABLED.value}: + raise AppError(422, "VALIDATION_ERROR", "status may only be ACTIVE or DISABLED") + search.status = status + search.updated_at = now_utc() + self.session.flush() + return self.get(search.id) + + def list_all(self) -> list[dict[str, Any]]: + rows = self.session.scalars(select(DiscoverySearchRecord).order_by(DiscoverySearchRecord.created_at)).all() + return [self.get(row.id) for row in rows] + + def list_due(self) -> list[dict[str, Any]]: + now = now_utc() + rows = self.session.scalars( + select(DiscoverySearchRecord) + .join(BuyingProjectRecord, BuyingProjectRecord.id == DiscoverySearchRecord.buying_project_id) + .where( + DiscoverySearchRecord.next_run_at <= now, + DiscoverySearchRecord.status.in_((SearchStatus.ACTIVE.value, SearchStatus.TOO_BROAD.value)), + BuyingProjectRecord.status == ProjectStatus.ACTIVE.value, + ) + .order_by(DiscoverySearchRecord.next_run_at) + ).all() + return [self.get(row.id) for row in rows] + + def list_runs(self, search_id: UUID) -> list[dict[str, Any]]: + self._require_search(search_id) + rows = self.session.scalars( + select(SearchRunRecord) + .where(SearchRunRecord.discovery_search_id == search_id) + .order_by(SearchRunRecord.started_at.desc()) + ).all() + return [self._run_out(row) for row in rows] + + def get_run(self, run_id: UUID) -> dict[str, Any]: + run = self.session.get(SearchRunRecord, run_id) + if run is None: + raise AppError(404, "NOT_FOUND", "search run not found") + items = self.session.scalars(select(SearchRunItemRecord).where(SearchRunItemRecord.search_run_id == run.id)).all() + body = self._run_out(run) + body["items"] = [ + jsonable( + { + "id": item.id, + "source_product_reference_id": item.source_product_reference_id, + "observation_id": item.observation_id, + "diff_status": item.diff_status, + } + ) + for item in items + ] + return body + + def run(self, search_id: UUID) -> dict[str, Any]: + return SearchExecutionService(self.session, self.adapter).execute(search_id) + + def _require_search(self, search_id: UUID) -> DiscoverySearchRecord: + search = self.session.get(DiscoverySearchRecord, search_id) + if search is None: + raise AppError(404, "NOT_FOUND", "discovery search not found") + return search + + def _mapping_dict(self, row: CriterionMappingRecord) -> dict[str, Any]: + return { + "criterion_key": row.criterion_key, + "external_filter_key": row.external_filter_key, + "source_value": row.source_value, + "encoding": row.encoding, + "status": row.status, + } + + def _run_out(self, run: SearchRunRecord) -> dict[str, Any]: + return jsonable( + { + "id": run.id, + "discovery_search_id": run.discovery_search_id, + "criteria_version": run.criteria_version, + "mapping_version": run.mapping_version, + "generated_url": run.generated_url, + "started_at": run.started_at, + "finished_at": run.finished_at, + "status": run.status, + "result_count": run.result_count, + "page_count": run.page_count, + "new_count": run.new_count, + "changed_count": run.changed_count, + "unchanged_count": run.unchanged_count, + "missing_count": run.missing_count, + "http_status_summary": run.http_status_summary, + "error_code": run.error_code, + } + ) + + +class SearchExecutionService: + def __init__(self, session: Session, adapter: DiscoverySourceAdapter | None = None) -> None: + self.session = session + self.adapter = adapter + self.searches = DiscoverySearchService(session, adapter) + self.projects = ProjectService(session) + self.mappings = CriterionMappingService(session) + self.catalog = CatalogRepository(session) + + def execute(self, search_id: UUID) -> dict[str, Any]: + search = self.session.get(DiscoverySearchRecord, search_id) + if search is None: + raise AppError(404, "NOT_FOUND", "discovery search not found") + project = self.session.get(BuyingProjectRecord, search.buying_project_id) + if project is None or project.status != ProjectStatus.ACTIVE.value: + raise AppError(409, "PROJECT_INACTIVE", "buying project is not active") + if search.status == SearchStatus.OUT_OF_SYNC.value: + raise AppError(409, "SEARCH_OUT_OF_SYNC", "generate-query is required before running") + if search.status in {SearchStatus.DISABLED.value, SearchStatus.PAUSED.value}: + raise AppError(409, "SEARCH_DISABLED", "discovery search is disabled") + criteria = self.projects.criteria_version(project.id, search.criteria_version) + category = self.session.get(SourceCategoryRecord, search.source_category_id) + if criteria is None or category is None: + raise AppError(422, "SEARCH_INVALID", "search is missing criteria or category") + now = now_utc() + run = SearchRunRecord( + id=uuid4(), + discovery_search_id=search.id, + criteria_version=search.criteria_version, + mapping_version=search.mapping_version, + generated_url=search.generated_url, + started_at=now, + finished_at=None, + status=RunStatus.RUNNING.value, + result_count=0, + page_count=0, + new_count=0, + changed_count=0, + unchanged_count=0, + missing_count=0, + http_status_summary={"pages": []}, + error_code=None, + ) + self.session.add(run) + self.session.flush() + adapter = self.adapter or discovery_adapter(search.source_id) + pages: list[dict[str, Any]] = [] + items: list[dict[str, Any]] = [] + try: + pages, items, error_code = self._paginate(adapter, search) + except AppError as exc: + run.status = RunStatus.FAILED.value + run.error_code = exc.code + run.finished_at = now_utc() + search.updated_at = run.finished_at + self.session.flush() + return self.searches.get_run(run.id) + run.page_count = len(pages) + run.result_count = len(items) + run.http_status_summary = {"pages": pages} + run.error_code = error_code + run.finished_at = now_utc() + if error_code == "BLOCKED": + run.status = RunStatus.BLOCKED.value + search.status = SearchStatus.BLOCKED.value + search.next_run_at = None + elif error_code == "BACKOFF": + run.status = RunStatus.PARTIAL.value + search.next_run_at = run.finished_at + self._backoff(search.id) + elif error_code == "PARTIAL": + run.status = RunStatus.PARTIAL.value + search.next_run_at = run.finished_at + timedelta(minutes=search.schedule_interval_minutes) + else: + run.status = RunStatus.SUCCESS.value + search.last_successful_run_at = run.finished_at + search.next_run_at = run.finished_at + timedelta(minutes=search.schedule_interval_minutes) + if run.result_count > search.broad_result_threshold: + run.status = RunStatus.TOO_BROAD.value + search.status = SearchStatus.TOO_BROAD.value + elif search.status == SearchStatus.TOO_BROAD.value: + search.status = SearchStatus.ACTIVE.value + search.updated_at = run.finished_at + self.session.flush() + if run.status in {RunStatus.SUCCESS.value, RunStatus.PARTIAL.value, RunStatus.TOO_BROAD.value}: + self._persist_results(search, run, category, items) + self._apply_run_counts(run) + CandidateRankingService(self.session).refresh(project.id) + self.session.flush() + return self.searches.get_run(run.id) + + def _paginate( + self, + adapter: DiscoverySourceAdapter, + search: DiscoverySearchRecord, + ) -> tuple[list[dict[str, Any]], list[dict[str, Any]], str | None]: + query = {"url": search.generated_url} + collected: list[dict[str, Any]] = [] + pages: list[dict[str, Any]] = [] + page = 1 + expected_pages = 1 + while page <= expected_pages and page <= MAX_PAGES: + try: + response = adapter.fetch_results(query, {"page": page}) + except AppError as exc: + if exc.code == "FIXTURE_NOT_FOUND" and page > 1: + return pages, collected, "PARTIAL" + raise + status = int(response.get("http_status") or 200) + pages.append({"page": page, "status": status}) + if status == 429: + return pages, collected, "BACKOFF" + if status == 403 or response.get("challenge") == "captcha" or response.get("blocked"): + return pages, collected, "BLOCKED" + if status >= 400: + return pages, collected, "PARTIAL" + collected.extend(adapter.parse_results(response)) + expected_pages = int(response.get("page_count") or page) + page += 1 + if page > MAX_PAGES + 1 or (expected_pages > MAX_PAGES and len(pages) >= MAX_PAGES): + return pages, collected, "PARTIAL" + return pages, collected, None + + def _backoff(self, search_id: UUID) -> timedelta: + rows = self.session.scalars( + select(SearchRunRecord) + .where(SearchRunRecord.discovery_search_id == search_id) + .order_by(SearchRunRecord.started_at.desc()) + .limit(8) + ).all() + streak = 0 + for row in rows: + if row.error_code == "BACKOFF": + streak += 1 + continue + break + return timedelta(minutes=BACKOFF_BASE_MINUTES * (2 ** min(streak, 5))) + + def _persist_results( + self, + search: DiscoverySearchRecord, + run: SearchRunRecord, + category: SourceCategoryRecord, + items: list[dict[str, Any]], + ) -> None: + adapter = self.adapter or discovery_adapter(search.source_id) + known_rows = list( + self.session.scalars( + select(SourceProductReferenceRecord).where( + SourceProductReferenceRecord.source_id == search.source_id, + SourceProductReferenceRecord.source_category_id == category.id, + ) + ) + ) + known = [(row.id, self._signals_from_ref(row)) for row in known_rows] + current_ids: set[UUID] = set() + current_obs: dict[UUID, dict[str, Any]] = {} + observations: dict[UUID, ProductObservationRecord] = {} + for raw in items: + normalized = adapter.normalize_result(raw) + incoming = self._signals_from_norm(normalized) + decision = decide_identity(incoming, known) + if decision.match_status == MatchStatus.MATCHED and decision.existing_id is not None: + ref = self.session.get(SourceProductReferenceRecord, decision.existing_id) + assert ref is not None + self._touch_ref(ref, normalized, incoming, MatchStatus.MATCHED) + else: + ref = self._new_ref(search, category, normalized, incoming, decision.match_status) + known.append((ref.id, incoming)) + known_rows.append(ref) + current_ids.add(ref.id) + obs = ProductObservationRecord( + id=uuid4(), + source_product_reference_id=ref.id, + search_run_id=run.id, + observed_at=run.finished_at or now_utc(), + raw_title=str(normalized.get("source_title") or ""), + best_price=None if normalized.get("best_price") is None else Decimal(str(normalized["best_price"])), + currency=normalized.get("currency"), + availability=normalized.get("availability"), + seller_count=normalized.get("seller_count"), + ) + self.session.add(obs) + self.session.flush() + _store_observation_attributes( + self.session, + obs.id, + normalized.get("raw_attributes") or {}, + normalized.get("normalized_attributes") or {}, + ) + observations[ref.id] = obs + current_obs[ref.id] = _obs_dict(self.session, obs) + self._upsert_candidate(search.buying_project_id, ref, decision.match_status) + self.session.flush() + previous_ids, previous_obs = self._previous_success(search.id, run.id) + diffs = diff_run( + previous_ids=previous_ids, + current_ids=current_ids, + previous_obs=previous_obs, + current_obs=current_obs, + run_status=run.status, + ) + for ref_id, status in diffs.items(): + obs = observations.get(ref_id) + self.session.add( + SearchRunItemRecord( + id=uuid4(), + search_run_id=run.id, + source_product_reference_id=ref_id, + observation_id=None if obs is None else obs.id, + diff_status=status.value, + ) + ) + ref = self.session.get(SourceProductReferenceRecord, ref_id) + if ref is None: + continue + missing = run.status == RunStatus.SUCCESS.value and status == DiffStatus.MISSING + reappeared = ref_id in current_ids and ref.lifecycle_status != LifecycleStatus.ACTIVE.value + lifecycle, streak = next_lifecycle( + current=ref.lifecycle_status, + missing_in_success_run=missing, + consecutive_missing=ref.consecutive_missing_success_runs, + reappeared=reappeared or (ref_id in current_ids and status != DiffStatus.MISSING), + ) + if ref.lifecycle_status == LifecycleStatus.ARCHIVED.value: + continue + if ref_id in current_ids: + ref.lifecycle_status = LifecycleStatus.ACTIVE.value + ref.consecutive_missing_success_runs = 0 + else: + ref.lifecycle_status = lifecycle + ref.consecutive_missing_success_runs = streak + + def _apply_run_counts(self, run: SearchRunRecord) -> None: + items = self.session.scalars( + select(SearchRunItemRecord).where(SearchRunItemRecord.search_run_id == run.id) + ).all() + run.new_count = sum(1 for item in items if item.diff_status == DiffStatus.NEW.value) + run.changed_count = sum(1 for item in items if item.diff_status == DiffStatus.CHANGED.value) + run.unchanged_count = sum(1 for item in items if item.diff_status == DiffStatus.UNCHANGED.value) + run.missing_count = sum(1 for item in items if item.diff_status == DiffStatus.MISSING.value) + + def _previous_success(self, search_id: UUID, current_run_id: UUID) -> tuple[set[UUID], dict[UUID, dict[str, Any]]]: + runs = self.session.scalars( + select(SearchRunRecord) + .where( + SearchRunRecord.discovery_search_id == search_id, + SearchRunRecord.status == RunStatus.SUCCESS.value, + SearchRunRecord.id != current_run_id, + ) + .order_by(SearchRunRecord.started_at.asc()) + ).all() + known: set[UUID] = set() + latest_obs: dict[UUID, dict[str, Any]] = {} + for run in runs: + items = self.session.scalars( + select(SearchRunItemRecord).where(SearchRunItemRecord.search_run_id == run.id) + ).all() + known.update( + item.source_product_reference_id for item in items if item.diff_status != DiffStatus.MISSING.value + ) + for row in self.session.scalars( + select(ProductObservationRecord).where(ProductObservationRecord.search_run_id == run.id) + ): + latest_obs[row.source_product_reference_id] = _obs_dict(self.session, row) + return known, latest_obs + + def _new_ref( + self, + search: DiscoverySearchRecord, + category: SourceCategoryRecord, + normalized: dict[str, Any], + incoming: IdentitySignals, + match_status: MatchStatus, + ) -> SourceProductReferenceRecord: + now = now_utc() + ref = SourceProductReferenceRecord( + id=uuid4(), + source_id=search.source_id, + source_category_id=category.id, + source_external_id=normalized.get("source_external_id"), + source_url=normalized.get("source_url"), + source_title=str(normalized.get("source_title") or ""), + manufacturer=normalized.get("manufacturer"), + model=normalized.get("model"), + manufacturer_sku=normalized.get("manufacturer_sku"), + gtin_ean=normalized.get("gtin_ean"), + normalized_spec_fingerprint=incoming.fingerprint, + first_seen_at=now, + last_seen_at=now, + lifecycle_status=LifecycleStatus.ACTIVE.value, + match_status=match_status.value, + consecutive_missing_success_runs=0, + product_variant_id=None, + ) + self.session.add(ref) + self.session.flush() + return ref + + def _touch_ref( + self, + ref: SourceProductReferenceRecord, + normalized: dict[str, Any], + incoming: IdentitySignals, + match_status: MatchStatus, + ) -> None: + ref.source_title = str(normalized.get("source_title") or ref.source_title) + ref.source_url = normalized.get("source_url") or ref.source_url + ref.manufacturer = normalized.get("manufacturer") or ref.manufacturer + ref.model = normalized.get("model") or ref.model + ref.manufacturer_sku = normalized.get("manufacturer_sku") or ref.manufacturer_sku + ref.gtin_ean = normalized.get("gtin_ean") or ref.gtin_ean + ref.normalized_spec_fingerprint = incoming.fingerprint + ref.last_seen_at = now_utc() + ref.match_status = match_status.value + if ref.lifecycle_status != LifecycleStatus.ARCHIVED.value: + ref.lifecycle_status = LifecycleStatus.ACTIVE.value + ref.consecutive_missing_success_runs = 0 + + def _upsert_candidate(self, project_id: UUID, ref: SourceProductReferenceRecord, match_status: MatchStatus) -> None: + existing = self.session.scalars( + select(ProjectCandidateRecord).where( + ProjectCandidateRecord.buying_project_id == project_id, + ProjectCandidateRecord.source_product_reference_id == ref.id, + ) + ).first() + now = now_utc() + if existing is None: + candidate = ProjectCandidateRecord( + id=uuid4(), + buying_project_id=project_id, + source_product_reference_id=ref.id, + product_variant_id=ref.product_variant_id, + status=CandidateStatus.CANDIDATE.value, + match_status=match_status.value, + product_eligibility=None, + product_score=None, + created_at=now, + updated_at=now, + ) + self.session.add(candidate) + self.session.flush() + self.session.add( + CandidateStatusHistoryRecord( + id=uuid4(), + candidate_id=candidate.id, + from_status=None, + to_status=candidate.status, + reason="discovered", + created_at=now, + ) + ) + return + existing.match_status = match_status.value + existing.product_variant_id = ref.product_variant_id + existing.updated_at = now + + def _signals_from_norm(self, normalized: dict[str, Any]) -> IdentitySignals: + attrs = dict(normalized.get("normalized_attributes") or {}) + if normalized.get("manufacturer"): + attrs.setdefault("manufacturer", normalized["manufacturer"]) + if normalized.get("model"): + attrs.setdefault("model", normalized["model"]) + return IdentitySignals( + source_external_id=normalized.get("source_external_id"), + gtin_ean=normalized.get("gtin_ean"), + manufacturer_sku=normalized.get("manufacturer_sku"), + source_url=normalized.get("source_url"), + title=str(normalized.get("source_title") or ""), + manufacturer=normalized.get("manufacturer"), + model=normalized.get("model"), + fingerprint=spec_fingerprint(attrs), + ) + + def _signals_from_ref(self, row: SourceProductReferenceRecord) -> IdentitySignals: + return IdentitySignals( + source_external_id=row.source_external_id, + gtin_ean=row.gtin_ean, + manufacturer_sku=row.manufacturer_sku, + source_url=row.source_url, + title=row.source_title, + manufacturer=row.manufacturer, + model=row.model, + fingerprint=row.normalized_spec_fingerprint or "", + ) + + +class CandidateRankingService: + def __init__(self, session: Session) -> None: + self.session = session + self.catalog = CatalogRepository(session) + + def refresh(self, project_id: UUID) -> list[dict[str, Any]]: + project = self.session.get(BuyingProjectRecord, project_id) + if project is None: + raise AppError(404, "NOT_FOUND", "buying project not found") + profile = None + if project.template_id is not None and project.template_version is not None: + profile = self.catalog.get_profile(project.template_id, project.template_version) + model = self.catalog.get_scoring_model(profile.scoring_version) if profile is not None else None + if profile is None or model is None: + return self.list_candidates(project_id) + definitions = self.catalog.all_definitions() + candidates = self.session.scalars( + select(ProjectCandidateRecord).where(ProjectCandidateRecord.buying_project_id == project_id) + ).all() + payload: list[dict[str, Any]] = [] + for candidate in candidates: + ref = self.session.get(SourceProductReferenceRecord, candidate.source_product_reference_id) + if ref is None or ref.lifecycle_status == LifecycleStatus.ARCHIVED.value: + continue + obs = self.session.scalars( + select(ProductObservationRecord) + .where(ProductObservationRecord.source_product_reference_id == ref.id) + .order_by(ProductObservationRecord.observed_at.desc()) + ).first() + payload.append( + { + "candidate_id": candidate.id, + "source_product_reference_id": ref.id, + "manufacturer": None if ref is None else ref.manufacturer, + "model": None if ref is None else ref.model, + "source_title": None if ref is None else ref.source_title, + "normalized_attributes": {} if obs is None else _attribute_map(self.session, obs.id), + "lifecycle_status": ref.lifecycle_status, + "match_status": candidate.match_status, + "best_price": None if obs is None else obs.best_price, + } + ) + ranked = rank_candidates( + profile=profile, + scoring_model=model, + definitions=definitions, + candidates=payload, + shortlist_size=project.shortlist_size, + as_of=now_utc(), + ) + by_id = {item["candidate_id"]: item for item in ranked} + now = now_utc() + for candidate in candidates: + item = by_id.get(candidate.id) + if item is None: + continue + new_status = item["candidate_status"] + if candidate.status != new_status: + self.session.add( + CandidateStatusHistoryRecord( + id=uuid4(), + candidate_id=candidate.id, + from_status=candidate.status, + to_status=new_status, + reason="ranking", + created_at=now, + ) + ) + candidate.status = new_status + candidate.product_eligibility = item["product_eligibility"] + candidate.product_score = item["product_score"] + candidate.updated_at = now + self.session.flush() + return ranked + + def list_candidates(self, project_id: UUID, shortlisted_only: bool = False) -> list[dict[str, Any]]: + project = self.session.get(BuyingProjectRecord, project_id) + if project is None: + raise AppError(404, "NOT_FOUND", "buying project not found") + stmt = select(ProjectCandidateRecord).where(ProjectCandidateRecord.buying_project_id == project_id) + if shortlisted_only: + stmt = stmt.where(ProjectCandidateRecord.status == CandidateStatus.SHORTLISTED.value) + rows = self.session.scalars(stmt.order_by(ProjectCandidateRecord.product_score.desc().nullslast())).all() + return [self._candidate_summary(project, row) for row in rows] + + def get_candidate(self, project_id: UUID, candidate_id: UUID) -> dict[str, Any]: + project = self.session.get(BuyingProjectRecord, project_id) + candidate = self.session.get(ProjectCandidateRecord, candidate_id) + if project is None or candidate is None or candidate.buying_project_id != project_id: + raise AppError(404, "NOT_FOUND", "candidate not found") + summary = self._candidate_summary(project, candidate) + ref = self.session.get(SourceProductReferenceRecord, candidate.source_product_reference_id) + obs = None + if ref is not None: + obs = self.session.scalars( + select(ProductObservationRecord) + .where(ProductObservationRecord.source_product_reference_id == ref.id) + .order_by(ProductObservationRecord.observed_at.desc()) + ).first() + summary.update( + { + "normalized_attributes": {} if obs is None else _attribute_map(self.session, obs.id), + "source": None + if ref is None + else { + "source_id": ref.source_id, + "source_category_id": ref.source_category_id, + "source_external_id": ref.source_external_id, + "source_url": ref.source_url, + }, + "current_observation": None if obs is None else _observation_out(self.session, obs), + } + ) + return jsonable(summary) + + def list_observations(self, project_id: UUID, candidate_id: UUID) -> list[dict[str, Any]]: + candidate = self.session.get(ProjectCandidateRecord, candidate_id) + if candidate is None or candidate.buying_project_id != project_id: + raise AppError(404, "NOT_FOUND", "candidate not found") + rows = self.session.scalars( + select(ProductObservationRecord) + .where(ProductObservationRecord.source_product_reference_id == candidate.source_product_reference_id) + .order_by(ProductObservationRecord.observed_at.desc()) + ).all() + return [_observation_out(self.session, row) for row in rows] + + def _candidate_summary(self, project: BuyingProjectRecord, row: ProjectCandidateRecord) -> dict[str, Any]: + ref = self.session.get(SourceProductReferenceRecord, row.source_product_reference_id) + obs = None + last_item = None + if ref is not None: + obs = self.session.scalars( + select(ProductObservationRecord) + .where(ProductObservationRecord.source_product_reference_id == ref.id) + .order_by(ProductObservationRecord.observed_at.desc()) + ).first() + last_item = self.session.scalars( + select(SearchRunItemRecord) + .where(SearchRunItemRecord.source_product_reference_id == ref.id) + .order_by(SearchRunItemRecord.id.desc()) + ).first() + ranking_status = ( + RankingStatus.NOT_CONFIGURED.value if project.template_id is None else RankingStatus.CONFIGURED.value + ) + return jsonable( + { + "id": row.id, + "candidate_id": row.id, + "buying_project_id": row.buying_project_id, + "source_product_reference_id": row.source_product_reference_id, + "product_variant_id": row.product_variant_id, + "title": None if ref is None else ref.source_title, + "source_title": None if ref is None else ref.source_title, + "manufacturer": None if ref is None else ref.manufacturer, + "model": None if ref is None else ref.model, + "source_url": None if ref is None else ref.source_url, + "current_best_price": None if obs is None else obs.best_price, + "currency": None if obs is None else obs.currency, + "current_dynamic_attributes": {} if obs is None else _dynamic_attributes(self.session, obs.id), + "product_score": row.product_score, + "product_eligibility": row.product_eligibility, + "ranking_status": ranking_status, + "shortlist_status": row.status, + "status": row.status, + "current_diff_status": None if last_item is None else last_item.diff_status, + "product_lifecycle_status": None if ref is None else ref.lifecycle_status, + "lifecycle_status": None if ref is None else ref.lifecycle_status, + "match_status": row.match_status, + "first_seen_at": None if ref is None else ref.first_seen_at, + "last_seen_at": None if ref is None else ref.last_seen_at, + "created_at": row.created_at, + "updated_at": row.updated_at, + } + ) + + +def _attribute_map(session: Session, observation_id: UUID) -> dict[str, Any]: + rows = session.scalars( + select(ProductObservationAttributeRecord).where( + ProductObservationAttributeRecord.observation_id == observation_id + ) + ).all() + mapped: dict[str, Any] = {} + for row in rows: + definition = None if row.criterion_definition_id is None else session.get(CriterionDefinitionRecord, row.criterion_definition_id) + key = row.attribute_key + if definition is not None and definition.scoring_attribute_key: + key = definition.scoring_attribute_key + mapped[key] = row.normalized_value + return mapped + + +def _dynamic_attributes(session: Session, observation_id: UUID) -> list[dict[str, Any]]: + rows = session.scalars( + select(ProductObservationAttributeRecord).where( + ProductObservationAttributeRecord.observation_id == observation_id + ) + ).all() + return [ + { + "criterion_definition_id": row.criterion_definition_id, + "raw_name": row.raw_name or row.attribute_key, + "raw_value": row.raw_value, + "normalized_value": row.normalized_value, + "unit": row.unit, + } + for row in rows + ] + + +def _store_observation_attributes( + session: Session, + observation_id: UUID, + raw_attributes: dict[str, Any], + normalized_attributes: dict[str, Any], +) -> None: + keys = set(raw_attributes) | set(normalized_attributes) + definitions = { + row.scoring_attribute_key or row.key: row + for row in session.scalars(select(CriterionDefinitionRecord)).all() + if row.scoring_attribute_key or row.key + } + attr_maps = { + row.raw_name: row + for row in session.scalars(select(SourceAttributeMappingRecord)).all() + } + for key in keys: + mapping = attr_maps.get(key) + definition = None if mapping is None else session.get(CriterionDefinitionRecord, mapping.criterion_definition_id) + if definition is None: + definition = definitions.get(key) + session.add( + ProductObservationAttributeRecord( + id=uuid4(), + observation_id=observation_id, + criterion_definition_id=None if definition is None else definition.id, + attribute_key=key, + raw_name=key, + raw_value=raw_attributes.get(key), + normalized_value=normalized_attributes.get(key), + unit=None if definition is None else definition.unit, + ) + ) + + +def _obs_dict(session: Session, row: ProductObservationRecord) -> dict[str, Any]: + return { + "best_price": row.best_price, + "availability": row.availability, + "seller_count": row.seller_count, + "normalized_attributes": _attribute_map(session, row.id), + } + + +def _observation_out(session: Session, row: ProductObservationRecord) -> dict[str, Any]: + return jsonable( + { + "observation_id": row.id, + "observed_at": row.observed_at, + "best_price": row.best_price, + "currency": row.currency, + "availability": row.availability, + "seller_count": row.seller_count, + "dynamic_attributes": _dynamic_attributes(session, row.id), + "search_run_id": row.search_run_id, + } + ) + + +class SourceAttributeMappingService: + def __init__(self, session: Session) -> None: + self.session = session + + def list(self) -> list[dict[str, Any]]: + rows = self.session.scalars(select(SourceAttributeMappingRecord).order_by(SourceAttributeMappingRecord.raw_name)).all() + return [self._out(row) for row in rows] + + def create(self, payload: dict[str, Any]) -> dict[str, Any]: + definition = get_definition(self.session, payload["criterion_definition_id"]) + if definition is None: + raise AppError(404, "NOT_FOUND", "criterion definition not found") + now = now_utc() + row = SourceAttributeMappingRecord( + id=uuid4(), + source_id=str(payload["source_id"]), + source_category_id=payload.get("source_category_id"), + raw_name=str(payload["raw_name"]), + criterion_definition_id=definition.id, + status=str(payload.get("status") or MappingStatus.LEARNED_UNVERIFIED.value), + created_at=now, + updated_at=now, + ) + self.session.add(row) + self.session.flush() + return self._out(row) + + def patch(self, mapping_id: UUID, payload: dict[str, Any]) -> dict[str, Any]: + row = self.session.get(SourceAttributeMappingRecord, mapping_id) + if row is None: + raise AppError(404, "NOT_FOUND", "source attribute mapping not found") + if "raw_name" in payload: + row.raw_name = str(payload["raw_name"]) + if "criterion_definition_id" in payload: + row.criterion_definition_id = payload["criterion_definition_id"] + if "status" in payload: + row.status = str(payload["status"]) + row.updated_at = now_utc() + self.session.flush() + return self._out(row) + + def _out(self, row: SourceAttributeMappingRecord) -> dict[str, Any]: + return jsonable( + { + "id": row.id, + "source_id": row.source_id, + "source_category_id": row.source_category_id, + "raw_name": row.raw_name, + "criterion_definition_id": row.criterion_definition_id, + "status": row.status, + "created_at": row.created_at, + "updated_at": row.updated_at, + } + ) + diff --git a/backend/app/services/evaluation_service.py b/backend/app/services/evaluation_service.py new file mode 100644 index 0000000..7773e91 --- /dev/null +++ b/backend/app/services/evaluation_service.py @@ -0,0 +1,210 @@ +from datetime import datetime +from decimal import Decimal +from typing import Any +from uuid import UUID, uuid4 + +from sqlalchemy.orm import Session + +from app.clock import now_utc +from app.domain.serialize import jsonable +from app.domain.timeutil import require_utc, to_iso_z +from app.errors import AppError +from app.persistence import models as orm +from app.persistence.db import engine +from app.persistence.repositories import CatalogRepository +from app.scoring.engine import evaluate_deal +from app.scoring.mathutil import round_score +from app.settings import get_settings +from sqlalchemy.orm import Session as SASession +from sqlalchemy.orm import sessionmaker + + +def evaluate_and_store( + *, + variant_id: UUID, + payload: dict[str, Any], +) -> dict[str, Any]: + now = now_utc() + as_of = payload.get("as_of") + if as_of is None: + as_of = now + else: + as_of = require_utc(as_of, "as_of") + if as_of > now: + raise AppError(422, "AS_OF_IN_FUTURE", "as_of must not be in the future") + snapshot_cutoff = now + + maker = sessionmaker(bind=engine, autoflush=False, autocommit=False) + with engine.connect().execution_options(isolation_level="REPEATABLE READ") as connection: + with SASession(bind=connection) as session: + with session.begin(): + repo = CatalogRepository(session) + variant = repo.get_variant(variant_id) + if variant is None: + raise AppError(404, "NOT_FOUND", "variant not found", {"id": str(variant_id)}) + offer = repo.get_offer(payload["offer_id"]) + if offer is None: + raise AppError(404, "NOT_FOUND", "offer not found", {"id": str(payload["offer_id"])}) + if offer.variant_id != variant_id: + raise AppError(422, "OFFER_VARIANT_MISMATCH", "offer does not belong to the variant") + product = repo.get_product(variant.product_id) + if product is None: + raise AppError(404, "NOT_FOUND", "product not found") + profile = repo.get_profile(payload["profile_id"], int(payload["profile_version"])) + if profile is None: + raise AppError(404, "NOT_FOUND", "profile version not found") + model = repo.get_scoring_model(profile.scoring_version) + if model is None: + raise AppError(422, "UNKNOWN_SCORING_VERSION", "scoring version is missing") + attributes = tuple(repo.list_attributes(variant_id)) + observations, _ = repo.list_observations(offer.id, limit=None) + result = evaluate_deal( + profile=profile, + scoring_model=model, + product=product, + variant=variant, + offer=offer, + attributes=attributes, + observations=tuple(observations), + definitions=repo.all_definitions(), + as_of=as_of, + snapshot_cutoff=snapshot_cutoff, + engine_build=get_settings().engine_build, + ) + response = evaluation_to_response(result, evaluation_id=uuid4(), created_at=now) + record = orm.EvaluationRecord( + id=response["id"], + variant_id=variant.id, + offer_id=offer.id, + profile_id=profile.id, + profile_version=profile.version, + scoring_version=model.version, + as_of=as_of, + snapshot_cutoff=snapshot_cutoff, + scoring_hash=model.definition_hash, + engine_build=result.engine_build, + product_eligibility=result.product_eligibility.value, + offer_eligibility=result.offer_eligibility.value, + overall_eligibility=result.overall_eligibility.value, + product_score=result.product_score, + price_score=result.price_score, + deal_score=result.deal_score, + classification=result.classification, + price_reference_scope=result.price_reference_scope.value, + input_snapshot=result.input_snapshot, + result_snapshot=response, + created_at=now, + ) + repo.add_evaluation(record) + for component in result.components: + repo.add_component( + orm.EvaluationComponentRecord( + id=uuid4(), + evaluation_id=record.id, + kind=component.kind, + component_key=component.component_key, + raw_score=component.raw_score, + weight=component.weight, + contribution=component.contribution, + details=jsonable(component.details), + ) + ) + return response + + +def load_evaluation(session: Session, evaluation_id: UUID) -> dict[str, Any]: + record = CatalogRepository(session).get_evaluation(evaluation_id) + if record is None: + raise AppError(404, "NOT_FOUND", "evaluation not found", {"id": str(evaluation_id)}) + return record.result_snapshot + + +def evaluation_to_response(result, *, evaluation_id: UUID, created_at: datetime) -> dict[str, Any]: + stats = result.price_statistics + return { + "id": str(evaluation_id), + "profile_id": result.profile_id, + "profile_version": result.profile_version, + "scoring_version": result.scoring_version, + "variant_id": str(result.variant_id), + "offer_id": str(result.offer_id), + "as_of": to_iso_z(result.as_of), + "snapshot_cutoff": to_iso_z(result.snapshot_cutoff), + "scoring_hash": result.scoring_hash, + "engine_build": result.engine_build, + "product_eligibility": result.product_eligibility.value, + "offer_eligibility": result.offer_eligibility.value, + "overall_eligibility": result.overall_eligibility.value, + "eligible": result.eligible, + "product_score": _num(result.product_score), + "price_score": _num(result.price_score), + "deal_score": _num(result.deal_score), + "classification": result.classification, + "price_basis": None if result.price_basis is None else result.price_basis.value, + "product_coverage": _num(result.product_coverage), + "failed_constraints": [_constraint(item) for item in result.failed_constraints], + "unknown_constraints": [_constraint(item) for item in result.unknown_constraints], + "constraint_results": [_constraint(item) for item in result.constraint_results], + "product_gates": [_gate(item) for item in result.product_gates], + "offer_gates": [_gate(item) for item in result.offer_gates], + "components": [_component(item) for item in result.components], + "price_statistics": { + "current_total": _money(stats.current_total), + "median_30d": _money(stats.median_30d), + "median_30d_days": stats.median_30d_days, + "median_90d": _money(stats.median_90d), + "median_90d_days": stats.median_90d_days, + "lowest_365d": _money(stats.lowest_365d), + "lowest_365d_days": stats.lowest_365d_days, + "lowest_365d_is_full_year": stats.lowest_365d_is_full_year, + "used_observation_ids": [str(item) for item in stats.used_observation_ids], + "current_observation_id": None if stats.current_observation_id is None else str(stats.current_observation_id), + }, + "price_reference_scope": result.price_reference_scope.value, + "reasons": [{"code": item.code, "message": item.message} for item in result.reasons], + "warnings": list(result.warnings), + "input_snapshot": result.input_snapshot, + "created_at": to_iso_z(created_at), + } + + +def _num(value: Decimal | None) -> float | None: + return None if value is None else float(round_score(value)) + + +def _money(value: Decimal | None) -> str | None: + return None if value is None else format(value.quantize(Decimal("0.01")), "f") + + +def _constraint(item) -> dict[str, Any]: + return { + "constraint_id": item.constraint_id, + "attribute": item.attribute, + "operator": item.operator, + "expected": jsonable(item.expected), + "actual": jsonable(item.actual), + "status": item.status, + "mandatory": item.mandatory, + "attribute_row_id": None if item.attribute_row_id is None else str(item.attribute_row_id), + "reason_code": item.reason_code, + } + + +def _gate(item) -> dict[str, Any]: + return { + "key": item.key, + "status": item.status, + "reason_code": item.reason_code, + "details": jsonable(item.details), + } + + +def _component(item) -> dict[str, Any]: + return { + "kind": item.kind, + "component_key": item.component_key, + "raw_score": None if item.raw_score is None else float(round_score(item.raw_score)), + "weight": None if item.weight is None else float(item.weight), + "contribution": None if item.contribution is None else float(round_score(item.contribution)), + "details": jsonable(item.details), + } diff --git a/backend/app/services/import_service.py b/backend/app/services/import_service.py new file mode 100644 index 0000000..8b1b0cf --- /dev/null +++ b/backend/app/services/import_service.py @@ -0,0 +1,94 @@ +from app.errors import AppError +from app.services.ingestion_service import IngestionService +from app.sources.base import ProductSource + + +class ImportService: + def __init__(self, ingestion: IngestionService) -> None: + self.ingestion = ingestion + + def import_product(self, source: ProductSource, external_id: str) -> None: + product = source.get_product(external_id) + try: + self.ingestion.create_product( + { + "id": product.id, + "category": product.category, + "brand": product.brand, + "name": product.name, + "model": product.model, + } + ) + except AppError as exc: + if exc.status_code != 409: + raise + for variant in product.variants: + try: + self.ingestion.create_variant( + product.id, + { + "id": variant.id, + "name": variant.name, + "manufacturer_sku": variant.manufacturer_sku, + "identifiers": variant.identifiers, + }, + ) + except AppError as exc: + if exc.status_code != 409: + raise + for attribute in variant.attributes: + try: + self.ingestion.add_attribute( + variant.id, + { + "id": attribute.id, + "attribute": attribute.attribute, + "raw_value": attribute.raw_value, + "normalized_value": attribute.normalized_value, + "unit": attribute.unit, + "normalization_status": attribute.normalization_status, + "normalizer_version": attribute.normalizer_version, + "confidence": attribute.confidence, + "source_id": source.source_key, + "source_reference": attribute.source_reference, + "observed_at": attribute.observed_at, + "normalization_error": attribute.normalization_error, + }, + ) + except AppError as exc: + if exc.status_code != 409: + raise + for offer in source.get_offers(external_id): + try: + self.ingestion.create_offer( + offer.variant_id, + { + "id": offer.id, + "source_id": source.source_key, + "external_id": offer.external_id, + "seller": offer.seller, + "url": offer.url, + "market": offer.market, + "currency": offer.currency, + "condition": offer.condition, + }, + ) + except AppError as exc: + if exc.status_code != 409: + raise + for price in source.get_price_history(offer.external_id): + self.ingestion.add_price( + offer.id, + { + "id": price.id, + "source_id": source.source_key, + "external_observation_id": price.external_observation_id, + "observed_at": price.observed_at, + "item_price": price.item_price, + "shipping_price": price.shipping_price, + "currency": price.currency, + "availability": price.availability, + "raw_payload": price.raw_payload, + "confidence": price.confidence, + }, + ) diff --git a/backend/app/services/ingestion_service.py b/backend/app/services/ingestion_service.py new file mode 100644 index 0000000..843fa51 --- /dev/null +++ b/backend/app/services/ingestion_service.py @@ -0,0 +1,263 @@ +from datetime import datetime +from decimal import Decimal +from typing import Any +from uuid import UUID, uuid4 + +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from app.domain.enums import Availability, NormalizationStatus, OfferCondition, ValueType +from app.domain.models import AttributeDefinition, AttributeStatement, Offer, PriceObservation, Product, ProductVariant +from app.clock import now_utc +from app.domain.timeutil import require_utc +from app.domain.validation import DomainValidationError, parse_profile, validate_attribute_key, validate_currency +from app.errors import AppError +from app.normalization.manual import ManualNormalizer +from app.persistence import models as orm +from app.persistence.mapping import to_observation +from app.persistence.repositories import CatalogRepository + + +def _now() -> datetime: + return now_utc() + + +def _uuid(value: UUID | None) -> UUID: + return value or uuid4() + + +class IngestionService: + def __init__(self, session: Session) -> None: + self.session = session + self.repo = CatalogRepository(session) + self.normalizer = ManualNormalizer() + + def create_definition(self, payload: dict[str, Any]) -> AttributeDefinition: + key = str(payload["key"]) + validate_attribute_key(key) + row = orm.AttributeDefinitionRecord( + key=key, + value_type=ValueType(payload["value_type"]).value, + canonical_unit=payload.get("canonical_unit"), + minimum=payload.get("minimum"), + maximum=payload.get("maximum"), + description=payload.get("description"), + ) + if self.repo.get_definition(key) is not None: + raise AppError(409, "CONFLICT", "attribute definition already exists", {"key": key}) + return self.repo.add_definition(row) + + def create_profile(self, payload: dict[str, Any]) -> orm.ProductProfileRecord: + try: + model_versions = self.repo.scoring_versions() + scoring_version = payload.get("scoring_version") + model = self.repo.get_scoring_model(scoring_version) if scoring_version else None + if model is None: + raise AppError(422, "UNKNOWN_SCORING_VERSION", "scoring version does not exist") + profile = parse_profile( + payload, + attribute_definitions=self.repo.all_definitions(), + scoring_versions=model_versions, + supported_strategies=set(model.supported_preference_strategies), + ) + except DomainValidationError as exc: + raise AppError(422, "PROFILE_INVALID", str(exc), exc.details) from exc + except AppError: + raise + row = orm.ProductProfileRecord( + id=profile.id, + version=profile.version, + name=profile.name, + category=profile.category, + scoring_version=profile.scoring_version, + definition=profile.definition, + definition_hash=profile.definition_hash, + created_at=_now(), + ) + if self.repo.get_profile(profile.id, profile.version) is not None: + raise AppError(409, "CONFLICT", "profile version already exists", {"id": profile.id, "version": profile.version}) + return self.repo.add_profile(row) + + def create_product(self, payload: dict[str, Any]) -> Product: + product_id = _uuid(payload.get("id")) + if self.repo.get_product(product_id) is not None: + raise AppError(409, "CONFLICT", "product already exists", {"id": str(product_id)}) + row = orm.ProductRecord( + id=product_id, + category=str(payload["category"]), + brand=str(payload["brand"]), + name=str(payload["name"]), + model=payload.get("model"), + created_at=_now(), + ) + return self.repo.add_product(row) + + def create_variant(self, product_id: UUID, payload: dict[str, Any]) -> ProductVariant: + if self.repo.get_product(product_id) is None: + raise AppError(404, "NOT_FOUND", "product not found", {"id": str(product_id)}) + variant_id = _uuid(payload.get("id")) + if self.repo.get_variant(variant_id) is not None: + raise AppError(409, "CONFLICT", "variant already exists", {"id": str(variant_id)}) + row = orm.ProductVariantRecord( + id=variant_id, + product_id=product_id, + name=str(payload["name"]), + manufacturer_sku=payload.get("manufacturer_sku"), + identifiers=payload.get("identifiers") or {}, + created_at=_now(), + ) + return self.repo.add_variant(row) + + def add_attribute(self, variant_id: UUID, payload: dict[str, Any]) -> AttributeStatement: + variant = self.repo.get_variant(variant_id) + if variant is None: + raise AppError(404, "NOT_FOUND", "variant not found", {"id": str(variant_id)}) + definition = self.repo.get_definition(payload["attribute"]) + if definition is None: + raise AppError(422, "UNKNOWN_ATTRIBUTE", "attribute definition does not exist") + if self.repo.get_source(payload["source_id"]) is None: + raise AppError(422, "UNKNOWN_SOURCE", "source does not exist") + observed_at = require_utc(payload["observed_at"], "observed_at") + ingested_at = _now() + if observed_at > ingested_at: + raise AppError(422, "FUTURE_OBSERVATION", "observed_at must not be after ingestion") + confidence = Decimal(str(payload["confidence"])) + if not Decimal("0") <= confidence <= Decimal("1"): + raise AppError(422, "CONFIDENCE_RANGE", "confidence must be between 0 and 1") + statement = AttributeStatement( + id=_uuid(payload.get("id")), + variant_id=variant_id, + attribute=definition.key, + raw_value=payload["raw_value"], + normalized_value=payload.get("normalized_value"), + unit=payload.get("unit"), + normalization_status=NormalizationStatus(payload["normalization_status"]), + normalizer_version=payload.get("normalizer_version") or self.normalizer.version, + confidence=confidence, + source_id=payload["source_id"], + source_reference=payload["source_reference"], + observed_at=observed_at, + ingested_at=ingested_at, + normalization_error=payload.get("normalization_error"), + ) + statement = self.normalizer.validate(definition, statement) + if self.repo.get_attribute(statement.id) is not None: + raise AppError(409, "CONFLICT", "attribute statement already exists", {"id": str(statement.id)}) + row = orm.VariantAttributeRecord( + id=statement.id, + variant_id=variant_id, + attribute_key=statement.attribute, + source_id=statement.source_id, + raw_value=statement.raw_value, + normalized_value=statement.normalized_value, + unit=statement.unit, + normalization_status=statement.normalization_status.value, + normalizer_version=statement.normalizer_version, + confidence=statement.confidence, + source_reference=statement.source_reference, + observed_at=statement.observed_at, + ingested_at=statement.ingested_at, + normalization_error=statement.normalization_error, + ) + return self.repo.add_attribute(row) + + def create_offer(self, variant_id: UUID, payload: dict[str, Any]) -> Offer: + if self.repo.get_variant(variant_id) is None: + raise AppError(404, "NOT_FOUND", "variant not found", {"id": str(variant_id)}) + if self.repo.get_source(payload["source_id"]) is None: + raise AppError(422, "UNKNOWN_SOURCE", "source does not exist") + offer_id = _uuid(payload.get("id")) + if self.repo.get_offer(offer_id) is not None: + raise AppError(409, "CONFLICT", "offer already exists", {"id": str(offer_id)}) + if self.repo.get_offer_by_external(payload["source_id"], str(payload["external_id"])) is not None: + raise AppError(409, "CONFLICT", "offer source/external_id already exists") + row = orm.OfferRecord( + id=offer_id, + variant_id=variant_id, + source_id=payload["source_id"], + external_id=str(payload["external_id"]), + seller=str(payload["seller"]), + url=payload.get("url"), + market=str(payload["market"]), + currency=validate_currency(str(payload["currency"])), + condition=OfferCondition(payload["condition"]).value, + created_at=_now(), + ) + return self.repo.add_offer(row) + + def add_price(self, offer_id: UUID, payload: dict[str, Any]) -> tuple[PriceObservation, bool]: + offer = self.repo.get_offer(offer_id) + if offer is None: + raise AppError(404, "NOT_FOUND", "offer not found", {"id": str(offer_id)}) + if payload["source_id"] != offer.source_id: + raise AppError(422, "SOURCE_MISMATCH", "source_id must match the offer") + currency = validate_currency(str(payload["currency"])) + if currency != offer.currency: + raise AppError(422, "CURRENCY_MISMATCH", "currency must match the offer") + observed_at = require_utc(payload["observed_at"], "observed_at") + ingested_at = _now() + if observed_at > ingested_at: + raise AppError(422, "FUTURE_OBSERVATION", "observed_at must not be after ingestion") + item_price = Decimal(str(payload["item_price"])) + if item_price <= 0: + raise AppError(422, "ITEM_PRICE", "item_price must be > 0") + shipping = payload.get("shipping_price") + shipping_price = None if shipping is None else Decimal(str(shipping)) + if shipping_price is not None and shipping_price < 0: + raise AppError(422, "SHIPPING_PRICE", "shipping_price must be >= 0") + confidence = Decimal(str(payload["confidence"])) + if not Decimal("0") <= confidence <= Decimal("1"): + raise AppError(422, "CONFIDENCE_RANGE", "confidence must be between 0 and 1") + external_id = str(payload["external_observation_id"]) + existing = self.repo.get_observation_by_external(offer_id, external_id) + incoming = { + "source_id": payload["source_id"], + "observed_at": observed_at, + "item_price": item_price, + "shipping_price": shipping_price, + "currency": currency, + "availability": Availability(payload["availability"]).value, + "raw_payload": payload["raw_payload"], + "confidence": confidence, + } + if existing is not None: + if _observation_content(existing) != incoming: + raise AppError(409, "CONFLICT", "price observation content differs") + return to_observation(existing), False + row = orm.PriceObservationRecord( + id=_uuid(payload.get("id")), + offer_id=offer_id, + source_id=payload["source_id"], + external_observation_id=external_id, + observed_at=observed_at, + ingested_at=ingested_at, + item_price=item_price, + shipping_price=shipping_price, + currency=currency, + availability=incoming["availability"], + raw_payload=payload["raw_payload"], + confidence=confidence, + ) + try: + return self.repo.add_observation(row), True + except IntegrityError: + self.session.rollback() + existing = self.repo.get_observation_by_external(offer_id, external_id) + if existing is None: + raise + if _observation_content(existing) != incoming: + raise AppError(409, "CONFLICT", "price observation content differs") + return to_observation(existing), False + + +def _observation_content(row: orm.PriceObservationRecord) -> dict[str, Any]: + return { + "source_id": row.source_id, + "observed_at": row.observed_at, + "item_price": Decimal(str(row.item_price)), + "shipping_price": None if row.shipping_price is None else Decimal(str(row.shipping_price)), + "currency": row.currency, + "availability": row.availability, + "raw_payload": row.raw_payload, + "confidence": Decimal(str(row.confidence)), + } diff --git a/backend/app/services/project_service.py b/backend/app/services/project_service.py new file mode 100644 index 0000000..80a5014 --- /dev/null +++ b/backend/app/services/project_service.py @@ -0,0 +1,346 @@ +from __future__ import annotations + +from decimal import Decimal +from typing import Any +from uuid import UUID, uuid4 + +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from app.clock import now_utc +from app.discovery.criteria import criterion_payload, parse_project_criteria +from app.discovery.definitions import get_definition, get_definition_by_key, upsert_definition +from app.discovery.enums import CandidateStatus, DiffStatus, ProjectStatus, RankingStatus, SearchStatus +from app.domain.serialize import jsonable +from app.domain.validation import DomainValidationError +from app.errors import AppError +from app.persistence.discovery_models import ( + BuyingProjectCriteriaRecord, + BuyingProjectRecord, + DiscoverySearchRecord, + ProductCategoryRecord, + ProjectCandidateRecord, + ProjectCriterionRecord, + SearchRunItemRecord, + SearchRunRecord, +) +from app.persistence.repositories import CatalogRepository +from app.services.catalog_service import CatalogService, ensure_product_category + + +INACTIVE_SEARCH = {SearchStatus.BLOCKED.value, SearchStatus.DISABLED.value, SearchStatus.PAUSED.value} + + +def _uuid(value: UUID | None) -> UUID: + return value or uuid4() + + +class ProjectService: + def __init__(self, session: Session) -> None: + self.session = session + self.catalog = CatalogRepository(session) + self.categories = CatalogService(session) + + def create(self, payload: dict[str, Any]) -> dict[str, Any]: + template_id = payload.get("template_id") or payload.get("profile_id") + template_version = payload.get("template_version") or payload.get("profile_version") + profile = None + if template_id is not None: + if template_version is None: + raise AppError(422, "VALIDATION_ERROR", "template_version is required when template_id is set") + profile = self.catalog.get_profile(str(template_id), int(template_version)) + if profile is None: + raise AppError(404, "NOT_FOUND", "template version not found") + product_category_id = payload.get("product_category_id") or (None if profile is None else profile.category) + if not product_category_id: + raise AppError(422, "VALIDATION_ERROR", "product_category_id is required") + if self.session.get(ProductCategoryRecord, product_category_id) is None: + ensure_product_category(self.session, str(product_category_id)) + market = payload.get("market") or (None if profile is None else profile.market) + currency = payload.get("currency") or (None if profile is None else profile.currency) + if not market or not currency: + raise AppError(422, "VALIDATION_ERROR", "market and currency are required") + target_budget = payload.get("target_budget") + if target_budget is None and profile is not None and getattr(profile, "budget", None) is not None: + target_budget = getattr(profile.budget, "target", None) + criteria_payload = payload.get("criteria") + if not criteria_payload: + raise AppError(422, "CRITERIA_REQUIRED", "criteria are required") + try: + parsed = parse_project_criteria(criteria_payload) + except DomainValidationError as exc: + raise AppError(422, "CRITERIA_INVALID", str(exc), exc.details) from exc + shortlist_size = int(payload.get("shortlist_size") or 5) + if shortlist_size < 1: + raise AppError(422, "VALIDATION_ERROR", "shortlist_size must be > 0") + now = now_utc() + project = BuyingProjectRecord( + id=_uuid(payload.get("id")), + name=str(payload["name"]), + status=ProjectStatus.ACTIVE.value, + workspace_id=payload.get("workspace_id"), + product_category_id=str(product_category_id), + market=str(market), + currency=str(currency), + target_budget=None if target_budget is None else Decimal(str(target_budget)), + template_id=None if profile is None else profile.id, + template_version=None if profile is None else profile.version, + shortlist_size=shortlist_size, + criteria_version=1, + created_at=now, + updated_at=now, + archived_at=None, + ) + self.session.add(project) + self.session.flush() + self._store_criteria(project.id, 1, parsed, str(product_category_id), now) + return self.get(project.id) + + def replace_criteria(self, project_id: UUID, payload: dict[str, Any]) -> dict[str, Any]: + project = self._require_project(project_id) + try: + parsed = parse_project_criteria(payload) + except DomainValidationError as exc: + raise AppError(422, "CRITERIA_INVALID", str(exc), exc.details) from exc + current = self.latest_criteria(project_id) + version = (current.version if current is not None else 0) + 1 + now = now_utc() + self._store_criteria(project.id, version, parsed, project.product_category_id, now) + project.criteria_version = version + project.updated_at = now + for search in self.session.scalars( + select(DiscoverySearchRecord).where(DiscoverySearchRecord.buying_project_id == project.id) + ): + if search.status not in INACTIVE_SEARCH: + search.status = SearchStatus.OUT_OF_SYNC.value + search.updated_at = now + self.session.flush() + return self.get(project.id) + + def add_criteria(self, project_id: UUID, payload: dict[str, Any]) -> dict[str, Any]: + return self.replace_criteria(project_id, payload) + + def patch(self, project_id: UUID, payload: dict[str, Any]) -> dict[str, Any]: + project = self._require_project(project_id) + allowed = {"name", "target_budget", "shortlist_size", "status"} + unknown = set(payload) - allowed + if unknown: + raise AppError(422, "VALIDATION_ERROR", "field cannot be patched", {"fields": sorted(unknown)}) + if "name" in payload: + project.name = str(payload["name"]) + if "target_budget" in payload: + project.target_budget = None if payload["target_budget"] is None else Decimal(str(payload["target_budget"])) + if "shortlist_size" in payload: + size = int(payload["shortlist_size"]) + if size < 1: + raise AppError(422, "VALIDATION_ERROR", "shortlist_size must be > 0") + project.shortlist_size = size + if "status" in payload: + status = str(payload["status"]) + if status not in {item.value for item in ProjectStatus}: + raise AppError(422, "VALIDATION_ERROR", "invalid project status") + project.status = status + if status == ProjectStatus.ARCHIVED.value: + project.archived_at = now_utc() + project.updated_at = now_utc() + self.session.flush() + return self.get(project.id) + + def get(self, project_id: UUID) -> dict[str, Any]: + project = self._require_project(project_id) + criteria = self.latest_criteria(project_id) + return self._out(project, criteria) + + def get_criteria(self, project_id: UUID) -> dict[str, Any]: + project = self._require_project(project_id) + criteria = self.latest_criteria(project_id) + items = [] if criteria is None else self.criteria_items(project.id, criteria.version) + return jsonable( + { + "project_id": project.id, + "criteria_version": None if criteria is None else criteria.version, + "items": items, + } + ) + + def list(self, limit: int, offset: int) -> tuple[list[dict[str, Any]], int]: + total = self.session.scalar(select(func.count()).select_from(BuyingProjectRecord)) or 0 + rows = self.session.scalars( + select(BuyingProjectRecord).order_by(BuyingProjectRecord.created_at).limit(limit).offset(offset) + ).all() + return [self._summary(row) for row in rows], total + + def latest_criteria(self, project_id: UUID) -> BuyingProjectCriteriaRecord | None: + return self.session.scalars( + select(BuyingProjectCriteriaRecord) + .where(BuyingProjectCriteriaRecord.project_id == project_id) + .order_by(BuyingProjectCriteriaRecord.version.desc()) + ).first() + + def criteria_version(self, project_id: UUID, version: int) -> BuyingProjectCriteriaRecord | None: + return self.session.get(BuyingProjectCriteriaRecord, (project_id, version)) + + def criteria_items(self, project_id: UUID, version: int) -> list[dict[str, Any]]: + rows = self.session.scalars( + select(ProjectCriterionRecord) + .where(ProjectCriterionRecord.project_id == project_id, ProjectCriterionRecord.version == version) + .order_by(ProjectCriterionRecord.item_id) + ).all() + return [criterion_payload(row) for row in rows] + + def available_criteria(self, project_id: UUID) -> dict[str, Any]: + project = self._require_project(project_id) + sources = self.categories.sources_for_category(project.product_category_id) + source = sources[0]["source"] if sources else "geizhals" + return self.categories.available_criteria(project.product_category_id, source) + + def _store_criteria( + self, + project_id: UUID, + version: int, + parsed: dict[str, Any], + category: str | None, + created_at, + ) -> None: + self.session.add( + BuyingProjectCriteriaRecord( + project_id=project_id, + version=version, + definition_hash=parsed["definition_hash"], + created_at=created_at, + ) + ) + self.session.flush() + for item in parsed["items"]: + definition = self._resolve_definition(item, category) + self.session.add( + ProjectCriterionRecord( + id=uuid4(), + project_id=project_id, + version=version, + item_id=item["id"], + criterion_definition_id=definition.id, + criterion_key=definition.key, + operator=item["operator"], + value=item["value"], + enabled_for_discovery=item["enabled_for_discovery"], + created_at=created_at, + ) + ) + self.session.flush() + + def _resolve_definition(self, item: dict[str, Any], category: str | None): + if item.get("criterion_definition_id") is not None: + definition = get_definition(self.session, item["criterion_definition_id"]) + if definition is None: + raise AppError(422, "UNKNOWN_CRITERION", "criterion definition not found") + return definition + existing = get_definition_by_key(self.session, str(item["criterion"])) + if existing is not None: + return existing + if not item.get("value_type"): + raise AppError( + 422, + "UNKNOWN_CRITERION", + "criterion definition missing; provide value_type or create it first", + {"criterion": item["criterion"]}, + ) + return upsert_definition( + self.session, + { + "key": item["criterion"], + "origin": "INTERNAL", + "product_category_id": item.get("category") or category, + "label": item["criterion"], + "value_type": item["value_type"], + "unit": item.get("unit"), + "semantic": item.get("semantic"), + "category": item.get("category") or category, + "scoring_attribute_key": item["criterion"], + }, + ) + + def _require_project(self, project_id: UUID) -> BuyingProjectRecord: + project = self.session.get(BuyingProjectRecord, project_id) + if project is None: + raise AppError(404, "NOT_FOUND", "buying project not found") + return project + + def _out(self, project: BuyingProjectRecord, criteria: BuyingProjectCriteriaRecord | None) -> dict[str, Any]: + items = [] if criteria is None else self.criteria_items(project.id, criteria.version) + return jsonable( + { + "id": project.id, + "name": project.name, + "status": project.status, + "workspace_id": project.workspace_id, + "product_category_id": project.product_category_id, + "market": project.market, + "currency": project.currency, + "target_budget": project.target_budget, + "template_id": project.template_id, + "template_version": project.template_version, + "profile_id": project.template_id, + "profile_version": project.template_version, + "shortlist_size": project.shortlist_size, + "created_at": project.created_at, + "updated_at": project.updated_at, + "archived_at": project.archived_at, + "criteria_version": project.criteria_version if criteria is None else criteria.version, + "criteria": None if criteria is None else {"items": items}, + "criteria_hash": None if criteria is None else criteria.definition_hash, + "ranking_status": RankingStatus.NOT_CONFIGURED.value + if project.template_id is None + else RankingStatus.CONFIGURED.value, + } + ) + + def _summary(self, project: BuyingProjectRecord) -> dict[str, Any]: + category = self.session.get(ProductCategoryRecord, project.product_category_id) + searches = list( + self.session.scalars( + select(DiscoverySearchRecord).where(DiscoverySearchRecord.buying_project_id == project.id) + ) + ) + active = next((row for row in searches if row.status not in INACTIVE_SEARCH), searches[0] if searches else None) + last_run = None + if active is not None: + last_run = self.session.scalars( + select(SearchRunRecord) + .where(SearchRunRecord.discovery_search_id == active.id) + .order_by(SearchRunRecord.started_at.desc()) + ).first() + candidates = list( + self.session.scalars( + select(ProjectCandidateRecord).where(ProjectCandidateRecord.buying_project_id == project.id) + ) + ) + counts = {"NEW": 0, "CHANGED": 0, "MISSING": 0} + if last_run is not None: + for item in self.session.scalars( + select(SearchRunItemRecord).where(SearchRunItemRecord.search_run_id == last_run.id) + ): + if item.diff_status in counts: + counts[item.diff_status] += 1 + return jsonable( + { + "id": project.id, + "name": project.name, + "product_category": None + if category is None + else {"id": category.id, "name": category.name, "status": category.status}, + "status": project.status, + "last_successful_run_at": None if active is None else active.last_successful_run_at, + "next_run_at": None if active is None else active.next_run_at, + "discovery_status": None if active is None else active.status, + "candidate_count": len(candidates), + "shortlist_count": sum(1 for row in candidates if row.status == CandidateStatus.SHORTLISTED.value), + "new_count": last_run.new_count if last_run is not None else counts[DiffStatus.NEW.value], + "changed_count": last_run.changed_count if last_run is not None else counts[DiffStatus.CHANGED.value], + "missing_count": last_run.missing_count if last_run is not None else counts[DiffStatus.MISSING.value], + "current_search_result_count": None if last_run is None else last_run.result_count, + "ranking_status": RankingStatus.NOT_CONFIGURED.value + if project.template_id is None + else RankingStatus.CONFIGURED.value, + } + ) diff --git a/app/settings.py b/backend/app/settings.py similarity index 70% rename from app/settings.py rename to backend/app/settings.py index 748fb8d..bd04c9b 100644 --- a/app/settings.py +++ b/backend/app/settings.py @@ -1,3 +1,4 @@ +from datetime import datetime from functools import lru_cache from pydantic_settings import BaseSettings, SettingsConfigDict @@ -13,6 +14,12 @@ class Settings(BaseSettings): db_password: str = "change_me" environment: str = "development" engine_build: str = "dev" + clock_now: datetime | None = None + cors_allowed_origins: str = "http://localhost:5173,http://127.0.0.1:5173" + + @property + def cors_origins(self) -> list[str]: + return [item.strip() for item in self.cors_allowed_origins.split(",") if item.strip()] @property def database_url(self) -> str: diff --git a/backend/app/sources/__init__.py b/backend/app/sources/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/sources/base.py b/backend/app/sources/base.py new file mode 100644 index 0000000..eb5e14b --- /dev/null +++ b/backend/app/sources/base.py @@ -0,0 +1,85 @@ +from dataclasses import dataclass +from datetime import datetime +from decimal import Decimal +from typing import Any, Protocol +from uuid import UUID + + +@dataclass(frozen=True) +class SourceProduct: + source_key: str + external_id: str + id: UUID + category: str + brand: str + name: str + model: str | None + variants: tuple["SourceVariant", ...] + + +@dataclass(frozen=True) +class SourceVariant: + id: UUID + name: str + manufacturer_sku: str | None + identifiers: dict[str, Any] + attributes: tuple["SourceAttribute", ...] + + +@dataclass(frozen=True) +class SourceAttribute: + id: UUID + attribute: str + raw_value: Any + normalized_value: Any + unit: str | None + normalization_status: str + normalizer_version: str + confidence: Decimal + source_reference: str + observed_at: datetime + normalization_error: str | None = None + + +@dataclass(frozen=True) +class SourceOffer: + id: UUID + variant_id: UUID + external_id: str + seller: str + url: str | None + market: str + currency: str + condition: str + + +@dataclass(frozen=True) +class SourcePriceObservation: + id: UUID | None + external_offer_id: str + external_observation_id: str + observed_at: datetime + item_price: Decimal + shipping_price: Decimal | None + currency: str + availability: str + raw_payload: Any + confidence: Decimal + + +class ProductSource(Protocol): + source_key: str + + def get_product(self, external_id: str) -> SourceProduct: ... + def get_offers(self, external_product_id: str) -> list[SourceOffer]: ... + def get_price_history(self, external_offer_id: str) -> list[SourcePriceObservation]: ... + + +class DiscoverySourceAdapter(Protocol): + source_key: str + + def discover_taxonomy(self, category_ref: str) -> dict[str, Any]: ... + def build_query(self, criteria: list[dict[str, Any]], mappings: list[dict[str, Any]]) -> dict[str, Any]: ... + def fetch_results(self, query: dict[str, Any], pagination: dict[str, Any]) -> dict[str, Any]: ... + def parse_results(self, response: dict[str, Any]) -> list[dict[str, Any]]: ... + def normalize_result(self, result: dict[str, Any]) -> dict[str, Any]: ... diff --git a/backend/app/sources/geizhals/__init__.py b/backend/app/sources/geizhals/__init__.py new file mode 100644 index 0000000..adcd323 --- /dev/null +++ b/backend/app/sources/geizhals/__init__.py @@ -0,0 +1,3 @@ +from app.sources.geizhals.adapter import GeizhalsAdapter + +__all__ = ["GeizhalsAdapter"] diff --git a/backend/app/sources/geizhals/adapter.py b/backend/app/sources/geizhals/adapter.py new file mode 100644 index 0000000..dcbc3d0 --- /dev/null +++ b/backend/app/sources/geizhals/adapter.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any +from urllib.parse import parse_qs, urlparse + +from app.discovery.enums import FilterType +from app.errors import AppError + +FIXTURE_DIR = Path(__file__).resolve().parents[3] / "fixtures" / "geizhals" + + +class GeizhalsAdapter: + source_key = "geizhals" + + def __init__(self, fixture_dir: Path | None = None, live: bool = False) -> None: + self.fixture_dir = fixture_dir or FIXTURE_DIR + self.live = live + + def discover_taxonomy(self, category_ref: str) -> dict[str, Any]: + payload = self._load(f"{category_ref}-taxonomy.json") + return payload + + def build_query(self, criteria: list[dict[str, Any]], mappings: list[dict[str, Any]]) -> dict[str, Any]: + from app.discovery.urlgen import generate_search_url + + category_url = f"https://geizhals.de/?cat={self._category(mappings, criteria)}" + url, internal_only = generate_search_url( + category_url=category_url, + criteria_items=criteria, + mappings=mappings, + ) + return {"url": url, "internal_only": internal_only} + + def fetch_results(self, query: dict[str, Any], pagination: dict[str, Any]) -> dict[str, Any]: + if self.live: + raise AppError(422, "LIVE_FETCH_DISABLED", "live Geizhals fetch is not enabled in this process") + page = int(pagination.get("page") or 1) + category = parse_qs(urlparse(query["url"]).query).get("cat", ["nb"])[0] + return self._load(f"{category}-search-page{page}.json") + + def parse_results(self, response: dict[str, Any]) -> list[dict[str, Any]]: + return list(response.get("items") or []) + + def normalize_result(self, result: dict[str, Any]) -> dict[str, Any]: + attrs = result.get("normalized_attributes") or {} + return { + "source_external_id": result.get("source_external_id"), + "source_url": result.get("source_url"), + "source_title": result.get("source_title"), + "manufacturer": result.get("manufacturer"), + "model": result.get("model"), + "manufacturer_sku": result.get("manufacturer_sku"), + "gtin_ean": result.get("gtin_ean"), + "best_price": result.get("best_price"), + "currency": result.get("currency") or "EUR", + "availability": result.get("availability") or "unknown", + "seller_count": result.get("seller_count"), + "raw_attributes": result.get("raw_attributes") or {}, + "normalized_attributes": attrs, + } + + def _load(self, name: str) -> dict[str, Any]: + path = self.fixture_dir / name + if not path.exists(): + raise AppError(404, "FIXTURE_NOT_FOUND", f"geizhals fixture missing: {name}") + return json.loads(path.read_text(encoding="utf-8")) + + def _category(self, mappings: list[dict[str, Any]], criteria: list[dict[str, Any]]) -> str: + for mapping in mappings: + if mapping.get("category_key"): + return str(mapping["category_key"]) + return "nb" + + +def filter_type(raw: str) -> str: + try: + return FilterType(raw).value + except ValueError: + return FilterType.UNKNOWN.value diff --git a/backend/app/sources/manual.py b/backend/app/sources/manual.py new file mode 100644 index 0000000..2eae0cd --- /dev/null +++ b/backend/app/sources/manual.py @@ -0,0 +1,110 @@ +import json +from datetime import datetime +from decimal import Decimal +from pathlib import Path +from uuid import UUID + +from app.errors import AppError +from app.sources.base import ( + SourceAttribute, + SourceOffer, + SourcePriceObservation, + SourceProduct, + SourceVariant, +) + + +def _parse_dt(value: str) -> datetime: + return datetime.fromisoformat(value.replace("Z", "+00:00")) + + +class ManualSource: + source_key = "manual" + + def __init__(self, path: Path) -> None: + self.path = path + self._data = json.loads(path.read_text(encoding="utf-8")) + self._products = {item["external_id"]: item for item in self._data["products"]} + self._offers = {item["external_id"]: item for item in self._data.get("offers", [])} + self._prices = self._data.get("prices", []) + + def list_external_product_ids(self) -> list[str]: + return list(self._products) + + def get_product(self, external_id: str) -> SourceProduct: + raw = self._products.get(external_id) + if raw is None: + raise AppError(404, "NOT_FOUND", "manual product not found", {"external_id": external_id}) + variants = [] + for variant in raw["variants"]: + attributes = tuple( + SourceAttribute( + id=UUID(item["id"]), + attribute=item["attribute"], + raw_value=item["raw_value"], + normalized_value=item.get("normalized_value"), + unit=item.get("unit"), + normalization_status=item["normalization_status"], + normalizer_version=item.get("normalizer_version", "manual-v1"), + confidence=Decimal(str(item["confidence"])), + source_reference=item["source_reference"], + observed_at=_parse_dt(item["observed_at"]), + normalization_error=item.get("normalization_error"), + ) + for item in variant["attributes"] + ) + variants.append( + SourceVariant( + id=UUID(variant["id"]), + name=variant["name"], + manufacturer_sku=variant.get("manufacturer_sku"), + identifiers=variant.get("identifiers") or {}, + attributes=attributes, + ) + ) + return SourceProduct( + source_key=self.source_key, + external_id=external_id, + id=UUID(raw["id"]), + category=raw["category"], + brand=raw["brand"], + name=raw["name"], + model=raw.get("model"), + variants=tuple(variants), + ) + + def get_offers(self, external_product_id: str) -> list[SourceOffer]: + product = self.get_product(external_product_id) + variant_ids = {variant.id for variant in product.variants} + return [ + SourceOffer( + id=UUID(item["id"]), + variant_id=UUID(item["variant_id"]), + external_id=item["external_id"], + seller=item["seller"], + url=item.get("url"), + market=item["market"], + currency=item["currency"], + condition=item["condition"], + ) + for item in self._offers.values() + if UUID(item["variant_id"]) in variant_ids + ] + + def get_price_history(self, external_offer_id: str) -> list[SourcePriceObservation]: + return [ + SourcePriceObservation( + id=UUID(item["id"]) if item.get("id") else None, + external_offer_id=external_offer_id, + external_observation_id=item["external_observation_id"], + observed_at=_parse_dt(item["observed_at"]), + item_price=Decimal(str(item["item_price"])), + shipping_price=None if item.get("shipping_price") is None else Decimal(str(item["shipping_price"])), + currency=item["currency"], + availability=item["availability"], + raw_payload=item["raw_payload"], + confidence=Decimal(str(item["confidence"])), + ) + for item in self._prices + if item["external_offer_id"] == external_offer_id + ] diff --git a/backend/fixtures/geizhals/nb-search-page1.json b/backend/fixtures/geizhals/nb-search-page1.json new file mode 100644 index 0000000..fc00d08 --- /dev/null +++ b/backend/fixtures/geizhals/nb-search-page1.json @@ -0,0 +1,38 @@ +{ + "page": 1, + "page_count": 2, + "result_count": 3, + "http_status": 200, + "items": [ + { + "source_external_id": "gh-nb-a", + "source_url": "https://geizhals.de/example-mobile-13-a.html", + "source_title": "Example Mobile 13 32GB", + "manufacturer": "Example", + "model": "Mobile 13", + "manufacturer_sku": "EM13-32-1T", + "gtin_ean": "4000000000001", + "best_price": "1298.00", + "currency": "EUR", + "availability": "in_stock", + "seller_count": 8, + "raw_attributes": {"RAM": "32GB"}, + "normalized_attributes": {"ram_gb": 32, "storage_gb": 1024, "weight_kg": 1.2, "battery_score": 90, "display_score": 90, "cpu_score": 90} + }, + { + "source_external_id": "gh-nb-b", + "source_url": "https://geizhals.de/example-mobile-13-b.html", + "source_title": "Example Mobile 13 8GB", + "manufacturer": "Example", + "model": "Mobile 13", + "manufacturer_sku": "EM13-8-1T", + "gtin_ean": "4000000000002", + "best_price": "1298.00", + "currency": "EUR", + "availability": "in_stock", + "seller_count": 5, + "raw_attributes": {"RAM": "8GB"}, + "normalized_attributes": {"ram_gb": 8, "storage_gb": 1024, "weight_kg": 1.2, "battery_score": 90, "display_score": 90, "cpu_score": 90} + } + ] +} diff --git a/backend/fixtures/geizhals/nb-search-page2.json b/backend/fixtures/geizhals/nb-search-page2.json new file mode 100644 index 0000000..f745aed --- /dev/null +++ b/backend/fixtures/geizhals/nb-search-page2.json @@ -0,0 +1,23 @@ +{ + "page": 2, + "page_count": 2, + "result_count": 3, + "http_status": 200, + "items": [ + { + "source_external_id": "gh-nb-c", + "source_url": "https://geizhals.de/example-mobile-13-c.html", + "source_title": "Example Mobile 13 unknown weight", + "manufacturer": "Example", + "model": "Mobile 13 UW", + "manufacturer_sku": "EM13-32-UW", + "gtin_ean": "4000000000003", + "best_price": "1298.00", + "currency": "EUR", + "availability": "in_stock", + "seller_count": 3, + "raw_attributes": {"RAM": "32GB"}, + "normalized_attributes": {"ram_gb": 32, "storage_gb": 1024, "battery_score": 90, "display_score": 90, "cpu_score": 90} + } + ] +} diff --git a/backend/fixtures/geizhals/nb-taxonomy.json b/backend/fixtures/geizhals/nb-taxonomy.json new file mode 100644 index 0000000..8ee9cb8 --- /dev/null +++ b/backend/fixtures/geizhals/nb-taxonomy.json @@ -0,0 +1,27 @@ +{ + "source": "geizhals", + "external_category_key": "nb", + "name": "Notebooks", + "canonical_category": "notebook", + "source_url": "https://geizhals.de/?cat=nb", + "filters": [ + { + "external_filter_key": "17023", + "label": "Arbeitsspeicher", + "filter_type": "NUMERIC_THRESHOLD", + "canonical_unit": "GB", + "options": [ + {"source_value": "ab 16GB", "normalized_value": 16, "operator": ">=", "unit": "GB"}, + {"source_value": "ab 24GB", "normalized_value": 24, "operator": ">=", "unit": "GB"}, + {"source_value": "ab 32GB", "normalized_value": 32, "operator": ">=", "unit": "GB"} + ] + }, + { + "external_filter_key": "price", + "label": "Preis", + "filter_type": "NUMERIC_RANGE", + "canonical_unit": "EUR", + "range": {"minimum": 0, "maximum": null, "step": 1, "unit": "EUR"} + } + ] +} diff --git a/backend/fixtures/manual-demo.json b/backend/fixtures/manual-demo.json new file mode 100644 index 0000000..b2bcbfa --- /dev/null +++ b/backend/fixtures/manual-demo.json @@ -0,0 +1,80 @@ +{ + "products": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "external_id": "example-mobile-13", + "category": "notebook", + "brand": "Example", + "name": "Example Mobile 13", + "model": "EM13", + "variants": [ + { + "id": "22222222-2222-4222-8222-222222222222", + "name": "32 GB / 1 TB", + "manufacturer_sku": "EM13-32-1T", + "identifiers": {"sku": "EM13-32-1T"}, + "attributes": [ + {"id": "aaaaaaa1-0000-4000-8000-000000000001", "attribute": "ram_gb", "raw_value": "32GB LPDDR5X RAM", "normalized_value": 32, "unit": "GB", "normalization_status": "normalized", "normalizer_version": "manual-v1", "confidence": 1.0, "source_reference": "demo/notebook-a/ram", "observed_at": "2026-10-03T09:00:00Z"}, + {"id": "aaaaaaa1-0000-4000-8000-000000000002", "attribute": "storage_gb", "raw_value": "1024GB SSD", "normalized_value": 1024, "unit": "GB", "normalization_status": "normalized", "normalizer_version": "manual-v1", "confidence": 1.0, "source_reference": "demo/notebook-a/storage", "observed_at": "2026-10-03T09:00:00Z"}, + {"id": "aaaaaaa1-0000-4000-8000-000000000003", "attribute": "weight_kg", "raw_value": "1.2 kg", "normalized_value": 1.2, "unit": "kg", "normalization_status": "normalized", "normalizer_version": "manual-v1", "confidence": 1.0, "source_reference": "demo/notebook-a/weight", "observed_at": "2026-10-03T09:00:00Z"}, + {"id": "aaaaaaa1-0000-4000-8000-000000000004", "attribute": "battery_score", "raw_value": 90, "normalized_value": 90, "normalization_status": "normalized", "normalizer_version": "manual-v1", "confidence": 1.0, "source_reference": "demo/notebook-a/battery", "observed_at": "2026-10-03T09:00:00Z"}, + {"id": "aaaaaaa1-0000-4000-8000-000000000005", "attribute": "display_score", "raw_value": 90, "normalized_value": 90, "normalization_status": "normalized", "normalizer_version": "manual-v1", "confidence": 1.0, "source_reference": "demo/notebook-a/display", "observed_at": "2026-10-03T09:00:00Z"}, + {"id": "aaaaaaa1-0000-4000-8000-000000000006", "attribute": "cpu_score", "raw_value": 90, "normalized_value": 90, "normalization_status": "normalized", "normalizer_version": "manual-v1", "confidence": 1.0, "source_reference": "demo/notebook-a/cpu", "observed_at": "2026-10-03T09:00:00Z"} + ] + }, + { + "id": "22222222-2222-4222-8222-22222222222b", + "name": "8 GB / 1 TB", + "manufacturer_sku": "EM13-8-1T", + "identifiers": {"sku": "EM13-8-1T"}, + "attributes": [ + {"id": "bbbbbbb1-0000-4000-8000-000000000001", "attribute": "ram_gb", "raw_value": "8GB RAM", "normalized_value": 8, "unit": "GB", "normalization_status": "normalized", "normalizer_version": "manual-v1", "confidence": 1.0, "source_reference": "demo/notebook-b/ram", "observed_at": "2026-10-03T09:00:00Z"}, + {"id": "bbbbbbb1-0000-4000-8000-000000000002", "attribute": "storage_gb", "raw_value": "1024GB SSD", "normalized_value": 1024, "unit": "GB", "normalization_status": "normalized", "normalizer_version": "manual-v1", "confidence": 1.0, "source_reference": "demo/notebook-b/storage", "observed_at": "2026-10-03T09:00:00Z"}, + {"id": "bbbbbbb1-0000-4000-8000-000000000003", "attribute": "weight_kg", "raw_value": "1.2 kg", "normalized_value": 1.2, "unit": "kg", "normalization_status": "normalized", "normalizer_version": "manual-v1", "confidence": 1.0, "source_reference": "demo/notebook-b/weight", "observed_at": "2026-10-03T09:00:00Z"}, + {"id": "bbbbbbb1-0000-4000-8000-000000000004", "attribute": "battery_score", "raw_value": 90, "normalized_value": 90, "normalization_status": "normalized", "normalizer_version": "manual-v1", "confidence": 1.0, "source_reference": "demo/notebook-b/battery", "observed_at": "2026-10-03T09:00:00Z"}, + {"id": "bbbbbbb1-0000-4000-8000-000000000005", "attribute": "display_score", "raw_value": 90, "normalized_value": 90, "normalization_status": "normalized", "normalizer_version": "manual-v1", "confidence": 1.0, "source_reference": "demo/notebook-b/display", "observed_at": "2026-10-03T09:00:00Z"}, + {"id": "bbbbbbb1-0000-4000-8000-000000000006", "attribute": "cpu_score", "raw_value": 90, "normalized_value": 90, "normalization_status": "normalized", "normalizer_version": "manual-v1", "confidence": 1.0, "source_reference": "demo/notebook-b/cpu", "observed_at": "2026-10-03T09:00:00Z"} + ] + }, + { + "id": "22222222-2222-4222-8222-22222222222c", + "name": "32 GB / unknown weight", + "manufacturer_sku": "EM13-32-UW", + "identifiers": {}, + "attributes": [ + {"id": "ccccccc1-0000-4000-8000-000000000001", "attribute": "ram_gb", "raw_value": "32GB LPDDR5X RAM", "normalized_value": 32, "unit": "GB", "normalization_status": "normalized", "normalizer_version": "manual-v1", "confidence": 1.0, "source_reference": "demo/notebook-c/ram", "observed_at": "2026-10-03T09:00:00Z"}, + {"id": "ccccccc1-0000-4000-8000-000000000002", "attribute": "storage_gb", "raw_value": "1024GB SSD", "normalized_value": 1024, "unit": "GB", "normalization_status": "normalized", "normalizer_version": "manual-v1", "confidence": 1.0, "source_reference": "demo/notebook-c/storage", "observed_at": "2026-10-03T09:00:00Z"}, + {"id": "ccccccc1-0000-4000-8000-000000000003", "attribute": "weight_kg", "raw_value": "weight unknown", "normalized_value": null, "unit": "kg", "normalization_status": "unknown", "normalizer_version": "manual-v1", "confidence": 1.0, "source_reference": "demo/notebook-c/weight", "observed_at": "2026-10-03T09:00:00Z", "normalization_error": "manual unknown"}, + {"id": "ccccccc1-0000-4000-8000-000000000004", "attribute": "battery_score", "raw_value": 90, "normalized_value": 90, "normalization_status": "normalized", "normalizer_version": "manual-v1", "confidence": 1.0, "source_reference": "demo/notebook-c/battery", "observed_at": "2026-10-03T09:00:00Z"}, + {"id": "ccccccc1-0000-4000-8000-000000000005", "attribute": "display_score", "raw_value": 90, "normalized_value": 90, "normalization_status": "normalized", "normalizer_version": "manual-v1", "confidence": 1.0, "source_reference": "demo/notebook-c/display", "observed_at": "2026-10-03T09:00:00Z"}, + {"id": "ccccccc1-0000-4000-8000-000000000006", "attribute": "cpu_score", "raw_value": 90, "normalized_value": 90, "normalization_status": "normalized", "normalizer_version": "manual-v1", "confidence": 1.0, "source_reference": "demo/notebook-c/cpu", "observed_at": "2026-10-03T09:00:00Z"} + ] + } + ] + } + ], + "offers": [ + {"id": "33333333-3333-4333-8333-333333333333", "variant_id": "22222222-2222-4222-8222-222222222222", "external_id": "demo-offer-a", "seller": "DemoShop", "url": "https://example.test/a", "market": "DE", "currency": "EUR", "condition": "new"}, + {"id": "33333333-3333-4333-8333-33333333333b", "variant_id": "22222222-2222-4222-8222-22222222222b", "external_id": "demo-offer-b", "seller": "DemoShop", "url": "https://example.test/b", "market": "DE", "currency": "EUR", "condition": "new"}, + {"id": "33333333-3333-4333-8333-33333333333c", "variant_id": "22222222-2222-4222-8222-22222222222c", "external_id": "demo-offer-c", "seller": "DemoShop", "url": "https://example.test/c", "market": "DE", "currency": "EUR", "condition": "new"}, + {"id": "33333333-3333-4333-8333-33333333333d", "variant_id": "22222222-2222-4222-8222-222222222222", "external_id": "demo-offer-d", "seller": "OtherShop", "url": "https://example.test/d", "market": "DE", "currency": "EUR", "condition": "new"} + ], + "prices": [ + {"id": "ddddddd1-0000-4000-8000-0000000000a1", "external_offer_id": "demo-offer-a", "external_observation_id": "a-2026-09-01", "observed_at": "2026-09-01T10:00:00Z", "item_price": "1599.00", "shipping_price": "0.00", "currency": "EUR", "availability": "in_stock", "confidence": 1.0, "raw_payload": {"total": "1599.00"}}, + {"id": "ddddddd1-0000-4000-8000-0000000000a2", "external_offer_id": "demo-offer-a", "external_observation_id": "a-2026-09-15", "observed_at": "2026-09-15T10:00:00Z", "item_price": "1599.00", "shipping_price": "0.00", "currency": "EUR", "availability": "in_stock", "confidence": 1.0, "raw_payload": {"total": "1599.00"}}, + {"id": "ddddddd1-0000-4000-8000-0000000000a3", "external_offer_id": "demo-offer-a", "external_observation_id": "a-2026-10-01", "observed_at": "2026-10-01T10:00:00Z", "item_price": "1599.00", "shipping_price": "0.00", "currency": "EUR", "availability": "in_stock", "confidence": 1.0, "raw_payload": {"total": "1599.00"}}, + {"id": "ddddddd1-0000-4000-8000-0000000000a4", "external_offer_id": "demo-offer-a", "external_observation_id": "a-2026-10-03", "observed_at": "2026-10-03T10:00:00Z", "item_price": "1298.00", "shipping_price": "0.00", "currency": "EUR", "availability": "in_stock", "confidence": 1.0, "raw_payload": {"total": "1298.00"}}, + {"id": "ddddddd1-0000-4000-8000-0000000000b1", "external_offer_id": "demo-offer-b", "external_observation_id": "b-2026-09-01", "observed_at": "2026-09-01T10:00:00Z", "item_price": "1599.00", "shipping_price": "0.00", "currency": "EUR", "availability": "in_stock", "confidence": 1.0, "raw_payload": {"total": "1599.00"}}, + {"id": "ddddddd1-0000-4000-8000-0000000000b2", "external_offer_id": "demo-offer-b", "external_observation_id": "b-2026-09-15", "observed_at": "2026-09-15T10:00:00Z", "item_price": "1599.00", "shipping_price": "0.00", "currency": "EUR", "availability": "in_stock", "confidence": 1.0, "raw_payload": {"total": "1599.00"}}, + {"id": "ddddddd1-0000-4000-8000-0000000000b3", "external_offer_id": "demo-offer-b", "external_observation_id": "b-2026-10-01", "observed_at": "2026-10-01T10:00:00Z", "item_price": "1599.00", "shipping_price": "0.00", "currency": "EUR", "availability": "in_stock", "confidence": 1.0, "raw_payload": {"total": "1599.00"}}, + {"id": "ddddddd1-0000-4000-8000-0000000000b4", "external_offer_id": "demo-offer-b", "external_observation_id": "b-2026-10-03", "observed_at": "2026-10-03T10:00:00Z", "item_price": "1298.00", "shipping_price": "0.00", "currency": "EUR", "availability": "in_stock", "confidence": 1.0, "raw_payload": {"total": "1298.00"}}, + {"id": "ddddddd1-0000-4000-8000-0000000000c1", "external_offer_id": "demo-offer-c", "external_observation_id": "c-2026-09-01", "observed_at": "2026-09-01T10:00:00Z", "item_price": "1599.00", "shipping_price": "0.00", "currency": "EUR", "availability": "in_stock", "confidence": 1.0, "raw_payload": {"total": "1599.00"}}, + {"id": "ddddddd1-0000-4000-8000-0000000000c2", "external_offer_id": "demo-offer-c", "external_observation_id": "c-2026-09-15", "observed_at": "2026-09-15T10:00:00Z", "item_price": "1599.00", "shipping_price": "0.00", "currency": "EUR", "availability": "in_stock", "confidence": 1.0, "raw_payload": {"total": "1599.00"}}, + {"id": "ddddddd1-0000-4000-8000-0000000000c3", "external_offer_id": "demo-offer-c", "external_observation_id": "c-2026-10-01", "observed_at": "2026-10-01T10:00:00Z", "item_price": "1599.00", "shipping_price": "0.00", "currency": "EUR", "availability": "in_stock", "confidence": 1.0, "raw_payload": {"total": "1599.00"}}, + {"id": "ddddddd1-0000-4000-8000-0000000000c4", "external_offer_id": "demo-offer-c", "external_observation_id": "c-2026-10-03", "observed_at": "2026-10-03T10:00:00Z", "item_price": "1298.00", "shipping_price": "0.00", "currency": "EUR", "availability": "in_stock", "confidence": 1.0, "raw_payload": {"total": "1298.00"}}, + {"id": "ddddddd1-0000-4000-8000-0000000000d1", "external_offer_id": "demo-offer-d", "external_observation_id": "d-2026-09-01", "observed_at": "2026-09-01T10:00:00Z", "item_price": "1599.00", "shipping_price": "0.00", "currency": "EUR", "availability": "in_stock", "confidence": 1.0, "raw_payload": {"total": "1599.00"}}, + {"id": "ddddddd1-0000-4000-8000-0000000000d2", "external_offer_id": "demo-offer-d", "external_observation_id": "d-2026-09-15", "observed_at": "2026-09-15T10:00:00Z", "item_price": "1599.00", "shipping_price": "0.00", "currency": "EUR", "availability": "in_stock", "confidence": 1.0, "raw_payload": {"total": "1599.00"}}, + {"id": "ddddddd1-0000-4000-8000-0000000000d3", "external_offer_id": "demo-offer-d", "external_observation_id": "d-2026-10-01", "observed_at": "2026-10-01T10:00:00Z", "item_price": "1599.00", "shipping_price": "0.00", "currency": "EUR", "availability": "in_stock", "confidence": 1.0, "raw_payload": {"total": "1599.00"}}, + {"id": "ddddddd1-0000-4000-8000-0000000000d4", "external_offer_id": "demo-offer-d", "external_observation_id": "d-2026-10-03", "observed_at": "2026-10-03T10:00:00Z", "item_price": "2049.00", "shipping_price": "0.00", "currency": "EUR", "availability": "in_stock", "confidence": 1.0, "raw_payload": {"total": "2049.00"}} + ] +} diff --git a/migrations/env.py b/backend/migrations/env.py similarity index 100% rename from migrations/env.py rename to backend/migrations/env.py diff --git a/migrations/script.py.mako b/backend/migrations/script.py.mako similarity index 100% rename from migrations/script.py.mako rename to backend/migrations/script.py.mako diff --git a/migrations/versions/0001_deploy_skeleton.py b/backend/migrations/versions/0001_deploy_skeleton.py similarity index 84% rename from migrations/versions/0001_deploy_skeleton.py rename to backend/migrations/versions/0001_deploy_skeleton.py index 5f471f1..cac90ca 100644 --- a/migrations/versions/0001_deploy_skeleton.py +++ b/backend/migrations/versions/0001_deploy_skeleton.py @@ -1,4 +1,4 @@ -"""Deploy skeleton — domain schema follows in a later revision. +"""Deploy skeleton — domain schema follows in 0002. Revision ID: 0001_deploy_skeleton Revises: diff --git a/backend/migrations/versions/0002_domain_schema.py b/backend/migrations/versions/0002_domain_schema.py new file mode 100644 index 0000000..f5504c3 --- /dev/null +++ b/backend/migrations/versions/0002_domain_schema.py @@ -0,0 +1,233 @@ +"""Global product/price schema for v0.1. No user or workspace columns. + +Revision ID: 0002_domain_schema +Revises: 0001_deploy_skeleton +Create Date: 2026-10-03 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision: str = "0002_domain_schema" +down_revision: str | None = "0001_deploy_skeleton" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + "attribute_definitions", + sa.Column("key", sa.String(128), primary_key=True), + sa.Column("value_type", sa.String(32), nullable=False), + sa.Column("canonical_unit", sa.String(32)), + sa.Column("minimum", sa.Numeric(12, 4)), + sa.Column("maximum", sa.Numeric(12, 4)), + sa.Column("description", sa.Text()), + sa.CheckConstraint( + "value_type IN ('number', 'string', 'boolean', 'string_list')", + name="ck_attribute_definitions_value_type", + ), + ) + op.create_table( + "sources", + sa.Column("id", sa.String(64), primary_key=True), + sa.Column("name", sa.String(128), nullable=False), + sa.Column("adapter_type", sa.String(64), nullable=False), + ) + op.create_table( + "scoring_models", + sa.Column("version", sa.String(64), primary_key=True), + sa.Column("algorithm_id", sa.String(128), nullable=False), + sa.Column("definition", postgresql.JSONB, nullable=False), + sa.Column("definition_hash", sa.String(64), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + ) + op.create_table( + "product_profiles", + sa.Column("id", sa.String(128), primary_key=True), + sa.Column("version", sa.Integer(), primary_key=True), + sa.Column("name", sa.String(256), nullable=False), + sa.Column("category", sa.String(128), nullable=False), + sa.Column("scoring_version", sa.String(64), sa.ForeignKey("scoring_models.version"), nullable=False), + sa.Column("definition", postgresql.JSONB, nullable=False), + sa.Column("definition_hash", sa.String(64), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.CheckConstraint("version > 0", name="ck_product_profiles_version"), + ) + op.create_table( + "products", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("category", sa.String(128), nullable=False), + sa.Column("brand", sa.String(128), nullable=False), + sa.Column("name", sa.String(256), nullable=False), + sa.Column("model", sa.String(128)), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + ) + op.create_table( + "product_variants", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("product_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("products.id"), nullable=False), + sa.Column("name", sa.String(256), nullable=False), + sa.Column("manufacturer_sku", sa.String(128)), + sa.Column("identifiers", postgresql.JSONB, nullable=False, server_default=sa.text("'{}'::jsonb")), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + ) + op.create_index("ix_product_variants_product_id", "product_variants", ["product_id"]) + op.create_table( + "variant_attributes", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("variant_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("product_variants.id"), nullable=False), + sa.Column("attribute_key", sa.String(128), sa.ForeignKey("attribute_definitions.key"), nullable=False), + sa.Column("source_id", sa.String(64), sa.ForeignKey("sources.id"), nullable=False), + sa.Column("raw_value", postgresql.JSONB, nullable=False), + sa.Column("normalized_value", postgresql.JSONB), + sa.Column("unit", sa.String(32)), + sa.Column("normalization_status", sa.String(32), nullable=False), + sa.Column("normalizer_version", sa.String(64), nullable=False), + sa.Column("confidence", sa.Numeric(4, 3), nullable=False), + sa.Column("source_reference", sa.String(256), nullable=False), + sa.Column("observed_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("ingested_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("normalization_error", sa.Text()), + sa.CheckConstraint("confidence >= 0 AND confidence <= 1", name="ck_variant_attributes_confidence"), + sa.CheckConstraint( + "normalization_status IN ('normalized', 'unknown')", + name="ck_variant_attributes_status", + ), + ) + op.execute( + "CREATE INDEX ix_variant_attributes_lookup ON variant_attributes " + "(variant_id, attribute_key, observed_at DESC, ingested_at DESC, id DESC)" + ) + op.create_table( + "offers", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("variant_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("product_variants.id"), nullable=False), + sa.Column("source_id", sa.String(64), sa.ForeignKey("sources.id"), nullable=False), + sa.Column("external_id", sa.String(256), nullable=False), + sa.Column("seller", sa.String(256), nullable=False), + sa.Column("url", sa.Text()), + sa.Column("market", sa.String(16), nullable=False), + sa.Column("currency", sa.String(3), nullable=False), + sa.Column("condition", sa.String(32), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.UniqueConstraint("source_id", "external_id", name="uq_offers_source_external"), + sa.CheckConstraint("currency ~ '^[A-Z]{3}$'", name="ck_offers_currency"), + sa.CheckConstraint("condition IN ('new', 'refurbished', 'used')", name="ck_offers_condition"), + ) + op.create_index("ix_offers_variant_id", "offers", ["variant_id"]) + op.create_table( + "price_observations", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("offer_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("offers.id"), nullable=False), + sa.Column("source_id", sa.String(64), sa.ForeignKey("sources.id"), nullable=False), + sa.Column("external_observation_id", sa.String(256), nullable=False), + sa.Column("observed_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("ingested_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("item_price", sa.Numeric(12, 2), nullable=False), + sa.Column("shipping_price", sa.Numeric(12, 2)), + sa.Column("currency", sa.String(3), nullable=False), + sa.Column("availability", sa.String(32), nullable=False), + sa.Column("raw_payload", postgresql.JSONB, nullable=False), + sa.Column("confidence", sa.Numeric(4, 3), nullable=False), + sa.UniqueConstraint("offer_id", "external_observation_id", name="uq_price_obs_offer_external"), + sa.CheckConstraint("item_price > 0", name="ck_price_obs_item_price"), + sa.CheckConstraint("shipping_price IS NULL OR shipping_price >= 0", name="ck_price_obs_shipping"), + sa.CheckConstraint("confidence >= 0 AND confidence <= 1", name="ck_price_obs_confidence"), + sa.CheckConstraint( + "availability IN ('in_stock', 'out_of_stock', 'unknown')", + name="ck_price_obs_availability", + ), + sa.CheckConstraint("currency ~ '^[A-Z]{3}$'", name="ck_price_obs_currency"), + ) + op.execute( + "CREATE INDEX ix_price_observations_lookup ON price_observations " + "(offer_id, observed_at DESC, ingested_at DESC, id DESC)" + ) + op.create_table( + "evaluations", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("variant_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("product_variants.id"), nullable=False), + sa.Column("offer_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("offers.id"), nullable=False), + sa.Column("profile_id", sa.String(128), nullable=False), + sa.Column("profile_version", sa.Integer(), nullable=False), + sa.Column("scoring_version", sa.String(64), sa.ForeignKey("scoring_models.version"), nullable=False), + sa.Column("as_of", sa.DateTime(timezone=True), nullable=False), + sa.Column("snapshot_cutoff", sa.DateTime(timezone=True), nullable=False), + sa.Column("scoring_hash", sa.String(64), nullable=False), + sa.Column("engine_build", sa.String(64), nullable=False), + sa.Column("product_eligibility", sa.String(16), nullable=False), + sa.Column("offer_eligibility", sa.String(16), nullable=False), + sa.Column("overall_eligibility", sa.String(16), nullable=False), + sa.Column("product_score", sa.Numeric(6, 2)), + sa.Column("price_score", sa.Numeric(6, 2)), + sa.Column("deal_score", sa.Numeric(6, 2)), + sa.Column("classification", sa.String(32), nullable=False), + sa.Column("price_reference_scope", sa.String(32), nullable=False), + sa.Column("input_snapshot", postgresql.JSONB, nullable=False), + sa.Column("result_snapshot", postgresql.JSONB, nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint( + ["profile_id", "profile_version"], + ["product_profiles.id", "product_profiles.version"], + name="fk_evaluations_profile", + ), + sa.CheckConstraint( + "product_eligibility IN ('ELIGIBLE', 'INELIGIBLE', 'UNKNOWN')", + name="ck_eval_product_eligibility", + ), + sa.CheckConstraint( + "offer_eligibility IN ('ELIGIBLE', 'INELIGIBLE', 'UNKNOWN')", + name="ck_eval_offer_eligibility", + ), + sa.CheckConstraint( + "overall_eligibility IN ('ELIGIBLE', 'INELIGIBLE', 'UNKNOWN')", + name="ck_eval_overall_eligibility", + ), + sa.CheckConstraint( + "product_score IS NULL OR (product_score >= 0 AND product_score <= 100)", + name="ck_eval_product_score", + ), + sa.CheckConstraint( + "price_score IS NULL OR (price_score >= 0 AND price_score <= 100)", + name="ck_eval_price_score", + ), + sa.CheckConstraint( + "deal_score IS NULL OR (deal_score >= 0 AND deal_score <= 100)", + name="ck_eval_deal_score", + ), + ) + op.create_index("ix_evaluations_variant_id", "evaluations", ["variant_id"]) + op.execute( + "CREATE INDEX ix_evaluations_profile_created ON evaluations " + "(profile_id, profile_version, created_at DESC)" + ) + op.create_table( + "evaluation_components", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("evaluation_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("evaluations.id"), nullable=False), + sa.Column("kind", sa.String(64), nullable=False), + sa.Column("component_key", sa.String(128), nullable=False), + sa.Column("raw_score", sa.Numeric(12, 6)), + sa.Column("weight", sa.Numeric(12, 6)), + sa.Column("contribution", sa.Numeric(12, 6)), + sa.Column("details", postgresql.JSONB, nullable=False), + sa.UniqueConstraint("evaluation_id", "kind", "component_key", name="uq_eval_component"), + ) + + +def downgrade() -> None: + op.drop_table("evaluation_components") + op.drop_table("evaluations") + op.drop_table("price_observations") + op.drop_table("offers") + op.drop_table("variant_attributes") + op.drop_table("product_variants") + op.drop_table("products") + op.drop_table("product_profiles") + op.drop_table("scoring_models") + op.drop_table("sources") + op.drop_table("attribute_definitions") diff --git a/backend/migrations/versions/0003_discovery_schema.py b/backend/migrations/versions/0003_discovery_schema.py new file mode 100644 index 0000000..ea38c5a --- /dev/null +++ b/backend/migrations/versions/0003_discovery_schema.py @@ -0,0 +1,229 @@ +"""Discovery, taxonomy, search runs, and project candidates. + +Revision ID: 0003_discovery_schema +Revises: 0002_domain_schema +Create Date: 2026-10-04 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision: str = "0003_discovery_schema" +down_revision: str | None = "0002_domain_schema" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + "buying_projects", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("name", sa.String(256), nullable=False), + sa.Column("status", sa.String(32), nullable=False), + sa.Column("workspace_id", sa.String(64)), + sa.Column("profile_id", sa.String(128), nullable=False), + sa.Column("profile_version", sa.Integer(), nullable=False), + sa.Column("shortlist_size", sa.Integer(), nullable=False, server_default="5"), + sa.Column("schedule_interval_minutes", sa.Integer(), nullable=False, server_default="180"), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("archived_at", sa.DateTime(timezone=True)), + sa.ForeignKeyConstraint( + ["profile_id", "profile_version"], + ["product_profiles.id", "product_profiles.version"], + name="fk_buying_projects_profile", + ), + sa.CheckConstraint("status IN ('ACTIVE', 'PURCHASED', 'ARCHIVED')", name="ck_buying_projects_status"), + sa.CheckConstraint("shortlist_size > 0", name="ck_buying_projects_shortlist"), + sa.CheckConstraint("schedule_interval_minutes > 0", name="ck_buying_projects_interval"), + ) + op.create_table( + "buying_project_criteria", + sa.Column("project_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("buying_projects.id"), primary_key=True), + sa.Column("version", sa.Integer(), primary_key=True), + sa.Column("definition", postgresql.JSONB, nullable=False), + sa.Column("definition_hash", sa.String(64), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.CheckConstraint("version > 0", name="ck_project_criteria_version"), + ) + op.create_table( + "source_categories", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("source_id", sa.String(64), sa.ForeignKey("sources.id"), nullable=False), + sa.Column("external_category_key", sa.String(64), nullable=False), + sa.Column("name", sa.String(256), nullable=False), + sa.Column("source_url", sa.Text(), nullable=False), + sa.Column("status", sa.String(32), nullable=False), + sa.Column("last_verified_at", sa.DateTime(timezone=True)), + sa.UniqueConstraint("source_id", "external_category_key", name="uq_source_category"), + ) + op.create_table( + "source_filter_definitions", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("source_category_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("source_categories.id"), nullable=False), + sa.Column("external_filter_key", sa.String(64), nullable=False), + sa.Column("label", sa.String(256), nullable=False), + sa.Column("filter_type", sa.String(32), nullable=False), + sa.Column("canonical_unit", sa.String(32)), + sa.Column("status", sa.String(32), nullable=False), + sa.UniqueConstraint("source_category_id", "external_filter_key", name="uq_source_filter"), + ) + op.create_table( + "source_filter_options", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("filter_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("source_filter_definitions.id"), nullable=False), + sa.Column("source_value", sa.String(256), nullable=False), + sa.Column("normalized_value", postgresql.JSONB), + sa.Column("operator", sa.String(16)), + sa.Column("unit", sa.String(32)), + sa.UniqueConstraint("filter_id", "source_value", name="uq_filter_option"), + ) + op.create_table( + "source_filter_ranges", + sa.Column("filter_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("source_filter_definitions.id"), primary_key=True), + sa.Column("minimum", sa.Numeric(12, 2)), + sa.Column("maximum", sa.Numeric(12, 2)), + sa.Column("step", sa.Numeric(12, 2)), + sa.Column("unit", sa.String(32)), + ) + op.create_table( + "criterion_definitions", + sa.Column("key", sa.String(128), primary_key=True), + sa.Column("value_type", sa.String(32), nullable=False), + sa.Column("unit", sa.String(32)), + sa.Column("semantic", sa.String(128)), + sa.Column("description", sa.Text()), + ) + op.create_table( + "criterion_mappings", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("criterion_key", sa.String(128), sa.ForeignKey("criterion_definitions.key"), nullable=False), + sa.Column("source_id", sa.String(64), sa.ForeignKey("sources.id"), nullable=False), + sa.Column("source_category_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("source_categories.id"), nullable=False), + sa.Column("external_filter_key", sa.String(64), nullable=False), + sa.Column("source_value", sa.String(256)), + sa.Column("encoding", postgresql.JSONB, nullable=False), + sa.Column("status", sa.String(32), nullable=False), + sa.Column("definition_hash", sa.String(64), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + ) + op.create_table( + "discovery_searches", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("buying_project_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("buying_projects.id"), nullable=False), + sa.Column("source_id", sa.String(64), sa.ForeignKey("sources.id"), nullable=False), + sa.Column("source_category_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("source_categories.id"), nullable=False), + sa.Column("criteria_version", sa.Integer(), nullable=False), + sa.Column("mapping_version", sa.String(64), nullable=False), + sa.Column("generated_url", sa.Text(), nullable=False), + sa.Column("status", sa.String(32), nullable=False), + sa.Column("broad_result_threshold", sa.Integer(), nullable=False, server_default="300"), + sa.Column("next_run_at", sa.DateTime(timezone=True)), + sa.Column("last_successful_run_at", sa.DateTime(timezone=True)), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + ) + op.create_table( + "search_runs", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("discovery_search_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("discovery_searches.id"), nullable=False), + sa.Column("criteria_version", sa.Integer(), nullable=False), + sa.Column("mapping_version", sa.String(64), nullable=False), + sa.Column("generated_url", sa.Text(), nullable=False), + sa.Column("started_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("finished_at", sa.DateTime(timezone=True)), + sa.Column("status", sa.String(32), nullable=False), + sa.Column("result_count", sa.Integer(), nullable=False, server_default="0"), + sa.Column("page_count", sa.Integer(), nullable=False, server_default="0"), + sa.Column("http_status_summary", postgresql.JSONB, nullable=False), + sa.Column("error_code", sa.String(64)), + ) + op.create_table( + "source_product_references", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("source_id", sa.String(64), sa.ForeignKey("sources.id"), nullable=False), + sa.Column("source_category_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("source_categories.id"), nullable=False), + sa.Column("source_external_id", sa.String(256)), + sa.Column("source_url", sa.Text()), + sa.Column("source_title", sa.String(512), nullable=False), + sa.Column("manufacturer", sa.String(256)), + sa.Column("model", sa.String(256)), + sa.Column("manufacturer_sku", sa.String(128)), + sa.Column("gtin_ean", sa.String(32)), + sa.Column("normalized_spec_fingerprint", sa.Text()), + sa.Column("first_seen_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("last_seen_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("lifecycle_status", sa.String(32), nullable=False), + sa.Column("match_status", sa.String(32), nullable=False), + sa.Column("consecutive_missing_success_runs", sa.Integer(), nullable=False, server_default="0"), + sa.Column("product_variant_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("product_variants.id")), + ) + op.create_table( + "product_observations", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("source_product_reference_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("source_product_references.id"), nullable=False), + sa.Column("search_run_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("search_runs.id"), nullable=False), + sa.Column("observed_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("raw_title", sa.String(512), nullable=False), + sa.Column("best_price", sa.Numeric(12, 2)), + sa.Column("currency", sa.String(3)), + sa.Column("raw_attributes", postgresql.JSONB, nullable=False), + sa.Column("normalized_attributes", postgresql.JSONB, nullable=False), + sa.Column("availability", sa.String(32)), + sa.Column("seller_count", sa.Integer()), + ) + op.create_table( + "search_run_items", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("search_run_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("search_runs.id"), nullable=False), + sa.Column("source_product_reference_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("source_product_references.id"), nullable=False), + sa.Column("observation_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("product_observations.id")), + sa.Column("diff_status", sa.String(32), nullable=False), + sa.UniqueConstraint("search_run_id", "source_product_reference_id", name="uq_run_item"), + ) + op.create_table( + "project_candidates", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("buying_project_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("buying_projects.id"), nullable=False), + sa.Column("source_product_reference_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("source_product_references.id"), nullable=False), + sa.Column("product_variant_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("product_variants.id")), + sa.Column("status", sa.String(32), nullable=False), + sa.Column("match_status", sa.String(32), nullable=False), + sa.Column("product_eligibility", sa.String(16)), + sa.Column("product_score", sa.Numeric(6, 2)), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.UniqueConstraint("buying_project_id", "source_product_reference_id", name="uq_project_candidate"), + ) + op.create_table( + "candidate_status_history", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("candidate_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("project_candidates.id"), nullable=False), + sa.Column("from_status", sa.String(32)), + sa.Column("to_status", sa.String(32), nullable=False), + sa.Column("reason", sa.String(128)), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + ) + op.create_index("ix_discovery_searches_next_run", "discovery_searches", ["next_run_at"]) + op.create_index("ix_search_runs_search", "search_runs", ["discovery_search_id", "started_at"]) + + +def downgrade() -> None: + op.drop_table("candidate_status_history") + op.drop_table("project_candidates") + op.drop_table("search_run_items") + op.drop_table("product_observations") + op.drop_table("source_product_references") + op.drop_table("search_runs") + op.drop_table("discovery_searches") + op.drop_table("criterion_mappings") + op.drop_table("criterion_definitions") + op.drop_table("source_filter_ranges") + op.drop_table("source_filter_options") + op.drop_table("source_filter_definitions") + op.drop_table("source_categories") + op.drop_table("buying_project_criteria") + op.drop_table("buying_projects") diff --git a/backend/migrations/versions/0004_dynamic_attributes_criteria.py b/backend/migrations/versions/0004_dynamic_attributes_criteria.py new file mode 100644 index 0000000..bf8ee0b --- /dev/null +++ b/backend/migrations/versions/0004_dynamic_attributes_criteria.py @@ -0,0 +1,127 @@ +"""Dynamic attribute and criterion value records. + +Revision ID: 0004_dynamic_attributes +Revises: 0003_discovery_schema +Create Date: 2026-10-04 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision: str = "0004_dynamic_attributes" +down_revision: str | None = "0003_discovery_schema" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.add_column("criterion_definitions", sa.Column("category", sa.String(128))) + op.add_column("source_categories", sa.Column("canonical_category", sa.String(128))) + op.create_table( + "project_criteria", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("project_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("version", sa.Integer(), nullable=False), + sa.Column("item_id", sa.String(128), nullable=False), + sa.Column("criterion_key", sa.String(128), sa.ForeignKey("criterion_definitions.key"), nullable=False), + sa.Column("operator", sa.String(16), nullable=False), + sa.Column("value", postgresql.JSONB), + sa.Column("mandatory", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.ForeignKeyConstraint( + ["project_id", "version"], + ["buying_project_criteria.project_id", "buying_project_criteria.version"], + name="fk_project_criteria_version", + ), + sa.UniqueConstraint("project_id", "version", "item_id", name="uq_project_criterion_item"), + ) + op.create_table( + "product_observation_attributes", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column( + "observation_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("product_observations.id"), + nullable=False, + ), + sa.Column("attribute_key", sa.String(128), nullable=False), + sa.Column("raw_value", postgresql.JSONB), + sa.Column("normalized_value", postgresql.JSONB), + sa.Column("unit", sa.String(32)), + sa.UniqueConstraint("observation_id", "attribute_key", name="uq_observation_attribute"), + ) + _migrate_criteria_json() + _migrate_observation_json() + op.drop_column("buying_project_criteria", "definition") + op.drop_column("product_observations", "raw_attributes") + op.drop_column("product_observations", "normalized_attributes") + + +def _migrate_criteria_json() -> None: + bind = op.get_bind() + rows = bind.execute(sa.text("SELECT project_id, version, definition FROM buying_project_criteria")).mappings() + for row in rows: + items = (row["definition"] or {}).get("items") or [] + for raw in items: + bind.execute( + sa.text( + """ + INSERT INTO project_criteria + (id, project_id, version, item_id, criterion_key, operator, value, mandatory) + VALUES + (gen_random_uuid(), :project_id, :version, :item_id, :criterion_key, :operator, :value, :mandatory) + """ + ), + { + "project_id": row["project_id"], + "version": row["version"], + "item_id": str(raw.get("id") or raw.get("criterion")), + "criterion_key": str(raw.get("criterion")), + "operator": str(raw.get("operator")), + "value": raw.get("value"), + "mandatory": bool(raw.get("mandatory", True)), + }, + ) + + +def _migrate_observation_json() -> None: + bind = op.get_bind() + rows = bind.execute( + sa.text("SELECT id, raw_attributes, normalized_attributes FROM product_observations") + ).mappings() + for row in rows: + raw = row["raw_attributes"] or {} + normalized = row["normalized_attributes"] or {} + keys = set(raw) | set(normalized) + for key in keys: + bind.execute( + sa.text( + """ + INSERT INTO product_observation_attributes + (id, observation_id, attribute_key, raw_value, normalized_value, unit) + VALUES + (gen_random_uuid(), :observation_id, :attribute_key, :raw_value, :normalized_value, NULL) + """ + ), + { + "observation_id": row["id"], + "attribute_key": key, + "raw_value": raw.get(key), + "normalized_value": normalized.get(key), + }, + ) + + +def downgrade() -> None: + op.add_column("product_observations", sa.Column("raw_attributes", postgresql.JSONB, nullable=False, server_default="{}")) + op.add_column( + "product_observations", + sa.Column("normalized_attributes", postgresql.JSONB, nullable=False, server_default="{}"), + ) + op.add_column("buying_project_criteria", sa.Column("definition", postgresql.JSONB, nullable=False, server_default='{"items":[]}')) + op.drop_table("product_observation_attributes") + op.drop_table("project_criteria") + op.drop_column("source_categories", "canonical_category") + op.drop_column("criterion_definitions", "category") diff --git a/backend/migrations/versions/0005_frontend_mvp_v03.py b/backend/migrations/versions/0005_frontend_mvp_v03.py new file mode 100644 index 0000000..5e4b841 --- /dev/null +++ b/backend/migrations/versions/0005_frontend_mvp_v03.py @@ -0,0 +1,261 @@ +"""Frontend MVP v0.3 contracts: categories, optional templates, criterion UUIDs. + +Revision ID: 0005_frontend_mvp_v03 +Revises: 0004_dynamic_attributes +Create Date: 2026-10-04 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision: str = "0005_frontend_mvp_v03" +down_revision: str | None = "0004_dynamic_attributes" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + "product_categories", + sa.Column("id", sa.String(64), primary_key=True), + sa.Column("name", sa.String(256), nullable=False), + sa.Column("status", sa.String(32), nullable=False, server_default="ACTIVE"), + ) + op.execute("INSERT INTO product_categories (id, name, status) VALUES ('notebook', 'Notebooks', 'ACTIVE')") + + op.add_column("source_categories", sa.Column("product_category_id", sa.String(64))) + op.create_foreign_key( + "fk_source_categories_product_category", + "source_categories", + "product_categories", + ["product_category_id"], + ["id"], + ) + op.execute( + """ + UPDATE source_categories + SET product_category_id = canonical_category + WHERE canonical_category IS NOT NULL + AND EXISTS (SELECT 1 FROM product_categories WHERE id = source_categories.canonical_category) + """ + ) + + op.add_column("criterion_definitions", sa.Column("id", postgresql.UUID(as_uuid=True))) + op.execute("UPDATE criterion_definitions SET id = gen_random_uuid() WHERE id IS NULL") + op.alter_column("criterion_definitions", "id", nullable=False) + op.add_column( + "criterion_definitions", + sa.Column("origin", sa.String(16), nullable=False, server_default="SOURCE"), + ) + op.add_column("criterion_definitions", sa.Column("source", sa.String(64))) + op.add_column("criterion_definitions", sa.Column("product_category_id", sa.String(64))) + op.add_column("criterion_definitions", sa.Column("label", sa.String(256))) + op.add_column("criterion_definitions", sa.Column("scoring_attribute_key", sa.String(128))) + op.execute("UPDATE criterion_definitions SET label = key WHERE label IS NULL") + op.execute("UPDATE criterion_definitions SET scoring_attribute_key = key") + op.execute( + """ + UPDATE criterion_definitions + SET product_category_id = category + WHERE category IS NOT NULL + AND EXISTS (SELECT 1 FROM product_categories WHERE id = criterion_definitions.category) + """ + ) + op.alter_column("criterion_definitions", "label", nullable=False) + op.create_foreign_key( + "fk_criterion_definitions_product_category", + "criterion_definitions", + "product_categories", + ["product_category_id"], + ["id"], + ) + + op.add_column("project_criteria", sa.Column("criterion_definition_id", postgresql.UUID(as_uuid=True))) + op.add_column( + "project_criteria", + sa.Column("enabled_for_discovery", sa.Boolean(), nullable=False, server_default=sa.true()), + ) + op.add_column("project_criteria", sa.Column("created_at", sa.DateTime(timezone=True))) + op.execute( + """ + UPDATE project_criteria + SET enabled_for_discovery = mandatory, + created_at = timezone('utc', now()) + """ + ) + op.execute( + """ + UPDATE project_criteria AS pc + SET criterion_definition_id = cd.id + FROM criterion_definitions AS cd + WHERE pc.criterion_key = cd.key + """ + ) + op.alter_column("project_criteria", "criterion_definition_id", nullable=False) + op.alter_column("project_criteria", "created_at", nullable=False) + op.drop_constraint("project_criteria_criterion_key_fkey", "project_criteria", type_="foreignkey") + + op.add_column("criterion_mappings", sa.Column("criterion_definition_id", postgresql.UUID(as_uuid=True))) + op.execute( + """ + UPDATE criterion_mappings AS cm + SET criterion_definition_id = cd.id + FROM criterion_definitions AS cd + WHERE cm.criterion_key = cd.key + """ + ) + op.alter_column("criterion_mappings", "criterion_definition_id", nullable=False) + op.drop_constraint("criterion_mappings_criterion_key_fkey", "criterion_mappings", type_="foreignkey") + + op.drop_constraint("criterion_definitions_pkey", "criterion_definitions", type_="primary") + op.create_primary_key("criterion_definitions_pkey", "criterion_definitions", ["id"]) + op.create_unique_constraint("uq_criterion_definition_key", "criterion_definitions", ["key"]) + op.create_foreign_key( + "fk_project_criteria_definition", + "project_criteria", + "criterion_definitions", + ["criterion_definition_id"], + ["id"], + ) + op.create_foreign_key( + "fk_criterion_mappings_definition", + "criterion_mappings", + "criterion_definitions", + ["criterion_definition_id"], + ["id"], + ) + + op.create_table( + "source_attribute_mappings", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("source_id", sa.String(64), sa.ForeignKey("sources.id"), nullable=False), + sa.Column("source_category_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("source_categories.id")), + sa.Column("raw_name", sa.String(256), nullable=False), + sa.Column( + "criterion_definition_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("criterion_definitions.id"), + nullable=False, + ), + sa.Column("status", sa.String(32), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + ) + + op.add_column("buying_projects", sa.Column("product_category_id", sa.String(64))) + op.add_column("buying_projects", sa.Column("market", sa.String(8))) + op.add_column("buying_projects", sa.Column("currency", sa.String(3))) + op.add_column("buying_projects", sa.Column("target_budget", sa.Numeric(12, 2))) + op.add_column("buying_projects", sa.Column("template_id", sa.String(128))) + op.add_column("buying_projects", sa.Column("template_version", sa.Integer())) + op.add_column("buying_projects", sa.Column("criteria_version", sa.Integer(), nullable=False, server_default="1")) + op.add_column("buying_projects", sa.Column("updated_at", sa.DateTime(timezone=True))) + op.execute( + """ + UPDATE buying_projects + SET template_id = profile_id, + template_version = profile_version, + product_category_id = 'notebook', + market = 'DE', + currency = 'EUR', + updated_at = created_at + """ + ) + op.execute( + """ + UPDATE buying_projects AS bp + SET criteria_version = COALESCE(( + SELECT MAX(version) FROM buying_project_criteria WHERE project_id = bp.id + ), 1) + """ + ) + op.alter_column("buying_projects", "product_category_id", nullable=False) + op.alter_column("buying_projects", "market", nullable=False) + op.alter_column("buying_projects", "currency", nullable=False) + op.alter_column("buying_projects", "updated_at", nullable=False) + op.create_foreign_key( + "fk_buying_projects_product_category", + "buying_projects", + "product_categories", + ["product_category_id"], + ["id"], + ) + op.drop_constraint("fk_buying_projects_profile", "buying_projects", type_="foreignkey") + op.drop_constraint("ck_buying_projects_interval", "buying_projects", type_="check") + op.drop_column("buying_projects", "profile_id") + op.drop_column("buying_projects", "profile_version") + op.drop_column("buying_projects", "schedule_interval_minutes") + op.create_foreign_key( + "fk_buying_projects_template", + "buying_projects", + "product_profiles", + ["template_id", "template_version"], + ["id", "version"], + ) + + op.add_column( + "discovery_searches", + sa.Column("schedule_interval_minutes", sa.Integer(), nullable=False, server_default="180"), + ) + op.add_column("search_runs", sa.Column("new_count", sa.Integer(), nullable=False, server_default="0")) + op.add_column("search_runs", sa.Column("changed_count", sa.Integer(), nullable=False, server_default="0")) + op.add_column("search_runs", sa.Column("unchanged_count", sa.Integer(), nullable=False, server_default="0")) + op.add_column("search_runs", sa.Column("missing_count", sa.Integer(), nullable=False, server_default="0")) + + op.add_column( + "product_observation_attributes", + sa.Column("criterion_definition_id", postgresql.UUID(as_uuid=True)), + ) + op.add_column("product_observation_attributes", sa.Column("raw_name", sa.String(256))) + op.create_foreign_key( + "fk_observation_attributes_definition", + "product_observation_attributes", + "criterion_definitions", + ["criterion_definition_id"], + ["id"], + ) + op.execute( + """ + UPDATE product_observation_attributes AS poa + SET criterion_definition_id = cd.id, + raw_name = poa.attribute_key + FROM criterion_definitions AS cd + WHERE poa.attribute_key = cd.scoring_attribute_key + OR poa.attribute_key = cd.key + """ + ) + + +def downgrade() -> None: + op.drop_constraint("fk_observation_attributes_definition", "product_observation_attributes", type_="foreignkey") + op.drop_column("product_observation_attributes", "raw_name") + op.drop_column("product_observation_attributes", "criterion_definition_id") + op.drop_column("search_runs", "missing_count") + op.drop_column("search_runs", "unchanged_count") + op.drop_column("search_runs", "changed_count") + op.drop_column("search_runs", "new_count") + op.drop_column("discovery_searches", "schedule_interval_minutes") + op.drop_constraint("fk_buying_projects_template", "buying_projects", type_="foreignkey") + op.add_column("buying_projects", sa.Column("profile_id", sa.String(128))) + op.add_column("buying_projects", sa.Column("profile_version", sa.Integer())) + op.add_column( + "buying_projects", + sa.Column("schedule_interval_minutes", sa.Integer(), nullable=False, server_default="180"), + ) + op.execute("UPDATE buying_projects SET profile_id = template_id, profile_version = template_version") + op.alter_column("buying_projects", "profile_id", nullable=False) + op.alter_column("buying_projects", "profile_version", nullable=False) + op.drop_constraint("fk_buying_projects_product_category", "buying_projects", type_="foreignkey") + op.drop_column("buying_projects", "updated_at") + op.drop_column("buying_projects", "criteria_version") + op.drop_column("buying_projects", "template_version") + op.drop_column("buying_projects", "template_id") + op.drop_column("buying_projects", "target_budget") + op.drop_column("buying_projects", "currency") + op.drop_column("buying_projects", "market") + op.drop_column("buying_projects", "product_category_id") + op.drop_table("source_attribute_mappings") + raise NotImplementedError("full downgrade of criterion UUID primary key is not supported") diff --git a/backend/profiles/mobile-development-notebook.json b/backend/profiles/mobile-development-notebook.json new file mode 100644 index 0000000..93a2f8d --- /dev/null +++ b/backend/profiles/mobile-development-notebook.json @@ -0,0 +1,23 @@ +{ + "id": "mobile-development-notebook", + "version": 1, + "name": "Mobiler Entwicklungsrechner", + "category": "notebook", + "currency": "EUR", + "market": "DE", + "allowed_conditions": ["new"], + "budget": {"target": "1500.00", "maximum": "2000.00"}, + "scoring_version": "generic-v1", + "constraints": [ + {"id": "min_ram", "attribute": "ram_gb", "operator": ">=", "value": 16, "mandatory": true}, + {"id": "min_storage", "attribute": "storage_gb", "operator": ">=", "value": 512, "mandatory": true}, + {"id": "max_weight", "attribute": "weight_kg", "operator": "<=", "value": 1.6, "mandatory": true} + ], + "preferences": [ + {"id": "ram", "attribute": "ram_gb", "strategy": "numeric_linear", "direction": "higher", "acceptable": 16, "ideal": 32, "weight": 0.30}, + {"id": "weight", "attribute": "weight_kg", "strategy": "numeric_linear", "direction": "lower", "acceptable": 1.6, "ideal": 1.0, "weight": 0.25}, + {"id": "battery", "attribute": "battery_score", "strategy": "numeric_linear", "direction": "higher", "acceptable": 60, "ideal": 100, "weight": 0.20}, + {"id": "display", "attribute": "display_score", "strategy": "numeric_linear", "direction": "higher", "acceptable": 60, "ideal": 100, "weight": 0.15}, + {"id": "cpu", "attribute": "cpu_score", "strategy": "numeric_linear", "direction": "higher", "acceptable": 60, "ideal": 100, "weight": 0.10} + ] +} diff --git a/pyproject.toml b/backend/pyproject.toml similarity index 89% rename from pyproject.toml rename to backend/pyproject.toml index f745736..8e9046c 100644 --- a/pyproject.toml +++ b/backend/pyproject.toml @@ -2,7 +2,6 @@ name = "buying-agent" version = "0.1.0" description = "Generic buying and deal evaluation agent" -readme = "README.md" requires-python = ">=3.13" dependencies = [ "alembic>=1.16", @@ -25,7 +24,7 @@ requires = ["setuptools>=75"] build-backend = "setuptools.build_meta" [tool.setuptools.packages.find] -include = ["app*"] +include = ["app*", "scripts*"] [tool.pytest.ini_options] testpaths = ["tests"] @@ -33,4 +32,5 @@ addopts = "-ra" markers = [ "slow: long-running tests skipped in the default CI suite", "integration: needs PostgreSQL 17", + "e2e: HTTP end-to-end against PostgreSQL 17", ] diff --git a/backend/rules/generic-v1.json b/backend/rules/generic-v1.json new file mode 100644 index 0000000..254e1b5 --- /dev/null +++ b/backend/rules/generic-v1.json @@ -0,0 +1,23 @@ +{ + "version": "generic-v1", + "algorithm_id": "linear-budget-history-v1", + "supported_preference_strategies": ["numeric_linear"], + "minimum_confidence": 0.8, + "maximum_price_age_hours": 48, + "optional_constraint_penalty_points": 5, + "missing_preference_score": 0, + "product_weight": 0.6, + "price_weight": 0.4, + "budget_weight": 0.4, + "history_weight": 0.6, + "history_window_days": 90, + "minimum_history_days": 3, + "history_discount_for_max_score": 0.2, + "classification_upper_bounds": [ + {"upper": 60, "inclusive": false, "label": "IGNORE"}, + {"upper": 75, "inclusive": false, "label": "WATCH"}, + {"upper": 85, "inclusive": false, "label": "GOOD"}, + {"upper": 92, "inclusive": true, "label": "VERY_GOOD"}, + {"upper": 100, "inclusive": true, "label": "EXCEPTIONAL"} + ] +} diff --git a/backend/scripts/__init__.py b/backend/scripts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/scripts/bootstrap.py b/backend/scripts/bootstrap.py new file mode 100644 index 0000000..633d363 --- /dev/null +++ b/backend/scripts/bootstrap.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +import json +from datetime import datetime, timezone +from pathlib import Path + +from sqlalchemy.orm import Session + +from app.domain.validation import parse_scoring_model +from app.persistence.db import SessionLocal +from app.persistence.models import ScoringModelRecord, SourceRecord +from app.services.catalog_service import ensure_product_category + +ROOT = Path(__file__).resolve().parent.parent +RULES = ROOT / "rules" / "generic-v1.json" + + +def run_bootstrap(session: Session) -> None: + _seed_source(session) + _seed_scoring_model(session) + ensure_product_category(session, "notebook", "Notebooks") + session.commit() + + +def _seed_source(session: Session) -> None: + _upsert_source(session, "manual", "Manual entry", "manual") + _upsert_source(session, "geizhals", "Geizhals", "geizhals") + + +def _upsert_source(session: Session, source_id: str, name: str, adapter_type: str) -> None: + existing = session.get(SourceRecord, source_id) + if existing is None: + session.add(SourceRecord(id=source_id, name=name, adapter_type=adapter_type)) + return + if existing.adapter_type != adapter_type: + raise SystemExit(f"bootstrap conflict: source '{source_id}' has a different adapter_type") + + +def _seed_scoring_model(session: Session) -> None: + definition = json.loads(RULES.read_text(encoding="utf-8")) + model = parse_scoring_model(definition) + existing = session.get(ScoringModelRecord, model.version) + if existing is None: + session.add( + ScoringModelRecord( + version=model.version, + algorithm_id=model.algorithm_id, + definition=model.definition, + definition_hash=model.definition_hash, + created_at=datetime.now(timezone.utc), + ) + ) + return + if existing.definition_hash != model.definition_hash: + raise SystemExit( + f"bootstrap conflict: scoring model {model.version} hash " + f"{existing.definition_hash} != {model.definition_hash}" + ) + + +def main() -> None: + session = SessionLocal() + try: + run_bootstrap(session) + print("bootstrap ok") + finally: + session.close() + + +if __name__ == "__main__": + main() diff --git a/backend/scripts/demo.py b/backend/scripts/demo.py new file mode 100644 index 0000000..e6d2a18 --- /dev/null +++ b/backend/scripts/demo.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +import json +import urllib.error +import urllib.request +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +PROFILE = json.loads((ROOT / "profiles" / "mobile-development-notebook.json").read_text(encoding="utf-8")) +FIXTURE = json.loads((ROOT / "fixtures" / "manual-demo.json").read_text(encoding="utf-8")) +BASE = "http://127.0.0.1:8000" +AS_OF = "2026-10-03T12:00:00Z" + +DEFINITIONS = [ + {"key": "ram_gb", "value_type": "number", "canonical_unit": "GB", "minimum": 0, "description": "RAM in GB"}, + {"key": "storage_gb", "value_type": "number", "canonical_unit": "GB", "minimum": 0, "description": "Storage in GB"}, + {"key": "weight_kg", "value_type": "number", "canonical_unit": "kg", "minimum": 0, "description": "Weight in kg"}, + {"key": "battery_score", "value_type": "number", "minimum": 0, "maximum": 100, "description": "Manual battery score"}, + {"key": "display_score", "value_type": "number", "minimum": 0, "maximum": 100, "description": "Manual display score"}, + {"key": "cpu_score", "value_type": "number", "minimum": 0, "maximum": 100, "description": "Manual CPU score"}, + {"key": "ports", "value_type": "string_list", "description": "Ports"}, +] + + +def request(method: str, path: str, body=None, expected=(200, 201, 409)): + data = None if body is None else json.dumps(body, default=str).encode() + req = urllib.request.Request( + f"{BASE}{path}", + data=data, + method=method, + headers={"Content-Type": "application/json"} if body is not None else {}, + ) + try: + with urllib.request.urlopen(req) as response: + payload = json.loads(response.read().decode()) + if response.status not in expected: + raise SystemExit(f"{method} {path} -> {response.status}: {payload}") + return response.status, payload + except urllib.error.HTTPError as exc: + payload = json.loads(exc.read().decode()) + if exc.code not in expected: + raise SystemExit(f"{method} {path} -> {exc.code}: {payload}") from exc + return exc.code, payload + + +def main() -> None: + for definition in DEFINITIONS: + request("POST", "/api/v1/attribute-definitions", definition) + request("POST", "/api/v1/profiles", PROFILE) + + product = FIXTURE["products"][0] + request( + "POST", + "/api/v1/products", + { + "id": product["id"], + "category": product["category"], + "brand": product["brand"], + "name": product["name"], + "model": product.get("model"), + }, + ) + for variant in product["variants"]: + request( + "POST", + f"/api/v1/products/{product['id']}/variants", + { + "id": variant["id"], + "name": variant["name"], + "manufacturer_sku": variant.get("manufacturer_sku"), + "identifiers": variant.get("identifiers") or {}, + }, + ) + for attribute in variant["attributes"]: + request("POST", f"/api/v1/variants/{variant['id']}/attributes", {**attribute, "source_id": "manual"}) + + for offer in FIXTURE["offers"]: + request( + "POST", + f"/api/v1/variants/{offer['variant_id']}/offers", + { + "id": offer["id"], + "source_id": "manual", + "external_id": offer["external_id"], + "seller": offer["seller"], + "url": offer.get("url"), + "market": offer["market"], + "currency": offer["currency"], + "condition": offer["condition"], + }, + ) + for price in FIXTURE["prices"]: + offer = next(item for item in FIXTURE["offers"] if item["external_id"] == price["external_offer_id"]) + request( + "POST", + f"/api/v1/offers/{offer['id']}/prices", + { + "id": price["id"], + "source_id": "manual", + "external_observation_id": price["external_observation_id"], + "observed_at": price["observed_at"], + "item_price": price["item_price"], + "shipping_price": price["shipping_price"], + "currency": price["currency"], + "availability": price["availability"], + "raw_payload": price["raw_payload"], + "confidence": price["confidence"], + }, + expected=(200, 201), + ) + + cases = { + "A": ("22222222-2222-4222-8222-222222222222", "33333333-3333-4333-8333-333333333333"), + "B": ("22222222-2222-4222-8222-22222222222b", "33333333-3333-4333-8333-33333333333b"), + "C": ("22222222-2222-4222-8222-22222222222c", "33333333-3333-4333-8333-33333333333c"), + "D": ("22222222-2222-4222-8222-222222222222", "33333333-3333-4333-8333-33333333333d"), + } + for label, (variant_id, offer_id) in cases.items(): + status, result = request( + "POST", + f"/api/v1/evaluate/{variant_id}", + { + "profile_id": "mobile-development-notebook", + "profile_version": 1, + "offer_id": offer_id, + "as_of": AS_OF, + }, + expected=(201,), + ) + print( + f"case {label}: {status} overall={result['overall_eligibility']} " + f"product={result['product_score']} price={result['price_score']} " + f"deal={result['deal_score']} class={result['classification']}" + ) + + +if __name__ == "__main__": + main() diff --git a/backend/scripts/seed_notebook_pilot.py b/backend/scripts/seed_notebook_pilot.py new file mode 100644 index 0000000..a8aeb06 --- /dev/null +++ b/backend/scripts/seed_notebook_pilot.py @@ -0,0 +1,81 @@ +"""Notebook-pilot metadata only. Not part of the core schema or bootstrap.""" + +from __future__ import annotations + +from sqlalchemy.orm import Session + +from app.discovery.definitions import get_definition_by_key, upsert_definition +from app.persistence.models import AttributeDefinitionRecord +from app.services.catalog_service import ensure_product_category + + +NOTEBOOK_ATTRIBUTE_SEEDS = ( + {"key": "ram_gb", "value_type": "number", "canonical_unit": "GB", "minimum": 0}, + {"key": "storage_gb", "value_type": "number", "canonical_unit": "GB", "minimum": 0}, + {"key": "weight_kg", "value_type": "number", "canonical_unit": "kg", "minimum": 0}, + {"key": "battery_score", "value_type": "number", "minimum": 0, "maximum": 100}, + {"key": "display_score", "value_type": "number", "minimum": 0, "maximum": 100}, + {"key": "cpu_score", "value_type": "number", "minimum": 0, "maximum": 100}, +) + +NOTEBOOK_CRITERION_SEEDS = ( + { + "key": "ram_gb", + "origin": "SOURCE", + "source": "geizhals", + "product_category_id": "notebook", + "label": "Arbeitsspeicher", + "value_type": "number", + "unit": "GB", + "semantic": "working_memory", + "category": "notebook", + "scoring_attribute_key": "ram_gb", + }, + { + "key": "storage_gb", + "origin": "SOURCE", + "source": "geizhals", + "product_category_id": "notebook", + "label": "SSD", + "value_type": "number", + "unit": "GB", + "semantic": "storage", + "category": "notebook", + "scoring_attribute_key": "storage_gb", + }, + { + "key": "weight_kg", + "origin": "SOURCE", + "source": "geizhals", + "product_category_id": "notebook", + "label": "Gewicht", + "value_type": "number", + "unit": "kg", + "semantic": "weight", + "category": "notebook", + "scoring_attribute_key": "weight_kg", + }, + { + "key": "price_eur", + "origin": "SOURCE", + "source": "geizhals", + "product_category_id": "notebook", + "label": "Preis", + "value_type": "number", + "unit": "EUR", + "semantic": "price", + "category": "notebook", + "scoring_attribute_key": "price_eur", + }, +) + + +def seed_notebook_pilot(session: Session) -> None: + ensure_product_category(session, "notebook", "Notebooks") + for item in NOTEBOOK_ATTRIBUTE_SEEDS: + if session.get(AttributeDefinitionRecord, item["key"]) is None: + session.add(AttributeDefinitionRecord(**item, description=None)) + for item in NOTEBOOK_CRITERION_SEEDS: + if get_definition_by_key(session, item["key"]) is None: + upsert_definition(session, item) + session.flush() diff --git a/scripts/startup.sh b/backend/scripts/startup.sh similarity index 85% rename from scripts/startup.sh rename to backend/scripts/startup.sh index f9fb964..fb45c38 100644 --- a/scripts/startup.sh +++ b/backend/scripts/startup.sh @@ -3,5 +3,6 @@ set -e echo "=== BuyingAgent API startup ===" alembic upgrade head +python -m scripts.bootstrap echo "=== starting uvicorn ===" exec uvicorn app.main:app --host 0.0.0.0 --port 8000 diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..80f8fa7 --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,150 @@ +import json +from datetime import datetime, timezone +from decimal import Decimal +from pathlib import Path +from uuid import UUID, uuid4 + +import pytest + +from app.domain.enums import Availability, NormalizationStatus, OfferCondition, ValueType +from app.domain.models import ( + AttributeDefinition, + AttributeStatement, + Offer, + PriceObservation, + Product, + ProductVariant, +) +from app.domain.validation import parse_profile, parse_scoring_model + +ROOT = Path(__file__).resolve().parents[1] +AS_OF = datetime(2026, 10, 3, 12, 0, tzinfo=timezone.utc) +CUTOFF = datetime(2026, 10, 3, 12, 0, tzinfo=timezone.utc) + + +@pytest.fixture +def scoring_model(): + definition = json.loads((ROOT / "rules" / "generic-v1.json").read_text(encoding="utf-8")) + return parse_scoring_model(definition) + + +@pytest.fixture +def definitions() -> dict[str, AttributeDefinition]: + return { + "ram_gb": AttributeDefinition("ram_gb", ValueType.NUMBER, "GB", Decimal("0"), None, None), + "storage_gb": AttributeDefinition("storage_gb", ValueType.NUMBER, "GB", Decimal("0"), None, None), + "weight_kg": AttributeDefinition("weight_kg", ValueType.NUMBER, "kg", Decimal("0"), None, None), + "battery_score": AttributeDefinition("battery_score", ValueType.NUMBER, None, Decimal("0"), Decimal("100"), None), + "display_score": AttributeDefinition("display_score", ValueType.NUMBER, None, Decimal("0"), Decimal("100"), None), + "cpu_score": AttributeDefinition("cpu_score", ValueType.NUMBER, None, Decimal("0"), Decimal("100"), None), + "ports": AttributeDefinition("ports", ValueType.STRING_LIST, None, None, None, None), + "color": AttributeDefinition("color", ValueType.STRING, None, None, None, None), + "has_lte": AttributeDefinition("has_lte", ValueType.BOOLEAN, None, None, None, None), + } + + +@pytest.fixture +def profile(scoring_model, definitions): + raw = json.loads((ROOT / "profiles" / "mobile-development-notebook.json").read_text(encoding="utf-8")) + return parse_profile( + raw, + attribute_definitions=definitions, + scoring_versions={scoring_model.version}, + supported_strategies=set(scoring_model.supported_preference_strategies), + ) + + +def utc(year, month, day, hour=10, minute=0) -> datetime: + return datetime(year, month, day, hour, minute, tzinfo=timezone.utc) + + +def statement(attribute: str, value, *, confidence="1.0", status="normalized", observed=None, ingested=None, row_id=None): + return AttributeStatement( + id=row_id or uuid4(), + variant_id=UUID("22222222-2222-4222-8222-222222222222"), + attribute=attribute, + raw_value=value, + normalized_value=None if status != "normalized" else value, + unit="GB" if attribute.endswith("_gb") else ("kg" if attribute.endswith("_kg") else None), + normalization_status=NormalizationStatus(status), + normalizer_version="manual-v1", + confidence=Decimal(str(confidence)), + source_id="manual", + source_reference=f"test/{attribute}", + observed_at=observed or utc(2026, 10, 3, 9), + ingested_at=ingested or utc(2026, 10, 3, 9, 1), + ) + + +def product_a() -> Product: + return Product( + id=UUID("11111111-1111-4111-8111-111111111111"), + category="notebook", + brand="Example", + name="Example Mobile 13", + model="EM13", + created_at=utc(2026, 10, 1, 8), + ) + + +def variant_a() -> ProductVariant: + return ProductVariant( + id=UUID("22222222-2222-4222-8222-222222222222"), + product_id=UUID("11111111-1111-4111-8111-111111111111"), + name="32/1024", + manufacturer_sku="EM13-32-1T", + identifiers={}, + created_at=utc(2026, 10, 1, 8), + ) + + +def offer_a() -> Offer: + return Offer( + id=UUID("33333333-3333-4333-8333-333333333333"), + variant_id=UUID("22222222-2222-4222-8222-222222222222"), + source_id="manual", + external_id="demo-offer-a", + seller="DemoShop", + url=None, + market="DE", + currency="EUR", + condition=OfferCondition.NEW, + created_at=utc(2026, 10, 1, 8), + ) + + +def observation(day, total, *, offer_id=None, obs_id=None, availability="in_stock", confidence="1.0", hour=10): + return PriceObservation( + id=obs_id or uuid4(), + offer_id=offer_id or UUID("33333333-3333-4333-8333-333333333333"), + source_id="manual", + external_observation_id=str(obs_id or uuid4()), + observed_at=utc(2026, day[0], day[1], hour) if isinstance(day, tuple) else day, + ingested_at=utc(2026, 10, 3, 11), + item_price=Decimal(str(total)), + shipping_price=Decimal("0.00"), + currency="EUR", + availability=Availability(availability), + raw_payload={"total": str(total)}, + confidence=Decimal(str(confidence)), + ) + + +def case_a_attributes(): + return ( + statement("ram_gb", 32), + statement("storage_gb", 1024), + statement("weight_kg", Decimal("1.2")), + statement("battery_score", 90), + statement("display_score", 90), + statement("cpu_score", 90), + ) + + +def case_a_prices(): + return ( + observation((9, 1), "1599.00"), + observation((9, 15), "1599.00"), + observation((10, 1), "1599.00"), + observation((10, 3), "1298.00"), + ) diff --git a/backend/tests/e2e/__init__.py b/backend/tests/e2e/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/e2e/test_discovery_walkthrough.py b/backend/tests/e2e/test_discovery_walkthrough.py new file mode 100644 index 0000000..ae5d1d3 --- /dev/null +++ b/backend/tests/e2e/test_discovery_walkthrough.py @@ -0,0 +1,199 @@ +import json +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import text + +from app.main import app +from app.persistence.db import SessionLocal, engine +from scripts.bootstrap import run_bootstrap +from scripts.seed_notebook_pilot import seed_notebook_pilot + +pytestmark = [pytest.mark.e2e, pytest.mark.integration] + +ROOT = Path(__file__).resolve().parents[2] +PROFILE = json.loads((ROOT / "profiles" / "mobile-development-notebook.json").read_text(encoding="utf-8")) + +DEFINITIONS = [ + {"key": "ram_gb", "value_type": "number", "canonical_unit": "GB", "minimum": 0}, + {"key": "storage_gb", "value_type": "number", "canonical_unit": "GB", "minimum": 0}, + {"key": "weight_kg", "value_type": "number", "canonical_unit": "kg", "minimum": 0}, + {"key": "battery_score", "value_type": "number", "minimum": 0, "maximum": 100}, + {"key": "display_score", "value_type": "number", "minimum": 0, "maximum": 100}, + {"key": "cpu_score", "value_type": "number", "minimum": 0, "maximum": 100}, +] + +CRITERIA = { + "items": [ + {"id": "ram", "criterion": "ram_gb", "operator": ">=", "value": 24, "mandatory": True}, + {"id": "price", "criterion": "price_eur", "operator": "<=", "value": 2000, "mandatory": True}, + {"id": "weight", "criterion": "weight_kg", "operator": "<=", "value": 1.6, "mandatory": True}, + ] +} + + +@pytest.fixture(scope="module", autouse=True) +def require_postgres() -> None: + try: + with engine.connect() as connection: + connection.execute(text("SELECT 1")) + except Exception as exc: + pytest.skip(f"PostgreSQL not available: {exc}") + session = SessionLocal() + try: + run_bootstrap(session) + seed_notebook_pilot(session) + session.commit() + finally: + session.close() + + +@pytest.fixture +def client() -> TestClient: + return TestClient(app) + + +def _ok(response, allowed=(200, 201, 409)) -> None: + assert response.status_code in allowed, response.text + + +def _seed_profile(client: TestClient) -> None: + for definition in DEFINITIONS: + _ok(client.post("/api/v1/attribute-definitions", json=definition)) + _ok(client.post("/api/v1/profiles", json=PROFILE)) + + +def test_notebook_discovery_pilot(client: TestClient) -> None: + _seed_profile(client) + taxonomy = client.post("/api/v1/sources/geizhals/categories/nb/discover-taxonomy") + assert taxonomy.status_code == 201, taxonomy.text + category_id = taxonomy.json()["id"] + filters = client.get("/api/v1/sources/geizhals/categories/nb/filters") + assert filters.status_code == 200 + assert {item["external_filter_key"] for item in filters.json()["filters"]} >= {"17023", "price"} + + ram = client.post( + "/api/v1/criterion-mappings", + json={ + "criterion_key": "ram_gb", + "source_id": "geizhals", + "source_category_id": category_id, + "external_filter_key": "17023", + "source_value": "ab 24GB", + "encoding": {"url_param": "xf"}, + }, + ) + assert ram.status_code == 201, ram.text + assert ram.json()["status"] == "VERIFIED" + + price = client.post( + "/api/v1/criterion-mappings", + json={ + "criterion_key": "price_eur", + "source_id": "geizhals", + "source_category_id": category_id, + "external_filter_key": "price", + "source_value": None, + "encoding": {"url_param": "maxp"}, + "probe_value": 2000, + "operator": "<=", + }, + ) + assert price.status_code == 201, price.text + assert price.json()["status"] == "VERIFIED" + + project = client.post( + "/api/v1/buying-projects", + json={ + "name": "Notebook-Pilot", + "profile_id": "mobile-development-notebook", + "profile_version": 1, + "shortlist_size": 5, + "schedule_interval_minutes": 180, + "criteria": CRITERIA, + }, + ) + assert project.status_code == 201, project.text + project_id = project.json()["id"] + assert project.json()["workspace_id"] is None + assert project.json()["criteria_version"] == 1 + available = client.get(f"/api/v1/buying-projects/{project_id}/available-criteria") + assert available.status_code == 200, available.text + assert available.json()["product_category_id"] == "notebook" + scoring_keys = {item.get("scoring_attribute_key") for item in available.json()["criteria"]} + assert {"ram_gb", "price_eur"} <= scoring_keys + ram = next(item for item in available.json()["criteria"] if item.get("scoring_attribute_key") == "ram_gb") + assert ram["mapping_status"] == "VERIFIED" + + search = client.post( + "/api/v1/discovery-searches", + json={ + "buying_project_id": project_id, + "source_id": "geizhals", + "source_category_id": category_id, + "broad_result_threshold": 300, + }, + ) + assert search.status_code == 201, search.text + search_id = search.json()["id"] + assert "xf=17023_ab+24GB" in search.json()["generated_url"] + assert "maxp=2000" in search.json()["generated_url"] + assert search.json()["schedule_interval_minutes"] == 180 + + due = client.get("/api/v1/discovery-searches?due=true") + assert due.status_code == 200 + assert any(item["id"] == search_id for item in due.json()["items"]) + + run = client.post(f"/api/v1/discovery-searches/{search_id}/run") + assert run.status_code == 201, run.text + assert run.json()["status"] == "SUCCESS" + assert run.json()["page_count"] == 2 + assert run.json()["result_count"] == 3 + assert {item["diff_status"] for item in run.json()["items"]} == {"NEW"} + + shortlist = client.get(f"/api/v1/buying-projects/{project_id}/shortlist") + assert shortlist.status_code == 200 + assert len(shortlist.json()["items"]) == 1 + assert shortlist.json()["items"][0]["product_eligibility"] == "ELIGIBLE" + assert shortlist.json()["items"][0]["status"] == "SHORTLISTED" + + candidates = client.get(f"/api/v1/buying-projects/{project_id}/candidates") + assert candidates.status_code == 200 + eligibilities = {item["product_eligibility"] for item in candidates.json()["items"]} + assert eligibilities == {"ELIGIBLE", "INELIGIBLE", "UNKNOWN"} + assert all(item.get("deal_score") is None for item in candidates.json()["items"]) + + updated = client.post( + f"/api/v1/buying-projects/{project_id}/criteria", + json={"items": CRITERIA["items"][:2]}, + ) + assert updated.status_code == 201 + assert updated.json()["criteria_version"] == 2 + stale = client.get(f"/api/v1/discovery-searches/{search_id}") + assert stale.json()["status"] == "OUT_OF_SYNC" + blocked_run = client.post(f"/api/v1/discovery-searches/{search_id}/run") + assert blocked_run.status_code == 409 + refreshed = client.post(f"/api/v1/discovery-searches/{search_id}/generate-query") + assert refreshed.status_code == 200 + assert refreshed.json()["status"] == "ACTIVE" + assert "weight_kg" not in refreshed.json().get("internal_only", []) + assert "weight_kg" not in refreshed.json()["generated_url"] + + client.patch(f"/api/v1/discovery-searches/{search_id}", json={"status": "DISABLED"}) + broad = client.post( + "/api/v1/discovery-searches", + json={ + "buying_project_id": project_id, + "source_id": "geizhals", + "source_category_id": category_id, + "broad_result_threshold": 2, + }, + ) + assert broad.status_code == 201, broad.text + broad_run = client.post(f"/api/v1/discovery-searches/{broad.json()['id']}/run") + assert broad_run.status_code == 201, broad_run.text + assert broad_run.json()["status"] == "TOO_BROAD" + assert broad_run.json()["result_count"] == 3 + stored = client.get(f"/api/v1/discovery-searches/{broad.json()['id']}") + assert stored.json()["status"] == "TOO_BROAD" diff --git a/backend/tests/e2e/test_http_walkthrough.py b/backend/tests/e2e/test_http_walkthrough.py new file mode 100644 index 0000000..c5262ff --- /dev/null +++ b/backend/tests/e2e/test_http_walkthrough.py @@ -0,0 +1,204 @@ +import json +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import text + +from app.main import app +from app.persistence.db import SessionLocal, engine +from scripts.bootstrap import run_bootstrap + +pytestmark = [pytest.mark.e2e, pytest.mark.integration] + +ROOT = Path(__file__).resolve().parents[2] +PROFILE = json.loads((ROOT / "profiles" / "mobile-development-notebook.json").read_text(encoding="utf-8")) +FIXTURE = json.loads((ROOT / "fixtures" / "manual-demo.json").read_text(encoding="utf-8")) +AS_OF = "2026-10-03T12:00:00Z" + +DEFINITIONS = [ + {"key": "ram_gb", "value_type": "number", "canonical_unit": "GB", "minimum": 0}, + {"key": "storage_gb", "value_type": "number", "canonical_unit": "GB", "minimum": 0}, + {"key": "weight_kg", "value_type": "number", "canonical_unit": "kg", "minimum": 0}, + {"key": "battery_score", "value_type": "number", "minimum": 0, "maximum": 100}, + {"key": "display_score", "value_type": "number", "minimum": 0, "maximum": 100}, + {"key": "cpu_score", "value_type": "number", "minimum": 0, "maximum": 100}, +] + + +@pytest.fixture(scope="module", autouse=True) +def require_postgres() -> None: + try: + with engine.connect() as connection: + connection.execute(text("SELECT 1")) + except Exception as exc: + pytest.skip(f"PostgreSQL not available: {exc}") + session = SessionLocal() + try: + run_bootstrap(session) + finally: + session.close() + + +@pytest.fixture +def client() -> TestClient: + return TestClient(app) + + +def _ok(response, allowed=(200, 201, 409)) -> None: + assert response.status_code in allowed, response.text + + +def _seed(client: TestClient) -> None: + for definition in DEFINITIONS: + _ok(client.post("/api/v1/attribute-definitions", json=definition)) + _ok(client.post("/api/v1/profiles", json=PROFILE)) + product = FIXTURE["products"][0] + _ok( + client.post( + "/api/v1/products", + json={ + "id": product["id"], + "category": product["category"], + "brand": product["brand"], + "name": product["name"], + "model": product.get("model"), + }, + ) + ) + for variant in product["variants"]: + _ok( + client.post( + f"/api/v1/products/{product['id']}/variants", + json={ + "id": variant["id"], + "name": variant["name"], + "manufacturer_sku": variant.get("manufacturer_sku"), + "identifiers": variant.get("identifiers") or {}, + }, + ) + ) + for attribute in variant["attributes"]: + _ok(client.post(f"/api/v1/variants/{variant['id']}/attributes", json={**attribute, "source_id": "manual"})) + for offer in FIXTURE["offers"]: + _ok( + client.post( + f"/api/v1/variants/{offer['variant_id']}/offers", + json={ + "id": offer["id"], + "source_id": "manual", + "external_id": offer["external_id"], + "seller": offer["seller"], + "url": offer.get("url"), + "market": offer["market"], + "currency": offer["currency"], + "condition": offer["condition"], + }, + ) + ) + for price in FIXTURE["prices"]: + offer = next(item for item in FIXTURE["offers"] if item["external_id"] == price["external_offer_id"]) + _ok( + client.post( + f"/api/v1/offers/{offer['id']}/prices", + json={ + "id": price["id"], + "source_id": "manual", + "external_observation_id": price["external_observation_id"], + "observed_at": price["observed_at"], + "item_price": price["item_price"], + "shipping_price": price["shipping_price"], + "currency": price["currency"], + "availability": price["availability"], + "raw_payload": price["raw_payload"], + "confidence": price["confidence"], + }, + ), + allowed=(200, 201), + ) + + +def test_http_cases_and_replay(client: TestClient) -> None: + _seed(client) + cases = { + "A": ("22222222-2222-4222-8222-222222222222", "33333333-3333-4333-8333-333333333333"), + "B": ("22222222-2222-4222-8222-22222222222b", "33333333-3333-4333-8333-33333333333b"), + "C": ("22222222-2222-4222-8222-22222222222c", "33333333-3333-4333-8333-33333333333c"), + "D": ("22222222-2222-4222-8222-222222222222", "33333333-3333-4333-8333-33333333333d"), + } + created = {} + for label, (variant_id, offer_id) in cases.items(): + response = client.post( + f"/api/v1/evaluate/{variant_id}", + json={ + "profile_id": "mobile-development-notebook", + "profile_version": 1, + "offer_id": offer_id, + "as_of": AS_OF, + }, + ) + assert response.status_code == 201, response.text + created[label] = response.json() + + assert created["A"]["product_score"] == 90.21 + assert created["A"]["price_score"] == 98.24 + assert created["A"]["deal_score"] == 93.42 + assert created["A"]["classification"] == "EXCEPTIONAL" + assert created["A"]["eligible"] is True + assert created["A"]["price_reference_scope"] == "offer" + + assert created["B"]["product_eligibility"] == "INELIGIBLE" + assert created["B"]["offer_eligibility"] == "ELIGIBLE" + assert created["B"]["deal_score"] is None + assert created["B"]["product_score"] is not None + + assert created["C"]["product_eligibility"] == "UNKNOWN" + assert created["C"]["classification"] == "INSUFFICIENT_DATA" + + assert created["D"]["product_eligibility"] == "ELIGIBLE" + assert created["D"]["offer_eligibility"] == "INELIGIBLE" + assert created["D"]["product_score"] == 90.21 + assert created["D"]["price_score"] is not None + assert created["D"]["deal_score"] is None + + stored = client.get(f"/api/v1/evaluations/{created['A']['id']}") + assert stored.status_code == 200 + assert stored.json()["deal_score"] == created["A"]["deal_score"] + assert stored.json()["input_snapshot"] == created["A"]["input_snapshot"] + + extra = client.post( + "/api/v1/offers/33333333-3333-4333-8333-333333333333/prices", + json={ + "source_id": "manual", + "external_observation_id": "a-later", + "observed_at": "2026-10-03T11:00:00Z", + "item_price": "999.00", + "shipping_price": "0.00", + "currency": "EUR", + "availability": "in_stock", + "raw_payload": {"total": "999.00"}, + "confidence": 1.0, + }, + ) + assert extra.status_code == 201 + replay = client.get(f"/api/v1/evaluations/{created['A']['id']}") + assert replay.json()["deal_score"] == created["A"]["deal_score"] + assert replay.json()["price_statistics"]["current_total"] == created["A"]["price_statistics"]["current_total"] + + +def test_error_contracts(client: TestClient) -> None: + missing = client.get("/api/v1/products/00000000-0000-4000-8000-000000000000") + assert missing.status_code == 404 + assert "error" in missing.json() + assert missing.json()["error"]["code"] == "NOT_FOUND" + + bad = client.post("/api/v1/products", json={"category": "notebook"}) + assert bad.status_code == 422 + assert "error" in bad.json() + + client.post("/api/v1/attribute-definitions", json={"key": "ports", "value_type": "string_list"}) + again = client.post("/api/v1/attribute-definitions", json={"key": "ports", "value_type": "string_list"}) + assert again.status_code == 409 + + no_version = client.get("/api/v1/profiles/mobile-development-notebook") + assert no_version.status_code == 422 diff --git a/backend/tests/integration/__init__.py b/backend/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/integration/test_discovery_lifecycle.py b/backend/tests/integration/test_discovery_lifecycle.py new file mode 100644 index 0000000..5e83673 --- /dev/null +++ b/backend/tests/integration/test_discovery_lifecycle.py @@ -0,0 +1,249 @@ +from copy import deepcopy +from decimal import Decimal + +import pytest +from sqlalchemy import text + +from app.discovery.enums import DiffStatus, LifecycleStatus, MatchStatus, RunStatus +from app.errors import AppError +from app.persistence.db import SessionLocal, engine +from app.persistence.discovery_models import ( + ProductObservationRecord, + ProjectCandidateRecord, + SourceProductReferenceRecord, +) +from app.services.discovery_service import ( + CriterionMappingService, + DiscoverySearchService, + SearchExecutionService, + TaxonomyService, +) +from app.services.ingestion_service import IngestionService +from app.services.project_service import ProjectService +from app.sources.geizhals.adapter import GeizhalsAdapter +from scripts.bootstrap import run_bootstrap +from scripts.seed_notebook_pilot import seed_notebook_pilot +from tests.e2e.test_http_walkthrough import DEFINITIONS, PROFILE + +pytestmark = pytest.mark.integration + +ITEM_A = { + "source_external_id": "gh-nb-a", + "source_url": "https://geizhals.de/example-mobile-13-a.html", + "source_title": "Example Mobile 13 32GB", + "manufacturer": "Example", + "model": "Mobile 13", + "manufacturer_sku": "EM13-32-1T", + "gtin_ean": "4000000000001", + "best_price": "1298.00", + "currency": "EUR", + "availability": "in_stock", + "seller_count": 8, + "raw_attributes": {"RAM": "32GB"}, + "normalized_attributes": {"ram_gb": 32, "storage_gb": 1024, "weight_kg": 1.2}, +} + + +def _item(suffix: str, **overrides) -> dict: + item = deepcopy(ITEM_A) + item["source_external_id"] = f"gh-nb-{suffix}" + item["source_url"] = f"https://geizhals.de/example-mobile-13-{suffix}.html" + item["manufacturer_sku"] = f"EM13-{suffix}" + item["gtin_ean"] = None + item["source_title"] = f"Example Mobile 13 {suffix}" + item.update(overrides) + return item + + +class ScriptedAdapter: + source_key = "geizhals" + + def __init__(self, pages: list[dict]) -> None: + self.pages = pages + self._inner = GeizhalsAdapter() + + def discover_taxonomy(self, category_ref: str) -> dict: + return self._inner.discover_taxonomy(category_ref) + + def build_query(self, criteria, mappings) -> dict: + return self._inner.build_query(criteria, mappings) + + def fetch_results(self, query, pagination) -> dict: + page = int(pagination.get("page") or 1) + return self.pages[page - 1] + + def parse_results(self, response) -> list: + return list(response.get("items") or []) + + def normalize_result(self, result) -> dict: + return self._inner.normalize_result(result) + + +@pytest.fixture(scope="module", autouse=True) +def require_postgres() -> None: + try: + with engine.connect() as connection: + connection.execute(text("SELECT 1")) + except Exception as exc: + pytest.skip(f"PostgreSQL not available: {exc}") + + +def _session(): + session = SessionLocal() + run_bootstrap(session) + seed_notebook_pilot(session) + ingestion = IngestionService(session) + for definition in DEFINITIONS: + try: + ingestion.create_definition(definition) + except AppError as exc: + if exc.status_code != 409: + raise + try: + ingestion.create_profile(PROFILE) + except AppError as exc: + if exc.status_code != 409: + raise + return session + + +def _prepare_search(session, adapter, threshold=300): + taxonomy = TaxonomyService(session, adapter).discover("geizhals", "nb") + mappings = CriterionMappingService(session) + mappings.create( + { + "criterion_key": "ram_gb", + "source_id": "geizhals", + "source_category_id": taxonomy["id"], + "external_filter_key": "17023", + "source_value": "ab 24GB", + "encoding": {"url_param": "xf"}, + } + ) + project = ProjectService(session).create( + { + "name": "Lifecycle", + "profile_id": "mobile-development-notebook", + "profile_version": 1, + "criteria": {"items": [{"id": "ram", "criterion": "ram_gb", "operator": ">=", "value": 24}]}, + } + ) + search = DiscoverySearchService(session, adapter).create( + { + "buying_project_id": project["id"], + "source_id": "geizhals", + "source_category_id": taxonomy["id"], + "broad_result_threshold": threshold, + } + ) + return search["id"] + + +def _page(items, status=200, page_count=1): + return {"page": 1, "page_count": page_count, "result_count": len(items), "http_status": status, "items": items} + + +def test_partial_does_not_mark_missing_or_disappeared(): + session = _session() + try: + adapter = ScriptedAdapter([_page([_item("partial")])]) + search_id = _prepare_search(session, adapter) + first = SearchExecutionService(session, adapter).execute(search_id) + assert first["status"] == RunStatus.SUCCESS.value + ref_id = first["items"][0]["source_product_reference_id"] + adapter.pages = [_page([], status=429)] + second = SearchExecutionService(session, adapter).execute(search_id) + assert second["status"] == RunStatus.PARTIAL.value + assert all(item["diff_status"] != DiffStatus.MISSING.value for item in second["items"]) + ref = session.get(SourceProductReferenceRecord, ref_id) + assert ref is not None + assert ref.lifecycle_status == LifecycleStatus.ACTIVE.value + assert ref.consecutive_missing_success_runs == 0 + finally: + session.close() + + +def test_three_success_misses_disappear_and_reappear(): + session = _session() + try: + item = _item("gone") + adapter = ScriptedAdapter([_page([item])]) + search_id = _prepare_search(session, adapter) + first = SearchExecutionService(session, adapter).execute(search_id) + ref_id = first["items"][0]["source_product_reference_id"] + adapter.pages = [_page([])] + for _ in range(3): + SearchExecutionService(session, adapter).execute(search_id) + ref = session.get(SourceProductReferenceRecord, ref_id) + assert ref is not None + assert ref.lifecycle_status == LifecycleStatus.DISAPPEARED.value + changed = deepcopy(item) + changed["best_price"] = "999.00" + adapter.pages = [_page([changed])] + run = SearchExecutionService(session, adapter).execute(search_id) + session.refresh(ref) + assert ref.lifecycle_status == LifecycleStatus.ACTIVE.value + assert any(item["diff_status"] == DiffStatus.CHANGED.value for item in run["items"]) + assert all(item["diff_status"] != DiffStatus.NEW.value for item in run["items"]) + prices = [ + row.best_price + for row in session.query(ProductObservationRecord).filter( + ProductObservationRecord.source_product_reference_id == ref_id + ) + ] + assert Decimal("1298.00") in prices + assert Decimal("999.00") in prices + finally: + session.close() + + +def test_uncertain_match_stays_separate(): + session = _session() + try: + first = _item("matched", manufacturer="Example", model="Uncertain 13") + second = _item( + "uncertain", + source_external_id=None, + manufacturer_sku=None, + source_url="https://geizhals.de/other-uncertain.html", + source_title=first["source_title"], + manufacturer="Example", + model="Uncertain 13", + normalized_attributes={"ram_gb": 16, "storage_gb": 512, "weight_kg": 2.0}, + ) + adapter = ScriptedAdapter([_page([first, second])]) + search_id = _prepare_search(session, adapter) + project_id = DiscoverySearchService(session, adapter).get(search_id)["buying_project_id"] + run = SearchExecutionService(session, adapter).execute(search_id) + assert run["status"] == RunStatus.SUCCESS.value + ref_ids = {item["source_product_reference_id"] for item in run["items"]} + assert len(ref_ids) == 2 + refs = [session.get(SourceProductReferenceRecord, ref_id) for ref_id in ref_ids] + assert MatchStatus.MATCH_UNCERTAIN.value in {ref.match_status for ref in refs if ref is not None} + candidates = ( + session.query(ProjectCandidateRecord) + .filter(ProjectCandidateRecord.buying_project_id == project_id) + .all() + ) + assert len(candidates) == 2 + assert MatchStatus.MATCH_UNCERTAIN.value in {row.match_status for row in candidates} + finally: + session.close() + + +def test_backoff_and_blocked(): + session = _session() + try: + adapter = ScriptedAdapter([_page([ITEM_A], status=429)]) + search_id = _prepare_search(session, adapter) + backoff = SearchExecutionService(session, adapter).execute(search_id) + assert backoff["status"] == RunStatus.PARTIAL.value + assert backoff["error_code"] == "BACKOFF" + adapter.pages = [_page([ITEM_A], status=403)] + blocked = SearchExecutionService(session, adapter).execute(search_id) + assert blocked["status"] == RunStatus.BLOCKED.value + stored = DiscoverySearchService(session, adapter).get(search_id) + assert stored["status"] == "BLOCKED" + assert stored["next_run_at"] is None + finally: + session.close() diff --git a/backend/tests/integration/test_manual_source.py b/backend/tests/integration/test_manual_source.py new file mode 100644 index 0000000..8006b41 --- /dev/null +++ b/backend/tests/integration/test_manual_source.py @@ -0,0 +1,59 @@ +from pathlib import Path + +import pytest +from sqlalchemy import text + +from app.persistence.db import SessionLocal, engine +from app.services.import_service import ImportService +from app.services.ingestion_service import IngestionService +from app.sources.manual import ManualSource +from scripts.bootstrap import run_bootstrap + +pytestmark = pytest.mark.integration +ROOT = Path(__file__).resolve().parents[2] + + +@pytest.fixture(scope="module", autouse=True) +def require_postgres() -> None: + try: + with engine.connect() as connection: + connection.execute(text("SELECT 1")) + except Exception as exc: + pytest.skip(f"PostgreSQL not available: {exc}") + + +def test_manual_source_import_is_idempotent() -> None: + session = SessionLocal() + try: + run_bootstrap(session) + ingestion = IngestionService(session) + for item in ( + {"key": "ram_gb", "value_type": "number", "canonical_unit": "GB", "minimum": 0}, + {"key": "storage_gb", "value_type": "number", "canonical_unit": "GB", "minimum": 0}, + {"key": "weight_kg", "value_type": "number", "canonical_unit": "kg", "minimum": 0}, + {"key": "battery_score", "value_type": "number", "minimum": 0, "maximum": 100}, + {"key": "display_score", "value_type": "number", "minimum": 0, "maximum": 100}, + {"key": "cpu_score", "value_type": "number", "minimum": 0, "maximum": 100}, + ): + try: + ingestion.create_definition(item) + except Exception: + session.rollback() + source = ManualSource(ROOT / "fixtures" / "manual-demo.json") + importer = ImportService(ingestion) + importer.import_product(source, "example-mobile-13") + session.commit() + importer.import_product(source, "example-mobile-13") + session.commit() + from sqlalchemy import func, select + + from app.persistence.models import OfferRecord, PriceObservationRecord, ProductRecord + + products = session.scalar(select(func.count()).select_from(ProductRecord)) + offers = session.scalar(select(func.count()).select_from(OfferRecord)) + prices = session.scalar(select(func.count()).select_from(PriceObservationRecord)) + assert products >= 1 + assert offers >= 4 + assert prices >= 16 + finally: + session.close() diff --git a/tests/integration/test_ready.py b/backend/tests/integration/test_ready.py similarity index 100% rename from tests/integration/test_ready.py rename to backend/tests/integration/test_ready.py diff --git a/backend/tests/integration/test_schema_and_bootstrap.py b/backend/tests/integration/test_schema_and_bootstrap.py new file mode 100644 index 0000000..fa5c182 --- /dev/null +++ b/backend/tests/integration/test_schema_and_bootstrap.py @@ -0,0 +1,53 @@ +import pytest +from sqlalchemy import text + +from alembic import command +from alembic.config import Config + +from app.domain.validation import parse_scoring_model +from app.persistence.db import SessionLocal, engine +from app.persistence.models import ScoringModelRecord +from scripts.bootstrap import run_bootstrap + +pytestmark = pytest.mark.integration + + +@pytest.fixture(scope="module", autouse=True) +def require_postgres() -> None: + try: + with engine.connect() as connection: + connection.execute(text("SELECT 1")) + except Exception as exc: + pytest.skip(f"PostgreSQL not available: {exc}") + + +def test_upgrade_is_idempotent() -> None: + config = Config("alembic.ini") + command.upgrade(config, "head") + command.upgrade(config, "head") + + +def test_bootstrap_is_idempotent_and_rejects_hash_change(tmp_path, monkeypatch) -> None: + session = SessionLocal() + try: + run_bootstrap(session) + run_bootstrap(session) + row = session.get(ScoringModelRecord, "generic-v1") + assert row is not None + model = parse_scoring_model(row.definition) + assert row.definition_hash == model.definition_hash + row.definition_hash = "0" * 64 + session.commit() + with pytest.raises(SystemExit): + run_bootstrap(session) + row.definition_hash = model.definition_hash + session.commit() + finally: + session.close() + + +@pytest.mark.slow +def test_downgrade_and_upgrade_on_disposable_db() -> None: + config = Config("alembic.ini") + command.downgrade(config, "base") + command.upgrade(config, "head") diff --git a/backend/tests/unit/__init__.py b/backend/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/unit/test_constraints.py b/backend/tests/unit/test_constraints.py new file mode 100644 index 0000000..d86e276 --- /dev/null +++ b/backend/tests/unit/test_constraints.py @@ -0,0 +1,59 @@ +from decimal import Decimal + +from app.domain.constraints import evaluate_constraint +from app.domain.enums import ConstraintOperator +from app.domain.models import Constraint +from tests.conftest import statement + + +def _eval(definitions, attribute, operator, value, stmt, confidence="0.8"): + return evaluate_constraint( + Constraint("c1", attribute, ConstraintOperator(operator), value, True), + stmt, + definitions[attribute], + Decimal(confidence), + ) + + +def test_number_operators_pass_fail_unknown(definitions): + assert _eval(definitions, "ram_gb", ">=", 16, statement("ram_gb", 16)).status == "PASS" + assert _eval(definitions, "ram_gb", ">", 16, statement("ram_gb", 16)).status == "FAIL" + assert _eval(definitions, "ram_gb", "<", 16, statement("ram_gb", 8)).status == "PASS" + assert _eval(definitions, "ram_gb", "<=", 16, statement("ram_gb", 16)).status == "PASS" + assert _eval(definitions, "ram_gb", ">=", 16, None).status == "UNKNOWN" + + +def test_equality_and_in(definitions): + assert _eval(definitions, "color", "==", "black", statement("color", "black")).status == "PASS" + assert _eval(definitions, "color", "!=", "silver", statement("color", "black")).status == "PASS" + assert _eval(definitions, "has_lte", "==", True, statement("has_lte", True)).status == "PASS" + assert _eval(definitions, "ram_gb", "in", [16, 32], statement("ram_gb", 32)).status == "PASS" + assert _eval(definitions, "ram_gb", "in", [16], statement("ram_gb", 8)).status == "FAIL" + + +def test_contains_and_empty_list(definitions): + empty = statement("ports", []) + assert _eval(definitions, "ports", "exists", None, empty).status == "PASS" + assert _eval(definitions, "ports", "contains", "hdmi", empty).status == "FAIL" + present = statement("ports", ["hdmi", "usb-c"]) + assert _eval(definitions, "ports", "contains", "hdmi", present).status == "PASS" + + +def test_exists_unknown_when_unusable(definitions): + assert _eval(definitions, "ram_gb", "exists", None, None).status == "UNKNOWN" + unknown = statement("ram_gb", None, status="unknown") + assert _eval(definitions, "ram_gb", "exists", None, unknown).status == "UNKNOWN" + assert _eval(definitions, "ram_gb", "!=", 8, None).status == "UNKNOWN" + + +def test_confidence_boundary(definitions): + exact = statement("ram_gb", 32, confidence="0.8") + below = statement("ram_gb", 32, confidence="0.799") + assert _eval(definitions, "ram_gb", ">=", 16, exact).status == "PASS" + assert _eval(definitions, "ram_gb", ">=", 16, below).status == "UNKNOWN" + + +def test_no_type_coercion(definitions): + text_number = statement("ram_gb", "32") + text_number = text_number.__class__(**{**text_number.__dict__, "normalized_value": "32"}) + assert _eval(definitions, "ram_gb", ">=", 16, text_number).status == "UNKNOWN" diff --git a/backend/tests/unit/test_discovery_diff.py b/backend/tests/unit/test_discovery_diff.py new file mode 100644 index 0000000..490e0b9 --- /dev/null +++ b/backend/tests/unit/test_discovery_diff.py @@ -0,0 +1,74 @@ +from app.discovery.diff import diff_run, next_lifecycle, observations_changed +from app.discovery.enums import DiffStatus, LifecycleStatus, RunStatus + + +PREV = {"best_price": "1298.00", "availability": "in_stock", "seller_count": 8, "normalized_attributes": {"ram_gb": 32}} +CURR = {"best_price": "1199.00", "availability": "in_stock", "seller_count": 8, "normalized_attributes": {"ram_gb": 32}} + + +def test_changed_only_for_relevant_fields(): + assert observations_changed(PREV, CURR) is True + title_only = {**PREV, "normalized_attributes": {"ram_gb": 32, "title": "new title", "html": ""}} + assert observations_changed(PREV, title_only) is False + assert observations_changed(PREV, {**PREV, "seller_count": 12}) is True + assert observations_changed(PREV, {**PREV, "availability": "out_of_stock"}) is True + + +def test_diff_statuses_and_partial_skips_missing(): + previous_ids = {"a", "b"} + current_ids = {"b", "c"} + previous_obs = {"a": PREV, "b": PREV} + current_obs = {"b": CURR, "c": PREV} + success = diff_run( + previous_ids=previous_ids, + current_ids=current_ids, + previous_obs=previous_obs, + current_obs=current_obs, + run_status=RunStatus.SUCCESS.value, + ) + assert success["c"] == DiffStatus.NEW + assert success["b"] == DiffStatus.CHANGED + assert success["a"] == DiffStatus.MISSING + partial = diff_run( + previous_ids=previous_ids, + current_ids=current_ids, + previous_obs=previous_obs, + current_obs=current_obs, + run_status=RunStatus.PARTIAL.value, + ) + assert "a" not in partial + assert DiffStatus.MISSING not in partial.values() + + +def test_unchanged_and_disappeared_lifecycle(): + same = diff_run( + previous_ids={"a"}, + current_ids={"a"}, + previous_obs={"a": PREV}, + current_obs={"a": PREV}, + run_status=RunStatus.SUCCESS.value, + ) + assert same["a"] == DiffStatus.UNCHANGED + status, streak = next_lifecycle( + current=LifecycleStatus.ACTIVE.value, + missing_in_success_run=True, + consecutive_missing=2, + reappeared=False, + ) + assert status == LifecycleStatus.DISAPPEARED.value + assert streak == 3 + status, streak = next_lifecycle( + current=LifecycleStatus.DISAPPEARED.value, + missing_in_success_run=False, + consecutive_missing=3, + reappeared=True, + ) + assert status == LifecycleStatus.ACTIVE.value + assert streak == 0 + archived, _ = next_lifecycle( + current=LifecycleStatus.ARCHIVED.value, + missing_in_success_run=True, + consecutive_missing=5, + reappeared=True, + ) + assert archived == LifecycleStatus.ARCHIVED.value diff --git a/backend/tests/unit/test_discovery_identity.py b/backend/tests/unit/test_discovery_identity.py new file mode 100644 index 0000000..e28a217 --- /dev/null +++ b/backend/tests/unit/test_discovery_identity.py @@ -0,0 +1,83 @@ +from app.discovery.enums import MatchStatus +from app.discovery.identity import IdentitySignals, decide_identity, spec_fingerprint + + +def _sig(**overrides) -> IdentitySignals: + base = IdentitySignals( + source_external_id="gh-1", + gtin_ean="4000000000001", + manufacturer_sku="EM13-32", + source_url="https://geizhals.de/a.html", + title="Example Mobile 13", + manufacturer="Example", + model="Mobile 13", + fingerprint=spec_fingerprint({"manufacturer": "Example", "model": "Mobile 13", "ram": 32}), + ) + return IdentitySignals(**{**base.__dict__, **overrides}) + + +def test_sku_and_ean_and_url_match(): + known = [("a", _sig())] + assert decide_identity(_sig(), known).reason == "source_external_id" + assert decide_identity(_sig(source_external_id="other"), known).reason == "gtin_ean" + assert decide_identity(_sig(source_external_id="other", gtin_ean="x"), known).reason == "manufacturer_sku" + assert decide_identity( + _sig(source_external_id="other", gtin_ean="x", manufacturer_sku="y"), + known, + ).reason == "source_url" + + +def test_title_plus_fingerprint_matches_stable_specs(): + known = [("a", _sig(source_external_id=None, gtin_ean=None, manufacturer_sku=None, source_url=None))] + incoming = _sig( + source_external_id=None, + gtin_ean=None, + manufacturer_sku=None, + source_url=None, + title="Example Mobile 13", + ) + decision = decide_identity(incoming, known) + assert decision.match_status == MatchStatus.MATCHED + assert decision.reason == "title+fingerprint" + + +def test_title_only_is_uncertain_and_never_merges(): + known = [("a", _sig(source_external_id=None, gtin_ean=None, manufacturer_sku=None, source_url=None))] + incoming = _sig( + source_external_id=None, + gtin_ean=None, + manufacturer_sku=None, + source_url=None, + fingerprint="different", + manufacturer="Other", + model="Other", + ) + decision = decide_identity(incoming, known) + assert decision.match_status == MatchStatus.MATCH_UNCERTAIN + assert decision.existing_id is None + assert decision.reason == "title only" + + +def test_fingerprint_uses_observed_keys_only(): + left = spec_fingerprint({"capacity_l": 2, "color": "black"}) + right = spec_fingerprint({"capacity_l": 5, "color": "black"}) + assert left != right + assert "capacity_l=2" in left + assert "ram_gb" not in left + assert spec_fingerprint({"title": "ignored", "html": "", "noise": 1}) == "noise=1" + + +def test_similar_names_do_not_merge(): + known = [("a", _sig(source_external_id=None, gtin_ean=None, manufacturer_sku=None, source_url=None))] + incoming = _sig( + source_external_id=None, + gtin_ean=None, + manufacturer_sku=None, + source_url=None, + title="Example Mobile 14", + fingerprint="other", + manufacturer="Example", + model="Mobile 14", + ) + decision = decide_identity(incoming, known) + assert decision.match_status == MatchStatus.NEW_REFERENCE diff --git a/backend/tests/unit/test_discovery_ranking.py b/backend/tests/unit/test_discovery_ranking.py new file mode 100644 index 0000000..b688cf0 --- /dev/null +++ b/backend/tests/unit/test_discovery_ranking.py @@ -0,0 +1,48 @@ +from decimal import Decimal + +from app.discovery.ranking import rank_candidates + + +def test_shortlist_uses_product_score_only(profile, scoring_model, definitions): + ranked = rank_candidates( + profile=profile, + scoring_model=scoring_model, + definitions=definitions, + shortlist_size=1, + candidates=[ + { + "id": "a", + "manufacturer": "Example", + "source_title": "Example Mobile 13 32GB", + "normalized_attributes": { + "ram_gb": 32, + "storage_gb": 1024, + "weight_kg": 1.2, + "battery_score": 90, + "display_score": 90, + "cpu_score": 90, + }, + }, + { + "id": "b", + "manufacturer": "Example", + "source_title": "Example Mobile 13 8GB", + "normalized_attributes": { + "ram_gb": 8, + "storage_gb": 1024, + "weight_kg": 1.2, + "battery_score": 90, + "display_score": 90, + "cpu_score": 90, + }, + }, + ], + ) + assert ranked[0]["id"] == "a" + assert ranked[0]["eligible"] is True + assert ranked[0]["candidate_status"] == "SHORTLISTED" + assert ranked[0]["product_score"] == Decimal("90.21") + assert ranked[1]["eligible"] is False + assert ranked[1]["candidate_status"] == "CANDIDATE" + assert "deal_score" not in ranked[0] + assert "price_score" not in ranked[0] diff --git a/backend/tests/unit/test_discovery_urlgen.py b/backend/tests/unit/test_discovery_urlgen.py new file mode 100644 index 0000000..af70bee --- /dev/null +++ b/backend/tests/unit/test_discovery_urlgen.py @@ -0,0 +1,81 @@ +from app.discovery.mapping import verify_roundtrip +from app.discovery.urlgen import generate_search_url + + +CRITERIA = [ + {"id": "ram", "criterion": "ram_gb", "operator": ">=", "value": 24, "mandatory": True}, + {"id": "price", "criterion": "price_eur", "operator": "<=", "value": 2000, "mandatory": True}, + {"id": "weight", "criterion": "weight_kg", "operator": "<=", "value": 1.6, "mandatory": True}, +] + +MAPPINGS = [ + { + "criterion_key": "ram_gb", + "external_filter_key": "17023", + "source_value": "ab 24GB", + "encoding": {"url_param": "xf"}, + "status": "VERIFIED", + }, + { + "criterion_key": "price_eur", + "external_filter_key": "price", + "source_value": None, + "encoding": {"url_param": "maxp"}, + "status": "VERIFIED", + }, +] + + +def test_ram_and_price_url_is_reproducible(): + first, internal = generate_search_url( + category_url="https://geizhals.de/?cat=nb", + criteria_items=CRITERIA, + mappings=MAPPINGS, + ) + second, _ = generate_search_url( + category_url="https://geizhals.de/?cat=nb", + criteria_items=CRITERIA, + mappings=MAPPINGS, + ) + assert first == second + assert "xf=17023_ab+24GB" in first + assert "maxp=2000" in first + assert internal == ["weight_kg"] + + +def test_internal_only_does_not_change_url(): + without_weight, _ = generate_search_url( + category_url="https://geizhals.de/?cat=nb", + criteria_items=CRITERIA[:2], + mappings=MAPPINGS, + ) + with_weight, internal = generate_search_url( + category_url="https://geizhals.de/?cat=nb", + criteria_items=CRITERIA, + mappings=MAPPINGS, + ) + assert without_weight == with_weight + assert "weight_kg" in internal + + +def test_broken_mapping_is_not_used(): + url, internal = generate_search_url( + category_url="https://geizhals.de/?cat=nb", + criteria_items=CRITERIA, + mappings=[{**MAPPINGS[0], "status": "BROKEN"}, MAPPINGS[1]], + ) + assert "17023" not in url + assert "ram_gb" in internal + + +def test_roundtrip_verifies_xf_and_maxp(): + assert verify_roundtrip( + category_url="https://geizhals.de/?cat=nb", + criterion=CRITERIA[0], + mapping=MAPPINGS[0], + ) + assert verify_roundtrip( + category_url="https://geizhals.de/?cat=nb", + criterion=CRITERIA[1], + mapping=MAPPINGS[1], + ) diff --git a/backend/tests/unit/test_engine_cases.py b/backend/tests/unit/test_engine_cases.py new file mode 100644 index 0000000..76d56ba --- /dev/null +++ b/backend/tests/unit/test_engine_cases.py @@ -0,0 +1,165 @@ +from datetime import timedelta +from decimal import Decimal +from uuid import uuid4 + +from app.domain.enums import Eligibility, PriceReferenceScope +from app.scoring.deal import classify_deal_score +from app.scoring.engine import evaluate_deal +from app.scoring.mathutil import round_score +from tests.conftest import ( + AS_OF, + CUTOFF, + case_a_attributes, + case_a_prices, + offer_a, + observation, + product_a, + statement, + variant_a, +) + + +def _run(profile, scoring_model, definitions, attributes=None, observations=None, product=None, offer=None): + return evaluate_deal( + profile=profile, + scoring_model=scoring_model, + product=product or product_a(), + variant=variant_a(), + offer=offer or offer_a(), + attributes=attributes or case_a_attributes(), + observations=observations or case_a_prices(), + definitions=definitions, + as_of=AS_OF, + snapshot_cutoff=CUTOFF, + engine_build="test", + ) + + +def test_reference_case_a(profile, scoring_model, definitions): + result = _run(profile, scoring_model, definitions) + assert result.product_eligibility == Eligibility.ELIGIBLE + assert result.offer_eligibility == Eligibility.ELIGIBLE + assert result.overall_eligibility == Eligibility.ELIGIBLE + assert result.eligible is True + assert result.product_score == Decimal("90.21") + assert result.price_statistics.median_30d == Decimal("1599.00") + assert result.price_statistics.median_30d_days == 2 + assert result.price_statistics.median_90d == Decimal("1599.00") + assert result.price_statistics.median_90d_days == 3 + assert result.price_statistics.lowest_365d == Decimal("1599.00") + assert result.price_statistics.lowest_365d_days == 3 + assert result.price_score == Decimal("98.24") + assert result.deal_score == Decimal("93.42") + assert result.classification == "EXCEPTIONAL" + assert result.product_coverage == Decimal("1") + assert result.price_reference_scope == PriceReferenceScope.OFFER + assert result.price_basis.value == "BUDGET_AND_HISTORY" + assert any(reason.code == "PRICE_BELOW_MEDIAN" for reason in result.reasons) + + +def test_case_b_ineligible_product(profile, scoring_model, definitions): + attrs = ( + statement("ram_gb", 8), + statement("storage_gb", 1024), + statement("weight_kg", Decimal("1.2")), + statement("battery_score", 90), + statement("display_score", 90), + statement("cpu_score", 90), + ) + result = _run(profile, scoring_model, definitions, attributes=attrs) + assert result.product_eligibility == Eligibility.INELIGIBLE + assert result.offer_eligibility == Eligibility.ELIGIBLE + assert result.overall_eligibility == Eligibility.INELIGIBLE + assert result.eligible is False + assert result.product_score is not None + assert result.deal_score is None + assert result.classification == "INELIGIBLE" + assert any(item.constraint_id == "min_ram" and item.status == "FAIL" for item in result.constraint_results) + + +def test_case_c_unknown_weight(profile, scoring_model, definitions): + attrs = ( + statement("ram_gb", 32), + statement("storage_gb", 1024), + statement("weight_kg", None, status="unknown"), + statement("battery_score", 90), + statement("display_score", 90), + statement("cpu_score", 90), + ) + result = _run(profile, scoring_model, definitions, attributes=attrs) + assert result.product_eligibility == Eligibility.UNKNOWN + assert result.offer_eligibility == Eligibility.ELIGIBLE + assert result.overall_eligibility == Eligibility.UNKNOWN + assert result.eligible is None + assert result.product_score is not None + assert result.deal_score is None + assert result.classification == "INSUFFICIENT_DATA" + + +def test_case_d_over_budget_keeps_product_fit(profile, scoring_model, definitions): + prices = ( + observation((9, 1), "1599.00"), + observation((9, 15), "1599.00"), + observation((10, 1), "1599.00"), + observation((10, 3), "2049.00"), + ) + result = _run(profile, scoring_model, definitions, observations=prices) + assert result.product_eligibility == Eligibility.ELIGIBLE + assert result.offer_eligibility == Eligibility.INELIGIBLE + assert result.overall_eligibility == Eligibility.INELIGIBLE + assert result.product_score == Decimal("90.21") + assert result.price_score is not None + assert result.deal_score is None + + +def test_freshness_boundary(profile, scoring_model, definitions): + fresh = observation((10, 3), "1298.00") + fresh = fresh.__class__(**{**fresh.__dict__, "observed_at": AS_OF - timedelta(hours=48)}) + result = _run(profile, scoring_model, definitions, observations=(fresh,)) + assert result.offer_eligibility in {Eligibility.ELIGIBLE, Eligibility.UNKNOWN} + stale = fresh.__class__(**{**fresh.__dict__, "observed_at": AS_OF - timedelta(hours=48, seconds=1)}) + result_stale = _run(profile, scoring_model, definitions, observations=(stale,)) + assert result_stale.offer_eligibility == Eligibility.UNKNOWN + assert result_stale.price_score is None + + +def test_unknown_shipping(profile, scoring_model, definitions): + price = observation((10, 3), "1298.00") + price = price.__class__(**{**price.__dict__, "shipping_price": None}) + result = _run(profile, scoring_model, definitions, observations=(price,)) + assert result.offer_eligibility == Eligibility.UNKNOWN + assert result.price_score is None + + +def test_classification_boundaries(scoring_model): + assert classify_deal_score(Decimal("59.999"), scoring_model) == "IGNORE" + assert classify_deal_score(Decimal("60"), scoring_model) == "WATCH" + assert classify_deal_score(Decimal("74.999"), scoring_model) == "WATCH" + assert classify_deal_score(Decimal("75"), scoring_model) == "GOOD" + assert classify_deal_score(Decimal("85"), scoring_model) == "VERY_GOOD" + assert classify_deal_score(Decimal("92"), scoring_model) == "VERY_GOOD" + assert classify_deal_score(Decimal("92.01"), scoring_model) == "EXCEPTIONAL" + + +def test_rounding_does_not_shift_class(scoring_model): + raw = Decimal("59.996") + assert classify_deal_score(raw, scoring_model) == "IGNORE" + assert round_score(raw) == Decimal("60.00") + + +def test_other_category_without_engine_change(profile, scoring_model, definitions): + camera = product_a().__class__(**{**product_a().__dict__, "category": "camera"}) + result = _run(profile, scoring_model, definitions, product=camera) + assert result.product_eligibility == Eligibility.INELIGIBLE + assert any(gate.reason_code == "CATEGORY_MISMATCH" for gate in result.product_gates) + + +def test_does_not_mix_other_offer_history(profile, scoring_model, definitions): + other = observation((9, 1), "100.00", offer_id=uuid4()) + mixed = case_a_prices() + (other,) + result = _run(profile, scoring_model, definitions, observations=mixed) + # Engine is called with the provided list; the assembler in v0.1 should only + # receive one offer. Guard the function by using only same-offer rows. + same_offer = tuple(item for item in mixed if item.offer_id == offer_a().id) + result = _run(profile, scoring_model, definitions, observations=same_offer) + assert result.price_statistics.median_90d == Decimal("1599.00") diff --git a/tests/unit/test_health_live.py b/backend/tests/unit/test_health_live.py similarity index 100% rename from tests/unit/test_health_live.py rename to backend/tests/unit/test_health_live.py diff --git a/backend/tests/unit/test_price_score.py b/backend/tests/unit/test_price_score.py new file mode 100644 index 0000000..9df962b --- /dev/null +++ b/backend/tests/unit/test_price_score.py @@ -0,0 +1,103 @@ +from datetime import datetime, timezone +from decimal import Decimal +from uuid import uuid4 + +from app.domain.models import Budget +from app.scoring.price import daily_series, median, score_price, select_current_observation +from tests.conftest import observation, utc + + +def test_budget_and_history_points(scoring_model): + budget = Budget(Decimal("1500.00"), Decimal("2000.00")) + score, budget_score, history_score, basis, warning = score_price( + current_price=Decimal("1500.00"), + reference_history=(Decimal("1500"), Decimal("1500"), Decimal("1500")), + budget=budget, + model=scoring_model, + ) + assert budget_score == Decimal("100") + assert history_score == Decimal("50") + assert basis.value == "BUDGET_AND_HISTORY" + assert warning is None + + over, over_budget, _, _, _ = score_price( + current_price=Decimal("2000.00"), + reference_history=(Decimal("1000"), Decimal("1000"), Decimal("1000")), + budget=budget, + model=scoring_model, + ) + assert over_budget == Decimal("0") + + cheap, _, hist, _, _ = score_price( + current_price=Decimal("800.00"), + reference_history=(Decimal("1000"), Decimal("1000"), Decimal("1000")), + budget=budget, + model=scoring_model, + ) + assert hist == Decimal("100") + expensive, _, hist2, _, _ = score_price( + current_price=Decimal("1200.00"), + reference_history=(Decimal("1000"), Decimal("1000"), Decimal("1000")), + budget=budget, + model=scoring_model, + ) + assert hist2 == Decimal("0") + + +def test_budget_only_with_short_history(scoring_model): + budget = Budget(Decimal("1500.00"), Decimal("2000.00")) + for days in ((), (Decimal("10"),), (Decimal("10"), Decimal("11"))): + score, budget_score, history_score, basis, warning = score_price( + current_price=Decimal("1298.00"), + reference_history=days, + budget=budget, + model=scoring_model, + ) + assert score == budget_score == Decimal("100") + assert history_score is None + assert basis.value == "BUDGET_ONLY" + assert warning == "INSUFFICIENT_HISTORY" + + +def test_median_even_and_odd(): + assert median((Decimal("1"), Decimal("3"), Decimal("5"))) == Decimal("3") + assert median((Decimal("1"), Decimal("2"), Decimal("3"), Decimal("4"))) == Decimal("2.5") + + +def test_daily_series_latest_then_quality_filter(scoring_model): + offer = uuid4() + morning = observation((10, 1), "10.00", offer_id=offer, hour=8) + later_unknown = observation((10, 1), "9.00", offer_id=offer, hour=12, availability="unknown") + values, _ = daily_series( + (morning, later_unknown), + window_start=datetime(2026, 9, 1, tzinfo=timezone.utc).date(), + window_end_exclusive=datetime(2026, 10, 3, tzinfo=timezone.utc).date(), + minimum_confidence=scoring_model.minimum_confidence, + ) + assert values == () + + +def test_current_observation_does_not_fall_back(): + cheap_old = observation((10, 2), "10.00") + unknown_new = observation((10, 3), "99.00", availability="unknown") + current = select_current_observation( + (cheap_old, unknown_new), + as_of=utc(2026, 10, 3, 12), + snapshot_cutoff=utc(2026, 10, 3, 12), + ) + assert current is not None + assert current.availability.value == "unknown" + assert current.item_price == Decimal("99.00") + + +def test_no_offer_object_required_for_price_function(scoring_model): + score, _, _, basis, _ = score_price( + current_price=Decimal("1298.00"), + reference_history=(Decimal("1599"), Decimal("1599"), Decimal("1599")), + budget=Budget(Decimal("1500"), Decimal("2000")), + model=scoring_model, + ) + from app.scoring.mathutil import round_score + + assert basis.value == "BUDGET_AND_HISTORY" + assert round_score(score) == Decimal("98.24") diff --git a/backend/tests/unit/test_product_score.py b/backend/tests/unit/test_product_score.py new file mode 100644 index 0000000..2b6174d --- /dev/null +++ b/backend/tests/unit/test_product_score.py @@ -0,0 +1,91 @@ +from decimal import Decimal + +import pytest + +from app.domain.enums import ConstraintStatus, PreferenceDirection, PreferenceStrategy +from app.domain.models import ConstraintResult, Preference +from app.domain.validation import DomainValidationError, parse_profile +from app.scoring.mathutil import round_score +from app.scoring.product import score_numeric_linear, score_product +from tests.conftest import statement + + +def test_numeric_linear_acceptable_and_ideal(): + assert score_numeric_linear(Decimal("16"), Decimal("16"), Decimal("32"), PreferenceDirection.HIGHER) == Decimal("50") + assert score_numeric_linear(Decimal("32"), Decimal("16"), Decimal("32"), PreferenceDirection.HIGHER) == Decimal("100") + assert score_numeric_linear(Decimal("1.6"), Decimal("1.6"), Decimal("1.0"), PreferenceDirection.LOWER) == Decimal("50") + assert score_numeric_linear(Decimal("1.0"), Decimal("1.6"), Decimal("1.0"), PreferenceDirection.LOWER) == Decimal("100") + + +def test_numeric_linear_clamps(): + assert score_numeric_linear(Decimal("8"), Decimal("16"), Decimal("32"), PreferenceDirection.HIGHER) == Decimal("25") + assert score_numeric_linear(Decimal("64"), Decimal("16"), Decimal("32"), PreferenceDirection.HIGHER) == Decimal("100") + assert score_numeric_linear(Decimal("0.5"), Decimal("1.6"), Decimal("1.0"), PreferenceDirection.LOWER) == Decimal("100") + + +def test_weights_need_not_sum_to_one(scoring_model): + prefs = ( + Preference("a", "ram_gb", PreferenceStrategy.NUMERIC_LINEAR, PreferenceDirection.HIGHER, Decimal("16"), Decimal("32"), Decimal("2")), + Preference("b", "cpu_score", PreferenceStrategy.NUMERIC_LINEAR, PreferenceDirection.HIGHER, Decimal("60"), Decimal("100"), Decimal("2")), + ) + snapshot = {"ram_gb": statement("ram_gb", 32), "cpu_score": statement("cpu_score", 100)} + score, coverage, _ = score_product(prefs, snapshot, (), scoring_model) + assert score == Decimal("100") + assert coverage == Decimal("1") + + +def test_missing_preference_scores_zero(scoring_model): + prefs = ( + Preference("ram", "ram_gb", PreferenceStrategy.NUMERIC_LINEAR, PreferenceDirection.HIGHER, Decimal("16"), Decimal("32"), Decimal("1")), + ) + score, coverage, _ = score_product(prefs, {}, (), scoring_model) + assert score == Decimal("0") + assert coverage == Decimal("0") + + +def test_optional_penalty(scoring_model): + prefs = ( + Preference("ram", "ram_gb", PreferenceStrategy.NUMERIC_LINEAR, PreferenceDirection.HIGHER, Decimal("16"), Decimal("32"), Decimal("1")), + ) + snapshot = {"ram_gb": statement("ram_gb", 32)} + optional = ConstraintResult("opt", "ports", "contains", "hdmi", None, ConstraintStatus.FAIL.value, False, None, "CONSTRAINT_FAIL") + score, _, _ = score_product(prefs, snapshot, (optional,), scoring_model) + assert score == Decimal("95") + + +def test_unknown_strategy_rejected(definitions, scoring_model): + raw = { + "id": "x", + "version": 1, + "name": "x", + "category": "notebook", + "currency": "EUR", + "market": "DE", + "allowed_conditions": ["new"], + "budget": {"target": "100.00", "maximum": "200.00"}, + "scoring_version": "generic-v1", + "constraints": [], + "preferences": [ + { + "id": "ram", + "attribute": "ram_gb", + "strategy": "boolean_preferred", + "direction": "higher", + "acceptable": 16, + "ideal": 32, + "weight": 1, + } + ], + } + with pytest.raises(DomainValidationError): + parse_profile( + raw, + attribute_definitions=definitions, + scoring_versions={scoring_model.version}, + supported_strategies=set(scoring_model.supported_preference_strategies), + ) + + +def test_reference_preference_scores(): + assert round_score(score_numeric_linear(Decimal("1.2"), Decimal("1.6"), Decimal("1.0"), PreferenceDirection.LOWER)) == Decimal("83.33") + assert score_numeric_linear(Decimal("90"), Decimal("60"), Decimal("100"), PreferenceDirection.HIGHER) == Decimal("87.5") diff --git a/backend/tests/unit/test_project_criteria_parse.py b/backend/tests/unit/test_project_criteria_parse.py new file mode 100644 index 0000000..301dffd --- /dev/null +++ b/backend/tests/unit/test_project_criteria_parse.py @@ -0,0 +1,27 @@ +from uuid import uuid4 + +from app.discovery.criteria import parse_project_criteria +from app.settings import Settings + + +def test_parse_accepts_definition_id_and_enabled_flag(): + definition_id = uuid4() + parsed = parse_project_criteria( + { + "items": [ + { + "criterion_definition_id": str(definition_id), + "operator": ">=", + "value": 24, + "enabled_for_discovery": True, + } + ] + } + ) + assert parsed["items"][0]["criterion_definition_id"] == definition_id + assert parsed["items"][0]["enabled_for_discovery"] is True + + +def test_cors_origins_are_split_from_environment(): + settings = Settings(cors_allowed_origins="http://localhost:4173, http://127.0.0.1:4173") + assert settings.cors_origins == ["http://localhost:4173", "http://127.0.0.1:4173"] diff --git a/tests/unit/test_settings.py b/backend/tests/unit/test_settings.py similarity index 100% rename from tests/unit/test_settings.py rename to backend/tests/unit/test_settings.py diff --git a/uv.lock b/backend/uv.lock similarity index 100% rename from uv.lock rename to backend/uv.lock diff --git a/docker-compose.dev-env.yml b/docker-compose.dev-env.yml index d185c0e..f3a6a1b 100644 --- a/docker-compose.dev-env.yml +++ b/docker-compose.dev-env.yml @@ -21,7 +21,7 @@ services: api: build: - context: . + context: ./backend dockerfile: Dockerfile target: runtime environment: @@ -32,6 +32,7 @@ services: DB_PASSWORD: "${DB_PASSWORD:-dev_password_change_me}" ENVIRONMENT: "${ENVIRONMENT:-development}" ENGINE_BUILD: "${ENGINE_BUILD:-dev}" + CORS_ALLOWED_ORIGINS: "${CORS_ALLOWED_ORIGINS:-http://localhost:5173,http://127.0.0.1:5173,http://192.168.2.49:5174}" ports: - "${BUYING_AGENT_API_PORT:-8095}:8000" depends_on: @@ -53,10 +54,25 @@ services: networks: - dev-buyingagent-network + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + environment: + API_PROXY_TARGET: http://api:8000 + ports: + - "${BUYING_AGENT_FRONTEND_PORT:-5174}:80" + depends_on: + api: + condition: service_healthy + restart: unless-stopped + networks: + - dev-buyingagent-network + api-test: profiles: ["test"] build: - context: . + context: ./backend dockerfile: Dockerfile target: test environment: @@ -67,6 +83,7 @@ services: DB_PASSWORD: "${DB_PASSWORD:-dev_password_change_me}" ENVIRONMENT: test ENGINE_BUILD: test + CLOCK_NOW: "2026-10-03T12:00:00Z" depends_on: postgres: condition: service_healthy diff --git a/docker-compose.test.yml b/docker-compose.test.yml index bcebb95..28b7fb0 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -22,7 +22,7 @@ services: api: build: - context: . + context: ./backend dockerfile: Dockerfile target: test environment: @@ -33,6 +33,7 @@ services: DB_PASSWORD: test ENVIRONMENT: test ENGINE_BUILD: test + CLOCK_NOW: "2026-10-03T12:00:00Z" depends_on: postgres: condition: service_healthy diff --git a/docker-compose.yml b/docker-compose.yml index cee279f..23c4e52 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -23,7 +23,7 @@ services: api: build: - context: . + context: ./backend dockerfile: Dockerfile target: runtime container_name: buyingagent-api @@ -35,6 +35,7 @@ services: DB_PASSWORD: "${DB_PASSWORD:-change_me}" ENVIRONMENT: "${ENVIRONMENT:-production}" ENGINE_BUILD: "${ENGINE_BUILD:-dev}" + CORS_ALLOWED_ORIGINS: "${CORS_ALLOWED_ORIGINS:-http://localhost:4173,http://127.0.0.1:4173}" ports: - "${BUYING_AGENT_API_BIND:-127.0.0.1}:${BUYING_AGENT_API_PORT:-8000}:8000" depends_on: @@ -56,6 +57,22 @@ services: networks: - buyingagent-network + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + container_name: buyingagent-frontend + environment: + API_PROXY_TARGET: http://api:8000 + ports: + - "${BUYING_AGENT_FRONTEND_BIND:-127.0.0.1}:${BUYING_AGENT_FRONTEND_PORT:-4173}:80" + depends_on: + api: + condition: service_healthy + restart: unless-stopped + networks: + - buyingagent-network + volumes: buyingagent-db-data: diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 44b9a5e..aeda9bb 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -5,8 +5,9 @@ **Runner:** Gitea Actions (`/home/lars/gitea-runner/`, Label `ubuntu-latest` → Host) **Repo:** `http://192.168.2.144:3000/Lars/BuyingAgent.git` -Fachliche Grundlage: `.specs/buying-agent-v0.1-spec.md` bzw. `docs/buying-agent-v0.1-spec-v1.1.md`. -v0.1 hat keine Web-UI; öffentlich wird nur die API bereitgestellt. +Fachliche Grundlage: `docs/buying-agent-v0.1-spec-v1.1.md`. +Architektur: `docs/buying-agent-architecture-addendum-v1.md` (Monorepo `backend/` + `frontend/`-Platzhalter). +v0.1 hat keine Web-UI; öffentlich wird nur die API bereitgestellt. Der Image-Build-Kontext ist `./backend`. --- diff --git a/docs/buying-agent-architecture-addendum-v1.md b/docs/buying-agent-architecture-addendum-v1.md new file mode 100644 index 0000000..6b473a7 --- /dev/null +++ b/docs/buying-agent-architecture-addendum-v1.md @@ -0,0 +1,377 @@ +# Buying Agent – Architecture Addendum v1 + +**Status:** ergänzende Architekturvorgabe +**Gültig zusammen mit:** `buying-agent-v0.1-spec-v1.1.md` +**Stand:** 2026-10-03 + +## 1. Zweck + +Dieses Addendum ergänzt die bestehende Spezifikation um übergreifende Architekturentscheidungen für das spätere Zielsystem. + +Es ersetzt **nicht** die fachliche Spezifikation `buying-agent-v0.1-spec-v1.1.md`. + +Bei Konflikten zwischen beiden Dokumenten hat dieses Addendum für die hier behandelten Architekturthemen Vorrang. + +Ziel ist, den aktuellen v0.1-Slice schlank zu halten, ohne spätere Erweiterungen auf Frontend, mehrere Kaufprojekte und mehrere Benutzer unnötig zu erschweren. + +--- + +## 2. Zielstruktur des Repositories + +Das Projekt wird als Monorepo strukturiert: + +```text +buying-agent/ +├── README.md +├── docker-compose.yml +├── .env.example +├── .gitignore +├── docs/ +│ ├── buying-agent-v0.1-spec-v1.1.md +│ └── buying-agent-architecture-addendum-v1.md +├── backend/ +│ ├── Dockerfile +│ ├── pyproject.toml +│ ├── uv.lock +│ ├── alembic.ini +│ ├── migrations/ +│ ├── app/ +│ ├── rules/ +│ ├── profiles/ +│ ├── fixtures/ +│ ├── scripts/ +│ └── tests/ +└── frontend/ + └── README.md +``` + +### Regeln + +- `docker-compose.yml` bleibt im Repository-Root. +- Das Backend ist der aktuell implementierte v0.1-Scope. +- `frontend/` wird in v0.1 **nicht implementiert**. +- `frontend/README.md` dokumentiert lediglich, dass dort später die Benutzeroberfläche entsteht. +- Keine zusätzliche `infra/`-Struktur in v0.1, solange sie nicht konkret benötigt wird. + +--- + +## 3. Backend-/Frontend-Grenze + +Das Backend bleibt das **System of Record** und besitzt: + +- Domain-Modell +- Constraints +- Scoring +- Evaluation +- Persistenz +- Preis- und Produktlogik +- zukünftige Kaufentscheidungslogik + +Das spätere Frontend konsumiert ausschließlich die versionierte Backend-API, primär unter: + +```text +/api/v1 +``` + +Das Frontend enthält keine fachlich verbindliche Business-Logik. + +--- + +## 4. Zukünftige Multi-User-Architektur + +Das Zielsystem soll später von Familie und gegebenenfalls engen Freunden genutzt werden können. + +Die Architektur darf daher nicht von einem impliziten Single-User-Modell ausgehen. + +Zielmodell: + +```text +User + ↕ +WorkspaceMember + ↕ +Workspace + ↓ +BuyingProject + ↓ +ProjectCandidate + ↓ +ProductVariant +``` + +Ein Benutzer kann Mitglied in mehreren Workspaces sein. + +Beispiele: + +```text +Lars +├── Privat +├── Familie +└── Freunde +``` + +### Noch nicht Bestandteil von v0.1 + +Nicht implementieren: + +- Login +- Authentifizierung +- Passwortverwaltung +- Einladungen +- RBAC +- komplexe ACLs +- Sharing-UI +- Workspace-Administration + +Die aktuelle Architektur darf deren spätere Einführung aber nicht unnötig erschweren. + +--- + +## 5. Globale versus Workspace-bezogene Daten + +### Global wiederverwendbare Daten + +Diese Entitäten bleiben systemweit gemeinsam nutzbar: + +- Product +- ProductVariant +- Offer +- PriceObservation +- AttributeDefinition +- Source + +Sie erhalten **keine** `user_id` oder `workspace_id`. + +Ein Produkt oder Angebot darf nicht pro Benutzer oder Workspace dupliziert werden. + +### Workspace-bezogene Daten + +Spätere benutzerbezogene Beschaffungskontexte gehören zu einem Workspace: + +- BuyingProject +- ProjectCandidate +- projektspezifische Profil-/Anforderungsauswahl +- Notification Preferences +- Beschaffungsdeadline +- Zielpreis +- Kaufentscheidungsstatus +- gegebenenfalls spätere Warenkorb-/Beschaffungsinformationen + +Diese Objekte werden erst in späteren Slices implementiert. + +--- + +## 6. BuyingProject als zukünftiges Kernobjekt + +Ein `BuyingProject` repräsentiert ein konkretes Beschaffungsvorhaben. + +Beispiele: + +- Mobiler Entwicklungsrechner +- Lokaler LLM-Server +- Fernseher +- Kamera + +Ein Workspace kann mehrere BuyingProjects parallel besitzen. + +Ein BuyingProject kann mehrere ProjectCandidates beobachten. + +```text +BuyingProject +├── Candidate A → ProductVariant +├── Candidate B → ProductVariant +└── Candidate C → ProductVariant +``` + +### Wichtig für v0.1 + +`BuyingProject` und `ProjectCandidate` müssen in v0.1 noch nicht implementiert werden. + +Die aktuelle Implementierung darf jedoch nicht annehmen: + +- es existiert genau ein aktives Profil, +- es existiert genau ein Kaufvorhaben, +- es gibt globale Singleton-Konfiguration für einen aktuellen Kauf, +- alle Daten gehören automatisch demselben Benutzer, +- es existiert nur eine gleichzeitig relevante Evaluation. + +Mehrere unabhängige Profile und Evaluationen müssen bereits parallel koexistieren können. + +--- + +## 7. Rolle von n8n + +n8n bleibt eine externe Orchestrierungsschicht. + +Spätere mögliche Aufgaben: + +- Scheduler +- Preisabfragen +- Datenimport +- Benachrichtigungen +- externe Integrationen +- volatile oder häufig veränderte Workflows + +Kern-Business-Logik bleibt im Programmcode, insbesondere wenn sie: + +- deterministisch sein soll, +- versioniert werden muss, +- getestet werden muss, +- fachliche Entscheidungen reproduzierbar machen soll. + +n8n darf für veränderliche Prozesslogik eingesetzt werden, aber nicht zur Voraussetzung für das Funktionieren der Buying Engine werden. + +--- + +## 8. Zukünftiges Frontend + +Das Zielsystem soll später eine einfach bedienbare Oberfläche erhalten. + +Die Standardoberfläche soll auch für nichttechnische Familienmitglieder verständlich bleiben. + +Die Engine darf gleichzeitig deutlich leistungsfähiger sein als die Oberfläche. + +Voraussichtliche spätere Kernbereiche: + +- Übersicht laufender Kaufprojekte +- Kandidaten je Projekt +- aktueller bester Deal +- Preisverlauf +- Produkt-Fit +- Offer-Fit +- DealScore +- verständliche Gründe +- Beobachtungsstatus +- konkrete Kaufempfehlung +- ggf. spätere Benachrichtigungs- und Warenkorbfunktionen + +Keine dieser UI-Funktionen ist Bestandteil von v0.1. + +--- + +## 9. Kaufentscheidungslogik + +Langfristig soll das System nicht nur Scores liefern, sondern auch konfigurierbare Kaufentscheidungen unterstützen. + +Beispiele möglicher Einflussfaktoren: + +- Zielpreis +- Maximalpreis +- spätestes Beschaffungsdatum +- aktueller Preis +- Preisentwicklung der letzten Tage/Wochen/Monate +- Abstand zu historischem Median +- Produkt-Fit +- Offer-Fit +- Verfügbarkeit +- Lieferzeit +- Händlerqualität +- alternative Kandidaten +- Beschaffungsdringlichkeit + +Die Kaufentscheidung soll nachvollziehbar und möglichst deterministisch bleiben. + +LLMs dürfen später Erklärungen, Normalisierung oder qualitative Interpretation unterstützen, sollen aber keine nicht nachvollziehbare Blackbox-Entscheidung erzwingen. + +--- + +## 10. Datenquellen + +Das Zielsystem muss mehrere Produkt- und Preisquellen unterstützen können. + +Mögliche spätere Quellen: + +- Amazon / Keepa +- Preisvergleichsportale +- Hersteller +- Händler +- weitere APIs + +v0.1 implementiert weiterhin **keine reale externe Quelle**. + +Die bestehende Source-Adapter-Grenze bleibt die vorgesehene Erweiterungsstelle. + +--- + +## 11. Scope-Grenze für die laufende v0.1-Implementierung + +Dieses Addendum darf nicht als Anlass dienen, v0.1 unnötig zu vergrößern. + +Insbesondere **nicht jetzt implementieren**: + +- echtes Frontend +- Authentifizierung +- User Management +- Workspace Management +- RBAC +- Einladungen +- BuyingProject +- ProjectCandidate +- Notifications +- n8n +- externe Preisquellen +- automatische Produktsuche +- Warenkorbmanagement + +Ziel ist Architekturkompatibilität, nicht vorgezogene Feature-Entwicklung. + +--- + +## 12. Arbeitsauftrag für die laufende Implementierung + +Der Coding-Agent soll vor Fortsetzung des laufenden Builds einen kurzen Architecture-Alignment-Check durchführen. + +### Prüfen + +1. Entspricht die Repository-Struktur bereits der Monorepo-Struktur mit: + - `backend/` + - `frontend/` + - `docs/` + - Root-`docker-compose.yml` + +2. Gibt es Annahmen über: + - genau einen Benutzer, + - genau ein aktives Profil, + - genau ein Kaufprojekt, + - globale Singleton-Zustände? + +3. Sind Product, ProductVariant, Offer und PriceObservation weiterhin global und unabhängig von späteren Workspaces modelliert? + +4. Ist das Backend klar von zukünftiger Frontend- und n8n-Logik getrennt? + +### Änderungen + +Nur notwendige strukturelle Anpassungen durchführen. + +Keine vorgezogene Multi-User- oder Frontend-Funktionalität implementieren. + +Die fachlichen Anforderungen aus `buying-agent-v0.1-spec-v1.1.md` bleiben vollständig gültig. + +### Abschlussbericht + +Dokumentieren: + +- geänderte oder verschobene Dateien, +- notwendige Architekturänderungen, +- Änderungen an Datenbank/Migrationen, +- ausgeführte Tests, +- Testergebnisse, +- verbleibende Risiken für spätere Workspace-/BuyingProject-Erweiterung. + +--- + +## 13. Vorrang und Weiterentwicklung + +Dieses Dokument ist ein Architektur-Addendum zur v0.1-Spezifikation. + +Nach Abschluss von v0.1 sollen die Erkenntnisse aus: + +- `buying-agent-v0.1-spec-v1.1.md` +- diesem Addendum +- der realen Implementierung + +in eine konsolidierte Folgespezifikation für den nächsten Slice übernommen werden. + +Die Kernregel lautet: + +> Nicht vorzeitig bauen, aber bekannte Zielanforderungen nicht architektonisch verbauen. diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..966b86a --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,11 @@ +FROM node:22-alpine AS build +WORKDIR /app +COPY package.json ./ +RUN npm install +COPY . . +RUN npm run build + +FROM nginx:1.27-alpine +COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=build /app/dist /usr/share/nginx/html +EXPOSE 80 diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..7c947cb --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,12 @@ +# Buying Agent Frontend + +React + TypeScript + Vite PWA. Das Frontend konsumiert ausschließlich `/api/v1`. + +```bash +npm install +npm run dev +npm test +npm run build +``` + +Lokaler Vite-Devserver proxyt `/api` auf `http://127.0.0.1:8000`. diff --git a/frontend/e2e/notebook-pilot.spec.ts b/frontend/e2e/notebook-pilot.spec.ts new file mode 100644 index 0000000..e64fb63 --- /dev/null +++ b/frontend/e2e/notebook-pilot.spec.ts @@ -0,0 +1,7 @@ +import { expect, test } from "@playwright/test"; + +test("opens the project list on desktop and mobile viewports", async ({ page }) => { + const response = await page.goto("/projects"); + if (!response || response.status() >= 500) test.skip(true, "frontend not running"); + await expect(page.getByRole("heading", { name: "Projekte" })).toBeVisible(); +}); diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 0000000..9482344 --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,6 @@ +import tseslint from "typescript-eslint"; + +export default tseslint.config( + tseslint.configs.recommended, + { ignores: ["dist"] }, +); diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..5475c5d --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,15 @@ + + + + + + + + + Buying Agent + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..ef13eb6 --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,21 @@ +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + location /api/ { + proxy_pass http://api:8000; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + + location /health { + proxy_pass http://api:8000; + proxy_set_header Host $host; + } + + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..4702822 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,8837 @@ +{ + "name": "buying-agent-frontend", + "version": "0.3.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "buying-agent-frontend", + "version": "0.3.0", + "dependencies": { + "@tanstack/react-query": "^5.90.2", + "react": "^19.1.1", + "react-dom": "^19.1.1", + "react-router-dom": "^7.9.3" + }, + "devDependencies": { + "@playwright/test": "^1.55.1", + "@testing-library/jest-dom": "^6.8.0", + "@testing-library/react": "^16.3.0", + "@types/react": "^19.1.13", + "@types/react-dom": "^19.1.9", + "@vitejs/plugin-react": "^5.0.3", + "eslint": "^9.36.0", + "jsdom": "^27.0.0", + "typescript": "^5.9.2", + "typescript-eslint": "^8.44.1", + "vite": "^7.1.7", + "vite-plugin-pwa": "^1.0.3", + "vitest": "^3.2.4" + } + }, + "node_modules/@acemir/cssom": { + "version": "0.9.31", + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", + "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.2.tgz", + "integrity": "sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.0.0", + "@csstools/css-color-parser": "^4.0.1", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.2.5" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "6.8.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz", + "integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.1.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.6" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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-annotate-as-pure": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.29.7.tgz", + "integrity": "sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "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==", + "dev": true, + "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-create-class-features-plugin": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.29.7.tgz", + "integrity": "sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-member-expression-to-functions": "^7.29.7", + "@babel/helper-optimise-call-expression": "^7.29.7", + "@babel/helper-replace-supers": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", + "@babel/traverse": "^7.29.7", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-create-regexp-features-plugin": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.29.7.tgz", + "integrity": "sha512-907Uymvqgg1dwUA+7IGwFAOSYzQOuzPXKNJ1yxzwPffzkYFg2q2eHi1fIOs6sXkG9NbIUMunnUlkYsfRFNvomg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "regexpu-core": "^6.3.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-define-polyfill-provider": { + "version": "0.6.8", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.8.tgz", + "integrity": "sha512-47UwBLPpQi1NoWzLuHNjRoHlYXMwIJoBf7MFou6viC/sIHWYygpvr0B6IAyh5sBdA2nr2LPIRww8lfaUVQINBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "debug": "^4.4.3", + "lodash.debounce": "^4.0.8", + "resolve": "^1.22.11" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-member-expression-to-functions": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.29.7.tgz", + "integrity": "sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "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==", + "dev": true, + "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==", + "dev": true, + "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-optimise-call-expression": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.29.7.tgz", + "integrity": "sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-remap-async-to-generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.29.7.tgz", + "integrity": "sha512-16AMiW26DbXWBbr3B8wNozKM0ydMLB892vaOaJW/fPJdnT8vJk5sdkQcU/isqUxyCE0cEoa8wZOcbgDuC4b6Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-wrap-function": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-replace-supers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.29.7.tgz", + "integrity": "sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-member-expression-to-functions": "^7.29.7", + "@babel/helper-optimise-call-expression": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-skip-transparent-expression-wrappers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.29.7.tgz", + "integrity": "sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-wrap-function": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.29.7.tgz", + "integrity": "sha512-iES0Skag9ERIF68aXadpO6dbXa03mNWK3sEqJaMnLNs/eC3l0lkImdfoy6Y09/SfkpawdAB4RjQ7PVA7TcVGdw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "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==", + "dev": true, + "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.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-firefox-class-in-computed-class-key": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.29.7.tgz", + "integrity": "sha512-j8SrR0zLZrRsC09DlszEx8FpMiwukKffYXMK0d5LmOglO7vGG6sz/BR/20yHqWH+Lnn31JTt2PE3hIWNgM2J6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-safari-class-field-initializer-scope": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.29.7.tgz", + "integrity": "sha512-r8j8escF+U2FUHo0KOhPUdMzUO+jp9fInva6+ACVAF3Y97Ev+5iNZwiqTghmzNeWwDkOPlYuTcfb1vDaoZKmAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.29.7.tgz", + "integrity": "sha512-GE1TFSiuFeGsCxmYXZl8HwoPrVlwe4rHPFE8weieGKZqnDORK+Ar3vgWMgW+AOxQ6/2TgLSKx9p6W7O4rC6qgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-safari-rest-destructuring-rhs-array": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-rest-destructuring-rhs-array/-/plugin-bugfix-safari-rest-destructuring-rhs-array-7.29.7.tgz", + "integrity": "sha512-oBNVCvnO5tND+xSopWvV8WNGfpTfgP4Zr/YXXSj8zfmcPktp5Ku/aZlsIowgSD4fjmgHn6sGmB9APVsU5zOdhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.29.7.tgz", + "integrity": "sha512-QQt9qKHZ2sg/kivaLr7lnQr8HVrQDdBNSfCsTjiDxRuX/K5ORyKq+Bu8Xr0cDE3Dfkv0cw28Ve0EKyKMvulkOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", + "@babel/plugin-transform-optional-chaining": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.13.0" + } + }, + "node_modules/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.29.7.tgz", + "integrity": "sha512-pn6QacGLgvCcwc+syUhKE/qSjV2D1IHDB84RNxWYSt1mW3K/SCtjinZ2p0cETJxAWBjPy3K/1lHwG5BjjPxNlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-proposal-private-property-in-object": { + "version": "7.21.0-placeholder-for-preset-env.2", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz", + "integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-assertions": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.29.7.tgz", + "integrity": "sha512-/An1OCBN93thpBAGyfsK2pcf0jvju1SAtKkL2Ny++B5Sy6sqgzXDQH1cZxWbF96Wuk+bn41MDA9bLd4VVAw6rw==", + "dev": true, + "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-syntax-import-attributes": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.29.7.tgz", + "integrity": "sha512-zGYcYfq/WmZ4V+kBIXQon9dSSc8ircGZqw9ZaNhhGj9nZkeBu1jHLBDQqYYi5WA9uawvA2sIMbry2nCFhf5Djg==", + "dev": true, + "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-syntax-unicode-sets-regex": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz", + "integrity": "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-arrow-functions": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.29.7.tgz", + "integrity": "sha512-N7zArUXWzAMzm+/N0uPBeVB3Fam5lMxtUwMmDK5f/IBBS7a7p1qeUoxd/6CckXoxUdgsntq1Dh8xNW06maZbDQ==", + "dev": true, + "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-async-generator-functions": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.29.7.tgz", + "integrity": "sha512-d98gXZkgswvkyohMBABkhm3GeXhYj8psWfwQ2C7gtfrKGTykQa/iOIi+JJhwMjPlZ6Vm2XN+DCf3Es1EoG4ZLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-remap-async-to-generator": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-to-generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.29.7.tgz", + "integrity": "sha512-pcUb2SS+RMo9TWVBwKGI5ShtoG7R+zBsFmCKDa6fe8c+hPr3XJlZgoE5j6i8W7gDjhyvy+85vmYexanvXh3d1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-remap-async-to-generator": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-block-scoped-functions": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.29.7.tgz", + "integrity": "sha512-cUSmjh72N+rN4PrkFlN1dJwNCwjVp5d38/CQrEsFggkD10UiFlBFgdH3tv5dNsLuHY+3S8db2xCHjhZcv5WgvA==", + "dev": true, + "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-block-scoping": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.29.7.tgz", + "integrity": "sha512-ONyr4+AZhKh8yKWInVxU9AXA9EbsyeLcL6V0dJy6M2/62vuvpGm29zzuymbTpdc451GEpDIdAyPLP3r+P61yKQ==", + "dev": true, + "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-class-properties": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.29.7.tgz", + "integrity": "sha512-GtcpjFvanPfzNQi3eTitsCqtRRmmqzpy/A+yhTR1HaZo1Ly3EA8ZXxlPyHdR8/IuRMYc3E4wdGBewB2QKQjAaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-static-block": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.29.7.tgz", + "integrity": "sha512-kibJgmEdX2iMwsHY2tSZNDgj8PwIlCQz7FK9KuGKO8zsuoUwSEhoNnNVp/emKWrbY4HeO6kkXfdMqRKKKXBm2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.12.0" + } + }, + "node_modules/@babel/plugin-transform-classes": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.29.7.tgz", + "integrity": "sha512-qV0OGGBVacduzQHE649JyCneOFI/maT+YKsO+K4Yi3xv2wTPNjM/W2o2gdzMwEAZz7fXNTHAe0NcSg30bIN69g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-replace-supers": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-computed-properties": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.29.7.tgz", + "integrity": "sha512-RK7/IyU5phpuCdBAuig5VkzG/EnbDaui5SQGdU9BFrHdV+mV4cUjLMQ9lJDjLNtWHsqtiefpGZUXQP2BiTYMsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/template": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-destructuring": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.29.7.tgz", + "integrity": "sha512-iPX8aD6H9zV5s7ZsqTdNocPN/MGQ5sSMnElKrktxjJRMnB2jN/1p2+R7GkfD6CAYoVFqy5A4XnSIUeGgJzIWpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-dotall-regex": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.29.7.tgz", + "integrity": "sha512-3qc18hsD2RdZiyJNDNc7HQpv6xbncwh8FYtxNFFzclSyh/trPD9KkVR9BDECUjDLvb7yJVF15GfYUuC+LMkkiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-duplicate-keys": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.29.7.tgz", + "integrity": "sha512-6IvRRriEMqnBwD6chtxdLpMYCHWEzN+oL5cyQtjykya19UgzbmKhxmhZgKC/LHxS2nYr9Q/qYPZ5Lr6jOL9+yQ==", + "dev": true, + "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-duplicate-named-capturing-groups-regex": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.29.7.tgz", + "integrity": "sha512-2wiIyo2BjtgU7HufSeDnL9L2O7zr8jmhFKuSr65VpRkUiRKRNpb0mdlk56+XPPKoIrfHqzbMuglDvZun0RISsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-dynamic-import": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.29.7.tgz", + "integrity": "sha512-giOlEm/EFjfjr+te9NsdjkUo2v4f8rS/SXPumRVHAtbNcyNlvtREkU1dZzaIDclNpnaVhlCqRdFKhJBjBikzLg==", + "dev": true, + "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-explicit-resource-management": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-explicit-resource-management/-/plugin-transform-explicit-resource-management-7.29.7.tgz", + "integrity": "sha512-Rstj7coNz8sE+7Ju7ihpHLI564lsK5pUpNNlvptCIC/16E/S5hbl6n3kESPKdNRmqEWlpn5xpS5Q2dvXBsySLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/plugin-transform-destructuring": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-exponentiation-operator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.29.7.tgz", + "integrity": "sha512-zFpMOTLZBdW5LfObqcSbL6kefg4R4eLdmvS0wbN9M6D5Mym/sKm9toOoWyVOa+xDjvCnuWcHls2YonXwHvH3CQ==", + "dev": true, + "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-export-namespace-from": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.29.7.tgz", + "integrity": "sha512-24B2nOy2TeJSMheqwPD4DDQOV/elLSIlKxjZt4i05H5AgdPdWR3n18HnNrcJ+j76WJd9gbwb9jPjNYUy6RautA==", + "dev": true, + "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-for-of": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.29.7.tgz", + "integrity": "sha512-zeSIHh0+E1Um1WJRXCFlHQYu2ieJNdivLLjlBEp+dIBu3S51n+SZZmIXjxnItw6pz56Cn+KvK68BIBVsxq2JiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-function-name": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.29.7.tgz", + "integrity": "sha512-otRWaHXE6fbAGkePvaj/kvs3HsqXfPhlnzwSOlnFgbqCPMd975dW+4wZ00WFBt+/YlBGcJwNrARQTOJOb4ZrIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-json-strings": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.29.7.tgz", + "integrity": "sha512-RRnE2+eon1rJAq8MnoF1b5kTpY1vU88twHcvcKMrsqP/jxIRqDVs9iJB5fqPuqyeFAW0wJo4MlUIPpQCq/aRsg==", + "dev": true, + "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-literals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.29.7.tgz", + "integrity": "sha512-DZ/oLP21ZuWx1vKqnoNv6/tvEK48AQOBRai40CX9dTjGluvT/YZCyY3rryDtyUqCEoyNroy5KKPwX2iQCiRvyw==", + "dev": true, + "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-logical-assignment-operators": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.29.7.tgz", + "integrity": "sha512-A0H91hh6W8MFRkp5TqJmMr39jzGD1A1E1Ysiv2O06Sfbhkapm+XyIzxWCEh5kqwOZ1/8QZ0dY3SeQ7XBqfJd5Q==", + "dev": true, + "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-member-expression-literals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.29.7.tgz", + "integrity": "sha512-hl1kwFZCCiDyfH25Xmco9jTrkPgnS9pmOzSG7W5I4SaGbLeqKv417hcU2RKmaxoPEgsoJh7ZPOrnPGq99bHoUg==", + "dev": true, + "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-modules-amd": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.29.7.tgz", + "integrity": "sha512-fxtQoH3m5ywUSIfaH0FGCzWu4McsYon5bD3K4XnskC7f+OyQMj7rsOMi4NvvmJ83WwBAg4UCe+ov4VZlqEvyew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-commonjs": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.29.7.tgz", + "integrity": "sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-systemjs": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.8.tgz", + "integrity": "sha512-6iSnEK0zlkLKU4heofK/AdmRD4e2SHVpJMtrwnTCzhnaM98ria4rTrOXBBi45BTTYnJtO8txnPsX4fChYXkmeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.8" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-umd": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.29.7.tgz", + "integrity": "sha512-B4UkaTK3QpgCwJnrxKfMPKdo92CN7OKXAlpAAnM3UPu0Q0lCCk57ylA9AJbRy2v8dDKOPAAWcoR6CMyeoHwRCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-named-capturing-groups-regex": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.29.7.tgz", + "integrity": "sha512-vuFoLwr4qnv2xbZ16SQd6uPcH5FNrLHhk/Jzo++0XJFcaDsr4gjJVg6j398oMHiC+83k/GiBzviwF5KBJkPUtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-new-target": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.29.7.tgz", + "integrity": "sha512-fEo41GmsOUhOBlw8ioo6zvjX5Xc2Lqkzlyfqbpsk3eB6TReV18uhxZ0esfEokVbY2+PVJAQHNKxER6lGrzNd3A==", + "dev": true, + "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-nullish-coalescing-operator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.29.7.tgz", + "integrity": "sha512-idmp1dFaekP9GbcMvG24Kvw2BfhFZjHnNJCkV4WuIY4PskJzwI3f1N5OdgYke38T7rftO6ERulFRn2cFeZwRkg==", + "dev": true, + "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-numeric-separator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.29.7.tgz", + "integrity": "sha512-zR7fv/z14OjgHl4AgRtkDBvBMhIzCxqV/qN/2BCRC7LjFwvuzjYe7gDWxC4Wl/SNsLM6SE1IWvRPYMgSJaUvNw==", + "dev": true, + "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-object-rest-spread": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.29.7.tgz", + "integrity": "sha512-Ld98jn4c0smUywL57m7SgsHq3OpThOa6LqZJif3G6jYOovPleoFhVrBJ1WegRApSFB2wu4+RelAj9AC9G08Z4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/plugin-transform-destructuring": "^7.29.7", + "@babel/plugin-transform-parameters": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-object-super": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.29.7.tgz", + "integrity": "sha512-Ea/diGcw0twB5IlZPO5sgET6fJsLJqPABqTuFWIR+iMPGPZJkATEIWx0wa+aEQ5UY1CBQyP/gkAiLEqn1vBiQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-replace-supers": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-catch-binding": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.29.7.tgz", + "integrity": "sha512-sLsyndxK2VwX6yNUOakMb7Sh553ZTe/vVM1XJ+9Z5aW1ytsc8xOIwmyk05NNjN60vkc5/KqoTH6hB4V41LJhng==", + "dev": true, + "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-optional-chaining": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.29.7.tgz", + "integrity": "sha512-6GM1dhvK3gNODkXcEcMCOLEDCLSoZ/sBbro2Ax8HURyasQ4NshagQixkRFdh5niI6E4gmA/jYI/4aT7rRos3ZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-parameters": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.29.7.tgz", + "integrity": "sha512-ZDOBqV/qLYJI0YElr8DcENEyARsFQeESqWXH6gZlghYXuPPjvweuDhP4VyEi4BlUBlLRFZVjxoZDMjxhLW766g==", + "dev": true, + "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-private-methods": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.29.7.tgz", + "integrity": "sha512-/6Rz4DK1ETDEM/bWHsPHcaEe7ZaT1EqSXjtSP/L0DijOYuaUhiRiOKcwpZ8P7zR4xXEHc2ITdiCgBm9Tpyv9ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-property-in-object": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.29.7.tgz", + "integrity": "sha512-+BNo06dnrzdNNqCm1X6YUaVv0DKk8Q+JYcoZfOkLhYWNCXzlwTSRq8zGWayT1csjcpNXV9CQTBRRbmTLZac5cA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-property-literals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.29.7.tgz", + "integrity": "sha512-bOMRLQuI0A5ZqHq3OWJ89/rXpJ/NJrbVhXiP4zwPGMs6kpcVsuTUNjwoE30K0Qm3mf48a/TnRYYD6vPNqcg6jA==", + "dev": true, + "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-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==", + "dev": true, + "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==", + "dev": true, + "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-regenerator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.29.8.tgz", + "integrity": "sha512-0UpIXPtdDtMXfnV2OJAVMLpj3H/92vmkA6lpSRakmycJvj3VUy6Xs1dM8tXRugupykr5WB+LpiVl0J8LMVg2mg==", + "dev": true, + "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-regexp-modifiers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.29.7.tgz", + "integrity": "sha512-mB5Fs0VWrJ42ZCmc8114v60qetdaUVNkj9PmSZRmanCZM3S9hm0CFRLjRmYIsuXav14l2jvZ+4T8iiCGnhj3nQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-reserved-words": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.29.7.tgz", + "integrity": "sha512-5+YhdpVgmfSmwZyLMftfaiffLRMHjzIRHFHHLdibcSyJm2pasMrKHrO3Ptrt2DRshjvpgjEJJ1zVW14WPq/6QA==", + "dev": true, + "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-shorthand-properties": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.29.7.tgz", + "integrity": "sha512-I+WYbGBAiCn7nA6xBrlgPH+MB7HWb4u8pv5S0Pv7OtwNvIFvCCb24YlttKEeUFVurfBCEaOTnuhlqsb7f0Z5Dg==", + "dev": true, + "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-spread": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.29.8.tgz", + "integrity": "sha512-4S9ksMGVWUshvgK0mKfvZky7leuG5/uoFVwMpAomJ8bMoDJiNHRVmc1EglwW/CmGVSqqWpEbXm9FmbRit22qoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-sticky-regex": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.29.7.tgz", + "integrity": "sha512-BCHzNYJGe9l7EpwwDBN/ztlL2NYFFq8hp9ddjtUEM9f2O7S7kKV/lL6Fwo7IF7NSkYhPK2vO+86nIGltA90MsA==", + "dev": true, + "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-template-literals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.29.7.tgz", + "integrity": "sha512-NCSEJ4sLFU2gqAub45HYh4fus2yQ36rr6ei6vpU7NdoJqCpxvEG8E6eJpscGyXP3VHD2Ny+fSXr04k1hoUrFqA==", + "dev": true, + "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-typeof-symbol": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.29.7.tgz", + "integrity": "sha512-223mNGoTkBiTEWFoK+Q6Go3tueMRclO8vxxxxquNCYuNI4jWOofFKJRRDu6SDrB8Sgo1UEGW9T4GAQ8ZyRso1A==", + "dev": true, + "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-unicode-escapes": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.29.7.tgz", + "integrity": "sha512-jCfXxSjf94lf4E0hKE0AByxF6F3/pVFqRdUUNkDJhsY0m1ZKjnN6ZYyMeHNpzflxb/0q5b7t3p+BE+SLF1WOtA==", + "dev": true, + "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-unicode-property-regex": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.29.7.tgz", + "integrity": "sha512-OgZ+zoAJgZLUCunsTRQ5LAjOywDv5zzZ2/hQ5aMw1pGXyY2rtE8/chXYUmu3AlVHKpm10KEdG9aMwbI/K76ZGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-regex": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.29.7.tgz", + "integrity": "sha512-7D/x/23/d/3VqZ0QA+LGbZMlGwZjztBygSWWWsfTPoQ1oQ6Q1P6Mr3d0kk42XabyUVw+fha3LqdRsFqeKqvCyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-sets-regex": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.29.7.tgz", + "integrity": "sha512-BLOhLht9DOJwIxlmp91wHvkXv1lguuHS3/FwUO8HL1H0u8s4hR1gASVFyilu9iGtcTRYqjTZmlsFFeQletntEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/preset-env": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.29.7.tgz", + "integrity": "sha512-GYzX36n1nsciIb0uyH0GHwxwtNwPQIcpxSeiVLDtG/B7jB5xXgchnmL1f/jCX5o+pwnaDBtO60ONSJhEBJfxYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.29.7", + "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.29.7", + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.29.7", + "@babel/plugin-bugfix-safari-rest-destructuring-rhs-array": "^7.29.7", + "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.29.7", + "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.29.7", + "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2", + "@babel/plugin-syntax-import-assertions": "^7.29.7", + "@babel/plugin-syntax-import-attributes": "^7.29.7", + "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", + "@babel/plugin-transform-arrow-functions": "^7.29.7", + "@babel/plugin-transform-async-generator-functions": "^7.29.7", + "@babel/plugin-transform-async-to-generator": "^7.29.7", + "@babel/plugin-transform-block-scoped-functions": "^7.29.7", + "@babel/plugin-transform-block-scoping": "^7.29.7", + "@babel/plugin-transform-class-properties": "^7.29.7", + "@babel/plugin-transform-class-static-block": "^7.29.7", + "@babel/plugin-transform-classes": "^7.29.7", + "@babel/plugin-transform-computed-properties": "^7.29.7", + "@babel/plugin-transform-destructuring": "^7.29.7", + "@babel/plugin-transform-dotall-regex": "^7.29.7", + "@babel/plugin-transform-duplicate-keys": "^7.29.7", + "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.29.7", + "@babel/plugin-transform-dynamic-import": "^7.29.7", + "@babel/plugin-transform-explicit-resource-management": "^7.29.7", + "@babel/plugin-transform-exponentiation-operator": "^7.29.7", + "@babel/plugin-transform-export-namespace-from": "^7.29.7", + "@babel/plugin-transform-for-of": "^7.29.7", + "@babel/plugin-transform-function-name": "^7.29.7", + "@babel/plugin-transform-json-strings": "^7.29.7", + "@babel/plugin-transform-literals": "^7.29.7", + "@babel/plugin-transform-logical-assignment-operators": "^7.29.7", + "@babel/plugin-transform-member-expression-literals": "^7.29.7", + "@babel/plugin-transform-modules-amd": "^7.29.7", + "@babel/plugin-transform-modules-commonjs": "^7.29.7", + "@babel/plugin-transform-modules-systemjs": "^7.29.7", + "@babel/plugin-transform-modules-umd": "^7.29.7", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.29.7", + "@babel/plugin-transform-new-target": "^7.29.7", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.29.7", + "@babel/plugin-transform-numeric-separator": "^7.29.7", + "@babel/plugin-transform-object-rest-spread": "^7.29.7", + "@babel/plugin-transform-object-super": "^7.29.7", + "@babel/plugin-transform-optional-catch-binding": "^7.29.7", + "@babel/plugin-transform-optional-chaining": "^7.29.7", + "@babel/plugin-transform-parameters": "^7.29.7", + "@babel/plugin-transform-private-methods": "^7.29.7", + "@babel/plugin-transform-private-property-in-object": "^7.29.7", + "@babel/plugin-transform-property-literals": "^7.29.7", + "@babel/plugin-transform-regenerator": "^7.29.7", + "@babel/plugin-transform-regexp-modifiers": "^7.29.7", + "@babel/plugin-transform-reserved-words": "^7.29.7", + "@babel/plugin-transform-shorthand-properties": "^7.29.7", + "@babel/plugin-transform-spread": "^7.29.7", + "@babel/plugin-transform-sticky-regex": "^7.29.7", + "@babel/plugin-transform-template-literals": "^7.29.7", + "@babel/plugin-transform-typeof-symbol": "^7.29.7", + "@babel/plugin-transform-unicode-escapes": "^7.29.7", + "@babel/plugin-transform-unicode-property-regex": "^7.29.7", + "@babel/plugin-transform-unicode-regex": "^7.29.7", + "@babel/plugin-transform-unicode-sets-regex": "^7.29.7", + "@babel/preset-modules": "0.1.6-no-external-plugins", + "babel-plugin-polyfill-corejs2": "^0.4.15", + "babel-plugin-polyfill-corejs3": "^0.14.0", + "babel-plugin-polyfill-regenerator": "^0.6.6", + "core-js-compat": "^3.48.0", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-modules": { + "version": "0.1.6-no-external-plugins", + "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz", + "integrity": "sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/types": "^7.4.4", + "esutils": "^2.0.2" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.2.tgz", + "integrity": "sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.3.tgz", + "integrity": "sha512-iex20d8CHVkyvg6B7UKV7uHnI2Bqo9g+EFfT9E0y+GvTvhZ/DwONJ+9aKb1dlqm0ZiGsL5RXjp0fCoJYnkeDjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.2", + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.6.tgz", + "integrity": "sha512-iiPQ3iRWwnJkeEn6RIu6SJPr7hYrLz6XZ9s/QZl+2/LI5KQVjpl2fdmDSZKuD4xP6GMmMPgHFFXg6k1Wkz0Trg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.2", + "@csstools/css-calc": "^3.4.3" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.2", + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.2.tgz", + "integrity": "sha512-40cSKyMvK+tq4qz6Awrlye2WGuOKt3FwPgtGg6KTfbHOWNw+Rk1rzbAtZnZ6IBhsY491HLRnDXwoyBAijmmILA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.15.tgz", + "integrity": "sha512-J0u7HkVl2nzSlhsiTOp4AmwcUQ3D+mGEEKfBy/7To5/y7F2OHwyLrXfrhR0SMgr4p5Lo+eaMVSeai24zUcBIxA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.2.tgz", + "integrity": "sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.7.tgz", + "integrity": "sha512-F42g89Qd5oAWtp0k0nnSrjziAKza7w8SVT4mStc18LZMaRb4J1HQAHLCalEtDCxrTuksx7NU9qsmeLwpOfPqWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.3.2", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz", + "integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.16.0.tgz", + "integrity": "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@isaacs/cliui": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz", + "integrity": "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "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==", + "dev": true, + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@playwright/test": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz", + "integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/plugin-babel": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-6.1.0.tgz", + "integrity": "sha512-dFZNuFD2YRcoomP4oYf+DvQNSUA9ih+A3vUqopQx5EdtPGo3WBnQcI/S8pwpz91UsGfL0HsMSOlaMld8HrbubA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.18.6", + "@rollup/pluginutils": "^5.0.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0", + "@types/babel__core": "^7.1.9", + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "@types/babel__core": { + "optional": true + }, + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-node-resolve": { + "version": "16.0.3", + "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-16.0.3.tgz", + "integrity": "sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "@types/resolve": "1.20.2", + "deepmerge": "^4.2.2", + "is-module": "^1.0.0", + "resolve": "^1.22.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^2.78.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-replace": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@rollup/plugin-replace/-/plugin-replace-6.0.3.tgz", + "integrity": "sha512-J4RZarRvQAm5IF0/LwUUg+obsm+xZhYnbMXmXROyoSE1ATJe3oXSb9L5MMppdxP2ylNSjv6zFBwKYjcKMucVfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "magic-string": "^0.30.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-terser": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-terser/-/plugin-terser-1.0.0.tgz", + "integrity": "sha512-FnCxhTBx6bMOYQrar6C8h3scPt8/JwIzw3+AJ2K++6guogH5fYaIFia+zZuhqv0eo1RN7W1Pz630SyvLbDjhtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "serialize-javascript": "^7.0.3", + "smob": "^1.0.0", + "terser": "^5.17.4" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "rollup": "^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.4.0.tgz", + "integrity": "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils/node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.64.0.tgz", + "integrity": "sha512-FLLpX941CD/7aBvX3GqLGBM9OvY5PGXTryPJkG5r5/OuBpZpV5k45Uo3RxAvqd35tsyc3hH1bhtz921IKzoIzg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.64.0.tgz", + "integrity": "sha512-iI32XM/jnDYJsvx5ai/za/7gWO9XoSeFFtU06/ZCaRgyilfJOjaopD3nOpk2WrTCGOyeDt4hleUiGOAnd5tHNg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.64.0.tgz", + "integrity": "sha512-dRBZlbt2sEtF8Mn4MODThze9fIPdDoOhVSlXMAKy5dQbOYK9pOOoPY4Fat3OTtr0+8zbJo5TTZwlsfxY/kDNJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.64.0.tgz", + "integrity": "sha512-Y3whrJOMucfkhgNkvcM93M6j8C53iEAMmPMYD99C6G1kUy4ELigLWk9ar2/4aCBSRgsql0zdlOemLgW2m5GZgQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.64.0.tgz", + "integrity": "sha512-S6S1GEzPjFF6IONYJX3AKt/799+8RgBgCcwRnUo+Ep1IvBxUbw5M51iU0TDdK2NWWVnSvCQ8yi6Em0fGASMOng==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.64.0.tgz", + "integrity": "sha512-Ed1DPoFENkXj4IGzSB12SesV40iP6LxCCrbTIkSGIQBpOOxpBM+0kj62lmaeIHfMWTDoddqTOtqc7s8LYfn+Pw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.64.0.tgz", + "integrity": "sha512-J/6xhutJPtFyIkmHdf4olOdHHKH5kOJNumojdWn+Elv5ICEYTVOa362Vw27TxsK0BLzb1le3EDlO3UbvFK1lnQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.64.0.tgz", + "integrity": "sha512-G4L/Nnx59d2LvDoV/P4S5lZ7oVLT6hodOHMacqjiq2LL2gts86KbkEBTYL53+fBt0odhf7XPIbIATGoYiJctxg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.64.0.tgz", + "integrity": "sha512-zF16pvJDEUksGYRbfnXne7pdklAyj2XDmJeYKgP93oq0cUyX9Y8O7lUo1R+IY1GagP/YBvCEDCe7eEcAKpw//g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.64.0.tgz", + "integrity": "sha512-nIggWn8n+w12+R5lsV+PYYoUY8vuObxPAQqJI7WES+9v0qspYuAHoZMfYM0yLxlipasT5BY9ht5W8t5c/rSCkg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.64.0.tgz", + "integrity": "sha512-WLOTiHPzszP0dxIMsHpFeDSOSB5I49Mj8K4+yKE3H1X/D0JRG5XZ5jrctJLMjnUc/LUzaKiwq6EHWNJ8pup8PA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.64.0.tgz", + "integrity": "sha512-35RK9aRhh7g7Pmeu5gQ01aCvrhXzj9LoBE6xtUcEH1ns+nyTaSzxSpADmBQINu4QiTDGvmQYwo1j5hlg1rRXFw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.64.0.tgz", + "integrity": "sha512-RqTfnpoTjuWfPAmqK5p2edt2AMCDJWm6oTn7sKfmyx+6tRga0ip05SJ977o6udTcIiDeF/CJMUfkaN4gtWt6Aw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.64.0.tgz", + "integrity": "sha512-RQjZPigjxFvFZDijG6rjlSXxhRaTf9zLYVpGK+lVcga64oMqP1GbgVqCb46eHTTceSIgp89Ja0y5aPe3GFBYHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.64.0.tgz", + "integrity": "sha512-tjL58mgQcTRnDRpeky+Sth1MKQeYi2Ct6r3Luq9E5aTSjnM/i/JbozqZkKqp9thM5+oDSZxo+05f9kdQedfTGA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.64.0.tgz", + "integrity": "sha512-ictoRyVaoZ8C67myHef+8hhCa6IplXJJ/xUCkVCxziV4zxMZh+jvhkhGsPBLqj1S72fnqD0QH5zL4lcaa4X7DQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.64.0.tgz", + "integrity": "sha512-8aVvZ2hfr9RLFzBQkQNmYeHLDja8fLeQAmLt757zdKN/B+8L809KlLJKctQjS22wZotptV8G6o7fVPuAs8S1tQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.64.0.tgz", + "integrity": "sha512-2dEF8GAcDKwshUfydD+GhosppNyBzhVUHkdFF3CXd3FpOkzj/RZzF5aoEtzklujklT5qFVRU2GK/cFeLaTNKVg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.64.0.tgz", + "integrity": "sha512-3XHTvoo2hKkh0MiOfS7SkhHjMaaWF7P4F3drocZZaho/Z8w7h/jpnuBHm91BOQLfvrKHs1yR+zir4E8RI3XzTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.64.0.tgz", + "integrity": "sha512-HKtkzodL4a3KFWDXDoO61cRynAWTR3LnAE0oGtfG9lcNFpNuVYUFOByxDgNqEvic9rvaQbAj+SFwTEWDptRaIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.64.0.tgz", + "integrity": "sha512-JBsxqiY16uyyCujtoC3s7zL+SrYyNElptD7K4V+KUoJ2V8/XG3hPWpheK+SuPwNh1cikJvIZmIdsrYXvD6PLJw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.64.0.tgz", + "integrity": "sha512-S5Qrijh37qhnUYQ0ay8WN3LuWeKjow7wTUyCOPMZtnXiBXYXdsZETI6ZLfYnQAKovltckPLBFdNeD61P2rJc7g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.64.0.tgz", + "integrity": "sha512-zbZ6iMmLFEsCtTdc2/y1F/cDFVNNCPF7Ae8PCT/wdPE//hGwYp6r+MyCLMFX+kOeIe09Pz6ASfnnfpSTN1FJQQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.64.0.tgz", + "integrity": "sha512-L6Hw69oNpuYaOr1zCG30v0/DqKYCSRwodPGwIcVZ/6f8SOzq9rxtL/xNVI5MO4wjli+Ko0ErK9uHJL3mNPt8wQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.64.0.tgz", + "integrity": "sha512-nl+CTWIgOGszQmUz/kanfgrfFZ4qiwxKIfT/ryl3N3Ewk5tqizmYtNXiqb7Bu/fDtNgu1kyxdFiAUJNur3iysg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tanstack/query-core": { + "version": "5.104.1", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.104.1.tgz", + "integrity": "sha512-TiRghcrGkUTE+M4JBxvpPSpBhxvIzQairw+cksrHYGr+2uwC77yW1SY9nYxnYRYOppvucf8rEg6b5byoz4rTpw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.104.1", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.104.1.tgz", + "integrity": "sha512-Zz70EgjahNI7aCz8BZxM3vUGU44ycxEAzMuCUjLeA7SVhhOkg3ZgPjPJPvHjs6Rmky/dtmnu1WzjGHNHmhCoZQ==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.104.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@testing-library/dom": { + "version": "10.4.2", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.2.tgz", + "integrity": "sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.3", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz", + "integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@trickfilm400/rollup-plugin-off-main-thread": { + "version": "3.0.0-pre1", + "resolved": "https://registry.npmjs.org/@trickfilm400/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-3.0.0-pre1.tgz", + "integrity": "sha512-/67zpWDBLV+oYAEL682s1ktXL0HgqX76f6gaVGkGnVZlBbm1zd0v4Bz8MFF2GGhoX9rvfq3KSQHubFHwa6w6/Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "ejs": "^3.1.10", + "json5": "^2.2.3", + "magic-string": "^0.30.21", + "string.prototype.matchall": "^4.0.12" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", + "dev": true, + "license": "MIT", + "peer": true, + "peerDependencies": { + "@types/react": "^19.3.0" + } + }, + "node_modules/@types/resolve": { + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", + "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.71.0.tgz", + "integrity": "sha512-pqcS9c1HxZTHt7End4nXqd0s5lJrrFzrgCkKFJrsbUnaL6M3+6oBFZaslg6Gjsl3argl2DDRFROnXARaZ2e4Nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.71.0", + "@typescript-eslint/type-utils": "8.71.0", + "@typescript-eslint/utils": "8.71.0", + "@typescript-eslint/visitor-keys": "8.71.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.71.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.12", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.12.tgz", + "integrity": "sha512-/8UvqAPU9DGTI9k4mxtf49U37Isfwr8Uts96+SBHIkFxPJnHS0Ew4f00sM4Scd8V8EjM0jUNtVDdv1kPdt35lg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.71.0.tgz", + "integrity": "sha512-CG4nPk1f2zc8yw4pALqHsFYH2hdo+h1T9daSp21+Hnxi9LOE3GT9hAfTKJCBXVNM2GmYs1eMEP615wPoeOgk3A==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@typescript-eslint/scope-manager": "8.71.0", + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/typescript-estree": "8.71.0", + "@typescript-eslint/visitor-keys": "8.71.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.71.0.tgz", + "integrity": "sha512-aABjw5rjBacYONVPaPiWOCjJu0vEF4a25iQuodlmQYL1trtLZ0X/y+2Vzl3BKI1odM4LnwLE1oUDXYp1wzx1TQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.71.0", + "@typescript-eslint/types": "^8.71.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.71.0.tgz", + "integrity": "sha512-gWF0BhUcnjZxSpLE8ngS/59n2SB0J3YqRxvX1+2aoRJk9hNtHSLOV+TcarFiOr5ipXm3yc1QrI4c9YZc8zyCxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/visitor-keys": "8.71.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.0.tgz", + "integrity": "sha512-Z1UlWHADEK2Mlb9NpWfDeSjqoZ5EyrOv4R3eQpbkzqn/EwaIdOpXXupEA1+0ZIOSJSZZDBHG0BrQyN8zUG6Pwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.71.0.tgz", + "integrity": "sha512-i8uO1qbdxeKgRnS5sCRt6On3/nfo2d2DwQe3Yvjx543zLy7r8ySqRuPPiIIXAhS03U0v5NfAFx+rUgxFzKKwNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/typescript-estree": "8.71.0", + "@typescript-eslint/utils": "8.71.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.71.0.tgz", + "integrity": "sha512-cJ4OoxPGWvFnBTnSZyaU+qJzGTqPTGJY+gDchj6cRyLRdmIdt4rcsE4twj+zPfrNiWuVi38wijHzShL++Z9atQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.71.0.tgz", + "integrity": "sha512-PEEF4G5sLLWAS5BpPrUvms4ySZkiBQQZM4z+3ReI46axK5Vqr/vXBQatJQIZZOYdGyPUAKTtsrWzpqKuU+3DEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.71.0", + "@typescript-eslint/tsconfig-utils": "8.71.0", + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/visitor-keys": "8.71.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.71.0.tgz", + "integrity": "sha512-pKR/tEMVrXZG23UFKUn5BQf3zfmfk7KQceI2cGzywZ5nxM5Eu3hEJU1utjWzydtzBbcJAQhHN8iPCxobHpPcZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.71.0", + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/typescript-estree": "8.71.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.71.0.tgz", + "integrity": "sha512-8eQ9R218XORK+KLosnf4bu/QsUXvUyVwTbArg7/0NMB1Pu87OJKvj4nhFblkYE8gQV73mW1dx1ptlPCkwRGa7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.71.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "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==", + "dev": true, + "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/@vitest/expect": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", + "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", + "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", + "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.7", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", + "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", + "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/babel-plugin-polyfill-corejs2": { + "version": "0.4.17", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.17.tgz", + "integrity": "sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-define-polyfill-provider": "^0.6.8", + "semver": "^6.3.1" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-corejs3": { + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.14.2.tgz", + "integrity": "sha512-coWpDLJ410R781Npmn/SIBZEsAetR4xVi0SxLMXPaMO4lSf1MwnkGYMtkFxew0Dn8B3/CpbpYxN0JCgg8mn67g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.8", + "core-js-compat": "^3.48.0" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-regenerator": { + "version": "0.6.8", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.8.tgz", + "integrity": "sha512-M762rNHfSF1EV3SLtnCJXFoQbbIIz0OyRwnCmV0KPC7qosSfCO0QLTSuJX3ayAebubhE6oYBAYPrBA5ljowaZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.8" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.27", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.27.tgz", + "integrity": "sha512-ElY12DaROGuan+lMmZ8Cvo/ZUbXPe7Enc/9VU/b1T3Kp4dwytRcNdR8DoSJN5SNJT/CuvcCA0DHDVmMOCePdRQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bidi-js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz", + "integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/browserslist": { + "version": "4.29.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.3.tgz", + "integrity": "sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA==", + "dev": true, + "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.11.26", + "caniuse-lite": "^1.0.30001813", + "electron-to-chromium": "^1.5.439", + "node-releases": "^2.0.57", + "update-browserslist-db": "^1.3.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001814", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001814.tgz", + "integrity": "sha512-/Uaf1lAzr59XcMpW0o96WoEfr+VXK2OX4U9AgFoiSHsVJ4HppnIFUjtYzsyDH2+tgANaQb2/oxYGwCPapN1FpA==", + "dev": true, + "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/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/common-tags": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/common-tags/-/common-tags-1.8.2.tgz", + "integrity": "sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/core-js-compat": { + "version": "3.50.0", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.50.0.tgz", + "integrity": "sha512-XGpFGbMLHwSt74YLTKho7Ib242qi6O8MSX+sRokV4oz7iKXvQWGYZthjIhjRGMxjzVkAubBO512dKGYcefmX3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.7" + }, + "engines": { + "node": ">=6.4.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/crypto-random-string": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-2.0.0.tgz", + "integrity": "sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssstyle": { + "version": "5.3.7", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.7.tgz", + "integrity": "sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^4.1.1", + "@csstools/css-syntax-patches-for-csstree": "^1.0.21", + "css-tree": "^3.1.0", + "lru-cache": "^11.2.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cssstyle/node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "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/data-urls": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.1.tgz", + "integrity": "sha512-euIQENZg6x8mj3fO6o9+fOW8MimUI4PpD/fZBhJfeioZVy9TUpM4UY7KjQNVZFlqwJ0UdzRDzkycB997HEq1BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^15.1.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls/node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ejs": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", + "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "jake": "^10.8.5" + }, + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.444", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.444.tgz", + "integrity": "sha512-5ss/uJfoDYDHT0lfJzT6FbcskIzROIOPf0BbbFkGcvDzoJU7i//9GDrwwIHQVmIsrAGiF3ihpADBRIsrEFt1rQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/entities": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", + "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-abstract": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", + "integrity": "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-abstract-get": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-abstract-get/-/es-abstract-get-1.0.0.tgz", + "integrity": "sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.2", + "is-callable": "^1.2.7", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.4.tgz", + "integrity": "sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-abstract-get": "^1.0.0", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "is-callable": "^1.2.7", + "is-date-object": "^1.1.0", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.5.tgz", + "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.6", + "@eslint/js": "9.39.5", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eta": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/eta/-/eta-4.6.0.tgz", + "integrity": "sha512-lW6is4T1NFOYnmqGZIfvixqj7A7sSvScF+DN8EK6K58xI5MZ5UvYe0GjopxOXQtZvUn4eDdVuZ8XSoYWTMEKwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/bgub/eta?sponsor=1" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/filelist": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", + "integrity": "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.0.1" + } + }, + "node_modules/filelist/node_modules/brace-expansion": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/filelist/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.2.0.tgz", + "integrity": "sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2", + "hasown": "^2.0.4", + "is-callable": "^1.2.7", + "is-document.all": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-own-enumerable-property-symbols": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/get-own-enumerable-property-symbols/-/get-own-enumerable-property-symbols-3.0.2.tgz", + "integrity": "sha512-I0UBV/XOz1XkIJHEUDMZAbzCThU/H8DxmSfmdGcKPnVhu2VfFqr34jr9777IyaTYvxjedWhqVIilEDsCdP5G6g==", + "dev": true, + "license": "ISC" + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz", + "integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "foreground-child": "^3.3.1", + "jackspeak": "^4.1.1", + "minimatch": "^10.1.1", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^2.0.0" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/idb": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", + "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.17.0", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.17.0.tgz", + "integrity": "sha512-J/vG0zBCbIKOQFfufSwyXdMrsohyJIUNkrnmo6WZGzoM7tr/lsbfW5b2BvisL6zsyMzK9UxV9L6c7AoFbyXHOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-document.all": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-document.all/-/is-document.all-1.0.0.tgz", + "integrity": "sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-module": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", + "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-obj": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-1.0.1.tgz", + "integrity": "sha512-l4RyHgRqGN4Y3+9JHVrNqO+tN0rV5My76uW5/nuO4K1b6vw5G8d/cmFjP9tRfEsdhZNt0IFdZuK/c2Vr4Nb+Qg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-regexp": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-regexp/-/is-regexp-1.0.0.tgz", + "integrity": "sha512-7zjFAPO4/gwyQAAgRRmqeEeyIICSdmCqa3tsVHMdBzaXXRiqopZL4Cyghg/XulGWrtABTpbnYYzzIRffLkP4oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.2.3.tgz", + "integrity": "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^9.0.0" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jake": { + "version": "10.9.4", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", + "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "async": "^3.2.6", + "filelist": "^1.0.4", + "picocolors": "^1.1.1" + }, + "bin": { + "jake": "bin/cli.js" + }, + "engines": { + "node": ">=10" + } + }, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsdom": { + "version": "27.4.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.4.0.tgz", + "integrity": "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@acemir/cssom": "^0.9.28", + "@asamuzakjp/dom-selector": "^6.7.6", + "@exodus/bytes": "^1.6.0", + "cssstyle": "^5.3.4", + "data-urls": "^6.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.0", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^15.1.0", + "ws": "^8.18.3", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsonpointer": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/jsonpointer/-/jsonpointer-5.0.1.tgz", + "integrity": "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "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==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "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/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.57", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.57.tgz", + "integrity": "sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/own-keys": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.2.tgz", + "integrity": "sha512-19YVAg7T+WTrxggPukVq7DjTv6+PJ867TmhCvBsYwmbFCsZd344rq2Ld1p0wo8f8Qrrhgp82c6FJRqdXWtSEhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "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.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/pretty-bytes": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-6.1.1.tgz", + "integrity": "sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", + "license": "MIT", + "peer": true, + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.0" + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "7.18.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.4.tgz", + "integrity": "sha512-PUPQcMhMGRAslLcvtlPz/kmzBEWPhLdgLFrL7pLNepBL6dX0lWj4WD2cUYVgYCuT3jxvghYFg81cDTj44DhetQ==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.18.4", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.4.tgz", + "integrity": "sha512-yrfmJHIpDG7taCpqKjT1G5B6q3O2K+RN8/fgNf0lTjCwiPbQ0ei6vXX9ZjQR+7ld8Tr7Z5xmyMnZ8YJrphWQUw==", + "license": "MIT", + "dependencies": { + "react-router": "7.18.4" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regenerate": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", + "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==", + "dev": true, + "license": "MIT" + }, + "node_modules/regenerate-unicode-properties": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.3.0.tgz", + "integrity": "sha512-9ns8odR8e9q3otKeHj3DE80jZYKg6zVeZKE+zQmRKXFoVUcuiTGeO6iusDhPRoIQQPVPRvH+SfBhrTud4eczuw==", + "dev": true, + "license": "MIT", + "dependencies": { + "regenerate": "^1.4.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexpu-core": { + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.5.2.tgz", + "integrity": "sha512-zkmVH92DlzjSFc4xKGZp/2BXIFrDIdBQBC8gUXKCsMgKYb1bkCkxfSuoW1werDjaTEMTbULkHtKFHe1L+Abnaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "regenerate": "^1.4.2", + "regenerate-unicode-properties": "^10.3.0", + "regjsgen": "^0.8.0", + "regjsparser": "^0.13.0", + "unicode-match-property-ecmascript": "^2.0.0", + "unicode-match-property-value-ecmascript": "^2.2.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regjsgen": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.8.0.tgz", + "integrity": "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/regjsparser": { + "version": "0.13.3", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.3.tgz", + "integrity": "sha512-ycwFAS14Jw4mppvmK4GR/J6u3WpWpjkEApehuHtLc/8VpPNpDMbQ4WjqwplXifGeyKOzHSFLmSPqzksDQE2Sfg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "jsesc": "~3.1.0" + }, + "bin": { + "regjsparser": "bin/parser" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rollup": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.64.0.tgz", + "integrity": "sha512-gjA/kFeDfffII8COoPsvLPvE16hWDTEc1+0QKQNKUkuV8iJjqsC4xXs8/FzuL0v9EV39kuxTbAgcFoTRyy2TGQ==", + "dev": true, + "license": "MIT", + "peer": true, + "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.64.0", + "@rollup/rollup-android-arm64": "4.64.0", + "@rollup/rollup-darwin-arm64": "4.64.0", + "@rollup/rollup-darwin-x64": "4.64.0", + "@rollup/rollup-freebsd-arm64": "4.64.0", + "@rollup/rollup-freebsd-x64": "4.64.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.64.0", + "@rollup/rollup-linux-arm-musleabihf": "4.64.0", + "@rollup/rollup-linux-arm64-gnu": "4.64.0", + "@rollup/rollup-linux-arm64-musl": "4.64.0", + "@rollup/rollup-linux-loong64-gnu": "4.64.0", + "@rollup/rollup-linux-loong64-musl": "4.64.0", + "@rollup/rollup-linux-ppc64-gnu": "4.64.0", + "@rollup/rollup-linux-ppc64-musl": "4.64.0", + "@rollup/rollup-linux-riscv64-gnu": "4.64.0", + "@rollup/rollup-linux-riscv64-musl": "4.64.0", + "@rollup/rollup-linux-s390x-gnu": "4.64.0", + "@rollup/rollup-linux-x64-gnu": "4.64.0", + "@rollup/rollup-linux-x64-musl": "4.64.0", + "@rollup/rollup-openbsd-x64": "4.64.0", + "@rollup/rollup-openharmony-arm64": "4.64.0", + "@rollup/rollup-win32-arm64-msvc": "4.64.0", + "@rollup/rollup-win32-ia32-msvc": "4.64.0", + "@rollup/rollup-win32-x64-gnu": "4.64.0", + "@rollup/rollup-win32-x64-msvc": "4.64.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", + "integrity": "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "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==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/serialize-javascript": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.1.2.tgz", + "integrity": "sha512-GL2BWwVa6JydKO6l/ljVgjAZF4QJ3S7dWDWi53s5GZT8PJzD2fNxi67HANQGKAqPrwEpL5ba7gUBGi0Ls/sEoQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/smob": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/smob/-/smob-1.6.2.tgz", + "integrity": "sha512-RQsvleCbF8cVHEv+xuDGaA4pOizFqJ0GgjtMSRo6oP8pnN7WsigHgVGey6aILRBKv4W2YOMHLqbKdnB6hpB9fw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/source-map": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0.tgz", + "integrity": "sha512-d8EqvL+k/SOXCreS/SUzg2ciyHqBBLcN/yuRjFsbvVhHTE2pgei7oAhmPM7kWFbkX6OSMQfUq4KbkF3au9lhYQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/source-map-support/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string.prototype.matchall": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.1.0.tgz", + "integrity": "sha512-tHNHTxInrYLCga9O9YGxWA3G9/nnzQw8UGAyqGx3Ar1pSTTzIuM4woFSq4SowkXCjJIwq5sIiQvEfRI9tCH1qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.2", + "get-intrinsic": "^1.3.0", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "regexp.prototype.flags": "^1.5.4", + "set-function-name": "^2.0.2", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.11.tgz", + "integrity": "sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.2", + "es-object-atoms": "^1.1.2", + "has-property-descriptors": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.10.tgz", + "integrity": "sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/stringify-object": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/stringify-object/-/stringify-object-3.3.0.tgz", + "integrity": "sha512-rHqiFh1elqCQ9WPLIC8I0Q/g/wj5J1eMkyoiD6eoQApWHP0FtlK7rqnhmabL5VUY9JQCcqwwvlOaSuutekgyrw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "get-own-enumerable-property-symbols": "^3.0.0", + "is-obj": "^1.0.1", + "is-regexp": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-comments/-/strip-comments-2.0.1.tgz", + "integrity": "sha512-ZprKx+bBLXv067WTCALv8SSz5l2+XhpYCsVtSqlMnkAXMWDq+/ekVbl1ghqP9rUHTzv6sm/DwCOiYutU/yp1fw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/temp-dir": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz", + "integrity": "sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/tempy": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tempy/-/tempy-0.6.0.tgz", + "integrity": "sha512-G13vtMYPT/J8A4X2SjdtBTphZlrp1gKv6hZiOjw14RCWg6GbHuQBGtjlx75xLbYV/wEc0D7G5K4rxKP/cXk8Bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-stream": "^2.0.0", + "temp-dir": "^2.0.0", + "type-fest": "^0.16.0", + "unique-string": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/terser": { + "version": "5.51.2", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.51.2.tgz", + "integrity": "sha512-bWnjSNscmuI+GJze6ZupnHP8G/cTcsJF+bXCeQknk2SHQsgbNJnLrqiH9jZ2W4STPVXH2mDKKRX3iwPhc9Cn/Q==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "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/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.6.tgz", + "integrity": "sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.16", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.16.tgz", + "integrity": "sha512-QwBER5KMR86IIjpIiO7H/Z3IMJPsZ1A6RKPAqzTTgOyUQUSt9FdnKcqhTaJmkY6HVrgouZHZR0ncK5QxvmnQeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.16" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.16", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.16.tgz", + "integrity": "sha512-MDolfaSJtlSK5Y0A1xl3277ekubZwobpBjugknDizI9O5Rm60a1m8k4ICK+MRsCDzPygT81mp3BBf5RKDlFRfA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.16.0.tgz", + "integrity": "sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.5.tgz", + "integrity": "sha512-0FHJvLPqZ7KJzp17O13jfsAjsqazgrxBu2zEK95PmUz8lv2+GjRuxUInCr2Rk9Dms3ihN21zJ929ZO43yJ95QQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.9", + "for-each": "^0.3.5", + "gopd": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.10" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.8.tgz", + "integrity": "sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "for-each": "^0.3.5", + "gopd": "^1.2.0", + "is-typed-array": "^1.1.15", + "possible-typed-array-names": "^1.1.0", + "reflect.getprototypeof": "^1.0.10" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.71.0.tgz", + "integrity": "sha512-fBdHYiqQ14RW6mOMXD14Svn82ZsCYAoQSzGRzyEjR59S5A2Krh/l7fGTOQ7iCr8gGy/mHVXtEF7s5fgjEdV0Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.71.0", + "@typescript-eslint/parser": "8.71.0", + "@typescript-eslint/typescript-estree": "8.71.0", + "@typescript-eslint/utils": "8.71.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/unicode-canonical-property-names-ecmascript": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", + "integrity": "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-ecmascript": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", + "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "unicode-canonical-property-names-ecmascript": "^2.0.0", + "unicode-property-aliases-ecmascript": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-value-ecmascript": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.1.tgz", + "integrity": "sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-property-aliases-ecmascript": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.2.0.tgz", + "integrity": "sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unique-string": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-2.0.0.tgz", + "integrity": "sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "crypto-random-string": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/upath": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/upath/-/upath-1.2.0.tgz", + "integrity": "sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4", + "yarn": "*" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz", + "integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==", + "dev": true, + "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/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "dev": true, + "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/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite-plugin-pwa": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-1.3.0.tgz", + "integrity": "sha512-c5kMgN+ITrOtHXp8PAtk2uOIEea6XjP/unCGxOWWBzQ6qa65qj/awHg0wf+QF9E/2u9vh86LqxPwzEPNbM2r5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.6", + "pretty-bytes": "^6.1.1", + "tinyglobby": "^0.2.10", + "workbox-build": "^7.4.1", + "workbox-window": "^7.4.1" + }, + "engines": { + "node": ">=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vite-pwa/assets-generator": "^1.0.0", + "vite": "^3.1.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "workbox-build": "^7.4.1", + "workbox-window": "^7.4.1" + }, + "peerDependenciesMeta": { + "@vite-pwa/assets-generator": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", + "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.7", + "@vitest/mocker": "3.2.7", + "@vitest/pretty-format": "^3.2.7", + "@vitest/runner": "3.2.7", + "@vitest/snapshot": "3.2.7", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.7", + "@vitest/ui": "3.2.7", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-15.1.0.tgz", + "integrity": "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.24", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.24.tgz", + "integrity": "sha512-wk4Mf4pR5mRP7eYuuTBCIQ9d0ud2Fv2jRLQpfgnRjbOxAFHmjKFValgTpitVKzJJS8ajnYQV2Du1SZ8j6b/EUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/workbox-background-sync": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-background-sync/-/workbox-background-sync-7.4.1.tgz", + "integrity": "sha512-HhT7KE8tOWDm02wRNshXUnUPofMlhenF2DBdUnDPOubhizzPeItkYTmAB6td1Z2cjYPa98vzEiPLEuzn5hN66g==", + "dev": true, + "license": "MIT", + "dependencies": { + "idb": "^7.0.1", + "workbox-core": "7.4.1" + } + }, + "node_modules/workbox-broadcast-update": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-broadcast-update/-/workbox-broadcast-update-7.4.1.tgz", + "integrity": "sha512-uAlgslKLvbQY+suirIdnBCSYrcgBhjp81Nj4l1lj/Jmj0MJO2CJERnCJjT0GFVwmReV0N+zs78K6gqd5gr9/+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-core": "7.4.1" + } + }, + "node_modules/workbox-build": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-build/-/workbox-build-7.4.1.tgz", + "integrity": "sha512-SDhxIvEAde9Gy/5w4Yo1Jh/M49Z0qE3q0oteyE8zGq0DScxFqVBcCtIXFuLtmtxRQZCMbf0prco4VyEu3KBQuw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@apideck/better-ajv-errors": "^0.3.1", + "@babel/core": "^7.24.4", + "@babel/preset-env": "^7.11.0", + "@babel/runtime": "^7.11.2", + "@rollup/plugin-babel": "^6.1.0", + "@rollup/plugin-node-resolve": "^16.0.3", + "@rollup/plugin-replace": "^6.0.3", + "@rollup/plugin-terser": "^1.0.0", + "@trickfilm400/rollup-plugin-off-main-thread": "^3.0.0-pre1", + "ajv": "^8.6.0", + "common-tags": "^1.8.0", + "eta": "^4.5.1", + "fast-json-stable-stringify": "^2.1.0", + "fs-extra": "^9.0.1", + "glob": "^11.0.1", + "pretty-bytes": "^5.3.0", + "rollup": "^4.53.3", + "source-map": "^0.8.0-beta.0", + "stringify-object": "^3.3.0", + "strip-comments": "^2.0.1", + "tempy": "^0.6.0", + "upath": "^1.2.0", + "workbox-background-sync": "7.4.1", + "workbox-broadcast-update": "7.4.1", + "workbox-cacheable-response": "7.4.1", + "workbox-core": "7.4.1", + "workbox-expiration": "7.4.1", + "workbox-google-analytics": "7.4.1", + "workbox-navigation-preload": "7.4.1", + "workbox-precaching": "7.4.1", + "workbox-range-requests": "7.4.1", + "workbox-recipes": "7.4.1", + "workbox-routing": "7.4.1", + "workbox-strategies": "7.4.1", + "workbox-streams": "7.4.1", + "workbox-sw": "7.4.1", + "workbox-window": "7.4.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/workbox-build/node_modules/@apideck/better-ajv-errors": { + "version": "0.3.7", + "resolved": "https://registry.npmjs.org/@apideck/better-ajv-errors/-/better-ajv-errors-0.3.7.tgz", + "integrity": "sha512-TajUJwGWbDwkCx/CZi7tRE8PVB7simCvKJfHUsSdvps+aTM/PDPP4gkLmKnc+x3CE//y9i/nj74GqdL/hwk7Iw==", + "dev": true, + "license": "MIT", + "dependencies": { + "jsonpointer": "^5.0.1", + "leven": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "ajv": ">=8" + } + }, + "node_modules/workbox-build/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/workbox-build/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/workbox-build/node_modules/pretty-bytes": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz", + "integrity": "sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/workbox-cacheable-response": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-cacheable-response/-/workbox-cacheable-response-7.4.1.tgz", + "integrity": "sha512-8xaFoJdDc2OjrlbbL3gEeBO1WKcMwRqwLRupgqahYXu75yXajPLuwrbXMrIGZuWYXrQwk0xDjOxZ/ujCy/oJYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-core": "7.4.1" + } + }, + "node_modules/workbox-core": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-core/-/workbox-core-7.4.1.tgz", + "integrity": "sha512-DT+vu46eh/2vRsSHTY4Xmc32Z1rr9PRlQUXr1Dx30ZuXRWwOsvZgGgcwxcasubQLQmbTNYZjv44LkBAQ4tT5tQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/workbox-expiration": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-expiration/-/workbox-expiration-7.4.1.tgz", + "integrity": "sha512-lRKUF7b+OGbeXkQk1s6MHXOa3d7Xxf7Of31W6c6hCfipfIyrtdWZ89stq21AHZMaoG7VNFoHply4Ox+rU31TWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "idb": "^7.0.1", + "workbox-core": "7.4.1" + } + }, + "node_modules/workbox-google-analytics": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-google-analytics/-/workbox-google-analytics-7.4.1.tgz", + "integrity": "sha512-Mks1JwLEt++ZAkF6sS1OpSh9RtAMIsiDgRpK+codiHGIPXeaUOgi4cPc3GFadUl8V5QPeypEk8Oxgl3HlwVzHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-background-sync": "7.4.1", + "workbox-core": "7.4.1", + "workbox-routing": "7.4.1", + "workbox-strategies": "7.4.1" + } + }, + "node_modules/workbox-navigation-preload": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-navigation-preload/-/workbox-navigation-preload-7.4.1.tgz", + "integrity": "sha512-C4KVsjPcYKJOhr631AxR9XoG2rLF3QiTk5aMv36MXOjtWvm8axwNFAtKUPGsWUwLXXAMgYM1En7fsvndaXeXRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-core": "7.4.1" + } + }, + "node_modules/workbox-precaching": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-precaching/-/workbox-precaching-7.4.1.tgz", + "integrity": "sha512-cdr/9qByww7yzEp7zg/qI4ukUrrNjQLgN+ONQRpjy/VqGQXwkgHwr00KksGJK8v0VifwDXBb8a4cWNZH71jn3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-core": "7.4.1", + "workbox-routing": "7.4.1", + "workbox-strategies": "7.4.1" + } + }, + "node_modules/workbox-range-requests": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-range-requests/-/workbox-range-requests-7.4.1.tgz", + "integrity": "sha512-7i2oxAUE82gHdAJBCAQ04JzNOdRPqzuOzGfoUyJpFSmeqBNYGPrAH8GPoPjUQTfp+NycwrD2H68VtuF8qxv0vQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-core": "7.4.1" + } + }, + "node_modules/workbox-recipes": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-recipes/-/workbox-recipes-7.4.1.tgz", + "integrity": "sha512-gnbVfmV4/TtmQaM4x9AtuXhcdstJsep3XMVeztOrQVPT+R6+6DeBjGTCQ7fFCXm+4GEHUA5VEBTyi5+4gWGeog==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-cacheable-response": "7.4.1", + "workbox-core": "7.4.1", + "workbox-expiration": "7.4.1", + "workbox-precaching": "7.4.1", + "workbox-routing": "7.4.1", + "workbox-strategies": "7.4.1" + } + }, + "node_modules/workbox-routing": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-routing/-/workbox-routing-7.4.1.tgz", + "integrity": "sha512-yubJGErZOusuidAenaL5ypfhQOa7urxP/f8E0ws7FPb4039RiWXUWBAyUkmUoOL/BcQGen3h0J8872d51IYxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-core": "7.4.1" + } + }, + "node_modules/workbox-strategies": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-strategies/-/workbox-strategies-7.4.1.tgz", + "integrity": "sha512-GZxpaw9NbmOelj7667uZ2kpk5BFpOGbO4X0qjwh5ls8XQ8C+Lha5LQchTiUzsTFSS+NlUpftYAyOVXvQUrcqOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-core": "7.4.1" + } + }, + "node_modules/workbox-streams": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-streams/-/workbox-streams-7.4.1.tgz", + "integrity": "sha512-HWWtraKUbJknd9kgqGcpQ3G114HOPYvqs8HaJMDs2ebLNAimDkVDaWfAXE6Ybl+m8U6KsCE6pWyLYuigWmnAXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-core": "7.4.1", + "workbox-routing": "7.4.1" + } + }, + "node_modules/workbox-sw": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-sw/-/workbox-sw-7.4.1.tgz", + "integrity": "sha512-fez5f2DUlDJWTFYkCWQpY10N8gtztd849NswCbVFk0QlcSM4HT5A8x4g4ii650yem4I8tHY0R7JZahwp3ltIPw==", + "dev": true, + "license": "MIT" + }, + "node_modules/workbox-window": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-window/-/workbox-window-7.4.1.tgz", + "integrity": "sha512-notZDH2u8VXaqyuD7xaqIfEFi6SRM4SUSd7ewe9PDsVqADuepxX2ZMY3uvuZGxzY5ZOsGC/vD3A/3smFtJt4/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/trusted-types": "^2.0.2", + "workbox-core": "7.4.1" + } + }, + "node_modules/ws": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", + "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..60ade86 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,36 @@ +{ + "name": "buying-agent-frontend", + "private": true, + "version": "0.3.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -p tsconfig.app.json --noEmit && vite build", + "preview": "vite preview", + "lint": "eslint src", + "typecheck": "tsc -p tsconfig.app.json --pretty false --noEmit", + "test": "vitest run", + "e2e": "playwright test" + }, + "dependencies": { + "@tanstack/react-query": "^5.90.2", + "react": "^19.1.1", + "react-dom": "^19.1.1", + "react-router-dom": "^7.9.3" + }, + "devDependencies": { + "@playwright/test": "^1.55.1", + "@testing-library/jest-dom": "^6.8.0", + "@testing-library/react": "^16.3.0", + "@types/react": "^19.1.13", + "@types/react-dom": "^19.1.9", + "@vitejs/plugin-react": "^5.0.3", + "eslint": "^9.36.0", + "jsdom": "^27.0.0", + "typescript": "^5.9.2", + "typescript-eslint": "^8.44.1", + "vite": "^7.1.7", + "vite-plugin-pwa": "^1.0.3", + "vitest": "^3.2.4" + } +} diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 0000000..289d964 --- /dev/null +++ b/frontend/playwright.config.ts @@ -0,0 +1,10 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "./e2e", + use: { baseURL: process.env.PLAYWRIGHT_BASE_URL || "http://127.0.0.1:4173" }, + projects: [ + { name: "desktop", use: { ...devices["Desktop Chrome"] } }, + { name: "mobile", use: { ...devices["Pixel 5"] } }, + ], +}); diff --git a/frontend/public/icon.svg b/frontend/public/icon.svg new file mode 100644 index 0000000..abc2fc2 --- /dev/null +++ b/frontend/public/icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/public/manifest.webmanifest b/frontend/public/manifest.webmanifest new file mode 100644 index 0000000..813338f --- /dev/null +++ b/frontend/public/manifest.webmanifest @@ -0,0 +1,9 @@ +{ + "name": "Buying Agent", + "short_name": "BuyingAgent", + "start_url": "/", + "display": "standalone", + "background_color": "#111318", + "theme_color": "#111318", + "icons": [{ "src": "/icon.svg", "sizes": "any", "type": "image/svg+xml" }] +} diff --git a/frontend/public/offline.html b/frontend/public/offline.html new file mode 100644 index 0000000..f7e40f8 --- /dev/null +++ b/frontend/public/offline.html @@ -0,0 +1,11 @@ + + + + + + Offline + + +

Buying Agent ist offline. Bitte Verbindung prüfen und erneut versuchen.

+ + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..f431afe --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,44 @@ +import { NavLink, Route, Routes } from "react-router-dom"; +import { AdminPage } from "./pages/AdminPage"; +import { CandidateDetailPage } from "./pages/CandidateDetailPage"; +import { ProjectDetailPage } from "./pages/ProjectDetailPage"; +import { ProjectListPage } from "./pages/ProjectListPage"; +import { WizardPage } from "./pages/WizardPage"; + +function Nav() { + return ( + <> + + Projekte + + Neu + Admin + + ); +} + +export function App() { + return ( +
+ +
+ + } /> + } /> + } /> + } /> + } /> + } /> + +
+
+ ); +} diff --git a/frontend/src/api.ts b/frontend/src/api.ts new file mode 100644 index 0000000..f8cd2e5 --- /dev/null +++ b/frontend/src/api.ts @@ -0,0 +1,152 @@ +const API = "/api/v1"; + +async function request(path: string, init?: RequestInit): Promise { + const response = await fetch(`${API}${path}`, { + headers: { "Content-Type": "application/json", ...(init?.headers || {}) }, + ...init, + }); + const body = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(body.message || `HTTP ${response.status}`); + } + return body as T; +} + +export const api = { + productCategories: () => request<{ items: ProductCategory[] }>("/product-categories"), + categorySources: (id: string) => request<{ items: CategorySource[] }>(`/product-categories/${id}/sources`), + availableCriteria: (id: string, source: string) => + request(`/product-categories/${id}/available-criteria?source=${encodeURIComponent(source)}`), + templates: () => request<{ items: BuyingTemplate[] }>("/buying-templates"), + projects: () => request<{ items: ProjectSummary[] }>("/buying-projects"), + project: (id: string) => request(`/buying-projects/${id}`), + createProject: (payload: unknown) => request("/buying-projects", { method: "POST", body: JSON.stringify(payload) }), + patchProject: (id: string, payload: unknown) => + request(`/buying-projects/${id}`, { method: "PATCH", body: JSON.stringify(payload) }), + putCriteria: (id: string, payload: unknown) => + request(`/buying-projects/${id}/criteria`, { method: "PUT", body: JSON.stringify(payload) }), + searches: (projectId: string) => request<{ items: DiscoverySearch[] }>(`/buying-projects/${projectId}/discovery-searches`), + createSearch: (projectId: string, payload: unknown) => + request(`/buying-projects/${projectId}/discovery-searches`, { method: "POST", body: JSON.stringify(payload) }), + generateQuery: (searchId: string) => request(`/discovery-searches/${searchId}/generate-query`, { method: "POST" }), + runSearch: (searchId: string) => request(`/discovery-searches/${searchId}/run`, { method: "POST" }), + runs: (searchId: string) => request<{ items: SearchRun[] }>(`/discovery-searches/${searchId}/runs`), + candidates: (projectId: string) => request<{ items: Candidate[] }>(`/buying-projects/${projectId}/candidates`), + candidate: (projectId: string, candidateId: string) => + request(`/buying-projects/${projectId}/candidates/${candidateId}`), + observations: (projectId: string, candidateId: string) => + request<{ items: Observation[] }>(`/buying-projects/${projectId}/candidates/${candidateId}/observations`), + shortlist: (projectId: string) => request<{ items: Candidate[] }>(`/buying-projects/${projectId}/shortlist`), + sources: () => request<{ items: Source[] }>("/sources"), + sourceCategories: (source: string) => request<{ items: SourceCategory[] }>(`/sources/${source}/categories`), + discoverTaxonomy: (source: string, category: string) => + request(`/sources/${source}/categories/${category}/discover-taxonomy`, { method: "POST" }), + filters: (source: string, category: string) => request<{ filters: SourceFilter[] }>(`/sources/${source}/categories/${category}/filters`), +}; + +export type ProductCategory = { id: string; name: string; status: string }; +export type CategorySource = { source: string; source_category_id: string; display_name: string; status: string }; +export type BuyingTemplate = { id: string; version: number; name: string; product_category_id?: string; market?: string; currency?: string }; +export type ProjectSummary = { + id: string; + name: string; + product_category?: ProductCategory; + status: string; + last_successful_run_at?: string | null; + next_run_at?: string | null; + discovery_status?: string | null; + candidate_count: number; + shortlist_count: number; + new_count: number; + changed_count: number; + missing_count: number; +}; +export type ProjectDetail = { + id: string; + name: string; + status: string; + product_category_id: string; + market: string; + currency: string; + target_budget?: string | null; + shortlist_size: number; + criteria_version: number; + ranking_status: string; + criteria?: { items: ProjectCriterion[] }; +}; +export type ProjectCriterion = { + id: string; + criterion_definition_id: string; + criterion?: string; + operator: string; + value: unknown; + enabled_for_discovery: boolean; +}; +export type AvailableCriterion = { + criterion_definition_id: string; + label: string; + origin: string; + filter_type?: string | null; + unit?: string | null; + operators: string[]; + options?: { source_value: string; normalized_value: unknown }[]; + mapping_status?: string | null; + supports_discovery: boolean; + scoring_attribute_key?: string | null; +}; +export type AvailableCriteria = { product_category_id: string; source: string; criteria: AvailableCriterion[] }; +export type DiscoverySearch = { + id: string; + status: string; + generated_url: string; + schedule_interval_minutes: number; + next_run_at?: string | null; + last_successful_run_at?: string | null; + source_id: string; + source_category_id: string; +}; +export type SearchRun = { + id: string; + status: string; + started_at: string; + finished_at?: string | null; + result_count: number; + page_count: number; + new_count: number; + changed_count: number; + unchanged_count: number; + missing_count: number; + error_code?: string | null; +}; +export type Candidate = { + candidate_id: string; + title?: string; + manufacturer?: string | null; + model?: string | null; + source_url?: string | null; + current_best_price?: string | null; + currency?: string | null; + current_dynamic_attributes?: { raw_name: string; normalized_value: unknown; unit?: string | null }[]; + product_score?: number | null; + product_eligibility?: string | null; + ranking_status: string; + shortlist_status: string; + current_diff_status?: string | null; + product_lifecycle_status?: string | null; + match_status?: string; + first_seen_at?: string; + last_seen_at?: string; + current_observation?: Observation | null; +}; +export type Observation = { + observation_id: string; + observed_at: string; + best_price?: string | null; + currency?: string | null; + availability?: string | null; + seller_count?: number | null; + dynamic_attributes?: { raw_name: string; normalized_value: unknown }[]; +}; +export type Source = { id: string; name: string }; +export type SourceCategory = { external_category_key: string; name: string; status: string }; +export type SourceFilter = { label: string; external_filter_key: string; filter_type: string; status: string }; diff --git a/frontend/src/components/CriteriaControl.test.tsx b/frontend/src/components/CriteriaControl.test.tsx new file mode 100644 index 0000000..ec83dc2 --- /dev/null +++ b/frontend/src/components/CriteriaControl.test.tsx @@ -0,0 +1,33 @@ +import { render, screen } from "@testing-library/react"; +import { CriteriaControl, pickSource } from "./CriteriaControl"; + +const base = { + criterion_definition_id: "1", + label: "Arbeitsspeicher", + origin: "SOURCE", + operators: [">="], + supports_discovery: true, +}; + +test("renders numeric threshold without notebook-specific fields", () => { + render( + undefined} + />, + ); + expect(screen.getByDisplayValue("24")).toBeInTheDocument(); + expect(screen.queryByLabelText("RAM GB")).not.toBeInTheDocument(); +}); + +test("picks a single active source automatically", () => { + expect(pickSource([{ source: "geizhals", status: "ACTIVE" }])).toBe("geizhals"); + expect( + pickSource([ + { source: "geizhals", status: "ACTIVE" }, + { source: "idealo", status: "ACTIVE" }, + ]), + ).toBeNull(); +}); diff --git a/frontend/src/components/CriteriaControl.tsx b/frontend/src/components/CriteriaControl.tsx new file mode 100644 index 0000000..b5000ab --- /dev/null +++ b/frontend/src/components/CriteriaControl.tsx @@ -0,0 +1,71 @@ +import type { AvailableCriterion } from "../api"; + +export function CriteriaControl({ + criterion, + value, + operator, + onChange, +}: { + criterion: AvailableCriterion; + value: unknown; + operator: string; + onChange: (next: { operator: string; value: unknown }) => void; +}) { + const type = criterion.filter_type || "TEXT"; + return ( +
+ + {type === "BOOLEAN" ? ( + + ) : type === "ENUM" || type === "MULTI_ENUM" ? ( + + ) : ( + + )} +
+ ); +} + +export function pickSource(sources: { source: string; status: string }[]) { + const active = sources.filter((item) => item.status === "ACTIVE"); + if (active.length === 1) return active[0].source; + return null; +} diff --git a/frontend/src/components/State.tsx b/frontend/src/components/State.tsx new file mode 100644 index 0000000..fa2bb5a --- /dev/null +++ b/frontend/src/components/State.tsx @@ -0,0 +1,20 @@ +export function LoadingState() { + return
Laden …
; +} + +export function ErrorState({ message, onRetry }: { message: string; onRetry?: () => void }) { + return ( +
+

{message}

+ {onRetry ? ( + + ) : null} +
+ ); +} + +export function EmptyState({ children }: { children: string }) { + return
{children}
; +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..1161348 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,18 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { BrowserRouter } from "react-router-dom"; +import { App } from "./App"; +import "./styles.css"; + +const client = new QueryClient(); + +createRoot(document.getElementById("root")!).render( + + + + + + + , +); diff --git a/frontend/src/pages/AdminPage.tsx b/frontend/src/pages/AdminPage.tsx new file mode 100644 index 0000000..a2ba08d --- /dev/null +++ b/frontend/src/pages/AdminPage.tsx @@ -0,0 +1,63 @@ +import { useMutation, useQuery } from "@tanstack/react-query"; +import { api } from "../api"; +import { EmptyState, LoadingState } from "../components/State"; + +export function AdminPage() { + const sources = useQuery({ queryKey: ["sources"], queryFn: api.sources }); + const source = sources.data?.items.find((item) => item.id === "geizhals")?.id || sources.data?.items[0]?.id || ""; + const categories = useQuery({ + queryKey: ["source-categories", source], + queryFn: () => api.sourceCategories(source), + enabled: Boolean(source), + }); + const category = categories.data?.items[0]?.external_category_key || ""; + const filters = useQuery({ + queryKey: ["filters", source, category], + queryFn: () => api.filters(source, category), + enabled: Boolean(source && category), + }); + const discover = useMutation({ + mutationFn: () => api.discoverTaxonomy(source, category), + onSuccess: () => filters.refetch(), + }); + if (sources.isLoading) return ; + return ( +
+

Quellen und Taxonomie

+

Technische IDs sind nur hier sichtbar.

+
+

Source: {source || "–"}

+

Kategorie: {category || "–"}

+ +
+ {!filters.data?.filters.length ? ( + Taxonomie wurde noch nicht geladen. + ) : ( +
+ + + + + + + + + + + {filters.data.filters.map((item) => ( + + + + + + + ))} + +
LabelFilterTypStatus
{item.label}{item.external_filter_key}{item.filter_type}{item.status}
+
+ )} +
+ ); +} diff --git a/frontend/src/pages/CandidateDetailPage.tsx b/frontend/src/pages/CandidateDetailPage.tsx new file mode 100644 index 0000000..6640ef2 --- /dev/null +++ b/frontend/src/pages/CandidateDetailPage.tsx @@ -0,0 +1,53 @@ +import { useQuery } from "@tanstack/react-query"; +import { useParams } from "react-router-dom"; +import { api } from "../api"; +import { EmptyState, ErrorState, LoadingState } from "../components/State"; + +export function CandidateDetailPage() { + const { projectId = "", candidateId = "" } = useParams(); + const detail = useQuery({ + queryKey: ["candidate", projectId, candidateId], + queryFn: () => api.candidate(projectId, candidateId), + }); + const history = useQuery({ + queryKey: ["observations", projectId, candidateId], + queryFn: () => api.observations(projectId, candidateId), + }); + if (detail.isLoading) return ; + if (detail.isError) return ; + const item = detail.data!; + return ( +
+

{item.title}

+
+

+ {item.manufacturer} {item.model} +

+

+ Preis: {item.current_best_price || "–"} {item.currency || ""} +

+

Ranking: {item.ranking_status}

+

Diff: {item.current_diff_status}

+

Lifecycle: {item.product_lifecycle_status}

+
+
+

Attribute

+ {(item.current_dynamic_attributes || []).map((attr) => ( +

+ {attr.raw_name}: {String(attr.normalized_value ?? "")} {attr.unit || ""} +

+ ))} +
+
+

Historie

+ {history.isLoading ? : null} + {(history.data?.items || []).length === 0 ? Noch keine Beobachtungen. : null} + {(history.data?.items || []).map((obs) => ( +

+ {obs.observed_at}: {obs.best_price} {obs.currency} +

+ ))} +
+
+ ); +} diff --git a/frontend/src/pages/CandidateList.tsx b/frontend/src/pages/CandidateList.tsx new file mode 100644 index 0000000..6e8d525 --- /dev/null +++ b/frontend/src/pages/CandidateList.tsx @@ -0,0 +1,45 @@ +import { useQuery } from "@tanstack/react-query"; +import { Link } from "react-router-dom"; +import { api } from "../api"; +import { EmptyState, ErrorState, LoadingState } from "../components/State"; + +export function CandidateList({ projectId, shortlist = false }: { projectId: string; shortlist?: boolean }) { + const project = useQuery({ queryKey: ["project", projectId], queryFn: () => api.project(projectId) }); + const query = useQuery({ + queryKey: [shortlist ? "shortlist" : "candidates", projectId], + queryFn: () => (shortlist ? api.shortlist(projectId) : api.candidates(projectId)), + }); + if (query.isLoading || project.isLoading) return ; + if (query.isError) return ; + const items = query.data?.items || []; + if (shortlist && project.data?.ranking_status === "NOT_CONFIGURED") { + return Ranking ist noch nicht konfiguriert.; + } + if (!items.length) return Noch keine Kandidaten gefunden.; + return ( +
+ + + + + + + + + + + {items.map((item) => ( + + + + + + + ))} + +
TitelPreisDiffScore
+ {item.title || "Kandidat"} + {item.current_best_price ? `${item.current_best_price} ${item.currency || ""}` : "–"}{item.current_diff_status || "–"}{item.ranking_status === "NOT_CONFIGURED" ? "n/a" : item.product_score ?? "–"}
+
+ ); +} diff --git a/frontend/src/pages/ProjectDetailPage.tsx b/frontend/src/pages/ProjectDetailPage.tsx new file mode 100644 index 0000000..9476471 --- /dev/null +++ b/frontend/src/pages/ProjectDetailPage.tsx @@ -0,0 +1,127 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { NavLink, Route, Routes, useParams } from "react-router-dom"; +import { api } from "../api"; +import { EmptyState, ErrorState, LoadingState } from "../components/State"; +import { CandidateList } from "./CandidateList"; + +export function ProjectDetailPage() { + const { projectId = "" } = useParams(); + const project = useQuery({ queryKey: ["project", projectId], queryFn: () => api.project(projectId) }); + if (project.isLoading) return ; + if (project.isError) return ; + const data = project.data!; + return ( +
+

{data.name}

+

+ {data.product_category_id} · {data.market}/{data.currency} · Version {data.criteria_version} +

+
+ + Übersicht + + Kandidaten + Shortlist + Kriterien + Historie +
+ + } /> + } /> + } /> + } /> + } /> + +
+ ); +} + +function Overview({ projectId }: { projectId: string }) { + const searches = useQuery({ queryKey: ["searches", projectId], queryFn: () => api.searches(projectId) }); + const run = useMutation({ + mutationFn: async (searchId: string) => { + await api.generateQuery(searchId); + return api.runSearch(searchId); + }, + }); + if (searches.isLoading) return ; + const items = searches.data?.items || []; + if (!items.length) return Noch kein SearchRun ausgeführt.; + return ( +
+ {items.map((search) => ( +
+

Status: {search.status}

+ {search.status === "OUT_OF_SYNC" ?

Suchparameter wurden geändert. Die externe Suche muss aktualisiert werden.

: null} + +
+ ))} +
+ ); +} + +function CriteriaPanel({ projectId }: { projectId: string }) { + const client = useQueryClient(); + const project = useQuery({ queryKey: ["project", projectId], queryFn: () => api.project(projectId) }); + const save = useMutation({ + mutationFn: () => api.putCriteria(projectId, { items: project.data?.criteria?.items || [] }), + onSuccess: () => client.invalidateQueries({ queryKey: ["project", projectId] }), + }); + if (project.isLoading) return ; + const items = project.data?.criteria?.items || []; + return ( +
+ {items.map((item) => ( +

+ {item.criterion || item.criterion_definition_id} {item.operator} {String(item.value)} +

+ ))} + +
+ ); +} + +function HistoryPanel({ projectId }: { projectId: string }) { + const searches = useQuery({ queryKey: ["searches", projectId], queryFn: () => api.searches(projectId) }); + const searchId = searches.data?.items[0]?.id; + const runs = useQuery({ + queryKey: ["runs", searchId], + queryFn: () => api.runs(searchId!), + enabled: Boolean(searchId), + }); + if (!searchId) return Noch kein SearchRun ausgeführt.; + if (runs.isLoading) return ; + const items = runs.data?.items || []; + return ( +
+ + + + + + + + + + + + + {items.map((run) => ( + + + + + + + + + ))} + +
ZeitStatusTrefferNEWCHANGEDMISSING
{run.started_at}{run.status}{run.result_count}{run.new_count}{run.changed_count}{run.missing_count}
+
+ ); +} diff --git a/frontend/src/pages/ProjectListPage.tsx b/frontend/src/pages/ProjectListPage.tsx new file mode 100644 index 0000000..7c5a26c --- /dev/null +++ b/frontend/src/pages/ProjectListPage.tsx @@ -0,0 +1,53 @@ +import { useQuery } from "@tanstack/react-query"; +import { Link } from "react-router-dom"; +import { api } from "../api"; +import { EmptyState, ErrorState, LoadingState } from "../components/State"; + +export function ProjectListPage() { + const query = useQuery({ queryKey: ["projects"], queryFn: api.projects }); + if (query.isLoading) return ; + if (query.isError) return query.refetch()} />; + const items = query.data?.items || []; + return ( +
+
+

Projekte

+ + Neues Projekt + +
+ {items.length === 0 ? ( + Noch keine Projekte vorhanden. + ) : ( +
+ + + + + + + + + + + + + {items.map((item) => ( + + + + + + + + + ))} + +
NameKategorieStatusSucheKandidatenNeu
+ {item.name} + {item.product_category?.name || "–"}{item.status}{item.discovery_status || "–"}{item.candidate_count}{item.new_count}
+
+ )} +
+ ); +} diff --git a/frontend/src/pages/WizardPage.tsx b/frontend/src/pages/WizardPage.tsx new file mode 100644 index 0000000..5c46fa4 --- /dev/null +++ b/frontend/src/pages/WizardPage.tsx @@ -0,0 +1,185 @@ +import { useMutation, useQuery } from "@tanstack/react-query"; +import { useMemo, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { api, type AvailableCriterion } from "../api"; +import { CriteriaControl, pickSource } from "../components/CriteriaControl"; +import { EmptyState, ErrorState, LoadingState } from "../components/State"; + +type Draft = Record; + +export function WizardPage() { + const navigate = useNavigate(); + const categories = useQuery({ queryKey: ["product-categories"], queryFn: api.productCategories }); + const [name, setName] = useState(""); + const [categoryId, setCategoryId] = useState(""); + const [market, setMarket] = useState("DE"); + const [currency, setCurrency] = useState("EUR"); + const [budget, setBudget] = useState(""); + const [sourceOverride, setSourceOverride] = useState(null); + const [selected, setSelected] = useState([]); + const [draft, setDraft] = useState({}); + + const sources = useQuery({ + queryKey: ["category-sources", categoryId], + queryFn: () => api.categorySources(categoryId), + enabled: Boolean(categoryId), + }); + const source = sourceOverride || pickSource(sources.data?.items || []); + const criteria = useQuery({ + queryKey: ["available-criteria", categoryId, source], + queryFn: () => api.availableCriteria(categoryId, source!), + enabled: Boolean(categoryId && source), + }); + const create = useMutation({ + mutationFn: async () => { + if (!source || !sources.data) throw new Error("Keine Source verfügbar"); + const chosen = (criteria.data?.criteria || []).filter((item) => selected.includes(item.criterion_definition_id)); + const project = await api.createProject({ + name, + product_category_id: categoryId, + market, + currency, + target_budget: budget || null, + criteria: { + items: chosen.map((item) => ({ + criterion_definition_id: item.criterion_definition_id, + operator: draft[item.criterion_definition_id]?.operator || item.operators[0] || ">=", + value: coerce(item, draft[item.criterion_definition_id]?.value), + enabled_for_discovery: true, + })), + }, + }); + const sourceRow = sources.data.items.find((item) => item.source === source) || sources.data.items[0]; + await api.createSearch(project.id, { + source_id: sourceRow.source, + source_category_id: sourceRow.source_category_id, + }); + return project; + }, + onSuccess: (project) => navigate(`/projects/${project.id}`), + }); + + const sourceItems = sources.data?.items || []; + const needsPicker = sourceItems.filter((item) => item.status === "ACTIVE").length > 1; + + const visible = useMemo(() => criteria.data?.criteria || [], [criteria.data]); + + if (categories.isLoading) return ; + if (categories.isError) return ; + + return ( +
+

Neues Projekt

+
+ + +
+ + + +
+
+ + {categoryId && sources.isLoading ? : null} + {categoryId && !sources.isLoading && sourceItems.length === 0 ? ( + Keine Source für diese Produktkategorie verfügbar. + ) : null} + {needsPicker ? ( + + ) : source ? ( +

Quelle: {source}

+ ) : null} + + {criteria.isLoading ? : null} + {visible.length > 0 ? ( +
+ {visible.map((item) => { + const on = selected.includes(item.criterion_definition_id); + return ( +
+ + {on ? ( + ="} + value={draft[item.criterion_definition_id]?.value} + onChange={(next) => setDraft((current) => ({ ...current, [item.criterion_definition_id]: next }))} + /> + ) : null} +
+ ); + })} +
+ ) : null} + + {create.isError ? : null} + +
+ ); +} + +function coerce(criterion: AvailableCriterion, value: unknown) { + if (value == null || value === "") return value; + if ((criterion.filter_type || "").startsWith("NUMERIC")) return Number(value); + return value; +} diff --git a/frontend/src/styles.css b/frontend/src/styles.css new file mode 100644 index 0000000..ea3f133 --- /dev/null +++ b/frontend/src/styles.css @@ -0,0 +1,41 @@ +:root { + color-scheme: dark; + font-family: "Segoe UI", sans-serif; + background: #111318; + color: #e8eaed; +} +* { box-sizing: border-box; } +body { margin: 0; } +a { color: inherit; text-decoration: none; } +button, input, select { font: inherit; } +.app { min-height: 100vh; display: flex; } +.sidebar { width: 220px; padding: 20px 16px; border-right: 1px solid #2a2d36; display: flex; flex-direction: column; gap: 12px; } +.brand { font-weight: 700; letter-spacing: 0.04em; } +.nav a, .bottom-nav a { display: block; padding: 8px 10px; border-radius: 8px; color: #b8bcc6; } +.nav a.active, .bottom-nav a.active { background: #1d4ed8; color: white; } +.main { flex: 1; padding: 24px; } +.bottom-nav { display: none; } +h1 { font-size: 24px; margin: 0 0 16px; } +h2 { font-size: 18px; } +.card { background: #181b22; border: 1px solid #2a2d36; border-radius: 12px; padding: 16px; } +.row { display: flex; gap: 12px; flex-wrap: wrap; align-items: center; } +.grid { display: grid; gap: 12px; } +table { width: 100%; border-collapse: collapse; } +th, td { text-align: left; padding: 8px 6px; border-bottom: 1px solid #2a2d36; } +.muted { color: #9aa0ad; } +.danger { color: #f87171; } +.button { background: #1d4ed8; color: white; border: 0; border-radius: 8px; padding: 8px 14px; cursor: pointer; } +.button.secondary { background: #2a2d36; } +input, select { background: #111318; color: #e8eaed; border: 1px solid #3a3f4b; border-radius: 8px; padding: 8px; width: 100%; } +label { display: grid; gap: 6px; font-size: 14px; } +.tabs { display: flex; gap: 8px; margin-bottom: 16px; } +.tabs a { padding: 6px 10px; border-radius: 8px; background: #181b22; } +.tabs a.active { background: #1d4ed8; } +.state { padding: 24px; color: #9aa0ad; } +@media (max-width: 800px) { + .app { flex-direction: column; } + .sidebar { display: none; } + .bottom-nav { display: flex; position: sticky; bottom: 0; background: #181b22; border-top: 1px solid #2a2d36; } + .bottom-nav a { flex: 1; text-align: center; } + .main { padding: 16px 16px 72px; } +} diff --git a/frontend/src/test-setup.ts b/frontend/src/test-setup.ts new file mode 100644 index 0000000..f149f27 --- /dev/null +++ b/frontend/src/test-setup.ts @@ -0,0 +1 @@ +import "@testing-library/jest-dom/vitest"; diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json new file mode 100644 index 0000000..66d0c83 --- /dev/null +++ b/frontend/tsconfig.app.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "types": ["vitest/globals"] + }, + "include": ["src"] +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..82a8007 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,4 @@ +{ + "files": [], + "references": [{ "path": "./tsconfig.app.json" }] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..418f70e --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,46 @@ +/// +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import { VitePWA } from "vite-plugin-pwa"; + +export default defineConfig({ + plugins: [ + react(), + VitePWA({ + registerType: "autoUpdate", + manifest: { + name: "Buying Agent", + short_name: "BuyingAgent", + start_url: "/", + display: "standalone", + background_color: "#111318", + theme_color: "#111318", + icons: [ + { src: "/icon.svg", sizes: "any", type: "image/svg+xml", purpose: "any" }, + ], + }, + workbox: { + navigateFallback: "/offline.html", + runtimeCaching: [ + { + urlPattern: ({ request }) => request.mode === "navigate", + handler: "NetworkFirst", + }, + ], + }, + }), + ], + server: { + port: 5173, + proxy: { + "/api": "http://127.0.0.1:8000", + "/health": "http://127.0.0.1:8000", + }, + }, + test: { + environment: "jsdom", + globals: true, + setupFiles: "./src/test-setup.ts", + exclude: ["e2e/**", "node_modules/**"], + }, +}); diff --git a/scripts/ci-pytest.sh b/scripts/ci-pytest.sh index e2bde91..9f1ea58 100644 --- a/scripts/ci-pytest.sh +++ b/scripts/ci-pytest.sh @@ -3,13 +3,14 @@ set -e APP_DIR="${APP_DIR:-$HOME/docker/buyingagent-dev}" COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.dev-env.yml}" +BACKEND_DIR="${APP_DIR}/backend" echo "pytest uses Dev Postgres (${APP_DIR})" cd "$APP_DIR" i=1 while [ "$i" -le 60 ]; do - if [ -f "$APP_DIR/pyproject.toml" ] \ + if [ -f "$BACKEND_DIR/pyproject.toml" ] \ && grep -q "api-test:" "$COMPOSE_FILE" \ && docker compose -f "$COMPOSE_FILE" exec -T api true >/dev/null 2>&1; then echo "api and test profile ready (attempt $i)" @@ -18,7 +19,7 @@ while [ "$i" -le 60 ]; do if [ "$i" -eq 60 ]; then echo "timeout waiting for Dev checkout/api" docker compose -f "$COMPOSE_FILE" ps || true - ls -la "$APP_DIR" || true + ls -la "$APP_DIR" "$BACKEND_DIR" || true exit 1 fi i=$((i + 1)) @@ -44,18 +45,20 @@ docker compose -f "$COMPOSE_FILE" --profile test build --pull=false api-test docker compose -f "$COMPOSE_FILE" --profile test run --rm --no-deps \ -v "${APP_DIR}:/src:ro" \ - -w /src \ - -e PYTHONPATH=/src \ + -w /src/backend \ + -e PYTHONPATH=/src/backend \ -e DB_NAME=buyingagent_test \ -e ENVIRONMENT=test \ -e ENGINE_BUILD=test \ + -e CLOCK_NOW=2026-10-03T12:00:00Z \ api-test /app/.venv/bin/alembic upgrade head docker compose -f "$COMPOSE_FILE" --profile test run --rm --no-deps \ -v "${APP_DIR}:/src:ro" \ - -w /src \ - -e PYTHONPATH=/src \ + -w /src/backend \ + -e PYTHONPATH=/src/backend \ -e DB_NAME=buyingagent_test \ -e ENVIRONMENT=test \ -e ENGINE_BUILD=test \ - api-test /app/.venv/bin/pytest tests -ra --tb=short + -e CLOCK_NOW=2026-10-03T12:00:00Z \ + api-test /app/.venv/bin/pytest tests -m "not slow" -ra --tb=short