From c7e4d5918880306d7a315406310914cc96228209 Mon Sep 17 00:00:00 2001 From: sriram Date: Thu, 1 Oct 2026 12:17:42 +0530 Subject: [PATCH] Electronics Catalog: API, MCP server, frontend and deployment Verified catalogue of mobiles and laptops sold in India, collected from real retail listings (FastAPI backend, React frontend, Postgres/pgvector). - REST API under /api/elec (read-only catalogue; admin endpoints need login) - MCP server (FastMCP) at /mcp/ with list_categories, search_products, get_product and price_history tools - Real ratings and reviews read from product pages and search results - Production Dockerfile (requirements-api.txt, no PyTorch) and .env.production.example; remote database only via an explicit ELEC_ALLOW_REMOTE_DB host/name allowlist - docs/API.md: endpoint and MCP reference with live examples Co-Authored-By: Claude Opus 5.5 (1M context) --- .env.example | 2 + .gitignore | 12 + README.md | 152 ++ backend/.dockerignore | 14 + backend/.env.example | 45 + backend/.env.production.example | 43 + backend/Dockerfile | 33 + backend/app/__init__.py | 0 backend/app/api/__init__.py | 0 backend/app/api/background.py | 31 + backend/app/api/deps.py | 144 ++ backend/app/api/job_store.py | 59 + backend/app/api/routers/__init__.py | 0 backend/app/api/routers/auth.py | 357 +++ backend/app/api/routers/elec.py | 188 ++ backend/app/api/routers/elec_admin.py | 124 + backend/app/api/routers/health.py | 36 + backend/app/api/schemas.py | 73 + backend/app/electronics/__init__.py | 0 backend/app/electronics/cli.py | 261 ++ backend/app/electronics/collector.py | 581 +++++ backend/app/electronics/db/__init__.py | 0 backend/app/electronics/db/connection.py | 68 + backend/app/electronics/db/migrate.py | 63 + .../electronics/db/migrations/0001_schema.sql | 4 + .../db/migrations/0002_reference.sql | 57 + .../db/migrations/0003_observations.sql | 160 ++ .../electronics/db/migrations/0004_views.sql | 71 + .../db/migrations/0005_price_outliers.sql | 44 + .../db/migrations/0006_reviews_and_price.sql | 30 + backend/app/electronics/db/repository.py | 588 +++++ backend/app/electronics/extract/__init__.py | 0 .../app/electronics/extract/html_fallback.py | 123 + backend/app/electronics/extract/jsonld.py | 202 ++ .../app/electronics/extract/serp_parser.py | 207 ++ backend/app/electronics/match/__init__.py | 0 backend/app/electronics/match/matcher.py | 124 + backend/app/electronics/match/rematch.py | 105 + backend/app/electronics/models.py | 68 + backend/app/electronics/net/__init__.py | 0 backend/app/electronics/net/breaker.py | 61 + backend/app/electronics/net/polite_client.py | 223 ++ backend/app/electronics/normalise/__init__.py | 0 .../app/electronics/normalise/brand_alias.py | 86 + .../app/electronics/normalise/grounding.py | 55 + backend/app/electronics/normalise/llm_fill.py | 71 + .../electronics/normalise/spec_normaliser.py | 101 + .../app/electronics/normalise/title_parser.py | 374 +++ backend/app/electronics/price_lookup.py | 102 + backend/app/electronics/probe/__init__.py | 0 backend/app/electronics/probe/site_probe.py | 99 + backend/app/electronics/reference/__init__.py | 132 + backend/app/electronics/reference/brands.yaml | 100 + backend/app/electronics/reference/sites.yaml | 77 + .../app/electronics/reference/spec_keys.yaml | 127 + backend/app/electronics/reviews.py | 96 + backend/app/electronics/search/__init__.py | 0 backend/app/electronics/search/engine.py | 65 + backend/app/electronics/search/providers.py | 234 ++ backend/app/infrastructure/__init__.py | 0 backend/app/infrastructure/security.py | 427 ++++ backend/app/infrastructure/settings.py | 373 +++ backend/app/main.py | 133 + backend/app/mcp_server.py | 121 + backend/app/services/__init__.py | 0 backend/app/services/embeddings_service.py | 52 + backend/app/services/ollama_service.py | 171 ++ backend/data_diag_laptops.py | 26 + backend/data_wait_for_run.py | 14 + backend/pytest.ini | 16 + backend/requirements-api.txt | 35 + backend/requirements-dev.txt | 14 + backend/requirements.txt | 33 + backend/tests/conftest.py | 149 ++ backend/tests/test_api.py | 39 + backend/tests/test_auth.py | 405 +++ backend/tests/test_auth_diagnostics.py | 329 +++ backend/tests/test_cors_origins.py | 129 + backend/tests/test_elec_database.py | 239 ++ backend/tests/test_elec_extract_and_net.py | 308 +++ backend/tests/test_elec_parsers.py | 330 +++ backend/tests/test_elec_reviews.py | 144 ++ backend/tests/test_mcp.py | 93 + backend/tests/test_ollama_reachability.py | 108 + docker-compose.yml | 33 + docs/API.md | 252 ++ ...talog — End_end_Project Documentation.docx | Bin 0 -> 197219 bytes frontend/.env.development | 2 + frontend/.env.example | 2 + frontend/.oxlintrc.json | 8 + frontend/index.html | 22 + frontend/package-lock.json | 2176 +++++++++++++++++ frontend/package.json | 29 + frontend/public/favicon.svg | 13 + frontend/public/icons.svg | 24 + frontend/src/App.jsx | 98 + frontend/src/api/client.js | 130 + frontend/src/assets/vite.svg | 1 + frontend/src/components/Atoms.jsx | 19 + frontend/src/components/BrandMark.jsx | 21 + frontend/src/components/ErrorBoundary.jsx | 48 + frontend/src/components/NavigationHeader.jsx | 132 + frontend/src/components/ProductCard.jsx | 60 + frontend/src/components/ProductModal.jsx | 313 +++ frontend/src/context/AuthContext.jsx | 119 + frontend/src/context/useAuth.js | 20 + frontend/src/index.css | 88 + frontend/src/lib/format.js | 38 + frontend/src/main.jsx | 10 + frontend/src/pages/AdminPage.jsx | 213 ++ frontend/src/pages/HomePage.jsx | 155 ++ frontend/src/pages/LoginPage.jsx | 215 ++ frontend/vite.config.js | 30 + run_project.py | 279 +++ start_app.bat | 19 + 115 files changed, 14329 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 backend/.dockerignore create mode 100644 backend/.env.example create mode 100644 backend/.env.production.example create mode 100644 backend/Dockerfile create mode 100644 backend/app/__init__.py create mode 100644 backend/app/api/__init__.py create mode 100644 backend/app/api/background.py create mode 100644 backend/app/api/deps.py create mode 100644 backend/app/api/job_store.py create mode 100644 backend/app/api/routers/__init__.py create mode 100644 backend/app/api/routers/auth.py create mode 100644 backend/app/api/routers/elec.py create mode 100644 backend/app/api/routers/elec_admin.py create mode 100644 backend/app/api/routers/health.py create mode 100644 backend/app/api/schemas.py create mode 100644 backend/app/electronics/__init__.py create mode 100644 backend/app/electronics/cli.py create mode 100644 backend/app/electronics/collector.py create mode 100644 backend/app/electronics/db/__init__.py create mode 100644 backend/app/electronics/db/connection.py create mode 100644 backend/app/electronics/db/migrate.py create mode 100644 backend/app/electronics/db/migrations/0001_schema.sql create mode 100644 backend/app/electronics/db/migrations/0002_reference.sql create mode 100644 backend/app/electronics/db/migrations/0003_observations.sql create mode 100644 backend/app/electronics/db/migrations/0004_views.sql create mode 100644 backend/app/electronics/db/migrations/0005_price_outliers.sql create mode 100644 backend/app/electronics/db/migrations/0006_reviews_and_price.sql create mode 100644 backend/app/electronics/db/repository.py create mode 100644 backend/app/electronics/extract/__init__.py create mode 100644 backend/app/electronics/extract/html_fallback.py create mode 100644 backend/app/electronics/extract/jsonld.py create mode 100644 backend/app/electronics/extract/serp_parser.py create mode 100644 backend/app/electronics/match/__init__.py create mode 100644 backend/app/electronics/match/matcher.py create mode 100644 backend/app/electronics/match/rematch.py create mode 100644 backend/app/electronics/models.py create mode 100644 backend/app/electronics/net/__init__.py create mode 100644 backend/app/electronics/net/breaker.py create mode 100644 backend/app/electronics/net/polite_client.py create mode 100644 backend/app/electronics/normalise/__init__.py create mode 100644 backend/app/electronics/normalise/brand_alias.py create mode 100644 backend/app/electronics/normalise/grounding.py create mode 100644 backend/app/electronics/normalise/llm_fill.py create mode 100644 backend/app/electronics/normalise/spec_normaliser.py create mode 100644 backend/app/electronics/normalise/title_parser.py create mode 100644 backend/app/electronics/price_lookup.py create mode 100644 backend/app/electronics/probe/__init__.py create mode 100644 backend/app/electronics/probe/site_probe.py create mode 100644 backend/app/electronics/reference/__init__.py create mode 100644 backend/app/electronics/reference/brands.yaml create mode 100644 backend/app/electronics/reference/sites.yaml create mode 100644 backend/app/electronics/reference/spec_keys.yaml create mode 100644 backend/app/electronics/reviews.py create mode 100644 backend/app/electronics/search/__init__.py create mode 100644 backend/app/electronics/search/engine.py create mode 100644 backend/app/electronics/search/providers.py create mode 100644 backend/app/infrastructure/__init__.py create mode 100644 backend/app/infrastructure/security.py create mode 100644 backend/app/infrastructure/settings.py create mode 100644 backend/app/main.py create mode 100644 backend/app/mcp_server.py create mode 100644 backend/app/services/__init__.py create mode 100644 backend/app/services/embeddings_service.py create mode 100644 backend/app/services/ollama_service.py create mode 100644 backend/data_diag_laptops.py create mode 100644 backend/data_wait_for_run.py create mode 100644 backend/pytest.ini create mode 100644 backend/requirements-api.txt create mode 100644 backend/requirements-dev.txt create mode 100644 backend/requirements.txt create mode 100644 backend/tests/conftest.py create mode 100644 backend/tests/test_api.py create mode 100644 backend/tests/test_auth.py create mode 100644 backend/tests/test_auth_diagnostics.py create mode 100644 backend/tests/test_cors_origins.py create mode 100644 backend/tests/test_elec_database.py create mode 100644 backend/tests/test_elec_extract_and_net.py create mode 100644 backend/tests/test_elec_parsers.py create mode 100644 backend/tests/test_elec_reviews.py create mode 100644 backend/tests/test_mcp.py create mode 100644 backend/tests/test_ollama_reachability.py create mode 100644 docker-compose.yml create mode 100644 docs/API.md create mode 100644 docs/Electronics Catalog — End_end_Project Documentation.docx create mode 100644 frontend/.env.development create mode 100644 frontend/.env.example create mode 100644 frontend/.oxlintrc.json create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/public/favicon.svg create mode 100644 frontend/public/icons.svg create mode 100644 frontend/src/App.jsx create mode 100644 frontend/src/api/client.js create mode 100644 frontend/src/assets/vite.svg create mode 100644 frontend/src/components/Atoms.jsx create mode 100644 frontend/src/components/BrandMark.jsx create mode 100644 frontend/src/components/ErrorBoundary.jsx create mode 100644 frontend/src/components/NavigationHeader.jsx create mode 100644 frontend/src/components/ProductCard.jsx create mode 100644 frontend/src/components/ProductModal.jsx create mode 100644 frontend/src/context/AuthContext.jsx create mode 100644 frontend/src/context/useAuth.js create mode 100644 frontend/src/index.css create mode 100644 frontend/src/lib/format.js create mode 100644 frontend/src/main.jsx create mode 100644 frontend/src/pages/AdminPage.jsx create mode 100644 frontend/src/pages/HomePage.jsx create mode 100644 frontend/src/pages/LoginPage.jsx create mode 100644 frontend/vite.config.js create mode 100644 run_project.py create mode 100644 start_app.bat diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5094fb0 --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +# Root .env - read by docker compose only. Copy to .env and set a password. +POSTGRES_PASSWORD=change-me-local-only diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ff55c11 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +# Electronics Catalog. Secrets (.env, .env.production) and build output never go into git. +.env +.env.production +__pycache__/ +*.pyc +*.log +.venv/ +backend/data/ +frontend/node_modules/ +frontend/dist/ +.DS_Store +Thumbs.db diff --git a/README.md b/README.md new file mode 100644 index 0000000..3051908 --- /dev/null +++ b/README.md @@ -0,0 +1,152 @@ +# Electronics Catalog (local) + +A local catalogue of **real** electronics products sold in India, with a Tamil Nadu focus: +the products, their prices, images and the e-commerce platforms that list them. It is a +converted copy of `Project_Deploy`, which stays untouched. It runs only on this machine +and is not pushed anywhere. + +## How data is collected (search-first) + +1. **Discover**: web search (DuckDuckGo via `ddgs`; Google Programmable Search too when a + key is set) runs `site: ` on every registered + platform. Only single-product URLs are kept. +2. **Gate each platform** (`probe`), grading it A, B or C: + - **A**: robots.txt allows the page, it returns HTTP 200 with no bot check, and it + carries schema.org Product JSON-LD with an INR price. The product page is read. + - **B**: fetchable, but only the page HTML/meta is readable. + - **C**: blocked, CAPTCHA, robots.txt disallows, or no data without JavaScript. + **Web search results only**, with nothing fetched from the site. + - Amazon.in and Flipkart are `serp_only` by policy and are never fetched directly. +3. **Collect**: + - A/B pages are read politely: robots.txt obeyed, an honest User-Agent, 3 s between + requests to a site, and a circuit breaker on any 403/429/CAPTCHA. + - C platforms contribute what their search result shows: the title (with variant) and + URL, plus a price or stock status only when the snippet or the search engine's own + structured data states it. +4. **Match** each listing to one canonical variant (brand + model + RAM + storage; MPN for + laptops). A different model number is never merged. Uncertain matches go to a review + queue. +5. **Verify**: a product is shown only when listings on **at least two platforms** (at + least one a retailer) confirm it. + +**Anti-fabrication rules:** +- Every listing and price row stores its source URL and the text the value was read from. +- Prices come only from parsers, never from the LLM. EMI, "₹X off", exchange and bank-offer + amounts are rejected. +- The local LLM (qwen2.5:1.5b) only fills missing *spec* fields from fetched text. Every + value it returns must appear in that text, or it is dropped. +- Images come only from a product's own listings and are checked live. Only URLs are + stored. +- Unknown stays unknown: `in_stock` is NULL and the price is "not stated". +- `pincode_applied` is true only if a site actually accepted the pincode. Currently none + does, so prices are national listing prices. + +## Platforms + +| Platform | Region | Mode (as probed on 29 Sep 2026) | +|---|---|---| +| Amazon.in, Flipkart | national | C: search only (policy) | +| Croma | national | C: blocked (HTTP 403), search only | +| Tata CLiQ | national | C: bot-check page, search only | +| Reliance Digital, Vijay Sales | national | A: product pages read | +| Poorvika, Vasanth & Co | Tamil Nadu | A: product pages read | +| Sangeetha Mobiles | Tamil Nadu | C: no data without JavaScript, search only | +| Viveks | Tamil Nadu | C: few product pages indexed | +| Brand official sites | - | probed per brand | + +Grades are re-checked every 7 days (`probe`). A tripped breaker pauses a site for 24 h. + +## Setup (once) + +```powershell +# 1. Local database (container elec_catalog_pg, 127.0.0.1:5433, DB electronics_catalog) +copy .env.example .env # set POSTGRES_PASSWORD +docker compose up -d + +# 2. Backend +cd backend +copy .env.example .env # same DB_PASSWORD; set ELEC_CONTACT to a real email +py -3.13 -m venv .venv +.venv\Scripts\pip install torch --index-url https://download.pytorch.org/whl/cpu +.venv\Scripts\pip install -r requirements.txt -r requirements-dev.txt +.venv\Scripts\python -m app.electronics.cli migrate +.venv\Scripts\python -m app.electronics.cli seed-reference + +# 3. Frontend +cd ..\frontend +npm install +``` + +## Run + +```powershell +start_app.bat # database + API (127.0.0.1:8000) + UI (http://localhost:5173) +``` + +`AUTH_ALLOW_ANY_LOGIN=true` in `backend/.env` accepts any password for user `admin`. That +is fine locally because the API binds to 127.0.0.1. + +## Collect data (CLI, from `backend/`) + +```powershell +.venv\Scripts\python -m app.electronics.cli probe # grade platforms A/B/C +.venv\Scripts\python -m app.electronics.cli collect --category mobiles --brand samsung --brand xiaomi --limit 10 +.venv\Scripts\python -m app.electronics.cli collect --category laptops --brand hp --brand lenovo --limit 10 +.venv\Scripts\python -m app.electronics.cli report # what is in the catalogue +.venv\Scripts\python -m app.electronics.cli review # uncertain matches +.venv\Scripts\python -m app.electronics.cli verify-grounding # audit: every price has evidence +``` + +Useful flags: +- `--no-fetch`: search results only. +- `--no-llm`: fully deterministic. +- `--budget N`: cap on search queries. +- `--reprobe`: re-grade the sites. + +The same run can be started from the Admin page. + +**Prices for Amazon, Flipkart and Croma.** Free search snippets almost never show a price +for these platforms, so their listings usually record availability only. To get their +prices without ever fetching them, set `GOOGLE_API_KEY` and `GOOGLE_CSE_ID` in +`backend/.env` (Google Programmable Search, 100 free queries a day). The Google Cloud +project behind the key must have the **Custom Search API** enabled. Then run: + +```powershell +.venv\Scripts\python -m app.electronics.cli prices --limit 40 +``` + +- Google queries are kept for this price pass. Discovery uses DuckDuckGo and falls back to + Google only when DuckDuckGo gives no answer. +- A price is taken only from Google's structured offer data for the **same product page** + (same site product ID), and it is stored with that data as evidence. +- A rejected key switches Google off for the rest of the run and reports why. + +## Tests + +```powershell +cd backend +.venv\Scripts\python -m pytest -q +``` + +The tests need no network and never touch real data. The database tests use a separate +`electronics_catalog_test` database on the same local server, and they are skipped if the +container is down. + +## Layout + +``` +docker-compose.yml local Postgres + pgvector only +backend/app/electronics/ + reference/*.yaml brand allow-list + aliases, platforms, spec dictionary + db/migrations/*.sql schema `elec` (tables + views), applied by `cli migrate` + net/ polite HTTP client, circuit breaker + search/ DuckDuckGo / Google CSE providers, cache + budget + probe/ A/B/C platform grading + extract/ JSON-LD, HTML meta/spec tables, search-snippet prices + normalise/ brand aliases, title parser, spec units, grounding, LLM gap-fill + match/ listing -> canonical variant + collector.py the pipeline + cli.py command line +backend/app/api/routers/elec*.py read API + admin API +frontend/src catalogue UI (category -> brand -> product -> platforms) +``` diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..1f46cfb --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,14 @@ +# Secrets never go into the image - settings arrive via the container env. +.env +.env.* +!.env.production.example + +.venv/ +__pycache__/ +**/__pycache__/ +*.pyc +*.log +data/ +tests/ +.pytest_cache/ +data_*.py diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..5e9fa90 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,45 @@ +# Electronics Catalog backend - LOCAL ONLY. Copy to backend/.env. +# settings.py refuses to start if DB_HOST is not local or DB_NAME is not +# electronics_catalog. + +# --- Database (docker-compose.yml at the repo root) --- +DB_HOST=127.0.0.1 +DB_PORT=5433 +DB_NAME=electronics_catalog +DB_USER=postgres +DB_PASSWORD=change-me-local-only + +# --- Local LLM (spec gap-filling from fetched text only) --- +USE_OLLAMA=true +OLLAMA_BASE_URL=http://localhost:11434 +OLLAMA_MODEL_NAME=qwen2.5:1.5b +ELEC_USE_LLM=true + +# --- Embeddings --- +USE_EMBEDDINGS=true +EMBEDDINGS_MODEL=sentence-transformers/all-MiniLM-L6-v2 + +# --- Web search --- +USE_DDG_SEARCH=true +# Optional Google Programmable Search (both required to enable it) +GOOGLE_API_KEY= +GOOGLE_CSE_ID= +SEARCH_REGION=in-en + +# --- Polite fetching --- +# A real contact address for the User-Agent header. +ELEC_CONTACT=admin@example.com +ELEC_SITE_MIN_INTERVAL_SECONDS=3 +ELEC_REFERENCE_PINCODES=641001:Coimbatore,600001:Chennai + +# --- API --- +API_CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173 + +# --- Auth (local dev) --- +# Generate real values with: python -c "import secrets;print(secrets.token_urlsafe(48))" +AUTH_ENABLED=true +AUTH_SECRET_KEY= +AUTH_ADMIN_USERNAME=admin +AUTH_ADMIN_PASSWORD_HASH= +# true = any password is accepted locally (the username still selects the role) +AUTH_ALLOW_ANY_LOGIN=true diff --git a/backend/.env.production.example b/backend/.env.production.example new file mode 100644 index 0000000..104257e --- /dev/null +++ b/backend/.env.production.example @@ -0,0 +1,43 @@ +# Production environment for the Electronics Catalog API container. +# Copy to .env.production (never committed, never baked into the image) or paste +# each line into the deployment platform's Environment settings. + +# --- Database: the production copy in loyalycatalogue (schema elec) --- +DB_HOST=31.97.228.132 +DB_PORT=6054 +DB_NAME=loyalycatalogue +DB_USER=admin +# The server is remote: allow more than the 5 s local default to connect. +DB_CONNECT_TIMEOUT_SECONDS=15 +# The single quotes ARE part of this password. Container env values are taken +# literally (docker --env-file and platform Environment tabs do not strip +# quotes), so write it exactly as the password, quotes included: +DB_PASSWORD='' +# Explicit opt-in past the local-only guard in settings.py. Exact host and +# database only; any other remote target is still refused. +ELEC_ALLOW_REMOTE_DB=true +ELEC_REMOTE_DB_HOSTS=31.97.228.132 +ELEC_REMOTE_DB_NAMES=loyalycatalogue + +# --- Features not available in the container --- +USE_OLLAMA=false +ELEC_USE_LLM=false +USE_EMBEDDINGS=false + +# --- Web search (only used by admin collection runs) --- +USE_DDG_SEARCH=true +USE_GOOGLE_CSE=false +ELEC_CONTACT= + +# --- Browser access: every first-party web app that calls this API, exact +# origins only (no "*"). Pinned by tests/test_cors_origins.py; add a new app +# there and here together. Non-browser callers (curl, MCP clients) ignore CORS. --- +API_CORS_ORIGINS=https://app.nearledaily.com,http://localhost:3100,https://catalogue.nearle.ai.in + +# --- Auth: guards the admin endpoints. Never deploy with AUTH_ENABLED=false +# or AUTH_ALLOW_ANY_LOGIN=true. --- +AUTH_ENABLED=true +AUTH_ALLOW_ANY_LOGIN=false +AUTH_SECRET_KEY= +AUTH_ADMIN_USERNAME=admin +AUTH_ADMIN_PASSWORD_HASH= diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..533055a --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,33 @@ +# Electronics Catalog API - production image. +# +# docker build -t electronics-catalog-api backend/ +# docker run -d --name electronics-catalog-api -p 8000:8000 \ +# --env-file backend/.env.production --restart unless-stopped electronics-catalog-api +# +# No secrets are baked in: .env / .env.production are excluded by .dockerignore +# and every setting arrives through the container environment (the platform's +# Environment tab, or --env-file). See .env.production.example for the list. +FROM python:3.13-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 + +WORKDIR /app + +COPY requirements-api.txt . +RUN pip install -r requirements-api.txt + +COPY app ./app + +# Run as an unprivileged user; the app writes nothing to disk. +RUN useradd --create-home --uid 10001 appuser +USER appuser + +EXPOSE 8000 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/api/health', timeout=4).status == 200 else 1)" + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--proxy-headers", "--forwarded-allow-ips", "*"] 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/background.py b/backend/app/api/background.py new file mode 100644 index 0000000..962612b --- /dev/null +++ b/backend/app/api/background.py @@ -0,0 +1,31 @@ +""" +Minimal in-process background job dispatcher for long-running admin jobs +(catalog ingestion, store seeding, ML model training, nutrition +enrichment). + +This deliberately does NOT use Starlette's `BackgroundTasks`. BackgroundTasks +run *synchronously after the response is sent*: an async background task is +awaited directly on the server's event loop, and a sync one is awaited in the +request's thread. Either way the request handler does not return until the job +finishes. For jobs that take minutes (LLM calls, web scraping, ML training, +Open Food Facts lookups), that turns a "kick off a job and return 202" endpoint +into a blocking call and, for async tasks, freezes the whole API event loop for +the duration. + +A daemon thread returns control to the caller immediately, and the job's +progress stays visible via the job_store polling endpoints the UI already +uses. Daemon threads are a deliberate, documented trade-off (see +`app/api/job_store.py`): state is process-local and not safe across multiple +uvicorn workers - fine for this project's intended single-process, CPU-only +deployment. +""" +from __future__ import annotations + +import threading +from typing import Any, Callable + + +def run_in_background(func: Callable[[], Any], *, name: str) -> None: + """Start `func` on a new daemon thread and return immediately.""" + thread = threading.Thread(target=func, name=name, daemon=True) + thread.start() diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py new file mode 100644 index 0000000..1383dec --- /dev/null +++ b/backend/app/api/deps.py @@ -0,0 +1,144 @@ +""" +Request-scoped authentication dependencies. + +Guards are attached per route, not as middleware matching on paths. Two +reasons that matters here: + + * A path-matching middleware silently stops guarding a route the moment + somebody renames it. A ``Depends`` on the route function cannot drift out + of sync with the route it protects. + * FastAPI reflects these into the OpenAPI schema, so ``/docs`` shows which + operations need a credential instead of implying everything is open. + +The guard therefore holds regardless of which host the request arrives on - +through the frontend's nginx on ``{$DOMAIN}``, or directly on ``api.{$DOMAIN}``. + +Usage:: + + @router.post("/thing", dependencies=[Depends(require_admin)]) + def create_thing(): ... + + @router.post("/other", dependencies=[Depends(require_permission("add_product"))]) + def other_thing(): ... + + @router.post("/who", ...) + def who(principal: Principal = Depends(get_principal)): ... +""" +from __future__ import annotations + +from typing import Callable, Optional + +from fastapi import Depends, HTTPException, status +from fastapi.security import APIKeyHeader, HTTPAuthorizationCredentials, HTTPBearer + +from app.infrastructure.security import ( + AuthError, + Principal, + anonymous_principal, + decode_access_token, + principal_for_api_key, +) +from app.infrastructure.settings import AUTH_ENABLED + +# auto_error=False on both: with two accepted credential types, letting either +# scheme raise on its own would reject a request that carried the *other* one. +# get_principal decides, once it has seen both. +_bearer_scheme = HTTPBearer(auto_error=False, description="Access token from POST /api/auth/login") +_api_key_scheme = APIKeyHeader( + name="X-API-Key", + auto_error=False, + description="Static key for machine consumers (see API_KEYS)", +) + +_UNAUTHENTICATED = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Not authenticated. Send a bearer token from POST /api/auth/login, or an X-API-Key header.", + headers={"WWW-Authenticate": "Bearer"}, +) + + +def get_principal( + credentials: Optional[HTTPAuthorizationCredentials] = Depends(_bearer_scheme), + api_key: Optional[str] = Depends(_api_key_scheme), +) -> Principal: + """Resolve the caller, or raise 401. Use this to require *any* valid credential.""" + if not AUTH_ENABLED: + return anonymous_principal() + + if credentials is not None and credentials.credentials: + try: + return decode_access_token(credentials.credentials) + except AuthError as exc: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=str(exc), + headers={"WWW-Authenticate": "Bearer"}, + ) from exc + + if api_key: + try: + return principal_for_api_key(api_key) + except AuthError as exc: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc) + ) from exc + + raise _UNAUTHENTICATED + + +def get_optional_principal( + credentials: Optional[HTTPAuthorizationCredentials] = Depends(_bearer_scheme), + api_key: Optional[str] = Depends(_api_key_scheme), +) -> Optional[Principal]: + """ + Resolve the caller if they presented a valid credential, else None. + + For endpoints that are public but behave differently when signed in. A + credential that is present but *invalid* still raises - failing open there + would mean a typo'd token silently downgrades to anonymous access. + """ + if not AUTH_ENABLED: + return anonymous_principal() + if credentials is None and not api_key: + return None + return get_principal(credentials, api_key) + + +def require_role(*roles: str) -> Callable[[Principal], Principal]: + """Require the caller to hold one of ``roles``.""" + allowed = frozenset(roles) + + def _dependency(principal: Principal = Depends(get_principal)) -> Principal: + if principal.role not in allowed: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=( + f"This operation requires the {' or '.join(sorted(allowed))} role; " + f"you are signed in as '{principal.role}'." + ), + ) + return principal + + return _dependency + + +def require_permission(permission: str) -> Callable[[Principal], Principal]: + """ + Require a specific permission. ``admin`` passes every check - see + ``Principal.has_permission``. + """ + + def _dependency(principal: Principal = Depends(get_principal)) -> Principal: + if not principal.has_permission(permission): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"This operation requires the '{permission}' permission.", + ) + return principal + + return _dependency + + +# The two guards used most often, named so route decorators stay readable. +require_admin = require_role("admin") +require_authenticated = get_principal diff --git a/backend/app/api/job_store.py b/backend/app/api/job_store.py new file mode 100644 index 0000000..138fd67 --- /dev/null +++ b/backend/app/api/job_store.py @@ -0,0 +1,59 @@ +""" +Tiny in-memory job tracker for background catalog-generation tasks. + +Deliberately not a queue/Celery/Redis setup - the original project already +had celery+redis in requirements.txt but nothing wired it up, and adding a +broker is unnecessary operational weight for a single-developer, CPU-only +project. A process-local dict is enough to let the React UI show +"running -> done/failed" status for a brand ingestion job started from the +admin panel. + +NOTE: state is lost on server restart, and is per-process (not safe for +multiple uvicorn workers). For this project's intended scale (one backend +process on a personal machine) that's a fine trade-off; see the docs' +"Scaling beyond a single machine" section if this ever needs to change. +""" +from __future__ import annotations + +import threading +import time +import uuid +from dataclasses import dataclass, field +from typing import Dict, Optional + + +@dataclass +class Job: + job_id: str + brand: str + status: str = "pending" # pending -> running -> done | failed + detail: Optional[str] = None + created_at: float = field(default_factory=time.time) + updated_at: float = field(default_factory=time.time) + + +class JobStore: + def __init__(self) -> None: + self._jobs: Dict[str, Job] = {} + self._lock = threading.Lock() + + def create(self, brand: str) -> Job: + job = Job(job_id=str(uuid.uuid4()), brand=brand) + with self._lock: + self._jobs[job.job_id] = job + return job + + def update(self, job_id: str, status: str, detail: Optional[str] = None) -> None: + with self._lock: + job = self._jobs.get(job_id) + if job: + job.status = status + job.detail = detail + job.updated_at = time.time() + + def get(self, job_id: str) -> Optional[Job]: + with self._lock: + return self._jobs.get(job_id) + + +job_store = JobStore() diff --git a/backend/app/api/routers/__init__.py b/backend/app/api/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/routers/auth.py b/backend/app/api/routers/auth.py new file mode 100644 index 0000000..53dc39d --- /dev/null +++ b/backend/app/api/routers/auth.py @@ -0,0 +1,357 @@ +""" +Authentication router - issues and inspects access tokens. + +This replaces an earlier version that returned a role profile without issuing +anything, accepted an empty password, and granted `admin` to any username that +asked for the role. It decided which buttons the UI drew; it protected nothing. +Now the token this returns is the credential every write endpoint checks (see +app/api/deps.py), so the rules hold for curl and partner scripts too, not just +for the React app. + +Accounts come from the environment - two of them, admin and user, configured as +PBKDF2 digests. That is deliberately not a user database: this project has no +user table, no registration flow and no password reset, and inventing one here +would be a bigger change than the problem calls for. Machine consumers get +API_KEYS instead. If per-user accounts become a real requirement, this module +is the seam to replace. + +For local work there is AUTH_ALLOW_ANY_LOGIN, which skips the password check +here and nowhere else - the token still gets signed and every guard downstream +still checks it. It is off by default and logs a warning at startup when on. +""" +from __future__ import annotations + +import logging +import threading +import time +from typing import Dict, List, Tuple + +from fastapi import APIRouter, Depends, HTTPException, Request, status +from pydantic import BaseModel, Field + +from app.api.deps import get_principal +from app.infrastructure.security import ( + ROLE_PERMISSIONS, + Principal, + create_access_token, + hash_is_wellformed, + password_hash_fingerprint, + verify_password, +) +from app.infrastructure.settings import ( + AUTH_ADMIN_PASSWORD_HASH, + AUTH_ADMIN_USERNAME, + AUTH_ALLOW_ANY_LOGIN, + AUTH_ENABLED, + AUTH_LOCKOUT_SECONDS, + AUTH_MAX_LOGIN_ATTEMPTS, + AUTH_USER_PASSWORD_HASH, + AUTH_USER_USERNAME, + config_source, +) + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/auth", tags=["auth"]) + +if AUTH_ENABLED and AUTH_ALLOW_ANY_LOGIN: + logger.warning( + "AUTH_ALLOW_ANY_LOGIN=true: /api/auth/login accepts ANY password, so anyone " + "who can reach this port can sign in as admin. Local development only - " + "set it to false in backend/.env before exposing this server." + ) + + +class LoginRequest(BaseModel): + username: str = Field(min_length=1, max_length=150) + password: str = Field(min_length=1, max_length=1024) + + +class UserProfile(BaseModel): + username: str + role: str + display_name: str + email: str + permissions: List[str] = Field(default_factory=list) + + +class LoginResponse(BaseModel): + access_token: str + token_type: str = "bearer" + expires_in: int = Field(description="Token lifetime in seconds") + user: UserProfile + + +# A syntactically valid hash of an unguessable value. Never matches any real +# password; it exists only so the unknown-username path in login() does the +# same PBKDF2 work as the known one, keeping the two indistinguishable by timing. +_DUMMY_HASH = ( + "pbkdf2_sha256$600000$YWJjZGVmZ2hpamtsbW5vcA==$" + "S1cVFrGD4pDkGqSjbEbaVSTONzGhCT9BOaWPQ2vwvvA=" +) + + +def _accounts() -> Dict[str, dict]: + """ + The configured accounts, read per call so a settings reload is picked up. + + Usernames are compared case-insensitively (matching what the login form + sends), but the password is not touched - the previous version lowercased + it before comparing, which silently shrank the effective keyspace. + + An account with a blank password hash is omitted entirely rather than + included with an unmatchable digest. Both spellings deny the login, but + only omission keeps it out of the account table, so nothing downstream can + treat it as a real account. This is how the optional `user` account is + switched off: leave AUTH_USER_PASSWORD_HASH unset and only `admin` exists. + """ + accounts = { + AUTH_ADMIN_USERNAME.lower(): { + "password_hash": AUTH_ADMIN_PASSWORD_HASH, + "role": "admin", + "display_name": "System Administrator", + "email": "admin@nutritionintel.com", + }, + } + + if AUTH_USER_PASSWORD_HASH: + accounts[AUTH_USER_USERNAME.lower()] = { + "password_hash": AUTH_USER_PASSWORD_HASH, + "role": "user", + "display_name": "Product & Store Manager", + "email": "user@nutritionintel.com", + } + + return accounts + + +# --------------------------------------------------------------------------- +# Failed-login throttle +# --------------------------------------------------------------------------- +# In-process and per-worker: with several uvicorn workers a determined attacker +# gets AUTH_MAX_LOGIN_ATTEMPTS per worker, not overall. That is a real limit, +# not a rounding error - but it still turns an unbounded password oracle into a +# rate-limited one without adding Redis to the deployment. Move this to a shared +# store if you ever run many workers. +_failures: Dict[Tuple[str, str], Tuple[int, float]] = {} +_failures_lock = threading.Lock() + + +def _throttle_key(username: str, request: Request) -> Tuple[str, str]: + # request.client.host is the real client IP because uvicorn runs with + # --proxy-headers behind nginx/Caddy (see backend/Dockerfile); without that + # every request would appear to come from the proxy and share one bucket. + client = request.client.host if request.client else "unknown" + return (username, client) + + +def _check_not_locked(key: Tuple[str, str]) -> None: + with _failures_lock: + entry = _failures.get(key) + if entry is None: + return + count, first_seen = entry + if time.time() - first_seen > AUTH_LOCKOUT_SECONDS: + del _failures[key] + return + if count >= AUTH_MAX_LOGIN_ATTEMPTS: + retry_after = int(AUTH_LOCKOUT_SECONDS - (time.time() - first_seen)) + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail=f"Too many failed sign-in attempts. Try again in {retry_after}s.", + headers={"Retry-After": str(max(retry_after, 1))}, + ) + + +def _record_failure(key: Tuple[str, str]) -> None: + now = time.time() + with _failures_lock: + count, first_seen = _failures.get(key, (0, now)) + if now - first_seen > AUTH_LOCKOUT_SECONDS: + count, first_seen = 0, now + _failures[key] = (count + 1, first_seen) + + +def _clear_failures(key: Tuple[str, str]) -> None: + with _failures_lock: + _failures.pop(key, None) + + +# --------------------------------------------------------------------------- +# Routes +# --------------------------------------------------------------------------- +@router.post("/login", response_model=LoginResponse) +def login(payload: LoginRequest, request: Request) -> LoginResponse: + """Exchange a username and password for an access token.""" + if not AUTH_ENABLED: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=( + "Authentication is disabled on this server (AUTH_ENABLED=false), so no " + "token can be issued. Every endpoint is open; sign-in is not required." + ), + ) + + username = payload.username.strip().lower() + key = _throttle_key(username, request) + + if AUTH_ALLOW_ANY_LOGIN: + # Dev bypass: any password gets in. The username still picks the + # account, so `admin` lands on the admin pages and `user` on the user + # ones; anything else is an unconfigured name and gets the lower of the + # two roles rather than silently minting an admin. Throttling is skipped + # because there is no longer a password to guess. + account = _accounts().get(username) or { + "role": "user", + "display_name": payload.username.strip() or username, + "email": f"{username}@nutritionintel.com", + } + logger.warning( + "AUTH_ALLOW_ANY_LOGIN: signing in %r as %s without checking the password", + username, + account["role"], + ) + else: + _check_not_locked(key) + + account = _accounts().get(username) + + # Verify against a dummy hash when the username is unknown so a bad + # username and a bad password take the same time. Otherwise the response + # latency alone enumerates valid usernames. + stored_hash = account["password_hash"] if account else _DUMMY_HASH + + # A hash that does not parse can never match, and verify_password bails + # out of one before doing any PBKDF2 work - measured here, 0.16ms against + # 439ms for a real digest. That inverts the very property _DUMMY_HASH + # exists to protect: an account whose configured hash is corrupt would + # answer ~2700x faster than every other username, announcing which + # account is broken to anyone with a stopwatch. So spend the same work + # regardless; the result is a rejection either way. + hash_usable = hash_is_wellformed(stored_hash) + password_ok = verify_password( + payload.password, stored_hash if hash_usable else _DUMMY_HASH + ) + + if account is None or not password_ok: + _record_failure(key) + # The reason goes to the LOG, never to the caller - the response + # below is byte-identical whichever of these it was, so nothing here + # can be used to enumerate usernames. It is computed after both the + # lookup and the PBKDF2 call above, so it adds no timing signal + # either. Without it, a deployment whose configured hash or admin + # username has drifted is indistinguishable from someone simply + # typing the wrong password, and this is exactly how a production + # sign-in outage stayed unexplained: the log said "Failed sign-in + # for 'admin'" and nothing more. + if account is None: + logger.warning( + "Failed sign-in for %r from %s: reason=unknown-username. " + "Configured accounts: %s (AUTH_ADMIN_USERNAME source=%s).", + username, + key[1], + ", ".join(sorted(_accounts())), + config_source("AUTH_ADMIN_USERNAME"), + ) + elif not hash_usable: + # ERROR, not WARNING: this is a broken deployment, not a bad + # guess. No password can ever match, so every sign-in to this + # account will 401 until the hash itself is replaced. + logger.error( + "Failed sign-in for %r from %s: reason=malformed-hash. The configured " + "password hash does not parse as pbkdf2_sha256$$$" + " (fingerprint=%s, source=%s). Nobody can sign in to this " + "account until it is regenerated with scripts/make_auth_secrets.py.", + username, + key[1], + password_hash_fingerprint(stored_hash) or "(empty)", + config_source("AUTH_ADMIN_PASSWORD_HASH"), + ) + else: + logger.warning( + "Failed sign-in for %r from %s: reason=bad-password. The account exists " + "and its hash parses (fingerprint=%s, source=%s); the password did not " + "match. If this IS the password you deployed, then the running config " + "carries a different hash than the file you are reading - compare that " + "fingerprint against: python scripts/make_auth_secrets.py " + "--fingerprint .env.production", + username, + key[1], + password_hash_fingerprint(stored_hash), + config_source("AUTH_ADMIN_PASSWORD_HASH"), + ) + # One message for every failure mode, for the same reason. + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid username or password.", + ) + + _clear_failures(key) + role = account["role"] + permissions = ROLE_PERMISSIONS.get(role, []) + token, expires_in = create_access_token(username, role, permissions) + logger.info("Issued token for %r (role=%s)", username, role) + + return LoginResponse( + access_token=token, + expires_in=expires_in, + user=UserProfile( + username=username, + role=role, + display_name=account["display_name"], + email=account["email"], + permissions=permissions, + ), + ) + + +@router.get("/me", response_model=UserProfile) +def me(principal: Principal = Depends(get_principal)) -> UserProfile: + """ + Who the presented credential belongs to. 401 if it is missing or expired. + + The frontend calls this on boot to check a restored session before showing + the app, so an expired token lands on the login page rather than on a + dashboard whose every request then fails. + """ + account = _accounts().get(principal.username, {}) + return UserProfile( + username=principal.username, + role=principal.role, + display_name=account.get("display_name", principal.username.title()), + email=account.get("email", f"{principal.username}@nutritionintel.com"), + permissions=principal.permissions, + ) + + +@router.get("/roles") +def list_roles() -> dict: + """ + The available roles and what each may do. + + Note there are no demo credentials here any more. The passwords are set per + deployment via AUTH_ADMIN_PASSWORD_HASH / AUTH_USER_PASSWORD_HASH; this + endpoint used to publish working ones to anyone who asked. + """ + return { + "roles": [ + { + "id": "admin", + "name": "Admin", + "description": ( + "Full access: catalog brand cards, project details, Excel/CSV " + "train/test uploads, discount allocation, analytics and nutrition. " + "Implicitly holds every permission." + ), + "permissions": ROLE_PERMISSIONS["admin"], + }, + { + "id": "user", + "name": "User", + "description": ( + "Combined user and store role: single or batch product uploads with " + "image and DB/JSON sync, store inventory, profit analytics, nutrition." + ), + "permissions": ROLE_PERMISSIONS["user"], + }, + ] + } diff --git a/backend/app/api/routers/elec.py b/backend/app/api/routers/elec.py new file mode 100644 index 0000000..5cc229d --- /dev/null +++ b/backend/app/api/routers/elec.py @@ -0,0 +1,188 @@ +"""Read-only catalogue API: category -> brand -> product -> per-platform offers. + +Only VERIFIED products are served (see repository.refresh_verification), and +every price is returned with the site, URL, source type and time it was seen. +Money is returned as a decimal string, never a float. +""" +from __future__ import annotations + +from decimal import Decimal +from typing import Any, Dict, List, Optional + +from fastapi import APIRouter, HTTPException, Query + +from app.electronics.db.connection import connect +from app.electronics.db.repository import product_rating_and_reviews +from app.electronics.reviews import select_reviews + +router = APIRouter(prefix="/elec", tags=["electronics"]) + + +def _money(value: Optional[Decimal]) -> Optional[str]: + return None if value is None else format(value, "f") + + +def _clean(row: Dict[str, Any]) -> Dict[str, Any]: + out = {} + for k, v in row.items(): + if isinstance(v, Decimal): + out[k] = _money(v) if k in ("price", "mrp", "best_price", "min_price", "max_price") else float(v) + elif hasattr(v, "isoformat"): + out[k] = v.isoformat() + else: + out[k] = v + return out + + +@router.get("/categories") +def categories() -> List[dict]: + with connect() as conn: + rows = conn.execute( + "SELECT c.slug, c.name, coalesce(sum(s.product_count), 0)::int AS product_count " + "FROM elec.category c LEFT JOIN elec.v_brand_summary s ON s.category = c.slug " + "GROUP BY c.slug, c.name ORDER BY c.name" + ).fetchall() + return [_clean(r) for r in rows] + + +@router.get("/brands") +def brands(category: str = Query(...)) -> List[dict]: + with connect() as conn: + rows = conn.execute( + """ + SELECT b.name AS brand, b.slug AS brand_slug, + coalesce(s.product_count, 0)::int AS product_count, s.min_price, s.max_price, + (SELECT image_url FROM elec.v_brand_catalog v + WHERE v.brand_slug = b.slug AND v.category = %(c)s AND v.image_url IS NOT NULL + ORDER BY v.platform_count DESC LIMIT 1) AS sample_image + FROM elec.brand b + JOIN elec.brand_category bc ON bc.brand_id = b.id + JOIN elec.category c ON c.id = bc.category_id AND c.slug = %(c)s + LEFT JOIN elec.v_brand_summary s ON s.brand_slug = b.slug AND s.category = %(c)s + ORDER BY coalesce(s.product_count, 0) DESC, b.name + """, + {"c": category}, + ).fetchall() + return [_clean(r) for r in rows] + + +@router.get("/products") +def products( + category: Optional[str] = None, + brand: Optional[str] = None, + q: Optional[str] = Query(None, max_length=100), + min_price: Optional[Decimal] = None, + max_price: Optional[Decimal] = None, + in_stock: bool = False, + site: Optional[str] = None, + tn_only: bool = False, + limit: int = Query(48, ge=1, le=200), + offset: int = Query(0, ge=0), +) -> dict: + where, params = ["TRUE"], {} + if category: + where.append("v.category = %(category)s"); params["category"] = category + if brand: + where.append("v.brand_slug = %(brand)s"); params["brand"] = brand + if q: + where.append("(v.display_name ILIKE %(q)s OR v.brand ILIKE %(q)s)"); params["q"] = f"%{q}%" + if min_price is not None: + where.append("v.best_price >= %(min_price)s"); params["min_price"] = min_price + if max_price is not None: + where.append("v.best_price <= %(max_price)s"); params["max_price"] = max_price + if in_stock: + where.append("EXISTS (SELECT 1 FROM elec.v_product_availability a WHERE a.product_id = v.product_id AND a.in_stock)") + if site: + where.append("EXISTS (SELECT 1 FROM elec.v_product_availability a WHERE a.product_id = v.product_id AND a.domain = %(site)s)") + params["site"] = site + if tn_only: + where.append("v.sold_by_tn_retailer") + sql_where = " AND ".join(where) + with connect() as conn: + total = conn.execute(f"SELECT count(*) AS n FROM elec.v_brand_catalog v WHERE {sql_where}", params).fetchone()["n"] + rows = conn.execute( + f"SELECT v.* FROM elec.v_brand_catalog v WHERE {sql_where} " + f"ORDER BY v.platform_count DESC, v.best_price NULLS LAST, v.display_name " + f"LIMIT %(limit)s OFFSET %(offset)s", + {**params, "limit": limit, "offset": offset}, + ).fetchall() + return {"total": total, "products": [_clean(r) for r in rows]} + + +@router.get("/products/{product_id}") +def product(product_id: int) -> dict: + with connect() as conn: + row = conn.execute("SELECT * FROM elec.v_brand_catalog WHERE product_id = %s", (product_id,)).fetchone() + if not row: + raise HTTPException(status_code=404, detail="Product not found or not verified") + specs = conn.execute("SELECT spec_sources FROM elec.product WHERE id = %s", (product_id,)).fetchone() + offers = conn.execute( + "SELECT * FROM elec.v_product_availability WHERE product_id = %s " + "ORDER BY (price IS NULL), (source_type = 'search_snippet'), price, site", + (product_id,), + ).fetchall() + images = conn.execute( + "SELECT i.url, i.source_type, s.name AS site, l.source_url AS found_on " + "FROM elec.product_image i JOIN elec.source_listing l ON l.id = i.source_listing_id " + "JOIN elec.site s ON s.id = l.site_id WHERE i.product_id = %s ORDER BY i.rank, i.id", + (product_id,), + ).fetchall() + rated = product_rating_and_reviews(conn, product_id) + result = _clean(row) + result["spec_sources"] = specs["spec_sources"] if specs else {} + result["offers"] = [_clean(o) for o in offers] + result["images"] = [dict(i) for i in images] + result["rating"] = _overall_rating(rated["sources"]) + overall = result["rating"]["value"] if result["rating"] else None + result["reviews"] = [_clean(r) for r in select_reviews(overall, rated["reviews"])] + return result + + +def _overall_rating(sources: List[dict]) -> Optional[dict]: + """The product's rating across the platforms that state one: the mean + weighted by each platform's rating count (a platform that states no count + weighs as 1). None when no platform states a rating - never a guess.""" + if not sources: + return None + weight = lambda s: max(int(s["review_count"] or 0), 1) # noqa: E731 + total = sum(weight(s) for s in sources) + value = sum(Decimal(s["rating"]) * weight(s) for s in sources) / total + counts = [s["review_count"] for s in sources if s["review_count"]] + return { + "value": round(float(value), 1), + "count": sum(counts) if counts else None, + "sources": [ + {"site": s["site"], "rating": float(s["rating"]), "review_count": s["review_count"], "source_url": s["source_url"]} + for s in sources + ], + } + + +@router.get("/products/{product_id}/price-history") +def price_history(product_id: int) -> List[dict]: + with connect() as conn: + rows = conn.execute( + """ + SELECT s.name AS site, h.price, h.mrp, h.in_stock, h.source_type, h.observed_at + FROM elec.price_history h + JOIN elec.product_listing_map m ON m.listing_id = h.listing_id AND m.review_status IN ('auto','approved') + JOIN elec.source_listing l ON l.id = h.listing_id + JOIN elec.site s ON s.id = l.site_id + WHERE m.product_id = %s AND h.price IS NOT NULL + ORDER BY h.observed_at + """, + (product_id,), + ).fetchall() + return [_clean(r) for r in rows] + + +@router.get("/sites") +def sites() -> List[dict]: + with connect() as conn: + rows = conn.execute( + "SELECT s.name, s.domain, s.kind, s.region, s.policy, s.probe_outcome, s.probed_at, " + "s.breaker_until, s.breaker_reason, s.probe_evidence->>'reason' AS probe_reason, " + "(SELECT count(*) FROM elec.source_listing l WHERE l.site_id = s.id)::int AS listings " + "FROM elec.site s ORDER BY (s.kind = 'brand_official'), s.name" + ).fetchall() + return [_clean(r) for r in rows] diff --git a/backend/app/api/routers/elec_admin.py b/backend/app/api/routers/elec_admin.py new file mode 100644 index 0000000..8b5d61c --- /dev/null +++ b/backend/app/api/routers/elec_admin.py @@ -0,0 +1,124 @@ +"""Admin endpoints: start a collection run, see runs, probe sites, review matches.""" +from __future__ import annotations + +import threading +import time +import uuid +from typing import Dict, List, Optional + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field + +from app.api.background import run_in_background +from app.api.deps import require_admin +from app.electronics.db import repository as repo +from app.electronics.reference import load_reference + +router = APIRouter(prefix="/elec/admin", tags=["electronics-admin"], dependencies=[Depends(require_admin)]) + +_jobs: Dict[str, dict] = {} +_jobs_lock = threading.Lock() +_run_lock = threading.Lock() # one collection at a time: polite to sites, kind to 8 GB RAM + + +class RunRequest(BaseModel): + category: str = Field(..., examples=["mobiles"]) + brands: List[str] = Field(default_factory=list, description="brand slugs; empty = all for the category") + limit: int = Field(10, ge=1, le=60, description="max models per brand") + expand: int = Field(6, ge=0, le=30) + budget: int = Field(150, ge=10, le=1000, description="max search queries") + fetch_pages: bool = True + use_llm: bool = True + + +def _job_update(job_id: str, **fields) -> None: + with _jobs_lock: + _jobs[job_id].update(fields, updated_at=time.time()) + + +@router.post("/runs", status_code=202) +def start_run(req: RunRequest) -> dict: + ref = load_reference() + if req.category not in ref.categories: + raise HTTPException(400, f"unknown category {req.category!r}") + brands = req.brands or [b.slug for b in ref.brands_for(req.category)] + bad = [b for b in brands if b not in ref.brands or req.category not in ref.brands[b].categories] + if bad: + raise HTTPException(400, f"not allow-listed for {req.category}: {bad}") + if _run_lock.locked(): + raise HTTPException(409, "A collection run is already in progress") + job_id = str(uuid.uuid4()) + with _jobs_lock: + _jobs[job_id] = {"job_id": job_id, "status": "queued", "log": [], "stats": {}, "created_at": time.time()} + + def work() -> None: + from app.electronics.collector import Collector, RunOptions + + with _run_lock: + _job_update(job_id, status="running") + + def progress(msg: str) -> None: + with _jobs_lock: + _jobs[job_id]["log"] = (_jobs[job_id]["log"] + [msg])[-200:] + + try: + opts = RunOptions(category=req.category, brands=brands, max_products_per_brand=req.limit, + expand_per_brand=req.expand, search_budget=req.budget, + fetch_pages=req.fetch_pages, use_llm=req.use_llm) + stats = Collector(opts, progress=progress).run() + _job_update(job_id, status="done", stats=stats) + except Exception as exc: # noqa: BLE001 - reported to the UI + _job_update(job_id, status="failed", error=repr(exc)) + + run_in_background(work, name=f"elec-run-{job_id[:8]}") + return {"job_id": job_id} + + +@router.get("/runs/{job_id}") +def get_job(job_id: str) -> dict: + with _jobs_lock: + job = _jobs.get(job_id) + if not job: + raise HTTPException(404, "unknown job") + return dict(job) + + +@router.get("/runs") +def list_runs(limit: int = 20) -> List[dict]: + rows = repo.recent_runs(limit) + for r in rows: + for k in ("started_at", "ended_at"): + if r.get(k): + r[k] = r[k].isoformat() + return rows + + +@router.get("/review") +def review_queue() -> List[dict]: + return [{**r, "confidence": float(r["confidence"])} for r in repo.review_queue()] + + +class ReviewDecision(BaseModel): + approve: bool + + +@router.post("/review/{listing_id}") +def review(listing_id: int, decision: ReviewDecision) -> dict: + if not repo.set_review(listing_id, decision.approve): + raise HTTPException(404, "no pending match for that listing") + return {"ok": True, "products": repo.refresh_verification()} + + +@router.post("/sites/{domain}/probe") +def probe(domain: str) -> dict: + from app.electronics.net.polite_client import PoliteClient + from app.electronics.probe.site_probe import probe_site + from app.electronics.search.engine import SearchEngine + + site = load_reference().sites.get(domain) + if not site: + raise HTTPException(404, "unknown site") + with PoliteClient() as client: + res = probe_site(site, client, SearchEngine(budget=4)) + repo.set_probe_result(domain, res["outcome"], res["robots_allowed"], res["evidence"]) + return res diff --git a/backend/app/api/routers/health.py b/backend/app/api/routers/health.py new file mode 100644 index 0000000..fd41daf --- /dev/null +++ b/backend/app/api/routers/health.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +import logging + +from fastapi import APIRouter + +from app.api.schemas import AuthConfigOut, HealthOut, SearchStatusOut +from app.electronics.db.connection import check_connection +from app.infrastructure.security import auth_config_summary +from app.infrastructure.settings import ( + DB_NAME, EMBEDDINGS_MODEL, OLLAMA_MODEL_NAME, USE_DDG_SEARCH, USE_GOOGLE_CSE, +) +from app.services import ollama_service + +logger = logging.getLogger(__name__) +router = APIRouter(tags=["health"]) + + +@router.get("/health", response_model=HealthOut) +def health() -> HealthOut: + """Liveness/readiness probe used by the React app to show a banner when + Postgres or Ollama aren't reachable, instead of failing silently. + + Ollama being down does not make the service degraded: it is only used to + fill spec gaps, and the pipeline runs deterministically without it.""" + db_ok = check_connection() + return HealthOut( + status="ok" if db_ok else "degraded", + database=db_ok, + database_name=DB_NAME, + ollama=bool(ollama_service._ensure_client()), + ollama_model=OLLAMA_MODEL_NAME, + embeddings_model=EMBEDDINGS_MODEL, + search=SearchStatusOut(ddg=USE_DDG_SEARCH, google_cse=USE_GOOGLE_CSE), + auth=AuthConfigOut(**auth_config_summary()), + ) diff --git a/backend/app/api/schemas.py b/backend/app/api/schemas.py new file mode 100644 index 0000000..68c8880 --- /dev/null +++ b/backend/app/api/schemas.py @@ -0,0 +1,73 @@ +"""Pydantic response models for the health endpoint. The electronics +catalogue's own models live in app/electronics/api_models.py.""" +from __future__ import annotations + +from typing import List, Optional + +from pydantic import BaseModel, Field + + +class ApiKeyInfoOut(BaseModel): + """One configured machine consumer, named but never quoted. + + `fingerprint` is a truncated digest of name+secret, not the secret. It exists + so a caller who was issued a key can confirm THAT key is the one this + deployment loaded - the question a 401 cannot answer, since an undeployed key + and a wrong key fail identically. + """ + + name: str + role: str + fingerprint: str + + +class AuthConfigOut(BaseModel): + """ + The effective auth configuration, reported by /api/health. + + Unauthenticated on purpose. The failure this exists to diagnose is "nobody + can sign in", so anything gated behind an admin token is unreachable + exactly when it is needed. Nothing here is a secret: the admin username is + already the documented one, allow_any_login=true is a fact an operator + urgently needs (and an attacker discovers with a single login attempt + anyway), and the fingerprint is a truncated hash of a salted digest, not a + password. The API key block follows the same rule: it names which consumers + are configured and fingerprints their keys, so a caller can tell an + undeployed key from a rejected one, but it never renders a secret. What it buys is a one-command answer to "is this deployment + running the config I think it is?" - compare the fingerprint here against + the one printed by scripts/make_auth_secrets.py --fingerprint. + """ + + enabled: bool + allow_any_login: bool + admin_username: str + password_hash_valid: bool + password_hash_iterations: Optional[int] = None + password_hash_fingerprint: str + # "process-env" | "env-file" | "default" - which one actually won. + admin_username_source: str + password_hash_source: str + # Machine consumers. Names and fingerprints only - the secrets themselves are + # never rendered here, and _parse_api_keys enforces enough entropy that the + # fingerprints do not give them away. Defaulted so a client of this schema + # still validates against a deployment predating these fields. + api_keys_count: int = 0 + api_keys: List[ApiKeyInfoOut] = Field(default_factory=list) + api_keys_source: str = "default" + + +class SearchStatusOut(BaseModel): + """Which web-search providers discovery can use right now.""" + ddg: bool + google_cse: bool + + +class HealthOut(BaseModel): + status: str + database: bool + database_name: str + ollama: bool + ollama_model: str + embeddings_model: str + search: SearchStatusOut + auth: AuthConfigOut diff --git a/backend/app/electronics/__init__.py b/backend/app/electronics/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/electronics/cli.py b/backend/app/electronics/cli.py new file mode 100644 index 0000000..63a1090 --- /dev/null +++ b/backend/app/electronics/cli.py @@ -0,0 +1,261 @@ +"""Command line for the electronics pipeline. Run from backend/: + + python -m app.electronics.cli migrate + python -m app.electronics.cli seed-reference + python -m app.electronics.cli probe [--site croma.com] [--all] + python -m app.electronics.cli collect --category mobiles --brand samsung --brand xiaomi --limit 15 + python -m app.electronics.cli reviews [--category mobiles] + python -m app.electronics.cli report + python -m app.electronics.cli review [--approve ID | --reject ID] + python -m app.electronics.cli verify-grounding +""" +from __future__ import annotations + +import json +import logging +import re +from decimal import Decimal +from typing import List, Optional + +import typer + +app = typer.Typer(add_completion=False, help="Electronics catalogue: search-first, evidence-backed collection.") + + +def _setup_logging(verbose: bool) -> None: + logging.basicConfig(level=logging.DEBUG if verbose else logging.INFO, + format="%(asctime)s %(levelname)s %(name)s: %(message)s") + for noisy in ("httpx", "httpcore", "primp", "ddgs", "urllib3", "sentence_transformers"): + logging.getLogger(noisy).setLevel(logging.WARNING) + + +@app.command() +def migrate() -> None: + """Create/upgrade the elec schema in the local electronics_catalog database.""" + from app.electronics.db.migrate import run_migrations + + applied = run_migrations() + typer.echo(f"Applied: {', '.join(applied) if applied else 'nothing (up to date)'}") + + +@app.command("seed-reference") +def seed_reference() -> None: + """Load brands, aliases, categories and sites from reference/*.yaml.""" + from app.electronics.db import repository as repo + from app.electronics.reference import load_reference + + typer.echo(json.dumps(repo.seed_reference(load_reference()))) + + +@app.command() +def probe(site: List[str] = typer.Option([], "--site", help="Domain(s) to probe; default all probe-policy sites"), + include_official: bool = typer.Option(False, "--official", help="Also probe brand official sites"), + verbose: bool = False) -> None: + """Grade sites A/B/C: may they be scraped, or only searched?""" + _setup_logging(verbose) + from app.electronics.db import repository as repo + from app.electronics.net.polite_client import PoliteClient + from app.electronics.probe.site_probe import probe_site + from app.electronics.reference import load_reference + from app.electronics.search.engine import SearchEngine + + ref = load_reference() + if site: + targets = [s for s in ref.sites.values() if s.domain in site] + else: + targets = [s for s in ref.sites.values() if include_official or s.kind != "brand_official"] + run_id = repo.start_run("probe", {"sites": [s.domain for s in targets]}) + client = PoliteClient(on_fetch=lambda r, host: repo.log_fetch(run_id, r.url, host, r.status, r.bytes, + r.outcome, r.robots_allowed)) + engine = SearchEngine(budget=len(targets) * 3) + results = {} + try: + for s in targets: + res = probe_site(s, client, engine) + repo.set_probe_result(s.domain, res["outcome"], res["robots_allowed"], res["evidence"]) + results[s.domain] = res["outcome"] + typer.echo(f"{s.name:28} {s.domain:24} {res['outcome']} {res['evidence'].get('reason')}") + finally: + client.close() + repo.finish_run(run_id, "done", {"grades": results}) + + +@app.command() +def collect(category: str = typer.Option(..., help="mobiles | laptops"), + brand: List[str] = typer.Option([], "--brand", help="Brand slug(s); default all brands of the category"), + limit: int = typer.Option(15, help="Max models per brand"), + expand: int = typer.Option(8, help="Models per brand looked up on other platforms"), + budget: int = typer.Option(200, help="Max search queries this run"), + no_fetch: bool = typer.Option(False, "--no-fetch", help="Search results only; fetch no pages"), + no_llm: bool = typer.Option(False, "--no-llm", help="Deterministic spec parsing only"), + no_embed: bool = typer.Option(False, "--no-embed"), + reprobe: bool = False, + verbose: bool = False) -> None: + """Discover and collect real listings for allow-listed brands.""" + _setup_logging(verbose) + from app.electronics.collector import Collector, RunOptions + from app.electronics.reference import load_reference + + ref = load_reference() + if category not in ref.categories: + raise typer.BadParameter(f"unknown category {category!r}; use one of {list(ref.categories)}") + brands = brand or [b.slug for b in ref.brands_for(category)] + unknown = [b for b in brands if b not in ref.brands or category not in ref.brands[b].categories] + if unknown: + raise typer.BadParameter(f"not allow-listed for {category}: {unknown}") + opts = RunOptions(category=category, brands=brands, max_products_per_brand=limit, expand_per_brand=expand, + search_budget=budget, use_llm=not no_llm, fetch_pages=not no_fetch, reprobe=reprobe) + stats = Collector(opts, progress=typer.echo).run(embed=not no_embed) + typer.echo(json.dumps(stats, indent=2, sort_keys=True)) + + +@app.command() +def prices(limit: int = typer.Option(40, help="Max Google queries (free tier: 100/day)"), + category: Optional[str] = typer.Option(None, help="mobiles | laptops (default both)")) -> None: + """Fill missing prices on search-only platforms from Google's structured data (no site fetches).""" + _setup_logging(False) + from app.electronics.price_lookup import lookup_prices + + stats = lookup_prices(limit=limit, category=category, progress=typer.echo) + typer.echo(json.dumps(stats, indent=2, default=str)) + if stats.get("error"): + typer.echo("\nGoogle search is not usable yet: " + str(stats["error"])) + raise typer.Exit(code=1) + + +@app.command() +def rematch(category: str = typer.Option(..., help="mobiles | laptops"), + no_embed: bool = typer.Option(False, "--no-embed")) -> None: + """Rebuild products from stored listings with the current matching rules (no network).""" + _setup_logging(False) + from app.electronics.match.rematch import rematch as run_rematch + + stats = run_rematch(category) + if not no_embed: + from app.electronics.collector import embed_verified_products + + try: + stats["embedded"] = embed_verified_products() + except Exception as exc: # noqa: BLE001 + typer.echo(f"Embedding skipped: {exc}") + typer.echo(json.dumps(stats, indent=2)) + + +@app.command() +def reviews(category: Optional[str] = typer.Option(None, help="mobiles | laptops (default both)"), + limit: int = typer.Option(200, help="Max product pages to re-read"), + verbose: bool = False) -> None: + """Re-read ratings and customer reviews from the product pages already on file. + + Only pages the collector itself reads (scraped / brand official listings) + are fetched, politely (robots.txt, per-site pacing, circuit breaker). A + rating or review is stored only when the page's own schema.org data states + it; nothing is generated. + """ + _setup_logging(verbose) + from rapidfuzz import fuzz + + from app.electronics.db import repository as repo + from app.electronics.extract.jsonld import extract_products + from app.electronics.net.polite_client import PoliteClient + + rows = repo.listings_for_review_backfill(category)[:limit] + run_id = repo.start_run("reviews", {"category": category, "pages": len(rows)}) + client = PoliteClient(on_fetch=lambda r, host: repo.log_fetch(run_id, r.url, host, r.status, r.bytes, + r.outcome, r.robots_allowed)) + stats = {"pages": 0, "pages_ok": 0, "rated": 0, "reviews_stored": 0, "no_matching_product": 0} + status, error = "done", None + try: + for row in rows: + stats["pages"] += 1 + res = client.get(row["source_url"]) + if not res.ok: + continue + stats["pages_ok"] += 1 + products = extract_products(res.text) + # The same product the listing was stored from: its SKU, else its name. + match = next((p for p in products if p.get("sku") and p["sku"] == row["source_sku"]), None) + if match is None: + title = (row["title"] or "").lower() + scored = [(fuzz.token_set_ratio(p["name"].lower(), title), p) for p in products] + scored = [sp for sp in scored if sp[0] >= 85] + match = max(scored, key=lambda sp: sp[0])[1] if scored else None + if match is None: + stats["no_matching_product"] += 1 + continue + if match.get("rating") is not None and Decimal(0) < match["rating"] <= Decimal(5): + repo.update_listing_rating(row["listing_id"], match["rating"], match.get("review_count")) + stats["rated"] += 1 + if match.get("reviews"): + stats["reviews_stored"] += repo.save_reviews(row["listing_id"], match["reviews"]) + typer.echo(f" {row['domain']:22} rating={match.get('rating')} reviews={len(match.get('reviews') or [])}") + except Exception as exc: # noqa: BLE001 + status, error = "failed", repr(exc) + raise + finally: + client.close() + repo.finish_run(run_id, status, stats, error) + typer.echo(json.dumps(stats, indent=2)) + + +@app.command() +def report() -> None: + """Counts per brand/category and per site.""" + from app.electronics.db.connection import connect + + with connect() as conn: + typer.echo("Sites:") + for r in conn.execute("SELECT name, domain, policy, probe_outcome, breaker_until FROM elec.site " + "WHERE kind <> 'brand_official' ORDER BY name"): + typer.echo(f" {r['name']:22} {r['policy']:9} grade={r['probe_outcome'] or '-'}" + f"{' breaker until ' + str(r['breaker_until']) if r['breaker_until'] else ''}") + typer.echo("\nProducts by status:") + for r in conn.execute("SELECT verification_status, count(*) n FROM elec.product GROUP BY 1"): + typer.echo(f" {r['verification_status']:12} {r['n']}") + typer.echo("\nVerified catalogue (brand / category):") + for r in conn.execute("SELECT * FROM elec.v_brand_summary ORDER BY category, brand"): + typer.echo(f" {r['brand']:10} {r['category']:8} products={r['product_count']:3} " + f"price ₹{r['min_price']}–₹{r['max_price']} max_platforms={r['max_platforms']}") + typer.echo("\nListings by site and source type:") + for r in conn.execute("SELECT s.name, l.source_type, count(*) n, count(l.price) priced " + "FROM elec.source_listing l JOIN elec.site s ON s.id = l.site_id " + "GROUP BY 1, 2 ORDER BY 1, 2"): + typer.echo(f" {r['name']:22} {r['source_type']:15} {r['n']:4} (with price: {r['priced']})") + + +@app.command() +def review(approve: Optional[int] = typer.Option(None, help="listing id to approve"), + reject: Optional[int] = typer.Option(None, help="listing id to reject")) -> None: + """Show uncertain listing-to-product matches, or approve/reject one.""" + from app.electronics.db import repository as repo + + if approve or reject: + ok = repo.set_review(approve or reject, approve is not None) + refreshed = repo.refresh_verification() + typer.echo(f"{'updated' if ok else 'nothing pending for that listing'}; products: {refreshed}") + return + for r in repo.review_queue(): + typer.echo(f"[{r['listing_id']}] {r['site']}: {r['listing_title']}\n -> {r['product']} " + f"({r['method']}, {r['confidence']}) {r['source_url']}") + + +@app.command("verify-grounding") +def verify_grounding(sample: int = 100) -> None: + """Audit: every stored price must appear in the evidence text stored with it.""" + from app.electronics.db import repository as repo + + bad = 0 + rows = repo.grounding_sample(sample) + for r in rows: + digits = re.sub(r"\D", "", r["evidence_text"].replace(".00", "")) + price = r["price"] + whole = str(int(price)) if price == price.to_integral() else str(price) + if whole.replace(".", "") not in digits: + bad += 1 + typer.echo(f"NOT GROUNDED listing {r['id']}: price {price} not in evidence ({r['source_url']})") + typer.echo(f"Checked {len(rows)} priced listings; {bad} without evidence.") + raise typer.Exit(code=1 if bad else 0) + + +if __name__ == "__main__": + app() diff --git a/backend/app/electronics/collector.py b/backend/app/electronics/collector.py new file mode 100644 index 0000000..209d860 --- /dev/null +++ b/backend/app/electronics/collector.py @@ -0,0 +1,581 @@ +"""Search-first collection of real product listings. + +For one category and a set of allow-listed brands: + + 1. DISCOVER web search `site: ` on every + registered platform (marketplaces, national chains, Tamil Nadu + chains, the brand's own site). Only URLs that are single product + pages on a registered platform are kept. + 2. EXPAND for each model found, search ` price` to find the + same model on other platforms. + 3. COLLECT per URL, by the platform's probe grade: + A/B and breaker closed -> fetch the page politely and read + JSON-LD / meta / spec tables + C (or fetch refused) -> use the search result itself: its + title, snippet price and stock text + 4. MATCH link the listing to one canonical variant (match.matcher) + 5. ENRICH specs (deterministic, LLM gap-fill grounded in page text) and + images (only from the product's own listings, validated live) + 6. VERIFY products with listings on ≥2 sites (≥1 a retailer) become + verified and visible. + +Nothing in this module invents a product, price or image: every value is read +from a page or a search result, and stored with that URL and text. +""" +from __future__ import annotations + +import hashlib +import json +import logging +import re +from dataclasses import dataclass, field +from decimal import Decimal +from typing import Callable, Dict, List, Optional, Tuple +from urllib.parse import urlparse + +from rapidfuzz import fuzz + +from app.electronics.db import repository as repo +from app.electronics.extract.html_fallback import extract_page, spec_tables, visible_text +from app.electronics.extract.jsonld import extract_products +from app.electronics.extract.serp_parser import clean_result_title, read_price, read_rating, read_stock +from app.electronics.match.matcher import decide +from app.electronics.models import Listing +from app.electronics.net.breaker import CircuitBreaker +from app.electronics.net.polite_client import PoliteClient +from app.electronics.normalise.brand_alias import looks_like_device_title +from app.electronics.normalise.llm_fill import fill_missing +from app.electronics.normalise.spec_normaliser import normalise_specs +from app.electronics.normalise.title_parser import ParsedTitle, fill_from_context, parse_title, variant_key +from app.electronics.probe.site_probe import probe_site +from app.electronics.reference import BrandRef, SiteRef, load_reference, site_for_url +from app.electronics.search.engine import SearchEngine +from app.electronics.search.providers import SearchHit +from app.infrastructure.settings import ELEC_PROBE_TTL_DAYS, MIN_IMAGE_BYTES + +from bs4 import BeautifulSoup + +logger = logging.getLogger(__name__) + +# Titles that belong to another category even when the brand matches. +_OFF_CATEGORY = { + "mobiles": re.compile(r"\b(?:tab|tablet|pad|watch|buds|earbuds|laptop|book|monitor|tv|television|band)\b", re.I), + "laptops": re.compile(r"\b(?:tablet|tab|monitor|mouse|keyboard|phone|smartphone|printer|desktop|all[- ]in[- ]one)\b", re.I), +} +_LISTING_PAGE = re.compile(r"/(?:search|s|c|category|categories|brand|brands|compare|offers?|deals?)(?:/|\?|$)", re.I) + + +@dataclass +class RunOptions: + category: str + brands: List[str] # brand slugs + max_products_per_brand: int = 15 + expand_per_brand: int = 8 # models to look up on other platforms + search_budget: int = 200 + use_llm: bool = True + fetch_pages: bool = True + find_images: bool = True + reprobe: bool = False + + +@dataclass +class RunStats: + counts: Dict[str, int] = field(default_factory=dict) + + def inc(self, key: str, n: int = 1) -> None: + self.counts[key] = self.counts.get(key, 0) + n + + +def source_sku(site: SiteRef, url: str) -> str: + rx = site.product_url_re + if rx is not None: + m = rx.search(url) + if m and m.groups() and m.group(1): + return m.group(1) + p = urlparse(url) + return (p.netloc.lower().removeprefix("www.") + p.path.rstrip("/").lower())[:300] + + +_INDIA_PATH = re.compile(r"^/(?:in|in-en|en-in|en_in|in_en)(?:/|$)", re.I) + + +def is_product_url(site: SiteRef, url: str) -> bool: + p = urlparse(url) + if _LISTING_PAGE.search(p.path): + return False + if site.kind == "brand_official": + # Only the brand's Indian storefront: www.samsung.com/in/..., not + # us.samsung.com or news.samsung.com. Domains that are Indian already + # (oneplus.in, motorola.co.in) qualify as they are. + host = (p.hostname or "").lower() + if host not in (site.domain, "www." + site.domain, "in." + site.domain): + return False + if not site.domain.endswith((".in", ".co.in")) and not _INDIA_PATH.search(p.path) and not host.startswith("in."): + return False + rx = site.product_url_re + if rx is not None: + return bool(rx.search(url)) + return p.path.count("/") >= 2 # brand sites: at least /section/product + + +def embed_verified_products() -> int: + """MiniLM vectors for verified products that do not have one yet.""" + rows = repo.products_without_embedding() + if not rows: + return 0 + from app.services.embeddings_service import embed_texts + + texts = [ + f"{r['brand']} {r['display_name']} {r['category']} " + + " ".join(f"{k} {v}" for k, v in (r["canonical_specs"] or {}).items()) + for r in rows + ] + for r, vec in zip(rows, embed_texts(texts)): + repo.set_embedding(r["id"], vec) + return len(rows) + + +class Collector: + def __init__(self, options: RunOptions, *, progress: Optional[Callable[[str], None]] = None) -> None: + self.opt = options + self.ref = load_reference() + self.stats = RunStats() + self.progress = progress or (lambda msg: logger.info(msg)) + self.run_id: Optional[int] = None + self.ids = repo.id_maps() + self.site_rows = {r["domain"]: r for r in repo.sites()} + self.breaker = CircuitBreaker(on_trip=self._on_trip) + for r in self.site_rows.values(): + if r.get("breaker_until"): + self.breaker.preload(r["domain"], r["breaker_until"].timestamp(), r.get("breaker_reason") or "") + self.client = PoliteClient(breaker=self.breaker, on_fetch=self._on_fetch) + self.engine = SearchEngine(budget=options.search_budget) + self._touched_products: Dict[int, List[Tuple[int, Listing]]] = {} + + # -- callbacks ----------------------------------------------------------- + def _on_trip(self, host: str, reason: str, until: float) -> None: + self.stats.inc("breaker_trips") + self.progress(f"Circuit breaker opened for {host}: {reason}. Falling back to web search for it.") + repo.trip_breaker(host, reason, until) + + def _on_fetch(self, result, host: str) -> None: + self.stats.inc(f"fetch_{result.outcome}") + repo.log_fetch(self.run_id, result.url, host, result.status, result.bytes, result.outcome, result.robots_allowed) + + # -- grading ------------------------------------------------------------- + def _grade(self, site: SiteRef) -> str: + if site.policy == "serp_only": + return "C" + host = site.domain + if self.breaker.is_open(host) or self.breaker.is_open("www." + host): + return "C" + return (self.site_rows.get(site.domain) or {}).get("probe_outcome") or "C" + + def ensure_probes(self, sites: List[SiteRef]) -> None: + from datetime import datetime, timedelta, timezone + + stale_before = datetime.now(timezone.utc) - timedelta(days=ELEC_PROBE_TTL_DAYS) + for site in sites: + row = self.site_rows.get(site.domain) or {} + if not self.opt.reprobe and row.get("probed_at") and row["probed_at"] > stale_before: + continue + self.progress(f"Probing {site.name} ({site.domain})") + result = probe_site(site, self.client, self.engine) + repo.set_probe_result(site.domain, result["outcome"], result["robots_allowed"], result["evidence"]) + row.update(probe_outcome=result["outcome"]) + self.site_rows[site.domain] = row + self.stats.inc(f"probe_{result['outcome']}") + self.progress(f" -> grade {result['outcome']}: {result['evidence'].get('reason')}") + + # -- discovery ----------------------------------------------------------- + def _accept_hit(self, hit: SearchHit, brand: BrandRef) -> Optional[Tuple[SiteRef, ParsedTitle]]: + site = site_for_url(hit.url) + if site is None or not self._site_allowed_for(site, brand): + return None + if not is_product_url(site, hit.url): + return None + title = clean_result_title(hit.title) + if not looks_like_device_title(title) or _OFF_CATEGORY[self.opt.category].search(title): + return None + parsed = parse_title(title, self.opt.category, expected_brand=brand.slug) + if parsed.brand is None or not parsed.model_norm: + return None + fill_from_context(parsed, self.opt.category, snippet=hit.snippet or "") + return site, parsed + + def _site_allowed_for(self, site: SiteRef, brand: BrandRef) -> bool: + if site.kind == "brand_official": + return site.brand_slug == brand.slug + return True + + def _platforms_for(self, brand: BrandRef) -> List[SiteRef]: + return [s for s in self.ref.sites.values() + if s.kind != "brand_official" or s.brand_slug == brand.slug] + + def _search_names(self, brand: BrandRef) -> List[str]: + """The brand, plus the sub-brands phones are actually sold under + ("Redmi", "POCO", "iQOO") - a search for "Xiaomi" alone misses most + Redmi listings.""" + names = [brand.name] + if self.opt.category == "mobiles": + names += [s.upper() if len(s) <= 4 else s.title() for s in brand.sub_brands + if s not in ("mi", "iphone", "pixel", "narzo")][:2] + return names + + def _collect_hits(self, hits: Optional[List[SearchHit]], brand: BrandRef, query: str, + found: Dict, models: Dict[str, ParsedTitle]) -> int: + new = 0 + for hit in hits or []: + accepted = self._accept_hit(hit, brand) + if not accepted: + continue + site_ref, parsed = accepted + key = (site_ref.domain, source_sku(site_ref, hit.url)) + if key not in found: + found[key] = (hit, site_ref, parsed, query) + new += 1 + models.setdefault(parsed.model_norm, parsed) + return new + + def discover(self, brand: BrandRef) -> Dict[Tuple[str, str], Tuple[SearchHit, SiteRef, ParsedTitle, str]]: + category = self.ref.categories[self.opt.category] + found: Dict[Tuple[str, str], Tuple[SearchHit, SiteRef, ParsedTitle, str]] = {} + models: Dict[str, ParsedTitle] = {} + per_site_target = max(4, self.opt.max_products_per_brand) + for site in self._platforms_for(brand): + got = 0 + for name in self._search_names(brand): + for terms in category.query_terms or category.search_terms: + query = f"site:{site.domain} {name} {terms}" + hits = self.engine.text(query, max_results=20) + if hits is None: + self.stats.inc("search_unavailable") + continue + got += self._collect_hits(hits, brand, query, found, models) + if got >= per_site_target: + break + if got >= per_site_target: + break + self.progress(f"{brand.name}: {len(found)} listing URLs, {len(models)} models from platform searches") + + # Cross-platform: look each variant up by name, to find the same product + # on platforms the site: searches missed. Phones are grouped by model + # line; laptops by full configuration (line + CPU + RAM + storage), + # because one laptop line is sold in dozens of configurations and only + # the exact one confirms a product. + for p in self._expansion_targets(found): + query = f"{brand.name} {p.model or p.model_norm} {self._variant_terms(p)} price" + self._collect_hits(self.engine.text(re.sub(r"\s+", " ", query), max_results=20), brand, query, found, models) + self.progress(f"{brand.name}: {len(found)} listing URLs after cross-platform search") + return found + + def _expansion_targets(self, found: Dict) -> List[ParsedTitle]: + """Which variants to look up on other platforms, most useful first: + variants seen on the most sites, then ones whose page we can read with + a price (grade A/B platforms), since one more site verifies those.""" + groups: Dict[str, Dict] = {} + for (domain, _), (_, site, parsed, _) in found.items(): + if self.opt.category == "laptops": + key = variant_key(parsed, "laptops") + if key is None: + continue + else: + key = parsed.model_norm + g = groups.setdefault(key, {"parsed": parsed, "sites": set(), "readable": False}) + g["sites"].add(domain) + g["readable"] = g["readable"] or self._grade(site) in ("A", "B") + ranked = sorted(groups.values(), key=lambda g: (-len(g["sites"]), not g["readable"])) + return [g["parsed"] for g in ranked[: self.opt.expand_per_brand]] + + @staticmethod + def _variant_terms(p: ParsedTitle) -> str: + parts = [] + if p.processor: + # "ryzen 5 7530u" -> "Ryzen 5 7530U", "i5-1334u" -> "i5-1334U" + parts.append(" ".join(t.upper() if any(ch.isdigit() for ch in t) and len(t) > 2 else t.title() + for t in p.processor.split())) + if p.ram_gb: + parts.append(f"{format(p.ram_gb.normalize(), 'f')}GB RAM") + if p.storage_gb: + parts.append(f"{format(p.storage_gb.normalize(), 'f')}GB") + return " ".join(parts) + + # -- listing construction -------------------------------------------------- + def _base_listing(self, site: SiteRef, url: str, parsed: ParsedTitle, title: str, source_type: str, + evidence: str, confidence: float, parser: str, query: str) -> Listing: + l = Listing( + site_domain=site.domain, source_sku=source_sku(site, url), source_url=url, source_type=source_type, + brand_slug=parsed.brand.brand_slug, category=self.opt.category, title=title, + evidence_text=evidence, confidence=confidence, parser=parser, family=parsed.brand.family, + model=parsed.model, model_number=parsed.mpn, ram_gb=parsed.ram_gb, storage_gb=parsed.storage_gb, + colour=parsed.colour, search_query=query, + ) + l.model_norm = parsed.model_norm + l.processor = parsed.processor + l.variant_key = variant_key(parsed, self.opt.category) + return l + + def listing_from_search(self, hit: SearchHit, site: SiteRef, parsed: ParsedTitle, query: str) -> Listing: + title = clean_result_title(hit.title) + evidence = f"{hit.title} — {hit.snippet}".strip(" —") + reading = read_price(f"{hit.title} {hit.snippet}") + price, mrp = reading.price, reading.mrp + # A snippet that names a different RAM/storage than the title is about + # another variant; its price cannot be trusted for this one. + snippet_variant = parse_title(hit.snippet or "", self.opt.category) + for a, b in ((snippet_variant.storage_gb, parsed.storage_gb), (snippet_variant.ram_gb, parsed.ram_gb)): + if a is not None and b is not None and a != b: + price = mrp = None + self.stats.inc("snippet_price_variant_conflict") + # Truncated titles ("... - (16 GB ...") lose the variant; the snippet + # of the same result usually states it. + fill_from_context(parsed, self.opt.category, snippet=hit.snippet or "") + parser, confidence = f"serp:{hit.provider}", (0.55 if price is not None else 0.45) + in_stock = read_stock(hit.snippet or "") + availability = None if in_stock is None else ("InStock" if in_stock else "OutOfStock") + # Structured offer the search engine read from the page itself (Google + # pagemap). Better than snippet text, and still no request to the site. + if hit.offer and price is None: + try: + offered = Decimal(str(hit.offer["price"]).replace(",", "")) + except Exception: # noqa: BLE001 + offered = None + if offered is not None and Decimal(500) <= offered <= Decimal(1000000): + price, mrp = offered, None + evidence = f"{evidence} || search-engine offer data: {json.dumps(hit.offer['raw'], default=str)[:600]}" + parser, confidence = f"serp:{hit.provider}:pagemap", 0.65 + av = (hit.offer.get("availability") or "").lower().replace(" ", "") + if "instock" in av: + in_stock, availability = True, "InStock" + elif "outofstock" in av or "soldout" in av: + in_stock, availability = False, "OutOfStock" + # Rating: the engine's structured data first (read from the page + # itself), else an explicit "x out of 5" in this result's own text. + rating, review_count = None, None + if hit.rating and hit.rating.get("rating") is not None: + rating = Decimal(str(hit.rating["rating"])) + review_count = hit.rating.get("review_count") + evidence = f"{evidence} || search-engine rating data: {json.dumps(hit.rating.get('raw'), default=str)[:300]}" + else: + stated = read_rating(f"{hit.title} {hit.snippet}") + if stated.rating is not None: + rating, review_count = stated.rating, stated.review_count + listing = self._base_listing(site, hit.url, parsed, title, "search_snippet", evidence, + confidence, parser, query) + listing.price, listing.mrp = price, mrp + listing.in_stock, listing.availability = in_stock, availability + listing.rating, listing.review_count = rating, review_count + return listing + + def listing_from_page(self, hit: SearchHit, site: SiteRef, parsed_hit: ParsedTitle, query: str, + html: str, final_url: str) -> Optional[Listing]: + products = extract_products(html) + page = extract_page(html) + name = None + product = None + for p in products: + pp = parse_title(p["name"], self.opt.category, expected_brand=parsed_hit.brand.brand_slug) + if pp.brand and pp.model_norm and fuzz.token_set_ratio(pp.model_norm, parsed_hit.model_norm) >= 85: + product, name = p, p["name"] + break + if name is None: + name = page.get("name") + if not name: + return None + parsed = parse_title(name, self.opt.category, expected_brand=parsed_hit.brand.brand_slug) + if parsed.brand is None or not parsed.model_norm: + return None + if fuzz.token_set_ratio(parsed.model_norm, parsed_hit.model_norm) < 85: + # The URL did not lead to the product the search result named. + self.stats.inc("page_title_mismatch") + return None + # Fill variant fields the page name leaves out from the search title + # of the same URL (both are statements by the same site). + for attr in ("ram_gb", "storage_gb", "colour", "mpn", "processor"): + if getattr(parsed, attr) is None and getattr(parsed_hit, attr) is not None: + setattr(parsed, attr, getattr(parsed_hit, attr)) + + grade = self._grade(site) + source_type = "brand_official" if site.kind == "brand_official" else "scraped_page" + if product is not None: + price = product.get("price") + currency = product.get("currency") + evidence = product["evidence"] + parser = "jsonld" + else: + price, currency, evidence, parser = page.get("price"), page.get("currency"), page.get("evidence") or "", "html_meta" + if currency not in (None, "INR"): + price = None + if currency is None and site.kind == "brand_official": + price = None # a brand's global site may not be quoting rupees + if price is not None and not (Decimal(500) <= price <= Decimal(1000000)): + price = None + evidence = evidence or f"{name} ({final_url})" + listing = self._base_listing( + site, final_url if final_url.startswith("http") else hit.url, parsed, name, source_type, + evidence, 0.9 if (price is not None and parser == "jsonld") else 0.75, f"{parser}:grade{grade}", query, + ) + # The site's own SKU when the page states it; otherwise its canonical URL. + listing.source_sku = ((product or {}).get("sku") or source_sku(site, final_url or hit.url))[:300] + listing.price = price + listing.in_stock = (product or page).get("in_stock") + listing.availability = (product or page).get("availability") + listing.image_urls = list(dict.fromkeys((product or {}).get("images", []) + page.get("images", [])))[:8] + if product: + listing.gtin = product.get("gtin") + listing.model_number = listing.model_number or product.get("mpn") + listing.rating = product.get("rating") + listing.review_count = product.get("review_count") + listing.reviews = list(product.get("reviews") or []) + listing.colour = listing.colour or product.get("color") + raw_specs = dict((product or {}).get("properties") or {}) + raw_specs.update({k: v for k, v in spec_tables(BeautifulSoup(html, "lxml")).items() if k not in raw_specs}) + listing.specs_raw = dict(list(raw_specs.items())[:150]) + listing.specs, listing.spec_sources = normalise_specs(self.opt.category, raw_specs) + if self.opt.use_llm: + wanted = [k for k in self.ref.spec_keys.get(self.opt.category, {}) if k not in listing.specs] + if wanted: + text = "\n".join(f"{k}: {v}" for k, v in raw_specs.items()) or visible_text(html, 3500) + extra, extra_src = fill_missing(self.opt.category, text, wanted) + listing.specs.update(extra) + listing.spec_sources.update(extra_src) + self.stats.inc("llm_specs_kept", len(extra)) + if self.opt.category == "laptops": + # "13th Gen Intel Core i7/ 16GB RAM" in a title names no CPU model; + # the page's own spec table usually does. + spec_texts = tuple(str(v) for k, v in raw_specs.items() if "processor" in k.lower() or "cpu" in k.lower()) + spec_texts += (str(listing.specs.get("processor") or ""),) + before = parsed.processor + fill_from_context(parsed, self.opt.category, spec_texts=spec_texts) + if parsed.processor != before: + listing.processor = parsed.processor + listing.variant_key = variant_key(parsed, self.opt.category) + listing.content_hash = hashlib.sha1(html.encode("utf-8", "ignore")).hexdigest() + return listing + + # -- persistence ----------------------------------------------------------- + def store(self, listing: Listing) -> Optional[int]: + try: + listing_id = repo.upsert_listing(listing, self.ids, self.run_id) + except ValueError as exc: + self.stats.inc("rejected_listing") + logger.info("Listing rejected (%s): %s", exc, listing.source_url) + return None + self.stats.inc(f"listing_{listing.source_type}") + if listing.reviews: + self.stats.inc("reviews_stored", repo.save_reviews(listing_id, listing.reviews)) + if listing.price is not None: + self.stats.inc("listing_with_price") + decision = decide(listing, repo.product_candidates(listing.brand_slug, listing.category)) + if decision is None: + self.stats.inc("listing_unmatched_no_variant") + return listing_id + product_id = decision.product_id or repo.create_product(listing, self.ids) + if decision.product_id is None: + self.stats.inc("product_created") + repo.map_listing(listing_id, product_id, decision.method, decision.confidence, decision.review_status) + if decision.review_status == "pending": + self.stats.inc("match_pending_review") + else: + repo.merge_product_specs(product_id, listing.specs, listing.spec_sources, listing.source_url) + self._touched_products.setdefault(product_id, []).append((listing_id, listing)) + return listing_id + + # -- images ---------------------------------------------------------------- + def attach_images(self) -> None: + rank_for = {"brand_official": 10, "scraped_page": 20, "search_snippet": 50} + for product_id, entries in self._touched_products.items(): + if repo.product_image_count(product_id) >= 3: + continue + added = 0 + for listing_id, listing in sorted(entries, key=lambda e: rank_for[e[1].source_type]): + for url in listing.image_urls: + if added >= 3: + break + if self.client.check_image(url, MIN_IMAGE_BYTES): + repo.add_image(product_id, url, listing_id, listing.source_type, rank_for[listing.source_type]) + added += 1 + self.stats.inc("images_from_pages") + if added or not self.opt.find_images: + continue + added = self._images_from_search(product_id, entries) + self.stats.inc("images_from_search", added) + + def _images_from_search(self, product_id: int, entries: List[Tuple[int, Listing]]) -> int: + """Image search results are used only when the page an image sits on is + one of THIS product's own listings (same site, same product), and the + image result's title names the model.""" + _, listing = entries[0] + brand = self.ref.brands[listing.brand_slug] + listing_by_site = {l.site_domain: lid for lid, l in entries} + hits = self.engine.images(f"{brand.name} {listing.model or listing.model_norm}", max_results=15) or [] + added = 0 + for hit in hits: + site = site_for_url(hit.url) + if site is None or site.domain not in listing_by_site: + continue + parsed = parse_title(clean_result_title(hit.title), listing.category, expected_brand=brand.slug) + if not parsed.model_norm or fuzz.token_set_ratio(parsed.model_norm, listing.model_norm or "") < 90: + continue + if self.client.check_image(hit.image_url, MIN_IMAGE_BYTES): + repo.add_image(product_id, hit.image_url, listing_by_site[site.domain], "search_image", 60) + added += 1 + if added >= 2: + break + return added + + # -- embeddings ------------------------------------------------------------ + def embed(self) -> int: + return embed_verified_products() + + # -- the run --------------------------------------------------------------- + def run(self, *, embed: bool = True) -> Dict[str, int]: + self.run_id = repo.start_run("collect", { + "category": self.opt.category, "brands": self.opt.brands, + "max_products_per_brand": self.opt.max_products_per_brand, + "search_budget": self.opt.search_budget, + }) + status, error = "done", None + try: + brands = [self.ref.brands[b] for b in self.opt.brands] + probe_targets = {s.domain: s for b in brands for s in self._platforms_for(b) if s.policy == "probe"} + if self.opt.fetch_pages: + self.ensure_probes(list(probe_targets.values())) + for brand in brands: + found = self.discover(brand) + # Keep the most common models first, up to the per-brand limit. + by_model: Dict[str, int] = {} + for (_, _), (_, _, parsed, _) in found.items(): + by_model[parsed.model_norm] = by_model.get(parsed.model_norm, 0) + 1 + keep = set(sorted(by_model, key=lambda m: -by_model[m])[: self.opt.max_products_per_brand]) + for (domain, _sku), (hit, site, parsed, query) in found.items(): + if parsed.model_norm not in keep: + continue + listing = None + if self.opt.fetch_pages and self._grade(site) in ("A", "B"): + res = self.client.get(hit.url) + if res.ok: + listing = self.listing_from_page(hit, site, parsed, query, res.text, res.final_url) + if listing is None: + self.stats.inc("page_unusable_fell_back_to_search") + if listing is None: + listing = self.listing_from_search(hit, site, parsed, query) + self.store(listing) + self.progress(f"{brand.name}: stored listings; {self.stats.counts}") + self.attach_images() + verification = repo.refresh_verification() + self.stats.counts.update({f"products_{k}": v for k, v in verification.items()}) + if embed: + try: + self.stats.inc("embedded", self.embed()) + except Exception as exc: # noqa: BLE001 - embeddings are optional + logger.warning("Embedding step skipped: %s", exc) + self.stats.counts.update({f"search_{k}": v for k, v in self.engine.stats.items()}) + except Exception as exc: + status, error = "failed", repr(exc) + logger.exception("Collection run failed") + raise + finally: + repo.finish_run(self.run_id, status, self.stats.counts, error) + self.client.close() + return self.stats.counts diff --git a/backend/app/electronics/db/__init__.py b/backend/app/electronics/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/electronics/db/connection.py b/backend/app/electronics/db/connection.py new file mode 100644 index 0000000..06dc096 --- /dev/null +++ b/backend/app/electronics/db/connection.py @@ -0,0 +1,68 @@ +"""Connections to the local electronics database. + +settings.py has already refused to load unless DB_HOST is local and DB_NAME is +electronics_catalog, so nothing here can reach another database. +""" +from __future__ import annotations + +import logging +from contextlib import contextmanager +from typing import Iterator + +import psycopg +from psycopg.rows import dict_row + +from app.infrastructure.settings import ( + DB_CONNECT_TIMEOUT_SECONDS, + DB_HOST, + DB_NAME, + DB_PASSWORD, + DB_PORT, + DB_USER, +) + +logger = logging.getLogger(__name__) + + +def connect(*, autocommit: bool = False) -> psycopg.Connection: + conn = psycopg.connect( + host=DB_HOST, + port=DB_PORT, + dbname=DB_NAME, + user=DB_USER, + password=DB_PASSWORD, + connect_timeout=DB_CONNECT_TIMEOUT_SECONDS, + autocommit=autocommit, + row_factory=dict_row, + ) + try: + from pgvector.psycopg import register_vector + + register_vector(conn) + except Exception: # noqa: BLE001 - the extension is created by migration 0001 + pass + return conn + + +@contextmanager +def transaction() -> Iterator[psycopg.Connection]: + """A connection whose work is committed on success, rolled back on error.""" + conn = connect() + try: + yield conn + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() + + +def check_connection() -> bool: + try: + with connect(autocommit=True) as conn: + conn.execute("SELECT 1") + return True + except Exception as exc: # noqa: BLE001 - a health probe reports, never raises + logger.debug("Database unreachable: %s", exc) + return False diff --git a/backend/app/electronics/db/migrate.py b/backend/app/electronics/db/migrate.py new file mode 100644 index 0000000..38bfd26 --- /dev/null +++ b/backend/app/electronics/db/migrate.py @@ -0,0 +1,63 @@ +"""Tiny migration runner for the numbered SQL files in ./migrations. + +Each file runs once, in its own transaction, and is recorded in +elec.schema_migrations with a checksum. Editing an applied file is refused +rather than silently ignored - add a new numbered file instead. +""" +from __future__ import annotations + +import hashlib +import logging +from pathlib import Path +from typing import List + +from app.electronics.db.connection import connect + +logger = logging.getLogger(__name__) + +MIGRATIONS_DIR = Path(__file__).resolve().parent / "migrations" + +_BOOTSTRAP = """ +CREATE SCHEMA IF NOT EXISTS elec; +CREATE TABLE IF NOT EXISTS elec.schema_migrations ( + version TEXT PRIMARY KEY, + checksum TEXT NOT NULL, + applied_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +""" + + +def _files() -> List[Path]: + return sorted(MIGRATIONS_DIR.glob("[0-9][0-9][0-9][0-9]_*.sql")) + + +def run_migrations() -> List[str]: + """Apply pending migrations. Returns the versions applied by this call.""" + applied_now: List[str] = [] + with connect() as conn: + conn.execute(_BOOTSTRAP) + conn.commit() + done = { + r["version"]: r["checksum"] + for r in conn.execute("SELECT version, checksum FROM elec.schema_migrations") + } + for path in _files(): + sql = path.read_text(encoding="utf-8") + checksum = hashlib.sha256(sql.encode("utf-8")).hexdigest() + version = path.stem + if version in done: + if done[version] != checksum: + raise RuntimeError( + f"Migration {version} was edited after it was applied. " + f"Revert the edit and add a new numbered migration instead." + ) + continue + logger.info("Applying migration %s", version) + with conn.transaction(): + conn.execute(sql) + conn.execute( + "INSERT INTO elec.schema_migrations (version, checksum) VALUES (%s, %s)", + (version, checksum), + ) + applied_now.append(version) + return applied_now diff --git a/backend/app/electronics/db/migrations/0001_schema.sql b/backend/app/electronics/db/migrations/0001_schema.sql new file mode 100644 index 0000000..6c9425c --- /dev/null +++ b/backend/app/electronics/db/migrations/0001_schema.sql @@ -0,0 +1,4 @@ +-- Extensions and the dedicated schema. Everything this project owns lives in +-- schema `elec` of database `electronics_catalog`. +CREATE EXTENSION IF NOT EXISTS vector; +CREATE SCHEMA IF NOT EXISTS elec; diff --git a/backend/app/electronics/db/migrations/0002_reference.sql b/backend/app/electronics/db/migrations/0002_reference.sql new file mode 100644 index 0000000..5e2f0e9 --- /dev/null +++ b/backend/app/electronics/db/migrations/0002_reference.sql @@ -0,0 +1,57 @@ +-- Reference data: brands, categories, retail sites. Seeded from +-- app/electronics/reference/*.yaml by `elec seed-reference`. + +CREATE TABLE elec.brand ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + slug TEXT NOT NULL UNIQUE, + parent_brand_id INT REFERENCES elec.brand(id), + is_popular BOOLEAN NOT NULL DEFAULT TRUE, + official_domains TEXT[] NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Every spelling that resolves to a brand. Sub-brands (Redmi, iQOO, Pixel) +-- resolve to their parent and are remembered as the product family. +CREATE TABLE elec.brand_alias ( + alias TEXT PRIMARY KEY CHECK (alias = lower(alias)), + brand_id INT NOT NULL REFERENCES elec.brand(id) ON DELETE CASCADE, + is_sub_brand BOOLEAN NOT NULL DEFAULT FALSE +); + +CREATE TABLE elec.category ( + id SERIAL PRIMARY KEY, + slug TEXT NOT NULL UNIQUE, + name TEXT NOT NULL UNIQUE +); + +CREATE TABLE elec.brand_category ( + brand_id INT NOT NULL REFERENCES elec.brand(id) ON DELETE CASCADE, + category_id INT NOT NULL REFERENCES elec.category(id) ON DELETE CASCADE, + PRIMARY KEY (brand_id, category_id) +); + +-- A retail platform or a brand's own site, with the outcome of its probe. +-- probe_outcome A = fetchable with structured product data (scraped) +-- B = fetchable, product data from page HTML/state (scraped) +-- C = not fetched: serp_only policy, robots.txt disallow, +-- block/CAPTCHA, or unreachable -> web search only +CREATE TABLE elec.site ( + id SERIAL PRIMARY KEY, + domain TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + kind TEXT NOT NULL CHECK (kind IN ('marketplace','national_chain','tn_regional','brand_official')), + region TEXT NOT NULL CHECK (region IN ('national','TN')), + policy TEXT NOT NULL CHECK (policy IN ('probe','serp_only')), + brand_id INT REFERENCES elec.brand(id), + product_url TEXT, + pincode_param TEXT, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + probe_outcome CHAR(1) CHECK (probe_outcome IN ('A','B','C')), + robots_allowed BOOLEAN, + probe_evidence JSONB NOT NULL DEFAULT '{}'::jsonb, + probed_at TIMESTAMPTZ, + breaker_until TIMESTAMPTZ, + breaker_reason TEXT, + CHECK (kind <> 'brand_official' OR brand_id IS NOT NULL) +); diff --git a/backend/app/electronics/db/migrations/0003_observations.sql b/backend/app/electronics/db/migrations/0003_observations.sql new file mode 100644 index 0000000..37b35b1 --- /dev/null +++ b/backend/app/electronics/db/migrations/0003_observations.sql @@ -0,0 +1,160 @@ +-- Runs, fetch audit trail, search cache, listings, prices, canonical products. + +CREATE TABLE elec.crawl_run ( + id BIGSERIAL PRIMARY KEY, + kind TEXT NOT NULL, + params JSONB NOT NULL DEFAULT '{}'::jsonb, + status TEXT NOT NULL DEFAULT 'running' CHECK (status IN ('running','done','failed')), + stats JSONB NOT NULL DEFAULT '{}'::jsonb, + error TEXT, + started_at TIMESTAMPTZ NOT NULL DEFAULT now(), + ended_at TIMESTAMPTZ +); + +-- Every HTTP request made to a retail or brand site. Evidence that the +-- crawler obeyed robots.txt and its rate limits. +CREATE TABLE elec.fetch_log ( + id BIGSERIAL PRIMARY KEY, + crawl_run_id BIGINT REFERENCES elec.crawl_run(id) ON DELETE SET NULL, + url TEXT NOT NULL, + host TEXT NOT NULL, + status INT, + bytes INT, + outcome TEXT NOT NULL, + robots_allowed BOOLEAN, + fetched_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX fetch_log_host_time ON elec.fetch_log (host, fetched_at DESC); + +CREATE TABLE elec.search_cache ( + provider TEXT NOT NULL, + kind TEXT NOT NULL CHECK (kind IN ('text','images')), + query TEXT NOT NULL, + results JSONB NOT NULL, + fetched_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (provider, kind, query) +); + +-- One canonical product = one real-world variant (model + RAM + storage). +-- verification_status becomes 'verified' only when the product has a brand +-- official page, or listings on at least two different sites. +CREATE TABLE elec.product ( + id BIGSERIAL PRIMARY KEY, + brand_id INT NOT NULL REFERENCES elec.brand(id), + category_id INT NOT NULL REFERENCES elec.category(id), + family TEXT, + model TEXT NOT NULL, + model_norm TEXT NOT NULL, + variant_key TEXT NOT NULL UNIQUE, + display_name TEXT NOT NULL, + ram_gb NUMERIC(6,1), + storage_gb NUMERIC(7,1), + processor TEXT, + mpn TEXT, + gtin TEXT, + canonical_specs JSONB NOT NULL DEFAULT '{}'::jsonb, + spec_sources JSONB NOT NULL DEFAULT '{}'::jsonb, + verification_status TEXT NOT NULL DEFAULT 'unverified' + CHECK (verification_status IN ('verified','unverified','rejected')), + evidence_count INT NOT NULL DEFAULT 0, + embedding vector(384), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX product_brand_cat ON elec.product (brand_id, category_id); +CREATE INDEX product_specs_gin ON elec.product USING GIN (canonical_specs); +CREATE INDEX product_embedding_hnsw ON elec.product USING hnsw (embedding vector_cosine_ops); + +-- The latest state of one product page on one site, or of one search result +-- that points at such a page. Nothing is stored without the URL it came from +-- and the text that the values were read from. +CREATE TABLE elec.source_listing ( + id BIGSERIAL PRIMARY KEY, + site_id INT NOT NULL REFERENCES elec.site(id), + source_sku TEXT NOT NULL, + source_url TEXT NOT NULL CHECK (source_url ~ '^https?://'), + source_type TEXT NOT NULL CHECK (source_type IN ('scraped_page','search_snippet','brand_official')), + brand_id INT NOT NULL REFERENCES elec.brand(id), + category_id INT NOT NULL REFERENCES elec.category(id), + family TEXT, + title TEXT NOT NULL, + model TEXT, + model_number TEXT, + ram_gb NUMERIC(6,1), + storage_gb NUMERIC(7,1), + colour TEXT, + price NUMERIC(12,2) CHECK (price IS NULL OR price BETWEEN 500 AND 1000000), + mrp NUMERIC(12,2) CHECK (mrp IS NULL OR mrp BETWEEN 500 AND 1000000), + currency TEXT NOT NULL DEFAULT 'INR' CHECK (currency = 'INR'), + availability TEXT, + in_stock BOOLEAN, + pincode TEXT, + pincode_applied BOOLEAN NOT NULL DEFAULT FALSE, + rating NUMERIC(3,2) CHECK (rating IS NULL OR rating BETWEEN 0 AND 5), + review_count INT, + gtin TEXT, + image_urls TEXT[] NOT NULL DEFAULT '{}', + specs_raw JSONB NOT NULL DEFAULT '{}'::jsonb, + specs JSONB NOT NULL DEFAULT '{}'::jsonb, + evidence_text TEXT NOT NULL CHECK (length(evidence_text) > 0), + search_query TEXT, + confidence NUMERIC(3,2) NOT NULL CHECK (confidence BETWEEN 0 AND 1), + parser TEXT NOT NULL, + content_hash TEXT, + first_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(), + crawl_run_id BIGINT REFERENCES elec.crawl_run(id) ON DELETE SET NULL, + UNIQUE (site_id, source_sku), + CHECK (pincode_applied = FALSE OR pincode IS NOT NULL) +); +CREATE INDEX listing_brand_cat ON elec.source_listing (brand_id, category_id); + +-- Append-only price observations. UPDATE is refused by a trigger. +CREATE TABLE elec.price_history ( + id BIGSERIAL PRIMARY KEY, + listing_id BIGINT NOT NULL REFERENCES elec.source_listing(id) ON DELETE CASCADE, + price NUMERIC(12,2) CHECK (price IS NULL OR price BETWEEN 500 AND 1000000), + mrp NUMERIC(12,2), + availability TEXT, + in_stock BOOLEAN, + source_type TEXT NOT NULL, + pincode TEXT, + pincode_applied BOOLEAN NOT NULL DEFAULT FALSE, + evidence_text TEXT NOT NULL CHECK (length(evidence_text) > 0), + observed_at TIMESTAMPTZ NOT NULL DEFAULT now(), + crawl_run_id BIGINT REFERENCES elec.crawl_run(id) ON DELETE SET NULL +); +CREATE INDEX price_history_listing_time ON elec.price_history (listing_id, observed_at DESC); + +CREATE FUNCTION elec.refuse_update() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + RAISE EXCEPTION 'elec.price_history is append-only'; +END $$; +CREATE TRIGGER price_history_append_only BEFORE UPDATE ON elec.price_history + FOR EACH ROW EXECUTE FUNCTION elec.refuse_update(); + +-- Which canonical product a listing belongs to, and how sure we are. +-- Only 'auto' and 'approved' links count as evidence or appear in views. +CREATE TABLE elec.product_listing_map ( + listing_id BIGINT PRIMARY KEY REFERENCES elec.source_listing(id) ON DELETE CASCADE, + product_id BIGINT NOT NULL REFERENCES elec.product(id) ON DELETE CASCADE, + method TEXT NOT NULL CHECK (method IN ('gtin','mpn','variant_key','fuzzy','manual')), + confidence NUMERIC(3,2) NOT NULL CHECK (confidence BETWEEN 0 AND 1), + review_status TEXT NOT NULL CHECK (review_status IN ('auto','pending','approved','rejected')), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + reviewed_at TIMESTAMPTZ +); +CREATE INDEX map_product ON elec.product_listing_map (product_id); + +-- Images are URLs only (never downloaded), each tied to the listing it was +-- found on and checked live. +CREATE TABLE elec.product_image ( + id BIGSERIAL PRIMARY KEY, + product_id BIGINT NOT NULL REFERENCES elec.product(id) ON DELETE CASCADE, + url TEXT NOT NULL CHECK (url ~ '^https?://'), + source_listing_id BIGINT NOT NULL REFERENCES elec.source_listing(id) ON DELETE CASCADE, + source_type TEXT NOT NULL, + rank INT NOT NULL DEFAULT 100, + validated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (product_id, url) +); diff --git a/backend/app/electronics/db/migrations/0004_views.sql b/backend/app/electronics/db/migrations/0004_views.sql new file mode 100644 index 0000000..afaed4d --- /dev/null +++ b/backend/app/electronics/db/migrations/0004_views.sql @@ -0,0 +1,71 @@ +-- Read-side views. Public views only ever show VERIFIED products and links +-- that are 'auto' or 'approved'. + +CREATE VIEW elec.v_product_availability AS +SELECT p.id AS product_id, + b.name AS brand, + c.slug AS category, + p.display_name, + s.id AS site_id, + s.name AS site, + s.domain, + s.kind AS site_kind, + s.region AS site_region, + l.id AS listing_id, + l.source_url, + l.source_type, + l.title AS listing_title, + l.colour, + l.price, + l.mrp, + l.in_stock, + l.availability, + l.pincode, + l.pincode_applied, + l.confidence, + l.last_seen_at AS observed_at +FROM elec.product p +JOIN elec.brand b ON b.id = p.brand_id +JOIN elec.category c ON c.id = p.category_id +JOIN elec.product_listing_map m ON m.product_id = p.id AND m.review_status IN ('auto','approved') +JOIN elec.source_listing l ON l.id = m.listing_id +JOIN elec.site s ON s.id = l.site_id +WHERE p.verification_status = 'verified'; + +-- Cheapest known price per product. Scraped prices are preferred over search +-- snippet prices; a listing known to be out of stock is skipped. +CREATE VIEW elec.v_best_price AS +SELECT DISTINCT ON (product_id) + product_id, site, domain, source_url, source_type, price, mrp, in_stock, observed_at +FROM elec.v_product_availability +WHERE price IS NOT NULL AND in_stock IS DISTINCT FROM FALSE +ORDER BY product_id, (source_type = 'search_snippet'), price, observed_at DESC; + +CREATE VIEW elec.v_brand_catalog AS +SELECT p.id AS product_id, b.name AS brand, b.slug AS brand_slug, c.slug AS category, + p.family, p.display_name, p.model, p.ram_gb, p.storage_gb, p.processor, + p.canonical_specs, + bp.price AS best_price, + bp.site AS best_price_site, + bp.source_type AS best_price_source_type, + (SELECT count(DISTINCT a.site_id) FROM elec.v_product_availability a + WHERE a.product_id = p.id) AS platform_count, + (SELECT coalesce(bool_or(a.site_region = 'TN'), FALSE) FROM elec.v_product_availability a + WHERE a.product_id = p.id) AS sold_by_tn_retailer, + (SELECT i.url FROM elec.product_image i WHERE i.product_id = p.id + ORDER BY i.rank, i.id LIMIT 1) AS image_url, + p.updated_at +FROM elec.product p +JOIN elec.brand b ON b.id = p.brand_id +JOIN elec.category c ON c.id = p.category_id +LEFT JOIN elec.v_best_price bp ON bp.product_id = p.id +WHERE p.verification_status = 'verified'; + +CREATE VIEW elec.v_brand_summary AS +SELECT brand, brand_slug, category, + count(*) AS product_count, + min(best_price) AS min_price, + max(best_price) AS max_price, + max(platform_count) AS max_platforms +FROM elec.v_brand_catalog +GROUP BY brand, brand_slug, category; diff --git a/backend/app/electronics/db/migrations/0005_price_outliers.sql b/backend/app/electronics/db/migrations/0005_price_outliers.sql new file mode 100644 index 0000000..f71ff40 --- /dev/null +++ b/backend/app/electronics/db/migrations/0005_price_outliers.sql @@ -0,0 +1,44 @@ +-- Search results carry cached, sometimes seller-specific prices. A price that +-- disagrees sharply with the product-page price for the same product (or is +-- below what the category can cost) is kept with its evidence but flagged, and +-- is never used as the "best price". Set by repository.flag_price_outliers(). +ALTER TABLE elec.source_listing ADD COLUMN price_outlier BOOLEAN NOT NULL DEFAULT FALSE; + +CREATE OR REPLACE VIEW elec.v_product_availability AS +SELECT p.id AS product_id, + b.name AS brand, + c.slug AS category, + p.display_name, + s.id AS site_id, + s.name AS site, + s.domain, + s.kind AS site_kind, + s.region AS site_region, + l.id AS listing_id, + l.source_url, + l.source_type, + l.title AS listing_title, + l.colour, + l.price, + l.mrp, + l.in_stock, + l.availability, + l.pincode, + l.pincode_applied, + l.confidence, + l.last_seen_at AS observed_at, + l.price_outlier +FROM elec.product p +JOIN elec.brand b ON b.id = p.brand_id +JOIN elec.category c ON c.id = p.category_id +JOIN elec.product_listing_map m ON m.product_id = p.id AND m.review_status IN ('auto','approved') +JOIN elec.source_listing l ON l.id = m.listing_id +JOIN elec.site s ON s.id = l.site_id +WHERE p.verification_status = 'verified'; + +CREATE OR REPLACE VIEW elec.v_best_price AS +SELECT DISTINCT ON (product_id) + product_id, site, domain, source_url, source_type, price, mrp, in_stock, observed_at +FROM elec.v_product_availability +WHERE price IS NOT NULL AND NOT price_outlier AND in_stock IS DISTINCT FROM FALSE +ORDER BY product_id, (source_type = 'search_snippet'), price, observed_at DESC; diff --git a/backend/app/electronics/db/migrations/0006_reviews_and_price.sql b/backend/app/electronics/db/migrations/0006_reviews_and_price.sql new file mode 100644 index 0000000..96e5037 --- /dev/null +++ b/backend/app/electronics/db/migrations/0006_reviews_and_price.sql @@ -0,0 +1,30 @@ +-- Best price: a product whose every priced listing is out of stock still has a +-- price worth showing. In-stock (or unknown-stock) prices still win; an +-- out-of-stock price is used only when nothing else is priced. Same columns +-- as 0005, so v_brand_catalog keeps working unchanged. +CREATE OR REPLACE VIEW elec.v_best_price AS +SELECT DISTINCT ON (product_id) + product_id, site, domain, source_url, source_type, price, mrp, in_stock, observed_at +FROM elec.v_product_availability +WHERE price IS NOT NULL AND NOT price_outlier +ORDER BY product_id, (in_stock IS FALSE), (source_type = 'search_snippet'), price, observed_at DESC; + +-- Individual customer reviews, exactly as a product page publishes them in its +-- schema.org JSON-LD. Nothing here is generated: every row is a review the +-- listing's own page stated. Sentiment is derived only from the reviewer's +-- own star rating (>=4 positive, >=3 neutral, <3 negative); NULL when the +-- review states no rating. +CREATE TABLE elec.listing_review ( + id BIGSERIAL PRIMARY KEY, + listing_id BIGINT NOT NULL REFERENCES elec.source_listing(id) ON DELETE CASCADE, + author TEXT, + rating NUMERIC(2,1) CHECK (rating IS NULL OR rating BETWEEN 0 AND 5), + title TEXT, + body TEXT NOT NULL, + review_date TEXT, + sentiment TEXT CHECK (sentiment IS NULL OR sentiment IN ('positive','neutral','negative')), + content_hash TEXT NOT NULL, + fetched_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (listing_id, content_hash) +); +CREATE INDEX listing_review_listing_idx ON elec.listing_review (listing_id); diff --git a/backend/app/electronics/db/repository.py b/backend/app/electronics/db/repository.py new file mode 100644 index 0000000..9c09c55 --- /dev/null +++ b/backend/app/electronics/db/repository.py @@ -0,0 +1,588 @@ +"""All SQL used by the pipeline. psycopg3, no ORM - the same style as the +original project, with each function owning one statement or one small unit +of work.""" +from __future__ import annotations + +import hashlib +import json +import re +from datetime import datetime, timezone +from decimal import Decimal +from typing import Any, Dict, List, Optional + +from psycopg.types.json import Jsonb + +from app.electronics.db.connection import connect, transaction +from app.electronics.models import Listing +from app.electronics.reference import Reference, slugify + + +def _json(value: Any) -> Jsonb: + return Jsonb(json.loads(json.dumps(value, default=str))) + + +# --------------------------------------------------------------------------- +# Reference data +# --------------------------------------------------------------------------- +def seed_reference(ref: Reference) -> Dict[str, int]: + """Idempotent upsert of brands, aliases, categories and sites.""" + counts = {"brands": 0, "aliases": 0, "categories": 0, "sites": 0} + with transaction() as conn: + for c in ref.categories.values(): + conn.execute( + "INSERT INTO elec.category (slug, name) VALUES (%s, %s) " + "ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name", + (c.slug, c.name), + ) + counts["categories"] += 1 + for b in ref.brands.values(): + row = conn.execute( + "INSERT INTO elec.brand (name, slug, official_domains) VALUES (%s, %s, %s) " + "ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, official_domains = EXCLUDED.official_domains " + "RETURNING id", + (b.name, b.slug, list(b.official)), + ).fetchone() + counts["brands"] += 1 + for alias in b.aliases: + conn.execute( + "INSERT INTO elec.brand_alias (alias, brand_id, is_sub_brand) VALUES (%s, %s, FALSE) " + "ON CONFLICT (alias) DO UPDATE SET brand_id = EXCLUDED.brand_id, is_sub_brand = FALSE", + (alias, row["id"]), + ) + counts["aliases"] += 1 + for sub in b.sub_brands: + conn.execute( + "INSERT INTO elec.brand_alias (alias, brand_id, is_sub_brand) VALUES (%s, %s, TRUE) " + "ON CONFLICT (alias) DO UPDATE SET brand_id = EXCLUDED.brand_id, is_sub_brand = TRUE", + (sub, row["id"]), + ) + counts["aliases"] += 1 + for cat in b.categories: + conn.execute( + "INSERT INTO elec.brand_category (brand_id, category_id) " + "SELECT %s, id FROM elec.category WHERE slug = %s ON CONFLICT DO NOTHING", + (row["id"], cat), + ) + for s in ref.sites.values(): + conn.execute( + """ + INSERT INTO elec.site (domain, name, kind, region, policy, brand_id, product_url, pincode_param) + VALUES (%s, %s, %s, %s, %s, (SELECT id FROM elec.brand WHERE slug = %s), %s, %s) + ON CONFLICT (domain) DO UPDATE SET + name = EXCLUDED.name, kind = EXCLUDED.kind, region = EXCLUDED.region, + policy = EXCLUDED.policy, brand_id = EXCLUDED.brand_id, + product_url = EXCLUDED.product_url, pincode_param = EXCLUDED.pincode_param + """, + (s.domain, s.name, s.kind, s.region, s.policy, s.brand_slug, s.product_url, s.pincode_param), + ) + counts["sites"] += 1 + return counts + + +def id_maps() -> Dict[str, Dict[str, int]]: + with connect() as conn: + return { + "brand": {r["slug"]: r["id"] for r in conn.execute("SELECT id, slug FROM elec.brand")}, + "category": {r["slug"]: r["id"] for r in conn.execute("SELECT id, slug FROM elec.category")}, + "site": {r["domain"]: r["id"] for r in conn.execute("SELECT id, domain FROM elec.site")}, + } + + +def sites() -> List[dict]: + with connect() as conn: + return list(conn.execute("SELECT * FROM elec.site ORDER BY kind, name")) + + +def set_probe_result(domain: str, outcome: str, robots_allowed: Optional[bool], evidence: dict) -> None: + with transaction() as conn: + conn.execute( + "UPDATE elec.site SET probe_outcome = %s, robots_allowed = %s, probe_evidence = %s, probed_at = now() " + "WHERE domain = %s", + (outcome, robots_allowed, _json(evidence), domain), + ) + + +def trip_breaker(domain: str, reason: str, until_epoch: float) -> None: + with transaction() as conn: + conn.execute( + "UPDATE elec.site SET breaker_until = to_timestamp(%s), breaker_reason = %s, " + "probe_outcome = 'C' WHERE domain = %s OR %s LIKE '%%.' || domain", + (until_epoch, reason, domain, domain), + ) + + +# --------------------------------------------------------------------------- +# Runs and fetch log +# --------------------------------------------------------------------------- +def start_run(kind: str, params: dict) -> int: + with transaction() as conn: + return conn.execute( + "INSERT INTO elec.crawl_run (kind, params) VALUES (%s, %s) RETURNING id", (kind, _json(params)) + ).fetchone()["id"] + + +def finish_run(run_id: int, status: str, stats: dict, error: Optional[str] = None) -> None: + with transaction() as conn: + conn.execute( + "UPDATE elec.crawl_run SET status = %s, stats = %s, error = %s, ended_at = now() WHERE id = %s", + (status, _json(stats), error, run_id), + ) + + +def log_fetch(run_id: Optional[int], url: str, host: str, status: Optional[int], nbytes: int, + outcome: str, robots_allowed: Optional[bool]) -> None: + with transaction() as conn: + conn.execute( + "INSERT INTO elec.fetch_log (crawl_run_id, url, host, status, bytes, outcome, robots_allowed) " + "VALUES (%s, %s, %s, %s, %s, %s, %s)", + (run_id, url, host, status, nbytes, outcome, robots_allowed), + ) + + +def recent_runs(limit: int = 20) -> List[dict]: + with connect() as conn: + return list(conn.execute("SELECT * FROM elec.crawl_run ORDER BY id DESC LIMIT %s", (limit,))) + + +# --------------------------------------------------------------------------- +# Search cache +# --------------------------------------------------------------------------- +def search_cache_get(provider: str, kind: str, query: str, ttl_hours: int) -> Optional[List[dict]]: + with connect() as conn: + row = conn.execute( + "SELECT results FROM elec.search_cache WHERE provider = %s AND kind = %s AND query = %s " + "AND fetched_at > now() - make_interval(hours => %s)", + (provider, kind, query, ttl_hours), + ).fetchone() + return row["results"] if row else None + + +def search_cache_put(provider: str, kind: str, query: str, results: List[dict]) -> None: + with transaction() as conn: + conn.execute( + "INSERT INTO elec.search_cache (provider, kind, query, results) VALUES (%s, %s, %s, %s) " + "ON CONFLICT (provider, kind, query) DO UPDATE SET results = EXCLUDED.results, fetched_at = now()", + (provider, kind, query, _json(results)), + ) + + +def google_queries_today() -> int: + with connect() as conn: + return conn.execute( + "SELECT count(*) AS n FROM elec.search_cache WHERE provider = 'google' AND fetched_at::date = current_date" + ).fetchone()["n"] + + +# --------------------------------------------------------------------------- +# Listings and prices +# --------------------------------------------------------------------------- +def upsert_listing(listing: Listing, ids: Dict[str, Dict[str, int]], run_id: Optional[int]) -> int: + """Write the latest state of a listing and append one price observation.""" + listing.validate() + site_id = ids["site"][listing.site_domain] + brand_id = ids["brand"][listing.brand_slug] + category_id = ids["category"][listing.category] + with transaction() as conn: + existing = conn.execute( + "SELECT id, source_type, price FROM elec.source_listing WHERE site_id = %s AND source_sku = %s", + (site_id, listing.source_sku), + ).fetchone() + # A scraped page is better evidence than a search snippet about the + # same page. Never let a later snippet overwrite scraped values. + if existing and existing["source_type"] in ("scraped_page", "brand_official") and listing.source_type == "search_snippet": + conn.execute("UPDATE elec.source_listing SET last_seen_at = now() WHERE id = %s", (existing["id"],)) + return existing["id"] + params = dict( + site_id=site_id, source_sku=listing.source_sku, source_url=listing.source_url, + source_type=listing.source_type, brand_id=brand_id, category_id=category_id, + family=listing.family, title=listing.title[:500], model=listing.model, + model_number=listing.model_number, ram_gb=listing.ram_gb, storage_gb=listing.storage_gb, + colour=listing.colour, price=listing.price, mrp=listing.mrp, availability=listing.availability, + in_stock=listing.in_stock, pincode=listing.pincode, pincode_applied=listing.pincode_applied, + rating=listing.rating, review_count=listing.review_count, gtin=listing.gtin, + image_urls=listing.image_urls[:12], specs_raw=_json(listing.specs_raw), specs=_json(listing.specs), + evidence_text=listing.evidence_text[:4000], search_query=listing.search_query, + confidence=round(listing.confidence, 2), parser=listing.parser, content_hash=listing.content_hash, + crawl_run_id=run_id, + ) + row = conn.execute( + """ + INSERT INTO elec.source_listing ( + site_id, source_sku, source_url, source_type, brand_id, category_id, family, title, model, + model_number, ram_gb, storage_gb, colour, price, mrp, availability, in_stock, pincode, + pincode_applied, rating, review_count, gtin, image_urls, specs_raw, specs, evidence_text, + search_query, confidence, parser, content_hash, crawl_run_id) + VALUES ( + %(site_id)s, %(source_sku)s, %(source_url)s, %(source_type)s, %(brand_id)s, %(category_id)s, + %(family)s, %(title)s, %(model)s, %(model_number)s, %(ram_gb)s, %(storage_gb)s, %(colour)s, + %(price)s, %(mrp)s, %(availability)s, %(in_stock)s, %(pincode)s, %(pincode_applied)s, + %(rating)s, %(review_count)s, %(gtin)s, %(image_urls)s, %(specs_raw)s, %(specs)s, + %(evidence_text)s, %(search_query)s, %(confidence)s, %(parser)s, %(content_hash)s, + %(crawl_run_id)s) + ON CONFLICT (site_id, source_sku) DO UPDATE SET + source_url = EXCLUDED.source_url, source_type = EXCLUDED.source_type, + brand_id = EXCLUDED.brand_id, category_id = EXCLUDED.category_id, family = EXCLUDED.family, + title = EXCLUDED.title, model = EXCLUDED.model, model_number = EXCLUDED.model_number, + ram_gb = EXCLUDED.ram_gb, storage_gb = EXCLUDED.storage_gb, colour = EXCLUDED.colour, + price = EXCLUDED.price, mrp = EXCLUDED.mrp, availability = EXCLUDED.availability, + in_stock = EXCLUDED.in_stock, pincode = EXCLUDED.pincode, + pincode_applied = EXCLUDED.pincode_applied, rating = EXCLUDED.rating, + review_count = EXCLUDED.review_count, gtin = EXCLUDED.gtin, image_urls = EXCLUDED.image_urls, + specs_raw = EXCLUDED.specs_raw, specs = EXCLUDED.specs, evidence_text = EXCLUDED.evidence_text, + search_query = EXCLUDED.search_query, confidence = EXCLUDED.confidence, parser = EXCLUDED.parser, + content_hash = EXCLUDED.content_hash, crawl_run_id = EXCLUDED.crawl_run_id, last_seen_at = now() + RETURNING id + """, + params, + ).fetchone() + listing_id = row["id"] + if listing.price is not None or listing.in_stock is not None: + conn.execute( + "INSERT INTO elec.price_history (listing_id, price, mrp, availability, in_stock, source_type, " + "pincode, pincode_applied, evidence_text, crawl_run_id) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)", + (listing_id, listing.price, listing.mrp, listing.availability, listing.in_stock, + listing.source_type, listing.pincode, listing.pincode_applied, + listing.evidence_text[:2000], run_id), + ) + return listing_id + + +# --------------------------------------------------------------------------- +# Ratings and reviews +# --------------------------------------------------------------------------- +def save_reviews(listing_id: int, reviews: List[Dict[str, Any]]) -> int: + """Store the reviews a listing's page publishes. Idempotent per review + text; an empty list changes nothing (a later search-only sighting must + not erase what the page said). Returns the number of new rows.""" + from app.electronics.reviews import sentiment_for + + added = 0 + with transaction() as conn: + for r in reviews: + body = (r.get("body") or "").strip() + if not body: + continue + digest = hashlib.sha1(f"{r.get('author') or ''}|{body}".encode("utf-8", "ignore")).hexdigest() + row = conn.execute( + "INSERT INTO elec.listing_review (listing_id, author, rating, title, body, review_date, sentiment, " + "content_hash) VALUES (%s,%s,%s,%s,%s,%s,%s,%s) " + "ON CONFLICT (listing_id, content_hash) DO NOTHING RETURNING id", + (listing_id, (r.get("author") or None) and str(r["author"])[:200], r.get("rating"), + (r.get("title") or None) and str(r["title"])[:300], body[:4000], + (r.get("review_date") or None) and str(r["review_date"])[:40], + sentiment_for(r.get("rating")), digest), + ).fetchone() + added += 1 if row else 0 + return added + + +def update_listing_rating(listing_id: int, rating: Optional[Decimal], review_count: Optional[int]) -> None: + """Refresh only the rating fields of a listing (used by the review backfill).""" + with transaction() as conn: + conn.execute( + "UPDATE elec.source_listing SET rating = %s, review_count = %s WHERE id = %s", + (rating, review_count, listing_id), + ) + + +def product_rating_and_reviews(conn, product_id: int) -> Dict[str, Any]: + """Per-platform ratings and all stored reviews for a verified product's + approved listings, each with the page it was read from.""" + sources = conn.execute( + "SELECT a.site, a.source_url, l.rating, l.review_count FROM elec.v_product_availability a " + "JOIN elec.source_listing l ON l.id = a.listing_id " + "WHERE a.product_id = %s AND l.rating > 0 ORDER BY l.review_count DESC NULLS LAST, a.site", + (product_id,), + ).fetchall() + reviews = conn.execute( + "SELECT a.site, a.source_url, r.author, r.rating, r.title, r.body, r.review_date, r.sentiment " + "FROM elec.v_product_availability a JOIN elec.listing_review r ON r.listing_id = a.listing_id " + "WHERE a.product_id = %s", + (product_id,), + ).fetchall() + return {"sources": [dict(s) for s in sources], "reviews": [dict(r) for r in reviews]} + + +def listings_for_review_backfill(category: Optional[str] = None) -> List[dict]: + """Page-read listings of verified products, for re-reading ratings/reviews.""" + sql = ( + "SELECT a.listing_id, a.source_url, a.domain, a.site_kind, a.category, l.source_sku, l.title " + "FROM elec.v_product_availability a JOIN elec.source_listing l ON l.id = a.listing_id " + "WHERE a.source_type IN ('scraped_page','brand_official')" + ) + params: tuple = () + if category: + sql += " AND a.category = %s" + params = (category,) + with connect() as conn: + return list(conn.execute(sql + " ORDER BY a.listing_id", params)) + + +# --------------------------------------------------------------------------- +# Products, matching, images +# --------------------------------------------------------------------------- +def product_candidates(brand_slug: str, category: str) -> List[dict]: + with connect() as conn: + return list(conn.execute( + "SELECT p.id, p.variant_key, p.model_norm, p.ram_gb, p.storage_gb, p.processor, p.mpn, p.gtin " + "FROM elec.product p JOIN elec.brand b ON b.id = p.brand_id JOIN elec.category c ON c.id = p.category_id " + "WHERE b.slug = %s AND c.slug = %s AND p.verification_status <> 'rejected'", + (brand_slug, category), + )) + + +def _cpu_label(processor: Optional[str]) -> str: + """"ryzen 5 7530u" -> "Ryzen 5 7530U", "i5-1334u" -> "i5-1334U".""" + if not processor: + return "" + def fmt(t: str) -> str: + if re.fullmatch(r"i[3579]-\w+", t): + return "i" + t[1:].upper() # i5-1334U + if any(ch.isdigit() for ch in t): + return t.upper() # 7530U, M5 + return t.title() # Ryzen, Core, Ultra + return " ".join(fmt(t) for t in processor.split()) + + +def product_display_name(listing: Listing) -> str: + variant = [x for x in ( + _cpu_label(listing.processor) if listing.category == "laptops" else "", + f"{_fmt_gb(listing.ram_gb)} RAM" if listing.ram_gb else "", + _fmt_gb(listing.storage_gb) if listing.storage_gb else "", + ) if x] + if listing.category == "laptops" and not listing.processor and listing.model_number: + variant.insert(0, listing.model_number) # the part number is what tells it apart + return " ".join(x for x in [load_brand_name(listing.brand_slug), listing.model, + f"({', '.join(variant)})" if variant else ""] if x) + + +def create_product(listing: Listing, ids: Dict[str, Dict[str, int]]) -> int: + display = product_display_name(listing) + with transaction() as conn: + row = conn.execute( + """ + INSERT INTO elec.product (brand_id, category_id, family, model, model_norm, variant_key, display_name, + ram_gb, storage_gb, processor, mpn, gtin) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) + ON CONFLICT (variant_key) DO UPDATE SET updated_at = now() + RETURNING id + """, + (ids["brand"][listing.brand_slug], ids["category"][listing.category], listing.family, + listing.model or listing.model_norm, listing.model_norm, listing.variant_key, display, + listing.ram_gb, listing.storage_gb, listing.processor, listing.model_number, listing.gtin), + ).fetchone() + return row["id"] + + +def _fmt_gb(value: Optional[Decimal]) -> str: + if value is None: + return "" + if value >= 1024 and value % 1024 == 0: + return f"{int(value // 1024)}TB" + return f"{format(value.normalize(), 'f')}GB" + + +_BRAND_NAMES: Dict[str, str] = {} + + +def load_brand_name(slug: str) -> str: + if not _BRAND_NAMES: + from app.electronics.reference import load_reference + + _BRAND_NAMES.update({s: b.name for s, b in load_reference().brands.items()}) + return _BRAND_NAMES.get(slug, slug.title()) + + +def map_listing(listing_id: int, product_id: int, method: str, confidence: float, review_status: str) -> None: + with transaction() as conn: + conn.execute( + """ + INSERT INTO elec.product_listing_map (listing_id, product_id, method, confidence, review_status) + VALUES (%s, %s, %s, %s, %s) + ON CONFLICT (listing_id) DO UPDATE SET + product_id = EXCLUDED.product_id, method = EXCLUDED.method, confidence = EXCLUDED.confidence, + review_status = CASE WHEN elec.product_listing_map.review_status IN ('approved','rejected') + AND elec.product_listing_map.product_id = EXCLUDED.product_id + THEN elec.product_listing_map.review_status + ELSE EXCLUDED.review_status END + """, + (listing_id, product_id, method, round(confidence, 2), review_status), + ) + + +def merge_product_specs(product_id: int, specs: Dict[str, Any], sources: Dict[str, str], source_url: str) -> None: + """Add spec keys the product does not have yet. Existing values win: + specs are only ever filled, never overwritten by a later source.""" + if not specs: + return + with transaction() as conn: + row = conn.execute( + "SELECT canonical_specs, spec_sources FROM elec.product WHERE id = %s FOR UPDATE", (product_id,) + ).fetchone() + current, cur_src = dict(row["canonical_specs"] or {}), dict(row["spec_sources"] or {}) + changed = False + for key, value in specs.items(): + if key not in current: + current[key] = value + cur_src[key] = {"url": source_url, "from": sources.get(key, "")} + changed = True + if changed: + conn.execute( + "UPDATE elec.product SET canonical_specs = %s, spec_sources = %s, updated_at = now() WHERE id = %s", + (_json(current), _json(cur_src), product_id), + ) + + +def add_image(product_id: int, url: str, listing_id: int, source_type: str, rank: int) -> None: + with transaction() as conn: + conn.execute( + "INSERT INTO elec.product_image (product_id, url, source_listing_id, source_type, rank) " + "VALUES (%s, %s, %s, %s, %s) ON CONFLICT (product_id, url) DO UPDATE SET validated_at = now()", + (product_id, url, listing_id, source_type, rank), + ) + + +def product_image_count(product_id: int) -> int: + with connect() as conn: + return conn.execute("SELECT count(*) AS n FROM elec.product_image WHERE product_id = %s", + (product_id,)).fetchone()["n"] + + +# The least a new device in the category can plausibly cost. Anything below is +# an accessory, an EMI or an offer amount that slipped through. +CATEGORY_MIN_PRICE = {"mobiles": 3000, "laptops": 15000} +OUTLIER_TOLERANCE = 0.35 + + +def flag_price_outliers() -> int: + """Flag prices that cannot be trusted as this product's price: + * below the category's floor (CATEGORY_MIN_PRICE); + * a search-result price more than OUTLIER_TOLERANCE away from the price + read off a product page for the same product; + * with no page price, a search-result price that far from the median of + at least three prices for the product. + Flagged prices stay stored with their evidence; they are just never used as + the best price. Returns the number flagged.""" + floor_cases = " ".join(f"WHEN '{k}' THEN {v}" for k, v in CATEGORY_MIN_PRICE.items()) + with transaction() as conn: + conn.execute("UPDATE elec.source_listing SET price_outlier = FALSE WHERE price_outlier") + cur = conn.execute( + f""" + WITH prices AS ( + SELECT l.id, m.product_id, l.price, l.source_type, c.slug + FROM elec.source_listing l + JOIN elec.product_listing_map m ON m.listing_id = l.id AND m.review_status IN ('auto','approved') + JOIN elec.category c ON c.id = l.category_id + WHERE l.price IS NOT NULL + ), + ref AS ( + SELECT product_id, + percentile_cont(0.5) WITHIN GROUP (ORDER BY price) + FILTER (WHERE source_type <> 'search_snippet') AS page_median, + percentile_cont(0.5) WITHIN GROUP (ORDER BY price) AS all_median, + count(*) AS n + FROM prices GROUP BY product_id + ) + UPDATE elec.source_listing l SET price_outlier = TRUE + FROM prices p JOIN ref r ON r.product_id = p.product_id + WHERE l.id = p.id AND ( + p.price < CASE p.slug {floor_cases} ELSE 0 END + OR (p.source_type = 'search_snippet' AND r.page_median IS NOT NULL + AND abs(p.price - r.page_median) / r.page_median > %(tol)s) + OR (p.source_type = 'search_snippet' AND r.page_median IS NULL AND r.n >= 3 + AND abs(p.price - r.all_median) / r.all_median > %(tol)s) + ) + """, + {"tol": OUTLIER_TOLERANCE}, + ) + return cur.rowcount + + +def refresh_verification() -> Dict[str, int]: + flag_price_outliers() + """A product is VERIFIED when auto/approved listings on at least two + different sites point at it, and at least one of them is a retailer + (so it is actually sold). Everything else stays unverified and hidden.""" + with transaction() as conn: + conn.execute( + """ + WITH ev AS ( + SELECT m.product_id, + count(DISTINCT l.site_id) AS sites, + count(DISTINCT l.site_id) FILTER (WHERE s.kind <> 'brand_official') AS retail_sites + FROM elec.product_listing_map m + JOIN elec.source_listing l ON l.id = m.listing_id + JOIN elec.site s ON s.id = l.site_id + WHERE m.review_status IN ('auto','approved') + GROUP BY m.product_id + ) + UPDATE elec.product p SET + evidence_count = coalesce(ev.sites, 0), + verification_status = CASE + WHEN p.verification_status = 'rejected' THEN 'rejected' + WHEN coalesce(ev.sites, 0) >= 2 AND coalesce(ev.retail_sites, 0) >= 1 THEN 'verified' + ELSE 'unverified' END, + updated_at = now() + FROM elec.product p2 LEFT JOIN ev ON ev.product_id = p2.id + WHERE p.id = p2.id + """ + ) + rows = conn.execute( + "SELECT verification_status AS s, count(*) AS n FROM elec.product GROUP BY 1" + ).fetchall() + return {r["s"]: r["n"] for r in rows} + + +def products_without_embedding(limit: int = 500) -> List[dict]: + with connect() as conn: + return list(conn.execute( + "SELECT p.id, p.display_name, p.canonical_specs, b.name AS brand, c.name AS category " + "FROM elec.product p JOIN elec.brand b ON b.id = p.brand_id JOIN elec.category c ON c.id = p.category_id " + "WHERE p.embedding IS NULL AND p.verification_status = 'verified' LIMIT %s", (limit,))) + + +def set_embedding(product_id: int, vector: List[float]) -> None: + import numpy as np + + with transaction() as conn: + conn.execute("UPDATE elec.product SET embedding = %s WHERE id = %s", (np.array(vector), product_id)) + + +def review_queue(limit: int = 100) -> List[dict]: + with connect() as conn: + return list(conn.execute( + """ + SELECT m.listing_id, m.product_id, m.method, m.confidence, l.title AS listing_title, l.source_url, + s.name AS site, p.display_name AS product + FROM elec.product_listing_map m + JOIN elec.source_listing l ON l.id = m.listing_id + JOIN elec.site s ON s.id = l.site_id + JOIN elec.product p ON p.id = m.product_id + WHERE m.review_status = 'pending' + ORDER BY m.confidence DESC, m.listing_id LIMIT %s + """, (limit,))) + + +def set_review(listing_id: int, approve: bool) -> bool: + with transaction() as conn: + cur = conn.execute( + "UPDATE elec.product_listing_map SET review_status = %s, reviewed_at = now() " + "WHERE listing_id = %s AND review_status = 'pending'", + ("approved" if approve else "rejected", listing_id), + ) + return cur.rowcount > 0 + + +def now_utc() -> datetime: + return datetime.now(timezone.utc) + + +def grounding_sample(n: int = 50) -> List[dict]: + with connect() as conn: + return list(conn.execute( + "SELECT id, source_url, source_type, price, evidence_text FROM elec.source_listing " + "WHERE price IS NOT NULL ORDER BY random() LIMIT %s", (n,))) + + +def slug(text: str) -> str: + return slugify(text) diff --git a/backend/app/electronics/extract/__init__.py b/backend/app/electronics/extract/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/electronics/extract/html_fallback.py b/backend/app/electronics/extract/html_fallback.py new file mode 100644 index 0000000..ee6390f --- /dev/null +++ b/backend/app/electronics/extract/html_fallback.py @@ -0,0 +1,123 @@ +"""Product facts from page markup when there is no usable JSON-LD. + +Only machine-readable markup is trusted for the price: OpenGraph/product meta +tags and schema.org microdata (itemprop="price"). Free text on the page is not +scanned for rupee amounts - a product page shows EMIs, offers and other +products' prices, and picking the wrong one is worse than picking none. +Spec tables (,
) supply specifications. +""" +from __future__ import annotations + +import json +import re +from decimal import Decimal, InvalidOperation +from typing import Any, Dict, List, Optional + +from bs4 import BeautifulSoup + + +def _dec(value: Optional[str]) -> Optional[Decimal]: + if not value: + return None + try: + return Decimal(re.sub(r"[^\d.]", "", value)) + except InvalidOperation: + return None + + +def _meta(soup: BeautifulSoup, *names: str) -> Optional[str]: + for name in names: + tag = soup.find("meta", attrs={"property": name}) or soup.find("meta", attrs={"name": name}) + if tag and tag.get("content"): + return tag["content"].strip() + return None + + +def spec_tables(soup: BeautifulSoup, limit: int = 200) -> Dict[str, str]: + specs: Dict[str, str] = {} + for row in soup.select("table tr"): + cells = row.find_all(["th", "td"]) + if len(cells) == 2: + k, v = (c.get_text(" ", strip=True) for c in cells) + if k and v and len(k) <= 60 and len(v) <= 200: + specs.setdefault(k, v) + if len(specs) >= limit: + return specs + for dl in soup.find_all("dl"): + for dt in dl.find_all("dt"): + dd = dt.find_next_sibling("dd") + if dd: + k, v = dt.get_text(" ", strip=True), dd.get_text(" ", strip=True) + if k and v and len(k) <= 60 and len(v) <= 200: + specs.setdefault(k, v) + return specs + + +def extract_page(html: str) -> Dict[str, Any]: + soup = BeautifulSoup(html, "lxml") + title = _meta(soup, "og:title", "twitter:title") + if not title: + h1 = soup.find("h1") + title = h1.get_text(" ", strip=True) if h1 else None + images: List[str] = [] + for name in ("og:image", "og:image:secure_url", "twitter:image"): + v = _meta(soup, name) + if v and v.startswith("http") and v not in images: + images.append(v) + + price = _dec(_meta(soup, "product:price:amount", "og:price:amount")) + currency = _meta(soup, "product:price:currency", "og:price:currency") + evidence = "" + if price is not None: + evidence = f"meta product:price:amount={price} currency={currency}" + else: + tag = soup.find(attrs={"itemprop": "price"}) + if tag is not None: + raw = tag.get("content") or tag.get_text(" ", strip=True) + price = _dec(raw) + cur_tag = soup.find(attrs={"itemprop": "priceCurrency"}) + currency = (cur_tag.get("content") if cur_tag else None) or currency + if price is not None: + evidence = f'itemprop="price" {raw} currency={currency}' + + availability = _meta(soup, "product:availability", "og:availability") + in_stock = None + if availability: + low = availability.lower().replace(" ", "") + in_stock = True if "instock" in low else False if ("outofstock" in low or "oos" == low) else None + + return { + "name": title, + "images": images, + "price": price, + "currency": currency, + "availability": availability, + "in_stock": in_stock, + "properties": spec_tables(soup), + "evidence": evidence, + } + + +_STATE_RE = re.compile( + r"]*id=\"__NEXT_DATA__\"[^>]*>(.*?)" + r"|window\.__(?:INITIAL|PRELOADED)_STATE__\s*=\s*(\{.*?\})\s*;?\s*", + re.DOTALL, +) + + +def embedded_state(html: str) -> Optional[Any]: + """The page's server-rendered application state, when it embeds one.""" + for m in _STATE_RE.finditer(html): + raw = m.group(1) or m.group(2) + try: + return json.loads(raw) + except (json.JSONDecodeError, TypeError): + continue + return None + + +def visible_text(html: str, limit: int = 6000) -> str: + soup = BeautifulSoup(html, "lxml") + for tag in soup(["script", "style", "noscript", "svg", "header", "footer", "nav"]): + tag.decompose() + return re.sub(r"\s+", " ", soup.get_text(" ", strip=True))[:limit] diff --git a/backend/app/electronics/extract/jsonld.py b/backend/app/electronics/extract/jsonld.py new file mode 100644 index 0000000..5c7e01f --- /dev/null +++ b/backend/app/electronics/extract/jsonld.py @@ -0,0 +1,202 @@ +"""schema.org Product data embedded in a page as JSON-LD. + +This is the preferred source on any page: it is what the site publishes for +search engines, so it is stable and states price, currency and availability +explicitly. +""" +from __future__ import annotations + +import json +import re +from decimal import Decimal, InvalidOperation +from typing import Any, Dict, Iterable, List, Optional + +from bs4 import BeautifulSoup + +_PRODUCT_TYPES = {"product", "productgroup", "productmodel", "individualproduct"} + + +def _types(node: dict) -> set: + t = node.get("@type") + if isinstance(t, list): + return {str(x).lower() for x in t} + return {str(t).lower()} if t else set() + + +def _walk(node: Any) -> Iterable[dict]: + if isinstance(node, dict): + yield node + for v in node.values(): + yield from _walk(v) + elif isinstance(node, list): + for item in node: + yield from _walk(item) + + +def json_ld_blocks(html: str) -> List[Any]: + soup = BeautifulSoup(html, "lxml") + blocks = [] + for tag in soup.find_all("script", attrs={"type": re.compile(r"ld\+json", re.I)}): + raw = (tag.string or tag.get_text() or "").strip() + if not raw: + continue + try: + blocks.append(json.loads(raw)) + except json.JSONDecodeError: + # Some sites put several objects or trailing commas in one tag. + try: + blocks.append(json.loads(re.sub(r",\s*([}\]])", r"\1", raw))) + except json.JSONDecodeError: + continue + return blocks + + +def _dec(value: Any) -> Optional[Decimal]: + if value is None or value == "": + return None + try: + return Decimal(str(value).replace(",", "").strip()) + except InvalidOperation: + return None + + +def _text(value: Any) -> Optional[str]: + if isinstance(value, dict): + value = value.get("name") or value.get("@value") + if isinstance(value, list): + value = value[0] if value else None + return str(value).strip() if value not in (None, "") else None + + +def _images(value: Any) -> List[str]: + out: List[str] = [] + for v in value if isinstance(value, list) else [value]: + if isinstance(v, dict): + v = v.get("url") or v.get("contentUrl") + if isinstance(v, str) and v.startswith(("http://", "https://")): + out.append(v) + return out + + +def _availability(value: Any) -> tuple: + text = (_text(value) or "").lower() + if not text: + return None, None + if "instock" in text or "limitedavailability" in text or "onlineonly" in text: + return "InStock", True + if any(k in text for k in ("outofstock", "soldout", "discontinued", "preorder", "presale")): + return text.rsplit("/", 1)[-1], False + return text.rsplit("/", 1)[-1], None + + +def _offer(offers: Any) -> Dict[str, Any]: + """The price/availability of the product's (lowest) offer.""" + candidates = offers if isinstance(offers, list) else [offers] + best: Dict[str, Any] = {} + for o in candidates: + if not isinstance(o, dict): + continue + price = _dec(o.get("price")) + if price is None: + price = _dec(o.get("lowPrice")) + if price is None and isinstance(o.get("priceSpecification"), dict): + price = _dec(o["priceSpecification"].get("price")) + currency = _text(o.get("priceCurrency")) or ( + _text(o["priceSpecification"].get("priceCurrency")) if isinstance(o.get("priceSpecification"), dict) else None + ) + availability, in_stock = _availability(o.get("availability")) + entry = {"price": price, "currency": currency, "availability": availability, "in_stock": in_stock, + "raw": {k: o.get(k) for k in ("price", "lowPrice", "priceCurrency", "availability") if k in o}} + if price is not None and (not best or best.get("price") is None or price < best["price"]): + best = entry + elif not best: + best = entry + return best + + +MAX_REVIEWS_PER_PAGE = 30 + + +def _review_rating(value: Any) -> Optional[Decimal]: + """A reviewer's star rating, rescaled to 0-5 when the page uses another scale.""" + if not isinstance(value, dict): + return None + rating = _dec(value.get("ratingValue")) + if rating is None: + return None + best = _dec(value.get("bestRating")) or Decimal(5) + if best <= 0: + return None + if best != 5: + rating = rating * Decimal(5) / best + if not (Decimal(0) <= rating <= Decimal(5)): + return None + return rating.quantize(Decimal("0.1")) + + +def _reviews(node: dict) -> List[Dict[str, Any]]: + """Customer reviews published on the Product node (schema.org Review). + + Only reviews with text are kept - a bare star with no words is not + something a reader can weigh. Nothing is paraphrased or summarised: body, + title and author are the page's own strings. + """ + raw = node.get("review") or node.get("reviews") or [] + out: List[Dict[str, Any]] = [] + for r in raw if isinstance(raw, list) else [raw]: + if not isinstance(r, dict): + continue + body = _text(r.get("reviewBody")) or _text(r.get("description")) + if not body: + continue + out.append({ + "author": _text(r.get("author")), + "rating": _review_rating(r.get("reviewRating")), + "title": _text(r.get("name")) or _text(r.get("headline")), + "body": body[:4000], + "review_date": _text(r.get("datePublished")) or _text(r.get("dateCreated")), + }) + if len(out) >= MAX_REVIEWS_PER_PAGE: + break + return out + + +def extract_products(html: str) -> List[Dict[str, Any]]: + """All schema.org Product nodes on the page, flattened to plain fields.""" + products: List[Dict[str, Any]] = [] + for block in json_ld_blocks(html): + for node in _walk(block): + if not (_types(node) & _PRODUCT_TYPES): + continue + name = _text(node.get("name")) + if not name: + continue + offer = _offer(node.get("offers")) if node.get("offers") else {} + if not offer and isinstance(node.get("hasVariant"), list): + offer = _offer([v.get("offers") for v in node["hasVariant"] if isinstance(v, dict) and v.get("offers")]) + props = {} + for p in node.get("additionalProperty") or []: + if isinstance(p, dict) and p.get("name") and p.get("value") not in (None, ""): + props[str(p["name"])] = str(p["value"]) + rating = node.get("aggregateRating") if isinstance(node.get("aggregateRating"), dict) else {} + products.append({ + "name": name, + "brand": _text(node.get("brand")), + "sku": _text(node.get("sku")) or _text(node.get("productID")), + "mpn": _text(node.get("mpn")), + "gtin": next((_text(node.get(k)) for k in ("gtin13", "gtin", "gtin12", "gtin14", "gtin8") if node.get(k)), None), + "color": _text(node.get("color")), + "images": _images(node.get("image")), + "description": _text(node.get("description")), + "price": offer.get("price"), + "currency": offer.get("currency"), + "availability": offer.get("availability"), + "in_stock": offer.get("in_stock"), + # Sites publish 0 for "no ratings yet"; that is not a rating. + "rating": (_dec(rating.get("ratingValue")) or None), + "review_count": int(_dec(rating.get("reviewCount") or rating.get("ratingCount")) or 0) or None, + "reviews": _reviews(node), + "properties": props, + "evidence": json.dumps({"name": name, "offers": offer.get("raw")}, default=str)[:1500], + }) + return products diff --git a/backend/app/electronics/extract/serp_parser.py b/backend/app/electronics/extract/serp_parser.py new file mode 100644 index 0000000..af43fd2 --- /dev/null +++ b/backend/app/electronics/extract/serp_parser.py @@ -0,0 +1,207 @@ +"""Read prices and stock state out of text we did not render ourselves: +search-result titles/snippets, and visible page text. + +The rules lean hard towards NOT returning a price. A snippet usually carries +several rupee amounts - the selling price, the MRP, an EMI, a bank discount, an +exchange value, "₹X off" - and taking the wrong one is worse than taking none. +An amount is only a price when nothing around it says it is something else. +""" +from __future__ import annotations + +import re +from dataclasses import dataclass +from decimal import Decimal, InvalidOperation +from typing import List, Optional + +PRICE_MIN = Decimal("500") +PRICE_MAX = Decimal("1000000") + +# ₹ / Rs / Rs. / INR followed by an amount with Indian (1,29,999) or western +# (129,999) grouping, or none. +_AMOUNT = r"(\d{1,3}(?:,\d{2,3})+(?:\.\d{1,2})?|\d+(?:\.\d{1,2})?)" +_MONEY_RE = re.compile(r"(?:₹|\bRs\.?|\bINR)\s?" + _AMOUNT, re.IGNORECASE) + +# Words that make an amount something other than the selling price. +_REJECT_BEFORE = re.compile( + r"(?:emi|save|saving|savings|cashback|cash\s*back|exchange|bank|discount|coupon|" + r"extra|instant|up\s*to|upto|flat|off\s+upto|worth|delivery|shipping|fee|charges?|" + r"starting|starts|from|onwards|min(?:imum)?|as\s+low\s+as|down\s*payment|per\s+month)\W*$", + re.IGNORECASE, +) +_REJECT_AFTER = re.compile( + r"^\W{0,3}(?:off\b|/\s*m(?:o|onth)?\b|per\s+month|p\.?m\.?\b|a\s+month|emi\b|/-?\s*emi|" + r"cashback|discount|savings?|onwards|\+\s*shipping|delivery)", + re.IGNORECASE, +) +_MRP_BEFORE = re.compile(r"(?:m\.?\s?r\.?\s?p\.?|list\s+price|was|original\s+price)[\s:]*$", re.IGNORECASE) +_RANGE_BETWEEN = re.compile(r"^\s*(?:-|–|—|to)\s*$", re.IGNORECASE) + +_OUT_OF_STOCK = re.compile( + r"\b(?:out\s+of\s+stock|currently\s+unavailable|sold\s+out|coming\s+soon|notify\s+me|" + r"temporarily\s+unavailable|not\s+available)\b", + re.IGNORECASE, +) +_IN_STOCK = re.compile(r"\b(?:in\s+stock|available\s+now|buy\s+now|add\s+to\s+cart)\b", re.IGNORECASE) + + +@dataclass(frozen=True) +class Amount: + value: Decimal + kind: str # price | mrp | rejected + reason: str + start: int + end: int + raw: str + + +def parse_amount(raw: str) -> Optional[Decimal]: + try: + value = Decimal(raw.replace(",", "")) + except InvalidOperation: + return None + return value + + +def find_amounts(text: str) -> List[Amount]: + """Every rupee amount in `text`, each classified as price, mrp or rejected.""" + out: List[Amount] = [] + if not text: + return out + matches = list(_MONEY_RE.finditer(text)) + for i, m in enumerate(matches): + value = parse_amount(m.group(1)) + if value is None: + continue + before = text[max(0, m.start() - 28): m.start()] + after = text[m.end(): m.end() + 22] + kind, reason = "price", "" + if _MRP_BEFORE.search(before): + kind, reason = "mrp", "labelled MRP" + elif _REJECT_BEFORE.search(before): + kind, reason = "rejected", f"preceded by {_REJECT_BEFORE.search(before).group(0).strip()!r}" + elif _REJECT_AFTER.search(after): + kind, reason = "rejected", f"followed by {_REJECT_AFTER.search(after).group(0).strip()!r}" + # A range ("₹10,999 - ₹12,999") names no single price. + if kind == "price": + if i + 1 < len(matches) and _RANGE_BETWEEN.match(text[m.end(): matches[i + 1].start()]): + kind, reason = "rejected", "start of a price range" + elif i > 0 and _RANGE_BETWEEN.match(text[matches[i - 1].end(): m.start()]): + kind, reason = "rejected", "end of a price range" + if kind != "rejected" and not (PRICE_MIN <= value <= PRICE_MAX): + kind, reason = "rejected", "outside plausible range" + out.append(Amount(value, kind, reason, m.start(), m.end(), m.group(0))) + return out + + +@dataclass(frozen=True) +class PriceReading: + price: Optional[Decimal] + mrp: Optional[Decimal] + evidence: str # the exact substring the price was read from ("" if none) + + +def read_price(text: str) -> PriceReading: + """The single selling price stated in `text`, or None. + + If the text states two different unlabelled prices, it is ambiguous (a + listing page snippet often shows several variants) and None is returned. + """ + amounts = find_amounts(text) + prices = [a for a in amounts if a.kind == "price"] + mrps = [a for a in amounts if a.kind == "mrp"] + distinct = {a.value for a in prices} + price: Optional[Decimal] = None + evidence = "" + if len(distinct) == 1: + price = prices[0].value + evidence = prices[0].raw + mrp = mrps[0].value if mrps else None + if price is not None and mrp is not None and mrp < price: + mrp = None # an "MRP" below the selling price was misread; drop it + return PriceReading(price, mrp, evidence) + + +def read_stock(text: str) -> Optional[bool]: + """True/False only when the text says so; None when it does not.""" + if not text: + return None + if _OUT_OF_STOCK.search(text): + return False + if _IN_STOCK.search(text): + return True + return None + + +@dataclass(frozen=True) +class RatingReading: + rating: Optional[Decimal] + review_count: Optional[int] + evidence: str # the exact substring the rating was read from ("" if none) + + +# Only ratings the text states explicitly on a 5-point scale: +# "4.3 out of 5 stars", "Rating: 4.3/5", "Rated 4.3 / 5", "4.3★", "4.3 ★ (1,234 ratings)" +_RATING_PATTERNS = ( + re.compile(r"\b([0-5](?:\.\d{1,2})?)\s*out\s+of\s*5(?:\.0)?\b(?:\s*stars?)?", re.IGNORECASE), + # "x/5" only with a rating word before it or "stars" after it - a bare + # "1/5" is as likely a sensor size or a fraction. + re.compile(r"\brat(?:ing|ed)\s*[:\-]?\s*([0-5](?:\.\d{1,2})?)\s*/\s*5(?:\.0)?\b", re.IGNORECASE), + re.compile(r"\b([0-5](?:\.\d{1,2})?)\s*/\s*5(?:\.0)?\s*stars?\b", re.IGNORECASE), + re.compile(r"\b([0-5](?:\.\d{1,2})?)\s*(?:★|☆|⭐)"), + re.compile(r"\brat(?:ing|ed)\s*[:\-]?\s*([0-5](?:\.\d{1,2})?)\s*(?:stars?|★)", re.IGNORECASE), +) +_RATING_COUNT = re.compile( + r"^[\s()\-|·,.:]*(?:stars?)?[\s()\-|·,.:]*(\d{1,3}(?:,\d{2,3})+|\d+)\s*(?:customer\s+)?(?:ratings?|reviews?|votes?)\b", + re.IGNORECASE, +) + + +def read_rating(text: str) -> RatingReading: + """The product rating a search title/snippet states, or None. + + Only an explicit "x out of 5" / "x/5" / "x★" statement counts; bare + numbers never do. If the text states two different ratings it is + ambiguous (several products on one results page) and None is returned. + """ + if not text: + return RatingReading(None, None, "") + found = [] + for pattern in _RATING_PATTERNS: + for m in pattern.finditer(text): + try: + value = Decimal(m.group(1)) + except InvalidOperation: + continue + if Decimal(0) < value <= Decimal(5): + found.append((value, m)) + if not found or len({v for v, _ in found}) != 1: + return RatingReading(None, None, "") + value, m = min(found, key=lambda f: f[1].start()) + count = None + tail = _RATING_COUNT.match(text[m.end(): m.end() + 40]) + if tail: + count = int(tail.group(1).replace(",", "")) + evidence = text[m.start(): m.end() + (tail.end() if tail else 0)].strip() + return RatingReading(value, count, evidence) + + +# Titles returned by search engines carry the site name; it is not part of the +# product title. +_TITLE_SUFFIX = re.compile( + r"\s*(?:[|\-–:]\s*)?(?:buy\s+online.*|online\s+at\s+best\s+price.*|" + r"at\s+best\s+price.*|price\s+in\s+india.*|" + r"amazon\.in.*|flipkart(?:\.com)?.*|croma.*|reliance\s+digital.*|vijay\s+sales.*|" + r"tata\s+cliq.*|poorvika.*|sangeetha.*|vasanth.*|viveks.*)$", + re.IGNORECASE, +) +_TITLE_PREFIX = re.compile(r"^(?:buy\s+|amazon\.in\s*:\s*)", re.IGNORECASE) + + +def clean_result_title(title: str) -> str: + t = (title or "").strip() + # Engines truncate with "..." and sometimes run several results' titles + # together after it; everything past the first ellipsis is not this page. + t = re.split(r"\s*(?:\.\.\.|…)", t, maxsplit=1)[0] + t = _TITLE_PREFIX.sub("", t) + t = _TITLE_SUFFIX.sub("", t) + return t.strip(" -|:–") diff --git a/backend/app/electronics/match/__init__.py b/backend/app/electronics/match/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/electronics/match/matcher.py b/backend/app/electronics/match/matcher.py new file mode 100644 index 0000000..e1b86f6 --- /dev/null +++ b/backend/app/electronics/match/matcher.py @@ -0,0 +1,124 @@ +"""Link a listing to its canonical product (one real-world variant). + +From most to least certain: + 1. GTIN - same barcode -> auto + 2. MPN - same manufacturer part number (laptops) -> auto + 3. variant key - same brand, model, RAM and storage -> auto + 4. fuzzy - model names ≥ AUTO_RATIO similar AND every + hard attribute (RAM, storage, processor) equal -> auto + ≥ REVIEW_RATIO -> pending (review queue) + 5. otherwise a new product is created for the variant. + +A listing that states too little to identify a variant (no storage on a +phone title, for example) is stored but not linked to any product. +""" +from __future__ import annotations + +from dataclasses import dataclass +from decimal import Decimal +from typing import List, Optional + +from rapidfuzz import fuzz + +from app.electronics.normalise.title_parser import laptop_line, processor_is_specific + +AUTO_RATIO = 92 +REVIEW_RATIO = 85 + + +@dataclass +class MatchDecision: + product_id: Optional[int] # None -> create a new product + method: str + confidence: float + review_status: str + + +def _eq(a: Optional[Decimal], b: Optional[Decimal]) -> bool: + if a is None or b is None: + return a is None and b is None + return Decimal(a) == Decimal(b) + + +def _number_tokens(model_norm: str) -> set: + """Tokens that carry a digit ("s25", "a37", "15", "2a", "8a"). Two models + whose number tokens differ are different products, however similar the + rest of the name is: Galaxy S25 vs S26, A27 vs A37, iPhone 15 vs 16.""" + return {t for t in (model_norm or "").split() if any(ch.isdigit() for ch in t)} + + +def _lines_compatible(a: str, b: str) -> bool: + """One model line is the other plus/minus extra words, and they agree on + every number token they both carry ("15" is not "15s", "slim 3" is not + "slim 5").""" + ta, tb = set(a.split()), set(b.split()) + return bool(ta and tb) and (ta <= tb or tb <= ta) + + +def decide(listing, candidates: List[dict]) -> Optional[MatchDecision]: + """`listing` is a models.Listing with variant_key/model_norm set; + `candidates` are product rows of the same brand and category.""" + if not listing.variant_key: + return None + if listing.gtin: + for c in candidates: + if c.get("gtin") and c["gtin"] == listing.gtin: + return MatchDecision(c["id"], "gtin", 0.99, "auto") + if listing.model_number: + for c in candidates: + if c.get("mpn") and c["mpn"].lower() == listing.model_number.lower(): + return MatchDecision(c["id"], "mpn", 0.97, "auto") + for c in candidates: + if c["variant_key"] == listing.variant_key: + return MatchDecision(c["id"], "variant_key", 0.95, "auto") + + # Laptops: the same configuration (exact CPU model, RAM, storage) within a + # compatible model line - "ideapad slim 3" and "ideapad slim 3 15amn8" - + # is the same product. Two compatible candidates means the title is too + # vague to choose ("pavilion" vs "pavilion 14" and "pavilion 15"): review. + if listing.category == "laptops" and processor_is_specific(listing.processor) \ + and listing.ram_gb is not None and listing.storage_gb is not None: + line = laptop_line(listing.model_norm) + same_config = [ + c for c in candidates + if c.get("processor") == listing.processor + and _eq(c.get("ram_gb"), listing.ram_gb) and _eq(c.get("storage_gb"), listing.storage_gb) + and _lines_compatible(line, laptop_line(c["model_norm"])) + ] + if len(same_config) == 1: + return MatchDecision(same_config[0]["id"], "variant_key", 0.85, "auto") + if len(same_config) > 1: + return MatchDecision(same_config[0]["id"], "variant_key", 0.6, "pending") + + # One side does not state the RAM ("Apple iPhone 15 (128 GB)"). Same model + # and storage with exactly one candidate is the same variant; with several + # candidates it is ambiguous and goes to review. + same_model = [c for c in candidates + if c["model_norm"] == listing.model_norm and _eq(c.get("storage_gb"), listing.storage_gb) + and (c.get("ram_gb") is None) != (listing.ram_gb is None)] + if len(same_model) == 1: + return MatchDecision(same_model[0]["id"], "variant_key", 0.85, "auto") + if len(same_model) > 1: + return MatchDecision(same_model[0]["id"], "variant_key", 0.6, "pending") + + best, best_score = None, 0.0 + for c in candidates: + if not (_eq(c.get("storage_gb"), listing.storage_gb) and _eq(c.get("ram_gb"), listing.ram_gb)): + continue + if listing.category == "laptops" and (c.get("processor") or listing.processor) and c.get("processor") != listing.processor: + continue + if _number_tokens(c["model_norm"]) != _number_tokens(listing.model_norm): + continue + score = fuzz.token_set_ratio(c["model_norm"], listing.model_norm or "") + # token_set_ratio treats "galaxy s24" and "galaxy s24 ultra" as a + # subset match (100). Different words mean different models. + extra = set((listing.model_norm or "").split()) ^ set(c["model_norm"].split()) + if extra: + score = min(score, fuzz.ratio(c["model_norm"], listing.model_norm or "")) + if score > best_score: + best, best_score = c, score + if best is not None and best_score >= AUTO_RATIO: + return MatchDecision(best["id"], "fuzzy", round(best_score / 100 * 0.9, 2), "auto") + if best is not None and best_score >= REVIEW_RATIO: + return MatchDecision(best["id"], "fuzzy", round(best_score / 100 * 0.8, 2), "pending") + return MatchDecision(None, "variant_key", 0.9, "auto") diff --git a/backend/app/electronics/match/rematch.py b/backend/app/electronics/match/rematch.py new file mode 100644 index 0000000..1356a6c --- /dev/null +++ b/backend/app/electronics/match/rematch.py @@ -0,0 +1,105 @@ +"""Rebuild canonical products for a category from the listings already stored. + +Products and listing links are derived data: every fact lives on the listing +(title, snippet evidence, specs, URL). When the parsing or matching rules +improve, this re-runs them over the stored listings - no network requests - +and keeps each image attached to the listing it was found on. + +Review decisions (approved/rejected links) are lost, because the products they +pointed at are rebuilt; uncertain matches simply come back to the queue. +""" +from __future__ import annotations + +import logging +from decimal import Decimal +from typing import Dict, List + +from app.electronics.db import repository as repo +from app.electronics.db.connection import connect, transaction +from app.electronics.match.matcher import decide +from app.electronics.models import Listing +from app.electronics.normalise.title_parser import fill_from_context, parse_title, variant_key + +logger = logging.getLogger(__name__) + +_ORDER = {"brand_official": 0, "scraped_page": 1, "search_snippet": 2} + + +def _listing_from_row(row: dict, category: str) -> Listing: + parsed = parse_title(row["title"], category, expected_brand=row["brand_slug"]) + snippet = "" + if row["source_type"] == "search_snippet" and " — " in row["evidence_text"]: + snippet = row["evidence_text"].split(" — ", 1)[1].split(" || ", 1)[0] + raw = row["specs_raw"] or {} + spec_texts = tuple(str(v) for k, v in raw.items() if "processor" in k.lower() or "cpu" in k.lower()) + spec_texts += (str((row["specs"] or {}).get("processor") or ""),) + fill_from_context(parsed, category, snippet=snippet, spec_texts=spec_texts) + l = Listing( + site_domain=row["domain"], source_sku=row["source_sku"], source_url=row["source_url"], + source_type=row["source_type"], brand_slug=row["brand_slug"], category=category, + title=row["title"], evidence_text=row["evidence_text"], confidence=float(row["confidence"]), + parser=row["parser"], family=parsed.brand.family if parsed.brand else row["family"], + model=parsed.model, model_number=row["model_number"] or parsed.mpn, + ram_gb=parsed.ram_gb, storage_gb=parsed.storage_gb, colour=row["colour"], + gtin=row["gtin"], specs=row["specs"] or {}, + ) + l.model_norm, l.processor = parsed.model_norm, parsed.processor + l.variant_key = variant_key(parsed, category) if parsed.brand else None + return l + + +def rematch(category: str) -> Dict[str, int]: + stats: Dict[str, int] = {"listings": 0, "linked": 0, "pending": 0, "unlinked": 0, "products": 0, "images": 0} + with connect() as conn: + rows = conn.execute( + """ + SELECT l.*, b.slug AS brand_slug, s.domain + FROM elec.source_listing l + JOIN elec.brand b ON b.id = l.brand_id + JOIN elec.site s ON s.id = l.site_id + JOIN elec.category c ON c.id = l.category_id + WHERE c.slug = %s + """, + (category,), + ).fetchall() + images = conn.execute( + """ + SELECT i.url, i.source_listing_id, i.source_type, i.rank FROM elec.product_image i + JOIN elec.product p ON p.id = i.product_id JOIN elec.category c ON c.id = p.category_id + WHERE c.slug = %s + """, + (category,), + ).fetchall() + with transaction() as conn: + # Maps and images cascade from the products. + conn.execute( + "DELETE FROM elec.product p USING elec.category c WHERE c.id = p.category_id AND c.slug = %s", + (category,), + ) + + ids = repo.id_maps() + product_of_listing: Dict[int, int] = {} + rows.sort(key=lambda r: (_ORDER.get(r["source_type"], 9), r["id"])) + for row in rows: + stats["listings"] += 1 + listing = _listing_from_row(row, category) + decision = decide(listing, repo.product_candidates(listing.brand_slug, category)) + if decision is None: + stats["unlinked"] += 1 + continue + product_id = decision.product_id or repo.create_product(listing, ids) + stats["products"] += decision.product_id is None + repo.map_listing(row["id"], product_id, decision.method, decision.confidence, decision.review_status) + product_of_listing[row["id"]] = product_id + if decision.review_status == "pending": + stats["pending"] += 1 + else: + stats["linked"] += 1 + repo.merge_product_specs(product_id, listing.specs, {}, listing.source_url) + for img in images: + pid = product_of_listing.get(img["source_listing_id"]) + if pid is not None: + repo.add_image(pid, img["url"], img["source_listing_id"], img["source_type"], img["rank"]) + stats["images"] += 1 + stats.update({f"products_{k}": v for k, v in repo.refresh_verification().items()}) + return stats diff --git a/backend/app/electronics/models.py b/backend/app/electronics/models.py new file mode 100644 index 0000000..527aa6d --- /dev/null +++ b/backend/app/electronics/models.py @@ -0,0 +1,68 @@ +"""The record a collector produces for one product page / search result.""" +from __future__ import annotations + +from dataclasses import dataclass, field +from decimal import Decimal +from typing import Any, Dict, List, Optional + +SOURCE_TYPES = ("scraped_page", "search_snippet", "brand_official") + + +@dataclass +class Listing: + site_domain: str + source_sku: str + source_url: str + source_type: str + brand_slug: str + category: str + title: str + evidence_text: str + confidence: float + parser: str + family: Optional[str] = None + model: Optional[str] = None + model_number: Optional[str] = None + ram_gb: Optional[Decimal] = None + storage_gb: Optional[Decimal] = None + colour: Optional[str] = None + price: Optional[Decimal] = None + mrp: Optional[Decimal] = None + availability: Optional[str] = None + in_stock: Optional[bool] = None + pincode: Optional[str] = None + pincode_applied: bool = False + rating: Optional[Decimal] = None + review_count: Optional[int] = None + # Customer reviews the page itself publishes (schema.org Review); stored + # in elec.listing_review, not on the listing row. + reviews: List[Dict[str, Any]] = field(default_factory=list) + gtin: Optional[str] = None + image_urls: List[str] = field(default_factory=list) + specs_raw: Dict[str, Any] = field(default_factory=dict) + specs: Dict[str, Any] = field(default_factory=dict) + spec_sources: Dict[str, str] = field(default_factory=dict) + search_query: Optional[str] = None + content_hash: Optional[str] = None + # Not stored on the listing; used for matching. + variant_key: Optional[str] = None + model_norm: Optional[str] = None + processor: Optional[str] = None + + def validate(self) -> None: + """The anti-fabrication contract, checked before anything is written.""" + if self.source_type not in SOURCE_TYPES: + raise ValueError(f"bad source_type {self.source_type!r}") + if not self.source_url.startswith(("http://", "https://")): + raise ValueError("listing without a real source URL") + if not self.evidence_text.strip(): + raise ValueError("listing without evidence text") + if self.price is not None: + if not (Decimal(500) <= self.price <= Decimal(1000000)): + raise ValueError(f"implausible price {self.price}") + if self.mrp is not None and self.price is not None and self.mrp < self.price: + self.mrp = None + if self.pincode_applied and not self.pincode: + raise ValueError("pincode_applied without a pincode") + if not 0 <= self.confidence <= 1: + raise ValueError("confidence out of range") diff --git a/backend/app/electronics/net/__init__.py b/backend/app/electronics/net/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/electronics/net/breaker.py b/backend/app/electronics/net/breaker.py new file mode 100644 index 0000000..64c438a --- /dev/null +++ b/backend/app/electronics/net/breaker.py @@ -0,0 +1,61 @@ +"""Per-host circuit breaker. + +One 403, 429, 503 or CAPTCHA page opens the breaker for that host for +ELEC_BREAKER_COOLDOWN_HOURS. While it is open the host is not requested at all +and its products are collected from web search results instead. There is no +retry-with-a-different-identity: a block is an answer. +""" +from __future__ import annotations + +import threading +import time +from typing import Callable, Dict, Optional, Tuple + +from app.infrastructure.settings import ELEC_BREAKER_COOLDOWN_HOURS + + +class CircuitBreaker: + def __init__( + self, + cooldown_seconds: float = ELEC_BREAKER_COOLDOWN_HOURS * 3600, + on_trip: Optional[Callable[[str, str, float], None]] = None, + clock: Callable[[], float] = time.time, + ) -> None: + self.cooldown = cooldown_seconds + self.on_trip = on_trip + self._clock = clock + self._open: Dict[str, Tuple[float, str]] = {} + self._lock = threading.Lock() + + @staticmethod + def _key(host: str) -> str: + host = host.lower() + return host[4:] if host.startswith("www.") else host + + def preload(self, host: str, until_epoch: float, reason: str) -> None: + """Restore a breaker that was opened in an earlier run (elec.site).""" + if until_epoch > self._clock(): + with self._lock: + self._open[self._key(host)] = (until_epoch, reason) + + def trip(self, host: str, reason: str) -> None: + until = self._clock() + self.cooldown + with self._lock: + self._open[self._key(host)] = (until, reason) + if self.on_trip: + self.on_trip(self._key(host), reason, until) + + def is_open(self, host: str) -> bool: + key = self._key(host) + with self._lock: + entry = self._open.get(key) + if not entry: + return False + if entry[0] <= self._clock(): + del self._open[key] + return False + return True + + def reason(self, host: str) -> Optional[str]: + entry = self._open.get(self._key(host)) + return entry[1] if entry else None diff --git a/backend/app/electronics/net/polite_client.py b/backend/app/electronics/net/polite_client.py new file mode 100644 index 0000000..270c460 --- /dev/null +++ b/backend/app/electronics/net/polite_client.py @@ -0,0 +1,223 @@ +"""The only way this project fetches a retail or brand web page. + +What it guarantees, for every request: + * robots.txt is consulted first (protego). If robots.txt cannot be read + because the server errors or blocks it, the site is treated as disallowed. + * at least ELEC_SITE_MIN_INTERVAL_SECONDS between requests to one host. + * an honest User-Agent naming the project and a contact address. + * no JavaScript, no cookies kept between requests, no proxies, no retries on + 403/429 - a block is respected, not worked around. + * a size cap on the response body. + * a circuit breaker: a 403/429/CAPTCHA response opens it for the host, and + every later request to that host is refused until the cooldown passes. + * every request is reported to `on_fetch` (the fetch_log table). +""" +from __future__ import annotations + +import logging +import re +import threading +import time +from dataclasses import dataclass +from typing import Callable, Dict, Optional, Tuple +from urllib.parse import urlparse + +import httpx +from protego import Protego + +from app.electronics.net.breaker import CircuitBreaker +from app.infrastructure.settings import ( + ELEC_MAX_PAGE_BYTES, + ELEC_SITE_MIN_INTERVAL_SECONDS, + REQUEST_TIMEOUT_SECONDS, + USER_AGENT, +) + +logger = logging.getLogger(__name__) + +ROBOTS_TTL_SECONDS = 24 * 3600 + +# Pages that are a bot check rather than content. Matched on the first 20 KB. +_CAPTCHA_MARKERS = re.compile( + r"captcha|robot\s*check|are\s+you\s+a\s+robot|verify\s+you\s+are\s+human|" + r"/errors/validatecaptcha|px-captcha|cf-challenge|challenge-platform|access\s+denied|" + r"unusual\s+traffic|request\s+blocked|bot\s+detection|akamai.*reference", + re.IGNORECASE, +) + + +@dataclass +class FetchResult: + url: str + final_url: str + status: Optional[int] + text: str + outcome: str # ok | robots_disallowed | blocked | captcha | breaker_open | http_error | network_error | too_large | not_html + robots_allowed: Optional[bool] + bytes: int = 0 + + @property + def ok(self) -> bool: + return self.outcome == "ok" + + +class PoliteClient: + def __init__( + self, + *, + min_interval: float = ELEC_SITE_MIN_INTERVAL_SECONDS, + breaker: Optional[CircuitBreaker] = None, + on_fetch: Optional[Callable[[FetchResult, str], None]] = None, + transport: Optional[httpx.BaseTransport] = None, + sleep: Callable[[float], None] = time.sleep, + clock: Callable[[], float] = time.monotonic, + ) -> None: + self.min_interval = min_interval + self.breaker = breaker or CircuitBreaker() + self.on_fetch = on_fetch + self._sleep = sleep + self._clock = clock + self._last: Dict[str, float] = {} + self._locks: Dict[str, threading.Lock] = {} + self._robots: Dict[str, Tuple[float, Optional[Protego], bool]] = {} + self._guard = threading.Lock() + self._client = httpx.Client( + headers={ + "User-Agent": USER_AGENT, + "Accept": "text/html,application/xhtml+xml,application/json;q=0.9,*/*;q=0.5", + "Accept-Language": "en-IN,en;q=0.9", + }, + follow_redirects=True, + timeout=REQUEST_TIMEOUT_SECONDS, + transport=transport, + ) + + def close(self) -> None: + self._client.close() + + def __enter__(self) -> "PoliteClient": + return self + + def __exit__(self, *exc) -> None: + self.close() + + # -- pacing -------------------------------------------------------------- + def _host_lock(self, host: str) -> threading.Lock: + with self._guard: + return self._locks.setdefault(host, threading.Lock()) + + def _wait_turn(self, host: str) -> None: + last = self._last.get(host) + if last is not None: + gap = self.min_interval - (self._clock() - last) + if gap > 0: + self._sleep(gap) + self._last[host] = self._clock() + + # -- robots.txt ---------------------------------------------------------- + def _robots_for(self, scheme: str, host: str) -> Tuple[Optional[Protego], bool]: + """(parser, reachable). parser None + reachable True = no robots.txt + (everything allowed); reachable False = could not read it (deny).""" + cached = self._robots.get(host) + if cached and self._clock() - cached[0] < ROBOTS_TTL_SECONDS: + return cached[1], cached[2] + url = f"{scheme}://{host}/robots.txt" + parser: Optional[Protego] = None + reachable = False + self._wait_turn(host) + try: + resp = self._client.get(url) + if resp.status_code == 200: + parser, reachable = Protego.parse(resp.text), True + elif resp.status_code in (404, 410): + parser, reachable = None, True + else: + reachable = False + if resp.status_code in (403, 429): + self.breaker.trip(host, f"robots.txt returned HTTP {resp.status_code}") + except httpx.HTTPError as exc: + logger.info("robots.txt unreachable for %s: %s", host, exc) + self._robots[host] = (self._clock(), parser, reachable) + return parser, reachable + + def robots_allowed(self, url: str) -> bool: + p = urlparse(url) + parser, reachable = self._robots_for(p.scheme or "https", p.netloc.lower()) + if not reachable: + return False + return True if parser is None else bool(parser.can_fetch(url, USER_AGENT)) + + # -- fetch --------------------------------------------------------------- + def _report(self, result: FetchResult) -> FetchResult: + if self.on_fetch: + try: + self.on_fetch(result, urlparse(result.url).netloc.lower()) + except Exception as exc: # noqa: BLE001 - logging must never break a crawl + logger.debug("fetch log failed: %s", exc) + return result + + def get(self, url: str, *, check_robots: bool = True, accept_non_html: bool = False) -> FetchResult: + host = urlparse(url).netloc.lower() + if self.breaker.is_open(host): + return FetchResult(url, url, None, "", "breaker_open", None) + with self._host_lock(host): + allowed: Optional[bool] = None + if check_robots: + allowed = self.robots_allowed(url) + if not allowed: + return self._report(FetchResult(url, url, None, "", "robots_disallowed", False)) + self._wait_turn(host) + try: + with self._client.stream("GET", url) as resp: + status = resp.status_code + final = str(resp.url) + ctype = resp.headers.get("content-type", "").lower() + body = bytearray() + too_large = False + for chunk in resp.iter_bytes(): + body.extend(chunk) + if len(body) > ELEC_MAX_PAGE_BYTES: + too_large = True + break + encoding = resp.encoding or "utf-8" + except httpx.HTTPError as exc: + logger.info("fetch failed %s: %s", url, exc) + return self._report(FetchResult(url, url, None, "", "network_error", allowed)) + + text = bytes(body).decode(encoding, errors="replace") if body else "" + n = len(body) + if status in (403, 429, 503) or (status == 200 and _CAPTCHA_MARKERS.search(text[:20000]) and len(text) < 60000): + outcome = "captcha" if status == 200 or _CAPTCHA_MARKERS.search(text[:20000]) else "blocked" + self.breaker.trip(host, f"HTTP {status} ({outcome})") + return self._report(FetchResult(url, final, status, "", outcome, allowed, n)) + if status != 200: + return self._report(FetchResult(url, final, status, "", "http_error", allowed, n)) + if too_large: + return self._report(FetchResult(url, final, status, "", "too_large", allowed, n)) + if not accept_non_html and "html" not in ctype and "json" not in ctype: + return self._report(FetchResult(url, final, status, "", "not_html", allowed, n)) + return self._report(FetchResult(url, final, status, text, "ok", allowed, n)) + + def check_image(self, url: str, min_bytes: int) -> bool: + """One ranged GET to confirm a URL serves a real image. Paced per host + like any request; robots.txt is not consulted because this fetches a + single file the product page itself references, as a browser would.""" + host = urlparse(url).netloc.lower() + if not url.startswith(("http://", "https://")) or self.breaker.is_open(host): + return False + with self._host_lock(host): + self._wait_turn(host) + try: + with self._client.stream("GET", url, headers={"Accept": "image/*", "Range": f"bytes=0-{min_bytes * 4}"}) as resp: + if resp.status_code not in (200, 206): + return False + if not resp.headers.get("content-type", "").lower().startswith("image/"): + return False + got = 0 + for chunk in resp.iter_bytes(): + got += len(chunk) + if got >= min_bytes: + return True + return got >= min_bytes + except httpx.HTTPError: + return False diff --git a/backend/app/electronics/normalise/__init__.py b/backend/app/electronics/normalise/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/electronics/normalise/brand_alias.py b/backend/app/electronics/normalise/brand_alias.py new file mode 100644 index 0000000..55d75ef --- /dev/null +++ b/backend/app/electronics/normalise/brand_alias.py @@ -0,0 +1,86 @@ +"""Resolve the brand of a product title against the closed allow-list.""" +from __future__ import annotations + +import re +from dataclasses import dataclass +from functools import lru_cache +from typing import List, Optional, Tuple + +from app.electronics.reference import load_reference + + +@dataclass(frozen=True) +class BrandMatch: + brand_slug: str + brand_name: str + family: Optional[str] # sub-brand (Redmi, iQOO, Pixel...) when the title used one + matched: str # the alias text found in the title + + +@lru_cache(maxsize=1) +def _alias_table() -> List[Tuple[str, str, bool]]: + """(alias, brand_slug, is_sub_brand), longest alias first.""" + ref = load_reference() + rows: List[Tuple[str, str, bool]] = [] + for b in ref.brands.values(): + for a in b.aliases: + rows.append((a, b.slug, False)) + for s in b.sub_brands: + rows.append((s, b.slug, True)) + rows.sort(key=lambda r: -len(r[0])) + return rows + + +def resolve_brand(title: str, *, expected: Optional[str] = None) -> Optional[BrandMatch]: + """The allow-listed brand a title starts with (or names within its first + few words), or None. `expected` restricts the match to one brand slug. + + Only the start of the title is considered: "Case for Samsung Galaxy S24" + is an accessory, not a Samsung phone. + """ + if not title: + return None + ref = load_reference() + head = " ".join(re.findall(r"[a-z0-9+]+", title.lower())[:3]) + for alias, slug, is_sub in _alias_table(): + if expected and slug != expected: + continue + pattern = r"(?:^|\s)" + re.escape(alias) + r"(?:\s|$)" + m = re.search(pattern, head) + if not m: + continue + # The brand/sub-brand must be the first or second word ("Apple iPhone", + # "Samsung Galaxy", "Xiaomi Redmi Note") - not buried later. + if len(head[: m.start()].split()) > 1: + continue + # "Google Pixel 8", "Xiaomi Redmi Note 13": the parent brand matched, + # but the family is the sub-brand that follows it. + sub = alias if is_sub else next( + (s for s in ref.brands[slug].sub_brands if re.search(r"(?:^|\s)" + re.escape(s) + r"(?:\s|$)", head)), + None, + ) + return BrandMatch(slug, ref.brands[slug].name, _family_casing(sub) if sub else None, alias) + return None + + +_CASING = {"iphone": "iPhone", "iqoo": "iQOO", "macbook": "MacBook", "rog": "ROG", "tuf": "TUF", + "cmf": "CMF", "loq": "LOQ", "poco": "POCO", "mi": "Mi", "xps": "XPS", "thinkpad": "ThinkPad", + "ideapad": "IdeaPad", "thinkbook": "ThinkBook", "vivobook": "Vivobook", "zenbook": "Zenbook"} + + +def _family_casing(sub: str) -> str: + return _CASING.get(sub, sub.title()) + + +# Words that mark an accessory or a non-product page, not a device. +_NOT_A_DEVICE = re.compile( + r"\b(?:case|cover|back\s+cover|tempered|screen\s+guard|protector|charger|adapter|cable|" + r"skin|sleeve|bag|backpack|stand|holder|refurbished|renewed|pre-?owned|used|" + r"compare|vs\.?|versus|review|specifications?\s+and|price\s+list|best\s+\w+\s+under|" + r"top\s+\d+|all\s+models)\b", + re.IGNORECASE, +) + + +def looks_like_device_title(title: str) -> bool: + return bool(title) and not _NOT_A_DEVICE.search(title) diff --git a/backend/app/electronics/normalise/grounding.py b/backend/app/electronics/normalise/grounding.py new file mode 100644 index 0000000..828f215 --- /dev/null +++ b/backend/app/electronics/normalise/grounding.py @@ -0,0 +1,55 @@ +"""Is a value actually stated in the text it supposedly came from? + +Every value the LLM returns passes through value_in_source() against the exact +text the model was shown. Anything that cannot be found there is discarded, +which is what stops a small model's guess becoming a stored fact. +""" +from __future__ import annotations + +import re +from decimal import Decimal, InvalidOperation +from typing import Union + +_WS = re.compile(r"\s+") + + +def _norm_text(text: str) -> str: + text = text.lower().replace(" ", " ") + text = re.sub(r"[^\w.+ ]+", " ", text) + text = re.sub(r"(? set: + found = set() + for raw in re.findall(r"\d[\d,]*(?:\.\d+)?", text): + try: + found.add(Decimal(raw.replace(",", "")).normalize()) + except InvalidOperation: + continue + return found + + +def value_in_source(value: Union[str, int, float, Decimal, None], source: str) -> bool: + if value is None or not source: + return False + if isinstance(value, bool): + return False + if isinstance(value, (int, float, Decimal)): + try: + return Decimal(str(value)).normalize() in _numbers_in(source) + except InvalidOperation: + return False + text = str(value).strip() + if not text: + return False + # A string with a number in it ("5000 mAh", "Snapdragon 8 Gen 3") must have + # every one of its numbers in the source, and its words too. + nums = _numbers_in(text) + if nums and not nums <= _numbers_in(source): + return False + words = [w for w in _norm_text(text).split() if not re.fullmatch(r"[\d.,]+", w)] + hay = f" {_norm_text(source)} " + return all(f" {w} " in hay for w in words) if words else bool(nums) diff --git a/backend/app/electronics/normalise/llm_fill.py b/backend/app/electronics/normalise/llm_fill.py new file mode 100644 index 0000000..429f481 --- /dev/null +++ b/backend/app/electronics/normalise/llm_fill.py @@ -0,0 +1,71 @@ +"""Fill MISSING spec keys from page text with the local LLM - and keep only +what the text actually says. + +The model sees one block of text that we fetched (a spec section or a +description) and is asked to copy values out of it. Every value it returns is: + 1. checked by grounding.value_in_source() against that same text, and + 2. normalised by spec_normaliser (units, plausible ranges). +Anything failing either step is dropped. Prices, product names and images are +never asked of the model. +""" +from __future__ import annotations + +import json +import logging +from typing import Any, Dict, Iterable, Tuple + +from app.electronics.normalise.grounding import value_in_source +from app.electronics.normalise.spec_normaliser import normalise_value +from app.infrastructure.settings import ELEC_USE_LLM + +logger = logging.getLogger(__name__) + +MAX_SOURCE_CHARS = 3500 + +SYSTEM_PROMPT = ( + "You copy product specifications out of the text you are given. " + "Rules: use ONLY the given text; copy each value exactly as written, including its unit; " + "if the text does not state a value, use null; never guess, estimate or use outside knowledge. " + "Reply with one JSON object whose keys are exactly the requested keys." +) + + +def fill_missing( + category: str, + source_text: str, + missing_keys: Iterable[str], + *, + generate=None, +) -> Tuple[Dict[str, Any], Dict[str, str]]: + """(specs, sources) for whichever of `missing_keys` the text states.""" + keys = [k for k in missing_keys if k != "colour"] + text = (source_text or "").strip()[:MAX_SOURCE_CHARS] + if not keys or not text or not ELEC_USE_LLM: + return {}, {} + if generate is None: + from app.services.ollama_service import generate_json as generate + + prompt = ( + f"Requested keys: {json.dumps(keys)}\n\n" + f"Text:\n\"\"\"\n{text}\n\"\"\"\n\n" + "JSON:" + ) + reply = generate(SYSTEM_PROMPT, prompt) + if not isinstance(reply, dict): + return {}, {} + + specs: Dict[str, Any] = {} + sources: Dict[str, str] = {} + for key in keys: + raw = reply.get(key) + if raw is None or isinstance(raw, (dict, list, bool)): + continue + if not value_in_source(raw, text): + logger.debug("LLM value %r for %s not found in source text; dropped", raw, key) + continue + value = normalise_value(category, key, raw) + if value is None: + continue + specs[key] = value + sources[key] = f"llm-extracted: {str(raw)[:80]}" + return specs, sources diff --git a/backend/app/electronics/normalise/spec_normaliser.py b/backend/app/electronics/normalise/spec_normaliser.py new file mode 100644 index 0000000..fbdcb5a --- /dev/null +++ b/backend/app/electronics/normalise/spec_normaliser.py @@ -0,0 +1,101 @@ +"""Map raw spec labels/values from a page to canonical keys and units. + +Deterministic and table-driven (reference/spec_keys.yaml). A value that cannot +be parsed, or lands outside the plausible range for its key, is dropped - never +estimated. +""" +from __future__ import annotations + +import re +from decimal import Decimal, InvalidOperation +from functools import lru_cache +from typing import Any, Dict, Optional, Tuple + +from app.electronics.reference import load_reference + + +def _label(text: str) -> str: + return re.sub(r"[^a-z0-9]+", " ", str(text).lower()).strip() + + +@lru_cache(maxsize=None) +def _synonyms(category: str) -> Dict[str, str]: + table: Dict[str, str] = {} + for key, spec in load_reference().spec_keys.get(category, {}).items(): + for syn in [key.replace("_", " "), *spec.get("synonyms", [])]: + table.setdefault(_label(syn), key) + return table + + +def canonical_key(category: str, label: str) -> Optional[str]: + return _synonyms(category).get(_label(label)) + + +# unit -> (regex for the unit in text, factor into the canonical unit) +_UNIT_PATTERNS = { + "GB": [(r"tb", Decimal(1024)), (r"gb", Decimal(1)), (r"mb", Decimal(1) / 1024)], + "inch": [(r"(?:inch(?:es)?|in\b|\"|”)", Decimal(1)), (r"cm", Decimal(1) / Decimal("2.54"))], + "Hz": [(r"hz", Decimal(1))], + "MP": [(r"mp|megapixel", Decimal(1))], + "mAh": [(r"mah", Decimal(1))], + "kg": [(r"kg|kilogram", Decimal(1)), (r"(? Optional[Decimal]: + text = str(value).lower().replace(",", "") + patterns = _UNIT_PATTERNS.get(unit, []) + # Prefer an amount written in the canonical unit ("39.62 cm (15.6 inch)" -> 15.6). + for unit_re, factor in patterns: + m = re.search(r"(\d+(?:\.\d+)?)\s*(?:" + unit_re + r")", text) + if m: + try: + return (Decimal(m.group(1)) * factor).quantize(Decimal("0.01")).normalize() + except InvalidOperation: + return None + # A bare number is accepted only when nothing else is in the value. + m = re.fullmatch(r"\s*(\d+(?:\.\d+)?)\s*", text) + if m: + return Decimal(m.group(1)).normalize() + return None + + +def normalise_value(category: str, key: str, value: Any) -> Optional[Any]: + spec = load_reference().spec_keys.get(category, {}).get(key) + if spec is None or value is None: + return None + text = str(value).strip() + if not text or text.lower() in {"na", "n/a", "-", "none", "not applicable", "no"}: + return None + kind = spec.get("type") + if kind == "number": + num = _to_number(text, spec.get("unit", "")) + if num is None: + return None + lo, hi = spec.get("range", [None, None]) + if (lo is not None and num < Decimal(str(lo))) or (hi is not None and num > Decimal(str(hi))): + return None + return float(num) if num != num.to_integral() else int(num) + if kind == "enum": + low = text.lower() + for canon, words in spec.get("values", {}).items(): + if any(re.search(r"\b" + re.escape(w) + r"\b", low) for w in words): + return canon + return None + return re.sub(r"\s+", " ", text)[:120] + + +def normalise_specs(category: str, raw: Dict[str, Any]) -> Tuple[Dict[str, Any], Dict[str, str]]: + """(specs, sources): canonical key -> value, and key -> the raw label it came from.""" + specs: Dict[str, Any] = {} + sources: Dict[str, str] = {} + for label, value in (raw or {}).items(): + key = canonical_key(category, label) + if not key or key in specs: + continue + norm = normalise_value(category, key, value) + if norm is not None: + specs[key] = norm + sources[key] = f"{label}: {value}"[:200] + return specs, sources diff --git a/backend/app/electronics/normalise/title_parser.py b/backend/app/electronics/normalise/title_parser.py new file mode 100644 index 0000000..daca4a6 --- /dev/null +++ b/backend/app/electronics/normalise/title_parser.py @@ -0,0 +1,374 @@ +"""Split a retail product title into model, variant and a matching key. + +Everything returned is read from the title text; a value the title does not +state is None. Titles differ a lot between sites: + + Samsung Galaxy S24 5G (Onyx Black, 8GB RAM, 256GB Storage) Amazon + SAMSUNG Galaxy S24 5G (Onyx Black, 256 GB) (8 GB RAM) Flipkart + Samsung Galaxy S24 5G (8GB RAM, 256GB, Onyx Black) Croma + Redmi Note 13 Pro 5G (8GB + 256GB) + Apple iPhone 15 (128 GB) - Black + HP 15s, 13th Gen Intel Core i5-1334U, 16GB DDR4, 512GB SSD, ... fd0112TU + +so the model is taken from the text before the first bracket/comma, and RAM / +storage / colour from anywhere in the title. +""" +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from decimal import Decimal +from typing import List, Optional + +from app.electronics.normalise.brand_alias import BrandMatch, resolve_brand + +_NUM = r"(\d+(?:\.\d+)?)" + +# "8GB RAM", "8 GB LPDDR5X RAM", "RAM 8GB", "16GB DDR4" (laptops) +_RAM_RES = [ + re.compile(_NUM + r"\s*GB\s*(?:LP)?(?:DDR\s?\d\w?\s*)?RAM\b", re.IGNORECASE), + re.compile(r"\bRAM\s*[:\-]?\s*" + _NUM + r"\s*GB", re.IGNORECASE), + re.compile(_NUM + r"\s*GB\s*(?:LP)?DDR\s?\d", re.IGNORECASE), + re.compile(_NUM + r"\s*GB\s*(?:unified\s+memory|memory)\b", re.IGNORECASE), +] +# "8GB + 256GB", "8/256", "8GB/256GB", "12+512GB" +_PAIR_RE = re.compile(r"(? Decimal: + d = Decimal(value) + if unit.upper() == "TB": + d = d * 1024 + return d.normalize() if d == d.to_integral() else d + + +def _parse_ram_storage(text: str, category: str): + ram = storage = None + for rx in _RAM_RES: + m = rx.search(text) + if m: + ram = _dec(m.group(1)) + break + m = _PAIR_RE.search(text) + if m: + a, b, unit = m.group(1), m.group(2), (m.group(3) or "GB") + pair_ram, pair_storage = _dec(a), _dec(b, unit) + # "Core Ultra 5/ 16GB RAM/ 512GB" is not a 5 GB / 16 GB pair: a real + # pair has device-sized storage. + min_pair_storage = Decimal(16) if category == "mobiles" else Decimal(64) + if pair_storage > pair_ram and pair_storage >= min_pair_storage: + ram = ram if ram is not None else pair_ram + storage = pair_storage + if storage is None: + m = _STORAGE_LABELLED.search(text) + if m: + storage = _dec(m.group(1), m.group(2)) + if storage is None: + # Unlabelled sizes: the storage is the largest one that is not the RAM. + min_storage = Decimal(16) if category == "mobiles" else Decimal(32) + sizes = [_dec(v, u) for v, u in _SIZE_ANY.findall(text)] + candidates = [s for s in sizes if s != ram and s >= min_storage] + if candidates: + storage = max(candidates) + if ram is None: + # "(8 GB RAM)" handled above; an unlabelled small size next to a larger + # one ("8GB 256GB") is the RAM. + sizes = [_dec(v, u) for v, u in _SIZE_ANY.findall(text)] + small = [s for s in sizes if s <= (24 if category == "mobiles" else 64) and (storage is None or s < storage)] + if len(set(small)) == 1 and storage is not None: + ram = small[0] + plausible_ram = Decimal(32) if category == "mobiles" else Decimal(128) + if ram is not None and not (Decimal(1) <= ram <= plausible_ram): + ram = None + return ram, storage + + +def parse_processor(text: str) -> Optional[str]: + """Normalised CPU name ("i5-1334u", "ryzen 3 7320u", "core ultra 5 125h", + "core 5 120u", "athlon 7120u", "m2"), or None.""" + for rx in _PROCESSOR_RES: + m = rx.search(text or "") + if not m: + continue + parts = [g for g in m.groups() if g] + matched = m.group(0).lower() + if "ryzen" in matched: + return f"ryzen {parts[0]} {parts[1]}".lower() + if "athlon" in matched: + return f"athlon {parts[1]}".lower() + if "ultra" in matched and "core" in matched: + return f"core ultra {parts[0]} {parts[1]}".lower() + if "core" in matched and parts[0].lower().startswith("i"): + return f"{parts[0]}-{parts[1]}".lower() + if "core" in matched: + return f"core {parts[0]} {parts[1]}".lower() + return " ".join(parts).lower() + return None + + +_parse_processor = parse_processor + + +def processor_is_specific(processor: Optional[str]) -> bool: + """True for a CPU named down to its model number ("i5-1334u"), which + together with brand, model line, RAM and storage identifies a laptop + configuration. "m2" (Apple) also counts.""" + if not processor: + return False + return bool(re.search(r"\d{3,}", processor)) or bool(re.fullmatch(r"m[1-9](?: (?:pro|max|ultra))?", processor)) + + +_MPN_RE = re.compile( + r"(? Optional[str]: + """A manufacturer part number such as 82XV00BHIN or fd0112TU, when the + title states one (laptops). Tokens that are specs or CPU names are not.""" + candidates = [] + for m in _MPN_RE.finditer(text): + tok = m.group(1) + low = tok.lower() + if len(tok) < 6 or len(tok) > 20: + continue + if sum(c.isdigit() for c in tok) < 2 or sum(c.isalpha() for c in tok) < 2: + continue + if _SPEC_TOKEN.match(low) or re.match(r"^(?:i[3579]|m[1-9]|rtx|gtx|rx|ddr|lpddr)", low): + continue + if processor and low in processor.replace("-", " ").split() + [processor.replace(" ", "")]: + continue + if re.search(r"\d+(?:gb|tb|mp|mah|hz|w)$", low): + continue + if re.search(r"-(?:core|inch|cell|bit|gen|thread)s?$|^\d+-", low) and not re.search(r"[a-z]\d", low.split("-")[-1]): + continue # "10-Core", "15-inch", "3-Cell" describe hardware, not a part number + candidates.append(tok) + return candidates[-1].upper() if candidates else None + + +def _parse_colour(title: str) -> Optional[str]: + # Inside brackets first: "(Onyx Black, 8GB RAM, 256GB Storage)" + for group in re.findall(r"\(([^()]*)\)", title): + for part in re.split(r"[,|/]", group): + part = part.strip() + if part and not re.search(r"\d", part) and _COLOUR_WORDS.search(part): + return part.title() + # Trailing "- Black" + m = re.search(r"[-–|,]\s*([A-Za-z][A-Za-z ]{2,30})\s*$", title) + if m and _COLOUR_WORDS.search(m.group(1)) and not re.search(r"\d", m.group(1)): + return m.group(1).strip().title() + return None + + +def normalise_model(model: str) -> str: + text = model.lower() + text = re.sub(r"[()\[\],|]", " ", text) + text = _MODEL_NOISE.sub(" ", text) + text = re.sub(r"\+", " plus ", text) + text = re.sub(r"[^a-z0-9 ]+", " ", text) + tokens = [t for t in text.split() if not _SPEC_TOKEN.match(t)] + return " ".join(tokens) + + +_LAPTOP_SPEC_START = re.compile( + r"\b(?:intel|amd|apple\s+m[1-9]|m[1-9]\s+chip|core\s+(?:i[3579]|ultra)|ryzen|snapdragon|celeron|pentium|" + r"mediatek|\d+(?:th|nd|rd|st)\s+gen|\d+(?:\.\d+)?\s*(?:-|\s)?(?:inch|cm|\"))", + re.IGNORECASE, +) +_DISPLAY_NOISE = re.compile( + r"\b(?:5g|4g|lte|smartphone|smart\s+phone|mobile\s+phone|dual\s+sim|laptop|notebook|" + r"thin\s+and\s+light|thin\s+&\s+light|gaming|new|latest)\b", + re.IGNORECASE, +) + + +def _model_from_title(title: str, brand: Optional[BrandMatch], category: str, colour: Optional[str], + mpn: Optional[str] = None): + """(display model, matching model_norm) from the head of the title.""" + head = re.sub(r"^\s*buy\s+", "", title, flags=re.IGNORECASE) + if mpn: + # A part number is not the model name. HP writes the line into it + # ("15-fc0500AU" is an HP 15), so that prefix is kept. + prefix = mpn.split("-", 1)[0] if "-" in mpn and len(mpn.split("-", 1)[0]) <= 4 else "" + head = re.sub(re.escape(mpn), f" {prefix} ", head, flags=re.IGNORECASE) + # A short model token in brackets right after the name is part of it: + # "Nothing Phone (2a) 5G (Black, 128 GB)". + head = re.sub(r"\((?:19|20)\d\d\)", " ", head) # "(2026)" is a model year, not part of the name + head = re.sub(r"\(([A-Za-z0-9+ ]{1,6})\)", lambda m: " " + m.group(1) + " " + if not re.search(r"\d\s*(?:gb|tb)", m.group(1), re.I) else m.group(0), head, count=1) + head = re.split(r"\s[-–|]\s|[(,|\[:]", head, maxsplit=1)[0] + if category == "laptops": + m = _LAPTOP_SPEC_START.search(head) + if m and m.start() > 0: + head = head[: m.start()] + if brand: + # Drop the parent brand's own name ("Samsung Galaxy S24" -> "Galaxy S24", + # "Apple iPhone 15" -> "iPhone 15"); a sub-brand stays ("Redmi Note 13"). + from app.electronics.reference import load_reference + + parent_aliases = sorted(load_reference().brands[brand.brand_slug].aliases, key=len, reverse=True) + for alias in parent_aliases: + head = re.sub(r"^\s*" + re.escape(alias) + r"\b", "", head, flags=re.IGNORECASE).strip() + head = re.sub(r"\b\d+\s*GB\s*RAM\b", " ", head, flags=re.IGNORECASE) + head = _PAIR_RE.sub(" ", head) + head = _SIZE_ANY.sub(" ", head) + if colour: + head = re.sub(re.escape(colour), " ", head, flags=re.IGNORECASE) + words = head.split() + while len(words) > 1 and _COLOUR_WORDS.fullmatch(words[-1]): + words.pop() # "iPhone 15 Black" -> "iPhone 15" + head = " ".join(words) + display = re.sub(r"\s+", " ", _DISPLAY_NOISE.sub(" ", head)).strip(" -–") + norm = normalise_model(head) + if not norm: + return None, None + return display or head.strip(), norm + + +def parse_title(title: str, category: str, *, expected_brand: Optional[str] = None) -> ParsedTitle: + title = re.sub(r"\s+", " ", (title or "")).strip() + brand = resolve_brand(title, expected=expected_brand) + parsed = ParsedTitle(title=title, brand=brand) + if not title: + return parsed + + parsed.ram_gb, parsed.storage_gb = _parse_ram_storage(title, category) + parsed.colour = _parse_colour(title) + if re.search(r"\b5G\b", title, re.IGNORECASE): + parsed.network = "5G" + if category == "laptops": + parsed.processor = _parse_processor(title) + parsed.mpn = _parse_mpn(title, parsed.processor) + + parsed.model, parsed.model_norm = _model_from_title(title, brand, category, parsed.colour, parsed.mpn) + return parsed + + +def variant_key(parsed: ParsedTitle, category: str) -> Optional[str]: + """The identity of one real-world variant, or None if the title does not + state enough to tell variants apart.""" + if not parsed.brand or not parsed.model_norm: + return None + b = parsed.brand.brand_slug + fmt = lambda d: "na" if d is None else format(d.normalize(), "f") # noqa: E731 + if category == "laptops": + # A laptop configuration is its model line + CPU + RAM + storage. That + # is what every site states (a part number is shown by only a few), so + # it is the key whenever it is complete; the MPN is the fallback. + line = laptop_line(parsed.model_norm) + if line and processor_is_specific(parsed.processor) and parsed.ram_gb and parsed.storage_gb: + return f"{b}|laptops|{line}|{parsed.processor}|{fmt(parsed.ram_gb)}|{fmt(parsed.storage_gb)}" + if parsed.mpn: + return f"{b}|laptops|mpn:{parsed.mpn.lower()}" + return None + if parsed.storage_gb is None: + return None + return f"{b}|{category}|{parsed.model_norm}|{fmt(parsed.ram_gb)}|{fmt(parsed.storage_gb)}" + + +# Lenovo/Asus machine-type codes ("15amn8", "15irh10", "14iah8", "x1504za") +# name a chassis generation, and one site prints them where another does not. +_MACHINE_CODE = re.compile(r"^(?:\d{2}[a-z]{2,4}\d{1,2}|[a-z]\d{4}[a-z]{1,3})$") + + +def laptop_line(model_norm: Optional[str]) -> str: + """The model line used for matching: "ideapad slim 3 15amn8" -> "ideapad slim 3".""" + tokens = [t for t in (model_norm or "").split() if not _MACHINE_CODE.match(t)] + return " ".join(tokens) + + +def fill_from_context(parsed: ParsedTitle, category: str, *, snippet: str = "", + spec_texts: tuple = ()) -> ParsedTitle: + """Fill variant fields a (often truncated) title leaves out, from text the + same site published about the same page: its search snippet, or the spec + table of the fetched page. Only unambiguous values are taken - a snippet + naming two different storage sizes is describing several variants.""" + if snippet and (parsed.ram_gb is None or parsed.storage_gb is None): + sizes = {_dec(v, u) for v, u in _SIZE_ANY.findall(snippet)} + if len(sizes) <= 2: + ram, storage = _parse_ram_storage(snippet, category) + if parsed.storage_gb is None and storage is not None: + parsed.storage_gb = storage + if parsed.ram_gb is None and ram is not None and ram != parsed.storage_gb: + parsed.ram_gb = ram + if category == "laptops" and not processor_is_specific(parsed.processor): + # A title that already names a CPU family ("Snapdragon X", "Core i7") + # is only completed from the page's own spec table, never from a + # snippet - snippets often run several products' titles together. + sources = list(spec_texts) + ([snippet] if parsed.processor is None and snippet else []) + found = set() + for text in sources: + found |= {cpu for cpu in all_processors(text) if processor_is_specific(cpu)} + if len(found) == 1: + parsed.processor = found.pop() + return parsed + + +def all_processors(text: str) -> set: + """Every CPU named anywhere in `text` (a snippet can name several).""" + found = set() + for rx in _PROCESSOR_RES: + for m in rx.finditer(text or ""): + cpu = parse_processor(m.group(0)) + if cpu: + found.add(cpu) + return found diff --git a/backend/app/electronics/price_lookup.py b/backend/app/electronics/price_lookup.py new file mode 100644 index 0000000..f2b88ab --- /dev/null +++ b/backend/app/electronics/price_lookup.py @@ -0,0 +1,102 @@ +"""Fill missing prices on search-only platforms (Amazon.in, Flipkart, Croma...) +from Google Programmable Search, without fetching those sites. + +For each listing that has no price (or an unconfirmed one), search Google for +that product on that site. A price is taken only when: + * the result is the SAME product page (its site product id equals the + listing's), and + * Google's structured data for the page (pagemap offer / product:price meta) + states an INR price. +The listing is updated through the normal path, so the price is stored with +its evidence, appended to price_history, and outlier-checked. +""" +from __future__ import annotations + +import logging +from typing import Callable, Dict, Optional + +from app.electronics.collector import Collector, RunOptions, RunStats, source_sku +from app.electronics.db import repository as repo +from app.electronics.db.connection import connect +from app.electronics.normalise.title_parser import parse_title +from app.electronics.reference import load_reference, site_for_url +from app.electronics.search.engine import SearchEngine + +logger = logging.getLogger(__name__) + + +def _listings_needing_price(limit: int, category: Optional[str]) -> list: + with connect() as conn: + return conn.execute( + """ + SELECT l.id, l.title, l.source_sku, l.source_url, s.domain, b.slug AS brand_slug, c.slug AS category, + (p.verification_status = 'verified') AS verified + FROM elec.source_listing l + JOIN elec.site s ON s.id = l.site_id + JOIN elec.brand b ON b.id = l.brand_id + JOIN elec.category c ON c.id = l.category_id + JOIN elec.product_listing_map m ON m.listing_id = l.id AND m.review_status IN ('auto','approved') + JOIN elec.product p ON p.id = m.product_id + WHERE l.source_type = 'search_snippet' + AND (l.price IS NULL OR l.price_outlier) + AND (s.policy = 'serp_only' OR coalesce(s.probe_outcome, 'C') = 'C') + AND (%(category)s::text IS NULL OR c.slug = %(category)s) + ORDER BY (p.verification_status = 'verified') DESC, l.last_seen_at DESC + LIMIT %(limit)s + """, + {"limit": limit, "category": category}, + ).fetchall() + + +def lookup_prices(limit: int = 40, category: Optional[str] = None, + progress: Callable[[str], None] = logger.info) -> Dict[str, object]: + stats: Dict[str, object] = {"checked": 0, "priced": 0, "no_same_page": 0, "no_structured_price": 0} + engine = SearchEngine(budget=limit) + if not engine.google.enabled: + stats["error"] = "Google Programmable Search is not configured (GOOGLE_API_KEY / GOOGLE_CSE_ID)" + return stats + ids = repo.id_maps() + ref = load_reference() + run_id = repo.start_run("price_lookup", {"limit": limit, "category": category}) + try: + for row in _listings_needing_price(limit, category): + if not engine.google.enabled: + break + site = ref.sites[row["domain"]] + query = f"site:{row['domain']} {row['title'][:110]}" + hits = engine.text(query, max_results=10, providers="google") + stats["checked"] += 1 + if hits is None: + continue + same = [h for h in hits + if (s := site_for_url(h.url)) is not None and s.domain == site.domain + and source_sku(site, h.url) == row["source_sku"]] + if not same: + stats["no_same_page"] += 1 + continue + hit = next((h for h in same if h.offer), None) + if hit is None: + stats["no_structured_price"] += 1 + continue + collector = Collector.__new__(Collector) # only its listing builder is used + collector.opt = RunOptions(category=row["category"], brands=[row["brand_slug"]]) + collector.stats = RunStats() + parsed = parse_title(row["title"], row["category"], expected_brand=row["brand_slug"]) + if parsed.brand is None: + continue + listing = collector.listing_from_search(hit, site, parsed, query) + listing.source_sku = row["source_sku"] + if listing.price is None: + stats["no_structured_price"] += 1 + continue + repo.upsert_listing(listing, ids, run_id) + stats["priced"] += 1 + progress(f"{site.name}: {row['title'][:70]} -> Rs {listing.price}") + stats["products"] = repo.refresh_verification() + if engine.google.error: + stats["error"] = engine.google.error + repo.finish_run(run_id, "done", {k: v for k, v in stats.items() if k != "products"}) + except Exception as exc: + repo.finish_run(run_id, "failed", {}, repr(exc)) + raise + return stats diff --git a/backend/app/electronics/probe/__init__.py b/backend/app/electronics/probe/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/electronics/probe/site_probe.py b/backend/app/electronics/probe/site_probe.py new file mode 100644 index 0000000..9deb8ca --- /dev/null +++ b/backend/app/electronics/probe/site_probe.py @@ -0,0 +1,99 @@ +"""Decide, per site, whether it may be scraped or only searched. + + A robots.txt allows product pages, HTTP 200 without a bot check, and the + page carries a schema.org Product with an INR offer -> scrape + B fetchable, product name/specs readable from the HTML, but no + structured price -> scrape specs/images, + price from search + C serp_only policy, robots.txt disallows, blocked / CAPTCHA, or the page + has no product data without JavaScript -> web search only + +The probe looks at 2-3 real product URLs for the site, found through web +search, so it grades the pages the collector would actually fetch. +""" +from __future__ import annotations + +import logging +from typing import Dict, List, Optional + +from app.electronics.extract.html_fallback import embedded_state, extract_page +from app.electronics.extract.jsonld import extract_products +from app.electronics.net.polite_client import PoliteClient +from app.electronics.reference import SiteRef, load_reference, site_for_url +from app.electronics.search.engine import SearchEngine + +logger = logging.getLogger(__name__) + + +def sample_product_urls(site: SiteRef, engine: SearchEngine, limit: int = 3) -> List[str]: + ref = load_reference() + urls: List[str] = [] + if site.kind == "brand_official": + brand = ref.brands[site.brand_slug] + terms = [ref.categories[c].search_terms[0] for c in brand.categories] + queries = [f"site:{site.domain} {brand.name} {t}" for t in terms] + else: + queries = [f"site:{site.domain} samsung galaxy 5g", f"site:{site.domain} lenovo laptop"] + rx = site.product_url_re + for q in queries: + for hit in engine.text(q, max_results=15) or []: + s = site_for_url(hit.url) + if not s or s.domain != site.domain: + continue + if rx is not None and not rx.search(hit.url): + continue + if hit.url not in urls: + urls.append(hit.url) + if len(urls) >= limit: + return urls + return urls + + +def grade_page(html: str) -> Dict[str, object]: + products = extract_products(html) + priced = [p for p in products if p.get("price") is not None and (p.get("currency") in (None, "INR"))] + page = extract_page(html) + return { + "jsonld_products": len(products), + "jsonld_priced": len(priced), + "meta_price": page.get("price") is not None, + "has_title": bool(page.get("name")), + "spec_rows": len(page.get("properties") or {}), + "embedded_state": embedded_state(html) is not None, + } + + +def probe_site(site: SiteRef, client: PoliteClient, engine: SearchEngine) -> Dict[str, object]: + """Returns {"outcome", "robots_allowed", "evidence"}; never raises.""" + if site.policy == "serp_only": + return {"outcome": "C", "robots_allowed": None, + "evidence": {"reason": "policy serp_only: this site is never fetched directly"}} + urls = sample_product_urls(site, engine) + if not urls: + return {"outcome": "C", "robots_allowed": None, + "evidence": {"reason": "no product URLs found through web search"}} + pages: List[dict] = [] + robots_any: Optional[bool] = None + for url in urls: + res = client.get(url) + entry = {"url": url, "status": res.status, "outcome": res.outcome} + robots_any = res.robots_allowed if robots_any is None else (robots_any or bool(res.robots_allowed)) + if res.ok: + entry.update(grade_page(res.text)) + pages.append(entry) + if res.outcome in ("captcha", "blocked", "breaker_open"): + break + ok_pages = [p for p in pages if p["outcome"] == "ok"] + if any(p["outcome"] in ("captcha", "blocked") for p in pages): + outcome, reason = "C", "blocked or bot check - not fetched again until the breaker cools down" + elif all(p["outcome"] == "robots_disallowed" for p in pages): + outcome, reason = "C", "robots.txt disallows product pages" + elif not ok_pages: + outcome, reason = "C", "product pages could not be fetched" + elif any(p.get("jsonld_priced") for p in ok_pages): + outcome, reason = "A", "schema.org Product with an INR offer" + elif any(p.get("has_title") and (p.get("spec_rows") or p.get("meta_price") or p.get("jsonld_products")) for p in ok_pages): + outcome, reason = "B", "product details readable from HTML; no structured price" + else: + outcome, reason = "C", "no product data without JavaScript" + return {"outcome": outcome, "robots_allowed": robots_any, "evidence": {"reason": reason, "pages": pages}} diff --git a/backend/app/electronics/reference/__init__.py b/backend/app/electronics/reference/__init__.py new file mode 100644 index 0000000..8d98d8f --- /dev/null +++ b/backend/app/electronics/reference/__init__.py @@ -0,0 +1,132 @@ +"""Reference data (brands, categories, sites, spec dictionary) loaded from YAML.""" +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from functools import lru_cache +from pathlib import Path +from typing import Dict, List, Optional + +import yaml + +_DIR = Path(__file__).resolve().parent + + +def slugify(text: str) -> str: + return re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-") + + +@dataclass(frozen=True) +class BrandRef: + name: str + slug: str + categories: tuple + aliases: tuple + sub_brands: tuple + official: tuple + + +@dataclass(frozen=True) +class CategoryRef: + slug: str + name: str + search_terms: tuple + query_terms: tuple = () + + +@dataclass(frozen=True) +class SiteRef: + domain: str + name: str + kind: str + region: str + policy: str + product_url: Optional[str] = None + pincode_param: Optional[str] = None + brand_slug: Optional[str] = None + + @property + def product_url_re(self) -> Optional[re.Pattern]: + return re.compile(self.product_url) if self.product_url else None + + +@dataclass(frozen=True) +class Reference: + brands: Dict[str, BrandRef] + categories: Dict[str, CategoryRef] + sites: Dict[str, SiteRef] + spec_keys: Dict[str, dict] = field(default_factory=dict) + + def brands_for(self, category: str) -> List[BrandRef]: + return [b for b in self.brands.values() if category in b.categories] + + +def _load_yaml(name: str) -> dict: + return yaml.safe_load((_DIR / name).read_text(encoding="utf-8")) or {} + + +@lru_cache(maxsize=1) +def load_reference() -> Reference: + raw_brands = _load_yaml("brands.yaml") + brands: Dict[str, BrandRef] = {} + for b in raw_brands.get("brands", []): + slug = slugify(b["name"]) + brands[slug] = BrandRef( + name=b["name"], + slug=slug, + categories=tuple(b.get("categories", [])), + aliases=tuple(a.lower() for a in b.get("aliases", [])), + sub_brands=tuple(s.lower() for s in b.get("sub_brands", [])), + official=tuple(b.get("official", [])), + ) + categories = { + c["slug"]: CategoryRef(c["slug"], c["name"], tuple(c.get("search_terms", [])), + tuple(c.get("query_terms", []))) + for c in raw_brands.get("categories", []) + } + + sites: Dict[str, SiteRef] = {} + for s in _load_yaml("sites.yaml").get("sites", []): + sites[s["domain"]] = SiteRef( + domain=s["domain"], + name=s["name"], + kind=s["kind"], + region=s["region"], + policy=s["policy"], + product_url=s.get("product_url"), + pincode_param=s.get("pincode_param"), + ) + # Every brand's official domains become sites of their own. They are + # probed like any retailer - an official page is the best evidence there is. + for b in brands.values(): + for domain in b.official: + sites.setdefault( + domain, + SiteRef( + domain=domain, + name=f"{b.name} (official)", + kind="brand_official", + region="national", + policy="probe", + brand_slug=b.slug, + ), + ) + + spec_keys = _load_yaml("spec_keys.yaml").get("categories", {}) + return Reference(brands=brands, categories=categories, sites=sites, spec_keys=spec_keys) + + +def site_for_url(url: str) -> Optional[SiteRef]: + """The registered site a URL belongs to (subdomains included), or None.""" + from urllib.parse import urlparse + + host = (urlparse(url).hostname or "").lower() + if not host: + return None + ref = load_reference() + best: Optional[SiteRef] = None + for domain, site in ref.sites.items(): + if host == domain or host.endswith("." + domain): + if best is None or len(domain) > len(best.domain): + best = site + return best diff --git a/backend/app/electronics/reference/brands.yaml b/backend/app/electronics/reference/brands.yaml new file mode 100644 index 0000000..a8a97f7 --- /dev/null +++ b/backend/app/electronics/reference/brands.yaml @@ -0,0 +1,100 @@ +# Brand allow-list. A listing whose brand does not resolve to one of these is +# rejected - the catalogue is closed-world by design. +# +# aliases spellings seen on retail pages (matched case-insensitively, +# longest alias first, as a whole word at the start of a title) +# sub_brands product families sold under a parent brand. They resolve to the +# parent, and are kept as the product family. +# official the brand's own Indian web domains. A product page on one of +# these is the strongest evidence that a product exists. +brands: + - name: Samsung + categories: [mobiles, laptops] + aliases: [samsung] + official: [samsung.com] + - name: Apple + categories: [mobiles, laptops] + aliases: [apple] + sub_brands: [iphone, macbook] + official: [apple.com] + - name: Xiaomi + categories: [mobiles] + aliases: [xiaomi] + sub_brands: [redmi, poco, mi] + official: [mi.com] + - name: OnePlus + categories: [mobiles] + aliases: [oneplus, one plus] + official: [oneplus.in] + - name: Vivo + categories: [mobiles] + aliases: [vivo] + sub_brands: [iqoo] + official: [vivo.com, iqoo.com] + - name: Oppo + categories: [mobiles] + aliases: [oppo] + official: [oppo.com] + - name: Realme + categories: [mobiles] + aliases: [realme] + sub_brands: [narzo] + official: [realme.com] + - name: Motorola + categories: [mobiles] + aliases: [motorola, moto] + official: [motorola.co.in, motorola.com] + - name: Google + categories: [mobiles] + aliases: [google] + sub_brands: [pixel] + official: [store.google.com] + - name: Nothing + categories: [mobiles] + aliases: [nothing] + sub_brands: [cmf] + official: [nothing.tech] + - name: HP + categories: [laptops] + aliases: [hp, hewlett packard] + sub_brands: [omen, victus, pavilion, envy, spectre] + official: [hp.com] + - name: Dell + categories: [laptops] + aliases: [dell] + sub_brands: [alienware, inspiron, vostro, latitude, xps] + official: [dell.com] + - name: Lenovo + categories: [laptops] + aliases: [lenovo] + sub_brands: [thinkpad, ideapad, legion, yoga, thinkbook, loq] + official: [lenovo.com] + - name: Asus + categories: [laptops] + aliases: [asus] + sub_brands: [rog, tuf, vivobook, zenbook] + official: [asus.com] + - name: Acer + categories: [laptops] + aliases: [acer] + sub_brands: [aspire, nitro, predator, swift] + official: [acer.com] + - name: MSI + categories: [laptops] + aliases: [msi] + official: [msi.com] + +# search_terms: the category word used when probing brand sites. +# query_terms: appended to `site: ` during discovery. They +# read like the variant part of a product title, which is what +# makes search engines return single product pages rather than +# category or blog pages. +categories: + - slug: mobiles + name: Mobiles + search_terms: [smartphone, mobile phone] + query_terms: ["5G 8GB RAM 128GB", "5G 8GB 256GB", "12GB RAM 256GB"] + - slug: laptops + name: Laptops + search_terms: [laptop] + query_terms: ["laptop 16GB RAM 512GB SSD", "laptop 8GB RAM 512GB SSD"] diff --git a/backend/app/electronics/reference/sites.yaml b/backend/app/electronics/reference/sites.yaml new file mode 100644 index 0000000..251e9a8 --- /dev/null +++ b/backend/app/electronics/reference/sites.yaml @@ -0,0 +1,77 @@ +# Retail platforms. +# +# kind marketplace | national_chain | tn_regional +# region national | TN (TN = a Tamil Nadu retail chain) +# policy serp_only -> NEVER fetched directly; everything comes from web +# search results (titles, snippets, image results) +# probe -> fetched only if the site probe grades it A or B +# (robots.txt allows, HTTP 200, no CAPTCHA); otherwise +# it falls back to search results like serp_only +# product_url regex a URL must match to count as a single product page. +# Group 1, when present, is the site's own product id. +# pincode_param optional query parameter the site accepts for a delivery +# pincode. Only sites that actually honour it get +# pincode_applied=true on their prices. +# +# Brand official sites are generated from brands.yaml (kind brand_official). +sites: + - domain: amazon.in + name: Amazon.in + kind: marketplace + region: national + policy: serp_only + product_url: '/(?:dp|gp/product)/([A-Z0-9]{10})' + - domain: flipkart.com + name: Flipkart + kind: marketplace + region: national + policy: serp_only + product_url: '/p/(itm[0-9a-z]+)' + - domain: croma.com + name: Croma + kind: national_chain + region: national + policy: probe + product_url: '/p/(\d{5,})' + - domain: reliancedigital.in + name: Reliance Digital + kind: national_chain + region: national + policy: probe + product_url: '(?:/p/|/product/[^?#]*?-)(\d{6,})' + - domain: vijaysales.com + name: Vijay Sales + kind: national_chain + region: national + policy: probe + product_url: '/p/(?:P?)(\d{3,})/' + - domain: tatacliq.com + name: Tata CLiQ + kind: marketplace + region: national + policy: probe + product_url: '/p-(mp\d+)' + - domain: poorvika.com + name: Poorvika + kind: tn_regional + region: TN + policy: probe + product_url: '/([a-z0-9-]{8,})/p/?$' + - domain: sangeethamobiles.com + name: Sangeetha Mobiles + kind: tn_regional + region: TN + policy: probe + product_url: '(?i)/product-?details/(?:[^/?#]+/)?(\d+)' + - domain: vasanthandco.in + name: Vasanth & Co + kind: tn_regional + region: TN + policy: probe + product_url: '/(?:product|products)/([a-z0-9-]{8,})' + - domain: viveks.com + name: Viveks + kind: tn_regional + region: TN + policy: probe + product_url: '/([a-z0-9-]{8,})\.html$' diff --git a/backend/app/electronics/reference/spec_keys.yaml b/backend/app/electronics/reference/spec_keys.yaml new file mode 100644 index 0000000..78eb382 --- /dev/null +++ b/backend/app/electronics/reference/spec_keys.yaml @@ -0,0 +1,127 @@ +# Canonical specification keys per category. +# +# type number | text | enum +# unit canonical unit for numbers (values are converted into it) +# synonyms spec labels seen on retail/brand pages (case-insensitive, +# punctuation ignored). A label maps to the first key that lists it. +# range plausible [min, max] after conversion; values outside are dropped +# values allowed canonical values for enums, each with its match words +# +# A value is only ever stored if it was read from a page or snippet. Nothing +# here supplies a default. +categories: + mobiles: + ram_gb: + type: number + unit: GB + range: [1, 32] + synonyms: [ram, memory ram, ram size, ram capacity, installed ram, system memory] + storage_gb: + type: number + unit: GB + range: [8, 2048] + synonyms: [internal storage, storage, rom, internal memory, storage capacity, inbuilt memory, memory storage capacity] + display_inch: + type: number + unit: inch + range: [3, 9] + synonyms: [display size, screen size, display, screen size inches, standing screen display size] + display_type: + type: enum + synonyms: [display type, screen type, display technology, panel type] + values: + AMOLED: [amoled, super amoled, dynamic amoled, pole amoled, fluid amoled] + OLED: [oled, super retina, ltpo oled] + LCD: [lcd, ips lcd, tft, ips] + refresh_hz: + type: number + unit: Hz + range: [30, 240] + synonyms: [refresh rate, screen refresh rate, display refresh rate] + processor: + type: text + synonyms: [processor, chipset, processor name, soc, cpu, processor brand] + rear_camera_mp: + type: number + unit: MP + range: [2, 250] + synonyms: [rear camera, primary camera, main camera, back camera, rear camera resolution, primary camera resolution] + front_camera_mp: + type: number + unit: MP + range: [2, 60] + synonyms: [front camera, secondary camera, selfie camera, front camera resolution] + battery_mah: + type: number + unit: mAh + range: [1000, 10000] + synonyms: [battery capacity, battery, battery power, battery capacity mah] + os: + type: enum + synonyms: [operating system, os, os version] + values: + Android: [android] + iOS: [ios] + network: + type: enum + synonyms: [network type, network, cellular technology, connectivity technology, network connectivity] + values: + 5G: [5g] + 4G: [4g, lte] + colour: + type: text + synonyms: [colour, color, colour name, color name] + laptops: + processor: + type: text + synonyms: [processor, processor name, cpu, processor model, processor type] + ram_gb: + type: number + unit: GB + range: [2, 128] + synonyms: [ram, ram size, memory, system memory, installed ram, ram capacity] + storage_gb: + type: number + unit: GB + range: [32, 8192] + synonyms: [ssd capacity, storage, hard disk size, hard drive size, storage capacity, ssd, internal storage] + storage_type: + type: enum + synonyms: [storage type, hard disk type, hard drive interface, drive type] + values: + SSD: [ssd, nvme, solid state] + HDD: [hdd, hard disk drive] + eMMC: [emmc] + display_inch: + type: number + unit: inch + range: [10, 19] + synonyms: [screen size, display size, standing screen display size, display] + resolution: + type: text + synonyms: [resolution, screen resolution, display resolution, maximum display resolution] + gpu: + type: text + synonyms: [graphics, graphics processor, gpu, graphic processor, graphics coprocessor, graphics card] + os: + type: enum + synonyms: [operating system, os] + values: + Windows: [windows] + macOS: [macos, mac os] + ChromeOS: [chrome os, chromeos] + Linux: [linux, ubuntu] + DOS: [dos, free dos, freedos] + weight_kg: + type: number + unit: kg + range: [0.5, 5] + synonyms: [weight, item weight, product weight, laptop weight] + battery_wh: + type: number + unit: Wh + range: [20, 120] + synonyms: [battery capacity, battery, battery power] + colour: + type: text + synonyms: [colour, color] diff --git a/backend/app/electronics/reviews.py b/backend/app/electronics/reviews.py new file mode 100644 index 0000000..dc8e798 --- /dev/null +++ b/backend/app/electronics/reviews.py @@ -0,0 +1,96 @@ +"""Which real customer reviews to show for a product, and in what mix. + +Every review passed in here was read from a product page's own schema.org +data (see extract/jsonld.py); this module only classifies and selects - it +never writes, rewrites or summarises review text. + +Sentiment is the reviewer's own star rating, nothing inferred: + >= 4 positive, >= 3 neutral, < 3 negative. + +The mix follows the product's overall rating, so the reviews shown read like +the rating does: + rating >= 4.0 mostly positive, some neutral, a little negative + 3.0 < rating < 4.0 mostly neutral, some positive, a little negative + rating <= 3.0 mostly negative, a little positive and neutral +When a group has too few reviews its slots go to the other groups, in the +same priority order. Nothing is ever padded: if only 3 real reviews exist, +3 are shown. +""" +from __future__ import annotations + +from decimal import Decimal +from typing import Any, Dict, List, Optional, Sequence, Tuple + +POSITIVE, NEUTRAL, NEGATIVE = "positive", "neutral", "negative" +MAX_REVIEWS = 10 + +# (group, share of MAX_REVIEWS), highest priority first. +_MIX_HIGH: Tuple[Tuple[str, int], ...] = ((POSITIVE, 6), (NEUTRAL, 3), (NEGATIVE, 1)) +_MIX_MID: Tuple[Tuple[str, int], ...] = ((NEUTRAL, 5), (POSITIVE, 3), (NEGATIVE, 2)) +_MIX_LOW: Tuple[Tuple[str, int], ...] = ((NEGATIVE, 6), (POSITIVE, 2), (NEUTRAL, 2)) + + +def sentiment_for(rating: Any) -> Optional[str]: + """The group a reviewer's own star rating puts a review in; None when the + review states no rating.""" + if rating is None: + return None + try: + value = Decimal(str(rating)) + except Exception: # noqa: BLE001 + return None + if value >= 4: + return POSITIVE + if value >= 3: + return NEUTRAL + return NEGATIVE + + +def mix_for(product_rating: Any) -> Tuple[Tuple[str, int], ...]: + if product_rating is None: + return _MIX_MID # no overall rating stated: a balanced view + value = Decimal(str(product_rating)) + if value >= 4: + return _MIX_HIGH + if value > 3: + return _MIX_MID + return _MIX_LOW + + +def _rank_key(review: Dict[str, Any]) -> tuple: + # Newest first (ISO dates sort as text), then the more substantial review. + return (str(review.get("review_date") or ""), len(review.get("body") or "")) + + +def select_reviews(product_rating: Any, reviews: Sequence[Dict[str, Any]], + max_n: int = MAX_REVIEWS) -> List[Dict[str, Any]]: + """Up to `max_n` of `reviews`, mixed by sentiment as described above. + + Reviews without a star rating have no sentiment and are not shown: there + is no honest way to place them in the mix. + """ + groups: Dict[str, List[Dict[str, Any]]] = {POSITIVE: [], NEUTRAL: [], NEGATIVE: []} + seen = set() + for r in reviews: + s = r.get("sentiment") or sentiment_for(r.get("rating")) + key = (r.get("body") or "").strip().lower() + if s is None or not key or key in seen: + continue + seen.add(key) + groups[s].append({**r, "sentiment": s}) + for g in groups.values(): + g.sort(key=_rank_key, reverse=True) + + mix = mix_for(product_rating) + scale = max_n / MAX_REVIEWS + quota = {g: int(round(n * scale)) for g, n in mix} + picked: Dict[str, List[Dict[str, Any]]] = {g: groups[g][: quota[g]] for g, _ in mix} + # Hand unused slots to the other groups, in priority order. + spare = max_n - sum(len(v) for v in picked.values()) + for g, _ in mix: + if spare <= 0: + break + extra = groups[g][len(picked[g]): len(picked[g]) + spare] + picked[g].extend(extra) + spare -= len(extra) + return [r for g, _ in mix for r in picked[g]] diff --git a/backend/app/electronics/search/__init__.py b/backend/app/electronics/search/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/electronics/search/engine.py b/backend/app/electronics/search/engine.py new file mode 100644 index 0000000..0fe7ec9 --- /dev/null +++ b/backend/app/electronics/search/engine.py @@ -0,0 +1,65 @@ +"""Cached, budgeted access to the search providers. + +Results are cached in elec.search_cache so a re-run does not query again +within SEARCH_CACHE_TTL_HOURS, and each run has a query budget so a large +brand list cannot hammer the providers. +""" +from __future__ import annotations + +import logging +from typing import Dict, List, Optional + +from app.electronics.db import repository as repo +from app.electronics.search.providers import DuckDuckGoProvider, GoogleCseProvider, SearchHit +from app.infrastructure.settings import GOOGLE_CSE_DAILY_QUOTA, SEARCH_CACHE_TTL_HOURS + +logger = logging.getLogger(__name__) + + +class SearchEngine: + def __init__(self, *, budget: int = 200, use_cache: bool = True) -> None: + self.budget = budget + self.use_cache = use_cache + self.used = 0 + self.stats: Dict[str, int] = {"cache_hits": 0, "queries": 0, "unavailable": 0} + self.ddg = DuckDuckGoProvider() + self.google = GoogleCseProvider( + quota_left=lambda: GOOGLE_CSE_DAILY_QUOTA - repo.google_queries_today() + ) + + def _ask(self, provider, kind: str, query: str, max_results: int) -> Optional[List[SearchHit]]: + if not provider.enabled: + return None + cached = repo.search_cache_get(provider.name, kind, query, SEARCH_CACHE_TTL_HOURS) if self.use_cache else None + if cached is not None: + self.stats["cache_hits"] += 1 + return [SearchHit.from_dict(d) for d in cached] + if self.used >= self.budget: + logger.info("Search budget (%d) spent; skipping %r", self.budget, query) + return None + self.used += 1 + self.stats["queries"] += 1 + self.stats[f"queries_{provider.name}"] = self.stats.get(f"queries_{provider.name}", 0) + 1 + hits = provider.text(query, max_results) if kind == "text" else provider.images(query, max_results) + if hits is None: + self.stats["unavailable"] += 1 + return None + repo.search_cache_put(provider.name, kind, query, [h.to_dict() for h in hits]) + return hits + + def _run(self, kind: str, query: str, max_results: int, providers: str) -> Optional[List[SearchHit]]: + """providers: "default" = DuckDuckGo, with Google only when DuckDuckGo + gives no answer (keeps the 100/day Google quota for price lookups); + "google" = Google only.""" + if providers == "google": + return self._ask(self.google, kind, query, max_results) + hits = self._ask(self.ddg, kind, query, max_results) + if hits is None: + hits = self._ask(self.google, kind, query, max_results) + return hits + + def text(self, query: str, max_results: int = 20, *, providers: str = "default") -> Optional[List[SearchHit]]: + return self._run("text", query, max_results, providers) + + def images(self, query: str, max_results: int = 15) -> Optional[List[SearchHit]]: + return self._run("images", query, max_results, "default") diff --git a/backend/app/electronics/search/providers.py b/backend/app/electronics/search/providers.py new file mode 100644 index 0000000..8f863f0 --- /dev/null +++ b/backend/app/electronics/search/providers.py @@ -0,0 +1,234 @@ +"""Web search: DuckDuckGo (ddgs, no key) and, when configured, Google +Programmable Search. + +Three outcomes, never collapsed: a list of hits, an empty list ("we asked and +nothing matched"), or None ("we could not ask" - throttled, offline, no +quota). A throttle is not evidence that a product is not sold anywhere. +""" +from __future__ import annotations + +import logging +import threading +import time +from dataclasses import asdict, dataclass +from typing import Callable, List, Optional + +import requests + +from app.infrastructure.settings import ( + GOOGLE_API_KEY, + GOOGLE_CSE_ID, + SEARCH_MIN_INTERVAL_SECONDS, + SEARCH_REGION, + USE_DDG_SEARCH, + USE_GOOGLE_CSE, +) + +logger = logging.getLogger(__name__) + + +@dataclass +class SearchHit: + url: str + title: str + snippet: str + provider: str + rank: int + image_url: Optional[str] = None # image searches: the image itself (url = page it is on) + # Structured offer data the search engine itself extracted from the page + # (Google CSE "pagemap"): {"price", "currency", "availability", "raw"}. + offer: Optional[dict] = None + # Aggregate rating the search engine extracted from the page's own + # structured data (Google CSE "pagemap"): {"rating", "review_count", "raw"}. + rating: Optional[dict] = None + + def to_dict(self) -> dict: + return asdict(self) + + @classmethod + def from_dict(cls, d: dict) -> "SearchHit": + return cls(**{k: d.get(k) for k in ("url", "title", "snippet", "provider", "rank", "image_url", "offer", "rating")}) + + +class _Pacer: + def __init__(self, interval: float, sleep: Callable[[float], None] = time.sleep) -> None: + self.interval = interval + self._sleep = sleep + self._last = 0.0 + self._lock = threading.Lock() + + def wait(self) -> None: + with self._lock: + gap = self.interval - (time.monotonic() - self._last) + if gap > 0: + self._sleep(gap) + self._last = time.monotonic() + + +class DuckDuckGoProvider: + name = "ddg" + + def __init__(self, interval: float = SEARCH_MIN_INTERVAL_SECONDS) -> None: + self._pacer = _Pacer(interval) + self.enabled = USE_DDG_SEARCH + + # "auto" rotates ddgs's engines; yahoo is a second opinion when it is throttled. + BACKENDS = ("auto", "yahoo") + + def text(self, query: str, max_results: int = 20) -> Optional[List[SearchHit]]: + """Hits, or None when no backend answered. ddgs reports a throttle and + a genuinely empty result the same way ("No results found"), so an + empty answer is treated as unknown rather than as "not listed".""" + if not self.enabled: + return None + try: + from ddgs import DDGS + except ImportError: + logger.warning("ddgs is not installed; DuckDuckGo search unavailable") + return None + for backend in self.BACKENDS: + self._pacer.wait() + try: + with DDGS(timeout=20) as ddgs: + rows = list(ddgs.text(query, region=SEARCH_REGION, safesearch="moderate", + max_results=max_results, backend=backend) or []) + except Exception as exc: # noqa: BLE001 - ddgs raises many types on throttling + logger.info("DuckDuckGo(%s) text search gave no answer (%s): %s", backend, query, exc) + continue + hits = [ + SearchHit(r.get("href") or r.get("url") or "", r.get("title") or "", r.get("body") or "", + f"{self.name}", i) + for i, r in enumerate(rows) + if (r.get("href") or r.get("url") or "").startswith("http") + ] + if hits: + return hits + return None + + def images(self, query: str, max_results: int = 15) -> Optional[List[SearchHit]]: + if not self.enabled: + return None + try: + from ddgs import DDGS + except ImportError: + return None + self._pacer.wait() + try: + with DDGS(timeout=20) as ddgs: + rows = list(ddgs.images(query, region=SEARCH_REGION, safesearch="moderate", + max_results=max_results) or []) + except Exception as exc: # noqa: BLE001 + if "no results" in str(exc).lower(): + return [] + logger.info("DuckDuckGo image search failed (%s): %s", query, exc) + return None + return [ + SearchHit(r.get("url") or "", r.get("title") or "", "", self.name, i, image_url=r.get("image")) + for i, r in enumerate(rows) + if str(r.get("image") or "").startswith("http") and str(r.get("url") or "").startswith("http") + ] + + +class GoogleCseProvider: + name = "google" + ENDPOINT = "https://www.googleapis.com/customsearch/v1" + + def __init__(self, quota_left: Callable[[], int] = lambda: 100) -> None: + self.enabled = USE_GOOGLE_CSE + self.error: Optional[str] = None + self._quota_left = quota_left + self._pacer = _Pacer(1.0) + + def _call(self, query: str, extra: dict) -> Optional[List[dict]]: + if not self.enabled: + return None + if self._quota_left() <= 0: + self.error = "daily query quota used up" + return None + self._pacer.wait() + try: + resp = requests.get( + self.ENDPOINT, + params={"key": GOOGLE_API_KEY, "cx": GOOGLE_CSE_ID, "q": query, "gl": "in", "num": 10, **extra}, + timeout=20, + ) + except requests.RequestException as exc: + logger.info("Google CSE failed: %s", exc) + return None + if resp.status_code in (400, 401, 403): + # A key/project problem will not fix itself mid-run: stop asking. + try: + message = resp.json().get("error", {}).get("message", "") + except ValueError: + message = resp.text[:200] + self.enabled = False + self.error = f"HTTP {resp.status_code}: {message}" + logger.warning("Google Programmable Search disabled for this run - %s", self.error) + return None + if resp.status_code != 200: + logger.info("Google CSE HTTP %s: %s", resp.status_code, resp.text[:200]) + return None + return resp.json().get("items", []) or [] + + def text(self, query: str, max_results: int = 10) -> Optional[List[SearchHit]]: + items = self._call(query, {}) + if items is None: + return None + return [SearchHit(i.get("link", ""), i.get("title", ""), i.get("snippet", ""), self.name, n, + offer=pagemap_offer(i.get("pagemap") or {}), + rating=pagemap_rating(i.get("pagemap") or {})) + for n, i in enumerate(items[:max_results]) if i.get("link")] + + def images(self, query: str, max_results: int = 10) -> Optional[List[SearchHit]]: + items = self._call(query, {"searchType": "image"}) + if items is None: + return None + return [SearchHit((i.get("image") or {}).get("contextLink", ""), i.get("title", ""), "", self.name, n, + image_url=i.get("link")) + for n, i in enumerate(items[:max_results]) if i.get("link")] + + +def pagemap_offer(pagemap: dict) -> Optional[dict]: + """The offer Google extracted from the page's own structured data + (schema.org Offer, or product:price meta tags), if any. INR only.""" + candidates = [] + for offer in pagemap.get("offer") or []: + candidates.append((offer.get("price"), offer.get("pricecurrency"), offer.get("availability"), offer)) + for meta in pagemap.get("metatags") or []: + price = meta.get("product:price:amount") or meta.get("og:price:amount") + if price: + candidates.append((price, meta.get("product:price:currency") or meta.get("og:price:currency"), + meta.get("product:availability") or meta.get("og:availability"), + {k: v for k, v in meta.items() if "price" in k or "availability" in k})) + for price, currency, availability, raw in candidates: + if price and (currency or "").upper() == "INR": + return {"price": str(price), "currency": "INR", "availability": availability, "raw": raw} + return None + + +def pagemap_rating(pagemap: dict) -> Optional[dict]: + """The aggregate rating Google extracted from the page's own structured + data (schema.org AggregateRating), if any. Only a value on a 5-point + scale is accepted.""" + for node in pagemap.get("aggregaterating") or []: + try: + value = float(str(node.get("ratingvalue", "")).replace(",", ".")) + except ValueError: + continue + best = node.get("bestrating") + try: + if best not in (None, "") and float(best) != 5: + continue + except ValueError: + continue + if not 0 < value <= 5: + continue + count = None + for key in ("reviewcount", "ratingcount"): + digits = "".join(ch for ch in str(node.get(key) or "") if ch.isdigit()) + if digits: + count = int(digits) + break + return {"rating": round(value, 2), "review_count": count, + "raw": {k: v for k, v in node.items() if k in ("ratingvalue", "reviewcount", "ratingcount", "bestrating")}} + return None diff --git a/backend/app/infrastructure/__init__.py b/backend/app/infrastructure/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/infrastructure/security.py b/backend/app/infrastructure/security.py new file mode 100644 index 0000000..dd72e50 --- /dev/null +++ b/backend/app/infrastructure/security.py @@ -0,0 +1,427 @@ +""" +Password hashing, access-token issuance/verification, and the Principal that +represents an authenticated caller. + +Two kinds of credential reach this module: + + * Interactive users. ``POST /api/auth/login`` exchanges a username/password + for a short-lived signed JWT. No password is ever stored - only a PBKDF2 + digest, read from the environment (``AUTH_ADMIN_PASSWORD_HASH`` / + ``AUTH_USER_PASSWORD_HASH``). Generate those with + ``python scripts/make_auth_secrets.py``. + + * Machine consumers. A static key sent as ``X-API-Key``, mapped to a role by + ``API_KEYS``. These do not expire, so treat one as a long-lived secret and + give each consumer its own so it can be revoked individually. + +PBKDF2-HMAC-SHA256 is used rather than bcrypt or argon2 deliberately: it is in +the standard library, so the slim Python image needs no compiled dependency, +and at the iteration count below it meets OWASP's current guidance. The +encoded form carries its own iteration count, so raising the constant later +does not invalidate hashes already issued. +""" +from __future__ import annotations + +import base64 +import hashlib +import hmac +import logging +import secrets +import time +from dataclasses import dataclass, field +from typing import Dict, List, Optional, Tuple + +import jwt + +from app.infrastructure.settings import ( + API_KEYS, + AUTH_ADMIN_PASSWORD_HASH, + AUTH_ADMIN_USERNAME, + AUTH_ALLOW_ANY_LOGIN, + AUTH_ENABLED, + AUTH_SECRET_KEY, + AUTH_TOKEN_TTL_MINUTES, + config_source, +) + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Roles and permissions +# --------------------------------------------------------------------------- +# These mirror the permission strings the React UI already keys its navigation +# off, so the server now enforces the same vocabulary the client was only +# displaying. `admin` is a superuser: has_permission() grants it everything +# rather than requiring every new permission to be added to this list. +ROLE_PERMISSIONS: Dict[str, List[str]] = { + "admin": [ + "view_catalog", + "view_project_details", + "upload_train_test", + "allocate_discounts", + "manage_analytics", + "manage_nutrition", + ], + "user": [ + "add_product", + "upload_batch_products", + "update_db_and_json", + "fetch_images", + "upload_store_inventory", + "view_store_analytics", + "view_nutrition_insights", + "optimize_profits", + ], + # An outside API client that may send spreadsheets for catalog ingestion and + # do NOTHING else. One permission, deliberately. + # + # This role exists because API keys carry no per-key scoping: + # principal_for_api_key() derives permissions entirely from the role, so + # "upload-only" can only be expressed as a role. Reusing `user` would have + # been less code and would also have handed an outside contributor + # add_product, upload_batch_products and upload_store_inventory - real + # write access to the catalog - to solve a problem that needed one verb. + # + # WHAT A LEAKED UPLOADER KEY COSTS. Real CPU: this permission starts the + # 11-stage pipeline, which is the point of the endpoint. The bound is not + # "this role cannot work" but "all ingestion, from every source, shares one + # worker" - batch_worker runs a single batch at a time behind a queue of + # BATCH_QUEUE_MAX, past which POST /api/uploads/catalog answers 429. So a + # key can occupy the ingestion worker; it cannot multiply it, and it cannot + # touch the request path the healthcheck reads. + # + # What it still cannot do: read the catalog, read another caller's + # submissions (every read on that router is filtered by submitted_by), or + # cancel, resume or delete anything. + "uploader": [ + "upload_catalog", + ], +} + +VALID_ROLES = frozenset(ROLE_PERMISSIONS) + +JWT_ALGORITHM = "HS256" +JWT_ISSUER = "brand-catalog-rag" + +# OWASP's floor for PBKDF2-HMAC-SHA256 at time of writing. +_PBKDF2_ITERATIONS = 600_000 +_PBKDF2_PREFIX = "pbkdf2_sha256" + + +@dataclass(frozen=True) +class Principal: + """Whoever is making the current request, once their credential checks out.""" + + username: str + role: str + permissions: List[str] = field(default_factory=list) + # "user" - logged in via /api/auth/login, carrying a JWT + # "api_key" - a machine consumer from API_KEYS + # "anonymous" - AUTH_ENABLED=false; no credential was checked at all + kind: str = "user" + + def has_permission(self, permission: str) -> bool: + return self.role == "admin" or permission in self.permissions + + +class AuthError(Exception): + """A credential was absent, malformed, expired, or simply wrong.""" + + +# --------------------------------------------------------------------------- +# Password hashing +# --------------------------------------------------------------------------- +def hash_password(password: str, *, iterations: int = _PBKDF2_ITERATIONS) -> str: + """Return an encoded digest: ``pbkdf2_sha256$$$``.""" + salt = secrets.token_bytes(16) + digest = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, iterations) + return "$".join( + ( + _PBKDF2_PREFIX, + str(iterations), + base64.b64encode(salt).decode("ascii"), + base64.b64encode(digest).decode("ascii"), + ) + ) + + +def _parse_encoded_hash(encoded: str) -> Optional[Tuple[bytes, bytes, int]]: + """ + Split an encoded digest into ``(salt, digest, iterations)``, or None if it + is not one. + + One parser, three callers. `verify_password` needs the parts, while + `hash_is_wellformed` and `describe_password_hash` need only the verdict - + and a login failing because the *configured* hash is corrupt is a different + incident from a wrong password, so the two must agree on what "corrupt" + means. Two copies of this parse would eventually disagree. + + Values arrive here straight from the environment, so a hash pasted into a + deployment platform's form field as "pbkdf2_sha256$..." is unwrapped rather + than rejected: the surrounding quotes are almost never intended as part of + the secret, and the failure they cause otherwise is a silent 401. + """ + if not encoded: + return None + encoded = encoded.strip().strip("'\"") + try: + prefix, raw_iterations, raw_salt, raw_digest = encoded.split("$") + if prefix != _PBKDF2_PREFIX: + return None + # validate=True so junk is rejected rather than silently discarded: + # b64decode's default drops non-alphabet characters, which would let a + # subtly corrupted hash decode to the wrong bytes and fail as a "wrong + # password" instead of as the configuration error it is. + # binascii.Error subclasses ValueError, so it is caught below. + salt = base64.b64decode(raw_salt, validate=True) + digest = base64.b64decode(raw_digest, validate=True) + iterations = int(raw_iterations) + except (ValueError, TypeError): + return None + # A structurally valid string that decodes to nothing is still unusable, + # and PBKDF2 rejects a non-positive iteration count by raising. + if not salt or not digest or iterations < 1: + return None + return salt, digest, iterations + + +def hash_is_wellformed(encoded: str) -> bool: + """Whether a configured digest can be checked against at all. + + Distinct from "does the password match": this asks whether the credential + *store* is usable, which is a deployment fault rather than a sign-in one. + """ + return _parse_encoded_hash(encoded) is not None + + +def password_hash_fingerprint(encoded: str) -> str: + """ + A short, non-reversible identifier for a configured digest. + + Safe to log and to publish: it is a truncated SHA-256 of the *encoded + digest*, and that digest already embeds a 16-byte random salt, so this says + which credential is loaded without saying anything about the password + behind it. It exists so a running deployment can be compared against the + config it was supposed to have been built from - the failure this project + actually hit - without moving a secret in order to do the comparison. + """ + if not encoded: + return "" + return hashlib.sha256(encoded.strip().strip("'\"").encode("utf-8")).hexdigest()[:12] + + +def api_key_fingerprint(name: str, secret: str) -> str: + """ + A short, non-reversible identifier for a configured API key. + + Same purpose as password_hash_fingerprint - say *which* credential is loaded + without moving the credential - but the safety argument is different and + worth stating. That function digests an encoded hash which already embeds a + 16-byte random salt. An API key has no salt, so the name is mixed in here to + keep two consumers that were mistakenly issued the same secret from + fingerprinting identically, and settings._parse_api_keys enforces a minimum + secret length so the digest cannot be walked back with a wordlist. + """ + if not secret: + return "" + cleaned = secret.strip().strip("'\"") + material = f"{name}:{cleaned}" + return hashlib.sha256(material.encode("utf-8")).hexdigest()[:12] + + +def describe_api_keys() -> List[Dict[str, object]]: + """Every configured key as {name, role, fingerprint}, sorted by name. + + Sorted so two deployments' /api/health output can be diffed line for line; + API_KEYS is keyed by secret, whose iteration order says nothing useful. + """ + return sorted( + ( + {"name": name, "role": role, "fingerprint": api_key_fingerprint(name, secret)} + for secret, (name, role) in API_KEYS.items() + ), + key=lambda entry: entry["name"], + ) + + +def describe_password_hash(encoded: str) -> Dict[str, object]: + """A loggable/publishable summary of a configured digest. Never its bytes.""" + parsed = _parse_encoded_hash(encoded) + return { + "valid": parsed is not None, + "algorithm": _PBKDF2_PREFIX if parsed is not None else None, + "iterations": parsed[2] if parsed is not None else None, + "fingerprint": password_hash_fingerprint(encoded), + } + + +def auth_config_summary() -> Dict[str, object]: + """ + The effective authentication configuration, in a form safe to both log and + publish. Contains no password and no hash - only the fingerprint. + + This is deliberately one function with two callers (the startup log in + app/main.py and GET /api/health), because its entire purpose is letting two + *deployments* be compared, and that only works if both report the same + fields computed the same way. + + `*_source` is the field that earns this its keep. A value of "process-env" + means the container's own environment supplied it and the .env file baked + into the image was ignored - which is invisible from anywhere else, and is + precisely how a corrected credential can keep failing after a redeploy. + + The same argument is why the API keys are summarised here. backend/Dockerfile + copies .env.production in at BUILD time, so a key added to that file and then + merely restarted is not present in the running process - and from outside, + an undeployed key is indistinguishable from a wrong one, because both are + just a 401. Publishing the names and fingerprints answers "is my key on this + deployment?" without anyone having to send the secret to find out. + """ + described = describe_password_hash(AUTH_ADMIN_PASSWORD_HASH) + return { + "enabled": AUTH_ENABLED, + "allow_any_login": AUTH_ALLOW_ANY_LOGIN, + "admin_username": AUTH_ADMIN_USERNAME, + "password_hash_valid": bool(described["valid"]), + "password_hash_iterations": described["iterations"], + "password_hash_fingerprint": described["fingerprint"], + "admin_username_source": config_source("AUTH_ADMIN_USERNAME"), + "password_hash_source": config_source("AUTH_ADMIN_PASSWORD_HASH"), + "api_keys_count": len(API_KEYS), + "api_keys": describe_api_keys(), + "api_keys_source": config_source("API_KEYS"), + } + + +def verify_password(password: str, encoded: str) -> bool: + """ + Check a password against an encoded digest. + + Returns False rather than raising on a malformed digest: a typo in + AUTH_ADMIN_PASSWORD_HASH must fail the login, not 500 the endpoint and + hand the caller a stack trace describing the credential store. + """ + if not encoded: + return False + parsed = _parse_encoded_hash(encoded) + if parsed is None: + logger.error( + "A configured password hash is malformed and cannot be used. Regenerate " + "it with: python scripts/make_auth_secrets.py" + ) + return False + + salt, digest, iterations = parsed + candidate = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, iterations) + return hmac.compare_digest(candidate, digest) + + +# --------------------------------------------------------------------------- +# Access tokens +# --------------------------------------------------------------------------- +def create_access_token( + username: str, + role: str, + permissions: List[str], + *, + ttl_minutes: Optional[int] = None, +) -> tuple[str, int]: + """Issue a signed JWT. Returns ``(token, expires_in_seconds)``.""" + ttl = (ttl_minutes if ttl_minutes is not None else AUTH_TOKEN_TTL_MINUTES) * 60 + now = int(time.time()) + payload = { + "sub": username, + "role": role, + "perms": permissions, + "iss": JWT_ISSUER, + "iat": now, + "exp": now + ttl, + } + return jwt.encode(payload, AUTH_SECRET_KEY, algorithm=JWT_ALGORITHM), ttl + + +def decode_access_token(token: str) -> Principal: + """ + Verify a JWT and return the Principal it names. + + The algorithm is pinned to a single-item allow-list rather than read from + the token header. That is what closes the two classic JWT bypasses: a token + presenting ``alg: none``, and one presenting ``alg: HS256`` against a key + the server intended to use asymmetrically. + """ + try: + payload = jwt.decode( + token, + AUTH_SECRET_KEY, + algorithms=[JWT_ALGORITHM], + issuer=JWT_ISSUER, + options={"require": ["exp", "iat", "sub"]}, + ) + except jwt.ExpiredSignatureError as exc: + raise AuthError("Token has expired. Sign in again.") from exc + except jwt.InvalidTokenError as exc: + raise AuthError("Invalid authentication token.") from exc + + role = payload.get("role") + if role not in VALID_ROLES: + raise AuthError("Token names an unknown role.") + + perms = payload.get("perms") + return Principal( + username=str(payload["sub"]), + role=role, + # Fall back to the role's current grants if the token predates a + # permission change, rather than trusting an arbitrary claim shape. + permissions=list(perms) if isinstance(perms, list) else ROLE_PERMISSIONS.get(role, []), + kind="user", + ) + + +# --------------------------------------------------------------------------- +# API keys (machine consumers) +# --------------------------------------------------------------------------- +def principal_for_api_key(presented: str) -> Principal: + """ + Resolve an ``X-API-Key`` value to a Principal. + + Every configured key is compared even after a match, using compare_digest, + so the time taken does not reveal how far down the list a near-miss got. + """ + matched: Optional[tuple[str, str]] = None + for secret, (name, role) in API_KEYS.items(): + if hmac.compare_digest(presented, secret): + matched = (name, role) + if matched is None: + raise AuthError("Invalid API key.") + + name, role = matched + return Principal( + username=name, + role=role, + permissions=ROLE_PERMISSIONS.get(role, []), + kind="api_key", + ) + + +def anonymous_principal() -> Principal: + """ + The stand-in used when ``AUTH_ENABLED=false``. + + It is deliberately an admin: disabling auth is meant to make local + development frictionless, and a half-privileged anonymous caller would + produce confusing 403s instead. Nothing calls this when auth is on. + """ + return Principal( + username="anonymous", + role="admin", + permissions=ROLE_PERMISSIONS["admin"], + kind="anonymous", + ) + + +if not AUTH_ENABLED: + logger.warning( + "AUTH_ENABLED=false: every endpoint is unauthenticated, including catalog " + "generation, ML training, and the upload endpoints. This is for local " + "development only - never run it on a host reachable from the internet." + ) diff --git a/backend/app/infrastructure/settings.py b/backend/app/infrastructure/settings.py new file mode 100644 index 0000000..70f3d15 --- /dev/null +++ b/backend/app/infrastructure/settings.py @@ -0,0 +1,373 @@ +""" +Centralized configuration for the Electronics Catalog backend. + +Every credential is read ONLY from the environment (backend/.env via +python-dotenv, or real OS variables). Non-secret values keep safe local +defaults. + +LOCAL-ONLY GUARD +---------------- +This project is a copy of the grocery catalogue, whose .env files pointed at a +remote production database. To make it impossible to write electronics data +there by accident, settings refuse to load unless DB_HOST is a local host and +DB_NAME is the dedicated electronics database. See _guard_local_database(). +The one exception is an explicit production opt-in (ELEC_ALLOW_REMOTE_DB plus +an exact host/database allowlist), used only by the production deployment. +""" +from __future__ import annotations + +import os +from pathlib import Path + +# Snapshotted BEFORE load_dotenv, and that ordering is the entire point. +# load_dotenv() is called without override=True, so a variable already in the +# process environment silently beats the .env file and keeps beating it no +# matter how many times the file is corrected. That is not hypothetical here: +# the deployment platform injects its Environment tab into the container, so a +# stale value left in that tab overrides the credentials baked into the image +# (backend/Dockerfile copies .env.production to /app/.env) and the only symptom +# is a 401 that nothing explains. Comparing a name against this set answers +# "which of the two won?" - see config_source() below. +_PREEXISTING_ENV = frozenset(os.environ) + +try: + from dotenv import load_dotenv + + # backend/.env (one level up from this file: app/infrastructure/settings.py) + _env_path = Path(__file__).resolve().parents[2] / ".env" + load_dotenv(_env_path) +except ImportError: + # python-dotenv not installed - fall back to whatever is already in the + # process environment (e.g. set by the shell, Docker, systemd, CI, etc.) + pass + + +# Names whose raw value arrived wrapped in quotes or padded with whitespace. +# Recorded rather than merely fixed: stripping keeps the login working, but the +# only place the original shape is still visible is right here, before the value +# is normalised. A quoted hash is the signature of a value pasted into a web +# form, so surfacing it at startup is what stops the next person rediscovering +# it from a 401. See DB_PASSWORD in .env.production for the counter-case where +# the quotes ARE part of the secret - which is why this warns, and does not fail. +_ENV_NEEDED_CLEANUP = set() + + +def _clean(name: str, raw: str) -> str: + """Strip surrounding quotes/whitespace off an env value, remembering if it mattered.""" + cleaned = raw.strip().strip("'\"") + if cleaned != raw: + _ENV_NEEDED_CLEANUP.add(name) + return cleaned + + +def cleaned_env_names() -> list: + """Which settings needed quote/whitespace stripping. Reported at startup.""" + return sorted(_ENV_NEEDED_CLEANUP) + + +def config_source(name: str) -> str: + """ + Where a setting's value actually came from: the process environment, the + .env file, or this module's own default. + + Reported at startup for the AUTH_* values (see app/main.py) so that an + override arriving from outside the image is visible in the logs instead of + being inferred from a failing login. + """ + if name in _PREEXISTING_ENV: + return "process-env" + if name in os.environ: + return "env-file" + return "default" + + +def _bool(name: str, default: str) -> bool: + return os.getenv(name, default).strip().lower() in {"1", "true", "yes"} + + +def _require(name: str, *, feature_flag: str) -> str: + """Read a required secret. Raises if missing and the owning feature is enabled.""" + value = os.getenv(name) + if not value: + raise RuntimeError( + f"Missing required environment variable '{name}'. It is required because " + f"'{feature_flag}' is enabled. Set it in backend/.env (copy from " + f".env.example) or disable the feature by setting {feature_flag}=false." + ) + return value + + +# --------------------------------------------------------------------------- +# Paths +# --------------------------------------------------------------------------- +_BACKEND_ROOT = Path(__file__).resolve().parents[2] +DATA_DIR = Path(os.getenv("DATA_DIR", "").strip() or _BACKEND_ROOT / "data") + +# --------------------------------------------------------------------------- +# Ollama (local LLM) - only ever used to read text we fetched, never to invent +# --------------------------------------------------------------------------- +USE_OLLAMA = _bool("USE_OLLAMA", "true") +OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://localhost:11434") +OLLAMA_MODEL_NAME = os.getenv("OLLAMA_MODEL_NAME", "qwen2.5:1.5b") +OLLAMA_TIMEOUT_SECONDS = int(os.getenv("OLLAMA_TIMEOUT_SECONDS", "120")) + +# --------------------------------------------------------------------------- +# Embeddings (sentence-transformers, CPU-friendly) +# --------------------------------------------------------------------------- +USE_EMBEDDINGS = _bool("USE_EMBEDDINGS", "true") +EMBEDDINGS_MODEL = os.getenv("EMBEDDINGS_MODEL", "sentence-transformers/all-MiniLM-L6-v2") +EMBEDDINGS_DIM = int(os.getenv("EMBEDDINGS_DIM", "384")) + +# --------------------------------------------------------------------------- +# Postgres / pgvector - the LOCAL electronics database only +# --------------------------------------------------------------------------- +ELECTRONICS_DB_NAME = "electronics_catalog" +# The test suite uses its own database on the same local server. +ALLOWED_DB_NAMES = frozenset({ELECTRONICS_DB_NAME, ELECTRONICS_DB_NAME + "_test"}) +LOCAL_DB_HOSTS = frozenset({"localhost", "127.0.0.1", "::1", "host.docker.internal", "postgres"}) + +DB_HOST = os.getenv("DB_HOST", "127.0.0.1").strip() +DB_PORT = os.getenv("DB_PORT", "5433").strip() +DB_NAME = os.getenv("DB_NAME", ELECTRONICS_DB_NAME).strip() +DB_USER = os.getenv("DB_USER", "postgres").strip() +DB_PASSWORD = _require("DB_PASSWORD", feature_flag="the electronics database") +DB_CONNECT_TIMEOUT_SECONDS = int(os.getenv("DB_CONNECT_TIMEOUT_SECONDS", "5")) + + +def _csv_set(name: str) -> frozenset: + return frozenset(v.strip() for v in os.getenv(name, "").split(",") if v.strip()) + + +# Production opt-in. Off by default: without ELEC_ALLOW_REMOTE_DB=true the +# guard below behaves exactly as it always has. With it on, only the host(s) +# and database name(s) listed here are accepted - never "any remote host". +ELEC_ALLOW_REMOTE_DB = _bool("ELEC_ALLOW_REMOTE_DB", "false") +ELEC_REMOTE_DB_HOSTS = _csv_set("ELEC_REMOTE_DB_HOSTS") +ELEC_REMOTE_DB_NAMES = _csv_set("ELEC_REMOTE_DB_NAMES") + + +def _guard_local_database(host: str, name: str, *, allow_remote: bool = False, + remote_hosts: frozenset = frozenset(), remote_names: frozenset = frozenset()) -> None: + """Refuse to run against anything but the local electronics database, + unless the production opt-in names this exact host and database.""" + if allow_remote and host in remote_hosts: + if name not in remote_names: + raise RuntimeError( + f"DB_NAME={name!r} is not in ELEC_REMOTE_DB_NAMES for remote host {host!r}." + ) + return + if host not in LOCAL_DB_HOSTS: + raise RuntimeError( + f"DB_HOST={host!r} is not a local host. This project only runs against the " + f"local Docker database (see docker-compose.yml); it must never touch the " + f"remote catalogue database." + ) + if name not in ALLOWED_DB_NAMES: + raise RuntimeError( + f"DB_NAME={name!r}; expected {ELECTRONICS_DB_NAME!r}. The electronics data " + f"lives in its own database so existing databases are never modified." + ) + + +_guard_local_database(DB_HOST, DB_NAME, allow_remote=ELEC_ALLOW_REMOTE_DB, + remote_hosts=ELEC_REMOTE_DB_HOSTS, remote_names=ELEC_REMOTE_DB_NAMES) + +# --------------------------------------------------------------------------- +# Web search (discovery, prices, images) +# --------------------------------------------------------------------------- +# DuckDuckGo needs no key. Google Programmable Search is used in addition when +# both GOOGLE_API_KEY and GOOGLE_CSE_ID are set (100 free queries/day). +USE_DDG_SEARCH = _bool("USE_DDG_SEARCH", "true") +GOOGLE_API_KEY = os.getenv("GOOGLE_API_KEY", "").strip() +GOOGLE_CSE_ID = os.getenv("GOOGLE_CSE_ID", "").strip() +USE_GOOGLE_CSE = bool(GOOGLE_API_KEY and GOOGLE_CSE_ID) and _bool("USE_GOOGLE_CSE", "true") +GOOGLE_CSE_DAILY_QUOTA = int(os.getenv("GOOGLE_CSE_DAILY_QUOTA", "100")) +SEARCH_REGION = os.getenv("SEARCH_REGION", "in-en") +# Minimum pause between two search queries to the same provider. +SEARCH_MIN_INTERVAL_SECONDS = float(os.getenv("SEARCH_MIN_INTERVAL_SECONDS", "2.5")) +SEARCH_CACHE_TTL_HOURS = int(os.getenv("SEARCH_CACHE_TTL_HOURS", "24")) + +# --------------------------------------------------------------------------- +# Polite fetching of retailer / brand pages +# --------------------------------------------------------------------------- +# An honest User-Agent with a contact address. Set ELEC_CONTACT to a real +# address before running a crawl. +ELEC_CONTACT = os.getenv("ELEC_CONTACT", "admin@example.com").strip() +USER_AGENT = os.getenv( + "USER_AGENT", f"ElectronicsCatalogBot/0.1 (+mailto:{ELEC_CONTACT}; local research)" +) +REQUEST_TIMEOUT_SECONDS = int(os.getenv("REQUEST_TIMEOUT_SECONDS", "20")) +ELEC_SITE_MIN_INTERVAL_SECONDS = float(os.getenv("ELEC_SITE_MIN_INTERVAL_SECONDS", "3")) +ELEC_BREAKER_COOLDOWN_HOURS = float(os.getenv("ELEC_BREAKER_COOLDOWN_HOURS", "24")) +ELEC_MAX_PAGE_BYTES = int(os.getenv("ELEC_MAX_PAGE_BYTES", str(3 * 1024 * 1024))) +ELEC_PROBE_TTL_DAYS = int(os.getenv("ELEC_PROBE_TTL_DAYS", "7")) +MIN_IMAGE_BYTES = int(os.getenv("MIN_IMAGE_BYTES", "3000")) + +# Reference pincodes (Tamil Nadu). "pincode:City" pairs, comma-separated. The +# first one is the default. A pincode is stored against a price only when the +# site actually accepted it. +ELEC_REFERENCE_PINCODES = [ + tuple(p.split(":", 1)) if ":" in p else (p, "") + for p in (x.strip() for x in os.getenv("ELEC_REFERENCE_PINCODES", "641001:Coimbatore,600001:Chennai").split(",")) + if p +] + +# The LLM may only fill spec gaps from text we fetched; set false to run fully +# deterministic. +ELEC_USE_LLM = _bool("ELEC_USE_LLM", "true") + +# --------------------------------------------------------------------------- +# FastAPI / web server +# --------------------------------------------------------------------------- +API_CORS_ORIGINS = [ + origin.strip() + for origin in os.getenv("API_CORS_ORIGINS", "http://localhost:5173,http://127.0.0.1:5173").split(",") + if origin.strip() +] + +# --------------------------------------------------------------------------- +# Authentication +# --------------------------------------------------------------------------- +# CORS above is not access control - browsers enforce it, and curl ignores it +# entirely. These settings are what actually guards the write/compute endpoints +# (catalog generation, ML training, uploads, chat). +# +# AUTH_ENABLED=false turns every guard off, restoring the old behaviour where +# any caller could reach any endpoint. It exists so a fresh checkout still runs +# without generating secrets first; app/infrastructure/security.py logs a +# warning at import when it is off. Never deploy with it off. +AUTH_ENABLED = _bool("AUTH_ENABLED", "true") + +# Signs and verifies access tokens. Changing it invalidates every issued token, +# which is the intended way to force everyone to sign in again. Generate with: +# python scripts/make_auth_secrets.py +AUTH_SECRET_KEY = ( + _require("AUTH_SECRET_KEY", feature_flag="AUTH_ENABLED") + if AUTH_ENABLED + else os.getenv("AUTH_SECRET_KEY", "") +) + +# How long an issued token stays valid. 12h by default: long enough that a +# working day needs one sign-in, short enough that a leaked token expires. +AUTH_TOKEN_TTL_MINUTES = int(os.getenv("AUTH_TOKEN_TTL_MINUTES", "720")) + +# The interactive accounts. Only PBKDF2 digests are stored - never a password. +# `make_auth_secrets.py` prints the lines ready to paste. +# +# `admin` is required whenever auth is on: without it nobody could sign in. +AUTH_ADMIN_USERNAME = _clean( + "AUTH_ADMIN_USERNAME", os.getenv("AUTH_ADMIN_USERNAME", "admin") +) +AUTH_ADMIN_PASSWORD_HASH = _clean( + "AUTH_ADMIN_PASSWORD_HASH", + ( + _require("AUTH_ADMIN_PASSWORD_HASH", feature_flag="AUTH_ENABLED") + if AUTH_ENABLED + else os.getenv("AUTH_ADMIN_PASSWORD_HASH", "") + ), +) + +# The second `user` account is OPTIONAL, and left unset in this deployment. +# An empty hash is how the account is switched off: auth.py builds its account +# table from these values and omits any entry whose hash is blank, so there is +# nothing to sign in to. Setting the hash again re-enables it with no code +# change - which is exactly what the test suite does in tests/conftest.py. +AUTH_USER_USERNAME = _clean( + "AUTH_USER_USERNAME", os.getenv("AUTH_USER_USERNAME", "user") +) +AUTH_USER_PASSWORD_HASH = _clean( + "AUTH_USER_PASSWORD_HASH", os.getenv("AUTH_USER_PASSWORD_HASH", "") +) + +# Failed-login throttle, applied per username+client-IP. Prevents an exposed +# login endpoint from being a free password oracle. +AUTH_MAX_LOGIN_ATTEMPTS = int(os.getenv("AUTH_MAX_LOGIN_ATTEMPTS", "10")) +AUTH_LOCKOUT_SECONDS = int(os.getenv("AUTH_LOCKOUT_SECONDS", "300")) + +# Local-development escape hatch: accept ANY password at /api/auth/login, so a +# developer who does not have the configured passwords to hand can still reach +# the admin and user pages. The username still selects the role, and the token +# issued is a normal signed one - so every downstream guard, /api/auth/me, and +# the React route gating all behave exactly as they do in production. What is +# skipped is only the password check. +# +# This is NOT the same as AUTH_ENABLED=false. That disables every guard *and* +# makes /api/auth/login return 503, which breaks the login page outright. This +# flag keeps the whole auth machinery running and unlocks just the front door. +# +# Anyone who can reach the API can sign in as admin while it is on. Keep it +# false anywhere the port is reachable by someone you would not hand the admin +# password to. +AUTH_ALLOW_ANY_LOGIN = _bool("AUTH_ALLOW_ANY_LOGIN", "false") + + +# Shortest acceptable API key secret. token_urlsafe(32) yields 43 characters, so +# this rejects hand-typed values without rejecting anything the documented +# generator produces. +API_KEY_MIN_LENGTH = 32 + + +def _parse_api_keys(raw: str) -> dict: + """ + Parse ``API_KEYS`` - ``name:role:secret`` triples, comma-separated. + + Keyed by secret because that is what an inbound request presents. One entry + per consumer is the point: a shared key cannot be revoked for one caller + without breaking all of them. + + Secrets must be at least API_KEY_MIN_LENGTH characters. That is not about + guessing the key over the network - the lockout and the network itself make + online brute force impractical - but about what /api/health publishes. It + reports a truncated digest of every configured key so a deployment can be + checked against the config it was built from, and a digest of a *raw* secret + is only safe when the secret is unguessable offline. An admin password hash + embeds a random salt, so its fingerprint discloses nothing; an API key has no + salt, and a hand-picked "changeme" would fall to a wordlist in seconds. + Generate one with: python -c "import secrets; print(secrets.token_urlsafe(32))" + """ + parsed: dict = {} + for entry in raw.split(","): + entry = entry.strip() + if not entry: + continue + parts = entry.split(":") + if len(parts) != 3: + raise RuntimeError( + f"Malformed API_KEYS entry {entry!r}. Expected 'name:role:secret', " + f"comma-separated between entries." + ) + name, role, secret = (p.strip() for p in parts) + # MUST stay in step with ROLE_PERMISSIONS in app/infrastructure/security.py, + # which is the source of truth. It is duplicated rather than imported + # because security.py imports THIS module, so importing it back here + # would be a cycle. A role added there but not here is rejected at boot + # with the message below - loud, and before any request is served. + if role not in {"admin", "user", "uploader"}: + raise RuntimeError( + f"API_KEYS entry {name!r} has role {role!r}; expected 'admin', 'user' " + f"or 'uploader'." + ) + if not secret: + raise RuntimeError(f"API_KEYS entry {name!r} has an empty secret.") + if len(secret) < API_KEY_MIN_LENGTH: + raise RuntimeError( + f"API_KEYS entry {name!r} has a {len(secret)}-character secret; at least " + f"{API_KEY_MIN_LENGTH} are required, because /api/health publishes a digest " + f"of it. Generate one with: " + f"python -c \"import secrets; print(secrets.token_urlsafe(32))\"" + ) + parsed[secret] = (name, role) + return parsed + + +# Machine consumers of api.. Empty by default - browser sessions go +# through /api/auth/login instead, and a key that nobody needs is only risk. +# +# NAME THE KEY FOR ITS FUNCTION, NOT THE PERSON HOLDING IT. +# /api/health is public and reports {name, role, fingerprint} for every +# configured key (describe_api_keys in security.py). The secret is never +# exposed, but the NAME is - so `catalog-drop:uploader:...` is right and +# `priya-laptop:uploader:...` publishes a colleague's name to anyone who +# curls the health endpoint. +API_KEYS = _parse_api_keys(os.getenv("API_KEYS", "")) + diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..d862af7 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,133 @@ +""" +FastAPI application entry point for the Electronics Catalog (local only). + +Run with (from backend/): + .venv\\Scripts\\python -m uvicorn app.main:app --host 127.0.0.1 --port 8000 +""" +from __future__ import annotations + +import logging +import os +import threading +from contextlib import asynccontextmanager +from pathlib import Path + +from fastapi import FastAPI, HTTPException, Request +from fastapi.encoders import jsonable_encoder +from fastapi.exceptions import RequestValidationError +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import FileResponse, JSONResponse +from fastapi.staticfiles import StaticFiles + +from app.api.routers import auth, elec, elec_admin, health +from app.infrastructure.security import auth_config_summary +from app.infrastructure.settings import API_CORS_ORIGINS, DB_HOST, DB_NAME, DB_PORT, cleaned_env_names +from app.mcp_server import mcp +from fastmcp.utilities.lifespan import combine_lifespans + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", +) +logger = logging.getLogger(__name__) + + +def _init_schema() -> None: + """Apply pending migrations and make sure reference data exists. Runs on a + thread so the API answers /api/health even while Postgres is starting.""" + try: + from app.electronics.db import repository as repo + from app.electronics.db.migrate import run_migrations + from app.electronics.reference import load_reference + + applied = run_migrations() + if applied: + logger.info("Applied migrations: %s", ", ".join(applied)) + repo.seed_reference(load_reference()) + except Exception as exc: # noqa: BLE001 - the health endpoint reports the DB state + logger.warning("Schema initialisation skipped: %s", exc) + + +@asynccontextmanager +async def lifespan(_app: FastAPI): + logger.info("Electronics Catalog using database %s at %s:%s", DB_NAME, DB_HOST, DB_PORT) + threading.Thread(target=_init_schema, daemon=True).start() + yield + + +# MCP endpoint (FastMCP) for AI assistants, served at /mcp/ by this same app. +# Its session manager must start with the app, hence the combined lifespan. +mcp_app = mcp.http_app(path="/") + +app = FastAPI( + title="Electronics Catalog API", + description=( + "Local, evidence-backed catalogue of electronics sold in India (Tamil Nadu focus). " + "Products, prices, availability and images are collected from real retail listings " + "found through web search; nothing is generated." + ), + version="1.0.0", + lifespan=combine_lifespans(lifespan, mcp_app.lifespan), +) + +_allow_credentials = "*" not in API_CORS_ORIGINS +app.add_middleware( + CORSMiddleware, + allow_origins=API_CORS_ORIGINS, + allow_credentials=_allow_credentials, + allow_methods=["*"], + allow_headers=["*"], +) + + +def _json_safe(value): + if isinstance(value, float) and (value != value or value in (float("inf"), float("-inf"))): + return str(value) + if isinstance(value, dict): + return {k: _json_safe(v) for k, v in value.items()} + if isinstance(value, (list, tuple)): + return [_json_safe(v) for v in value] + return value + + +@app.exception_handler(RequestValidationError) +async def _validation_error_as_422(request: Request, exc: RequestValidationError) -> JSONResponse: + return JSONResponse(status_code=422, content={"detail": _json_safe(jsonable_encoder(exc.errors()))}) + + +_auth_cfg = auth_config_summary() +logger.info( + "Auth config: enabled=%s allow_any_login=%s admin_username=%r hash_valid=%s", + _auth_cfg["enabled"], _auth_cfg["allow_any_login"], _auth_cfg["admin_username"], + _auth_cfg["password_hash_valid"], +) +if _auth_cfg["allow_any_login"]: + logger.warning("AUTH_ALLOW_ANY_LOGIN=true: any password is accepted. Keep the API on 127.0.0.1.") +if cleaned_env_names(): + logger.warning("Settings arrived quoted or padded and were cleaned: %s", ", ".join(cleaned_env_names())) + +app.include_router(health.router, prefix="/api") +app.include_router(auth.router, prefix="/api") +app.include_router(elec.router, prefix="/api") +app.include_router(elec_admin.router, prefix="/api") +app.mount("/mcp", mcp_app) + +_dist_override = os.getenv("FRONTEND_DIST_DIR", "").strip() +FRONTEND_DIST = Path(_dist_override) if _dist_override else Path(__file__).resolve().parents[2] / "frontend" / "dist" + +if (FRONTEND_DIST / "assets").exists(): + logger.info("Serving built frontend from %s", FRONTEND_DIST) + app.mount("/assets", StaticFiles(directory=str(FRONTEND_DIST / "assets")), name="assets") + + @app.get("/{full_path:path}") + def serve_frontend(full_path: str): + if full_path.startswith(("api", "mcp", "docs", "redoc", "openapi.json")): + raise HTTPException(status_code=404, detail="Not found") + file_path = FRONTEND_DIST / full_path + if file_path.exists() and file_path.is_file(): + return FileResponse(file_path) + return FileResponse(FRONTEND_DIST / "index.html") +else: + @app.get("/") + def root() -> dict: + return {"service": "Electronics Catalog API", "docs": "/docs", "health": "/api/health", "mcp": "/mcp/"} diff --git a/backend/app/mcp_server.py b/backend/app/mcp_server.py new file mode 100644 index 0000000..a9a99d7 --- /dev/null +++ b/backend/app/mcp_server.py @@ -0,0 +1,121 @@ +"""MCP server (FastMCP) over the read-only catalogue. + +Mounted by app/main.py at /mcp/, in the same process as the REST API. Each tool +calls the same function the matching /api/elec endpoint uses, so the two can +never disagree. Only read-only catalogue data is exposed: no admin, no login, +no collection runs. + +Images are returned as URLs (the retailer's own image address, as stored in +elec.product_image); nothing is downloaded or re-hosted. +""" +from __future__ import annotations + +from decimal import Decimal +from typing import Any, Dict, List, Optional + +import anyio +from fastapi import HTTPException +from fastmcp import FastMCP +from fastmcp.exceptions import ToolError + +from app.api.routers import elec + +mcp = FastMCP( + name="Electronics Catalog", + instructions=( + "Verified catalogue of mobiles and laptops sold in India (Tamil Nadu focus). " + "Every product is confirmed by real listings on at least two retail platforms; " + "prices, ratings and reviews come with the page they were read from. Prices are " + "rupee strings. Use search_products to find products, then get_product for " + "per-platform offers, specs, images, rating and reviews." + ), +) + +_SEARCH_FIELDS = ( + "product_id", "brand", "category", "display_name", "ram_gb", "storage_gb", + "best_price", "best_price_site", "platform_count", "sold_by_tn_retailer", "image_url", +) + + +async def _run(fn, *args): + """The catalogue functions use blocking DB calls; keep them off the event loop.""" + try: + return await anyio.to_thread.run_sync(lambda: fn(*args)) + except HTTPException as exc: + raise ToolError(str(exc.detail)) from exc + + +@mcp.tool +async def list_categories() -> List[Dict[str, Any]]: + """List product categories (e.g. mobiles, laptops) with how many verified products each has.""" + return await _run(elec.categories) + + +@mcp.tool +async def search_products( + query: Optional[str] = None, + category: Optional[str] = None, + brand: Optional[str] = None, + max_price: Optional[float] = None, + min_price: Optional[float] = None, + limit: int = 20, +) -> Dict[str, Any]: + """Search verified products. + + Args: + query: Text to match in the product or brand name, e.g. "galaxy s25", "vivobook". + category: Category slug: "mobiles" or "laptops". + brand: Brand slug, e.g. "samsung", "xiaomi", "hp", "lenovo". + max_price: Highest best price in rupees. + min_price: Lowest best price in rupees. + limit: Maximum products to return (1-100). + + Returns the total match count and, per product: id, name, variant, best price (rupee + string) and the platform offering it, number of platforms, and an image URL (or null). + """ + limit = max(1, min(int(limit), 100)) + result = await _run( + elec.products, + category, brand, (query or None), + None if min_price is None else Decimal(str(min_price)), + None if max_price is None else Decimal(str(max_price)), + False, None, False, limit, 0, + ) + return { + "total": result["total"], + "products": [{k: p.get(k) for k in _SEARCH_FIELDS} for p in result["products"]], + } + + +@mcp.tool +async def get_product(product_id: int) -> Dict[str, Any]: + """Full details of one product by its product_id (from search_products). + + Returns per-platform offers (price, MRP, source URL, when seen), normalised specs, + image URLs, the overall rating with per-platform sources (null if none is published), + and up to 10 real customer reviews (often empty). + """ + d = await _run(elec.product, int(product_id)) + return { + "product_id": d["product_id"], + "brand": d.get("brand"), + "category": d.get("category"), + "display_name": d.get("display_name"), + "best_price": d.get("best_price"), + "best_price_site": d.get("best_price_site"), + "specs": d.get("canonical_specs") or {}, + "offers": [ + {k: o.get(k) for k in ("site", "price", "mrp", "source_url", "observed_at", "price_outlier")} + for o in d.get("offers", []) + ], + "image_urls": [i["url"] for i in d.get("images", [])], + "rating": d.get("rating"), + "reviews": d.get("reviews", []), + } + + +@mcp.tool +async def price_history(product_id: int) -> List[Dict[str, Any]]: + """Every price observed for a product, per platform, oldest first (rupee strings, ISO times).""" + rows = await _run(elec.price_history, int(product_id)) + return [{k: r.get(k) for k in ("site", "price", "mrp", "observed_at")} for r in rows] 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/embeddings_service.py b/backend/app/services/embeddings_service.py new file mode 100644 index 0000000..926341b --- /dev/null +++ b/backend/app/services/embeddings_service.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +from typing import List, Optional, TYPE_CHECKING + +from app.infrastructure.settings import EMBEDDINGS_MODEL + +if TYPE_CHECKING: # pragma: no cover - typing only, no runtime cost + from sentence_transformers import SentenceTransformer + +_model_singleton: Optional["SentenceTransformer"] = None + + +def get_device() -> str: + """Prefer CUDA if available, otherwise CPU. + + Imports torch lazily: on an 8GB RAM / CPU-only laptop there is no + benefit to importing torch (and paying its startup/memory cost) until + an embedding is actually requested, so the FastAPI process can boot + and answer /api/health almost instantly. + """ + import torch # local import - see docstring + + return "cuda" if torch.cuda.is_available() else "cpu" + + +def get_embedding_model() -> "SentenceTransformer": + global _model_singleton + if _model_singleton is None: + from sentence_transformers import SentenceTransformer # local import - see get_device() + + device = get_device() + _model_singleton = SentenceTransformer(EMBEDDINGS_MODEL, device=device) + return _model_singleton + + +def embed_texts(texts: List[str]) -> List[List[float]]: + """Embed a batch of texts into normalized 384-dim vectors (MiniLM-L6-v2). + + Normalized so that pgvector's cosine-distance operator (`<=>`) behaves + consistently for the RAG retrieval step in `app.services.vector_store`. + """ + if not texts: + return [] + model = get_embedding_model() + embeddings = model.encode( + texts, + batch_size=32, + normalize_embeddings=True, + convert_to_numpy=True, + show_progress_bar=False, + ) + return embeddings.tolist() diff --git a/backend/app/services/ollama_service.py b/backend/app/services/ollama_service.py new file mode 100644 index 0000000..2c317a8 --- /dev/null +++ b/backend/app/services/ollama_service.py @@ -0,0 +1,171 @@ +""" +Thin client for the local Ollama server. + +In this project the LLM is an EXTRACTOR, never a source: it is only ever +handed text that was fetched from a real page or search result, and every +value it returns is checked against that text by +app.electronics.normalise.grounding before it is kept. There are deliberately +no functions here that ask the model to list products, prices or images. +""" +from __future__ import annotations + +import json +import re +import time + +import requests + +from app.infrastructure.settings import OLLAMA_BASE_URL, OLLAMA_MODEL_NAME, USE_OLLAMA, OLLAMA_TIMEOUT_SECONDS + + +# Reachability is asked once per this many seconds, not once per caller. +# +# WHY THIS CACHE EXISTS. The probe below costs up to 5 seconds when nothing is +# listening, and `_ensure_client` is called per ROW by stage 2 of the ingestion +# pipeline (store_catalog_pipeline.stage_2_row_intake -> fetch_product_details). +# Uncached, a 2000-row sheet ingested with use_llm on, against a configured but +# unreachable Ollama, spends up to ~2.8 hours doing nothing but timing out - and +# presents as a batch that has hung rather than one that has failed. That is not +# hypothetical: USE_OLLAMA=true pointing at localhost:11434 is the default +# developer configuration, and `ollama serve` is not always running beside it. +# +# /api/health calls this too (app/api/routers/system.py), so the same cache +# stops a down Ollama adding 5s to every health request. +# +# A TTL rather than a permanent memo, deliberately: this is a liveness fact, not +# configuration. Cached forever, an Ollama started after the API would never be +# noticed and /api/health would report it down until a redeploy. +_PROBE_TTL_SECONDS = 30.0 +_probe_cache: tuple[float, bool] | None = None + + +def reset_reachability_cache() -> None: + """Forget the cached probe. For tests, and for anything that knows the + answer just changed.""" + global _probe_cache + _probe_cache = None + + +def _ensure_client(): + """None when Ollama is switched off, True/False for reachable or not. + + Three return values, not two - `system.py` relies on telling "disabled" from + "configured but down", so do not collapse this to a bool. + """ + if not USE_OLLAMA: + # No network call on this path, so nothing worth caching. + return None + + global _probe_cache + now = time.monotonic() + if _probe_cache is not None and now - _probe_cache[0] < _PROBE_TTL_SECONDS: + return _probe_cache[1] + + # Verify Ollama is reachable + try: + resp = requests.get(f"{OLLAMA_BASE_URL}/api/tags", timeout=5) + reachable = resp.status_code == 200 + except Exception: + reachable = False + + _probe_cache = (now, reachable) + return reachable + + +def _generate( + system: str, + user_prompt: str, + max_retries: int = 2, + *, + temperature: float = 0.0, + json_mode: bool = False, +) -> str: + """Call Ollama's chat endpoint and return text safely. + + Retries up to `max_retries` times when the response is empty, since + small local models (e.g. qwen2.5:1.5b) sometimes return empty content + for complex JSON prompts on the first attempt. + """ + if not _ensure_client(): + return "" + for attempt in range(max_retries + 1): + try: + resp = requests.post( + f"{OLLAMA_BASE_URL}/api/chat", + json={ + "model": OLLAMA_MODEL_NAME, + "messages": [ + {"role": "system", "content": system}, + {"role": "user", "content": user_prompt}, + ], + "stream": False, + "options": {"temperature": temperature}, + **({"format": "json"} if json_mode else {}), + }, + timeout=OLLAMA_TIMEOUT_SECONDS, + ) + resp.raise_for_status() + data = resp.json() + content = (data.get("message", {}).get("content", "") or "").strip() + if content: + return content + if attempt < max_retries: + import time + time.sleep(1.0) + except Exception: + if attempt >= max_retries: + return "" + import time + time.sleep(1.0) + return "" + + +def _extract_json(text: str) -> dict | None: + """Extract JSON from model response, trying multiple strategies. + + Handles both JSON objects {...} and JSON arrays [...] since small + local models frequently return bare arrays instead of an object with + a ``products`` key. + """ + if not text: + return None + # Try fenced code block (object or array) + match = re.search(r"```(?:json)?\s*(\{[\s\S]*?\}|\[[\s\S]*?\])\s*```", text) + if match: + try: + return json.loads(match.group(1)) + except json.JSONDecodeError: + pass + # Try first JSON value in text (greedy - object) + brace = re.search(r"\{[\s\S]*\}", text) + if brace: + try: + return json.loads(brace.group(0)) + except json.JSONDecodeError: + pass + # Try first JSON value in text (greedy - array) + bracket = re.search(r"\[[\s\S]*\]", text) + if bracket: + try: + return json.loads(bracket.group(0)) + except json.JSONDecodeError: + pass + # Try parsing entire text + try: + return json.loads(text.strip()) + except json.JSONDecodeError: + pass + # Fallback: try to fix common issues + cleaned = text.strip() + cleaned = re.sub(r"(?<=[:,\[])\s*'", '"', cleaned) + cleaned = re.sub(r"'\s*(?=[,:\}\]])", '"', cleaned) + try: + return json.loads(cleaned) + except json.JSONDecodeError: + return None + + +def generate_json(system_prompt: str, user_prompt: str) -> dict | list | None: + """Deterministic JSON-mode completion. None when Ollama is unavailable or + the reply is not JSON - callers must treat that as "no extra data".""" + return _extract_json(_generate(system_prompt, user_prompt, max_retries=1, json_mode=True)) diff --git a/backend/data_diag_laptops.py b/backend/data_diag_laptops.py new file mode 100644 index 0000000..d5998eb --- /dev/null +++ b/backend/data_diag_laptops.py @@ -0,0 +1,26 @@ +"""Diagnostic: laptop products, their sites, and listings left unlinked.""" +from app.electronics.db.connection import connect + +with connect() as c: + rows = c.execute( + """ + SELECT p.variant_key, p.verification_status AS v, string_agg(s.name, ', ') AS sites + FROM elec.product p + JOIN elec.category ca ON ca.id = p.category_id AND ca.slug = 'laptops' + LEFT JOIN elec.product_listing_map m ON m.product_id = p.id + LEFT JOIN elec.source_listing l ON l.id = m.listing_id + LEFT JOIN elec.site s ON s.id = l.site_id + GROUP BY 1, 2 ORDER BY 1 + """ + ).fetchall() + for r in rows: + print(f"{r['v'][:5]:5} {r['variant_key']:60} | {r['sites']}") + print("\nUnlinked:") + for r in c.execute( + """ + SELECT s.name, l.title FROM elec.source_listing l JOIN elec.site s ON s.id = l.site_id + JOIN elec.category ca ON ca.id = l.category_id AND ca.slug = 'laptops' + WHERE NOT EXISTS (SELECT 1 FROM elec.product_listing_map m WHERE m.listing_id = l.id) + """ + ): + print(f" {r['name'][:12]:12} {r['title'][:120]}".encode("ascii", "replace").decode()) diff --git a/backend/data_wait_for_run.py b/backend/data_wait_for_run.py new file mode 100644 index 0000000..498b638 --- /dev/null +++ b/backend/data_wait_for_run.py @@ -0,0 +1,14 @@ +"""Block until no collection run is in progress (keeps runs sequential and polite).""" +import time + +from app.electronics.db.connection import connect + +while True: + with connect() as c: + running = c.execute( + "SELECT count(*) AS n FROM elec.crawl_run WHERE status = 'running' AND started_at > now() - interval '6 hours'" + ).fetchone()["n"] + if not running: + break + time.sleep(30) +print("no run in progress") diff --git a/backend/pytest.ini b/backend/pytest.ini new file mode 100644 index 0000000..81ef9f3 --- /dev/null +++ b/backend/pytest.ini @@ -0,0 +1,16 @@ +[pytest] +testpaths = tests + +# Warnings are errors. A DeprecationWarning is a change that will break the +# build later, and the only reliable time to deal with one is when it first +# appears - once a few are tolerated, the new one is invisible in the noise. +# +# When a dependency starts warning about something you cannot fix yet, add a +# narrow ignore here rather than relaxing this line, e.g.: +# +# ignore:some message regex:DeprecationWarning:the_package.* +# +# Keep each ignore as specific as the warning it silences, so it stops applying +# once the dependency is fixed. +filterwarnings = + error diff --git a/backend/requirements-api.txt b/backend/requirements-api.txt new file mode 100644 index 0000000..d5c7106 --- /dev/null +++ b/backend/requirements-api.txt @@ -0,0 +1,35 @@ +# Production API image: everything in requirements.txt EXCEPT sentence-transformers. +# +# Embeddings are only computed inside collection runs (collector.embed -> +# services/embeddings_service.py, imported lazily), and the collector already +# skips that step when it fails. Leaving MiniLM/PyTorch out keeps the image +# ~2 GB smaller; set USE_EMBEDDINGS=false in the production environment. +# Keep the two files in step when a dependency is added. + +# --- API --- +fastapi>=0.115.0 +uvicorn[standard]>=0.30.6 +pydantic>=2.9.2 +python-dotenv>=1.0.1 +PyJWT>=2.9.0 +fastmcp>=4.0,<5 # MCP endpoint at /mcp/ (app/mcp_server.py) + +# --- Database --- +psycopg[binary]>=3.2.3 +pgvector>=0.2.5 + +# --- HTTP, search, parsing (used by admin collection runs) --- +requests>=2.31.0 +httpx>=0.27.2 +ddgs>=9.14.4 +protego>=0.3.1 +extruct>=0.17.0 +beautifulsoup4>=4.12.3 +lxml>=4.9.3 +PyYAML>=6.0 + +# --- Normalisation / matching --- +rapidfuzz>=3.9.0 + +# --- CLI --- +typer>=0.12.0 diff --git a/backend/requirements-dev.txt b/backend/requirements-dev.txt new file mode 100644 index 0000000..33f06a2 --- /dev/null +++ b/backend/requirements-dev.txt @@ -0,0 +1,14 @@ +# Test-only dependencies, kept out of requirements.txt so the Docker image does +# not ship them - the container has no tests/ directory to run anyway (the +# Dockerfile copies app/, cli/, scripts/, data/ and serve.py, nothing else). +# +# For development, install both files: +# pip install -r requirements.txt -r requirements-dev.txt + +pytest>=8.3.3 + +# Starlette's TestClient deprecates the httpx 0.x backend and emits a +# StarletteDeprecationWarning without this. pytest.ini turns warnings into +# errors, so it is a hard requirement of the suite, not a nicety. Test-only: +# the application itself uses the `httpx` pinned in requirements.txt. +httpx2>=2.10.0 diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..0e33d5f --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,33 @@ +# Electronics Catalog backend (local only). Python 3.13. +# py -3.13 -m venv .venv && .venv\Scripts\pip install -r requirements.txt -r requirements-dev.txt + +# --- API --- +fastapi>=0.115.0 +uvicorn[standard]>=0.30.6 +pydantic>=2.9.2 +python-dotenv>=1.0.1 +PyJWT>=2.9.0 +fastmcp>=4.0,<5 # MCP endpoint at /mcp/ (app/mcp_server.py) + +# --- Database --- +psycopg[binary]>=3.2.3 +pgvector>=0.2.5 + +# --- Embeddings (MiniLM, CPU) --- +sentence-transformers>=3.0.1 + +# --- HTTP, search, parsing --- +requests>=2.31.0 +httpx>=0.27.2 +ddgs>=9.14.4 +protego>=0.3.1 # robots.txt parser (Google-style wildcards) +extruct>=0.17.0 # JSON-LD / microdata / OpenGraph +beautifulsoup4>=4.12.3 +lxml>=4.9.3 +PyYAML>=6.0 + +# --- Normalisation / matching --- +rapidfuzz>=3.9.0 + +# --- CLI --- +typer>=0.12.0 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..1ed4594 --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,149 @@ +""" +Shared test setup. + +Every environment variable here must be set BEFORE `app.main` is imported, +because app/infrastructure/settings.py reads the environment once at import +time. + +Hermetic by construction: + * no web search, no LLM calls (USE_DDG_SEARCH / USE_GOOGLE_CSE / USE_OLLAMA off) + * database tests use their OWN database, electronics_catalog_test, on the + local Docker server - never the real electronics_catalog data, and the + settings guard makes any non-local host impossible anyway. They are + skipped when that server is not running. +""" +from __future__ import annotations + +import base64 +import hashlib +import os +import secrets +import sys +from pathlib import Path + +import pytest + +BACKEND = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(BACKEND)) + +TEST_ADMIN_PASSWORD = "test-admin-password" +TEST_USER_PASSWORD = "test-user-password" +TEST_API_KEY = "test-api-key-value-not-a-real-secret" +TEST_DB_NAME = "electronics_catalog_test" + + +def _hash(password: str, iterations: int = 20_000) -> str: + """Byte-compatible with security.hash_password, at a low iteration count.""" + salt = secrets.token_bytes(16) + digest = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, iterations) + return "$".join(("pbkdf2_sha256", str(iterations), base64.b64encode(salt).decode(), + base64.b64encode(digest).decode())) + + +# Local DB connection details come from backend/.env when it exists (the +# password is generated per machine); the database NAME is always the test one. +try: + from dotenv import dotenv_values + + _local_env = dotenv_values(BACKEND / ".env") +except Exception: # noqa: BLE001 + _local_env = {} +os.environ["DB_HOST"] = "127.0.0.1" +os.environ["DB_PORT"] = _local_env.get("DB_PORT") or "5433" +os.environ["DB_USER"] = _local_env.get("DB_USER") or "postgres" +os.environ["DB_PASSWORD"] = _local_env.get("DB_PASSWORD") or "test-password-not-real" +os.environ["DB_NAME"] = TEST_DB_NAME +os.environ["DB_CONNECT_TIMEOUT_SECONDS"] = "3" + +os.environ["USE_OLLAMA"] = "false" +os.environ["ELEC_USE_LLM"] = "false" +os.environ["USE_DDG_SEARCH"] = "false" +os.environ["USE_GOOGLE_CSE"] = "false" +os.environ["GOOGLE_API_KEY"] = "" +os.environ["GOOGLE_CSE_ID"] = "" + +# Auth is set unconditionally: the suite asserts on the real guards. +os.environ["AUTH_ENABLED"] = "true" +os.environ["AUTH_ALLOW_ANY_LOGIN"] = "false" +os.environ["AUTH_SECRET_KEY"] = "test-secret-key-not-for-production-use-at-all" +os.environ["AUTH_ADMIN_USERNAME"] = "admin" +os.environ["AUTH_ADMIN_PASSWORD_HASH"] = _hash(TEST_ADMIN_PASSWORD) +os.environ["AUTH_USER_USERNAME"] = "user" +os.environ["AUTH_USER_PASSWORD_HASH"] = _hash(TEST_USER_PASSWORD) +os.environ["API_KEYS"] = f"test-machine:user:{TEST_API_KEY}" +os.environ["AUTH_MAX_LOGIN_ATTEMPTS"] = "3" +os.environ["AUTH_LOCKOUT_SECONDS"] = "60" + +from fastapi.testclient import TestClient # noqa: E402 + +from app.main import app # noqa: E402 + + +@pytest.fixture(scope="session") +def client() -> TestClient: + return TestClient(app) + + +@pytest.fixture(autouse=True) +def _reset_login_throttle(): + from app.api.routers import auth as auth_router + + with auth_router._failures_lock: + auth_router._failures.clear() + yield + with auth_router._failures_lock: + auth_router._failures.clear() + + +def _token(client: TestClient, username: str, password: str) -> str: + resp = client.post("/api/auth/login", json={"username": username, "password": password}) + assert resp.status_code == 200, resp.text + return resp.json()["access_token"] + + +@pytest.fixture +def admin_headers(client: TestClient) -> dict: + return {"Authorization": f"Bearer {_token(client, 'admin', TEST_ADMIN_PASSWORD)}"} + + +@pytest.fixture +def user_headers(client: TestClient) -> dict: + return {"Authorization": f"Bearer {_token(client, 'user', TEST_USER_PASSWORD)}"} + + +_DATA_TABLES = ("product_image, product_listing_map, price_history, source_listing, product, " + "fetch_log, search_cache, crawl_run") + + +@pytest.fixture(scope="session") +def test_database(): + """Create/migrate/seed electronics_catalog_test once per session, or skip.""" + import psycopg + + try: + with psycopg.connect(host="127.0.0.1", port=os.environ["DB_PORT"], dbname="postgres", + user=os.environ["DB_USER"], password=os.environ["DB_PASSWORD"], + connect_timeout=3, autocommit=True) as admin: + exists = admin.execute("SELECT 1 FROM pg_database WHERE datname = %s", (TEST_DB_NAME,)).fetchone() + if not exists: + admin.execute(f'CREATE DATABASE "{TEST_DB_NAME}"') + except Exception as exc: # noqa: BLE001 + pytest.skip(f"local Postgres not reachable ({exc}); run `docker compose up -d`") + from app.electronics.db import repository as repo + from app.electronics.db.migrate import run_migrations + from app.electronics.reference import load_reference + + run_migrations() + repo.seed_reference(load_reference()) + return TEST_DB_NAME + + +@pytest.fixture +def db(test_database): + """A clean test database for one test (reference data kept).""" + from app.electronics.db.connection import connect + + with connect(autocommit=True) as conn: + conn.execute(f"TRUNCATE {', '.join('elec.' + t.strip() for t in _DATA_TABLES.split(','))} CASCADE") + conn.execute("UPDATE elec.site SET probe_outcome = NULL, breaker_until = NULL, breaker_reason = NULL") + yield test_database diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py new file mode 100644 index 0000000..16da17d --- /dev/null +++ b/backend/tests/test_api.py @@ -0,0 +1,39 @@ +"""Smoke tests for the FastAPI layer (no database needed).""" +from __future__ import annotations + + +def test_root(client) -> None: + resp = client.get("/") + assert resp.status_code == 200 + + +def test_health_reports_database_and_search(client) -> None: + resp = client.get("/api/health") + assert resp.status_code == 200 + body = resp.json() + assert body["status"] in {"ok", "degraded"} + assert body["database_name"] == "electronics_catalog_test" + assert body["search"] == {"ddg": False, "google_cse": False} + + +def test_openapi_lists_only_electronics_routes(client) -> None: + paths = client.get("/openapi.json").json()["paths"] + for expected in ("/api/health", "/api/auth/login", "/api/elec/categories", "/api/elec/brands", + "/api/elec/products", "/api/elec/products/{product_id}", "/api/elec/sites", + "/api/elec/admin/runs"): + assert expected in paths, f"missing route: {expected}" + grocery = [p for p in paths if any(w in p for w in ("nutrition", "catalog/generate", "stores", "discounts", + "trending", "uploads", "brand-discovery"))] + assert grocery == [] + + +def test_admin_run_requires_admin(client, user_headers) -> None: + assert client.post("/api/elec/admin/runs", json={"category": "mobiles"}).status_code == 401 + assert client.post("/api/elec/admin/runs", json={"category": "mobiles"}, + headers=user_headers).status_code == 403 + + +def test_admin_run_rejects_brand_outside_allow_list(client, admin_headers) -> None: + resp = client.post("/api/elec/admin/runs", json={"category": "mobiles", "brands": ["nokia"]}, + headers=admin_headers) + assert resp.status_code == 400 diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py new file mode 100644 index 0000000..f20a61e --- /dev/null +++ b/backend/tests/test_auth.py @@ -0,0 +1,405 @@ +""" +Tests for authentication and the endpoint guards. + +The behaviours asserted here are the ones the previous implementation got +wrong, so each has a comment saying what it prevents rather than just what it +checks. They need no database or Ollama: a request that is rejected at the +guard never reaches a service. +""" +from __future__ import annotations + +import time + +import jwt +import pytest + +from tests.conftest import TEST_ADMIN_PASSWORD, TEST_API_KEY, TEST_USER_PASSWORD + +# Every write/compute endpoint, with a request body valid enough that a 422 +# would prove the guard let the request through to validation. +WRITE_ENDPOINTS = [ + ("/api/elec/admin/runs", {"json": {"category": "mobiles"}}), + ("/api/elec/admin/review/1", {"json": {"approve": True}}), + ("/api/elec/admin/sites/croma.com/probe", {}), +] + +ADMIN_ONLY_ENDPOINTS = [ + ("/api/elec/admin/runs", {"category": "mobiles"}), + ("/api/elec/admin/review/1", {"approve": True}), + ("/api/elec/admin/sites/croma.com/probe", None), +] + + +# --------------------------------------------------------------------------- +# Guards +# --------------------------------------------------------------------------- +@pytest.mark.parametrize("path,kwargs", WRITE_ENDPOINTS) +def test_write_endpoints_reject_anonymous_callers(client, path, kwargs): + """Starting a crawl or probing a site must never be open to anyone who can + reach the port.""" + resp = client.post(path, **kwargs) + assert resp.status_code == 401, f"{path} answered {resp.status_code}, expected 401" + + +@pytest.mark.parametrize("path,body", ADMIN_ONLY_ENDPOINTS) +def test_admin_endpoints_reject_the_user_role(client, user_headers, path, body): + """403, not 401: the caller is authenticated, just not allowed.""" + resp = client.post(path, json=body, headers=user_headers) + assert resp.status_code == 403, f"{path} answered {resp.status_code}, expected 403" + + +def test_admin_passes_an_admin_only_endpoint(client, admin_headers): + """400 (a brand outside the allow-list) proves the guard let admin through.""" + resp = client.post("/api/elec/admin/runs", json={"category": "mobiles", "brands": ["nokia"]}, + headers=admin_headers) + assert resp.status_code == 400 + + +@pytest.mark.parametrize("path", ["/api/health", "/api/auth/roles", "/openapi.json"]) +def test_read_endpoints_stay_public(client, path): + """Guarding writes must not have closed off what the app browses.""" + assert client.get(path).status_code == 200 + + +# --------------------------------------------------------------------------- +# Login +# --------------------------------------------------------------------------- +def test_login_succeeds_and_returns_a_token(client): + resp = client.post( + "/api/auth/login", json={"username": "admin", "password": TEST_ADMIN_PASSWORD} + ) + assert resp.status_code == 200 + body = resp.json() + assert body["token_type"] == "bearer" + assert body["access_token"] + assert body["expires_in"] > 0 + assert body["user"]["role"] == "admin" + + +def test_login_is_case_insensitive_on_username_only(client): + """Usernames are normalised; passwords are not. The old version lowercased + the password before comparing, which quietly shrank the keyspace.""" + assert client.post( + "/api/auth/login", json={"username": "ADMIN", "password": TEST_ADMIN_PASSWORD} + ).status_code == 200 + assert client.post( + "/api/auth/login", json={"username": "admin", "password": TEST_ADMIN_PASSWORD.upper()} + ).status_code == 401 + + +def test_login_rejects_an_empty_password(client): + """The old implementation treated an empty password as valid for any known + username (`if pwd in passwords or pwd == ""`).""" + resp = client.post("/api/auth/login", json={"username": "admin", "password": ""}) + assert resp.status_code == 422 # min_length=1 on the schema + + +def test_login_rejects_an_unknown_username(client): + """The old fallback granted a profile to ANY username, and `admin` to any + username that also asked for role='admin'.""" + resp = client.post( + "/api/auth/login", json={"username": "somebody-new", "password": "whatever"} + ) + assert resp.status_code == 401 + + +def test_login_cannot_be_talked_into_a_role(client): + """A `role` field in the body is not part of the schema and must not be + honoured - the role comes from the account the password belongs to.""" + resp = client.post( + "/api/auth/login", + json={"username": "user", "password": TEST_USER_PASSWORD, "role": "admin"}, + ) + assert resp.status_code == 200 + assert resp.json()["user"]["role"] == "user" + + +def test_failed_logins_are_throttled(client): + """An exposed login endpoint must not be an unlimited password oracle.""" + for _ in range(3): # AUTH_MAX_LOGIN_ATTEMPTS in conftest + assert client.post( + "/api/auth/login", json={"username": "admin", "password": "wrong"} + ).status_code == 401 + + resp = client.post("/api/auth/login", json={"username": "admin", "password": "wrong"}) + assert resp.status_code == 429 + assert "Retry-After" in resp.headers + + # The lockout must also hold against the CORRECT password, or it is trivial + # to sidestep by guessing until you land on it. + assert client.post( + "/api/auth/login", json={"username": "admin", "password": TEST_ADMIN_PASSWORD} + ).status_code == 429 + + +def test_roles_endpoint_no_longer_publishes_working_passwords(client): + """It used to return demo_username/demo_password for both accounts.""" + body = client.get("/api/auth/roles").json() + assert "demo_password" not in client.get("/api/auth/roles").text + assert {r["id"] for r in body["roles"]} == {"admin", "user"} + + +# --------------------------------------------------------------------------- +# Tokens +# --------------------------------------------------------------------------- +def test_me_returns_the_signed_in_profile(client, admin_headers): + resp = client.get("/api/auth/me", headers=admin_headers) + assert resp.status_code == 200 + assert resp.json()["username"] == "admin" + + +def test_me_requires_a_token(client): + assert client.get("/api/auth/me").status_code == 401 + + +def test_expired_token_is_rejected(client): + from app.infrastructure.security import create_access_token + + token, _ = create_access_token("admin", "admin", [], ttl_minutes=-1) + resp = client.get("/api/auth/me", headers={"Authorization": f"Bearer {token}"}) + assert resp.status_code == 401 + assert "expired" in resp.json()["detail"].lower() + + +def test_unsigned_alg_none_token_is_rejected(client): + """ + The classic JWT bypass: present a token with `alg: none` and no signature. + decode_access_token pins algorithms to ["HS256"] instead of trusting the + header, which is what closes it. + """ + forged = jwt.encode( + { + "sub": "admin", + "role": "admin", + "perms": [], + "iss": "brand-catalog-rag", + "iat": int(time.time()), + "exp": int(time.time()) + 3600, + }, + key="", + algorithm="none", + ) + assert client.get( + "/api/auth/me", headers={"Authorization": f"Bearer {forged}"} + ).status_code == 401 + + +def test_token_signed_with_the_wrong_key_is_rejected(client): + forged = jwt.encode( + { + "sub": "admin", + "role": "admin", + "perms": [], + "iss": "brand-catalog-rag", + "iat": int(time.time()), + "exp": int(time.time()) + 3600, + }, + # At least 32 bytes: PyJWT warns about shorter HMAC keys (RFC 7518 + # §3.2), and a warning raised from a test asserting a rejection is + # noise that hides real ones. + key="a-wrong-key-that-is-long-enough-to-not-warn", + algorithm="HS256", + ) + assert client.get( + "/api/auth/me", headers={"Authorization": f"Bearer {forged}"} + ).status_code == 401 + + +def test_token_naming_an_unknown_role_is_rejected(client): + """A validly signed token still cannot invent a role.""" + from app.infrastructure.settings import AUTH_SECRET_KEY + + token = jwt.encode( + { + "sub": "admin", + "role": "superuser", + "perms": ["everything"], + "iss": "brand-catalog-rag", + "iat": int(time.time()), + "exp": int(time.time()) + 3600, + }, + key=AUTH_SECRET_KEY, + algorithm="HS256", + ) + assert client.get( + "/api/auth/me", headers={"Authorization": f"Bearer {token}"} + ).status_code == 401 + + +def test_garbage_bearer_token_is_rejected(client): + assert client.get( + "/api/auth/me", headers={"Authorization": "Bearer not-even-a-jwt"} + ).status_code == 401 + + +# --------------------------------------------------------------------------- +# API keys (machine consumers) +# --------------------------------------------------------------------------- +def test_valid_api_key_authenticates(client): + resp = client.get("/api/auth/me", headers={"X-API-Key": TEST_API_KEY}) + assert resp.status_code == 200 + assert resp.json()["role"] == "user" + + +def test_invalid_api_key_is_rejected(client): + assert client.get( + "/api/auth/me", headers={"X-API-Key": "wrong-key"} + ).status_code == 401 + + +def test_api_key_is_bound_to_its_configured_role(client): + """The test key is a `user`, so admin-only endpoints must still refuse it.""" + resp = client.post( + "/api/elec/admin/runs", + json={"category": "mobiles"}, + headers={"X-API-Key": TEST_API_KEY}, + ) + assert resp.status_code == 403 + + +# --------------------------------------------------------------------------- +# Password hashing +# --------------------------------------------------------------------------- +def test_password_round_trip(): + from app.infrastructure.security import hash_password, verify_password + + encoded = hash_password("correct horse battery staple", iterations=1000) + assert verify_password("correct horse battery staple", encoded) + assert not verify_password("wrong", encoded) + + +def test_hashes_are_salted(): + """Two hashes of the same password must differ, or the digest leaks that + two accounts share a password.""" + from app.infrastructure.security import hash_password + + assert hash_password("same", iterations=1000) != hash_password("same", iterations=1000) + + +@pytest.mark.parametrize("bad", ["", "not-a-hash", "pbkdf2_sha256$notanint$a$b", "a$b$c$d"]) +def test_malformed_hash_fails_closed(bad): + """A typo in AUTH_ADMIN_PASSWORD_HASH must fail the login, not 500 the + endpoint and hand the caller a stack trace of the credential store.""" + from app.infrastructure.security import verify_password + + assert verify_password("anything", bad) is False + + +# --------------------------------------------------------------------------- +# Why a sign-in failed +# --------------------------------------------------------------------------- +# The caller is told the same thing whatever went wrong - that is deliberate and +# is pinned below. The operator is not: an account whose configured hash is +# stale or corrupt needs a different repair from a mistyped password, and +# collapsing the two is how a production sign-in outage stayed unexplained for a +# day. These tests hold both halves at once: three reasons in the log, one +# response on the wire. +# +# Throttle budget: conftest sets AUTH_MAX_LOGIN_ATTEMPTS=3 per (username, IP), +# so each test below keeps `admin` to at most two attempts. Exceeding it turns a +# 401 assertion into a 429 and reads like a code bug. +import logging + +from app.api.routers import auth as auth_router +from app.infrastructure.security import hash_is_wellformed + +_AUTH_LOGGER = "app.api.routers.auth" + + +def test_an_unknown_username_is_logged_as_such(client, caplog): + with caplog.at_level(logging.WARNING, logger=_AUTH_LOGGER): + assert client.post( + "/api/auth/login", json={"username": "nobody", "password": "whatever"} + ).status_code == 401 + + assert "reason=unknown-username" in caplog.text + # Names the setting to look at, since that is the actual repair. + assert "AUTH_ADMIN_USERNAME" in caplog.text + + +def test_a_wrong_password_is_logged_as_such(client, caplog): + with caplog.at_level(logging.WARNING, logger=_AUTH_LOGGER): + assert client.post( + "/api/auth/login", json={"username": "admin", "password": "not-the-password"} + ).status_code == 401 + + assert "reason=bad-password" in caplog.text + + +def test_a_malformed_configured_hash_is_logged_as_an_error(client, caplog, monkeypatch): + """Not a WARNING: no password can match an unparseable digest, so this is a + broken deployment rather than a failed guess. `_accounts()` re-reads this + module global on every call, which is what makes it patchable here.""" + monkeypatch.setattr(auth_router, "AUTH_ADMIN_PASSWORD_HASH", "not-a-hash") + + with caplog.at_level(logging.WARNING, logger=_AUTH_LOGGER): + assert client.post( + "/api/auth/login", json={"username": "admin", "password": TEST_ADMIN_PASSWORD} + ).status_code == 401 + + assert "reason=malformed-hash" in caplog.text + assert any( + r.levelno == logging.ERROR and "malformed-hash" in r.getMessage() + for r in caplog.records + ) + + +def test_every_failure_reason_returns_an_identical_response(client, monkeypatch): + """The log distinguishes them; the wire must not. If any of these three + responses differed - by status, body, or headers - the endpoint would + enumerate valid usernames and report its own misconfiguration to anyone.""" + unknown = client.post( + "/api/auth/login", json={"username": "nobody", "password": "x"} + ) + wrong = client.post( + "/api/auth/login", json={"username": "admin", "password": "not-the-password"} + ) + monkeypatch.setattr(auth_router, "AUTH_ADMIN_PASSWORD_HASH", "not-a-hash") + broken = client.post( + "/api/auth/login", json={"username": "admin", "password": TEST_ADMIN_PASSWORD} + ) + + responses = [unknown, wrong, broken] + assert {r.status_code for r in responses} == {401} + assert len({r.text for r in responses}) == 1 + assert all(r.json() == {"detail": "Invalid username or password."} for r in responses) + for r in responses: + joined = r.text + " ".join(f"{k}:{v}" for k, v in r.headers.items()) + for leak in ("unknown-username", "bad-password", "malformed-hash", "reason"): + assert leak not in joined + + +def test_the_failure_log_never_carries_the_hash_or_the_password(client, caplog): + with caplog.at_level(logging.WARNING, logger=_AUTH_LOGGER): + client.post( + "/api/auth/login", + json={"username": "admin", "password": "some-guessed-password"}, + ) + + assert "some-guessed-password" not in caplog.text + assert TEST_ADMIN_PASSWORD not in caplog.text + assert "pbkdf2_sha256$" not in caplog.text + + +def test_a_malformed_hash_still_costs_a_full_password_check(client, monkeypatch): + """verify_password returns from an unparseable digest without doing any + PBKDF2 work - measured at 0.16ms against 439ms for a real one. Left alone, + an account with a corrupt hash would answer ~2700x faster than every other + username and announce itself to anyone with a stopwatch, inverting the + property _DUMMY_HASH exists to provide. So the work must still be paid.""" + checked = [] + real_verify = auth_router.verify_password + + def spy(password, encoded): + checked.append(encoded) + return real_verify(password, encoded) + + monkeypatch.setattr(auth_router, "AUTH_ADMIN_PASSWORD_HASH", "not-a-hash") + monkeypatch.setattr(auth_router, "verify_password", spy) + + assert client.post( + "/api/auth/login", json={"username": "admin", "password": TEST_ADMIN_PASSWORD} + ).status_code == 401 + + assert len(checked) == 1, "exactly one verification per attempt" + assert hash_is_wellformed(checked[0]), "the broken hash must not short-circuit it" diff --git a/backend/tests/test_auth_diagnostics.py b/backend/tests/test_auth_diagnostics.py new file mode 100644 index 0000000..23e4863 --- /dev/null +++ b/backend/tests/test_auth_diagnostics.py @@ -0,0 +1,329 @@ +""" +The credential-diagnostics surface: hash fingerprints, config provenance, and +the `auth` block on /api/health. + +These exist because of a real incident. Production rejected the correct admin +password while localhost accepted it, and every observable said the app was +healthy: /api/health was 200, CORS passed, the route table was current, and the +only log line was `Failed sign-in for 'admin'` - which is what a user with caps +lock on produces too. Nothing distinguished "wrong password" from "this image +was built from a different .env.production", so there was no way to tell which +of them it was without a shell on the box. + +What is pinned here is therefore not a feature so much as the ability to answer +one question from outside a container: *is this deployment running the +credential I think it is?* The fingerprint is the answer, and these tests hold +it to the two properties that make it usable - it identifies a hash, and it +discloses nothing about the password behind it. +""" +from __future__ import annotations + +import pytest + +from app.infrastructure.security import ( + api_key_fingerprint, + auth_config_summary, + describe_api_keys, + describe_password_hash, + hash_is_wellformed, + hash_password, + password_hash_fingerprint, +) +from app.infrastructure.settings import API_KEY_MIN_LENGTH, _parse_api_keys, config_source +from tests.conftest import TEST_ADMIN_PASSWORD + + +# --------------------------------------------------------------------------- +# Fingerprint +# --------------------------------------------------------------------------- +def test_fingerprint_is_stable_for_a_given_hash(): + """Comparing prod against local is the whole point, so the same input must + give the same answer on both machines and across runs.""" + encoded = hash_password("whatever", iterations=1000) + assert password_hash_fingerprint(encoded) == password_hash_fingerprint(encoded) + + +def test_fingerprint_differs_when_the_hash_does(): + """Including for the same password: two deployments that hashed the same + password separately are NOT running the same credential, and a fingerprint + that hid that would defeat the comparison.""" + a = hash_password("same-password", iterations=1000) + b = hash_password("same-password", iterations=1000) + assert a != b, "salts must differ" + assert password_hash_fingerprint(a) != password_hash_fingerprint(b) + + +@pytest.mark.parametrize("wrapper", ['"{}"', "'{}'", " {} ", "{}\r", "\n{}\n"]) +def test_fingerprint_ignores_quotes_and_whitespace(wrapper): + """A hash pasted into a platform's Environment tab arrives wrapped. It is + the same credential, so it must fingerprint the same - otherwise the + comparison reports a spurious mismatch in exactly the case it exists for.""" + encoded = hash_password("p", iterations=1000) + assert password_hash_fingerprint(wrapper.format(encoded)) == password_hash_fingerprint( + encoded + ) + + +def test_fingerprint_discloses_no_part_of_the_hash(): + """It is served unauthenticated, so it must be a digest OF the credential + and not a piece of it.""" + encoded = hash_password("p", iterations=1000) + fp = password_hash_fingerprint(encoded) + + assert len(fp) == 12 + assert all(c in "0123456789abcdef" for c in fp) + assert fp not in encoded + # Nor any run of it long enough to be a foothold into salt or digest. + for start in range(len(fp) - 5): + assert fp[start : start + 6] not in encoded + + +def test_absent_hash_fingerprints_as_empty(): + assert password_hash_fingerprint("") == "" + + +# --------------------------------------------------------------------------- +# describe_password_hash / hash_is_wellformed +# --------------------------------------------------------------------------- +@pytest.mark.parametrize( + "bad", ["", "not-a-hash", "pbkdf2_sha256$notanint$a$b", "a$b$c$d", "bcrypt$1$a$b"] +) +def test_a_malformed_hash_is_reported_invalid(bad): + """Same inputs as test_malformed_hash_fails_closed, held against the shared + parser - the two must agree on what 'unusable' means, since one decides the + login and the other decides what the log calls it.""" + assert hash_is_wellformed(bad) is False + assert describe_password_hash(bad)["valid"] is False + + +def test_a_real_hash_is_reported_valid_with_its_iteration_count(): + described = describe_password_hash(hash_password("p", iterations=4321)) + assert described["valid"] is True + assert described["iterations"] == 4321 + assert described["algorithm"] == "pbkdf2_sha256" + + +def test_describe_never_returns_the_hash_itself(): + encoded = hash_password("p", iterations=1000) + assert encoded not in str(describe_password_hash(encoded)) + + +# --------------------------------------------------------------------------- +# Config provenance +# --------------------------------------------------------------------------- +def test_config_source_reports_process_env_for_harness_supplied_values(): + """conftest writes the AUTH_* values into os.environ before app.main is + imported - which is structurally the same thing a deployment platform's + Environment tab does. That this reads back as 'process-env' is the + executable proof that an override is detectable at all.""" + assert config_source("AUTH_ADMIN_PASSWORD_HASH") == "process-env" + assert config_source("AUTH_ADMIN_USERNAME") == "process-env" + + +def test_config_source_reports_default_for_something_never_set(): + assert config_source("AUTH_NOT_A_REAL_SETTING_XYZ") == "default" + + +# --------------------------------------------------------------------------- +# /api/health +# --------------------------------------------------------------------------- +def test_health_reports_the_effective_auth_configuration(client): + auth = client.get("/api/health").json()["auth"] + + assert auth["enabled"] is True + assert auth["allow_any_login"] is False + assert auth["admin_username"] == "admin" + assert auth["password_hash_valid"] is True + assert auth["password_hash_iterations"] == 20_000 # conftest._hash + assert auth["password_hash_fingerprint"] == auth_config_summary()[ + "password_hash_fingerprint" + ] + assert auth["password_hash_source"] == "process-env" + + +def test_health_never_exposes_a_hash_or_a_password(client): + """The leak canary on an unauthenticated endpoint. A configured digest + always contains '$' separators; a password would appear verbatim.""" + body = client.get("/api/health").text + + assert TEST_ADMIN_PASSWORD not in body + assert "pbkdf2_sha256$" not in body + assert "$" not in body + + +def test_health_stays_ok_shaped_when_auth_is_misconfigured(client, monkeypatch): + """An unusable credential must NOT flip `status` to degraded: the container + healthcheck and the frontend's connectivity banner both read that field, so + doing so would turn a login problem into an outage and a misleading "database + unreachable" banner. The signal belongs in auth.password_hash_valid.""" + from app.api.routers import health as health_router + + monkeypatch.setattr( + health_router, "auth_config_summary", lambda: {**auth_config_summary(), + "password_hash_valid": False} + ) + body = client.get("/api/health").json() + + assert body["auth"]["password_hash_valid"] is False + assert body["status"] in {"ok", "degraded"} # decided by db/ollama only + + +# --------------------------------------------------------------------------- +# API keys +# --------------------------------------------------------------------------- +# Same incident, one layer out. A key added to .env.production and then merely +# restarted into a running container is absent from the process, because the +# Dockerfile copies that file in at BUILD time - and from outside, an undeployed +# key and a wrong key are both just a 401. These pin the ability to tell them +# apart without anyone sending the secret to find out. + +_GOOD_SECRET = "cs3JwvApS5Je_Qfe1sNYq6YtUBDDeqp4OEgy2_41sQg" + + +def test_api_key_fingerprint_is_stable_and_hex(): + fp = api_key_fingerprint("partner", _GOOD_SECRET) + + assert fp == api_key_fingerprint("partner", _GOOD_SECRET) + assert len(fp) == 12 + assert all(c in "0123456789abcdef" for c in fp) + + +def test_api_key_fingerprint_differs_when_the_secret_does(): + assert api_key_fingerprint("partner", _GOOD_SECRET) != api_key_fingerprint( + "partner", _GOOD_SECRET[:-1] + "X" + ) + + +def test_api_key_fingerprint_separates_consumers_sharing_a_secret(): + """The name is mixed in, so two consumers mistakenly issued the same secret + do not report the same fingerprint - which would hide the mistake behind the + very field meant to reveal it.""" + assert api_key_fingerprint("console-a", _GOOD_SECRET) != api_key_fingerprint( + "console-b", _GOOD_SECRET + ) + + +@pytest.mark.parametrize("wrapper", ["{}", "'{}'", '"{}"', " {} "]) +def test_api_key_fingerprint_ignores_quotes_and_whitespace(wrapper): + """A value pasted into a deployment platform's Environment tab arrives + wrapped often enough that settings strips it; the fingerprint must agree, + or comparing two ends reports a mismatch that is not real.""" + assert api_key_fingerprint("partner", wrapper.format(_GOOD_SECRET)) == ( + api_key_fingerprint("partner", _GOOD_SECRET) + ) + + +def test_api_key_fingerprint_discloses_no_part_of_the_secret(): + """Served unauthenticated, so it must be a digest OF the key, not a piece.""" + fp = api_key_fingerprint("partner", _GOOD_SECRET) + + assert fp not in _GOOD_SECRET + for start in range(len(fp) - 5): + assert fp[start : start + 6] not in _GOOD_SECRET + + +def test_absent_secret_fingerprints_as_empty(): + assert api_key_fingerprint("partner", "") == "" + + +def test_describe_api_keys_is_sorted_by_name(monkeypatch): + """API_KEYS is keyed by secret, whose order says nothing. Sorting is what + lets two deployments' output be diffed line for line.""" + from app.infrastructure import security + + monkeypatch.setattr( + security, "API_KEYS", + {_GOOD_SECRET: ("zulu", "user"), _GOOD_SECRET[::-1]: ("alpha", "admin")}, + ) + + assert [k["name"] for k in describe_api_keys()] == ["alpha", "zulu"] + assert [k["role"] for k in describe_api_keys()] == ["admin", "user"] + + +# --------------------------------------------------------------------------- +# API keys on /api/health +# --------------------------------------------------------------------------- +def test_health_reports_the_keys_this_deployment_actually_loaded(client): + """Against the harness's own API_KEYS, not a monkeypatched one - this is the + end-to-end wiring from settings through the summary to the response body.""" + from tests.conftest import TEST_API_KEY + + auth = client.get("/api/health").json()["auth"] + + assert auth["api_keys_count"] == 1 + assert auth["api_keys"] == [{ + "name": "test-machine", + "role": "user", + "fingerprint": api_key_fingerprint("test-machine", TEST_API_KEY), + }] + + +def test_health_reports_an_empty_list_when_no_keys_are_configured(client, monkeypatch): + """The state production was in while the colleague's console got 401s: auth + enabled, admin login working, and not one machine consumer deployed.""" + from app.infrastructure import security + + monkeypatch.setattr(security, "API_KEYS", {}) + auth = client.get("/api/health").json()["auth"] + + assert auth["api_keys_count"] == 0 + assert auth["api_keys"] == [] + + +def test_health_names_configured_keys_and_fingerprints_them(client, monkeypatch): + from app.infrastructure import security + + monkeypatch.setattr(security, "API_KEYS", {_GOOD_SECRET: ("colleague-console", "admin")}) + auth = client.get("/api/health").json()["auth"] + + assert auth["api_keys_count"] == 1 + assert auth["api_keys"] == [{ + "name": "colleague-console", + "role": "admin", + "fingerprint": api_key_fingerprint("colleague-console", _GOOD_SECRET), + }] + + +def test_health_never_exposes_an_api_key_secret(client, monkeypatch): + """The leak canary, extended to machine credentials.""" + from app.infrastructure import security + + monkeypatch.setattr(security, "API_KEYS", {_GOOD_SECRET: ("colleague-console", "admin")}) + body = client.get("/api/health").text + + assert _GOOD_SECRET not in body + for start in range(0, len(_GOOD_SECRET) - 7): + assert _GOOD_SECRET[start : start + 8] not in body + + +def test_health_reports_where_the_keys_came_from(client): + """Which of the two config sources won. Unlike the admin hash - where + "process-env" flags a stale Environment tab shadowing the image - API_KEYS is + deliberately supplied by that tab, so "process-env" is the expected value in + production and "env-file" would mean the tab entry has gone missing.""" + auth = client.get("/api/health").json()["auth"] + + assert auth["api_keys_source"] in {"process-env", "env-file", "default"} + + +# --------------------------------------------------------------------------- +# Parsing +# --------------------------------------------------------------------------- +def test_parse_api_keys_accepts_a_generated_secret(): + parsed = _parse_api_keys(f"partner:admin:{_GOOD_SECRET}") + + assert parsed == {_GOOD_SECRET: ("partner", "admin")} + + +def test_parse_api_keys_rejects_a_secret_too_short_to_fingerprint_safely(): + """A raw key carries no salt, so publishing its digest is only safe while the + key itself is unguessable offline. A hand-picked one must be refused at + startup rather than quietly fingerprinted onto a public endpoint.""" + with pytest.raises(RuntimeError, match="at least"): + _parse_api_keys("partner:admin:changeme") + + +def test_parse_api_keys_length_limit_admits_the_documented_generator(): + import secrets as _secrets + + assert len(_secrets.token_urlsafe(32)) >= API_KEY_MIN_LENGTH diff --git a/backend/tests/test_cors_origins.py b/backend/tests/test_cors_origins.py new file mode 100644 index 0000000..e59a886 --- /dev/null +++ b/backend/tests/test_cors_origins.py @@ -0,0 +1,129 @@ +"""The browser is the only client that enforces CORS, and no test here is one. + +THE FAILURE THIS FILE EXISTS FOR +-------------------------------- +The merchant console called `https://mcp.nearle.ai.in/api/...` from Chrome and got +`TypeError: Failed to fetch` for a whole day. The same URL under curl returned 200 +with the right body, so every server-side check - ours and theirs - passed. The +response carried `Access-Control-Allow-Credentials: true` and no +`Access-Control-Allow-Origin`, and the browser discarded it before any code saw it. + +The cause was not missing middleware. `CORSMiddleware` was installed and correct; +the console's origin was simply not in `API_CORS_ORIGINS`, so Starlette declined to +echo the header. Nothing in the server log looked wrong: a healthy 200, every time. + +That is why the deployed allowlist is asserted as DATA below. A unit test cannot +fail the way a browser fails, so the next best thing is to pin the one value whose +absence produces a silent, total outage for a first-party client. +""" +from __future__ import annotations + +import re +from pathlib import Path + +import pytest + +ENV_PRODUCTION = Path(__file__).resolve().parents[1] / ".env.production" + +# Every first-party browser client of this API. A new one added to the console +# and not added here is the bug above, repeated. +REQUIRED_ORIGINS = ( + "https://app.nearledaily.com", # merchant console + "http://localhost:3100", # merchant console, local development + "https://catalogue.nearle.ai.in", # catalogue frontend +) + + +def _deployed_origins(): + """The allowlist the image ships with. + + Read from the file rather than from `settings`, because the test process has + its own environment - importing the setting would assert on the developer's + machine instead of on what gets deployed. + """ + if not ENV_PRODUCTION.exists(): + pytest.skip(".env.production is not present in this checkout") + for line in ENV_PRODUCTION.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if line.startswith("API_CORS_ORIGINS="): + value = line.split("=", 1)[1] + return [o.strip() for o in value.split(",") if o.strip()] + return [] + + +@pytest.mark.parametrize("origin", REQUIRED_ORIGINS) +def test_every_first_party_browser_client_is_allowed(origin): + assert origin in _deployed_origins(), ( + "%s is missing from API_CORS_ORIGINS in .env.production. Browser calls " + "from it fail as an opaque 'Failed to fetch' while curl still returns " + "200, so nothing server-side will catch this." % origin + ) + + +def test_the_allowlist_is_not_a_wildcard(): + """A wildcard would disable `allow_credentials` (see the guard in main.py), + silently breaking any authenticated browser call to this API.""" + assert "*" not in _deployed_origins() + + +def test_every_origin_is_a_bare_scheme_and_host(): + """An Origin header is scheme + host + port, never a path and never a + trailing slash. `https://app.nearledaily.com/` does not match and fails + exactly as if it were absent.""" + for origin in _deployed_origins(): + assert re.fullmatch(r"https?://[A-Za-z0-9.\-]+(:\d+)?", origin), origin + + +# --------------------------------------------------------------------------- +# The middleware itself +# --------------------------------------------------------------------------- +# These run against whatever origins the TEST environment carries, so they pin +# the behaviour rather than the deployed list: an allowed origin is echoed, an +# unknown one is not, and a preflight from an unknown origin is refused. + +def _allowed_origin(): + from app.infrastructure.settings import API_CORS_ORIGINS + if not API_CORS_ORIGINS: + pytest.skip("no CORS origins configured in the test environment") + return API_CORS_ORIGINS[0] + + +def test_an_allowed_origin_is_echoed_back(client): + origin = _allowed_origin() + + response = client.get("/api/health", headers={"Origin": origin}) + + assert response.headers.get("access-control-allow-origin") == origin + + +def test_an_unknown_origin_gets_no_header_at_all(client): + """The response still returns 200 with a correct body - which is why this is + invisible everywhere except a browser.""" + response = client.get("/api/health", headers={"Origin": "https://not-listed.example"}) + + assert response.status_code == 200 + assert "access-control-allow-origin" not in response.headers + + +def test_a_preflight_from_an_allowed_origin_succeeds(client): + response = client.options( + "/api/health", + headers={"Origin": _allowed_origin(), + "Access-Control-Request-Method": "GET", + "Access-Control-Request-Headers": "content-type"}, + ) + + assert response.status_code == 200 + assert response.headers.get("access-control-allow-origin") == _allowed_origin() + + +def test_a_preflight_from_an_unknown_origin_is_refused(client): + """Starlette answers 400 here. It reads as a malformed request in the log, + which is how this was mistaken for a second, unrelated bug.""" + response = client.options( + "/api/health", + headers={"Origin": "https://not-listed.example", + "Access-Control-Request-Method": "GET"}, + ) + + assert response.status_code == 400 diff --git a/backend/tests/test_elec_database.py b/backend/tests/test_elec_database.py new file mode 100644 index 0000000..90f3d03 --- /dev/null +++ b/backend/tests/test_elec_database.py @@ -0,0 +1,239 @@ +"""Database tests against the local electronics_catalog_test database: +constraints, append-only history, verification rule, views and the API. +Skipped when the local Postgres container is not running.""" +from __future__ import annotations + +from decimal import Decimal + +import psycopg +import pytest + +from app.electronics.collector import Collector, RunOptions +from app.electronics.db import repository as repo +from app.electronics.db.connection import connect +from app.electronics.models import Listing +from app.electronics.normalise.title_parser import parse_title, variant_key + + +def _listing(site: str, sku: str, title: str, *, price=None, source_type="search_snippet", + evidence=None) -> Listing: + p = parse_title(title, "mobiles") + l = Listing(site_domain=site, source_sku=sku, source_url=f"https://www.{site}/p/{sku}", + source_type=source_type, brand_slug=p.brand.brand_slug, category="mobiles", title=title, + evidence_text=evidence or f"{title} ₹{price}", confidence=0.5, parser="test", + model=p.model, ram_gb=p.ram_gb, storage_gb=p.storage_gb, price=price) + l.model_norm, l.variant_key = p.model_norm, variant_key(p, "mobiles") + return l + + +def test_settings_guard_refuses_remote_database(): + from app.infrastructure.settings import _guard_local_database + + with pytest.raises(RuntimeError, match="not a local host"): + _guard_local_database("31.97.228.132", "electronics_catalog") + with pytest.raises(RuntimeError, match="expected 'electronics_catalog'"): + _guard_local_database("localhost", "pgvector") + _guard_local_database("localhost", "electronics_catalog") + + +def test_settings_guard_remote_opt_in_is_exact(): + from app.infrastructure.settings import _guard_local_database + + prod = dict(remote_hosts=frozenset({"31.97.228.132"}), remote_names=frozenset({"loyalycatalogue"})) + # Listed host + name, with the flag on: allowed. + _guard_local_database("31.97.228.132", "loyalycatalogue", allow_remote=True, **prod) + # Same host without the flag: still refused. + with pytest.raises(RuntimeError, match="not a local host"): + _guard_local_database("31.97.228.132", "loyalycatalogue", **prod) + # Flag on but another host or another database: refused. + with pytest.raises(RuntimeError, match="not a local host"): + _guard_local_database("10.0.0.9", "loyalycatalogue", allow_remote=True, **prod) + with pytest.raises(RuntimeError, match="not in ELEC_REMOTE_DB_NAMES"): + _guard_local_database("31.97.228.132", "pgvector", allow_remote=True, **prod) + + +def test_constraints_reject_fabricated_rows(db): + ids = repo.id_maps() + with connect() as conn: + base = dict(site=ids["site"]["croma.com"], brand=ids["brand"]["samsung"], cat=ids["category"]["mobiles"]) + bad_rows = [ + ("no URL", "INSERT INTO elec.source_listing (site_id, source_sku, source_url, source_type, brand_id, " + "category_id, title, evidence_text, confidence, parser) VALUES (%(site)s,'x','not-a-url'," + "'search_snippet',%(brand)s,%(cat)s,'t','e',0.5,'t')"), + ("no evidence", "INSERT INTO elec.source_listing (site_id, source_sku, source_url, source_type, brand_id, " + "category_id, title, evidence_text, confidence, parser) VALUES (%(site)s,'x','https://a.in/x'," + "'search_snippet',%(brand)s,%(cat)s,'t','',0.5,'t')"), + ("absurd price", "INSERT INTO elec.source_listing (site_id, source_sku, source_url, source_type, brand_id, " + "category_id, title, evidence_text, confidence, parser, price) VALUES (%(site)s,'x','https://a.in/x'," + "'search_snippet',%(brand)s,%(cat)s,'t','e',0.5,'t', 5)"), + ("non-INR", "INSERT INTO elec.source_listing (site_id, source_sku, source_url, source_type, brand_id, " + "category_id, title, evidence_text, confidence, parser, currency) VALUES (%(site)s,'x','https://a.in/x'," + "'search_snippet',%(brand)s,%(cat)s,'t','e',0.5,'t','USD')"), + ("pincode claim", "INSERT INTO elec.source_listing (site_id, source_sku, source_url, source_type, brand_id, " + "category_id, title, evidence_text, confidence, parser, pincode_applied) VALUES (%(site)s,'x'," + "'https://a.in/x','search_snippet',%(brand)s,%(cat)s,'t','e',0.5,'t',TRUE)"), + ("bad grade", "UPDATE elec.site SET probe_outcome = 'D' WHERE id = %(site)s"), + ] + for label, sql in bad_rows: + with pytest.raises(psycopg.errors.CheckViolation): + with conn.transaction(): + conn.execute(sql, base) + pytest.fail(label) + + +def test_listing_validation_rejects_missing_evidence(): + l = _listing("croma.com", "1", "Samsung Galaxy S24 5G (8GB RAM, 256GB)", price=Decimal(74999)) + l.evidence_text = " " + with pytest.raises(ValueError): + l.validate() + + +def test_price_history_is_append_only(db): + ids = repo.id_maps() + lid = repo.upsert_listing(_listing("croma.com", "1", "Samsung Galaxy S24 5G (8GB RAM, 256GB)", + price=Decimal(74999)), ids, None) + with connect() as conn: + with pytest.raises(psycopg.errors.RaiseException): + conn.execute("UPDATE elec.price_history SET price = 1000 WHERE listing_id = %s", (lid,)) + + +def test_verification_needs_two_sites_including_a_retailer(db): + c = Collector.__new__(Collector) # use store() without network setup + c.opt = RunOptions(category="mobiles", brands=["samsung"]) + c.ids = repo.id_maps() + c.run_id = None + c._touched_products = {} + from app.electronics.collector import RunStats + c.stats = RunStats() + + title = "Samsung Galaxy S24 5G (Onyx Black, 8GB RAM, 256GB Storage)" + c.store(_listing("amazon.in", "B0CS5XW6TN", title, price=Decimal(74999))) + assert repo.refresh_verification() == {"unverified": 1} + with connect() as conn: + assert conn.execute("SELECT count(*) n FROM elec.v_brand_catalog").fetchone()["n"] == 0 + + # A second, different platform listing the same variant (written its own way). + c.store(_listing("flipkart.com", "itm1", "SAMSUNG Galaxy S24 5G (Onyx Black, 256 GB) (8 GB RAM)", + price=Decimal(72999))) + assert repo.refresh_verification() == {"verified": 1} + with connect() as conn: + row = conn.execute("SELECT * FROM elec.v_brand_catalog").fetchone() + offers = conn.execute("SELECT site FROM elec.v_product_availability ORDER BY price").fetchall() + assert row["platform_count"] == 2 and row["best_price"] == Decimal("72999.00") + assert [o["site"] for o in offers] == ["Flipkart", "Amazon.in"] + + +def test_scraped_listing_is_not_downgraded_by_a_snippet(db): + ids = repo.id_maps() + title = "Samsung Galaxy S24 5G (8GB RAM, 256GB)" + scraped = _listing("croma.com", "303838", title, price=Decimal(74999), source_type="scraped_page", + evidence='{"price": "74999"}') + lid = repo.upsert_listing(scraped, ids, None) + snippet = _listing("croma.com", "303838", title, price=Decimal(69999)) + assert repo.upsert_listing(snippet, ids, None) == lid + with connect() as conn: + row = conn.execute("SELECT price, source_type FROM elec.source_listing WHERE id = %s", (lid,)).fetchone() + assert (row["price"], row["source_type"]) == (Decimal("74999.00"), "scraped_page") + + +def test_catalogue_api_serves_verified_products(db, client): + c = Collector.__new__(Collector) + c.opt = RunOptions(category="mobiles", brands=["samsung"]) + c.ids, c.run_id, c._touched_products = repo.id_maps(), None, {} + from app.electronics.collector import RunStats + c.stats = RunStats() + c.store(_listing("amazon.in", "B0CS5XW6TN", "Samsung Galaxy S24 5G (8GB RAM, 256GB)", price=Decimal(74999))) + c.store(_listing("poorvika.com", "samsung-galaxy-s24", "Samsung Galaxy S24 5G (8GB RAM, 256GB)", + price=Decimal(73999))) + repo.refresh_verification() + + brands = client.get("/api/elec/brands", params={"category": "mobiles"}).json() + assert brands[0]["brand_slug"] == "samsung" and brands[0]["product_count"] == 1 + listing = client.get("/api/elec/products", params={"category": "mobiles", "tn_only": True}).json() + assert listing["total"] == 1 + product = listing["products"][0] + assert product["best_price"] == "73999.00" and product["sold_by_tn_retailer"] is True + detail = client.get(f"/api/elec/products/{product['product_id']}").json() + assert {o["site"] for o in detail["offers"]} == {"Amazon.in", "Poorvika"} + assert all(o["source_url"].startswith("https://") for o in detail["offers"]) + assert client.get("/api/elec/products/999999").status_code == 404 + + +def test_implausible_snippet_prices_are_flagged(db): + c = Collector.__new__(Collector) + c.opt = RunOptions(category="mobiles", brands=["samsung"]) + c.ids, c.run_id, c._touched_products = repo.id_maps(), None, {} + from app.electronics.collector import RunStats + c.stats = RunStats() + title = "Samsung Galaxy S24 5G (8GB RAM, 256GB)" + c.store(_listing("poorvika.com", "s24", title, price=Decimal(74999), source_type="scraped_page", + evidence='{"price": "74999"}')) + c.store(_listing("flipkart.com", "itm1", title, price=Decimal(129999))) # 73% above the page price + c.store(_listing("amazon.in", "B0X", title, price=Decimal(72999))) # plausible + repo.refresh_verification() + with connect() as conn: + flagged = {r["site"]: r["price_outlier"] for r in conn.execute( + "SELECT site, price_outlier FROM elec.v_product_availability")} + best = conn.execute("SELECT price FROM elec.v_best_price").fetchone()["price"] + assert flagged == {"Poorvika": False, "Flipkart": True, "Amazon.in": False} + assert best == Decimal("74999.00") # page price preferred; the outlier never wins + + +def test_google_price_lookup_only_trusts_the_same_page(db, monkeypatch): + from app.electronics import price_lookup + from app.electronics.search.providers import SearchHit + + c = Collector.__new__(Collector) + c.opt = RunOptions(category="mobiles", brands=["samsung"]) + c.ids, c.run_id, c._touched_products = repo.id_maps(), None, {} + from app.electronics.collector import RunStats + c.stats = RunStats() + title = "Samsung Galaxy S24 5G (8GB RAM, 256GB)" + c.store(_listing("amazon.in", "B0CS5XW6TN", title)) # no price yet + c.store(_listing("poorvika.com", "s24", title, price=Decimal(74999), source_type="scraped_page", + evidence='{"price": "74999"}')) + offer = {"price": "72999", "currency": "INR", "availability": "InStock", "raw": {"price": "72999"}} + hits = [ + # Another product page on the same site, with a price: must be ignored. + SearchHit("https://www.amazon.in/other/dp/B0OTHER123", "Samsung Galaxy S24 Ultra", "", "google", 0, + offer={**offer, "price": "129999"}), + SearchHit("https://www.amazon.in/Samsung-Galaxy/dp/B0CS5XW6TN/ref=x", title, "", "google", 1, offer=offer), + ] + + class FakeGoogle: + enabled, error = True, None + + class FakeEngine: + def __init__(self, budget): + self.google = FakeGoogle() + + def text(self, query, max_results=10, providers="default"): + assert providers == "google" + return hits + + monkeypatch.setattr(price_lookup, "SearchEngine", FakeEngine) + stats = price_lookup.lookup_prices(limit=5) + assert stats["priced"] == 1 + with connect() as conn: + row = conn.execute("SELECT price, parser, evidence_text FROM elec.source_listing WHERE source_sku = 'B0CS5XW6TN'").fetchone() + assert row["price"] == Decimal("72999.00") and row["parser"].endswith("pagemap") and "72999" in row["evidence_text"] + + +def test_google_disables_itself_on_a_rejected_key(monkeypatch): + import app.electronics.search.providers as providers + + calls = [] + + class Resp: + status_code = 403 + text = "forbidden" + + def json(self): + return {"error": {"message": "This project does not have the access to Custom Search JSON API."}} + + monkeypatch.setattr(providers, "USE_GOOGLE_CSE", True) + monkeypatch.setattr(providers.requests, "get", lambda *a, **k: calls.append(1) or Resp()) + g = providers.GoogleCseProvider(quota_left=lambda: 100) + g._pacer.interval = 0 + assert g.text("q") is None and g.text("q2") is None + assert calls == [1] and not g.enabled and "Custom Search JSON API" in g.error diff --git a/backend/tests/test_elec_extract_and_net.py b/backend/tests/test_elec_extract_and_net.py new file mode 100644 index 0000000..635abad --- /dev/null +++ b/backend/tests/test_elec_extract_and_net.py @@ -0,0 +1,308 @@ +"""Offline tests: JSON-LD/HTML extraction, the polite HTTP client (robots.txt, +pacing, circuit breaker, CAPTCHA detection), the site probe grading and the +matcher. Network access is replaced with httpx.MockTransport.""" +from __future__ import annotations + +from decimal import Decimal +from types import SimpleNamespace + +import httpx + +from app.electronics.extract.html_fallback import extract_page +from app.electronics.extract.jsonld import extract_products +from app.electronics.match.matcher import decide +from app.electronics.net.breaker import CircuitBreaker +from app.electronics.net.polite_client import PoliteClient +from app.electronics.probe.site_probe import grade_page +from app.electronics.reference import load_reference, site_for_url +from app.electronics.collector import is_product_url, source_sku + +PRODUCT_PAGE = """ + + + +

Samsung Galaxy S24

+
RAM8 GB
Internal Storage256 GB
+ +""" + +HTML_ONLY_PAGE = """ + + +

No cost EMI from ₹3,300/month

+
Display Size6.1 inch
+""" + + +def test_jsonld_product(): + [p] = extract_products(PRODUCT_PAGE) + assert p["price"] == Decimal("74999.00") and p["currency"] == "INR" + assert p["in_stock"] is True and p["availability"] == "InStock" + assert p["gtin"] == "8806095467245" and p["mpn"] == "SM-S921BZKCINS" + assert p["images"][0].endswith("s24-1.jpg") + assert p["properties"] == {"Battery Capacity": "4000 mAh"} + assert "74999" in p["evidence"] + + +def test_jsonld_aggregate_offer_and_graph(): + html = """""" + [p] = extract_products(html) + assert p["price"] == Decimal("48990") + + +def test_html_meta_price_only_from_markup(): + page = extract_page(HTML_ONLY_PAGE) + assert page["price"] == Decimal("69900") + assert page["properties"] == {"Display Size": "6.1 inch"} + # The EMI amount in body text is never read as a price. + assert "3300" not in page["evidence"].replace(",", "") + + +def test_grade_page(): + assert grade_page(PRODUCT_PAGE)["jsonld_priced"] == 1 + g = grade_page(HTML_ONLY_PAGE) + assert g["jsonld_priced"] == 0 and g["meta_price"] and g["has_title"] + + +# --------------------------------------------------------------------------- +# Polite client +# --------------------------------------------------------------------------- +def _client(handler, **kw) -> PoliteClient: + kw.setdefault("sleep", lambda s: None) + return PoliteClient(transport=httpx.MockTransport(handler), min_interval=kw.pop("min_interval", 0), **kw) + + +def test_robots_disallow_is_obeyed(): + requested = [] + + def handler(req: httpx.Request): + requested.append(req.url.path) + if req.url.path == "/robots.txt": + return httpx.Response(200, text="User-agent: *\nDisallow: /p/\n") + return httpx.Response(200, text=PRODUCT_PAGE, headers={"content-type": "text/html"}) + + with _client(handler) as c: + res = c.get("https://shop.example.in/p/123") + assert res.outcome == "robots_disallowed" + assert requested == ["/robots.txt"] # the product page itself was never requested + + +def test_unreadable_robots_means_disallowed(): + def handler(req): + if req.url.path == "/robots.txt": + return httpx.Response(500) + return httpx.Response(200, text="ok", headers={"content-type": "text/html"}) + + with _client(handler) as c: + assert c.get("https://shop.example.in/p/1").outcome == "robots_disallowed" + + +def test_missing_robots_allows(): + def handler(req): + if req.url.path == "/robots.txt": + return httpx.Response(404) + return httpx.Response(200, text=PRODUCT_PAGE, headers={"content-type": "text/html"}) + + with _client(handler) as c: + res = c.get("https://shop.example.in/p/1") + assert res.ok and "Galaxy S24" in res.text + + +def test_429_trips_breaker_and_stops_further_requests(): + calls = [] + + def handler(req): + calls.append(req.url.path) + if req.url.path == "/robots.txt": + return httpx.Response(404) + return httpx.Response(429) + + trips = [] + breaker = CircuitBreaker(on_trip=lambda host, reason, until: trips.append(host)) + with _client(handler, breaker=breaker) as c: + assert c.get("https://shop.example.in/p/1").outcome == "blocked" + assert c.get("https://shop.example.in/p/2").outcome == "breaker_open" + assert trips == ["shop.example.in"] + assert calls == ["/robots.txt", "/p/1"] # nothing after the block + + +def test_captcha_page_trips_breaker(): + def handler(req): + if req.url.path == "/robots.txt": + return httpx.Response(404) + return httpx.Response(200, text="Robot Check. Enter the characters you see", + headers={"content-type": "text/html"}) + + with _client(handler) as c: + assert c.get("https://shop.example.in/p/1").outcome == "captcha" + assert c.breaker.is_open("shop.example.in") + + +def test_requests_to_one_host_are_paced(): + now = [0.0] + slept = [] + + def sleep(s): + slept.append(round(s, 2)) + now[0] += s + + def handler(req): + now[0] += 0.5 # each request takes 0.5 s + if req.url.path == "/robots.txt": + return httpx.Response(404) + return httpx.Response(200, text="x", headers={"content-type": "text/html"}) + + c = PoliteClient(transport=httpx.MockTransport(handler), min_interval=3.0, sleep=sleep, clock=lambda: now[0]) + for i in range(3): + c.get(f"https://shop.example.in/p/{i}") + c.close() + # robots.txt, then 3 pages: every gap after the first is topped up to 3 s. + assert slept == [2.5, 2.5, 2.5] + + +def test_user_agent_is_honest(): + seen = {} + + def handler(req): + seen["ua"] = req.headers["user-agent"] + return httpx.Response(404) + + with _client(handler) as c: + c.get("https://shop.example.in/p/1") + assert seen["ua"].startswith("ElectronicsCatalogBot/") and "mailto:" in seen["ua"] + + +# --------------------------------------------------------------------------- +# Sites, URLs, matching +# --------------------------------------------------------------------------- +def test_marketplaces_are_never_fetched_directly(): + ref = load_reference() + assert ref.sites["amazon.in"].policy == "serp_only" + assert ref.sites["flipkart.com"].policy == "serp_only" + assert {s.name for s in ref.sites.values() if s.region == "TN"} >= {"Poorvika", "Sangeetha Mobiles"} + + +def test_product_urls_and_skus(): + amazon = site_for_url("https://www.amazon.in/Samsung-Galaxy-Storage/dp/B0CS5XW6TN/ref=sr_1_1") + assert amazon.domain == "amazon.in" + assert source_sku(amazon, "https://www.amazon.in/Samsung-Galaxy-Storage/dp/B0CS5XW6TN/ref=sr_1_1") == "B0CS5XW6TN" + assert not is_product_url(amazon, "https://www.amazon.in/s?k=samsung+galaxy") + fk = site_for_url("https://www.flipkart.com/samsung-galaxy-s24/p/itm123abc?pid=MOBGX") + assert source_sku(fk, "https://www.flipkart.com/samsung-galaxy-s24/p/itm123abc?pid=MOBGX") == "itm123abc" + croma = site_for_url("https://www.croma.com/samsung-galaxy-s24/p/303838") + assert is_product_url(croma, "https://www.croma.com/samsung-galaxy-s24/p/303838") + assert not is_product_url(croma, "https://www.croma.com/phones-wearables/c/1") + samsung = site_for_url("https://www.samsung.com/in/smartphones/galaxy-s24/buy/") + assert samsung.kind == "brand_official" + assert is_product_url(samsung, "https://www.samsung.com/in/smartphones/galaxy-s24/buy/") + assert not is_product_url(samsung, "https://us.samsung.com/smartphones/galaxy-s24/buy/") + assert not is_product_url(samsung, "https://www.samsung.com/uk/smartphones/galaxy-s24/buy/") + assert not is_product_url(samsung, "https://news.samsung.com/in/galaxy-s24-launch") + oneplus = site_for_url("https://www.oneplus.in/nord-ce4-lite") + assert is_product_url(oneplus, "https://www.oneplus.in/nord-ce4-lite/specs") + assert site_for_url("https://example.com/whatever") is None + + +def _listing(**kw): + base = dict(variant_key=None, model_norm=None, ram_gb=None, storage_gb=None, gtin=None, + model_number=None, category="mobiles", processor=None) + base.update(kw) + return SimpleNamespace(**base) + + +def _cand(pid, key, norm, ram, storage, **kw): + return dict(id=pid, variant_key=key, model_norm=norm, ram_gb=ram, storage_gb=storage, + processor=kw.get("processor"), mpn=kw.get("mpn"), gtin=kw.get("gtin")) + + +def test_matcher(): + cands = [ + _cand(1, "samsung|mobiles|galaxy s24|8|256", "galaxy s24", Decimal(8), Decimal(256)), + _cand(2, "samsung|mobiles|galaxy s24 ultra|12|256", "galaxy s24 ultra", Decimal(12), Decimal(256)), + ] + exact = decide(_listing(variant_key="samsung|mobiles|galaxy s24|8|256", model_norm="galaxy s24", + ram_gb=Decimal(8), storage_gb=Decimal(256)), cands) + assert (exact.product_id, exact.review_status) == (1, "auto") + # "galaxy s24" must never be merged into "galaxy s24 ultra". + other = decide(_listing(variant_key="samsung|mobiles|galaxy s24 plus|12|256", model_norm="galaxy s24 plus", + ram_gb=Decimal(12), storage_gb=Decimal(256)), cands) + assert other.product_id is None + # Unknown RAM, one candidate with the same model+storage -> same variant. + no_ram = decide(_listing(variant_key="samsung|mobiles|galaxy s24|na|256", model_norm="galaxy s24", + storage_gb=Decimal(256)), cands) + assert (no_ram.product_id, no_ram.review_status) == (1, "auto") + assert decide(_listing(), cands) is None # nothing to identify a variant by + + +# --------------------------------------------------------------------------- +# Search-engine offer data (Google CSE pagemap) +# --------------------------------------------------------------------------- +def test_pagemap_offer_inr_only(): + from app.electronics.search.providers import pagemap_offer + + assert pagemap_offer({"offer": [{"price": "74999", "pricecurrency": "INR", + "availability": "https://schema.org/InStock"}]})["price"] == "74999" + assert pagemap_offer({"metatags": [{"product:price:amount": "69900", "product:price:currency": "INR"}]}) + assert pagemap_offer({"offer": [{"price": "799", "pricecurrency": "USD"}]}) is None + assert pagemap_offer({}) is None + + +def test_listing_from_search_uses_engine_offer(): + from app.electronics.collector import Collector, RunOptions, RunStats + from app.electronics.search.providers import SearchHit + from app.electronics.normalise.title_parser import parse_title + + c = Collector.__new__(Collector) + c.opt, c.stats = RunOptions(category="mobiles", brands=["samsung"]), RunStats() + title = "Samsung Galaxy A56 5G (Awesome Olive, 256 GB) (8 GB RAM) - Flipkart" + hit = SearchHit("https://www.flipkart.com/samsung-galaxy-a56/p/itmabc", title, + "Buy Samsung Galaxy A56 5G online at best price", "google", 0, + offer={"price": "42999", "currency": "INR", "availability": "InStock", + "raw": {"price": "42999", "pricecurrency": "INR"}}) + site = site_for_url(hit.url) + listing = c.listing_from_search(hit, site, parse_title(title, "mobiles"), "q") + assert listing.price == Decimal("42999") and listing.in_stock is True + assert "42999" in listing.evidence_text and listing.parser.endswith("pagemap") + assert listing.source_type == "search_snippet" # still never fetched from Flipkart + + +def test_matcher_never_merges_different_model_numbers(): + for existing, incoming in (("galaxy s25 ultra", "galaxy s26 ultra"), ("galaxy s25 fe", "galaxy s26 fe"), + ("galaxy a37", "galaxy a27"), ("iphone 15", "iphone 16")): + cands = [_cand(1, f"samsung|mobiles|{existing}|12|256", existing, Decimal(12), Decimal(256))] + d = decide(_listing(variant_key=f"samsung|mobiles|{incoming}|12|256", model_norm=incoming, + ram_gb=Decimal(12), storage_gb=Decimal(256)), cands) + assert d.product_id is None, (existing, incoming) + + +def test_laptop_matching_by_configuration(): + cands = [_cand(1, "lenovo|laptops|ideapad slim 3|i5-13420h|16|512", "ideapad slim 3 15irh10", + Decimal(16), Decimal(512), processor="i5-13420h")] + same = decide(_listing(category="laptops", variant_key="lenovo|laptops|ideapad slim 3|i5-13420h|16|512x", + model_norm="ideapad slim 3", processor="i5-13420h", + ram_gb=Decimal(16), storage_gb=Decimal(512)), cands) + assert (same.product_id, same.review_status) == (1, "auto") + # A different line (Slim 5) or a different CPU is a different laptop. + for norm, cpu in (("ideapad slim 5", "i5-13420h"), ("ideapad slim 3", "i5-1235u")): + d = decide(_listing(category="laptops", variant_key="x", model_norm=norm, processor=cpu, + ram_gb=Decimal(16), storage_gb=Decimal(512)), cands) + assert d.product_id is None, (norm, cpu) + + +def test_vague_laptop_line_goes_to_review(): + cands = [ + _cand(1, "hp|laptops|pavilion 14|i5-1240p|8|512", "pavilion 14", Decimal(8), Decimal(512), processor="i5-1240p"), + _cand(2, "hp|laptops|pavilion 15|i5-1240p|8|512", "pavilion 15", Decimal(8), Decimal(512), processor="i5-1240p"), + ] + d = decide(_listing(category="laptops", variant_key="hp|laptops|pavilion|i5-1240p|8|512", model_norm="pavilion", + processor="i5-1240p", ram_gb=Decimal(8), storage_gb=Decimal(512)), cands) + assert d.review_status == "pending" diff --git a/backend/tests/test_elec_parsers.py b/backend/tests/test_elec_parsers.py new file mode 100644 index 0000000..f6f6b2a --- /dev/null +++ b/backend/tests/test_elec_parsers.py @@ -0,0 +1,330 @@ +"""Offline tests: title parsing, SERP price reading, spec normalisation, +grounding and the LLM guard. No network, no database.""" +from __future__ import annotations + +from decimal import Decimal + +import pytest + +from app.electronics.extract.serp_parser import clean_result_title, read_price, read_stock +from app.electronics.normalise.brand_alias import looks_like_device_title, resolve_brand +from app.electronics.normalise.grounding import value_in_source +from app.electronics.normalise.llm_fill import fill_missing +from app.electronics.normalise.spec_normaliser import normalise_specs +from app.electronics.normalise.title_parser import parse_title, variant_key + +# --------------------------------------------------------------------------- +# Titles: the same variant written the way different sites write it must give +# the same key; different variants must not. +# --------------------------------------------------------------------------- +SAME_VARIANT = [ + ("mobiles", "samsung|mobiles|galaxy s24|8|256", [ + "Samsung Galaxy S24 5G (Onyx Black, 8GB RAM, 256GB Storage)", + "SAMSUNG Galaxy S24 5G (Onyx Black, 256 GB) (8 GB RAM)", + "Samsung Galaxy S24 5G (8GB RAM, 256GB, Onyx Black)", + "Samsung Galaxy S24 8GB 256GB Onyx Black", + ]), + ("mobiles", "xiaomi|mobiles|redmi note 13 pro|8|256", [ + "Redmi Note 13 Pro 5G (8GB + 256GB)", + "Xiaomi Redmi Note 13 Pro 5G (Arctic White, 8GB RAM, 256GB Storage)", + "REDMI Note 13 Pro 5G (Arctic White, 256 GB) (8 GB RAM)", + ]), + ("mobiles", "apple|mobiles|iphone 15|na|128", [ + "Apple iPhone 15 (128 GB) - Black", + "Apple iPhone 15 128GB Black", + "iPhone 15 128 GB: 5G Smartphone with Dynamic Island", + ]), + ("mobiles", "nothing|mobiles|2a|8|128", [ + "Nothing Phone (2a) 5G (Black, 8GB RAM, 128GB)", + "Nothing Phone (2a) 5G (Black, 128 GB) (8 GB RAM)", + ]), + ("laptops", "hp|laptops|15s|i5-1334u|16|512", [ + "HP 15s, 13th Gen Intel Core i5-1334U, 16GB DDR4, 512GB SSD, (Win 11, Office 21, Silver, 1.69kg), " + "15.6-inch(39.6 cm) FHD, Intel Iris Xe Graphics, fd0112TU", + "HP 15s Laptop fd0112TU Intel Core i5 1334U 16GB 512GB SSD", + ]), + ("laptops", "dell|laptops|inspiron 3520|i5-1235u|16|512", [ + "Dell Inspiron 3520 Laptop, Intel Core i5-1235U, 16GB, 512GB SSD, 15.6\" FHD", + "Dell Inspiron 3520 Intel Core i5-1235U 16GB RAM 512GB SSD 15.6 inch", + ]), +] + + +@pytest.mark.parametrize("category,key,titles", SAME_VARIANT) +def test_same_variant_same_key(category, key, titles): + for title in titles: + assert variant_key(parse_title(title, category), category) == key, title + + +@pytest.mark.parametrize("a,b", [ + ("Samsung Galaxy S24 5G (8GB RAM, 256GB)", "Samsung Galaxy S24 Ultra 5G (12GB RAM, 256GB)"), + ("Samsung Galaxy S24 5G (8GB RAM, 128GB)", "Samsung Galaxy S24 5G (8GB RAM, 256GB)"), + ("Redmi Note 13 5G (8GB + 256GB)", "Redmi Note 13 Pro 5G (8GB + 256GB)"), + ("Apple iPhone 15 (128 GB)", "Apple iPhone 15 Plus (128 GB)"), +]) +def test_different_variants_differ(a, b): + assert variant_key(parse_title(a, "mobiles"), "mobiles") != variant_key(parse_title(b, "mobiles"), "mobiles") + + +def test_title_without_storage_has_no_variant(): + # A title that does not say which variant it is cannot be linked to one. + assert variant_key(parse_title("Samsung Galaxy S24 5G price in India", "mobiles"), "mobiles") is None + + +@pytest.mark.parametrize("title,brand,family", [ + ("Google Pixel 8a (Obsidian, 128GB)", "google", "Pixel"), + ("iQOO Z9 5G (Brushed Green, 8GB RAM, 128GB Storage)", "vivo", "iQOO"), + ("POCO X6 Pro 5G (Racing Grey, 12GB RAM, 512GB)", "xiaomi", "POCO"), + ("Lenovo IdeaPad Slim 3 Intel Core i5", "lenovo", "IdeaPad"), +]) +def test_sub_brands_resolve_to_parent(title, brand, family): + m = resolve_brand(title) + assert m.brand_slug == brand and m.family == family + + +def test_brand_outside_allow_list_is_rejected(): + assert resolve_brand("Nokia G42 5G (6GB RAM, 128GB)") is None + # The brand must lead the title - an accessory "for Samsung" is not Samsung. + assert resolve_brand("Spigen case for Samsung Galaxy S24") is None + + +def test_accessories_are_not_devices(): + assert not looks_like_device_title("Samsung Galaxy S24 Back Cover Case") + assert not looks_like_device_title("Samsung Galaxy S24 vs iPhone 15 comparison") + assert looks_like_device_title("Samsung Galaxy S24 5G (8GB RAM, 256GB)") + + +def test_laptop_fields(): + p = parse_title("ASUS Vivobook 15, Intel Core i3-1215U 12th Gen, 8GB RAM, 512GB SSD, 15.6\" FHD, " + "Windows 11, X1504ZA-NJ321WS", "laptops") + assert (p.model, p.processor, p.ram_gb, p.storage_gb, p.mpn) == ( + "Vivobook 15", "i3-1215u", Decimal(8), Decimal(512), "X1504ZA-NJ321WS") + mac = parse_title("Apple MacBook Air Laptop: Apple M2 chip, 13.6-inch Liquid Retina Display, " + "8GB Unified Memory, 256GB SSD Storage - Midnight", "laptops") + assert (mac.model, mac.processor, mac.ram_gb, mac.storage_gb) == ("MacBook Air", "m2", Decimal(8), Decimal(256)) + + +def test_terabyte_storage(): + p = parse_title("Samsung Galaxy S24 Ultra 5G (Titanium Black, 12GB RAM, 1TB Storage)", "mobiles") + assert p.storage_gb == Decimal(1024) + + +# --------------------------------------------------------------------------- +# SERP snippet prices: only an unambiguous selling price is read. +# --------------------------------------------------------------------------- +@pytest.mark.parametrize("text,price", [ + ("Buy Samsung Galaxy S24 5G for ₹74,999 online", Decimal("74999")), + ("Price: ₹1,29,999.00 Free delivery", Decimal("129999.00")), + ("Rs. 12,999 only", Decimal("12999")), + ("Rs.12999", Decimal("12999")), + ("INR 45,490", Decimal("45490")), + ("₹64,999 M.R.P: ₹79,999 (19% off)", Decimal("64999")), + ("Deal price ₹15,499. No Cost EMI from ₹2,583/month.", Decimal("15499")), + ("₹18,999 Save ₹4,000 with bank offers", Decimal("18999")), + ("Get ₹3,000 off. Now at ₹21,999", Decimal("21999")), + ("Price ₹74,999 . Up to ₹5,000 cashback", Decimal("74999")), + ("₹74999 ₹74,999 in stock", Decimal("74999")), # same price twice is still one price +]) +def test_snippet_price_read(text, price): + assert read_price(text).price == price + + +@pytest.mark.parametrize("text", [ + "No Cost EMI starting from ₹2,583/month", + "Save ₹4,000 on exchange", + "Exchange offer up to ₹12,000", + "Price range ₹10,999 - ₹12,999", + "Starting from ₹9,999", + "Get extra ₹2,000 off with HDFC Bank cards", + "Samsung Galaxy S24 5G 8GB 256GB", # no amount at all + "₹299 screen guard", # outside plausible device range + "8GB+128GB ₹17,999 | 8GB+256GB ₹19,999", # two variants, two prices: ambiguous + "₹2,583 per month EMI", + "Flat ₹3,000 discount", + "Delivery charges ₹49", +]) +def test_snippet_non_prices_rejected(text): + assert read_price(text).price is None + + +def test_mrp_is_separate_and_never_below_price(): + r = read_price("₹64,999 MRP ₹79,999") + assert (r.price, r.mrp) == (Decimal("64999"), Decimal("79999")) + assert read_price("₹64,999 MRP ₹59,999").mrp is None + + +def test_stock_phrases(): + assert read_stock("Currently unavailable. We don't know when") is False + assert read_stock("Out of Stock") is False + assert read_stock("In stock. Delivery by tomorrow") is True + assert read_stock("Samsung Galaxy S24 8GB 256GB") is None + + +def test_clean_result_title(): + assert clean_result_title("Samsung Galaxy S24 5G (Onyx Black, 8GB RAM, 256GB) : Amazon.in: Electronics") \ + == "Samsung Galaxy S24 5G (Onyx Black, 8GB RAM, 256GB)" + assert clean_result_title("Buy Apple iPhone 15 (128 GB) - Black Online at Best Price | Croma") \ + == "Apple iPhone 15 (128 GB) - Black" + + +# --------------------------------------------------------------------------- +# Specs and grounding +# --------------------------------------------------------------------------- +def test_spec_normalisation_units_and_ranges(): + specs, sources = normalise_specs("mobiles", { + "Battery Capacity": "5000 mAh", + "Display Size": "15.49 cm (6.1 inch)", + "RAM": "8 GB", + "Internal Storage": "1 TB", + "Primary Camera": "50MP + 12MP + 10MP", + "Network Type": "5G, 4G VOLTE", + "Operating System": "Android 14", + "Refresh Rate": "120 Hz", + "Unrelated Label": "whatever", + "Screen Size": "600 inch", # later duplicate key; also out of range + }) + assert specs == {"battery_mah": 5000, "display_inch": 6.1, "ram_gb": 8, "storage_gb": 1024, + "rear_camera_mp": 50, "network": "5G", "os": "Android", "refresh_hz": 120} + assert sources["battery_mah"].startswith("Battery Capacity") + + +def test_spec_out_of_range_dropped(): + specs, _ = normalise_specs("mobiles", {"Battery Capacity": "50000 mAh", "RAM": "512 GB"}) + assert specs == {} + + +def test_laptop_weight_in_grams(): + specs, _ = normalise_specs("laptops", {"Weight": "1690 g"}) + assert specs == {"weight_kg": 1.69} + + +@pytest.mark.parametrize("value,source,ok", [ + ("5000 mAh", "Battery: 5,000mAh Li-ion", True), + (5000, "Battery 5000 mAh", True), + ("6000 mAh", "Battery 5000 mAh", False), + ("Snapdragon 8 Gen 3", "Processor: Qualcomm Snapdragon 8 Gen 3 for Galaxy", True), + ("Snapdragon 8 Gen 2", "Processor: Qualcomm Snapdragon 8 Gen 3 for Galaxy", False), + ("AMOLED", "6.2-inch Dynamic AMOLED 2X display", True), + ("OLED", "6.2-inch LCD display", False), + (None, "anything", False), +]) +def test_value_in_source(value, source, ok): + assert value_in_source(value, source) is ok + + +def test_llm_values_not_in_source_are_discarded(monkeypatch): + """The guard that stops a small model's guess becoming a stored fact.""" + import app.electronics.normalise.llm_fill as llm_fill + + monkeypatch.setattr(llm_fill, "ELEC_USE_LLM", True) + source = "Display: 6.2 inch Dynamic AMOLED 2X, 120Hz. Battery 4000 mAh. Processor: Exynos 2400" + + def fake_generate(system, prompt): + return { + "battery_mah": "4000 mAh", # stated -> kept + "refresh_hz": "120Hz", # stated -> kept + "rear_camera_mp": "200 MP", # invented -> dropped + "processor": "Snapdragon 8 Gen 3", # invented -> dropped + "os": "Android", # not in the text -> dropped + } + + specs, sources = fill_missing("mobiles", source, + ["battery_mah", "refresh_hz", "rear_camera_mp", "processor", "os"], + generate=fake_generate) + assert specs == {"battery_mah": 4000, "refresh_hz": 120} + assert all(v.startswith("llm-extracted") for v in sources.values()) + + +def test_llm_disabled_returns_nothing(): + called = [] + specs, _ = fill_missing("mobiles", "Battery 5000 mAh", ["battery_mah"], + generate=lambda *a: called.append(1) or {"battery_mah": "5000 mAh"}) + assert specs == {} and called == [] # ELEC_USE_LLM=false in the test environment + + +# --------------------------------------------------------------------------- +# Laptops: real titles from the pilot run that previously failed to group. +# --------------------------------------------------------------------------- +@pytest.mark.parametrize("title,cpu", [ + ("HP 15 (2024) AMD Ryzen 3 Quad Core 7320U - (8 GB/512 GB SSD", "ryzen 3 7320u"), + ("HP 15 AMD Ryzen R3 7320U Windows 11 Home Laptop, 15-fc0500AU ( Natural Silver, 8GB-512GB )", "ryzen 3 7320u"), + ("HP 15s AMD Ryzen 5 Hexa Core 5500U - (16 GB/512 GB SSD", "ryzen 5 5500u"), + ("HP 15 (2026), AMD Athlon Dual Core 7120U - (8 GB DDR5/512 GB", "athlon 7120u"), + ("HP 15 Laptop, Intel Core 5-120U, 16GB DDR4 RAM, 512GB SSD", "core 5 120u"), + ("Lenovo IdeaPad Slim 3 Intel Core i5 13th Gen 13420H - (16 GB/512 GB SSD", "i5-13420h"), + ("Lenovo IdeaPad Slim 3 15IPH11 Intel Core Ultra 5 125H (16GB RAM/ 512GB SSD)", "core ultra 5 125h"), +]) +def test_laptop_processors(title, cpu): + assert parse_title(title, "laptops").processor == cpu + + +def test_cpu_slash_is_not_a_ram_storage_pair(): + p = parse_title("Lenovo IdeaPad Slim 3 15IPH11 Laptop (Intel Core Ultra 5/ 16GB RAM/ 512GB SSD/ 15.3 Inch)", + "laptops") + assert (p.ram_gb, p.storage_gb) == (Decimal(16), Decimal(512)) + + +@pytest.mark.parametrize("category,key,titles", [ + ("laptops", "hp|laptops|15|ryzen 3 7320u|8|512", [ + "HP 15 (2024) AMD Ryzen 3 Quad Core 7320U - (8 GB/512 GB SSD/Windows 11 Home)", + "HP 15 AMD Ryzen R3 7320U Windows 11 Home Laptop, 15-fc0500AU ( Natural Silver, 8GB-512GB )", + "Buy HP 15-FC0500AU AMD Ryzen 3 7320U 8GB RAM 512GB SSD Windows 11 Home Silver Laptop", + ]), + ("laptops", "lenovo|laptops|ideapad slim 3|i5-13420h|16|512", [ + "Lenovo IdeaPad Slim 3 Intel Core i5 13th Gen 13420H - (16 GB/512 GB SSD/Windows 11 Home)", + "Lenovo IdeaPad Slim 3 15IRH10 Intel Core i5-13420H 16GB RAM 512GB SSD 15.3 inch", + ]), +]) +def test_laptop_configuration_keys(category, key, titles): + for title in titles: + assert variant_key(parse_title(title, category), category) == key, title + + +def test_laptop_mpn_is_the_fallback_key(): + # No CPU model number stated: the part number still identifies it. + p = parse_title("Lenovo IdeaPad Slim 3 Intel Core i3 13th Gen Laptop, 82X700HMIN (Arctic Grey, 8GB-512GB)", + "laptops") + assert variant_key(p, "laptops") == "lenovo|laptops|mpn:82x700hmin" + + +def test_truncated_title_filled_from_snippet(): + from app.electronics.normalise.title_parser import fill_from_context + + p = parse_title("Lenovo IdeaPad Slim 5 Intel Core i7 13th Gen 13700H - (16 GB", "laptops") + assert variant_key(p, "laptops") is None + fill_from_context(p, "laptops", snippet="Lenovo IdeaPad Slim 5 Intel Core i7 13th Gen 13700H - (16 GB/512 GB SSD/" + "Windows 11 Home) 14 inch WUXGA OLED") + assert variant_key(p, "laptops") == "lenovo|laptops|ideapad slim 5|i7-13700h|16|512" + + +def test_ambiguous_snippet_is_not_used(): + from app.electronics.normalise.title_parser import fill_from_context + + p = parse_title("HP Pavilion Intel Core i5 12th Gen 1240P - (8 GB", "laptops") + fill_from_context(p, "laptops", snippet="Available in 8 GB/256 GB, 16 GB/512 GB and 16 GB/1 TB SSD") + assert p.storage_gb is None + + +def test_cpu_filled_from_spec_table(): + from app.electronics.normalise.title_parser import fill_from_context + + p = parse_title("Lenovo IdeaPad Slim 3 Laptop (13th Gen Intel Core i7/ 16GB RAM/ 512GB SSD)", "laptops") + fill_from_context(p, "laptops", spec_texts=("13th Gen Intel Core i7-13620H",)) + assert p.processor == "i7-13620h" + + +def test_merged_search_titles_are_cut(): + assert clean_result_title("HP 15 (2026), AMD Athlon Dual Core 7120U - (8 GB DDR5/512 GB ...HP 15 (2026), " + "AMD Ryzen 5 Hexa Core 7535U") == "HP 15 (2026), AMD Athlon Dual Core 7120U - (8 GB DDR5/512 GB" + + +def test_merged_snippet_never_supplies_a_cpu(): + from app.electronics.normalise.title_parser import fill_from_context + + p = parse_title("ASUS Vivobook 16, Snapdragon X, 16GB RAM, 512GB SSD, FHD+ 16", "laptops") + fill_from_context(p, "laptops", snippet="ASUS Vivobook 16 (2026),Intel Core Ultra 5 225H ... ASUS Vivobook 16, " + "Intel Core i5-13420H Processor") + assert p.processor != "core ultra 5 225h" + q = parse_title("HP 15 Laptop (16 GB", "laptops") + fill_from_context(q, "laptops", snippet="HP 15 Intel Core i5-1334U or AMD Ryzen 5 7530U variants") + assert q.processor is None # two CPUs named: ambiguous diff --git a/backend/tests/test_elec_reviews.py b/backend/tests/test_elec_reviews.py new file mode 100644 index 0000000..953623c --- /dev/null +++ b/backend/tests/test_elec_reviews.py @@ -0,0 +1,144 @@ +"""Ratings and reviews: read only what a page or search result states, and pick +the review mix by the product's rating. Offline tests first; the database +tests are skipped when the local Postgres container is not running.""" +from __future__ import annotations + +import json +from decimal import Decimal + +from app.electronics.extract.jsonld import extract_products +from app.electronics.extract.serp_parser import read_rating +from app.electronics.reviews import select_reviews, sentiment_for +from app.electronics.search.providers import SearchHit, pagemap_rating + + +# --------------------------------------------------------------------------- +# Search-result ratings +# --------------------------------------------------------------------------- +def test_read_rating_accepts_explicit_statements(): + r = read_rating("Samsung Galaxy S24 5G ... 4.3 out of 5 stars 1,234 ratings. ₹74,999") + assert r.rating == Decimal("4.3") and r.review_count == 1234 + assert read_rating("Rating: 4.1/5 based on reviews").rating == Decimal("4.1") + r = read_rating("4.4★ (12,345 ratings)") + assert r.rating == Decimal("4.4") and r.review_count == 12345 + + +def test_read_rating_refuses_guesses(): + assert read_rating("Galaxy S24 8GB 256GB ₹74,999").rating is None + assert read_rating("1/5 inch sensor, 50MP").rating is None # a fraction, not a rating + assert read_rating("5/5G phone").rating is None + assert read_rating("4.2 out of 5 ... 3.9 out of 5").rating is None # two products: ambiguous + assert read_rating("7 out of 5").rating is None + + +def test_pagemap_rating_and_cached_hits_without_rating(): + got = pagemap_rating({"aggregaterating": [{"ratingvalue": "4.5", "reviewcount": "2,310", "bestrating": "5"}]}) + assert got["rating"] == 4.5 and got["review_count"] == 2310 + assert pagemap_rating({"aggregaterating": [{"ratingvalue": "9", "bestrating": "10"}]}) is None + # Search results cached before the rating field existed still load. + hit = SearchHit.from_dict({"url": "https://a.in/p", "title": "t", "snippet": "s", "provider": "ddg", "rank": 0}) + assert hit.rating is None + + +# --------------------------------------------------------------------------- +# Page reviews (schema.org JSON-LD) +# --------------------------------------------------------------------------- +def test_jsonld_reviews_are_read_verbatim(): + ld = { + "@context": "https://schema.org", "@type": "Product", "name": "Samsung Galaxy S24", + "aggregateRating": {"ratingValue": "4.4", "reviewCount": "120"}, + "review": [ + {"@type": "Review", "author": {"@type": "Person", "name": "Arun"}, "name": "Great phone", + "reviewBody": "Battery lasts all day.", "datePublished": "2026-05-01", + "reviewRating": {"ratingValue": "5", "bestRating": "5"}}, + {"@type": "Review", "author": "Priya", "reviewBody": "Heats up while gaming.", + "reviewRating": {"ratingValue": "4", "bestRating": "10"}}, + {"@type": "Review", "author": "No words", "reviewRating": {"ratingValue": "1"}}, + ], + } + html = f'' + p = extract_products(html)[0] + assert p["rating"] == Decimal("4.4") and p["review_count"] == 120 + assert [r["body"] for r in p["reviews"]] == ["Battery lasts all day.", "Heats up while gaming."] + assert p["reviews"][0]["author"] == "Arun" and p["reviews"][0]["title"] == "Great phone" + assert p["reviews"][1]["rating"] == Decimal("2.0") # 4 out of 10, rescaled + + +# --------------------------------------------------------------------------- +# Review mix +# --------------------------------------------------------------------------- +def _pool(pos: int, neu: int, neg: int) -> list: + out = [] + for label, n, stars in (("p", pos, 5), ("u", neu, 3), ("n", neg, 1)): + out += [{"body": f"{label}{i}", "rating": stars} for i in range(n)] + return out + + +def _counts(picked: list) -> tuple: + return tuple(sum(1 for r in picked if r["sentiment"] == s) for s in ("positive", "neutral", "negative")) + + +def test_sentiment_is_the_reviewers_own_stars(): + assert [sentiment_for(x) for x in (5, 4, 3.5, 3, 2.9, 1, None)] == [ + "positive", "positive", "neutral", "neutral", "negative", "negative", None] + + +def test_high_rating_shows_mostly_positive(): + assert _counts(select_reviews(4.7, _pool(20, 20, 20))) == (6, 3, 1) + + +def test_middling_rating_shows_mostly_neutral(): + assert _counts(select_reviews(3.6, _pool(20, 20, 20))) == (3, 5, 2) + + +def test_low_rating_shows_mostly_negative(): + assert _counts(select_reviews(2.5, _pool(20, 20, 20))) == (2, 2, 6) + + +def test_short_groups_hand_slots_on_and_nothing_is_padded(): + picked = select_reviews(4.8, _pool(3, 20, 0)) + assert len(picked) == 10 and _counts(picked) == (3, 7, 0) + assert len(select_reviews(4.8, _pool(1, 1, 1))) == 3 + assert select_reviews(4.8, [{"body": "no stars", "rating": None}]) == [] + + +# --------------------------------------------------------------------------- +# Database + API +# --------------------------------------------------------------------------- +def test_api_serves_ratings_reviews_and_out_of_stock_price(db, client): + from app.electronics.collector import Collector, RunOptions, RunStats + from app.electronics.db import repository as repo + from app.electronics.models import Listing + from app.electronics.normalise.title_parser import parse_title, variant_key + + def _listing(site, sku, title, *, price, source_type="search_snippet", evidence=None): + p = parse_title(title, "mobiles") + l = Listing(site_domain=site, source_sku=sku, source_url=f"https://www.{site}/p/{sku}", + source_type=source_type, brand_slug=p.brand.brand_slug, category="mobiles", title=title, + evidence_text=evidence or f"{title} ₹{price}", confidence=0.5, parser="test", + model=p.model, ram_gb=p.ram_gb, storage_gb=p.storage_gb, price=price) + l.model_norm, l.variant_key = p.model_norm, variant_key(p, "mobiles") + return l + + c = Collector.__new__(Collector) + c.opt = RunOptions(category="mobiles", brands=["samsung"]) + c.ids, c.run_id, c._touched_products, c.stats = repo.id_maps(), None, {}, RunStats() + title = "Samsung Galaxy S24 5G (8GB RAM, 256GB)" + a = _listing("amazon.in", "B0CS5XW6TN", title, price=Decimal(74999)) + a.in_stock, a.rating, a.review_count = False, Decimal("4.6"), 300 + b = _listing("croma.com", "303838", title, price=Decimal(73999), source_type="scraped_page", + evidence='{"price": "73999"}') + b.in_stock, b.rating, b.review_count = False, Decimal("4.0"), 100 + b.reviews = [{"author": "Arun", "rating": Decimal(5), "title": "Great", "body": "Battery lasts all day."}, + {"author": "Priya", "rating": Decimal(2), "body": "Heats up."}] + c.store(a) + c.store(b) + repo.refresh_verification() + + product = client.get("/api/elec/products", params={"category": "mobiles"}).json()["products"][0] + assert product["best_price"] == "73999.00" # every listing out of stock, price still shown + detail = client.get(f"/api/elec/products/{product['product_id']}").json() + assert detail["rating"]["value"] == 4.5 and detail["rating"]["count"] == 400 + assert {s["site"] for s in detail["rating"]["sources"]} == {"Amazon.in", "Croma"} + assert [r["body"] for r in detail["reviews"]] == ["Battery lasts all day.", "Heats up."] + assert all(r["source_url"].startswith("https://") for r in detail["reviews"]) diff --git a/backend/tests/test_mcp.py b/backend/tests/test_mcp.py new file mode 100644 index 0000000..f281ca6 --- /dev/null +++ b/backend/tests/test_mcp.py @@ -0,0 +1,93 @@ +"""MCP endpoint (app/mcp_server.py): the tool surface, and that tools return the +same catalogue data as the REST API. Database tests are skipped when the local +Postgres container is not running.""" +from __future__ import annotations + +from decimal import Decimal + +import anyio +from fastmcp import Client + +from app.mcp_server import mcp + + +def _call(name: str, args: dict): + async def go(): + async with Client(mcp) as c: + return (await c.call_tool(name, args)).data + return anyio.run(go) + + +def test_only_read_only_catalogue_tools_are_exposed(): + async def go(): + async with Client(mcp) as c: + return {t.name: set(t.input_schema.get("properties", {})) for t in await c.list_tools()} + tools = anyio.run(go) + assert set(tools) == {"list_categories", "search_products", "get_product", "price_history"} + assert tools["search_products"] == {"query", "category", "brand", "max_price", "min_price", "limit"} + # Nothing that can start a run, log in, or change data. + assert not any(w in name for name in tools for w in ("admin", "run", "login", "probe", "review")) + + +def test_mcp_endpoint_answers_an_initialize_handshake(): + from fastapi.testclient import TestClient + + from app.main import app + + body = {"jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": {"protocolVersion": "2025-06-18", "capabilities": {}, + "clientInfo": {"name": "test", "version": "1"}}} + headers = {"Accept": "application/json, text/event-stream"} + with TestClient(app) as c: # `with` runs the lifespan that starts MCP sessions + r = c.post("/mcp/", json=body, headers=headers) + assert r.status_code == 200, r.text + assert "Electronics Catalog" in r.text # server name in the initialize result + + +def test_tools_return_the_catalogue(db): + from app.electronics.collector import Collector, RunOptions, RunStats + from app.electronics.db import repository as repo + from app.electronics.models import Listing + from app.electronics.normalise.title_parser import parse_title, variant_key + + def listing(site, sku, price): + title = "Samsung Galaxy S24 5G (8GB RAM, 256GB)" + p = parse_title(title, "mobiles") + l = Listing(site_domain=site, source_sku=sku, source_url=f"https://www.{site}/p/{sku}", + source_type="search_snippet", brand_slug="samsung", category="mobiles", title=title, + evidence_text=f"{title} ₹{price}", confidence=0.5, parser="test", + model=p.model, ram_gb=p.ram_gb, storage_gb=p.storage_gb, price=Decimal(price)) + l.model_norm, l.variant_key = p.model_norm, variant_key(p, "mobiles") + return l + + c = Collector.__new__(Collector) + c.opt = RunOptions(category="mobiles", brands=["samsung"]) + c.ids, c.run_id, c._touched_products, c.stats = repo.id_maps(), None, {}, RunStats() + c.store(listing("amazon.in", "B0CS5XW6TN", 74999)) + c.store(listing("poorvika.com", "samsung-galaxy-s24", 73999)) + repo.refresh_verification() + + cats = {x["slug"]: x["product_count"] for x in _call("list_categories", {})} + assert cats["mobiles"] == 1 + + found = _call("search_products", {"query": "galaxy", "category": "mobiles", "max_price": 80000}) + assert found["total"] == 1 + hit = found["products"][0] + assert hit["best_price"] == "73999.00" and hit["best_price_site"] == "Poorvika" + assert _call("search_products", {"max_price": 1000})["total"] == 0 + + detail = _call("get_product", {"product_id": hit["product_id"]}) + assert {o["site"] for o in detail["offers"]} == {"Amazon.in", "Poorvika"} + assert all(o["source_url"].startswith("https://") for o in detail["offers"]) + assert detail["image_urls"] == [] and detail["rating"] is None + + history = _call("price_history", {"product_id": hit["product_id"]}) + assert {h["price"] for h in history} == {"74999.00", "73999.00"} + + +def test_unknown_product_is_a_tool_error(db): + import pytest + from fastmcp.exceptions import ToolError + + with pytest.raises(ToolError, match="not found"): + _call("get_product", {"product_id": 999999}) diff --git a/backend/tests/test_ollama_reachability.py b/backend/tests/test_ollama_reachability.py new file mode 100644 index 0000000..ecb849d --- /dev/null +++ b/backend/tests/test_ollama_reachability.py @@ -0,0 +1,108 @@ +"""The reachability probe in front of every Ollama call. + +WHY THIS FILE EXISTS +-------------------- +`_ensure_client()` asks Ollama for `/api/tags` with a 5-second timeout, and +`stage_2_row_intake` calls it once per ROW through `fetch_product_details`. +Uncached, a 2000-row sheet ingested with `use_llm` on, against a configured but +unreachable Ollama, spends up to ~2.8 hours doing nothing but timing out - and +shows as a batch that has hung, not one that has failed. + +That was survivable only while `use_llm` defaulted to false everywhere. It no +longer does: `UPLOAD_AUTORUN_USE_LLM` is true, so every auto-started upload now +takes this path. The cache is what makes that default safe, and the first test +below is the one that stops it being quietly removed in a later refactor. + +`/api/health` calls the same function, so a down Ollama also stops adding five +seconds to every health request. +""" +from __future__ import annotations + +import pytest + +from app.services import ollama_service + + +@pytest.fixture(autouse=True) +def _clean_probe_cache(): + """The cache is a module global and outlives a test.""" + ollama_service.reset_reachability_cache() + yield + ollama_service.reset_reachability_cache() + + +@pytest.fixture +def probe_calls(monkeypatch): + """Count the HTTP probes, and make every one of them fail. + + Failure is the case that matters: a reachable Ollama answers in + milliseconds, an unreachable one costs the full timeout, and it is the + second that used to be paid per row. + """ + calls: list = [] + + def boom(url, **kwargs): + calls.append(url) + raise OSError("connection refused") + + monkeypatch.setattr(ollama_service, "USE_OLLAMA", True) + monkeypatch.setattr(ollama_service.requests, "get", boom) + return calls + + +def test_an_unreachable_ollama_is_probed_once_not_once_per_call(probe_calls): + """The whole point. Ten rows must not be ten timeouts.""" + for _ in range(10): + assert ollama_service._ensure_client() is False + + assert len(probe_calls) == 1, ( + f"{len(probe_calls)} probes for 10 calls - the cache is not holding, and " + f"an ingest will pay the 5s timeout per row" + ) + + +def test_the_cache_expires_so_a_late_start_is_noticed(probe_calls, monkeypatch): + """A permanent memo would mean an Ollama started after the API is never + seen, and /api/health reports it down until someone redeploys.""" + clock = [1000.0] + monkeypatch.setattr(ollama_service.time, "monotonic", lambda: clock[0]) + + ollama_service._ensure_client() + assert len(probe_calls) == 1 + + clock[0] += ollama_service._PROBE_TTL_SECONDS + 1 + ollama_service._ensure_client() + assert len(probe_calls) == 2, "the probe never expired" + + +def test_a_reachable_ollama_is_also_cached(monkeypatch): + """Both outcomes are cached. Caching only the failure would leave the happy + path paying an HTTP round trip per row - cheap, but per row and pointless.""" + calls: list = [] + + class Ok: + status_code = 200 + + def ok(url, **kwargs): + calls.append(url) + return Ok() + + monkeypatch.setattr(ollama_service, "USE_OLLAMA", True) + monkeypatch.setattr(ollama_service.requests, "get", ok) + + assert [ollama_service._ensure_client() for _ in range(5)] == [True] * 5 + assert len(calls) == 1 + + +def test_disabled_stays_none_and_never_touches_the_network(monkeypatch): + """Three return values, not two: `system.py` tells "switched off" from + "configured but down", and /api/health's `ollama` field means different + things in each case. Collapsing this to a bool would break that. + """ + def never(*_args, **_kwargs): + raise AssertionError("USE_OLLAMA is false - nothing may be requested") + + monkeypatch.setattr(ollama_service, "USE_OLLAMA", False) + monkeypatch.setattr(ollama_service.requests, "get", never) + + assert ollama_service._ensure_client() is None diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..3ffe180 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,33 @@ +# Local-only PostgreSQL + pgvector for the Electronics Catalog. +# +# Names are deliberately different from the grocery project's +# (catalog_rag_postgres / catalog_rag_pgdata / port 5432) so the two can never +# share a container, a volume or a port. Bound to 127.0.0.1: not reachable +# from the network. +# +# docker compose up -d +# +# Ollama runs natively on Windows (http://localhost:11434), not in Docker. +services: + postgres: + image: pgvector/pgvector:pg16 + container_name: elec_catalog_pg + restart: unless-stopped + environment: + POSTGRES_DB: electronics_catalog + POSTGRES_USER: postgres + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in the root .env} + # Small footprint for an 8 GB laptop that also runs Ollama and MiniLM. + command: ["postgres", "-c", "shared_buffers=256MB", "-c", "work_mem=16MB", "-c", "max_connections=40"] + ports: + - "127.0.0.1:5433:5432" + volumes: + - elec_catalog_pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d electronics_catalog"] + interval: 5s + timeout: 5s + retries: 20 + +volumes: + elec_catalog_pgdata: diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..f16d0aa --- /dev/null +++ b/docs/API.md @@ -0,0 +1,252 @@ +# Electronics Catalog API + +Read-only catalogue of **mobiles and laptops sold in India** (Tamil Nadu focus), available as a +REST API and as an MCP server for AI assistants. + +Every product is verified by real listings on **at least two retail platforms**, and every price, +image, rating and review comes with the page it was read from. Nothing is generated. + +| | | +|---|---| +| **Base URL** | `http://31.97.228.132:8000` (HTTPS address coming: `https://catalogue-api.workolik.com`) | +| **Interactive docs** | `http://31.97.228.132:8000/docs` (try every endpoint in the browser) | +| **OpenAPI schema** | `http://31.97.228.132:8000/openapi.json` | +| **MCP endpoint** | `http://31.97.228.132:8000/mcp/` (keep the trailing slash) | +| **Auth** | None for everything in this document | +| **Format** | JSON, UTF-8 | + +Current data: **43 verified products** (15 mobiles, 28 laptops) from Amazon, Flipkart, Croma, +Reliance Digital, Vijay Sales, Tata CLiQ, Poorvika, Sangeetha, Vasanth & Co and Viveks. + +--- + +## Conventions + +- **Money is a decimal string in rupees**: `"42999.00"`, never a float. Parse it as a decimal. +- **Times are ISO 8601** with timezone: `"2026-09-29T12:54:38.447374+05:30"`. +- **`null` means "not stated by any source"**, not zero. For example `rating: null` means no platform publishes a rating. +- **Errors** return an HTTP status with `{"detail": "..."}`. Unknown product → `404`. Bad parameter → `422`. +- **Data freshness**: the catalogue is refreshed by collection runs, not live per request. Each offer has `observed_at`, the time it was last read. + +--- + +## Endpoints + +| Method | Path | Returns | +|---|---|---| +| GET | [`/api/health`](#get-apihealth) | Service and database status | +| GET | [`/api/elec/categories`](#get-apieleccategories) | Categories with product counts | +| GET | [`/api/elec/brands`](#get-apielecbrands) | Brands in a category | +| GET | [`/api/elec/products`](#get-apielecproducts) | Product list with filters | +| GET | [`/api/elec/products/{product_id}`](#get-apielecproductsproduct_id) | Full product detail | +| GET | [`/api/elec/products/{product_id}/price-history`](#get-apielecproductsproduct_idprice-history) | Price over time per platform | +| GET | [`/api/elec/sites`](#get-apielecsites) | The retail platforms read | + +### `GET /api/health` +```bash +curl http://31.97.228.132:8000/api/health +``` +```json +{ "status": "ok", "database": true, "database_name": "loyalycatalogue", ... } +``` +`status` is `"degraded"` when the database is unreachable. + +### `GET /api/elec/categories` +```bash +curl http://31.97.228.132:8000/api/elec/categories +``` +```json +[ + { "slug": "laptops", "name": "Laptops", "product_count": 28 }, + { "slug": "mobiles", "name": "Mobiles", "product_count": 15 } +] +``` + +### `GET /api/elec/brands` +| Param | Required | Example | +|---|---|---| +| `category` | yes | `laptops` | + +```bash +curl "http://31.97.228.132:8000/api/elec/brands?category=laptops" +``` +Each item: `brand`, `brand_slug` (use it to filter products), `product_count`, `min_price`, +`max_price`, `sample_image`. Brands with `product_count: 0` are allow-listed but have no verified products yet. + +### `GET /api/elec/products` +| Param | Example | Meaning | +|---|---|---| +| `category` | `mobiles`, `laptops` | Category slug | +| `brand` | `samsung`, `hp` | Brand slug from `/brands` | +| `q` | `galaxy a56` | Text search on product and brand name (max 100 chars) | +| `min_price`, `max_price` | `30000` | Filter on `best_price`, rupees | +| `site` | `croma.com` | Only products listed on that platform (domain from `/sites`) | +| `limit` | `48` | 1–200, default 48 | +| `offset` | `0` | Paging offset | + +Sorted by number of platforms (most first), then price. + +```bash +curl "http://31.97.228.132:8000/api/elec/products?category=mobiles&brand=samsung&limit=1" +``` +```json +{ + "total": 6, + "products": [ + { + "product_id": 551, + "brand": "Samsung", + "category": "mobiles", + "display_name": "Samsung Galaxy A56 (8GB RAM, 256GB)", + "ram_gb": 8.0, + "storage_gb": 256.0, + "best_price": "42999.00", + "best_price_site": "Reliance Digital", + "platform_count": 5, + "image_url": "https://img-prd-pim.poorvika.com/product/Samsung-Galaxy-A56-5G-Awesome-Graphite-Main.png" + } + ] +} +``` +`total` is the full match count; use `limit`/`offset` to page. Some extra fields (`brand_slug`, +`family`, `model`, `processor`, `sold_by_tn_retailer`, `updated_at`, ...) are also present. + +### `GET /api/elec/products/{product_id}` +```bash +curl http://31.97.228.132:8000/api/elec/products/551 +``` +Everything from the list item, plus: + +| Field | Content | +|---|---| +| `offers[]` | One per platform listing: `site`, `domain`, `site_region` (`TN` / `national`), `price`, `mrp`, `in_stock`, `source_url`, `source_type`, `listing_title`, `observed_at`, `price_outlier` | +| `canonical_specs` | Normalised specs, e.g. `ram_gb`, `storage_gb`, `display_inch`, `processor`, `battery_mah`, `os` | +| `spec_sources` | Per spec, the page it was read from | +| `images[]` | `url`, `site`, `found_on` (page the image is on), `source_type` | +| `rating` | `{ value, count, sources: [{ site, rating, review_count, source_url }] }`, or `null` | +| `reviews[]` | Up to 10 real customer reviews: `site`, `source_url`, `author`, `rating`, `title`, `body`, `review_date`, `sentiment` (`positive` / `neutral` / `negative`, from the reviewer's own stars) | + +One offer from the response: +```json +{ + "site": "Reliance Digital", + "domain": "reliancedigital.in", + "site_region": "national", + "price": "42999.00", + "mrp": null, + "in_stock": true, + "source_url": "https://www.reliancedigital.in/product/samsung-galaxy-a56-5g-256-gb-8-gb-ram-awesome-olive-mobile-phone-m7x9g6-8968988", + "source_type": "scraped_page", + "observed_at": "2026-09-29T12:54:38.447374+05:30", + "price_outlier": false +} +``` +Notes: +- **`source_type`** says how a value was read: `scraped_page` (the platform's product page), `brand_official` (the brand's site) or `search_snippet` (a search-engine result; Amazon and Flipkart are only read this way). +- **`price_outlier: true`** marks a search-engine price that disagrees with the product-page prices. It is kept for transparency but never used as `best_price`. +- **`in_stock`** is `true`, `false` or `null` (not stated). `best_price` prefers in-stock offers, but a product that is out of stock everywhere still shows its price. +- **Reviews are often empty**: most retailers do not publish review text in a readable form, and none are invented. + +### `GET /api/elec/products/{product_id}/price-history` +```bash +curl http://31.97.228.132:8000/api/elec/products/551/price-history +``` +```json +[ + { "site": "Reliance Digital", "price": "42999.00", "mrp": null, "in_stock": true, + "source_type": "scraped_page", "observed_at": "2026-09-29T12:54:38.447374+05:30" } +] +``` +Oldest first, one row per observation per platform. + +### `GET /api/elec/sites` +```bash +curl http://31.97.228.132:8000/api/elec/sites +``` +Each platform: `name`, `domain`, `kind` (`marketplace`, `national_chain`, `tn_regional`, +`brand_official`), `region`, and how many listings were read from it. + +--- + +## Using it from code + +**Python (`requests`)** +```python +import requests +from decimal import Decimal + +BASE = "http://31.97.228.132:8000" + +r = requests.get(f"{BASE}/api/elec/products", + params={"category": "laptops", "max_price": 60000, "limit": 100}, timeout=30) +r.raise_for_status() +for p in r.json()["products"]: + print(p["display_name"], Decimal(p["best_price"]), "at", p["best_price_site"]) + +detail = requests.get(f"{BASE}/api/elec/products/551", timeout=30).json() +for offer in detail["offers"]: + print(offer["site"], offer["price"], offer["source_url"]) +``` + +**JavaScript (`fetch`)** +```js +const BASE = "http://31.97.228.132:8000"; +const res = await fetch(`${BASE}/api/elec/products?category=mobiles&q=galaxy`); +const { total, products } = await res.json(); +products.forEach(p => console.log(p.display_name, p.best_price, p.best_price_site)); +``` +Browsers only allow calls from the web apps on the API's allowlist +(`app.nearledaily.com`, `catalogue.nearle.ai.in`, `localhost:3100`). Calls from servers, scripts, +curl and MCP clients are not affected. Ask for a new web origin to be added if needed. + +--- + +## MCP (for AI assistants) + +The same catalogue is an MCP server (Streamable HTTP transport) at +**`http://31.97.228.132:8000/mcp/`**. Read-only, no login. + +| Tool | Arguments | Returns | +|---|---|---| +| `list_categories` | none | Categories with product counts | +| `search_products` | `query`, `category`, `brand`, `max_price`, `min_price`, `limit` (all optional, `limit` 1–100, default 20) | `total` and products: id, name, RAM/storage, best price and platform, platform count, image URL | +| `get_product` | `product_id` | Offers per platform, specs, `image_urls`, rating, reviews | +| `price_history` | `product_id` | Every observed price per platform | + +Images are returned as **URLs** on the retailers' own image servers; nothing is re-hosted. Only part +of the catalogue has an image. + +**Claude Code** +```bash +claude mcp add --transport http electronics-catalog http://31.97.228.132:8000/mcp/ +``` + +**Cursor** (`.cursor/mcp.json`) and other clients that take a URL +```json +{ "mcpServers": { "electronics-catalog": { "url": "http://31.97.228.132:8000/mcp/" } } } +``` + +**Python (`fastmcp`)** +```python +import asyncio +from fastmcp import Client + +async def main(): + async with Client("http://31.97.228.132:8000/mcp/") as c: + found = await c.call_tool("search_products", {"category": "laptops", "max_price": 60000}) + print(found.data["total"]) + detail = await c.call_tool("get_product", {"product_id": 551}) + print(detail.data["display_name"], detail.data["image_urls"]) + +asyncio.run(main()) +``` + +Example questions once connected: *"Which laptops under ₹60,000 are sold on the most platforms?"*, +*"Compare Galaxy A56 prices across stores"*, *"Show the price history of product 551."* + +--- + +## Not part of this API + +`/api/auth/*` and `/api/elec/admin/*` (collection runs, platform probes) need an admin login and are +for the catalogue operators only. diff --git a/docs/Electronics Catalog — End_end_Project Documentation.docx b/docs/Electronics Catalog — End_end_Project Documentation.docx new file mode 100644 index 0000000000000000000000000000000000000000..b07e531bff47ac4f053f16252938e3a69b236add GIT binary patch literal 197219 zcmZ5{b8shN*KBOtwr$(q*v`hbZQHi({GyGGjcwb>-S>X=Ro(Ya&6%m1|DK*cQ)l|= zR+0q;LjwW=g8Em)KnyZO+7X9GVIcKhUr%v5|1}qYFt8N5qLMM-X88g)VZX-}C{b^j{z+3U?(O z`~z1G1PBQB-`&8;%*L6K;Xl{f1%M1FGZMtE2DJ0dOj}R{+m(38w%7_JR??a+(|Cy7 z($z+Efn{kLK@Wo9j}Kh)_6&8FMjZ~Bj;0$w){KJBSaN9jo}VMA1g@E)GK&sZ!=PKU z-=P$})QT_F?_;!P?Z55ncF4Yt_CO)l@}Xq8e2 z+rX#n!x4Tp>L|4d?)l}TqJI1MNLnNZ|(Lhuaieq2e+d=<58`bQQan-#)NRT19UVaqA*Uk#1(hck=L1; zlIqdb-J2iI7&F;Td#G3ZOFoRwZ50Du{3b@m1`=%L(d1XO&95ArS{bY2ZQIKt0K-zRY-YDsE@o=PSY@*?;9%6LSAW z=le(YHSB*Q%bgFUSoROmlYfQu{~~MT;P4+{<>RJdM2L|j48%9xTQQLL*o!Q;iq@lN z=!GqY8#{V^)gGI_X8!DDEN3Kl2p>uPowO=11$smy$@BK)yQF}n?%;-FKo()V8E`V2 z?o$a|CuzWP7P&quZEaGE`T}n{h=bY9SQKLK9>rg7E;5QBXgjROZm7&$aRxU5g+rn^ zqD@(;k(HkB#(=(}=(GlEB%hV|3O>1bL}+hTu&y=uWW|26ZbAI-HF+jY0*TN70pb6n z%zxP8ZtrBu_>VHKw*S6S|5?E5lu6RNd-s{+&BSBAo0$&j__HpT z2&{S_nFLhepf&A2nzWrrR^aBO1Z+HAY<=PmS65T{oX_8oF#~=Ph`X(v$J<3EW zQ@^*P{O>cqWTxX$nA*Hr8#fO1fNHUF5%~OCA1=>jzb23s=2VtSbpJM%Be$b`zqiGb z?DLYb4kw2^X|wKR3tG~X`|I~Ec?mv~wqobH8CLE&3X>VP7VJ937HQ{VRJ&TGA-Otx zTN^(8*$gGxJ&t3Z_W9sIsn_T^Ab->$a%T}*UX;#Q_w2*fPQBlMOBQn8B>HX%M|qoY zHyhUbb;&q8L>W*tu=;`ywpY*4evTjWMMeGNw$z+5T>i)j=y(ocE9@LkyfgZ1Gxm%A z6t11G=a7G{$=G!qnNMl>c~X1s$EcGu&Y8qX^M^3{fAl+8J{oxL6Ctq2GHox;=f?K` z_*mDy44dQ)t<2K@UH|LF=RS1v1NZ&WUog7*^VY#+JW*$P$2n?$@qX~)H#Mz!JDv+S zZI(<-$CSWhgmSsWlNX4~4EZH8h9)TU`2vIB`V z^94udjjI!Xr$rhI+R+?IqXY-0Xd+5iV|P7T*J^m-c)5-f+Z7x*kKNG6fM@g50?TD- z*kK|Pgth|B-jeHeuVfLYAc2Qs%atK=ZuI$&AKWeQk8A_rII3GACkWs~W8 z_{xWubtR)9wc~=JsFR>y#9)F%MN`KCsppdP0zpNvkWtJM#DLMvf<8h=Y|AS&pU9Be zP@zIlu$DeZv5YhhX9>993Mwq5?`@*rorV7+k6VvPvp33INMyo*O|NMX;>jG^)T-95 z?gXQqxIPtZb?i>A`9-EyMTx<|sdEdaFBo3j+fILq?^j?@>lvDLn&Lfz7_D{Hdwr7m z=H%strS@0-10+v~B|~RevLBD$J+4UT7d`MC#FwF}4rC^RxbO1W(@TX6W)}BF zv;?7B;%949r7ViG?4J!meZ+b<8fGiwGNVOFudCR#9T_}WX~YZVw$*Qy|7!Ie+KNQr zh0*W5>eVMKf&|D}P&U`?l+jB$jv6u2l36tn$bN)oLOh6}&p`35&h;I|^ODe#4UsES zJf-q31|tqT^AISz>oZ?GBfRK13~TMPyNx{hfW0|Pp_a?TI%!SCbwEPe8$I==(3wOp zu=Fc*te;=Haq@P5@jP1|Nd9PA9D)@Lu{x7_U*wYHp-7Wi$NXw%YwL6+F;T$v7bzBP z6I>}4D;Qx|Lh3J>Zxf~6BD|Hz5d&B?od&+4KSHKif1u5J>hEy)$voqG5#x1&5v_`F zw+yf0zgn?eO+47E;do?JH#ah#Zepgp@qb`o#k7!{g|Esr=%DAPxFxs)qooxGTLQYx zCTg*0BI>Ro;H;}qy9F$yS^p?_@AVWfpEag|_j(B1-jwig*PDsi@>AoRT63Ltj)GgG z4mt5rUzuHU*7At7?xv!>VYAkO^B1%B>)<<5XUK5%Ac^i}gs5TVadAK44l-|>cUf9Y zg^02k{~lXB#m~{V6X@uJ^BGohWc6lKW~tK-p*zDOdV1CFc~So z`|q~OC-5hYwi3!Iw$IqaJo#E zaej}utF5BAlGa(=cQV>jmhIT_(1%TURnEuju${%`y^@ACFbOe^>cw8suZv}azAJNa zj0+jSiHe2ddgotNH}B!8-WJPOb)`A-AyvU~BuXv@;bQB-Ngm!m38!Mjh23{EzI+Qh zeW%*V!GN+*KY@i{HM6*fRF+ae9&fD~84{OrW?ug5}F%ETUy- zbuJpsn%d=6(TW|-x)NlaT+=G6sGIFgaS)(Y`>o>Am+_{lI2 zYo^%QIDn$TL|qORW|?(3`sxP?ai5xi5=Mu39=RVAp=c?z7^j6H(pI5T&Sc(HpsCNY z-=f?^4Ab)PES!uJwo&++3$1>o}CZbBW+_v{{ENkPNLE>!2>tXnieT?F#zJp zMW7c%@QumwiX|=L7XI-)zMoR_g+pJmrvb{eQHWVUy+avR;Bt|owLPA=w}x@)c7l9& zE6g2!P6!cl5T+FnOd;7)I4YRVb^^g63(dR$235VBK69K>yV!5*K^Oe^8z2&kor@~&rKsgA*XK}GxwA6gWT9ybg8mgbvHu6e{3(T z%sI@Ebg&dkSj+;-omGcw>g){KOCRRbsf2T7a8kH>z4-~scbE&u5+8;l34=&RCJ3m! z8Fa*A`Ey5HBU>ntxQQ`j(21S}t%T)B%jD01A_vt`yad#fjY9~dJ^f1I$*F%kHd?;R z>S*M*9MXrzjkC<<^ok>wZsD6Jpu_w@4X&!S$`kwz>=P-lN}9*jqj41c^ro}NE9*4T zbpo2aXTXm&z&*apCT52{vEv?U@B93)sriysJZ@j(-4;Yln`O%(&``*JM_b6<2mHu_ zSuuGmR{Tzo{!3S|Wq88Ol^5S7d%PQ!JfhrZ$v1NJlh{iaTK4HwZW7MVcHfWJ5VvcQ|R6%b8;V+*CU=giDEmaRDgog&*Y3s|#)CSo?elg9Eq zvk6hr3H(p%E|jX!T-<8!V9?SK3xX$LYXlBKb8Wg7AM0#zX_R$o2M6&)sllBQi#VIe zPSa=;DL8uDH_T&NtOPGSbrDRC%M?yBp%l0y=-R=Sh?dSSj^LSRL*s-mjDTeKMj*Z7 z7N7T8yY6fA)DNFZ9P>lCsO{;<{^##=BucvXt0<@C>vW13+AsVWafr^eK}XN}-N)P; z5S{awb^Tgo)~@>8tBwbxk%rQrq9fHnU89sxPD;w~hSo-8B`2Ksb z8!981cRrqLojFD&y$R~t8BR)jM9U@CYb-8~$0|5U_&bzk;-%A0B5Df@fu<8=BlMde zr#S2Sm5wk&by%#&X9Ptr`)BQ6<+De_>+pRl;i2g4DY^JDv&7bx^n z#g~oBr8LB?`isZ?G^gx@^FyBfwOLnt5b->WnJefk)a(Td)_5kH)xKF+bvpui(J+}o zW{U`yp}rubi9Psw<=e&Ys3kU0>QPyPGox;>Zf$;}Z&0jZ#ktmI++3tin{##>cVZbO zNC@v?&8+&c)RNtnGf?0f*E<@x$bz>D|4xnU&`FqP?){<3#l`fZr+R7TTJ`(n)iigK zjIV7mzL<*-61G-gf+hEqq3wvWOZEczaAym0w$t)?wU;z6t1?aon^)z}RQbJ(@f-H5 za9w^~%|i|0T2`{@K1+VfG&^CiV{aF<10-zlAa!G z#{zXS4rJXD%F{A&)L%kk@Ud9O^yGZyIo&j_Hd2(q)b8{RJR$b{4MJc*(jBsgQObLV z+{Iy!hKOPa#HeZF6HL`ZyUfG$X_;zbW*hl47HZ%u((%mIKKqP9`K6+!t&IqdNM1mZ zrX)j<`90Q>ARDrhmdGBAAl2X6Ow^k#vMKRg609?Zvl$!bJDL98) zt#cxNs#u2xke(ys`P$RL{jyM=HsPt8`apcc8vlCapDuq zP-VZ^6E6vvxw<(EmF^=3J3RQ2af}jc7!t0r*FqVQU^WsAAyCOI=rTk3$EYPzA;RDj z34-r0W%*D64m3z!lsS*6zf;GY{m8H8g5xn0F~!GOeT0ON)e#KYYO&z)R2(21g+h`R zs`Mb%EvZQ2(;0Hmslyrle|+6d4kmZL?~ZQz?d@7PZhvrN36qCt?ih(0=4fjZr(@wivA@SN^-5NF?{&;=vdlpJs22x{j_>bckI-v*$R3CISr5r@Cu42J z+YP3kBej-t%iL*^%E07hz5tcXc+CfNJwDy`I)J+O`x0Tz|&ZCuV&h}teAv+|kw@!wv=UH!|6sYChL z8E4at_;KSq+zigZXqOdfxg?<(zjAo;$oPyO@xM4(iyu4}AHI7LX&EUh^fI>nL+zqT zx@vmO!*EG>xsH?$M2P}{*$V-Ze3kjZa8?YKbnWgxhz-!N)vX{T_NWbBZIxb&%vk#Z zR^~*l91ZXPLG~a{CSWFK>K@~|GrRj}Su?sw{8K*3C{9|FEfl7VM)3<=hUt;Quayc^ zTS}Q0wXd75UJ0zt*G7ksRr}MZk~!%PKigtO*U%|QZw8(a30y)AdLHSMW&@==ZpHuA zp|5IW|GBJPiYp$cfe(sX{tBwL`;XDjnfZAI-PXSIz^&S9o+YWhqKbgIM1yuu_Ev-8 zu2x0VE!ANA03bj*vin#08LLOoAKdz!F&g+c0A@(nd`-;xped`nzVKN`}@4%B2b;~ zcmu(B@3x=ruGSAMeL;iZWzB3u+i%#{1IcBBxW3^DY0 z_WX8$?G?pWFZ+-3Rs^38%`K2HoYpBlB_tDY_}A85Odlu>n{M=3&HUnf*x|X@{H(k* zDS0kh2eb)Cu%j!~Z`y7FZmP_bG0Xr@?7t>zNy5#Z`Sj<}QKJS-m5y49l_i?>Nrm-W z2XK6y<8VGg;GrByaeob52{X}3#jq#9eqClWB0+!x8i!YZ=W|O+|EX-~G*#tOFx>=8 z^4)Y=rQy&aQz}YPK&(Aw^EK61k1RCrOimHK_-fdOkH((KfQSr z$r!3~U`EX0y6n+SCvD+~j(sC>R%REhP3w|<5|f_BpsgiDTbWle1IO`lk0eTA=9yA)*d_#ksOyJi|!&DN3_GNQ;V%oeW1Hd5Wv!3h=CUA#M2ipn|c zv!(fp14TC!2T|EO)#tZhlNO`J&5q~iA&bDb$J_Pm>scIPuDZvib;Tx-{bIzi|EEMj zD@MrKLf$6CEBfl0UhKK+x)Y~QYGcl+l|HRoYu0_BQgjV$dJ@dA_OK>zDeLsPnyS8a zR!EO96>?r;g12*LiK+8N`HX}`ar4?&6w4zGF|o;Z!4NF9{Juh{kHR5`%_iMeP}_50 z`rULi=nzezSp>2fGwoZ_bM8KCx+lMX!(`c|vwgz~z>%L^3?P<`mPa2o`}B1;eo>tM zr##&I&!7Bl!rltfPS_3aK8B``&Y|TFD-jPlUEN9Yu$_}JzXHcc=E@JeF?(B~?@i9p zebbl1IN$M;McA-i4RV@#*wa2$&Z%SguXR|QwgtGV!onT{bLJ~zPOvo3nt1}3`(N-4 z5bW@810A7XLw6^-D<6+j+j30M8h|9$lDW76 zsdD}UcBmRQ9d*-@^|Di@Vd4ywgr)8r2eDp_4b-|c1sm+x{u6dd1C{OME#|E4^rf z5Bw1lfr-=3aa1~?$TjWc0$r~!?|GG#nGLPJ`NB2kg3yfLp9^BhOIN*p7ngofXHkp4 zKuhklQhVJ(cmjOGaiYYq4fvSJE-O2pzuXbGxS_%}5`usCkIf8p>n?YP?Y#X*ywOuc zd1e6P&v*VUCcuS4o9O%Vn_ushYV>^%52ii8IKss?rbBnVAUrW6AyZ)=sUfn_iygxa zb|DbTba0Qp5sfIQ0T@(ZRwdr$z!E@GE<=k1soG^@9hOv9!>_o$fl$(mMt?HNhaE!B z4KI`kAqV>_bq;k5Syczzaxs)iM-LI+QXEc5>ebSdOJtH%)F!&Xur0JBVT~s_RU%%#MC$mcoYV^D*rH1H z9blqM$m>A0h}P{9q&wiiMdm=wI)A-23X2>G`ns-Fs zb+q`gXOybebr+xfTG`PuzhMvZTFfMsZ;A)wcZ3J)-hn=JDj5fwe=y0-3LM5h)zrc2 zhju=3|1z`q4aJrczRSYXlkkT&{8OdrZ;z}CXim4`QohoLy~}pSd1`dOPQPFIUk<>6 zC(yc3E208?^3inR&yW#VgqGzF(C0Ag^+bWsKr4D2n7rq^@Pid{UA|(h2FodA{eKg# zOWi|fPVD(C9jU=6wPo#rk-}3*wjXQEA4RfKZ85_B7Pj&>vVEK=QAOxUE? zO>Bcvn=vF-@(ok>O;4z zQy-{*GLXXndi$}KtW}ndnOfXGsiG)wqoeXiO&!L5K(mi3iS_fPHLOt+4U9>iacb~o z;}K2YIu>$Tv^YG>4BL#`^S6NJa;Jr?-C)|ih$hLz(_<{U#LWsHd|s^ZPTBZA?W5lg zU5nqUJT`ZFe(^gqjuB%6Pqpw?Nabh{2g97U(v(oeo^Ghe)`==yx@|CdJ4GIoPGCFE zT=3#HE=uOj3k@x&5E%mhi5Y0vC$~i?YOn~w1xdIh(JUibwLk({NGO42mQJ(G1Hl6D zwKbR<=rOy>8@nMG{G>P!mET7#xU>`DMae^jtL!$XJtrx zuF-_?7?LMACaI|0#IsJ}Fks^uaPaI=9hL}F-8+OR&m^Bn@~9w~E*U%di%c?IRKOtOzSmECYsr zvty5hpc>Usql`8fQ}bdv`Mq!WVBrBqcsK9?rI3+;TDVZ|Fr19cy3_1^W6wx^A^PTH z{`i%2a#Z=!(PBc4{@Zbp;uEaW2!|=^EhR!tgVTTeZX`F$a1L*jgrr0%vK@=n054n& z=DuJ`W{(Kg12*?tW9AlL6rNObf^s}l`*WchcGBqDRdAL|T|qIpd@zpyujlKN{cYmP zIrAdG`#doT@G0ngn9zn&LWDHNPRt<&<1lgGvuF{QL97T`ZbF&AuiCPIB^GrEM@Fj_ zWC~PWTEk!TcY*Y^q2F!#WF+R^+7BOS+=0R6^Fp{G&yuOc{C?PQE#ud0r=8@~R27-Q zm(Y~!zb(qbAIzUffMD85tKGO$qn*oMMV%2x-8~*0{bi0= ziM>C}Pns0)clqbeZ$2f*_HTJ&n5q;RYe`?PPRVzk_Pq*Z znD&Ic9l1h-64C45e3@|~JLkvS?+;JgHQJPAGBZ0jD|d#VAe}0@Nxnv+WF{j8j4g$NXg%y?qjYf6bVeB zn9DwC{KN$#(+UMt5YW(Dn zWXFERD}7aXL$1ib$0S3{)}?=naI59Kk237%clo*B&e3x;bAk@ zLdmJ`TJ$&?CAxVug)1imTGl4V4^HFZ}V2CCsc!ASZA};pqRoBHVb9>27tk2)*XB@fT1nirY+! zx%igA)(AbxFdR&bd*1wDgr3Kb+#)6PN33R{W7$RndN;AV9KpN0d8vl=y^@$CHHqeN zi6O)?;4Hdn=DleTn#@)7Y8A4r)h#^Yg;@kRHnL#M0F(~r>W1{itdL>tbo6Nf3vn*Q zs-LV{M7_G~w&jaw1LqN}XA+C-8I)^wKSrl{*Vi_ykq`7=O{Q<5Q#G_@bj#uf5`tqv zauHReWobSi#)V-pN9r=h(ZUU66>42OiSzvp1t~q5YW!<||5hAGRJYs=eJ4e&c+1Vg zB>kGC(@PT#*H9FM;H+ojIbrf$+|*B!(ks%$Exiq@&o9! zl2sHdI`VTw@+sk{k0r8E8C-KF&qkgH67vBU0FPQ%sNKc>eNMvjx?*R{2($k!g)0xoE@Y~AU zoPj7Br}#X-aWRv)KRaKu?yh5>_baed^CT9<1sKqX5SyRAv#?b%p5Q24)@7WYyB1wc zBB9rYh*7h$`1uHFEgo5bI?h=1^UuB4EH&|ZyC5202`nThz%XigaDFs|hk(=Q0%lJb zsJ@Q5RjuBxi~TXz+N$|Klt*cem(o(}Ur?Jk3X)|X$ji^pIRk;uBHAO(7rS4L)ds@T z(>y#xJ;2wmoDieHPd4(-FgdlLuiDTu9d1j2RgqO|L4HDRfH3rOjUMO4GYsLi(c2cu z^GFDV=qwB^ih)%FkX}%B`qCSBTD1Mi#r;NObZ)S7jkr~Rb9g%WmR#Ywh9EZ%G@cGe z{7Klv&Szltb%6@jZ>qpya+T~cfYRDSEXIZ*cbNni$EuQqXqYS&=f8WXI4!}S%$Mg`Q3;51utM*DfvMQb~_aF%*;~&o9Yk@E)LpW>DyKD zV3U2MmP7Dtz(!KEX@{$@H|})>V4_wO>zX3*@V$Kby8rD48f!iWAcz-17XFW9=VD4M ztT%q+S3At?U(hXGETAbsWomLSK2ZK63}efB$^P(?&1lsEe7~8dPZ8nC z>Mf|)xl?3Zl*V-l>-ax#LObJ1^ht-^|nXhv_TI#nIK#LGWFke)B*5vOMNb>_+m{e9_kX2$VNj zl_9XfR0O@7-gnPtl(P0l_HZq7oq@=(;Ze?0EpQ&(-LH5ZCN$ftAR=h5HZIx|QTYc( z5e7IFeQS8bC3-@FvEt^qY#?{>>O>=dH`w(4u7%Xg=(IAx#;936Rw*Of(-ZrShq`Ds zrOwIwS>^i`ytlQvGtlljHcz^1TnAsj&JlHOPhZ$^7Jbe3YH)SmD_S6kwlijLz%_;lHMx$BmkS>9@T$10;OL ze38sy{|-2!V#AZ{z{+KSi#mwBpluV}1S_3}0CMp1H?K7wz@^1c7~b6OPBQRQAJ8b6 zb&>LnGC(RREy=vVhSv3f^v^qXetsDltBr{C&9WX{=a$ZSI@rOI@T60iBNRsex_=yS zLN-O=xXy zgs;vsCdu1pY7GkU9v;QhOfnqXvO4vo_)sbX<1S(XU;A;Z1-*eD3RS4;yfI2^8bC!k zMpkZQHSlC*E2DH3enu2n50}9mEY_j$D$G5krFJv)N5U1DpCIkWZ?dPBlL0hmNy>pa zT*ST^;51EaTX7j>CfvrAf*)2us?O5=a_7I2*3!#}a+#%TCH7efOm;8yBUdRp;B;(N zUT>nGTaGlYVM`?-<^+|oh&xYDoz8Y@FB$p*M)gW=)=Ir&qG^TTx%cs`Mel(P@zyZ& z4d)>L{T?qh_y#t)AD0ny#U-!y%`xeSfTAAdMM%ma(_zKS;fk)|5|Ih4y8Q~Zf$x0# z9}&9E&EM|p^8w=8OWB~_^C%K}E9nW^gKa+z5(H1?M@;BD2LeHiYm&R25L-nAr5_*K zjk`#tqB$C7zl!_XBvbr!>cFfd9P>QzoDB4G^cs+Tz;x-BHVZ4+B!=`I6T7@I*TRi6 z-RxD;j02P6=9uLPlD)2V6YW&Kx&_`I9Y&b3TpB8q8Re$SZbwF-JTbrrYjgY)gmwu# zYyVq5dIj|^w9GH%u6J*yz#U3&0#1>J3mP`$xTm`=|5Ks-@lGB@-r#%3e?V4SVuyu$ zxhJ&qT$M61LkKuI4wp@2l#gYF4k67s@@Q>|7+wvjFpdj_GTBl6X|^Vbvk?3ST`&d9 z_Kj+^3;gCmw;joff^|Enech^<+&WZRhzl(dypDc^sRu>oFLLfz^?1kKE}?29X2^H9 z#J!Wb_}hAz`j${X;~$&HIe&aH6r=PCW!N}v$2?C{Y98k)Oz>ns&)X*Vb?={R(|UK6 zN$EMZdT^WsfjfNm1hH$~eL-z>gs{_WDCLvkhR8#TV}n^5C5CWK!UJ=I-9l&;iiDKR z8Iv$XxOP%|4nGn*N?6LNyi~z~B%p{T@g^X?KhQ1oJ^uF#aXNV&b60zJ_S@xMovlZ? zRitcHdBeLF0zQ*)UD~d3NJu~Eq+z6--W#hkXEx9J_rgbu$e|+nwV#Kw=5Q@;sqI69 z^BY)dvGO{9Hp*1J63yotYM8(JtL>i`Ui+UJ9`Sz-r_L^(HfGNMv77d$ zZ#bdNrD@>1urWlAsVth*aAYNBd6ks~Mj`AR0)#xM#6vha~<*Q$40 zXe_*6eYncxS|BAkU=#sT!|@D}sk@K`Fbiy2yk0 zWXRc!u(21}aXAh%v}QAE(C70LM0z*a{>`#>Eoajyb1srm7=0=vVjMZXD`fJlYC%6$ zguZ&J5U6@IMh<3(P1|NW*bM22WXN;!U?elm-O>TAqS{e)dNELbmMC|}2m%+UiojV1 z7&D*Hzr$Z2qhxdMMi|#?RV;q6nwawiW&(|vRuP^_y7VV(_uXp zaBmNTfa7^VDJj4CM7XMitL-#y--$*>Z%6JzfQNTf8BzXxdrfv;;pJgrRXKVGd=cT3$1Q8;#L zi2+CB@HQ`v2ZI-~iE)~IUGT}(Dx|TFQ>Q(tw^39*HL+J(nNK=CxDaGSQ2{_CVZ>6>p2n4`+?mzuzC3s);LNPgNCmJ zBxkwItEvkwJaoVW9gu?)a7FOrKPsNvyr(Z3W^O84(Km|Kqk}$`qoI?@!ZsGuet7IU z>4RYWHRMx%#@+pa|GUNW)S$9Z{v|OY{u#LTy2fboUH6D{?p~t8C!PQOju#| z;9BcO@W=fXEf%R9u7X($unq&{X`jJ1kV(yYBMgS&-8FjSV9;8ujU`W21IU9rFYEO+ zCyUV?of{6vY+_b4;s{8O0it5WvNTnAd<@t1D!Z1!;*ptXWRN;VfUPU}>_~P46mjt* z8!l8HYv%@*vKd_sOxnT5uqF!Qp|xZ+JY4wFs!jE(CiNyFZB=E*{L zwG9>Tu<*w97p^bUcKxe6>hnK3srBqpgL8wGKM?8fUnkbR?^JEKIC3BX`DGaTL(ouA z#)28I8NJ!)y&Z!C_LU?^Pb(_Ul+}X^hB-pA+K~7T=H~aWO z%`n&$IzE_w#$5H6+|r^T zC+GqeR#JOCET?$ztG7+tuYvabeTE>*JSEj3O zEQea>blk#}LTiXH)_+gJsKjMip7|eYo+Vp@y{W!FTx7TBpm!N|*OPP}RUF)Bw**r* zcK(||`L%^Ci~pEI`j16;|I48N3HAKH*@XWI_$2<{fKQkK`P7za`~T#7^v!$&GIFOO zSd$Z^&0em=VOB(t5&38QU;7ZvyA7x+6>CsfbxvI&Vb)HVof>XAdN%okl8!xGs9Ci& zl@0qh`i!0E&C|Fslwrv+V4bax#6BJ8&JQ zRYR7A1fv8?4H%z@yo#!iuQ^|d2^;94iER{UO2O5@*`bTIm@pM-ur)KaGGer{ zHL@^cW^k~xxa|I=mZLZ)5Ya8)U(h=m7f9?#@kHzUvHrQ~(2waFUw5MB*%#rh{7cX() z-m|$LBgZ3Jc#XNNN!1=!Yc?~G$-_eqZjfBp6&V|2US6K}?9j)7RRc611EebTZCECr zx~OgI%!kRU(6sp^7zzsuSu7ZfrEqy}Aw=?CLp=}fmOhLpNQ_WoZgO&RadAXMD*~oz zQ@I$G<>{9#m$9%vU88$_2jk~9VtR=hV|{%CP=HD|bI!n%OufM>R$jntu8q(9b9(v? zmBB09EalqNDAL%NM?IAI@Ni6+@AnIAXjEc*hiy7j#SwHiUQ3Vj_l3aJAb*gT5<_BH ziUPsgqHE*)(1o)`E`RE{Rr7*P=*0EMy#u>O;r{1z!Rm`QNobO&q@-l5!p7U1*XoG* zjtj|A$Dgu%iAc8RU8cr>`V#eT(?j8ayUAIUzrxnD0 zTMX+r&}}H@kzxm9s1ECgdk)#7rgL_)>L+jYyaII{n}t3e?8zDqh<^gIyXUKiXX@CA z3r)IIF^Yq@e^HE>kPW|J7QHN_-`l7!lSd86(?6N3Un#Q}UUf48TxJjPqvq}DlWQ{3 zWeP$<17d9bPJvREoePH)L)X`K$#;w;zt2q;G-;Mo`;)aO{7_XHbp(R}l+gz=CUJ2& z&(;Qd-wJR()T?RIX1mZH2h9{X7fA7g(xJsyiXPoNaAOA#U3t_=x^_*5E5A!3z?D>0 zIU8@%5R2ilI78^che!_o2Hg|i;9Mx1v~C88lQvrBzWkZ$D=eNtAAM*c2J`VdTx8y^U=SDr0=$E z(T~&;Tm@uC-yiDO(?)?|(C1G50{cT8!)uIYxa9o0ugRx0Zf2)tX&EH?<^QW6m1LC) z^{#7jB6|Sd?UHZ?ZJuiEnD<612!cn0e@<$A3}>9gppBF*KSWXZ9X|ivo&R6@E&L!t z{&qIXUwjWGgVD8XO|5dZ0Ze5NbKQNer(`?7zFuFX7!=z4J7^of+{uH#?HzP+5&W~y zEa}v>i>A?Ug=48iTevq*->&JFOE0VPh7k!qf8rQ0+=546z+TvjaR=`zB?O)#HN3m? znTzHN#kX*CXTD^W%hkH;%6r_PWeB8dq5j)%l_arl#pV@X)bQ>NG7&}aTk+ozD>QU9 zPT~dkEE_`ND2j{K)(|xGb7Vvlo-Ty1z(5dts^gb)+I@HM^gR_<;vg&=Q(y}B?PDMm zka+hjRI&RZtJ)SqrC=DLobm9Db+&Ea$Oqg%+3Zr7D8%W8rdqJEdxPJ;S^+tQW4GqB z-q7#b2fw|0P#WPvjLoKRD-6K@HfLQC!a)|Ed}p26``-CHtD!)tnuEKwvqMbZIPzJ| z6IBe0Cjmo}w1W87reC3!D3SfzNv^n!5!A@1hU(+^5a2;nAkrj|GX-;Ywd;PX`}a!p z=KCb@r_-b*Hng4rfB`H8+jqf9a2FV(&EtMXXsfNBw^yMZ9aP3VF2`~Z{2T49i0cjcY;5w! zy(DbBT(`W22DWkO1S4exfDjOxzt^e18-s39nuUpRrGLgneg%BPxvEXS$tl0;eATNg zG5X|@-j#sHPH8-H@h;YcH4~X8H3gsbOe=6^uR_Z{_#i@08kxRDTi@A4@}syUu679d}RhFHf%^Z8eSnN#w{mWFEtgV&+ z^L@mLDp?bpUbjF(y-$Ff*N24+XWIt;(ein=65LbG9j#C>AyQ*DSZGoeudu}i&uL8{$7Jn%8nmo z>~9?++0j;T+kRE8Ur(~;={q=2%%Uej15|6Zqj;QKj zl_BEW(e3nDIs%eOLi5#Qk<*B@kI$w1SDt(O-y)t>*%!f!sst8D1yCIs3?GT|vCW$^ ziG`s-zAZ6pSkXMEqzzl?2=_$%!!^vRwXYhaAc+ z8I(b|PF&u7PBHhf$<_7Nr^^jJ0|f{Ta+0`X=onxRmdK}Tj@|l6XRM(-$ zM4Gwg|D%dc+1$aNB^AG1A4O_!zHPG2=V^SoHT*a3J0&8kkKf=Cy*JF?(Tlo1siM)G zu7fg5>{ux7XQpKJj0Y*m@;KI1NUxp#Ek*_OX99cs$nV&<7Zdn592NeP$mZv7HlW2p zjs=1m%nUAptR5a4*-Nfd%wG)@xfFMrgkg5nz-HskJHW&G(6gf z6$dEc>h~YT7R4eAlaaYvBs!3+vYv;ltZr^rAWa=WTs9T2I9Ack1BOmBAf`E{bR! zE(a1Z;)2N&g6~f!b(}T}9CJFhP*YM5UIW_^#89VxYV0!+!9Z=juO=A1ea2jGgi(&h z<2)fi;0R6v(+}>w7|NIIDu^%Jg#Jx4L0Ho4rJ+%muF;JNKHF6<@sMc(*jX zK>5PX_;Qhv*z{vt?yIeKKqgQ+D1dwg(sFZ)kz?w%u1J`qiODi(>jX5V?yx5beO^0(1e2O<-VjYFIX|AdwsW0|q}^RackFZLVwXm%KMp2zRg+UQ%xl1aTVmaMvC+8AI;N^2_oj&BUX+$@ip9M;VSOCOPrL4;bax`ZQUeC__(^sH9D zhpyT8Yh`V>1vdMx3Y%M)gDNY5dpwGGeM5O_<=e~UAUdUVay%zSR0~$WN#58j5F{d9 z9hZ`lW(JJTw)NdTR8$jDDv2ZtA&2Vm7pW>y?D2LhAo31)xf;IQ9-Qkh#SI}HX}!0#*+X{u->yVkkC^*Y7uLBCxgTBKvL>lX^0aKmjIscE)zKZMi_VFM zt*j1lTV#|ribkHy=N$;{$uf)!z6lr@+%=HjV@SmPw?E$5V42#9q?Z z4q?~c<#91)_0s%o6aNn2LO?=NK8X%X!{0^4pd?dIo<%^Ety95XaSIM}k8{4QfcPIS zz&~)r@bh_?ni5B*<#nSP$Ito1E4O2PVIduLaI2<9i?y0-%ZSx{Z^+HT^mwB1AQ5D= zFTjdbORRB*wt^qw$%kZ+iGhEc8a`X5sTlW`Op;-gH>L~lK z$-xK&eiem6ref5jaOw@UDbQvlQIou&?c5@L)Ra5ELX-zstG%~3S@Mwhb!zAA#WYfa1 zIx8W{4WEeRW~(;sbcuar$h-ajxzk3ev_Q$QVOLp(!Ph-YT{5v`tIHalwpNGDFU*Az z!Nle~(4Fu5A^4bIGQ4CS6$&`TWDQgciXfrPmsNhiMRrDhfrNIJ6tU4Aa|)XjDUMIFi~>O1Ve8xBryy5dK|?hpd3KEJ2ACWgwf`K)}yMP9*C) zqhqv$<_|KtoNq1ut`K`eC^zZ7#yIZupUAH>qrfMNaYVf8VMqvEdnn-JdG=^a&%YCR zJXEb!RaM<9BCIcX5=Ip~1wSPEN=V1fH~Zp3DDcOYOfwwSRc{N`p|M9aPG0cc!c zY{*>~3H)bF?Ss(l`g;tP?k}l+cKcF`P5+cz^VWPXW__n2YJfJr{-zUgAf70Alx+JltEE6_Wp22R5PsguS4H!nAcpqpazGCO$qpX zonRD?-o%^!i7@oY#m2Nyjg1lVwJY+7#V~cG@eg;xY>A9TI{X=XaxSAucZ>DBG~A_T z;O=l-8okE(&B3(wnr8}ophlfOX+@*ov-_1=Nax>>*yS9uW?rs?shq0Dlhktas+920 zv|Lo}A+0L$-$fY$GkGkg2p`ERU!7t=Ouut^TuObd>Uw>s2C<6;s`iT z5vJ2_F7Gx$IM5tQVjCl3H4gE`EptujZ2J)Z=TtwtBPs6s|7=Udhm5mpWnug$k_>rw zn3LZrW6b3f4;2~N(-@f^gadhqJKGOI&r6a_00OSsiaIg4Lb8QgHeE3wp(gN1N?iTR z3L&{fG!CQoNa=j5-6}>uF|YgS-Px*$4@$~g1&=wW)gPD`_Pv()en~Q^yz}_+?5|FR z2oZ0HH>HE`$7xni4BkzQpI)JT5|--|)l(`yrGP)JjL24IV-pNth-=6!H=3CZy59Vq zMA%YFZzKxal142HY1RoxBf2|Zr+*jIAsof5h8D@efR94UQC!Rhv91>8u$e`6I^ZGh zLBS%8ygJ{VY86X>T`JctWD?c;>gd(0ElXHyHbioUj6x?aN0X=>);mkcWPOQ(0w1n+ z!#yWMm$BJv%7JSb8Rb!)4Lt~*_4o+nsG~+F;Xm9Ccawt6N#mbi?>FtbcL%?{J{g*z z4im)!np}>Td%J4byF|}wrX^zuh4-retY@&9CB_jCM@+fOdisR=n8o|QJz8Xw)lYHT zuhFPie^#z|;^}fdDs1d6_zH77&{$^|r)S4=c*^e({`6rLgWf#8O54};R0Br`=|JEi zy~`X#uXG9>9-C>q$HkXVHc|uJ_G_B4^=Er~#l+Fl>&7wW^g(t2c~EU)-?=4XkN>MS zs=;EZ9Dxo#tJR-kyvm^bcz1lc;Yh}fbmi!sfi(CEb=JeCz<_|glmg?o+3P! zLXZ1IwWlJIYdxoPhyXHBB0Of&tom8zcMO5Y@f>ZzI%O;al0c$C62Ff- zPcMga1o{`gkyOU4?clIgs2L>5psD$JHA|C=_lMoMCy)941Xkmr`Ua&?SYyt;`QRl& z(OLM2YQgsx6?tw;5Cp2(t8ttCx;4U<8isw8`D7+4J_kYtx-aq{l4EJ4*U;tTieFL` z?#FZAVNKYs+Y-E!6S{RpgeR4L23s$O`%E_M|CaAzx0ou=)_J}=Q~Mg8GE9eOE+MTI zJAQU>5Y9G-9(8%SEedvgSZT2-TESx0Cp=SHJ`}Se2=n3%cy+Csd#j3BOq#mr0k#H8@-=&=N_{R;@~XzOP&&M=YD1nJ;x(DfknmbU8H%lAWUK zAQWo&4q(>YCBh9|WK@vqre zTG`*@%@?OXV97%*%ejrtlIK6nnTA(sM9&Cxn6ko~C@8{knEv zvv!Mh#W>YW>|(&n&aWtb@7vE+2?{Z-QBQY9gJGj5=OAAc)}&y#NnzAa4^LI#Tiom_ z>;;3g+OT$wx%|l`-@S9yPY|fM^Rle27Mf-D{7ZcSeVxe~mU0aS0k3YSf_vI#6X{B! zFf_h?%4jps4t=jwwm97Ox}5gx+Ev%NIvi@?I5v^z@A4!{73Y7&XQlZgwO*d7ESgj! z5ovW+R8snOiWKmrLEdTP(Y~!12vNr(#VtbuC_I#7PqJ4Kr6A4L+_;B z@ZBiKjrcg7HhBRNT}!7Hff>4HkVeY!T8E3r=@MyKgZdJzb-n$o2{ZU~Wxc>q!%rf) z{Lk*wIrOX4x{(&%6)YXJKanXnVP_SFD*1eLFn(6BZ6&EU?j11{oYC51YyIm7r$fX< ztt@VUMq@D#OWs^XvA5)d(K`*_Fjf1f{^UT@O<}7|RDYked6jamXv=XfJ+{hXZ0ujK zE0Mqiy;=Mnlm4PGC12#WTC0g{B5CpGMz@!WCAYPDrRsOVfQfY01o-%(wSry3zPgb; zl6X;nI7XDmtQe|H@AJ$v3_cWM0m4b?M@xf(brZauvc3EhOIc4(PZG#bz8@gC5Eb0* zlVQE-EN*7}#zvk{uKSC=cP3_CNfTV2yz}+0B0pO8c!Vtg7kMa(b& zXtP+`^JXHptzMnuOUIypY3yZDjX6#nS92`Q4F#arPX@EQHAC> z+6!%wsx|`03lA(*@S-Vh(Wzkl+e29Dq&?BKsE^{ny7-u(-}bAms6>R;NokBaVN@hs zFm>fE;-kroIyh?d9_MQa(rUwy$63|}g##o5KU?#O;+pxCLl0wxcfVi@iCfaS+OD?N ztd$XP*$M@#-8qqHDl5D--FCzApBfM8?T@91<_Jr8EY|ALIplFzDT58!PXVW51u;~i z*b*rMPCFshe@06Byus3zM{KrM)4BX$P8_49R7M?8L|DzkNnPKZ-W70oW7WJ>%X%vQUEY!;WDwk<*|RtwvbFpZt7!LiG3>)^K#2Qx*P zTyBRi*Yi&{xeYvC*L$pM0J}v}Lpp}%6`WRLSNEK@!F{pHvdEduP2!X2I*IQ)xTwSV z*^u9@=4bA(m(=jxUkTr@$C-IO&aqZ#ew0VfSE>yP2a>CH&b|({8w{ZfJnUSRurHkK@*vNfX3p!`!+hzDx3MoFq>>NW~ zj-d-jEJgz`%N7A@MFF^ozJBNUkEqRMYWUlkaG2K(+?K_K`!QVM6ArqI<jmMTm%jkDYnlfd8W3*gxD;yy|Az~38#(rNUX~0lA}EQJi^rCwx}UR9`a80&)mgf- zu}*~KK5uc#jMtBR28eTpl7xq1hdFD+Cnrm6Ls~^HlQvx+Y-8KP3j9JJ(*Dd@Y7prG z=%~m#IQs@88){~-I&k-qiEwYvmIBw8e6`zYY9!nXbw1Xcv!2tZN0&qKa8C)-6C4IM zOIn4v2XX2uh4!*g+=Zf6=ZOM=KwH2OQ$ZVYX7N*?1tY3`<<%ta{$L?1(g!wb)0oFk7a zkh3MiJc<%O7erQ@!UHWDsomyqKU_h_UV(rtlY@PsrqCdjlgi`hi;=4=}KXh$w z-v`I)^tW%m)rg-sE#FDAYKR-Yi;zU#n*5H&12Y$z#?P#`Ipi6^Vu+F6m}d%z*P{RI zi?*V~5*Xlu=?s(EZxFJuu&9n;j3Ccg<=`uVUqBq z8Vn2j@{QxC69P$;{lhnkX28pmvy$v)ub&zlrk>OTlAB!2a?Mhw8+|MOvP_d2T8Hk0 zVS6IZNpxL*g4q%$+}-)PVttPQ`2@=xrKu$@lP)kPG`2$##j_Nb-F(8a4(01j{+?Rc zGM2HAZEr8~pF7e1>iW|YjAb{_vi}BJ9cD%D=?Ig4cl(=oGIS*<0+af->*A#TL}Q{h zZ};u>xkv+6P~nMyUQwrBbHdhSCe-tT0Yg$sewXiaP*lWVLnjvWPY(DymNK4-yiwim zxDE9QU*x%`&uKTT$17|+jk(I_3<;O1+CByub}xUfDF-ywx-ZXn63?JU-t#L0v&?d) zur!V9kl*hK3lBY+(Ky&Kf`j4e_BJ`fyV*u=*KW07MbPuIe|420BY;z=jdEMx<#gxd zgg!|MK?x*{){sZT!h&%`n) z=V)M&udlCSMg4O%U*qm>6LDGFTQ|a_pG(Kl#r@Vms=@Q~63JftZF)_3# zNMD-CY#tSTE$wbkCwgxEPkqNsvSPc zeJo@7k^IE2z+knw(W8}?DiX3n1mzxYux^KQM^~3%z?*OR?^9c=*-|0_-*%FZ8Kx8c z;lG1mk@w2QX_bpD*V-FC=6s@-&(7iX;Idhm7^5+3v7TS)@$vW!AO2BR6zNDf#KbY@ z*p*<^Z9`T#a)>eqEsN_btiNUGS)Wj+Kx@DD zz~h1)jY*wL5()FUU#0kR=a-5{+qkfB9!n05(x2#z#^Ja;see<&VFU#?ee}7nm`jVq zqf&Vl62!U-bIBm#F0rn@2=)d%FK`x^tv6Xrr%_lT34d4^)=VzHkMr|_T1L!tLX%J= zmKBUrh%tl$;BX!xH&iY18l52K%-r8(}?gIiMxvwC4D4$+Y58 zIQ$m1Q0bog2hZ30F2Y#CpZirpylz3r?^Fs2VR8E(CS54SBpfv4T)i(D*A1ftV+5f9 zAZKCepT2;l`cpbd^H1|+JZ+=K{RA6KgxhtFUyz({M=0X-68)JPV+}5 zoyF<)kX0by?PcdtMHmb4xnE_-^Yw}HO)izM<>SA|oKcka)NUJTWp2T&AMe;6^m!p* zw)g$F;5`!48#OMoh~D1AAR4lfCjDm1>cD?LuoPNPYQSA$DSZ}D8bUxp!T4z*qz^6f z@8~1fZr%zfhKKRR5(5N6@`*=J=Ly=pBmLW^!`B%#nvo>`pBc-k3;1{*ms#JCOrutp zR=F7CmNJLegNcL$0<%OW4JTpsYIj)B?+J@iI{A8k9QmL6n14X>`85Rv1*I}*?dJwv3R`RM<*7T>-$755v^LGT%wTIE1SVKQda?d(J_877&nGS~FE6*V74T35X8KeXcU=jD zKlpfFGM13_S^au%RHwz7)AMr6^J)jNa0HSQ$Bh88jpQX=K)iRpXyJkTov&U zn(*6b61AS59ww!+R9(;qYh%yG4`vBoYpTaAHh99vb&7WNpz{I`03^G7)+`M(1>fkz4S30ury{YZrg5|Y^EfHslC z>MQzQ><>EaX3KzAFm4bl9sxOqh?Kee-<1}dai6WlYOSI;{^A${ZnEQkv9)$5hqZRn z-AE?APD689lIX|tu8R;H4^HbjgzfCNCIqqfhh22ji+TF53)wk20ncaL6Aqg2L*|+m zj~0rGpv&z+i^E2t^S54vi6%^qt?)aJaH}nR)Ev*$<;6Y=?Q$I zUZYLK=c(8egn%dWdxI6l`2O{Nb9`Ao`)i;-CTBn@gBFIN36x7E8nP|&j_+Yg7M^{%#F^FonW0&X+*WXy~`-^{0cpfWrQH`or~dr6M?QuUq8rxqOeYM7-B~qvRhBgp!!}g5W$Y zC)*g8SxE`Q&#fCix0h4!<#KR)FdVQG$s&5E4EG7h1whcsw7WZ9X7iTpk7nQqVk!^^ zBiQHh`;;26Quvrfhr}~~Po%g8=kR)vDJwUTwu^+RO9&ts?3X+v7z0qr&LXV#Y*(AD z$SoxBwfd-l;%2wNxrXSCgfEhsG({dE62QBh?Cg!cfcIGi!EGHBf*|H!6KsvB0QFLZ zJb|z&1S~AJayoCx4K=v45ju@3^|QyD18aHP#>kBXk6+qGI|HG}hZ0`TcQy5x&f`m3 zK&6I0)i)d>nqYWzoK`z>G7Fd_Hg8FI$+wrs{gXovA)`tmtcvGSxva`Vew9*LTIFIP zgYU0T;Qifm5qK+WidD1Q*h0k_fp5t5tpWl9WX-m;o(_G{|HB2~xx#@@78TB=RTFKO z8xief;8F2I?8!M-gW=IZ%M?ZTsujxEbgCn_l}O(W!d>>qLRVsW0JTMECz7QPj|X{X zx3SXt-JVX%;h`~{xA^~zZ2%&-ms1LQY>891e2vOjM&^b=r&U%yo8Je=sHUupdcdSW z+<;Akxf!M<8}$ms`32s)iC>NB- zqn2${cRxV^(O*f=I1hStl=I)cE(d6z>IdYLsN}}7xS7X@6L@f1T#uB!TJExWTu^wY zPPxeDPFGs0PXB)X=31VXwm6zZ&1}%i5H2pX;q&(Lc)hF1b{7Q_@winn+;12d2{gGM zt=bWV+}tRU!4(t~ltss5gV~Iw!R`9)&ui@9MU8ORLT6F1-nd=ID%ZEF!&Xjk==Brk zj;fP|Nw2fndYLy{ z;Yjw2?im+Dys8!+@d{h{mVn#7TC1t_=xhuw9S@QqvD}(P&{cB;_IC^ct{>(x*Ry_) zx7lc-xMiBlI0DAF`Q9kW9cHSM$O1k?Oyi?DUxQa7)Y5KShr zMub3O>JQ%t2N_KwikBw(>PT`u zn#E-|fJD-sm^Mq^YCB(ix9BcTexoINIm13$ z-%1;LAUtb5-qkmin1ULDI-6~m*-lR47&@y>c8B8(dVM=UVTSQ)RhkXPY$FzR>qJz#GS3!`wmuO?|SVq|)9G+-ykIMcR`gCT=fRFkHe_iP`=xIPS( zAoDfZmu@wNfLpuOmbT8GMyU`+x$bIrSaL=Sj~4W8mi5@6&!3T6mN|!IT|!N<^uD2r z(uBvF>oxxE4}1&)wiuHL(Vh;q%41JQhi?it8Z=5{v^sHJU`t&(FUfB{foXo$f~m z^AUQp{-9<<*ZNtj*ELp-LfY9ayPlX#s}eg;qTlIy^k*!E-YnAj{(K!_3zJF~n2$d? zO0AH)Wq@r+K=L)MsbCkP!^y8u)2tHwU3;h>VT-lF?L_?+Yeda%rZ6-U((BpfV9E$e zNh{}@+YoN)0;77h=0Y8{B$&}PE@X;iPjCsB+I)gs_zA0{pjTimdG6iCQ{|73|6Z&x9q7w|*(|n#2R}Ezlw`FwmNYlc&AM&3Xv(mf?`OL(-I9qg1=an$2A;xc~iXnD~kE^IP(q z9qtqBe8U+VeC&gPK5~99k(I@3ThWQ+0%~#ZeAICvBImbYH`3IJ99|F8cc{w3$c>qc zqCXeb9vn6cC{DcIEs8w*hu)f^rs>>nX0#S%b{QNO!g zu|QA$OmmZ!jd@6^RoI36j(P6#UuS>tjTdHXn+l$qGrcm7F?}SzQLCTn&Sm7e-^cJwbrYXm6NA4F0cZJeA2GhO{;d)j$x&2D$P;7s7ik zzYmw!wJDxq)jTqDk2g4Rx54BW#un)?0T!0)RzGBQG3W_*FCJ_iePuSDT41a67a?Vz z5yJ6!t|Eiof{cgxl+|gc65u>VWYcDA&-j}y)iU3Y~|AmY7fFGK=s5x%$F?hJl3`b0(XgI-GoS~SE38o6@AZH4PC7qvV3{6iU1 zqhl6XCZs?R3T`U-fS3gSDoryf1V0~N$X!1l&)fEN;f)L-_(K5Z&L${__J8TK47)L2^8e`Qr36+E6pBLS$TIchxLxnM5*{@cI5i zA(0~D1(R5qua3!nQPl7GtZj2Hk6Jz(WDrW)Tm_ong~RHShHJrfTVjDW_Oz|YmMD%{ z``W66HsGMx?r3EDtRVPW{OO_mSn%aC_$Utf?xI?&>6`m0&HKyMFtHkS=ojyw3v~u{ z7m**#JfE*`w?n&uyK|&ox7dirBOsJlUqs=SSzcaVs)VdR>#Gyl0FUUceJaYIz4=|~ z*C()^uCD4WQ}Z4p6j(Zi?g(C~O05c~kQuuNnGvziYL@D?s4sqd*aDqc!1kLo&yEh> zC(*mpWz?B2P*7EoWSlW7*rcOGIt7XL-0SA==pn!FWS$^#Ltd|ru;n6DHlLSr4PB>d z{Q2+B3OtOSq64TvvH>L{!CU*&jUECm*B0@SJM!q}T>1r>?5|h7e$Sx6LgIpa%?9Hr z9L7JA7c5@arrl(DX-v&oRPlL|{Z`+&zUcq_c6+ioeJKk$hkC10eyC?zG<>$)ROWH9 zvBY`&tN44;Tj@+dt84}vO{aQ|cEP6PQh#b<2CIq0^WE9}lOYuQ?dh`1?cvNZmw&;! zqM`QBkcICd^@N;SZjJe!_UoP4>pD+Jse%D-JwEr9hkBN6Nn13to7BYg>I}E|`FWu; zydPjlYocFe=m81up^kxu=J0g6>3U-w1&>YBi(;wE<6>=X4dbl%d(v-=yOSQj=esBH z34()c9t;kW)S$Pww}AI8LX-$e&CWFQ!F-hlxBXiGZ*AHbTxR_dyEzY|rKwxzAKVTb zA0(O&rp*lGn{* z^@oCD1DrRoxZdf8?1P6qcfWXO^s8QzN3Y&c*i{4ZgH(>j^NiF)*`hV}^?~H`^CQ)t ze$T@`r<|=f8uWX(VMu0}l@WN}mKc2U_-Wv`2xO@B?B9{yYzJZ*q5M$)| zX0zAN#`~z35U6x0vHp)NXAXhn0{}uvG-`Df@&tOkZp_D18Kbah3-tv(&ezV@yF%R$ zy3febK0;lk!w=$MQp=SNKe*mQWFrU#u~<5<>%GA+ zRLpMARk8Lo*^O?mI)gs{r`w~OgXyl$&Zo!6(Z!tX?BltL*-kf`SOSHQ6AarCgE#Us z;ZW>9#uyF0p$Pa3h3~&>_X*I35Fdj+_-lyI{U7P9kMte{+~t3rVrV}g|92>m4hRJH zc7`G!#1XQ}@+ZUs$;wbq7Ey^8S_)_XhYR4^d+YHD0s=qs6t{W1ySux`-j6dy;X8sT za;#VbT^<)O;sV_E?FK#0;vsou3VFsJyM1jAo4^sH@zlvKkF$v!YK4IiL~$4=50@_g zvrM3@fd30{eQ*DWmWE>6KRpDQ(D+*O=E@6yXX3D@)Bkm)$zpoq?Qxz?6etLvQl$#M zY-cwb(5y}*Au9y_mN`5UJXo3`r&njFQmc|rt6d)on14b&i6!R!W_Enbpw);JX=?t) zwfigTz-GZ7!ziruaYBpz+}m?Ra5yF}onFHpFtgDY(B{9sBAda+ZL=WKyL0kvd|#_) z9agSXMD;zB!)z>Bb2XjSB$Cb`U-Riquk*F#{Ctv^)<qk$qa~^&|M>cedgoB*kEG1qQFz8|55j&lE{AD;Ha) z)vg0XfOSmHwJEnRk2kbQ)Ek{{Dm~73r+6<}3|fuE0WY)$Z!cuJcFT=k;K~$8NXW3o zzAE(|nKXM=;~|W9FPDQi|GyJQx*aYDV;KaT6bk3vI|RxN%KMX9-1h6&9_yXpdbf}L zgf=~Jn->g+vy)B8<5H>NoBP<_p;vdRBaXq7-{!Dc2t}=6k1}=P(msu?o7!=kPpX9JvPy}N7;_vwWHW-dxXV4dxe>jG{7?D&;vQJMYLZMsr ze18##&vUvzo<=j6*vi+{`5xy&aLD57jC8~I;qBFD2>be2?zN3v^8Rm33vXV7p2xYSjzDUJoY30! zFpH5Y*IQ zmM!1?e zYS1>U~IHjS0<4?pAXK9uYHn}>==-g03 zX@ku=xfP+V#`fWK;q^Yk={AfpRqvZ|&wyd}&nR-i7*92h?9Eow%Per@PS%0yKd)S~ zGIL=hevh;BiX>JeR7;}0fs|U=efyMJQ)LdWSYyx&s_y>c$>mSnDUT^JI5y4+~D3~MCy{0h9nkNCu0Y|tK@&pyco#7#Z9 zQv4Fq>UNyTZpQGipC^Uwcd}Y)Qpf_9<+w92c_bm8soVI(oL8w;ZES?M)Oemu?_^xN9<)WqlMQKv<`s;lQmfQ^ zlyli?HG0hs8MJ$8!Yfi!@o0pu8xLX2IZi4%_0G-V;o*PSl4+sCC)-SCmU#7X23RR3c-js!yf>aJkMdiCNcOcAdv<=x-dI z;?D-qB#EVb`aGjy4%HIaL^4W6 z?D42KoPy%FppJR6LIxja!DSZ4uh-uOSJo{aAKIBz{#u^$xw-5NG+T^UbO!iJJW*SC zUTk!f9-Gxw$R+)u9kblWov$$Xs#E6K?sYr4BuNS|dg4it5!JP>yOyrw#BH7}fAqQB zqM2Yjf(|Rf{;u?(ZdW!L^nEy*c>t&ujGWoq$I&S&tygrQ95$6|R({6l8yfB-%=KV8 zx+#wPs`6JnmBnGrdthWFmDgl6RUJ0|+-&)pD@U|fX0}9KF^1~hCvY5X^V*>vwjPbNJi>wOev%t`fuLoTR3!dg%8(znSw5o$=Bnw>tiV#nxi`P zlY~9oSj6lDO243O0*GXOI=dxTl&{KHtcEZaou-YhplkD_(DbP)qk!uCuc`$-?32&F zw?hsqP2Vix+p7Y=YvX-m3L`Ff;obQf{*%ai3W)EJB19U#zvKV2C!B2vmdkepNnPKUCDeBGkkb@7_XJ2 z{R;)4t>4zqVJ)yE=;FgB=2pn{{`hDXG$ent*{Pe%FoN%^Z2-e+wvi*`WqcPX7#-Bt zGMI*<)NRrq8Q=)p&wBjqVaLaTC8tIre!jqRJPFd8Lz806b#i4Ck0Qq`q{RnN?O-0y z^YTZr@%uT}tOCFADfz;hvs$({E;a-1iBjCB5A-cxuO)xm?vG5%&h_a2<{z;23af0cx!r$Idq6q1y?Yp{ z@Z;t;A*=Em3!xh!wDNVNSss@?CiD58M2?cogl?G{_)f5TELo2;W&n0O2Js~wR^a;n zNF>29sw}-~fvvFc=g9cCdled0Z+y6m$PfBOv{)R%7dq^YyMnG~F(`e9FHNK_(ZN@p z3gYkn6%(|#AGiuXB*P_d#3;R_`k@m>)M_^@yW@thZeKG}gTeG$RoDp~oR;NO!lL$_ zi@-8(iGoGw$y$(UjFI_B@LZu>2Bk>3Vn)up&?k&8p&b%+)sIeCR1{2Tmjch?{HPC4 zEc&e}dm%CA;^8=<@(HNg)yBlm+F)q(y(mBV*T{{}ej_{tgXw}G^Q}N9*`#NhbFIE~ z%RgLJOJ!!mbI!r*=5qXAHz=aob@H=B`mOE+)zB^p*=S5vR%5{!J&BGYw_}7IyN-ip zH6_VnnKpVt%a3^aZy%W0ll&7zOy;|{vAFc!`wo1_n~eI5c}&e=0+x+=cp)Wjk2rS) zEM>wrYzPae+P~J>xGp!^v;(gtzqew(^WRlW5xDFQ!M>NQd$SuZ?U)lPU@<1=Gl!2m znJpp~_T&=v_E~(syQ3X6F-JtZH~RQ?_YYV$2xu)7|hG#?wbdvTgx2!A<1+hI*+^sr}KX7qU`{qC|yN-Q|kUhBMj zl~TL+_eUIIIXl|`QxE8 z^vx8GOxopd&*tL#p(TVR#fElCk0ECY zYsjW=Zhze09CF}KMkxJCmO(=PU_NCjO_s&$!!TiGwfEmFz-&kjhV=PPGofUia+ra2 zHZg-;DvDHvgT&eOk-Ux)#a&Q=f|@g-*p)^O1{+D_{=g|+6jT~%A9u@65psX z6`Bsl!X7^>p`civuQW3|5VHv%_zP%_8??_d){OehPm2YH4gcOawVN);Sh{@)j|`(G zR3c%w-&YNaJuhP!pp2~DlKyIv=T!HRm-pJW=6Ibk!C8kE%J8cB^mJoX(3hDK=x|a; zgWnFgp&x?i=xDKe*ZLT;=Hnp_71*^-n)Hm(YUL;Y0?TKq$={&i-@liL%_!dXNZtN4@n!^;p0oM-Xhl(h>A|YWXrea2Hx>m z;%{(Rhd5+CyM3pbERlSR&&=>02bVk<(;1D3#h)_Q;>_AuOS9_8a##F8i^qv z3*Bx#xG+6rCTVs&JxogW`vCxojkb&4w@0%c4(mhU0k$R5T={P5yt9>#h_UcD@6bNl ztBPYdX#Ienp5chuym@a$M&T^Z%|vSD2NyVC{Rhc3$h=bAX^a^>TrTwJl}W&(k&CCc zQ1IbURqv%$Mr?Ad z@s%gR<9ZH~OD!uE(P1#iXlwG+S|AZlF0b|ZF)CNGo>O|xZ|FOPWv{%;Epv<{JXDD+ zC@Ow?slK$oexiq;`$FwdLs zqwk1xJ7yZ#z*(x9$?KwnkXg_26{(~ddyldsH~wBC3}4Xu;PV@Y?xv6LtLw`_k7tJ5 zp#s3C9^ee5;z6oC;~I3qOxI|SOa!XVMSOZi;e%&2UEp(nks}~yn?h&M)V?5rcdVF-9WNR-;{M#g?QTPgW+J!HqF{uIpSDq#mmiHmRgzZUQb zc#loqVznKdClzJ8R;S$I@2VO_F1XzjYV#=iRA)&%8M){Ihr?q~ziOA%wRKJ7PZ7lx z%B@!!tro}8;RtRK?5srdT20jv>+G{r-`rO>wu#Q3Xy%dK%^QY$oGsYXjvPZ{5{glWpQfYPU~8Th#Aev)kC*!l6>f=4mXZ^uGrA{ zxgoDh>lw3Z{l1Mw_qG_`KJia65!U?cUKJ^p+hlEv-7QqT%LF!Q1hI@i%= zc=D@Mah#i3T_tm38e=2lcjp?x5%Tq>qbp46=s>Z*&A>GqN{rKlqS(d%X4KT%t;??! zVLxrU1`&5TB{6AV)S?Os3f{8P%Ee1Z4ARvNug=!x8}}PUZjjnUY^)>#hY~6sulGjt zWs{z+Wc_mV4L7!&KZ|q)79@GgHl!S!M*e}=y#epg5i?OF4n*Q+tF6$JmFD9`jzYmRFH(Ll)6rD*Z^nI= z6WO?_d&!&q03Sy6n){1~+v9or^@e)kPXG1!@+1z;vnHo)Dibc7IR{E#*3{;%FIMK2 zo|oIy6EZPWyDLI_x>>a{3Fj9SHQds#sQ&VL-{ZvoL?>k73KVmWTTdyQwXyT${n8W& z-$~S{P^tGg2WXfxYF2dR2BAli0s>z>KQ-4{)@W8dLCOkF)2vYMKEJu&ri-NfCB=nJ zhQw(zSM!WJT?l)CU}2qppq2i~=W-!YUTdEFW3^lts@M6Jh1T!+3D{VXAzn0a%IWiq z;?vmyolH@}eo`=a{$%!6gLI<8hF+}+d`c11&w7-W0BValm89#9^6^b&2X6fh-R_G_ z%O(e3(98{~Y$8Aj{|&@4uT$40=ri#j13;ep_<6226$>{;87v z`Dh!=8JMPeNo>j}{rZ!}z0pCE2(>~5{TVaF0gvTkNqwrtas#vVl1lY(w}8`@IM8B$M2Tl{GNJd3pGI!Y(^4Ad-I$)kCB5IX^qD72~;@N z!8eSK&^%4-T73+SHjvGV2Lglr%e|*WEQMNNO`g8L=7rVf%iKfHk-bQWhSt`+hE^Cx>?i|;Y znsVL7(|Lp@u0ZaZ#O3(xNkv74MxvuKi3eGZRZMO#PJ8&qB*fM$mTgMha?b$bE+6<> z=eKsPxzE+k<#Zt-#jihs%niz8zqlwqX!yXM_5MmL_q_LbZf;YJdF!g*YOTlEc-&ch zPRi?o_BhDg?tU9~3zC^!dSO)N@c4HxV!H38cZU!pMq`Piby%Hpzu&x5UXl(wTWm+~ z49*W{#|KLEK@6Lzo{OE|6S2M%;xG9zZNAH>CZ0_B{NpJYtASfKapN>0>U^qP_`-KE zMlj_5YN|jUy4j7aOJ2sDApkVqA0w+a>DSm6>4_lmCab6Xkcexw;#W~Kn8Es$RD@EI~=VFVcxi07kOt zz+???sRsZ6lq~&IiyJLz4gD^Gjw_Uza7cna9e1t$YD?%ri`=5~?a}Ob@;7RC zV(XJy3(9l$x851vD|yc9#~FjMoTbd`#)myNXhA|lK&G+8I6C>No9_MmRrPp)&-<4m zTF7ewADgjsz^;IGAgQpH=rwe@P_YKnPUGpmQ(ltOGwllpuZ>2fSAJ{7$Kqt#2$CZ4 z(E@pri31^@qrZpj^A-9LvlAiKuU~h1Xb#N^V=+5zeHlP)ZEYaov8>OCHV#)~)%7w-7&=;$GGrVvq92Rq}N)tzIAwJbAyBi;pg&ZoL zKRx?A-Wg+BJag$I2^|3LH5L|Y5pK%A+9ZSw^tQ`Og3t&g*>SA8kLMAPug#!&h3|^j zJEJ5m!=zRD;Ess_4XBLtbfJ8w%n(wsn)Y&HJIGz1#}6wgk0R@9&nQL=DTUMF6wTV& z8mGu>D+)y~CXVkUY~$i@zox3*9rCGe2K3#v?n^4TeD z;9;v5-JnIbmD^T}^*PkKM`*dme3Sux4_=U3C_nlQ(_LrYP=}zda&21*6aBlu9aGK~Phs;dvns@%+dP_ONTJ9J=>?$ zb@ft~%57$~qKN=xXrW@3e_&dF20wT$CQQg{=i<_&KlaNHmHsP}z8tZB1ExVe+@ThEQlf(+}hch5ht1a z)J(=}u<xsZLs9t!WeBC=BrrLX(L2P!9}GOoZWD&z5XE zZ~SX^vG99nRy>?Co1~YT?dnu%&;xc08ydd+mP#DsS^Ocf+-A*f1%&+_5^VW#3JIV0SQPu}c1nO4(tpWz*|{{mtXitj!QmNn8sMc2?LSwB;yS z?^l+AhT~fVyisCe&4dWk^ScggIBnA%I0!U2l{@bY{FdkUfzAAT4axp_iFU{2r5@ec zr+cKUbdQo%rNREH+sW(ku3WF#B@IA`1#+C&$U&j5w!))}oHrDHhC^?0GnjQ-OqXT` zQ9pe77X!Y(t8V0;8mOT_PH3AE()6!8&(6TqC~w<5%cPOjKyW`l-kpimtrWXx@-l%F zu9GLunyRceng5#wNa70`JtYUQAYV|7(Mb>YhmoQ8`d@z-LgKhNy3J#_JL0rHTdi$% z$Xc7VnE{hC!yeH0&%8W0(&vhl@ksVX5)@LuA3B{d=@&6jwa~QKD1iV7J107D8v3-L=7w;*RObE3?03uJhaJ~uj<%&oRCenn_K8M4p z=402>9^L#Sk5D`s-^aU%aM_ELDx>bc0>h9NzKi)zl#TvB#GKyu^fwhHjW|j3nhP zwTilx?BkJa(1W*FOa|;XK#;EQk&#r`?E~u~S7!z4n zYb_dYUjfuhW~MDh#B=vfQl?R)Bre>Btw=cSa|!Ly>q4u8ZRRW1AP(nSLPV%`NVYpJ zsa!UZ`GY_hl%mP>lIlC*F3Q5?R&S+tsrz!n=?Du>+OPInuURzH(;Y@jDvy(e!8?&0 zIU3nS+r2VgOBWL|95-~k5xOsG)#n({VvQm#t8SQ^CHy#mg@k*W&om%Bi5_^>bNPWr zhPH-JD}E-|jPZMT%w-(6S9WQUYGFm9zXemR0sOr*bMCp=n1J)l0W$PlF@+tqUDncn zIA`xYHu2txz}8sC@)c~&oE96bq7*?Vm;Ps>clV!fN`eI3VYQ|FL$P;d;DAt>5BG zPqXvolFrLiXVp9yfsVvS=Z~v%c^p21@J?bbT^3?+xA8z!r zCdX6dg(j02Qa&2RECF1}Jb-FM$aMk5bcr=xC2p;;aFldnQI1ydeMV1=oC_0K=TUez=_pgh@E2|m5K8J zEO{V7%S<~+5&tpqMH78Zo3r{)<+j!OblGEuDCrhh6=ApRCX5{MT=E*#sKjfdSp;mM zCa8<`a&{F|S@<2ST5Jcq$tJN(0?4r;D8(xIq7*nsT9diGwip}Rbkb~QM>p@#_lBqb zHOyD&7wSJoHu$06DX(~5UNF+K%7|W?4=0vuoVHvMM;A<}UQCa}3l!7*Kl;XBe{|dL zx;yQhuh&K#Qc~qsehzCNr=~$8DpYJnLh>HTD3qT1`?V*DGAlU8BKf}GEgr2WU1+Uwhe`RC4xGUFHs7}8)?eZOn0s?`-)i}|2 zm%cajpV>Z}^vNYUvlBo^ng}K`6Hp;c1F?gD4Xm-&)2BcNw#QUTwSmIKe#xD zCD6*JR;H`CczBTTnc}nzM)wdJ{UXO%G{WV!*Xs_ryonB$z=>S_WFTQd)^Ro%M<;qL zB^%TA^neMX2#R{OFa%!b>hkr5-GsK0lI;^SLebj&5ahr^)4^Dh%o%XPAzWHI%G9{BG4a6mhrIc>S~q1YAAvgfE`6HDC!Ut z6{Wb(vWnA*He8mad}}LEcEZ=3;sv|;3M-j7$D^|iXoPs(qNxv0jYl)DZ=EAxlhh9J z4ei)^I7=JsKE5YGkx_=on7HByAONt&y~4`RoAU_R`Gck0Wb#T8cbgIELfjB}ELZ5} zD3bml3W#1V)dhQH@YF~t%J2KM5dU?W!+huqz3Az3qX(ixFpgFcFGeitOkc?N>0*B? zKREAjrZ`TOR#Cj7L$h+tzk|hMANS1OzSexaKEFHWAg%wu=p0%cI?)*QwobFN`r0S! z){51Z{wNB+qnSb2=HkIIItGRw#9n?lv0UQsCG3{bH7o_-poj&x+)miXkGwluJoS2T zKIS_8*p7!Q@xBc?6HG7Xeu_bEy~%I4f>fiN&g0Y{k#&D_IPG!1HbfVq90(>Cg!u=B zOPH$Ui1xOFpsq&?FH_f)TGLQMPMwMU?BOJq)n*rdi!nV;>lyqTHzU?R&g5}Iey>Hu z#baKY*0~ecmzT|+m!iMlyo;liXBZ$aR>|kWFqZn_UpaOV!{Bz*4!YB8aaE4&OyO~g zl}llFJDV!^y8V>%5ggbh9nmf$E*|sx{C6R^^M9^48Q8oa_YBiI8KnJx0CE5wi1)WS{p9DxyRXxq)~7M;Qrx3AL4tu zSZi@a@B}>1Ou9cJ7xV&G`aa&3KV74f@o=GGVDvPu-(Ijip20!Ti?ddbbMY^D#8`|{ zLYyyd1Jsd!)7+LvGlS>r5Z!9$i2DX6!;X`wUUWF}lTM2(F|P0b0_!5JhUXyIC+pni zBliNp>#$06k+B%+5Dv33Tlc7wel%F zcorrF*I(gIe`={n!v82Hv0F$#3K^-4r8oBw@jKeyz-}4$m8q|GhtCDP$_`tPxy$%{ zPY&XXr)y0I8K~k=1j_LL`=~G9_K5J2XXg_z2#v^o>TgOukR0IEWYpb)M!=97fuWCe z0s&r21_Vc#zVf^7!yK`7=Y6T0JWjLrh9^b)k@I`)JUzI)Lnnk;zZT$fKZ^h1wB1(@ z)35Id{U8-iDE|}!uQ$CsU2cTkQbhAOeRwIYWwNEv_$UDCVc{)(b_| z_j?PV4bRopsTL518@CcENyn%ykxU;=LFKusrHZ~=#-Bb9$@G!b^ z^rAwY+iRU&y1ezh}ba#{aZE~Q5J zxBn631C5OP>0F+4OfCh$Tlfp>Nnp*@)lHq+F~)@UPpc5$r~B6kTIuM2iM!dK@_cg- z#4Q&+<${Q0kt6l<3x9T3KVVY;>aIl86x3`%+-x&a5fPxCd1!ApGB#7Y&9)JNfw}^A zhS~j5)eQZMKLE>o<8T>zcbfM5EsXk~{Zbh)oUnW+{OrtLmJm_A)F}~8cs$cATcTEc zOYy&8u6RcCk>mtMO}qIHpE9lL-Bu9vBvS|xbvSJSgIpp^&PaQ{cz#pP6}MZeE7i*{ z(Lw3jpa~};6Z8_7{HtAK+Do)MmeGGw0`P(154U;s5=0z+{ARqvs#GNvmH8u%Htu{Z zMeuqAVO!^JZ(29&wjq>PjL$i_O zYN_!xUgw?YYXPsDi~hBCKKG+o4vVqHT3<2zcwU#p;%Rd*V*P74&eEU|Iswa6xh^#R ztLh=-9mw=}j=5%>#$rrQ=;q8sD*}^buH2x$MzeBF9a36gAmF@nF|uAj9i}?pZQ@a; z)4bB#E5U-sf7WU}o5qFNVF;ftR2)s^QlaSaOa5mMcV4uLzX(F#H-s%t(m3sg9UICL zg1pvxU00f%7&XdfI>hLJtEv3U@Zj4c?g_v7c94PJ2%PL)(4khfF{ky+UzypGhBDKG$WO;0aUY!J zCZ}#bU}V?BU!y$9dh6LqKM*)10R_T9=k`A^-@<%2AxreWQMwMJTG2$Yi>5XO#$1_p zz3=K$gWWQ><$^Q<7K+zjD4(I%s<1ki(Teu6kYBwpiXnU3O-?2_?bHIQll(gEc^Oi< zvtU~1)s}T4R)ejpU0JyA!;h@c)n=2&|3RO`-SNx2NqL>m7He%TvJEG}^Q?&HfXD5= zNJ&OQOyi!9(vhPn9LM+7dDg+e>%FtpRyvyoTQ^9hW{0RmAd87))Kt_=`R6wjrvh6` zvy2kq1eKbVzMEYVdaQ=2Y`*EYJu^>0muj~J2F+rPa<4G_uQ*ZisXPYl;b#G%czw;y z`r~nQtQwyUpP#O7%d~5a?D8ZzUEW_n{RC|nHl_;|s5JFzHBOy(hag_NA?yOaPsvsP z3o61#an3X_Fj$}uB!vN$#>Vm_vm2>Q3gonFOk-G|YgO&3eg8qXY8MhI#oi10J?r`4 z#5}aP*}QrC7H3(rQdyO8B*x*Cf}TV|(Db5`-{TBSF8IkEZE6F&&s}D?e5g)SRD}2C7H>zLWP}d*gJLF6 zBo>hoN7-M#f)Wili^59N$b)v}IcNkID_&FHh|IGpl6jJa>nWKo4xE%lwTZ^?8I0r2Hu=#(#11<`a z3io4a{HRJq=2)zz{yLMJW=(jn*Mf+)fb~y8M9jkGsO1(9bbRlEv4{gd0vtM?q$hGH z!+&wxu;k z-RV5-cg51kYRfeJc)7#ve^?!hXF8_!4j&RKG*bwjVoHHIf0Z8vpFHdau8o-(%$daw zqyhkjfO5}I?{2a(I4lZC`=0hj(*`Q&6w*zncD^2!1U~2>z*W;P0MA!urK*i+%5Jlk z^Vw2O_#@mKzErQtn=m?&bM;CI+{R zS;_Csn>SvF1)_F=s{|1U*1zx~-hw2xk;k|C0&DWATsDg}_1+S5Q^l%uqiGP71UwQ( zR$a@5s_N&OchjsA!R`|ql+MWS-n$>d#iC65BdLNSy~|L3Tzsqd#EmQnjVqx2@j-xx zbnC8Im5zF3xz0)~j&^jyCt3h_MRI2EsqfC5!S8{kk9vQ-vRHe%>=8fQ?A1#k$dnj~ zqO0{)kQDzO8_t8m>37I2?01XIzN4l7x5+6i+U>m$kiKuV)kD3~K$YAc9B0_!9a`4P z*D~20EXLlW-W6(gG(&tx&dGI#n~wAz^HXj`c4tUYEIHCl003ZZisovReNK>;MJul(A})Tj{trXI zXLzn$-(_cD^UXW7JBka&Pw=Z=*(4S!s&b|3*s!(N%qHJE$1`~>7d{`F8xPawLWm z8N5Ph<6BuUMz0AUecE9RMSbEqqMafe)aO!H3H(d-#V}X1YUAFZyC#QP zbnnMI<~dTQ@US(B3(m$LrD|}j7}S=Jfpd$$4PM<+(OBM5uHp7%UI_SXM<9vCP*g+j{cnwu zT!MzAO!7Cf{{fcNEtGzfSSPs*ySz~#<7vz;N^7(%1|;ygrRvllgQGuZQH167^$ErT zeBQ(1tOuj`os(CH3a*;VD%b~gtW|gUv}Xi0xk|oFg!=6~ip!1_o__8T!OiE5^-r8O zFU>Fn#($uHC9+M}Bz4Gqg?|Cc<@S$$|z~+Ww=g(r9)tXRh&U_x9luBHQ zd#r-c2qi=O7iT4{(XWSh*goxo^}`97&8^IP9j!bt$`{FQ6#Yc>?9{F`eSNLp3)%4* z=w0V!P@4leGFI&2ES4b(5<1HD%EhDg%e`3n8sicWxpl-A`3nzwJ?Ttt9TNxoYK z@+zksKe?d(xh=5_JA|k=JN%fU&JH^B#ypCDFTBOG`h%atnyFiaC~#(> zNlGm$^b7U1q6mUtV!>0(CzOGR-`y72=ugEj<^SxH7ORqDEa5s$D&$)hN_e-eJK^U4 zw%K%0<)!l{6!3zc0Q|7V@L#Svv*cwIWB9SR5Hq35D<}xj^gS}<{T~1I3wXwJ^@m(0 ze#Uq0i`|UMdWYYg0U`EFO`8Xk)Ygtnb-zG22UeENqHUg+w2CPmM%^u{bczFMxs&O3 zD@|W@N`nD!mJ?VFUw3xHZjZx~=zlwid%2vZ&MxuAbMQkjZVe&6r+e^Un-3~Zxw(4QkA3Y7PyyuO5<@djvIgPp*S$PYHGmZ^ugpe-4Ej@ z$>X(w-N1h84zvE~d=WJNx{rf6eLLft+1l5H&am41Hm7b_QU~j4*xGA2ze!)@`W199 zjxKUIXKeB|poAr^)T`1EnW*)!k9HbuZag#QsXEI1nG3$vi%zBX+!!wJPT~Ho%h|9T z2aO1|L*NlsZ?o=ah*(*yJ)mXO`F_6@)9kXpp@?*$Sr@IMuWa1TpkoX={Q6vJNUbM6 zAC<7ZRUhP)FcUn9C+ag3Xci^SMXrIXvWuZ4Hj(GC+4Ue5MPBeD?(XTqh1IabcC9Uc zv0C7zm#B{M@{cw5F4L;^J-MEaAH<`RC*^iuL4voJvEMycE3V5VlxXRQb|Ilsgdw97 zJ{LpEX382J);}3s&M#G>exQx_qw*E|7mTPdXm*Cf>KQflqbT@cXQ#_K-IB>HnB{k8 zt04EYmD`h)X>t$2NHW11^U;!z-%LKc;^xFsiuGL)Fpvv6-}kSzM`wxqvv}g1v#eqa zT1Zqh+){R!STnUh_L)laBIY}BMMX=a=;QczSNTnhI_|I|VIOGZ0{wOumXiCULvH5x zs`cCMFL4jGfpQtAruNe(E)bk^LEk5&40iL7nZ#5sJKvuO08tWr)@Fu{|EQHfI(qL@ zJP8>~zalB<gGmA}%w(5Q#R#IMJNR?3eMtY+Wfvm(@O^2y2u=8)O>O6LLwwX(oh z&jT^t%sO>T5C>LKXewz(BpCc`vfv=U&h$6f3$Xz04JYaj#+r+QWl~EiJRKh$S?|^^ z)>!mIl2{EtP&QYPeAjL9;^cW6DCzwu&D_#rkXU~0{cuhF$HSdGJh*0&X1xulYET=N z4Ef(o$63Z|6tbrK*+Qhjuk#Z1@#JrDD3SXd+1uF-yJ^n2??0D%pB@o_C?d-T4vyC= z^>7jR#Wd{io7`u$lI`aM@G^Ow_h#mpo1`oC9_Ay(*e_tWel!xh7y7~XBJY*2n7_kt zUju+wT7VHIrm4d5Untjom{4TwGme+%_5%-91r`~<$rhBi)|S=Lmi=f@ z9~(R8oov1J?BPOjIBD&_x{?91u=Iw_+hhCA$^ny3LJA?@9?=QhxndV0B29ZRXzJ;Q zKR+5ffELxg(2g)lD2_&^3l)QQF?(zaBz$&{2ykPSNI8wxsbiT#)C1LcYh@Cdc6WAM zcP@->sI$Wh)rzN3%kG?TaB+P=AOB5rqvS+mgfM87&E(r~GY3=b96yS7=r%dV^n}Kd z@_G-fuFCr$m2VmdwsPC683KMN(C*Kd8RSDiPn!YA@}Ia5=57tCznD zK3CM!h7pcJMc$}SIS_dw`Ax+vKnhts=yYi|S$jPm8|9w<8I@mbe_}J>aH{Zp@j3*T zI-@@_jm>$-@_2k*bpH(>04Ki){W1T8?NC?nhF-o!oPrPK)!E<_tOq10b3BqVsN0gX+z1sG%&OR0OONy|C6CGZJ`r zvX;*h`47oG%nN-;v*@#b$kMZz0R~fD;6vrxM-!4+w4Nvm(vKe{aXExF!@Y-~J*eA; zM-`YI9^>fEN^)wY8e&@N9p3Md9-!yzNj8?~;-LfHksrUQ`Q1wW>)-g+w-b%*wUD#0 zT{KI=Ri?^n9CfnPOs!F-)f>M22?OGXAg8Q*{^S<8b%^0KjC}fsaUJx`lw!c6$osNpLTSzN&}^{$|c6GCY>|yg7(@4oF}? z&cfRpPb-C(7_WD<7jba-rLoivBTxJY$?fARA#Q7@uv=@Jj9%da6Lai!w1Z9rt-Cs; zzUX3=q7*xu=ak7Lv4jV)YG2VsYIjecgo44(=$@#8KHC#rpv$M;0(p{PEMfusC#1kL z%Rlb_-$VhfJ!O7?BqjUomGR>rVve~|O~2zg<^W`bPPGcn7oFAB3JN|~9C^vxKisJD z>Vek#;~BC^EbRyqzwbS`(RG%S3^DHcmi0%R?x#z?37?MVE3fxPkKqfC3)OiP0B_6L zl8D2S|6iPgXwQ30F(6#v?~k~<`|3Q)lRoD#8pF^JoHAECU(xXCe_A!U?F9Z8Bar>S zF@jaJLk?4V2m1@}0Plw@Y?#|C8Fqse&jHyiKDQ6wB&C?gZQpU#y6jhQ6eyDM8ZUUP ztII}z&Us=oIG-sFPe-=uV>cVhDGD|CP?iwVpHU#6R;g7j$IKuVNh&>^ z0r0Wj_+>aT*UzYhwdMHb_#!bKxxQ=n-|y@Farg|hzs4*e%ME738v6lR2xyT-RE1VI1~K~znmb-60W2ykiJnr_ z|12TP7v?lOM6f%ouH<3xFIVDP9$7{hOrLep=)E|F+Z~eYoa?bYl^>`9 zzX>R^ZKg@7H7z#C+glTCeY`u=ZOG0atrslqPA)LRkt8Yrw;Ai)jCaQ&UqO1Ri-|9=hLnkaz&YRm53t_vy z*c4O3R&M|1b8~?EB{1!YW5ui0oooWL&UaNuEsb>1r2}z6zN}lUvTH*-e|SJi-`jQi z--XSnizd&iSCUS{Q#LW}u`^&qnzO+9pSQf=*8)C|I*nH0J5!t8AvoQN$voPTIs)b= z_v)n@w(A|32-H)9%Rc#3s_!aUq9ottmt1w9*O;Vy#J;33gYM2dO6^gMulHG%O5HPh z{aD1)bS>ZVbK0+{3DN@$+C0bd`dM`99M?Mp!8<{B-%Z3zV043wmOrpb0M5NvW+<-$ zxu`T7Y!@Hz;Js_DdAC*Ulk{1BeUr6Pz)=N6NvqK+3Gpp-qBij4G|3M>FJqf z;_zcFgU8T)WLz$yjmE~P&sLh5rH(WL#t(bL2tcqu+Q)O{ypBI1rN?GKJfz&wR8AYs zN^(({zUa7gz4a`v53@lV3Q3Ux^y-Q|L)#sL({h5|>*k}h6_qMO9ZHqsCY9F1LbXYn zFOs@F>`#hp(zDy$$RRq>7RDa6KNxf_z6UE-oGdQ))beK0iMf zDrU$f0os{2jiG0P)A`-sZW~Dx$VwxA)%rX}!n}e~Q>ZNzJh&Tkd!ISk_!FaE0i+_+ zjha78pjF?X5itB&syvBd3!r~35&WAEH$ReF^{RB6y&GI+y}iwmcd zK)ubV5I1sy#mfr+>t6weeUXQmQMB5vE;l-ytwSnT2W>AuX(Az54cdgKSJ={doNcc6 z5x+0;4B+oYTQ%;s`(J(eIf@k&ae^27a;adVn4~uJ{|wRF30q#b;M*=;7zJ$ih8yOY zkNzQMN40A=fcUnXJaU<$#MG0pIYuW$AtNDq0)e#hsWW}AmkSlM!fX-o#nUk^V$Tm} z`q*El;^-7vP69c_jf@MEBQOKqN&Fj(2R~?&;T#Xf^($ry;4WR~tNvv(9IeXK!l(Ln zTUAkYaS047@pl|sQ{ELD9UmX>@28VZWNN_Zfj`3^LD05X8rjS*KMtfeqymd)chnLq zHmcrzIMGrlkV~=kyMZVD)I9uJ#IY7d{toHA=}7Y6R1=idu*3Uq1UaNipX6gp9It>+ zN&c22xW~{_eGqhqVa<3h8BREQC(gGu$cAyA*eq;$Ga{_e!7l+26NMx18;@a*||{ z$xPVLB3LGQ=vz-HQXVRQ*-*b6Kc3~H%pU%gH*Vyqjfe}wlv{QV)S zbsxih(`v=_NTA%pKO$gIgyVB8gL&JY8~=!c$F1i}-e1_=9I$L#;wE5tdn0es=7~#X zQbE1Bp08)-kVZ@V9e3Pvv4TGu$!foNT0I`jKic(FVj&gNc@k*I$QR8!f8`iKz#1*C zYSIwNCjp(-u>hKYPkzPlfd@KSZ^S~K?_>Klf2BUdf3$a;3who_W8$%zM&NU#k%D#O zX@P~4u^a|HeJRcEv$q_Q1%D-ohV~P)&ofsRKgDr7)UsQ+z4A*?z+Pbey4q#Lp_XsY z$st`FE%x^I{yXDJ1(z>N3H0g{`as*&hn8zBfH;6|WPdUnMkxpp8M)cz_T_kynI)6g zImGFZjgO}v=kFW<_Ftcy?$6!%-LF2(B4AR9+z<kk8=O zoGGA9pj5yX@Id}6#eUNO4ejN1cfx{k&a5YxTwNfW$mDtF6+nZb|3M*cC<0gT{(Nm= z13AWaB84S0ZxDnh@efUb7CszOuqRX@8*)AZ0h3BTn@lTDTna{MxBEjDkVr^KFS|I# z%Zu3i=|6v^x6X8)ghCw9IsC`r_Mid{2#5)Lf#v@A*zxeNB_`ZG*sTB&aQg!^#5qJH z;B^od=5)w2=X2E3?HkB1XNn{0Iv92!^M5vk?Ul+gizxZZHEXw^_$ zp=>JE*NWc@`(XrJf?%TylpF+C)eKEbNM)0y-}0~;BOAbiFR_=uJ@W7$OSu(ofHBP| ze+z9oAr|52bbelQF(=Fk-g%(Ihn62S`det94x-89nI_ag-Y zS16>?dY-2Rc((TMMbQCqHve;BWPX^Giy1y=;_g_;p>j@49{k0WOt=rUJ ze$Xh*7!ND=M-k|uOHd(i(qcS5Y`qepHSJGk{Re20#4nLHminEQS|JV&%rhYt^09wj zP~#zD{8nEutV}5v5Bz$<&22OQ0;v3pG=U%iS9_hUrQ=R!3wqvB&>UduM&ff=-km%q z6n|m-EB=K=yLxNy`!J|nNu?P~MM^&A0a7zXudYTbwH)4!Y$JW?{>-q-l-Y~~`BLg{2iPr-l840N0C zdvx+Xqpb1R4|4A(hJP?0tS$S_$Lk$@DMRs;>S^_<&`NzZ22bs}b{UNJSPjBzq;(h8rK$9M?r<=o&XA@Zpxg=Jx0qcLhQ+XtV$1JaJ_>;r+ zfQfF8EC2+-FY)nQQa4)=<}(;#G6DHdLyXbXI{mp4B`8QpVxdlE5D*^9E&S8^`mg=Y z`a@R9D15npwi!bf!@e&GROD2|o@{=_@Vy_hfCDQ{ZzO{4z2a%?c>giX)KCvxcDlYV z4coJqI+9)Cy4-Uc^xMcCWm#LK}@w+=7XFpwR^{#h# zR_k_vIZ%Fne%dXLWKWxAfTmao@GPCc)MebIAv%}7x(d25yT+uL0Y8=4+ z;cPJhm#uirJXpwbFglY~r3kD@L8}aX3_fSjQ8McEKC)4i-cwqpuK+#KpqGBL5ue^ZFDvw4VZtnMkKbr&jhOr`qK9 zXkJ%WH^qFT)B9+y6y@CIba}qNUm}*=?d9PzgXeo}&EU=HN)w;+5f+D~s)3Md7`bG$ zhKK7^j>rKO2!k^Gq4nkUwdD~h=HFO=@9V941_g?le5q3YXKhugI-Vxl7No0K5H!{jxgP97Kb|GvX*pj;*|X9Yh6x1;nUR^dgZ49)Dz9$A00bYfC>V(fVsP2UGHxMQnyfWsHC!Vy6w$V+myRA<35)C z;n=JmF$7#^*}iXK{4Z;5u5rUL*z`EM?JlQjPx=W|axRu0cPF%!51*wP-pIN#DOZvoraN zcB{<}M-${3GG^mx;LDq4`x980BAHo_nAZs&bBN0Oe$Dl&|KV~6jfl5iBoJ0~bh62A zWBn%3Y$78LGn3Et^Rt?Kll9Wo(R?|Z*+k#XdWYvqy+t5`@s&abw|`hXrOb%P$!`BO zD+l=YLyD>0K{!;xlCGY+KjZ22>Q&;|qCG(f5jc$D^PfB!{UBN~7OM4pAFoU(Ou+)R@wwohnjn>^qDyu3Z1*VtNex?i?yn+ZR*dq3k~4)p+OK$gFf+s-lD z-=D2L-<`I(p6jNuTcFFZTmSy#w0OB(TS&v?g&Y3*bbCyzQdFT_D4ogcoF~ckUAbq0 zQK$80B$57ny<;?6$akwJxcqCSrHg#7b3=u02Yzr8wL;d_-l$Pc($}lQ+2ROX<^l1Y zfv6f1fXrk%7g`&^UZh}}y^gSo5NWemy@N>QTPTt{{H?~5)=v3y=v9E9SoYa8XN2TliAfeZ8&=V(Trmi z)x_M}h*&iCXRAE!Ex(w%UNG29WySsAVQM0jLib=87LLmT%<=Sgx3{;?&tvdfUC)`? zX2wK2@d=_)e$|ATfJt~K_8~5evsjHXAz?AV; z>$z-hiapxB*XMg~B-5h#a-Sxl4JkmtHut5jtdIpdV6AP7Ea%Sf9Wr94%4C4X zgBZDKXqOWfxncFVF|R8x6Kpo#p4}a$sdI#f!UjnNI^>-3~!{fRcO=IzoQn@XCtT ziwfI3tb95rN(a;vet>%0F$iCR` zVr_(RWHVXtt*VBrb3<|DgE1sA3S_OqcO06v#zohgIt<#M#m$q;83-Yv`o8sW!WUc3 zmlfu@dL<(-$B+o=R(|8a{@K#n>Qk*Gh8*M}1K2A&AUckm0$}1YX$yZMuSSiH{q1h3EsJg#lhixefxnh4G&$MB}x%{LeSh1sxvZggte?0^zhCv6QP{ zpKcSTJ+F3$_gcw5h;fI0)Cx-Nq=++=izW4+vQ_;oHDV=u6WcBMks;qzC4ov#6y;Y@ z=|~JTHz(dSUs)2q&P#778t8_ha`J4g?Gn3|b$2|St4~{?62bO>>2nRmP%*gfWR1;bcF6-K|(*rB9tkwFuNhisVt1aIpKc91}PGcIo5IY@P zKf{5&qCEJ%4_R-R=yxSp?2dA#`ywOq{S@h(Hlis(*3|eTU-ZaDs4GOzP^UX(8_B1x z-b1}$c#J8Mg74rRpcD2dGR029alS`>WI<;o+cV(|>Sm2Q>=BQrkcyR-m_a+WcE|Ob z>XBg}IdtvQzoI>e!sqBghHn8xQ^}=aRaa;=olWOUijEE%nyNt_V8xaf&zw(8Og#Kx z$XIEz+i62z8KQ3$5D-uc0F*l^23|~B^Zafug!kAhT!UA-=+H3pifDhvP4{E3*2eGg zSyDmgven*gy<8}iNxnNbURcesW6gocJLo%Z{a28tOUDjol%xvk&}8qxS(uyK3M ztlf;BiBE%A!Lu$=Vj-i0{p}0Gvo^YEz6!#%zG+|7!IJy^S>_x4PeGkFm&8zNy1L|K zT)LGG&wG4MYmmBPJjmiyr-;(>ffuh8m;S4)i&sukS>{K?1I(~{G*xT&ibAOb)3enU z)BQy|Ru8S8!zHJ`tqxkymnGh=_b0i$`kW*`nkuRrkg{@Vzk`MG<4~a7p6&$PEOwYX_x5svfoQ*^mhEFNdzgslbPcGA` zkS*w_ZR_Ma^b|-~`oz$KS0{N%td-j}CuxaF*=}@psFi;` z8UYX-mz}hrfAjm+XswxR5;DC<#tgqGMiO))(=(Ev^V46HPj>F%w2Zp{&Ll1Pr=|85v7uGy9D1a{Xr<#1=QA66U(g2^bf2zk;@qHK5*=nTN^- z=nY?96ojIYj378+k_NDPDXw`}b_uHs^hkfM6Sevdwarxk5ho>v6=Wxlqtj`1=wc#3 zF>I0%Y0_x5DF2@1MU-m8fM*4of@F!Af)oXWFiLDP!kzJY+|YQy#qNEE&pnH{yS+`s zJc{?^C`eLOFlvQ7Fq6vQu6a@sh-B=uDp2x!Y8weoLd)i|%bHqqH=qlJ)gkzzm(AxY zk7`$RmN)pdFAP&MD4b3zNH?VT$CiS-iU1++S zPM4nm(9<=+sY(QEtET+nS8F8TN;dCNnp^k-FxZ zIINgYJ#{(mAp*z1unXv&zA#Ka=Oc>hhX$f;W$gWuhXvvOCXb~$vt&lcf>O`>Go6Aj z0C7R{rBT;gDA#;-NIW}&gx#X4)nv?rpHpa;h175|%!)aFWS8Y;i%EtdZ?Dgz1-2mi zB9g^5M46qLQ20~>u?76N76T<s{5GZvW9F#spmHAab>M5VlFUni5w{`a&wRw;5|$ zMx^R=0~tw92~%$Jq8&lXI9aHM`ejnPBFRd0C+w@%Z(*m`0~xJTqF2Rt*pgOmQyaY^ z%_B%KO6X$}X?-*@#TMALGZ1LOk2poZ)*gmFxX{*RM{#^0nQ~p?=hE zt(CGKqEOY)Wv$iu>a#;owOfbzbl%VpU~g$wI4%%7c6U5oeVyNWISP9Jr1&bUTI8WxU*O^v50b3NIob`b<1*Vfiv@A8d7*{C%^LWOg87ajRQyC;Sxs!gJG zRJQCg>rX9cep6kNYPC?IS)svVARkX5b@Hufy0a zuC9POsMct`bbGw0r9hv%7fz}vpDpN(pIhO9bhr_OfH@+QF9wjxE2PIf1bNLFt=Ist zuy&7+BRB=F9L>|pSKV41_i&hXE2^?|Y7B!jG&(#Q!jAxCE5zO9XMq|uhQZtZ8sVo} zPxl+XqX$H5m`C!?yQU}*{I%nbV{CnQ?pM12y|D(X#q~~e>vpYwV*%dfUE4ZQGiHa& zMsv{a`)o(ptv+*w6GuhIKZT{J6tW22-TGAT&o>s$6z4gRO!>S!OC)264f=OC-7WQf z35iCNnI)0!Mlq~Q%efa6zoRqdg^-d}_?q@5(yJpQsRqZAOQIgDE#8@=>DV}1gi$)u z@ZFGuJ|#5Kec`@9p=NUC2*;-r73F7!0|-y%cPtuicH7MsMYZpnZ1;ts;In^Z;V*G{3XKYB3t00}*1kOsza4Gjs2!4ZH)2 z>p&T(O4uLL5mN54HxzBu;+3s%E@~u%pTf|{U4bvpL`>E1Jkg5APV)ZhDy>8*(39YI zbD^aw&+GO2-RX*%h8c$jzF?v60&fzzdAST`=F@q>w{u??Dl|<#td^=!0r@_ zBhls8JEKFchT_QMu(6hn)@75a;3ULS!l~K{B{vXGsdSg3C6PRD4yKFcGt=12q7lg# ztMy`x@kN<)+a$jYbg-74%od}uPJ#hTy$<;m=qb62q%<@K-TqK)reo;KViVu@Mkwn) z=%TvlmSsNiEQQOM%gtTP2mul02e4sdl-<$S0qEFpt0s|jqh+YmGv z!Q$whD^-b`|FP&?UU7J_)oWs$#h}SLyZ`&x`D_)>fjmjb`^m|j2SM~8kzO6>&rlyB z9gfS))CuI7)BZuD^pjjP^QEb#3cS1PM);#bdNJ#Y#C$Xy5(`Bge#*fCrPN6!P^I8W z1a)79lK6BpdA`5B##74d>Z5SS?s4olGS7aH^!MWOygPAx@PJa4S$Yo466Em6z{l)# zJzrlKToeAL^}g9;Hknl#0lD00W94z{`E+wwQ+NRAg$VZcKkcarlNaC^^NArA2zpf{ ztHCZYJxJ>D5$Wh3z+wD`r4`gGmnrWsg+j!uiVgrM^?~ELX|u%&<$sE%qKvD|+(H_I zN(g>+$pZ8i4mKpH^n@x}H9?fdAG##S>#1wV{Fqfs(1AGOxFEbas45C^ako+HH1z== zTU*~R+=iHKhsg%rl$6YG*v1)XRxxny*SIr=ZQKO!^%a!C;U zc`(N0fC2KaEjdauVjGA_IJ!GNW)m5B76T3VugCCN^NM${2$)pX%k`yRUc3P|x4edK zk0p>nV(wSFaeM$kGV%eBXM^=MchG>fOIN@bGlPfh5)W%RTs z--@zE%JXN^GILc+l|iS|GQo5~sc@)-G$*;X3|zuITHolcug2540Q)=UY637Ac~;Jq z{vC>_+*S>i^U`+>M2iPz-CH4@j5n^}u*~AQ(jdjn6w>vCf5VgYYH7)49UEe%NcKlO9(}sut;ZQg1S>$HkN*i5z@N?UK6CPMC+T-> zAqX~3ltH`sr;ic9D)(i+e;(rxkJkfO#qf5|uiNeZY%R%gC)Y3bBa(PZuGCiGw75?6AM_g zA`&*Jc7UpYSmd0FK_bD3*r-Ija~+n<_A?hrQI`Mz&V_od2s|!t3Vno`D{l7 z_=gleg}4GK70?je@K&4a`P1X$-bCgKJ3bmxK~Y?V=jArcRq7M`!W=lVpV5~_dNhl+ zYC+eIJ#RUp|9-3tK|F%|%XkH>O!axBLCiczU^dwc+^mj(+pSulSrCQij3B zMckE#;H<9OyHhPq>7tmnXG`?OTrr5fnBLqkalo=-sMPN~E@@L|-tZ1W(wqxwV}U$? z!Ms69q55>N7Fr5PC~~VmLsYK$IjdNu%jq&MlXmxFu$zHs$=3eL=qOU#hvL$UN>2^e zyW8GSGzamV5o|hD8ZU)FadFh~|5jY2iY2Z`1kQ!U@%zQte{oSO|B8Y+TO@b9P?;_$ z^>8>#U0=M{F$FJy+`?L$Y4Jh9G#3tnZ2NkDGA{UYFuOMPOoNrWJN>V4Ya5$0K7D5?pxg0Akoyz6|jz+=JSRjCME{0 z2j7W1h}}e&fi86ALoa7y>|jTrb3KpdN}q2I!60i;4>pur-VlvsG@&D8oL^g8TX@=T zVBfCddM<%&+K?_DtFf>ohDUS~qt>6@-GZ~-_nX7nXd*sivjoC=?O4ues_=tFJ>Mv7 z`Xq2F8Bra1GYptNl1T4*G-q+S-LH_vA2;bZD<i5}n3`#*44ka1(72o{Rl#M_Zgx3c4n-r`J*HJIFkh~>7*FH)o4oXP zC+v#`!h+>QfIwhqugD3FAq&+1S*3+vNdU-cb#1juuZ;@S89wxNqjULbwaayK{`Lxt zukyxY*6S2U5b}Of6{q5|TW@ykR};sNtNQ^0fhzQ2vkvr%}`|Te92sTosckyombZ@HAZcTdsy_kgzs5=o(m)V&PQ`{v7d6=+E)6J zaBr#yK>%`@BnJ1ZKM~mUNe^fs>)X@v%E|wgUiActo;>kl<8l3mF`dJX3>c-!X2$P+ zW&9d44s--%6oRSDFi>uJ%qDX~TbJAY$3mOxfN%eWf{MUlq&mS0w|n=zEs@OX`g z&9qt|#b!|a^Sm0gcW_J+-Q&2)y?sBUCxf%m>0Pe(4iSb)4bc0&_`(DP0#%SV!#c6J znvTOTC@~B+ugBn!N>8*TelwqJ*h*dP4ogPkSH5LDKU;;YnMppQ+0^Xe<}e{Sxi~?kg56?9JPbqP_s;F{B6F6|WGqX5 z2T7!TjbXnivF@dRnpW#e6Jm*(ysRXJ-%<7kyUF#=>!$R3q<@c|b+msjQf8+=h zGWjiN{m>8*MZpwVNFFl-G?MIM5+N_ybf(M)$ev~eu?oA*)6>^KUUW)pubBWT}t!!{Rx46*NIHNUthm2Nd0bE&Jy_}GJS`L!(eP|+@}oJU^1(@ zqSxt#fP(URb4X4nUXAl`w#HWex|fLH_sHz3qZmmUd)T_`e9r_(TynOVWj zn$P8I>uhIbXvq0^as8$l0tS@5u)x{jaqr+rS6vUsT|~fH(PsZf*tuy|IFcBON@QSY z2-GonZmis&$n-Cd${zB1TQ0IG|62K|HyA^3ynhd`r`zSTR#j=3tMZ7LK&`OnD-qXd zy$-EMxRDESG*`M(?y5(*__)5t$7UwKzoA~O8|?eQYWLQ&-7llv;^;oS9gKs6Gpb%O z61St5{d;lI?5Y4EDJdxehdEQwrYtGH7|Gf?e3Om!D(Wbk)QHn=dGpLCY|_`%N~^-I z>#a~G`4{%#?_5T`{TWKGm)_*FHP1$im1t%s2c9mwb6t2hugOGu^}vuQkDG)3_kukS zBJQ7d_f?rUZpHrmWw#rv$hLOHw1o^DBo z5+m>Kiu#5VsH2G=Ll>b8SrX~h59iAjY}wcjdEee*xGMBQ6;}DojT)>L&sOaGR5?o5 zB?UZgDlSf&FFo(i^19pgHEWG&Rf??di3Lti!r4$5zHz@VHA;$FkVaMAMi> zxQ$jCE4MYZG_~#=(((U|1vFXAqT&WvbQoL~stmdu02iuOXas_{dSSafZY|F{17C`D zJCZDI@{byAm&Tasj|{rIMP%7>b0;N{NtTQ4-oc8QB{ck)_-Z>;a?>CCN`5V|6)O$P zBr)83o-}Imd_d z`SL2=4&~ecw%U;7O%9Ny!}5*s{S60J&?4^#onr-?+QJS-37PAchi zz9vyU2fNPCQE4>s>FLKsQ3MY;HxCco2U3K`F+u|(wiY}d9uO7+US-~WSn=KCw}w2P zp2i{hd3l>mlNl^F{Uh^VWby_KcMPGRAnoT7u^ECA0e?2N);hh9tJUb^V6B(y@vfp= z&sHDzFUTmA2-wYs>UEiUhT4n1O)ahe?2RH=$aT|zJAJ&%T-l$<^u~L`ki%6L@p)XV z)cO8rjJ5(cID4^Yd!bSb{&UVPlTM>Dp-+sb*{_$D$E!WN={%F&m8SdiH9pTfjN{%=agBoQc&@9X%Ku8c<-ObBLW-EeUGxwtC@^aL24> z3QF&KDJi^!ceh`k@8RVo5E5!ggj}#7YDEG=zNoi(oR0XvK2NpR?7zS;Jc^zYkzx)E zjivLg3=Z;{$&>GgW7lY*p~a-!U#GsA+sT-hl(8B$qI!j^v=j)DHZ@Nf-XKH0+}_G((N#o%)If0l3u6)Q~l`4 z@v?bG#Orc66U8hN?XVr>5P>tUSLYJ=0ME5LH5fxeP7BR40IlEzXrDh`bU9!C#W7Z@ zLcQ58daaPb9c-qVi$EPo57NS1-0W{)g$3BZ6zX42P5wus!+H1Dac2$EG?>H@~#kwVx)7~eB$ED z=JExQ2r^cEKbZ2rJLMZ;c&{r~$X&02GY*OK*?mssGsBrXmqKJpkI6h$4EtCoiIL5e!{4%}))YqxfEKAy@Z~Ywh{nwlt8!?#v^|L+)Zzed_k%y; z?Rulmv+bJ41*LgOY@B4w3)FnsYe-_)ldg=qO}^@AGIM`n2ER!fcMAL^sk!xOD4)v> zs3UOaVA{xSB-7P!sFhF5UcG`s$EMp)L@u2JH~HY?Lk;wNz^sG4ea0sTR+2V z1bZeAfx4VJMh@Fm==X<@h|nAbMl=OO6_o5`A7M(PG#Z_1+MX#_$rI_+791H)30T4V z^B0~k-I#a@;QRK^g}xlYkxx6lK5-cBXcO@|Vc_5}!#x3*PIN+qXpQUOSmB zX6~DzyqD4LZPsq?4%%AU{%C=TiD?}`SRFCye%56Pc?3dOFK9M?0L9s@w;Me9u|Ja- zy$4Ba*BCB!`v*>D^F>5D9WU5uNauLpJP(b4|3N;_IW zHk(E3Pb6%6k5Hd4dUCz>G~%}!djtB+m9jfzN8xh_%8>PZm13st9t=w9iOi-Ci$v;@ z8*qmvNpkWw8y%i-Pb~r8B@Xk2=gSwlzvcyHXE!zzG{&Q*NK!9U>4d74f7Nc?rNX{w zMkzeX96tWAwyK?KzY(UOo3nVdTDVeCjfOuYUfoy#liFZxViNooB-GO@5(&jic&gWi zs&EwoErJf6%lvoinj>PbbQ;F=^)2CmgM%xiPM}w{5Al;^m809FS1Y5?p|)H1`ns_2 zOGG3t&Q4ZVHe1l!(9*Juf;lr2JN@BsM0K%L3oZ4UG6EN9udmrXvpfLXSxdN;3mv`Q zVC7nni!+vOWoXH3I!3}k>6eztsumR*6-00~&)`g>(rBGJW740!RBLASJ11^NWr@(7 zp!f>OIZU z!p_vxo%op^UroYn&lwS$4vec!Nim0)p@LfHPF%N7(h_d%o0?Lzz!9kWC5SY+FXX-* zb0B4X@+^fpD6<$A8QDi|2#o#lqG?hXTJNwSRgD#s#*c;|5~6vD^%S|ZT@lJltJeuR z63PEb6s=GuS!4m9nz9)0le0h|;gsiQg_9Or@oU9y^nvQ(R26Rom% z=>)x6RlsC%530lo5n+Ll5^*LG>CB&I_OfGAgJ@Fxw-ICVV#Z++Q^qZq-Y)g(o)|)& zlhacoT!QDbOhZ)p%7p zXvj*|B|1jqc_NlyyR{^k=%rH}bdaV9*v!Uxt|qsv)T`|!ag>dxHggkU7B!jEIP5xo z-*t^oEf-2Go=%Z3q+lU+E6tFnN0m{Y9Z`w+oReCv9xgG_i1_{HlDN~cjITF;iPev- zPGC(+KQL>x(l9VY7D5iU7cH+4eEa6)#GaH=IwlY;&I|2jysMQ8sXOa)6VRpdq0Qr> zTYXJ$>EIbRT=W3^z zWabWSJSSIk(K@+_5@UMCa`d#~3NI9}tT-@S0`FlDbJ*ZQF7Me}7I}6aV!&Jjj(&5& z?e&QxAu6Mh0xq>7Xlgx)Fo8zu2sRmC9lz=^u{>EXC^%Ed*Y>TQXFF*A zYN^=(DtsgeZ^Ck}q|p1oCj?@ctHk@|`tIuL_V)7XuHA8o=&>p+p~!q0deQbzWNBDf zSck{G1nShth%}3n*TE((v$E_Y^?sVAon5#B=IlNL1ryWPj3R$bqF#=$)#=T&yL{Xi z!7}x(Ul&{C9986F*(Ak|)lyr$1aZmE#|tN`EggzmJ!MNujKZ!c`0NFGT@YLXw*20A zH?_}&0-i1=qgkhmdMmZ6!Yb!+%R{B7+AVX7F7;DE^?q%iEu;ec9)g0u?K=6rxTDWI z|6Y9iD(JWzPiVY=A(}TBL*#7lWIcbC9A79+fQQ%8qN`nFh&LxBwzs!`&npfn@hE;Hlf)1i^}2qv{Tvv? zMtAZ0eE*<3u};kD^K_#bn}2`4F)K{G%`uAf^kuc)u@51GU|d!BZe&-@~Ib$KT`qBP6PZ@sm1 zn*ZI7=Dq%~F7{UElW)~s1Wo_sWXz-o$XveRh@;i{_;Tgkm-W>S2p~6r4w@nAGotzY zIehSn@H$s(WwF{?7pBw zi=aP=LS{W^`i+$<{=yS1bc*Nn^?g^NR@fbi6MI6ht*Nh9itE@L2-x!Mf zTCE$%T&mW_iMc*RDL583cidR1by4VY+WLMu92@Fvy`x*fPX*}9AO5Pr9 zPUS)+P(^n+yyDAbyR9}0dM{%MN(#Uwe@GrUC~up*ww4y#bEh;md`GDgblm;a<)XZo z(>M^0tZ05NWg@|YBV94)4w2syALod9U zq#nnC40Wx_=GgoB?vwkbV3~G{w3}P=13Z-3@OJR;$>8h#i?!iWenY&fDkd*4zWuvX zEe(UCIjNWZWM82!;X}DBRwAjjmhYxg*jH<9u8MXvm6mvSXKPWWq-*YmdD~J(Qf!L8 zJLUm03xRNGpi{C>q?;3`S|K4JUe@ov9fNQ09%!q#I=rq2%Z23GstThHHWACfO~*fD z77oKpY{hXA1CEt@@meeV8^@GGb`!V9i^PInIo|utFbmaM^r(*mht}ADvj^^U(O17zxv<|xCXo)N*yX*m;|uhon`bYJMC)W<-)_^!ApXaX zpU4>hIP44r!gVfI>AFAPRqnq&`@S|n;;rFxST(%ec9m|D4gYL;rsOKfB^GqkH;BXK z6R|zItJI?D4Glt@QW}gV5^&#cZfSXpCF>;}+8FHI=&+*J*6k#*h9n`Poh??#hK7Ud zGBw(N_%{~dcA2M9V|W2?Hl7B+!3meO3q}mTPfBFgd&?UQrdG(>9Zl{spUz8*Ms@b@ zSZhC2YL&%n5Njhl&qe6x;*k-VxL#l5Ga31jH~}-x<9uX%)8^Xswmcx*TQEf;=;fzB z&_d2vc~dMILkv9sR+Ztsih}OiQF*V2DRD|DK z=%3{IaItl3rA|&x-oY=>WW63VQvIy~|LRdTg+ut6LxO&}-Q8&HIYrM`?KV7jzgtb&Du;jmrp8)HB_Ld1BQp==sW zW=^El;k4`Y(tsF2oHw^L{H7zJ@Mki6<2G+}t*mw^3nZ06ZYbr(11V3nIgGSdF}THEpcfzrYC{zI+% zY>2D*qs?+SX1W!xZLVmrlKu(KDat*qF+~83c@(*16kvlPr_`F)iqC4Hf+xyxz111B zf#~vyBOv|&#pQH)x=#I^&(*fPqa`QSL2`4>zOxzqxAaM$eHVy%Uo4y$u4gHcK~1`~ zB?A>*ATmAuc$WI@>?~PPhURXgTs{BXkxp-Gsjk?NI={|zx1Lfzg>9dbOC2qSfLX6y z7-chrPH{8+et5W|5Z560;F$RNdFw#ET3ffxr6DjM6QAy^R5q3Eav{X|}bmugL#8W>>*u|^tQGF$o4O79Nrd7&V) z)a+g4iy?`#y!)jSMbW)lT(Gg>c`|3GeyS8RF@9^rSS-~pJo29OR8%Z6&TtDw&9ryE z!lgkc#DWilr$onlH0|jdZm?PuiC&zYt+P(8U0C2uvy7zGmDCfUJ7OFOBibhEQ%Im1 z4~h9mt7~<-h(&w3GZ>xc$d2Had5WT1p@G0kfVh8_xk5u=Y_;Zb`~Hy+mDbRKNNz8A zTe}8$6{@J{8uZ%k^*FQs%i;G000s#MV>Dl-J6~}6jeMhB1RnVt%sfc0OtqBt$+s(v z$6nQ9IKHr_rzdCJp7aM{ZsD1Lhgzv}Vl^rDg{)f*Nj)Lm#R#@*2E6YuNm_YgVq%$J zsQkbL$@8O*QiU&GB|O*FPXqEP!AC?(JHnzCGvT%}r4HC_!4lt!n1$#FKK;$jfvEKG zy-CxKk zVzab6*O;ku3HgY`@BK8lqQ$}j4HOXzK`lSfM!3(!fzT`XGA%SHcX4<5StCIqez8Dm zHZK2-IwXi4!HtKA)J3`6g{a2P<2He#VxH{-gC5pe?`#?&HCtxbVc#r3&hQatcG7)qwX&CAP_4cZ2MkjLmORNKxm~)7z`p4j)L8 z9i+Lc+vx=%nSpTT+39seP+4RZuD$Viy>B|1TKtO6yF!4Og(^JJ@+A%^ea<$En}G^M z;^dTwpMAl=#GHNu_)W12c*@E;nVWw^%m_N%P_NR+8st}KvK3&jL?riRxZapP{ry&R( zLHQMy2RZ!6V}Ps>Y$3cakJLV5pu@t3B+@M!Kwqwb-II?#6@Kp)Jg#RakKfJyFl&r~ z&S3(mdP7h}d2yw9J`cg>h70+;Z!9|#L`5q5#7-zg$NN#k2aw(bK=WT3$DYxE#24>W#}7Y5_;H0QM^xyXl3 zcAI77_m8>MU9W;k;E*D;2>OO7WES|i@y}xhf?^XPfE0e4I#2(XC>$D*!QWN|Vqdy3 z|7i~nMDqGU^Z)w+8b`~uzHi)+u_ zy}jYT!}&t@!=IX!yQb!Re3W;wsve zXQzquNv@~M9Xijb46hf5v&H4=OwSK)vlZ7%siin<&jvhS8^-4nXrcQ$<9d&d~i$dw@u?h0jaBtLg5 zeba4qN_~%d9A8}xFcF`~5OjW+5|NP^MZ~7V$H&L{S^uMDtG+ZLDk=)FYxuW2V^VNH zkg&On$8q<|moMDjN5%s<7Y$bPjr6AK%|EeavTqJ&Czl64{~58kFop_*B<8=6kTDXc zv)`+It~HSn^nF{+5$RoSIL4`~i-?FoLO@vWu!1Y|9((`HtXn(Q1B9}CXmdT^{62TU z(jSpLWhJI6bGF)o_jgI)Az@sIe;!yFJ$}62?|PZx<>vnA^D}!RAA?fbQd5Y5VT{I1 z9#cid0YO~!VKkXjBD_RLh>D7e|HUK@D8XhXKjMUuE$EH-uTAeFg(NvHU_`AG_UpJA+itMKe=Lm3E7xg(jn*;M?IWb)j_P!u`?c zs*pVN^JFq}*G-v$fp9k)k4;Fz7*!c&YNZ5H^I@&&cs!S#Amg#ydKcPlw8!&C%7)cK zML#Z(D3khsu=kchbv0d>XmAJ#c5ruhcX!v|8r&uL!5xCTp5PYTAvgpG!QI{69i|TN z`+ak#>Q>F4skwjd&vUx>-o4lEUh+Jvdnx5O?;IB^W^W&D)qNdkoX8(w_8Iq8+t;13 zj);i(9qs=(6yK{nY^|cAQe_abw1A=cq9&S0N?uSJSJHlWwuVKcN^bMztro1V`X}JdWx@@1;ltr-cv(uZO_d=-@Ny_SNMB1Dy5Q- z_pdLH=u8H`d|yI!o9qMGA}P#S#DuN3Am3CU#PNMY_y)y z(YrcMpR$>IZMU3I)zrj7!FwV^BpEc;11{`zrhF`ABy;<_Pn@TkWz}J+ z8l|VV_ege=a;X)A9`o7bjm>aMRiIY?SQs*q?P`7e8fDi)i^-br3nKcx`v;)q{uOF( z*zLhmr|D1vohH3l7HUnkL{zG*dw5)Miks_3&qKSno0K@UHS$EF*A4FV@k~@0!tYqT zsPb5*k|j4gjP}TXd3d}I57b7xrP)kEF6F{|D>J*5B84_y*F#z4mk^(YR@b`W5JY(4 zXI+UGSnB?{dUXFF?6rSsTqqLAz*hhl5*b*VlX}MaQ?b&cU=tE@t*7n{ry_en8Qjm5 zYZ6ns?5dA)w|l)<_eU0vpC~F~oG!QQY;CnaR%xIFobzRhJ;U*b;A+f3~%-u-Hz_g(ej6;FEW|_F9TRpreo2kwH;Q0w?LxK$w zl1arFv_D;9oUXLjnO=yh>tAeA{8%IYN^G0KZuLi%h-)qikMpP4$kIIe#qF*5rQ`2w znky{&P{l9$I22g|-jvhcy^_%_UKWup7mYdmPWUqVrszR|LBHC6r7-E5*w{pg7dDez zA6{Fj1qMQ1?J@aplheY+it#u>Jn^}SugA{XmnwrCZfi+-d*y)sw z5SuO5xA}M(5iQonKwCKbGX79UNA6_N)oEDI%{}#_$M)gMvL5PqDS026p5Ca# zYCVLv&0_t+fD)+sVUeTUDM-CcPrKBXTdH0+Dc7vra2k_PDxJf-@}7;H9J|=CMA_KI z#iils=od}Brt6B=4axe-GNUXEzy0b`lY>aG%MH>C|JTQ9WFo;NW#!AA;Z%6yS5EWm zgu>wo+k)aoi#x@7>P%7zTZ(wrm=w2=FZE7aUgYg`>W+*?RygR`9=m^O^k9NND8Es| z3SI!{&JnG@zMS>aD4$WoR?>(fC@LxrMt|k_X5Wn3*Y+Wc&l{G&=;3nbtvpYAO+Ee= zl6m7bgUg+B5&2PfFA@XQ;Ntu|+*1hj?uW(mgUxPAoyBNZgzbDaDq!;4U=0!Pw-1Q% zM_+WV7N&{;R0;u9f6#p`-c-oK)>>TfI-UmO^B+D+9}@Cc6D0%_v>J~}YM$PnoDLIcJW8(suMaajo6Z4qpMtwDcjQz~5{u;ld$3Z=&=83F z6lQ}Me$Uq`to89riL)Jj;qZ7(lXG_#`*4C>Dw;^@ z^2Uv-si|3beRH5jMM4uVA@!Jn>?WMzu^H?nZOLW7tbT)voUNdaP%o)xV`xZJd54nY zfrk?T51D7g<=}N*L#3Q4-85U0JGgJ{z%ZCdw%~hnXm;SK-?E969Hq{wbO}xX0A1}e zZB9VcXBh~z5eNd6HM+t=LpK5O-0As!Z#=;LO^MW3C|FLLS<#b^iw+;NHT3f(75W$S zTfP_6a~nYjIr-$Cge<-`TW$b>qiH;N#v&b%WvGE}N3#s`6rs<2gTVN z0E<9$zskG({YNsn@}BGV{sMx~!#@!6xk1knH0^bXL<)-)$DE9d7v_HMz%6UFYr6#l z334bl!oOel+9Uco2CZQmT_vu@*fcqkk1#SsFu(sqXq z6A=-Cpl3~4KvB5#rOH6;(biEyM7k5@_rtLGD`6Z@pA3h?s;ZvJ+4^AcUT|T#NXobU zah2x?{+#4w*Y6(yl*^*bkegDE=1fTNxLLVrx59@GnEF%JCSFd1}q z$3R5dJIu*fD^2cWg>v@FSeFz??KugWrrrReh1rrsu8!pFj6vP*nSuQhWZm4ucCX0cEHS8z)TkQ1qctN+p7P$Dp zk2`rFBmAV0TUR!ruU`HI>Lg2vX+XrK$J#Zd6z6oMEy|@zzqqnw6(X3oPl)wxURfti zBq*#*<4niV)m7wewnYx>043;_fcJ;j3@0oq`3$Nt3+L!esT!7(aReI0-l#6K{j<1W zUXEImy*`5mdGQB(`^x*{)%;?uXdY*5x6=f{RqA)Y+Y0AT7XSxP(43!_m%-&g5@~9x zKr$c46THOm$2BGaDw`t8B=KiLSZnf^YBZ1M`LW)u-(Q7KF)E9c&OAR&Ckz= zAntZAyu92AJuG{ms7P~&jbYK5Q|V7Fw4e#T5I)IZ(YE8z@r}XxzV?}t`5{X;ZLXDT zZb-%8-`?KFL`D1DF-9UJ>dR;bajJR22Jr2JglKLN8tAj+Bnq>)j-Q{O)4m)|SESBu zW?JnXM`Bq#{`jUxeRYYE6|dZ%T#Ihd0aF?ia+Q=(c8cV)F}UQ>Lzo^R$fo zrDs4C1m0JzcDNpycL#>VeHzQ;I;+raQvP7c=D?(s1u5j7T~Vlb!aMkt%wkw7DL5=l z$YULGhh8!4OQ&xT0bgS@E)V{kK$F!e8im|tH)I+1$5@SlfqoWGVcPtK@@$lt{GFmW z?U@@qcK`79?cG0__@dscs|F>EU%!5R2GTG0BLjV?6!4i=5GuKEiO{jB8$p{CvfF>t z6AvOfJ2x5moSwN&NGEqJm%6*VpG`_v&LcX79*MwIf6b>dwjj$hu5XyaP8lKrW$pN+ zUo1r}qrZdW{O|%%a4u06k544RgWI#(OJaimTM zp@h=v!KpuMFO?}WchP3{r@82^n(OAblG7Y+q)}b>G%zf^Tm(CFY~BtV3JH+n>$|xd!dK zuEWj(l$1(#(M?R)6>y0NDyY+}_YyZ@)+t$BmEg85rcm z^~t&fu^5M+W%D(Wk&^yRWFd8*$a`2dFksNBFEhx8Rfkg@_p8t;Wq-^@xZ($s9<`>W zjQVeQ+}2riSl9Advb-#Uf=C8crQ4s~*3w^|?g)k4rn@IdjQ)ul>O}BTwocbfp zyiUkdnYS{y8Fk2Y;B^Z)ftQ{%EEADi8NM@YgI!{HvDiFb8-bozdm}j<-j&y$u6k3+ zbSY$-LXPV{R11fRAHKV>Itm-6h?0B0{TkUGP6a1*f<@SUmL$co-arJfXk&qA) zas>tjEidcE*BUhWCaQ)PNXL_M^O7QJ0r*rHcuom~goFx43dLpUX0Ch^m3zOjY{Oel=66gUxGg;<)l}bdyi7sEA`#$ zYWMSY9y^Z5T@IV$69>hd= ztF5k6^Ydw)peoxI=X<1p=y2?(M|$?eIh|&p1R7!HK%Y2QYQb85ULL=|+0|44r4*;{ zcF7wF?+4cbP)}T-)(4P>^U5QKwOq`T&tpA}_wBLCkfg6hK9zA#{+Y3{F+AgXMr0EY zQT*w_!H_E4YR=hO=fE{|E`Bf%Fr<=F1JOT!et!N3ZgRrOSdL(H;tTaZ=$$nQgpw&c z8UVng$L(NgsnzC2Fw=R7lfaZEG~gmGLo6tm(>bf97pT6MFNK`G>FV@WQD^kJ97`l5 zno<%&R-vKGQhe9iVAE$I62G+E=!)1eU8$csWWE=3(i`^KsdM|cYT&%qsg?y=)VI@R z9Li5ADV+eS;!A#<;Tyue#~Yi?-kH)r5nBTVYNy$Pu9ItPozn4nDJd`yXjvbHvUyz( zZ;6yQs)Jfs@Ff|=J)juOwTINSauv$CSJn%=_(rZDXj);C?T8r@@%_FDo3V3H6u%1@_O9`#t%K)PEe>Vu^Vq*Id-(jc8`1^k= zWS9>Z?dmTpqnrIny1BV&aXYadsRB$Ng+xRkup0|^)y^D4Wb>ul2Smp|$$!;h9M2w~*5k8igD$pQi2X&ye8|#6g#shqXcL8EauG;SJ(D zt$cHac5|sHARZ1TW*M(NzZEeA9va#&)1jW*6FxzoOK1sEXN-vex>TmUp~PYdL~W+Z z;dnBsrc(iw%rs6{=OEb65BwJP_La)Rc(YYsOXXXfZ?|5b^NsU=qXckcK_mSTdb&O+ zb~Dop;X-vFzNJ*e7V;j|L!T)HKCoFRo-DTr$j$z|aB|uk13jH@h^go<`TP4<=yJLq zE^8c2e4{wzvV>=8vD|pZ<*+t!P<96- zb#?vl2~&KZJ=U=%XX}pl<08QLj^PT~9opt%WzeK1@*i7b0xamm-DC9hBX? zEp#S>o>E|lxBJ;&5LdBta#=~(Qj`6q=lai|6&i@4N8E15_aTV88t zB+$EthY`8>zF_m4VxQV{IPYKWArs!umS`tZAUtU3cN##M){W%|1TF4X`Rx&72pO#t zC%N7J#JM@*qSi06RaRc7;NqgTU{_Rdz)3<*Sn7P9eB7TP+ZuS&F1FXFqdN*i2I}iG zk$O$$OI5u^YIZ^}-n~i2mgrP?AqC;X%WLWA+@4NhbvLZEx#vZqR6X1uY|YT6qUu@B zbp$OUu?@l87v5A^x;FRaOCnWilyiz&RAMoaRz~(T>v5042gP34)qlwqYvILXaA;yB^d-1p`!|*;zKwGqHo^_0Kvaqm7KVmf22J&9s zX@lKzM>AIXXV zm1G(BN(!eR&v)GXO`l?g;RjqJPxqXzv@yclx3;wOr>(tKGg-V5Km+#}^+_^GbUc)y zSo0m$AaqPf!#RkvqwXQu`75P&{%HK*jpNpDHAP(m9@gWKbax|SjfQ=(nkve6gE-q( zCv=Lm(``BjI9#=4C-^IkzE1o#AI+$9KRdnJ8?|3-4nRO~!LJb;Pz?Eqp{j3a0nqS9 zhx3+#_o!DoppPBw`ifvGgf_e>nqE<8&<=}oem|L4==}8Xa#xedWhab3(z^xgi;K5s zz4+5JmQWxa_QwmQLXOw%--E4zSW(WXt*(j+yT|L3Vs-~V=QzWe@a78rCoa~%u7|T~ zi!#Zy8a0XfZEpBNzMtMact{fX+)w|gLh6kNhI}M?*gIbpXiGm&3edmU{EWw0NrB@w z-C*OcM^@joHGp3Am0rJ<9U2$_nIf0MxYp`AtPqO_FK@)7SZ3FD`-mxT0fB;))@d{m zdSinsCB_tnB0r+j@N`F1?Xrg?`OXEg4i?PaYXwTv*z38=$jejf7X`L>3Z@7K$O0d) zWda~!3Yg?)5nm1Lp@Q&X5HXXGJYEBYiywr1ZR2`>SgPozd>XF_4h{8vyhdGUIdilI zgS*ZPJ{x%yjg9?+ey`8R3u*v>+G|!z$B*rKenG|#^@GU_gMnoHgciRneJxAN%>i+| zOj2Gt&Ibi`K3-kKFZ(Oye8De27hE__$eEd$y>D$dzalw%Jl!%(o?TpA6j2s&N<>yq zi&T}(irXGPt*;v`!^n8(XW^p+fUWzX;lYR_ugEMxHu5#FQm;jebzIxOxtTi`_9sdp zm`TG4)U4_u6!_&}WTaKfDBX`BIhBI5cl(Ynzs{jcHW+ z-9V!vB8w+lHif=l^|Ol$qR(F5p6OUlyN9fNdTHH<@e>N!=js+7FwaN2CGt-HNn6OsQ;k?Rbewi-kUt1Van!8O0Nyv|6vJ9hm>LeX;94Jj~NU7jLY(GL&y)A z<8UG|*tEWwfB3}vh4+6D&E84He|r%K|GzPg|Hm8CaYUZsYfV+4V+H*l`~K4_`&`D+ zu+DU-2YypZ7@VzPI-ElQLWm&3{tqV!Ktn@6wYfcp{Z8x)%^nQyGFG&Ig9i97k$Dg* zj&|OM9Kw3u7u!8J4-J}_Q2G9!=OUctt!++eIhrACKE0=dr0oqwjg^#eB?VLc+XaS5 zu&*=LGmOde?-g6%J!+gkmG7s)uB%+BF)^rrh%ji80#8xVoXx56acU_fF-a65Nbn=` zjYRN(g2)Kc^72OilBn^a59cI|^z7k2t|kbZVc>T%MyFSYmbqs#6cqCE_y=DUK@r#4 z8BS$FCR*-I#piL>!K^odz6c_QP_rBft9ApCyrB5|0 zZ{$5(V*E{t_)YomD+q-$847j+AD%s>h4q%bpX+NG&A+N;q4vdwLi5Le@wgzpk9dQ+ zPQK5fki)Fwf4I~^A2d#5^L7ieU8rUIcV}BiQ=}Vk6*PLPKGJkJl0Z4xlfV%x2aW25|wMR-Iff<>zM2NoRCv7Eeg6627W ziHW_vy~CN2t?gg#D1bphXHGQBhG5vHp5L&nxLU zUlqX#I!#4mv$STl3xFx6Nwjx`68^^R^GGfj{r!8Te%oTx+6MptaN90?=dQ)TiP8-owxkGzJoil0yRkNc0+P!%Z7NAkbn` zgDS-HGhw^uRp<~Dg;V>!>d!R&rN$E>-)E24W$ko4@Hut|Q&eLhp|{JOsFWGUn36d6 zv(?fUI%t0N;QK1${NGzt@}pTiSJ6jv^=B!>ljsLKzy0uVczhPoOYVvkGD+ON6v}F_ z)$zlxE-WlWh8|w ziajni#X^w`TTSCrQ>pNG2>9->ad9OjJO15U`2&=zFPB8W(7D!?*VwqZTT@eGGyAi{ z?dId{r+(e?FLuY`4Idmw<|+)mr?z03{2Lk+VhIK4xHYE@Y_U=i9bfR+ZM7<_Z}i*T zvIKp~6qek$BU2QJSDGA_BGq}O;eB^Uv!CWT?Q|Qg87iwyhw`58PVHA~eO_8QDl4&S z-h>7ez@>c?A#PLX-jkx?@x1U;rJo9_@ZS0&wz%ydE-@aFu?xI1n;h0Ix}nyXK%L^^ z;%S+g;MGZL_ik56LjokTx!iA$=BkF1>zGvQErq+-9GIBsrOfdN`G2`|;PSf`bpAu9 z8JU}>P{?7@XK}eJ8X5V!^vh!IVxH#^33^l;buVl9Rc!c$sKp38-Fxi)wc6IJAw}BW z*g(DV?NEgH*?o2$?j;=Fxn_jU&-rhYOWh{-N2w_C10JziiNJi}nf7$Z+z zHhvhC6m1v#9p^^Vgymjl1=v1PAhG#-~dX(g;XzhMXPh|5+oC!Oiy`q$anTeDACjDG94Ex8m1{o_;L zgVF4zWLk~iWBF3A-CvN9VgQtiqbUq{YTtXJGkML>^xRYk{W)x2e_a`D!{>2MV$=@>Yu^JnFR+J08sfS6h7H1?<&KhThV{MdGi=2*wR?LF5~m{6 z($-eU=FIoJO4&s7hLfWFKD;B8i*mluE~RwZ2NixYYMAY-Xlwg(Ao@u9G(wS|nTxAx z?Z?JalY_%b>k#{gikPwA3a=WtF)@mmn3(QotDZNsdQ+5)Sm;+X?q{oN)3B73ly*xE zn$JT!znXPSOiaoReJ3hcdEk(a0dLv-o{;H4Z*RD$U+gdf@kr8!T)w6Sn+Tq4b^Py| zSo7UQBMo6eTh)DKq?Oe}qX2SoKJNPLYS)^ocu4@Vte%661f935o0{D9Y>(UABR@YB zd>KrGfq}`$$iSpCr&{?a%jbQ2d{6g=&~Sb*MO9D*2BP&DSXt3P5D;T!Un+`p%uPqD z3_D>-RDRuep81X?(WvE1D!84kdOl8<5(;>i+V@aZsxidY)rr>M%dE=6;6sb#5fp0g zuXXyJ?Rs78D#Wh&J+0O5l*iSZLdS>77;<&w=9>H%2}8d4=QZNMnQzj@)-z=m zcW345EzYA^lG0>nD|L9oHq+0$Eb23Rt)0M!TC?GzM5>tbQU88)FnsrxW89R`XJ>3vl+UUwZg+AS#g&6!VyKqQg z+D-adU+45%&=ngOxum;ousSo-GB)cBUSNB}VofjoZMoe~Z5wSD)TLU&!aBc)hc`R# zIG-%KCcTh@YaS-=nxz{&(|GHXQO%_HSE8mHl1W_Gqj#BxhOwB4-SU5u{kpCm6Qg3pXpoSYq~&q zTUvfzEA9{z747RJg|q+q6@ZN$o|PHW?-v^)~+RmM-QDfq!H8rq=XeDss;16i= zoFXILbj{{rUko&w1kkz^*8c|!NH>XLBm$<~^P&r- zi?{nkDM3N4AOHjJYYN)%I_;KjQO=)kPehg>RGrb?O<6S$;%Nxz+>kUS21jyH^=@&d%R=hS|o`mA`L3x!(|jE95h*^#IoxW6BaL_FA4Q4mmi&<yJ!!-Wz>6-+{Mou^w{ zc4l_^auo5ehZ@a$FHbNZwIUsS5(CuoGAs4l4o`g}5ZH`nO4XV)&pOJ6>bC}BH|nhF%rj(X z3S`*eJv8T{=Jl)?TYqZTfw%MqLeN6)bfXmZZSgYrKP12zd7$<6 z^`XSyczAo(ll7(1uBd{vm@)@|fmi{r>uT#6YrpRV#XiV`{{IsNQ8?=B@81yq^5x63 zzH)}Ny!@w6pP0G0lunui{2s3n;8_1j&c3|&<0ca9Te)`ZO{LX1vKW@!0(HXStp6Su zH4F}s7!VgbNu^d%1-8CN_GI(a z)%A{4>Rj&>?gqecF|2g*QI>TkF)Q=?O`{eU!5hp;?Zn<)2#N^bVvjXv+hgy-9VL)p zcX4tu2N^tRuLzf*2nzIt&wDr#pDm9vNXA$#Mti_k zZkuJy!q&D2K9G3&-Q$WDm+Jid@zMfXq=2gfXq?Vk6BlRkojCkj(k%!dtf|ML!Ew*G zI0`w;OLBP9m5W!OZ=+ei(|>Fs1WHqhOLaayKLE(!1IItT_xteK=uA1}pG2dK<#rdC zT&~|8U=Q!}yM6sPDi8nm;XRuAPoNC<|2O`BBm~pP%N;~A_&^ko$LsE=G|LMMQBk0t zo^1-fc678tLHPoo`oGEH$V^Je2V-ezB}yPc99&$pF?(W0cGgid8{G_4Xc2huG7f)Z z<`@Ay@+(_y6e|(s=dFedUF|Ua1Bgj;!t#9TH5Am;!N$5HeCq?jATBTm5{Flrg70t0 z!Zqe#nk4tcJ3HI|4?Z>|$y}OV|MNrsr>Vd2fy5o)%>&^M)r~b%mzo7@F583uCf;*0 zmp)u#P1lOX=TYE@paesM(F0)6EjfST+B@zDGm#L?#lgYGRtGlSXzxqpd(nTJ%_b6p zn;L1rL4HRFcs2d8lKU0ZlwR`HBgeAv7Uk#RN%+|v$&>%$bq-Vu+Mz| zj!2`X=9SCd-ZCu_d~ZwAsZ=Jzd8OVk8&F_}D%>=Y!=}-8 zp%xE&+kYv0=`X)#zkfq=C@t0gAClH}5*0f-_R@Nu+%GKbHrMfrKc4_va)e zuXd(=ZSL`#&Ul%dUxd2VKsb6`TZe)`$G4G8M_|~OrkWZ$BP$q-!1vQ@4nO1r&W}}W zkG(0sU(o4R=1uYuurjkus0a!+0RVt=fnt@)JPEmsbZxL^3X~=g^p*dF`1vD}FStzL zbN!%=c3BeH@VKcd#M{A?)Yuc(YGih`L$sw zzJ9!Kav(G40LC!&%gs+kv4gu2!8o6p4Qu?#;5Z6Hb{fkOG{H$KRssUh1WiXWD)hAX z{-(3p+JgyS>8-TPJtiZo{@~PH(`l?YGkH3^bNLPO^mOfUD35onlR^rqH?OhqVy8c9 z{nLX^Lv!<8l|jfxl>SAn&=4K)9e<0eusg`&iN2sLtBTCR}2roL_nflJJ-{6quwKhMyiom=II-=p>bLNPJ_ z!Tst*XMZlp%R93fTkE9+gva z99z;yXx29H)r({nie#@L&_JENtpV|(bMk$P`uG1y>xkC@0DuH2;%m1#?E;u-t z-{%qCFcO`{#LjNynw*_|dTou!u$tkviN4ACK6gHXBaq zcfa-F`L;hEi~u+{TdcS0e0$}aO57TVjm2SuHT)fo$7MB1o|2o+VS_n}WjLB6pxf@{ z`W~l1D_c@2W(mi!^Yi~wFIB~$`(iu$Q+xMsdOrpxy_Vy87X*;U?O3bb>t_3GVQLBh zOJHa;!sm8e(%RGNdgNj1cD+Br%U_9ApTy`+14I$li1ZVQx`Fx2e-|@|OB2QwXO~k0q)bZhR z$Mb5}O1pbu!Sigj9p)ptk=6HM$a~c$`;{_{a#m7~+(kK8153hFzb;kOK89Y8exHQ@>_&%L* zNuoeb)4~VFi9#9(#f9u1t_%*A=(RW_6A6BgjP$xa*3RU#i%T33O|f3?@M%*52^K46 zn^=86nyYewqqZ2$BBxQqE3*qE_9fh&C7k&W7O=Us^>lkOJmbCk%QFg{1`_bR(F=!# zl`0Yhd$QV|X~w<#H~s2jORA4jx(t={eE@4NV`GTC{M`n@Xd}JqDDrGO2E;jN{W_HC!hVU1MR z_wDtCN-3vn$#SMlBbioX8^3>YvexZnagyRQ9;bNO+w;SfymXf=s7lhnWU5F(P*8AC znwy*Z`gBG21lv0pZ#0|F{cKg>r(p;1K{1E7xNGrr%>Pkvu%PlT^JUkrM)6yr`R;-^th9M`u zULOED@~1FCWootP`13C-ZSG`QDn;_?It|vjAk7uNsM9w(iX?+O18G^iB|r9s zByZ{!T-otx{Wf4NeC2ed4NDP*@$;A0n>oWm!YH)n3`R!Avb`Sd)^9Yop-f~_F~ObP zV2_E_=ixHMjJU^8@b2o0m7U$WQY*>_D~@P?wVkh{>+px}9-{FFqe3I00-K&atp@Al zqOg7Qk&OPQ9dJ?NY(C9{s{M~G@lPjBYt37bMqeePac@pn_?$S7@%awcIy=8D5%9SY zhao#rGX|)n3wSrC&12oc-t>o1So8!V{Qmu0hq6Yu-V!y14Wlo@6a{HrZTQDCb60ig zChtP6nOL{7+$LID$xtFyl~#@DB4x$TpPcr~C1#4;y1Kg9qZ9d3$oSmVVqr+n#u7+C z+%$P7Uy&MSM^sA1tY+7vFCEV}M|014;*yeihof0MWGj9R)-#QEOO!Kv)P#rNa*Ug3 z)04X+nKfdCGRG(lA|3FCKUz@@RA|`%dHi{FK?!1e`8(3FNS@zyHm82?|2!d|`;@pM zotKw~Qn5bvqfGUu7K`{#EmbB85mS%Az~>;y7ne-!Bs$G_Gjx6dhhI^V^s4oy8$A*W z?hupU8mjk~#|w48Jg+tp#o>vF;0|I4@mS^ca0HNB`QTL=+5!gydMtX_&VfaY6vXJB zdkDSVPx9A^c1w*rxRTHU4{LsJ(eowE;uRvLD*3-t7<8v$fe^P$C&3cUg~BY&))fq0 zphC0&*G!hLq2D4>Q?X3(#52B;s3IX^F(#F%(a?BXpRIK|EY{OZH-BsRh(Y^f)Jf%D z>7u~#0VSqe`yAKbB4io{@QD$aV>X;ZGb4YsJL06>B04)f6_>zXC43`aBRZBN;M6fW z^CKAbV3!n=1yvA&q@LwZzRrh*;hL8cwExr0Kz{~nZ%2EOb1aaSB;_i{JFe@t$bAlhL|V=8KHG+%989ka<>!k(0snWb7NOM)HCN*weV z=jT;7MAki1=QE_{2jj0bk;^M9H7IbZIyfvy6N*t?*PJ%9^7T{A*30xHr~%E~b4&Oo zmMZOJc>epo^75au@F-3yu;3HP7Dn&duT5A}GO_afl&<-B{8vm8*rqx5DPcCA2yQ*Ze4Z5D*abA;NEC6pVc&wS7Qz zv_~asmRDN7A`@j|kLJ}j=bevebl0IQN3{bkhe|~V8H&m~k{6o#3 zlAkRc9TlYA)6=sqW4$PJJG8eJU1aFErTgP%#92^uL+R4bH&2$Kf*WV zm&kieQ2&b4ZL;q^?@jc_yV&T3GnH0%ZAMQs(6Zb%P{xpkBs&`^hwOl)Z=L*eG*=ZS z*?-VmC}!32QQ|vMY6OCn%Q3X(XTJ~+^S~W|6wwb8EGC1$(CN`HKT;|UJ7e;C<~Gjk z6(E`G!yU<5MuJt4=c&{gt#gEW&UZ)f@TIF3XPeBo+0h(dpFJ5niLniKk2E1(Mq>VCmZqQTdwQj zboYYDP<|U`n88&uEWq}UKLnHqkO&r%6GniUo{xwe{vZn4*&2jP&PButKNEe^A|p|g z&P-^KLB7}Zm&D)$9&}=W+U|k77QlcUI&dSUD{1{Pa*ja8yinL*QArH2*baGO5&44? z`NI8V5d#ax;v-dDl<~YFbcgn3+yX6k-it(Z_iG-Z^0e7-3d=^M@Df=OlRgpS=d4K% zo7wg0IBBhRFAhK651%EYp5{u`s4kZKL*}nuU!KzA(!=hC!C5}iUBw@yZ7;`J2WDqe z#)O)_9jFpsg$|1NCckO$CTKHMH(+&2e7f#&KBvlaKb{Xb%|u{ov|4H97{egL>b7HO zwVL=O7IWD{-1r%$A6m`S&Tb?)CKFHSdK#EQ4E@De(DN6j)9J(2UOt&QjmXX6jEcv- z!95ay5$SK}$hu)1=DOj!cf?l$)RuJ@=pI5Fq8J!IB#nSzs3w7V<)nr@OpJg9KHq0< z+lr777(>X1KQ^EIL?veQc-R_< z-D^;9b~H{hP4G|SQG#@~mQ*A&PKcW6@bO+kFnjy-zhYkPRe;&pvk&C>uVS zDd#fjgJyS1jz( zcnDrnTFUhR(G8L4gT+AbUR=L8`!v}b=_0zRY%bda`iFBs-`-h4{DL}fgAz!P%Y9*&!O9e+v3|e)gz-UP~tRfs3F=f2XFRg&=oKQ;j zcE|?{S_9b&lB-&?VN#&K%Yd0G-=n}y+$?re=R%!@RH#3kc-)hU52XFyG?tY%cd4tc z?8!O{B`v=Wy@L%z8gTF;*tO9c)*tZbnCS5r-aE0i4_c%EmOVmWRX7Z3D}bLLG9?_7 z?Bi;l1fmuVA^aHC2b*uV$J)>)x$_f2WWeDjg~fRuI5;?IJ{9_itvDxmrx+s2@1dK9 zC{VkGvhZD9!k&30;XpQ$(&4wrW!up#p8lbS3@!)8Yyb=;{sH2QlbMY0!H~tNr+;uT zG^IHHy5l*8a>b<7hi2Pd3-X)}9i2v7nwJQd0EBK0|9t;#&e#G+BM@Gua9wYm*h@Lj(G|zWTw7F*`*^q$3;J>>wdnyI!ElJQKD6&b6`d*uT5#b+t3PiK>>O?`n)8&>x7(`jkEhXG2VfXGwV)sA6 zz6Miu<6aOH5d@-5!qz?ey^_SYkxY4(B6(?e=+}|T;*)j{&g79Bc;qd3-@biAr{Xe0 z^QJ|zN3)aF(n_0DQ6`tRQBqR+c08F~a9?T+5G1H-YvZeDl&6B++1xTJ`??s<@CNo& z<-~0uT}Px;!>JWUN%K~w#skYViQnY=S*}pV!Xek`tD$oNg$v=GP&(G1gdS`?mlNY( zOC_UmCqd4%4DP4P=XC41-FCt`AR(QxDZFz4>?iCl_&{kh=`JdmKKK3!wL$&-%uxCP zG%W>2=h-U5*z2#8qQ{%$Zu85lt2wcDlD~uxQ^V7tUsb&{?zi{kt69Dd#7GdQk0F*} z)?}5X8#rMmn&!FvOoji>3o=edD1~Y`3cpkW$c9K==UPtW0Y71jGN?i@DT2sIXQ`C< zzupoRGGuZ_y#m{LTCc0TVQ>>&&t#x5I86kj0{avDG$OK7 z>qll1$5?sF$vXJJD1BbE1R$ZBhN!5hmgW{B*?x1&5^0_eY=o ztKahl94PD)>J;o}T;hoC;0+g5P08iVv@R@_3=NKC6htFS%ljpV&Qg7;;%14BQ%K|T+JLoS54vDB zuj^u)dsSzr&?KT`(!_3lettSLY;05SKdVGXco85VucwFK7nI;XXVO32F)hpf!(>|b zV_>F;h=~0Dn*`g&`Z~CbR5G1r4xc+xyfS0@xNeWU7=Z{>lnv=7aYv>k0#B8T9IJ`Y zCk_f9oJ@2lPHq|+59?S$fxw?TE^X?SdM!E))+J)ciJkPq7#eN#223aMJLn=&n#6mg zq!vN=uPF?=_A9LvF}3_IyPpzxn;h0=uO|c0rK0>uSFxUPhPJ^+!5d#Z-W>V8K3J^& z^4$EI3EU+PBv!>kJ9icl>gOwBHT&}yBxLwVHGy)eV!X*GiV}|@#nPR2K)1dy*EA3k z5+dO9h;F6iRK^3X_R#D;$iVOwk_7(Yg9r8~pZjTePUPg3!)7?m%)q32Y5{CDH8tL^ z;QRag?jSfhLkCJCbd5(+J?I|KQG5i8ne@nHl&NC_0|Qw=y zZ_f{PhnDb8^SP7tcECoz=VYl42@yKM?zCFVDxCD_V1!yj{&-@DSAtaA=lhFi??^qc zVe-!+oZBPd{icljTknT(0JwOZU_$7rq7@{=D?2y9%V9<={A+17$>aZE?VX|{@49~N zjykqGwrzLpq+_RI+qR94ZL4Ej9ox38ioTKiexAMeyAQsjeOxtajH*>@jrE`NH?Pa( z!-p0*XwThs;^+fyG)Lf*7UVeJ^Xc+pz18vc@j}K;pHsPdxu_skws-Vyp9JIE;9~PP zDrbvnXYJ0|A8b-yQUpS3iGFH>^YdsJFdD|ov|gtTdsLBKnDO#l<3a8*_`Gg@vT^;P zGfvLVqJ+X;hLH&@gkl$LowK40dcN+_J6%72N~J?%@}>QT$?f;-bthU396{Jc^ZEvw zDGB8%h9q(-oVYv+Q71Nn^NfnaVe`9d5q66~yQQZalR-CAl3VJ=SnvAVGZH_!4C0tk zpj$a*GzP6YVay3i$-thYPsH=fi#-kK!BkMIZfhf@dTlP{c<$TaI5k5QXXr(p;c%e_ z)H2b{d;?)jcS2XZF5npqloAn_{Ny$e67iO0mh~hCiWkg$Bf{3Be|>NdebTV z6IN;74G`ed6$8DDkhSI4@`1}_a^6hm373MTTJX1T&X+0aw_?(1lF9-|Kffw6jQTQ2 zIiu)!l?|xvUN6k^Jg99pMY7X8_8TMq=RxTP0Zlts55GV?iWQ5b-aNZ~-V1BO{Rcnq zrUYJG?M)c4pf>3o=b{+cEEo5$&P)J~gJHLKDfYy~xoF@y{NCJG-y1uACF0QDUrSE% zMk21`@p=4)B;+Iz@CiEJh4>{qTYO#?eq(mhei^rX1Tf+j+6AZ2UojAx=+V5%FIJq- zIB_igD!NHdtGaVSTmSKXecYV~TbD-)6o-13E@?#d2FvQ7G;V^a8+snIb|8nN z+`WpZZ)!SZwi=8;XE7OX@p5)!*9{$7e#QwnXS%nzW^hC~Y#uz}}_Ryv#; zk6>_^1J6g4O=tDr5g~uM-sz{1&qkw(8~t`-z`x(F)oim2&ipVGjWfb~%@hRo9d0C> zkEiPmcvjO=Oo{Rl;mqxN3D*uwV82wYQ$E$xY&H`h56$CvD1)`(aPT(|JM3;DQkbF^ z5)CH>rG1b;-mEYET4T@^RC4%=EU&3?De6tEZwld~I@RKr^eqFKAQCtwpsOP>5mh86 zG!&A1ZL`ypq;!G@wJTtD*+g1e`o6Bwa!HIs_m7d}eLk-z+;oja#Qw+4FoBngldrF@ zxVU(#z<{LwvHh>jPZBElvnjPh24(T75PU3S%jsM}J-uAcC_pBn)$w$m;ZT%*UqN4( zvrxD9#~U`tO_6j8^G*`eZ0lmuwmij-^j4R5E3oQUUVv^J>?CwE730>$Y9kK26&AN| zCbAVwPEdz#r$@PbQptH)cp|LLS`$19W?4*?R&&H6q|eOl!B~9ek`FOd*)~jHdM;KA zH={h#sCwVt(&Xf188E1IoTUoT%`XQT97Gg?sc&fLcsfs|z^GQC8V%j3&}gAj`8_Xk z2$}{u78Duv!-2nW%UKtYRwblq*a9qNGBS3#TptC~Z3#1Ip?)D0hX6nn?IynV#rswC zKm|P0$N4RRf$z<@l4QnWmCv$fj7$lS-d#PPW79ruK?=V@W8`SI@2SS{Cr=$o7) z?Oc30V{I7#UHeyDra}Xl7?|QSANXj?CLfe&lQG@INzl}C3?PWEAU6ol*hp==6*im_ zN_+AKr&_4$y%Q6%R8=4~(uXqLVYtGHcOZ$D-~$4^yz;4x{=VWdKZ17xxjMn09gZ!u z$%_QDOBI9q^FkH442NTqKFUPGQ53g~SN<4ZOI>YsPvx-ut`1NC%}*8|w7RIva*G4K z9xRlw6?MTBw=)sA8z!Zsfzt!#-4~iSA@V5eX2wFBhw{b{|mu zj|lm_Krrhx>P;9U%jvKuRGftb$&8v{=CQOnb?ZXG2LyI5{V>ZP4x5xyAfa#$W@`?bfLZ%jT7Xg3(kPrx%jiBH{?nSdGdF5oe=i%{V zpAC2~euDJ<=(SF;$p+8$(a$FORpgj&ki!`5P%P^I7=njRVb;qglvShKnLhg698#Op zT!g>|a(0B#pZ9%I@EXEFt>ZUi==J9>uw73B(QAr%_UbOh>H_Dp#cRDn1YF({7SqYx zx7n=Jj0^_UIGc>1)kO^!W*lfuFF?wNP zVW(db;EnWEWj@Rc08Kg=_pk3mtn91v5F>F zBZZK!vRe6LN3>lUM)R|i#LjQL_Sq>9S5G!qZ6`uP7BQ1v8?(??gPP<>Mqf+8ywme3 z*&`%5IhnD*;5Y{w3|9{%Nf%zL^jF@)+0q?Uu*-fk3^-uBAxw@0b^W_C$?h{1f1uS; zwa#FDGS_N6VHg@wqHe5FiTD2~>KFZ}(e2!gU0~9O!EIF9^Yioj25y7gCby7c;q?{z zgMo*i$mC+74O&$Q$Pw^;ytxUFiV9*&g7H~caLdMc)KZD@;Xuk4;|5M-a8Q=dzg0jy zOcBU`4LZU>@4k0+btxz)Tx%v+Rn+$P^!!v;E#iQa?j?$24DAZe$e+s<6Bpm=^i-p* zE&kpgL;)5U82GYh)RI2iE-nnhD_p?k8hL6ne(L~8hkqFn-cyb)Rv^1 zl0eAJD=7FO=>O3=LHjqUVuTqcJDX!uR1||s^N-z5KJCI!ehsgmc;&jDHIXrB{)2IG zND`Qxho0i0A(I3;7?n*fBw+}5hZ7lZ{zeyvha2s#vG{yKHUeC#R9r#9!M&1x(0&fd zEOlmovwu*d7DEop7Hgk2>6 zB>&)jC-7166RcML6mjA+kdg+)R+#qv7WkLy5;ghp{`$g`+U9yyy;L{|E=Wr76}&n& znJ@hU7eM_H(!IC%wlxw#|F@svb=M}5Gdp`j58_!-%l z8mSb6kip}>$8EU=hw%HpK3c~Ui#i^TkIrIE2@4T#ilEV$n10C>{jat)3o&&&JPbVoC30yU-fPKi&Znnr zm)y~b6tP@h>FXw~MX(Axt&T?~TgZ)r5_}d*<<@HB)pq(c_L5SKjsb+s)E1u53tcqq z>=+dX6B#MU00n2dE(-J0h~LyYKj4c+MiXd7Una9>T^U!(O@7d0^NUC0CuZ8eO1jvN zND@Ld#`(!}>+9!1Lx(ReE>5ResD;JE3~{TfE^_B=Tuj%Y&OsKIfq`aYW9@%El@=E` zqM)D&XUoXQ%zve4On3~+SIG>U8 z^SGW2xkd)C*~~gU0BHC+c`X+!i_B@9E{TYGOHZv<{xG>8cX%<=P*78!E>zg@wC|=P zq9wCgCU>~sNn)mFWVoKJMxTI9B4cj!5!+_-dJN~y=z)TPp#}c9#f$^MHI|SoORLZ1 ziyo*oN@TS?sNeR4^Ljqc4-6=`E0wnHuhN_S<0d6km5Aw%9DDzGe z72(pYv-jxFW$}t_fHrIkob!-Rkmw2co-FZ&0F&00T7Z@iX}xJuX(eQ)o5t@Awa| z7Am&O42j0=o-X;$7HkQ1?sobiGdQfTpPr5cvi_=JaoWWtxFj$;jr4?tL47y@WAN4x z2)Lh}UkJT^U}2rFcE&Qi+@H)nT5C;Z@dWzh5Og}9v3ECos5s|5U2djvIdXq?c|1_R z^{2SsVcueu|LTkW_=tUhnVdv_eLMr?`V~#W_x`IWg*;580(Xh|n^bXsx8U?cKvcNhN&L8+GzQ7!SOR@|BEerBi|M@Ojt|McorHOx|i?f>E6fG_i=%T3yFvi z`Xu2j%g2xd{Hx#hTY@A6@nWvocQk=aty=x@?ZuIkBNBr_g>U!EC4l#S^l-NHjF7n4 zr1LryopCLozmx0#K43aJ^p7SsjKOwE^``dQ>iGVs(#v045|aPSom!#$Xlkw9AJ5bW zbbxr;+uOIh*tpu;hsVb59j(>=QB+e;TK!(EiPTy1W2db->>n2aVA$)x?+@VrM8 zy}dtC;=S4eygzB0^N8<#9fRjwcT!2z(K!{W^xbYV!JqS+lM4>O`B9yAm!J51?a|Q_ z+eH1+zbT21^3A$)W$Y$ZYd*&AKKL4lIgv(Y@2@z1jg7-zW06OFq6YZh{x(NIi1FG9 zhCNacKt_%ji$?m{=>hNYcuLkLWpH=*uMCy*fKIDP$RAq}>Bsb}w8SfY>R=4s>g@u( zjzhnIm*8-<`FdIxA2oO9!`Y$`%q^(igkWIE{@XHoGp6r76V+*}Oc z1A(8MVjICdqIfL+j*k6kLQx1j!Dz$o!9lC>9r-=`vyG9Fa*5oAZs%(RaiMQF|6n9$ zsK!olGW3lNnG%gNT~7j8wJS$JqlrE_NIBP~yC)0eQ@nyVu9d1AM4zp?}4>WYyzS_Wuj49L6}2 zvq#<|?SJAA5&%RWzO(GEG~mlUM8sbn#{1raVZGX9y;up6xI<+g0BG6ze0J;)ajuOm zrJ=1vvx;$<8#hw_NhQfLS8B@Hd3l+#?XX=^WzB>eATHC;&Y@W? zbep9QF8-nWjw!&MsnSpZg#W=9FoG24x0JCRr$-v}#ekB-O-AS2D_5b>+qCeSKrETIA+p6qNSsAJW_?S;>Y+&s}Bu92I%r~ zf4a=t8j8~W78?-+F@HZxrTtYf`nLf#(^%P4WYdK3H%6&giOG;9iE1ux@ z0p4Ht1~yu%W*o2wW~u66TcEmNE(Amz9BvJ*hI~RR+({-5^?UX-+Bs1ZpyFLzT(EgQ zjx%+?D-?*y7Nesfa-*`t!56Dua1-qAU2S!{>V5J906A$aM`o*17iBuF=Lo}4fL|R3 zE-qdC{C4#EsT8u=r^m+w_u`&BY6l4!Yd3r1b>Y#n$hNlfR${aj=l|3@kJyQcc$T`h+g{A1)NO zI^0>jSLboL;Bs^F;=V4^7pF<*D}SPK9|TS;O5}1xM0!?Mu(_-$i^OB`C)2sSAGXd! z>RUXQCcz*d?dPDuoQ@u=(pZWuu-nj4P?7q1> z!lF@rb1t#4VB0zLK%Q0b1VprO8T_g3jvH36-SY6*6!1Bx0WF zpFmmOpvQ>;&dUo!26E*yir#6aL&W0s`dHPiTD2eYu+i@NuL(FW00NfHZ2DIlG`K){ z-#D%OO#X|ELzyJrSVyIxKS&tpZw5e~!QkJioX{RWVr|fVe;?iUl0sA?b_IiIwM>E*^4+>A-bKftmd;(pFw5Wocg#T zdMgf(j0;GJu$be*04{5wjHf4L7XowLv_pj|T?&gcMMA^#@Lif6RUsd3{^4;gQXVdk z3oc?E$@lPr9DeU|`@LeRs}`YH0n0~iH@It@)dq9OxsjBDph%2KHHodXFIM(2W}nA+ z29M_#w70$(zPG+ao=Ez3e>9MhWy*mhkjZtDcH}UU9&=!uo0R*WG5_K1C=ye@Kn%gv zzO|)2xT|J4^qIi-Jo7I*ssigAN(4Z`CXV6o;86Y$&`Q3N0*rTVd z4@jKP=enF?5&zS&JRx=~?O29c)o2efYm&U*tROW-SrrG%btEJ9;TrZn@^pYE@ZKL6 zWJ~UXGn&(ItFFh(MPuE&mta7I(bvKepm)E39nJNfO16wY^t(e8JL z!iz$1vqa7z%sE`%BZR%zp>$0Y4Hui9>G??%8 z1xq(P6&;tI7n)jg;~gPQR2|E#u+Z^!AZ;p!D?##Z!pu7lPs+9iN_Z`}Pvk-5{r>EKE!| zueU(&9>cw9*Lj_^)|(P`G`dM~3r-MD*SlC}5!$=o>hNctbEiMyJ&vq%X2o z6Y)4!qILlHgI9U1@*_N)UJ@cyqD=hktzwX@HcY)Xi{kg7O_3fl_{A%>iGGRl0)?nQqOsBG`lLV2g(|K?N$=p5_T_+Y6Abd~P ziz1!EWM2{R>|7}~QU@be;yozB2t-jl=F=N33Ve}0!*FeX z_OP4?yKsstH)1?Y8Ua-2OYT=-E{{6gZUV>)Y+iVq&+EwBmvCx)jHy@AAM$TFEFlBrsQ63?)qW50NMe;6S2O(<3zoH{y(NZ%6v6ko zPF%h$rhe5QazQEA{xw&H;d@16TOuOcNP_@T)@LP`=MJzEDOf*#P*05X@vKY< z9c8nW=)XSVB@Ind`O6IY2Yz;Vy%1AU9*?KZjqqN8wR8Np7I1&QApRihAR)Uw%{v8$ z<3|bompsNoH8OLIqQoI0g)*MSRE`d^9MBDtzdaHhkR1ab`&Eo zecHiN^J;0b_9TR;;s34n)j>nWKShll6i};HKfE|7N)_2&E6aPf!lZ~H5b(XsR3yrK zv?rWFY;v7$heraBS0v37Z$$Yizq@0P;>bMLWJT3ctE!dVmJ3zDIu6lh(antf# zd8{qjhTsrYB$f1d-!U~0((jM^kuV5rIbUh~ubjEe_GEoqCZ%j4D6tg1xf408H1!xu$vH89$7rbr;JNaS60p&+*Xecx8dS}R1;Z9nuyudk;(CR!4tD%l zT3R{Amhx69NKEM3fgv7lZ6S2U6nR)hMJ* zs?rP0NL%a=u1br>(`)Wb+_5oxL?c}YvVB$oR^(y#&r$+su)Z8{U4i1BQ%YKi) z_fn0{!_AFJz-xer;418B@o3fC zo`%3}Zq#xVPS=<1N(F5&Y>#AB!R(;01EShC~ zjyzwZFX7>0@$2A;d-W;D-7ihfTei+SuQ5L?plYww~mzD4MH3oYfG!dq4N>r#;vAsiw z`hhw*b{=0`1jjXZf9_phUmwzGMlk&nkxrr0DP+BtLchQUO%_UF8^yxHDwX50%^t=D z#c!lg?UM7%n`5OI-kOWN*`vVccI(-APvE@3HDC&aM4aJo_nEB@$UMZX>iuL|Vr2U+ zSjZ#Mb0%wLRXLlGY9{L2yQMhE=WM^-AmJ31pCd}1OosmDR+hRu80!XJHPT{Vqr_lw zmD}iaZfRRs7yvVax6d0Wa+H#Bx!|lL(eFBZ3AknhAYiH9(X)qh1%-hRICgV;LvyC` z$gNxTrfN;+D?aayRjZw&tzME9xGJYhwqd?g!a_(o2nYxcj*g;eUK>E!+bzW*X_XqE zz@4^38r5J9EPgU8=9&5OZVInHBdU=dM$wGs5BNNv#(Z{YDNRkJK8MCmOP1go)t zN+-y?_y
7_DG>4(3a%^UngT!-bdZV($!&(8J^F5D0P@Mz8qNGdg0B{ftKmb~2X z(6h@eY|Xh0Ba3hcWcx4S+{;QycXXn+ZW;9bG`7+3?nqj4->wKlOQzIFAVbG0HyDiQ z^m+jWF;9k*`u*#7@7!GCNo-aYo{~)F-k@S=20ngV%jFjT_o1#f%<{W-;LFR+O(-M& zLiy71V%4y1_va`YYU%od6Vyct16LA>C=+bOk+)9~3_5+3)!T@|=a<;{#)Lmvk(|-I z_J)5`H+?g|1cDVyOG`9xhk;!hAziZ(jf7~r8jWBFKRiC(rzS`RmO%gJ0!5Ukfu{OG zP@D_%{xWOrIY=1L+e)0%MM+93a*_TH30J99LNCQ^dFXV~0oMWdbhTv&E?B64f1yC6 z;qC2pd&Dr^4DcN>=LbK3xkDrmIZ1+U)4m}0=LU2+(y!(FUEeHTdjA*QRT-4{Rx|<){ix0JZ5ffh z$Rj$v`)zi0Xz2Uk&miE#nVO8e;^`l9NZTvhx6Sf{(IL)eev*x^6?8CfxO;nX zWL3k*>N^OG%Hj9MFi=^wHb|%m$UKBJMX%8Rlp5>oP#W_l3%i1YcI$M1zV>JRd3<%{ zd%JJ%#Ftc|JSb05a?99UT}`i;=LVdYOzmej9{VdleZZ_5geWX5Tm<{VOK-VUnH=-L z=Xzff&e<@ukBv2EpN{!iMyd+5Ar$ubqx*5c<_y`SC=Pq z#S$Cw10sWtV-_WqM`qn#%rxQ@J39;twMjH7l9;!q<v8UslRT#E2;sOqf_=;~DV z>Oa2h&XiNZcM%lThptsu$MdUYRkpZ1h&mPN3gF{~ z*7DSRS>m(&$YKyg2Y6;gI9#1ju3fxO7ZJuYBFuC z+;)CVPh800d8lcfBJ?}f`m2y?V-(enyquTfB60a+A%-!0^;8SHao+Px@d5&f%OED$M{Q7qT zCl|3@=D-am<#;-qZ-1`xl4A^MRxwwR_Kyp<^%jKC&~Eq8z{r5Pcn^c}%jofnI9Ai; zWll^X=m?In%N4R}Bn%9X=j+}pF2CP|iikgiYB)v;J{m0!F0`6qFq#beV{r;UAd0 zTQf6d|0gFKl-ol>&`Z2)^Jk(pzrS}c-`K__5p`qc) z{N?J-l|P39hurwz)gK!7Sv<{%1nezh(VEjfHM-rqcTcxec1g$6c?C!Bp3*6_Hp`_7 zGx<$EKC#*iZMo&X)^nWPAASrDuZyxG>oh9WQI7Ks=rL}` z+y0-?_zI1h54hjC^zsS4J)cfoKR>+Zp`{e-6c0*En>nfhp^Za67SKu^A^g5Ez)A(B z37n|-X8Tq>n0MIEiIJfFgA0C zTdt7#AljV1AFB&AECvQh53#Yaj?SH7mK!Cu0(4hxN^6rUYJq;@{WwoY1T8RTk8jxl zJ8IszI?!C0^P{8CJlcPZzOK|hH1M8f0Ut*vhrgLrjwO>i1EwXS zPLHLEGBPr`R$!pnRl1$!77G?nR~x|+X4AFEh5SH^QnUpQ*>VVIvDEVlwVEhgrVh!( zV#X{^V$eA}Zl~2c;fXV41*`qFX4^Gx<699YDyaAV#r`%8b@lm*l=ZslgTE_h=h8Ew zTOICuQ+58esnZh2=miA@W65-CqG3*8T$^FVG@v;5w+Ca-ZqcYo3pHDIpIx1%Q<-8{ z)O-N6Zz@NZmm!&6n;q^oI_)rLs#TiKUK;O9Qp?_S47u?M|7%a-3j4>Cgs_=SL!k%M z|CE3zvS4s8@x%Skp*>e>pQuzK_Y(&;y3i_h8>8>yWVNxqB}Vf}Y*Izy56gPJscj+6 zblfzkG#Uf;ClNkfxy^O>uaw6Np81NcpU%c^qKV}4 z8+G0|Kr?dQ3M*4JulZ6YO?>2e_m_R;7+jxE@AE@hoNaYCw{<-s-WK!vLq3oD`9IK= zIwB(ApcuRf@bU1lkg?V;5h_~ER(;ja{SdYK&ZqMi3+*+NrYHIvN0VB$j>GKg_h68Z zH+O?gcKU3xw^ig{G4D=Tc4n@o{#Inut%OAn@3Y@xz=#_ z%uyUTvZdBX7E9-|jT!oK!pqM5a){}zwp$<$CesV0DXRPgh@vF!ld{ z-=#pap&PmFXMf-94Q;l1!*Yx~-=HeswBSO(qKDvwQtzHEqcd#Q>hEc`Z59Y41-a}$ zq3lt}r1N|KO{DmElProk)paPdjpg=wmM9F+q0baw8os(urqdc67+7!dsj5v%@+#s0 z`gR|T<$T_kDXlvRtxRUKAnvHio_zu>*D5vYv$)+s!#&8snc`(dm#To2X#%%2E?QQpWXsC7Ipce2| zdGYkSXG4ub!1V)#fFXq2IXpZ)*H$R@Go^CXU~`w^PUCf7O`gD+!FraU2Y-5fA~4E# zmcnd2W}Toqm^A~zGyq0v;ILPU=R!(lQ|T_7gexb{*DIcFj=z`R2Y$IedU}4X zt*x^K;AU|Bq43zOZeipl$u)u2`a2C#CMlBt$fMQciA7me8oRp%%447r^UQPn*`c>Z zf@FfJW*KDU8vwrsUf&4>*hlogRZJxaiVU1_*ly}kXb#~a3DEuj=9u1d%fwY+8|YCE zrz2)lIw?TzS^5O&_Cq@g^7C~&J&Y|aGrW`{l`oRUE`J?1H4U;p6y@bjiNg`VF!Ac| z8j)ieEPK%5X-c`1cp&5Bf9#54?LLe)Oj0>(sI)?i7i-NmfbIZa39*->{o>HGP{f-e zDK3qD*)$>wb8w3ZN%0cWu_D5g`bBe)5bf8B4r9;^5clx}5h?htS#Y75+pS+P=6@+H z<6|fP`LP4uzDq;@F&_KwE9%VEH{0rfZK-f2W5zf-Uqn0-`kOLug8eQHK?z#_F`e`h z@Cp%Z&Y;r9D$Q;;VNUN6An|H;s<2>SWMpJ})sp}Z#P;@NR02t!9XXel1#h^b5bZ$j z9HHn*kd@L=CCjCLx>#e;+t$mnB&MsZIm?Is_ohQH;1gBgk+1*~O>R=4AL!I_b@9GMg{3R6H1FGKvt2*f|%DSFq49Zc3xs=Zlp_2t^D( z-C)l0_Vw^XT zM|XEPQR3)sam`Cgx+^PEXW$|xCPoXN7#DYay#uC%s+2)2s=m5Ck-=6&aV?={kQbn9K1fg;pb?=ttZ!jT&vY^BFmD_w&lXqGAh;Y!6&6AmVyb z8p{zW-M9A*w|5{qTv5H_AT%mPeM4_m2EOIe$3}%(4nXtsuVNUhTRPR)2iR-1whN%e zEWFB^Nen3nhIBh|aDgU;kJpBin>!rT=k>Ab6PKT~+7H3(wTbSyMXL!Y+O{(sOQ2Sx zdvNHX(1x^Ve>8c~FCHaSLaXDm@;-a9);yWbQ|~l-yKZ74W$gCJf7s*iokXKHL%;ow z&(~+6@yBR}c>Bd)Ic4vh+v$8hS5Wv76$uTk#pdEi$?bvC8UZm>0O39oGIE_=Z^v-# z`v4h-t;aSh)dVt$m?e|NN!CKeXF#k%xzmXvnKVfYpx8;{Vr&j=u2_zpon6c<`Sw??wWfzhP~D8`3lSNQN8KZcp9JM@$?S-4|c`Xm-f<92-w4kOg2d3%p!7c;C+2l zojzcjr4s=jUImV_#n<)$K=b~F50A|pRRsE9^|;;X;fpZ}4$kYgzwKs+yT%U^d3?tK zG>43XFI1fXd~a{>T(O+T>tj`5py8a!g!FI9c)oT)b@k=Z#9?Ic9Cqv5mK#(W)ye^8 z;-{;PiTHjb_p9+VmNAX&Vn4Eptk)xEb%7l!RPY;NiFO@dkNvk+$LI5nc81%XR)>QC z+8U)2n^1(T+Us3W%&y542EgG&7TAMshg&!VYznLSY^7eWQ0)NHTip8|h0GU^Ea8r$ z8lbe=nv{eN3a-^;Wl$DXWzF;~8OAwZv6ZX!gr7(*pVh4wUuR!^cQmQnwoh(~sIQ;r z@9)p4M0YkYJp2j-UhnuipDv!wHfEgud=Cl&FVx=b_N_6eSbqDL_`c*r+o*T6j3J0o zOffSv6J|UxIB2eMbTEbqL=q}M0)A6WOfH8V;>{MpuM zsmu`+KI49Mc0S%3?*4R%ml)lpdNNlcuem6WXkukmSU$@}mjd`!8G^4nR+eYzdpkhD zHr0?n_q*^p!~+=_naG>*7kZXz6c|me8p|}ORq~_GC~bn5qE{%8_{hX)t1bApcg5V^ z8H%_-2>barDYxbf70&($kq#6h*yljbhzSWDqP2keE!x7UEw!@U<(BXEmhWey_v<3H zO2tiZ8nbcVQR$4K$yB=g?ZA!0!THWW?G+U1dyju$GOb2~>(u3H<5_97-Q)O4qvfaW zA0zki*VJyc8X@0^;4op=t4&meZ}yGi-RImYJY?UW~tA#bPhnd+7) z7QLi1=muG@H9Zs$!*3>OX=pW@&mqhp*{j)kG}*3sT&`o^KfkG0X*I{c_-VMgaY~(C z96)=9*oAin?rrx9k5ACmPJLy1oRP}6XJ9ggGMX-)g@Sb0ZC86J;6ddMXmT}gj~ECy zUjB_>g=Qq;aybzGo~uwJSb!wptgE{k{t12d4V$;H=T{9-0V6|ACZ7pz1zq7*Dpd<8pXonYh;p0j7Ib)FrGG_4NW(> z|3m(cWQ(HaSMtZc-$Nu)`QrGJ@AU<>1d~2f+%PQ6E&57bu|_vAyVHXRfvh_Gtljvo z*w4@O8Nn6@;%uS4=JXAryoX7Yti2tutIM{kyXEtayt(U}v@)Bz{ZymXt!gE-xX8}u zabJ0T(NFbsxiP4bD|F2#olI-|_xHtSK}nX5Jr@}f)Gm+R?tuLO@mNaDv~Lfg4NOsk z?G`fE6hlt;&k~}6QOxqq*G`Wodk1QUjbEmw2;q(T{cTzteEeR|xlYs2s1!7OheI<< z)MeG5mKjY|nvI9Pcmg)GSLxGt&6aEFm=3r5W$DRdRNtaF6`wMitpMFN(G_Q+PWHYr z8Q(~XG})b^#AETpW7V9z$)uBiuzMAra%;+jCkruSvr8D={=%(@5`g4LH`4|vI zO+Mnaiv~K(9Q2i|(1=Chsx;ep+iP(wMZP{>1T*xa?bt>(S+B8KFKV|rUZ_T`rHe); zx3#r>Q&%Tnp7VK!>`^SmPaWH=vNtjf@#*Y+Fza8-N%PAN;7J6CG~vgp$HEPx!I z&1?L_fsxyl)BP44KtNDvDv~%XxW?%XoL{cn6T$pF_pg%vdS##%88ElGslR%kwrfG7 zMk^j&rPCZgwOn#%GQW>ep1qwPd{?8>9`Sd0=lSRS-f%1)4-k<$6I$M*$$F{Fevdqg z&)6PZ@Hc5(%jFZ&uu@bwsYy?I4NJLb$w^+CJoL&bq9s2~$)|TNM{;bp+ z_nY{O_4qN)Qq93w3V}w-ALB8ifG&fVSw2sf@H1N7j_c(*5|~lVX5GN=@Cs>+(To%_ zn3O$HCrVx&otZSMz=M>&Y0cFc&ODDeY|y9^-HnnA2>e_MbNf=KXJ;~!~IDnv-wa@JM{~O zxEI>Vt94u91PTlWBkZ*Qm~!|vZniqUuD90TrDky0&iwh6C&YMqcQ}b5dCbi&Mjx{A~qJsn@F62t^i z@uNpTb`0`JV_f0k#%)wgJh4q86nOz2cjo0A8X6+suQy+9QOO}YAM6NK_C&`=U!!Fg zAKb!zBMHu)7#YDTu}v5gY1C_Xy%1sI_mK%AvRp4Ln|_dues`hFbWzGLisQD9LxU8j z@B+GdMoQakw2gxEkh#pugkkR0D;9kcBNGu3RjAcaYqv0h64xp*ia}XywK{5P3zneo zWpaC?h^?=zw2DTmmeMcJX|R-3upMj4r8!=nW4HJojHP_qs?~R`B#jf7i8B2%1}ZC$ z9e=))m%Co@RO+s*NcKBni-v&wh@SuHI^y3@RgY?+FXE z^v8+dk#n#SBcGxZ$q4cg$MW7Sr1FBmOt~{-j{{z#?1&JdG`LKhX5i!hcjq(!iyjWf zE?uuSPv+Vw?&VLIjmuT4bW82@Pq-NXT|K5RFq7#npd1kI48OlCbH{Su`P)5HD&&hS zRd2YSFMscCwVO%6-6{qu;dnxZrX1-81&5Sp!R0{PfEZLTj=q(2f9{2&4hd=`(U~&v zqla|y|7-DByv@YQ#99UeS88`Y>rLs|3iu;r(r!3BIUNTT-$P1h6-&tURG}AVM++W^ zj6L(Aik|8VE*^J$JX3%P2mtbW9p{!Dd5Z+^KoPS9>Tq#!NB`gxTZj2>vJ>E5s|)o` zyg80Z41|5HvsiG*M)7^Tr>Zo}=5;@u06@JCgNO6GPSSG1k06Gbd&MWwBonD1bX#9< z=h2oCPIR5tuyru^_xFdw%Mo%i zUw?*vqviKG7w}rvV_o%mhrCSz>va<@=vyY?>UpYlzZ=>cj_Lb(yX>O(^ILLHO%|VN z_A%>TLa+V6WF3(J%$wg1i5L-opBmQF5rKLb_Y%J`>p9;nxdfC<4u2$rzagLFp_A2G zl_#P-TNa)5M0&S<5;DfgbR8-HvifBNS(w9G8xequfI)v85`9*u?IAb=H;onN>fq#* zpn(!VsED{;(&DIpvLEZEarb1g{(8aVbUs^z1L%7V6^%Ts(P$?!oy>ILb_9>lqb3^F zGj-qo)~C1Gu@YwZVLN_h5nEBC!tH!I5RJ2hhl@ucm)XZMI`SfqC4ljw2z z%Zdfd<7^em4<91A{%g)54WcK!y{)bMP)GFb?F|8U+0-Q2^drC*9$TG8gr%s$oP#VK zX4GhzkP4lek~ZuLY>Qn+|K|?uMyofhvG8wPNgaFKppuf3+1HD0Gucc|4O|C^_7L1} zq)rgM4+(~YoUT{vy0vz7oYGA9jQRVNbf9xmq6-dxGmno`MI%$pR+HX{%L0Iz?AEvK zrKNeop-ZI-i460t!UK)S>#ZIS4p;o%gE~5`yZD%xOKr|L3neVmsv8>&FF@cm=1Skw z&-v{wOZ&M%`TQsg@s<*?XkYR8;|}Z>p)8xcS2B(U$)KrXxeOv;r=9dQm+c2}7r30^ zzj-+IG6!QZBmt1${_>L3vEykx-7Xj0cF2TjNX)og4$C=)zIP8d$V=f-%HY~`U5_Oe zb+y07b05itChml${g1`+F7%`4Z6dSLI-8!art`pPgBekW{KdPX=Np73GsW!C26CkJzoz3bv+l1jZWr2%JBVKDSColIb*isnvaPXw^UkvG?J(;lmIJ%x3b*DiI7u zd2JoPLRZLFm|6I#J^KCP21n(FXipa#fi3*RY+suD)$s(GUn~a3`HXMD!75}VTW*Z} zr z4~Ons?5mz+d5_ol<}_L5^CFt)0hzL{otDq?Cdz6Q;ZQ!H>zs}vd)!F$*Drg0crEhN z>us5VoBKz=^z!m^@pK}KJ~^8H{2E8B1mQEWw_ZfXHYS7ab&f+NR*A>MkJ%J(waP-I zP=+A!=KCU>jW$gevTg|}%3E+pQ%Fci5Fsz4BsEUoOI%g6*@m=J zf9l_AZVVOkvM53!HrUY{IS-T;ZJ6wJlnjw}uh?ZKP6x-@F^!x}O^!QI^!9^4_po#3*7g}VlKcXx+VeBb`p-nZ_< zJ?B>4r#oKetgfDONRKgQkM7^>ew{2-+;BXYl2&4g?)>_PI3)b5k)D^H&G!NenkNf} zu8adRuZQD?D(EXWVTWgebKntvq=hMWT<6H2}HVpjh9b9-c=f^GXUu6;c1I;3g> z8WJzU<*{*RFKn8-HO1Z$+CJ@nU#Z$a>VP7P%Mw&v&-m5C+9Z(HFQWnVS4GC4=I08} zseU8%Qgt3KE@ubEN)PAS2F`B6@gO^F`7~aGW`%Egz?+YlXQ;5~FO=ZF6z4^9=UVKt zVwj+VjuH8cK?(~`ms1c1wf?)Bh&94t#JJj$9XNnA&um2F;o(#yxO9Ro9Z9!Fv zd5`0e%JV02!_sb=iZ7L&WDsn77Y&f?Go|fNe3$|J_)5&VmbLhYT%gQ<7n^2kf7e&=-E-Zs?KZ047o9T)Wqy0;-cob7cLj=f5l zY-_Q#fe3q)jzTf4@5(&oAoL5aR8Xl6hnFUY9(JjvkSIb~xEXd=twNVfqS;FQwj+a* zTH}Y`XJ==K7+%V)V5;IIk%m-)r-4w6SM|pa!FY^XANEgL+%H#}og+@i7l*LbN4y33 zcqj~WW|+1RUfiYn)3|&oW?-&uUaVJ2eV!2nT&#$=9C)9imI|6=6By~x1^}Rt(DyBR zIk~GOynib7_&kr_Wd$;$`b#`nE;SC+m+MB82vl2ahHNuvtSh=RT_7Ne3L@k%Ox5x= zYZgcq?Pxw~-eLN_@U4%D_^viPb2RJc@1FC!?$c*-u<*_6QyH|VX=yW^Y7XVb(Xio# zV1Z<*^`?ab(!4W2C~<8A-$^2Cz@lR^MjaUDGbigPd7|JlcjUMD9GAqz0FxrU#33#u zm{YSVniB!H-wW9BNO%Tpb5~v(6tV<$qF=TnfGn-wM}Bybak>?aE=buc^=}U)eL~E) z2-d0l@#=Ut{__0zH6?a0IwE2bV#cE?7jSmk-csa>EHt)Drb0zcjZjY(8=G3QLwvt` zR#&Qdw|P6>#cNnsLq*5t^VIzX5u*`e$nUlH$uNMneXZk*623jjF;kQznO%X74!8iN z#P-dcBJ_0`6%}dqCq?IsZP;?pwC~h+K%Z6A*BLq@s zH6M;4yh7)rf*^{I0&;TO^y;71Y(19R6U`yMzTkbyyOHN1ey=*3#YcjV`8Ae@LZ8-0xV02XTo;Y$8`v3wcDT8@>2ZC~C5b0vF@aiO`*#O*D_2a!oZdDCm7Rwd2k(9Z5z=beU^*TQYM~NYMM-z90_}!rf@* z;z{yy;`h6cVPLnY=l1TB%>4w_shN7sQ{fND!|4KMOLt*%z^NLPKM4rTQ_BLbYqAz} z>i;0w@P8xnuyL7IuQT-Z_WaQ4;MHW#V9X&ZeKi=0d%w{bh=8^~YYPB}KzP41{5ol- z$O183_-|ZVuL@Eqq+nx!d_{N#b8Xx?P0jbj7C6fOYrTZ!jA;>QIfWuxscMDnto?kMlm#D3@-2V^Uowo>|Ku}bva&9kQw`5CeESiJ8J)K(P zpTFIAD#Rq2pVqv|?zY@Yz_90FL1FSW+vS!pOd`fM-j8CVBSpvWgB3x;v*o%qj=L@& z<*uhOqzc!`s;@{%c>)3hmzMNsV;!+Dnm)?jI0{{4afCWjk zX;&H3JrYLCQ>~dXYO!MMPM5i(Fy#mIE9-192{Nl6mEOd|G4f~7?3cmH{349NVYs>2 zjyOBZYq_5s8;gl)4BQX+GE=S_v+DC2Pgg(-DK%wa7VvIKl)Y}(j6I=MR$*%)D~6#K ztn6`u4dn6aF57P~de~fj9}de^{BQ2GL!$ za9#bl+<6V&Iu2*pQ2k5bm-tyg6ivkQApwb+c>PJ(`|)zZdx1flcG%JC(IDldF{4$e z`|@hPW=)cwM8Q*bQ@+1%QFciF_kO^E?%P=<@#a_{XKWQ}ds?bl=~PiR;Pu(zPvw}= z!2WQykSFprI-QENo6`IgCl7lD^!X-q;kJU~xg2XoQYvBWj!OT8BB3Ryg4XN; zsCVY9{`=PE%2VGaZ@vN$ll0v994{U_?p~CT;78RAwc--DQ08HtAn(uZ9N#WXOjZRf zvv9PS_{-;RX$Hg(M81B_=V)uof*%(Ej}y2)(euGJz#Z!Ss39DN zXLh=>yoXy}R_-oHgfz$2$yr}xHU?@@dMG(=uw7-=ul}Gd>}37dLWI-*Jw-qzSh!jZ zu2=zN78~h(wMQOV#G6>8Ln`d`DJu8z@iAM_ofx;dTG+N+lIb^I2!+GfS7Ced&Srgn z*S&>&ipsg>>yOlk=t;FVN~12;x-5qEK}eGyMTuk$1!w|>*t{1$3cF>+r=+w8nLXW& z>2+Eb*3wpgR@wgtD~KFa>K>F1O@K#CY>RmN)sQyv?)xWNnF@L%dm~qP zG?8e=$o_=x_wbFT3+UDb zk@8e*9!SyH`$=BxPtxSiht)N3a*{8Fu^w-XyN<^7px84pXJ`sy(i$jP$ToUE;%cPu zJIDQVlqhLGxjLFA@}9l3H>ose1s-3D+A00K>Nt8G?z@uxc=#V%fdA_-;zOt^jbi(S zMoFGqg|33x$w7&m>vGY=>*Mil)gFS`Xxe1;G|Pfx3x9bWR^1N<1VEWCMB=0Vo^k`y zx~GlImH6sA^Bjp09l5>r{ElFzbRN^ur`7!-HYz!MJiir#A0NKi6a@vgQWzK0M3W6= zi$L%8-YdT5e3d)2^q$I@45uQPR#nIcdVBN5Zp1^+;Onh!jtDl&O4(h(-JWe;_diwM zB5dg`HD4dFjeLa{fNY$3o-RW)n;g{7`Mh?+6AfCCMLc&DrFRa=!I5ZazmrkK!k!;7wCaBJ45v;W&RYJY zb1aaxsn}!r*@=@KcD!f{WBk+ZVJutVaBuIMsp;|UUF`1jrFKP>E?uhHUob~G7_r47ebjPDDd&(mGiUpIf3Po8-v=!g@l!syvG zMqN4?fQJzt06@wE9Dr?B$Vm`}wO974Q+IJwt{aS^+tU)m=X!%`CA~YU?|4YakPkW) z_EwI9xSqcKi#oHhSs0^PsVAY{V7cm#c$Cg*v9ojFak{cR5Kp64X#n@k)dqnWva_<9 zv%D9^MDD2n5wrOFi{#kjZ4$Hg(jVpA9C7RCC(4jJMK84`> z?#QJIIG+UIJX{m`oefUW#S?N{eKR&D#KQ7=e&Av}U2G6p#f+v#cG(#&p2(Hp{4s?u z{$FrIwMwT!CqQ1oDyYTZIGfk#>4?wqKG*p7Sa$AKT2`$bH8nN0c=$*08RP2%b@fsW zi0>)$5(EK_NOY2v-wnox@~=ER3a;1(i_4uTKGYX)1uS_IdmthrqHrK{***)jR@mdJ z|L)v(rdSs*>S-O$@D5^5^EBdxdDh=xyISu&xD}=ai_TVoPEX*{nzd$})3iZ6Evz;1 zFF1`@*bCGCs@ilY1PSMEy(_drA0n$cIsez=AE4rMra%KCJUoyb6X*J-_xi9AiYZx2 zB}Wm1G7DYxqw0iLM-3ma4jE66h(LLnDv%erry~CVth1Od;VK)iFHgr~(eYD2rE}T~ z!ZR}J=nxhBt5fL%cRo$j>VdLjGCL}XK?WRRy21EkxTG?)Ka7II7&Hdee5LZv;%?Bk z>7Gd}$i$woNKE+rdg`$Ljy=`ng{NAlj*#_PNomjeS9aalWeJiu$}glm^(sDNDhf$d z+rN2qs01DgTTDOI=BiAH3?-xKj5;|@?(U%z6eSv^=z#&4f%*CQ8EA=q*L)6JtQB=v z+t!$8r5xp*b%7+avwhs!26Q4&m?9QSi_Ma94I(+#P$P(qRx)UeJxOF-YfP ztkb!z3rv%Yqgw;vVj^W%XEgF1aNdr3xH7i0lkOaYd7v;XlUHI4SP{pa+JX4Oj$jFz zcC%FRdKiDtl%2%}8(_7cO2;5az#Y;j*h<7REG!Y9rCR%Ekym14u|lMg_SMHuHXfdA z0oym9AUTTdQlrlow_=>Kp`r0d{3j=I^u#5^lE9*~R(S%y1-Q0GQ2J!|}U9DE@eOC>r-^Tzb@VS0OUdFErdPfPA7ac6tEZK!0z z4x7E#%y^?+Mq*r@ZJisv-QAydW4Z-0X!NQJ6`%4`&Cm(E0^lRoc{n=g@fg+a`b9$T zpIq#`j>GY6=l8w6cCw4R`e)Q<-*eW&!|J^NRA{Q5z zk+cTTmpDVLfKP33KY@GAuY6u-JMuA$4YuoKhUkg!a_KU)ac_&wg-@26MuOah_+3wc znNAXBX6VcC@C;5YgMq&#qKWkjE7BPA+cc1tsUN)F6HE|Yex!O}+k)#>s&1p~W29&M zAJD*`jN%7zrJK# zCf$9(pMk9u?*(0`NYX-41(QC#y!*nUuRS_Ki%pC^;xMogJbpG=Fa}D0dcLhn=dtz+ z+hxi7>}kGG<{m>TL?HS^{7q)stPm=)&XWCj?@mBS$fsG>qY_$FMElaIvPIw1Atv(~ zP7*18o{)gR0(s7He7n`_{GI=R*ZqY>s)taN_kpRMox0_iG>CJRL3=n z!|0NbEX_0v!}qvCjGUb3COJ{^fP9m3y(+;Nchb8rX)dH>WOyGx2DJyZU1olg4!{Np z>P8TI-(QGZSux7w@25)3uSJGn5V4z|VZQwBP}_KZyzMXgNUQYuK}UzW<>l)|-Z^WK zd>VIM6?11{Y{z{Tug}p$Zm3ks6PrthlQ)QLrNuoEGqK<|L8CS%Yjs_ndP!)6-Rfv| zplUy7yJ1b9$NsgoMgL7M5_xsOaVrAb#P8om;^7~a7qK`KgFeN5R{H6y^vlu zzB)3Xa5u_TIaWM8iBV6>HG?O0%?B03H_Qfcb;8rqJ&#IQSeTY}yc1|kI%12UyqIdz zRjf)K5)uN`LCkCy;L-YVD{GU?+(bUyY_y5XL;}Gr&D)P0m;5&#&)4u4E{WB5=I;s_ zow9IsE*8_|!W80KjKXs#!@Ye~TBH_S!6?fRP1hy)n(t$##eo^=Yy<79qgg`fNw@p} z5k4LvJrOT3i~8UI@YFSvlfUjaYRgKy&!HCy=ihgCU)ewV4%Va*ge4qElNZ%0>Y%g8 zSRV%m2QN3;TOT(^>C}t6i0yFFznYygujO(-^7r45)JGQq5aElw?aJkwu;^3&^ToL#TBE#o6OF;8}9&wEWi1nI`ETF0ESS*y? zkEHWZ%M%-F)VC@JqcgD=_G*j9ab$n$w50H)f))jLc&7xVj|-e6ckz;nKGZtu>{4#ef;EJ^wz z3So3~G$*1FW4w2#_@J}ItML7E}s)Qe`;J?IJ zz*iW>e~I7T|2M0D29~Mn$_j>{;1ZQgd4uh0Gi25M`XCVm0_EW;5{TK^u@0_}r1Pj0 zTwHqx^n5@;O9X?fNf6HFXR7l>MpIq_LB+w2uU8hT%L)(~5D4UPoa@w_aZJ>l6yD`d~$S%*d z$@pyOX{+bke<@TzAaL+2@9xe>x+d_UfjTNZfmyv-t7=1qdOb@TL=Z}FH}wGn?k!0Nu{r)h3p6f^eanhgK|#E^+}_Fen3GBO^mf3X$X4h+GuJw@#3;IE8n z|AFxS{oUhZzVT3jlTjR#J0sJesISRSe+V`pwrbMXXM&2KU+2}xu>BssPnWo1TJn20 z9h~aJgK$Tf`rlScUgeE48Uh$uaJ-40UPcyDoId>X5XCS2)0OMLyeK9+yd$liw+gRj z&reUB$TM~W6`$5nQ?{C_Eoye2g-;Ay-2TD}mS}0QJ`*V%3C70qYhuC&^mGaTyy1rf zD2KDcWwVo#o}_czv?F13B_$=vr*jkC&95yrIod8aZ#O(Yc5~)RMzxowrq17xKoT*s z_?;bQM@d9QOKkMzVEX(N8s7<55!VPZuZJcM3I*IitFhso~+c#PKB10m+RL5 z5c=Bw`O6IFKog0e+gf`zG(y1=8JBS?*Q}D;dV&5lN8aeM-cVm(KQ~uB3dKC$X1ZDa zbcHOeNywMSYhMdf- zztm!pON%K0dDj&Z^HZhsELggJOW9KwYI7Wjcw4u~=yJ3a=q`hBgarc42Uc7TbtA2= z6Z^xqOxH+iqNk{!iH@l3`^E6C?VfqwP z)Cv{!=}Z&|Ql@*O4wn_OabbT427bwodEojnrIiYU^2ffb*>QI?T*J+5S0ykM)!DxI z=hl)zt4A<@U}300r=}Vuy=F1L!xk-L2)k%?>CdAJR1)?gxm5Nd*{8;DfCD(Na)ZsH zh0$;Rp%ynlqv_Ch)14mKguRtk8hWGy**gOdsQabFtZE?;s8$ZApg zK~u>Jl0W359aeLEtef%{wld}WRMAdBef@uI0c`NdPh2Ax*~wYL$NZk7Ef?GRlyy29 z-H;Dn$j72_dPMhI@rvmU=EYSrt6l;=%gtPc3RzN8QtkZ<&IggC&rMNLQS1c0ug#{H zVc30`TM_q8#|y2pvo;(0&z{r(<%R7uZtJv!s1{E=(vMez-QBh3%93=gLY_BHgSYWm z*w_a%<(f?nrjbo<=kEe`3Aui>xt#+qmIY9AiyNKx{|2Mr(Wy1s)qEf6LBt>vuKj*J z*x0BwMM|$$bVt26qR+x|8j6v|X%X&5ge~Z?<+$1FY)O>Pl+LrRx4pNgR;9AdZaP>Z z?78>rV*Y^Z2XL>|X}|N@a#p9ohN4p}L)2^}BRWP`YpYljDhlv?Oy{xrr7s8+B$nUw zez=-80LZm`I~NmxJfRVCGO87^8h=+*jClu-%r78711VT=`~qtJeoZeRATT^qoX2s1 zR;aJYm&_Q9>`&Pct%(16e0?}OJTh`PQ)-xJ7i%__<=zO%<%0%$&44U>V!J}aahbwz z-l68wE0w4fFE}3nEm&V5wpANi2pN8WG7k|qOf0O(RPZSdS(#Q9@CQw&Q3r%W79B27`KuUX=%ci=`FL<$mCtu6){{*tNSrqri_i&@xgd`d3l1J`NzkQGO_BR%iV0>xrGH% zAqV_vBsGh0JeG+Tca-h{@Cx+hK-Nn@!q|g@!;!Pvu+0kzAUvc001>`Cm>vpUS>csd zuh4_1p^j{EI~OD@Q^x)o7k`8tZoPZD8(QEvg{x%t*}89c{mWEY!dAz0spe0A^Z9C= z(LaMK>P1>egY?n=H`@XxicAJK7P z(6+U_+2(V^QT~`2>mTr#Yc;D`9Kr04W+I`WmDBOW?#*+A?ZJ7HuBHFPH2n2BRxn9J zg}W2g3z=BJDkqd?gB4l8gkCk%>-84 zmz<~jOIe5hs9X_+q{rLS1M))5-R>cD~HCbbKK=K^SXUMDCdsZMeC!Y4zr`D**+U8L+?9-oD#Ux+xf? zs$XqMFPwv(2-9MG+#N=M_7_g%_;u0taojvgB!-wT$gWmqx+I?Zxz5eV$Y|B;StP?; znF%xMqhG-rxH$50!*>t~%Pm<3R6<5v7M)M|O4TUwRs-35n<3nuR@#&d-S|f^v_@)rpw{l6`T<~C&v8c|0%2py zUazS_nv3k3?_=t(QzeNEc5|oG5JjlZa}PdQc4$0}yjHFGOUd+M!cSgm>fVtN>z4rW zeA(pt-5LVB!S?%)Xq8fhirH+kk)ph#={9VL4r7+{?==ys(^KJg;FHZ%&SYexW89pLdK!5by=3U_H43m7Dc++ zn=kc!h2o;lf8WYb#u4rOTH4aE2LWPi9ndl>LjOhmaD8DV;O(YKVwqRwJ-jK&IIT*PBi`USI4lK9xDQd?ke zf6f~yAuj&%e7wW)ee~sOa;!UhcQmVqL7{7KV}5S#>8$IDtYAb*;kXkVo13^t@{^~X za}Yl0E*u$$euHy=cenI~?)|{QY>~;1kmpTr`+SWV<^4`TsU~x-ka%&QRLKy@u}-bk z_06&F6xP{pxh@YIc1GlMWd*3d&}zO~1$o~Pf50!!mvZ&B(kDL<{1hvM=}TBGiwQ8r zco<`m7GuXnGGbsjTxosfJEGvWnJZ#jX?8g-Ss32rciqn+Z8aOGgOj`)SXESq)|LNI zR~NnsJ2g2s^J+OzIewptANUi?tesjuO-4E)?BG2KHc4*e{!|qQHLk_CFB;_Y+ko+Q zHmAK0*Lx)JZkIb~3CEIT*Qye4nRQND;IXL{G@!?&+BM$~3h#94)X^STwoqo{^L^pr zKdS9A>nAQguqw$b*o({_h)C&Y2_}K75=E-^iMW1X5fMdTsY)1c=D3SBX)6~0$rN(c zh_rVW3Z4Z}NYK?0s_8eop8CUP1&8f*eMJz4Pvf?Xrrx58z{?^1YS`TtluHp%`t6a| zdv7ypR)@>7G^RUjq@#oU(f{PaM$DdAY!g@ z2YPCo&^L8+bCaYZ_Sz|hkkFE!cz~S!XI#k?{rAP6=~c}aN{H+ky$5iR`zpiG$$e`x z@QdYJgmr#v(JtERB`Lhp)pGrOnQ32dZW9FpUXsm-pPVkvLhg?@$7{cS1-+l1=i}a~ zteC~5)GRfC)4pZtjVjiJ!YZUqmuSptaf@FcaIv;FHltZDNyV8MC4`}A2Y*YJY}YQ1 z^TJpb+1|`eYjT+fhRkY7T@ziXQ+;Be4nvBAy{h~r*0ik88mrrjjeX@U9+k$`BYqeD zmg`r0X*X7ps4lk7(o$nCJel@HApHQPt8OjtfrkK_E=HNYQC8_4RX z2%91GeKhT|?-Z)v>?YdX%iH(Kma(~YLj3A9?ez2t@~)8Q{TVsAZ6eIdlHBFWpK(_8 zGind}Nn8McoUr9`doqJBhnF#Z?^=>5pgr41?_&{!Nl@H!p;c0Gv8BFHUmt^vpSJfG zm(dcQA#lx?#x=jR)oo!|ZiKV<*HY3;?N-tfQ3mM(M?-bB*fHoM1RTWXb-JQ|JQfmM z(%c(`Nkp5yvz$)&yOI2gCGy+n0Un#Bt=rX`)A<^ROD2Cm&aZ$v%U$$H+qt6Hluz^e zVy(F(Kwxgc?o-Yo#rk>)9OP!A(T`Xqq|LijSY!NgAP_+~ ztk>YDKpmd|6osF7%vrtwe7B3Ou=~rM;dH1{Q?hP)d6c!^6(c>p3Pl$&DLFmt11UDW zFy#BOM^vv>aN`p5fMnmk2P>*^ODcyCI3j}RZvHFfLF~3ft;qMQems_Us;vb~I<=ip zKeLjTuUW#^B96Ui)~}W`Hh-$?d-u7;73))USU|#QR$pQt~bNG zCX$PbcxYaLJ8sHv{p0I{RHy?dZ>O=aipvNWH#%^aPu>l=zA(+gfj6fsLMFQ%^P~H` zil^t}dY5E^Yq8RwuN}XB{aWurC{+y1aLJB&*dU{B^PC;(ntblOcjPUUd+{l`L7mWI zHSR7V{}Q4P@z_3(@7o>ATyAoNgcVCS*a)F&!+%Mlk!~L3Uf$Ohr9%VOjZOd@;)AqU zV4Iw#%BF1h;`0aMTpzI}PEK621T~h)LrlCMZ%7c) zNa;`5Al`>bSr|#o<;{&GyRz4@<~KLDmkcW12GUTP=Xv%L&&!jgrgF=7879OeB!+EX zzhkZX91f7v)k&r#L7e7uMY72icZ|kTa`!tUmUQZtgoK3QVd9GW=sn@&y6h(ruhT`! z*y{O`FZS_XG*p4qDIdq~+7Py!{y8|cBnGS9bWJ_{f`br(? z)Vm}4$;7v|_kZuQmFIUOTwG1z$0$nE<@vtZJEVQ~V1Ce->onLqImM}+dm&A&@H_1l z$^CrO{1b%sM4)>Q3A{&}s5xveCqmQs#}Tb{BIzfEk-81)$#LZVkvH0)A$$qhpojOc zu&`RH=W^FFi3}9~MDZbfju)Kr^z<|_mX5wZ0DyTTx9bn4|2M@b-1Y5;7@Z@Vc8 zWRHWXfdi&S`>K)(jc)IuuS}AgW7)5z7>U&KY3n#qeH|Sg#&X8S8zt%t6WxoA(%8?2 z3uW3U(`K$#nov5zFtC9Tgf40uji`ilso7zhC1({+`Xt!rcwt&YLt2DIl+hP`Kl^a} z=+qq~B_)|HDR}mG)Z5)(&?9r(I*aBi4yI;ILw>zNGPnwl_y=mdhx9}N+O43TcTj9pD6(lKIdb-TrJ;j3{ zIjeqpN{C?dlWM)^3!QF8RI#q2clAg* zPk7&~><^rJ1T?~Mwvn{b(auuMa;4MS)9$cM+Le3}Cgoo9(>Jv-c+8*YV}LZL;A*1} ziFG?$6`QE2C?fh(m{)OTCP^n#EU>;me)ou=p;WW7#q0bX4Fd5}#7DX~7Q?m%+tnPt z`>BEW(-rYw`yN9{@+Yn*pIV=wm2H4zi!hpm937BYc-1wr|pW@Kt=I8iV3Zg^^=pwEhL2%aFy%v zl7F7)F~2jvi<%G<0@{scrCx{jW#|1RZz?(ZYLy9*!^F0}p728vnT)zSx|d1aiOb0F z@EIK=RvQV5<5j;=hLZh^*Tvm=I*)Bxc{vF&aW`VDLdP(Relz~n4w}q$vSAW=p?;My z)%H1u=}@Apkn_RL$f5sOcCJ{^#r(q|Zi34>7F7s3Npxy%lt_$e*5PdBZ(>$#tH#NS z!g|})hyC_3o0XQ>oZa?A!<28bD>K|I)v&a{jEmD%NGR;n7pk4@q2!qvjhqwX{x_=t zb~ao#a3IT&@*dX8AS-KkG^?W+Hgx12Jo3hSremA21FQ0?c)dG3lD1l~5@j??d;Y#{leJqLR*Yx{}6RPloE%-s9#GG_Jp`RPn? zoo|Jr6> zOf8&S-X~-{x{Ik*3^UbXR!T~@zgu+jX`HRjhXoLbXAd%t{e`TH{PgcC5zZ?ndS*Q4 z45<62hnpKHBB@*=5f1LiwjhJ@C9(jXJbYj!@2B6$qn6QUh}AstMj zd%W6Loih28QACb*cUAN9jCgl{zue}`5tC09uI4p9P`q`A6ff8+sH~5jIaq7GsPOE5 z(U1QXRxBV;9o}tSPy?IhsM+~&160bno6M|VAhiyp)%{4Agjsm3oZB~5qD@3_@e%&) z0k8LG9E;9W$MW;d2vN!NR;90j0c}t`jp9_PuHh7F+%grI*3uXzTjgPlMXAXlF}c!U zqdU|M3voD&%M!P$K{9WuNO_Y-RP^qHLIf`b9*e;{`HX|-u|woFj*W!%H6=!Ve!>w< zuq@y{SM(;-f+IsVNlbt-pnWv6S!;1_szm>hV1bZXzuN-O8rPkXalCh#t+=p&;v=JG z#nTsC+xP3VxEUyaTuges41SA=!)cPwZ?kdm%;L1`YCo&j7mXE_5Z|8u)^2i6uYZ2H z#={%Q7xGhdfmbDIZ?KrGP~BzLvTTWH?VOpxy$-ioer>8WD7)V15&5m)e6?p(wzk+n zZqAyf4K8HGUv>oKuy{*YiRSj7gN0dGzOZD zVZ1Dp_(OU=81k6-XB@LIZaW-@fzA~Fc^6L~v*6+JkvwQ9Tcn4iu<(%%Zc@bkf|sAZ zxg{c^LiLB3Sb*1H4G*Pm#MN6iUBYPR>)k?M98_&mA!8F0Hb*!-F6TIX(Jm4Z7pLok zL^wqBgTHm@c_ooTF;P(r%3_XPXe8_(44a)774}av3$?0@7c&2%6it76CdDJ$-P=p( zJvk4f4b+1igi!BXY?tEDYJmnNv`!V#bW;-?ZY<1w>uMN&K{xHYk4=Y?5>Yjz3VhQ( zmKW#dww019i744-aAW?39}vFQ{1dyM+TPv$o~XV^mlHgpegS>1$5?+D)^?mg&J`vtj#NbSIx4*OoB!sD1k5=SeZ3dQb98ikZk^eW zO;Qc1*w1`j)=sO}YjW6kFN#9o1Roj-JePj$zu5EtwQwe`ZgzLiIgca9^+hKjZrNR_ z)SgyD4hVc$!*9o?I=wEPW$?M@8)#^Ix?nhl5q9p*HLoKZX2^Pori^*>Uk(co-zR`+ ziA-P=69gY~ZaBi(f4OyR%^8{(|K&=(s++wVPG(=*hSx7YjgGNh)Z>MnjOg%P$DhAcJ7Q`VIX! zC=mw&b1*RE2V7fij6WH+y}P-g%CjobvNt1I^eSR?_t1-pj!t;%aNub?Vr(n`pH{WN zxLyt_JJ0183KS0);N>-18`ym8N0I`8Kx7(jZhObN$yit$OpJ{B^lHh~b;vkBQc|_M z`}&rfyu;kxfWU_{%v+&&+Z}qHsVXIxmPh^m*w$05Tlc-|jUH9hkF-j8d5(NjOq4)i z4r)?L3jW=a;!@;3gP(VYh*S@@-Q4fx&-xYz^KE-%4)i$B}*A zRQWAYq1Tl6itugi)MFVH1*OC8WQiKQKH+C$VZj6d0P=Y_lSAJ_eXbAOmBwY+3Y=2I z#lt@)SJxepwaYpnih? zSAzR`K@qXk)CD~e0(gnGI}eqjllL`+F@WI+k&$6W%&yH0OXDADUk1tFDit+07DH8l zuuXt35@OviaqJkC7Z#{16PBN8-nqdN;nM*qCH@&F6H`;wM!TxlJNT{s@D{i8tL+H0 z)0JRZ5C}v-AsL+kkBsYLXIEWao%llZ@&&f#=g*&KJI-s6{r0UB?e7xJ*eg&v4n?+)$fkz>Gx)0E}`L zWr^h}fq%&*NA-AjH?kA+o&5vEUo4Z=UU%QQ0oj)>cYR{WmTT(k%XP}z-tZp;`!gdb zrz^p)cs3hUIch_1WlluPmx2g93|FX(R$jQ}0vtvLY2_PFAo8VJcJ0woCjBicdqtF5_IpSL^Gf7)q^*c^8z2YtIE`bXl`VU!%l z{}%$%F{R+->6;vo-sVis(HDx<(JKJ}K-s19trFVD&uy)rJAlTGpG7?jR^J|pCq7+; zCWEU}y1RqM8#>n4e^*sa*xOf~pP$EdlO%%sKOrHV0{}+&f2yko5*ZpmAP~YptUyCp zSXgm!YHF0T0ny?UpCkLcv^F}N+n^4Rf+9<>jSTBT;-ASmD0qL2oXDuX(ixD=0( zNPoZU;9L%Rw1R@ET*dD+~LhvRJL;KVqR&i%H6XdQpM z9PHk*o;8Vt$jiwws8>Wu82|^>P}k_Z}q>KYvf6=nBon=>8#L1vSCR>}0i&~5WS6V8;}zKX5E z)-XIQOag&aUE@YUF%>L8W?DcKL-xsIERjL8N2U(H^^I2<_nYyLkij3L8BHs#D7Tl_ zNnmyiIJZI0iHTSigQABFS7vqpvE5H^O<`nEBxNX!Gi5t_YBIF4iUcJ{ zQQjVCH~tCy7mF8+!=%%>BslhXdzvlS#sVJHcqFGLga zE_g1A=AV@nywoIrvCRPX2ZLF|XgOG2a)tre*io9647&fkppon}H|$ZK;4j5)`gB>Mvo6ak;~t zFj?$~uDA8)qa$w>}gUVOOi zp=1`dqBp(%O_jufb9<<~dwD!PU20OzlZvggoIObE9T_>k*dD^Sv$Mlz{rWj>^6BBQ z5*8K~AD`dJ-2WXsW$s_7uW!{E001x^uCEG-XhmYXdUYfSp0Ys|)se|g@XNZ|e3i`er)I)JGw z9*aQ+t5HCLSgyodD!$nN0&H@z`W0H8%-5J@dtFF-d9@^nt*@^?KinJxRhN)6h#}Oc(x5d?rDt-|aX5vIh{uN0?jct)CX3g;b9EJJK9M7z z&V9Vn>KRSUH$|h>>|~*)rk2U?tW#_5j}TzJSg+sgRP27alkI&&fa}l|f|gQXHkNgN zxhrC~)}F|qiG_v5noTO?p{1qu3r#H2>*31cc%csBw7+q5R8|EuTcI!Dda{%$;3_9C ze>hz_3vsn2A|iSoI@;WHe0}kjZaX$?#fEs^o@5HT|Ly9cU}mmx*dB}`;^|HP;k&fB zNMz|BW^HY4Ia9`GxAvLwZBKerN&H|1P0c7IA{(6#aS?XLvPFEJ+)&LyQ_X6X2Kruh zV_8CIMBK#uPDL;KU&O>%N2*Nv64(xBD=Q75FG)nDjMMs2J&o-8g)T!f45As*Wm!6!Q5YVXGz90rX~Nhedq*g39j7cn9)DSVDQZQhS* zT$TmHZ{|NuTg$mBlfI+*8XoI~Jdrw|v$c*)0axQ+8s$3qGKp9eyZs3kN*~-v*uQ;9 zG9*Y%N#QjeOfVfvYW00JxR;Rg!?7Lz<^ON@_M?)=RqL-eV?oqwT!OT0qpIf+pO=dB?IbPHR zoi2>e`0WYVuCxIEAby*QO_IFI5^z0{Nu&>pWn*KDr%}`pcHSE&r}AJ?EkJd+!*ADg zJzaUeIbKwe(QEZk2c6PJU2G2t@bibCgNLVeX(gfvgYEPaXsElAjUNgXvt3S>?l*cO z-af%^dN;yZf>2XalYBZi8r+BFX*m6^=Zhg0uq+jynneB6@nS=BL58q*b73J30w(0) z?oO}Ku48uAb^MnoJ~k%i*$B_7xg6B@)hB4LD-=W0K!}l1jPa&*NGLoU7|? zl}TUt8|3YHt|~Vx>Nj6a6{|8a zF;&g2`o6ZZzx0v$I@#I%-1`2`ODXa#6fJ%Vul>e)SEynJFFA9`SmOWU?Jc|F?7FVa zBqTtBLvVKs65L5}3-0dj?u7(*ClK7-rGUbn;1q7bDO?IK=)rZ}_xtqIqrdeS{R`^+ zP?~U7pSVUYu?H7Kg7huHah)+gMvQcd}+0qBw=J^q;HB3GypnN&z#{2 zxgRg)i$}#1E~M`b#t_~~AkS6lXu#Y(#9sNe8!ZkwRKi2@Kr(}GvDyo1I855AR!-f$ zD@#j8di|be1k-Y4Bi#8>Z>RfKVUM>Om6}dhd*f*=1`NFCo12F8)a9yW&IeQE(w&EX z7h40vPa2h)b<#;RGPoWW7Z(vaaJ{bsyAh!NoEtlAE0SAwq&Sj^)-r<#y zuOKCbj>VYJ0Ur~E`tRK41g~4gy}%XqR;>8qwB6%OK1YaYB+e@hncZbiZl{q&ukB~4 z+f?rNt$gp0#J_P75z}|Mm&jf*aRE0eS5|>F&j$r@r>nqVBMXat^(t+09TE(Ti&+I> zX?hD26PLX|zh3X+r3Qqn=*UP+gF5@QHk}rGAP}gftsP0v^yN$E^TQF9#n#TwYgdec zq10ylMj;tlMP+t8lbhRm?f_h&!D0qEW3X~ZLqm%}i(GBGz75`Dn*dj6h+pg1=z~m# zVk3mkFHXvkHmP|xghX&iN#k;fV{nsb75rbGVKIc<@emgM4juwTMS|(+X-dghG5Oct zf`TGHdPx45u6+sUV(}9PV?zY!yxX77=LW$On#>oEqImheql3+;{zau28JYrkoA z+D=|U=?{S?=5s}NSqJ&FJPh5QtSo3Xn~xLx+fvP7!+BB!H(E}MfD}C4hDD+RpJ9)H zt8s1wRHE)=y>@qO?npF3E@WO`dP;0Ybva}rUg!R`HDch!Ywg$X(2VxI+!^Wcx@shq z%%S>unosJ(@$FCg*X`*7$rB=-G^)u{|oe#!5;&FJk(o*AgG`}@+lKAvSAiC$>_3@I5OrA^%0}u%0 zbXeE*GXP|@ITwMUAKw7`Av-mwQF)<9)3deq z+XtwP^r?84Hx$xFBZ~R_w=b-xr{{r|^0hO8*Y860b$p5a^-KJOuVX!)?JY|lt&Bp5 zsWQ!1ZtewtWxsOZaAFp+6@cpG-#6u`;s1}Ve!L3B!=xwdp{e~bYkv|hG3&A?_%WM= z1hdQwauzoe6&aaWpWfoIVceq+@}@TXTBcb$H8s`l0b1|yqQp>w508l%sF|5u(0Kg` z`9IyWfZ=-=ix&H}nNGiFyE}eh7h8SaP8
  • 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 #KlU6c>!0@ms4*q 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 literal 0 HcmV?d00001 diff --git a/frontend/.env.development b/frontend/.env.development new file mode 100644 index 0000000..d3bca28 --- /dev/null +++ b/frontend/.env.development @@ -0,0 +1,2 @@ +# Local dev: blank = relative /api/* requests, proxied by Vite to http://127.0.0.1:8000. +VITE_API_BASE_URL= diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..0ac9018 --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,2 @@ +# Leave unset for local development: Vite proxies /api/* to http://127.0.0.1:8000. +# VITE_API_BASE_URL= diff --git a/frontend/.oxlintrc.json b/frontend/.oxlintrc.json new file mode 100644 index 0000000..1255078 --- /dev/null +++ b/frontend/.oxlintrc.json @@ -0,0 +1,8 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["react", "oxc"], + "rules": { + "react/rules-of-hooks": "error", + "react/only-export-components": ["warn", { "allowConstantExport": true }] + } +} diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..b5b186b --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,22 @@ + + + + + + + + + + + Electronics Catalog + + + + +

    + + + + \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..3b29d8d --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,2176 @@ +{ + "name": "frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.0", + "dependencies": { + "lucide-react": "^1.21.0", + "react": "^19.2.7", + "react-dom": "^19.2.7", + "react-router-dom": "^7.18.0", + "recharts": "^2.15.0" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.3.1", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.2", + "oxlint": "^1.69.0", + "tailwindcss": "^4.3.1", + "vite": "^8.1.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==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "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/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "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/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.137.0.tgz", + "integrity": "sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@oxlint/binding-android-arm-eabi": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.71.0.tgz", + "integrity": "sha512-ImGmd1njEg4FEJH03jhRnveEegtO3czCtfptvaHivKAZQIYATbVFBrrzbaYMYv0oJioTnxZAZVSyV+oL7W8S2g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-android-arm64": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.71.0.tgz", + "integrity": "sha512-4A5BEexBrwY1YFF8Kiq/lp/wQPRG79G3BWIE1FuWaM5MvmpYSd+7ZySVcKkHdwo0UDzdQGddp6pD9mpctMqLnw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-arm64": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.71.0.tgz", + "integrity": "sha512-9wJA9GJulLwS2usU3CEisI/ESDO1n1z9eyTCvApMDrAkbJ1ve0mORgTMjcWWsKxkzkeZ2N/Gpra5IQE7x8tYgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-x64": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.71.0.tgz", + "integrity": "sha512-PlLCjS06V0PeJMAJwzjrExw1sYNW9Gch3JtNlcwwZDXGlTYDuwHNN89zYH8LTXFfgkVtsYvs2nv0FqrzyuFDzg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-freebsd-x64": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.71.0.tgz", + "integrity": "sha512-Lhil7bWre0ncxbUoDoxfS0JzpTz17BRQKW7iwoAUY8GJ66+WwJEfYPCFJ1P0WgVZR5/O/b3Q2pENlHOjeXLOGQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-gnueabihf": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.71.0.tgz", + "integrity": "sha512-Oo9/L58PYD3RC0x05d2upAPLllHytTjHQGsnC06P6Ynn7jKkp5mdImQxXdJ3+FnBaKspNpGogzgVsi6g872LiA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-musleabihf": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.71.0.tgz", + "integrity": "sha512-mSHfyfgJrEbyIR29ejaeS50BdPk+GoNPlC1dckpDiUZbJAIel68sjSMdOt4WY0/gva+ECC7FNITQkxMJU+vSBw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-gnu": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.71.0.tgz", + "integrity": "sha512-n9yY4M2tiy3aij4AqtlnspzpfdpeT5JQfK2/w2d8oyp5W0FRwOb1dIeX99nORNcxGr08iD9bH8N5XFz3I2iy8w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-musl": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.71.0.tgz", + "integrity": "sha512-fJZrs5sDZtTaPIOiemRQQmo82Ezy+vOGXemPc4Ok7iVVsYsFa7SlW6Z5XN819VfsqBHRm3NJ3rTdnR8+bJYJdQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-ppc64-gnu": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.71.0.tgz", + "integrity": "sha512-cwl7VKGERIy9p+G+AvZdfy/06q0aHXaTt/mMRReC751iuNYJgqKjB7NydXSS30nBT9vtr2tunciOtrR4fD6FUA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-gnu": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.71.0.tgz", + "integrity": "sha512-eZ8ieVXvzGi8jr7+ybQGPK2STw3mldfxZlgA2738iflfB/rzA69sE6m5rDRpQaxC7dpm745Enlh1Tod0QAk9Gg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-musl": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.71.0.tgz", + "integrity": "sha512-puMDbQYe6+NXwfMusojoA7CXGn2b3utukmd23PQqc1E3XhVCwyZ+FueSMzDYeNgDV2dUfIVXAAKZBcFDeCL6sA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-s390x-gnu": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.71.0.tgz", + "integrity": "sha512-4NJLxBs1ujISCt3L/1FcywLs73PWtJuw+piD6feK2V6h6OS6P7xu9/sWt1DTRLibe6QCzmfZzmM/2HPORoV/Lg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-gnu": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.71.0.tgz", + "integrity": "sha512-cFDaiR8L3430qp88tfZnvFlt3KotFhR/DlbIL0nHOMMYiG/9Wy4l+6f7t8G8pTa9bd8Lt8+M0y/qjRQ/xcB74g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-musl": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.71.0.tgz", + "integrity": "sha512-orfixdt76KlpNly9z0PkWBBNfwjKz+JFVLP/7wnVchlKNU9Dpt9InU/ZggeSej6fC7qwHmHNOGlhLnQXcYoGuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-openharmony-arm64": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.71.0.tgz", + "integrity": "sha512-9emQu2lAp6yhPB3XuI+++vR+l/o6JR1X+EpxwcumPdQXBWXEPAsquPGL7l158EqU8SebQMXTUa/S5zN98juyHw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-arm64-msvc": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.71.0.tgz", + "integrity": "sha512-bd5kI8spYwTm3BILDtGhi73zoup5dw8MlPQNT8YB3BD5UIsjNe3K9/4ctrzQMX4SZMoK5HgzVLkLJzacEXB7fA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-ia32-msvc": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.71.0.tgz", + "integrity": "sha512-W4HvOHGzVLHcrmFu+bMrJlho+/yrlX5ZNdJZqGe8MEldkQG+RHYhxxad9P4jvWAYFmIqUA5i9DQ8QsJqSU9GIw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-x64-msvc": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.71.0.tgz", + "integrity": "sha512-D2kyEIPHk/G/wiZLnwTVC/sVst+T/lKldVOjAFpgTIBUAOlry72e5OiapDbDBF4LfJLkN5ypJb/8Eu6yJzkveQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.3.tgz", + "integrity": "sha512-DT6Z3PhvioeHMvxo+xHc3KtqggrI7CCTXCmC2h/5zUlp5jVitv7XEy+9q5/7v8IolhlioawpMo8Kg0EEBy7J0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.3.tgz", + "integrity": "sha512-0NwgwsjM7LrsuVnXMK3koTpagBNOhloc/BNjKqZjv4V5zI5r13qx69uVhRx+o5Z0yy4Hzq+lpy7TAgUG/ocvrw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.3.tgz", + "integrity": "sha512-YtiBp4disu6V560loT6PjMdiRaWmVvDNrUunAalbiFx2ggeJwxdAsgZMcoGP17uyAsTwAj5V1niksxlHnVQ1Sw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.3.tgz", + "integrity": "sha512-yD3EkEdXk2LypPxnf/kSZHirarsI8gcPzc62SukhR9VJTyvV+F9Q/GxWNuCojc7sXyuVC4DxRGhdDK4X8VSsbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.3.tgz", + "integrity": "sha512-c+8vieQbsD7HNAHKIA34w0GJ9FedFFuJGD+7E6vz7Q3uqAIugL5p45fhlsj4UaAsHpcmlqugBWMhA0/j7o0sIg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.3.tgz", + "integrity": "sha512-50jD0uUwLvur7Zz9LHz17kaAdTPjn5wN93hEgjvmYFRZwiR7ZJYovTd5ipyWJDAnXKvZ+wgc+/Ika6dwSF5OcA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.3.tgz", + "integrity": "sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.3.tgz", + "integrity": "sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.3.tgz", + "integrity": "sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.3.tgz", + "integrity": "sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.3.tgz", + "integrity": "sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.3.tgz", + "integrity": "sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.3.tgz", + "integrity": "sha512-Elcv/BtML9lXrV6JuKITc/grN2kYV9gjsQpW8Jfw4ioK0TOkjBjye0nnyqQNy9STNaI20lXNaQBRrD5gSgR0Yg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.3.tgz", + "integrity": "sha512-2DrEfhluH9yhiaFApmsjsjwrSYbNcY1oFTzYSP1a535jDbV98zCFanA/96TBUd0iDFcxGmw9QRExwGCXz3U+/g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.3.tgz", + "integrity": "sha512-OL4OMk7UPXOeVGGd3qo5zJyPIljf4AFgk5QAkPPS+OoLuOOozhuaQGC18MxVTnw/06q93gShAJzlwnSCY9YtqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.1.tgz", + "integrity": "sha512-6NDaqRoAMSXD1mr/RXu0HBvNE9a2n5tHPsxu9XHLws8o4Twes5rBM2205SUUiJ9goAtadrN6xTGX0UDEwp/N4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "5.21.6", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.1" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.1.tgz", + "integrity": "sha512-yVPyo8RNkabVr3O2EhHEE0Rewu7YKzc1DhIqfL46LKveFrmu9XbDazNOJY7/GRuvw1h6u3utWnR29H/p5JPlgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.1", + "@tailwindcss/oxide-darwin-arm64": "4.3.1", + "@tailwindcss/oxide-darwin-x64": "4.3.1", + "@tailwindcss/oxide-freebsd-x64": "4.3.1", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.1", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.1", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.1", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.1", + "@tailwindcss/oxide-linux-x64-musl": "4.3.1", + "@tailwindcss/oxide-wasm32-wasi": "4.3.1", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.1", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.1" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.1.tgz", + "integrity": "sha512-SVlyf61g374l5cHyg8x9kf5xmLcOaxvOTsbsqDnSsDJaKOEFZ7GCvi84VAVGpxojYOs1+3K6M0UjXfqPU8vmOQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.1.tgz", + "integrity": "sha512-hVnWLwv+e/l7c4WKyVtHVrIPvYdqWHjRB3MDIqARynzFtnQg85kmQEFCbV9Ja0VVx4xXTIiDWY60Y7iz/iNoDA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.1.tgz", + "integrity": "sha512-Cf7abu0WVgbhU7ANgPUnSAvm7nCvMweusHb8FnaHlLfv/Caq4GYaEZg7ZImzzmjx4lIAfuS8q+eLIS7A7IzxIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.1.tgz", + "integrity": "sha512-ZZqzX2Y+GXtXXfqSfpJhDm60OoZfvLHLCgm+J7NVqgHHJjG/m9ugZI77RwTsVd4fnBJuCFP6Ae6kTJb71UdS8g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.1.tgz", + "integrity": "sha512-/Ah/xik0LaMYfv9DZ0S/t4pBlBNYOcqtRwusjgovHkvT8ixueWCLyJjsaF5kQIckjb4IT8Q6K6p/iPmZMixYgg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.1.tgz", + "integrity": "sha512-gqdFoVJlw444GvpnheZLHmvTzSxI/cOUUh2KSNejQjTcYkW062SVD+En0rUgD+QV91bz1XGIGtt1HJd48xUGbQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.1.tgz", + "integrity": "sha512-Bwv9KwOvE0VKa86xPFif9b9c3Y1NxOV1P0gLti/IYaWEsQYZXDlxfGEtA8mdDZ7SG3wyNXAWYT5SIn3giL57oA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.1.tgz", + "integrity": "sha512-Ymi8O8T15HYQdOUWUtTI6ldN0neHP85FC+Qz32xTcZ7iJXtem/x8ITev0o1e9e5rkqj4lONZfTRLvkmin1+tKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.1.tgz", + "integrity": "sha512-M+P/91qJ6uILLw4k2G93GMDRAXj61SMvFQYt39AqvUqYgExXpLL5aepfns7sj4HiAQeolirQF9E0lzRvdf4zPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.1.tgz", + "integrity": "sha512-zsM8uOeqvVGHsAXsJxsT28ttosFahLJKCLOTUBqRAtKnVgGSRitds9T432QiT8b77Yga7JIBkulIRRlJPtYhRA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.10.0", + "@emnapi/runtime": "^1.10.0", + "@emnapi/wasi-threads": "^1.2.1", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.1.tgz", + "integrity": "sha512-aiNvSq9BsVk8V513lDKlrCFAgf8qBMPZTpgEhInL+NwQqs97mYmupVMrPrgBBSL8Pv/0zXu9MrMF9rMun1ZeNg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.1.tgz", + "integrity": "sha512-xDEyu1rg290472FEGaKHnzyDyh5QH+AlWvsU5hMoMtPpzmKlRI0jaYKCgSHDYtaQWZOYbMaduSyCwFwY4n1HmA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.1.tgz", + "integrity": "sha512-hItDHuIIlEV61R+faXu66s1K36aTurO/Qw0e45Vskz57gXl9pWOT6eg3zmcEui6CZXddbN7zd41bwmvag4JGwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.1", + "@tailwindcss/oxide": "4.3.1", + "tailwindcss": "4.3.1" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz", + "integrity": "sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "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/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.21.6", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz", + "integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, + "node_modules/fast-equals": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.1.tgz", + "integrity": "sha512-DjlFSM5Pk9cGcL0q5QXl66eGzx0N6szNgaswwc5ZphlBohjTVJSnGgI+rJVOgOi65qUoQnDZN4nDqi33udtydQ==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "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/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/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/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lucide-react": { + "version": "1.21.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.21.0.tgz", + "integrity": "sha512-reEZMXq8Qdd5jg5XYkQ5TR1fB/GiQ7ih4vcrthYDtgjSDwh0i6/YLiGjsWsIwgN49gpAnd4J2elSNzncMEEUUQ==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "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/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "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/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/oxlint": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.71.0.tgz", + "integrity": "sha512-U1m1X+C0vDj7DC1e13IoZULzEcPczE7UOMTs8VlZGHUEIUaSTZKo5qkPsQEfzpgnQ29Pea/w3Xntk62UCecxZw==", + "dev": true, + "license": "MIT", + "bin": { + "oxlint": "bin/oxlint" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxlint/binding-android-arm-eabi": "1.71.0", + "@oxlint/binding-android-arm64": "1.71.0", + "@oxlint/binding-darwin-arm64": "1.71.0", + "@oxlint/binding-darwin-x64": "1.71.0", + "@oxlint/binding-freebsd-x64": "1.71.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.71.0", + "@oxlint/binding-linux-arm-musleabihf": "1.71.0", + "@oxlint/binding-linux-arm64-gnu": "1.71.0", + "@oxlint/binding-linux-arm64-musl": "1.71.0", + "@oxlint/binding-linux-ppc64-gnu": "1.71.0", + "@oxlint/binding-linux-riscv64-gnu": "1.71.0", + "@oxlint/binding-linux-riscv64-musl": "1.71.0", + "@oxlint/binding-linux-s390x-gnu": "1.71.0", + "@oxlint/binding-linux-x64-gnu": "1.71.0", + "@oxlint/binding-linux-x64-musl": "1.71.0", + "@oxlint/binding-openharmony-arm64": "1.71.0", + "@oxlint/binding-win32-arm64-msvc": "1.71.0", + "@oxlint/binding-win32-ia32-msvc": "1.71.0", + "@oxlint/binding-win32-x64-msvc": "1.71.0" + }, + "peerDependencies": { + "oxlint-tsgolint": ">=0.22.1", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "oxlint-tsgolint": { + "optional": true + }, + "vite-plus": { + "optional": true + } + } + }, + "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.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "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.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.7" + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/react-router": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.0.tgz", + "integrity": "sha512-pTTGt8J+ji1NOmYnjzT+bAJy/1zD+Jp4ziO6cL7T3ZLvXKtusO7BpFqlRXitqpcPVqllsIXFHRMt+2/k3Xn6HQ==", + "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.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.0.tgz", + "integrity": "sha512-Fi0yY6kgtKae/Th2xibdWK0KSdYZ4B53Gyf6wRtomOKWgpNm7H7+DyfDhncdz9FKbpS+1jmDhg3F4WoGJ+yFOA==", + "license": "MIT", + "dependencies": { + "react-router": "7.18.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/react-smooth": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", + "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==", + "license": "MIT", + "dependencies": { + "fast-equals": "^5.0.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/recharts": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz", + "integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==", + "deprecated": "1.x and 2.x branches are no longer active. Bump to Recharts v3 to receive latest features and bugfixes. See https://github.com/recharts/recharts/wiki/3.0-migration-guide", + "license": "MIT", + "dependencies": { + "clsx": "^2.0.0", + "eventemitter3": "^4.0.1", + "lodash": "^4.17.21", + "react-is": "^18.3.1", + "react-smooth": "^4.0.4", + "recharts-scale": "^0.4.4", + "tiny-invariant": "^1.3.1", + "victory-vendor": "^36.6.8" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/recharts-scale": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", + "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", + "license": "MIT", + "dependencies": { + "decimal.js-light": "^2.4.1" + } + }, + "node_modules/rolldown": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.3.tgz", + "integrity": "sha512-1F1eEtUBtFvcGm1HQ9TiUIUHPQG7mSAODrhIzjxoUEFuo8OcbrGLiVLkevNgj84TE4lnHvnumwFjhJO5Eu135g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.137.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.3", + "@rolldown/binding-darwin-arm64": "1.1.3", + "@rolldown/binding-darwin-x64": "1.1.3", + "@rolldown/binding-freebsd-x64": "1.1.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.3", + "@rolldown/binding-linux-arm64-gnu": "1.1.3", + "@rolldown/binding-linux-arm64-musl": "1.1.3", + "@rolldown/binding-linux-ppc64-gnu": "1.1.3", + "@rolldown/binding-linux-s390x-gnu": "1.1.3", + "@rolldown/binding-linux-x64-gnu": "1.1.3", + "@rolldown/binding-linux-x64-musl": "1.1.3", + "@rolldown/binding-openharmony-arm64": "1.1.3", + "@rolldown/binding-wasm32-wasi": "1.1.3", + "@rolldown/binding-win32-arm64-msvc": "1.1.3", + "@rolldown/binding-win32-x64-msvc": "1.1.3" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/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/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.1.tgz", + "integrity": "sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "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/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/victory-vendor": { + "version": "36.9.2", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", + "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/vite": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.0.tgz", + "integrity": "sha512-BuJcQK/56NQTWDGn4ABea3q4SSBdNPWwNZKTkkUpcMPnLoquSYH8llRtSUIgoL1KSCpHt5eghLShn50mH36y7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "~1.1.2", + "tinyglobby": "^0.2.17" + }, + "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", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.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 + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..27e4e04 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,29 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "build:unified": "vite build --mode unified", + "lint": "oxlint", + "preview": "vite preview" + }, + "dependencies": { + "lucide-react": "^1.21.0", + "react": "^19.2.7", + "react-dom": "^19.2.7", + "react-router-dom": "^7.18.0", + "recharts": "^2.15.0" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.3.1", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.2", + "oxlint": "^1.69.0", + "tailwindcss": "^4.3.1", + "vite": "^8.1.0" + } +} diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..3e18c01 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1,13 @@ + + + + + + + + diff --git a/frontend/public/icons.svg b/frontend/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/frontend/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..0810a0a --- /dev/null +++ b/frontend/src/App.jsx @@ -0,0 +1,98 @@ +import React, { Suspense, lazy } from 'react'; +import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; +import { AuthProvider } from './context/AuthContext'; +import { useAuth } from './context/useAuth'; +import { ErrorBoundary } from './components/ErrorBoundary'; +import { LoginPage } from './pages/LoginPage'; + +/* + * Every page except the login screen is loaded on demand. + * + * Imported eagerly, the whole app landed in one ~790kB bundle, and every + * visitor downloaded all of it before they could reach the login form. + * Splitting per route means each page's code arrives when it is first opened. + * (The charting library that dominated that bundle went away with the + * analytics pages, but the split is still worth keeping.) + * + * LoginPage stays eager: it is the first thing an unauthenticated visitor + * sees, so deferring it would only add a round trip before the form appears. + */ +const HomePage = lazy(() => import('./pages/HomePage').then((m) => ({ default: m.HomePage }))); +const AdminPage = lazy(() => import('./pages/AdminPage').then((m) => ({ default: m.AdminPage }))); + +function PageFallback({ label }) { + return ( +
    + {label} +
    + ); +} + +function ProtectedRoute({ children, allowedRoles }) { + const { user, role, restoring } = useAuth(); + + // A token restored from sessionStorage is still being checked against + // /api/auth/me. Redirecting to /login now would bounce a valid session out + // on every refresh; rendering now would flash a dashboard about to be torn + // down if the token turns out to be expired. + if (restoring) { + return ; + } + + if (!user) { + return ; + } + + const effectiveRole = role || (user.role === 'store' ? 'user' : user.role); + + if (allowedRoles && !allowedRoles.includes(effectiveRole)) { + // Every remaining route is admin-only, so there is nowhere else in the app + // to send a non-admin. This can still be reached by a token minted before + // the `user` account was disabled - those stay valid for their 12h TTL - + // so it has to terminate somewhere public rather than redirect in a loop. + return ; + } + + return children; +} + +export default function App() { + return ( + + + + {/* Covers the network fetch for a lazily-loaded page chunk. */} + }> + + {/* Public Login Route */} + } /> + + {/* Catalog Route - RESTRICTED TO ADMIN ONLY */} + + + + } + /> + + {/* Admin Panel Route - RESTRICTED TO ADMIN ONLY */} + + + + } + /> + + {/* Catch-all redirect */} + } /> + + + + + + ); +} diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js new file mode 100644 index 0000000..f23b372 --- /dev/null +++ b/frontend/src/api/client.js @@ -0,0 +1,130 @@ +const BASE = import.meta.env.VITE_API_BASE_URL || ''; + +// Where the access token lives. sessionStorage, not localStorage: the token is +// a bearer credential, and a tab-scoped store means closing the tab ends the +// session rather than leaving a working credential on disk. +export const TOKEN_STORAGE_KEY = 'app_access_token'; + +// Read at module load so a page refresh is already authenticated before +// AuthContext mounts and the first request goes out. +let authToken = (() => { + try { + return sessionStorage.getItem(TOKEN_STORAGE_KEY); + } catch { + return null; + } +})(); + +let onUnauthorized = null; + +/** Called by AuthContext on login/logout. Pass null to clear. */ +export function setAuthToken(token) { + authToken = token || null; + try { + if (token) sessionStorage.setItem(TOKEN_STORAGE_KEY, token); + else sessionStorage.removeItem(TOKEN_STORAGE_KEY); + } catch { + /* private browsing with storage disabled - the in-memory copy still works */ + } +} + +export function getAuthToken() { + return authToken; +} + +/** + * Registered by AuthContext so an expired token anywhere in the app drops the + * session once, rather than leaving every panel to render its own 401 error. + */ +export function setUnauthorizedHandler(fn) { + onUnauthorized = fn; +} + +function authHeaders() { + return authToken ? { Authorization: `Bearer ${authToken}` } : {}; +} + +class ApiError extends Error { + constructor(message, status) { + super(message); + this.status = status; + } +} + +async function request(path, options = {}) { + let res; + try { + res = await fetch(`${BASE}${path}`, { + headers: { + 'Content-Type': 'application/json', + ...authHeaders(), + ...(options.headers || {}), + }, + ...options, + }); + } catch { + throw new ApiError( + `Could not reach the API at ${BASE || '(same origin)'}${path}. Is the backend running ` + + `(uvicorn app.main:app) and reachable?`, + 0 + ); + } + + if (!res.ok) { + let detail = `Request failed (${res.status})`; + try { + const body = await res.json(); + detail = body.detail || JSON.stringify(body); + } catch { + /* ignore parse errors, keep generic message */ + } + // 401 means the token is missing, expired or invalid - the session is over. + // 403 is a live session lacking a permission, so it must NOT log you out. + if (res.status === 401 && onUnauthorized) onUnauthorized(detail); + throw new ApiError(detail, res.status); + } + + if (res.status === 204) return null; + return res.json(); +} + +function qs(params = {}) { + const usp = new URLSearchParams(); + Object.entries(params).forEach(([k, v]) => { + if (v !== undefined && v !== null && v !== '' && v !== false) usp.set(k, v); + }); + const s = usp.toString(); + return s ? `?${s}` : ''; +} + +export const api = { + getHealth: () => request('/api/health'), + + // --- Auth --- + login: (username, password) => + request('/api/auth/login', { + method: 'POST', + body: JSON.stringify({ username, password }), + }), + getMe: () => request('/api/auth/me'), + getRoles: () => request('/api/auth/roles'), + + // --- Electronics catalogue (verified products only) --- + getCategories: () => request('/api/elec/categories'), + getBrands: (category) => request(`/api/elec/brands${qs({ category })}`), + getProducts: (params) => request(`/api/elec/products${qs(params)}`), + getProduct: (id) => request(`/api/elec/products/${id}`), + getPriceHistory: (id) => request(`/api/elec/products/${id}/price-history`), + getSites: () => request('/api/elec/sites'), + + // --- Admin --- + startRun: (body) => request('/api/elec/admin/runs', { method: 'POST', body: JSON.stringify(body) }), + getRunJob: (jobId) => request(`/api/elec/admin/runs/${jobId}`), + getRuns: () => request('/api/elec/admin/runs'), + probeSite: (domain) => request(`/api/elec/admin/sites/${encodeURIComponent(domain)}/probe`, { method: 'POST' }), + getReviewQueue: () => request('/api/elec/admin/review'), + reviewMatch: (listingId, approve) => + request(`/api/elec/admin/review/${listingId}`, { method: 'POST', body: JSON.stringify({ approve }) }), +}; + +export { ApiError }; diff --git a/frontend/src/assets/vite.svg b/frontend/src/assets/vite.svg new file mode 100644 index 0000000..5101b67 --- /dev/null +++ b/frontend/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/frontend/src/components/Atoms.jsx b/frontend/src/components/Atoms.jsx new file mode 100644 index 0000000..d746519 --- /dev/null +++ b/frontend/src/components/Atoms.jsx @@ -0,0 +1,19 @@ +export function Spinner({ label = 'Loading…', className = '' }) { + return ( +
    + + {label} +
    + ); +} + +export function EmptyState({ icon: Icon, title, subtitle, action }) { + return ( +
    + {Icon && } +

    {title}

    + {subtitle &&

    {subtitle}

    } + {action} +
    + ); +} diff --git a/frontend/src/components/BrandMark.jsx b/frontend/src/components/BrandMark.jsx new file mode 100644 index 0000000..62364c0 --- /dev/null +++ b/frontend/src/components/BrandMark.jsx @@ -0,0 +1,21 @@ +import React from 'react'; + +/* + * Electrical-products mark: a lightning bolt inside a plug face (two prongs + * above, a round socket ring around the bolt). Same drawing as + * public/favicon.svg, kept inline so it scales crisply and needs no asset. + */ +export function BrandMark({ className = 'h-12 w-12', title = 'Electronics Catalog' }) { + return ( + + + {/* Plug prongs */} + + + {/* Socket ring */} + + {/* Bolt */} + + + ); +} diff --git a/frontend/src/components/ErrorBoundary.jsx b/frontend/src/components/ErrorBoundary.jsx new file mode 100644 index 0000000..6f6f045 --- /dev/null +++ b/frontend/src/components/ErrorBoundary.jsx @@ -0,0 +1,48 @@ +import React from 'react'; +import { AlertTriangle, RefreshCw } from 'lucide-react'; + +export class ErrorBoundary extends React.Component { + constructor(props) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error) { + return { hasError: true, error }; + } + + componentDidCatch(error, errorInfo) { + console.error('ErrorBoundary caught an error:', error, errorInfo); + } + + render() { + if (this.state.hasError) { + return ( +
    +
    +
    + +
    +
    +
    +

    Something went wrong in this section

    +

    + {this.state.error?.message || 'An unexpected rendering error occurred.'} +

    +
    + +
    + ); + } + + return this.props.children; + } +} diff --git a/frontend/src/components/NavigationHeader.jsx b/frontend/src/components/NavigationHeader.jsx new file mode 100644 index 0000000..1446f74 --- /dev/null +++ b/frontend/src/components/NavigationHeader.jsx @@ -0,0 +1,132 @@ +import React from 'react'; +import { Link, useLocation, useNavigate } from 'react-router-dom'; +import { Wrench, ShoppingBag, AlertTriangle, UserCheck, ShieldCheck, LogOut } from 'lucide-react'; +import { useAuth } from '../context/useAuth'; + +export function NavigationHeader({ + title, + subtitle, + icon: TitleIcon, + health, + onResetHome, + children, +}) { + const location = useLocation(); + const path = location.pathname; + const navigate = useNavigate(); + const { user, role, logout } = useAuth(); + + const handleLogout = () => { + logout(); + navigate('/login'); + }; + + const getRoleBadge = () => { + if (role === 'admin') { + return ( + + Admin + + ); + } + return ( + + User + + ); + }; + + return ( + <> +
    +
    + {/* Left Section: Page Title */} +
    + {title && ( +
    + {TitleIcon && ( +
    + +
    + )} +
    +

    + {title} +

    + {subtitle &&

    {subtitle}

    } +
    +
    + )} +
    + + {/* Right Section: System Health + Role-Based Nav Tabs + Logout Button */} +
    + {health && health.status !== 'ok' && ( + + + Database offline - run `docker compose up -d` + + )} + + {/* User Role Profile Badge */} +
    + {user?.display_name || user?.username || 'Guest'} + {getRoleBadge()} +
    + + {/* Role-tailored Navigation Tabs */} + +
    +
    + + {children} +
    + + {/* + Logout is pinned to the bottom-right of the viewport rather than sitting + in the header row. It is rendered as a sibling of
    , not inside + it: the header creates a stacking/blur context via `sticky` and + `backdrop-blur`, and a fixed child of that context is positioned + against the header rather than the viewport. + + Icon-only, so the accessible name comes from aria-label - without it the + button would be announced as just "button". + */} + + + ); +} diff --git a/frontend/src/components/ProductCard.jsx b/frontend/src/components/ProductCard.jsx new file mode 100644 index 0000000..02dd928 --- /dev/null +++ b/frontend/src/components/ProductCard.jsx @@ -0,0 +1,60 @@ +import React from 'react'; +import { Store, MapPin, ImageOff } from 'lucide-react'; +import { formatINR, variantText } from '../lib/format'; + +export function ProductCard({ product, onOpen }) { + const price = formatINR(product.best_price); + return ( + + ); +} diff --git a/frontend/src/components/ProductModal.jsx b/frontend/src/components/ProductModal.jsx new file mode 100644 index 0000000..80597c8 --- /dev/null +++ b/frontend/src/components/ProductModal.jsx @@ -0,0 +1,313 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { X, ExternalLink, MapPin, Info, Star, MessageSquareText } from 'lucide-react'; +import { CartesianGrid, Legend, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'; +import { api } from '../api/client'; +import { Spinner } from './Atoms'; +import { formatINR, timeAgo, variantText } from '../lib/format'; + +const SPEC_LABELS = { + ram_gb: 'RAM (GB)', storage_gb: 'Storage (GB)', display_inch: 'Display (inch)', display_type: 'Display type', + refresh_hz: 'Refresh rate (Hz)', processor: 'Processor', rear_camera_mp: 'Rear camera (MP)', + front_camera_mp: 'Front camera (MP)', battery_mah: 'Battery (mAh)', os: 'OS', network: 'Network', + colour: 'Colour', storage_type: 'Storage type', resolution: 'Resolution', gpu: 'Graphics', + weight_kg: 'Weight (kg)', battery_wh: 'Battery (Wh)', +}; +const LINE_COLOURS = ['#16213e', '#e2a33d', '#3f8556', '#8b2e3c', '#44557d', '#c2872a', '#2f6a43']; + +const SENTIMENT_STYLE = { + positive: { dot: 'bg-leaf-500', label: 'Positive' }, + neutral: { dot: 'bg-slate-400', label: 'Neutral' }, + negative: { dot: 'bg-maroon-500', label: 'Negative' }, +}; + +function Stars({ value, size = 'h-3.5 w-3.5' }) { + const v = Number(value) || 0; + return ( + + {[1, 2, 3, 4, 5].map((i) => { + const fill = Math.max(0, Math.min(1, v - (i - 1))); + return ( + + + + + + + ); + })} + + ); +} + +function formatReviewDate(value) { + if (!value) return null; + const d = new Date(value); + return Number.isNaN(d.getTime()) ? value : d.toLocaleDateString('en-IN', { day: 'numeric', month: 'short', year: 'numeric' }); +} + +function RatingsAndReviews({ rating, reviews }) { + return ( +
    +

    Ratings & reviews

    +
    + + {reviews.length ? ( + + ) : ( +
    + + No verified reviews found on readable product pages. +
    + )} +
    +
    + ); +} + +function PriceHistory({ rows }) { + const { data, sites } = useMemo(() => { + const siteNames = [...new Set(rows.map((r) => r.site))]; + const byDay = new Map(); + rows.forEach((r) => { + const day = r.observed_at.slice(0, 10); + const entry = byDay.get(day) || { day }; + entry[r.site] = Number(r.price); + byDay.set(day, entry); + }); + return { data: [...byDay.values()], sites: siteNames }; + }, [rows]); + if (data.length < 2) { + return

    Price history appears after the product has been observed on more than one day.

    ; + } + return ( +
    + + + + + formatINR(v)} width={80} /> + formatINR(v)} /> + + {sites.map((s, i) => ( + + ))} + + +
    + ); +} + +export function ProductModal({ productId, onClose }) { + const [detail, setDetail] = useState(null); + const [history, setHistory] = useState([]); + const [error, setError] = useState(null); + const [imageIndex, setImageIndex] = useState(0); + + useEffect(() => { + let alive = true; + Promise.all([api.getProduct(productId), api.getPriceHistory(productId)]) + .then(([d, h]) => { + if (!alive) return; + setDetail(d); + setHistory(h); + }) + .catch((e) => alive && setError(e.message)); + return () => { + alive = false; + }; + }, [productId]); + + useEffect(() => { + const onKey = (e) => e.key === 'Escape' && onClose(); + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [onClose]); + + const specs = detail ? Object.entries(detail.canonical_specs || {}) : []; + const image = detail?.images?.[imageIndex]; + + return ( +
    +
    e.stopPropagation()} + > + + {!detail && !error && } + {error &&

    {error}

    } + {detail && ( +
    +
    +
    + {image ? ( + {detail.display_name} + ) : ( +

    No verified image yet

    + )} +
    + {image && ( +

    + Image from {image.site} ({image.source_type === 'search_image' ? 'image search, matched to this listing' : 'the product page'}).{' '} + Source page +

    + )} + {detail.images.length > 1 && ( +
    + {detail.images.map((img, i) => ( + + ))} +
    + )} +
    + +
    +
    +

    {detail.brand} · {detail.category}

    +

    {detail.display_name}

    +

    {variantText(detail)}

    +
    + +
    +

    Where it is sold

    +
    + + + + + + + + + + {detail.offers.map((o) => ( + + + + + + + ))} + +
    PlatformPriceSeen +
    + {o.site} + {o.site_region === 'TN' && ( + + TN + + )} + {o.colour &&

    {o.colour}

    } +
    + {o.price_outlier ? ( + + {formatINR(o.price)} + unconfirmed + + ) : ( + formatINR(o.price) || not stated + )} + {o.mrp &&

    {formatINR(o.mrp)}

    } +
    {timeAgo(o.observed_at)} + + Open + +
    +
    +

    + + Prices are national listing prices as published by each platform; none was checked against a Tamil Nadu + pincode unless marked. Platforms that are not fetched directly show only what their search results state. +

    +
    + + + +
    +

    Specifications

    + {specs.length ? ( +
    + {specs.map(([k, v]) => ( +
    +
    {SPEC_LABELS[k] || k}
    +
    {String(v)}
    +
    + ))} +
    + ) : ( +

    No specifications were readable from this product's pages yet.

    + )} +
    + +
    +

    Price history

    + +
    +
    +
    + )} +
    +
    + ); +} diff --git a/frontend/src/context/AuthContext.jsx b/frontend/src/context/AuthContext.jsx new file mode 100644 index 0000000..b8e1807 --- /dev/null +++ b/frontend/src/context/AuthContext.jsx @@ -0,0 +1,119 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { api, setAuthToken, setUnauthorizedHandler, getAuthToken } from '../api/client'; +import { AuthContext } from './useAuth'; + +const USER_STORAGE_KEY = 'app_user_session'; + +/* + * The server is now the only authority on identity. + * + * An earlier version of this file kept a VALID_CREDENTIALS table and signed + * people in locally whenever /api/auth/login was unreachable or rejected them. + * That made sense while the backend had no real auth - the profile only chose + * which buttons to draw. It does not any more: every write endpoint checks the + * token, so a locally-minted session would render a full dashboard whose every + * action then failed with a 401. Login lives entirely on the server. + */ +export function AuthProvider({ children }) { + const [user, setUser] = useState(() => { + // Only trust a stored profile if its token is still present alongside it. + try { + if (!getAuthToken()) return null; + const saved = sessionStorage.getItem(USER_STORAGE_KEY); + return saved ? JSON.parse(saved) : null; + } catch (e) { + console.warn('Could not restore auth session', e); + return null; + } + }); + + // True until a restored token has been checked against the server, so the + // app can hold off rendering rather than flashing a dashboard that is about + // to be torn down by a 401. + const [restoring, setRestoring] = useState(() => Boolean(getAuthToken())); + + const clearSession = useCallback(() => { + setUser(null); + setAuthToken(null); + try { + sessionStorage.removeItem(USER_STORAGE_KEY); + localStorage.removeItem(USER_STORAGE_KEY); + } catch { + /* storage unavailable - in-memory state is already cleared */ + } + }, []); + + // Any 401 from anywhere in the app ends the session exactly once. + useEffect(() => { + setUnauthorizedHandler(() => clearSession()); + return () => setUnauthorizedHandler(null); + }, [clearSession]); + + // Validate a restored token before trusting the profile stored next to it. + // Without this, a token that expired overnight would let the app mount and + // then fail every request individually. + useEffect(() => { + if (!restoring) return; + let cancelled = false; + + api.getMe() + .then((profile) => { + if (cancelled) return; + setUser(profile); + sessionStorage.setItem(USER_STORAGE_KEY, JSON.stringify(profile)); + }) + .catch(() => { + // 401 already cleared the session via the handler above; anything else + // (backend down mid-refresh) is also safest treated as signed out. + if (!cancelled) clearSession(); + }) + .finally(() => { + if (!cancelled) setRestoring(false); + }); + + return () => { cancelled = true; }; + }, [restoring, clearSession]); + + useEffect(() => { + // A previous version persisted to localStorage. Clear any leftover so an + // old profile can't outlive the tab it was created in. + localStorage.removeItem(USER_STORAGE_KEY); + }, []); + + /** + * Sign in. The `role` argument the login form passes is ignored on purpose: + * the role now comes from the account the password belongs to, not from the + * tab the user happened to click. Picking "Admin" and typing the user + * password signs you in as a user. + */ + const login = async (username, password) => { + const res = await api.login(username, password); + setAuthToken(res.access_token); + setUser(res.user); + try { + sessionStorage.setItem(USER_STORAGE_KEY, JSON.stringify(res.user)); + } catch { + /* storage unavailable - the session still works for this page load */ + } + setRestoring(false); + return res.user; + }; + + const logout = () => clearSession(); + + const hasPermission = useCallback( + (permission) => + Boolean(user) && (user.role === 'admin' || (user.permissions || []).includes(permission)), + [user] + ); + + const effectiveRole = user?.role === 'store' ? 'user' : (user?.role || 'user'); + + return ( + + {children} + + ); +} diff --git a/frontend/src/context/useAuth.js b/frontend/src/context/useAuth.js new file mode 100644 index 0000000..aa18451 --- /dev/null +++ b/frontend/src/context/useAuth.js @@ -0,0 +1,20 @@ +import { createContext, useContext } from 'react'; + +/* + * The context object and its hook live here, apart from AuthContext.jsx. + * + * That split is what React Fast Refresh needs: a module that exports a + * component alongside non-component values cannot be hot-swapped, so every + * edit to the provider would remount the whole tree and drop the session you + * were testing with. AuthContext.jsx now exports only , and the + * hook everything imports lives in this plain module. + */ +export const AuthContext = createContext(null); + +export function useAuth() { + const context = useContext(AuthContext); + if (!context) { + throw new Error('useAuth must be used within an AuthProvider'); + } + return context; +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..678edb9 --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,88 @@ +@import "tailwindcss"; + +/* + Design tokens - "Global Catalogue Ledger" direction. + Price-tag and ledger styling carried over from the original catalogue, + paired with a calm, modern data-tool palette. Navy/cream + base (echoes the navy/light-blue tone already used in this project's + engineering docs) with a turmeric-amber accent and a maroon highlight + for badges/sale-style tags. +*/ +@theme { + --font-display: "Sora", "Inter", system-ui, sans-serif; + --font-body: "Inter", system-ui, sans-serif; + --font-mono: "JetBrains Mono", "IBM Plex Mono", monospace; + + --color-ink-950: #0f1626; + --color-ink-900: #16213e; + --color-ink-800: #1f2d4f; + --color-ink-700: #2c3e63; + --color-ink-600: #44557d; + --color-ink-100: #e7eaf2; + --color-ink-50: #f2f4f8; + + --color-paper-50: #fdfbf6; + --color-paper-100: #faf6ed; + --color-paper-200: #f1ead9; + --color-paper-300: #e4d8bd; + + --color-amber-100: #fbe9c6; + --color-amber-400: #f0b94f; + --color-amber-500: #e2a33d; + --color-amber-600: #c2872a; + + --color-maroon-100: #f3dde1; + --color-maroon-500: #8b2e3c; + --color-maroon-600: #732431; + + --color-leaf-100: #dcefe1; + --color-leaf-500: #3f8556; + --color-leaf-600: #2f6a43; + + --color-slate-400: #7c8392; + --color-slate-500: #5b6472; + --color-slate-600: #454c58; +} + +html, body { + background-color: var(--color-paper-100); + color: var(--color-ink-950); + font-family: var(--font-body); +} + +/* Receipt-style dashed divider, used between chat turns and price-tag cards */ +.dash-divider { + background-image: repeating-linear-gradient( + to right, + var(--color-ink-700) 0, + var(--color-ink-700) 6px, + transparent 6px, + transparent 12px + ); + height: 1px; +} + +/* Price-tag "punch hole" notch used on product cards */ +.price-tag-notch::before { + content: ""; + position: absolute; + top: 14px; + left: -7px; + width: 14px; + height: 14px; + background: var(--color-paper-100); + border-radius: 50%; + border: 2px solid var(--color-ink-900); +} + +::-webkit-scrollbar { + width: 8px; + height: 8px; +} +::-webkit-scrollbar-thumb { + background: var(--color-ink-700); + border-radius: 8px; +} +::-webkit-scrollbar-track { + background: transparent; +} diff --git a/frontend/src/lib/format.js b/frontend/src/lib/format.js new file mode 100644 index 0000000..60a37b5 --- /dev/null +++ b/frontend/src/lib/format.js @@ -0,0 +1,38 @@ +const inr = new Intl.NumberFormat('en-IN', { style: 'currency', currency: 'INR', maximumFractionDigits: 0 }); + +/** Money arrives from the API as a decimal string ("74999.00"); null means unknown. */ +export function formatINR(value) { + if (value === null || value === undefined || value === '') return null; + const n = Number(value); + return Number.isFinite(n) ? inr.format(n) : null; +} + +export function timeAgo(iso) { + if (!iso) return ''; + const seconds = Math.max(0, (Date.now() - new Date(iso).getTime()) / 1000); + if (seconds < 90) return 'just now'; + const minutes = seconds / 60; + if (minutes < 90) return `${Math.round(minutes)} min ago`; + const hours = minutes / 60; + if (hours < 36) return `${Math.round(hours)} h ago`; + return `${Math.round(hours / 24)} days ago`; +} + +/** How a price/listing was obtained, in words a buyer understands. */ +export const SOURCE_LABEL = { + scraped_page: { label: 'Read from product page', tone: 'bg-leaf-100 text-leaf-600' }, + brand_official: { label: 'Brand official site', tone: 'bg-ink-100 text-ink-800' }, + search_snippet: { label: 'From web search result', tone: 'bg-amber-100 text-amber-600' }, + search_image: { label: 'Image search', tone: 'bg-amber-100 text-amber-600' }, +}; + +export function variantText(p) { + const parts = []; + if (p.ram_gb) parts.push(`${Number(p.ram_gb)} GB RAM`); + if (p.storage_gb) { + const gb = Number(p.storage_gb); + parts.push(gb >= 1024 && gb % 1024 === 0 ? `${gb / 1024} TB` : `${gb} GB`); + } + if (p.processor) parts.push(p.processor.toUpperCase()); + return parts.join(' · '); +} diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx new file mode 100644 index 0000000..b9a1a6d --- /dev/null +++ b/frontend/src/main.jsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import './index.css' +import App from './App.jsx' + +createRoot(document.getElementById('root')).render( + + + , +) diff --git a/frontend/src/pages/AdminPage.jsx b/frontend/src/pages/AdminPage.jsx new file mode 100644 index 0000000..b33b5bc --- /dev/null +++ b/frontend/src/pages/AdminPage.jsx @@ -0,0 +1,213 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { Wrench, Play, RefreshCw, Loader2 } from 'lucide-react'; +import { api } from '../api/client'; +import { NavigationHeader } from '../components/NavigationHeader'; +import { Spinner } from '../components/Atoms'; +import { timeAgo } from '../lib/format'; + +const BRANDS = { + mobiles: ['samsung', 'apple', 'xiaomi', 'oneplus', 'vivo', 'oppo', 'realme', 'motorola', 'google', 'nothing'], + laptops: ['hp', 'dell', 'lenovo', 'asus', 'acer', 'apple', 'msi', 'samsung'], +}; +// Recent runs are only a short-term progress aid: show the last few, and let +// them age out of view (the rows stay in the database - price history +// references them). +const RECENT_RUN_LIMIT = 3; +const RECENT_RUN_MAX_AGE_MS = 18 * 60 * 60 * 1000; + +function Card({ title, children, action }) { + return ( +
    +
    +

    {title}

    + {action} +
    + {children} +
    + ); +} + +function SitesPanel() { + const [sites, setSites] = useState(null); + const load = useCallback(() => api.getSites().then(setSites).catch(() => setSites([])), []); + useEffect(() => { + load(); + }, [load]); + const retail = (sites || []).filter((s) => s.kind !== 'brand_official'); + return ( + }> + {!sites ? ( + + ) : ( +
    + + + + + + + + + {retail.map((s) => ( + + + + + ))} + +
    PlatformRegion
    +

    {s.name}

    +

    {s.domain}

    +
    {s.region === 'TN' ? 'Tamil Nadu' : 'National'}
    +
    + )} +
    + ); +} + +function RunPanel() { + const [category, setCategory] = useState('mobiles'); + const [selected, setSelected] = useState(['samsung']); + const [limit, setLimit] = useState(10); + const [useLlm, setUseLlm] = useState(true); + const [job, setJob] = useState(null); + const [error, setError] = useState(null); + const [runs, setRuns] = useState([]); + const [now, setNow] = useState(() => Date.now()); + const timer = useRef(null); + + const loadRuns = useCallback(() => api.getRuns().then(setRuns).catch(() => {}), []); + useEffect(() => { + loadRuns(); + // Re-evaluate the age cut-off every minute so old runs drop out of view. + const tick = setInterval(() => setNow(Date.now()), 60000); + return () => { + clearInterval(timer.current); + clearInterval(tick); + }; + }, [loadRuns]); + const recentRuns = runs + .filter((r) => r.started_at && now - new Date(r.started_at).getTime() <= RECENT_RUN_MAX_AGE_MS) + .slice(0, RECENT_RUN_LIMIT); + const running = job?.status === 'running' || job?.status === 'queued'; + + const toggle = (b) => setSelected((s) => (s.includes(b) ? s.filter((x) => x !== b) : [...s, b])); + + const start = async () => { + setError(null); + try { + const { job_id } = await api.startRun({ category, brands: selected, limit, use_llm: useLlm }); + clearInterval(timer.current); + timer.current = setInterval(async () => { + const j = await api.getRunJob(job_id); + setJob(j); + if (j.status === 'done' || j.status === 'failed') { + clearInterval(timer.current); + loadRuns(); + } + }, 2000); + } catch (e) { + setError(e.message); + } + }; + + return ( + +
    +
    + {Object.keys(BRANDS).map((c) => ( + + ))} +
    +
    + {BRANDS[category].map((b) => ( + + ))} +
    +
    + + +
    +

    + Runs search the web for each brand, read pages only on platforms graded A/B (robots.txt obeyed, one request per 3 s per + site), and store nothing that was not read from a real page or search result. +

    + + {error &&

    {error}

    } + {job && ( +
    +

    status: {job.status}

    + {job.log.slice(-12).map((line, i) => ( +

    {line}

    + ))} + {job.error &&

    {job.error}

    } +
    + )} +
    +

    Recent runs

    + {!recentRuns.length &&

    No runs in the last 18 hours.

    } +
      + {recentRuns.map((r) => ( +
    • + + #{r.id} {r.kind} {r.params?.category || ''} {(r.params?.brands || []).join(', ')} + + + {r.status} · {timeAgo(r.started_at)} + {r.stats?.products_verified !== undefined && ` · ${r.stats.products_verified} verified`} + +
    • + ))} +
    +
    +
    +
    + ); +} + +export function AdminPage() { + return ( +
    + +
    +
    + +
    +
    + +
    +
    +
    + ); +} diff --git a/frontend/src/pages/HomePage.jsx b/frontend/src/pages/HomePage.jsx new file mode 100644 index 0000000..19ab85b --- /dev/null +++ b/frontend/src/pages/HomePage.jsx @@ -0,0 +1,155 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { Cpu, Search, Smartphone, Laptop, PackageSearch } from 'lucide-react'; +import { api } from '../api/client'; +import { NavigationHeader } from '../components/NavigationHeader'; +import { ProductCard } from '../components/ProductCard'; +import { ProductModal } from '../components/ProductModal'; +import { EmptyState, Spinner } from '../components/Atoms'; +import { formatINR } from '../lib/format'; + +const CATEGORY_ICONS = { mobiles: Smartphone, laptops: Laptop }; + +export function HomePage() { + const [health, setHealth] = useState(null); + const [categories, setCategories] = useState([]); + const [category, setCategory] = useState('mobiles'); + const [brands, setBrands] = useState([]); + const [brand, setBrand] = useState(''); + const [query, setQuery] = useState(''); + const [maxPrice, setMaxPrice] = useState(''); + const [result, setResult] = useState({ total: 0, products: [] }); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [openId, setOpenId] = useState(null); + + useEffect(() => { + api.getHealth().then(setHealth).catch(() => setHealth({ status: 'degraded', database: false })); + api.getCategories().then(setCategories).catch(() => {}); + }, []); + + useEffect(() => { + setBrand(''); + api.getBrands(category).then(setBrands).catch((e) => setError(e.message)); + }, [category]); + + const load = useCallback(() => { + setLoading(true); + setError(null); + api + .getProducts({ category, brand, q: query, max_price: maxPrice, limit: 120 }) + .then(setResult) + .catch((e) => setError(e.message)) + .finally(() => setLoading(false)); + }, [category, brand, query, maxPrice]); + + useEffect(() => { + const t = setTimeout(load, 250); + return () => clearTimeout(t); + }, [load]); + + return ( +
    + setBrand('')} + /> + +
    + {/* Category switch */} +
    + {(categories.length ? categories : [{ slug: 'mobiles', name: 'Mobiles' }, { slug: 'laptops', name: 'Laptops' }]).map((c) => { + const Icon = CATEGORY_ICONS[c.slug] || PackageSearch; + return ( + + ); + })} +
    + + {/* Brands */} +
    + + {brands.map((b) => ( + + ))} +
    + + {/* Filters */} +
    + + +
    + +

    + {loading ? 'Loading…' : `${result.total} verified product${result.total === 1 ? '' : 's'}`} · A product is shown only + when real listings on at least two platforms confirm it. +

    + + {error &&

    {error}

    } + {loading && !result.products.length ? ( + + ) : result.products.length ? ( +
    + {result.products.map((p) => ( + setOpenId(prod.product_id)} /> + ))} +
    + ) : ( + + )} +
    + + {openId && setOpenId(null)} />} +
    + ); +} diff --git a/frontend/src/pages/LoginPage.jsx b/frontend/src/pages/LoginPage.jsx new file mode 100644 index 0000000..e652a70 --- /dev/null +++ b/frontend/src/pages/LoginPage.jsx @@ -0,0 +1,215 @@ +import React, { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useAuth } from '../context/useAuth'; +import { ShieldCheck, Lock, User, ArrowRight, Eye, EyeOff, Zap, BadgeCheck, IndianRupee, MapPin } from 'lucide-react'; +import { BrandMark } from '../components/BrandMark'; + +const HIGHLIGHTS = [ + { icon: BadgeCheck, text: 'Only products confirmed by real listings on two or more platforms' }, + { icon: IndianRupee, text: 'Prices read from retailer pages and search results, never generated' }, + { icon: MapPin, text: 'National chains and Tamil Nadu retailers side by side' }, +]; + +// Faint circuit traces behind the brand panel - decorative only. +function CircuitPattern() { + return ( + + ); +} + +/* + * There is exactly one interactive account: `admin`. + * + * This screen used to offer an Admin/User tab pair, which was always more + * suggestion than control - the server assigns the role from the account the + * password belongs to (see AuthContext.login), so the tab never affected the + * outcome. With the `user` account switched off in the backend config, the + * tabs would have offered a sign-in that could only ever fail, so they are gone + * and this is a single admin form. + */ +export function LoginPage() { + const navigate = useNavigate(); + const { login } = useAuth(); + + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [showPassword, setShowPassword] = useState(false); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + + const handleFormSubmit = async (e) => { + e.preventDefault(); + setError(''); + if (!username.trim() || !password.trim()) { + setError('Please enter both username and password.'); + return; + } + + setLoading(true); + + try { + const user = await login(username, password); + setLoading(false); + // Only `admin` can sign in, and every route is admin-only, so there is + // one destination. Sending a non-admin to /login instead would loop. + navigate(user.role === 'admin' ? '/admin' : '/login'); + } catch (err) { + setLoading(false); + // Surface the server's own message: it distinguishes bad credentials + // from the lockout after repeated failures, which a generic string hides. + setError(err?.message || 'Login failed. Check your username and password.'); + } + }; + + return ( +
    +
    + {/* Brand panel */} +
    + +
    +
    +
    + +
    +

    Electronics Catalog

    +

    Electrical products · Tamil Nadu

    +
    +
    +
    +

    + Product platform +

    +

    + Every appliance, +
    + every price, verified. +

    +

    + Mobiles, laptops and more - collected from Indian retailers and checked across platforms. +

    +
    +
      + {HIGHLIGHTS.map(({ icon: Icon, text }) => ( +
    • + + + + {text} +
    • + ))} +
    +
    +
    + + {/* Sign-in panel */} +
    +
    +

    + Administrator Sign-In +

    +

    Welcome back

    +

    Please enter your username and password to sign in.

    +
    + + {error && ( +
    + {error} +
    + )} + +
    +
    + +
    + + setUsername(e.target.value)} + placeholder="Enter admin username" + className="w-full rounded-xl border border-ink-900/15 bg-white py-2.5 pl-9 pr-4 text-sm text-ink-950 placeholder-slate-400 transition focus:border-amber-500 focus:outline-none focus:ring-2 focus:ring-amber-500/25" + /> +
    +
    + +
    + +
    + + {/* pr-11, unlike the username field's pr-4, keeps a long + password from running underneath the toggle button. */} + setPassword(e.target.value)} + placeholder="Enter password" + className="w-full rounded-xl border border-ink-900/15 bg-white py-2.5 pl-9 pr-11 text-sm text-ink-950 placeholder-slate-400 transition focus:border-amber-500 focus:outline-none focus:ring-2 focus:ring-amber-500/25" + /> + {/* + type="button" is load-bearing: a +
    +
    + + +
    + +

    + Real listings from Indian retailers, found by web search. Nothing generated. +

    +
    +
    +
    + ); +} diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..535791e --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,30 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import tailwindcss from '@tailwindcss/vite' + +// Both dev and preview forward /api/* to FastAPI, so a same-origin (relative) +// request works whichever local server is delivering the page. Keep the two in +// sync: `preview` serves dist/, and without this it would answer /api with a +// 404 index.html, which surfaces in the UI as a login failure. +const apiProxy = { + '/api': { + // Proxy all /api/* requests straight to FastAPI on port 8000 + target: 'http://127.0.0.1:8000', + changeOrigin: true, + secure: false, + ws: true, + }, +} + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react(), tailwindcss()], + server: { + port: 5173, + proxy: apiProxy, + }, + preview: { + port: 4173, + proxy: apiProxy, + }, +}) diff --git a/run_project.py b/run_project.py new file mode 100644 index 0000000..67e1e4f --- /dev/null +++ b/run_project.py @@ -0,0 +1,279 @@ +#!/usr/bin/env python3 +"""Full-stack dev launcher: FastAPI backend + Vite frontend in one command. + +Starts uvicorn, waits until /api/health actually answers, then starts the +Vite dev server. Both children's logs are streamed to this console with a +[backend]/[frontend] prefix, and Ctrl+C shuts both down together. + +Deliberately stdlib-only: this script is the entry point *before* anything +is guaranteed to be installed, so it must run under a bare system Python +(it re-execs the backend under backend/venv if that exists). Do not add +third-party imports here. + +Usage: + python run_project.py # both services + python run_project.py --backend-only + python run_project.py --frontend-only + python run_project.py --no-reload # no uvicorn autoreload + python run_project.py --backend-port 8001 + +Note on data: the backend only ever connects to the LOCAL electronics_catalog +database (docker compose up -d); settings.py refuses any other host. +""" +from __future__ import annotations + +import argparse +import json +import os +import shutil +import signal +import subprocess +import sys +import threading +import time +import urllib.error +import urllib.request +from pathlib import Path +from typing import NoReturn + +ROOT = Path(__file__).resolve().parent +BACKEND = ROOT / "backend" +FRONTEND = ROOT / "frontend" + +IS_WINDOWS = os.name == "nt" + +# Backend boot is dominated by imports (sentence-transformers, sklearn, +# scipy), not by the app itself - app.main defers heavy work to a startup +# thread. 120s is slack for a cold first run on the 8GB/CPU-only target. +HEALTH_TIMEOUT_S = 120 + + +def log(msg: str) -> None: + print(f"[run] {msg}", flush=True) + + +def die(msg: str) -> NoReturn: + print(f"[run] ERROR: {msg}", file=sys.stderr, flush=True) + sys.exit(1) + + +def backend_python() -> str: + """Prefer backend/venv - that's where requirements.txt is installed.""" + candidates = [ + BACKEND / "venv" / "Scripts" / "python.exe", + BACKEND / "venv" / "bin" / "python", + BACKEND / ".venv" / "Scripts" / "python.exe", + BACKEND / ".venv" / "bin" / "python", + ] + for c in candidates: + if c.exists(): + return str(c) + log("no backend/venv found - falling back to the current interpreter") + return sys.executable + + +def npm_command() -> str: + # On Windows the real executable is npm.cmd; resolving it explicitly lets + # us keep shell=False, so there's a real PID to kill on shutdown. + for name in (("npm.cmd", "npm") if IS_WINDOWS else ("npm",)): + found = shutil.which(name) + if found: + return found + die("npm not found on PATH - install Node.js, or use --backend-only") + + +def preflight(want_backend: bool, want_frontend: bool) -> None: + if want_backend: + if not (BACKEND / "app" / "main.py").exists(): + die(f"missing {BACKEND / 'app' / 'main.py'} - run from the project root") + if not (BACKEND / ".env").exists(): + log("WARNING: backend/.env not found. Copy backend/.env.example and fill it in,") + log(" or the backend will start with defaults and fail to reach the DB.") + if want_frontend: + if not (FRONTEND / "package.json").exists(): + die(f"missing {FRONTEND / 'package.json'} - run from the project root") + if not (FRONTEND / "node_modules").exists(): + die("frontend/node_modules missing - run `npm install` in frontend/ first") + + +def stream(proc: subprocess.Popen, tag: str) -> threading.Thread: + """Pump a child's merged output into our stdout with a prefix.""" + + def pump() -> None: + assert proc.stdout is not None + for raw in proc.stdout: + print(f"[{tag}] {raw.rstrip()}", flush=True) + + t = threading.Thread(target=pump, name=f"stream-{tag}", daemon=True) + t.start() + return t + + +def spawn(cmd: list[str], cwd: Path, tag: str) -> subprocess.Popen: + log(f"starting {tag}: {' '.join(cmd)}") + kwargs: dict = {} + if IS_WINDOWS: + # Own process group => Ctrl+C reaches this launcher only, so we can + # tear both children down deterministically instead of racing them. + kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP + else: + kwargs["start_new_session"] = True + + proc = subprocess.Popen( + cmd, + cwd=str(cwd), + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + # No FORCE_COLOR: both children's output is piped through stream() + # rather than reaching a terminal, so forcing colour only embeds raw + # ANSI escapes in the log. It also made Node warn on every start - + # npm sets NO_COLOR when stdout is not a TTY, and Node complains when + # both are present. + env={**os.environ, "PYTHONUNBUFFERED": "1"}, + **kwargs, + ) + stream(proc, tag) + return proc + + +def kill_tree(proc: subprocess.Popen, tag: str) -> None: + """Kill a child *and its descendants*. + + Needed because the visible child is rarely the server: npm spawns node, + and `uvicorn --reload` spawns the actual worker. Terminating just the + parent leaves the grandchild holding the port, so the next run fails + with EADDRINUSE. + """ + if proc.poll() is not None: + return + log(f"stopping {tag}...") + try: + if IS_WINDOWS: + subprocess.run( + ["taskkill", "/F", "/T", "/PID", str(proc.pid)], + capture_output=True, + check=False, + ) + else: + os.killpg(os.getpgid(proc.pid), signal.SIGTERM) + except Exception as exc: # already dead, or no permission + log(f" ({tag} kill fell back to terminate: {exc})") + proc.terminate() + try: + proc.wait(timeout=15) + except subprocess.TimeoutExpired: + proc.kill() + + +def wait_for_health(port: int, proc: subprocess.Popen) -> bool: + """Poll /api/health until it answers. Returns False if the backend died. + + The endpoint returns 200 even when degraded (see routers/health.py), so a + 200 means "server is up" and the payload tells us what's actually broken. + """ + url = f"http://127.0.0.1:{port}/api/health" + log(f"waiting for backend at {url} (up to {HEALTH_TIMEOUT_S}s)...") + deadline = time.monotonic() + HEALTH_TIMEOUT_S + + while time.monotonic() < deadline: + if proc.poll() is not None: + log(f"backend exited early with code {proc.returncode} - see [backend] output above") + return False + try: + with urllib.request.urlopen(url, timeout=5) as resp: + body = json.loads(resp.read().decode("utf-8")) + log(f"backend up - status={body.get('status')}") + if not body.get("database"): + log(" WARNING: database unreachable. Check DB_* in backend/.env.") + log(" For a local Postgres+pgvector instead of a remote one:") + log(" cd backend && docker compose up -d") + if not body.get("ollama"): + log(" WARNING: Ollama unreachable. Run `ollama serve` and") + log(f" `ollama pull {body.get('ollama_model', 'qwen2.5:1.5b')}`.") + log(" Browse/search still work; /api/chat will not.") + return True + except (urllib.error.URLError, OSError, json.JSONDecodeError, TimeoutError): + time.sleep(1.5) + + log(f"backend did not answer within {HEALTH_TIMEOUT_S}s - starting frontend anyway") + return True + + +def main() -> int: + ap = argparse.ArgumentParser( + description="Run the Global Catalogue backend and frontend together.", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + ap.add_argument("--backend-only", action="store_true", help="skip the Vite dev server") + ap.add_argument("--frontend-only", action="store_true", help="skip uvicorn") + ap.add_argument("--backend-port", type=int, default=8000) + ap.add_argument("--frontend-port", type=int, default=5173) + ap.add_argument("--no-reload", action="store_true", help="disable uvicorn autoreload") + args = ap.parse_args() + + if args.backend_only and args.frontend_only: + die("--backend-only and --frontend-only are mutually exclusive") + + want_backend = not args.frontend_only + want_frontend = not args.backend_only + preflight(want_backend, want_frontend) + + procs: list[tuple[subprocess.Popen, str]] = [] + exit_code = 0 + + try: + if want_backend: + cmd = [ + backend_python(), "-m", "uvicorn", "app.main:app", + "--host", "127.0.0.1", "--port", str(args.backend_port), + ] + if not args.no_reload: + cmd.append("--reload") + backend_proc = spawn(cmd, BACKEND, "backend") + procs.append((backend_proc, "backend")) + + if want_frontend and not wait_for_health(args.backend_port, backend_proc): + return 1 + + if want_frontend: + # Vite proxies /api/* to the backend (see frontend/vite.config.js), + # so the app stays same-origin and needs no CORS or VITE_API_BASE_URL. + frontend_proc = spawn( + [npm_command(), "run", "dev", "--", "--port", str(args.frontend_port)], + FRONTEND, + "frontend", + ) + procs.append((frontend_proc, "frontend")) + + log("-" * 60) + if want_frontend: + log(f" App: http://localhost:{args.frontend_port}") + if want_backend: + log(f" API docs: http://localhost:{args.backend_port}/docs") + log(" Ctrl+C to stop everything") + log("-" * 60) + + # Exit as soon as *either* service dies - a half-running stack is + # more confusing than a clean shutdown. + while True: + for proc, tag in procs: + if proc.poll() is not None: + log(f"{tag} exited with code {proc.returncode} - shutting down") + return proc.returncode or 0 + time.sleep(0.5) + + except KeyboardInterrupt: + print(flush=True) + log("interrupted") + finally: + for proc, tag in reversed(procs): + kill_tree(proc, tag) + log("all services stopped") + + return exit_code + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/start_app.bat b/start_app.bat new file mode 100644 index 0000000..0f1e6d6 --- /dev/null +++ b/start_app.bat @@ -0,0 +1,19 @@ +@echo off +title Electronics Catalog - Local Launcher +echo ======================================================== +echo Electronics Catalog - search-first, evidence-backed (local only) +echo ======================================================== +echo. +cd /d "%~dp0" + +REM Start the local electronics database first (no-op if already running). +docker compose up -d + +REM run_project.py is stdlib-only and re-execs the backend under +REM backend\.venv itself, so plain system python is fine here. +python run_project.py %* +if errorlevel 1 ( + echo. + echo Launcher exited with an error - see the output above. +) +pause