diff --git a/.env.example b/.env.example index cf00a63..accdc43 100644 --- a/.env.example +++ b/.env.example @@ -22,6 +22,14 @@ JWT_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars # browser, so login "succeeds" but every subsequent request 401s. # COOKIE_SECURE=false +# Required — secret shared between "app" and "tech-step-intent-service" +# (docker-compose.yml, apps/api/src/config/env.ts). Unlike +# INTERNAL_WORKER_SECRET below, there's no "leave it unset" escape hatch: +# tech-step-intent-service is a core dependency, not an optional background +# job — without it, no recipe step can have its techniques detected at all. +# Generate your own the same way as JWT_SECRET above. +INTENT_SERVICE_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars + # Only needed to run the optional `tech-step-llm-worker` service — shared # between it and "app" (docker-compose.yml). Generate your own the same # way as JWT_SECRET above; leave both this and the service commented diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 919405d..5009fc9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,9 +19,15 @@ env: # exercise the success path (matching secret), not just the "unset" # rejection every environment that doesn't set this gets by default. INTERNAL_WORKER_SECRET: "ci-only-worker-secret-not-used-anywhere-else-32chars+" + # Shared between the `test` job's own uvicorn step (below) and apps/api's + # IntentServiceClient — see the `test` job for why this can't be a + # `services:` container like postgres above (GitHub Actions can only pull + # a published image, not build services/tech-step-intent-service/Dockerfile). + INTENT_SERVICE_BASE_URL: "http://localhost:8000" + INTENT_SERVICE_SECRET: "ci-only-intent-secret-not-used-anywhere-else-32chars+" jobs: - # Four independent jobs, no needs: between them — each starts in parallel + # Five independent jobs, no needs: between them — each starts in parallel # and reports as its own check, instead of the previous single chained # "lint-and-test then e2e" pipeline. lint: @@ -65,10 +71,63 @@ jobs: node-version: 22 cache: pnpm + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - uses: astral-sh/setup-uv@v3 + with: + enable-cache: true + + # `services:` (like the `postgres` container above) can only pull an + # already-published image — it can't build + # services/tech-step-intent-service/Dockerfile from this checkout. + # Running `uvicorn` as a plain background step instead: it keeps + # running for the rest of this job (GitHub Actions steps in one job + # share the same runner process tree), and `pnpm --filter api test` + # below needs a real instance to talk to per this repo's "never mock + # an internal service" test convention — same reasoning as the real + # `postgres` container just above, not a mock HTTP server. + - name: Install services/tech-step-intent-service + working-directory: services/tech-step-intent-service + run: uv sync --frozen + - name: Start services/tech-step-intent-service in the background + working-directory: services/tech-step-intent-service + run: | + uv run uvicorn intent_service.main:app --host 0.0.0.0 --port 8000 & + # `/health` only returns 200 once this service has finished + # training itself from scratch (no model ever persisted to disk — + # see its own README) — measured at ~335s per locale (~670s for + # fr+en combined) against the current ~74-technique corpus, + # trained on each technique's own synonyms in addition to its + # example phrases, so this wait is generous rather than the fast + # "base models only" check it used to be before that service + # trained itself at startup (see docker-compose.yml's healthcheck + # for the same reasoning). + timeout 900 bash -c 'until curl -sf http://localhost:8000/health > /dev/null; do sleep 2; done' + - run: pnpm install --frozen-lockfile - run: pnpm --filter api exec prisma migrate deploy - run: pnpm --filter api test + intent-service-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - uses: astral-sh/setup-uv@v3 + with: + enable-cache: true + + - name: Install services/tech-step-intent-service + working-directory: services/tech-step-intent-service + run: uv sync --frozen + - name: Run pytest + working-directory: services/tech-step-intent-service + run: uv run pytest -q + build: runs-on: ubuntu-latest steps: diff --git a/.gitignore b/.gitignore index 3507f9b..0df7f12 100644 --- a/.gitignore +++ b/.gitignore @@ -71,11 +71,12 @@ web_modules/ !.env.example !.env.test.example -# node-nlp's default auto-save file (apps/api/src/lib/recipe-matching/ -# tech-step-matcher.ts explicitly disables autoSave/autoLoad, but this is a -# belt-and-suspenders guard against it ever reappearing — a stale trained -# model on disk must never silently shadow TECH_STEP_TRAINING_DATA). -model.nlp +# Python virtualenvs/caches for services/tech-step-intent-service (this repo +# is otherwise all-Node — see that service's own .gitignore for the rest; +# duplicated here too since some tooling only honors the repo-root file). +services/tech-step-intent-service/.venv/ +services/tech-step-intent-service/__pycache__/ +services/tech-step-intent-service/.pytest_cache/ # parcel-bundler cache (https://parceljs.org/) .cache diff --git a/README.md b/README.md index 72cb20e..54864e9 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,8 @@ runtime Node pur (Docker, pas de transpilation à la volée), voir la note dans - Node.js 22 (voir `.nvmrc`) - pnpm 10 (`corepack enable` puis `corepack use pnpm@10.12.4`, ou installation manuelle) - Docker (pour Postgres en local) +- Python 3.12+ et [`uv`](https://docs.astral.sh/uv/) (pour + `services/tech-step-intent-service` en dev natif — requis, voir plus bas) ## Installation @@ -100,6 +102,17 @@ pnpm --filter api exec prisma migrate dev # techniques...) — automatique après `prisma migrate reset`, sinon à la main : pnpm --filter api prisma:seed +# Microservice de détection des techniques (spaCy) — requis, `pnpm dev:api` +# ne peut plus détecter aucune technique de cuisine sans lui. Lance-le en +# premier et laisse-le tourner : il s'entraîne lui-même à chaque démarrage +# (~11 minutes pour le corpus actuel, voir son propre README) avant de +# répondre quoi que ce soit sur /health. +cd services/tech-step-intent-service +uv sync +cp .env.example .env # édite-le : même INTENT_SERVICE_SECRET que apps/api/.env +uv run uvicorn intent_service.main:app --reload --port 8000 +cd ../.. + # Backend (http://localhost:3000) pnpm dev:api @@ -137,7 +150,7 @@ pnpm --filter web cy:run:component # tests de composant UI isolés (Cypress com pnpm build # build de tous les workspaces ``` -La CI GitHub Actions (`.github/workflows/ci.yml`) exécute quatre jobs indépendants (`lint`, `test`, `build`, `e2e` — ce dernier lance aussi `cy:run:component`) en parallèle, sur chaque push (toutes branches) et sur chaque PR vers `main` — pas de chaînage entre eux, chacun apparaît comme son propre check. Voir aussi [Déploiement](#déploiement) pour le pipeline de release (`.github/workflows/release.yml`). +La CI GitHub Actions (`.github/workflows/ci.yml`) exécute cinq jobs indépendants (`lint`, `test`, `intent-service-test`, `build`, `e2e` — ce dernier lance aussi `cy:run:component`) en parallèle, sur chaque push (toutes branches) et sur chaque PR vers `main` — pas de chaînage entre eux, chacun apparaît comme son propre check. `test` démarre `services/tech-step-intent-service` en arrière-plan (voir ce fichier) puisque la suite Mocha ne mocke jamais un service interne. Voir aussi [Déploiement](#déploiement) pour le pipeline de release (`.github/workflows/release.yml`). ### Base de test isolée de la base de dev (`apps/api`) @@ -158,6 +171,13 @@ Un garde-fou (`assertRunningAgainstTestDatabase()`) refuse d'exécuter `resetDatabase()` si `DATABASE_URL` ne contient ni `"test"` ni `"ci"` — la seule base qu'il doit rejeter est ta vraie base de dev. +`services/tech-step-intent-service` doit aussi tourner en local avant +`pnpm --filter api test` — les tests touchant `tech-step-matcher.ts` passent +par le vrai service (jamais un mock, voir +[specs/dev-conventions.md](specs/dev-conventions.md)) et échouent avec une +erreur de connexion, pas une assertion utile, s'il n'est pas démarré. Voir la +section [Développement](#développement) ci-dessus. + ## Déploiement Une seule image Docker (`apps/api/Dockerfile`) sert à la fois l'API et le frontend @@ -180,10 +200,12 @@ synchronisation de la table `sources` depuis le registre d'adaptateurs de code puis `node dist/server.js`. Les trois étapes sont sûres/idempotentes à répéter à chaque redémarrage du conteneur. -`docker-compose.yml` ne définit donc que deux services : `postgres` et `app` (un -seul port, `APP_PORT`, défaut `3000` — plus de `WEB_PORT`/`CORS_ORIGIN` à -coordonner entre deux origines, le frontend et l'API sont désormais servis depuis -la même origine). +Le duo `postgres`/`app` de `docker-compose.yml` n'expose donc qu'un seul port +applicatif, `APP_PORT` (défaut `3000`) — plus de `WEB_PORT`/`CORS_ORIGIN` à +coordonner entre deux origines, le frontend et l'API sont désormais servis +depuis la même origine. Les deux autres services du fichier +(`tech-step-intent-service`, `tech-step-llm-worker`) n'exposent eux aucun port +au host — voir leurs propres README pour leur rôle. **Pas de registre d'image** dans cette configuration : l'instance **Portainer** de production est reliée directement au dépôt Git et reconstruit elle-même diff --git a/apps/api/.env.example b/apps/api/.env.example index 9cd6e69..7273c4f 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -12,6 +12,13 @@ JWT_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars # JWT_EXPIRES_IN=7d # AUTH_COOKIE_NAME=session # CORS_ORIGIN=http://localhost:5173 +# INTENT_SERVICE_BASE_URL=http://localhost:8000 + +# Required — services/tech-step-intent-service must be running locally (see +# that service's own README) for any recipe save/preview to detect +# techniques at all. Must match that service's own INTENT_SERVICE_SECRET. +# Generate your own the same way as JWT_SECRET above. +INTENT_SERVICE_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars # Only needed if you're running services/tech-step-llm-worker locally — # every /internal/tech-steps/* request is rejected outright while unset. diff --git a/apps/api/.env.test.example b/apps/api/.env.test.example index 975619d..9c15d39 100644 --- a/apps/api/.env.test.example +++ b/apps/api/.env.test.example @@ -14,6 +14,15 @@ DATABASE_URL="postgresql://changeme:changeme@localhost:5432/batchcooking_test?sc # node -e "console.log(require('crypto').randomBytes(48).toString('hex'))" JWT_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars +# Required — the Mocha suite exercises the real techStepClassifier, which +# now round-trips over HTTP to services/tech-step-intent-service (no mocks +# of internal services, per this repo's test conventions). Start that +# service locally first (see its own README) with a matching +# INTENT_SERVICE_SECRET, or every test touching tech-step-matcher.ts fails +# with a connection error rather than a useful assertion failure. +INTENT_SERVICE_BASE_URL=http://localhost:8000 +INTENT_SERVICE_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars + # Optional — only needed to exercise tech-step-worker.routes.test.ts's # success path (a request with a matching secret); every other test runs # fine without it. Any value at least 32 chars works locally. diff --git a/apps/api/.mocharc.json b/apps/api/.mocharc.json index 193fdb9..2a00c64 100644 --- a/apps/api/.mocharc.json +++ b/apps/api/.mocharc.json @@ -2,5 +2,6 @@ "extension": ["ts"], "spec": "test/**/*.test.ts", "node-option": ["import=tsx"], - "timeout": 10000 + "timeout": 10000, + "require": ["test-support/mocha-root-hooks.ts"] } diff --git a/apps/api/package.json b/apps/api/package.json index 1147e02..8ccf511 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -26,7 +26,6 @@ "dotenv": "^16.4.5", "express": "^4.21.1", "jsonwebtoken": "^9.0.3", - "node-nlp": "4.27.0", "prisma": "^5.22.0", "zod": "^3.23.8" }, diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 4aa57db..fea6927 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -627,13 +627,13 @@ model RecipeIngredient { /// Matching a step's free text against these (`tech-step-matcher.ts`'s /// `TechStepClassifierService`) used to go through a DB-backed /// `TechStepMapping` table of per-locale regex expressions — replaced with -/// a node-nlp model trained from in-code data -/// (`tech-step-training-data.ts`) once regexes turned out unable to -/// generalize past their own literal vocabulary. Nothing queries/edits -/// that matching data at runtime anymore (it only ever feeds the -/// classifier's one-time training pass), so it no longer needs a table of -/// its own — this row now only exists to be a stable id/key other tables -/// (`StepTechStep`) reference. +/// a spaCy-based model (`services/tech-step-intent-service`) trained from +/// in-code data (`tech-step-training-data.ts`) once regexes turned out +/// unable to generalize past their own literal vocabulary. Nothing +/// queries/edits that matching data at runtime anymore (it only ever feeds +/// that service's one-time training pass), so it no longer needs a table +/// of its own — this row now only exists to be a stable id/key other +/// tables (`StepTechStep`) reference. model TechStep { id Int @id @default(autoincrement()) key String @unique diff --git a/apps/api/src/config/env.ts b/apps/api/src/config/env.ts index 47a86c4..9224ecc 100644 --- a/apps/api/src/config/env.ts +++ b/apps/api/src/config/env.ts @@ -71,6 +71,25 @@ const envSchema = z.object({ * fails closed rather than open if a real deployment forgets to set it. */ INTERNAL_WORKER_SECRET: z.string().min(32).optional(), + /** + * Base URL of `services/tech-step-intent-service` (the spaCy-based + * microservice `TechStepClassifierService` delegates NER + intent + * classification to, see `lib/recipe-matching/intent-service-client.ts`). + * Has a default (unlike `DATABASE_URL`/secrets below) since it isn't + * secret and dev natively runs it on a fixed local port — Docker Compose + * overrides it to the compose network's service name. + */ + INTENT_SERVICE_BASE_URL: z.string().url().default("http://localhost:8000"), + /** + * Shared secret sent as an `X-Intent-Service-Secret` header on every call + * to `services/tech-step-intent-service`. Unlike `INTERNAL_WORKER_SECRET` + * above, **required, no `.optional()`** — that service is a core + * dependency (recipe save/preview can no longer detect any technique + * without it), not an optional background job; an environment that + * forgets to set this must fail loudly at startup, not silently run with + * every technique detection request failing one at a time. + */ + INTENT_SERVICE_SECRET: z.string().min(32, "INTENT_SERVICE_SECRET must be at least 32 characters"), }); /** Parsed, validated environment — import this instead of reading `process.env` directly anywhere else. */ diff --git a/apps/api/src/db/reference-seed-data.ts b/apps/api/src/db/reference-seed-data.ts index ac5cf75..bc3920f 100644 --- a/apps/api/src/db/reference-seed-data.ts +++ b/apps/api/src/db/reference-seed-data.ts @@ -62,11 +62,12 @@ export const UNITS: Array<{ uid: string; type: UnitType; toBaseFactor: number }> // // Just a flat list of stable ids here — the actual matching data (per- // locale synonym lists + example phrasings the classifier trains on) lives -// in `lib/recipe-matching/tech-step-training-data.ts`'s -// `TECH_STEP_TRAINING_DATA`, not here: unlike this list, it's read by -// `TechStepClassifierService`'s training pass, not the seed script, so it -// doesn't belong alongside the rest of this file's DB-seeded reference -// data. Every entry here must have a matching entry there. +// in `services/tech-step-intent-service/intent_service/training_data.py`'s +// `TECH_STEP_TRAINING_DATA`, not here: it's owned and trained entirely by +// that separate Python service (see its own README), not read by this +// seed script at all, so it doesn't belong alongside the rest of this +// file's DB-seeded reference data. Every entry here must have a matching +// entry there. export const TECH_STEPS: string[] = [ "cook", "fry", @@ -94,6 +95,57 @@ export const TECH_STEPS: string[] = [ "bake", "plate", "coat", + // Lexique de techniques ajouté par la suite — voir + // `services/tech-step-intent-service/intent_service/training_data.py` + // pour les synonymes/phrases d'exemple de chacune. + "baste", + "appertize", + "whiskPale", + "goldenBrown", + "braise", + "truss", + "caramelize", + "score", + "lineMold", + "clarify", + "compote", + "concasse", + "confit", + "julienne", + "brunoise", + "mirepoix", + "paysanne", + "blindBake", + "bainMarie", + "smother", + "decant", + "dilute", + "punchDown", + "disgorge", + "loosen", + "shellEgg", + "scald", + "pod", + "emulsify", + "hollowOut", + "shock", + "setGel", + "glaze", + "thicken", + "filet", + "proof", + "peelBlanch", + "whipUp", + "moisten", + "pasteurize", + "poach", + "reduce", + "rubIn", + "dustWithFlour", + "sweat", + "sift", + "toast", + "zest", ]; // The 14 allergens EU Regulation 1169/2011 (Annex II) requires food diff --git a/apps/api/src/lib/recipe-matching/intent-service-client.ts b/apps/api/src/lib/recipe-matching/intent-service-client.ts new file mode 100644 index 0000000..0376d6c --- /dev/null +++ b/apps/api/src/lib/recipe-matching/intent-service-client.ts @@ -0,0 +1,101 @@ +import { env } from "../../config/env.js"; + +/** + * Thin fetch wrapper around `services/tech-step-intent-service`'s HTTP + * contract (`POST /v1/process`) — the microservice + * {@link TechStepClassifierService} (`tech-step-matcher.ts`) delegates NER + + * intent classification to, in place of the `node-nlp` `NlpManager` it used + * to own directly. See that service's own README for the full contract and + * why it never touches Postgres itself — it also owns its own training + * corpus now (`training_data.py`), trained once at its own startup, so + * `apps/api` never pushes anything to it; `process()` below is this + * client's only method. + * + * Authenticated with `INTENT_SERVICE_SECRET` — the inverse direction of + * `requireInternalWorker`'s `INTERNAL_WORKER_SECRET` (this time `apps/api` + * is the caller, not the callee), but the same "one flat shared secret" + * shape. + */ + +/** One candidate technique mention the service's `PhraseMatcher` found — offsets `[start, end)`, same convention as `String.prototype.slice`. Mirrors `EntityPayload` (Python `schemas.py`). */ +export interface IntentServiceEntity { + uid: string; + start: number; + end: number; +} + +/** The full result of a `POST /v1/process` call — mirrors `ProcessResponse` (Python `schemas.py`). `intent` is `null` only when `locale` isn't one this service trains for, or `text` is blank; otherwise always a real `uid` (the Python service's `textcat` has no "None" sentinel, unlike node-nlp — see that service's README). */ +export interface IntentServiceProcessResult { + entities: IntentServiceEntity[]; + intent: string | null; + score: number; +} + +/** + * Client for `services/tech-step-intent-service` — a real class (not a + * plain object of functions) per this repo's service-style-logic + * convention, even though it holds no state of its own: it's used as the + * one shared {@link intentServiceClient} singleton below, same reasoning as + * `TechStepClassifierService` itself. + */ +export class IntentServiceClient { + /** + * Performs a JSON request against the intent service and returns the + * parsed body. + * + * @throws {Error} if the response status is not in the 2xx range, or the + * request itself fails (network error, service down) — left as a plain + * `Error` rather than a typed `HttpError`: this is an internal + * service-to-service call, not a request `apps/api`'s own HTTP layer + * needs to map to a client-facing status code (see + * `TechStepClassifierService.warmUp`'s retry in `server.ts` for how a + * failure here is actually handled). + */ + private async _request( + path: string, + init: RequestInit = {}, + ): Promise { + try { + const response = await fetch(`${env.INTENT_SERVICE_BASE_URL}${path}`, { + ...init, + headers: { + "Content-Type": "application/json", + "X-Intent-Service-Secret": env.INTENT_SERVICE_SECRET, + ...init.headers, + }, + }); + if (!response.ok) { + const body = await response.text().catch(() => ""); + throw new Error(`${init.method ?? "GET"} ${path} failed: ${response.status} ${body}`); + } + return (await response.json()) as TResponseBody; + } catch (err) { + // Rethrown as-is — every caller (`TechStepClassifierService`) already + // wraps its own `await`s per the repo's try/catch convention; this is + // just where the `await` itself has to sit inside one. + throw err; + } + } + + /** + * Equivalent to the old `NlpManager.process(locale, text)` — returns every + * candidate technique mention (NER) plus the intent classifier's verdict + * for `text` as a whole, whether `text` is a full step description or a + * single clause `TechStepClassifierService` already cut out of one (this + * service doesn't know or care which, exactly like `NlpManager` before + * it). + */ + public async process(locale: string, text: string): Promise { + try { + return await this._request("/v1/process", { + method: "POST", + body: JSON.stringify({ locale, text }), + }); + } catch (err) { + throw err; + } + } +} + +/** Single shared instance — stateless, no reason for more than one (same reasoning as `techStepClassifier`/`prisma`). */ +export const intentServiceClient = new IntentServiceClient(); diff --git a/apps/api/src/lib/recipe-matching/tech-step-eval-dataset.ts b/apps/api/src/lib/recipe-matching/tech-step-eval-dataset.ts index 8a0d951..008ab35 100644 --- a/apps/api/src/lib/recipe-matching/tech-step-eval-dataset.ts +++ b/apps/api/src/lib/recipe-matching/tech-step-eval-dataset.ts @@ -2,8 +2,8 @@ * Hand-labeled evaluation set for {@link techStepClassifier} — what * `tech-step-eval.test.ts` runs the real classifier against to compute * precision/recall/F1 (`tech-step-evaluator.ts`), the objective gate any - * future change to `tech-step-training-data.ts` must clear (see that - * module's own doc comment). + * future change to `services/tech-step-intent-service`'s `training_data.py` + * must clear (see that module's own doc comment). * * Deliberately *not* reusing `TECH_STEP_TRAINING_DATA`'s own `utterances` * verbatim — scoring the classifier against the exact sentences it was @@ -246,7 +246,7 @@ export const TECH_STEP_EVAL_DATASET: TechStepEvalCase[] = [ // --- Documented false-positive traps, re-verified with fresh wording --- // `brown`'s EN synonyms are verb forms only ("browned"/"browning"), not // bare "brown" — precisely so this doesn't false-positive (see that - // entry's own comment in tech-step-training-data.ts). + // entry's own comment in training_data.py). { description: "This recipe calls for two tablespoons of brown sugar.", locale: "en", diff --git a/apps/api/src/lib/recipe-matching/tech-step-evaluator.ts b/apps/api/src/lib/recipe-matching/tech-step-evaluator.ts index 6158189..fc25846 100644 --- a/apps/api/src/lib/recipe-matching/tech-step-evaluator.ts +++ b/apps/api/src/lib/recipe-matching/tech-step-evaluator.ts @@ -3,8 +3,9 @@ * hand-labeled evaluation set (`tech-step-eval-dataset.ts`) — the objective * counterpart to the "inspected by eye" verdict every corpus change used to * get before this module existed. Every future edit to - * `tech-step-training-data.ts` (including the LLM-assisted suggestions the - * worker in `services/tech-step-llm-worker` proposes) is expected to run + * `services/tech-step-intent-service`'s `training_data.py` (including the + * LLM-assisted suggestions the worker in `services/tech-step-llm-worker` + * proposes) is expected to run * through `tech-step-eval.test.ts`'s regression gate, which calls * {@link computeTechStepMetrics} — a corpus change that raises recall on one * technique but silently tanks another's precision should fail loudly here, diff --git a/apps/api/src/lib/recipe-matching/tech-step-matcher.ts b/apps/api/src/lib/recipe-matching/tech-step-matcher.ts index 9bfff45..cfc51d3 100644 --- a/apps/api/src/lib/recipe-matching/tech-step-matcher.ts +++ b/apps/api/src/lib/recipe-matching/tech-step-matcher.ts @@ -1,6 +1,5 @@ -import { NlpManager } from "node-nlp"; import { prisma } from "../../db/prisma.js"; -import { TECH_STEP_TRAINING_DATA } from "./tech-step-training-data.js"; +import { intentServiceClient } from "./intent-service-client.js"; /** * Auto-detects which cooking techniques (`TechStep`) a free-text recipe @@ -15,25 +14,27 @@ import { TECH_STEP_TRAINING_DATA } from "./tech-step-training-data.js"; * generalize past its own vocabulary — a step describing melting butter as * "jusqu'à ce que le beurre ait disparu dans la poêle" mentions no verb any * regex could anchor on, yet unmistakably *means* `melt`. Replaced with a - * small hybrid pipeline built on `node-nlp` ({@link TechStepClassifierService}): + * small hybrid pipeline (originally built on `node-nlp`, now entirely + * delegated to `services/tech-step-intent-service` — a spaCy-based + * microservice, see {@link IntentServiceClient} and that service's own + * README): * - * 1. **NER** (node-nlp enum entities, `synonyms` in `TECH_STEP_TRAINING_DATA`) - * finds every *candidate* technique mention in the whole - * description, each with its exact character span — mechanically the - * same job the old regexes did, just as flat synonym lists instead of - * hand-written patterns (node-nlp's own stemmer/fuzzy matching already - * covers minor conjugation/typo variance the regexes had to enumerate - * by hand). This step alone is *not* the final answer — see step 3. + * 1. **NER** (the intent service's `PhraseMatcher`, built from its own + * `training_data.py`'s `synonyms`) finds every *candidate* technique + * mention in the whole description, each with its exact character span — + * mechanically the same job the old regexes did, just as flat synonym + * lists instead of hand-written patterns. This step alone is *not* the + * final answer — see step 3. * 2. The description is cut into clauses around those candidate spans * ({@link splitIntoClauses}) — a step naming two techniques ("Dans une * poêle chaude, faire chauffer une noix de beurre" is both `preheat` * and `melt`) needs each judged on its own surrounding context, not the * whole step lumped into one classification. - * 3. **NLP intent classification** (node-nlp's `NlpManager`, trained on - * `TECH_STEP_TRAINING_DATA`'s `utterances`) then classifies each clause - * on its own — this is what actually delivers "meaning, not keywords": - * the classifier was deliberately trained on paraphrases that never use - * the technique's own verb (e.g. "jusqu'à ce que le beurre ait + * 3. **NLP intent classification** (the intent service's `textcat`, trained + * on its own `training_data.py`'s `utterances`) then classifies each + * clause on its own — this is what actually delivers "meaning, not + * keywords": the classifier was deliberately trained on paraphrases that + * never use the technique's own verb (e.g. "jusqu'à ce que le beurre ait * disparu" for `melt`), so a clause reaching it gets labeled by what it * was trained to recognize as *meaning* a technique, not by which * literal word the NER step happened to anchor on. The NER-implied @@ -49,12 +50,12 @@ import { TECH_STEP_TRAINING_DATA } from "./tech-step-training-data.js"; * * `normalizeText` and {@link splitIntoClauses} are pure (no DB/model * access) so they stay unit-testable in isolation (see - * `test/tech-step-matcher.test.ts`); the classifier itself needs a one-time - * training pass (`_ensureTrained`, node-nlp's `NlpManager.train()`) plus a - * `TechStep.key -> id` lookup from the DB, both memoized on the shared - * {@link techStepClassifier} singleton rather than repeated per call — - * training is the expensive part (a few hundred ms for this corpus), never - * worth redoing per request let alone per step. + * `test/tech-step-matcher.test.ts`); this class only ever needs a + * `TechStep.key -> id` lookup from the DB, memoized on the shared + * {@link techStepClassifier} singleton rather than repeated per call — the + * NLP model itself trains once, inside `services/tech-step-intent-service`'s + * own startup, entirely independently of this class (see that service's + * README — this repo no longer pushes any corpus to it over HTTP). */ /** @@ -241,18 +242,31 @@ export function splitIntoClauses( * `TECH_STEP_TRAINING_DATA` — see `test/tech-step-matcher.test.ts` for the * cases this threshold was picked to pass. * - * Raised from `0.65` after finding real (non-adversarial) misclassified - * clauses that scored just above the old threshold — e.g. English recipe - * text run through the French classifier (which must find *nothing*, - * confirmed by `recipe-translation.test.ts`'s own locale-isolation test) - * scored `0.69` for `boil`, essentially classifier noise on - * out-of-vocabulary input rather than a real, confident verdict. The - * clauses this threshold exists to actually trust score far higher in - * practice (`0.91`–`1.0` for the real corrected cases found this session) - * — `0.75` sits comfortably above the noise floor and below every genuine - * match seen so far. + * Recalibrated for the migration off `node-nlp` to + * `services/tech-step-intent-service` (spaCy `textcat`, exclusive classes) + * — its score distribution is meaningfully different from node-nlp's own + * classifier, and shifts again every time the corpus' technique count + * changes (more exclusive classes generally means a *lower* natural + * confidence ceiling, softmax mass spread thinner). + * + * Currently `0.25`, set against the corpus as expanded to ~74 techniques + * (`services/tech-step-intent-service/intent_service/training_data.py`, + * `_TRAINING_ITERATIONS = 25`, `textcat` trained on each technique's own + * `synonyms` in addition to its `utterances` — see that constant's own + * comment for the calibration history) from manual spot-checks, not yet a + * real `calibrate-tech-step-threshold.ts` sweep against + * `TECH_STEP_EVAL_DATASET` (needs Postgres — see that script's own doc + * comment): observed real-case scores ranged `0.31`-`0.89` (`simmer` + * lowest, still correct in argmax and anchored anyway; `melt` highest, the + * motivating anchor-less case), against a noise floor around `0.02` + * (English text through the French classifier). `0.25` sits with real + * margin above the noise floor and below every real case seen so far, but + * **this is a placeholder pending the real eval-dataset sweep** — do not + * treat it as load-bearing precision the way the original `0.45` + * (calibrated against the ~26-technique corpus, `TECH_STEP_EVAL_DATASET` + * F1 plateauing exactly there) was. */ -export const CONFIDENCE_THRESHOLD = 0.75; +export const CONFIDENCE_THRESHOLD = 0.25; /** * One clause's full classification detail — the finer-grained sibling of @@ -273,70 +287,41 @@ export interface TechStepClauseClassification { end: number; /** The clause's NER anchor's own implied technique `uid`, if it had one — same as `TechStepClause.anchor.uid`. */ anchorUid: string | null; - /** The intent classifier's own top guess for this clause, whatever its score — `null` only when it returned node-nlp's `"None"` sentinel. Unlike {@link TechStepMatch}, never silently replaced by the anchor's uid — the whole point of this type is to expose the classifier's raw opinion, confident or not. */ + /** The intent classifier's own top guess for this clause, whatever its score — `null` only when the intent service had nothing trained for `locale`, or the clause text was blank. Unlike {@link TechStepMatch}, never silently replaced by the anchor's uid — the whole point of this type is to expose the classifier's raw opinion, confident or not. */ intentUid: string | null; /** The intent classifier's own confidence for `intentUid` — `0` when `intentUid` is `null` (nothing to have a score about). */ score: number; } /** - * Trains and owns the `node-nlp` model behind {@link matchTechStepSpans} — + * Owns the `TechStep.key -> id` lookup behind {@link matchTechStepSpans} — * a real class (not a plain object of functions) per this repo's * service-style-logic convention, even though it's only ever used as the * one shared {@link techStepClassifier} singleton below: it holds real - * state (the trained model, the memoized training/lookup promises), not - * just grouped stateless helpers. + * state (the memoized lookup promise), not just grouped stateless helpers. + * The actual NER/intent-classification model lives entirely in + * `services/tech-step-intent-service` (a separate process, trained from + * its own `training_data.py` at its own startup) — this class never + * trains or pushes anything to it, it only calls `POST /v1/process` and + * resolves whatever `uid` comes back to a local DB id. */ export class TechStepClassifierService { - /** node-nlp's manager — both NER (enum entities) and NLP (intent classification) live on the same instance, trained together. */ - private readonly _manager: NlpManager; - /** Memoized training pass — `undefined` until the first call starts it, after which every caller (concurrent or not) awaits the same promise rather than retraining. */ - private _trained: Promise | undefined; - /** Memoized `TechStep.key -> id` lookup — training data only knows techniques by their stable `uid`/`key`, resolved to the real DB id once, alongside training. */ + /** Memoized `TechStep.key -> id` lookup — resolved from the DB once, reused by every call rather than queried per request. `undefined` until the first call starts loading it, after which every caller (concurrent or not) awaits the same promise. */ + private _techStepIdsLoaded: Promise | undefined; private _techStepIdByUid: Map | undefined; - public constructor() { - this._manager = new NlpManager({ - languages: ["fr", "en"], - forceNER: true, - nlu: { log: false }, - // node-nlp's enum-entity NER defaults to a fuzzy (Levenshtein-based) - // 0.8 accuracy threshold — loose enough that e.g. "faire" (the - // generic French helper verb in almost every recipe step) fuzzy- - // matches `fry`'s synonym "frire" at 0.80, a false positive found - // while tuning this against the real training corpus. `1` (exact, - // after node-nlp's own case/accent/stemming normalization — real - // conjugation variance is still covered by listing each form in - // `tech-step-training-data.ts`) removed it without losing any real - // match. Precision matters more than recall for this stage — NER - // only proposes candidate split points, `_classifyClause`'s trained - // model (not fuzzy string distance) is what actually has to be - // right. - ner: { threshold: 1 }, - // node-nlp defaults to `autoSave`/`autoLoad: true` — silently - // persisting the trained model to a `model.nlp` file in the process's - // cwd, and *loading from that file instead of retraining* the next - // time a manager is constructed, if the file already exists. Found - // this the hard way: a stray `model.nlp` appeared at the repo root - // after running this locally. That's the opposite of what this - // service wants — `TECH_STEP_TRAINING_DATA` in code is the single - // source of truth this always trains fresh from (see this file's own - // doc comment) — a stale on-disk model silently shadowing a - // corpus/threshold update would be a nasty, hard-to-notice class of - // bug. Both off; nothing here should ever touch disk. - autoSave: false, - autoLoad: false, - }); - } - /** - * Forces training plus node-nlp's own one-time lazy setup (loading its - * bundled per-language stemmers/tokenizers on the *first* real - * `NlpManager.process()` call takes a few seconds by itself, separate - * from and much slower than the ~40ms `train()` pass — measured against - * this corpus while tuning the pipeline) to happen now, synchronously - * with server startup (see `server.ts`), rather than stalling whichever - * request happens to be first to save/preview a recipe. + * Forces the `TechStep.key -> id` lookup to load now, synchronously with + * server startup (see `server.ts`, which also retries this against a + * not-yet-reachable intent service), rather than stalling whichever + * request happens to be first to save/preview a recipe. Doesn't wait on + * `services/tech-step-intent-service` finishing its own training — that + * service is only ever considered "up" by Docker Compose/CI once it + * already is (see that service's `GET /health`), so by the time this + * runs in a real deployment it's already trained; a request racing an + * intent service that's genuinely still starting just gets an empty + * match list back (see `IntentServiceProcessResult`'s own doc comment), + * not an error. */ public async warmUp(): Promise { try { @@ -360,26 +345,22 @@ export class TechStepClassifierService { */ public async matchTechStepSpans(description: string, locale: string): Promise { try { - await this._ensureTrained(); + await this._ensureTechStepIdsLoaded(); if (description.trim().length === 0) return []; - const nerResult = await this._manager.process(locale, description); - const candidates: TechniqueCandidate[] = nerResult.entities - // node-nlp's language plugins also auto-extract their own built-in - // entities (numbers, durations, dates…) alongside the enum - // entities `_train` registered from `TECH_STEP_TRAINING_DATA` — - // `type === "enum"` is what tells the two apart; without this - // filter a step like "10 minutes" would hand `splitIntoClauses` a - // bogus "duration" candidate that resolves to no real technique. - .filter((entity) => entity.type === "enum") - .map((entity) => ({ - uid: entity.entity, - start: entity.start, - // node-nlp's own `end` is inclusive (verified against a real - // trained model) — `+ 1` converts to this module's `[start, end)` - // convention, matching `String.prototype.slice`. - end: entity.end + 1, - })); + // The intent service only ever returns enum-style candidates (its own + // `PhraseMatcher`, built solely from `TECH_STEP_TRAINING_DATA`'s + // `synonyms`) — unlike node-nlp, it never mixes in built-in + // numbers/durations/dates entities, so no `type === "enum"` filter is + // needed here anymore. Its `start`/`end` are already `[start, end)` + // (matching `String.prototype.slice`), unlike node-nlp's inclusive + // `end` — no `+ 1` needed either. + const nerResult = await intentServiceClient.process(locale, description); + const candidates: TechniqueCandidate[] = nerResult.entities.map((entity) => ({ + uid: entity.uid, + start: entity.start, + end: entity.end, + })); const clauses = splitIntoClauses(description, candidates); const matches: TechStepMatch[] = []; @@ -428,17 +409,15 @@ export class TechStepClassifierService { locale: string, ): Promise { try { - await this._ensureTrained(); + await this._ensureTechStepIdsLoaded(); if (description.trim().length === 0) return []; - const nerResult = await this._manager.process(locale, description); - const candidates: TechniqueCandidate[] = nerResult.entities - .filter((entity) => entity.type === "enum") - .map((entity) => ({ - uid: entity.entity, - start: entity.start, - end: entity.end + 1, - })); + const nerResult = await intentServiceClient.process(locale, description); + const candidates: TechniqueCandidate[] = nerResult.entities.map((entity) => ({ + uid: entity.uid, + start: entity.start, + end: entity.end, + })); const clauses = splitIntoClauses(description, candidates); const results: TechStepClauseClassification[] = []; @@ -456,15 +435,14 @@ export class TechStepClassifierService { }); continue; } - const result = await this._manager.process(locale, clauseText); - const intentUid = result.intent !== "None" ? result.intent : null; + const result = await intentServiceClient.process(locale, clauseText); results.push({ clauseText, start: clause.start, end: clause.end, anchorUid, - intentUid, - score: intentUid === null ? 0 : result.score, + intentUid: result.intent, + score: result.intent === null ? 0 : result.score, }); } return results; @@ -506,8 +484,8 @@ export class TechStepClassifierService { const clauseText = description.slice(clause.start, clause.end).trim(); if (clauseText.length === 0) return clause.anchor?.uid ?? null; - const result = await this._manager.process(locale, clauseText); - if (result.intent !== "None" && result.score >= CONFIDENCE_THRESHOLD) { + const result = await intentServiceClient.process(locale, clauseText); + if (result.intent !== null && result.score >= CONFIDENCE_THRESHOLD) { return result.intent; } return clause.anchor?.uid ?? null; @@ -517,52 +495,34 @@ export class TechStepClassifierService { } /** - * Trains `_manager` from {@link TECH_STEP_TRAINING_DATA} and resolves the - * `uid -> TechStep.id` lookup, both exactly once — memoized on - * `_trained` so a burst of concurrent calls (several steps of the same - * recipe save, awaited via the same event loop tick) all await the one - * in-flight training pass rather than each kicking off their own. + * Resolves the `uid -> TechStep.id` lookup exactly once — memoized on + * `_techStepIdsLoaded` so a burst of concurrent calls (several steps of + * the same recipe save, awaited via the same event loop tick) all await + * the one in-flight DB query rather than each firing their own. */ - private async _ensureTrained(): Promise { - if (this._trained === undefined) { - this._trained = this._train(); + private async _ensureTechStepIdsLoaded(): Promise { + if (this._techStepIdsLoaded === undefined) { + this._techStepIdsLoaded = this._loadTechStepIds(); } try { - await this._trained; + await this._techStepIdsLoaded; } catch (err) { - // A failed training pass must be retried by the *next* call, not - // leave every future call permanently rejecting against a stale - // failed promise. - this._trained = undefined; + // A failed load must be retried by the *next* call, not leave every + // future call permanently rejecting against a stale failed promise. + this._techStepIdsLoaded = undefined; throw err; } } - private async _train(): Promise { + private async _loadTechStepIds(): Promise { try { const techSteps = await prisma.techStep.findMany({ select: { id: true, key: true } }); this._techStepIdByUid = new Map(techSteps.map((techStep) => [techStep.key, techStep.id])); - - for (const entry of TECH_STEP_TRAINING_DATA) { - for (const [locale, data] of [ - ["fr", entry.fr], - ["en", entry.en], - ] as const) { - if (data.synonyms.length > 0) { - this._manager.addNamedEntityText(entry.uid, entry.uid, [locale], data.synonyms); - } - for (const utterance of data.utterances) { - this._manager.addDocument(locale, utterance, entry.uid); - } - } - } - - await this._manager.train(); } catch (err) { throw err; // see matchTechStepSpans()'s catch comment above } } } -/** Single shared instance — training is expensive enough (a few hundred ms) that every caller must reuse the one already-trained model, never spin up their own. */ +/** Single shared instance — every caller reuses the one memoized `TechStep.key -> id` lookup rather than re-querying the DB. The actual model training (expensive — a couple of minutes, both locales combined) happens entirely inside `services/tech-step-intent-service`'s own startup, not here — see that service's `_TRAINING_ITERATIONS`. */ export const techStepClassifier = new TechStepClassifierService(); diff --git a/apps/api/src/lib/recipe-matching/tech-step-training-data.ts b/apps/api/src/lib/recipe-matching/tech-step-training-data.ts deleted file mode 100644 index b4e6019..0000000 --- a/apps/api/src/lib/recipe-matching/tech-step-training-data.ts +++ /dev/null @@ -1,911 +0,0 @@ -/** - * Training corpus for {@link TechStepClassifierService} (`tech-step-matcher.ts`) - * — one entry per `TechStep` (`uid` matches `reference-seed-data.ts`'s - * `TECH_STEPS`, which still owns the reference `TechStep` rows themselves; - * this file replaces `TECH_STEPS[].mappings`' regex expressions as the - * *matching* data source). - * - * Two distinct kinds of content per technique/locale, feeding two distinct - * mechanisms of the classifier (see that file's doc comment for why both - * are needed): - * - * - `synonyms` — short literal words/set phrases, fed to node-nlp's NER - * (enum entities). Mechanically equivalent to the old regexes' verb-form - * alternations, just spelled out as plain words instead of a pattern - * (node-nlp's own stemmer/fuzzy matching already covers minor - * conjugation/typo variance that the regexes had to enumerate by hand). - * Used only to find *candidate* technique mentions and cut a step into - * clauses around them — never the final answer on their own. - * - `utterances` — full example clauses, fed to node-nlp's NLP Manager as - * training documents for the intent classifier. Deliberately mixes - * keyword-anchored phrasings (reinforces the obvious case) with - * paraphrases that never use the technique's own verb at all (e.g. - * "jusqu'à ce que le beurre ait disparu" for `melt`) — this second kind - * is what actually delivers on "comprendre le sens, pas juste les mots - * clés" (see the PR this file was introduced in): a clause reaching the - * classifier gets labeled by what it's trained to recognize as *meaning* - * this technique, not by which literal word triggered its extraction. - * - * Kept as static in-code data (not DB rows, unlike the old - * `TechStepMapping` table) because nothing needs to query/edit it at - * runtime — it only ever feeds one thing, the classifier's one-time - * training pass (see `TechStepClassifierService._ensureTrained`) — same - * reasoning `INGREDIENT_LABELS_EN` (`packages/shared`) is a plain object, - * not a database table. - */ - -/** One technique's matching data for one locale — see this file's doc comment for what each list feeds. */ -export interface TechStepLocaleTrainingData { - synonyms: string[]; - utterances: string[]; -} - -/** One technique's full training entry — `uid` must match a `TECH_STEPS[].uid` in `reference-seed-data.ts`. */ -export interface TechStepTrainingEntry { - uid: string; - fr: TechStepLocaleTrainingData; - en: TechStepLocaleTrainingData; -} - -export const TECH_STEP_TRAINING_DATA: TechStepTrainingEntry[] = [ - { - uid: "cook", - fr: { - synonyms: [ - "cuire", - "cuisez", - "cuisant", - "cuisson", - "cuit", - "cuite", - "cuites", - "cuits", - "cuisiner", - "cuisinez", - "cuisiné", - "cuisinée", - "faire cuire", - "laisser cuire", - ], - utterances: [ - "faire cuire à feu moyen", - "laisser cuire jusqu'à ce que ce soit prêt", - "la cuisson dure environ dix minutes", - "jusqu'à ce que la viande ne soit plus rose au centre", - "poursuivre la cuisson à couvert", - // Two real recipe clauses found misclassified (as `preheat` and - // `panFry` respectively, both above the confidence threshold) once - // real, longer, comma-heavy sentences started reaching the - // classifier — neither error came from a missing keyword (both - // clauses' own NER anchor, "laisser cuire"/"faire cuire", was - // already right), just the classifier's low-heat/occasional- - // stirring phrasing not resembling anything short and clean-cut it - // had actually been trained on. - "baisser le feu et laisser cuire à découvert encore un quart d'heure", - "faire cuire à feu doux en remuant de temps en temps", - ], - }, - en: { - // NOT "cooked through"/"cooking through" — both are word-prefix - // extensions of "cooked"/"cooking" above, so any text containing them - // matches BOTH the short and long form as separate overlapping NER - // candidates, corrupting clause-splitting (confirmed via "It should - // be cooking through evenly", which spuriously grew a second, - // wrongly-classified `roast` candidate). See this pattern flagged - // throughout the file wherever it was found — the fix is always to - // drop the longer, redundant form rather than keep both. - synonyms: ["cook", "cooks", "cooked", "cooking"], - utterances: [ - "cook over medium heat", - "cook until done", - "cooking takes about ten minutes", - "until no longer pink in the middle", - "continue cooking covered", - ], - }, - }, - { - uid: "fry", - fr: { - synonyms: [ - "frire", - "frit", - "frite", - "frites", - "friture", - "faire frire", - "faites frire", - "bain de friture", - "huile de friture", - ], - utterances: [ - "faire frire dans l'huile chaude", - "plonger dans la friture", - "jusqu'à ce que ce soit doré et croustillant à l'extérieur", - "l'huile doit être bien chaude avant d'y plonger les morceaux", - ], - }, - en: { - // NOT "frying oil" — a word-prefix extension of "frying" above (see - // the `cook` entry's comment for why that duplicates/corrupts NER - // candidates; here it was even worse, misclassifying as `preheat`). - synonyms: ["fry", "fries", "fried", "frying", "deep fry", "deep-fried", "deep frying"], - utterances: [ - "fry in hot oil", - "deep fry until golden", - "until crisp and golden on the outside", - "the oil should be very hot before adding the pieces", - ], - }, - }, - { - uid: "melt", - fr: { - synonyms: [ - "fondre", - "fondu", - "fondue", - "fondues", - "faire fondre", - "faites fondre", - // Also a plausible way to say "melt" (heating something — usually - // a fat — until it liquefies), not just a `preheat` phrasing — - // restores what the regex-based system anchored on before this - // pipeline replaced it. - "faire chauffer", - "faites chauffer", - "liquéfier", - "liquéfiez", - "liquéfié", - "faire liquéfier", - ], - utterances: [ - "faire fondre le beurre", - "jusqu'à ce que le beurre ait disparu dans la poêle", - "le beurre doit être complètement liquide", - "laisser le fromage devenir tout liquide sur feu doux", - ], - }, - en: { - synonyms: ["melt", "melts", "melted", "melting", "liquefy", "liquefied"], - utterances: [ - "melt the butter", - "until the butter has completely disappeared into the pan", - "the butter should be fully liquid", - "let the cheese turn completely liquid over low heat", - ], - }, - }, - { - uid: "deglaze", - fr: { - // NOT "déglacer la poêle"/"déglacer le fond de cuisson" — both are - // word-prefix extensions of "déglacer" above (see `cook`'s comment - // for why that duplicates NER candidates). - synonyms: ["déglacer", "déglacez", "déglacé", "déglacée", "déglaçage"], - utterances: [ - "déglacer avec le vin blanc", - "verser le vin dans la poêle chaude pour décoller les sucs", - "gratter les sucs de cuisson au fond de la casserole avec un peu de bouillon", - ], - }, - en: { - // NOT "deglaze the pan" — a word-prefix extension of "deglaze" above - // (see `cook`'s comment for why that duplicates NER candidates). - synonyms: ["deglaze", "deglazes", "deglazed", "deglazing", "lift the browned bits"], - utterances: [ - "deglaze with white wine", - "pour the wine into the hot pan to lift the browned bits", - "scrape up the browned bits at the bottom of the pan with a splash of stock", - ], - }, - }, - { - uid: "simmer", - fr: { - synonyms: [ - "mijoter", - "mijotez", - "mijote", - "mijotant", - "mijoté", - "frémir", - "frémissant", - "frémissante", - "à petit feu", - ], - utterances: [ - "laisser mijoter à feu doux", - "faire mijoter pendant une heure", - "de petites bulles doivent remonter doucement à la surface", - "laisser cuire tout doucement à couvert pendant longtemps", - ], - }, - en: { - // NOT "simmering gently" — a word-prefix extension of "simmering" - // above (see `cook`'s comment for why that duplicates NER candidates). - synonyms: ["simmer", "simmers", "simmered", "simmering", "gentle simmer", "low simmer"], - utterances: [ - "let it simmer over low heat", - "simmer for one hour", - "small bubbles should gently rise to the surface", - "let it cook very gently, covered, for a long time", - ], - }, - }, - { - uid: "boil", - fr: { - synonyms: [ - "bouillir", - "bouillant", - "bouillie", - "bouillies", - "ébullition", - "porter à ébullition", - "gros bouillons", - ], - utterances: [ - "porter à ébullition", - "faire bouillir l'eau", - "de grosses bulles doivent agiter la surface avec force", - "jusqu'à ce que ça bouillonne franchement", - ], - }, - en: { - // NOT "boiling point" — a word-prefix extension of "boiling" above - // (see `cook`'s comment for why that duplicates NER candidates). - synonyms: ["boil", "boils", "boiled", "boiling", "rolling boil"], - utterances: [ - "bring to a boil", - "boil the water", - "large bubbles should be vigorously breaking the surface", - "until it's rolling vigorously", - ], - }, - }, - { - uid: "roast", - fr: { - // NOT "rôti au four" — a word-prefix extension of "rôti" above (see - // `cook`'s comment for why that duplicates NER candidates). - synonyms: ["rôtir", "rôti", "rôtie", "rôties", "rôtis", "rôtissage"], - utterances: [ - "faire rôtir la volaille entière", - "le rôti doit dorer uniformément de tous les côtés", - "cuire la pièce de viande entière au four à chaleur sèche", - ], - }, - en: { - synonyms: ["roast", "roasts", "roasted", "roasting", "oven-roast", "oven roasted"], - utterances: [ - "roast the whole bird", - "it should brown evenly on every side", - "cook the whole piece of meat in dry oven heat", - ], - }, - }, - { - uid: "grill", - fr: { - synonyms: [ - "griller", - "grillez", - "grillé", - "grillée", - "grillées", - "grillade", - "grillades", - "barbecue", - "au barbecue", - ], - utterances: [ - "faire griller sur la grille du barbecue", - "marquer les steaks sur une plaque brûlante", - "des traces de quadrillage doivent apparaître à la cuisson", - ], - }, - en: { - synonyms: ["grill", "grills", "grilled", "grilling", "barbecue", "char-grill", "charbroiled"], - utterances: [ - "grill on the barbecue rack", - "sear the steaks on a scorching-hot plate", - "char marks should appear as it cooks", - ], - }, - }, - { - uid: "panFry", - fr: { - // Deliberately NOT "poêlé"/"poêlée"/"poêlés" here, despite reading - // like natural panFry vocabulary: node-nlp's French stemmer reduces - // them to the same root as the bare noun "poêle" (a pan), so - // registering them made every plain mention of "poêle" — e.g. - // `preheat`'s own "la poêle" — a false-positive panFry candidate too. - // Found via the "jusqu'à ce que le beurre ait disparu dans la poêle" - // regression test, which unexpectedly grew a spurious panFry match. - synonyms: ["sauter", "sautez", "sauté", "sautée", "sautées", "sautant", "à la poêle"], - utterances: [ - "faire sauter les légumes à la poêle", - "saisir rapidement à feu vif en remuant sans cesse", - "faire revenir en remuant vivement dans une poêle très chaude", - ], - }, - en: { - synonyms: [ - "sauté", - "sauteed", - "sautéed", - "sauteing", - "pan-fry", - "pan fried", - "pan-fried", - "stir-fry", - "pan searing", - "seared in a pan", - ], - utterances: [ - "sauté the vegetables in a pan", - "quickly sear over high heat, stirring constantly", - "cook briskly, stirring, in a very hot pan", - ], - }, - }, - { - uid: "blanch", - fr: { - synonyms: ["blanchir", "blanchissez", "blanchi", "blanchie", "blanchies", "blanchiment"], - utterances: [ - "faire blanchir les légumes deux minutes dans l'eau bouillante", - "plonger brièvement dans l'eau bouillante puis directement dans l'eau glacée", - "cuire très rapidement à l'eau bouillante avant de stopper la cuisson au froid", - ], - }, - en: { - // "parboil" is folded in here rather than kept a separate technique — - // in home-cooking usage (as opposed to professional usage, where they - // can differ) it names the same "briefly pre-cook in boiling water" - // move blanching does. - synonyms: [ - "blanch", - "blanches", - "blanched", - "blanching", - "parboil", - "parboiled", - "parboiling", - ], - utterances: [ - "blanch the vegetables for two minutes in boiling water", - "briefly plunge into boiling water then straight into ice water", - "cook very quickly in boiling water before stopping it cold", - ], - }, - }, - { - uid: "marinate", - fr: { - synonyms: [ - "mariner", - "marinez", - "mariné", - "marinée", - "marinées", - "marinade", - "macérer", - "macérez", - "macération", - "faire mariner", - ], - utterances: [ - "laisser mariner la viande toute la nuit au réfrigérateur", - "faire tremper dans la sauce plusieurs heures avant cuisson pour parfumer", - "laisser reposer dans le mélange d'huile et d'épices avant de cuisiner", - ], - }, - en: { - // NOT "marinating for" — a word-prefix extension of "marinating" - // above (see `cook`'s comment for why that duplicates NER candidates - // — here it was even worse, misclassifying as `simmer`). - synonyms: [ - "marinate", - "marinates", - "marinated", - "marinating", - "marinade", - "soak in the marinade", - ], - utterances: [ - "let the meat marinate overnight in the fridge", - "soak in the sauce for several hours before cooking to flavor it", - "let it sit in the oil and spice mixture before cooking", - ], - }, - }, - { - uid: "chop", - fr: { - // NOT "hacher grossièrement" — a word-prefix extension of "hacher" - // above (see `cook`'s comment for why that duplicates NER candidates). - synonyms: [ - "hacher", - "hachez", - "haché", - "hachée", - "hachées", - "hachis", - "couper en morceaux", - "tailler en morceaux", - ], - utterances: [ - "hacher finement les oignons", - "couper en tout petits morceaux irréguliers au couteau", - "réduire les herbes en petits fragments avant de les ajouter", - ], - }, - en: { - // NOT "chop coarsely" — a word-prefix extension of "chop" above (see - // `cook`'s comment for why that duplicates NER candidates). - synonyms: ["chop", "chops", "chopped", "chopping", "roughly chop", "coarsely chopped"], - utterances: [ - "finely chop the onions", - "cut into small, uneven pieces with a knife", - "break the herbs down into small bits before adding them", - ], - }, - }, - { - uid: "peel", - fr: { - synonyms: [ - "éplucher", - "épluchez", - "épluché", - "épluchée", - "épluchées", - "épluchage", - "peler", - "pelez", - "pelé", - "pelée", - "pelées", - ], - utterances: [ - "éplucher les pommes de terre", - "retirer la peau des carottes avec un économe", - "ôter la pelure du fruit avant de le couper", - ], - }, - en: { - synonyms: ["peel", "peels", "peeled", "peeling", "pare", "pared", "paring"], - utterances: [ - "peel the potatoes", - "remove the skin from the carrots with a peeler", - "take the skin off the fruit before cutting it", - ], - }, - }, - { - uid: "mince", - fr: { - synonyms: [ - "émincer", - "émincez", - "émincé", - "émincée", - "émincées", - "ciseler", - "ciselez", - "ciselé", - "ciselée", - "ciselées", - ], - utterances: [ - "émincer l'oignon en fines lamelles", - "couper en très fines tranches régulières", - "détailler en lamelles aussi fines que possible", - // Without this, a short clause naming a different vegetable — - // "Émincer les tomates" — scored just above `melt`'s confidence - // threshold instead (a training-set-composition side effect of - // adding utterances elsewhere in this same pass, found by the full - // regression suite). A second example anchored on a different noun - // widens `mince`'s own region enough to reclaim it. - "émincer les tomates en fines rondelles", - ], - }, - en: { - // NOT "mince finely" — a word-prefix extension of "mince" above (see - // `cook`'s comment for why that duplicates NER candidates). - synonyms: ["mince", "minces", "minced", "mincing", "thinly slice", "finely mince"], - utterances: [ - "mince the onion into thin strips", - "cut into very thin, even slices", - "slice into strips as thin as possible", - ], - }, - }, - { - uid: "mix", - fr: { - synonyms: [ - "mélanger", - "mélangez", - "mélangé", - "mélangée", - "mélangées", - "mélange", - "brasser", - "brassez", - "amalgamer", - "amalgamez", - ], - utterances: [ - "mélanger tous les ingrédients dans un saladier", - "combiner le sucre et la farine ensemble", - "remuer jusqu'à obtenir une préparation homogène", - ], - }, - en: { - synonyms: [ - "mix", - "mixes", - "mixed", - "mixing", - "combine", - "combined", - "blend", - "blended", - "blending", - "stir together", - ], - utterances: [ - "mix all the ingredients in a bowl", - "combine the sugar and flour together", - "stir until the mixture is smooth and even", - ], - }, - }, - { - uid: "whisk", - fr: { - synonyms: [ - "fouetter", - "fouettez", - "fouetté", - "fouettée", - "fouettées", - "au fouet", - "battre au fouet", - "monter au fouet", - ], - utterances: [ - "fouetter les œufs et le sucre", - "battre vigoureusement au fouet jusqu'à ce que ça blanchisse", - "travailler énergiquement pour incorporer de l'air au mélange", - // Without these, "Fouetter les blancs en neige" misclassified as - // `foldIn` — its own training utterance below also happens to say - // "les blancs en neige", and node-nlp's intent classifier leaned on - // that shared noun phrase over the actual verb. The exact phrase - // itself is needed (not just a paraphrase of it) — a longer, - // differently-worded utterance alone wasn't enough to outweigh - // `foldIn`'s own close phrasing. - "fouetter les blancs en neige", - "fouetter les blancs en neige jusqu'à ce qu'ils soient fermes", - ], - }, - en: { - synonyms: ["whisk", "whisks", "whisked", "whisking", "beat", "whip", "whipped", "whipping"], - utterances: [ - "whisk the eggs and sugar", - "beat vigorously with a whisk until pale", - "work it briskly to whip air into the mixture", - "whisk the egg whites until stiff peaks form", - ], - }, - }, - { - uid: "foldIn", - fr: { - synonyms: [ - "incorporer", - "incorporez", - "incorporé", - "incorporée", - "incorporées", - // NOT "incorporer délicatement" — it's a superstring of "incorporer" - // above, so both would match the same text and hand - // `splitIntoClauses` two overlapping candidates for one mention - // (found via "Incorporer délicatement la farine" producing two - // duplicate matches instead of one). - "mélanger délicatement", - ], - utterances: [ - "incorporer délicatement les blancs en neige", - "ajouter en soulevant doucement la masse pour ne pas casser les bulles", - "mélanger tout doucement de bas en haut pour garder l'air emprisonné", - ], - }, - en: { - synonyms: ["fold in", "folds in", "folded in", "folding in", "gently fold", "fold gently"], - utterances: [ - "gently fold in the beaten egg whites", - "add by gently lifting the batter so you don't knock the air out", - "very gently stir from the bottom up to keep the air trapped in", - ], - }, - }, - { - uid: "setAside", - fr: { - synonyms: [ - "réserver", - "réservez", - "réservé", - "réservée", - "réservées", - "mettre de côté", - "laisser de côté", - ], - utterances: [ - "réserver au frais en attendant", - "mettre de côté pour plus tard", - "laisser attendre sur le plan de travail pendant la préparation du reste", - ], - }, - en: { - synonyms: ["set aside", "sets aside", "setting aside", "set it aside", "reserve", "reserved"], - utterances: [ - "set aside in the fridge for now", - "put it aside for later", - "let it wait on the counter while you prepare the rest", - ], - }, - }, - { - uid: "season", - fr: { - synonyms: [ - "assaisonner", - "assaisonnez", - "assaisonné", - "assaisonnée", - "assaisonnement", - "relever", - "relevez", - "épicer", - "épicez", - ], - utterances: [ - "assaisonner avec du sel et du poivre", - "rectifier le goût en ajoutant des épices", - "ajouter du sel selon votre goût avant de servir", - ], - }, - en: { - synonyms: ["season", "seasons", "seasoned", "seasoning", "spice it up", "add seasoning"], - utterances: [ - "season with salt and pepper", - "adjust the taste by adding spices", - "add salt to taste before serving", - ], - }, - }, - { - uid: "drain", - fr: { - synonyms: [ - "égoutter", - "égouttez", - "égoutté", - "égouttée", - "égouttées", - "essorer", - "essorez", - "essoré", - "essorée", - ], - utterances: [ - "égoutter les pâtes dans une passoire", - "verser dans une passoire pour retirer l'eau de cuisson", - "laisser l'excédent d'eau s'écouler avant de servir", - ], - }, - en: { - synonyms: ["drain", "drains", "drained", "draining", "strain", "strained", "straining"], - utterances: [ - "drain the pasta in a colander", - "pour into a colander to remove the cooking water", - "let the excess water run off before serving", - ], - }, - }, - { - uid: "brown", - fr: { - synonyms: [ - "faire revenir", - "faites revenir", - "faire dorer", - "faites dorer", - "colorer", - "colorez", - "faire colorer", - ], - utterances: [ - "faire revenir les oignons dans l'huile chaude", - "faire dorer la viande sur toutes les faces", - "saisir jusqu'à ce que la surface prenne une belle couleur caramel", - ], - }, - en: { - // Verb forms only (not bare "brown"), same reasoning the old regex - // doc comment gave — a bare "brown" false-positives on ingredient - // descriptions like "brown sugar"/"brown rice", which never get to - // the classifier since they're not step text, but keeping the - // synonym itself anchored costs nothing and stays consistent. - synonyms: ["browned", "browning"], - utterances: [ - "brown the onions in hot oil", - "brown the meat on every side", - "sear until the surface turns a deep caramel color", - ], - }, - }, - { - uid: "rest", - fr: { - synonyms: ["reposer", "laisser reposer", "laissez reposer", "temps de repos"], - utterances: [ - "laisser reposer la pâte trente minutes", - "laisser la viande se détendre hors du four avant de la découper", - "attendre quelques minutes avant de servir pour que les jus se répartissent", - ], - }, - en: { - // Anchored to "let ... rest"/"rest for" rather than bare "rest", - // same false-positive reasoning as `brown` above ("the rest of the"). - synonyms: ["let it rest", "let them rest", "resting for", "rested for", "resting time"], - utterances: [ - "let the dough rest for thirty minutes", - "let the meat relax outside the oven before carving it", - "wait a few minutes before serving so the juices redistribute", - ], - }, - }, - { - uid: "preheat", - fr: { - synonyms: [ - "préchauffer", - "préchauffez", - "préchauffé", - "préchauffée", - // A pan already described as hot ("poêle chaude") implies it's - // been preheated, without the verb itself — the classic "Dans une - // poêle chaude, faire chauffer une noix de beurre" case (both - // `preheat` and `melt` in one instruction). - "poêle chaude", - "préchauffage", - ], - utterances: [ - "préchauffer le four à 180 degrés", - "mettre le four à chauffer avant d'y placer le plat", - "allumer le four à l'avance pour qu'il soit à température", - // A pan gets preheated too, not just an oven — without an example - // like this, "poêle" (which also appears throughout `panFry`'s own - // training utterances) biased the classifier toward `panFry` for - // any preheating clause that happens to mention a pan, found while - // testing against the classic "Préchauffer la poêle, puis faire - // fondre le beurre" case. - "préchauffer la poêle avant d'y verser l'huile", - "faire chauffer la poêle à vide quelques minutes", - // "poêle" + "feu vif" together still read as `panFry` (the act of - // actually cooking something in it) rather than `preheat` (getting - // it hot beforehand, nothing in it yet) without an example this - // close to that exact wording — found via "mettre la poêle sur feu - // vif" (no food mentioned at all) still classifying as panFry. - "mettre la poêle vide sur feu vif avant d'ajouter quoi que ce soit", - "mettre la poêle sur feu vif", - ], - }, - en: { - // NOT "preheating time" — a word-prefix extension of "preheating" - // above (see `cook`'s comment for why that duplicates NER candidates). - synonyms: ["preheat", "preheats", "preheated", "preheating", "hot pan"], - utterances: [ - "preheat the oven to 180 degrees", - "turn the oven on to heat up before putting the dish in", - "switch the oven on ahead of time so it's up to temperature", - "preheat the pan before adding the oil", - "heat the empty pan for a few minutes first", - ], - }, - }, - { - uid: "bake", - fr: { - synonyms: [ - "cuire au four", - "cuisson au four", - "enfourner", - "enfournez", - "au four", - "enfourné", - "enfournée", - ], - utterances: [ - "enfourner pendant quarante-cinq minutes", - "mettre au four jusqu'à ce que ce soit doré", - "cuire dans le four préchauffé jusqu'à ce que la surface soit ferme", - ], - }, - en: { - // NOT "baked in the oven" — a word-prefix extension of "baked" above - // (see `cook`'s comment for why that duplicates NER candidates). - synonyms: ["bake", "bakes", "baked", "baking", "in the oven", "oven-baked"], - utterances: [ - "bake for forty-five minutes", - "put it in the oven until golden", - "cook in the preheated oven until the surface is firm", - ], - }, - }, - { - uid: "plate", - fr: { - // NOT "dressage de l'assiette" — a word-prefix extension of - // "dressage" above (see `cook`'s comment for why that duplicates NER - // candidates). - synonyms: ["dresser", "dressez", "dressage", "disposer dans l'assiette"], - utterances: [ - "dresser harmonieusement dans les assiettes", - "disposer joliment sur l'assiette avant de servir", - "présenter avec soin au centre de l'assiette", - ], - }, - en: { - // NOT "plate up"/"plated nicely" — both are word-prefix extensions of - // "plate"/"plated" above (see `cook`'s comment for why that - // duplicates NER candidates). - synonyms: ["plate", "plates", "plated", "plating"], - utterances: [ - "plate it up nicely", - "arrange it neatly on the plate before serving", - "present it carefully in the center of the plate", - ], - }, - }, - { - uid: "coat", - fr: { - synonyms: [ - "napper", - "nappez", - "nappé", - "nappée", - "nappées", - "nappage", - "enrober", - "enrobez", - "enrobé", - "enrobée", - "enrobées", - ], - utterances: [ - "napper le gâteau de chocolat fondu", - "recouvrir uniformément d'une fine couche de sauce", - "verser la sauce par-dessus pour bien enrober", - ], - }, - en: { - // NOT "coat evenly" — a word-prefix extension of "coat" above (see - // `cook`'s comment for why that duplicates NER candidates). - synonyms: ["coat", "coats", "coated", "coating", "dredge", "dredged", "dredging"], - utterances: [ - "coat the cake with melted chocolate", - "cover evenly with a thin layer of sauce", - "pour the sauce over it so it's well covered", - ], - }, - }, -]; diff --git a/apps/api/src/scripts/backfill-tech-steps.ts b/apps/api/src/scripts/backfill-tech-steps.ts index 5facc3e..45753aa 100644 --- a/apps/api/src/scripts/backfill-tech-steps.ts +++ b/apps/api/src/scripts/backfill-tech-steps.ts @@ -6,8 +6,8 @@ import { renumberStepTechSteps } from "../modules/recipe/recipe-tech-step-correc /** * Recomputes every existing `Step`'s `"auto"`-sourced `StepTechStep` * entries against the *current* classifier - * (`tech-step-matcher.ts`/`tech-step-training-data.ts`), the same way - * `updateRecipe` does when a user resaves a recipe through the UI — + * (`tech-step-matcher.ts`, delegating to `services/tech-step-intent-service`), + * the same way `updateRecipe` does when a user resaves a recipe through the UI — * always `"fr"` (`DEFAULT_TECH_STEP_LOCALE` in `recipe.service.ts`; there's * no persisted per-recipe locale to recover for a step that already * exists, so this matches real resave behavior exactly rather than diff --git a/apps/api/src/scripts/calibrate-tech-step-threshold.ts b/apps/api/src/scripts/calibrate-tech-step-threshold.ts new file mode 100644 index 0000000..f1cc85e --- /dev/null +++ b/apps/api/src/scripts/calibrate-tech-step-threshold.ts @@ -0,0 +1,101 @@ +import { prisma } from "../db/prisma.js"; +import { TECH_STEP_EVAL_DATASET } from "../lib/recipe-matching/tech-step-eval-dataset.js"; +import { + computeTechStepMetrics, + type TechStepEvalOutcome, +} from "../lib/recipe-matching/tech-step-evaluator.js"; +import { techStepClassifier } from "../lib/recipe-matching/tech-step-matcher.js"; + +/** + * Candidate thresholds to sweep, `0.05` to `0.95` in `0.05` steps — fine + * enough to find a good value without an unreasonable number of full + * `TECH_STEP_EVAL_DATASET` passes (each threshold only needs one + * {@link techStepClassifier.classifyClauses} call per eval case, not a + * retrain — see this file's own doc comment for why). + */ +const CANDIDATE_THRESHOLDS = Array.from({ length: 19 }, (_, i) => Math.round((i + 1) * 5) / 100); + +/** + * One-off maintainer tool for recalibrating `CONFIDENCE_THRESHOLD` + * (`tech-step-matcher.ts`) after a change to the underlying intent + * classifier — most notably, the migration from `node-nlp` to + * `services/tech-step-intent-service` (spaCy): a different model produces a + * differently-shaped confidence score distribution, so a threshold tuned + * against the old classifier has no reason to still be the right cutoff for + * the new one. + * + * Reuses `techStepClassifier.classifyClauses` — already public, and + * deliberately *not* threshold-applied (see that method's own doc comment) + * — to get every eval case's raw `{anchorUid, intentUid, score}` per clause + * exactly once, then replays `_classifyClause`'s own decision rule + * (`intentUid` if confident enough, `anchorUid` otherwise) locally in this + * script for every candidate threshold. This is what makes a full sweep + * cheap: one classifier pass per eval case regardless of how many + * thresholds are being compared, rather than one full pass *per threshold*. + * + * Prints a threshold -> precision/recall/F1 table and the threshold that + * maximizes aggregate F1 — does **not** edit `tech-step-matcher.ts` itself. + * A maintainer reads the table, updates `CONFIDENCE_THRESHOLD` by hand (with + * an updated doc comment recording what run/F1 the new value was calibrated + * against, same as the existing comment's own format), then re-runs + * `retrain-tech-steps.ts` to confirm the change clears `MIN_OVERALL_F1`. + * + * Usage: + * + * pnpm --filter api exec tsx src/scripts/calibrate-tech-step-threshold.ts + */ +async function calibrateTechStepThreshold(): Promise { + console.info(`Classifying ${TECH_STEP_EVAL_DATASET.length} eval case(s)...`); + + // One classifier pass per eval case, all clauses' raw verdicts kept + // alongside the case's own `expectedKeys` — reused for every candidate + // threshold in the loop below. + const casesWithClauses = await Promise.all( + TECH_STEP_EVAL_DATASET.map(async (evalCase) => ({ + expectedKeys: evalCase.expectedKeys, + clauses: await techStepClassifier.classifyClauses(evalCase.description, evalCase.locale), + })), + ); + + console.info("\nthreshold precision recall f1"); + let bestThreshold = CANDIDATE_THRESHOLDS[0] ?? 0; + let bestF1 = -1; + + for (const threshold of CANDIDATE_THRESHOLDS) { + const outcomes: TechStepEvalOutcome[] = casesWithClauses.map(({ expectedKeys, clauses }) => { + const actualKeys = clauses + // Mirrors `_classifyClause`'s own decision rule exactly (see that + // method, `tech-step-matcher.ts`) — the classifier's own verdict + // when confident enough, otherwise its clause's NER anchor, `null` + // when neither applies (no keyword, no confident classification). + .map((clause) => + clause.intentUid !== null && clause.score >= threshold + ? clause.intentUid + : clause.anchorUid, + ) + .filter((key): key is string => key !== null); + return { expectedKeys, actualKeys }; + }); + + const { overall } = computeTechStepMetrics(outcomes); + console.info( + `${threshold.toFixed(2)} ${overall.precision.toFixed(3)} ${overall.recall.toFixed(3)} ${overall.f1.toFixed(3)}`, + ); + if (overall.f1 > bestF1) { + bestF1 = overall.f1; + bestThreshold = threshold; + } + } + + console.info( + `\nBest aggregate F1 ${bestF1.toFixed(3)} at threshold ${bestThreshold.toFixed(2)} — update CONFIDENCE_THRESHOLD in tech-step-matcher.ts by hand if this differs from the current value.`, + ); +} + +calibrateTechStepThreshold() + .then(() => prisma.$disconnect()) + .catch(async (err) => { + console.error(err); + await prisma.$disconnect(); + process.exit(1); + }); diff --git a/apps/api/src/scripts/list-pending-training-suggestions.ts b/apps/api/src/scripts/list-pending-training-suggestions.ts index 17089a3..08aa8b6 100644 --- a/apps/api/src/scripts/list-pending-training-suggestions.ts +++ b/apps/api/src/scripts/list-pending-training-suggestions.ts @@ -6,14 +6,15 @@ import { prisma } from "../db/prisma.js"; * comment) — generated by `services/tech-step-llm-worker`'s scheduled * jobs, from either a user correction or the worker's own low-confidence * audit (`sourceType`). What a maintainer reads *before* hand-editing - * `tech-step-training-data.ts` and running `retrain-tech-steps.ts` — this - * script never writes anything, purely a read-only report to stdout: + * `services/tech-step-intent-service/intent_service/training_data.py` and + * running `retrain-tech-steps.ts` — this script never writes anything, + * purely a read-only report to stdout: * * pnpm --filter api exec tsx src/scripts/list-pending-training-suggestions.ts * * Grouped by technique key so every suggestion for the same entry in - * `TECH_STEP_TRAINING_DATA` is read together, matching how that file - * itself is organized (one block per technique). + * `training_data.py`'s `TECH_STEP_TRAINING_DATA` is read together, matching + * how that file itself is organized (one block per technique). */ async function listPendingTrainingSuggestions(): Promise { const suggestions = await prisma.techStepTrainingSuggestion.findMany({ diff --git a/apps/api/src/scripts/retrain-tech-steps.ts b/apps/api/src/scripts/retrain-tech-steps.ts index 0e870e7..fbeebdf 100644 --- a/apps/api/src/scripts/retrain-tech-steps.ts +++ b/apps/api/src/scripts/retrain-tech-steps.ts @@ -28,10 +28,15 @@ function parseSuggestionIds(flag: "applied" | "rejected"): number[] { * Maintainer workflow closing the loop on a training-corpus change (see * this feature's plan document): * - * 1. A maintainer has already hand-edited `tech-step-training-data.ts` - * (informed by `list-pending-training-suggestions.ts`'s report), and + * 1. A maintainer has already hand-edited + * `services/tech-step-intent-service/intent_service/training_data.py` + * (informed by `list-pending-training-suggestions.ts`'s report), * decided which `TechStepTrainingSuggestion` ids they incorporated - * (`--applied=`) or explicitly discarded (`--rejected=`). + * (`--applied=`) or explicitly discarded (`--rejected=`), **and + * restarted `tech-step-intent-service`** so it retrains from the + * edited corpus — that service only ever trains once, at its own + * startup (see its README), so this script's eval gate below is + * meaningless against a service still running the old corpus. * 2. This script re-runs the F1 regression gate * ({@link runTechStepEvalSuite} against {@link MIN_OVERALL_F1}) — * refuses to backfill at all if the edited corpus scores worse than diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index 9c477a9..4f124c1 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -9,22 +9,45 @@ import { registerAllRecipeSources } from "./sources/index.js"; // doesn't happen inside app.ts/createServer() itself. registerAllRecipeSources(); -// Trains the tech-step classifier (and pays node-nlp's own one-time lazy -// setup cost — see `TechStepClassifierService.warmUp`) before accepting -// any traffic, so the first real recipe save/preview isn't the one stuck -// waiting several seconds for it. -try { - await techStepClassifier.warmUp(); -} catch (err) { - // Not fatal to startup — a failed warm-up just means the *next* call - // retries training itself (see `_ensureTrained`'s own retry-on-failure - // comment), same graceful-degrade posture as everywhere else training - // failures surface. Still worth a loud log: this shouldn't normally fail. - logger.error("Tech-step classifier warm-up failed", { - error: err instanceof Error ? err.message : String(err), - }); +/** + * Trains the tech-step classifier (a `POST /v1/train` round-trip per locale + * to `services/tech-step-intent-service` — see + * `TechStepClassifierService.warmUp`) before accepting any traffic, so the + * first real recipe save/preview isn't the one stuck waiting for it. + * + * Retried with exponential backoff: in Docker Compose, `app`'s own + * `depends_on: tech-step-intent-service: condition: service_healthy` + * (`docker-compose.yml`) already means that service is up by the time this + * runs, but native dev (`pnpm dev:api`, no Compose ordering at all) can + * easily start this before the intent service has finished loading its + * spaCy models — a transient connection failure here shouldn't need a + * manual restart. Still non-fatal after every attempt is exhausted: the + * *next* real call retries training itself (see `_ensureTrained`'s own + * retry-on-failure comment), same graceful-degrade posture as before this + * retry loop existed. + */ +async function warmUpTechStepClassifier(): Promise { + const maxAttempts = 5; + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + await techStepClassifier.warmUp(); + return; + } catch (err) { + if (attempt === maxAttempts) { + logger.error("Tech-step classifier warm-up failed after retries", { + error: err instanceof Error ? err.message : String(err), + attempts: attempt, + }); + return; + } + const delayMs = 1000 * 2 ** (attempt - 1); + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } + } } +await warmUpTechStepClassifier(); + const server = createServer(); server.listen(env.PORT, () => { diff --git a/apps/api/src/types/node-nlp.d.ts b/apps/api/src/types/node-nlp.d.ts deleted file mode 100644 index 36d8fa3..0000000 --- a/apps/api/src/types/node-nlp.d.ts +++ /dev/null @@ -1,50 +0,0 @@ -/** - * Minimal ambient typing for `node-nlp` (no official/DefinitelyTyped types - * exist for it) — declares only the `NlpManager` surface - * `tech-step-matcher.ts` actually calls, verified against the real - * package (v4.27.0) rather than the library's full documented API, which - * this repo doesn't use the rest of. - */ -declare module "node-nlp" { - /** Constructor options this repo passes — `NlpManager` accepts more, only what's used here is typed. */ - export interface NlpManagerOptions { - languages?: string[]; - forceNER?: boolean; - nlu?: { log?: boolean }; - ner?: { threshold?: number }; - /** Defaults to `true` — persists the trained model to `modelFileName` (default `model.nlp`, in `process.cwd()`). See `tech-step-matcher.ts`'s own constructor comment for why this repo always sets it `false`. */ - autoSave?: boolean; - /** Defaults to `true` — loads from `modelFileName` instead of training fresh if that file already exists. Always `false` here, same reasoning as `autoSave`. */ - autoLoad?: boolean; - } - - /** One entity `NlpManager.process`'s result reports — see `tech-step-matcher.ts`'s own `NerEntity` for the subset this repo reads. */ - export interface NlpEntity { - entity: string; - start: number; - end: number; - type: string; - accuracy?: number; - sourceText?: string; - } - - /** `NlpManager.process`'s result — trimmed to the fields this repo reads (the real object carries many more). */ - export interface NlpProcessResult { - intent: string; - score: number; - entities: NlpEntity[]; - } - - export class NlpManager { - public constructor(options?: NlpManagerOptions); - public addNamedEntityText( - entityName: string, - optionName: string, - languages: string[], - texts: string[], - ): void; - public addDocument(locale: string, utterance: string, intent: string): void; - public train(): Promise; - public process(locale: string, text: string): Promise; - } -} diff --git a/apps/api/test-support/mocha-root-hooks.ts b/apps/api/test-support/mocha-root-hooks.ts new file mode 100644 index 0000000..240b0a4 --- /dev/null +++ b/apps/api/test-support/mocha-root-hooks.ts @@ -0,0 +1,40 @@ +import { techStepClassifier } from "../src/lib/recipe-matching/tech-step-matcher.js"; +import { resetDatabase } from "./reset-db.js"; + +/** + * Mocha root hook plugin (see `.mocharc.json`'s `require`) — runs once + * before every test file's own suites, regardless of load order. + * + * Warms up `techStepClassifier` here — resolving the `TechStep.key -> id` + * lookup from the DB (see `TechStepClassifierService._loadTechStepIds`) — + * instead of leaving it to happen lazily on whichever test file Mocha + * happens to load first, same as `server.ts` does before the real server + * ever accepts traffic. Fast by itself (one DB query, one HTTP call to + * `services/tech-step-intent-service`): that service now trains itself + * entirely at its own process startup (see its own README), so unlike + * before this migration, nothing here waits on a slow training pass — CI's + * own "wait for `/health`" step (`.github/workflows/ci.yml`) is what + * ensures that service is already fully trained before `pnpm --filter api + * test` even starts. + * + * `resetDatabase()` runs first, deliberately: id resolution needs + * `TechStep` rows, and a freshly-migrated (never-seeded) test database has + * none yet. Every per-test `beforeEach` in this suite already calls + * `resetDatabase()` again before its own test, which is a no-op + * duplication of effort but not a correctness problem: `TRUNCATE ... + * RESTART IDENTITY` plus deterministic re-seeding (`seedReferenceData`) + * assigns the exact same ids every time, so the `uid -> id` map memoized + * here from this first reset stays valid for every reset after it. + */ +export const mochaHooks = { + // biome-ignore lint/suspicious/noExplicitAny: Mocha's root hook `this` (a Context with `.timeout()`) isn't typed without @types/mocha (not a dependency here) — same untyped-`this` shape already used in tech-step-worker.routes.test.ts. + async beforeAll(this: any): Promise { + // A little more generous than Mocha's normal 10s per-test default + // (`.mocharc.json`) purely for a slower/contended CI runner's first + // network round-trip to `services/tech-step-intent-service` — not + // because anything here waits on training anymore. + this.timeout(30000); + await resetDatabase(); + await techStepClassifier.warmUp(); + }, +}; diff --git a/apps/api/test/recipe-matching/recipe-translation.test.ts b/apps/api/test/recipe-matching/recipe-translation.test.ts index 6cc3460..e1a31f3 100644 --- a/apps/api/test/recipe-matching/recipe-translation.test.ts +++ b/apps/api/test/recipe-matching/recipe-translation.test.ts @@ -38,8 +38,9 @@ describe("recipe-translation", () => { // `translateRecipeSteps` now goes through `techStepClassifier` (a // trained model, not a pure regex test against a caller-supplied // mapping list — see `tech-step-matcher.ts`), so these tests exercise - // the real training corpus (`tech-step-training-data.ts`) against a real - // `TechStep` catalog rather than synthetic fixtures — same posture + // the real training corpus (`services/tech-step-intent-service`'s + // `training_data.py`) against a real `TechStep` catalog rather than + // synthetic fixtures — same posture // `tech-step-matcher.test.ts`'s own `techStepClassifier` describe block // takes, for the same reason. describe("translateRecipeSteps", () => { diff --git a/apps/api/test/recipe-matching/tech-step-matcher.test.ts b/apps/api/test/recipe-matching/tech-step-matcher.test.ts index a2fc55b..bf667de 100644 --- a/apps/api/test/recipe-matching/tech-step-matcher.test.ts +++ b/apps/api/test/recipe-matching/tech-step-matcher.test.ts @@ -118,13 +118,16 @@ describe("tech-step-matcher", () => { // `techStepClassifier` is the one shared singleton (see // tech-step-matcher.ts's own doc comment on why) — these tests // exercise it against the real training corpus - // (`tech-step-training-data.ts`) and the real seeded `TechStep` - // catalog, rather than synthetic injectable fixtures the old - // regex-based `matchTechStepSpans(description, mappings)` allowed. - // Training + node-nlp's own one-time per-language setup can take a - // few seconds on the very first call in the whole suite (subsequent - // calls reuse the same trained model and are fast) — comfortably - // inside this suite's default 10s timeout (.mocharc.json). + // (`services/tech-step-intent-service`'s `training_data.py`) and the + // real seeded `TechStep` catalog, rather than synthetic injectable + // fixtures the old regex-based `matchTechStepSpans(description, + // mappings)` allowed. Every call round-trips over HTTP to a real, + // locally running `services/tech-step-intent-service` (see that + // service's own README and `apps/api/.env.test`) — that service trains + // itself once at its own startup (`test-support/mocha-root-hooks.ts`'s + // root hook doesn't wait on it, CI's own "wait for /health" step + // already does), so calls here are just a normal HTTP round-trip, + // comfortably inside this suite's default 10s timeout (.mocharc.json). let simmerId: number; let cookId: number; let bakeId: number; diff --git a/apps/api/test/recipe/recipe-tech-step-correction.test.ts b/apps/api/test/recipe/recipe-tech-step-correction.test.ts index 240c218..d4fff8a 100644 --- a/apps/api/test/recipe/recipe-tech-step-correction.test.ts +++ b/apps/api/test/recipe/recipe-tech-step-correction.test.ts @@ -79,7 +79,7 @@ describe("Recipe tech-step corrections", () => { const { agent, profileId } = await signup(); // "Faire mijoter la sauce." names no technique the classifier itself // registers a bare-word anchor for at this exact span in isolation - // (see tech-step-training-data.ts) — irrelevant here either way, + // (see services/tech-step-intent-service's training_data.py) — irrelevant here either way, // since this test's whole point is the *manual* addition, not // whatever the classifier does or doesn't auto-detect for it. const { recipeId, stepId } = await createPublicRecipeWithStep(profileId); diff --git a/apps/api/test/reference.test.ts b/apps/api/test/reference.test.ts index a2a1427..303da36 100644 --- a/apps/api/test/reference.test.ts +++ b/apps/api/test/reference.test.ts @@ -3,7 +3,7 @@ import request from "supertest"; import { createApp } from "../src/app.js"; import { prisma } from "../src/db/prisma.js"; import { syncRecipeSources } from "../src/db/recipe-source-sync.js"; -import { seedReferenceData } from "../src/db/reference-seed-data.js"; +import { seedReferenceData, TECH_STEPS } from "../src/db/reference-seed-data.js"; import type { RecipeSourceAdapter } from "../src/lib/recipe-sources/recipe-source-adapter.js"; import { clearRecipeSources, @@ -137,7 +137,9 @@ describe("Reference data", () => { const res = await request(app).get("/reference/tech-steps"); expect(res.status).to.equal(200); - expect(res.body).to.have.length(26); + // `TECH_STEPS.length` (reference-seed-data.ts), not a hardcoded + // number — this catalog has grown since (26 -> 74) and will again. + expect(res.body).to.have.length(TECH_STEPS.length); expect(res.body.map((t: { key: string }) => t.key)).to.include("simmer"); expect(res.body[0]).to.have.keys(["id", "key"]); }); @@ -155,7 +157,7 @@ describe("Reference data", () => { await seedReferenceData(prisma); const res = await request(app).get("/reference/tech-steps"); - expect(res.body).to.have.length(26); + expect(res.body).to.have.length(TECH_STEPS.length); }); }); diff --git a/apps/web/src/locales/fr/translation.json b/apps/web/src/locales/fr/translation.json index e2769d5..2d2e583 100644 --- a/apps/web/src/locales/fr/translation.json +++ b/apps/web/src/locales/fr/translation.json @@ -425,7 +425,55 @@ "preheat": "Préchauffer", "bake": "Cuire au four", "plate": "Dresser", - "coat": "Napper" + "coat": "Napper", + "baste": "Arroser", + "appertize": "Appertiser", + "whiskPale": "Blanchir (jaunes d'œufs)", + "goldenBrown": "Blondir", + "braise": "Braiser", + "truss": "Brider", + "caramelize": "Caraméliser", + "score": "Cerner", + "lineMold": "Chemiser", + "clarify": "Clarifier", + "compote": "Compoter", + "concasse": "Concasser", + "confit": "Confire", + "julienne": "Couper en julienne", + "brunoise": "Couper en brunoise", + "mirepoix": "Couper en mirepoix", + "paysanne": "Couper en paysanne", + "blindBake": "Cuire à blanc", + "bainMarie": "Cuire au bain-marie", + "smother": "Cuire à l'étouffée", + "decant": "Décanter", + "dilute": "Délayer", + "punchDown": "Dégazer", + "disgorge": "Dégorger", + "loosen": "Détendre", + "shellEgg": "Écaler", + "scald": "Échauder", + "pod": "Écosser", + "emulsify": "Émulsionner", + "hollowOut": "Évider", + "shock": "Frapper", + "setGel": "Gélifier", + "glaze": "Glacer", + "thicken": "Lier", + "filet": "Lever les filets", + "proof": "Laisser pousser", + "peelBlanch": "Monder", + "whipUp": "Monter", + "moisten": "Mouiller", + "pasteurize": "Pasteuriser", + "poach": "Pocher", + "reduce": "Réduire", + "rubIn": "Sabler", + "dustWithFlour": "Singer", + "sweat": "Suer", + "sift": "Tamiser", + "toast": "Torréfier", + "zest": "Zester" }, "allergens": { "gluten": "Gluten", diff --git a/docker-compose.yml b/docker-compose.yml index 1ccc2b8..4af78a7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -27,9 +27,6 @@ services: context: . dockerfile: apps/api/Dockerfile restart: unless-stopped - depends_on: - postgres: - condition: service_healthy environment: NODE_ENV: production PORT: 3000 @@ -51,8 +48,61 @@ services: # default: `/internal/tech-steps/*` fails closed rather than open # for a deployment that doesn't run the worker at all. INTERNAL_WORKER_SECRET: ${INTERNAL_WORKER_SECRET:-} + # Compose network service name, not localhost — same reasoning as + # DATABASE_URL above. Unlike INTERNAL_WORKER_SECRET, no `:-` fallback: + # tech-step-intent-service is a core dependency (see its own entry + # below), not an optional background job. + INTENT_SERVICE_BASE_URL: "http://tech-step-intent-service:8000" + INTENT_SERVICE_SECRET: ${INTENT_SERVICE_SECRET:?set INTENT_SERVICE_SECRET in .env} ports: - "${APP_PORT:-3000}:3000" + depends_on: + postgres: + condition: service_healthy + tech-step-intent-service: + condition: service_healthy + + # spaCy-based NER + intent classification microservice + # (services/tech-step-intent-service) — `app` delegates all tech-step + # detection to it over HTTP (see `IntentServiceClient`, + # apps/api/src/lib/recipe-matching/intent-service-client.ts). Unlike + # `tech-step-llm-worker` below, **not optional**: without it, `app` can no + # longer detect any cooking technique in a recipe step at all. No exposed + # port — reachable only from `app` on the compose network, nothing ever + # calls into it from outside. + tech-step-intent-service: + build: + context: . + dockerfile: services/tech-step-intent-service/Dockerfile + restart: unless-stopped + environment: + INTENT_SERVICE_SECRET: ${INTENT_SERVICE_SECRET:?set INTENT_SERVICE_SECRET in .env} + healthcheck: + # No curl/wget in the python:3.12-slim base image — a one-line Python + # request is the healthcheck for a service that's already guaranteed + # to have Python (see this service's Dockerfile). + test: + [ + "CMD", + "python", + "-c", + "import urllib.request; urllib.request.urlopen('http://localhost:8000/health', timeout=2)", + ] + interval: 15s + timeout: 3s + retries: 5 + # This service trains itself from scratch on every start (no model + # ever persisted to disk, see its own README) — `/health` only + # returns 200 once that's done, not just once the base spaCy models + # are loaded. Measured at ~335s per locale (~670s for fr+en combined) + # against the current ~74-technique corpus, trained on each + # technique's own synonyms in addition to its example phrases + # (`intent_service/locale_pipeline.py`'s `_TRAINING_ITERATIONS`) — + # `start_period` generous enough that failing checks during that + # whole window never count against `retries` (which would otherwise + # flip this container to "unhealthy" mid-training, blocking `app`'s + # own `depends_on: condition: service_healthy` indefinitely). + start_period: 900s # Deliberately its own image, not built into `app`'s (see # services/tech-step-llm-worker/Dockerfile's own doc comment) — a diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d1f9569..c06548d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,9 +44,6 @@ importers: jsonwebtoken: specifier: ^9.0.3 version: 9.0.3 - node-nlp: - specifier: 4.27.0 - version: 4.27.0 prisma: specifier: ^5.22.0 version: 5.22.0 @@ -853,224 +850,12 @@ packages: resolution: {integrity: sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==, tarball: https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz} hasBin: true - '@microsoft/recognizers-text-choice@1.3.1': - resolution: {integrity: sha512-HubunMJVq/OetmdvcAmBh5skMlg+yiScm3V2wNyNZIVvLgli4+8nzbg/W/fI9dpaf6wv9ZQ7d2IYvn8swJBo3A==, tarball: https://registry.npmjs.org/@microsoft/recognizers-text-choice/-/recognizers-text-choice-1.3.1.tgz} - engines: {node: '>=10.3.0'} - - '@microsoft/recognizers-text-data-types-timex-expression@1.3.1': - resolution: {integrity: sha512-jarJIFIJZBqeofy3hh0vdQo1yOmTM+jCjj6/zmo9JunsQ6LO750eZHCg9eLptQhsvq321XCt5xdRNLCwU8YeNA==, tarball: https://registry.npmjs.org/@microsoft/recognizers-text-data-types-timex-expression/-/recognizers-text-data-types-timex-expression-1.3.1.tgz} - engines: {node: '>=10.3.0'} - - '@microsoft/recognizers-text-date-time@1.3.2': - resolution: {integrity: sha512-fUEGOTccS55ZY0erzjS1bunJYA9lGXjcZoru5oPOlnxbJS4Lk0ylgdH2Ub2EjAyqr8DIJhdLNOEesCdAXMvlNg==, tarball: https://registry.npmjs.org/@microsoft/recognizers-text-date-time/-/recognizers-text-date-time-1.3.2.tgz} - engines: {node: '>=10.3.0'} - - '@microsoft/recognizers-text-number-with-unit@1.3.1': - resolution: {integrity: sha512-gzCpPP4zQ5Vb+RHaWjzP2t1c+mj6GYOsFoI2NyJkm8OZ52XI+x9SJCgrrD2ujzjOd5/CQVC46rE22rfGwXLDkA==, tarball: https://registry.npmjs.org/@microsoft/recognizers-text-number-with-unit/-/recognizers-text-number-with-unit-1.3.1.tgz} - engines: {node: '>=10.3.0'} - - '@microsoft/recognizers-text-number@1.3.1': - resolution: {integrity: sha512-JBxhSdihdQLQilCtqISEBw5kM+CNGTXzy5j5hNoZECNUEvBUPkAGNEJAeQPMP5abrYks29aSklnSvSyLObXaNQ==, tarball: https://registry.npmjs.org/@microsoft/recognizers-text-number/-/recognizers-text-number-1.3.1.tgz} - engines: {node: '>=10.3.0'} - - '@microsoft/recognizers-text-sequence@1.3.1': - resolution: {integrity: sha512-J7Kg35hpm0NcFHmu69Bb4q7DPDiSpCd8ApUZqNm59itIjrQJHpSdl9HF6JxuQQz0Ftc/li5ZLqSuupJAmA/sgg==, tarball: https://registry.npmjs.org/@microsoft/recognizers-text-sequence/-/recognizers-text-sequence-1.3.1.tgz} - engines: {node: '>=10.3.0'} - - '@microsoft/recognizers-text-suite@1.3.0': - resolution: {integrity: sha512-uqG4vzy5N2CmBaeINny0bLdnGp0jDbT1moNoLC+Yim3G8kHOU9lpDfwA6VN6HTYaDM5854SNMEzLjJdS1TPFTw==, tarball: https://registry.npmjs.org/@microsoft/recognizers-text-suite/-/recognizers-text-suite-1.3.0.tgz} - engines: {node: '>=10.3.0'} - - '@microsoft/recognizers-text@1.3.1': - resolution: {integrity: sha512-HikLoRUgSzM4OKP3JVBzUUp3Q7L4wgI17p/3rERF01HVmopcujY3i6wgx8PenCwbenyTNxjr1AwSDSVuFlYedQ==, tarball: https://registry.npmjs.org/@microsoft/recognizers-text/-/recognizers-text-1.3.1.tgz} - engines: {node: '>=10.3.0'} - '@napi-rs/lzma-linux-x64-gnu@1.5.1': resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==, tarball: https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz} engines: {node: ^22.20 || ^24.12 || >=25} cpu: [x64] os: [linux] - '@nlpjs/builtin-duckling@4.26.1': - resolution: {integrity: sha512-3qkH955X2g5MXV1EqT3fTAT/lLEdiqqe5IgBDyr+MQB7FOV9R3YhqGIn3DFOl+TSm/tP5n/BAEptkTNn/TOpmQ==, tarball: https://registry.npmjs.org/@nlpjs/builtin-duckling/-/builtin-duckling-4.26.1.tgz} - - '@nlpjs/builtin-microsoft@4.26.1': - resolution: {integrity: sha512-AODgzTcfYUf5Ozm00aQnHImDum7Idtl0F9dSPoaXpfj7rZqP8hPZ7iWwdGTAvISH/da2YhjPOU65QSYk2YpjFA==, tarball: https://registry.npmjs.org/@nlpjs/builtin-microsoft/-/builtin-microsoft-4.26.1.tgz} - - '@nlpjs/core-loader@4.26.1': - resolution: {integrity: sha512-IiRtn65bdiUSQHy2kusco2fmhk39u2Mc2c5Fsm9+9EVG6BtJCmVEFU/btAzGDAmxEA/E4qKecaAT4LvcW6TPbA==, tarball: https://registry.npmjs.org/@nlpjs/core-loader/-/core-loader-4.26.1.tgz} - - '@nlpjs/core@4.26.1': - resolution: {integrity: sha512-M/PeFddsi3y7Z1piFJxsLGm5/xdMhcrpOsml7s6CTEgYo8iduaT30HDd61tZxDyvvJseU6uFqlXSn7XKkAcC1g==, tarball: https://registry.npmjs.org/@nlpjs/core/-/core-4.26.1.tgz} - - '@nlpjs/emoji@4.26.1': - resolution: {integrity: sha512-Q0PoXwIvaB1bnRXK4U/YD7mrqaz29Yfed3s2au0iXl1bffUgoG+hs4GORCvyy7DFCCLlc9d5yDM3oLIX/ggZ+Q==, tarball: https://registry.npmjs.org/@nlpjs/emoji/-/emoji-4.26.1.tgz} - - '@nlpjs/evaluator@4.26.1': - resolution: {integrity: sha512-WeUrC8qq7+V8Jhkkjc2yiXdzy9V0wbETv8/qasQmL0QmEuwBDJF+fvfl4z2vWpBb0vW07A8aNrFElKELzbpkdg==, tarball: https://registry.npmjs.org/@nlpjs/evaluator/-/evaluator-4.26.1.tgz} - - '@nlpjs/lang-all@4.26.1': - resolution: {integrity: sha512-UzRm1JRRAyQqilEOxQ2ySMOitKbhPk5iKYbjD8FREDcPjreUvDxVuQsYUOvYucmEyFcZU2U/TdJx+fX9/bcaKQ==, tarball: https://registry.npmjs.org/@nlpjs/lang-all/-/lang-all-4.26.1.tgz} - - '@nlpjs/lang-ar@4.26.1': - resolution: {integrity: sha512-MUlVtabt9ltG7WyzCQpFJymLJlnEqp3mxhgN9JHyFH7oZMK3REvMovFfvEUAbfiYrJEv/BN5KKLL7yrvUeaHtg==, tarball: https://registry.npmjs.org/@nlpjs/lang-ar/-/lang-ar-4.26.1.tgz} - - '@nlpjs/lang-bn@4.26.1': - resolution: {integrity: sha512-sim1iZKBDdehi/yBUKrLW51QvS9uB+sXW7lj+THVqBy5UsnEQvt4gzE0NsC873uJMh66vt2AlHkhzgPH0qH/nQ==, tarball: https://registry.npmjs.org/@nlpjs/lang-bn/-/lang-bn-4.26.1.tgz} - - '@nlpjs/lang-ca@4.26.1': - resolution: {integrity: sha512-fD4R5tcAB0uYtNxSEF20b1KmF6nUQSbiJqrIUJI5yis4ObjCYRQnSh4bjVDKUKxyONjbD6L8EaK5GrY1/jkwFQ==, tarball: https://registry.npmjs.org/@nlpjs/lang-ca/-/lang-ca-4.26.1.tgz} - - '@nlpjs/lang-cs@4.26.1': - resolution: {integrity: sha512-CqI6VB8toaJ/MlP1D4K9BctA6GpZJhMKyEy+OX9xavDe4r4ao/SxlSaIYK3izK0k+J38lJWC5lXYGazfCdTGjA==, tarball: https://registry.npmjs.org/@nlpjs/lang-cs/-/lang-cs-4.26.1.tgz} - - '@nlpjs/lang-da@4.26.1': - resolution: {integrity: sha512-krI/ojeDSi329ENM/hLIsbUh1x4XRTKAbtPcbFxAY6XVhcSVoWPO7L77jFTL1NQeE1oGRFzGHaeC9hZJ8phVbA==, tarball: https://registry.npmjs.org/@nlpjs/lang-da/-/lang-da-4.26.1.tgz} - - '@nlpjs/lang-de@4.26.1': - resolution: {integrity: sha512-HfZQwsE5FICq9taVZDiyktmdAePVF5948NM80et0d9mx43RWDFhHKQYgtJPwfQXtdCoQtOM5TOJ2FanGwzPeaA==, tarball: https://registry.npmjs.org/@nlpjs/lang-de/-/lang-de-4.26.1.tgz} - - '@nlpjs/lang-el@4.26.1': - resolution: {integrity: sha512-pcOvuSwPCXxI+2xNZZzM4V5pTRDntYoJi0SP/ic2nV4IPQ0nU2j16dYfg1HlvET/E6iN1VTqghrCaf10SMkDGA==, tarball: https://registry.npmjs.org/@nlpjs/lang-el/-/lang-el-4.26.1.tgz} - - '@nlpjs/lang-en-min@4.26.1': - resolution: {integrity: sha512-1sJZ7dy7ysqzbsB8IklguvB88J8EPIv4XGVkZCcwecKtOw+fp5LAsZ3TJVmEf18iK1gD4cEGr7qZg5fpPxTpWQ==, tarball: https://registry.npmjs.org/@nlpjs/lang-en-min/-/lang-en-min-4.26.1.tgz} - - '@nlpjs/lang-en@4.26.1': - resolution: {integrity: sha512-GVoJpOjyk5TtBAqo/fxsiuuH7jXycyakGT0gw5f01u9lOmUnpJegvXyGff/Nb0j14pXcGHXOhmpWrcTrG2B0LQ==, tarball: https://registry.npmjs.org/@nlpjs/lang-en/-/lang-en-4.26.1.tgz} - - '@nlpjs/lang-es@4.26.1': - resolution: {integrity: sha512-fIPQt+WPcNdyxZOCMkOPlMb4Y1iE585QxjB9IAdFz8ZtVg7mc4dlv5f46ud7ppdMh84iLOuOdo6pzu2Cqm14lw==, tarball: https://registry.npmjs.org/@nlpjs/lang-es/-/lang-es-4.26.1.tgz} - - '@nlpjs/lang-eu@4.26.1': - resolution: {integrity: sha512-Ha8GHTbgQYd7dwHM8aWHDyxmbUNUcyu/5xlBKqqBOPxysDyZ6Ad0tvj0FmJBy6mYhqmFTPBnEAo69cfuFSqWIQ==, tarball: https://registry.npmjs.org/@nlpjs/lang-eu/-/lang-eu-4.26.1.tgz} - - '@nlpjs/lang-fa@4.26.1': - resolution: {integrity: sha512-qJCmNXgJZnfNXUnKnxvEGEzSFBdQT4XU7/rMxuFmSJqmQY7fH/Vsmi5CKF94VRBPOIV4ULlEJuLpUWHXRmOnVQ==, tarball: https://registry.npmjs.org/@nlpjs/lang-fa/-/lang-fa-4.26.1.tgz} - - '@nlpjs/lang-fi@4.26.1': - resolution: {integrity: sha512-W/rUcrzSh3KE07q2vOsssTpU1sbX32gbBzKPZfRJ2ZUF4afO+eHxmAywikXubP4kiU3JxVNLvXXEjuGD3SBUbA==, tarball: https://registry.npmjs.org/@nlpjs/lang-fi/-/lang-fi-4.26.1.tgz} - - '@nlpjs/lang-fr@4.26.1': - resolution: {integrity: sha512-LTA852atCJnHtKDmtjx/ui5AnvEIkrPx+MJQ2mB3gn8ko6i2UITnJgPmJE9Kej5bLasVZOAJvU/SrfXEmnPGOw==, tarball: https://registry.npmjs.org/@nlpjs/lang-fr/-/lang-fr-4.26.1.tgz} - - '@nlpjs/lang-ga@4.26.1': - resolution: {integrity: sha512-JsP1CZ8r3Jd6o/Az7cN3exz0HDP3FNYLzh4Vi6ksEkdKF0yCjJ9G5dXZYqS9qFIN5ffemWn29G4WRELY6QH/cQ==, tarball: https://registry.npmjs.org/@nlpjs/lang-ga/-/lang-ga-4.26.1.tgz} - - '@nlpjs/lang-gl@4.26.1': - resolution: {integrity: sha512-y1NNu6NVy/6o5UNfihgg0WkSlVr4IvKA5W193CpRLZWS4FccQDmnFFhyYWRkshyDbgEsfsZ0Rs3BoE82+T2Ubg==, tarball: https://registry.npmjs.org/@nlpjs/lang-gl/-/lang-gl-4.26.1.tgz} - - '@nlpjs/lang-hi@4.26.1': - resolution: {integrity: sha512-Fw9rXqF5l8q9etJG5uOlEFpnMVjQEWMaCIgQfEcA1yTvieSV8mpoSvQkEZl+DFhww+azareoJ7ZCkx0gJ9UDuQ==, tarball: https://registry.npmjs.org/@nlpjs/lang-hi/-/lang-hi-4.26.1.tgz} - - '@nlpjs/lang-hu@4.26.1': - resolution: {integrity: sha512-7dPUn5/ZpLZmsdRwO+dtORuMIiIpnsWbgSLIKdOLh8irhgUR+M2bYTfkdnKcrEcHzHPP8Svn7pU0xk7OKSUA1w==, tarball: https://registry.npmjs.org/@nlpjs/lang-hu/-/lang-hu-4.26.1.tgz} - - '@nlpjs/lang-hy@4.26.1': - resolution: {integrity: sha512-T2brpLGDJryAwWmjtnmY8Ot6ZUkCz+/nRR9/QM1PybvZIqOVLjJqA49bqjJfT5DMN89HbwC7I/15NTT0y09i1Q==, tarball: https://registry.npmjs.org/@nlpjs/lang-hy/-/lang-hy-4.26.1.tgz} - - '@nlpjs/lang-id@4.26.1': - resolution: {integrity: sha512-rVuIkYFKdltFhMT/a2ZxD9ovoZSVZF7OPuqYjTXW9xKd3Ff32yUrzcf/pHXlqmZOSltqOH3E5jZRRDkHvgUOjQ==, tarball: https://registry.npmjs.org/@nlpjs/lang-id/-/lang-id-4.26.1.tgz} - - '@nlpjs/lang-it@4.26.1': - resolution: {integrity: sha512-BZA3QnfQGW91gYaybRmHnCAPBvQggtmHZJrAmuBZUKUS12HoQm8uybjw2fZO+vahEeUQceKNDISRcT1eLLijog==, tarball: https://registry.npmjs.org/@nlpjs/lang-it/-/lang-it-4.26.1.tgz} - - '@nlpjs/lang-ja@4.26.1': - resolution: {integrity: sha512-QgkuJOkHguRFyfnckH2It5/Kg8zecnOMJsHxYeuDC4tBF7jL/5xqWis+679lYLsXtAkrG8+fjVcBbjyopP0KHg==, tarball: https://registry.npmjs.org/@nlpjs/lang-ja/-/lang-ja-4.26.1.tgz} - - '@nlpjs/lang-ko@4.26.1': - resolution: {integrity: sha512-Q0N8bLJJ829ILWCKH1UQWPSNyuLaEURAXCawkDju4pt33DBLcpqz9IzO9dnqiFc+fjSgVzZ7WMaLT18hXZQ9vg==, tarball: https://registry.npmjs.org/@nlpjs/lang-ko/-/lang-ko-4.26.1.tgz} - - '@nlpjs/lang-lt@4.26.1': - resolution: {integrity: sha512-SeYZxRhdCy+ClQNnF/u0MAtcDui/ocdk4NtgNOCuwNTNuzhN3t3rfGeArfBGmZeg1SIeBLUDE9dsTxYCv5AOEg==, tarball: https://registry.npmjs.org/@nlpjs/lang-lt/-/lang-lt-4.26.1.tgz} - - '@nlpjs/lang-ms@4.26.1': - resolution: {integrity: sha512-KxWBS+tFY2U8z9UrjQIqMM40npGDOskP5DcWhaEE3zuhzf3RTDYjy8sdz34jVd0fBdbPihX133h3bFibg2Cm7w==, tarball: https://registry.npmjs.org/@nlpjs/lang-ms/-/lang-ms-4.26.1.tgz} - - '@nlpjs/lang-ne@4.26.1': - resolution: {integrity: sha512-K3E2l+0LTESv+dO+ZTIdvNa+zwMJvvnMiFYYkKvJst6lhc8JgvGOsPxGsjJn6PDhI3wyfQu+dg3b+bnVPu4FDA==, tarball: https://registry.npmjs.org/@nlpjs/lang-ne/-/lang-ne-4.26.1.tgz} - - '@nlpjs/lang-nl@4.26.1': - resolution: {integrity: sha512-I/mP1RRbUN4BQ+8NXAl2FKaLHbb7f6S8JVjxHQ0sKHT4BgQ3+r0yO+DVcEsHg+vWRiY1Fyzh0gq0PhLVnF6HnA==, tarball: https://registry.npmjs.org/@nlpjs/lang-nl/-/lang-nl-4.26.1.tgz} - - '@nlpjs/lang-no@4.26.1': - resolution: {integrity: sha512-a0CLL2c/OCzbg7J7ugyrsAksI96XhkQ3IeBbbx60o5o/9wsFNik6cPWrkpoE5xNtw7gLlAJWabwDiZXkl8Zrcw==, tarball: https://registry.npmjs.org/@nlpjs/lang-no/-/lang-no-4.26.1.tgz} - - '@nlpjs/lang-pl@4.26.1': - resolution: {integrity: sha512-nrDXlq+TzQLE5IpXPIlFMzd8OpquvApWsouh6fmLsD9HZLZI4O3w1M4sXXLzE+9Ggu9Cy1m1QJ0/i7XCcv115g==, tarball: https://registry.npmjs.org/@nlpjs/lang-pl/-/lang-pl-4.26.1.tgz} - - '@nlpjs/lang-pt@4.26.1': - resolution: {integrity: sha512-p6yZHaJ0e+n0avMHpdDw5PMk4HkKXjPbOMbrlg0dF+VRqChjxfH478Q423rDyzu/4MzDsIYB+p6KzL9AARKXpg==, tarball: https://registry.npmjs.org/@nlpjs/lang-pt/-/lang-pt-4.26.1.tgz} - - '@nlpjs/lang-ro@4.26.1': - resolution: {integrity: sha512-baUdTA0DWpDR0Tn6fxo+RDN/6gbuINLCARtHwap2UR/HKQWP2XoH/DIvcjZpwUTalr5MQjso31epcdeRRapczA==, tarball: https://registry.npmjs.org/@nlpjs/lang-ro/-/lang-ro-4.26.1.tgz} - - '@nlpjs/lang-ru@4.26.1': - resolution: {integrity: sha512-NaZ2DAOGxWG2Us9IyIDs3m6vhGpUaUJRVgzzHHyX3LO3xEYjZmtnA0jEpBaTOe2PuNHThv0WCZUNn9BSurV3PA==, tarball: https://registry.npmjs.org/@nlpjs/lang-ru/-/lang-ru-4.26.1.tgz} - - '@nlpjs/lang-sl@4.26.1': - resolution: {integrity: sha512-QBJwcJt+oKUpAnHKNJkLkx9Xm1n4dUPC5GPYfAXTnJZf0hNWJSY21GicdWi7Vu/qFJ3ghIqtSP8D7KIPLnibNw==, tarball: https://registry.npmjs.org/@nlpjs/lang-sl/-/lang-sl-4.26.1.tgz} - - '@nlpjs/lang-sr@4.26.1': - resolution: {integrity: sha512-drH3+UqTW637uLWsnLrcp8jEKUGxV61ZgCBjNkVQNEv1/jbpSg6IqgynSY2JyhtnlV0f870KS0HvSbyo5AD4Ng==, tarball: https://registry.npmjs.org/@nlpjs/lang-sr/-/lang-sr-4.26.1.tgz} - - '@nlpjs/lang-sv@4.26.1': - resolution: {integrity: sha512-2axkrYFC02tAlxCWeiEKISbe4dSteciP1CIggO/dZglnnLWgdF+g7kOeYMn7abCfFVSnh5vLqfDkrwnyIqt7Ag==, tarball: https://registry.npmjs.org/@nlpjs/lang-sv/-/lang-sv-4.26.1.tgz} - - '@nlpjs/lang-ta@4.26.1': - resolution: {integrity: sha512-keeh+croa1TAirV9Fd3OQMo5IkAlTGNWTNweHbi/htYMX0MKOPYxyqg+VH2bml+57VY2aUj/WYgV/p3ATx9EfQ==, tarball: https://registry.npmjs.org/@nlpjs/lang-ta/-/lang-ta-4.26.1.tgz} - - '@nlpjs/lang-th@4.26.1': - resolution: {integrity: sha512-2SWZhrln3rMw8/DsRc9yS5bi3qEdGfw2pq9Uejx/UYED5zvvL6kh9AiCJZT4k0wMBGEwWUV6HxJ0Pq/jOTHogg==, tarball: https://registry.npmjs.org/@nlpjs/lang-th/-/lang-th-4.26.1.tgz} - - '@nlpjs/lang-tl@4.26.1': - resolution: {integrity: sha512-AzmLtg28tm0VXCm0Q0EY3OtA3m4oYxaqh4VX6uhB4J+PoEsIkm0py12SJxMNIsh/r98pobCumH8KH9bvHQoCAg==, tarball: https://registry.npmjs.org/@nlpjs/lang-tl/-/lang-tl-4.26.1.tgz} - - '@nlpjs/lang-tr@4.26.1': - resolution: {integrity: sha512-p30uuXvE9pZeU/5XkrQfvxRgiAOBmP3EyBFGV/+P05PEogaqbsmmtVCgCnR63yeRvVnGbToPBPjRK3OO1y4AEQ==, tarball: https://registry.npmjs.org/@nlpjs/lang-tr/-/lang-tr-4.26.1.tgz} - - '@nlpjs/lang-uk@4.26.1': - resolution: {integrity: sha512-PVEvmlhvl6BL3e/Q4qjMPsnwON3cWEYvDh9dg+Si+sjD2Edu9tajolJKcQ6ZA4I8dXrld5xuXx+DEBH/uB4uWQ==, tarball: https://registry.npmjs.org/@nlpjs/lang-uk/-/lang-uk-4.26.1.tgz} - - '@nlpjs/lang-zh@4.26.1': - resolution: {integrity: sha512-kwqeqeEgMAMvucVX9HNE1p6s/2APP23ZsS8Um/lNvtswb4gL5jjYF9kyCvRfqlPBQSWWdRv7wwcnNXOvXYkxcQ==, tarball: https://registry.npmjs.org/@nlpjs/lang-zh/-/lang-zh-4.26.1.tgz} - - '@nlpjs/language-min@4.25.0': - resolution: {integrity: sha512-g8jtbDbqtRm+dlD/1Vnb4VWfKbKteApEGVTqIMxYkk6N/HMhvLZ5J2svrxzrB98a/HZ0fb//YBfFgymnz9Oukg==, tarball: https://registry.npmjs.org/@nlpjs/language-min/-/language-min-4.25.0.tgz} - - '@nlpjs/language@4.25.0': - resolution: {integrity: sha512-tUF6QENoUQ/E26RYc32IgsttStSF9cNO4ySN+BQECn8VpjukWdwbMw073MlOLXzjfeobxa+3hCVrmPPcW+V3UA==, tarball: https://registry.npmjs.org/@nlpjs/language/-/language-4.25.0.tgz} - - '@nlpjs/ner@4.27.0': - resolution: {integrity: sha512-ptwkxriJdmgHSH9TfP10JQ1jviaSl2SupSFGUvTuWkuJhobQd3hbnlSq40V6XYvJNmqh9M9zEab/AKeghxYOTA==, tarball: https://registry.npmjs.org/@nlpjs/ner/-/ner-4.27.0.tgz} - - '@nlpjs/neural@4.25.0': - resolution: {integrity: sha512-Oz20denGiBe0DlQsS7lN4TNrATN1nXlHKc/HB6jJPegjVmgJVCugDaHwIGoV7qOWyA6F2fRRwOgD+quNT2gVpg==, tarball: https://registry.npmjs.org/@nlpjs/neural/-/neural-4.25.0.tgz} - - '@nlpjs/nlg@4.26.1': - resolution: {integrity: sha512-PCJWiZ7464ChXXUGvjBZIFtoqkC24Oy6X63HgQrSv+63svz22Y5Cmu1MYLk77Nb+4keWv+hKhFJKDkvJoOpBVg==, tarball: https://registry.npmjs.org/@nlpjs/nlg/-/nlg-4.26.1.tgz} - - '@nlpjs/nlp@4.27.0': - resolution: {integrity: sha512-q6X7sY6TYVnQRZJKF/6mfLFlNA5oRYLhgQ5k3i1IBqH9lbWTAZJr31w/dCf97HXaYaj+vJp3h0ucfNumme9EIw==, tarball: https://registry.npmjs.org/@nlpjs/nlp/-/nlp-4.27.0.tgz} - - '@nlpjs/nlu@4.27.0': - resolution: {integrity: sha512-j4DUdoXS/y/Xag6ysYXx7Ve8NBmUVViUSCJhj3r49+zGyYtyVAHuVcqSej5q0tJjn0JSMT+6+ip8klON1q8ixw==, tarball: https://registry.npmjs.org/@nlpjs/nlu/-/nlu-4.27.0.tgz} - - '@nlpjs/request@4.25.0': - resolution: {integrity: sha512-MPVYWfFZY03WyFL7GWkUkv8tw968OXsdxFSJEvjXHzhiCe/vAlPCWbvoR+VnoQTgzLHxs/KIF6sIF2s9AzsLmQ==, tarball: https://registry.npmjs.org/@nlpjs/request/-/request-4.25.0.tgz} - - '@nlpjs/sentiment@4.26.1': - resolution: {integrity: sha512-U2WmcW3w6yDDO45+Y7v5e6DPQj8e0x+RUUePPyRu2uIZmUtIKG+qCPMWnNLMmYQZoSQEFxmMMlLcGDC7tN7o3w==, tarball: https://registry.npmjs.org/@nlpjs/sentiment/-/sentiment-4.26.1.tgz} - - '@nlpjs/similarity@4.26.1': - resolution: {integrity: sha512-QutSBFGo/huNuz60PgqCjub0oBd9S8MLrjme33U5GzxuSvToQzXtn9/ynIia8qDm009D09VXV+LPeNE4h7yuSg==, tarball: https://registry.npmjs.org/@nlpjs/similarity/-/similarity-4.26.1.tgz} - - '@nlpjs/slot@4.26.1': - resolution: {integrity: sha512-mK8EEy5O+mRGne822PIKMxHSFh8j+iC7hGJ6T31XdFsNhFEYXLI/0dmeBstZgTSKBTe27HNFgCCwuGb77u0o9w==, tarball: https://registry.npmjs.org/@nlpjs/slot/-/slot-4.26.1.tgz} - - '@nlpjs/xtables@4.25.0': - resolution: {integrity: sha512-+baCtMZIp+aDqODLQs8Wyyke5qUqQkL8AGWsZzwYuJV8S7xdW2+XklRnHnkFc3p3foC248TkzG5L8j9r6INOtg==, tarball: https://registry.npmjs.org/@nlpjs/xtables/-/xtables-4.25.0.tgz} - '@noble/hashes@1.8.0': resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==, tarball: https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz} engines: {node: ^14.21.3 || >=16} @@ -1333,10 +1118,6 @@ packages: resolution: {integrity: sha512-ID7fosbc50TbT0MK0EG12O+gAP3W3Aa/Pz4DaTtQtEvlc9Odaqi0de+xuZ7Li2GtK4HzEX7IuRWS/JmZLksR3Q==, tarball: https://registry.npmjs.org/@teppeis/multimaps/-/multimaps-3.0.0.tgz} engines: {node: '>=14'} - '@tootallnate/once@2.0.1': - resolution: {integrity: sha512-HqmEUIGRJ5fSXchkVgR5F7qn48bDBzv0kWj/Kfu5e6uci4UlEeng4331LnBkWffb++Ei3FOVLxo8JJWMFBDMeQ==, tarball: https://registry.npmjs.org/@tootallnate/once/-/once-2.0.1.tgz} - engines: {node: '>= 10'} - '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==, tarball: https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz} @@ -1506,10 +1287,6 @@ packages: engines: {node: '>=0.4.0'} hasBin: true - adler-32@1.3.1: - resolution: {integrity: sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==, tarball: https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz} - engines: {node: '>=0.8'} - agent-base@6.0.2: resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==, tarball: https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz} engines: {node: '>= 6.0.0'} @@ -1613,9 +1390,6 @@ packages: resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==, tarball: https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz} engines: {node: '>=8'} - async@2.6.4: - resolution: {integrity: sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==, tarball: https://registry.npmjs.org/async/-/async-2.6.4.tgz} - async@3.2.6: resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==, tarball: https://registry.npmjs.org/async/-/async-3.2.6.tgz} @@ -1657,9 +1431,6 @@ packages: bcrypt-pbkdf@1.0.2: resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==, tarball: https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz} - bignumber.js@7.2.1: - resolution: {integrity: sha512-S4XzBk5sMB+Rcb/LNcpzXr57VRTxgAvaAEDAl1AwRx27j00hT84O6OkteE7u8UB3NuaaygCRrEpqox4uDOrbdQ==, tarball: https://registry.npmjs.org/bignumber.js/-/bignumber.js-7.2.1.tgz} - binary-extensions@2.3.0: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==, tarball: https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz} engines: {node: '>=8'} @@ -1748,10 +1519,6 @@ packages: caseless@0.12.0: resolution: {integrity: sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==, tarball: https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz} - cfb@1.2.2: - resolution: {integrity: sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==, tarball: https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz} - engines: {node: '>=0.8'} - chai@5.3.3: resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==, tarball: https://registry.npmjs.org/chai/-/chai-5.3.3.tgz} engines: {node: '>=18'} @@ -1822,10 +1589,6 @@ packages: resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==, tarball: https://registry.npmjs.org/clone/-/clone-1.0.4.tgz} engines: {node: '>=0.8'} - codepage@1.15.0: - resolution: {integrity: sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==, tarball: https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz} - engines: {node: '>=0.8'} - color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==, tarball: https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz} engines: {node: '>=7.0.0'} @@ -1940,11 +1703,6 @@ packages: typescript: optional: true - crc-32@1.2.2: - resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==, tarball: https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz} - engines: {node: '>=0.8'} - hasBin: true - cross-env@10.1.0: resolution: {integrity: sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==, tarball: https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz} engines: {node: '>=20'} @@ -2132,9 +1890,6 @@ packages: resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==, tarball: https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz} engines: {node: '>=12'} - doublearray@0.0.2: - resolution: {integrity: sha512-aw55FtZzT6AmiamEj2kvmR6BuFqvYgKZUkfQ7teqVRNqD5UE0rw8IeW/3gieHNKQ5sPuDKlljWEn4bzv5+1bHw==, tarball: https://registry.npmjs.org/doublearray/-/doublearray-0.0.2.tgz} - dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==, tarball: https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz} engines: {node: '>= 0.4'} @@ -2401,10 +2156,6 @@ packages: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==, tarball: https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz} engines: {node: '>= 0.6'} - frac@1.1.2: - resolution: {integrity: sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==, tarball: https://registry.npmjs.org/frac/-/frac-1.1.2.tgz} - engines: {node: '>=0.8'} - fresh@0.5.2: resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==, tarball: https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz} engines: {node: '>= 0.6'} @@ -2510,9 +2261,6 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==, tarball: https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz} - grapheme-splitter@1.0.4: - resolution: {integrity: sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==, tarball: https://registry.npmjs.org/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz} - has-ansi@4.0.1: resolution: {integrity: sha512-Qr4RtTm30xvEdqUXbSBVWDu+PrTokJOwe/FU+VdfJPk+MXAPoeOzKpRyrDTnZIJwAkQ4oBLTU53nu0HrkF/Z2A==, tarball: https://registry.npmjs.org/has-ansi/-/has-ansi-4.0.1.tgz} engines: {node: '>=8'} @@ -2558,10 +2306,6 @@ packages: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==, tarball: https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz} engines: {node: '>= 0.8'} - http-proxy-agent@5.0.0: - resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==, tarball: https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz} - engines: {node: '>= 6'} - http-signature@1.4.0: resolution: {integrity: sha512-G5akfn7eKbpDN+8nPS/cb57YeA1jLTVxjpCj7tmm3QKPdyDy7T+qSC40e9ptydSWvkwjSXw1VbkpyEm39ukeAg==, tarball: https://registry.npmjs.org/http-signature/-/http-signature-1.4.0.tgz} engines: {node: '>=0.10'} @@ -2814,9 +2558,6 @@ packages: knuth-shuffle-seeded@1.0.6: resolution: {integrity: sha512-9pFH0SplrfyKyojCLxZfMcvkhf5hH0d+UwR9nTVJ/DDQJGuzcXjTwB7TP7sDfehSudlGGaOLblmEWqv04ERVWg==, tarball: https://registry.npmjs.org/knuth-shuffle-seeded/-/knuth-shuffle-seeded-1.0.6.tgz} - kuromoji@0.1.2: - resolution: {integrity: sha512-V0dUf+C2LpcPEXhoHLMAop/bOht16Dyr+mDiIE39yX3vqau7p80De/koFqpiTcL1zzdZlc3xuHZ8u5gjYRfFaQ==, tarball: https://registry.npmjs.org/kuromoji/-/kuromoji-0.1.2.tgz} - lazy-ass@1.6.0: resolution: {integrity: sha512-cc8oEVoctTvsFZ/Oje/kGnHbpWHYBe8IAJe4C0QNc3t8uM/0Y8+erSz/7Y1ALuXTEZTMvxXwO6YbX1ey3ujiZw==, tarball: https://registry.npmjs.org/lazy-ass/-/lazy-ass-1.6.0.tgz} engines: {node: '> 0.8'} @@ -3081,9 +2822,6 @@ packages: node-html-parser@5.3.3: resolution: {integrity: sha512-ncg1033CaX9UexbyA7e1N0aAoAYRDiV8jkTvzEnfd1GDvzFdrsXLzR4p4ik8mwLgnaKP/jyUFWDy9q3jvRT2Jw==, tarball: https://registry.npmjs.org/node-html-parser/-/node-html-parser-5.3.3.tgz} - node-nlp@4.27.0: - resolution: {integrity: sha512-LnkhOUPXX0CMFbSzJ1gHI+7Yb3ULLip5gRsqedXb6pryjcRCbNzPgHXcH/6G9B1vSbDfO+y3X2B4QZpfP12OyQ==, tarball: https://registry.npmjs.org/node-nlp/-/node-nlp-4.27.0.tgz} - node-releases@2.0.53: resolution: {integrity: sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==, tarball: https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz} engines: {node: '>=18'} @@ -3633,10 +3371,6 @@ packages: split@1.0.1: resolution: {integrity: sha512-mTyOoPbrivtXnwnIxZRFYRrPNtEFKlpB2fvjSnCQUiAA6qAZzqwna5envK4uk6OIeP17CsdF3rSBGYVBsU0Tkg==, tarball: https://registry.npmjs.org/split/-/split-1.0.1.tgz} - ssf@0.11.2: - resolution: {integrity: sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==, tarball: https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz} - engines: {node: '>=0.8'} - sshpk@1.18.0: resolution: {integrity: sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==, tarball: https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz} engines: {node: '>=0.10.0'} @@ -3977,14 +3711,6 @@ packages: wide-align@1.1.5: resolution: {integrity: sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==, tarball: https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz} - wmf@1.0.2: - resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==, tarball: https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz} - engines: {node: '>=0.8'} - - word@0.3.0: - resolution: {integrity: sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==, tarball: https://registry.npmjs.org/word/-/word-0.3.0.tgz} - engines: {node: '>=0.8'} - workerpool@6.5.1: resolution: {integrity: sha512-Fs4dNYcsdpYSAfVxhnl1L5zTksjvOJxtC5hzMNl+1t9B8hTJTdKDyZ5ju7ztgPy+ft9tBFXoOlDNiOT9WUXZlA==, tarball: https://registry.npmjs.org/workerpool/-/workerpool-6.5.1.tgz} @@ -4006,11 +3732,6 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==, tarball: https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz} - xlsx@0.18.5: - resolution: {integrity: sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==, tarball: https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz} - engines: {node: '>=0.8'} - hasBin: true - xmlbuilder@15.1.1: resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==, tarball: https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz} engines: {node: '>=8.0'} @@ -4064,9 +3785,6 @@ packages: yup@1.6.1: resolution: {integrity: sha512-JED8pB50qbA4FOkDol0bYF/p60qSEDQqBD0/qeIrUCG1KbPBIQ776fCUNb9ldbPcSTxA69g/47XTo4TqWiuXOA==, tarball: https://registry.npmjs.org/yup/-/yup-1.6.1.tgz} - zlibjs@0.3.1: - resolution: {integrity: sha512-+J9RrgTKOmlxFSDHo0pI1xM6BLVUv+o0ZT9ANtCxGkjIVCCUdx9alUF8Gm+dGLKbkkkidWIHFDZHDMpfITt4+w==, tarball: https://registry.npmjs.org/zlibjs/-/zlibjs-0.3.1.tgz} - zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==, tarball: https://registry.npmjs.org/zod/-/zod-3.25.76.tgz} @@ -4675,344 +4393,9 @@ snapshots: - encoding - supports-color - '@microsoft/recognizers-text-choice@1.3.1': - dependencies: - '@microsoft/recognizers-text': 1.3.1 - grapheme-splitter: 1.0.4 - - '@microsoft/recognizers-text-data-types-timex-expression@1.3.1': {} - - '@microsoft/recognizers-text-date-time@1.3.2': - dependencies: - '@microsoft/recognizers-text': 1.3.1 - '@microsoft/recognizers-text-number': 1.3.1 - '@microsoft/recognizers-text-number-with-unit': 1.3.1 - lodash: 4.18.1 - - '@microsoft/recognizers-text-number-with-unit@1.3.1': - dependencies: - '@microsoft/recognizers-text': 1.3.1 - '@microsoft/recognizers-text-number': 1.3.1 - lodash: 4.18.1 - - '@microsoft/recognizers-text-number@1.3.1': - dependencies: - '@microsoft/recognizers-text': 1.3.1 - bignumber.js: 7.2.1 - lodash: 4.18.1 - - '@microsoft/recognizers-text-sequence@1.3.1': - dependencies: - '@microsoft/recognizers-text': 1.3.1 - grapheme-splitter: 1.0.4 - - '@microsoft/recognizers-text-suite@1.3.0': - dependencies: - '@microsoft/recognizers-text': 1.3.1 - '@microsoft/recognizers-text-choice': 1.3.1 - '@microsoft/recognizers-text-data-types-timex-expression': 1.3.1 - '@microsoft/recognizers-text-date-time': 1.3.2 - '@microsoft/recognizers-text-number': 1.3.1 - '@microsoft/recognizers-text-number-with-unit': 1.3.1 - '@microsoft/recognizers-text-sequence': 1.3.1 - - '@microsoft/recognizers-text@1.3.1': {} - '@napi-rs/lzma-linux-x64-gnu@1.5.1': optional: true - '@nlpjs/builtin-duckling@4.26.1': - dependencies: - '@nlpjs/core': 4.26.1 - - '@nlpjs/builtin-microsoft@4.26.1': - dependencies: - '@microsoft/recognizers-text-suite': 1.3.0 - '@nlpjs/core': 4.26.1 - - '@nlpjs/core-loader@4.26.1': - dependencies: - '@nlpjs/core': 4.26.1 - '@nlpjs/request': 4.25.0 - transitivePeerDependencies: - - supports-color - - '@nlpjs/core@4.26.1': {} - - '@nlpjs/emoji@4.26.1': - dependencies: - '@nlpjs/core': 4.26.1 - - '@nlpjs/evaluator@4.26.1': - dependencies: - escodegen: 2.1.0 - esprima: 4.0.1 - - '@nlpjs/lang-all@4.26.1': - dependencies: - '@nlpjs/core': 4.26.1 - '@nlpjs/lang-ar': 4.26.1 - '@nlpjs/lang-bn': 4.26.1 - '@nlpjs/lang-ca': 4.26.1 - '@nlpjs/lang-cs': 4.26.1 - '@nlpjs/lang-da': 4.26.1 - '@nlpjs/lang-de': 4.26.1 - '@nlpjs/lang-el': 4.26.1 - '@nlpjs/lang-en': 4.26.1 - '@nlpjs/lang-es': 4.26.1 - '@nlpjs/lang-eu': 4.26.1 - '@nlpjs/lang-fa': 4.26.1 - '@nlpjs/lang-fi': 4.26.1 - '@nlpjs/lang-fr': 4.26.1 - '@nlpjs/lang-ga': 4.26.1 - '@nlpjs/lang-gl': 4.26.1 - '@nlpjs/lang-hi': 4.26.1 - '@nlpjs/lang-hu': 4.26.1 - '@nlpjs/lang-hy': 4.26.1 - '@nlpjs/lang-id': 4.26.1 - '@nlpjs/lang-it': 4.26.1 - '@nlpjs/lang-ja': 4.26.1 - '@nlpjs/lang-ko': 4.26.1 - '@nlpjs/lang-lt': 4.26.1 - '@nlpjs/lang-ms': 4.26.1 - '@nlpjs/lang-ne': 4.26.1 - '@nlpjs/lang-nl': 4.26.1 - '@nlpjs/lang-no': 4.26.1 - '@nlpjs/lang-pl': 4.26.1 - '@nlpjs/lang-pt': 4.26.1 - '@nlpjs/lang-ro': 4.26.1 - '@nlpjs/lang-ru': 4.26.1 - '@nlpjs/lang-sl': 4.26.1 - '@nlpjs/lang-sr': 4.26.1 - '@nlpjs/lang-sv': 4.26.1 - '@nlpjs/lang-ta': 4.26.1 - '@nlpjs/lang-th': 4.26.1 - '@nlpjs/lang-tl': 4.26.1 - '@nlpjs/lang-tr': 4.26.1 - '@nlpjs/lang-uk': 4.26.1 - '@nlpjs/lang-zh': 4.26.1 - '@nlpjs/language': 4.25.0 - - '@nlpjs/lang-ar@4.26.1': - dependencies: - '@nlpjs/core': 4.26.1 - - '@nlpjs/lang-bn@4.26.1': - dependencies: - '@nlpjs/core': 4.26.1 - - '@nlpjs/lang-ca@4.26.1': - dependencies: - '@nlpjs/core': 4.26.1 - - '@nlpjs/lang-cs@4.26.1': - dependencies: - '@nlpjs/core': 4.26.1 - - '@nlpjs/lang-da@4.26.1': - dependencies: - '@nlpjs/core': 4.26.1 - - '@nlpjs/lang-de@4.26.1': - dependencies: - '@nlpjs/core': 4.26.1 - - '@nlpjs/lang-el@4.26.1': - dependencies: - '@nlpjs/core': 4.26.1 - - '@nlpjs/lang-en-min@4.26.1': - dependencies: - '@nlpjs/core': 4.26.1 - - '@nlpjs/lang-en@4.26.1': - dependencies: - '@nlpjs/core': 4.26.1 - '@nlpjs/lang-en-min': 4.26.1 - - '@nlpjs/lang-es@4.26.1': - dependencies: - '@nlpjs/core': 4.26.1 - - '@nlpjs/lang-eu@4.26.1': - dependencies: - '@nlpjs/core': 4.26.1 - - '@nlpjs/lang-fa@4.26.1': - dependencies: - '@nlpjs/core': 4.26.1 - - '@nlpjs/lang-fi@4.26.1': - dependencies: - '@nlpjs/core': 4.26.1 - - '@nlpjs/lang-fr@4.26.1': - dependencies: - '@nlpjs/core': 4.26.1 - - '@nlpjs/lang-ga@4.26.1': - dependencies: - '@nlpjs/core': 4.26.1 - - '@nlpjs/lang-gl@4.26.1': - dependencies: - '@nlpjs/core': 4.26.1 - - '@nlpjs/lang-hi@4.26.1': - dependencies: - '@nlpjs/core': 4.26.1 - - '@nlpjs/lang-hu@4.26.1': - dependencies: - '@nlpjs/core': 4.26.1 - - '@nlpjs/lang-hy@4.26.1': - dependencies: - '@nlpjs/core': 4.26.1 - - '@nlpjs/lang-id@4.26.1': - dependencies: - '@nlpjs/core': 4.26.1 - - '@nlpjs/lang-it@4.26.1': - dependencies: - '@nlpjs/core': 4.26.1 - - '@nlpjs/lang-ja@4.26.1': - dependencies: - '@nlpjs/core': 4.26.1 - kuromoji: 0.1.2 - - '@nlpjs/lang-ko@4.26.1': - dependencies: - '@nlpjs/core': 4.26.1 - - '@nlpjs/lang-lt@4.26.1': - dependencies: - '@nlpjs/core': 4.26.1 - - '@nlpjs/lang-ms@4.26.1': - dependencies: - '@nlpjs/core': 4.26.1 - '@nlpjs/lang-id': 4.26.1 - - '@nlpjs/lang-ne@4.26.1': - dependencies: - '@nlpjs/core': 4.26.1 - - '@nlpjs/lang-nl@4.26.1': - dependencies: - '@nlpjs/core': 4.26.1 - - '@nlpjs/lang-no@4.26.1': - dependencies: - '@nlpjs/core': 4.26.1 - - '@nlpjs/lang-pl@4.26.1': - dependencies: - '@nlpjs/core': 4.26.1 - - '@nlpjs/lang-pt@4.26.1': - dependencies: - '@nlpjs/core': 4.26.1 - - '@nlpjs/lang-ro@4.26.1': - dependencies: - '@nlpjs/core': 4.26.1 - - '@nlpjs/lang-ru@4.26.1': - dependencies: - '@nlpjs/core': 4.26.1 - - '@nlpjs/lang-sl@4.26.1': - dependencies: - '@nlpjs/core': 4.26.1 - - '@nlpjs/lang-sr@4.26.1': - dependencies: - '@nlpjs/core': 4.26.1 - - '@nlpjs/lang-sv@4.26.1': - dependencies: - '@nlpjs/core': 4.26.1 - - '@nlpjs/lang-ta@4.26.1': - dependencies: - '@nlpjs/core': 4.26.1 - - '@nlpjs/lang-th@4.26.1': - dependencies: - '@nlpjs/core': 4.26.1 - - '@nlpjs/lang-tl@4.26.1': - dependencies: - '@nlpjs/core': 4.26.1 - - '@nlpjs/lang-tr@4.26.1': - dependencies: - '@nlpjs/core': 4.26.1 - - '@nlpjs/lang-uk@4.26.1': - dependencies: - '@nlpjs/core': 4.26.1 - - '@nlpjs/lang-zh@4.26.1': - dependencies: - '@nlpjs/core': 4.26.1 - - '@nlpjs/language-min@4.25.0': {} - - '@nlpjs/language@4.25.0': {} - - '@nlpjs/ner@4.27.0': - dependencies: - '@nlpjs/core': 4.26.1 - '@nlpjs/language-min': 4.25.0 - '@nlpjs/similarity': 4.26.1 - - '@nlpjs/neural@4.25.0': {} - - '@nlpjs/nlg@4.26.1': - dependencies: - '@nlpjs/core': 4.26.1 - - '@nlpjs/nlp@4.27.0': - dependencies: - '@nlpjs/core': 4.26.1 - '@nlpjs/ner': 4.27.0 - '@nlpjs/nlg': 4.26.1 - '@nlpjs/nlu': 4.27.0 - '@nlpjs/sentiment': 4.26.1 - '@nlpjs/slot': 4.26.1 - - '@nlpjs/nlu@4.27.0': - dependencies: - '@nlpjs/core': 4.26.1 - '@nlpjs/language-min': 4.25.0 - '@nlpjs/neural': 4.25.0 - '@nlpjs/similarity': 4.26.1 - - '@nlpjs/request@4.25.0': - dependencies: - http-proxy-agent: 5.0.0 - https-proxy-agent: 5.0.1 - transitivePeerDependencies: - - supports-color - - '@nlpjs/sentiment@4.26.1': - dependencies: - '@nlpjs/core': 4.26.1 - '@nlpjs/language-min': 4.25.0 - '@nlpjs/neural': 4.25.0 - - '@nlpjs/similarity@4.26.1': {} - - '@nlpjs/slot@4.26.1': {} - - '@nlpjs/xtables@4.25.0': - dependencies: - xlsx: 0.18.5 - '@noble/hashes@1.8.0': {} '@nodelib/fs.scandir@2.1.5': @@ -5199,8 +4582,6 @@ snapshots: '@teppeis/multimaps@3.0.0': {} - '@tootallnate/once@2.0.1': {} - '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.29.8 @@ -5423,8 +4804,6 @@ snapshots: acorn@8.18.0: {} - adler-32@1.3.1: {} - agent-base@6.0.2: dependencies: debug: 4.4.3(supports-color@8.1.1) @@ -5514,10 +4893,6 @@ snapshots: astral-regex@2.0.0: {} - async@2.6.4: - dependencies: - lodash: 4.18.1 - async@3.2.6: {} asynckit@0.4.0: {} @@ -5554,8 +4929,6 @@ snapshots: dependencies: tweetnacl: 0.14.5 - bignumber.js@7.2.1: {} - binary-extensions@2.3.0: {} blob-util@2.0.2: {} @@ -5654,11 +5027,6 @@ snapshots: caseless@0.12.0: {} - cfb@1.2.2: - dependencies: - adler-32: 1.3.1 - crc-32: 1.2.2 - chai@5.3.3: dependencies: assertion-error: 2.0.1 @@ -5738,8 +5106,6 @@ snapshots: clone@1.0.4: optional: true - codepage@1.15.0: {} - color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -5821,8 +5187,6 @@ snapshots: optionalDependencies: typescript: 5.9.3 - crc-32@1.2.2: {} - cross-env@10.1.0: dependencies: '@epic-web/invariant': 1.0.0 @@ -6067,8 +5431,6 @@ snapshots: dotenv@16.6.1: {} - doublearray@0.0.2: {} - dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -6461,8 +5823,6 @@ snapshots: forwarded@0.2.0: {} - frac@1.1.2: {} - fresh@0.5.2: {} from@0.1.7: {} @@ -6584,8 +5944,6 @@ snapshots: graceful-fs@4.2.11: {} - grapheme-splitter@1.0.4: {} - has-ansi@4.0.1: dependencies: ansi-regex: 4.1.1 @@ -6626,14 +5984,6 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 - http-proxy-agent@5.0.0: - dependencies: - '@tootallnate/once': 2.0.1 - agent-base: 6.0.2 - debug: 4.4.3(supports-color@8.1.1) - transitivePeerDependencies: - - supports-color - http-signature@1.4.0: dependencies: assert-plus: 1.0.0 @@ -6876,12 +6226,6 @@ snapshots: dependencies: seed-random: 2.2.0 - kuromoji@0.1.2: - dependencies: - async: 2.6.4 - doublearray: 0.0.2 - zlibjs: 0.3.1 - lazy-ass@1.6.0: {} lazy-ass@2.0.3: {} @@ -7133,26 +6477,6 @@ snapshots: css-select: 4.3.0 he: 1.2.0 - node-nlp@4.27.0: - dependencies: - '@nlpjs/builtin-duckling': 4.26.1 - '@nlpjs/builtin-microsoft': 4.26.1 - '@nlpjs/core-loader': 4.26.1 - '@nlpjs/emoji': 4.26.1 - '@nlpjs/evaluator': 4.26.1 - '@nlpjs/lang-all': 4.26.1 - '@nlpjs/language': 4.25.0 - '@nlpjs/neural': 4.25.0 - '@nlpjs/nlg': 4.26.1 - '@nlpjs/nlp': 4.27.0 - '@nlpjs/nlu': 4.27.0 - '@nlpjs/request': 4.25.0 - '@nlpjs/sentiment': 4.26.1 - '@nlpjs/similarity': 4.26.1 - '@nlpjs/xtables': 4.25.0 - transitivePeerDependencies: - - supports-color - node-releases@2.0.53: {} node-source-walk@7.0.2: @@ -7751,10 +7075,6 @@ snapshots: dependencies: through: 2.3.8 - ssf@0.11.2: - dependencies: - frac: 1.1.2 - sshpk@1.18.0: dependencies: asn1: 0.2.6 @@ -8079,10 +7399,6 @@ snapshots: dependencies: string-width: 4.2.3 - wmf@1.0.2: {} - - word@0.3.0: {} - workerpool@6.5.1: {} workerpool@9.3.4: {} @@ -8107,16 +7423,6 @@ snapshots: wrappy@1.0.2: {} - xlsx@0.18.5: - dependencies: - adler-32: 1.3.1 - cfb: 1.2.2 - codepage: 1.15.0 - crc-32: 1.2.2 - ssf: 0.11.2 - wmf: 1.0.2 - word: 0.3.0 - xmlbuilder@15.1.1: {} y18n@5.0.8: {} @@ -8174,6 +7480,4 @@ snapshots: toposort: 2.0.2 type-fest: 2.19.0 - zlibjs@0.3.1: {} - zod@3.25.76: {} diff --git a/services/tech-step-intent-service/.env.example b/services/tech-step-intent-service/.env.example new file mode 100644 index 0000000..71df986 --- /dev/null +++ b/services/tech-step-intent-service/.env.example @@ -0,0 +1,11 @@ +# Secret partagé attendu sur le header `X-Intent-Service-Secret` de chaque +# requête (sauf `GET /health`) — doit matcher `INTENT_SERVICE_SECRET` côté +# apps/api/.env (voir apps/api/src/config/env.ts). Requis, pas de valeur par +# défaut : `Settings` (intent_service/config.py) refuse de démarrer sans. +INTENT_SERVICE_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars + +# Optionnel — niveau du logging JSON structuré (intent_service/logging_config.py). +# INFO par défaut : chaque appel /v1/process et /v1/train journalise son +# input (locale/texte, entrées d'entraînement) et son output (entités, +# intent, score) à ce niveau. +# LOG_LEVEL=INFO diff --git a/services/tech-step-intent-service/.gitignore b/services/tech-step-intent-service/.gitignore new file mode 100644 index 0000000..99b40f3 --- /dev/null +++ b/services/tech-step-intent-service/.gitignore @@ -0,0 +1,5 @@ +.venv/ +__pycache__/ +*.pyc +.pytest_cache/ +.env diff --git a/services/tech-step-intent-service/Dockerfile b/services/tech-step-intent-service/Dockerfile new file mode 100644 index 0000000..068c419 --- /dev/null +++ b/services/tech-step-intent-service/Dockerfile @@ -0,0 +1,34 @@ +# Standalone image for services/tech-step-intent-service — hors du build +# apps/api (voir services/tech-step-llm-worker/Dockerfile pour le précédent +# direct : un service Python/spaCy n'a rien à faire dans l'image Node de +# l'API, et inversement). Rien n'est persisté sur disque (pas de VOLUME, +# contrairement au worker LLM) : tout l'état (textcat/matcher entraînés) +# vit en mémoire, reconstruit à chaque `/v1/train` depuis un corpus que ce +# service ne possède pas lui-même (voir intent_service/README.md). +FROM python:3.12-slim AS base +RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && rm -rf /var/lib/apt/lists/* +COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv +WORKDIR /service + +FROM base AS build +# `uv.lock` est commité pour ce service (même rigueur que +# `pnpm-lock.yaml`/`--frozen-lockfile` pour apps/api et +# services/tech-step-llm-worker) — `--frozen` échoue bruyamment si +# `pyproject.toml` a dérivé du lock plutôt que de re-résoudre en silence. +# `--no-install-project` sépare l'installation des dépendances (dont les +# wheels de modèles spaCy, pinnés par URL dans pyproject.toml) de la copie +# du code applicatif, pour que le cache de layer Docker survive à un +# changement dans intent_service/ sans retélécharger ~80 Mo de modèles. +# Chemins préfixés par `services/tech-step-intent-service/` : le contexte +# de build est la racine du repo (`docker-compose.yml`'s `build.context: .`), +# même convention que `services/tech-step-llm-worker/Dockerfile`. +COPY services/tech-step-intent-service/pyproject.toml services/tech-step-intent-service/uv.lock ./ +RUN uv sync --frozen --no-install-project --no-dev +COPY services/tech-step-intent-service/intent_service ./intent_service +RUN uv sync --frozen --no-dev + +FROM base AS runtime +ENV PYTHONUNBUFFERED=1 +COPY --from=build /service /service +EXPOSE 8000 +CMD ["uv", "run", "uvicorn", "intent_service.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/services/tech-step-intent-service/README.md b/services/tech-step-intent-service/README.md new file mode 100644 index 0000000..25695f2 --- /dev/null +++ b/services/tech-step-intent-service/README.md @@ -0,0 +1,172 @@ +# tech-step-intent-service + +Microservice de détection d'intention (technique de cuisine) — remplace le +pipeline `node-nlp` qui vivait dans `apps/api` +(`TechStepClassifierService`, `apps/api/src/lib/recipe-matching/tech-step-matcher.ts`) : + +1. **NER par phrases** (`spacy.matcher.PhraseMatcher`) — trouve les mentions + candidates d'une technique dans un texte, à partir des `synonyms` de + chaque technique. +2. **Classification d'intention** (`textcat` spaCy, bag-of-words) — verdict + de la technique qu'une clause de texte *signifie*, entraîné sur les + `utterances` de chaque technique (y compris des paraphrases n'utilisant + jamais le mot-clé lui-même). + +Basé sur **spaCy** (`fr_core_news_md`/`en_core_web_md`) plutôt que node-nlp — +écosystème NLP plus robuste/maintenu, avec l'ambition à terme (hors scope de +ce service en l'état) de pouvoir aussi absorber ce que fait aujourd'hui +`services/tech-step-llm-worker` une fois ce pipeline assez riche pour s'en +passer (les modèles `md`, avec vecteurs de mots, sont conservés dans ce but, +même si rien ici ne s'en sert encore). + +## Ce service est entièrement autonome + +Contrairement à sa toute première version, **ce service possède désormais +son propre corpus** — `intent_service/training_data.py` +(`TECH_STEP_TRAINING_DATA`), revu par PR comme le reste du code. Il +s'entraîne lui-même une seule fois, à son propre démarrage +(`PipelineRegistry.initialize()`, appelé par `main.py`'s `lifespan`), et ne +persiste jamais rien sur disque — un redémarrage du process réentraîne +toujours from scratch depuis ce fichier. `apps/api` ne connaît plus aucune +technique ni aucun synonyme : il n'appelle plus que `POST /v1/process` (plus +de `POST /v1/train`, supprimé). + +Workflow mainteneur pour changer le corpus : + +1. Éditer `intent_service/training_data.py` à la main (informé par le + rapport de `apps/api/src/scripts/list-pending-training-suggestions.ts`). +2. **Redémarrer ce service** (`docker compose restart tech-step-intent-service`, + ou simplement redéployer) — le nouveau corpus n'a d'effet qu'une fois + réentraîné au démarrage, contrairement à l'ancienne version qui pouvait + être réentraînée à chaud via `POST /v1/train`. +3. Depuis `apps/api`, lancer `pnpm --filter api exec tsx + src/scripts/retrain-tech-steps.ts` — vérifie le F1 contre + `TECH_STEP_EVAL_DATASET` avant de backfiller les recettes existantes. + +## Pourquoi ce service vit hors du workspace pnpm + +Même raisonnement que `services/tech-step-llm-worker` : un service Python +n'a rien à faire dans `pnpm-workspace.yaml` (qui ne couvre que +`apps/*`/`packages/*`), et ses dépendances (spaCy, ses modèles) ne doivent +jamais se retrouver dans l'image `apps/api`. **Aucun accès direct à +Postgres** non plus — la résolution `TechStep.key -> id` reste entièrement +côté `apps/api` (`TechStepClassifierService`), ce service ne manipule que +des `uid` (chaînes opaques) tout du long. + +## Contrat HTTP + +Voir `intent_service/schemas.py` pour le détail exact. En résumé : + +- `GET /health` — sans authentification, `200` une fois ce service + entièrement prêt : modèles spaCy de base chargés **et** les deux locales + entraînées (pas de lazy-load, voir `intent_service/main.py`) — voir + "Temps de démarrage" plus bas pour ce que ça implique en pratique. +- `POST /v1/process` — `{ locale, text }` → `{ entities: [{ uid, start, end }], intent, score }`. + +`/v1/process` exige le header `X-Intent-Service-Secret` (voir +`intent_service/security.py`), qui doit matcher `INTENT_SERVICE_SECRET` +côté `apps/api`. + +## Temps de démarrage + +**Ce service met plusieurs minutes à devenir `healthy`** — contrairement à +node-nlp (entraînement quasi instantané), entraîner le `textcat` sur le +corpus réel (~74 techniques, chaque technique entraînée sur ses `synonyms` +en plus de ses `utterances` — voir `locale_pipeline.py`) prend de l'ordre +de 335 secondes par locale (mesuré localement, sans GPU), donc environ 670 +secondes (~11 minutes) pour `fr`+`en` combinés à chaque démarrage du +process. `docker-compose.yml` et +`.github/workflows/ci.yml` ont un `start_period`/timeout d'attente +généreux pour ça — voir leurs propres commentaires. C'est un compromis +assumé, pas un défaut de configuration à corriger : moins d'itérations +entraîne plus vite mais laisse des verdicts corrects sous +`CONFIDENCE_THRESHOLD` (voir le commentaire de cette constante, +`apps/api/src/lib/recipe-matching/tech-step-matcher.ts`, et celui de +`_TRAINING_ITERATIONS`/`_TRAINING_BATCH_SIZE` dans `locale_pipeline.py` +pour le détail du compromis). + +## Logs + +`intent_service/logging_config.py` branche un format JSON structuré (une +ligne par évènement — `timestamp`/`level`/`message` + champs métier fusionnés +— même convention que `LoggerService` côté `apps/api`) sur toute la +journalisation de ce service, niveau `LOG_LEVEL` (`INFO` par défaut, voir +`.env.example`). `routes/process.py` journalise chaque appel avec son input +et son output complets, `pipeline_registry.py` journalise le déroulement de +l'entraînement au démarrage : + +```json +{"timestamp": "...", "level": "info", "message": "tech-step NLP process", "locale": "fr", "text": "faire fondre le beurre", "entities": [{"uid": "melt", "start": 6, "end": 13}], "intent": "melt", "score": 0.93} +``` + +Le chatter interne de spaCy (`"spacy"` logger — chargement de vocabulaire, +etc.) est explicitement mis à `WARNING` pour ne pas noyer ces lignes. + +## Setup + +Ce service utilise [`uv`](https://docs.astral.sh/uv/) pour ses dépendances +(`uv.lock` committé, `uv sync --frozen` partout — Dockerfile, CI, dev). + +```bash +cd services/tech-step-intent-service +uv sync +cp .env.example .env +# édite .env : génère un INTENT_SERVICE_SECRET, identique à celui d'apps/api +uv run uvicorn intent_service.main:app --reload --port 8000 +``` + +`apps/api` (natif, `pnpm dev:api`, ou sa suite Mocha) doit pointer +`INTENT_SERVICE_BASE_URL=http://localhost:8000` et le même +`INTENT_SERVICE_SECRET` (voir `apps/api/.env.example`). + +## Running via Docker Compose + +`docker-compose.yml` (racine) définit un service `tech-step-intent-service` +aux côtés de `postgres`/`app`/`tech-step-llm-worker` — **pas optionnel**, +contrairement au worker LLM : sans lui, `apps/api` ne peut plus détecter +aucune technique de cuisine. `app` attend qu'il soit `healthy` +(`depends_on: condition: service_healthy`) avant de démarrer — voir "Temps +de démarrage" ci-dessus pour combien de temps ça prend en pratique. + +## Testing + +```bash +uv run pytest +``` + +`tests/test_locale_pipeline_entities.py` rejoue les cas d'offsets caractère +exacts et d'insensibilité accents/casse de +`apps/api/test/recipe-matching/tech-step-matcher.test.ts` — le point de +fidélité le plus critique de ce service (voir le plan de migration). +`tests/conftest.py`'s fixture `client` (scope "session") ne s'entraîne +qu'une seule fois pour toute la suite — c'est *le vrai corpus complet*, +pas un jeu jouet, donc la première utilisation de cette fixture prend le +même temps qu'un vrai démarrage (voir "Temps de démarrage" ci-dessus). + +Aucun test ici ne dépend d'une vraie base Postgres ni d'`apps/api` en +service — à l'inverse, la suite Mocha d'`apps/api` +(`tech-step-matcher.test.ts`/`recipe-translation.test.ts`) exige elle une +vraie instance de ce service tournant (voir `apps/api/.env.test`), conforme +à la convention du repo de ne jamais mocker un service interne. + +## Limitations connues + +- **Démarrage lent** (~11 minutes) — voir "Temps de démarrage" ci-dessus. + Une optimisation possible non explorée : parallélisation de + l'entraînement `fr`/`en` (actuellement séquentiel, + `PipelineRegistry.initialize`). +- **`CONFIDENCE_THRESHOLD` côté `apps/api` est un placeholder** depuis + l'élargissement du corpus à ~74 techniques (calibré à la main, pas via + une vraie repasse de `calibrate-tech-step-threshold.ts` contre + `TECH_STEP_EVAL_DATASET` — voir le commentaire de cette constante). +- **Textcat bag-of-words** (`spacy.TextCatBOW.v3`) — suffisant pour le + corpus actuel une fois correctement entraîné, mais n'exploite pas les + vecteurs de mots des modèles `md` chargés. Migrable vers une architecture + tok2vec/similarité sans changer le contrat HTTP, si le F1 mesuré par + `apps/api/src/scripts/calibrate-tech-step-threshold.ts` le justifie un + jour. +- **Reconstruit tout le pipeline à chaque démarrage** (pas de persistance, + pas de fusion incrémentale) — un choix délibéré (voir + `LocalePipeline.train`), pas une limitation à lever : `training_data.py` + doit toujours rester l'unique source de vérité, jamais un état sur disque + qui pourrait dériver. diff --git a/services/tech-step-intent-service/intent_service/__init__.py b/services/tech-step-intent-service/intent_service/__init__.py new file mode 100644 index 0000000..eb0a434 --- /dev/null +++ b/services/tech-step-intent-service/intent_service/__init__.py @@ -0,0 +1,12 @@ +"""Microservice de détection d'intention (technique de cuisine). + +Remplace le pipeline `node-nlp` qui vivait dans `apps/api` +(`TechStepClassifierService`, `apps/api/src/lib/recipe-matching/tech-step-matcher.ts`) : +NER par phrases (synonymes) + classification d'intention (textcat), les deux +entraînés à la demande depuis un corpus qui reste possédé par `apps/api` +(`TECH_STEP_TRAINING_DATA`) et poussé ici via `POST /v1/train`. + +Ce service ne touche jamais Postgres — voir `services/tech-step-llm-worker` +pour le précédent architectural (même posture : aucun accès DB direct, +tout passe par HTTP, la résolution `TechStep.key -> id` reste côté `apps/api`). +""" diff --git a/services/tech-step-intent-service/intent_service/config.py b/services/tech-step-intent-service/intent_service/config.py new file mode 100644 index 0000000..16427a8 --- /dev/null +++ b/services/tech-step-intent-service/intent_service/config.py @@ -0,0 +1,52 @@ +"""Configuration du service, lue depuis l'environnement (`pydantic-settings`). + +Contrairement à `requireInternalWorker` côté `apps/api` +(`apps/api/src/middlewares/require-internal-worker.ts`), qui tolère un +`INTERNAL_WORKER_SECRET` absent (le worker LLM est un job de fond +optionnel) et échoue "juste" requête par requête dans ce cas, ce service est +une dépendance coeur : `INTENT_SERVICE_SECRET` absent doit empêcher +`uvicorn` de démarrer du tout plutôt que de démarrer dans un état où chaque +requête échouerait silencieusement en boucle — `Settings` n'a donc aucune +valeur par défaut ni type optionnel pour ce champ, la validation Pydantic +lève dès l'import de ce module si la variable manque. +""" + +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + # `env_file=".env"` : lu uniquement en dev natif (`cp .env.example .env`, + # voir le README de ce service) — sans effet en Docker, où + # docker-compose.yml passe les variables directement en `environment:` + # et où aucun `.env` n'est copié dans l'image. Un `.env` absent n'est pas + # une erreur ici (pydantic-settings ignore silencieusement un fichier + # manquant) ; c'est bien `intent_service_secret` ci-dessous, sans valeur + # par défaut, qui fait échouer le démarrage si la variable n'est + # disponible par aucune des deux voies. + # + # `case_sensitive` par défaut (False) : `INTENT_SERVICE_SECRET` (la + # convention majuscule utilisée partout ailleurs dans le repo, cf. + # `docker-compose.yml`/`.env.example`) matche bien le champ + # `intent_service_secret` ci-dessous. + model_config = SettingsConfigDict(env_file=".env") + + # Secret partagé attendu sur le header `X-Intent-Service-Secret` de + # chaque requête (sauf `GET /health`) — voir `security.py`. Doit matcher + # `INTENT_SERVICE_SECRET` côté `apps/api/src/config/env.ts`. + intent_service_secret: str + + # Pas de `port` ici : `uvicorn` prend son port en argument de ligne de + # commande (`--port`, voir le Dockerfile et le README de ce service), + # jamais lu depuis `Settings` — une variable d'env dupliquant ce que la + # commande de démarrage fixe déjà explicitement n'aurait aucun lecteur. + + # Niveau du logging structuré (`logging_config.py`) — voir ce module pour + # le format. `INFO` par défaut : c'est à ce niveau que `routes/process.py` + # journalise chaque input/output du pipeline NLP, et que + # `pipeline_registry.py` journalise l'entraînement au démarrage, pour + # qu'un déploiement par défaut les voie sans configuration + # supplémentaire (`docker logs`/Portainer). + log_level: str = "INFO" + + +settings = Settings() diff --git a/services/tech-step-intent-service/intent_service/locale_pipeline.py b/services/tech-step-intent-service/intent_service/locale_pipeline.py new file mode 100644 index 0000000..de604df --- /dev/null +++ b/services/tech-step-intent-service/intent_service/locale_pipeline.py @@ -0,0 +1,424 @@ +"""Pipeline spaCy pour UNE locale — l'équivalent Python de ce que +`node-nlp`'s `NlpManager` faisait pour cette locale dans +`TechStepClassifierService` (`apps/api/src/lib/recipe-matching/tech-step-matcher.ts`) : +NER par entités enum (ici un `PhraseMatcher`) + classification d'intention +(ici un `textcat`), les deux entraînés à partir du corpus possédé par ce +service lui-même (`training_data.TECH_STEP_TRAINING_DATA` — plus poussé par +`apps/api` via HTTP, voir `pipeline_registry.py`). + +Le modèle de base spaCy (tokenizer + vecteurs + le composant +`diacritics_normalizer` défini plus bas) est chargé une seule fois +(`preload()`, appelé au démarrage du process — voir `main.py` — pas +paresseusement au premier `train()`, pour que `GET /health` ne devienne +`200` qu'une fois ce coût payé) puis réutilisé à chaque `train()` : seul le +`textcat` (retiré puis rajouté à neuf) et le `PhraseMatcher` (remplacé) sont +reconstruits à chaque appel, jamais le tokenizer/les vecteurs. Rien n'est +jamais persisté sur disque — `training_data.py` reste l'unique source de +vérité, reconstruite en mémoire depuis zéro à chaque démarrage du process. +""" + +from __future__ import annotations + +import logging +import random +from dataclasses import dataclass, field + +import spacy +from spacy.language import Language +from spacy.matcher import PhraseMatcher +from spacy.tokens import Doc, Span +from spacy.training import Example +from spacy.util import filter_spans, fix_random_seed, minibatch + +from .text_normalization import normalize_text + +logger = logging.getLogger(__name__) + +# Modèle spaCy de base par locale — voir pyproject.toml pour la version +# pinnée exacte. `md` (pas `sm`) : conserve les vecteurs de mots, inutilisés +# par le pipeline v1 (textcat bag-of-words) mais retenus pour l'ambition +# future de similarité sémantique (voir le README de ce service). +SUPPORTED_LOCALES = { + "fr": "fr_core_news_md", + "en": "en_core_web_md", +} + +# Composants du modèle de base non utilisés par ce pipeline (on ne s'appuie +# ni sur le NER générique de spaCy, ni sur l'analyse syntaxique/morphologique +# — seuls le tokenizer et les vecteurs de mots restent nécessaires) : les +# exclure au chargement évite le coût mémoire/CPU de composants qui ne +# tourneraient jamais. +_EXCLUDED_COMPONENTS = ["parser", "ner", "tagger", "morphologizer", "attribute_ruler", "lemmatizer"] + +_TEXTCAT_PIPE_NAME = "textcat" + +# Nombre d'itérations d'entraînement du textcat et taille de minibatch — +# calibrés empiriquement contre le corpus réel (`training_data.py`), pas +# seulement contre les petits corpus jouets des tests de ce fichier. Trop +# peu d'itérations laisse des clauses correctement classifiées (bon argmax) +# mais avec une confiance dérisoire — bien en dessous de tout seuil +# raisonnable pour `CONFIDENCE_THRESHOLD` (`tech-step-matcher.ts`). +# +# Trois passes de calibration successives, toutes mesurées contre le +# corpus réel (74 techniques) : +# 1. `150` itérations (calibré pour le corpus original, ~26 techniques) ne +# passe plus à l'échelle une fois élargi : `150` sur 74 classes +# dépassait 17 minutes pour une seule locale, constaté en CI. +# 2. `40` itérations, `examples` limité aux `utterances` (pas les +# `synonyms`) : ~200s/locale, mais confiance faible sur les clauses +# ancrées sans paraphrase entraînée (`simmer`/`cook`/`bake` ~0.25-0.34). +# 3. **Configuration actuelle** : les `synonyms` de chaque technique sont +# désormais aussi des exemples d'entraînement du textcat (voir plus bas +# dans `train()`) — un signal "mot-clé isolé -> sa propre technique" +# qui manquait complètement avant. À `_TRAINING_ITERATIONS` inchangé +# (40), le nombre d'exemples par époque grimpe de ~286 à ~749 et le +# temps d'entraînement suit (~535s/locale) ; réduire à `25` retrouve un +# temps proche de l'étape 2 (~336s/locale, ~670s pour fr+en combinés) +# tout en gardant l'essentiel du gain de confiance apporté par les +# synonymes : melt ~0.89, preheat ~0.77, compote ~0.78, julienne ~0.76, +# zest ~0.66, bake ~0.62, cook ~0.38, simmer ~0.31 — le plus faible +# observé, mais désormais nettement au-dessus du seuil de confiance +# (contre ~0.25, sous le seuil d'alors, à l'étape 2). Bruit +# hors-vocabulaire toujours négligeable (anglais via le classifieur +# français : `~0.02`). Une vraie repasse de +# `calibrate-tech-step-threshold.ts` contre `TECH_STEP_EVAL_DATASET` +# reste nécessaire pour confirmer/affiner ces valeurs (voir +# `CONFIDENCE_THRESHOLD`'s propre commentaire, `tech-step-matcher.ts`) +# — ce qui précède est une mesure manuelle ponctuelle, pas un +# remplacement de cette calibration. +_TRAINING_ITERATIONS = 25 +_TRAINING_BATCH_SIZE = 16 +# Arrêt anticipé : `_TRAINING_ITERATIONS` reste le plafond (le pire cas ne +# change pas), un corpus/locale qui converge plus vite n'a pas à payer les +# itérations restantes pour rien. Une époque compte comme "sans progrès" +# quand sa perte totale ne descend pas d'au moins `_EARLY_STOPPING_MIN_DELTA` +# sous la meilleure perte vue jusqu'ici ; `_EARLY_STOPPING_PATIENCE` époques +# consécutives sans progrès arrêtent l'entraînement. +# +# Mesuré contre le corpus réel (74 techniques, budget de 40 itérations, +# avant le passage à 25) : ne s'est jamais déclenché — la perte continuait +# de baisser significativement sur toute la plage (cohérent avec la +# confiance qui grimpait encore nettement entre 15 et 40 itérations, voir +# le commentaire de `_TRAINING_ITERATIONS`). Ce n'est donc pas un gain de +# temps aujourd'hui, mais un filet de sécurité peu coûteux pour la suite : si +# `_TRAINING_ITERATIONS` est un jour augmenté pour une meilleure confiance, +# ceci évite de payer des itérations supplémentaires une fois la +# convergence réellement atteinte, sans qu'il faille retrouver le bon +# plafond à la main à chaque changement du corpus. +_EARLY_STOPPING_PATIENCE = 3 +_EARLY_STOPPING_MIN_DELTA = 0.001 +# Abaissé de `0.2` avec le reste de cette recalibration — `0.1` régularise +# encore contre la petite taille du corpus par technique tout en laissant +# plus de signal passer à chaque pas, ce qui a mesurablement aidé la +# confiance finale sans signe de sur-ajustement (le bruit hors-vocabulaire +# reste aussi bas qu'avant, voir ci-dessus). +_TRAINING_DROPOUT = 0.1 +# Seed fixe — un warm-up reproductible d'un redémarrage à l'autre (même +# corpus en entrée) est préférable à un score qui varie légèrement à chaque +# déploiement pour la même donnée, en particulier pendant la calibration du +# seuil de confiance côté apps/api. +_TRAINING_SEED = 0 + + +class _DiacriticsNormalizer: + """Composant de pipeline réécrivant `token.norm_` avec `normalize_text()` + (le port Python de `normalizeText()` côté `apps/api`) pour chaque token. + + Point clé : ce composant tourne aussi bien sur les `Doc` construits pour + les *patterns* du `PhraseMatcher` (voir `LocalePipeline.train`) que sur + le *texte cible* passé à `process()` — les deux passent donc par + exactement la même normalisation, ce qui garantit qu'un synonyme comme + "mijoter" matche indifféremment "MIJOTER"/"mijoté"/"Mijotée" dans le + texte, reproduisant le comportement `ner.threshold: 1` (exact après + normalisation, sans tolérance floue Levenshtein) de l'ancien `NlpManager`. + Indépendant des `entries` entraînées — ajouté une seule fois par + `preload()`, jamais retiré/rajouté par `train()`. + + Opère token par token, sur du texte déjà tokenisé — `normalize_text()` + ne fait que réécrire la forme d'un token existant (minuscule, sans + diacritique), jamais fusionner/scinder des tokens : les patterns + (`nlp.make_doc(synonym)` + ce composant appliqué à la main, voir + `LocalePipeline.train`) et le texte cible (`nlp(text)`, pipeline + complet) passent donc toujours par le *même* découpage en tokens que + le tokenizer du modèle de base leur donne, avant que ce composant n'y + touche — pas de risque de désalignement entre les deux. + """ + + def __call__(self, doc: Doc) -> Doc: + for token in doc: + token.norm_ = normalize_text(token.text) + return doc + + +@Language.factory("diacritics_normalizer") +def _create_diacritics_normalizer(nlp: Language, name: str) -> _DiacriticsNormalizer: + return _DiacriticsNormalizer() + + +@dataclass +class TrainEntry: + """Une technique à entraîner pour une locale — construit par + `PipelineRegistry.initialize()` depuis `training_data.entries_for_locale`.""" + + uid: str + synonyms: list[str] = field(default_factory=list) + utterances: list[str] = field(default_factory=list) + + +@dataclass +class Entity: + """Une mention candidate trouvée par le `PhraseMatcher` — offsets + caractère `[start, end)`, miroir de `EntityPayload` (`schemas.py`).""" + + uid: str + start: int + end: int + + +@dataclass +class ProcessResult: + """Résultat complet d'un `process()` — miroir de `ProcessResponse` + (`schemas.py`).""" + + entities: list[Entity] + intent: str | None + score: float + + +class UnsupportedLocaleError(ValueError): + """`locale` ne correspond à aucun modèle spaCy connu (voir + `SUPPORTED_LOCALES`) — distinct d'une locale simplement "pas encore + entraînée" (`LocalePipeline.is_trained is False`), qui n'est pas une + erreur (voir `process()`).""" + + +class LocalePipeline: + """Pipeline spaCy (NER par phrases + textcat) pour une locale donnée. + Un `PipelineRegistry` (voir `pipeline_registry.py`) en détient une + instance par locale supportée. + """ + + def __init__(self, locale: str) -> None: + if locale not in SUPPORTED_LOCALES: + raise UnsupportedLocaleError(f"Unsupported locale: {locale!r}") + self._locale = locale + self._model_name = SUPPORTED_LOCALES[locale] + # `None` tant que `preload()` n'a pas tourné. + self._base_nlp: Language | None = None + # `None` tant qu'aucun `train()` n'a réussi — `process()` traite ça + # comme "rien à trouver" plutôt qu'une erreur, exactement le + # comportement testé côté `apps/api` pour "une locale jamais + # entraînée". + self._matcher: PhraseMatcher | None = None + self._trained = False + + @property + def is_trained(self) -> bool: + return self._trained + + def preload(self) -> None: + """Charge le modèle spaCy de base (tokenizer + vecteurs) et le + composant `diacritics_normalizer` — idempotent, sans effet si déjà + chargé. Appelé au démarrage du process pour les deux locales + connues (voir `main.py`), pas paresseusement au premier `train()`. + """ + if self._base_nlp is not None: + return + nlp = spacy.load(self._model_name, exclude=_EXCLUDED_COMPONENTS) + nlp.add_pipe("diacritics_normalizer", first=True) + self._base_nlp = nlp + + def train(self, entries: list[TrainEntry]) -> tuple[int, int, int]: + """Reconstruit le `textcat` et le `PhraseMatcher` de ce pipeline à + partir de `entries` (le tokenizer/les vecteurs restent ceux chargés + par `preload()`). Retourne `(label_count, example_count, + synonym_count)` pour la journalisation (`pipeline_registry.py`) — + `example_count` est le nombre réel d'exemples donnés au `textcat` + (`utterances` *et* `synonyms` combinés, voir plus bas), pas + seulement `entry.utterances`. + + `entries` vide retombe à `is_trained == False` plutôt que de lever — + un appelant qui n'a rien à entraîner pour cette locale obtient le + même comportement que "jamais entraîné", pas une erreur 500. + """ + self.preload() + assert self._base_nlp is not None # garanti par preload() ci-dessus + + if _TEXTCAT_PIPE_NAME in self._base_nlp.pipe_names: + self._base_nlp.remove_pipe(_TEXTCAT_PIPE_NAME) + + if not entries: + self._matcher = None + self._trained = False + return (0, 0, 0) + + nlp = self._base_nlp + # `nlp.make_doc()` ne fait tourner *que* le tokenizer, pas les + # composants du pipeline — le `diacritics_normalizer` ajouté par + # `preload()` ne tournerait donc jamais sur les `Doc` de patterns + # s'ils n'étaient construits qu'avec `make_doc()`, alors que + # `process()` appelle `nlp(text)` (le pipeline complet) sur le texte + # cible. Sans ce correctif, un synonyme accentué comme "préchauffer" + # n'aurait jamais matché "PRÉCHAUFFER"/"Préchauffer" : trouvé en + # calibrant contre les cas exacts de `tech-step-matcher.test.ts` + # (fr, la locale la plus concernée par les accents) — un synonyme + # sans diacritique comme "faire fondre" masquait le bug en semblant + # fonctionner par coïncidence. Appliquer explicitement le même + # composant aux deux côtés garantit qu'ils passent par la même + # normalisation. + diacritics_normalizer = nlp.get_pipe("diacritics_normalizer") + + matcher = PhraseMatcher(nlp.vocab, attr="NORM") + synonym_count = 0 + for entry in entries: + if not entry.synonyms: + continue + patterns = [diacritics_normalizer(nlp.make_doc(synonym)) for synonym in entry.synonyms] + matcher.add(entry.uid, patterns) + synonym_count += len(entry.synonyms) + + # `textcat` (exclusive_classes) exige au moins deux labels (voir + # spaCy's error E867) — jamais un problème avec le vrai corpus + # (`TECH_STEP_TRAINING_DATA` a ~74 techniques), mais un `entries` à + # un seul élément resterait structurellement valide pour le NER + # seul : ne pas planter, juste ne pas construire de textcat du tout + # (`process()` retombe alors sur `intent: null` via son garde + # `if not cats`, exactement comme "rien à classifier"). Journalisé + # explicitement — sans ça, "pourquoi cette locale ne classifie + # jamais rien" ne serait visible qu'en déduisant `labelCount < 2` + # de la ligne "tech-step NLP pipeline trained" (`pipeline_registry.py`). + examples: list[Example] = [] + if len(entries) < 2: + logger.warning( + "tech-step NLP textcat skipped: fewer than 2 labels, intent classification disabled for this locale", + extra={"locale": self._locale, "labelCount": len(entries)}, + ) + else: + textcat = nlp.add_pipe( + _TEXTCAT_PIPE_NAME, + config={ + "model": { + "@architectures": "spacy.TextCatBOW.v3", + "exclusive_classes": True, + "ngram_size": 1, + "no_output_layer": False, + }, + }, + ) + for entry in entries: + textcat.add_label(entry.uid) + + for entry in entries: + cats = {other.uid: 0.0 for other in entries} + cats[entry.uid] = 1.0 + # `synonyms` (déjà utilisés pour le `PhraseMatcher` ci-dessus) + # sont aussi de bonnes phrases d'entraînement pour le + # `textcat` — un texte réduit au mot-clé lui-même ("fondre", + # "faire fondre") est le cas le plus net qui soit pour sa + # propre technique, et n'était auparavant vu par le textcat + # que noyé dans le contexte plus riche des `utterances`. + for text in (*entry.synonyms, *entry.utterances): + doc = nlp.make_doc(text) + examples.append(Example.from_dict(doc, {"cats": cats})) + + # Graine le RNG Python *et* celui de numpy/thinc sous-jacent à + # `nlp.update()` (initialisation des poids, masque de dropout) — + # `random.Random(_TRAINING_SEED)` ci-dessous ne couvre que l'ordre + # de mélange des exemples choisi par ce module, pas ce que spaCy + # fait en interne à chaque pas de gradient. + fix_random_seed(_TRAINING_SEED) + rng = random.Random(_TRAINING_SEED) + if examples: + optimizer = nlp.initialize(lambda: examples) + best_loss = float("inf") + epochs_without_improvement = 0 + for iteration in range(_TRAINING_ITERATIONS): + rng.shuffle(examples) + losses: dict[str, float] = {} + for batch in minibatch(examples, size=_TRAINING_BATCH_SIZE): + nlp.update(batch, sgd=optimizer, drop=_TRAINING_DROPOUT, losses=losses) + epoch_loss = losses.get(_TEXTCAT_PIPE_NAME, 0.0) + # Arrêt anticipé — voir `_EARLY_STOPPING_PATIENCE`'s propre + # commentaire. `_TRAINING_ITERATIONS` reste le plafond + # (pire cas inchangé), ceci ne fait que raccourcir les + # cas qui convergent plus vite. + if epoch_loss < best_loss - _EARLY_STOPPING_MIN_DELTA: + best_loss = epoch_loss + epochs_without_improvement = 0 + else: + epochs_without_improvement += 1 + if epochs_without_improvement >= _EARLY_STOPPING_PATIENCE: + logger.info( + "tech-step NLP textcat training stopped early", + extra={ + "locale": self._locale, + "iteration": iteration + 1, + "maxIterations": _TRAINING_ITERATIONS, + "finalLoss": epoch_loss, + }, + ) + break + else: + # Des `entries` avec des `uid` mais aucune `utterance` nulle + # part (corpus incomplet) : le textcat a des labels mais rien + # pour apprendre à les distinguer — toujours initialisé pour + # rester un pipeline valide ; `process()` renverra alors un + # score ~uniforme entre labels. Ce n'est pas ce module qui doit + # juger la qualité du corpus reçu (voir `tech-step-eval-runner.ts` + # côté apps/api pour ce rôle). + nlp.initialize() + + self._matcher = matcher + self._trained = True + return (len(entries), len(examples), synonym_count) + + def process(self, text: str) -> ProcessResult: + """Reproduit la forme de `NlpManager.process(locale, text)` : les + entités candidates (NER) et le verdict du classifieur d'intention + sur `text` tel quel — que ce soit la description complète ou une + clause déjà découpée côté `apps/api`, ce module ne le sait pas et ne + s'en soucie pas, exactement comme l'ancien `NlpManager`. + + `intent` vaut `None` dans deux cas distincts, tous deux silencieux + côté retour (voir le log d'avertissement de `train()` pour repérer + le second en amont) : `text` vide/blanc, ou `doc.cats` vide parce + que `train()` a reçu moins de deux labels pour cette locale (le + textcat n'a alors jamais été construit — voir son propre + commentaire). + """ + if not self._trained or self._base_nlp is None or self._matcher is None or not text.strip(): + return ProcessResult(entities=[], intent=None, score=0.0) + + doc = self._base_nlp(text) + + # A technique's own synonym list can legitimately contain one phrase + # nested inside another (`melt`'s "fondre" is a literal substring of + # its own "faire fondre") — the `PhraseMatcher` reports *both* as + # separate matches at overlapping positions, which without + # resolution would hand `splitIntoClauses` (apps/api) two candidates + # for what a human reads as one mention, producing the same + # techStepId twice in the final result. `filter_spans` keeps only + # the longest match at each position (so "faire fondre" wins over + # the "fondre" it contains) — found by a real regression in + # `tech-step-matcher.test.ts`'s "detects several distinct + # techniques..." case once this service replaced node-nlp (which + # apparently resolved this internally; nothing here recreates that + # by choice, `filter_spans` is spaCy's own documented tool for + # exactly this "one span per position" problem, e.g. as used for + # NER-style outputs). + matched_spans = [ + Span(doc, start, end, label=match_id) for match_id, start, end in self._matcher(doc) + ] + entities = sorted( + ( + Entity(uid=self._base_nlp.vocab.strings[span.label], start=span.start_char, end=span.end_char) + for span in filter_spans(matched_spans) + ), + key=lambda entity: entity.start, + ) + + cats = doc.cats + if not cats: + return ProcessResult(entities=entities, intent=None, score=0.0) + intent = max(cats, key=cats.get) + return ProcessResult(entities=entities, intent=intent, score=cats[intent]) diff --git a/services/tech-step-intent-service/intent_service/logging_config.py b/services/tech-step-intent-service/intent_service/logging_config.py new file mode 100644 index 0000000..6c59dde --- /dev/null +++ b/services/tech-step-intent-service/intent_service/logging_config.py @@ -0,0 +1,77 @@ +"""Logging structuré — même convention que `LoggerService` côté `apps/api` +(`apps/api/src/lib/logger.service.ts`) : une ligne JSON par évènement +(`timestamp`, `level`, `message`, + le reste des champs fournis fusionné), +jamais du texte libre, pour rester grep/parse-able par `docker logs`/ +Portainer ou un agrégateur de logs — cohérent avec le reste du repo plutôt +qu'un format propre à ce seul service. + +Configuré une fois au démarrage (`main.py`) plutôt que par un `print()` ad +hoc dans chaque route — `routes/process.py`/`pipeline_registry.py` appellent +`logging.getLogger(__name__)` normalement, ce module ne fait que brancher le +formateur JSON sur la racine du logging Python. +""" + +import json +import logging +import sys +from datetime import UTC, datetime +from typing import Any + + +class _JsonFormatter(logging.Formatter): + """Sérialise chaque `LogRecord` en une ligne JSON. Les champs + supplémentaires passés via `logger.info(msg, extra={...})` sont fusionnés + tels quels dans l'objet — c'est ce que `routes/process.py` utilise pour + joindre `locale`/`text`/`entities`/`intent`/`score` à la ligne.""" + + # Attributs standards de `LogRecord` — tout le reste posé sur le record + # (via `extra=`) est un champ métier ajouté par l'appelant, à fusionner + # dans la sortie JSON. + _STANDARD_ATTRS = frozenset(logging.LogRecord("", 0, "", 0, "", None, None).__dict__.keys()) + + def format(self, record: logging.LogRecord) -> str: + payload: dict[str, Any] = { + "timestamp": datetime.fromtimestamp(record.created, tz=UTC).isoformat(), + "level": record.levelname.lower(), + "message": record.getMessage(), + } + extra_fields = { + key: value for key, value in record.__dict__.items() if key not in self._STANDARD_ATTRS + } + payload.update(extra_fields) + if record.exc_info: + payload["error"] = self.formatException(record.exc_info) + return json.dumps(payload, ensure_ascii=False, default=str) + + +def configure_logging(level: str) -> None: + """Branche le formateur JSON sur la racine du logging Python — appelé + une fois au démarrage (`main.py`), avant que `routes/*` ne journalisent + quoi que ce soit.""" + # L'encodage par défaut de `sys.stdout` suit la locale de l'OS/console, + # pas forcément UTF-8 — sur Windows en particulier, garder ce défaut + # produit de vrais octets invalides (pas juste un affichage terminal + # trompeur) pour tout texte accentué journalisé par `routes/process.py` + # (le texte réel des étapes de recette, en français) — trouvé en + # vérifiant les octets bruts d'un log réel, pas juste son affichage. + # `reconfigure` existe sur `sys.stdout` dans toute exécution Python + # normale (pas dans certains contextes embarqués/redirigés exotiques) — + # protégé par `hasattr` pour ne jamais faire planter le démarrage pour un + # souci de confort d'affichage. + if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8") + handler = logging.StreamHandler(sys.stdout) + handler.setFormatter(_JsonFormatter()) + root = logging.getLogger() + root.handlers = [handler] + root.setLevel(level) + + # spaCy/thinc journalisent leur propre chatter interne ("Created + # vocabulary", "Finished initializing nlp object"...) sur le logger + # `"spacy"`, qui propage jusqu'à la racine et se retrouverait donc + # mélangé aux lignes input/output de `routes/process.py`/l'entraînement + # journalisé par `pipeline_registry.py` + # — ce sont ces dernières que ce service existe pour rendre visibles, pas + # le détail interne de spaCy. `WARNING` laisse quand même remonter un + # vrai problème (dépréciation, échec partiel) sans le bruit `INFO`. + logging.getLogger("spacy").setLevel(logging.WARNING) diff --git a/services/tech-step-intent-service/intent_service/main.py b/services/tech-step-intent-service/intent_service/main.py new file mode 100644 index 0000000..e6af2c9 --- /dev/null +++ b/services/tech-step-intent-service/intent_service/main.py @@ -0,0 +1,39 @@ +"""Point d'entrée FastAPI — `uv run uvicorn intent_service.main:app` (voir +le Dockerfile et le README de ce service). + +Le chargement des modèles spaCy de base *et* l'entraînement de chaque +locale (`PipelineRegistry.initialize`) se font dans le handler `lifespan` +ci-dessous, *avant* qu'uvicorn n'accepte de requêtes — `GET /health` ne +répond donc `200` qu'une fois ce coût payé (chargement + entraînement), +jamais pendant qu'il est encore en cours (uvicorn ne sert aucune requête +tant que le `lifespan` de démarrage n'est pas terminé). Ce service est +autonome : `training_data.TECH_STEP_TRAINING_DATA` vit dans ce module, +`apps/api` ne pousse plus rien via HTTP (voir `pipeline_registry.py` pour +le détail de ce que ça change par rapport à la version précédente). +""" + +from contextlib import asynccontextmanager + +from fastapi import FastAPI + +from .config import settings +from .logging_config import configure_logging +from .pipeline_registry import registry +from .routes import health, process + +# Avant tout le reste : `routes/process.py` journalise dès la première +# requête, `initialize()` ci-dessous journalise aussi (voir +# `pipeline_registry.py`) — le formateur JSON doit déjà être en place. +configure_logging(settings.log_level) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + registry.initialize() + yield + + +app = FastAPI(title="tech-step-intent-service", lifespan=lifespan) + +app.include_router(health.router) +app.include_router(process.router) diff --git a/services/tech-step-intent-service/intent_service/pipeline_registry.py b/services/tech-step-intent-service/intent_service/pipeline_registry.py new file mode 100644 index 0000000..396da22 --- /dev/null +++ b/services/tech-step-intent-service/intent_service/pipeline_registry.py @@ -0,0 +1,75 @@ +"""Détient un `LocalePipeline` par locale supportée — le seul état mutable +partagé du process (une instance vit pour toute la durée de vie d'`uvicorn`, +montée sur `app.state`, voir `main.py`). + +Volontairement une classe "registre" séparée de `LocalePipeline` lui-même : +`LocalePipeline` ne connaît qu'une seule locale, ce module route `process` +vers la bonne instance selon le `locale` reçu dans la requête — même +séparation de responsabilité que `TechStepClassifierService` (une seule +instance, un seul `NlpManager` multi-langues) avait implicitement via +node-nlp, explicitée ici puisque spaCy charge un modèle par langue. +""" + +import logging + +from .locale_pipeline import SUPPORTED_LOCALES, LocalePipeline, ProcessResult, TrainEntry +from .training_data import entries_for_locale + +logger = logging.getLogger(__name__) + + +class PipelineRegistry: + def __init__(self) -> None: + self._pipelines: dict[str, LocalePipeline] = { + locale: LocalePipeline(locale) for locale in SUPPORTED_LOCALES + } + + def initialize(self) -> None: + """Charge le modèle spaCy de base *et* entraîne chaque locale connue + depuis `training_data.TECH_STEP_TRAINING_DATA` — appelé une fois au + démarrage du process (`main.py`'s `lifespan`), avant que `uvicorn` + n'accepte de requêtes. + + Contrairement à la version précédente de ce service (où `apps/api` + poussait le corpus via `POST /v1/train` à son propre warm-up), ce + service est maintenant entièrement autonome : `apps/api` ne connaît + plus aucune technique, seulement le résultat de + `POST /v1/process`. `GET /health` ne répond `200` qu'une fois cette + méthode terminée (chargement *et* entraînement) — pas seulement le + chargement — pour que `docker-compose.yml`'s `depends_on: ... + condition: service_healthy` (et la boucle d'attente équivalente en + CI) ne laisse jamais `apps/api` démarrer face à un service qui + répondrait mais ne saurait encore rien détecter. + """ + logger.info("tech-step NLP initializing pipelines", extra={"locales": list(self._pipelines)}) + for locale, pipeline in self._pipelines.items(): + pipeline.preload() + entries = [TrainEntry(**entry) for entry in entries_for_locale(locale)] + label_count, example_count, synonym_count = pipeline.train(entries) + logger.info( + "tech-step NLP pipeline trained", + extra={ + "locale": locale, + "labelCount": label_count, + # Nombre réel d'exemples donnés au textcat (utterances + # *et* synonyms combinés — voir `LocalePipeline.train`), + # pas seulement le compte d'`utterances` du corpus. + "exampleCount": example_count, + "synonymCount": synonym_count, + }, + ) + logger.info("tech-step NLP pipelines ready", extra={"locales": list(self._pipelines)}) + + def process(self, locale: str, text: str) -> ProcessResult: + pipeline = self._pipelines.get(locale) + if pipeline is None: + # Une locale que ce service ne sait structurellement pas + # charger (pas de modèle spaCy connu) se comporte comme une + # locale "jamais entraînée" côté `process` — reproduit le test + # `apps/api` existant ("returns an empty sequence for a locale + # nothing was trained on"), qui ne distingue pas les deux cas. + return ProcessResult(entities=[], intent=None, score=0.0) + return pipeline.process(text) + + +registry = PipelineRegistry() diff --git a/services/tech-step-intent-service/intent_service/routes/__init__.py b/services/tech-step-intent-service/intent_service/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/tech-step-intent-service/intent_service/routes/health.py b/services/tech-step-intent-service/intent_service/routes/health.py new file mode 100644 index 0000000..ce08ee3 --- /dev/null +++ b/services/tech-step-intent-service/intent_service/routes/health.py @@ -0,0 +1,18 @@ +"""`GET /health` — sondé par le `healthcheck` Docker (`docker-compose.yml`) +et par l'étape CI qui attend que ce service soit prêt avant de lancer la +suite Mocha de `apps/api` (voir `.github/workflows/ci.yml`). Volontairement +sans authentification, même posture que le `GET /health` existant côté +`apps/api` (`app.ts`) — un healthcheck qui exigerait un secret compliquerait +sa configuration pour un gain de sécurité nul (il ne renvoie aucune donnée). +""" + +from fastapi import APIRouter + +from ..schemas import HealthResponse + +router = APIRouter() + + +@router.get("/health", response_model=HealthResponse) +def health() -> HealthResponse: + return HealthResponse(status="ok") diff --git a/services/tech-step-intent-service/intent_service/routes/process.py b/services/tech-step-intent-service/intent_service/routes/process.py new file mode 100644 index 0000000..8596266 --- /dev/null +++ b/services/tech-step-intent-service/intent_service/routes/process.py @@ -0,0 +1,43 @@ +"""`POST /v1/process` — appelé par `apps/api` (`IntentServiceClient.process`) +en remplacement direct de l'ancien `NlpManager.process(locale, text)`. Voir +`LocalePipeline.process` pour la sémantique exacte (locale non entraînée ou +`text` vide -> résultat vide, jamais une erreur). +""" + +import logging + +from fastapi import APIRouter, Depends + +from ..pipeline_registry import registry +from ..schemas import EntityPayload, ProcessRequest, ProcessResponse +from ..security import require_valid_secret + +logger = logging.getLogger(__name__) + +router = APIRouter(dependencies=[Depends(require_valid_secret)]) + + +@router.post("/v1/process", response_model=ProcessResponse) +def process(request: ProcessRequest) -> ProcessResponse: + result = registry.process(request.locale, request.text) + + # Une ligne par appel — input (`locale`/`text`) et output (`entities`/ + # `intent`/`score`) réunis dans la même ligne JSON, pour pouvoir suivre + # exactement ce que le pipeline a décidé pour un texte donné (voir + # `logging_config.py` pour le format). + logger.info( + "tech-step NLP process", + extra={ + "locale": request.locale, + "text": request.text, + "entities": [{"uid": entity.uid, "start": entity.start, "end": entity.end} for entity in result.entities], + "intent": result.intent, + "score": result.score, + }, + ) + + return ProcessResponse( + entities=[EntityPayload(uid=entity.uid, start=entity.start, end=entity.end) for entity in result.entities], + intent=result.intent, + score=result.score, + ) diff --git a/services/tech-step-intent-service/intent_service/schemas.py b/services/tech-step-intent-service/intent_service/schemas.py new file mode 100644 index 0000000..ffafdd1 --- /dev/null +++ b/services/tech-step-intent-service/intent_service/schemas.py @@ -0,0 +1,45 @@ +"""Modèles Pydantic du contrat HTTP — voir le plan de migration pour le +contrat exact attendu côté `apps/api` (`IntentServiceClient`, +`apps/api/src/lib/recipe-matching/intent-service-client.ts`). + +Pas de `POST /v1/train` ici — ce service s'entraîne lui-même au démarrage +depuis `training_data.py` (voir `pipeline_registry.py`/`main.py`), plus +besoin d'un contrat HTTP pour ça. +""" + +from pydantic import BaseModel + +# --------------------------------------------------------------------------- +# POST /v1/process +# --------------------------------------------------------------------------- + + +class ProcessRequest(BaseModel): + locale: str + text: str + + +class EntityPayload(BaseModel): + """Une mention candidate d'une technique — offsets caractère `[start, end)` + dans `text`, convention identique à `String.prototype.slice` côté + `apps/api` (pas de décalage `+1` à appliquer côté Node, contrairement à + l'ancien `NlpManager` de node-nlp).""" + + uid: str + start: int + end: int + + +class ProcessResponse(BaseModel): + entities: list[EntityPayload] + intent: str | None + score: float + + +# --------------------------------------------------------------------------- +# GET /health +# --------------------------------------------------------------------------- + + +class HealthResponse(BaseModel): + status: str diff --git a/services/tech-step-intent-service/intent_service/security.py b/services/tech-step-intent-service/intent_service/security.py new file mode 100644 index 0000000..108016c --- /dev/null +++ b/services/tech-step-intent-service/intent_service/security.py @@ -0,0 +1,35 @@ +"""Authentification des appels entrants — miroir inversé de `requireInternalWorker` +(`apps/api/src/middlewares/require-internal-worker.ts`) : ici c'est +`apps/api` qui appelle *ce* service, donc c'est ce service qui vérifie le +secret plutôt que de l'envoyer. + +Comparaison à temps constant (`hmac.compare_digest`, l'équivalent Python du +`timingSafeEqual` de Node utilisé côté `apps/api`) — même raisonnement : +un attaquant ne doit rien apprendre de la durée de la comparaison au-delà de +ce qu'une différence de longueur révèle déjà. +""" + +import hmac + +from fastapi import Header, HTTPException, status + +from .config import settings + +_SECRET_HEADER_NAME = "x-intent-service-secret" + + +def require_valid_secret( + x_intent_service_secret: str | None = Header(default=None, alias=_SECRET_HEADER_NAME), +) -> None: + """Dépendance FastAPI montée sur chaque route protégée (`/v1/*`) — pas + `GET /health`, sondé par le healthcheck Docker sans configuration + d'auth propre. + + `settings.intent_service_secret` est garanti non vide par `config.py` + (pas de valeur par défaut dans `Settings`) — le seul cas à traiter ici + est un header manquant ou incorrect côté appelant. + """ + if x_intent_service_secret is None or not hmac.compare_digest( + x_intent_service_secret, settings.intent_service_secret + ): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated") diff --git a/services/tech-step-intent-service/intent_service/text_normalization.py b/services/tech-step-intent-service/intent_service/text_normalization.py new file mode 100644 index 0000000..cafa2a6 --- /dev/null +++ b/services/tech-step-intent-service/intent_service/text_normalization.py @@ -0,0 +1,32 @@ +"""Port Python de `normalizeText` (`apps/api/src/lib/recipe-matching/tech-step-matcher.ts`). + +Doit rester bit-pour-bit équivalent à sa contrepartie TypeScript — c'est ce +qui garantit qu'un synonyme matché ici tombe exactement sur les mêmes +positions caractère que ce que `apps/api` attendait de node-nlp (voir +`LocalePipeline`'s `diacritics_normalizer`, qui applique cette fonction aux +patterns *et* au texte cible pour les faire matcher identiquement). + +TypeScript original : + + const COMBINING_DIACRITICS_PATTERN = /\\p{Diacritic}/gu; + export function normalizeText(text: string): string { + return text.normalize("NFD").replace(COMBINING_DIACRITICS_PATTERN, "").toLowerCase(); + } + +`unicodedata.combining(ch) != 0` (catégories Unicode Mn/Mc, la classe de +combinaison canonique) est l'idiome Python standard pour "strip accents +after NFD" — légèrement plus étroit que `\\p{Diacritic}` en théorie (qui +couvre aussi quelques diacritiques autonomes hors caractères combinants), +mais strictement équivalent pour tout caractère latin accentué usuel +(français/anglais) une fois décomposé en NFD, ce qui est le seul cas +réellement exercé par ce corpus. +""" + +import unicodedata + + +def normalize_text(text: str) -> str: + """Décompose en NFD, retire les marques combinantes (accents), met en minuscule.""" + decomposed = unicodedata.normalize("NFD", text) + stripped = "".join(char for char in decomposed if not unicodedata.combining(char)) + return stripped.lower() diff --git a/services/tech-step-intent-service/intent_service/training_data.py b/services/tech-step-intent-service/intent_service/training_data.py new file mode 100644 index 0000000..535c4fa --- /dev/null +++ b/services/tech-step-intent-service/intent_service/training_data.py @@ -0,0 +1,1692 @@ +"""Corpus d'entraînement pour {@link LocalePipeline} — anciennement possédé +par `apps/api` (`tech-step-training-data.ts`, poussé via `POST /v1/train` à +chaque warm-up serveur), rapatrié ici pour que ce service soit entièrement +autonome : il s'entraîne lui-même une seule fois au démarrage +(`pipeline_registry.py`'s `initialize()`, appelé par `main.py`'s +`lifespan`), sans dépendre d'un appel HTTP externe. `apps/api` ne connaît +plus aucune technique ni aucun synonyme — seul `TechStep.key -> id` +(`reference-seed-data.ts`) doit encore rester en phase avec les `uid` ici : +chaque `uid` ci-dessous doit avoir une entrée `TECH_STEPS` correspondante, +sans quoi `TechStepClassifierService` résout un match qu'il ne peut +persister (voir son propre commentaire sur ce cas). + +Deux types de contenu par technique/locale, comme avant la migration +Python : + +- `synonyms` — mots/phrases courtes alimentant le `PhraseMatcher` (NER par + énumération) — les mentions *candidates* d'une technique, avant tout + jugement de sens. +- `utterances` — phrases d'exemple complètes alimentant le `textcat` + (classification d'intention) — mélange volontaire de tournures ancrées + sur le mot-clé et de paraphrases qui ne l'emploient jamais, pour que le + classifieur apprenne à reconnaître le *sens*, pas seulement le mot. + +Les 26 premières entrées (jusqu'à `coat`) sont le corpus original, +directement porté depuis `tech-step-training-data.ts` (voir l'historique +Git de ce fichier côté `apps/api` pour le détail des régressions qui ont +façonné chaque liste). Les entrées suivantes ajoutent le lexique de +techniques fourni par l'utilisateur — un synonyme volontairement en phrase +complète plutôt qu'au mot nu quand une forme courte collisionnerait avec +une technique existante (ex. `whiskPale`/"blanchir un jaune d'œuf" à côté +de `blanch`/"blanchir un légume" — le même verbe français, deux sens +distincts ; `filter_spans` dans `locale_pipeline.py` retient alors la +phrase la plus longue et donc la plus spécifique quand les deux se +chevauchent). +""" + +from dataclasses import dataclass, field + + +@dataclass(frozen=True) +class TechStepLocaleTrainingData: + synonyms: list[str] = field(default_factory=list) + utterances: list[str] = field(default_factory=list) + + +@dataclass(frozen=True) +class TechStepTrainingEntry: + """`uid` doit correspondre à un `TechStep.key` (`reference-seed-data.ts` + côté `apps/api`, et à un libellé dans `apps/web`'s + `locales/fr/translation.json`'s `catalog.techSteps.`).""" + + uid: str + fr: TechStepLocaleTrainingData + en: TechStepLocaleTrainingData + + +TECH_STEP_TRAINING_DATA: list[TechStepTrainingEntry] = [ + # ------------------------------------------------------------------ + # Corpus original (26 techniques) — porté depuis tech-step-training-data.ts + # ------------------------------------------------------------------ + TechStepTrainingEntry( + uid="cook", + fr=TechStepLocaleTrainingData( + synonyms=[ + "cuire", "cuisez", "cuisant", "cuisson", "cuit", "cuite", "cuites", "cuits", + "cuisiner", "cuisinez", "cuisiné", "cuisinée", "faire cuire", "laisser cuire", + ], + utterances=[ + "faire cuire à feu moyen", + "laisser cuire jusqu'à ce que ce soit prêt", + "la cuisson dure environ dix minutes", + "jusqu'à ce que la viande ne soit plus rose au centre", + "poursuivre la cuisson à couvert", + "baisser le feu et laisser cuire à découvert encore un quart d'heure", + "faire cuire à feu doux en remuant de temps en temps", + ], + ), + en=TechStepLocaleTrainingData( + # NOT "cooked through"/"cooking through" — extensions de "cooked"/ + # "cooking" créant des candidats NER chevauchants (voir la note de + # `locale_pipeline.py` sur `filter_spans` : plus nécessaire de les + # bannir pour cette raison précise, mais gardé simple). + synonyms=["cook", "cooks", "cooked", "cooking"], + utterances=[ + "cook over medium heat", + "cook until done", + "cooking takes about ten minutes", + "until no longer pink in the middle", + "continue cooking covered", + ], + ), + ), + TechStepTrainingEntry( + uid="fry", + fr=TechStepLocaleTrainingData( + synonyms=[ + "frire", "frit", "frite", "frites", "friture", "faire frire", "faites frire", + "bain de friture", "huile de friture", + ], + utterances=[ + "faire frire dans l'huile chaude", + "plonger dans la friture", + "jusqu'à ce que ce soit doré et croustillant à l'extérieur", + "l'huile doit être bien chaude avant d'y plonger les morceaux", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["fry", "fries", "fried", "frying", "deep fry", "deep-fried", "deep frying"], + utterances=[ + "fry in hot oil", + "deep fry until golden", + "until crisp and golden on the outside", + "the oil should be very hot before adding the pieces", + ], + ), + ), + TechStepTrainingEntry( + uid="melt", + fr=TechStepLocaleTrainingData( + synonyms=[ + "fondre", "fondu", "fondue", "fondues", "faire fondre", "faites fondre", + "faire chauffer", "faites chauffer", "liquéfier", "liquéfiez", "liquéfié", + "faire liquéfier", + ], + utterances=[ + "faire fondre le beurre", + "jusqu'à ce que le beurre ait disparu dans la poêle", + "le beurre doit être complètement liquide", + "laisser le fromage devenir tout liquide sur feu doux", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["melt", "melts", "melted", "melting", "liquefy", "liquefied"], + utterances=[ + "melt the butter", + "until the butter has completely disappeared into the pan", + "the butter should be fully liquid", + "let the cheese turn completely liquid over low heat", + ], + ), + ), + TechStepTrainingEntry( + uid="deglaze", + fr=TechStepLocaleTrainingData( + synonyms=["déglacer", "déglacez", "déglacé", "déglacée", "déglaçage"], + utterances=[ + "déglacer avec le vin blanc", + "verser le vin dans la poêle chaude pour décoller les sucs", + "gratter les sucs de cuisson au fond de la casserole avec un peu de bouillon", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["deglaze", "deglazes", "deglazed", "deglazing", "lift the browned bits"], + utterances=[ + "deglaze with white wine", + "pour the wine into the hot pan to lift the browned bits", + "scrape up the browned bits at the bottom of the pan with a splash of stock", + ], + ), + ), + TechStepTrainingEntry( + uid="simmer", + fr=TechStepLocaleTrainingData( + synonyms=[ + "mijoter", "mijotez", "mijote", "mijotant", "mijoté", "frémir", "frémissant", + "frémissante", "à petit feu", + # "Mitonner" (lexique ajouté) — synonyme de mijoter, pas une + # technique distincte : sa propre définition le dit + # explicitement ("le laisser mijoter pour en décupler les + # saveurs"). + "mitonner", "mitonnez", "mitonné", "mitonnée", + ], + utterances=[ + "laisser mijoter à feu doux", + "faire mijoter pendant une heure", + "de petites bulles doivent remonter doucement à la surface", + "laisser cuire tout doucement à couvert pendant longtemps", + "mitonner le plat avec soin à feu très doux pour développer les saveurs", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["simmer", "simmers", "simmered", "simmering", "gentle simmer", "low simmer"], + utterances=[ + "let it simmer over low heat", + "simmer for one hour", + "small bubbles should gently rise to the surface", + "let it cook very gently, covered, for a long time", + ], + ), + ), + TechStepTrainingEntry( + uid="boil", + fr=TechStepLocaleTrainingData( + synonyms=[ + "bouillir", "bouillant", "bouillie", "bouillies", "ébullition", + "porter à ébullition", "gros bouillons", + ], + utterances=[ + "porter à ébullition", + "faire bouillir l'eau", + "de grosses bulles doivent agiter la surface avec force", + "jusqu'à ce que ça bouillonne franchement", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["boil", "boils", "boiled", "boiling", "rolling boil"], + utterances=[ + "bring to a boil", + "boil the water", + "large bubbles should be vigorously breaking the surface", + "until it's rolling vigorously", + ], + ), + ), + TechStepTrainingEntry( + uid="roast", + fr=TechStepLocaleTrainingData( + synonyms=["rôtir", "rôti", "rôtie", "rôties", "rôtis", "rôtissage"], + utterances=[ + "faire rôtir la volaille entière", + "le rôti doit dorer uniformément de tous les côtés", + "cuire la pièce de viande entière au four à chaleur sèche", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["roast", "roasts", "roasted", "roasting", "oven-roast", "oven roasted"], + utterances=[ + "roast the whole bird", + "it should brown evenly on every side", + "cook the whole piece of meat in dry oven heat", + ], + ), + ), + TechStepTrainingEntry( + uid="grill", + fr=TechStepLocaleTrainingData( + synonyms=[ + "griller", "grillez", "grillé", "grillée", "grillées", "grillade", "grillades", + "barbecue", "au barbecue", + ], + utterances=[ + "faire griller sur la grille du barbecue", + "marquer les steaks sur une plaque brûlante", + "des traces de quadrillage doivent apparaître à la cuisson", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["grill", "grills", "grilled", "grilling", "barbecue", "char-grill", "charbroiled"], + utterances=[ + "grill on the barbecue rack", + "sear the steaks on a scorching-hot plate", + "char marks should appear as it cooks", + ], + ), + ), + TechStepTrainingEntry( + uid="panFry", + fr=TechStepLocaleTrainingData( + # Deliberately pas "poêlé"/"poêlée"/"poêlés" : trop proche du nom + # commun "poêle" (voir tech-step-matcher.ts côté apps/api pour le + # faux positif que ça causait avec node-nlp — moins critique avec + # le `PhraseMatcher` exact de ce service, mais gardé par prudence). + synonyms=["sauter", "sautez", "sauté", "sautée", "sautées", "sautant", "à la poêle"], + utterances=[ + "faire sauter les légumes à la poêle", + "saisir rapidement à feu vif en remuant sans cesse", + "faire revenir en remuant vivement dans une poêle très chaude", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=[ + "sauté", "sauteed", "sautéed", "sauteing", "pan-fry", "pan fried", "pan-fried", + "stir-fry", "pan searing", "seared in a pan", + ], + utterances=[ + "sauté the vegetables in a pan", + "quickly sear over high heat, stirring constantly", + "cook briskly, stirring, in a very hot pan", + ], + ), + ), + TechStepTrainingEntry( + uid="blanch", + fr=TechStepLocaleTrainingData( + synonyms=["blanchir", "blanchissez", "blanchi", "blanchie", "blanchies", "blanchiment"], + utterances=[ + "faire blanchir les légumes deux minutes dans l'eau bouillante", + "plonger brièvement dans l'eau bouillante puis directement dans l'eau glacée", + "cuire très rapidement à l'eau bouillante avant de stopper la cuisson au froid", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["blanch", "blanches", "blanched", "blanching", "parboil", "parboiled", "parboiling"], + utterances=[ + "blanch the vegetables for two minutes in boiling water", + "briefly plunge into boiling water then straight into ice water", + "cook very quickly in boiling water before stopping it cold", + ], + ), + ), + TechStepTrainingEntry( + uid="marinate", + fr=TechStepLocaleTrainingData( + synonyms=[ + "mariner", "marinez", "mariné", "marinée", "marinées", "marinade", "macérer", + "macérez", "macération", "faire mariner", + ], + utterances=[ + "laisser mariner la viande toute la nuit au réfrigérateur", + "faire tremper dans la sauce plusieurs heures avant cuisson pour parfumer", + "laisser reposer dans le mélange d'huile et d'épices avant de cuisiner", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["marinate", "marinates", "marinated", "marinating", "marinade", "soak in the marinade"], + utterances=[ + "let the meat marinate overnight in the fridge", + "soak in the sauce for several hours before cooking to flavor it", + "let it sit in the oil and spice mixture before cooking", + ], + ), + ), + TechStepTrainingEntry( + uid="chop", + fr=TechStepLocaleTrainingData( + synonyms=[ + "hacher", "hachez", "haché", "hachée", "hachées", "hachis", "couper en morceaux", + "tailler en morceaux", + ], + utterances=[ + "hacher finement les oignons", + "couper en tout petits morceaux irréguliers au couteau", + "réduire les herbes en petits fragments avant de les ajouter", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["chop", "chops", "chopped", "chopping", "roughly chop", "coarsely chopped"], + utterances=[ + "finely chop the onions", + "cut into small, uneven pieces with a knife", + "break the herbs down into small bits before adding them", + ], + ), + ), + TechStepTrainingEntry( + uid="peel", + fr=TechStepLocaleTrainingData( + synonyms=[ + "éplucher", "épluchez", "épluché", "épluchée", "épluchées", "épluchage", "peler", + "pelez", "pelé", "pelée", "pelées", + ], + utterances=[ + "éplucher les pommes de terre", + "retirer la peau des carottes avec un économe", + "ôter la pelure du fruit avant de le couper", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["peel", "peels", "peeled", "peeling", "pare", "pared", "paring"], + utterances=[ + "peel the potatoes", + "remove the skin from the carrots with a peeler", + "take the skin off the fruit before cutting it", + ], + ), + ), + TechStepTrainingEntry( + uid="mince", + fr=TechStepLocaleTrainingData( + synonyms=[ + "émincer", "émincez", "émincé", "émincée", "émincées", "ciseler", "ciselez", + "ciselé", "ciselée", "ciselées", + ], + utterances=[ + "émincer l'oignon en fines lamelles", + "couper en très fines tranches régulières", + "détailler en lamelles aussi fines que possible", + "émincer les tomates en fines rondelles", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["mince", "minces", "minced", "mincing", "thinly slice", "finely mince"], + utterances=[ + "mince the onion into thin strips", + "cut into very thin, even slices", + "slice into strips as thin as possible", + ], + ), + ), + TechStepTrainingEntry( + uid="mix", + fr=TechStepLocaleTrainingData( + synonyms=[ + "mélanger", "mélangez", "mélangé", "mélangée", "mélangées", "mélange", "brasser", + "brassez", "amalgamer", "amalgamez", + ], + utterances=[ + "mélanger tous les ingrédients dans un saladier", + "combiner le sucre et la farine ensemble", + "remuer jusqu'à obtenir une préparation homogène", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=[ + "mix", "mixes", "mixed", "mixing", "combine", "combined", "blend", "blended", + "blending", "stir together", + ], + utterances=[ + "mix all the ingredients in a bowl", + "combine the sugar and flour together", + "stir until the mixture is smooth and even", + ], + ), + ), + TechStepTrainingEntry( + uid="whisk", + fr=TechStepLocaleTrainingData( + synonyms=[ + "fouetter", "fouettez", "fouetté", "fouettée", "fouettées", "au fouet", + "battre au fouet", "monter au fouet", + ], + utterances=[ + "fouetter les œufs et le sucre", + "battre vigoureusement au fouet jusqu'à ce que ça blanchisse", + "travailler énergiquement pour incorporer de l'air au mélange", + "fouetter les blancs en neige", + "fouetter les blancs en neige jusqu'à ce qu'ils soient fermes", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["whisk", "whisks", "whisked", "whisking", "beat", "whip", "whipped", "whipping"], + utterances=[ + "whisk the eggs and sugar", + "beat vigorously with a whisk until pale", + "work it briskly to whip air into the mixture", + "whisk the egg whites until stiff peaks form", + ], + ), + ), + TechStepTrainingEntry( + uid="foldIn", + fr=TechStepLocaleTrainingData( + synonyms=[ + "incorporer", "incorporez", "incorporé", "incorporée", "incorporées", + "mélanger délicatement", + ], + utterances=[ + "incorporer délicatement les blancs en neige", + "ajouter en soulevant doucement la masse pour ne pas casser les bulles", + "mélanger tout doucement de bas en haut pour garder l'air emprisonné", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["fold in", "folds in", "folded in", "folding in", "gently fold", "fold gently"], + utterances=[ + "gently fold in the beaten egg whites", + "add by gently lifting the batter so you don't knock the air out", + "very gently stir from the bottom up to keep the air trapped in", + ], + ), + ), + TechStepTrainingEntry( + uid="setAside", + fr=TechStepLocaleTrainingData( + synonyms=[ + "réserver", "réservez", "réservé", "réservée", "réservées", "mettre de côté", + "laisser de côté", + ], + utterances=[ + "réserver au frais en attendant", + "mettre de côté pour plus tard", + "laisser attendre sur le plan de travail pendant la préparation du reste", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["set aside", "sets aside", "setting aside", "set it aside", "reserve", "reserved"], + utterances=[ + "set aside in the fridge for now", + "put it aside for later", + "let it wait on the counter while you prepare the rest", + ], + ), + ), + TechStepTrainingEntry( + uid="season", + fr=TechStepLocaleTrainingData( + synonyms=[ + "assaisonner", "assaisonnez", "assaisonné", "assaisonnée", "assaisonnement", + "relever", "relevez", "épicer", "épicez", + ], + utterances=[ + "assaisonner avec du sel et du poivre", + "rectifier le goût en ajoutant des épices", + "ajouter du sel selon votre goût avant de servir", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["season", "seasons", "seasoned", "seasoning", "spice it up", "add seasoning"], + utterances=[ + "season with salt and pepper", + "adjust the taste by adding spices", + "add salt to taste before serving", + ], + ), + ), + TechStepTrainingEntry( + uid="drain", + fr=TechStepLocaleTrainingData( + synonyms=[ + "égoutter", "égouttez", "égoutté", "égouttée", "égouttées", "essorer", "essorez", + "essoré", "essorée", + ], + utterances=[ + "égoutter les pâtes dans une passoire", + "verser dans une passoire pour retirer l'eau de cuisson", + "laisser l'excédent d'eau s'écouler avant de servir", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["drain", "drains", "drained", "draining", "strain", "strained", "straining"], + utterances=[ + "drain the pasta in a colander", + "pour into a colander to remove the cooking water", + "let the excess water run off before serving", + ], + ), + ), + TechStepTrainingEntry( + uid="brown", + fr=TechStepLocaleTrainingData( + synonyms=[ + "faire revenir", "faites revenir", "faire dorer", "faites dorer", "colorer", + "colorez", "faire colorer", + ], + utterances=[ + "faire revenir les oignons dans l'huile chaude", + "faire dorer la viande sur toutes les faces", + "saisir jusqu'à ce que la surface prenne une belle couleur caramel", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["browned", "browning"], + utterances=[ + "brown the onions in hot oil", + "brown the meat on every side", + "sear until the surface turns a deep caramel color", + ], + ), + ), + TechStepTrainingEntry( + uid="rest", + fr=TechStepLocaleTrainingData( + synonyms=["reposer", "laisser reposer", "laissez reposer", "temps de repos"], + utterances=[ + "laisser reposer la pâte trente minutes", + "laisser la viande se détendre hors du four avant de la découper", + "attendre quelques minutes avant de servir pour que les jus se répartissent", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["let it rest", "let them rest", "resting for", "rested for", "resting time"], + utterances=[ + "let the dough rest for thirty minutes", + "let the meat relax outside the oven before carving it", + "wait a few minutes before serving so the juices redistribute", + ], + ), + ), + TechStepTrainingEntry( + uid="preheat", + fr=TechStepLocaleTrainingData( + synonyms=[ + "préchauffer", "préchauffez", "préchauffé", "préchauffée", "poêle chaude", + "préchauffage", + ], + utterances=[ + "préchauffer le four à 180 degrés", + "mettre le four à chauffer avant d'y placer le plat", + "allumer le four à l'avance pour qu'il soit à température", + "préchauffer la poêle avant d'y verser l'huile", + "faire chauffer la poêle à vide quelques minutes", + "mettre la poêle vide sur feu vif avant d'ajouter quoi que ce soit", + "mettre la poêle sur feu vif", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["preheat", "preheats", "preheated", "preheating", "hot pan"], + utterances=[ + "preheat the oven to 180 degrees", + "turn the oven on to heat up before putting the dish in", + "switch the oven on ahead of time so it's up to temperature", + "preheat the pan before adding the oil", + "heat the empty pan for a few minutes first", + ], + ), + ), + TechStepTrainingEntry( + uid="bake", + fr=TechStepLocaleTrainingData( + synonyms=["cuire au four", "cuisson au four", "enfourner", "enfournez", "au four", "enfourné", "enfournée"], + utterances=[ + "enfourner pendant quarante-cinq minutes", + "mettre au four jusqu'à ce que ce soit doré", + "cuire dans le four préchauffé jusqu'à ce que la surface soit ferme", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["bake", "bakes", "baked", "baking", "in the oven", "oven-baked"], + utterances=[ + "bake for forty-five minutes", + "put it in the oven until golden", + "cook in the preheated oven until the surface is firm", + ], + ), + ), + TechStepTrainingEntry( + uid="plate", + fr=TechStepLocaleTrainingData( + synonyms=["dresser", "dressez", "dressage", "disposer dans l'assiette"], + utterances=[ + "dresser harmonieusement dans les assiettes", + "disposer joliment sur l'assiette avant de servir", + "présenter avec soin au centre de l'assiette", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["plate", "plates", "plated", "plating"], + utterances=[ + "plate it up nicely", + "arrange it neatly on the plate before serving", + "present it carefully in the center of the plate", + ], + ), + ), + TechStepTrainingEntry( + uid="coat", + fr=TechStepLocaleTrainingData( + synonyms=[ + "napper", "nappez", "nappé", "nappée", "nappées", "nappage", "enrober", "enrobez", + "enrobé", "enrobée", "enrobées", + ], + utterances=[ + "napper le gâteau de chocolat fondu", + "recouvrir uniformément d'une fine couche de sauce", + "verser la sauce par-dessus pour bien enrober", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["coat", "coats", "coated", "coating", "dredge", "dredged", "dredging"], + utterances=[ + "coat the cake with melted chocolate", + "cover evenly with a thin layer of sauce", + "pour the sauce over it so it's well covered", + ], + ), + ), + # ------------------------------------------------------------------ + # Lexique ajouté (48 nouvelles techniques) + # ------------------------------------------------------------------ + TechStepTrainingEntry( + uid="baste", + fr=TechStepLocaleTrainingData( + synonyms=["arroser", "arrosez", "arrosé", "arrosée", "arrosées", "arrosage"], + utterances=[ + "arroser la volaille avec son jus de cuisson", + "arroser régulièrement le rôti pendant la cuisson", + "verser le jus de cuisson sur la viande toutes les dix minutes", + "napper la pièce de viande avec le beurre fondu pendant qu'elle cuit", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["baste", "bastes", "basted", "basting"], + utterances=[ + "baste the poultry with its cooking juices", + "baste the roast regularly while it cooks", + "spoon the pan juices over the meat every ten minutes", + "brush the meat with melted butter while it cooks", + ], + ), + ), + TechStepTrainingEntry( + uid="appertize", + fr=TechStepLocaleTrainingData( + synonyms=[ + "appertiser", "appertisez", "appertisé", "appertisée", "appertisation", + "stériliser", "stérilisez", "stérilisé", "mise en conserve", + ], + utterances=[ + "stériliser les bocaux avant de les fermer hermétiquement", + "appertiser les légumes pour les conserver plusieurs mois", + "faire chauffer les conserves fermées pour les stériliser", + "mettre en conserve dans des bocaux hermétiques après stérilisation", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["can", "canned", "canning", "sterilize the jars", "appertize", "appertization"], + utterances=[ + "sterilize the jars before sealing them", + "can the vegetables to preserve them for months", + "heat the sealed jars to sterilize them", + "preserve in airtight jars after sterilizing", + ], + ), + ), + TechStepTrainingEntry( + uid="whiskPale", + fr=TechStepLocaleTrainingData( + # Volontairement des phrases, pas le mot nu "blanchir" — sinon + # collision directe avec `blanch` ("blanchir un légume"), un + # homonyme sans rapport. `filter_spans` retient la phrase la plus + # longue quand les deux se chevauchent (voir la note de tête de + # fichier). + synonyms=[ + "blanchir les jaunes", "blanchir le jaune d'œuf", "blanchir les jaunes avec le sucre", + "faire blanchir les œufs et le sucre", "fouetter les jaunes jusqu'à blanchiment", + ], + utterances=[ + "blanchir les jaunes d'œufs avec le sucre jusqu'à ce que le mélange épaississe", + "fouetter énergiquement les jaunes et le sucre jusqu'à ce que la préparation blanchisse", + "battre le mélange jusqu'à ce qu'il devienne mousseux et clair", + "travailler les jaunes et le sucre au fouet jusqu'à obtenir un ruban", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["whisk until pale", "whisk the yolks and sugar", "beat until pale and fluffy", "ribbon stage"], + utterances=[ + "whisk the egg yolks with the sugar until the mixture turns pale", + "beat vigorously until the mixture becomes light and fluffy", + "whip until foamy and pale in color", + "work the yolks and sugar with a whisk until it reaches the ribbon stage", + ], + ), + ), + TechStepTrainingEntry( + uid="goldenBrown", + fr=TechStepLocaleTrainingData( + synonyms=["blondir", "blondissez", "blondi", "blondie", "faire blondir", "légèrement doré"], + utterances=[ + "faire blondir les oignons dans le beurre", + "laisser légèrement dorer sans colorer fortement", + "cuire doucement jusqu'à ce que ce soit juste doré", + "faire blondir le roux avant d'ajouter le liquide", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["cook until golden", "lightly brown", "blonde the onions", "until golden but not browned"], + utterances=[ + "cook the onions until lightly golden", + "let it turn golden without browning too much", + "cook gently until just golden", + "cook the roux until lightly golden before adding the liquid", + ], + ), + ), + TechStepTrainingEntry( + uid="braise", + fr=TechStepLocaleTrainingData( + synonyms=["braiser", "braisez", "braisé", "braisée", "braisées", "à l'étuvée en cocotte"], + utterances=[ + "braiser la viande à couvert pendant deux heures", + "laisser mijoter dans une cocotte fermée avec un fond de sauce", + "cuire à feu doux et à couvert dans une cocotte épaisse", + "faire cuire lentement dans son jus dans une cocotte fermée", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["braise", "braises", "braised", "braising"], + utterances=[ + "braise the meat covered for two hours", + "let it simmer in a closed pot with a little sauce", + "cook slowly, covered, in a heavy pot", + "cook it slowly in its own juices in a covered pot", + ], + ), + ), + TechStepTrainingEntry( + uid="truss", + fr=TechStepLocaleTrainingData( + synonyms=["brider", "bridez", "bridé", "bridée", "bridées", "bridage", "ficeler la volaille"], + utterances=[ + "brider la volaille avant de l'enfourner", + "ficeler les pattes et les ailes pour maintenir la forme", + "attacher la volaille avec de la ficelle de cuisine", + "maintenir les membres avec de la ficelle avant cuisson", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["truss", "trusses", "trussed", "trussing", "tie up the poultry"], + utterances=[ + "truss the poultry before putting it in the oven", + "tie the legs and wings to keep its shape", + "tie up the bird with kitchen twine", + "secure the limbs with string before cooking", + ], + ), + ), + TechStepTrainingEntry( + uid="caramelize", + fr=TechStepLocaleTrainingData( + synonyms=["caraméliser", "caramélisez", "caramélisé", "caramélisée", "caramélisées"], + utterances=[ + "caraméliser le sucre à sec dans une casserole", + "laisser les sucs caraméliser au fond de la cocotte", + "faire caraméliser les fruits dans le beurre et le sucre", + "napper le moule de caramel avant d'y verser la préparation", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["caramelize", "caramelizes", "caramelized", "caramelizing"], + utterances=[ + "caramelize the sugar dry in a saucepan", + "let the juices caramelize at the bottom of the pot", + "caramelize the fruit in butter and sugar", + "coat the mold with caramel before pouring in the mixture", + ], + ), + ), + TechStepTrainingEntry( + uid="score", + fr=TechStepLocaleTrainingData( + synonyms=["cerner", "cernez", "cerné", "cernée", "inciser la peau", "entailler légèrement"], + utterances=[ + "cerner la peau du fruit avant de le peler", + "inciser légèrement la peau avec la pointe d'un couteau", + "entailler la peau tout autour pour faciliter l'épluchage", + "marquer la pâte à l'emporte-pièce avant la cuisson", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["score", "scores", "scored", "scoring", "score the skin"], + utterances=[ + "score the skin of the fruit before peeling it", + "lightly cut the skin with the tip of a knife", + "score around the skin to make peeling easier", + "mark the dough with a cutter before baking", + ], + ), + ), + TechStepTrainingEntry( + uid="lineMold", + fr=TechStepLocaleTrainingData( + synonyms=["chemiser", "chemisez", "chemisé", "chemisée", "tapisser le moule"], + utterances=[ + "chemiser le moule avec du papier sulfurisé", + "tapisser le fond du moule de beurre et de farine", + "recouvrir les parois du moule de caramel avant de verser la préparation", + "beurrer et fariner le moule pour faciliter le démoulage", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["line the mold", "line the tin", "grease and flour the mold"], + utterances=[ + "line the mold with parchment paper", + "line the bottom of the mold with butter and flour", + "coat the sides of the mold with caramel before pouring in the mixture", + "butter and flour the mold to make unmolding easier", + ], + ), + ), + TechStepTrainingEntry( + uid="clarify", + fr=TechStepLocaleTrainingData( + synonyms=["clarifier", "clarifiez", "clarifié", "clarifiée", "beurre clarifié"], + utterances=[ + "clarifier le beurre fondu pour retirer le petit-lait", + "filtrer le bouillon pour le débarrasser de ses impuretés", + "séparer le blanc du jaune pour clarifier l'œuf", + "passer le jus au chinois pour obtenir un liquide clair", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["clarify", "clarifies", "clarified", "clarifying", "clarified butter"], + utterances=[ + "clarify the melted butter to remove the milk solids", + "strain the stock to remove any impurities", + "separate the white from the yolk to clarify the egg", + "strain the liquid through a fine sieve until clear", + ], + ), + ), + TechStepTrainingEntry( + uid="compote", + fr=TechStepLocaleTrainingData( + synonyms=["compoter", "compotez", "compoté", "compotée", "cuire en compote"], + utterances=[ + "laisser compoter les fruits à feu très doux", + "cuire longuement à couvert jusqu'à obtenir une texture de compote", + "laisser réduire doucement jusqu'à ce que les fruits s'effondrent", + "mijoter très longtemps à feu doux pour obtenir une marmelade", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["stew down", "compote", "cook down into a compote"], + utterances=[ + "let the fruit stew down over very low heat", + "cook covered for a long time until it reaches a compote texture", + "let it reduce slowly until the fruit breaks down", + "simmer for a long time over low heat until jammy", + ], + ), + ), + TechStepTrainingEntry( + uid="concasse", + fr=TechStepLocaleTrainingData( + synonyms=["concasser", "concassez", "concassé", "concassée", "concassées", "hacher grossièrement"], + utterances=[ + "concasser grossièrement les tomates", + "écraser les fruits secs au couteau", + "broyer grossièrement les épices au pilon", + "hacher très grossièrement les herbes avant de les ajouter", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["coarsely chop", "crush", "concasse", "roughly crush"], + utterances=[ + "coarsely chop the tomatoes", + "crush the nuts with a knife", + "roughly crush the spices with a mortar and pestle", + "very roughly chop the herbs before adding them", + ], + ), + ), + TechStepTrainingEntry( + uid="confit", + fr=TechStepLocaleTrainingData( + synonyms=["confire", "confit", "confite", "confites", "confisez", "cuisson au confit"], + utterances=[ + "faire confire les cuisses de canard dans leur graisse", + "laisser confire longuement à basse température", + "cuire doucement immergé dans la graisse pendant plusieurs heures", + "conserver les fruits en les confisant dans le sucre", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["confit", "confited", "confiting", "cook confit-style"], + utterances=[ + "confit the duck legs in their own fat", + "let it confit slowly at low temperature", + "cook it gently submerged in fat for several hours", + "preserve the fruit by confiting it in sugar", + ], + ), + ), + TechStepTrainingEntry( + uid="julienne", + fr=TechStepLocaleTrainingData( + synonyms=["julienne", "en julienne", "tailler en julienne", "couper en julienne"], + utterances=[ + "couper les carottes en julienne", + "tailler les légumes en fins bâtonnets", + "détailler en julienne avant de faire sauter", + "couper en fines lanières de trois à cinq centimètres", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["julienne", "cut into julienne", "julienne strips"], + utterances=[ + "cut the carrots into julienne", + "cut the vegetables into thin matchsticks", + "julienne the vegetables before stir-frying", + "cut into thin strips about two inches long", + ], + ), + ), + TechStepTrainingEntry( + uid="brunoise", + fr=TechStepLocaleTrainingData( + synonyms=["brunoise", "en brunoise", "tailler en brunoise", "couper en brunoise"], + utterances=[ + "couper les légumes en brunoise", + "tailler en tout petits dés réguliers", + "détailler en minuscules cubes après avoir taillé des tranches fines", + "couper en dés très fins pour la garniture", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["brunoise", "cut into brunoise", "fine dice"], + utterances=[ + "cut the vegetables into brunoise", + "cut into very small even dice", + "dice into tiny cubes after slicing thinly", + "finely dice for the garnish", + ], + ), + ), + TechStepTrainingEntry( + uid="mirepoix", + fr=TechStepLocaleTrainingData( + synonyms=["mirepoix", "en mirepoix", "tailler en mirepoix", "couper en mirepoix"], + utterances=[ + "couper les carottes et les oignons en mirepoix", + "tailler les légumes en gros dés pour le fond de sauce", + "détailler en cubes d'un centimètre pour la garniture aromatique", + "couper en gros dés irréguliers pour parfumer le bouillon", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["mirepoix", "cut into mirepoix", "large dice for a stock"], + utterances=[ + "cut the carrots and onions into mirepoix", + "cut the vegetables into large dice for the base", + "dice into one-centimeter cubes for the aromatic base", + "cut into large rough dice to flavor the stock", + ], + ), + ), + TechStepTrainingEntry( + uid="paysanne", + fr=TechStepLocaleTrainingData( + synonyms=["paysanne", "en paysanne", "tailler en paysanne", "couper en paysanne"], + utterances=[ + "couper les légumes en paysanne", + "tailler en fins triangles réguliers", + "détailler en tranches triangulaires avant de faire suer", + "couper en losanges fins pour le potage", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["paysanne cut", "cut into paysanne", "thin triangular cut"], + utterances=[ + "cut the vegetables paysanne-style", + "cut into thin, even triangles", + "cut into triangular slices before sweating", + "cut into thin diamonds for the soup", + ], + ), + ), + TechStepTrainingEntry( + uid="blindBake", + fr=TechStepLocaleTrainingData( + synonyms=["cuire à blanc", "cuisson à blanc", "précuire le fond de tarte"], + utterances=[ + "cuire le fond de tarte à blanc avant de le garnir", + "précuire la pâte à vide avec des poids de cuisson", + "faire cuire la pâte seule quelques minutes avant d'ajouter la garniture", + "enfourner le fond de tarte vide recouvert de billes de cuisson", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["blind bake", "blind-baked", "blind baking", "pre-bake the crust"], + utterances=[ + "blind bake the tart shell before filling it", + "pre-bake the empty crust with baking weights", + "bake the crust alone for a few minutes before adding the filling", + "bake the empty tart shell topped with baking beans", + ], + ), + ), + TechStepTrainingEntry( + uid="bainMarie", + fr=TechStepLocaleTrainingData( + synonyms=["bain-marie", "au bain-marie", "cuisson au bain-marie"], + utterances=[ + "cuire la crème au bain-marie", + "placer le récipient dans un fond d'eau chaude pour une cuisson douce", + "faire chauffer doucement au bain-marie pour ne pas le faire tourner", + "réchauffer la sauce au bain-marie sans qu'elle bouille", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["bain-marie", "water bath", "in a water bath", "double boiler"], + utterances=[ + "cook the custard in a bain-marie", + "place the container in a pan of hot water for gentle cooking", + "warm it gently over a water bath so it doesn't split", + "reheat the sauce in a double boiler without boiling it", + ], + ), + ), + TechStepTrainingEntry( + uid="smother", + fr=TechStepLocaleTrainingData( + synonyms=["étouffée", "à l'étouffée", "étuver", "étuvez", "étuvé", "cuisson à l'étuvée"], + utterances=[ + "cuire les légumes à l'étouffée dans un corps gras", + "laisser cuire à couvert à très basse température", + "étuver doucement pendant une longue durée", + "cuire lentement à feu très doux dans un récipient fermé", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["smother", "smothered", "cook covered on low heat", "stew gently covered"], + utterances=[ + "smother the vegetables in fat, covered", + "let it cook covered at very low temperature", + "cook it gently over a long time, covered", + "cook slowly over very low heat in a closed pot", + ], + ), + ), + TechStepTrainingEntry( + uid="decant", + fr=TechStepLocaleTrainingData( + synonyms=["décanter", "décantez", "décanté", "décantée", "décantation"], + utterances=[ + "laisser décanter le jus avant de le transvaser", + "transvaser délicatement en laissant le dépôt au fond", + "laisser reposer puis verser doucement dans un autre récipient", + "séparer le liquide clair du dépôt qui s'est formé au fond", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["decant", "decants", "decanted", "decanting"], + utterances=[ + "let the juice decant before pouring it off", + "carefully pour it off, leaving the sediment behind", + "let it settle then gently pour into another container", + "separate the clear liquid from the sediment that formed at the bottom", + ], + ), + ), + TechStepTrainingEntry( + uid="dilute", + fr=TechStepLocaleTrainingData( + synonyms=["délayer", "délayez", "délayé", "délayée", "diluer dans un liquide"], + utterances=[ + "délayer la farine dans un peu de lait froid", + "diluer la maïzena dans de l'eau avant de l'incorporer", + "mélanger la poudre avec un peu de liquide pour la dissoudre", + "incorporer progressivement le liquide en délayant bien", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["dilute", "dilutes", "diluted", "diluting", "mix into a liquid"], + utterances=[ + "dilute the flour in a little cold milk", + "dilute the cornstarch in water before adding it", + "mix the powder with a little liquid to dissolve it", + "gradually stir in the liquid, mixing well as you go", + ], + ), + ), + TechStepTrainingEntry( + uid="punchDown", + fr=TechStepLocaleTrainingData( + synonyms=["dégazer", "dégazez", "dégazé", "dégazage", "chasser l'air de la pâte"], + utterances=[ + "dégazer la pâte après la première pousse", + "pétrir légèrement pour chasser l'air de la pâte", + "aplatir la pâte au rouleau pour en retirer le gaz", + "presser la pâte pour en faire sortir les bulles d'air", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["punch down", "punch down the dough", "knock back", "deflate the dough"], + utterances=[ + "punch down the dough after the first rise", + "gently knead to knock the air out of the dough", + "flatten the dough with a rolling pin to release the gas", + "press the dough to push out the air bubbles", + ], + ), + ), + TechStepTrainingEntry( + uid="disgorge", + fr=TechStepLocaleTrainingData( + synonyms=["dégorger", "dégorgez", "dégorgé", "dégorgée", "faire dégorger"], + utterances=[ + "faire dégorger les concombres avec du sel", + "saler les légumes pour qu'ils perdent leur eau", + "laisser tremper la viande dans l'eau froide vinaigrée", + "laisser reposer avec du sel pour évacuer l'excès d'humidité", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["disgorge", "purge", "salt and drain", "draw out the moisture"], + utterances=[ + "salt the cucumbers to draw out their moisture", + "salt the vegetables so they release their water", + "soak the meat in cold vinegared water", + "let it sit with salt to remove excess moisture", + ], + ), + ), + TechStepTrainingEntry( + uid="loosen", + fr=TechStepLocaleTrainingData( + synonyms=["détendre", "détendez", "détendu", "détendue", "assouplir la préparation"], + utterances=[ + "détendre la pâte avec un peu de lait", + "ajouter un peu de crème pour assouplir la sauce", + "incorporer un œuf battu pour rendre la pâte plus fluide", + "allonger la préparation avec un peu de liquide", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["loosen", "loosens", "loosened", "loosening", "thin out the batter"], + utterances=[ + "loosen the batter with a little milk", + "add a little cream to loosen the sauce", + "stir in a beaten egg to make the batter more fluid", + "thin out the mixture with a little liquid", + ], + ), + ), + TechStepTrainingEntry( + uid="shellEgg", + fr=TechStepLocaleTrainingData( + synonyms=["écaler", "écalez", "écalé", "écalée", "retirer la coquille de l'œuf"], + utterances=[ + "écaler les œufs durs sous l'eau froide", + "retirer délicatement la coquille de l'œuf cuit", + "enlever la coquille des œufs mollets", + "peler l'œuf dur après l'avoir refroidi", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["shell the egg", "shelled the egg", "remove the eggshell"], + utterances=[ + "shell the hard-boiled eggs under cold water", + "gently remove the shell from the cooked egg", + "remove the shell from the soft-boiled eggs", + "peel the hard-boiled egg after cooling it", + ], + ), + ), + TechStepTrainingEntry( + uid="scald", + fr=TechStepLocaleTrainingData( + synonyms=["échauder", "échaudez", "échaudé", "échaudée", "ébouillanter brièvement"], + utterances=[ + "échauder les tomates pour retirer la peau facilement", + "plonger brièvement dans l'eau bouillante avant de peler", + "ébouillanter quelques secondes pour faciliter l'épluchage", + "tremper rapidement dans l'eau chaude pour détacher la peau", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["scald", "scalds", "scalded", "scalding", "briefly blanch to peel"], + utterances=[ + "scald the tomatoes to easily remove the skin", + "briefly dip in boiling water before peeling", + "scald for a few seconds to make peeling easier", + "quickly dip in hot water to loosen the skin", + ], + ), + ), + TechStepTrainingEntry( + uid="pod", + fr=TechStepLocaleTrainingData( + synonyms=["écosser", "écossez", "écossé", "écossée", "retirer la cosse"], + utterances=[ + "écosser les petits pois avant de les cuire", + "retirer la cosse des fèves fraîches", + "enlever l'enveloppe des haricots avant de les préparer", + "sortir les grains de leur cosse", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["pod", "shell the peas", "remove the pods"], + utterances=[ + "pod the peas before cooking them", + "remove the pods from the fresh fava beans", + "remove the shells from the beans before preparing them", + "take the grains out of their pods", + ], + ), + ), + TechStepTrainingEntry( + uid="emulsify", + fr=TechStepLocaleTrainingData( + synonyms=["émulsionner", "émulsionnez", "émulsionné", "émulsionnée", "monter en émulsion"], + utterances=[ + "émulsionner l'huile et le vinaigre pour la vinaigrette", + "fouetter énergiquement pour lier l'huile et l'eau", + "monter la sauce en émulsion en ajoutant l'huile petit à petit", + "mélanger vigoureusement pour obtenir un mélange homogène et lisse", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["emulsify", "emulsifies", "emulsified", "emulsifying"], + utterances=[ + "emulsify the oil and vinegar for the dressing", + "whisk vigorously to bind the oil and water together", + "build the emulsion by adding the oil little by little", + "mix vigorously until smooth and even", + ], + ), + ), + TechStepTrainingEntry( + uid="hollowOut", + fr=TechStepLocaleTrainingData( + synonyms=["évider", "évidez", "évidé", "évidée", "retirer la chair du fruit"], + utterances=[ + "évider les tomates avant de les farcir", + "creuser délicatement la courgette pour retirer la chair", + "retirer le cœur et les pépins du fruit à la cuillère", + "vider l'intérieur du légume avant de le garnir", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["hollow out", "hollowed out", "scoop out the flesh", "core out"], + utterances=[ + "hollow out the tomatoes before stuffing them", + "gently scoop out the zucchini to remove the flesh", + "remove the core and seeds from the fruit with a spoon", + "scoop out the inside of the vegetable before filling it", + ], + ), + ), + TechStepTrainingEntry( + uid="shock", + fr=TechStepLocaleTrainingData( + synonyms=["frapper", "frappez", "frappé", "frappée", "bain de glace"], + utterances=[ + "frapper les légumes dans l'eau glacée après cuisson", + "plonger immédiatement dans l'eau glacée pour stopper la cuisson", + "refroidir rapidement dans un bain d'eau et de glace", + "passer sous l'eau très froide pour préserver la couleur", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["shock", "shocked", "shock in ice water", "ice bath"], + utterances=[ + "shock the vegetables in ice water after cooking", + "plunge immediately into ice water to stop the cooking", + "cool quickly in an ice bath", + "run under very cold water to preserve the color", + ], + ), + ), + TechStepTrainingEntry( + uid="setGel", + fr=TechStepLocaleTrainingData( + synonyms=["gélifier", "gélifiez", "gélifié", "gélifiée", "prendre en gelée"], + utterances=[ + "ajouter de la gélatine pour gélifier la préparation", + "laisser prendre au réfrigérateur jusqu'à ce que ça gélifie", + "incorporer l'agar-agar pour obtenir une texture de gelée", + "laisser figer la préparation jusqu'à ce qu'elle soit ferme", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["set with gelatin", "gel", "gelled", "set into a jelly"], + utterances=[ + "add gelatin to set the mixture", + "let it set in the fridge until it gels", + "stir in the agar-agar to get a jelly-like texture", + "let the mixture firm up until set", + ], + ), + ), + TechStepTrainingEntry( + uid="glaze", + fr=TechStepLocaleTrainingData( + synonyms=["glacer", "glacez", "glacé", "glacée", "glaçage"], + utterances=[ + "glacer les carottes avec du beurre et du sucre", + "napper la pâtisserie d'un glaçage brillant", + "arroser la viande de son jus pour la faire glacer au four", + "saupoudrer de sucre glace et passer sous le grill", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["glaze", "glazes", "glazed", "glazing"], + utterances=[ + "glaze the carrots with butter and sugar", + "coat the pastry with a shiny glaze", + "baste the meat with its juices to glaze it in the oven", + "dust with powdered sugar and run under the broiler", + ], + ), + ), + TechStepTrainingEntry( + uid="thicken", + fr=TechStepLocaleTrainingData( + synonyms=["lier", "liez", "liée", "liés", "liaison de la sauce"], + utterances=[ + "lier la sauce avec un jaune d'œuf", + "épaissir le potage avec un peu de farine", + "ajouter de la crème pour donner plus de consistance à la sauce", + "incorporer la maïzena pour épaissir le jus", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["thicken", "thickens", "thickened", "thickening", "bind the sauce"], + utterances=[ + "thicken the sauce with an egg yolk", + "thicken the soup with a little flour", + "add cream to give the sauce more body", + "stir in cornstarch to thicken the juices", + ], + ), + ), + TechStepTrainingEntry( + uid="filet", + fr=TechStepLocaleTrainingData( + synonyms=["lever les filets", "faire lever", "désosser le poisson", "lever un filet"], + utterances=[ + "lever les filets du poisson à l'aide d'un couteau fin", + "faire lever la peau du poisson par le poissonnier", + "retirer les filets de la volaille en suivant l'os", + "désosser et lever les filets avant de cuisiner", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["fillet", "filleted", "filleting", "fillet the fish"], + utterances=[ + "fillet the fish with a thin knife", + "have the fishmonger skin the fish", + "remove the fillets from the poultry along the bone", + "bone out and fillet before cooking", + ], + ), + ), + TechStepTrainingEntry( + uid="proof", + fr=TechStepLocaleTrainingData( + synonyms=["laisser pousser", "faire pousser la pâte", "laisser lever", "temps de pousse"], + utterances=[ + "laisser pousser la pâte à pain une heure dans un endroit tiède", + "laisser la pâte à pizza doubler de volume", + "laisser reposer la pâte à brioche jusqu'à ce qu'elle gonfle", + "attendre que la levure fasse son effet et que la pâte lève", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["proof", "proofed", "proofing", "let the dough rise", "let it rise"], + utterances=[ + "let the bread dough rise for an hour in a warm place", + "let the pizza dough double in size", + "let the brioche dough rest until it puffs up", + "wait for the yeast to work and the dough to rise", + ], + ), + ), + TechStepTrainingEntry( + uid="peelBlanch", + fr=TechStepLocaleTrainingData( + synonyms=["monder", "mondez", "mondé", "mondée", "émonder", "émondez"], + utterances=[ + "monder les tomates en les plongeant dans l'eau bouillante", + "émonder les amandes pour retirer leur peau", + "plonger les fruits quelques secondes dans l'eau bouillante pour les peler facilement", + "peler les châtaignes après les avoir ébouillantées", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["peel by blanching", "blanch and peel", "skin after scalding"], + utterances=[ + "peel the tomatoes by dipping them in boiling water", + "blanch the almonds to remove their skins", + "dip the fruit briefly in boiling water to peel it easily", + "peel the chestnuts after scalding them", + ], + ), + ), + TechStepTrainingEntry( + uid="whipUp", + fr=TechStepLocaleTrainingData( + synonyms=["monter", "montez", "monté", "montée", "faire monter", "monter au fouet"], + utterances=[ + "monter la crème en chantilly", + "faire monter les blancs en neige ferme", + "battre au fouet électrique jusqu'à ce que le volume double", + "fouetter jusqu'à obtenir une préparation bien ferme et aérée", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["whip up", "whipped up", "build volume", "whip to stiff peaks"], + utterances=[ + "whip the cream into chantilly", + "whip the egg whites to stiff peaks", + "beat with an electric mixer until the volume doubles", + "whisk until the mixture is firm and airy", + ], + ), + ), + TechStepTrainingEntry( + uid="moisten", + fr=TechStepLocaleTrainingData( + synonyms=["mouiller", "mouillez", "mouillé", "mouillée", "mouillement"], + utterances=[ + "mouiller la préparation avec un peu de bouillon", + "ajouter de l'eau pour détendre et humidifier le mélange", + "verser un peu de lait pour réhydrater la pâte", + "incorporer un peu de liquide pour assouplir la préparation", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["moisten", "moistens", "moistened", "moistening", "add liquid"], + utterances=[ + "moisten the mixture with a little stock", + "add water to loosen and moisten the mixture", + "pour in a little milk to rehydrate the batter", + "stir in a little liquid to soften the mixture", + ], + ), + ), + TechStepTrainingEntry( + uid="pasteurize", + fr=TechStepLocaleTrainingData( + synonyms=["pasteuriser", "pasteurisez", "pasteurisé", "pasteurisée", "pasteurisation"], + utterances=[ + "pasteuriser le lait en le chauffant sans le faire bouillir", + "chauffer le jus de fruits pour éliminer les germes", + "porter le liquide à une température précise puis le refroidir brusquement", + "traiter le lait par la chaleur pour le conserver plus longtemps", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["pasteurize", "pasteurizes", "pasteurized", "pasteurizing"], + utterances=[ + "pasteurize the milk by heating it without boiling", + "heat the fruit juice to eliminate germs", + "bring the liquid to a precise temperature then cool it quickly", + "heat-treat the milk to preserve it longer", + ], + ), + ), + TechStepTrainingEntry( + uid="poach", + fr=TechStepLocaleTrainingData( + synonyms=["pocher", "pochez", "poché", "pochée", "pochées", "cuisson pochée"], + utterances=[ + "pocher les œufs dans l'eau frémissante", + "cuire le poisson à peine frémissant dans un bouillon", + "immerger la volaille dans un liquide à peine frémissant", + "laisser cuire doucement dans un fumet sans faire bouillir", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["poach", "poaches", "poached", "poaching"], + utterances=[ + "poach the eggs in simmering water", + "cook the fish in barely simmering stock", + "submerge the poultry in a barely simmering liquid", + "let it cook gently in a stock without boiling", + ], + ), + ), + TechStepTrainingEntry( + uid="reduce", + fr=TechStepLocaleTrainingData( + synonyms=["réduire", "réduisez", "réduit", "réduite", "faire réduire", "réduction"], + utterances=[ + "faire réduire la sauce de moitié", + "laisser réduire à feu vif pour concentrer les saveurs", + "augmenter le feu pour évaporer une partie du liquide", + "laisser mijoter à découvert jusqu'à ce que le jus épaississe", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["reduce", "reduces", "reduced", "reducing", "reduction"], + utterances=[ + "reduce the sauce by half", + "let it reduce over high heat to concentrate the flavors", + "increase the heat to evaporate some of the liquid", + "let it simmer uncovered until the liquid thickens", + ], + ), + ), + TechStepTrainingEntry( + uid="rubIn", + fr=TechStepLocaleTrainingData( + synonyms=["sabler", "sablez", "sablé", "sablée", "pâte sablée", "sabler la pâte"], + utterances=[ + "sabler la farine et le beurre du bout des doigts", + "malaxer rapidement pour obtenir une texture sableuse", + "frotter le beurre et la farine entre les doigts jusqu'à obtenir une texture friable", + "travailler la pâte sans la chauffer pour la rendre poudreuse", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["rub in", "rubbed in", "rub the butter into the flour", "sandy texture"], + utterances=[ + "rub the flour and butter between your fingertips", + "quickly work it into a sandy texture", + "rub the butter into the flour until it looks like breadcrumbs", + "work the dough without warming it so it stays crumbly", + ], + ), + ), + TechStepTrainingEntry( + uid="dustWithFlour", + fr=TechStepLocaleTrainingData( + synonyms=["singer", "singez", "singé", "singée", "saupoudrer de farine dans le corps gras"], + utterances=[ + "singer les légumes avec une cuillère de farine", + "saupoudrer de farine et laisser cuire quelques minutes avant de mouiller", + "ajouter la farine sur les aliments dorés et laisser cuire un instant", + "fariner légèrement la préparation avant d'ajouter le liquide", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["dust with flour", "stir in flour", "sprinkle flour over the fat"], + utterances=[ + "dust the vegetables with a spoonful of flour", + "sprinkle with flour and cook a few minutes before adding liquid", + "add the flour over the browned food and cook briefly", + "lightly flour the mixture before adding the liquid", + ], + ), + ), + TechStepTrainingEntry( + uid="sweat", + fr=TechStepLocaleTrainingData( + synonyms=["suer", "faire suer", "faites suer", "suez", "sué"], + utterances=[ + "faire suer les oignons à feu doux sans coloration", + "laisser suer les légumes émincés dans le beurre", + "cuire doucement à couvert pour faire perdre leur eau aux légumes", + "faire revenir sans coloration à feu très doux", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["sweat", "sweats", "sweated", "sweating"], + utterances=[ + "sweat the onions over low heat without browning", + "let the sliced vegetables sweat in the butter", + "cook gently, covered, to draw the water out of the vegetables", + "cook without browning over very low heat", + ], + ), + ), + TechStepTrainingEntry( + uid="sift", + fr=TechStepLocaleTrainingData( + synonyms=["tamiser", "tamisez", "tamisé", "tamisée", "passer au tamis"], + utterances=[ + "tamiser la farine avant de l'incorporer", + "passer le sucre glace au tamis pour retirer les grumeaux", + "faire passer la poudre d'amande à travers une passoire fine", + "filtrer la farine pour obtenir une texture fine et homogène", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["sift", "sifts", "sifted", "sifting"], + utterances=[ + "sift the flour before adding it", + "sift the powdered sugar to remove any lumps", + "pass the almond flour through a fine sieve", + "strain the flour to get a fine, even texture", + ], + ), + ), + TechStepTrainingEntry( + uid="toast", + fr=TechStepLocaleTrainingData( + synonyms=["torréfier", "torréfiez", "torréfié", "torréfiée", "torréfaction"], + utterances=[ + "torréfier les grains de café à la poêle", + "faire griller les fruits secs à sec pour développer leur arôme", + "passer les épices quelques minutes dans une poêle chaude sans matière grasse", + "faire dorer les amandes à sec au four", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["toast", "toasts", "toasted", "toasting", "dry-roast"], + utterances=[ + "toast the coffee beans in a pan", + "dry-toast the nuts to develop their flavor", + "toast the spices for a few minutes in a hot, dry pan", + "dry-roast the almonds in the oven", + ], + ), + ), + TechStepTrainingEntry( + uid="zest", + fr=TechStepLocaleTrainingData( + synonyms=["zester", "zestez", "zesté", "zestée", "prélever le zeste"], + utterances=[ + "zester le citron avant de le presser", + "prélever le zeste de l'orange à l'aide d'une râpe fine", + "râper finement la peau de l'agrume sans toucher la partie blanche", + "récupérer l'écorce colorée du citron vert pour parfumer la préparation", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["zest", "zests", "zested", "zesting"], + utterances=[ + "zest the lemon before juicing it", + "grate the zest of the orange with a fine grater", + "finely grate the citrus peel without touching the white pith", + "collect the colored peel of the lime to flavor the mixture", + ], + ), + ), +] + + +def entries_for_locale(locale: str) -> list[dict]: + """Aplati {@link TECH_STEP_TRAINING_DATA} en une liste `{uid, synonyms, + utterances}` pour une seule locale — la forme que + `LocalePipeline.train()` (via `TrainEntry`) attend. Retourne des `dict` + plutôt que `TrainEntry` directement pour ne pas faire dépendre ce module, + purement données, de `locale_pipeline.py`.""" + return [ + {"uid": entry.uid, **vars(getattr(entry, locale))} + for entry in TECH_STEP_TRAINING_DATA + if hasattr(entry, locale) + ] diff --git a/services/tech-step-intent-service/pyproject.toml b/services/tech-step-intent-service/pyproject.toml new file mode 100644 index 0000000..eb38dba --- /dev/null +++ b/services/tech-step-intent-service/pyproject.toml @@ -0,0 +1,53 @@ +[project] +name = "tech-step-intent-service" +version = "0.1.0" +description = "Microservice de détection d'intention (technique de cuisine) — remplace node-nlp côté apps/api." +requires-python = ">=3.12" +dependencies = [ + "fastapi>=0.115,<0.116", + "uvicorn[standard]>=0.32,<0.33", + "spacy>=3.8,<3.9", + # Fournit les tables de lookup ("lexeme_norm" notamment) que + # `nlp.initialize()` réclame pour l'anglais lors de l'entraînement du + # textcat (`en_core_web_md` ne les embarque pas lui-même, contrairement à + # `fr_core_news_md`) — sans ce paquet, entraîner un pipeline "en" lève + # `E955`. + "spacy-lookups-data>=1.0,<1.1", + "pydantic-settings>=2.6,<3", + # Modèles spaCy installés comme des dépendances pip normales, pinnées par + # URL de release GitHub (pas via `python -m spacy download`, qui résout + # "la dernière version compatible" et n'est pas verrouillable par + # `uv.lock`). `uv sync --frozen` installe donc déjà les modèles — aucune + # étape `spacy download` séparée, ni au Dockerfile ni en CI. Version + # 3.8.0 choisie pour matcher la ligne spaCy 3.8 pinnée ci-dessus (voir + # https://github.com/explosion/spacy-models/releases). + "fr_core_news_md @ https://github.com/explosion/spacy-models/releases/download/fr_core_news_md-3.8.0/fr_core_news_md-3.8.0-py3-none-any.whl", + "en_core_web_md @ https://github.com/explosion/spacy-models/releases/download/en_core_web_md-3.8.0/en_core_web_md-3.8.0-py3-none-any.whl", +] + +[dependency-groups] +dev = [ + "pytest>=8,<9", + # Requis par fastapi.testclient.TestClient (httpx en interne depuis FastAPI 0.110+). + "httpx>=0.27,<0.28", +] + +[tool.uv] +# Les deux modèles ci-dessus sont publiés comme des builds "any" universels +# (pas de wheel spécifique par plateforme) — rien à déclarer de plus ici, +# contrairement à un paquet avec des extras natifs par OS/arch. + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["intent_service"] + +[tool.hatch.metadata] +# Requis par hatchling pour accepter des dépendances pinnées par URL directe +# (les wheels de modèles spaCy ci-dessus) plutôt qu'un nom+version résolu +# depuis un index PyPI — voir la note sur `pyproject.toml` dans le plan de +# migration pour pourquoi ces modèles sont déclarés ainsi plutôt que via +# `python -m spacy download`. +allow-direct-references = true diff --git a/services/tech-step-intent-service/tests/conftest.py b/services/tech-step-intent-service/tests/conftest.py new file mode 100644 index 0000000..6b17b7d --- /dev/null +++ b/services/tech-step-intent-service/tests/conftest.py @@ -0,0 +1,31 @@ +"""`Settings` (`intent_service/config.py`) lève dès l'import si +`INTENT_SERVICE_SECRET` est absent — cette variable doit donc être définie +avant le tout premier `import intent_service...` de la session pytest. +`conftest.py` est chargé par pytest avant la collecte des modules de test, +donc avant que `test_routes_process.py`/`test_security.py` n'importent +`intent_service.main`. +""" + +import os + +os.environ.setdefault("INTENT_SERVICE_SECRET", "pytest-only-secret-not-used-anywhere-else-32ch") + +import pytest # noqa: E402 — après le `setdefault` ci-dessus, voir le docstring. +from fastapi.testclient import TestClient # noqa: E402 + +from intent_service.main import app # noqa: E402 + + +@pytest.fixture(scope="session") +def client(): + """`TestClient(app)` utilisé comme gestionnaire de contexte déclenche le + vrai `lifespan` — puisque `main.py`'s `lifespan` entraîne maintenant + l'intégralité du vrai corpus `training_data.TECH_STEP_TRAINING_DATA` + (pas un jeu jouet, voir `PipelineRegistry.initialize`), refaire ça une + fois par fichier de test (ou pire, une fois par test) multiplierait un + entraînement non négligeable sur toute la suite pour rien — scope + "session" pour que chaque test ayant besoin d'une vraie app en cours + d'exécution partage la même instance déjà entraînée. + """ + with TestClient(app) as test_client: + yield test_client diff --git a/services/tech-step-intent-service/tests/test_locale_pipeline_entities.py b/services/tech-step-intent-service/tests/test_locale_pipeline_entities.py new file mode 100644 index 0000000..eb2169f --- /dev/null +++ b/services/tech-step-intent-service/tests/test_locale_pipeline_entities.py @@ -0,0 +1,148 @@ +"""Rejoue les cas d'offsets caractère exacts et d'insensibilité accents/casse +de `tech-step-matcher.test.ts` (`apps/api/test/recipe-matching/tech-step-matcher.test.ts`) +contre le `PhraseMatcher`/`diacritics_normalizer` de `LocalePipeline` — le +point de fidélité le plus critique de cette migration (voir le plan). Doit +être vert *avant* de brancher `apps/api` dessus. + +Ces tests entraînent un pipeline minimal (pas le corpus complet +`TECH_STEP_TRAINING_DATA`, propriété de `apps/api`) avec juste assez de +`synonyms`/`utterances` pour reproduire chaque cas — le textcat n'est pas ce +qui est vérifié ici (voir `test_locale_pipeline_intent.py`). +""" + +import pytest + +from intent_service.locale_pipeline import LocalePipeline, TrainEntry + +# Un jeu d'entrées minimal mais réaliste, reprenant les synonymes réels de +# `tech-step-training-data.ts` pour "preheat"/"melt" qui rendent les cas +# `tech-step-matcher.test.ts` exacts (voir ce fichier, lignes 155/787). +_FR_ENTRIES = [ + TrainEntry( + uid="preheat", + synonyms=["préchauffer", "poêle chaude"], + utterances=["préchauffer le four à 180 degrés", "mettre la poêle sur feu vif"], + ), + TrainEntry( + # `synonyms` deliberately includes both "fondre" (standalone) and + # "faire fondre" (containing it) — mirrors the real corpus + # (`tech-step-training-data.ts`) exactly, and is what + # `test_does_not_double_match_a_synonym_nested_in_a_longer_one` + # below exists to guard: the `PhraseMatcher` reports both as + # separate overlapping matches, `LocalePipeline.process` must + # collapse them into one. + uid="melt", + synonyms=["fondre", "fondu", "faire fondre", "faire chauffer"], + utterances=["faire fondre le beurre", "faire chauffer une noix de beurre"], + ), + TrainEntry( + uid="simmer", + synonyms=["mijoter"], + utterances=["faire mijoter à feu doux", "laisser mijoter à couvert"], + ), +] + + +@pytest.fixture(scope="module") +def fr_pipeline() -> LocalePipeline: + pipeline = LocalePipeline("fr") + pipeline.train(_FR_ENTRIES) + return pipeline + + +def test_matches_an_exact_expression(fr_pipeline: LocalePipeline): + result = fr_pipeline.process("Faire mijoter à feu doux") + assert [entity.uid for entity in result.entities] == ["simmer"] + entity = result.entities[0] + text = "Faire mijoter à feu doux" + assert text[entity.start : entity.end].lower() == "mijoter" + + +def test_is_case_and_accent_insensitive(fr_pipeline: LocalePipeline): + result = fr_pipeline.process("FAIRE MIJOTER") + assert [entity.uid for entity in result.entities] == ["simmer"] + + +def test_returns_no_entities_when_nothing_matches(fr_pipeline: LocalePipeline): + result = fr_pipeline.process("Ranger les couverts dans le tiroir") + assert result.entities == [] + + +def test_returns_empty_for_an_empty_text(fr_pipeline: LocalePipeline): + result = fr_pipeline.process("") + assert result.entities == [] + assert result.intent is None + assert result.score == 0.0 + + +def test_untrained_locale_returns_empty_without_error(): + pipeline = LocalePipeline("en") + result = pipeline.process("melt the butter") + assert result.entities == [] + assert result.intent is None + assert result.score == 0.0 + + +def test_detects_two_techniques_with_exact_tight_spans_reading_order(fr_pipeline: LocalePipeline): + text = "Préchauffer la poêle, puis faire fondre le beurre" + result = fr_pipeline.process(text) + + uids_by_start = sorted(((entity.start, entity.uid) for entity in result.entities)) + assert [uid for _, uid in uids_by_start] == ["preheat", "melt"] + + preheat_entity = next(e for e in result.entities if e.uid == "preheat") + melt_entity = next(e for e in result.entities if e.uid == "melt") + assert text[preheat_entity.start : preheat_entity.end].lower() == "préchauffer" + assert text[melt_entity.start : melt_entity.end].lower() == "faire fondre" + + +def test_does_not_double_match_a_synonym_nested_in_a_longer_one(fr_pipeline: LocalePipeline): + # Regression: "fondre" is itself a substring of "faire fondre" — both + # are registered as `melt` synonyms (like the real corpus). Without + # `filter_spans` in `LocalePipeline.process`, the `PhraseMatcher` + # reports *both* overlapping matches, producing `melt` twice in + # apps/api's final `matchTechSteps` output instead of once (caught by a + # real CI failure in `tech-step-matcher.test.ts` once this service + # replaced node-nlp). + text = "faire fondre le beurre" + result = fr_pipeline.process(text) + assert [entity.uid for entity in result.entities] == ["melt"] + entity = result.entities[0] + assert text[entity.start : entity.end] == "faire fondre" + + +def test_matches_the_classic_poele_chaude_example_with_exact_offsets(fr_pipeline: LocalePipeline): + # Le cas motivant les context spans côté apps/api (tech-step-matcher.test.ts) : + # le mot-clé de `preheat` est un groupe nominal ("poêle chaude"), pas un + # verbe. Offsets attendus IDENTIQUES à ceux du test TS d'origine : + # preheat -> [9, 21) ("poêle chaude"), melt -> [23, 37) ("faire chauffer"). + text = "Dans une poêle chaude, faire chauffer une noix de beurre" + result = fr_pipeline.process(text) + + preheat_entity = next(e for e in result.entities if e.uid == "preheat") + melt_entity = next(e for e in result.entities if e.uid == "melt") + + assert (preheat_entity.start, preheat_entity.end) == (9, 21) + assert text[preheat_entity.start : preheat_entity.end] == "poêle chaude" + + assert (melt_entity.start, melt_entity.end) == (23, 37) + assert text[melt_entity.start : melt_entity.end] == "faire chauffer" + + +def test_chop_matches_english_text_tight_span(): + pipeline = LocalePipeline("en") + pipeline.train( + [ + TrainEntry( + uid="chop", + synonyms=["chop"], + utterances=["chop the onions finely", "finely chop the garlic"], + ), + TrainEntry(uid="boil", synonyms=["boil"], utterances=["bring to the boil", "boil the water"]), + ] + ) + text = "Chop the onions finely" + result = pipeline.process(text) + chop_entity = next(e for e in result.entities if e.uid == "chop") + assert (chop_entity.start, chop_entity.end) == (0, 4) + assert text[chop_entity.start : chop_entity.end] == "Chop" diff --git a/services/tech-step-intent-service/tests/test_locale_pipeline_intent.py b/services/tech-step-intent-service/tests/test_locale_pipeline_intent.py new file mode 100644 index 0000000..b9efa03 --- /dev/null +++ b/services/tech-step-intent-service/tests/test_locale_pipeline_intent.py @@ -0,0 +1,80 @@ +"""Vérifie le round-trip entraînement -> prédiction du `textcat` (la partie +"comprendre le sens, pas juste les mots clés" du pipeline — voir le +commentaire de `tech-step-matcher.ts` côté apps/api pour la motivation +d'origine).""" + +from intent_service.locale_pipeline import LocalePipeline, TrainEntry + +_ENTRIES = [ + TrainEntry( + uid="melt", + synonyms=["faire fondre"], + utterances=[ + "faire fondre le beurre à feu doux", + "laisser fondre le beurre dans la poêle", + "jusqu'à ce que le beurre ait disparu", + "jusqu'à ce que le beurre ait complètement disparu dans la poêle", + ], + ), + TrainEntry( + uid="boil", + synonyms=["bouillir"], + utterances=[ + "porter l'eau à ébullition", + "faire bouillir l'eau salée", + "laisser bouillir quelques minutes", + "porter à ébullition puis baisser le feu", + ], + ), +] + + +def test_train_returns_label_example_and_synonym_counts(): + pipeline = LocalePipeline("fr") + label_count, example_count, synonym_count = pipeline.train(_ENTRIES) + assert label_count == 2 + # `example_count` couvre les utterances *et* les synonyms (voir + # LocalePipeline.train — les synonymes sont aussi des exemples + # d'entraînement pour le textcat, pas seulement pour le PhraseMatcher). + expected_examples = sum(len(entry.utterances) + len(entry.synonyms) for entry in _ENTRIES) + assert example_count == expected_examples + assert synonym_count == sum(len(entry.synonyms) for entry in _ENTRIES) + assert pipeline.is_trained is True + + +def test_classifies_a_paraphrase_never_using_the_techniques_own_verb(): + # Le cas motivant tout le pipeline (voir tech-step-matcher.ts) : aucune + # forme de "fondre" dans cette phrase, mais elle ne peut raisonnablement + # signifier que `melt` une fois le textcat entraîné sur les paraphrases + # ci-dessus. + pipeline = LocalePipeline("fr") + pipeline.train(_ENTRIES) + + result = pipeline.process("jusqu'à ce que le beurre ait disparu dans la poêle") + assert result.intent == "melt" + assert result.score > 0.5 + + +def test_empty_entries_leaves_the_pipeline_untrained(): + pipeline = LocalePipeline("fr") + pipeline.train([]) + assert pipeline.is_trained is False + result = pipeline.process("faire fondre le beurre") + assert result.intent is None + assert result.entities == [] + + +def test_retraining_replaces_the_previous_textcat_rather_than_accumulating(): + # `textcat` (exclusive_classes) exige >= 2 labels (voir la note dans + # LocalePipeline.train) — le second entraînement garde donc 2 entrées, + # mais remplace "boil" par une technique différente ("chop"), pour + # vérifier que "boil" ne peut plus jamais ressortir après coup (pas de + # fusion incrémentale — voir la doc de `LocalePipeline.train`). + pipeline = LocalePipeline("fr") + pipeline.train(_ENTRIES) + + chop_entry = TrainEntry(uid="chop", synonyms=["couper"], utterances=["couper les légumes en dés"]) + pipeline.train([_ENTRIES[0], chop_entry]) + + result = pipeline.process("porter l'eau à ébullition") + assert result.intent != "boil" diff --git a/services/tech-step-intent-service/tests/test_logging_config.py b/services/tech-step-intent-service/tests/test_logging_config.py new file mode 100644 index 0000000..fe5aa1e --- /dev/null +++ b/services/tech-step-intent-service/tests/test_logging_config.py @@ -0,0 +1,57 @@ +"""Vérifie le format des lignes de log produites par +`logging_config._JsonFormatter` — ce que `routes/process.py` et +`pipeline_registry.py` utilisent pour journaliser l'input/l'output de +chaque appel NLP et le déroulement de l'entraînement au démarrage.""" + +import json +import logging + +from intent_service.logging_config import _JsonFormatter + + +def _make_record(**extra: object) -> logging.LogRecord: + record = logging.LogRecord( + name="intent_service.routes.process", + level=logging.INFO, + pathname=__file__, + lineno=1, + msg="tech-step NLP process", + args=(), + exc_info=None, + ) + for key, value in extra.items(): + setattr(record, key, value) + return record + + +def test_formats_a_record_as_json_with_timestamp_level_and_message(): + record = _make_record() + payload = json.loads(_JsonFormatter().format(record)) + assert payload["message"] == "tech-step NLP process" + assert payload["level"] == "info" + assert "timestamp" in payload + + +def test_merges_extra_fields_into_the_top_level_payload(): + record = _make_record( + locale="fr", + text="faire fondre le beurre", + entities=[{"uid": "melt", "start": 0, "end": 12}], + intent="melt", + score=0.93, + ) + payload = json.loads(_JsonFormatter().format(record)) + assert payload["locale"] == "fr" + assert payload["text"] == "faire fondre le beurre" + assert payload["entities"] == [{"uid": "melt", "start": 0, "end": 12}] + assert payload["intent"] == "melt" + assert payload["score"] == 0.93 + + +def test_preserves_accented_characters_literally_not_escaped(): + # `ensure_ascii=False` — un `docker logs` humain doit pouvoir lire + # directement "poêle", pas "poêle". + record = _make_record(text="Dans une poêle chaude") + line = _JsonFormatter().format(record) + assert "poêle" in line + assert "\\u00ea" not in line diff --git a/services/tech-step-intent-service/tests/test_routes_process.py b/services/tech-step-intent-service/tests/test_routes_process.py new file mode 100644 index 0000000..443d698 --- /dev/null +++ b/services/tech-step-intent-service/tests/test_routes_process.py @@ -0,0 +1,51 @@ +"""Contrat JSON de `POST /v1/process` — voir `schemas.py`/`routes/process.py`. + +Le service s'entraîne désormais lui-même au démarrage sur le vrai corpus +(`training_data.TECH_STEP_TRAINING_DATA`, voir `conftest.py`'s fixture +`client` partagée) — ces tests vérifient donc le contrat HTTP contre des +phrases réelles du corpus, plus besoin d'un `POST /v1/train` préalable avec +des données jouets. +""" + +from fastapi.testclient import TestClient + +from intent_service.config import settings + +_HEADERS = {"X-Intent-Service-Secret": settings.intent_service_secret} + + +def test_process_against_an_unsupported_locale_returns_empty_result(client: TestClient): + # "de" n'a aucun modèle spaCy connu (`SUPPORTED_LOCALES`) — se comporte + # comme "jamais entraîné" côté `/v1/process`, jamais une erreur (voir + # `PipelineRegistry.process`). + response = client.post( + "/v1/process", headers=_HEADERS, json={"locale": "de", "text": "faire mijoter à feu doux"} + ) + assert response.status_code == 200 + assert response.json() == {"entities": [], "intent": None, "score": 0.0} + + +def test_process_returns_entities_and_intent_for_a_real_corpus_sentence(client: TestClient): + response = client.post( + "/v1/process", headers=_HEADERS, json={"locale": "fr", "text": "Faire mijoter à feu doux"} + ) + assert response.status_code == 200 + body = response.json() + assert body["intent"] == "simmer" + assert body["score"] > 0 + assert [entity["uid"] for entity in body["entities"]] == ["simmer"] + + +def test_process_matches_english_text_against_the_english_trained_vocabulary(client: TestClient): + response = client.post( + "/v1/process", headers=_HEADERS, json={"locale": "en", "text": "Chop the onions finely"} + ) + assert response.status_code == 200 + body = response.json() + assert [entity["uid"] for entity in body["entities"]] == ["chop"] + + +def test_process_with_blank_text_returns_empty_result(client: TestClient): + response = client.post("/v1/process", headers=_HEADERS, json={"locale": "fr", "text": " "}) + assert response.status_code == 200 + assert response.json() == {"entities": [], "intent": None, "score": 0.0} diff --git a/services/tech-step-intent-service/tests/test_security.py b/services/tech-step-intent-service/tests/test_security.py new file mode 100644 index 0000000..6b5fe6a --- /dev/null +++ b/services/tech-step-intent-service/tests/test_security.py @@ -0,0 +1,37 @@ +"""`require_valid_secret` — miroir inversé de +`require-internal-worker.test.ts` côté `apps/api`. Utilise la fixture +`client` partagée (`conftest.py`) — pas besoin d'une app entraînée +séparément juste pour tester l'authentification. +""" + +from fastapi.testclient import TestClient + +from intent_service.config import settings + + +def test_rejects_a_missing_secret(client: TestClient): + response = client.post("/v1/process", json={"locale": "fr", "text": "faire fondre"}) + assert response.status_code == 401 + + +def test_rejects_a_wrong_secret(client: TestClient): + response = client.post( + "/v1/process", + json={"locale": "fr", "text": "faire fondre"}, + headers={"X-Intent-Service-Secret": "not-the-right-secret"}, + ) + assert response.status_code == 401 + + +def test_accepts_the_configured_secret(client: TestClient): + response = client.post( + "/v1/process", + json={"locale": "fr", "text": "faire fondre"}, + headers={"X-Intent-Service-Secret": settings.intent_service_secret}, + ) + assert response.status_code == 200 + + +def test_health_requires_no_secret(client: TestClient): + response = client.get("/health") + assert response.status_code == 200 diff --git a/services/tech-step-intent-service/tests/test_text_normalization.py b/services/tech-step-intent-service/tests/test_text_normalization.py new file mode 100644 index 0000000..339c6b4 --- /dev/null +++ b/services/tech-step-intent-service/tests/test_text_normalization.py @@ -0,0 +1,21 @@ +"""Réplique les cas de `normalizeText` de `tech-step-matcher.test.ts` +(`apps/api/test/recipe-matching/tech-step-matcher.test.ts`) contre le port +Python — les deux fonctions doivent rester bit-pour-bit équivalentes.""" + +from intent_service.text_normalization import normalize_text + + +def test_lowercases_and_strips_accents(): + assert normalize_text("Déglacer AU FOUR") == "deglacer au four" + + +def test_strips_a_variety_of_diacritics_including_cedilla(): + assert normalize_text("Façon Œuf à l'Étouffée") == "facon œuf a l'etouffee" + + +def test_leaves_already_plain_text_unchanged_aside_from_casing(): + assert normalize_text("Mix everything") == "mix everything" + + +def test_returns_an_empty_string_for_an_empty_input(): + assert normalize_text("") == "" diff --git a/services/tech-step-intent-service/uv.lock b/services/tech-step-intent-service/uv.lock new file mode 100644 index 0000000..929c3db --- /dev/null +++ b/services/tech-step-intent-service/uv.lock @@ -0,0 +1,1560 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "annotated-doc" +version = "0.0.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758, upload-time = "2026-07-28T13:50:58.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + +[[package]] +name = "anyio" +version = "4.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, +] + +[[package]] +name = "blis" +version = "1.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d0/d0/d8cc8c9a4488a787e7fa430f6055e5bd1ddb22c340a751d9e901b82e2efe/blis-1.3.3.tar.gz", hash = "sha256:034d4560ff3cc43e8aa37e188451b0440e3261d989bb8a42ceee865607715ecd", size = 2644873, upload-time = "2025-11-17T12:28:30.511Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/d1/429cf0cf693d4c7dc2efed969bd474e315aab636e4a95f66c4ed7264912d/blis-1.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2a1c74e100665f8e918ebdbae2794576adf1f691680b5cdb8b29578432f623ef", size = 6929663, upload-time = "2025-11-17T12:27:44.482Z" }, + { url = "https://files.pythonhosted.org/packages/11/69/363c8df8d98b3cc97be19aad6aabb2c9c53f372490d79316bdee92d476e7/blis-1.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3f6c595185176ce021316263e1a1d636a3425b6c48366c1fd712d08d0b71849a", size = 1230939, upload-time = "2025-11-17T12:27:46.19Z" }, + { url = "https://files.pythonhosted.org/packages/96/2a/fbf65d906d823d839076c5150a6f8eb5ecbc5f9135e0b6510609bda1e6b7/blis-1.3.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d734b19fba0be7944f272dfa7b443b37c61f9476d9ab054a9ac53555ceadd2e0", size = 2818835, upload-time = "2025-11-17T12:27:48.167Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ad/58deaa3ad856dd3cc96493e40ffd2ed043d18d4d304f85a65cde1ccbf644/blis-1.3.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1ef6d6e2b599a3a2788eb6d9b443533961265aa4ec49d574ed4bb846e548dcdb", size = 11366550, upload-time = "2025-11-17T12:27:49.958Z" }, + { url = "https://files.pythonhosted.org/packages/78/82/816a7adfe1f7acc8151f01ec86ef64467a3c833932d8f19f8e06613b8a4e/blis-1.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8c888438ae99c500422d50698e3028b65caa8ebb44e24204d87fda2df64058f7", size = 3023686, upload-time = "2025-11-17T12:27:52.062Z" }, + { url = "https://files.pythonhosted.org/packages/1e/e2/0e93b865f648b5519360846669a35f28ee8f4e1d93d054f6850d8afbabde/blis-1.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8177879fd3590b5eecdd377f9deafb5dc8af6d684f065bd01553302fb3fcf9a7", size = 14250939, upload-time = "2025-11-17T12:27:53.847Z" }, + { url = "https://files.pythonhosted.org/packages/20/07/fb43edc2ff0a6a367e4a94fc39eb3b85aa1e55e24cc857af2db145ce9f0d/blis-1.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:f20f7ad69aaffd1ce14fe77de557b6df9b61e0c9e582f75a843715d836b5c8af", size = 6192759, upload-time = "2025-11-17T12:27:56.176Z" }, + { url = "https://files.pythonhosted.org/packages/e6/f7/d26e62d9be3d70473a63e0a5d30bae49c2fe138bebac224adddcdef8a7ce/blis-1.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1e647341f958421a86b028a2efe16ce19c67dba2a05f79e8f7e80b1ff45328aa", size = 6928322, upload-time = "2025-11-17T12:27:57.965Z" }, + { url = "https://files.pythonhosted.org/packages/4a/78/750d12da388f714958eb2f2fd177652323bbe7ec528365c37129edd6eb84/blis-1.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d563160f874abb78a57e346f07312c5323f7ad67b6370052b6b17087ef234a8e", size = 1229635, upload-time = "2025-11-17T12:28:00.118Z" }, + { url = "https://files.pythonhosted.org/packages/e8/36/eac4199c5b200a5f3e93cad197da8d26d909f218eb444c4f552647c95240/blis-1.3.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:30b8a5b90cb6cb81d1ada9ae05aa55fb8e70d9a0ae9db40d2401bb9c1c8f14c4", size = 2815650, upload-time = "2025-11-17T12:28:02.544Z" }, + { url = "https://files.pythonhosted.org/packages/bf/51/472e7b36a6bedb5242a9757e7486f702c3619eff76e256735d0c8b1679c6/blis-1.3.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e9f5c53b277f6ac5b3ca30bc12ebab7ea16c8f8c36b14428abb56924213dc127", size = 11359008, upload-time = "2025-11-17T12:28:04.589Z" }, + { url = "https://files.pythonhosted.org/packages/84/da/d0dfb6d6e6321ae44df0321384c32c322bd07b15740d7422727a1a49fc5d/blis-1.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6297e7616c158b305c9a8a4e47ca5fc9b0785194dd96c903b1a1591a7ca21ddf", size = 3011959, upload-time = "2025-11-17T12:28:06.862Z" }, + { url = "https://files.pythonhosted.org/packages/20/c5/2b0b5e556fa0364ed671051ea078a6d6d7b979b1cfef78d64ad3ca5f0c7f/blis-1.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3f966ca74f89f8a33e568b9a1d71992fc9a0d29a423e047f0a212643e21b5458", size = 14232456, upload-time = "2025-11-17T12:28:08.779Z" }, + { url = "https://files.pythonhosted.org/packages/31/07/4cdc81a47bf862c0b06d91f1bc6782064e8b69ac9b5d4ff51d97e4ff03da/blis-1.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:7a0fc4b237a3a453bdc3c7ab48d91439fcd2d013b665c46948d9eaf9c3e45a97", size = 6192624, upload-time = "2025-11-17T12:28:14.197Z" }, + { url = "https://files.pythonhosted.org/packages/5f/8a/80f7c68fbc24a76fc9c18522c46d6d69329c320abb18e26a707a5d874083/blis-1.3.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c3e33cfbf22a418373766816343fcfcd0556012aa3ffdf562c29cddec448a415", size = 6934081, upload-time = "2025-11-17T12:28:16.436Z" }, + { url = "https://files.pythonhosted.org/packages/e5/52/d1aa3a51a7fc299b0c89dcaa971922714f50b1202769eebbdaadd1b5cff7/blis-1.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6f165930e8d3a85c606d2003211497e28d528c7416fbfeafb6b15600963f7c9b", size = 1231486, upload-time = "2025-11-17T12:28:18.008Z" }, + { url = "https://files.pythonhosted.org/packages/99/4f/badc7bd7f74861b26c10123bba7b9d16f99cd9535ad0128780360713820f/blis-1.3.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:878d4d96d8f2c7a2459024f013f2e4e5f46d708b23437dae970d998e7bff14a0", size = 2814944, upload-time = "2025-11-17T12:28:19.654Z" }, + { url = "https://files.pythonhosted.org/packages/72/a6/f62a3bd814ca19ec7e29ac889fd354adea1217df3183e10217de51e2eb8b/blis-1.3.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f36c0ca84a05ee5d3dbaa38056c4423c1fc29948b17a7923dd2fed8967375d74", size = 11345825, upload-time = "2025-11-17T12:28:21.354Z" }, + { url = "https://files.pythonhosted.org/packages/d4/6c/671af79ee42bc4c968cae35c091ac89e8721c795bfa4639100670dc59139/blis-1.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e5a662c48cd4aad5dae1a950345df23957524f071315837a4c6feb7d3b288990", size = 3008771, upload-time = "2025-11-17T12:28:23.637Z" }, + { url = "https://files.pythonhosted.org/packages/be/92/7cd7f8490da7c98ee01557f2105885cc597217b0e7fd2eeb9e22cdd4ef23/blis-1.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9de26fbd72bac900c273b76d46f0b45b77a28eace2e01f6ac6c2239531a413bb", size = 14219213, upload-time = "2025-11-17T12:28:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/0a/de/acae8e9f9a1f4bb393d41c8265898b0f29772e38eac14e9f69d191e2c006/blis-1.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:9e5fdf4211b1972400f8ff6dafe87cb689c5d84f046b4a76b207c0bd2270faaf", size = 6324695, upload-time = "2025-11-17T12:28:28.401Z" }, +] + +[[package]] +name = "catalogue" +version = "2.0.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/38/b4/244d58127e1cdf04cf2dc7d9566f0d24ef01d5ce21811bab088ecc62b5ea/catalogue-2.0.10.tar.gz", hash = "sha256:4f56daa940913d3f09d589c191c74e5a6d51762b3a9e37dd53b7437afd6cda15", size = 19561, upload-time = "2023-09-25T06:29:24.962Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/96/d32b941a501ab566a16358d68b6eb4e4acc373fab3c3c4d7d9e649f7b4bb/catalogue-2.0.10-py3-none-any.whl", hash = "sha256:58c2de0020aa90f4a2da7dfad161bf7b3b054c86a5f09fcedc0b2b740c109a9f", size = 17325, upload-time = "2023-09-25T06:29:23.337Z" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/3f/143b048436775b0f76ac3eec145c019e8173ccc2885c8f20319b996d5e83/charset_normalizer-3.5.1.tar.gz", hash = "sha256:6117b84ea48435e5356dc737f5121485c30920ba43375fa7b434fd753df0eac3", size = 171764, upload-time = "2026-08-15T08:20:44.807Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/27/78873dc8b6a56357517b74b6bb9568b80450e7bb4f6ef7e3fa9d22aa0bd7/charset_normalizer-3.5.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:5b6d1386bf0096d26d3a863dc0a487a5b4eb9aa93cf5ba69683d29dde6b9d60f", size = 344456, upload-time = "2026-08-15T08:17:10.072Z" }, + { url = "https://files.pythonhosted.org/packages/9a/4c/be49ada26b1f0232d57aa89bbebf997a5cc2332a5616b6eca26ff680044d/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4582c27e8c889d64811987b5967fbd3ae0c823fe1fd933b543d55ac20bb475fa", size = 238530, upload-time = "2026-08-15T08:17:11.563Z" }, + { url = "https://files.pythonhosted.org/packages/76/84/6f1290fa07ae6978d3960caa3eb1b8019bf9284ab7c2297b00c099ef4250/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1d1c7a53a6c2103925cdd6d7229f8c567379f211c869793df679f2e9f738c369", size = 230200, upload-time = "2026-08-15T08:17:12.919Z" }, + { url = "https://files.pythonhosted.org/packages/e7/a0/47b18adeed31c8f16ba9700f32c1b18594cfa09f47eb672a488c273c22bf/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e6621fb2a4988d6e53eedc455e5903e2679f3967b8acb3d639f1b63c14a2e893", size = 262222, upload-time = "2026-08-15T08:17:14.571Z" }, + { url = "https://files.pythonhosted.org/packages/38/fe/341861ac118dae06f3ec0eb487488af52128f2ef2faf0b11003944d22259/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7c0c10730342b0c9b35dd1d619beb8214e520bd96a1f870f452680b238aab3e0", size = 258951, upload-time = "2026-08-15T08:17:16.158Z" }, + { url = "https://files.pythonhosted.org/packages/6f/89/bb5108dc6c3651dca963f2b0a3ba19bbcb370c94e1b6d3e0e844a58e6dca/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9af956078716df40d985fb0dfeb2c2120c5ca92ba4ff4b388acfd01cdc14d08", size = 248801, upload-time = "2026-08-15T08:17:17.683Z" }, + { url = "https://files.pythonhosted.org/packages/b1/ba/ef83ae3aca816393decfa3530976f38a79812d707b80b580ac33b83f9877/charset_normalizer-3.5.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f9f8405c2c758532c74fed975dbee57be1f31a6e865c031870c79a6ed3212ada", size = 244070, upload-time = "2026-08-15T08:17:19.191Z" }, + { url = "https://files.pythonhosted.org/packages/f6/0b/c5292a2462d69b7378ea89793bbb5b2b6fcf6f7dd6d1667f9619094ad553/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:96fef3e886d6a9874b14f27fc193fbdc69d5d8035783d86aa4e1cea594e695f9", size = 240110, upload-time = "2026-08-15T08:17:20.547Z" }, + { url = "https://files.pythonhosted.org/packages/46/22/111e5be3b740d5c2a5bfcedb3d237b6591e5c2e82ae9d6ffcb121fe0909c/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5d8531a6569d025f68e2321e7638fb7978f23db58e5f69f56913837aae03816e", size = 232836, upload-time = "2026-08-15T08:17:21.895Z" }, + { url = "https://files.pythonhosted.org/packages/f9/d2/d2aad6fe0dbb44b194bf3becb60f5a0ac48446ade999a47fe7bb41eb09a7/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:aae2ee51122d3ae968a3837d97dc24a0aeebb0dea23694422cd172bd30017cd6", size = 262712, upload-time = "2026-08-15T08:17:23.727Z" }, + { url = "https://files.pythonhosted.org/packages/35/5a/337e4663a5eae6de99db940ee8066d4145caafb61327db62deda15313cce/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7235dc28fc6dd9d832ac7c7bce95367dedb85929f17368a0c2bee1e080b9acbf", size = 242977, upload-time = "2026-08-15T08:17:25.157Z" }, + { url = "https://files.pythonhosted.org/packages/ca/85/f82f8a92e31c7519410e2e1afdc630f28ec47490ce2c09a11c1a43cbb459/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4abdc5f9ad448c1ecbfae2974b820535d6bc6e7eef63babbab3d81cf46968c71", size = 260207, upload-time = "2026-08-15T08:17:26.602Z" }, + { url = "https://files.pythonhosted.org/packages/b7/52/643d11ffd60e9ac2fd1fb87e167a19285b9eefeff4a40e63c87cbfbeab36/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ba501e667c17d8411f98e67a022d9604ef179aff0e459b7e292c796837c13573", size = 250562, upload-time = "2026-08-15T08:17:27.971Z" }, + { url = "https://files.pythonhosted.org/packages/62/16/46556278c2168d12df9da7fede5dc6fc70e60301b26a82bbeec238c9cfe3/charset_normalizer-3.5.1-cp312-cp312-win32.whl", hash = "sha256:cfa1c0cc3a8f9f53f1243a5a99ac36fd003880199383b37672e86ddda9cb07e2", size = 178507, upload-time = "2026-08-15T08:17:29.277Z" }, + { url = "https://files.pythonhosted.org/packages/9d/7a/4c6c298171e6b3e745633180ff59350fc0ca0db1ffd28df1e369e0579f71/charset_normalizer-3.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:3617ac3cfd8b9888f145ad89dd6e692285834b0201c6074a5eeaad3fd4d668c2", size = 200551, upload-time = "2026-08-15T08:17:30.668Z" }, + { url = "https://files.pythonhosted.org/packages/cd/d7/eb95a042f0dd22e304b0b6472b154f3546a1a039a9ee89ccb2a7f61591fc/charset_normalizer-3.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:88e85ab89cb822c1e635f51d6d32e488f94e002e70e2f492bdb8b945543f345a", size = 180700, upload-time = "2026-08-15T08:17:32.028Z" }, + { url = "https://files.pythonhosted.org/packages/bc/61/2cb6ad133dbbb449fa2d37ccae973232f4827e799af258d15e589a3d1e9e/charset_normalizer-3.5.1-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:4f298bdadb8f0b9e5672877f647d1be9373ef5320c9e2f049795e26cad28b6a9", size = 211584, upload-time = "2026-08-15T08:17:33.597Z" }, + { url = "https://files.pythonhosted.org/packages/18/57/a305c968be1ca13f3dd1b32f445877e97addf55d80b65c7cb35fac82b777/charset_normalizer-3.5.1-cp313-cp313-android_24_x86_64.whl", hash = "sha256:88ca277405c2d3b71c4e1c2ee0e7966e807bcba86a69d11e19ba199d18ae4491", size = 223359, upload-time = "2026-08-15T08:17:35.022Z" }, + { url = "https://files.pythonhosted.org/packages/09/0a/d3646670292ce8d8f8cc11ac067d44885e697a5591f57a9221128da5e7b3/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9362dd90aa7dab48c0054a21187791ccf05473f7dba5d92b8033ae62164675e7", size = 194464, upload-time = "2026-08-15T08:17:36.452Z" }, + { url = "https://files.pythonhosted.org/packages/de/93/d51ec556e01042fed6f993ea859311bc7917b466684182fbbceb6ca24762/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:977cdbd483a9cff38179bea4fd754289a6f2195c7abd414aba85410b3e66cc5e", size = 197676, upload-time = "2026-08-15T08:17:37.819Z" }, + { url = "https://files.pythonhosted.org/packages/a4/a0/562247944386f7d4ef94467e84876600cc1e0f1b93239aaa9213d2bc3cbd/charset_normalizer-3.5.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e90251c0c7bdd54a100a0dce3c07b7e637278c93af29dbf78ebb89a58c4bac7d", size = 340473, upload-time = "2026-08-15T08:17:39.303Z" }, + { url = "https://files.pythonhosted.org/packages/31/e7/1d994be1b93d41e9502b8b0460eaa88a1dd8df335df415db87d6c3e91ab2/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94d78ecec2605a8d0398b0f365d5f12a63248438516f5dac536a5eff7337df4a", size = 240156, upload-time = "2026-08-15T08:17:40.66Z" }, + { url = "https://files.pythonhosted.org/packages/09/53/27923ce5cc6cbccb832037b27dca98882d9c53e9b69e866bbbef4aae7fc8/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d59b75732e9b6f27388e10c14b0259cc5f2e48c78627d185e6a177b58ad3cffe", size = 228246, upload-time = "2026-08-15T08:17:42.003Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/5a97e84d63af1d55c07439cb80e56d99a8efb4295700eb4e18c0d1615d2c/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0d929fc574b4d6fd9e7c0f5c2ede8716a41911923aa7fa5fce38e0818aa4a1ac", size = 263660, upload-time = "2026-08-15T08:17:43.627Z" }, + { url = "https://files.pythonhosted.org/packages/7a/c2/071575791dcc88316c0a9a65ce38897a82e4cfe4a325f0f7fe1b1ac47bcf/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:394fea06235c8543390050ed5f529187074b029fb027213f6c46ac11ab5d950e", size = 260354, upload-time = "2026-08-15T08:17:45.094Z" }, + { url = "https://files.pythonhosted.org/packages/fb/af/63240b0c0248c075c2535a1f1bd992821d8251b9f173abc13329661d09e4/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62b55f6722735a6c472f88361cde6640608773d9443cebdbb51abf436a1fcdd3", size = 250638, upload-time = "2026-08-15T08:17:46.496Z" }, + { url = "https://files.pythonhosted.org/packages/4d/66/70dfad64f15be09c15ccfee81330a7e515895dbe296dd23114e9a231268a/charset_normalizer-3.5.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa48b1b63d639f9483e0633e092f5851e2348c352f1f9bb6c8182f87884ef876", size = 244583, upload-time = "2026-08-15T08:17:47.963Z" }, + { url = "https://files.pythonhosted.org/packages/c0/24/ef36367d38b9ddd4bccbf72888c342e8de1f5ae506fa0b2dcf970e2732a1/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c71fb0d56c920c269cd3e2e3fe7c610e3f1fdb21a6ce60efa6430ff63676cea6", size = 242038, upload-time = "2026-08-15T08:17:49.481Z" }, + { url = "https://files.pythonhosted.org/packages/db/ab/55e683ba0fff2e43adafc10daa3001eac90fdaa419a97227d5a7067eedde/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:485a0d363cafefcd2538a73c7c838daa2035f09b2c9f9b5e3133f80c6aeb84c2", size = 233677, upload-time = "2026-08-15T08:17:50.845Z" }, + { url = "https://files.pythonhosted.org/packages/bd/67/0f40eaf8d1b6e7cf15e82382a2965efaca787fc1c2794b7021d37aaf5036/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0ea61a470e070686aa30892fed79e297d2c8d0ab46b8bcdf027d38c51da591", size = 264491, upload-time = "2026-08-15T08:17:52.61Z" }, + { url = "https://files.pythonhosted.org/packages/5c/64/12b4c2a11ee8df4fcc518c78b0d93e3a92bd3d5253d1617ce74ff0e8c7ef/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:90b7481fb62fbe172c558bc6fd1c4c98d82004a54a7551f20e11ac9bf0b8708c", size = 245196, upload-time = "2026-08-15T08:17:54.023Z" }, + { url = "https://files.pythonhosted.org/packages/37/2e/651d910af6d0fba325eee1cda37ec5443462ed25360e666c144166eb6091/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:35fe081843b35aad20ffeccec3eeffbe637b15d14f3fb22cc1b59cd8ec17e93c", size = 261660, upload-time = "2026-08-15T08:17:55.491Z" }, + { url = "https://files.pythonhosted.org/packages/90/c6/b09e05e6db7f64338e0dc067c79577b1138da86c1e38369096851d96be88/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fd0350afdc3aabd5576f60ea109228bd5538139713c7b094c5cd27c73a98bc6f", size = 252618, upload-time = "2026-08-15T08:17:57.025Z" }, + { url = "https://files.pythonhosted.org/packages/76/4e/362d4f9fdcdf5556fb2aa3ce7d4a58ebce03ed1ff03aa1d9aca8d02f13f3/charset_normalizer-3.5.1-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:9d9a0dc7cbe9bec24c3f767c9122c41fe5a1bc43f47cd099d00d393e09769de4", size = 140362, upload-time = "2026-08-15T08:17:58.425Z" }, + { url = "https://files.pythonhosted.org/packages/b4/d4/703be739b26acce318bd29eb3b25b7209e1b1f527f9eae3d1f1f01fdde2b/charset_normalizer-3.5.1-cp313-cp313-win32.whl", hash = "sha256:d63600d620ad0064c3a748b950ac5ea38a80190e5498532efefa4b7b3f1da1f3", size = 177755, upload-time = "2026-08-15T08:18:00.037Z" }, + { url = "https://files.pythonhosted.org/packages/8a/33/56d97ade41c8db611e727168c52ae46c9224c362ec28d4b65d7e9869e8da/charset_normalizer-3.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:aea996a6aba25260827c9ea511d1addfde2da9eb686ac961838509086188b7e6", size = 199295, upload-time = "2026-08-15T08:18:01.506Z" }, + { url = "https://files.pythonhosted.org/packages/5b/75/5b20dd1e6573a01a08158fe104104fa2c8abf941745596954185726cd46c/charset_normalizer-3.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:fd0a274c0e5f9a21565cd9d3dd749b61f96b7aa1e20a93aa1ba4029518f2e5c0", size = 179856, upload-time = "2026-08-15T08:18:02.929Z" }, + { url = "https://files.pythonhosted.org/packages/29/cd/2b812ce5e888f1ce69a5350281e58aab07ae64a958ecae8912f30865718e/charset_normalizer-3.5.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:774d157f112367ff4abd29019f38f023c24e00e56edc7829c20e358a5a913ad8", size = 212318, upload-time = "2026-08-15T08:18:04.403Z" }, + { url = "https://files.pythonhosted.org/packages/9e/4a/a6ee107430768a5334e6d63f31f148a04a1a491ef161a1ac9415a73f2fa8/charset_normalizer-3.5.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:26422d45fd13551cf564c58932f7d72b4f58b93b0fcf18c35ba6be12b46bb102", size = 224897, upload-time = "2026-08-15T08:18:05.997Z" }, + { url = "https://files.pythonhosted.org/packages/c3/d9/35ae3f64f29d0179c35c3baefe575904df2913dde519129c7f75995a2b1d/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:09a7bba9f739468c8e78c36a75c33768e53cb1959fc638f510454c14683f00d5", size = 194848, upload-time = "2026-08-15T08:18:07.397Z" }, + { url = "https://files.pythonhosted.org/packages/74/76/f2fc7380f056cc273a53af37f50d08ad54b2c59f61078f31432edcf1c2bd/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4c9548dc78002099910abaebc0a72ac58b7d30931869e0351c09b507dff4ece3", size = 198163, upload-time = "2026-08-15T08:18:08.989Z" }, + { url = "https://files.pythonhosted.org/packages/e9/40/095ce62fa078483cccc1fa2b36e6bc9580b85422a20ee9f925341c50e44f/charset_normalizer-3.5.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c428c6c31eb5f4277d7f8eccaf767fbd548ddd5ce3c8b4f4cbbfab3d96b5904c", size = 341823, upload-time = "2026-08-15T08:18:10.458Z" }, + { url = "https://files.pythonhosted.org/packages/f1/5a/0e58b1c04a1596e0256f407274a92d5fb2ee21324409d1fab1da48a65b5b/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2f06b7eae9dbe77fe1d644ca244dad508de8d302870a43f3c559b521270938a0", size = 242458, upload-time = "2026-08-15T08:18:11.989Z" }, + { url = "https://files.pythonhosted.org/packages/22/95/b4618ce912e6db0b1aae89ba788e38e8a7eba0f3025cc66e8c0699f977b2/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b7430cf5728e68f6c462254009a6ef4086e1bea43cf2f57aa9c55fb4f50ff96", size = 226717, upload-time = "2026-08-15T08:18:13.401Z" }, + { url = "https://files.pythonhosted.org/packages/8a/76/c681192bbda3d55356db5dadd64381d5202b37c6b598fcda5282e88b5d3d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab743e9bc90c1f73552ec33e10e3331315acd2c397b36065b591b0181de533cc", size = 266111, upload-time = "2026-08-15T08:18:14.961Z" }, + { url = "https://files.pythonhosted.org/packages/88/be/55127bfca72c0cff6c022488d140d7c5b04c771e3b72e9bdb4836d54979d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6f7deae3feb4edfa2efaf7c574fe88cbf055038a6abdb40188e4fff66d5699f", size = 263128, upload-time = "2026-08-15T08:18:16.515Z" }, + { url = "https://files.pythonhosted.org/packages/e0/91/39c3af510b0aa32bbda03374259200f28430febfd1bf5e511fe765282ce5/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15f024313246a4ed976c60f440bb8d257815513a681d212ff74fd46f7d715a90", size = 251240, upload-time = "2026-08-15T08:18:18.127Z" }, + { url = "https://files.pythonhosted.org/packages/1c/a5/cbe418bbc6ecdfc3e05a0116002897c4b403a5e838d697e64c78e9f0190d/charset_normalizer-3.5.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:823f82903d189af463d7df250ef1f7f696f3cee08cc8d91deb565e8d425f6506", size = 245282, upload-time = "2026-08-15T08:18:19.625Z" }, + { url = "https://files.pythonhosted.org/packages/cc/a4/689bb42e8e7cd492f3cb64907c6bc00ad247ec9a3628cd3f8eed126e8ae1/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:01e93745f7f219b703b60ba7afead36cfc4242782be5af484673fc500df12da5", size = 244597, upload-time = "2026-08-15T08:18:21.121Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ce/9962938e179cf9f699d3f1e7b3114b5d7642dee6a893745229f9dd04f274/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:329fc3ccb63ad22d867d84c2adea759a64079a37ba4a343433b02c7a2816871e", size = 231376, upload-time = "2026-08-15T08:18:22.57Z" }, + { url = "https://files.pythonhosted.org/packages/85/54/46000450ada53bd9eac5429a2c8c54cd2d9b39c0c255f229aea9af0948a5/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:bb57753e36e4855b8ca375069482250a6246372331a3e4f3407eaebb007443f5", size = 266715, upload-time = "2026-08-15T08:18:24.235Z" }, + { url = "https://files.pythonhosted.org/packages/3d/bb/618749d70f792b44252a777bf89bfb86823b9bbc1ea13fe8ce759b07f38a/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fce8cbd4997efeb450bd298b54f755dcdff18d496f7a5ddbb4867c6d7c88fdc3", size = 245848, upload-time = "2026-08-15T08:18:25.726Z" }, + { url = "https://files.pythonhosted.org/packages/7e/3f/ffb64458527c7668031d5eb095d978de561958dc9f5b53f8e488a533e603/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6c9cdde8becb25a7fde49924511aa2644d6f8081cc8df8e9452724303348d8e3", size = 264521, upload-time = "2026-08-15T08:18:27.193Z" }, + { url = "https://files.pythonhosted.org/packages/4f/ab/74a55fd803916a35ac461daf002708191aac19b546b80dc8cabfedc63d98/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9ac4444d8d4fd4c4bd08bf451ed3167aa9e7ec6cdb41b648794f1d1103652e36", size = 253054, upload-time = "2026-08-15T08:18:28.568Z" }, + { url = "https://files.pythonhosted.org/packages/a0/2a/6a9034b7d3c60b17499afb482df5878bf9fa20b50cc3887d5ef017a833db/charset_normalizer-3.5.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f03ac127268b43ef4fe9e6ab6794a6794b49485a0cc0c1db79876d2f33f75bc7", size = 140580, upload-time = "2026-08-15T08:18:30.214Z" }, + { url = "https://files.pythonhosted.org/packages/f3/46/1d362e1a00d035d66b9869e1281eee115907f7e390a16a07824ab5737360/charset_normalizer-3.5.1-cp314-cp314-win32.whl", hash = "sha256:1f5883d77fd409a261abb5dc8ccbe335720d798b1de4abb3b1d47ccbbc76b53b", size = 180325, upload-time = "2026-08-15T08:18:31.877Z" }, + { url = "https://files.pythonhosted.org/packages/7a/7c/4938c329b6a9d446f6a59aa2092ff7118f274209b5ed0e26893d1d30a63c/charset_normalizer-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:c658c50ac0c98cd755a2dd50b7977d3bca7df401dcc47fbdfa87db53ef7d4e8b", size = 204175, upload-time = "2026-08-15T08:18:33.466Z" }, + { url = "https://files.pythonhosted.org/packages/ac/33/eeb384dbd8dec570661354592f4f2e1b2fcc92585624d146a000caf53841/charset_normalizer-3.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:4bea7f8ebe90bbd7f0e4a2de42ca6924ba23e3e76418c408ff82f1d46fabd687", size = 184123, upload-time = "2026-08-15T08:18:34.913Z" }, + { url = "https://files.pythonhosted.org/packages/1c/6c/c73fa9d5a85f6ab05395de61c5f6984e0a9ff40bb5ff888d46dff02526c6/charset_normalizer-3.5.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:fbc597639158fd7c14d55e808718848319540f51b0e6746e3eefa59723a4a348", size = 381682, upload-time = "2026-08-15T08:18:36.349Z" }, + { url = "https://files.pythonhosted.org/packages/30/c7/63565f860921457feba93bae6c86fb7746deb4cffeed2f375cb845318146/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e71c909f353863b2b89c83de2ebed71ea6d0df8a6ef65a128193c5e650766bef", size = 240826, upload-time = "2026-08-15T08:18:37.887Z" }, + { url = "https://files.pythonhosted.org/packages/06/ae/7ae8807410dfa33f8e6f1715740adeaafa8a816cc4cb33508f54b1f7c896/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7ac76cf9afd34929d76eb7fcb63be476a4853d8a96f0dcf2d0db68a0cbdf9885", size = 227861, upload-time = "2026-08-15T08:18:39.315Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a3/887c1642f0da26000b0e0652d91071113c0e72cea33952e225cf589f49a9/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a3a370082ce34d0612f421e15fe011c53bb1feff21a26d06ad4fb244dab5a375", size = 260758, upload-time = "2026-08-15T08:18:40.88Z" }, + { url = "https://files.pythonhosted.org/packages/3e/11/e6f5b9a3d0e55b0ef7505cd3765cdd48f22db89994c947b316f52f801fd8/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:256dd4d85d9e4dc595e2bc983c980e73f62ddeb3165c58b4c3dfe78c5c8548c1", size = 259950, upload-time = "2026-08-15T08:18:42.351Z" }, + { url = "https://files.pythonhosted.org/packages/1b/ee/e4e10a94d51cd1ee638aa7e00b65399e6b2a4e8376ab6d2eac9f95586671/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:58d4aa13a59c969dbfdf9e6a9560e242cbfd9e8a8f50c2747714df1a423adf65", size = 249329, upload-time = "2026-08-15T08:18:43.914Z" }, + { url = "https://files.pythonhosted.org/packages/c4/25/d5f4198819e6059735a84e8d0bfb72dc33976da67b97adcd3fb5a5e07ec6/charset_normalizer-3.5.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0c6dfb5ca6723eeed15aa8e564a014d69fcb8812f94eef11fe3631e0508199f5", size = 243137, upload-time = "2026-08-15T08:18:45.368Z" }, + { url = "https://files.pythonhosted.org/packages/a5/e9/e925ca7569cf9fb9701fd82503fee73eea5268fdb856bdd64947092d3daa/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c010f5581d9c612804cc59fcf7b524b707fbcb72828551237ab545bb5c7034af", size = 242820, upload-time = "2026-08-15T08:18:46.842Z" }, + { url = "https://files.pythonhosted.org/packages/34/17/672c251a888ed2aebcdd2fe830ad0104e25ff83c43f5c4f9c15e9fc6853c/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:52ec005752a56ae79547a05c0139ca2501a0c866390b6115008456b9f0e7cde1", size = 230504, upload-time = "2026-08-15T08:18:48.353Z" }, + { url = "https://files.pythonhosted.org/packages/3f/fc/f6a85abebd42ce4da2f1db0aa56cc6a0df1995e318b3875d14401b8381d1/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2bced4061f000f7187254a02ad3433ae17eaf991747ceea2f478422590a5bba9", size = 263087, upload-time = "2026-08-15T08:18:49.859Z" }, + { url = "https://files.pythonhosted.org/packages/98/66/7c42677e739ba66746b297e2046918d793078094dc239e1e72768cffccc6/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9eea3ab2597a5e65fe65296e2d6a84570845a6b55532d90333d740d48bbc850a", size = 243269, upload-time = "2026-08-15T08:18:51.601Z" }, + { url = "https://files.pythonhosted.org/packages/de/d8/a50b79237f417af10f8c2a501ce8d1ca87829a22e69117891ca4ba20a69e/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:496846868fea80e479324862fa877f02411f2fd0f83b79ccee2607aa68b2a032", size = 258766, upload-time = "2026-08-15T08:18:53.23Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1d/0fc91aeaeb3c83b748f532399ce67cf84604b48297405d740000f7a9e786/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:85d5855daafc240cc045c026d7a15fd198a09b0fc8ff6f5ecbb5297b509cb11e", size = 250814, upload-time = "2026-08-15T08:18:54.768Z" }, + { url = "https://files.pythonhosted.org/packages/ae/10/3d8c777cf9024615295aa1b808324ad5b4a77855869c00824bad74ffaf8a/charset_normalizer-3.5.1-cp314-cp314t-win32.whl", hash = "sha256:58d3e12c88e0950bca850ae1f7c256055c097639c2edb9eb123af9807d8b15e4", size = 191074, upload-time = "2026-08-15T08:18:56.305Z" }, + { url = "https://files.pythonhosted.org/packages/4d/81/ae557d3c44d1a1d688696d60563413a0866a91b7ebc50f20df838be3d8c8/charset_normalizer-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:acaf604462bf330b0d07e7a07c1d6e4adac79e5fb13e9c5140590542cafacc00", size = 216476, upload-time = "2026-08-15T08:18:57.889Z" }, + { url = "https://files.pythonhosted.org/packages/27/e9/61c01fb8b804692569c036b3fc50495814502dcf13a60649c6055390b02c/charset_normalizer-3.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:fdb8a068947befafba9952162645dc2fecaeb400e64584829ed5e9b2fbe21a7f", size = 194115, upload-time = "2026-08-15T08:18:59.418Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4e/8544831ef59d8f27ce92c80871380fdacc8076a8a56ed62f82e54f991333/charset_normalizer-3.5.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:9085f87b0e38a2b92b8923059b4e8789fe40d9279712d15dcc670048d77079af", size = 342048, upload-time = "2026-08-15T08:19:01.054Z" }, + { url = "https://files.pythonhosted.org/packages/7f/a6/e3b46852424246065355644f4fb6dbccc0239a42a2eee27ecfc8957f0bcd/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2679de311c7946dde5d3b6f44941844133ff5c7cb86099c0061ab1e8901c20a8", size = 242997, upload-time = "2026-08-15T08:19:02.492Z" }, + { url = "https://files.pythonhosted.org/packages/03/3b/0cc9a26777334ab2f2e3089b948bbf4e4fe72ea70b897715ef6415043ec8/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:baf3775a2635e5a11fbd5e4e64ee69c7e86875d224a5c72aca4c141064589a90", size = 237014, upload-time = "2026-08-15T08:19:03.943Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c2/027335f0aa337a2a2e121bac1ad88c4f02ba6053ea0926802784f3db11af/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ac8c94b6539074e0f40899301273ac8402b9b3e01c7b7ba269ff30340aaaf20", size = 266174, upload-time = "2026-08-15T08:19:05.598Z" }, + { url = "https://files.pythonhosted.org/packages/86/d3/e367787febe4e74769dec0f406f2c3c8d1b955fce5aee1fd0f94e8367a45/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8fe532b3c966d1fb794e0698e4589d0444017ae77fc0b31edea13c0e35bcc449", size = 263361, upload-time = "2026-08-15T08:19:07.251Z" }, + { url = "https://files.pythonhosted.org/packages/af/3d/391b193eb9f3e84b02f9314088c386debdc0debee843535aaea2e2c6715d/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5c84bec0ab5ae0c64bfe73a7d2adcb5ce73b467523fc27fd6a28ab2aa6cbe35a", size = 252143, upload-time = "2026-08-15T08:19:08.816Z" }, + { url = "https://files.pythonhosted.org/packages/2e/57/de221f1745a90d418199761967e2776bfe2c275a1194220985e8c1d37833/charset_normalizer-3.5.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:854066be00447fa8de2ccbbe893e2ffc4b123ef16d897af794c1e18bd4a714b0", size = 252086, upload-time = "2026-08-15T08:19:10.255Z" }, + { url = "https://files.pythonhosted.org/packages/c8/e3/d119f86a01f9331e8186175f24873b1d74a7ee9e2e4b4d68f9947dae5afd/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:21b82d8082f6f5e7f456ef0bd16323d08de1266efbfeb476e64b2a91d1471a4e", size = 245231, upload-time = "2026-08-15T08:19:11.807Z" }, + { url = "https://files.pythonhosted.org/packages/26/de/d8e48c135ae480879539cdb179c8d3b50c7879497d75dd899b5763b69cee/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:838648accb3a7fd9803fd45c87bce8509648eb0c11bc34e216141300977244f2", size = 241546, upload-time = "2026-08-15T08:19:13.416Z" }, + { url = "https://files.pythonhosted.org/packages/67/c4/217755fd1abc50d326c252922cd642002758095a81ff45010337b8b3ef65/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:195ce897c6153c0700078142cf8efe3e6454ca4cf4357499e4078dfd83396626", size = 267033, upload-time = "2026-08-15T08:19:14.981Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d7/34d8e404e358d2adcc5a228c2134643af00104c8fb0bf525f3688d756f05/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:978eab16f55b4ab2c2a745be9a0a840bf8f09a7f227d9c76eb30214d078865a5", size = 252045, upload-time = "2026-08-15T08:19:16.618Z" }, + { url = "https://files.pythonhosted.org/packages/5e/fa/40414471acf0aa0692ca77305aa00e434fcd8288f0941c93c30e9a5f8f2f/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:cc0329df4caaceb950d2f580b5ac716a377f7059624a0bafaeaf8a218c6ed774", size = 264866, upload-time = "2026-08-15T08:19:18.101Z" }, + { url = "https://files.pythonhosted.org/packages/32/90/fcc850bae791abd2e0c041847f13e270aa08692a79f3e00de6d2dce1cb50/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:687c9ca3035544b113bea2055e180af96fb63c0c476e22a9180f51925186e7b7", size = 253932, upload-time = "2026-08-15T08:19:19.734Z" }, + { url = "https://files.pythonhosted.org/packages/af/af/53afe99068b3c10b4cbae592a52ef72a7c92c0188440e83ee3a078fd8f75/charset_normalizer-3.5.1-cp315-cp315-win32.whl", hash = "sha256:706bfd38730a5ac7a365793269a00f4e988178cec121391f4248d84ad8c972e9", size = 180320, upload-time = "2026-08-15T08:19:21.37Z" }, + { url = "https://files.pythonhosted.org/packages/c9/bc/f46a132041b29e4a8779ed712d3df1bf112e94ca8de58b66d7ec2c0cf8b9/charset_normalizer-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:92caef967d287a407085d61176fce4012b1dd62daed4eb6d5ceb26d3d2538712", size = 204174, upload-time = "2026-08-15T08:19:23.088Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5d/9ed554480eda8e447b673648628fdc29574d23dbad01fe11837adedd1cae/charset_normalizer-3.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:5fc45d653ea8c9a20479167e11d4a0f8cb2fa3470737ab6f9c827532313187b7", size = 184126, upload-time = "2026-08-15T08:19:24.471Z" }, + { url = "https://files.pythonhosted.org/packages/3b/32/9b8929bf384061ee1fe5d9c27c6f9776d3d824039ad4e14c88ec00c7808e/charset_normalizer-3.5.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:59171c6e45bf07d0d5cab3b0bf81d945035530f6873398b3b531c31184d46663", size = 381441, upload-time = "2026-08-15T08:19:26.038Z" }, + { url = "https://files.pythonhosted.org/packages/96/10/e9aa7923d3ddac652c99a1c5f7be494e737e151566a44abe018daf757f2c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9dbdd9205662134957cf0c324f639bdc5031c0ca056e2369e238db75187c0f11", size = 241742, upload-time = "2026-08-15T08:19:27.532Z" }, + { url = "https://files.pythonhosted.org/packages/28/53/a2d249ebddf47b889a100c0bdcb61a2f9dbb8bc24ef325cc062e4f476877/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e4b018dc5a0eee4676e38fe84a47a427816c590b93b55d9025274ec4d6ffc2dc", size = 235298, upload-time = "2026-08-15T08:19:29.274Z" }, + { url = "https://files.pythonhosted.org/packages/7d/07/469f78af590f7d5cd48e20d8dbfa3d66deeff9ba37768c04d886b5afd45c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ced3fdd71aaa83ce593746c2edb42b7a59cb4c19c8b5c407781c72e493aae55a", size = 262500, upload-time = "2026-08-15T08:19:30.955Z" }, + { url = "https://files.pythonhosted.org/packages/55/66/3bb56a47f7dcba014055b1a1d33c6f08bbe9c1e74dba154cfa25f90ae885/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:19a3dd5aa73cef1c99687c4fc57db016a9c17104ae1185da88ba566a5d3bebe4", size = 258888, upload-time = "2026-08-15T08:19:32.458Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c1/2adc2800903fb013210349313b710a5376856578d9e33e6b9a1d8b36714a/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc5d36d96478aa9c60654bd932525bf32964c62a7281eafdf16d85003a8d6004", size = 250243, upload-time = "2026-08-15T08:19:33.94Z" }, + { url = "https://files.pythonhosted.org/packages/95/b5/a18d0dd1157ab655cc2cb14a545f4a4784bbad70ab3502412e36097502d9/charset_normalizer-3.5.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:04368edf83514385ffc3e1cfd4546e595f4f1272dd23ba437a93a9cc3741d47b", size = 249871, upload-time = "2026-08-15T08:19:35.413Z" }, + { url = "https://files.pythonhosted.org/packages/ad/c3/525f508cd1e58d0450ac55ed40ac75bc3a97482c59def5278456a5fbf03c/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:9b5db6052055d34d41230fb78d7c439c23dc536a9896f6cb039e8dd92cfc1263", size = 243580, upload-time = "2026-08-15T08:19:36.886Z" }, + { url = "https://files.pythonhosted.org/packages/7c/c1/49a91fe7e97c8140094ca5c64161ab623a70d9f636bf834eace14048acb5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:252d099029bcbea642f2a06c4ed5046bdf8b5a8150b64afa5e027e88b106e5ee", size = 239807, upload-time = "2026-08-15T08:19:38.392Z" }, + { url = "https://files.pythonhosted.org/packages/d3/58/56a48c296601274c4689b864a8e2dfb209b81dfcb39472753ce95eea662b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:6199d5606e2bbf2b096cf64d03f8b6790c91081d5ac866b8e7bb6422738cc60c", size = 264083, upload-time = "2026-08-15T08:19:39.856Z" }, + { url = "https://files.pythonhosted.org/packages/10/4c/dc48409274a1817ff349711d26c62aa0c597df865d4d69ef79160c859193/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:77efcff2b23071c349402ac1066667a3d011f62398d81408c9b88ad991747c9e", size = 250317, upload-time = "2026-08-15T08:19:41.53Z" }, + { url = "https://files.pythonhosted.org/packages/81/58/d325912115caec62d6bdd77bbab5e0b7da5d234a9f20affdffcbcb530d0b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:a5cbd90ecf0fc62e64726917ad083b73001f0563657a87ec3c0b504e277dc90d", size = 258173, upload-time = "2026-08-15T08:19:43.07Z" }, + { url = "https://files.pythonhosted.org/packages/34/f7/b13b1ccae2c8ec63980d13be1890eb73f8aeabbfce02a24aabc0908788f5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:4d26f14f041e83dd8edfd61f4cd4fa7285d31798b5bf1f28e70c367ba6c41d61", size = 251960, upload-time = "2026-08-15T08:19:44.587Z" }, + { url = "https://files.pythonhosted.org/packages/1e/25/ed3f9919c5aef8cc818be1f972f565f7610d7b2076b8ebb98839516ffc3c/charset_normalizer-3.5.1-cp315-cp315t-win32.whl", hash = "sha256:ac13b004224fb341e1e25a1ed5e19d32f57cdb2a403e01f003b46f051a550f6f", size = 191186, upload-time = "2026-08-15T08:19:46.293Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/43c2b3e9d8267092b913eb8b0603f0f71993c395632886bd37a7223f96cf/charset_normalizer-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:35aea775dc2bd5f54cd84a1cd2696cc3207c479cb9cf0bd346f0d343e4300ddb", size = 215947, upload-time = "2026-08-15T08:19:47.853Z" }, + { url = "https://files.pythonhosted.org/packages/a8/76/9aad3e9c8865e5e0efa9a7f6f81c37a67635a985145ecd44528a81e088ee/charset_normalizer-3.5.1-cp315-cp315t-win_arm64.whl", hash = "sha256:fb78f6e7fcd8ad785d28cd577168bc1aaee827b25bb8755638f694794ea98f0a", size = 193909, upload-time = "2026-08-15T08:19:49.383Z" }, + { url = "https://files.pythonhosted.org/packages/5b/97/fb4e82231aba271ffd775a1b4993b0defc4e3059f286ae41d9433409fe85/charset_normalizer-3.5.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:41876ee62a3dddf48ff1121ad8f0798032aa03f2fd35f21f34a4cab14f18d8d2", size = 331467, upload-time = "2026-08-15T08:19:50.959Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2f/fe3f187327aac18e2d54e9d2b08e15d27bf9b642d9e51c219f130fc34d1a/charset_normalizer-3.5.1-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6dac12ff6b846103483683f60c5f8fee205121adc58ffd87e90a90a3af69e99", size = 253057, upload-time = "2026-08-15T08:19:52.654Z" }, + { url = "https://files.pythonhosted.org/packages/d7/c7/9e48cee5c161fe24da823b61bf381921d77cb994a0a4de148e95018c1984/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cee5dd7c6fb5dd52a0fe2a740f9bc6e3593f5f8b1788bde49de02086f30182b2", size = 240930, upload-time = "2026-08-15T08:19:54.163Z" }, + { url = "https://files.pythonhosted.org/packages/49/e0/716601f3cc69be7b198951150c75ead1ece33c3c8036ff6ffa46029659a0/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:343fb4f2821043bd87095f7b08a1a181febc8e36ac64212143bbfd0a0e1bc235", size = 230822, upload-time = "2026-08-15T08:19:55.807Z" }, + { url = "https://files.pythonhosted.org/packages/d3/05/71bfc5caa0abcc45aea1f6a4d50ac68e59605ddc7666fe8494f4cd229665/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ae4a097991662cd4fff0ddc74e0fe7874f82e00042fa0ea00855645ed0c79598", size = 260037, upload-time = "2026-08-15T08:19:57.312Z" }, + { url = "https://files.pythonhosted.org/packages/c3/92/de7e32ed05341e7a9c4c877c318418197b7f2d66a3b68d561bf2ac57ca3e/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b599739b93b2cbeded49645ae3c8d1405c29ddfbceac1545c87a3f9580a9e96", size = 255097, upload-time = "2026-08-15T08:19:59.056Z" }, + { url = "https://files.pythonhosted.org/packages/f5/7b/ade0a122600319dfa0b1000ab0f9731c94a817904cf3c5de408c73a4ede7/charset_normalizer-3.5.1-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b39b69b347e5e47a3b5b8cfc005c68c1ba347474e3960236c4944a8ecd174962", size = 250166, upload-time = "2026-08-15T08:20:00.612Z" }, + { url = "https://files.pythonhosted.org/packages/75/9c/019fbb9f4834491a160951349b1a3714439376f66e5f7cf18b4f18f0c7aa/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a2028475ba855475b8b4d3cfeb4994269c967aea8b9892dfba907f4263a863a3", size = 241821, upload-time = "2026-08-15T08:20:02.321Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b8/11d4840bfc99330cc7fbcc2681ee5a044553a6e77655508d8f9b2bff7b34/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:36047af20e17097c3bb9476c2b7655f2f7aa51322c0ba58c07695bedf755a950", size = 232529, upload-time = "2026-08-15T08:20:04.008Z" }, + { url = "https://files.pythonhosted.org/packages/18/96/2b3a21492d9f65171ac75d872f5018260013d00bfa0ff70ec9f179148cbd/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4c4fb141a727957c93edfe5c32a26ceb6b5f6461d67146e2d39f51e16170bea8", size = 260348, upload-time = "2026-08-15T08:20:05.877Z" }, + { url = "https://files.pythonhosted.org/packages/d6/aa/a69a2028e8bd052476c245460ab19d7de595de084dd968f2d75cd50c3e25/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:2f293479cce755c75f1697e87c409b7ae4c555c7dfecb6e988ad13abba943031", size = 247234, upload-time = "2026-08-15T08:20:07.487Z" }, + { url = "https://files.pythonhosted.org/packages/35/8a/3d130aeabcaf3d2466af76b7b141c08d9e89c9016ab4b7cdd0f7dc2d1c62/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:3588e376b3ea2eea84976f67273d679f229e24c66dce7b82ae45aef04ff6e072", size = 256917, upload-time = "2026-08-15T08:20:09.142Z" }, + { url = "https://files.pythonhosted.org/packages/80/c2/a7379b840292d0c1ab9fbd17d1f3967aa81794dc95bc74be8999d7fedcf7/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e199fb99720074809a7720f1c0b4d919eea8b87e88713e0f8f602f7bef543d9d", size = 254846, upload-time = "2026-08-15T08:20:10.727Z" }, + { url = "https://files.pythonhosted.org/packages/01/65/d43b714731bb2f40d4053dfa00ecfc1c5a301f8e3316c5db3a09af59fe94/charset_normalizer-3.5.1-cp37-abi3-win32.whl", hash = "sha256:dd732602a7009217f658d5863d12d79d373a4de0eebc111094bcdd3bb8e0a6cc", size = 174216, upload-time = "2026-08-15T08:20:12.334Z" }, + { url = "https://files.pythonhosted.org/packages/35/4f/b911ed898b26a09789eba9c9200c999aff6c61b4bafaf4838e56d1a1e1a3/charset_normalizer-3.5.1-cp37-abi3-win_amd64.whl", hash = "sha256:70055ff39b97c99e7ae40ea3e393fb62aa2e44dbd9b29f8d14f42fb0025c3959", size = 199764, upload-time = "2026-08-15T08:20:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/f0/a7/920baf467bfd9bf689f3b318340f37aee4572a71f162bd8db51da55ba4fa/charset_normalizer-3.5.1-cp37-abi3-win_arm64.whl", hash = "sha256:87e4f41d375c0b9be2fb5251aee4b8a689169e134535aed81bf085c3b647451e", size = 287318, upload-time = "2026-08-15T08:20:15.551Z" }, + { url = "https://files.pythonhosted.org/packages/cc/61/d01fc49b8dea277640b55a9e15960dbca9fdc8c9fde18e572d39c59f4019/charset_normalizer-3.5.1-py3-none-any.whl", hash = "sha256:6df0ec430f9a831772c23ca5a224cba36517a58a84bb32c32bb59a9fa67c47f6", size = 68658, upload-time = "2026-08-15T08:20:43.306Z" }, +] + +[[package]] +name = "click" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, +] + +[[package]] +name = "cloudpathlib" +version = "0.25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/07/9f/1791893f3d51ec36cd9e3f8da0130d278ee909a68bc17c83b3b3de98c91b/cloudpathlib-0.25.0.tar.gz", hash = "sha256:63612e17778c5e3a51b472def8d785d0aaaf347486d6b6786dc7be627556d4c6", size = 56441, upload-time = "2026-08-22T18:41:21.493Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/f9/9084c945d0b3ef8f129b4f0dd21baec759761a337902b70f17a3945015dd/cloudpathlib-0.25.0-py3-none-any.whl", hash = "sha256:8faef3ed3a0dd71d134e8617b4fdc5ce56a12a6b485c080cfe80106e5f1d1f5d", size = 66109, upload-time = "2026-08-22T18:41:20.417Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "confection" +version = "1.3.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/65/efd0fe8a936fc8ca2978cb7b82581fb20d901c6039e746a808f746b7647b/confection-1.3.3.tar.gz", hash = "sha256:f0f6810d567ff73993fe74d218ca5e1ffb6a44fb03f391257fc5d033546cbfaa", size = 54895, upload-time = "2026-03-24T18:45:24.331Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/e4/d66708bdf0d92fb4d49b22cdff4b10cec38aca5dcd7e81d909bb55c65cd7/confection-1.3.3-py3-none-any.whl", hash = "sha256:b9fef9ee84b237ef4611ec3eb5797b70e13063e6310ad9f15536373f5e313c82", size = 35902, upload-time = "2026-03-24T18:45:22.664Z" }, +] + +[[package]] +name = "cymem" +version = "2.0.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/2f0fbb32535c3731b7c2974c569fb9325e0a38ed5565a08e1139a3b71e82/cymem-2.0.13.tar.gz", hash = "sha256:1c91a92ae8c7104275ac26bd4d29b08ccd3e7faff5893d3858cb6fadf1bc1588", size = 12320, upload-time = "2025-11-14T14:58:36.902Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c9/52/478a2911ab5028cb710b4900d64aceba6f4f882fcb13fd8d40a456a1b6dc/cymem-2.0.13-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e8afbc5162a0fe14b6463e1c4e45248a1b2fe2cbcecc8a5b9e511117080da0eb", size = 43745, upload-time = "2025-11-14T14:57:32.52Z" }, + { url = "https://files.pythonhosted.org/packages/f9/71/f0f8adee945524774b16af326bd314a14a478ed369a728a22834e6785a18/cymem-2.0.13-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c9251d889348fe79a75e9b3e4d1b5fa651fca8a64500820685d73a3acc21b6a8", size = 42927, upload-time = "2025-11-14T14:57:33.827Z" }, + { url = "https://files.pythonhosted.org/packages/62/6d/159780fe162ff715d62b809246e5fc20901cef87ca28b67d255a8d741861/cymem-2.0.13-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:742fc19764467a49ed22e56a4d2134c262d73a6c635409584ae3bf9afa092c33", size = 258346, upload-time = "2025-11-14T14:57:34.917Z" }, + { url = "https://files.pythonhosted.org/packages/eb/12/678d16f7aa1996f947bf17b8cfb917ea9c9674ef5e2bd3690c04123d5680/cymem-2.0.13-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f190a92fe46197ee64d32560eb121c2809bb843341733227f51538ce77b3410d", size = 260843, upload-time = "2025-11-14T14:57:36.503Z" }, + { url = "https://files.pythonhosted.org/packages/31/5d/0dd8c167c08cd85e70d274b7235cfe1e31b3cebc99221178eaf4bbb95c6f/cymem-2.0.13-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d670329ee8dbbbf241b7c08069fe3f1d3a1a3e2d69c7d05ea008a7010d826298", size = 254607, upload-time = "2025-11-14T14:57:38.036Z" }, + { url = "https://files.pythonhosted.org/packages/b7/c9/d6514a412a1160aa65db539836b3d47f9b59f6675f294ec34ae32f867c82/cymem-2.0.13-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a84ba3178d9128b9ffb52ce81ebab456e9fe959125b51109f5b73ebdfc6b60d6", size = 262421, upload-time = "2025-11-14T14:57:39.265Z" }, + { url = "https://files.pythonhosted.org/packages/dd/fe/3ee37d02ca4040f2fb22d34eb415198f955862b5dd47eee01df4c8f5454c/cymem-2.0.13-cp312-cp312-win_amd64.whl", hash = "sha256:2ff1c41fd59b789579fdace78aa587c5fc091991fa59458c382b116fc36e30dc", size = 40176, upload-time = "2025-11-14T14:57:40.706Z" }, + { url = "https://files.pythonhosted.org/packages/94/fb/1b681635bfd5f2274d0caa8f934b58435db6c091b97f5593738065ddb786/cymem-2.0.13-cp312-cp312-win_arm64.whl", hash = "sha256:6bbd701338df7bf408648191dff52472a9b334f71bcd31a21a41d83821050f67", size = 35959, upload-time = "2025-11-14T14:57:41.682Z" }, + { url = "https://files.pythonhosted.org/packages/ce/0f/95a4d1e3bebfdfa7829252369357cf9a764f67569328cd9221f21e2c952e/cymem-2.0.13-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:891fd9030293a8b652dc7fb9fdc79a910a6c76fc679cd775e6741b819ffea476", size = 43478, upload-time = "2025-11-14T14:57:42.682Z" }, + { url = "https://files.pythonhosted.org/packages/bf/a0/8fc929cc29ae466b7b4efc23ece99cbd3ea34992ccff319089c624d667fd/cymem-2.0.13-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:89c4889bd16513ce1644ccfe1e7c473ba7ca150f0621e66feac3a571bde09e7e", size = 42695, upload-time = "2025-11-14T14:57:43.741Z" }, + { url = "https://files.pythonhosted.org/packages/4a/b3/deeb01354ebaf384438083ffe0310209ef903db3e7ba5a8f584b06d28387/cymem-2.0.13-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:45dcaba0f48bef9cc3d8b0b92058640244a95a9f12542210b51318da97c2cf28", size = 250573, upload-time = "2025-11-14T14:57:44.81Z" }, + { url = "https://files.pythonhosted.org/packages/36/36/bc980b9a14409f3356309c45a8d88d58797d02002a9d794dd6c84e809d3a/cymem-2.0.13-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e96848faaafccc0abd631f1c5fb194eac0caee4f5a8777fdbb3e349d3a21741c", size = 254572, upload-time = "2025-11-14T14:57:46.023Z" }, + { url = "https://files.pythonhosted.org/packages/fd/dd/a12522952624685bd0f8968e26d2ed6d059c967413ce6eb52292f538f1b0/cymem-2.0.13-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e02d3e2c3bfeb21185d5a4a70790d9df40629a87d8d7617dc22b4e864f665fa3", size = 248060, upload-time = "2025-11-14T14:57:47.605Z" }, + { url = "https://files.pythonhosted.org/packages/08/11/5dc933ddfeb2dfea747a0b935cb965b9a7580b324d96fc5f5a1b5ff8df29/cymem-2.0.13-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fece5229fd5ecdcd7a0738affb8c59890e13073ae5626544e13825f26c019d3c", size = 254601, upload-time = "2025-11-14T14:57:48.861Z" }, + { url = "https://files.pythonhosted.org/packages/70/66/d23b06166864fa94e13a98e5922986ce774832936473578febce64448d75/cymem-2.0.13-cp313-cp313-win_amd64.whl", hash = "sha256:38aefeb269597c1a0c2ddf1567dd8605489b661fa0369c6406c1acd433b4c7ba", size = 40103, upload-time = "2025-11-14T14:57:50.396Z" }, + { url = "https://files.pythonhosted.org/packages/2f/9e/c7b21271ab88a21760f3afdec84d2bc09ffa9e6c8d774ad9d4f1afab0416/cymem-2.0.13-cp313-cp313-win_arm64.whl", hash = "sha256:717270dcfd8c8096b479c42708b151002ff98e434a7b6f1f916387a6c791e2ad", size = 36016, upload-time = "2025-11-14T14:57:51.611Z" }, + { url = "https://files.pythonhosted.org/packages/7f/28/d3b03427edc04ae04910edf1c24b993881c3ba93a9729a42bcbb816a1808/cymem-2.0.13-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7e1a863a7f144ffb345397813701509cfc74fc9ed360a4d92799805b4b865dd1", size = 46429, upload-time = "2025-11-14T14:57:52.582Z" }, + { url = "https://files.pythonhosted.org/packages/35/a9/7ed53e481f47ebfb922b0b42e980cec83e98ccb2137dc597ea156642440c/cymem-2.0.13-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c16cb80efc017b054f78998c6b4b013cef509c7b3d802707ce1f85a1d68361bf", size = 46205, upload-time = "2025-11-14T14:57:53.64Z" }, + { url = "https://files.pythonhosted.org/packages/61/39/a3d6ad073cf7f0fbbb8bbf09698c3c8fac11be3f791d710239a4e8dd3438/cymem-2.0.13-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0d78a27c88b26c89bd1ece247d1d5939dba05a1dae6305aad8fd8056b17ddb51", size = 296083, upload-time = "2025-11-14T14:57:55.922Z" }, + { url = "https://files.pythonhosted.org/packages/36/0c/20697c8bc19f624a595833e566f37d7bcb9167b0ce69de896eba7cfc9c2d/cymem-2.0.13-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6d36710760f817194dacb09d9fc45cb6a5062ed75e85f0ef7ad7aeeb13d80cc3", size = 286159, upload-time = "2025-11-14T14:57:57.106Z" }, + { url = "https://files.pythonhosted.org/packages/82/d4/9326e3422d1c2d2b4a8fb859bdcce80138f6ab721ddafa4cba328a505c71/cymem-2.0.13-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c8f30971cadd5dcf73bcfbbc5849b1f1e1f40db8cd846c4aa7d3b5e035c7b583", size = 288186, upload-time = "2025-11-14T14:57:58.334Z" }, + { url = "https://files.pythonhosted.org/packages/ed/bc/68da7dd749b72884dc22e898562f335002d70306069d496376e5ff3b6153/cymem-2.0.13-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:9d441d0e45798ec1fd330373bf7ffa6b795f229275f64016b6a193e6e2a51522", size = 290353, upload-time = "2025-11-14T14:58:00.562Z" }, + { url = "https://files.pythonhosted.org/packages/50/23/dbf2ad6ecd19b99b3aab6203b1a06608bbd04a09c522d836b854f2f30f73/cymem-2.0.13-cp313-cp313t-win_amd64.whl", hash = "sha256:d1c950eebb9f0f15e3ef3591313482a5a611d16fc12d545e2018cd607f40f472", size = 44764, upload-time = "2025-11-14T14:58:01.793Z" }, + { url = "https://files.pythonhosted.org/packages/54/3f/35701c13e1fc7b0895198c8b20068c569a841e0daf8e0b14d1dc0816b28f/cymem-2.0.13-cp313-cp313t-win_arm64.whl", hash = "sha256:042e8611ef862c34a97b13241f5d0da86d58aca3cecc45c533496678e75c5a1f", size = 38964, upload-time = "2025-11-14T14:58:02.87Z" }, + { url = "https://files.pythonhosted.org/packages/a7/2e/f0e1596010a9a57fa9ebd124a678c07c5b2092283781ae51e79edcf5cb98/cymem-2.0.13-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d2a4bf67db76c7b6afc33de44fb1c318207c3224a30da02c70901936b5aafdf1", size = 43812, upload-time = "2025-11-14T14:58:04.227Z" }, + { url = "https://files.pythonhosted.org/packages/bc/45/8ccc21df08fcbfa6aa3efeb7efc11a1c81c90e7476e255768bb9c29ba02a/cymem-2.0.13-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:92a2ce50afa5625fb5ce7c9302cee61e23a57ccac52cd0410b4858e572f8614b", size = 42951, upload-time = "2025-11-14T14:58:05.424Z" }, + { url = "https://files.pythonhosted.org/packages/01/8c/fe16531631f051d3d1226fa42e2d76fd2c8d5cfa893ec93baee90c7a9d90/cymem-2.0.13-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bc116a70cc3a5dc3d1684db5268eff9399a0be8603980005e5b889564f1ea42f", size = 249878, upload-time = "2025-11-14T14:58:06.95Z" }, + { url = "https://files.pythonhosted.org/packages/47/4b/39d67b80ffb260457c05fcc545de37d82e9e2dbafc93dd6b64f17e09b933/cymem-2.0.13-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:68489bf0035c4c280614067ab6a82815b01dc9fcd486742a5306fe9f68deb7ef", size = 252571, upload-time = "2025-11-14T14:58:08.232Z" }, + { url = "https://files.pythonhosted.org/packages/53/0e/76f6531f74dfdfe7107899cce93ab063bb7ee086ccd3910522b31f623c08/cymem-2.0.13-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:03cb7bdb55718d5eb6ef0340b1d2430ba1386db30d33e9134d01ba9d6d34d705", size = 248555, upload-time = "2025-11-14T14:58:09.429Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7c/eee56757db81f0aefc2615267677ae145aff74228f529838425057003c0d/cymem-2.0.13-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1710390e7fb2510a8091a1991024d8ae838fd06b02cdfdcd35f006192e3c6b0e", size = 254177, upload-time = "2025-11-14T14:58:10.594Z" }, + { url = "https://files.pythonhosted.org/packages/77/e0/a4b58ec9e53c836dce07ef39837a64a599f4a21a134fc7ca57a3a8f9a4b5/cymem-2.0.13-cp314-cp314-win_amd64.whl", hash = "sha256:ac699c8ec72a3a9de8109bd78821ab22f60b14cf2abccd970b5ff310e14158ed", size = 40853, upload-time = "2025-11-14T14:58:12.116Z" }, + { url = "https://files.pythonhosted.org/packages/61/81/9931d1f83e5aeba175440af0b28f0c2e6f71274a5a7b688bc3e907669388/cymem-2.0.13-cp314-cp314-win_arm64.whl", hash = "sha256:90c2d0c04bcda12cd5cebe9be93ce3af6742ad8da96e1b1907e3f8e00291def1", size = 36970, upload-time = "2025-11-14T14:58:13.114Z" }, + { url = "https://files.pythonhosted.org/packages/b7/ef/af447c2184dec6dec973be14614df8ccb4d16d1c74e0784ab4f02538433c/cymem-2.0.13-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ff036bbc1464993552fd1251b0a83fe102af334b301e3896d7aa05a4999ad042", size = 46804, upload-time = "2025-11-14T14:58:14.113Z" }, + { url = "https://files.pythonhosted.org/packages/8c/95/e10f33a8d4fc17f9b933d451038218437f9326c2abb15a3e7f58ce2a06ec/cymem-2.0.13-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:fb8291691ba7ff4e6e000224cc97a744a8d9588418535c9454fd8436911df612", size = 46254, upload-time = "2025-11-14T14:58:15.156Z" }, + { url = "https://files.pythonhosted.org/packages/e7/7a/5efeb2d2ea6ebad2745301ad33a4fa9a8f9a33b66623ee4d9185683007a6/cymem-2.0.13-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d8d06ea59006b1251ad5794bcc00121e148434826090ead0073c7b7fedebe431", size = 296061, upload-time = "2025-11-14T14:58:16.254Z" }, + { url = "https://files.pythonhosted.org/packages/0b/28/2a3f65842cc8443c2c0650cf23d525be06c8761ab212e0a095a88627be1b/cymem-2.0.13-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c0046a619ecc845ccb4528b37b63426a0cbcb4f14d7940add3391f59f13701e6", size = 285784, upload-time = "2025-11-14T14:58:17.412Z" }, + { url = "https://files.pythonhosted.org/packages/98/73/dd5f9729398f0108c2e71d942253d0d484d299d08b02e474d7cfc43ed0b0/cymem-2.0.13-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:18ad5b116a82fa3674bc8838bd3792891b428971e2123ae8c0fd3ca472157c5e", size = 288062, upload-time = "2025-11-14T14:58:20.225Z" }, + { url = "https://files.pythonhosted.org/packages/5a/01/ffe51729a8f961a437920560659073e47f575d4627445216c1177ecd4a41/cymem-2.0.13-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:666ce6146bc61b9318aa70d91ce33f126b6344a25cf0b925621baed0c161e9cc", size = 290465, upload-time = "2025-11-14T14:58:21.815Z" }, + { url = "https://files.pythonhosted.org/packages/fd/ac/c9e7d68607f71ef978c81e334ab2898b426944c71950212b1467186f69f9/cymem-2.0.13-cp314-cp314t-win_amd64.whl", hash = "sha256:84c1168c563d9d1e04546cb65e3e54fde2bf814f7c7faf11fc06436598e386d1", size = 46665, upload-time = "2025-11-14T14:58:23.512Z" }, + { url = "https://files.pythonhosted.org/packages/66/66/150e406a2db5535533aa3c946de58f0371f2e412e23f050c704588023e6e/cymem-2.0.13-cp314-cp314t-win_arm64.whl", hash = "sha256:e9027764dc5f1999fb4b4cabee1d0322c59e330c0a6485b436a68275f614277f", size = 39715, upload-time = "2025-11-14T14:58:24.773Z" }, +] + +[[package]] +name = "en-core-web-md" +version = "3.8.0" +source = { url = "https://github.com/explosion/spacy-models/releases/download/en_core_web_md-3.8.0/en_core_web_md-3.8.0-py3-none-any.whl" } +wheels = [ + { url = "https://github.com/explosion/spacy-models/releases/download/en_core_web_md-3.8.0/en_core_web_md-3.8.0-py3-none-any.whl", hash = "sha256:5e6329fe3fecedb1d1a02c3ea2172ee0fede6cea6e4aefb6a02d832dba78a310" }, +] + +[[package]] +name = "fastapi" +version = "0.115.14" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ca/53/8c38a874844a8b0fa10dd8adf3836ac154082cf88d3f22b544e9ceea0a15/fastapi-0.115.14.tar.gz", hash = "sha256:b1de15cdc1c499a4da47914db35d0e4ef8f1ce62b624e94e0e5824421df99739", size = 296263, upload-time = "2025-06-26T15:29:08.21Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/50/b1222562c6d270fea83e9c9075b8e8600b8479150a18e4516a6138b980d1/fastapi-0.115.14-py3-none-any.whl", hash = "sha256:6c0c8bf9420bd58f565e585036d971872472b4f7d3f6c73b698e10cffdefb3ca", size = 95514, upload-time = "2025-06-26T15:29:06.49Z" }, +] + +[[package]] +name = "fr-core-news-md" +version = "3.8.0" +source = { url = "https://github.com/explosion/spacy-models/releases/download/fr_core_news_md-3.8.0/fr_core_news_md-3.8.0-py3-none-any.whl" } +wheels = [ + { url = "https://github.com/explosion/spacy-models/releases/download/fr_core_news_md-3.8.0/fr_core_news_md-3.8.0-py3-none-any.whl", hash = "sha256:8a70d090a54ef77525c3ffa6a6195b9d365f2cf369ae1cd84ede93f3d709079e" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httptools" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342, upload-time = "2026-05-25T22:17:48.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/88/1d21a36da8f5cb0fa49eafd4b169eba5608d57e75bbcf61845cbc6243216/httptools-0.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:880490234c10f70a9830743097e8958d6e4b9f5a0ffc24515023afeef984054d", size = 208247, upload-time = "2026-05-25T22:17:07.843Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/cc4feea2945cb3051038f090c9b36bd5b8a9d7f5a894a506a8983e33fd1c/httptools-0.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5931891fb7b441b8a3853cf1b85c82c903defce084dd5f6771ca46e31bf862c5", size = 113064, upload-time = "2026-05-25T22:17:09.136Z" }, + { url = "https://files.pythonhosted.org/packages/e3/a6/febbb8b8db0f58b38e44ad6cb946e6a255ae49b55f2e8543408fb7501ccd/httptools-0.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b15fc622b0f869d19207c4089a501d9bcc63ca5e071ffdd2f03f922df882dcb2", size = 523851, upload-time = "2026-05-25T22:17:10.106Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e4/f90a0df0b83beff265b7e3b65f2a4cefd95792d4be0ac3e16049f2acd3c2/httptools-0.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:425f83884fd6343828d8c565f046cb72b6d19063f6924093e11bcd8e1548cd09", size = 518842, upload-time = "2026-05-25T22:17:11.218Z" }, + { url = "https://files.pythonhosted.org/packages/9e/2d/0c9ac76dd2c893841fbf6498d6acec4f2442e1b7067f6e3e316a80e494e8/httptools-0.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7c3c97f4311c7be57e2986629df89d49cb434dbff78eafcd48c2bff986b15a", size = 501238, upload-time = "2026-05-25T22:17:12.728Z" }, + { url = "https://files.pythonhosted.org/packages/ca/42/906adc91ae3a5fa9c59c0a2f21c139725bd7e5b41ae6acd485cd14123ebf/httptools-0.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a1afd7c9fbff0d9f5d489c4ce2768bd09c84a46ddefc7161e6aa82ae35c85745", size = 509567, upload-time = "2026-05-25T22:17:13.842Z" }, + { url = "https://files.pythonhosted.org/packages/05/0b/4240efeb672751ee5b9b380cb0e3fdc050bc05f68adc7a8aefc4fcd9a69a/httptools-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd96f29b4bab1d42fa6e3d008711c75e0f79e94e06827330160e3a304227f150", size = 90918, upload-time = "2026-05-25T22:17:15.155Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e5/8cfcabc5546e8022f168be28bcdaa128a240a0befdd03b59d558b4f18bd6/httptools-0.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:614ceea8ea606848bece2338ac03b3ce5324bcb4be8dc7d377ed708012fa4db8", size = 205148, upload-time = "2026-05-25T22:17:16.333Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0e/0fb14848c19a686c8062ff9067c1a48793e3224b47bc5b201535b6036fce/httptools-0.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2d689918c15a013c65ef52d9fd495d766893ab831a2c8d89f2ac5940a5df847c", size = 111368, upload-time = "2026-05-25T22:17:17.586Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/46f1cecf06b9bbde8e4b8c88034ac7908989e5ff7a3a388ef38392949c1f/httptools-0.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eb3028cca2fc0a6d720e52ef61d8ebb62fcbfeb1de56874546d858d3f25a26b7", size = 486447, upload-time = "2026-05-25T22:17:18.564Z" }, + { url = "https://files.pythonhosted.org/packages/77/00/258bfc0837221f81d9725c45f9b948a6a6b2994a147a4fb66e85100c668f/httptools-0.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88bdd940f2b5d487b4d032c6afa5489a7dc4694410d43de3c38c4fb3af0dc45d", size = 482448, upload-time = "2026-05-25T22:17:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/04/ab/d1cef3b5523f4d272a70f42a776c3169a2dddfe3a54de4b2ce4a36341528/httptools-0.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a43c9dd399758ccc0531acb0a3c4a6c299ee893ee9400e9c893b7bdcfae0681", size = 464460, upload-time = "2026-05-25T22:17:20.882Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/5d1d072442277bb2b3434e0e60690b8e8c23840ef7de8b6ea54040a536d3/httptools-0.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0770728beb05094c809b98e814edff5fef69d26ad7d21185f2f6d5884a0ba683", size = 471312, upload-time = "2026-05-25T22:17:22.085Z" }, + { url = "https://files.pythonhosted.org/packages/0d/66/b96623b27e51a68199ef4efdda0613cced9233fe3062ac74e50749c5ad37/httptools-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:7685df791fad561384bfb139e77fde27a1ffd93134e016f95a0db424ffbf77b1", size = 90117, upload-time = "2026-05-25T22:17:23.074Z" }, + { url = "https://files.pythonhosted.org/packages/1a/12/fa3fbf5f9517b273edea2dc982aa82a8c634091e67c590792b729017bc6f/httptools-0.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:de242a49b5d18e0a8776e654e9f6bf6d89f3875a5c35b425a0e7ce940feb3fd6", size = 206183, upload-time = "2026-05-25T22:17:24.004Z" }, + { url = "https://files.pythonhosted.org/packages/30/fc/5e7c4cb443370f2090a3aba0453a07384d29ff66b7435bb90e77e1037599/httptools-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:159e9ab5f701ccd42e555a12f1ad8ff69702910fc1c996cf2bb66e5fcb7a231b", size = 112079, upload-time = "2026-05-25T22:17:25.216Z" }, + { url = "https://files.pythonhosted.org/packages/ba/53/771bd891eb0f236f32145d6a1775777ec85745f3cc983a1f23d1a3b8ddfe/httptools-0.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c4a9f1707e4823d54dfec6c33fa3697d302aed536ed352a7ebb5a061ddb869d0", size = 481596, upload-time = "2026-05-25T22:17:26.186Z" }, + { url = "https://files.pythonhosted.org/packages/62/42/94e15bc68ce3d423243c45d7f1b0c7561f13844f97dc52ae23182fb65628/httptools-0.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d76ad7b951387e3632c8716a9bb03ac5b45c5f16119aa409db0459520887944e", size = 480865, upload-time = "2026-05-25T22:17:27.542Z" }, + { url = "https://files.pythonhosted.org/packages/1c/7c/fe2980fc03723272e30f135b62360b075f513dfe7cc73aef36c7f04012bd/httptools-0.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a3b7387147361c3fd47a0bde763c5c91b5b4cd4dc9989b8ece84ff436c99843b", size = 463189, upload-time = "2026-05-25T22:17:28.546Z" }, + { url = "https://files.pythonhosted.org/packages/15/1b/47fc5fff68acd1bfa20b4734059c9a06cadb88119dcd5258b5b0d21d91c8/httptools-0.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f256d6ce930c52ca1cb2a960b7da03548c454e7d28b06059ad41bfe789036ce0", size = 466610, upload-time = "2026-05-25T22:17:29.816Z" }, + { url = "https://files.pythonhosted.org/packages/60/bd/07b13c93ffd9bec9546e0d43f8e19378dd696dbd278511406bc07371ef1f/httptools-0.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:19d1ee275bb59ba2643ba9a3a1e51cc0c788caf2b8df506368e03f56fdd08527", size = 92705, upload-time = "2026-05-25T22:17:31.133Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c4/121648f68ce066d7bd762d6b6d97e620847642d38d54f3d90ff11d947629/httptools-0.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:de1ed58a974e75d56560acc7e7fed01a454994429456f65209789992e41f2568", size = 215023, upload-time = "2026-05-25T22:17:32.401Z" }, + { url = "https://files.pythonhosted.org/packages/b9/b0/312a062ae741ae3e8baa8c8bf20be81b2e67337b259ab4349bebc7b6142e/httptools-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e93c227b595c6926c1acee96891dd9da4be338cfbe82e5cd3bb9d8dd7dc4ac0b", size = 117405, upload-time = "2026-05-25T22:17:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/fc/37/fccd705f795386bb05bf413012fecff2a33e5aa8c2f069096de3e9fd8702/httptools-0.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2a021c3a8e65cc125390d72f59b968afca3bdcaff25bd67965e0a055a14946ca", size = 558497, upload-time = "2026-05-25T22:17:34.732Z" }, + { url = "https://files.pythonhosted.org/packages/bd/39/f172e8003576de35f5ba77ff417cf0e34429d35dc014deef15afa337a72c/httptools-0.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48774d39cbb70e2b1f71f88852a3087ae1d3a1eb80482bb48c13067ab080c14f", size = 571585, upload-time = "2026-05-25T22:17:35.813Z" }, + { url = "https://files.pythonhosted.org/packages/3e/b9/f5564760af99f3dbbf3f9104dc00e5da27e96cf433c6bdcf77617f70bf3f/httptools-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:88eead8ec8680a9f146c655bc88445a325bd7921cfd8194c7337e9467282427d", size = 543297, upload-time = "2026-05-25T22:17:37.08Z" }, + { url = "https://files.pythonhosted.org/packages/99/67/8d9f2c313618e161b82f3873188e7196126da1d6e29688df40eb3997c77a/httptools-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2c032fa028f46871ec7e1fc59fc15e8023eab3e6bbe6ece786a1611719a5d081", size = 539535, upload-time = "2026-05-25T22:17:38.032Z" }, + { url = "https://files.pythonhosted.org/packages/48/63/b906c01e53f50d432c0defe43ce52764a111dc1bdd028bafbeb54dcfd008/httptools-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:384c17174464c8e873398b7af24f0b1f44d992c820328413951a625323155d77", size = 108209, upload-time = "2026-05-25T22:17:39.473Z" }, +] + +[[package]] +name = "httpx" +version = "0.27.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, + { name = "sniffio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/82/08f8c936781f67d9e6b9eeb8a0c8b4e406136ea4c3d1f89a5db71d42e0e6/httpx-0.27.2.tar.gz", hash = "sha256:f7c2be1d2f3c3c3160d441802406b206c2b76f5947b11115e6df10c6c65e66c2", size = 144189, upload-time = "2024-08-27T12:54:01.334Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/95/9377bcb415797e44274b51d46e3249eba641711cf3348050f76ee7b15ffc/httpx-0.27.2-py3-none-any.whl", hash = "sha256:7bb2708e112d8fdd7829cd4243970f0c223274051cb35ee80c03301ee29a3df0", size = 76395, upload-time = "2024-08-27T12:53:59.653Z" }, +] + +[[package]] +name = "idna" +version = "3.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/f7/abb373e5757eaec4b922b92f97ec8d6d7e057cf06778247604fbc4e7c3f3/idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15", size = 215237, upload-time = "2026-08-18T05:14:24.27Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "murmurhash" +version = "1.0.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/23/2e/88c147931ea9725d634840d538622e94122bceaf346233349b7b5c62964b/murmurhash-1.0.15.tar.gz", hash = "sha256:58e2b27b7847f9e2a6edf10b47a8c8dd70a4705f45dccb7bf76aeadacf56ba01", size = 13291, upload-time = "2025-11-14T09:51:15.272Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/46/be8522d3456fdccf1b8b049c6d82e7a3c1114c4fc2cfe14b04cba4b3e701/murmurhash-1.0.15-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d37e3ae44746bca80b1a917c2ea625cf216913564ed43f69d2888e5df97db0cb", size = 27884, upload-time = "2025-11-14T09:50:13.133Z" }, + { url = "https://files.pythonhosted.org/packages/ed/cc/630449bf4f6178d7daf948ce46ad00b25d279065fc30abd8d706be3d87e0/murmurhash-1.0.15-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0861cb11039409eaf46878456b7d985ef17b6b484103a6fc367b2ecec846891d", size = 27855, upload-time = "2025-11-14T09:50:14.859Z" }, + { url = "https://files.pythonhosted.org/packages/ff/30/ea8f601a9bf44db99468696efd59eb9cff1157cd55cb586d67116697583f/murmurhash-1.0.15-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5a301decfaccfec70fe55cb01dde2a012c3014a874542eaa7cc73477bb749616", size = 134088, upload-time = "2025-11-14T09:50:15.958Z" }, + { url = "https://files.pythonhosted.org/packages/c9/de/c40ce8c0877d406691e735b8d6e9c815f36a82b499d358313db5dbe219d7/murmurhash-1.0.15-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:32c6fde7bd7e9407003370a07b5f4addacabe1556ad3dc2cac246b7a2bba3400", size = 133978, upload-time = "2025-11-14T09:50:17.572Z" }, + { url = "https://files.pythonhosted.org/packages/47/84/bd49963ecd84ebab2fe66595e2d1ed41d5e8b5153af5dc930f0bd827007c/murmurhash-1.0.15-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5d8b43a7011540dc3c7ce66f2134df9732e2bc3bbb4a35f6458bc755e48bde26", size = 132956, upload-time = "2025-11-14T09:50:18.742Z" }, + { url = "https://files.pythonhosted.org/packages/4f/7c/2530769c545074417c862583f05f4245644599f1e9ff619b3dfe2969aafc/murmurhash-1.0.15-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:43bf4541892ecd95963fcd307bf1c575fc0fee1682f41c93007adee71ca2bb40", size = 134184, upload-time = "2025-11-14T09:50:19.941Z" }, + { url = "https://files.pythonhosted.org/packages/84/a4/b249b042f5afe34d14ada2dc4afc777e883c15863296756179652e081c44/murmurhash-1.0.15-cp312-cp312-win_amd64.whl", hash = "sha256:f4ac15a2089dc42e6eb0966622d42d2521590a12c92480aafecf34c085302cca", size = 25647, upload-time = "2025-11-14T09:50:21.049Z" }, + { url = "https://files.pythonhosted.org/packages/13/bf/028179259aebc18fd4ba5cae2601d1d47517427a537ab44336446431a215/murmurhash-1.0.15-cp312-cp312-win_arm64.whl", hash = "sha256:4a70ca4ae19e600d9be3da64d00710e79dde388a4d162f22078d64844d0ebdda", size = 23338, upload-time = "2025-11-14T09:50:22.359Z" }, + { url = "https://files.pythonhosted.org/packages/29/2f/ba300b5f04dae0409202d6285668b8a9d3ade43a846abee3ef611cb388d5/murmurhash-1.0.15-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fe50dc70e52786759358fd1471e309b94dddfffb9320d9dfea233c7684c894ba", size = 27861, upload-time = "2025-11-14T09:50:23.804Z" }, + { url = "https://files.pythonhosted.org/packages/34/02/29c19d268e6f4ea1ed2a462c901eed1ed35b454e2cbc57da592fad663ac6/murmurhash-1.0.15-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1349a7c23f6092e7998ddc5bd28546cc31a595afc61e9fdb3afc423feec3d7ad", size = 27840, upload-time = "2025-11-14T09:50:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/e2/63/58e2de2b5232cd294c64092688c422196e74f9fa8b3958bdf02d33df24b9/murmurhash-1.0.15-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3ba6d05de2613535b5a9227d4ad8ef40a540465f64660d4a8800634ae10e04f", size = 133080, upload-time = "2025-11-14T09:50:26.566Z" }, + { url = "https://files.pythonhosted.org/packages/aa/9a/d13e2e9f8ba1ced06840921a50f7cece0a475453284158a3018b72679761/murmurhash-1.0.15-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fa1b70b3cc2801ab44179c65827bbd12009c68b34e9d9ce7125b6a0bd35af63c", size = 132648, upload-time = "2025-11-14T09:50:27.788Z" }, + { url = "https://files.pythonhosted.org/packages/b2/e1/47994f1813fa205c84977b0ff51ae6709f8539af052c7491a5f863d82bdc/murmurhash-1.0.15-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:213d710fb6f4ef3bc11abbfad0fa94a75ffb675b7dc158c123471e5de869f9af", size = 131502, upload-time = "2025-11-14T09:50:29.339Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ea/90c1fd00b4aeb704fb5e84cd666b33ffd7f245155048071ffbb51d2bb57d/murmurhash-1.0.15-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b65a5c4e7f5d71f7ccac2d2b60bdf7092d7976270878cfec59d5a66a533db823", size = 132736, upload-time = "2025-11-14T09:50:30.545Z" }, + { url = "https://files.pythonhosted.org/packages/00/db/da73462dbfa77f6433b128d2120ba7ba300f8c06dc4f4e022c38d240a5f5/murmurhash-1.0.15-cp313-cp313-win_amd64.whl", hash = "sha256:9aba94c5d841e1904cd110e94ceb7f49cfb60a874bbfb27e0373622998fb7c7c", size = 25682, upload-time = "2025-11-14T09:50:31.624Z" }, + { url = "https://files.pythonhosted.org/packages/bb/83/032729ef14971b938fbef41ee125fc8800020ee229bd35178b6ede8ee934/murmurhash-1.0.15-cp313-cp313-win_arm64.whl", hash = "sha256:263807eca40d08c7b702413e45cca75ecb5883aa337237dc5addb660f1483378", size = 23370, upload-time = "2025-11-14T09:50:33.264Z" }, + { url = "https://files.pythonhosted.org/packages/10/83/7547d9205e9bd2f8e5dfd0b682cc9277594f98909f228eb359489baec1df/murmurhash-1.0.15-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:694fd42a74b7ce257169d14c24aa616aa6cd4ccf8abe50eca0557e08da99d055", size = 29955, upload-time = "2025-11-14T09:50:34.488Z" }, + { url = "https://files.pythonhosted.org/packages/b7/c7/3afd5de7a5b3ae07fe2d3a3271b327ee1489c58ba2b2f2159bd31a25edb9/murmurhash-1.0.15-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a2ea4546ba426390beff3cd10db8f0152fdc9072c4f2583ec7d8aa9f3e4ac070", size = 30108, upload-time = "2025-11-14T09:50:35.53Z" }, + { url = "https://files.pythonhosted.org/packages/02/69/d6637ee67d78ebb2538c00411f28ea5c154886bbe1db16c49435a8a4ab16/murmurhash-1.0.15-cp313-cp313t-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:34e5a91139c40b10f98d0b297907f5d5267b4b1b2e5dd2eb74a021824f751b98", size = 164054, upload-time = "2025-11-14T09:50:36.591Z" }, + { url = "https://files.pythonhosted.org/packages/ab/4c/89e590165b4c7da6bf941441212a721a270195332d3aacfdfdf527d466ca/murmurhash-1.0.15-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:dc35606868a5961cf42e79314ca0bddf5a400ce377b14d83192057928d6252ec", size = 168153, upload-time = "2025-11-14T09:50:37.856Z" }, + { url = "https://files.pythonhosted.org/packages/07/7a/95c42df0c21d2e413b9fcd17317a7587351daeb264dc29c6aec1fdbd26f8/murmurhash-1.0.15-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:43cc6ac3b91ca0f7a5ae9c063ba4d6c26972c97fd7c25280ecc666413e4c5535", size = 164345, upload-time = "2025-11-14T09:50:39.346Z" }, + { url = "https://files.pythonhosted.org/packages/d0/22/9d02c880a88b83bb3ce7d6a38fb727373ab78d82e5f3d8d9fc5612219f90/murmurhash-1.0.15-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:847d712136cb462f0e4bd6229ee2d9eb996d8854eb8312dff3d20c8f5181fda5", size = 161990, upload-time = "2025-11-14T09:50:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/750232524e0dc262e8dcede6536dafc766faadd9a52f1d23746b02948ad8/murmurhash-1.0.15-cp313-cp313t-win_amd64.whl", hash = "sha256:2680851af6901dbe66cc4aa7ef8e263de47e6e1b425ae324caa571bdf18f8d58", size = 28812, upload-time = "2025-11-14T09:50:41.971Z" }, + { url = "https://files.pythonhosted.org/packages/ff/89/4ad9d215ef6ade89f27a72dc4e86b98ef1a43534cc3e6a6900a362a0bf0a/murmurhash-1.0.15-cp313-cp313t-win_arm64.whl", hash = "sha256:189a8de4d657b5da9efd66601b0636330b08262b3a55431f2379097c986995d0", size = 25398, upload-time = "2025-11-14T09:50:43.023Z" }, + { url = "https://files.pythonhosted.org/packages/1c/69/726df275edf07688146966e15eaaa23168100b933a2e1a29b37eb56c6db8/murmurhash-1.0.15-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7c4280136b738e85ff76b4bdc4341d0b867ee753e73fd8b6994288080c040d0b", size = 28029, upload-time = "2025-11-14T09:50:44.124Z" }, + { url = "https://files.pythonhosted.org/packages/59/8f/24ecf9061bc2b20933df8aba47c73e904274ea8811c8300cab92f6f82372/murmurhash-1.0.15-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d4d681f474830489e2ec1d912095cfff027fbaf2baa5414c7e9d25b89f0fab68", size = 27912, upload-time = "2025-11-14T09:50:45.266Z" }, + { url = "https://files.pythonhosted.org/packages/ba/26/fff3caba25aa3c0622114e03c69fb66c839b22335b04d7cce91a3a126d44/murmurhash-1.0.15-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d7e47c5746785db6a43b65fac47b9e63dd71dfbd89a8c92693425b9715e68c6e", size = 131847, upload-time = "2025-11-14T09:50:46.819Z" }, + { url = "https://files.pythonhosted.org/packages/df/e4/0f2b9fc533467a27afb4e906c33f32d5f637477de87dd94690e0c44335a6/murmurhash-1.0.15-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e8e674f02a99828c8a671ba99cd03299381b2f0744e6f25c29cadfc6151dc724", size = 132267, upload-time = "2025-11-14T09:50:48.298Z" }, + { url = "https://files.pythonhosted.org/packages/da/bf/9d1c107989728ec46e25773d503aa54070b32822a18cfa7f9d5f41bc17a5/murmurhash-1.0.15-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:26fd7c7855ac4850ad8737991d7b0e3e501df93ebaf0cf45aa5954303085fdba", size = 131894, upload-time = "2025-11-14T09:50:49.485Z" }, + { url = "https://files.pythonhosted.org/packages/0d/81/dcf27c71445c0e993b10e33169a098ca60ee702c5c58fcbde205fa6332a6/murmurhash-1.0.15-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cb8ebafae60d5f892acff533cc599a359954d8c016a829514cb3f6e9ee10f322", size = 132054, upload-time = "2025-11-14T09:50:50.747Z" }, + { url = "https://files.pythonhosted.org/packages/bc/32/e874a14b2d2246bd2d16f80f49fad393a3865d4ee7d66d2cae939a67a29a/murmurhash-1.0.15-cp314-cp314-win_amd64.whl", hash = "sha256:898a629bf111f1aeba4437e533b5b836c0a9d2dd12d6880a9c75f6ca13e30e22", size = 26579, upload-time = "2025-11-14T09:50:52.278Z" }, + { url = "https://files.pythonhosted.org/packages/af/8e/4fca051ed8ae4d23a15aaf0a82b18cb368e8cf84f1e3b474d5749ec46069/murmurhash-1.0.15-cp314-cp314-win_arm64.whl", hash = "sha256:88dc1dd53b7b37c0df1b8b6bce190c12763014492f0269ff7620dc6027f470f4", size = 24341, upload-time = "2025-11-14T09:50:53.295Z" }, + { url = "https://files.pythonhosted.org/packages/38/9c/c72c2a4edd86aac829337ab9f83cf04cdb15e5d503e4c9a3a243f30a261c/murmurhash-1.0.15-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:6cb4e962ec4f928b30c271b2d84e6707eff6d942552765b663743cfa618b294b", size = 30146, upload-time = "2025-11-14T09:50:54.705Z" }, + { url = "https://files.pythonhosted.org/packages/ac/d7/72b47ebc86436cd0aa1fd4c6e8779521ec389397ac11389990278d0f7a47/murmurhash-1.0.15-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5678a3ea4fbf0cbaaca2bed9b445f556f294d5f799c67185d05ffcb221a77faf", size = 30141, upload-time = "2025-11-14T09:50:55.829Z" }, + { url = "https://files.pythonhosted.org/packages/64/bb/6d2f09135079c34dc2d26e961c52742d558b320c61503f273eab6ba743d9/murmurhash-1.0.15-cp314-cp314t-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ef19f38c6b858eef83caf710773db98c8f7eb2193b4c324650c74f3d8ba299e0", size = 163898, upload-time = "2025-11-14T09:50:56.946Z" }, + { url = "https://files.pythonhosted.org/packages/b9/e2/9c1b462e33f9cb2d632056f07c90b502fc20bd7da50a15d0557343bd2fed/murmurhash-1.0.15-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22aa3ceaedd2e57078b491ed08852d512b84ff4ff9bb2ff3f9bf0eec7f214c9e", size = 168040, upload-time = "2025-11-14T09:50:58.234Z" }, + { url = "https://files.pythonhosted.org/packages/e8/73/8694db1408fcdfa73589f7df6c445437ea146986fa1e393ec60d26d6e30c/murmurhash-1.0.15-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bba0e0262c0d08682b028cb963ac477bd9839029486fa1333fc5c01fb6072749", size = 164239, upload-time = "2025-11-14T09:50:59.95Z" }, + { url = "https://files.pythonhosted.org/packages/2d/f9/8e360bdfc3c44e267e7e046f0e0b9922766da92da26959a6963f597e6bb5/murmurhash-1.0.15-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4fd8189ee293a09f30f4931408f40c28ccd42d9de4f66595f8814879339378bc", size = 161811, upload-time = "2025-11-14T09:51:01.289Z" }, + { url = "https://files.pythonhosted.org/packages/f9/31/97649680595b1096803d877ababb9a67c07f4378f177ec885eea28b9db6d/murmurhash-1.0.15-cp314-cp314t-win_amd64.whl", hash = "sha256:66395b1388f7daa5103db92debe06842ae3be4c0749ef6db68b444518666cdcc", size = 29817, upload-time = "2025-11-14T09:51:02.493Z" }, + { url = "https://files.pythonhosted.org/packages/76/66/4fce8755f25d77324401886c00017c556be7ca3039575b94037aff905385/murmurhash-1.0.15-cp314-cp314t-win_arm64.whl", hash = "sha256:c22e56c6a0b70598a66e456de5272f76088bc623688da84ef403148a6d41851d", size = 26219, upload-time = "2025-11-14T09:51:03.563Z" }, +] + +[[package]] +name = "numpy" +version = "2.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/80/db0b4559e57ec36362bedbb05530a87fafbcb6067708c946967a41d449e7/numpy-2.5.2.tar.gz", hash = "sha256:d482d171c406ae88c5b19cad3b6a1c4c5209f886ab74bc44c2c865c23f52d860", size = 20773161, upload-time = "2026-08-09T13:48:27.962Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/72/dccb0aaf40972777283303919f613964227266d0c13adebb79ac124f1c3e/numpy-2.5.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:14e373cfc6387177e8409dac3c7159be8eb05cd77096cd7c950268b86f62831c", size = 16891693, upload-time = "2026-08-09T13:44:51.702Z" }, + { url = "https://files.pythonhosted.org/packages/60/2e/b5aee50a1f74ac815cf8331812cb8251e29024025de462e0c047641c614c/numpy-2.5.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4bbd96c833ecc8cc069ce518078fc8c60cb9cbfb0fea5b7a803ad65035596d03", size = 11903109, upload-time = "2026-08-09T13:44:55.501Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f4/29e78102a80601cf034d4e9767022cffeca2c3b4c926e1754572ca95593d/numpy-2.5.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:6e8172ddfcf5cf74b811d372b570b83c60bd2de87a6fbfbebdadb4a9bd9c6cbb", size = 5350202, upload-time = "2026-08-09T13:44:58.401Z" }, + { url = "https://files.pythonhosted.org/packages/11/4b/dcd3b7eadaf4035d2c7a4289d232523a6964f602598ef7674e4bd7291f93/numpy-2.5.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:65f188481f1669e26f62b701e8205d19e460fa4a9b52a1414ba382330e4a3414", size = 6687736, upload-time = "2026-08-09T13:45:00.813Z" }, + { url = "https://files.pythonhosted.org/packages/e5/21/4947e0e9d6c9fc2e2ff15b8949049ee44f63adb9cacc729ab8793f97e712/numpy-2.5.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8ee9c4eeb8454b3660a8b53493563c3e121c2fc94fbd72b848ef814ed7b676a9", size = 15612696, upload-time = "2026-08-09T13:45:04.151Z" }, + { url = "https://files.pythonhosted.org/packages/3a/5f/62d28cf019460c7f1394105b4d49d9911a9c444cb77ab0bd95a204c5a6de/numpy-2.5.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3cdec01fa790a186d430433fdd4d4ffb70eed6f0eeb4bf05c8dbe2dce0a9bcb8", size = 16722264, upload-time = "2026-08-09T13:45:07.714Z" }, + { url = "https://files.pythonhosted.org/packages/14/25/3f0be4c1b9fdf5dd5e708a6806978564d7c46a055c000496309ff2a2f8af/numpy-2.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7999d4ddb0c4025018373fd787510d46e04c769467af22869707b3c1cfd459ab", size = 16974396, upload-time = "2026-08-09T13:45:11.316Z" }, + { url = "https://files.pythonhosted.org/packages/22/72/6262cbdeeb45da9d971e40715f579d791603ba8ec0b5e2db1ac55454421d/numpy-2.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c1f017dc0875c9209d219f97feceb7d54c2661bb243deb4114478e1295808af7", size = 18476044, upload-time = "2026-08-09T13:45:14.869Z" }, + { url = "https://files.pythonhosted.org/packages/36/33/29208b8b075bde62d26a81d14b358c42b0f69b6cabd98d4ff97f37f22b05/numpy-2.5.2-cp312-cp312-win32.whl", hash = "sha256:d6a48072864e3324e194a8fbb3c657bcc5b5c869dbc64c9537b1d5c862572c0a", size = 6072817, upload-time = "2026-08-09T13:45:17.867Z" }, + { url = "https://files.pythonhosted.org/packages/7f/b9/87fea2769fe1c47c1b5b01d8310772c9d1a85d485de7cf386ef7a3332b02/numpy-2.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:28ac63476ec7651484215ee7fa15a1f78b57c14621f01e392afe17b9a1390ce4", size = 12464674, upload-time = "2026-08-09T13:45:20.734Z" }, + { url = "https://files.pythonhosted.org/packages/14/52/032b97e00461ab0809bbe4c588b035620e5a14b8cdee47ecddefc7b17d33/numpy-2.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:27650bb0e7140fa3d37b9923b4803645e0b125d190f326eecfd3f4dad8e8ade1", size = 10397131, upload-time = "2026-08-09T13:45:23.73Z" }, + { url = "https://files.pythonhosted.org/packages/f5/d2/6b24738a0ef4557d189b150046cd07823c50e4273e8aebd651222e24306f/numpy-2.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8e4cb9a754c8a0c62eaa88273a5fba3391f4a610d1dee893c0755da31c083f15", size = 16886595, upload-time = "2026-08-09T13:45:27.323Z" }, + { url = "https://files.pythonhosted.org/packages/65/60/f2d208d366f263f39c6e69ed309290717aab41078b6d04c9be2a84fa2a07/numpy-2.5.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:52c808f96484f5571a5cc863775ce50247c17dfb3b0361f8ed6b4b0456f80080", size = 11896845, upload-time = "2026-08-09T13:45:31.638Z" }, + { url = "https://files.pythonhosted.org/packages/3c/79/81e0bf24f4d020a2b1d5cd297a9f60c3f24eeb116f9bba5870443f7b6a4a/numpy-2.5.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:29d81e97f668489cba8ebfd796b9bdd453525d35dd9e162e2daec94bf3fc7740", size = 5343880, upload-time = "2026-08-09T13:45:34.373Z" }, + { url = "https://files.pythonhosted.org/packages/ba/cc/e3141cf06d1a8a2c7e107543fe1269c1d1af760d4d683c0794a4ee1127c2/numpy-2.5.2-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:afb3f0632d6b2e3ba04dbce8d1e48d321b369138b73830b5ca371a0e8d479d56", size = 6682264, upload-time = "2026-08-09T13:45:36.7Z" }, + { url = "https://files.pythonhosted.org/packages/29/f1/2a64a307d92c5d98f5255a4014eb43bb6103ee477087b61ecae44a3aa9b9/numpy-2.5.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0aadf13b60048d501e05fa699efaf7734e2494f3498a4c2a5521d822640324f3", size = 15609566, upload-time = "2026-08-09T13:45:39.518Z" }, + { url = "https://files.pythonhosted.org/packages/7b/44/59a1eb68e773c4098d107ef34a0dbdeca501d72ffcfbff9a7707343921ce/numpy-2.5.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29b86ff8a6cc556b47ec6b64b194815cc80e6bf5eedcc6cddfd65318cb0b4eee", size = 16709995, upload-time = "2026-08-09T13:45:43.661Z" }, + { url = "https://files.pythonhosted.org/packages/8a/4c/3e54d4ddbc359a1295f8b633e8106bcd4d7d4a206e82df051bdfb3058755/numpy-2.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6950c4b7dd562453090548ba7f5da7e59f57f85663f15d5dcc60e249192f7e59", size = 16972511, upload-time = "2026-08-09T13:45:47.094Z" }, + { url = "https://files.pythonhosted.org/packages/f2/9f/02e371638ebf19b66d46231e4be52999e87f32d1961b113bc45656608b22/numpy-2.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b9727f472d2f3888053b8a75ab0cb94745a9de224bb5846dbadc0092101bc71d", size = 18465609, upload-time = "2026-08-09T13:45:50.808Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ae/ad6645abc7a3510fe48e8ea1ab4598166f500057ef4ebf38bfad4f1577de/numpy-2.5.2-cp313-cp313-win32.whl", hash = "sha256:4f9744f9fbdcea0bc552e8f19e1f141f811a3f9bc2be2cc6e86d982cab23e3f4", size = 6070204, upload-time = "2026-08-09T13:45:54.111Z" }, + { url = "https://files.pythonhosted.org/packages/15/20/f3489f86d81ea460b2bcdceaed094142ca6579f6be0ec527b781d39afe68/numpy-2.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:85aaccb24182c25df891ad0ec333585967e115269d5f1b17f2c9ae005bc96657", size = 12460532, upload-time = "2026-08-09T13:45:57.167Z" }, + { url = "https://files.pythonhosted.org/packages/d5/21/35b31dde1b283b79de828b80f876afd8c94e28fe1e9c375f89e261cc4c0d/numpy-2.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:bd68ece1553d2023c09a4226d9e41c586ad2d20594d1a456186c33513d2cb3f2", size = 10396725, upload-time = "2026-08-09T13:46:00.478Z" }, + { url = "https://files.pythonhosted.org/packages/ac/f8/c3b222bf075b50afd8e949a07a15c4b312a4a84bd8102a332bcd953cbbb4/numpy-2.5.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d787cf769c3baeb5f6235e778edb52c08dfa923789b5958f28e6450f96107cb1", size = 16885180, upload-time = "2026-08-09T13:46:03.939Z" }, + { url = "https://files.pythonhosted.org/packages/17/e1/2c1d4b1987795a92b5bbf7c24fe249ab96aa2573ab0d7604802c189d7b86/numpy-2.5.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:24b9dc2e3d84aa58523798805194e23e736f3f6ce2d1a5b92583ae734e6dbda8", size = 11907878, upload-time = "2026-08-09T13:46:07.045Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ee/d08226fc858044355983a6e5b94f08ff6f3969e0a2b160a4a89f0ddb3445/numpy-2.5.2-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:9e9413326d726c2545bfa65d2c0876871e8d8386e77f992c1d426e180bbd4323", size = 5354922, upload-time = "2026-08-09T13:46:10.04Z" }, + { url = "https://files.pythonhosted.org/packages/94/f0/6d3d933056440ebbc5e6bad92065fc6c26a48a84a36b1208580e94eea76c/numpy-2.5.2-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:60e902ac295855348a5ca2ea4c89108989a9f5fddfad3dfc0a8f36b10358567e", size = 6679168, upload-time = "2026-08-09T13:46:12.275Z" }, + { url = "https://files.pythonhosted.org/packages/c4/3b/ecd49dd90033cceb2704d88ca905d4d7d89b0e8c739608754ffd325fa820/numpy-2.5.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50e500dc868e9313530ce12ba470fe50ff3afe3d62993ed6eff652dacd555b65", size = 15624501, upload-time = "2026-08-09T13:46:15.322Z" }, + { url = "https://files.pythonhosted.org/packages/c7/99/461bd36dbdfac6c1c53efa370bd55a83227542d0d118f1677dbf1a3dacd5/numpy-2.5.2-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:318b9a4c845dbea06708a29c84ee429cc3065048db34cdb799047643492050ee", size = 16713701, upload-time = "2026-08-09T13:46:18.949Z" }, + { url = "https://files.pythonhosted.org/packages/f9/9c/2b251df9e8a5d647b62b0cbc1b90a91850c1cf4859ecb532fd0b4eacff6c/numpy-2.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:34c319e2963be042673fb46570501b2f06c41924e17e3563d58646b4380dfb68", size = 16986065, upload-time = "2026-08-09T13:46:23.006Z" }, + { url = "https://files.pythonhosted.org/packages/8f/25/20de43f53ff1390534a124475055a19f01fe10c920a0fd11b8e18d6d6052/numpy-2.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f06571a052127dc1b4e8b83029b4d1b20daa2b64a31cdd181fc6bc774e9000eb", size = 18470031, upload-time = "2026-08-09T13:46:27.102Z" }, + { url = "https://files.pythonhosted.org/packages/56/5e/0c577ca308d6da5eb79b546ba10bbe5b60148192194e2da060913b1de4f1/numpy-2.5.2-cp314-cp314-win32.whl", hash = "sha256:2cc779226e476d1e1f08c74068c419e60f41a9e0e069c92f6671d31d5c985e98", size = 6121028, upload-time = "2026-08-09T13:46:30.046Z" }, + { url = "https://files.pythonhosted.org/packages/15/5c/7bcbd5b11f94199073320410cddcbb80cee62415bfeb540874b265c2d922/numpy-2.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:7587f53dfbd5edc0f7b87c6217b4c6d2d1f2ef9c3da70bc1315e7db5f8d7ec9d", size = 12597627, upload-time = "2026-08-09T13:46:32.886Z" }, + { url = "https://files.pythonhosted.org/packages/87/bc/4d0b06fba0da90ccc75af62823cb9dcedb6c9ea0cffa058cb2c9ee773a77/numpy-2.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:3e4c367352d3747784248a227fbec218e193b56f7e6692e3b64fc805478ecfdf", size = 10680414, upload-time = "2026-08-09T13:46:36.036Z" }, + { url = "https://files.pythonhosted.org/packages/cd/17/f429aac9dc08833a0d0f188eba38c532a751b1a1f2ca6018a37b455cb321/numpy-2.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b879fb674276e331513fb136b78dbc6bd3c848309e0d841cfd63be3896c4cfc1", size = 12026967, upload-time = "2026-08-09T13:46:39.084Z" }, + { url = "https://files.pythonhosted.org/packages/ca/9f/d0849de96a2a4ceaa16662f18ee13eaa9c0aa418269fdc8c4857c56b11da/numpy-2.5.2-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:fd0d703772bba096843785bd38371e31bb4a0c1151497ad5739d182114a73f7f", size = 5473874, upload-time = "2026-08-09T13:46:42.075Z" }, + { url = "https://files.pythonhosted.org/packages/89/3c/8df216d4a4a5422a3de045301cf7df8ea47286d76f5cb7160b0128ac26b7/numpy-2.5.2-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:3a2f061cebd9e3d23bdcfaaded5e2293a4c6a5b60fa42df85d410a725ce621bf", size = 6789276, upload-time = "2026-08-09T13:46:44.387Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3a/20d7e9891c4ddfadd6ff8d95bf4b29f353d8e1770553de2099880551dfb9/numpy-2.5.2-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6df895598c0edcb41030126c89e0f353b07d93238116143b7405e937359736c4", size = 15659154, upload-time = "2026-08-09T13:46:47.538Z" }, + { url = "https://files.pythonhosted.org/packages/aa/d6/f3aa3d2688bf501b858835c6bd087ae9b51a56ae6fca8e2b0990abd177af/numpy-2.5.2-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1ab3d4a901f844ea836c3e80bf463c6a27d7f3c14e8e292fcf28d348b25b9bce", size = 16748909, upload-time = "2026-08-09T13:46:51.442Z" }, + { url = "https://files.pythonhosted.org/packages/7d/8f/1c5cae8d2baf86ab802ae97a00be55bc7e21ebc11b12bbc33376c5f05342/numpy-2.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:cebc2d6dbb605a7703d59751dea4bd6b0ab127a5a4338a6f432df1936fef8b26", size = 17027685, upload-time = "2026-08-09T13:46:55.095Z" }, + { url = "https://files.pythonhosted.org/packages/5c/27/71d3467404aedc1c24ce79610f91b52b0b0f466c43a701aa56fc75c145ab/numpy-2.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eaca7ff36f0f52e2111ec71f169d8fd3e889e7ddc0d2592e0d703fd8d3ce8fac", size = 18501181, upload-time = "2026-08-09T13:46:59.09Z" }, + { url = "https://files.pythonhosted.org/packages/14/2f/42921d27c40aea7e077f4a423ae509fd9220b028cd787bafefd8ab2b3a5f/numpy-2.5.2-cp314-cp314t-win32.whl", hash = "sha256:ddf47472af2e4280d79bac82304f5e80150211f1b9e614b760061d5fdfbb6eba", size = 6271085, upload-time = "2026-08-09T13:47:01.903Z" }, + { url = "https://files.pythonhosted.org/packages/75/e6/bad5f5d56de9b1971bac959963dda276d35c40f1854475005434bbe08692/numpy-2.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:44ef9675d908e65f9953063837c3277730f3f4437615a4cdab67b366cabaf884", size = 12787971, upload-time = "2026-08-09T13:47:04.963Z" }, + { url = "https://files.pythonhosted.org/packages/df/05/f608795cb34391acd67e38d94a3c36abd8d8576293a3a80727d7595c372c/numpy-2.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:eaa088384c46f519dacb93b7ec483a6d6b19a4a2085ae4f25ab9b1c43d387d1e", size = 10750306, upload-time = "2026-08-09T13:47:07.976Z" }, + { url = "https://files.pythonhosted.org/packages/33/c6/28de0191c5f82b7d42a0a51390ba98587048aa93a39fafb05bdbe6e8d00c/numpy-2.5.2-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:078f9b027b478c9379b9677babbf0f8b8f1ecfada27636d7b9a93990c638739f", size = 16885274, upload-time = "2026-08-09T13:47:11.439Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/973ca116000d244897e468ea1aff30b589e5022e3c8744b71706fe33bd57/numpy-2.5.2-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:50a68f4bacd8a2b33d8da3d2269d0d78500f86ea582e4786dc10f5ef2c2c6842", size = 11907846, upload-time = "2026-08-09T13:47:15.128Z" }, + { url = "https://files.pythonhosted.org/packages/78/d9/8c4b3937ef204cb2fd88d389ccd0f265a2ffb11f35a01d2064cf46714bd6/numpy-2.5.2-cp315-cp315-macosx_14_0_arm64.whl", hash = "sha256:e79aba74ffaf5f78a050d777c184cddf8fdffabab38acf5f3ef1fecbc17895d6", size = 5354892, upload-time = "2026-08-09T13:47:18.07Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/b6ee65ea2999fdb7023935e108e6fb776ee4082aa15f159acfa857e578c8/numpy-2.5.2-cp315-cp315-macosx_14_0_x86_64.whl", hash = "sha256:9a0731745a72a184490a582fb4af2533512bd071ace67785b5fdffc0ae58dce8", size = 6679309, upload-time = "2026-08-09T13:47:20.456Z" }, + { url = "https://files.pythonhosted.org/packages/43/f3/acb18d8b137a393c8e7803a8c994c9e64bde3930692a69d826993113a159/numpy-2.5.2-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4ec954036759bcee3aa484f8603bd9c14f3e776293b85578b8734c2d72777c69", size = 15625850, upload-time = "2026-08-09T13:47:24.365Z" }, + { url = "https://files.pythonhosted.org/packages/a9/bf/a8e9bb0db815a0e265b5744ebedd3af0bd5faad8604e5b50a1cd012f3c91/numpy-2.5.2-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc649493697006bc90614a5f0bbc8cb3cb1866715c474e473694968d7e6b99ab", size = 16713664, upload-time = "2026-08-09T13:47:27.965Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c3/6e913736b3dd6582344af32418b5fb9dab34282e8a8174ae1d54ceb0fc13/numpy-2.5.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:cf7de32f486e4ac9e2d93b810f9e9ac72a728dd46a32a0bb403222f27f653514", size = 16986749, upload-time = "2026-08-09T13:47:31.541Z" }, + { url = "https://files.pythonhosted.org/packages/80/09/7d3b23eff5c7428ef6c01e6f7052bb60d504c4d33e317b36b8959c24ad97/numpy-2.5.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2ffa7bacab3e2ee1b19ed31766bb60bb380b68c23f051e199c5cc598afd68710", size = 18470495, upload-time = "2026-08-09T13:47:35.364Z" }, + { url = "https://files.pythonhosted.org/packages/a5/a4/68a321d825374f6eb677ffe8ef8c6b9a328304e6fd2e39d9530822776607/numpy-2.5.2-cp315-cp315-win32.whl", hash = "sha256:6b588cc8f902d6bff201c19fd00c43ab8545671e3554d014e12e14139e5e8617", size = 6120696, upload-time = "2026-08-09T13:47:38.561Z" }, + { url = "https://files.pythonhosted.org/packages/c8/23/deafbb1700f79fae9cd1e91220f133d124cc267de1b584da3fbf6db2f6cd/numpy-2.5.2-cp315-cp315-win_amd64.whl", hash = "sha256:07d4e89f3a9ab0a9ba24264ccdb642b3dd951b2281e8883a5481a4aa79cc31a7", size = 12597324, upload-time = "2026-08-09T13:47:41.401Z" }, + { url = "https://files.pythonhosted.org/packages/33/cd/3272ba105e3bbbdaeb11357eda31e7a6825ffe159e8171665660299a948f/numpy-2.5.2-cp315-cp315-win_arm64.whl", hash = "sha256:a610dc7e3c52edd39c2bc2375ff9c3fd59cb3ad00e4472d36f83bc1457145788", size = 10680466, upload-time = "2026-08-09T13:47:44.873Z" }, + { url = "https://files.pythonhosted.org/packages/0e/0e/58370637b1bb70a5c9ce2b43f4b521ccb224e36ccb76a6596b17ae4b447c/numpy-2.5.2-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:40f4d451aed46a8046a1aae41c4e55fb3612273df9c502480135e1501576a34b", size = 16993947, upload-time = "2026-08-09T13:47:48.97Z" }, + { url = "https://files.pythonhosted.org/packages/10/93/2abcb807712b289d6d60fe4cf30532f98974a8396d885650f3ba5a13026e/numpy-2.5.2-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:c081cbe16ba1ab53078e5ff29013621e33c509eedab055775d956427712c236e", size = 12025331, upload-time = "2026-08-09T13:47:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/8b/3a/2898e003a5fbaf87e76c039b4ee1f5eb390471b4ffe74887c1f34c4e791e/numpy-2.5.2-cp315-cp315t-macosx_14_0_arm64.whl", hash = "sha256:0090ccdd57ec2703e9b49d0bf554767370581c1dd0a6b2bb2b2d9def317d042a", size = 5472336, upload-time = "2026-08-09T13:47:55.403Z" }, + { url = "https://files.pythonhosted.org/packages/61/a5/23f69d07c544597b29758b31b55c27dc9d541012a2c1496189fef702aec2/numpy-2.5.2-cp315-cp315t-macosx_14_0_x86_64.whl", hash = "sha256:6a9bb119fb8dd21ba30b3f0e555b7e2b081bd9883af21ec9c1c633d161cda3a8", size = 6788387, upload-time = "2026-08-09T13:47:58.192Z" }, + { url = "https://files.pythonhosted.org/packages/15/ea/c0dbdbcf22f43782510a3e492dd3da73c6112b69cac8929d16d127536fc4/numpy-2.5.2-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a839318485284a6fb31be4f8f2c91c8f2cb22f4543c4a8903f12b0671ffe07cc", size = 15667096, upload-time = "2026-08-09T13:48:01.562Z" }, + { url = "https://files.pythonhosted.org/packages/fc/5e/29c73c31748cdb0f7566642125ba17fd5b56780cddf891b085dab27e4466/numpy-2.5.2-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba0a474801b8dc67b66bf465548abc90e82b44d2611b5770f33008dcabffe8ec", size = 16751730, upload-time = "2026-08-09T13:48:05.706Z" }, + { url = "https://files.pythonhosted.org/packages/47/95/02501e8454796bb58dadf7a99d3181e0b464bf264e1003039572f9779fac/numpy-2.5.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:0a4035ae1129ff8777f08bfbd44f1e5d8e9c049ce0c2dd78fc0d92c13e7251c0", size = 17038686, upload-time = "2026-08-09T13:48:09.627Z" }, + { url = "https://files.pythonhosted.org/packages/0e/b5/53a681d91b5c82687067d8ea5035e02d917b5509d6f334cb06484a954714/numpy-2.5.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:77843ca236b777e67f8d6b3660ea116e499612703a0ecd7093f316201eb9d8e2", size = 18507727, upload-time = "2026-08-09T13:48:13.744Z" }, + { url = "https://files.pythonhosted.org/packages/42/06/6e11443f7b64ee376c860506091103bf68f92d2cab9e8d96d4501babf07c/numpy-2.5.2-cp315-cp315t-win32.whl", hash = "sha256:7354826bc6f8f69402e9b7fe28d15fcd34feebd74f856f111585c5b0c9fb0251", size = 6269775, upload-time = "2026-08-09T13:48:17.543Z" }, + { url = "https://files.pythonhosted.org/packages/f1/18/195d6b86cd72dbbc501edfa778005fa6b87afd34c153e46028cd3a0938f4/numpy-2.5.2-cp315-cp315t-win_amd64.whl", hash = "sha256:e5651f3f87add730ee6608d915009e19c911fba0cb000c7e3ea994b7d768eb12", size = 12782559, upload-time = "2026-08-09T13:48:21.023Z" }, + { url = "https://files.pythonhosted.org/packages/b4/07/458c344f0f0c178f4481dad5cca790626ffe4c34eabf9467069d06ee4999/numpy-2.5.2-cp315-cp315t-win_arm64.whl", hash = "sha256:5f8e00be2ec6f45f4e8a41a527f68d44a7d96fee92a650e4d8b1326f77f61e6e", size = 10748103, upload-time = "2026-08-09T13:48:24.21Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "preshed" +version = "3.0.13" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cymem" }, + { name = "murmurhash" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/75/fe6b7bbd0dea530a001b0e24c331b21a0be2786e402abf3c57f5dce43d4b/preshed-3.0.13.tar.gz", hash = "sha256:d75f718bbfd97e992f7827e0fa7faf6a91bdd9c922d5baa4b50d62731396cb89", size = 18338, upload-time = "2026-03-23T08:57:31.378Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/fb/ccff23c44c04088c248539005fcda78b9014512a34d170c5360f02ad908b/preshed-3.0.13-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5d14eea14bd01291388928991d7df7d60b9fd19ae970e55006eb4d29b0c1e8eb", size = 138497, upload-time = "2026-03-23T08:56:35.321Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ce/cad5a8145881a771e6c0d002f2e585fc19b962f120860b54d32af5baa342/preshed-3.0.13-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f05b08ce92399c0655b5e0eb5a1cc1f9e295703ed3aabdfaf6538dfa8ae23d57", size = 138010, upload-time = "2026-03-23T08:56:36.399Z" }, + { url = "https://files.pythonhosted.org/packages/a7/a2/c5fed4fb3e946699259d11e4036a3cfdd8c89b3e542e3077d46781642425/preshed-3.0.13-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:62cf7f3113132891d6bba70ff547ad81c6fe50a31930bbbb8499f1d47cd122b7", size = 861498, upload-time = "2026-03-23T08:56:37.67Z" }, + { url = "https://files.pythonhosted.org/packages/51/94/8c9bc48a6ea4903f53a1a0031ce8e35687526949f25821762ef21493c007/preshed-3.0.13-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8b8de3f58043070a354477995acdd98626ce43e4193c708ebd0f694e467f5155", size = 868988, upload-time = "2026-03-23T08:56:39.324Z" }, + { url = "https://files.pythonhosted.org/packages/b6/df/ecd2f40055ff52527ca117ffbfafb888c1a3079b59fbabe03c5b8f9b7240/preshed-3.0.13-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:183b339956a9e1d7a4a00038a3b9587a734db9e8bd915939a49791bd1b372156", size = 1847382, upload-time = "2026-03-23T08:56:40.89Z" }, + { url = "https://files.pythonhosted.org/packages/e6/88/bdb244e40284ded3632a9f88c23bc80230bd7b2ae4a8b7f2cc91adead7a8/preshed-3.0.13-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2e77bed56aded7cbe5d28d6bd2178bc5b13eda0e0e464dab205fb578fa915000", size = 1919236, upload-time = "2026-03-23T08:56:42.616Z" }, + { url = "https://files.pythonhosted.org/packages/a0/c9/c91ea56342e6c364fc69b444a1ac5432327857199c44032c9cc9dc4c3a23/preshed-3.0.13-cp312-cp312-win_amd64.whl", hash = "sha256:04d8f13f2986e5d11af5ac51f55ce3106c70c41b483d20ea392e6180bdd0f870", size = 122938, upload-time = "2026-03-23T08:56:44.271Z" }, + { url = "https://files.pythonhosted.org/packages/b2/0b/6a99d99619fd83b14c696e2489caed7070647488d4d3ac0b723d35db2de0/preshed-3.0.13-cp312-cp312-win_arm64.whl", hash = "sha256:19318dc1cd8cac6663c6c830bf7e0002d2de853769fb03e056774e97c21bedfd", size = 109194, upload-time = "2026-03-23T08:56:45.346Z" }, + { url = "https://files.pythonhosted.org/packages/0e/2a/401158195d6dc7f6aef0b354d74d0e95c9da124499448c2b3dbb95b71204/preshed-3.0.13-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c0d0c14187dc0078d8a63bf190ec045a4d13e7748b6caeb557a7d575e411410b", size = 137289, upload-time = "2026-03-23T08:56:46.516Z" }, + { url = "https://files.pythonhosted.org/packages/88/8f/e20e64573988528785447a6893b2e7ab287ecfd85b3888e978b28812fd20/preshed-3.0.13-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7770987c2e57497cd26124a9be5f652b5b3ccd0def89859ab0da8bca6144a3de", size = 136847, upload-time = "2026-03-23T08:56:47.572Z" }, + { url = "https://files.pythonhosted.org/packages/b9/72/18168f881359c4482d312f8dc196371bdd61c1583a52b34390da4c88bbea/preshed-3.0.13-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4a7bc48220de579be6bdb0a8715482cf36e2a625a6fd5ad26c9f43485a4a23b5", size = 831478, upload-time = "2026-03-23T08:56:48.769Z" }, + { url = "https://files.pythonhosted.org/packages/fd/3a/3543476091087102775568cea9885dde3453569e9aeee365809108de572f/preshed-3.0.13-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e5c8462472f790c16708306aef3a102a762bd19dfe3d2f8ee08bd5e12f51b835", size = 839913, upload-time = "2026-03-23T08:56:49.937Z" }, + { url = "https://files.pythonhosted.org/packages/cf/65/b13f01329decc44ef53cfb6b4601ba85382dcb2a4ec78d9250f03a418066/preshed-3.0.13-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c046736239cc8d72670749b79b526e4111839a2fc461a58545d212797649129c", size = 1816452, upload-time = "2026-03-23T08:56:51.233Z" }, + { url = "https://files.pythonhosted.org/packages/d1/c7/f1a996c6832234efd4d543041b582418d41ac480ee55c557ec9e65344637/preshed-3.0.13-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7c333f18e9a81c8a6de0603fd8781e17115324b117c445ca91abdf7bfb1abe49", size = 1888978, upload-time = "2026-03-23T08:56:52.591Z" }, + { url = "https://files.pythonhosted.org/packages/e3/b9/96fb71499049885ce19545903fdd38877bbc2be0da47e37c04d01f3e9f66/preshed-3.0.13-cp313-cp313-win_amd64.whl", hash = "sha256:461327f8dd36520dcf1fd55a671e0c3c2c97a2d95e22fc85faa31173f4785dda", size = 122134, upload-time = "2026-03-23T08:56:54.392Z" }, + { url = "https://files.pythonhosted.org/packages/ef/a7/32a4903019d936a2316fdd330bedddac287ac26326107d24fb76a1fbc60a/preshed-3.0.13-cp313-cp313-win_arm64.whl", hash = "sha256:35d6c5acb3ee3b12b87a551913063f0cec784055c2af16e028c19fe875f079d0", size = 108497, upload-time = "2026-03-23T08:56:55.816Z" }, + { url = "https://files.pythonhosted.org/packages/bb/b5/993886c98f5caaa6f07a648cac97a7c62a3093091cad65e1e43a1bd41cc4/preshed-3.0.13-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d2f1efae396cadab5f3890a2fd43d2ee65373ef9096ccbb805e51e8d8bcc563b", size = 137882, upload-time = "2026-03-23T08:56:56.878Z" }, + { url = "https://files.pythonhosted.org/packages/c6/86/b7fd137cbf140afd6c45e895946068a15f5b55642916de0075e6eb18581c/preshed-3.0.13-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8d6acc1f5031a535a55a6f7148e2f274554a8343a16309c700cebea0fe7aee8c", size = 138233, upload-time = "2026-03-23T08:56:58.318Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ca/21a7e79625614134273dfed32bca5bb4c2ec1313e33fbd12d41657536f1f/preshed-3.0.13-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7da9d931e7660dcdd757e5870269f0c159126d682ed73ed313971d199eb0f334", size = 834835, upload-time = "2026-03-23T08:56:59.48Z" }, + { url = "https://files.pythonhosted.org/packages/8f/3a/2dbd299516461831ae90e0d5b0637137bf28520c4e6dd0b01d6f1886659a/preshed-3.0.13-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d4ae5cfe075bb7a07982e382bca44f41ddf041f4d24cbd358e8cccfc049259b8", size = 834928, upload-time = "2026-03-23T08:57:01.075Z" }, + { url = "https://files.pythonhosted.org/packages/7c/d3/af654eba4f6587c4ee02c5043e62c194b0a1c4431ffef0c67b9518f6b61c/preshed-3.0.13-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7557963d0125a3a7bcdb2eb6948f3e45da31b5a7f066b55320de3dea22d7557f", size = 1820368, upload-time = "2026-03-23T08:57:02.351Z" }, + { url = "https://files.pythonhosted.org/packages/bf/9b/ebcb2b9e8cb881e40b55b0bf450f8a6b187e2ef3ae0c685cce81d2d85026/preshed-3.0.13-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c4bc60dc994864095d784b7e4d77dba3e64188d169ac88722b699d175561fddb", size = 1888251, upload-time = "2026-03-23T08:57:04.158Z" }, + { url = "https://files.pythonhosted.org/packages/97/f7/c6c012779edcaa6e2cd092c554e98dc53e77f41205b07208655ba77e2327/preshed-3.0.13-cp314-cp314-win_amd64.whl", hash = "sha256:208dcebbe294bf1881ce33fb015d56ab2a7587aece85a09147727174207892e4", size = 125211, upload-time = "2026-03-23T08:57:05.83Z" }, + { url = "https://files.pythonhosted.org/packages/f8/82/390ef87d732ef64e673ef6bf9e5d898453986e979efa50fb3a400e2c0766/preshed-3.0.13-cp314-cp314-win_arm64.whl", hash = "sha256:cf8e1a7a1823b2a7765121446c630140ac6e8650c07a6efbf375e168d1fef4f7", size = 111942, upload-time = "2026-03-23T08:57:06.996Z" }, + { url = "https://files.pythonhosted.org/packages/80/3a/a9dde3167bcecb27ae82ce4567b5ab1aa3989113ae6814c092ce223cc4ef/preshed-3.0.13-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9ca43ecbc3783eda4d6ab3416ae2ecd9ef23dca5f53995843f69f7457bcd0677", size = 144997, upload-time = "2026-03-23T08:57:08.064Z" }, + { url = "https://files.pythonhosted.org/packages/74/d4/22d9355b50b6a13b407dcad0a81df83fb1d5602092d1f05834674dde8fda/preshed-3.0.13-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c8596e41a258ff213553a441e0bb3eb388fd8158e84a7bf3aae6d8ede2c166d3", size = 147294, upload-time = "2026-03-23T08:57:09.411Z" }, + { url = "https://files.pythonhosted.org/packages/70/42/a225ee83fdb306d2a503f21a627953b820f4e079c90c8a84338957cb8ff5/preshed-3.0.13-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4f8856ca3d88e9b250630d70abb4f260d8933151ddfb413024784b25b009868e", size = 952110, upload-time = "2026-03-23T08:57:10.592Z" }, + { url = "https://files.pythonhosted.org/packages/40/ba/09a9dfe3d22d7e745483fd5d7f2a82cd4d39c161f7d2daa0faa4bd6402be/preshed-3.0.13-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e5b2865aecbd2e1e10e5d19bb8bfad765863c1307c6c3e51f2a08bd64122409", size = 932217, upload-time = "2026-03-23T08:57:12.124Z" }, + { url = "https://files.pythonhosted.org/packages/6c/5c/e10e2e05133e7fcbd7c40536af1148c82dd24357b8f5726e2c7bc51cfd53/preshed-3.0.13-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:09f96b477c987755b3c945df214ea1c1c80bfb350e9f34e78da89585535b77e8", size = 1896542, upload-time = "2026-03-23T08:57:13.525Z" }, + { url = "https://files.pythonhosted.org/packages/37/aa/51e5b4109a4cdfae28c3613eeeb10764a3794ebef8de93ffbb109465bea3/preshed-3.0.13-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:670db59a52e1823b5f088c764df474e65b686592d4093adbeef14581c95ee2cb", size = 1959473, upload-time = "2026-03-23T08:57:15.706Z" }, + { url = "https://files.pythonhosted.org/packages/0e/6a/1d966f367a14c703dde629d150d996c1b727d442f620300b21c9ec1a24d1/preshed-3.0.13-cp314-cp314t-win_amd64.whl", hash = "sha256:b03e21b0bf95eb56e23973f32cabb930e94f352228652f81c0955dbd6967d904", size = 146229, upload-time = "2026-03-23T08:57:17.457Z" }, + { url = "https://files.pythonhosted.org/packages/22/80/368139067603e590a000122355f9c8576c8ebed4fb0b8849feaa2698489d/preshed-3.0.13-cp314-cp314t-win_arm64.whl", hash = "sha256:b980f3ea9bb74b7f94464bc3d6eb3c9162b6b79b531febd14c6465c24344d2cc", size = 119339, upload-time = "2026-03-23T08:57:18.882Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/ca/31c57507b13119d7d3cfa1576dad2911a4861e3be07b579395f4e9d393f9/pydantic_settings-2.15.0.tar.gz", hash = "sha256:694b793e84f766ba76a90ebdefc01d0a9a045dab0382bee70393da93712ad117", size = 261253, upload-time = "2026-08-07T09:24:57.419Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/a4/2bffa9f8e804325a09867f0e9d30795c80ea9f8d62560bd1b6ad6220eb2f/pydantic_settings-2.15.0-py3-none-any.whl", hash = "sha256:0ba092c291c94baceb5eff768aa0d56400a457585bc0175925a5a5510303da42", size = 69413, upload-time = "2026-08-07T09:24:55.839Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pytest" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/53/ed9d74092561d4b01a2ef1349d52cdbc135e526c245f366b089cfca6de49/python_dotenv-1.2.3.tar.gz", hash = "sha256:a20a594dabeaa385725aa239d5244871c143ecb356add8a20fcf23773a6c3a35", size = 58945, upload-time = "2026-08-16T16:54:54.067Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/17/c5c6b53ddc18f297992099b3d9ec16c855c0ccc83263a21fe4d1c625ec6c/python_dotenv-1.2.3-py3-none-any.whl", hash = "sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9", size = 22780, upload-time = "2026-08-16T16:54:52.473Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "setuptools" +version = "84.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/44/f5da03a8ef95d369145c5bb53050e7877c9f3d312e128605fd9504829143/setuptools-84.0.0.tar.gz", hash = "sha256:f4695c21257f0d9b537ec2692c941d02ee143b7cc1276941349a546573b2ef73", size = 1168449, upload-time = "2026-08-08T18:27:58.365Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/9c/c510029fc6ef33a6275cd2c5d3cecd6613dfd6aa401d57c54f1c18852ccf/setuptools-84.0.0-py3-none-any.whl", hash = "sha256:51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670", size = 818216, upload-time = "2026-08-08T18:27:56.719Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "smart-open" +version = "8.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/53/9c513747547fd595d5c143259129ea8b9c3ea2f6b7bb9dcea2b1966ded3c/smart_open-8.0.1.tar.gz", hash = "sha256:18b1c4496003c6902be17c15f032b5c319f307c89c6ae9e6b028b508bed8b2cf", size = 61882, upload-time = "2026-07-15T13:56:10.492Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/96/325b8c507ccecc50421fecc0345a502ee6e4a44785af3c4e6ecbadad624a/smart_open-8.0.1-py3-none-any.whl", hash = "sha256:3e97f90e92a952cb57863dfe132082c400a52eeeb27c067692fb51dbcc5b0089", size = 73504, upload-time = "2026-07-15T13:56:09.033Z" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "spacy" +version = "3.8.16" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "catalogue" }, + { name = "click" }, + { name = "confection" }, + { name = "cymem" }, + { name = "jinja2" }, + { name = "murmurhash" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "preshed" }, + { name = "pydantic" }, + { name = "requests" }, + { name = "setuptools" }, + { name = "spacy-legacy" }, + { name = "spacy-loggers" }, + { name = "srsly" }, + { name = "thinc" }, + { name = "tqdm" }, + { name = "typer" }, + { name = "wasabi" }, + { name = "weasel" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/5d/b0b4cd2f6e8a0470e50f7f0cfc1cea6e2f25572e97fb5d404c7941d5e88a/spacy-3.8.16.tar.gz", hash = "sha256:a3d19da23637cc396b42d22fc33852680f675d8ddcb847d3e2a0d094712d1794", size = 1330989, upload-time = "2026-08-24T10:05:57.936Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/b2/33e8e4cac876090c5d8b4016b23ca2647f66c2a87c518ea010047e78ee1f/spacy-3.8.16-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e045765035e9760f38637101f41a7c89d3b69659dcc70e716bb4011a95969ac9", size = 6565218, upload-time = "2026-08-24T10:04:38.825Z" }, + { url = "https://files.pythonhosted.org/packages/d2/1e/247e43597b10576eccfed55af7999663f00ae934394acebef724cabdc1b7/spacy-3.8.16-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:44a641085abbe3a09ea56a89f2e50b5f51aea6cf69213b70305fb48e341b883b", size = 6460523, upload-time = "2026-08-24T10:04:40.873Z" }, + { url = "https://files.pythonhosted.org/packages/0e/8c/2b150ea6e9b667b710e7365328edfdd3c6729af4e48124baa80c4784c26b/spacy-3.8.16-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2c46c35467d963a62dc0407c99d3a18562d4c85ee887a57e4a07dde020f1a38", size = 34901687, upload-time = "2026-08-24T10:04:44.138Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9c/3b5d64a72ac40cf584134c6639ac67f8f2d504eb081c91da3ad2ffae5c04/spacy-3.8.16-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8d0f63c3124d0a34a37e9b519d004cee1269744be07f91f843902e6c9f3e557a", size = 35496883, upload-time = "2026-08-24T10:04:48.383Z" }, + { url = "https://files.pythonhosted.org/packages/45/16/8c9d9afed3afbfd585e876c330ec8dcff878a64bc99c108355c8b59ea954/spacy-3.8.16-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:397a80c4d09a6237eebaaee00a2e5f8732ff4cc165f94679b899f30179d38ea8", size = 35012008, upload-time = "2026-08-24T10:04:52.983Z" }, + { url = "https://files.pythonhosted.org/packages/32/dd/eeaf28004591f0d282da809194d9f7bfb6a1c0626ca0d7d69e8b5436751f/spacy-3.8.16-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2810fd2ce41f6a8dde62642dad0d11fd9db7cb6a1cd40b1c8b70a01586e6ff9f", size = 36028110, upload-time = "2026-08-24T10:04:56.874Z" }, + { url = "https://files.pythonhosted.org/packages/4e/c0/4122fa6c3670d504ad5a3094c9b512f0d0908f66cefeb8c8c3349f8c6a28/spacy-3.8.16-cp312-cp312-win_amd64.whl", hash = "sha256:5991c334e71c23b798c25e0d403295dde4d2d1fb58c2e075450b964db03c05ea", size = 15180961, upload-time = "2026-08-24T10:05:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/36/55/2fd66419866455289c090cd177faf7d3ee360f36b2eea5e4c74b2c541a58/spacy-3.8.16-cp312-cp312-win_arm64.whl", hash = "sha256:770cc0581fc06c0723cb1488dc1d1da0570801168da786674626f342d903ed37", size = 14552405, upload-time = "2026-08-24T10:05:03.382Z" }, + { url = "https://files.pythonhosted.org/packages/a5/5a/ebe53a3cd1edf5f8cb4b9465ae2383b0b4f6a2c8bd96afa0408f682df4bb/spacy-3.8.16-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f42f257404e749d9048d3b3b97004692210057d38e03c2f156817258bf6daf2b", size = 6585155, upload-time = "2026-08-24T10:05:05.818Z" }, + { url = "https://files.pythonhosted.org/packages/29/f8/80f9ddf288c494b3a7b737bff3b938ef70328c49aaf0a90738aa90b638b8/spacy-3.8.16-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ce120d4077050352f344b987354be3e3fddb207436b537cf91a891837657ce2c", size = 6465153, upload-time = "2026-08-24T10:05:08.32Z" }, + { url = "https://files.pythonhosted.org/packages/7d/5f/b039865cf4e2fa82c8defc737c37af4480e70a56d1e1c380865b3df89f54/spacy-3.8.16-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f765cb6cbef82b5d98c46936a1385e87fb05919433fbc6b953e3c093ac30f8ef", size = 34527537, upload-time = "2026-08-24T10:05:11.65Z" }, + { url = "https://files.pythonhosted.org/packages/12/48/60048a3558f591fbaf11a73b342262699178cf44dd6fb88827118be34335/spacy-3.8.16-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:111d817b32755d869e5ed6cc258b55c50c6687f47b78f6ebb2c14b1ce5ee707c", size = 35151231, upload-time = "2026-08-24T10:05:15.911Z" }, + { url = "https://files.pythonhosted.org/packages/96/6e/3e09ebd5635e1c6a2b92baee5d593a907908ce1b38fb1f011dbb0d66c411/spacy-3.8.16-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8a8a2bf3eb3486a0992176b77ac1d38ca9c669623941fdb8d3dddacb44dfd28e", size = 34649694, upload-time = "2026-08-24T10:05:20.225Z" }, + { url = "https://files.pythonhosted.org/packages/78/8c/b31440943778f8c6a63dc568df5d0d3b4a58c9472784416474e71d04eca5/spacy-3.8.16-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a237491463e351755f0167546f6a821d42971c5275a219a62d468f19f654138b", size = 35664934, upload-time = "2026-08-24T10:05:24.369Z" }, + { url = "https://files.pythonhosted.org/packages/55/f4/a613999ef17bf8252d4e6a62609b9d16a932e1cfd56f9c682e5dd82d91ba/spacy-3.8.16-cp313-cp313-win_amd64.whl", hash = "sha256:cc7e449aec9a313bc037ef5ea45fb0ac99135d92dace8421d68414d16be39543", size = 15163063, upload-time = "2026-08-24T10:05:27.428Z" }, + { url = "https://files.pythonhosted.org/packages/15/6e/97039de4f188ae3c69b5de9c852b4c5c5252896d2bc3d9ed1af93655ba1d/spacy-3.8.16-cp313-cp313-win_arm64.whl", hash = "sha256:024ce6408ea00c7f8c6387a6de65bb67aad40c51c9d63705303c5cb9a8ef51b0", size = 14540066, upload-time = "2026-08-24T10:05:30.354Z" }, + { url = "https://files.pythonhosted.org/packages/3a/18/d495cb375546ea29f74224854e605daa08a3e438530aa0d311ad11699833/spacy-3.8.16-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:dc17227717aa254b63c90161d8de4ec672ca5bd8e5c92effba2a8510498ee355", size = 6603726, upload-time = "2026-08-24T10:05:32.809Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c9/6f12f115f672627f7cc9cc10201b6ae2e59f1907b30f38cf48e120108942/spacy-3.8.16-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39304dd9800065c09fa440983ac75cf444469b159959171c0e0d761b3e854d62", size = 6508263, upload-time = "2026-08-24T10:05:34.775Z" }, + { url = "https://files.pythonhosted.org/packages/f2/68/ec6fbae239df6e1ba1b8c7187fb9d3654b908c9789be5288004b8665fd56/spacy-3.8.16-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04fd0206c9f33a0542a40049211528742b5492ef5f971d06b151b9cc49b9237b", size = 34427589, upload-time = "2026-08-24T10:05:38.32Z" }, + { url = "https://files.pythonhosted.org/packages/c4/1c/439d28bda90d057e0688c80c89487174e8dae4988268abea461edaf4f28b/spacy-3.8.16-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c51eac85344784ca7b184f0c3f7da0fca47c354d63e05e733d90cae35a2ecc4", size = 34800189, upload-time = "2026-08-24T10:05:42.371Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c8/5b2392b10f6e7f1d2f8851bef3ddf5a62c949912ef842a62c7f191d6cadd/spacy-3.8.16-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b741266d901222dde979a802d5e9f3cf3d9bf77a15be3137b387f62905a74d57", size = 34587881, upload-time = "2026-08-24T10:05:46.195Z" }, + { url = "https://files.pythonhosted.org/packages/64/60/89d411d014ba7edc9603cdacacb7df88ca2b5a7cbde2771dff3f72a7c29d/spacy-3.8.16-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5e32a51b115674d3f42c6cde696c583dc1594f5bff6b430de4aba4c753d47f93", size = 35369907, upload-time = "2026-08-24T10:05:49.947Z" }, + { url = "https://files.pythonhosted.org/packages/bb/22/f3f45881d0f5cd7c3ee4011f4c5ffafa76d4ee520649b3eb04440d6dd67c/spacy-3.8.16-cp314-cp314-win_amd64.whl", hash = "sha256:86227a0a0d3dfee3f3dcc15f73c1387b586d77b075348afc250ffafea88ffcec", size = 15202134, upload-time = "2026-08-24T10:05:52.922Z" }, + { url = "https://files.pythonhosted.org/packages/24/2f/0f2470625e61a3f58792e8fd94ac5fd0682057c9918cac09c3aef3ca203d/spacy-3.8.16-cp314-cp314-win_arm64.whl", hash = "sha256:15908539b375bd8e3c627a076dac793b8fd790c5945f3a696837dd70776a4fc0", size = 14603383, upload-time = "2026-08-24T10:05:55.59Z" }, +] + +[[package]] +name = "spacy-legacy" +version = "3.0.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d9/79/91f9d7cc8db5642acad830dcc4b49ba65a7790152832c4eceb305e46d681/spacy-legacy-3.0.12.tar.gz", hash = "sha256:b37d6e0c9b6e1d7ca1cf5bc7152ab64a4c4671f59c85adaf7a3fcb870357a774", size = 23806, upload-time = "2023-01-23T09:04:15.104Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/55/12e842c70ff8828e34e543a2c7176dac4da006ca6901c9e8b43efab8bc6b/spacy_legacy-3.0.12-py2.py3-none-any.whl", hash = "sha256:476e3bd0d05f8c339ed60f40986c07387c0a71479245d6d0f4298dbd52cda55f", size = 29971, upload-time = "2023-01-23T09:04:13.45Z" }, +] + +[[package]] +name = "spacy-loggers" +version = "1.0.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/67/3d/926db774c9c98acf66cb4ed7faf6c377746f3e00b84b700d0868b95d0712/spacy-loggers-1.0.5.tar.gz", hash = "sha256:d60b0bdbf915a60e516cc2e653baeff946f0cfc461b452d11a4d5458c6fe5f24", size = 20811, upload-time = "2023-09-11T12:26:52.323Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/78/d1a1a026ef3af911159398c939b1509d5c36fe524c7b644f34a5146c4e16/spacy_loggers-1.0.5-py3-none-any.whl", hash = "sha256:196284c9c446cc0cdb944005384270d775fdeaf4f494d8e269466cfa497ef645", size = 22343, upload-time = "2023-09-11T12:26:50.586Z" }, +] + +[[package]] +name = "spacy-lookups-data" +version = "1.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fd/42/b747618ec64be73023b84c9eed7a09f5a345f514f367369b863f6a1dbf4f/spacy_lookups_data-1.0.5.tar.gz", hash = "sha256:6f935c81f145bdcc84fc6115f648764285c7ff3e8ff246295046814e96dad63c", size = 98442761, upload-time = "2023-07-28T12:01:14.833Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/9e/dae3acaacc7cbe8140acb181e09b9920f8b3ee81d2f1cd838d160c78f0c2/spacy_lookups_data-1.0.5-py2.py3-none-any.whl", hash = "sha256:466f21f087e4144bc93800679437ec5a17be7d0888734b1ba880b3ecb0978bc6", size = 98458367, upload-time = "2023-07-28T12:01:09.513Z" }, +] + +[[package]] +name = "srsly" +version = "2.5.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "catalogue" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2b/db/f794f219a6c788b881252d2536a8c4a97d2bdaadc690391e1cb53d123d71/srsly-2.5.3.tar.gz", hash = "sha256:08f98dbecbff3a31466c4ae7c833131f59d3655a0ad8ac749e6e2c149e2b0680", size = 490881, upload-time = "2026-03-23T11:56:59.865Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/cc/e9f7fcec4cc92ad8bad6316c4241638b8cf7380382d4489d94ec6c436452/srsly-2.5.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:71e51c046ccbeefb86524c6b1e17574f579c6ac4dc8ea4a09437d3e8f88342d3", size = 658379, upload-time = "2026-03-23T11:55:59.85Z" }, + { url = "https://files.pythonhosted.org/packages/21/e4/fea4512e9785f58509b2cf67d993323848e583161b5fcfdc7dd9d7c1f3df/srsly-2.5.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2f73c0db911552e94fe2016e1759d261d2f47926f68826664cada3723c87006a", size = 658513, upload-time = "2026-03-23T11:56:01.239Z" }, + { url = "https://files.pythonhosted.org/packages/20/b1/53591681b6ff2699a4f97b2d5552ba196eaa6a979b0873605f4c04b5f7ee/srsly-2.5.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c1ac27ae5f4bb9163c7d2c45fc8ec173aac3d92e32086d9472b326c5c6e570e", size = 1172265, upload-time = "2026-03-23T11:56:02.589Z" }, + { url = "https://files.pythonhosted.org/packages/4e/c9/741e29f534919a944a16da4184924b1d3404c4bf60716ab2b91be771d1e3/srsly-2.5.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:99026bcd9cbd3211cc36517400b04ca0fc5d3e412b14daf84ee6e65f67d9a2d8", size = 1180873, upload-time = "2026-03-23T11:56:03.944Z" }, + { url = "https://files.pythonhosted.org/packages/89/57/5554f786eccf78b2750d6ac63be126e1b67badec2cb409dd611cf6f8c52b/srsly-2.5.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:07d682679e639eb46ff7e6da4a92714f4d5ffe351d088ee66f221e9b1f8865bb", size = 1120437, upload-time = "2026-03-23T11:56:05.283Z" }, + { url = "https://files.pythonhosted.org/packages/eb/95/9b4f73b1be3692f86d72ccc131c8e50f26f824d5c8830a59390bcc5b60ef/srsly-2.5.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8e0542d85d6b55cf2934050d6ffcb1cd76c768dcf9572e7467002cf087bb366d", size = 1137376, upload-time = "2026-03-23T11:56:06.613Z" }, + { url = "https://files.pythonhosted.org/packages/5a/de/89ca640ca1953c4612279ce515d0af35658df3c06cdb324329bc91b4a7e1/srsly-2.5.3-cp312-cp312-win_amd64.whl", hash = "sha256:598f1e494c18cacb978299d77125415a586417081959f8ec3f068b32d97f8933", size = 652459, upload-time = "2026-03-23T11:56:07.994Z" }, + { url = "https://files.pythonhosted.org/packages/6d/4f/7ab6d49e36d9cc72ee15746cabd116eb6f338be8a06c1882968ee9d6c7d7/srsly-2.5.3-cp312-cp312-win_arm64.whl", hash = "sha256:4b1b721cd3ad1a9b2343519aadc786a4d09d5c0666962d49852eb12d6ec3fe26", size = 638411, upload-time = "2026-03-23T11:56:09.31Z" }, + { url = "https://files.pythonhosted.org/packages/9d/5c/12901e3794f4158abc6da750725aad6c2afddb1e4227b300fe7c71f66957/srsly-2.5.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e67b6bbacbfadea5e100266d2797f2d4cec9883ea4dc84a5537673850036a8d8", size = 656750, upload-time = "2026-03-23T11:56:10.708Z" }, + { url = "https://files.pythonhosted.org/packages/04/61/181c26370995f96f56f1b64b801e3ca1e0d703fc36506ae28606d62369fb/srsly-2.5.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:348c231b4477d8fe86603131d0f166d2feac9c372704dfc4398be71cc5b6fb07", size = 656746, upload-time = "2026-03-23T11:56:12.28Z" }, + { url = "https://files.pythonhosted.org/packages/77/c6/35876c78889f8ffe11ed3521644e666c3aef20ea31527b70f47456cf35c2/srsly-2.5.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b0938c2978c91ae1ef9c1f2ba35abb86330e198fb23469e356eba311e02233ee", size = 1155762, upload-time = "2026-03-23T11:56:14.075Z" }, + { url = "https://files.pythonhosted.org/packages/3e/da/40b71ca9906c8eb8f8feb6ac11d33dad458c85a56e1de764b96d402168a0/srsly-2.5.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5f6a837954429ecbe6dcdd27390d2fb4c7d01a3f99c9ffcf9ce66b2a6dd1b738", size = 1161092, upload-time = "2026-03-23T11:56:15.778Z" }, + { url = "https://files.pythonhosted.org/packages/dc/14/c0dd30cc8b93ce8137ff4766f743c882440ce49195fffc5d50eaeef311a6/srsly-2.5.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3576c125c486ce2958c2047e8858fe3cfc9ea877adfa05203b0986f9badee355", size = 1109984, upload-time = "2026-03-23T11:56:17.056Z" }, + { url = "https://files.pythonhosted.org/packages/08/f3/34354f183d8faafc631585571224b54d1b4b67e796972c36519c074ca355/srsly-2.5.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5fb59c42922e095d1ea36085c55bc16e2adb06a7bfe57b24d381e0194ae699f2", size = 1128409, upload-time = "2026-03-23T11:56:18.761Z" }, + { url = "https://files.pythonhosted.org/packages/a4/d9/5531f8a19492060b4e76e4ab06aca6f096fb5128fe18cc813d1772daf653/srsly-2.5.3-cp313-cp313-win_amd64.whl", hash = "sha256:111805927f05f5db440aeeacb85ce43da0b19ce7b2a09567a9ef8d30f3cc4d83", size = 650820, upload-time = "2026-03-23T11:56:20.096Z" }, + { url = "https://files.pythonhosted.org/packages/8e/8a/62fb7a971eca29e12f03fb9ddacb058548c14d33e5b5675ff0f85839cc7b/srsly-2.5.3-cp313-cp313-win_arm64.whl", hash = "sha256:0f106b0a700ab56e4a7c431b0f1444009ab6cb332edc7bbf6811c2a43f4722cb", size = 637278, upload-time = "2026-03-23T11:56:21.439Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5b/e4ef43c2a381711230af98d4c94a5323df48d6a7899ee652e05bf889290e/srsly-2.5.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:39c13d552a9f9674a12cdcdc66b0c2f02f3430d0cd04c5f9cf598824c2bd3d65", size = 661294, upload-time = "2026-03-23T11:56:23.29Z" }, + { url = "https://files.pythonhosted.org/packages/92/2d/ebce7f3717e52cd0a01f4ec570f388f3b7098526794fcf1ad734e0b8f852/srsly-2.5.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:14c930767cc169611a2dc14e23bc7638cfb616d6f79029700ade033607343540", size = 660952, upload-time = "2026-03-23T11:56:24.908Z" }, + { url = "https://files.pythonhosted.org/packages/22/47/a8f3e9b214be2624c8e8a78d38ca7b1d4e26b92d57018412e4bfc4abe89a/srsly-2.5.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2f2d464f0d0237e32fb53f0ec6f05418652c550e772b50e9918e83a1577cba4d", size = 1154554, upload-time = "2026-03-23T11:56:26.608Z" }, + { url = "https://files.pythonhosted.org/packages/d6/71/2a89dc3180a51e633a87a079ca064225f4aaf46c7b2a5fc720e28f261d98/srsly-2.5.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d18933248a5bb0ad56a1bae6003a9a7f37daac2ecb0c5bcbfaaf081b317e1c84", size = 1155746, upload-time = "2026-03-23T11:56:28.102Z" }, + { url = "https://files.pythonhosted.org/packages/b8/36/72e5ce3153927ca404b6f5bf5280e6ff3399c11557df472b153945468e0a/srsly-2.5.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7ea5412ea229e571ac9738cbe14f845cc06c8e4e956afb5f42061ccd087ef31f", size = 1112374, upload-time = "2026-03-23T11:56:29.591Z" }, + { url = "https://files.pythonhosted.org/packages/04/b2/0895de109c28eca0d41a811ab7c076d4e4a505e8466f06bae22f5180a1dd/srsly-2.5.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8d3988970b4cf7d03bdd5b5169302ff84562dd2e1e0f84aeb34df3e5b5dc19bf", size = 1127732, upload-time = "2026-03-23T11:56:31.458Z" }, + { url = "https://files.pythonhosted.org/packages/c7/79/a37fa7759797fbdfe0a2e029ab13e78b1e81e191220d2bb8ff57d869aefb/srsly-2.5.3-cp314-cp314-win_amd64.whl", hash = "sha256:6a02d7dcc16126c8fae1c1c09b2072798a1dc482ab5f9c52b12c7114dac47325", size = 656467, upload-time = "2026-03-23T11:56:33.14Z" }, + { url = "https://files.pythonhosted.org/packages/d7/25/0dae019b3b90ad9037f91de4c390555cdaac9460a93ad62b02b03babdff5/srsly-2.5.3-cp314-cp314-win_arm64.whl", hash = "sha256:1c9129c4abe31903ff7996904a51afdd5428060de6c3d12af49a4da5e8df2821", size = 643040, upload-time = "2026-03-23T11:56:34.448Z" }, + { url = "https://files.pythonhosted.org/packages/3a/44/72dd5285b2e05435d98b0797f101d91d9b345d491ddc1fdb9bd09e27ccb8/srsly-2.5.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:29d5d01ba4c2e9c01f936e5e6d5babc4a47b38c9cbd6e1ec23f6d5a49df32605", size = 666200, upload-time = "2026-03-23T11:56:35.753Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ad/002c71b87fc3f648c9bf0ec47de0c3822bf2c95c8896a589dd03e7fd3977/srsly-2.5.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5c8df4039426d99f0148b5743542842ab96b82daded0b342555e15a639927757", size = 667409, upload-time = "2026-03-23T11:56:37.172Z" }, + { url = "https://files.pythonhosted.org/packages/2a/35/2cea3d5e80aeecfc4ece9e7e1783e7792cc3bad7ab85ab585882e1db4e38/srsly-2.5.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:06a43d63bde2e8cccadb953d7fff70b18196ca286b65dd2ad16006d65f3f8166", size = 1265941, upload-time = "2026-03-23T11:56:38.825Z" }, + { url = "https://files.pythonhosted.org/packages/aa/38/8a4d7e86dd0370a2e5af251b646000197bb5b7e0f9aa360c71bbfb253d0d/srsly-2.5.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:808cfafc047f0dec507a34c8fa8e4cda5722737fd33577df73452f52f7aca644", size = 1250693, upload-time = "2026-03-23T11:56:40.449Z" }, + { url = "https://files.pythonhosted.org/packages/99/05/340129de5ea7b237271b12f8a6962cfa7eb0c5a3056794626d348c5ae7c7/srsly-2.5.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:71d4cbe2b2a1335c76ed0acae2dc862163787d8b01a705e1949796907ed94ccd", size = 1242408, upload-time = "2026-03-23T11:56:41.8Z" }, + { url = "https://files.pythonhosted.org/packages/01/cb/d7fee7ab27c6aa2e3f865fb7b50ba18c81a4c763bba12bdf53df246441bc/srsly-2.5.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:565f69083d33cb329cfc74317da937fb3270c0f40fabc1b4488702d8074b4a3e", size = 1242749, upload-time = "2026-03-23T11:56:43.246Z" }, + { url = "https://files.pythonhosted.org/packages/d8/d1/9bad3a0f2fa7b72f4e0cf1d267b00513092d20ef538c47f72823ae4f7656/srsly-2.5.3-cp314-cp314t-win_amd64.whl", hash = "sha256:8ac016ffaeac35bc010992b71bf8afdd39d458f201c8138d84cf78778a936e6c", size = 673783, upload-time = "2026-03-23T11:56:44.875Z" }, + { url = "https://files.pythonhosted.org/packages/2a/ae/57d1d7af907e20c077e113e0e4976f87b82c0a415403d99284a262229dd0/srsly-2.5.3-cp314-cp314t-win_arm64.whl", hash = "sha256:d822083fe26ec6728bd8c273ac121fc4ab3864a0fdf0cf0ff3efb188fcd209ed", size = 650229, upload-time = "2026-03-23T11:56:46.148Z" }, +] + +[[package]] +name = "starlette" +version = "0.46.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/20/08dfcd9c983f6a6f4a1000d934b9e6d626cff8d2eeb77a89a68eef20a2b7/starlette-0.46.2.tar.gz", hash = "sha256:7f7361f34eed179294600af672f565727419830b54b7b084efe44bb82d2fccd5", size = 2580846, upload-time = "2025-04-13T13:56:17.942Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/0c/9d30a4ebeb6db2b25a841afbb80f6ef9a854fc3b41be131d249a977b4959/starlette-0.46.2-py3-none-any.whl", hash = "sha256:595633ce89f8ffa71a015caed34a5b2dc1c0cdb3f0f1fbd1e69339cf2abeec35", size = 72037, upload-time = "2025-04-13T13:56:16.21Z" }, +] + +[[package]] +name = "tech-step-intent-service" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "en-core-web-md" }, + { name = "fastapi" }, + { name = "fr-core-news-md" }, + { name = "pydantic-settings" }, + { name = "spacy" }, + { name = "spacy-lookups-data" }, + { name = "uvicorn", extra = ["standard"] }, +] + +[package.dev-dependencies] +dev = [ + { name = "httpx" }, + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [ + { name = "en-core-web-md", url = "https://github.com/explosion/spacy-models/releases/download/en_core_web_md-3.8.0/en_core_web_md-3.8.0-py3-none-any.whl" }, + { name = "fastapi", specifier = ">=0.115,<0.116" }, + { name = "fr-core-news-md", url = "https://github.com/explosion/spacy-models/releases/download/fr_core_news_md-3.8.0/fr_core_news_md-3.8.0-py3-none-any.whl" }, + { name = "pydantic-settings", specifier = ">=2.6,<3" }, + { name = "spacy", specifier = ">=3.8,<3.9" }, + { name = "spacy-lookups-data", specifier = ">=1.0,<1.1" }, + { name = "uvicorn", extras = ["standard"], specifier = ">=0.32,<0.33" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "httpx", specifier = ">=0.27,<0.28" }, + { name = "pytest", specifier = ">=8,<9" }, +] + +[[package]] +name = "thinc" +version = "8.3.13" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "blis" }, + { name = "catalogue" }, + { name = "confection" }, + { name = "cymem" }, + { name = "murmurhash" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "preshed" }, + { name = "pydantic" }, + { name = "setuptools" }, + { name = "srsly" }, + { name = "wasabi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/13/46/76df95f2c327f9a9cef30c1523bf285627897097163584dcf5f77b2ebce2/thinc-8.3.13.tar.gz", hash = "sha256:68e658549fc1eb3ff92aed5147fcbb9c15d6e9cc0e623b4d0998d16522ffb4f9", size = 194640, upload-time = "2026-03-23T07:22:36.41Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/af/f7c1ebfe92eb5d27d7f2f3da67a11e2eb57bc30ab1553279af6dc65b65a8/thinc-8.3.13-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:77a41f66285321d20aaedaea1e87d7cd48dca6d2427bed1867ec7cba7109fc8d", size = 821097, upload-time = "2026-03-23T07:21:56.698Z" }, + { url = "https://files.pythonhosted.org/packages/45/8f/69d7338575d98df85d0b54c0f5fc277dba72587fe9ab846ecdd12a998bcb/thinc-8.3.13-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3710d318b4e5460cf366a6f7b5ddbefb5d39dbd4cfa408222750fdc6c27c4411", size = 791932, upload-time = "2026-03-23T07:21:58.38Z" }, + { url = "https://files.pythonhosted.org/packages/4b/a5/21d010c81e81e1589e5ccb4950e521804d13726e541e87f644c51815673b/thinc-8.3.13-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5a08c87143a6d20177652dca1ec0dc815d88216d8fc62594a57e8bc45bf5ed49", size = 3854219, upload-time = "2026-03-23T07:21:59.819Z" }, + { url = "https://files.pythonhosted.org/packages/f9/ff/6914bf370bd1d604d89e6dfb46b97d10cd9b00d42ff8c036283e92314a8c/thinc-8.3.13-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4b5ec9ff313819e7d8667794a3559463fa89ff45aaa73e3fd8d6273b1e0d7a7f", size = 3903307, upload-time = "2026-03-23T07:22:01.652Z" }, + { url = "https://files.pythonhosted.org/packages/f3/3d/5572b47fa155fb3388c071515b74024fa17a6efd1df9406da378f0aa84ef/thinc-8.3.13-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5c9a48f2bc1e04f138240ed5f9b815a9141a5de26accd0f08fa0137fcefed258", size = 4836882, upload-time = "2026-03-23T07:22:03.565Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f0/a8d77c7bac089697c6df302cc3c936a1ab36a4720deae889e6f1dbcbd0eb/thinc-8.3.13-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:79a29a44d76bd02f5ac0624268c6e42b3576ae472c791a8ae9c2d813ae789b59", size = 5033398, upload-time = "2026-03-23T07:22:05.045Z" }, + { url = "https://files.pythonhosted.org/packages/21/82/5651bb1f904d04220fc7670035ada921bf0638e2cff6444d67c12887a968/thinc-8.3.13-cp312-cp312-win_amd64.whl", hash = "sha256:ed1dc709ac4f2f03b710457889e4e02f05de51bc8456980c241d0b28798bc7cb", size = 1721248, upload-time = "2026-03-23T07:22:06.749Z" }, + { url = "https://files.pythonhosted.org/packages/94/8d/683703de021ffbe46833d722b70f49ffbbca8e5bd6876256977555d92d7d/thinc-8.3.13-cp312-cp312-win_arm64.whl", hash = "sha256:c6a049703a6011c8fe26ee41af7e70272145594140d82f79bb23de619c6a6525", size = 1645777, upload-time = "2026-03-23T07:22:08.104Z" }, + { url = "https://files.pythonhosted.org/packages/af/b9/7b46942176df459d1804a9e77b0976f7c56f3abf3ec7485d0e5f836a0382/thinc-8.3.13-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c2811dfd8d46d8b5d3b39051b23e64006b2994a5143b1978b436938018792af8", size = 817337, upload-time = "2026-03-23T07:22:09.538Z" }, + { url = "https://files.pythonhosted.org/packages/a7/79/53085a72cd8f4fc4e6e313d05ea5aa98e870684f4a0fb318a9875fc0a964/thinc-8.3.13-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5593e6300cb1ebe0c0e546e9c9fb49e7c2627a0aa688795cd4f995a8b820d2ec", size = 788120, upload-time = "2026-03-23T07:22:11.215Z" }, + { url = "https://files.pythonhosted.org/packages/9e/3e/d61b462b16da95ac6885f95bb395e672040ee594833e571a6edcffd234f5/thinc-8.3.13-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f697174d3fb474966ce50b430bbafa101a6d2f7ffb559dac4b5c59389ef72d22", size = 3844666, upload-time = "2026-03-23T07:22:12.67Z" }, + { url = "https://files.pythonhosted.org/packages/78/4c/898cc654bb123734c71ec5a425c02ca34439517d01ce1c95a6563295580e/thinc-8.3.13-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e9c7c5c104737b414c8c4ec578e67d78b6c859afe25cbc0684402e721415bd7f", size = 3890658, upload-time = "2026-03-23T07:22:14.668Z" }, + { url = "https://files.pythonhosted.org/packages/cd/56/1abdbf0a4ad628e8a05d6516fe0745969649d805367a3dccad8ee872981b/thinc-8.3.13-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7a99d0e242d1ccd23f9ae6bea7cd502f8626efa65c156b91d84581d0356696c3", size = 4819933, upload-time = "2026-03-23T07:22:16.85Z" }, + { url = "https://files.pythonhosted.org/packages/f1/22/b84dbdc6be5055bbdb2a7352e2c393f67e8593c137f1b83c82bf1e062b6e/thinc-8.3.13-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e676edd21a747afbe3e6b9f3fca8b962e36d146ded03b070cb0c28e2dfbe9499", size = 5018099, upload-time = "2026-03-23T07:22:18.356Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a8/763cd7ba949334c9d2cddc92dadb68b344cb9546dc01b8d4a733dcaa16c1/thinc-8.3.13-cp313-cp313-win_amd64.whl", hash = "sha256:8ad40307f20e83f77af28ff5c6be0b86af7a8b251d1231c545508d2763157d8f", size = 1720309, upload-time = "2026-03-23T07:22:19.81Z" }, + { url = "https://files.pythonhosted.org/packages/f5/15/a11f7bb3cbc97dfecf32a90552f5a8f8a5c99316a99c6c17bdabf5baf256/thinc-8.3.13-cp313-cp313-win_arm64.whl", hash = "sha256:723949cab11d1925c15447928513a718276316cec6e0de28337cca0a62be0521", size = 1644606, upload-time = "2026-03-23T07:22:21.339Z" }, + { url = "https://files.pythonhosted.org/packages/80/40/f4937d113912c6d669ffe982356ab29dcb6c7fe3be926a15981dbbb6a91c/thinc-8.3.13-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7badb0be4825535e6362c19e8a41872b65409e9da46d3453a391b843a0720865", size = 817024, upload-time = "2026-03-23T07:22:23.005Z" }, + { url = "https://files.pythonhosted.org/packages/d2/00/4d4ed1a11ba2920b85a03a0683b16d97dc5beb2e78078dbf0e13e43bcea7/thinc-8.3.13-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:565300b7e13de799e5abff00d445f537e9256cf7da4dcb0d0f005fc16748a29e", size = 792096, upload-time = "2026-03-23T07:22:24.349Z" }, + { url = "https://files.pythonhosted.org/packages/44/5d/dc33d6932be8721af2ef76b4a3a6e8020648630eabae61fb916d2a861d1d/thinc-8.3.13-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c17cef1900a1aba7e1487493d16b8aa0a8633116f1b2a51c6649a4000697f17b", size = 3842215, upload-time = "2026-03-23T07:22:25.836Z" }, + { url = "https://files.pythonhosted.org/packages/af/bc/a6d37d8dadc2c5b524f51192413481160c42c9dd6105e8d5551531623225/thinc-8.3.13-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f4f26d1eec9b2a6a8f2e0298a5515d13eb06d70730d0d9e1040bb329e12bf3fb", size = 3849253, upload-time = "2026-03-23T07:22:27.845Z" }, + { url = "https://files.pythonhosted.org/packages/7a/59/ce9c7067f1dfe5985875927de9cf7a79f9dae3e69487fd650dfba558029d/thinc-8.3.13-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a61a31fd0ce3c2771cf4901ba6df70e774ffe32febf1024c5b43d63575cd58fe", size = 4831163, upload-time = "2026-03-23T07:22:29.395Z" }, + { url = "https://files.pythonhosted.org/packages/4f/a8/f57819347fc4d8bef2204d15fcbb9d7dff2d6cdd5f83d5ed91456ddacc55/thinc-8.3.13-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ba8119daf84a12259ae4d251d36426417bafa0b34108890b4b7e2b50966bd990", size = 4986051, upload-time = "2026-03-23T07:22:30.933Z" }, + { url = "https://files.pythonhosted.org/packages/05/ef/a82214bb7c7c1e2d92b69e1a7654be90cfab180082c6108e45a98af2422c/thinc-8.3.13-cp314-cp314-win_amd64.whl", hash = "sha256:433e3826e018da489f1a8068e6de677f6eff3cc93991a599d90f12cd1bc26cdc", size = 1740382, upload-time = "2026-03-23T07:22:32.869Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ef/1648fda54e9689058335ff54f650a7a314db2a42e21af1b83949b2dc748e/thinc-8.3.13-cp314-cp314-win_arm64.whl", hash = "sha256:11754fada9ad5ba2e02d5f3f234f940e24015b82333db58372f4a6aedad9b43f", size = 1667687, upload-time = "2026-03-23T07:22:34.967Z" }, +] + +[[package]] +name = "tqdm" +version = "4.70.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/21/3b/6c24bec5be5e743ffd99576daa5cc077722fc7d5bbc00bd133fa0c698dc6/tqdm-4.70.0.tar.gz", hash = "sha256:55b0b0dbd97462d06ebee91e4dac24ed4d4702be82b24f07e6c1d27e08cea220", size = 795438, upload-time = "2026-07-27T11:33:15.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/1c/01bfd571a64e7f270e6bab5e33777debe0edc56759233ce84f27dec92d14/tqdm-4.70.0-py3-none-any.whl", hash = "sha256:7f585706bfddbdebf89daac705b2dfcc16890130727d3197ca62c732b4310953", size = 80184, upload-time = "2026-07-27T11:33:13.167Z" }, +] + +[[package]] +name = "typer" +version = "0.27.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/40/4a3db7990d1f62a53182aa96eaef57aeb2886a27f90a195bc66713565d31/typer-0.27.1.tar.gz", hash = "sha256:a79bef8469a79c45498e7b814ecf8d603cc7644e9acbd9e19cac0334240b18df", size = 203994, upload-time = "2026-08-03T14:41:03.438Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/89/9518bc0c3929bee36b3a4a8e3daddd6e03f92f9961c66d4983b837160543/typer-0.27.1-py3-none-any.whl", hash = "sha256:53150287edd11baeb4e4722c8e394fcdf8181c0ae89485cba8d25c778d5edd56", size = 122874, upload-time = "2026-08-03T14:41:04.391Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.32.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6a/3c/21dba3e7d76138725ef307e3d7ddd29b763119b3aa459d02cc05fefcff75/uvicorn-0.32.1.tar.gz", hash = "sha256:ee9519c246a72b1c084cea8d3b44ed6026e78a4a309cbedae9c37e4cb9fbb175", size = 77630, upload-time = "2024-11-20T19:41:13.341Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/c1/2d27b0a15826c2b71dcf6e2f5402181ef85acf439617bb2f1453125ce1f3/uvicorn-0.32.1-py3-none-any.whl", hash = "sha256:82ad92fd58da0d12af7482ecdb5f2470a04c9c9a53ced65b9bbb4a205377602e", size = 63828, upload-time = "2024-11-20T19:41:11.244Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvloop" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" }, + { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" }, + { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" }, + { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" }, + { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" }, + { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, + { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, + { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" }, + { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" }, + { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" }, + { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" }, + { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" }, + { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" }, + { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" }, + { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, +] + +[[package]] +name = "wasabi" +version = "1.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/f9/054e6e2f1071e963b5e746b48d1e3727470b2a490834d18ad92364929db3/wasabi-1.1.3.tar.gz", hash = "sha256:4bb3008f003809db0c3e28b4daf20906ea871a2bb43f9914197d540f4f2e0878", size = 30391, upload-time = "2024-05-31T16:56:18.99Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/7c/34330a89da55610daa5f245ddce5aab81244321101614751e7537f125133/wasabi-1.1.3-py3-none-any.whl", hash = "sha256:f76e16e8f7e79f8c4c8be49b4024ac725713ab10cd7f19350ad18a8e3f71728c", size = 27880, upload-time = "2024-05-31T16:56:16.699Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/2f/e42c992d2afda3108ea1c02acecc991b9f31d05c14adc2a7cee9ee211fc4/watchfiles-1.2.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bc13eb17538be00c874699dc0abe4ee2bc8d50bb1166a6b9e175ef3fd7eb8f26", size = 400115, upload-time = "2026-05-18T04:32:02.06Z" }, + { url = "https://files.pythonhosted.org/packages/5f/8f/6af2ea19065c91d8b0ea3516fdfc8c0d349f407e8e9fbf4e5a17360de8ad/watchfiles-1.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d95ddc1eb6914154253d239089900813f6a767e174b8e6a50e7fdacb7e4236c", size = 393659, upload-time = "2026-05-18T04:30:50.951Z" }, + { url = "https://files.pythonhosted.org/packages/13/01/b32a967c56fb3e3e5be3db52c3d3b87fa4513aa367d8ed1ad96d42952e5f/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f70d8b291ef6e88d19b1f297a6905ddb978888d9272b0d05e6f53309856bcfc", size = 453207, upload-time = "2026-05-18T04:31:04.231Z" }, + { url = "https://files.pythonhosted.org/packages/04/98/97557a812180338cb1abd32e1cffcc4588f59b5f23e0cb006b2ba95ba64a/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56d8641cf834c2836922899105bd3ce3d0dfc69291d52edf0b4d0436829b34c0", size = 459273, upload-time = "2026-05-18T04:31:50.377Z" }, + { url = "https://files.pythonhosted.org/packages/e8/a8/b4b08dcb7653b8087c6586f7ce649505900e866bbcfe40dc9587af02e686/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2581a94056e55d7d0a31a823ea92bf73749c489ca2285bfdc0fbe6b2bb49d50c", size = 489927, upload-time = "2026-05-18T04:31:42.485Z" }, + { url = "https://files.pythonhosted.org/packages/50/94/3dceea03545d2e5ddfd839f0ddd5e1cecbf1697b5a428d5ba11cef6af95d/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41bc1199f7523b3f82843c88cbb979180c949caef0342cf90968f178e5d49b01", size = 570476, upload-time = "2026-05-18T04:31:03.071Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f2/d39a5450c3532092b91f81d274360e613c2371bc874a89c7a1a3c5e8d138/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7571e4464cb6e434958f867f7f730b8ab0b75e3f8e5eac0499168486ab3c33a8", size = 465650, upload-time = "2026-05-18T04:30:12.701Z" }, + { url = "https://files.pythonhosted.org/packages/22/24/ed72f68cbc1333ca9b9f2200aa048bb6658ae41709bc1caad4310f4bdffd/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e53a384f76b631c3ae5334ce6a52f0baa3a911eb94a4eac7f160079868b716d5", size = 456398, upload-time = "2026-05-18T04:30:13.784Z" }, + { url = "https://files.pythonhosted.org/packages/0d/64/982ef4a4e5bab5b6e5b6becc8cd5e732f6130a78b855f0abec6439a9a135/watchfiles-1.2.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d20029a60a71a052a24c4db7673bc4de39ab89adbaccbfb5d67987c5d73f424d", size = 465140, upload-time = "2026-05-18T04:31:52.111Z" }, + { url = "https://files.pythonhosted.org/packages/a0/0c/95282abf4ed680b6096010bcfc30c5fa7a041fc5aa5a2ad17a2cc6c75bba/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2cb93af48550faf1cea04c303107c8b75833de7013e57ce27d3b8d21d8d0f58c", size = 630259, upload-time = "2026-05-18T04:31:25.676Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/607c1de1530c4bdcf2cf1d1ecc2505ddba5d96bd43ba9f2b0e79876f850f/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2995c176de7692b86a2e4c58d9ec718f753150a979cb4a754e2b4ffa38e70906", size = 659859, upload-time = "2026-05-18T04:30:24.333Z" }, + { url = "https://files.pythonhosted.org/packages/fa/08/d9e2e0f9e8e6791d33aefc694ad7eefa7f901f63caff84a81ded38692f9c/watchfiles-1.2.0-cp312-cp312-win32.whl", hash = "sha256:7a2cffd17d27d2ecbb310c2b1d8174f222a5495b1a721894afa88ec11e25b898", size = 275480, upload-time = "2026-05-18T04:30:31.307Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e6/9d42569c0102645cc8cea5d8c7d8a1e9d4ada2cb7f05f75e554b8aa2202a/watchfiles-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:f155b3a1b2a5fc89cdc70d47ee5d54e3b75e88efa34982028a35daef9ba00379", size = 288718, upload-time = "2026-05-18T04:32:10.745Z" }, + { url = "https://files.pythonhosted.org/packages/0a/26/88e0dc6ee3898169d7fa22bb6a69cabf2502d2ee25cb8c876d1262d204f8/watchfiles-1.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:8fa585ede612ee9f9e91b18bebf9ba11b9ae29a4e3a0d0cf6fca3e382133f0d5", size = 281026, upload-time = "2026-05-18T04:30:22.23Z" }, + { url = "https://files.pythonhosted.org/packages/d1/4d/70a7feced9f87e2ff26dba42667290f41694fc64646c67261fbb8cab5d5c/watchfiles-1.2.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:01ea8d66f0693b9b60a6541c8d10263091ca9a9060d242f3c1f3143f9aad2c98", size = 399730, upload-time = "2026-05-18T04:31:38.162Z" }, + { url = "https://files.pythonhosted.org/packages/31/3a/0da302f2307aee316922806ebd5726c542cbd787c938271cf14a074c7daf/watchfiles-1.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7ba0480b9a74af058f43b337e937a451e109295c420916d68ad24e3dc02f5e44", size = 392842, upload-time = "2026-05-18T04:30:27.051Z" }, + { url = "https://files.pythonhosted.org/packages/db/ef/d5bdb705c224dbc256aa0c1ec47bf4e61ec52558f2afb44a71a1fe4d7015/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f34e26a19f91f710c08e0183429f0d1d15df734e6bc78c31e77b9ea9c433658", size = 452989, upload-time = "2026-05-18T04:31:11.945Z" }, + { url = "https://files.pythonhosted.org/packages/71/29/5495f2c1661949ef7a35e4d71111d129cfe7606414a26887a919d0a55406/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b4e77f6a55f858504069abd35d336a637555c09bca453dde1ee1e5ada8a6a1fb", size = 458978, upload-time = "2026-05-18T04:30:52.606Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8c/7f9c07c433811c2fffd93e13fdfb7135de9aab5f2ae41be08960fa0047dc/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0cb4d80e212f116474a545c21c912b445f16bb0cef9e6a73a498164223e14e2f", size = 490248, upload-time = "2026-05-18T04:31:36.003Z" }, + { url = "https://files.pythonhosted.org/packages/3c/11/d93632febc52fbc21be90231bb7c17fd5387f46c9076fd40a5f9c2ae6910/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b974946a10af379d425e2eef5b62f5c6ebeaccf91d45eaad6f5b27ecd4f91aa0", size = 571847, upload-time = "2026-05-18T04:31:10.862Z" }, + { url = "https://files.pythonhosted.org/packages/55/b4/383173e73aabb07ad1d9c7aa859d95437ac46a6d6a1e11005facda0c9d19/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86bc13c25a8d1fcd70b51d0ce7c9b65e90de5666fcbfd3e34957cc73ee19aeb5", size = 465974, upload-time = "2026-05-18T04:30:17.006Z" }, + { url = "https://files.pythonhosted.org/packages/a7/6c/89b1a230a78f57c52dd8893adb1f92f94411721b6ec12596c56d98c74356/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca148d73dea36c9763aaa351e4d7a51780ec1584217c45276f4fe8239c768b71", size = 454782, upload-time = "2026-05-18T04:30:35.656Z" }, + { url = "https://files.pythonhosted.org/packages/24/62/1732118367cfff0a9fce3bf62ff4bfded09ef5df21d9d446b858b3f70a96/watchfiles-1.2.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:c525543d91961c6955b2636b308569e84a1d1c5f5f2932041ab9ef46422f43e3", size = 465182, upload-time = "2026-05-18T04:30:20.846Z" }, + { url = "https://files.pythonhosted.org/packages/28/96/716f7e5f51339bf22963f3345f9f27d7f3b30e2eadc597e257c881dd3c53/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a204794696ffb8f9b10fba6f7cb5216d42f3b2b71860ccac6b6e42f5f10973b0", size = 629841, upload-time = "2026-05-18T04:31:05.397Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/c40783950fd771ccf66ab3ec2722d188a9af1c7f96c6e811f36e40c6e03f/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:10d86db20695afe7997ac9e1717637d6714a8d0220458c33f3d2061f54cec427", size = 658028, upload-time = "2026-05-18T04:31:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/71/72/4508db1856d1d87fcbb3b63f4839bab1b5682cb0e8d224d122263c09654a/watchfiles-1.2.0-cp313-cp313-win32.whl", hash = "sha256:eb283ee99e21ad6443c8cdb06ac5b34b1308c329cbdf03fa02b445363714c799", size = 275183, upload-time = "2026-05-18T04:30:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/f9/36/14b76ca57652e5cc5fd1c11f32a261292c08a0d19a00351013c2549cbfb2/watchfiles-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:a0f27f01bee51861392bb6b7c4fdb290b27d1eb194e9e28788d68102a0e898d9", size = 288059, upload-time = "2026-05-18T04:32:07.937Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8d/0a85e395398d8d20fadfe5c5d32c726eee17a519e78fb356f2cf7531bffe/watchfiles-1.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:3651aa7058595e9cfb75d35dd5ada2bf9f48a5b8a0f3562821d3e210c507e077", size = 280186, upload-time = "2026-05-18T04:31:54.484Z" }, + { url = "https://files.pythonhosted.org/packages/37/68/36db056f1fdcc5f07302f56e631774d6835bcd6fa3ace402304621d5f9e5/watchfiles-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:faea288b6f0ab1902ef08f4ca6de005dccf856c4e0c4f21b8c5fce02d90a1b08", size = 399031, upload-time = "2026-05-18T04:30:44.576Z" }, + { url = "https://files.pythonhosted.org/packages/c1/64/01a9d6f66a82a5c101ce939274106cc72759d62427e153f01edd2b9f87c2/watchfiles-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01859b11fd9fbca670f4d5da00fbac282cfea9bd67a2125d8b2833a3b5617ea9", size = 391205, upload-time = "2026-05-18T04:30:25.413Z" }, + { url = "https://files.pythonhosted.org/packages/84/2c/0a44fe058cb4bb7b8ede6b6670698bbb7c0400740e378d00022189b7b31d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fff610d7bb2256a317bb1e96f0d7862c7aa8076733ee5df0fd41bbe76a24a4f4", size = 451892, upload-time = "2026-05-18T04:32:14.005Z" }, + { url = "https://files.pythonhosted.org/packages/67/a1/351e0d56cd35e6488b5c8b4fb11a809a5bc923e8fe8fed9faf8920be0c89/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b141a4891c995a039cd89e9a49e62df1dc8a559a5d1a6e4c7106d16c12777a55", size = 458867, upload-time = "2026-05-18T04:31:22.279Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7d/9d09605187f1b838998624049fcf8bf47b73c1a3b76901fcac1782f62277/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f22943b7770483f6ea0721c6b11d022947a98eb0acae14694de034f4d0d38925", size = 490217, upload-time = "2026-05-18T04:31:43.657Z" }, + { url = "https://files.pythonhosted.org/packages/60/5d/a17a16eccb182f04188cd308ec24b1a71a9b5c4e7098269cf35d9fa56d02/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bc6195825b7dcd217968bb1f801a60fd4c16e8eeab5bedc7fe917d7d5995ab4", size = 571458, upload-time = "2026-05-18T04:32:11.875Z" }, + { url = "https://files.pythonhosted.org/packages/d3/3d/4dd457062083ab1938e5dfd45032eb425cee2ac817287ca8ff4356183e5d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4a4b147f5dca2a5d325a06a832fb43f345751adfbc63204aec30e0d9ca965a2", size = 464707, upload-time = "2026-05-18T04:30:43.492Z" }, + { url = "https://files.pythonhosted.org/packages/c6/71/ea8c57b128f5383de74d0c7d2d9c57ad7c9a65a930c451bd25d524b295b7/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4543579a9bdb0c9560039b4ffddbdb39545707659fbc430ce4c10f3f68d557f9", size = 454663, upload-time = "2026-05-18T04:30:16.061Z" }, + { url = "https://files.pythonhosted.org/packages/53/fd/2e812bf938406d7db351f0703ddd3fc6c061cf30d96153a77bc79a943a44/watchfiles-1.2.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:20aa0e708b920bde876a4aa82dc7dd6ebea228a63a67cda6632c2fc87b787efa", size = 463537, upload-time = "2026-05-18T04:31:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/86/56/d17a7f1dd1bc3035f1072694a551301272f1739c2d8e319c927cb9e29b38/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:d413349d565dab74297f2a63e84a097936be69bf8f3b3801f27f380e32040f44", size = 629194, upload-time = "2026-05-18T04:31:14.141Z" }, + { url = "https://files.pythonhosted.org/packages/be/06/f1ff66bf5cae50aa4062779a0ecd0bbaf15e466195719074078947d9a17d/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f28b2725eb8cce327b9b3ab02415c853011dc55c95832fe90de6bc56f5315f72", size = 656194, upload-time = "2026-05-18T04:31:47.14Z" }, + { url = "https://files.pythonhosted.org/packages/e7/54/a9c7ea9a82a4ac65e7004c0a03920b5cdd2f9c3b678757d9cd425aa51d53/watchfiles-1.2.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b8c8358484d5fa12ef34f05b7f4168eaf1932f408725ff6d023c33ec17bd79d4", size = 400205, upload-time = "2026-05-18T04:32:05.153Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5d/c9ab3534374a4a67450696905d6ef16a04405448b8dc52bd752ae50423d4/watchfiles-1.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f04b092229ad2c50126dd3c922c8822e51e605993764a33058d4a791ab42281", size = 392508, upload-time = "2026-05-18T04:30:54.849Z" }, + { url = "https://files.pythonhosted.org/packages/26/ca/1ad30103535cf0cecd7b993e8d50edc5351b1820e38f2d22e3df58962feb/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a7ce236284f002a156f70add88efe5c70879cccbb658be0822c54b1306fc09d", size = 452448, upload-time = "2026-05-18T04:30:53.727Z" }, + { url = "https://files.pythonhosted.org/packages/37/a1/ceee2cdf2afbd715fa07758d39c9859513eae411b23196f7fd039e5feedd/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b9909cc2b48468b575eefa944919e1fe8a36c5849d5c7c168f80a8c1db69398e", size = 459605, upload-time = "2026-05-18T04:30:23.312Z" }, + { url = "https://files.pythonhosted.org/packages/e8/f6/421e30fd1cb3907a84ed92ab3f1983e37ba2dca015e9a894a048418417a2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a37faaed405c67e28e6be45a1fa4f206ef5a2860f27c237db9fa30704c38242", size = 490757, upload-time = "2026-05-18T04:30:47.358Z" }, + { url = "https://files.pythonhosted.org/packages/41/b0/55ed1b97ed08be7bba6f9a541cac15f2a858e1d74d2b07b6da70a82aab00/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9649193aa27bd9ff2e80ff29bfaa93085496c7a3a377592823cc58b77ee88add", size = 568672, upload-time = "2026-05-18T04:30:38.915Z" }, + { url = "https://files.pythonhosted.org/packages/d1/cf/d8ae8a80dd7bafab395ea7681c10237311bbf34d37704a8c744e7cf31fc7/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e4ff8e37f99cf1da89e255e07c9c4b37c214038c4283707bdec308cb1b0ea1f", size = 464197, upload-time = "2026-05-18T04:30:09.914Z" }, + { url = "https://files.pythonhosted.org/packages/7c/8a/3076c496ca8dafe0e8cd03fcebdfc47be4b1174b4e5b24ff6e396e6b3af2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:054dc20fd2e3132b4c3883b4a00d72fd6e1f56fdaf89fccd12e8057d74cd74d7", size = 453181, upload-time = "2026-05-18T04:30:14.829Z" }, + { url = "https://files.pythonhosted.org/packages/e5/10/9745e17c98e7b8a86454df0a3c7b5686bd650383f1e9f26e4ebcbd6cc0c0/watchfiles-1.2.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e140ed30ebde76796b686e67c182cff10ea2fbab186fafd1560f74bb5a473a6e", size = 465109, upload-time = "2026-05-18T04:30:28.123Z" }, + { url = "https://files.pythonhosted.org/packages/8f/95/8ef4a95481d3e0cb52d62a06fa6e972e81424be2d9698b91a2fecca9904c/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:bb7e52ecf68ba46d22df23467b87cffeb2146908aa523ebfe803019618cfda06", size = 630653, upload-time = "2026-05-18T04:31:49.304Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e4/3b3bf36b0f829b50c6ebcb8d031583863c59f923d6a6af3d485e470d0fac/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:23282a321c8baf9b3a3c4afff673f9fe65eb7fdc2338d765ccad9d3d1916a5ba", size = 657838, upload-time = "2026-05-18T04:31:06.497Z" }, + { url = "https://files.pythonhosted.org/packages/21/b1/6cbbb50c1f3002ab568777d44aa21206dfb8807a840990c4037523b51812/watchfiles-1.2.0-cp314-cp314-win32.whl", hash = "sha256:c0db965c5f79aa49fe672d297cf1febc5ad149b658594944f49a54a2b96270a7", size = 275108, upload-time = "2026-05-18T04:30:06.891Z" }, + { url = "https://files.pythonhosted.org/packages/92/45/190ce6db8dcb4536682cf75d3889ff1a27182a58cb519d343cb6d9ea63d8/watchfiles-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:71283b39fd17e5408eb123bd37aeecfd9d54c81fc184421943208aadb879d103", size = 288441, upload-time = "2026-05-18T04:32:12.901Z" }, + { url = "https://files.pythonhosted.org/packages/74/0d/3eae1c2313ab08378431d907c3f8095ecca00f3eda33111cf4f0f2591799/watchfiles-1.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:c5c19526f4e54a00f2666a6c0e9e40d582c09e865055ea7378bf0009aab857b3", size = 280684, upload-time = "2026-05-18T04:31:26.902Z" }, + { url = "https://files.pythonhosted.org/packages/b1/75/fb64e6c25d6b5ca636d03df34ffb1c6e9873303e76d27967e045f8df088f/watchfiles-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d73a585accffa5ae39c17264c36ec3166d2fad7000c780f5ef83b2722afb9dd2", size = 398857, upload-time = "2026-05-18T04:32:17.108Z" }, + { url = "https://files.pythonhosted.org/packages/73/4e/9f7adf01754cbf81843722ccfec169d8f26c69778281a302855cecd2ee08/watchfiles-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae99b14c5f21e026e0e9d96f40e07d8570ebee6cafd9d8fc318354606daa7a28", size = 392413, upload-time = "2026-05-18T04:31:07.911Z" }, + { url = "https://files.pythonhosted.org/packages/47/c8/bec626bcc2d69f44b9acb24ce7d60ed7b16b73628eea747fcbd169d8edda/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4429f3b105524a10b72c3a819b091c495d2811d419c1e1e8df773a5a5974f831", size = 452409, upload-time = "2026-05-18T04:31:20.142Z" }, + { url = "https://files.pythonhosted.org/packages/00/b7/b6362068e81e7c556d155a34c35d40ac3ef42d747b06d7f6e5bf58e359c2/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:43d818978d06062d9b22c4fab2ebe44cf5213d42dc8e62bda8c2760cfa2eeb33", size = 458827, upload-time = "2026-05-18T04:32:06.219Z" }, + { url = "https://files.pythonhosted.org/packages/67/f8/9a813fa42afb1e0b4625e75f0479826644d3ee8dc287e093799bc01f390c/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9f732dc58b2dbe69e464ccf8fff7a03b0dd0be439da4c0720d3558527d3d6b4", size = 490104, upload-time = "2026-05-18T04:31:56.034Z" }, + { url = "https://files.pythonhosted.org/packages/2f/bf/27dfb6094ca4c9aad21298b5525b6c53cb36121ee454331d05161e58d130/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f200104103feb097de4cab8fe4f5dd18a2026934c7dea98c55a2f5fd6d5a33b", size = 571360, upload-time = "2026-05-18T04:31:57.133Z" }, + { url = "https://files.pythonhosted.org/packages/fb/39/44a096d67270ea93df91d33877dbe91fbda3aa4f8ec2edf799d93eda8736/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63ac26eefbf4af1741247d6fb68b11c49a25b2f7413fbd318a83a12aaa9cf666", size = 464644, upload-time = "2026-05-18T04:30:57.33Z" }, + { url = "https://files.pythonhosted.org/packages/0e/80/c7472203bad6268e3ef1ad260739704847898938ad7ea8b63a5131f46b50/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c4997d4e4a55f0d02b6cde327322daf3a0400e5df6c6b15948994bf72497925", size = 454771, upload-time = "2026-05-18T04:30:48.736Z" }, + { url = "https://files.pythonhosted.org/packages/51/cf/3b10b268b4b7f0fc26e9debb5eef1998b515887840f444cd3ec80c688755/watchfiles-1.2.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4c887eba18b7945ac73067a8b4a66f21cd46c2539b2bc68588f7be6c7eb6d26b", size = 463494, upload-time = "2026-05-18T04:31:33.826Z" }, + { url = "https://files.pythonhosted.org/packages/3d/3e/a4302545cd589262a0dc7d140e86f7688eba3f9c72776c27f7e23b8864c4/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3416ff151bb6b5a8d8d11664974fbef4d9305b9b2957839ab5a270468fd8df30", size = 629383, upload-time = "2026-05-18T04:31:15.596Z" }, + { url = "https://files.pythonhosted.org/packages/db/99/d5649df0a9a410d45b7c882304d0b790903ac9b6e8f2cfd12114e0c6b9f2/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:0e831a271c035d89789cffc386b6aa1375f39f1cd25eb7ca0997e4970d152fc5", size = 656093, upload-time = "2026-05-18T04:31:58.707Z" }, + { url = "https://files.pythonhosted.org/packages/92/b9/362702539275019a54dd2e94511b31a9b89c5f9e6a21966de7eb692549fc/watchfiles-1.2.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:37a6721cdf3f65dbb13aa9503510ccb4451603ac837e44d265d7992a597e1374", size = 400109, upload-time = "2026-05-18T04:31:16.879Z" }, + { url = "https://files.pythonhosted.org/packages/8f/75/71d5ba62db781e5587bded1d944c675374bc4aa37ff33d5018d98e8b6538/watchfiles-1.2.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2b37d10b5a63bd4d87e18472d80fa525bd670586fae62e5dd580452764879b65", size = 392167, upload-time = "2026-05-18T04:31:28.058Z" }, + { url = "https://files.pythonhosted.org/packages/3c/01/c66dd95d0423fe30d31820e2d1d5bda773764131bbb6ac0cb1cf303ac328/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a105bc2283f67e8fbec74253ec2d94925de92ed72c0393f1206bf326b7b7b69", size = 452372, upload-time = "2026-05-18T04:31:00.836Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/2fe99557e72f85627c6a8eed50d889e8d101623e060a22ad75b875cb932d/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5327989a465505f05cfe06f04fa9d0c2fd5432bb243e10e6f012b1bdca3c8579", size = 459596, upload-time = "2026-05-18T04:31:34.96Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/d4acfa0023367428ed48351b3b9b267893037b6cadae55620c61c24bcfd4/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ecb47f183a8025b2aa18b546725c3657e542112ae9c0613a2af79b4fa8d04ad7", size = 490869, upload-time = "2026-05-18T04:31:59.923Z" }, + { url = "https://files.pythonhosted.org/packages/a4/5f/3164cbdce06c9fb95c4f7b9e2f9760b5e2797af43a9ecc317ef42a23a278/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8520a4ab0e37f770afc34459c4f8f7019e153f9124dc101c15538365875d1ab2", size = 571641, upload-time = "2026-05-18T04:32:00.948Z" }, + { url = "https://files.pythonhosted.org/packages/41/e6/85d3731c55e65cd7690f3f803d24c139588aaf863e4bf2148fe7a7fa1a19/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71cd71740ed2c15211ebb237ced4e39a1cdf6f80566e5fe95428da1626f4fde6", size = 464444, upload-time = "2026-05-18T04:30:34.298Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7d/562641012b8b09872742c3b8adf9629ec479fd78f8d68ae4a0c13da8add6/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f88af53d6ddaf72179ef613ddc905e6f4785f712b49b80b3bef9f3525e6194b4", size = 453593, upload-time = "2026-05-18T04:31:23.464Z" }, + { url = "https://files.pythonhosted.org/packages/56/fe/cb8ef3d6f929d14158fdaaad9925985b7310abc9384dcd4d82dd0016fb59/watchfiles-1.2.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:cee9d5efd929efdac5f7e58f72b3376f676b64050a91c5b99a7094c5b2317488", size = 465096, upload-time = "2026-05-18T04:31:30.384Z" }, + { url = "https://files.pythonhosted.org/packages/25/91/80908e835e100527a9267147b08c0eee1fa6ab0ffec15edc04d1d44885f7/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:b718bf356bbc15e559bd8ef41782b573b8ae0e3f177ab244b440568d7ea02cfb", size = 630638, upload-time = "2026-05-18T04:30:49.89Z" }, + { url = "https://files.pythonhosted.org/packages/46/4b/95ab2f256bb4af3cb2eb23b9317bda984ee6e0f11733a5c004a6c95b06e3/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:922c0e019fe68b3ae392965a766b02a71ba1168c932cebc3733cd52c5fe5b377", size = 657684, upload-time = "2026-05-18T04:31:32.027Z" }, +] + +[[package]] +name = "weasel" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cloudpathlib" }, + { name = "confection" }, + { name = "httpx" }, + { name = "packaging" }, + { name = "pydantic" }, + { name = "smart-open" }, + { name = "srsly" }, + { name = "typer" }, + { name = "wasabi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/e5/e272bb9a045105a1fdf4b798d8086f5932a178f4d738f17a74f5c9e0ae9a/weasel-1.0.0.tar.gz", hash = "sha256:7b129b44c90cc543b760532974ca1e4eb30dad2aa2026f57bdce66354ae610fc", size = 38682, upload-time = "2026-03-20T08:10:25.266Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/07/57ebf7a6798b016c064bd0ca81b4c6a99daa4dc377b898bc7b41eb6b5af0/weasel-1.0.0-py3-none-any.whl", hash = "sha256:89518acee027f49d743126c3502d35e6dd14f5768be5c37c9af47c171b6005cc", size = 50713, upload-time = "2026-03-20T08:10:23.637Z" }, +] + +[[package]] +name = "websockets" +version = "17.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/96/e01084f83a64bcb3a27994bd0cb0db68ff29d9c6707fae37ec19b18ba990/websockets-17.0.1.tar.gz", hash = "sha256:5baa9bc0dfbae8c507e51c8cf1b6d4628086f7a87bbd3a9952bd5f035451f1cc", size = 183298, upload-time = "2026-07-31T11:31:27.665Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/ff/6199a52d864215750af8668d84b0274775011a90052081f5a9495807a92b/websockets-17.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:10f461191125c63902ea7394ae9e752b1b5785641850c1d365bb30b0f88bc53f", size = 212603, upload-time = "2026-07-31T11:29:30.771Z" }, + { url = "https://files.pythonhosted.org/packages/54/7e/439a962bcada88dcf586da77a1b2385f91e2d2910e9359540934c827156b/websockets-17.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cffc84ddec6da7f447677266fee2a3c40ecc78172f00752aa1150b8a8d65df1d", size = 210286, upload-time = "2026-07-31T11:29:32.157Z" }, + { url = "https://files.pythonhosted.org/packages/d9/82/123660edc759c225626b3b91952c7625f85c77a8362acbc35a4623120f7d/websockets-17.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c23e532c8a2325a1e7486de8763a60dc43e83f01bcaeca07e3ba79652c156db1", size = 210549, upload-time = "2026-07-31T11:29:33.388Z" }, + { url = "https://files.pythonhosted.org/packages/cd/2f/2940e57080cf56f28190287516400126d5a76b52b9a61dc10ba6f6400dbe/websockets-17.0.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c09e097d0e46e3c289bedab9a475ae344b70c30ff5646e46af22b4e6fdc97b21", size = 219874, upload-time = "2026-07-31T11:29:34.608Z" }, + { url = "https://files.pythonhosted.org/packages/42/28/9ec976c16d63cc51c28dfec74b66854048c0b8b6579946e902f91b69e8bf/websockets-17.0.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f47b0815af3948ec6a440b3afa02f05b18cc0939549e91b5c677b5d9c2c8472a", size = 220150, upload-time = "2026-07-31T11:29:35.831Z" }, + { url = "https://files.pythonhosted.org/packages/f4/a4/850c699a16bbc451723856360c59bd997bec075e637154f3fa96e80d5760/websockets-17.0.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8848c207049ad49d318e5f64a3d4d7bb189f8328d0d98e65647788f2a085785c", size = 221389, upload-time = "2026-07-31T11:29:37.189Z" }, + { url = "https://files.pythonhosted.org/packages/47/e1/f60a891c1a4b3420d5052333a84eb7241e1fb4a71866dec1562f5fa30027/websockets-17.0.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2604de7228506b13a44a256a9d223943340c0e725af5d367dc068e192b027761", size = 224169, upload-time = "2026-07-31T11:29:38.61Z" }, + { url = "https://files.pythonhosted.org/packages/26/fb/e2a893be6fae4fddfe50ddc3035a331d3f381103d5467b7900026bdb3a64/websockets-17.0.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:07abc3bd196a48af476a82fd47f3f79a6a3f70937a9f930cef703cfa0c9d83b6", size = 222025, upload-time = "2026-07-31T11:29:39.897Z" }, + { url = "https://files.pythonhosted.org/packages/d9/72/e3144b2d79276fab9798ed7d4aea2f0847434f186800b6f56a1eddcb3114/websockets-17.0.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:769ce7e2acfd9a89f2bed3a9c0da229459516bbc00bd4c9e2ca492c613ae4861", size = 220779, upload-time = "2026-07-31T11:29:41.084Z" }, + { url = "https://files.pythonhosted.org/packages/c0/5e/69c02174fbcf1c40c6adc45d3c316a401558392fe7bab8969ef8c46f1689/websockets-17.0.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:07d78a509c3333f5908c83d7f78144ea68a6c9ec28110f5c54d81d8fcdc262c4", size = 218053, upload-time = "2026-07-31T11:29:42.322Z" }, + { url = "https://files.pythonhosted.org/packages/b3/09/7574778b095b99cfa0856583462f56568df784f9b41485145169b2ec9c64/websockets-17.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ffad64ce7ad3703d652a3fd9af26238377d24ce52c6ad8ff35d26d82f61f493f", size = 220825, upload-time = "2026-07-31T11:29:43.553Z" }, + { url = "https://files.pythonhosted.org/packages/c5/e6/f46571f38765dbc4cbc0d0b47de8db65768006dbbd4340e6f5f51bc1d895/websockets-17.0.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:e95e321d0d763f2b6633512605f6112ebd70d5746f3ce05c941909d4a25233f2", size = 219427, upload-time = "2026-07-31T11:29:44.731Z" }, + { url = "https://files.pythonhosted.org/packages/9d/31/6ff1fee057bd7e9dd5237fc064a749615378d003aa045b5bfc2d12b2f4f7/websockets-17.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cd526c8228e759c1006c4b7c9ac71dc4e925ced1a6a6a5a8e94643709738f63e", size = 220198, upload-time = "2026-07-31T11:29:45.997Z" }, + { url = "https://files.pythonhosted.org/packages/7a/4f/d41847227a44b9ad87c3d5a9fddbfad8b7c4d6032878d8460d9d37c2d44f/websockets-17.0.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8cd3369e42c0246afaf9d669cfc19797e3a49e8c0a639544459c57597108b966", size = 221304, upload-time = "2026-07-31T11:29:47.314Z" }, + { url = "https://files.pythonhosted.org/packages/63/30/21a7e326c6ad2eb526cd5b816383d59cdeb28b8805b65a543c3cfbd8e8ce/websockets-17.0.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:b580794e926cab7ff42ee4371ef14e0b22cb2bb722a607f77769136468f49a3f", size = 218858, upload-time = "2026-07-31T11:29:48.587Z" }, + { url = "https://files.pythonhosted.org/packages/2d/48/55b0331cd5bec9ce29748f79edc00075805450a47011d0c8e3b1c61dbf04/websockets-17.0.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5033ffe6804dd53afafa7d08e8c3eef2d2431f34d58ca30507a8442dd04a033a", size = 219840, upload-time = "2026-07-31T11:29:49.791Z" }, + { url = "https://files.pythonhosted.org/packages/78/6e/2e8bc06e546f49b32a58a2bc2957902d1809ecc37552d3d7ccd6639a126e/websockets-17.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6db9e5bf3649ab506c6ae8a3ac85a00fb1ae3816d75962771b2df8adbc5d40d2", size = 220116, upload-time = "2026-07-31T11:29:51.026Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ad/4bac01fa41aca54307157b9c9f68b066a6bb51fb18716ba618078a67b283/websockets-17.0.1-cp312-cp312-win32.whl", hash = "sha256:bc0bca48ba24c6c866847fd20478a51dd547fa0ad258dab9615c414ec534bbc0", size = 213050, upload-time = "2026-07-31T11:29:52.328Z" }, + { url = "https://files.pythonhosted.org/packages/82/d8/c3a78cccc74a554780e9e76e323d5cde891048627025f0f82623e22dc3df/websockets-17.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:2b3f3020171202b135ca078e20434977c6b2b02af647130d6980c9e39b9462e3", size = 213348, upload-time = "2026-07-31T11:29:53.891Z" }, + { url = "https://files.pythonhosted.org/packages/7b/25/e1b8824bd632c8a5a62d504b61e9e35e470b67e4be0206f5c28f90c7f86d/websockets-17.0.1-cp312-cp312-win_arm64.whl", hash = "sha256:41d6aa06b5ab832aee72fedf47a149535b121ac900b6bb4d3fe14712afac9a79", size = 213276, upload-time = "2026-07-31T11:29:55.299Z" }, + { url = "https://files.pythonhosted.org/packages/ba/a8/79c577bc2f874ee22f6f5ccdab97ba9ce6b96806be3fcc3a6d8490f88a21/websockets-17.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:55b12e47dcee83673a40d07686cfb6f9d6dfc285976ade9463f61d2bef3fad22", size = 212593, upload-time = "2026-07-31T11:29:56.518Z" }, + { url = "https://files.pythonhosted.org/packages/db/99/e1cfaf419bb3b2fcfd6792a846f1d936293132b0b9a56530ced016c83c7b/websockets-17.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c1c118a6b0e25bfc9a6802075d748fa6321714ffbdf3c88d29d9a0e3c7386c75", size = 210280, upload-time = "2026-07-31T11:29:57.768Z" }, + { url = "https://files.pythonhosted.org/packages/a2/ef/cc994494bf7d97e41833f6ff55c24f535e4d527a10370b9631737e9c2f00/websockets-17.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:734d20364dc2cfe03674883cafcf580b6e431c5ce42b476312b9285310230cf9", size = 210538, upload-time = "2026-07-31T11:29:59.021Z" }, + { url = "https://files.pythonhosted.org/packages/87/32/fbf2d132f63ba3e67f675bccf333469786a24e0418969ce1d8e6ff9e6f02/websockets-17.0.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9493314a99e599163c854fb5900ad7f7ea38c5cb9d9103aa30b3c6b8181c01fa", size = 219925, upload-time = "2026-07-31T11:30:00.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/50/64eee3d25a47fe744a9490e0627cc373dca096755db740f91c28bd61cd35/websockets-17.0.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:18ded646ce98cdd3c0235825b3252f1df55765ba49b616bb10282f758667b4d0", size = 220206, upload-time = "2026-07-31T11:30:01.52Z" }, + { url = "https://files.pythonhosted.org/packages/15/56/10ed4bc4dd75f204e3c62bd4898e44a8742a27773c80b188cfa7888aad2d/websockets-17.0.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c1bec5d6a19f5fbe87e4940739cfc65e7bb53d8b353e1029b8037a1653b321bc", size = 221445, upload-time = "2026-07-31T11:30:02.788Z" }, + { url = "https://files.pythonhosted.org/packages/bf/be/bb14328614c068ab09569962fbf218fc00413ce3febc6d2684c764b6f37e/websockets-17.0.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:872273e629ca7e3d35f16a2dc6ede84e1d5c831e616b8277de6e4f83114e7c58", size = 222887, upload-time = "2026-07-31T11:30:03.943Z" }, + { url = "https://files.pythonhosted.org/packages/32/1b/4cb0eec2fee310007104687493175af190019f705940c864f9c523fe9f6f/websockets-17.0.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1df81d174c1561292de9e40b141cafc04f69077272f6c352afe1d743e20810df", size = 222072, upload-time = "2026-07-31T11:30:05.258Z" }, + { url = "https://files.pythonhosted.org/packages/6c/9c/14e6391de777ddb39c439c450deb551406d445e25a5877d6fa25c49d4544/websockets-17.0.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:759adeb5b0c5775b563254ec63b5b79089fc0045b479143a0b1b8c0ebaae1253", size = 220826, upload-time = "2026-07-31T11:30:06.53Z" }, + { url = "https://files.pythonhosted.org/packages/cb/57/96e94e384442247bbed5d3ab67381c7257355c2d66b62c3ad33a17f5d385/websockets-17.0.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1d99db29b5444e3982f1ce2ba8a833508ad44b2f1fbd0bd99e81d825c0b461", size = 218107, upload-time = "2026-07-31T11:30:07.766Z" }, + { url = "https://files.pythonhosted.org/packages/c0/8c/9c9dedd14c3919435df9b35cdee7111268c751252b87652f3a6a4f56e760/websockets-17.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:02ed63bf26dda9fa27df730a41f6664586c4ee05972c8fb667ce1725b3fd13d3", size = 220889, upload-time = "2026-07-31T11:30:09.035Z" }, + { url = "https://files.pythonhosted.org/packages/94/4d/ca73c2ac82c00f50c529784bacb323e42da4816333211bc1543d90c9cf11/websockets-17.0.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:eab6de8a98b9a7772cf686d00b4de439fc7efb8ab05ae106ef227291d06f87c5", size = 219486, upload-time = "2026-07-31T11:30:10.289Z" }, + { url = "https://files.pythonhosted.org/packages/5b/da/fb37ac09dcd7c69dd73bac979ed393df35f78a3c232e293d1ff3bd586d24/websockets-17.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2a855b6dfe21c4d3420be265ae031829ba8ba0be0ea350d9f7c3ef30ae63ebe2", size = 220258, upload-time = "2026-07-31T11:30:11.605Z" }, + { url = "https://files.pythonhosted.org/packages/fc/04/9693f191d968a93f37326a17301a101d49580889c688f466699f89ecdee1/websockets-17.0.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7002d5f9e1c3ddd991cdfdbfee18cc8c8b196b2445022892badacd6cb338bbbc", size = 221358, upload-time = "2026-07-31T11:30:12.858Z" }, + { url = "https://files.pythonhosted.org/packages/cf/29/ad0d85c01db5dcf22898d51648bd2c25af0dd0a4a41c550b11acddeeeba7/websockets-17.0.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c395bda8e7d8f51a02e80261fb57127979e5c472675d9a96b2860619ad47da48", size = 218921, upload-time = "2026-07-31T11:30:14.064Z" }, + { url = "https://files.pythonhosted.org/packages/18/3b/bf8e855e495dcca63f2b8aa019cf2ada3160e1fa66d833c7417f3b1f7f38/websockets-17.0.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:aadc298969ad229d8e3029fc5cc751fdad286696230f9cf014e90ff9cd8e6ea0", size = 219871, upload-time = "2026-07-31T11:30:15.358Z" }, + { url = "https://files.pythonhosted.org/packages/3b/db/c7abd6639a93a40279cd1ddc57e09e1c4f8381c4cfccdb775aa5aac9770a/websockets-17.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f11a398d8170b7ac5000baf7f258dcda579ef3ea744e0cc6a165e0dfbc0d3198", size = 220154, upload-time = "2026-07-31T11:30:16.96Z" }, + { url = "https://files.pythonhosted.org/packages/f6/2a/25a9f8f2e5a6ef34e911d2f55d9f756bdeb92b4c28cfb77b8430bbc73cb1/websockets-17.0.1-cp313-cp313-win32.whl", hash = "sha256:846a4a8b0833e3cad57523d9e3bd50ec8ea05ab9d06c582f82a1340ba096af5f", size = 213038, upload-time = "2026-07-31T11:30:18.434Z" }, + { url = "https://files.pythonhosted.org/packages/81/2f/ea1380f72bb11b64fc5bc7ae0d42de5bbf3e6dc13b965706b2a1d4e17cdf/websockets-17.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:409d93efcaa14f7a99592c5baaef5ec6ca94fba0f5aec1a86f693977c69c9c1c", size = 213348, upload-time = "2026-07-31T11:30:19.693Z" }, + { url = "https://files.pythonhosted.org/packages/e6/c7/b956ed9151c3c74530ebc62d716fbfdbde7507a6acc6423a64f9ecfb6b8a/websockets-17.0.1-cp313-cp313-win_arm64.whl", hash = "sha256:90246fa9e6cb192a778ce6ce024057ec54317a894db7899c922dcdc1f4cbf6a5", size = 213282, upload-time = "2026-07-31T11:30:21.045Z" }, + { url = "https://files.pythonhosted.org/packages/98/dc/cadab608924ac605647031472fb1f8792d7d4ea07565ba1899ec42028e0d/websockets-17.0.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:53b90c00bc6201ab6695c7ff51a04d0e425514c37515e9eeecd2c1b978ac6c0e", size = 212640, upload-time = "2026-07-31T11:30:22.436Z" }, + { url = "https://files.pythonhosted.org/packages/16/7a/b034d13ca181211bbd58bb50835cb196a7784cd505b5a2079d4d03374f9f/websockets-17.0.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:5f33a649bfcb8312524173cc4bbafa7dbb236e18eee9aa31a1d324ca0ddda28c", size = 210332, upload-time = "2026-07-31T11:30:23.608Z" }, + { url = "https://files.pythonhosted.org/packages/2f/4d/943ede39b53744768edf1ed84a3f9401527388228a3d6c1249c02c3d6bd7/websockets-17.0.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cddc675ec31bca65473321f9a9794e488b43b3b8de5d02c8ef4810c5d5792163", size = 210546, upload-time = "2026-07-31T11:30:24.932Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e3/88dc159d2ae66743c669443246243f28d873b0c5e58271b8cc1ca0440334/websockets-17.0.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b3ff0ad440ad52dda64138f16895f66403f40192365e39b1010e889f289746b0", size = 219928, upload-time = "2026-07-31T11:30:26.221Z" }, + { url = "https://files.pythonhosted.org/packages/fe/f2/ff27eaefa15851a5cf7f004ab827a022bf2d6632cb520f89cb100db7e84b/websockets-17.0.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:72d7f2a5aeb4e82daa4ee18f125b4277f427033359be5c745ad709608446cc2c", size = 220279, upload-time = "2026-07-31T11:30:27.49Z" }, + { url = "https://files.pythonhosted.org/packages/19/2e/a5166149f363d2449c1cb2dde6486a245521979509d53b87a09f3e79662b/websockets-17.0.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6fd88365da261c53d3e943fb37e0d0721b9cde119f6b2e3fc84369b6ab234d63", size = 221525, upload-time = "2026-07-31T11:30:28.872Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b5/f46931269b3ff3bde65d27c65ddb22f9bb8ce92ac2c6c4df0910128f6219/websockets-17.0.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ab9f962a5b64a5c3c845d556b7dc4e6fb683f7b67179f8205e814bb2e0213ffe", size = 222897, upload-time = "2026-07-31T11:30:30.164Z" }, + { url = "https://files.pythonhosted.org/packages/42/f4/deccf3439f35df953ec35e13fe07986821c5f1ab5785d69614283bdb9034/websockets-17.0.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8c07f145d0b9e90cbd96035f31fb79199aef4da1872854e36ebeb258e3d57594", size = 222129, upload-time = "2026-07-31T11:30:31.489Z" }, + { url = "https://files.pythonhosted.org/packages/35/a5/e1b57a59da92ade37fd021567a17b518ea8267b28e5530075844cdb525fe/websockets-17.0.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9f7747d3daa41a11f25f7cca5dc988fc51da97b311bed4c9d843860f79779283", size = 220875, upload-time = "2026-07-31T11:30:32.805Z" }, + { url = "https://files.pythonhosted.org/packages/f0/30/e7d0889c790a854156de424575fd67af79ddbaed9ff3157ae863dfd1c1dc/websockets-17.0.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2abb1ba0a5133b7d2ef3c1c9f4b0c1e8a101012dce0b594ab2b2888d9a64820e", size = 218160, upload-time = "2026-07-31T11:30:34.512Z" }, + { url = "https://files.pythonhosted.org/packages/09/2e/43db785d6ed9ae7594fae7b62bbc9cb4dfee2b015e06a1005f1e5ce283b6/websockets-17.0.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f3fd9a1f87f8f0f3f8e9f9bd0195f7516562d13f5b178db8c5784d1f60b60bed", size = 220951, upload-time = "2026-07-31T11:30:35.806Z" }, + { url = "https://files.pythonhosted.org/packages/eb/f5/4ac3cab3d5e8a830657a822f64a8910e3803229c6783e59c3fd9a3487427/websockets-17.0.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2bc14b481e05e331811108daa1aeb41a5e237a5564ef2f02ec5a356a0f102f78", size = 219460, upload-time = "2026-07-31T11:30:37.273Z" }, + { url = "https://files.pythonhosted.org/packages/03/0e/c3a4020673ffc17c82cf1a467835038a196a555d3b4f2a50f0f063cf8ccc/websockets-17.0.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:57d2ee9b24b404ce75f3814f92073c0ed88106c950148d2427fe8d25ca254d1f", size = 220248, upload-time = "2026-07-31T11:30:38.527Z" }, + { url = "https://files.pythonhosted.org/packages/7d/87/e47a6a278cc1dfade38444c893ce18322943c25d4b780a74450d9d164be1/websockets-17.0.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1b363bfd72a52c0658a3154a4cff219f15a474b35a235057d38853bf151acce7", size = 221421, upload-time = "2026-07-31T11:30:39.879Z" }, + { url = "https://files.pythonhosted.org/packages/da/8f/473d5fc4e3836e375b0233c6ef26777e6e5e3f7bfc84ccd524eae4090ed5/websockets-17.0.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:10b1587c599fa0f2c89154587c80e0fda98ade6c9fa8c0260a2823fb1800b685", size = 218975, upload-time = "2026-07-31T11:30:41.192Z" }, + { url = "https://files.pythonhosted.org/packages/d4/b9/819ec2dcdf69031d7e9cab11247f3a6ff9bbc8c7c53ada1dbcb9055b227b/websockets-17.0.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d7d72843691f50b91127c50688df10cb72ec6f4c4b1d7e2c11ab33b16acf8e51", size = 219925, upload-time = "2026-07-31T11:30:42.524Z" }, + { url = "https://files.pythonhosted.org/packages/85/b9/6c0da301f6118502e079cf92f4e864adf28e56b3f8c0f6085076ced7b876/websockets-17.0.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:90973a3a00f23afdfd1c9b06fb84289bf0220f247ef8a62501a1967c7af54f7b", size = 220218, upload-time = "2026-07-31T11:30:44.04Z" }, + { url = "https://files.pythonhosted.org/packages/55/f9/cba32dc9dd856565263d6272f594255bd0e2781deb8cd982c026a54760ad/websockets-17.0.1-cp314-cp314-win32.whl", hash = "sha256:599b03beb77633bffc095334338fad79cafc2b01fbd58953838130a9ae967d7b", size = 212626, upload-time = "2026-07-31T11:30:45.579Z" }, + { url = "https://files.pythonhosted.org/packages/fa/95/91cdd8c192287d7ea741f37cf7d64fdc1a14410f06f73805e428a1a590af/websockets-17.0.1-cp314-cp314-win_amd64.whl", hash = "sha256:81ce19c6046ace11da7001781be7317bb1dc389f399af4b2ed962190f76f9add", size = 212969, upload-time = "2026-07-31T11:30:46.983Z" }, + { url = "https://files.pythonhosted.org/packages/8e/fd/8c98a1e431960661c5769ab1a4dd66494e87ab02d791cc79e51e0d9a289f/websockets-17.0.1-cp314-cp314-win_arm64.whl", hash = "sha256:efe0ae052a8d023b87198921e8a7ce1dc7768816bcd2fbc20df171ac73a04891", size = 212850, upload-time = "2026-07-31T11:30:48.304Z" }, + { url = "https://files.pythonhosted.org/packages/13/c1/142f5186ee7dc3beee0426b998a79e223e067b7689afcaa95890d64aa800/websockets-17.0.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ab56439c9f74c52770690c7b2f616b3bf775cb3920453ee355ac765c032d8bbf", size = 212967, upload-time = "2026-07-31T11:30:49.688Z" }, + { url = "https://files.pythonhosted.org/packages/02/de/4b03ed316c9dee180365286c298219809ff247be39beeaaf9958b21167ab/websockets-17.0.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:20a92f78ac8250984ed459faa9ca48c285adbfc0038ddc3fdac6046990a9c9ed", size = 210504, upload-time = "2026-07-31T11:30:50.987Z" }, + { url = "https://files.pythonhosted.org/packages/cd/d8/ad2b3e8f867e1e8cac3077e2f33ffb60b71bd763d6cfc71bd916f113c3bf/websockets-17.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6a434e59962a4fb9016bea327e1d14d6cd67670ecfb8942b4f4a0c24036634ce", size = 210702, upload-time = "2026-07-31T11:30:52.261Z" }, + { url = "https://files.pythonhosted.org/packages/48/18/7a77a82ce9d6f831c07b176da3942f7e71acd0f115f3ecdb1d00a040eb01/websockets-17.0.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2503c7e2a5049a12d5dac917a46d5d52591283a766165b8176bb167560421b38", size = 220290, upload-time = "2026-07-31T11:30:53.582Z" }, + { url = "https://files.pythonhosted.org/packages/f5/af/43c3e3c3ea7ba4693c2181743f3221957df28bededadcbd9fc8a0661bde0/websockets-17.0.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:28012a54510fe8301bb893ef143cec30a2780a2d3bc20b7bbdf4379d7a63945d", size = 220573, upload-time = "2026-07-31T11:30:54.966Z" }, + { url = "https://files.pythonhosted.org/packages/1f/bd/ed48eca15725743ee7e2dc172e15c61de29e85ec98dace7b14257f366836/websockets-17.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22bd00f8bae2bccdb5dbe41e20f58ba44ca9fff0b4b561aaf39099c35da762ed", size = 221747, upload-time = "2026-07-31T11:30:56.762Z" }, + { url = "https://files.pythonhosted.org/packages/99/50/838deb7937a8225c4925dd4a977eafea473fabf444178a99de0bc7e92bb0/websockets-17.0.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e98ec9ec61cce5bc4b8b218322ad090b0994eb060bb04da704c62ef0a3d864e6", size = 223891, upload-time = "2026-07-31T11:30:58.127Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e9/657fb70c6eb6bcd01adfa5d2b06496e9911e1c1a8813d353b8c00f7591cd/websockets-17.0.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8e387adb0c692c6b5571bdeafc8ac9d1901ea30f10309134780b16ecd35e6605", size = 222317, upload-time = "2026-07-31T11:30:59.416Z" }, + { url = "https://files.pythonhosted.org/packages/07/4c/82cb722afa5428fed981331210c4c07600570db01bb1620579f655b5adaf/websockets-17.0.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bd1470d2c53fe53269bf5619da7725d30dd9b9693f1689f7a85eab8dea734442", size = 221047, upload-time = "2026-07-31T11:31:00.757Z" }, + { url = "https://files.pythonhosted.org/packages/90/84/bd6d67d6bc65f0de0cb50de55dab42f256a9876a351c4736522eb168fda0/websockets-17.0.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:884af729b8ab50486acd94d9768c2b60914bf39b579ebba0a5cb73bfdfd61fd2", size = 218626, upload-time = "2026-07-31T11:31:02.48Z" }, + { url = "https://files.pythonhosted.org/packages/96/cb/6a372c8553976f0d8f97f5115826ed47e34b3be6b8bf0d0249af249a7416/websockets-17.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a60fa1a25cca1bcc2bf87b8d6be37a741f0a3239fb5e9cfb7a37173b68ffcf87", size = 221299, upload-time = "2026-07-31T11:31:03.795Z" }, + { url = "https://files.pythonhosted.org/packages/bc/31/f966e8472337974f74d788b3ef6c6f3b8b9a5f201efd16a843c91d269fa5/websockets-17.0.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:e8208f2729cba030ff872a92064c97584eeb9502f53d32a05a0f05d5a17ca6c6", size = 219789, upload-time = "2026-07-31T11:31:05.08Z" }, + { url = "https://files.pythonhosted.org/packages/57/34/404e83a6cc7b0efcac810b7041bffd72ff76900e6fd0aa45a26c92fb2ffe/websockets-17.0.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:54cdcaa56f5d3eafd57058f0fa4a3de93a310b43a3c4699f06efc4c0bd054a5a", size = 220678, upload-time = "2026-07-31T11:31:06.635Z" }, + { url = "https://files.pythonhosted.org/packages/e5/70/8946188c2a68d67251859b589a3634918cf7867bf0b891347a5ecaa43d30/websockets-17.0.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4d41c0a1d47a478bc432b3b9068097bee1ce0c5b19327ea6f75c2ab34ab1f2fb", size = 221697, upload-time = "2026-07-31T11:31:08.023Z" }, + { url = "https://files.pythonhosted.org/packages/04/16/ee73fc2083a2938ac6209f4ec804960496835b20a0068dbcfe8424957c04/websockets-17.0.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f991247276797d0c61ab7770bc9791eadc16f683b4d83517f624932adc1a8bab", size = 219390, upload-time = "2026-07-31T11:31:09.378Z" }, + { url = "https://files.pythonhosted.org/packages/94/6a/d5f88033c69932af6cdaa72da62516ade47c257e3bf69f4c0ba5f40e12a2/websockets-17.0.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:733e3cc7171fa1b899edbe725ef9382d0e960657dc1fd933f3281ae910c01dab", size = 220161, upload-time = "2026-07-31T11:31:10.915Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/6183dd2c0370287ecf4afe0bb33aca364208e5e7b0e1a286adcaecc0c78b/websockets-17.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:810cb3fb5fa6e447216f4e82d9a85cb8aed0929ae3538153ddfe8a6e3121a58d", size = 220591, upload-time = "2026-07-31T11:31:12.289Z" }, + { url = "https://files.pythonhosted.org/packages/26/93/70f6516d85b9744f7eac224c4b1b9ef4e84133f80b53be02080cb1c3e663/websockets-17.0.1-cp314-cp314t-win32.whl", hash = "sha256:17ac37716c0244e82c9e384c41653c090b1864c6610224ca3857e7f7b58fce10", size = 212755, upload-time = "2026-07-31T11:31:13.883Z" }, + { url = "https://files.pythonhosted.org/packages/f1/2c/9d9c1da5a7ea9af307b386d25f64d1dead4729644198d2b92e36db5dfd41/websockets-17.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:bb31f42ea095ea826463c770829aa188a86c9a5c976b1467cbbf583c811de833", size = 213094, upload-time = "2026-07-31T11:31:15.367Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/a585e7573e128070605d003b5544729bcd58d9756c7e99d550818ca4b916/websockets-17.0.1-cp314-cp314t-win_arm64.whl", hash = "sha256:dbfae8e75b342e31fc6fd1a8bbb393b7cbb91d6cfd581650300a94381e7b7e2b", size = 213009, upload-time = "2026-07-31T11:31:16.776Z" }, + { url = "https://files.pythonhosted.org/packages/09/ce/3929538b2b9918f5eee623fbf3346893973191f6df93f19bbda097bd7bb7/websockets-17.0.1-py3-none-any.whl", hash = "sha256:c6be9cba65c65cc76dfa3d4619e359ff02a4476c74e179b215236c11a0b32345", size = 206718, upload-time = "2026-07-31T11:31:26.037Z" }, +] + +[[package]] +name = "wrapt" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2b/b0/c1f5a970721f06b85c0cd5142e0ff8fe067708abd779b0c4f4be7d61d09f/wrapt-2.3.0.tar.gz", hash = "sha256:681a2d0eefd721998f90642762b8e75c2159ec531b20ad5e437245ea7b06a107", size = 131509, upload-time = "2026-07-28T06:06:14.895Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/4a/d17a0fad1bf1c5f2c887ff71fef75654141b0880bff71d157d955b5bec3a/wrapt-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0a45ffae742ce91a16e11cb6c7cd71e7f9994f3cbd283b962ab093f5c6dcf525", size = 82139, upload-time = "2026-07-28T06:04:35.082Z" }, + { url = "https://files.pythonhosted.org/packages/6e/55/51b92daaf6defb57f4dc56bdcce985400f75c6984a03ca5e78ccac717028/wrapt-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:69e477046f2237ef0bc6547544ee73008dc764ca26eff44f09e976d221b34d5d", size = 82723, upload-time = "2026-07-28T06:04:36.502Z" }, + { url = "https://files.pythonhosted.org/packages/28/7f/cfd9bc4b1f5e424eeea83d0493e43f3b1b02707ce8e50c47945873982bd5/wrapt-2.3.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5d221a6e6ddd302b8397433184e96b59f259f50024b854db1c411a881586b6b8", size = 172381, upload-time = "2026-07-28T06:04:37.674Z" }, + { url = "https://files.pythonhosted.org/packages/cb/89/ff7814f6eb6856b479946117d1138a2fbb46cdb6b1f379db359056c69743/wrapt-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:392158c9a7f2ab1b8699418bfc0fe6f83548788c418b27d7bf2019ad3405cebb", size = 174120, upload-time = "2026-07-28T06:04:38.987Z" }, + { url = "https://files.pythonhosted.org/packages/12/1e/8eded8615d39e3ce81f626937a3a87b280a2a86239a2bf14a4b4bb345034/wrapt-2.3.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e5301c35cf75655eb33498f2bd6ae8703ca19940e3167dc9cdf740c712a39c60", size = 163035, upload-time = "2026-07-28T06:04:40.361Z" }, + { url = "https://files.pythonhosted.org/packages/35/ea/a0af2d9da62897af2a055484920de05dade30d2ba2c0d65cbdea875d3d8b/wrapt-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:418f54bb09d1762db02c7009b4051149893af3153a87f92d70356703c11eea02", size = 171887, upload-time = "2026-07-28T06:04:41.614Z" }, + { url = "https://files.pythonhosted.org/packages/7e/dd/63cd4c864c65ef4906df64bd2d378f4a62b54f28063f282dfb3bf93caead/wrapt-2.3.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:1598becd30f8f2777d18564064eb4f4dbe1ab0e05a8f09786d0ef505ac782bf3", size = 161113, upload-time = "2026-07-28T06:04:42.864Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ee/82f1fc9e431b5c2c5a6d201aa865dbeae3984c311c6d11a185f0c8367cf6/wrapt-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3da470536bf9645143323dd41b32db55c6f4304ad382094c1a1da8a92061e10d", size = 170530, upload-time = "2026-07-28T06:04:44.212Z" }, + { url = "https://files.pythonhosted.org/packages/37/a5/5dc590e863a419930d988f8b7ca3e75a6befcfb10b6003b3a152f3d5f732/wrapt-2.3.0-cp312-cp312-win32.whl", hash = "sha256:fb8e2e6704a1e0b1b989546c69e2688371ef4a07fa5f61bde3eb6211186f5ac1", size = 78323, upload-time = "2026-07-28T06:04:45.484Z" }, + { url = "https://files.pythonhosted.org/packages/51/f9/4a6925a07951df56394f7e6ebe14f69f1c5ef9d87aa63e0839acf15aa63a/wrapt-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:cdc021cb0b62471d6aac7f2bd92f3b4658073775f9ee7fcd325c511129e7bcc8", size = 81180, upload-time = "2026-07-28T06:04:47.021Z" }, + { url = "https://files.pythonhosted.org/packages/a8/4f/8b5de0395b2a72216751d41c9861df6facaeb611b619d8810ed2b3b23eb2/wrapt-2.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:67bfe2485f50368c3fcd2275fc1fd100e350d601e0058921a7c82678a465aeab", size = 80155, upload-time = "2026-07-28T06:04:48.373Z" }, + { url = "https://files.pythonhosted.org/packages/8e/6e/0f88a072483e76b881e3fdcd6b6ffb4a5791002514fe541e72b1b73c859a/wrapt-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0d3fb71e65b001adfc42684522eeccd9c21d8ba679945abc993439567b66e59f", size = 81960, upload-time = "2026-07-28T06:04:49.622Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ff/b7e2776e7c294075eb712cc9ef573d1b818f393006d09787262b8fc871c4/wrapt-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:51a7a4181c1295774812271fbcd7c909df372bc25579d4ed9eb875caaf0ae86f", size = 82435, upload-time = "2026-07-28T06:04:50.9Z" }, + { url = "https://files.pythonhosted.org/packages/d8/90/343bb5d0f1f9669bc252a6073f085b4abf862511bd5c9c9eaec754341f1d/wrapt-2.3.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9045917809c63fdf7abe3a2ceaed3d670b8ee4500ddd9291192d30aeb34467c5", size = 170350, upload-time = "2026-07-28T06:04:52.187Z" }, + { url = "https://files.pythonhosted.org/packages/59/f8/13b79a392930bd0dd6b86cbfbfe1c40944110456e1dc6d809e5c46ece904/wrapt-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:54ca1d5573f69b5fe1d74f1f65799c68015e82f685efec9fd8cfa40a094c44d0", size = 170022, upload-time = "2026-07-28T06:04:53.599Z" }, + { url = "https://files.pythonhosted.org/packages/b2/fc/4f1b6918f5290db959d6e0c07f77385d87cede29c39c9cf8f145e9c82954/wrapt-2.3.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:242b60c21e30866e6a2fa606c612b47c553fa60c0eaeeeb7797fb842ac0ce609", size = 161043, upload-time = "2026-07-28T06:04:54.936Z" }, + { url = "https://files.pythonhosted.org/packages/01/e1/45d3cf74414780bdff6d0380467e003f6eb0f028b6c9403db868dbc7209c/wrapt-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e3f3d7ec0a51fbfe00d3aef047641ff2c58b25565b4717fc1f90e050be01cba8", size = 168576, upload-time = "2026-07-28T06:04:56.261Z" }, + { url = "https://files.pythonhosted.org/packages/f3/73/2fa58dd97f191c997755e2c6d569a68f0c433db4e4b36099bdd7227b6cac/wrapt-2.3.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:261f53870cd4fb2bf38f9f972c56c728fd224cb7c65721307de59d9e7e6741ae", size = 159140, upload-time = "2026-07-28T06:04:57.754Z" }, + { url = "https://files.pythonhosted.org/packages/29/a8/08a56e2000a8816d449dcbad8c8b081697acbbd490821ceca0f9d8e8d20c/wrapt-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8159ec0b0cb7608175eb150de94c19e34f4d47ac655f5ca9baf45df6b688ffd3", size = 169263, upload-time = "2026-07-28T06:04:59.161Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d4/354e1725e35a73b2af4fa70a3e024c7a5d1bf1802dfb862dcb668aae0253/wrapt-2.3.0-cp313-cp313-win32.whl", hash = "sha256:10461884b3014fbfc8eb7d09a93c5f246363e6711d9d881f95eb8c27fdef049f", size = 78241, upload-time = "2026-07-28T06:05:00.507Z" }, + { url = "https://files.pythonhosted.org/packages/6c/7e/34c87fa2174848dfee820322aaa318bab08913998ccecc8d2f57b4ad4639/wrapt-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:ac870cc97b73bb00ac353329e9559a4bebc47c4c86792ed9b23b58c15b6ad838", size = 81113, upload-time = "2026-07-28T06:05:01.839Z" }, + { url = "https://files.pythonhosted.org/packages/11/86/fcc9a530579e008c9478bb565a6cdfbfd33536660f069c8b91a6607c5050/wrapt-2.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:a65e8db2b4e90c2e7ade931086351c98ef420bf7a94ee08c95ac8a3cbbc43579", size = 80182, upload-time = "2026-07-28T06:05:03.152Z" }, + { url = "https://files.pythonhosted.org/packages/96/50/3864848b95b28ef73e17551fc8dccbff2628a834f52cf26a57f9c419fb83/wrapt-2.3.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:fd1f2f557dd3491fe75905e578f4db967393d40d1a8f468edc4d40ac7f2d5944", size = 83921, upload-time = "2026-07-28T06:05:04.476Z" }, + { url = "https://files.pythonhosted.org/packages/3b/4c/3d1921a60c3e8c71c540ff136e6a47a1fbccf7f671e818394889f7871d9c/wrapt-2.3.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:9f5d2aec29dfc76c37e23897dee92766a3fd4f3bff3ae7fc9c6b4bf37d8c1360", size = 84412, upload-time = "2026-07-28T06:05:05.921Z" }, + { url = "https://files.pythonhosted.org/packages/fa/1a/4a796ff7adb26ada6d4b758c94d47a38320b085e7099afc088efbbcdb006/wrapt-2.3.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:646d20d413ffcd1b0a2f700076e2d0252d872dcb7754860a73e45a59ea883614", size = 207168, upload-time = "2026-07-28T06:05:07.256Z" }, + { url = "https://files.pythonhosted.org/packages/1d/3e/d7777776806c579b761bac2f91721dda9f04c7a1b380213c5935cc750ae6/wrapt-2.3.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:379f670f45b7bb8993edd9f6fc36c6cc65edb81cffa0b504be34acb0303fff0a", size = 214351, upload-time = "2026-07-28T06:05:08.945Z" }, + { url = "https://files.pythonhosted.org/packages/63/27/2d64d394df7bf181955b3bb562bf33c4492fb4be113f53071106d43ad8b5/wrapt-2.3.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6208f302f110295d64b22a7ac96500c791bf492dce4366e622e4912b077c9687", size = 199020, upload-time = "2026-07-28T06:05:10.418Z" }, + { url = "https://files.pythonhosted.org/packages/3e/3d/fb31d3db7d9834d265fb1a27a2adf0ddf51557c67458c97b22439ad6ae3d/wrapt-2.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ed635a9ca4f3a5a2b900c10c69e823373bc00ebc114b459383596d3487da3570", size = 209969, upload-time = "2026-07-28T06:05:11.983Z" }, + { url = "https://files.pythonhosted.org/packages/1f/d1/8724b5da582e62070dc9bf4d8bf1972f317297eefd7ba1f2b5c6393ccf6c/wrapt-2.3.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:e3b9eaa742ae7a0aaaaad4ca4b69469d757af2d6e6663ef1dadc47adec0aeb41", size = 196324, upload-time = "2026-07-28T06:05:13.557Z" }, + { url = "https://files.pythonhosted.org/packages/0d/5c/3d9ef411149543016ee6bcf3af707f787cebd946527452b94bf122e9b7b4/wrapt-2.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d0f7284f88f4833705132d06d3b425a43095c2cbd07c58166aac3ab646ba12a4", size = 202610, upload-time = "2026-07-28T06:05:15.048Z" }, + { url = "https://files.pythonhosted.org/packages/13/9b/4fc042ceb757866dd4a5fc057b3b736f2b360d3703ce9f830d83dc9226e0/wrapt-2.3.0-cp313-cp313t-win32.whl", hash = "sha256:7ebb274aba688b043429eb1500ff8a76ce0cb8ac0812ca3e301f06247b8722b3", size = 79178, upload-time = "2026-07-28T06:05:16.469Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ff/b94878f8eed809ca042685276bcea9f24e8c2ca7c9653bb80bbb920a68a5/wrapt-2.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:c4bded758ad6f03b965830944a2f0bc5b2eb3767fe5a7310134315d1a6610e98", size = 82634, upload-time = "2026-07-28T06:05:18.026Z" }, + { url = "https://files.pythonhosted.org/packages/80/fb/663e1de5332a71685a729754312d327d4cada767c36e1c5a2db4c8de49e6/wrapt-2.3.0-cp313-cp313t-win_arm64.whl", hash = "sha256:d2cc64539da63e39ffb9c7ede849b6e8ddaaf7b3876b5cfb04efd85a5f3f4eb6", size = 81387, upload-time = "2026-07-28T06:05:19.417Z" }, + { url = "https://files.pythonhosted.org/packages/58/10/b073beaea89bc0d3670a75ff51139430a54b6af7ba7796507730634536dd/wrapt-2.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ea52a0d0f08c584943d5764be0e84efa912c8da23c23e1e285ff2f5641c18fcc", size = 81978, upload-time = "2026-07-28T06:05:21.133Z" }, + { url = "https://files.pythonhosted.org/packages/b3/31/0916d9cebf848ed3f1a0c1888faee421747df77331e4db2bc527a9a85988/wrapt-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd85b0aa88efdb189d6ae2f35f4526943a8f091c38599c9c31478241c819e6a1", size = 82518, upload-time = "2026-07-28T06:05:22.562Z" }, + { url = "https://files.pythonhosted.org/packages/f5/73/31c1bf0f3384062751c2094dadb314916d70aa9b6bfd26d994b4a7b393fa/wrapt-2.3.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:141ed6211286a9660d8d6702de598b43f0934b4f0eda16393f100a80f501d945", size = 170187, upload-time = "2026-07-28T06:05:23.904Z" }, + { url = "https://files.pythonhosted.org/packages/ed/25/fce087d54b79b8905f3c3c9dd5f454bbd8d8acb80b960c4a6aee5b4659b3/wrapt-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e49885a62ec4ee854d1b9e6371fda6afd219917225752abf729a3f36d4df9a5", size = 169288, upload-time = "2026-07-28T06:05:25.378Z" }, + { url = "https://files.pythonhosted.org/packages/c7/30/0d09e6dddc6b7a7230ac77f50254b5980ab4fcd22976f72f8cc8a0404458/wrapt-2.3.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d6159c9b2fefec02314e1332dbbbfaf960e369dfd26bcf7f8b258b5732065b3", size = 160932, upload-time = "2026-07-28T06:05:27.022Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ca/0913af0d2ec0c43865d32d615f518fea66c13c5c930e489e9b0de248e9a8/wrapt-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:24da48596326ef8e448cfa837b454f638713d3531262375f00e5a9681682fc07", size = 169017, upload-time = "2026-07-28T06:05:28.501Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f2/3d1e47ea81b822210f5df1bf942fd90780a75c055243d569b664529dea88/wrapt-2.3.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cd3a2edf0427013736b8127955cec62608c56e53ea47e82812ea32059cda407f", size = 159065, upload-time = "2026-07-28T06:05:30.01Z" }, + { url = "https://files.pythonhosted.org/packages/43/a5/ef2066ced8e5fca204e2b361e9708e36555b40949c583d997ea3b590817d/wrapt-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4fa0df3bff4e7ce45759f33fd39335fe2f60477bb9ecf7b8aa41e7d07ee36a23", size = 168821, upload-time = "2026-07-28T06:05:31.649Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e1/016104650d4e572fa91506eb396b3dd8efbccc9284fdc1c9479c3d21db28/wrapt-2.3.0-cp314-cp314-win32.whl", hash = "sha256:2935d5454b3f179a29b12cf390ee47246740ba2c3a7545b1b46ba31a5f2a4a0b", size = 78700, upload-time = "2026-07-28T06:05:33.391Z" }, + { url = "https://files.pythonhosted.org/packages/3d/97/6fdc20a9f2ca304748b3f0819cbf377d55260562777bf0b615431bc3c181/wrapt-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:cc2cea812e5cb179a796b766747e7d3b21088760d8deb95676d482b8c8e6fa7d", size = 81422, upload-time = "2026-07-28T06:05:34.774Z" }, + { url = "https://files.pythonhosted.org/packages/5e/a4/9cbd53bf05746bea2c392af39cb052427a8ec95cbd494d930733d8f44681/wrapt-2.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:22cc5c0a717bd4da87018ae0bffd4c19c6fb679d3ff357216ba566ab26c76cab", size = 80639, upload-time = "2026-07-28T06:05:36.228Z" }, + { url = "https://files.pythonhosted.org/packages/43/bb/6c5e4a0f66ea0d2b2dd267e8dd05a0014eea56840b3c8595d40b0a5d1f91/wrapt-2.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a6b5984cd65dd639546f0eb4b8eacf1c31cb2fe9fb5c27bffe240987cdb2cf84", size = 84030, upload-time = "2026-07-28T06:05:37.714Z" }, + { url = "https://files.pythonhosted.org/packages/6a/eb/a1aedf03283bc9cbf8a1783995ddc54e3c5a86878f19002d2c428494f4c5/wrapt-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c88abcf53daef80e01a75c7530e727fa6e2c1888fe83e3dcdba4c96216a1f5c7", size = 84419, upload-time = "2026-07-28T06:05:39.131Z" }, + { url = "https://files.pythonhosted.org/packages/63/61/50d511c0dc5105563849e86daa3e16ac7feef699f79fb05af45ea70107d5/wrapt-2.3.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:85de890ff968196e92dd1ae73a9fb8970495e7650a457b1c9ef0ac3dd550bce2", size = 207171, upload-time = "2026-07-28T06:05:40.69Z" }, + { url = "https://files.pythonhosted.org/packages/3f/59/9b538cf7795217e810699d16bc88b96a830d9b5c403eb2ec2db6b5f2ae81/wrapt-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50f416b74d092bb9f41b424e90dd457f365f7ba4b11de62a23679769a21bd85c", size = 214329, upload-time = "2026-07-28T06:05:42.287Z" }, + { url = "https://files.pythonhosted.org/packages/b3/28/9935d62b1499e5c8b3d191e99ba4eb31ca237a0b699142011a837e9dc7ea/wrapt-2.3.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:39febbee6d77301d31da6996b152ce52452da7c7ef72aba10c2fa976dff9c295", size = 199079, upload-time = "2026-07-28T06:05:43.958Z" }, + { url = "https://files.pythonhosted.org/packages/2b/01/4446b80fa2ffa47a3449b250d004ba1c1937f07f64a179608fec735df866/wrapt-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:93513bec052c6cd987f9f580c3df068c8bc4ebae6543736be3ca7ec5959cafcd", size = 209992, upload-time = "2026-07-28T06:05:45.677Z" }, + { url = "https://files.pythonhosted.org/packages/d4/07/56f26c9f9979586a021e8148747004aba4498f49458c90b0502969b904e1/wrapt-2.3.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:729126e667da34d251b8ebf8a45ef0c5ddadc21542b3d6e1abf4259ece6508df", size = 196334, upload-time = "2026-07-28T06:05:47.608Z" }, + { url = "https://files.pythonhosted.org/packages/8b/41/6d7bcc895b0f28b2250e10908f060687b9165429dcd7f22ddb3d4c031b74/wrapt-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:626b69db2021aa01671ec7bbc9740e558522bd44c18cf2ce69bf3d666a014109", size = 202644, upload-time = "2026-07-28T06:05:49.183Z" }, + { url = "https://files.pythonhosted.org/packages/cd/25/7860927edba06b758b8852a6f02e832be715563c67a6795d94350bc81099/wrapt-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:629d73378082c00a8173031f9fb30a3ac6abbc894a5bfdfae71fabc60642d501", size = 79685, upload-time = "2026-07-28T06:05:50.976Z" }, + { url = "https://files.pythonhosted.org/packages/c4/0f/270bafe92fde3b069a39bc01e39ee79340895b335640df861d43d2a51885/wrapt-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:42869085687f0aefd57c0f636c3f9354f8ffb321a8ba9cb52d19beb796e561c5", size = 83104, upload-time = "2026-07-28T06:05:52.405Z" }, + { url = "https://files.pythonhosted.org/packages/55/b3/af176d79a8515a8a720eccdad9a96f6e31a30abf2865430c8c42adf2fd13/wrapt-2.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:b1e5aa486e269b00ed35e64771c7d0ab8096cfd2643405ca8cd60ebedc099a51", size = 81774, upload-time = "2026-07-28T06:05:53.902Z" }, + { url = "https://files.pythonhosted.org/packages/00/39/3daf9f47be208606586de4568ba6713db53ebc8fd7a575aea1fe57983b69/wrapt-2.3.0-py3-none-any.whl", hash = "sha256:d8c7ed08477429752b8c44991f40ad7838b18332a160698740a6bfbc10d998a2", size = 61866, upload-time = "2026-07-28T06:06:12.9Z" }, +] diff --git a/specs/backend-architecture.md b/specs/backend-architecture.md index d89af2f..1e68513 100644 --- a/specs/backend-architecture.md +++ b/specs/backend-architecture.md @@ -473,49 +473,64 @@ sens est sans ambiguïté. `TechStepMapping` a été supprimée (migration interrogée/éditée à l'exécution, les données de matching vivent en code (`tech-step-training-data.ts`). +`node-nlp` a ensuite été remplacé à son tour par un microservice Python dédié, +`services/tech-step-intent-service` (spaCy — `PhraseMatcher` + `textcat`), +appelé en HTTP par `TechStepClassifierService` via `IntentServiceClient` +(`intent-service-client.ts`) — `node-nlp` était peu maintenu et tournait +in-process dans l'event loop Node ; spaCy offre un écosystème NLP plus +robuste, dans un processus séparé, avec l'ambition à terme de pouvoir aussi +absorber ce que fait `services/tech-step-llm-worker`. Ce service est +entièrement autonome : `TECH_STEP_TRAINING_DATA` (~74 techniques) vit +désormais dans son propre `training_data.py`, revu par PR comme le reste du +code mais plus poussé par `apps/api` via HTTP — le service s'entraîne +lui-même une seule fois, à son propre démarrage, et ne touche jamais +Postgres (voir son propre README, y compris pour le temps de démarrage — +plusieurs minutes, l'entraînement n'étant jamais persisté sur disque). + `normalizeText` (décomposition NFD + suppression des diacritiques + minuscule) reste utilisée par `ingredient-matcher.ts`, mais n'intervient plus dans la -détection des techniques elle-même — node-nlp gère sa propre normalisation -par langue. +détection des techniques elle-même — un port Python de cette même fonction +(`intent_service/text_normalization.py`) alimente le composant de +normalisation du pipeline spaCy côté service. **Pipeline en 3 étapes** (`TechStepClassifierService.matchTechStepSpans`) : -1. **NER** (entités enum node-nlp, `synonyms` de `TECH_STEP_TRAINING_DATA`) - trouve chaque mention *candidate* d'une technique dans la description - entière, avec sa position exacte — équivalent mécanique des anciennes - regex, en listes de synonymes plutôt qu'en patterns écrits à la main. - `ner.threshold: 1` (exact après normalisation, pas de tolérance floue - Levenshtein) — le défaut à 0.8 faisait matcher "faire" (verbe auxiliaire - omniprésent en français) contre le synonyme "frire" de `fry` par pure - proximité de chaîne, un faux positif détecté en calibrant contre le - corpus réel. +1. **NER** (le `PhraseMatcher` du service, construit depuis les `synonyms` de + `TECH_STEP_TRAINING_DATA`) trouve chaque mention *candidate* d'une + technique dans la description entière, avec sa position exacte — + équivalent mécanique des anciennes regex, en listes de synonymes plutôt + qu'en patterns écrits à la main. Le matching se fait sur une normalisation + stricte (accents/casse) sans tolérance floue de type Levenshtein — voir + `services/tech-step-intent-service/intent_service/locale_pipeline.py`. 2. La description est découpée en clauses autour de ces candidats (`splitIntoClauses`, pure/testable sans modèle) — une étape nommant deux techniques a besoin que chacune soit jugée sur son propre contexte, pas la phrase entière classée d'un bloc. -3. **Classification d'intention NLP** (le même `NlpManager`, entraîné sur les - `utterances` de `TECH_STEP_TRAINING_DATA`) classe chaque clause +3. **Classification d'intention NLP** (le `textcat` du service, entraîné sur + les `utterances` de `TECH_STEP_TRAINING_DATA`) classe chaque clause individuellement — c'est ce qui apporte la compréhension du **sens** : le corpus d'entraînement mélange volontairement des tournures ancrées sur le mot-clé et des paraphrases qui ne l'emploient jamais (ex. "jusqu'à ce que le beurre ait disparu" pour `melt`), donc le verdict final d'une clause vient de ce que le modèle reconnaît comme *signifiant* la technique, pas du mot littéral qui a déclenché son découpage. En dessous - de `CONFIDENCE_THRESHOLD` (0.65 — ajusté empiriquement contre le corpus - réel, voir `test/tech-step-matcher.test.ts`), retombe sur la technique - impliquée par l'ancre NER de la clause plutôt que d'abandonner un match - clairement ancré sur un mot-clé juste parce qu'un petit modèle n'est pas - assez confiant. + de `CONFIDENCE_THRESHOLD` (voir la constante dans `tech-step-matcher.ts` + pour la valeur courante et comment elle a été calibrée), retombe sur la + technique impliquée par l'ancre NER de la clause plutôt que d'abandonner + un match clairement ancré sur un mot-clé juste parce que le modèle n'est + pas assez confiant. -Entraînement (`_train`) et résolution `TechStep.key -> id` sont mémoïsés une -seule fois sur le singleton partagé `techStepClassifier` (jamais par requête). -Le tout premier appel réel à `NlpManager.process()` déclenche aussi le -chargement paresseux des ressources par langue de node-nlp (plusieurs -secondes, mesuré) — `server.ts` appelle `techStepClassifier.warmUp()` avant -d'accepter du trafic pour que ce ne soit jamais la première vraie requête qui -attend. +Résolution `TechStep.key -> id` mémoïsée une seule fois sur le singleton +partagé `techStepClassifier` (jamais par requête) — c'est tout ce +qu'`apps/api` a encore à mémoïser, l'entraînement du modèle lui-même vivant +entièrement côté `services/tech-step-intent-service`. `server.ts` appelle +`techStepClassifier.warmUp()` avant d'accepter du trafic, avec retry/backoff +si `services/tech-step-intent-service` n'est pas encore joignable (le cas +normal en Docker Compose, où `app` attend qu'il soit `healthy` avant même de +démarrer — voir `docker-compose.yml`, et le README de ce service pour +combien de temps ça prend). -**Deux pièges rencontrés en construisant ce pipeline**, tous deux corrigés -dans le code (pas juste contournés) : +**Pièges rencontrés en construisant ce pipeline**, tous corrigés dans le code +(pas juste contournés) : - `db/prisma.ts` construisait `new PrismaClient()` sans jamais importer `config/env.ts` — dans le run de test complet, un *autre* fichier chargeait toujours `config/env.ts` (donc `.env.test`) en premier par pur @@ -525,12 +540,20 @@ dans le code (pas juste contournés) : tant que `resetDatabase()` ne throw pas (heureusement son garde-fou le fait). Fixé en import `config/env.js` pour effet de bord tout en haut de `prisma.ts`, avant `new PrismaClient()`. -- `NlpManager` a `autoSave`/`autoLoad: true` par défaut — persiste le - modèle entraîné dans un fichier `model.nlp` (cwd du process) et le - recharge *au lieu de* ré-entraîner au prochain démarrage s'il existe déjà. - Un modèle obsolète sur disque masquerait silencieusement toute mise à - jour de `TECH_STEP_TRAINING_DATA`/`CONFIDENCE_THRESHOLD`. Les deux sont - explicitement à `false` dans le constructeur de `TechStepClassifierService`. +- (historique, node-nlp) `NlpManager` avait `autoSave`/`autoLoad: true` par + défaut — persistait le modèle entraîné dans un fichier `model.nlp` (cwd du + process) et le rechargeait *au lieu de* ré-entraîner au prochain démarrage + s'il existait déjà. Un modèle obsolète sur disque aurait masqué + silencieusement toute mise à jour du corpus. Non applicable au service + Python actuel : il réentraîne tout en mémoire à chaque démarrage du + process, sans jamais rien persister sur disque (voir ce service's own + README). +- `nlp.make_doc()` (spaCy) ne fait tourner que le tokenizer, pas les + composants du pipeline — un piège trouvé en construisant le `PhraseMatcher` + du nouveau service : les patterns de synonymes doivent explicitement + repasser par le composant de normalisation, sinon un synonyme accentué + ("préchauffer") ne matche jamais sa forme normalisée dans le texte cible + (voir le commentaire dans `locale_pipeline.py`'s `train()`). ### Résolution ingrédients/unités — `ingredient-matcher.ts` diff --git a/specs/batch-cooking-modele.md b/specs/batch-cooking-modele.md index ea2b71d..1627137 100644 --- a/specs/batch-cooking-modele.md +++ b/specs/batch-cooking-modele.md @@ -353,8 +353,9 @@ fiable. `tech_step` (`TechStep`, `key` unique, ex. `"simmer"`) est le catalogue des techniques (mijoter, préchauffer…) — juste un id/clé stable référencé par `step_tech_step`. Les données de détection elles-mêmes (synonymes + phrases -d'exemple par langue, entraînant un classifieur `node-nlp`) vivent en code -(`tech-step-training-data.ts`), pas dans une table — l'ancienne +d'exemple par langue) vivent en code dans le microservice spaCy lui-même +(`services/tech-step-intent-service/intent_service/training_data.py`), pas +dans une table ni côté `apps/api` — l'ancienne `tech_step_mapping` (`TechStepMapping`, une regex par technique/locale) a été supprimée une fois constaté que les regex ne généralisaient jamais au-delà de leur propre vocabulaire — voir