feat(tech-steps): fiabilise la detection des tech steps (corpus + LLM + corrections utilisateur)
Une seule feature livree en une seule PR, en 5 phases : - Phase 1 : enrichit le corpus NLP (tech-step-training-data.ts) et ajoute un harness d'evaluation (precision/rappel/F1) avec un jeu de test etiquete - la premiere metrique objective de qualite pour ce classifieur. - Phase 2 : schema Prisma (StepTechStepCorrection, TechStepTrainingSuggestion) + endpoints utilisateur (POST/GET corrections, ouverts a tout viewer, pas seulement l'auteur) + endpoints internes /internal/tech-steps/* proteges par secret partage (requireInternalWorker). - Phase 3 : UI de highlight/correction cote web (selection de texte -> association a une technique, ou clic sur un highlight existant pour le corriger/supprimer) - verifiee via Cypress (component + e2e, en Chrome reel). - Phase 4 : worker LLM autonome (services/tech-step-llm-worker, hors du monorepo pnpm comme experiments/llm-tech-step-poc) qui audite les clauses a faible confiance et transforme les corrections utilisateur en suggestions d'entrainement, sans jamais toucher le chemin interactif. - Phase 5 : script retrain-tech-steps.ts (gate de regression F1 + backfill) et list-pending-training-suggestions.ts pour la revue humaine avant application au corpus. Verification effectuee cette session : tsc/biome sur l'ensemble du repo, build complet (pnpm build), suite Cypress complete (component 39/39, e2e 75/76 - le seul echec est preexistant et sans rapport, cote recipe-form.feature/ingredient-picker), tests unitaires du worker (6/6) et son install/typecheck reels contre node-llama-cpp. Les tests Mocha d'apps/api (Phases 1 et 2) n'ont pas pu etre executes dans cette session (pas de Postgres local disponible) - a lancer avant merge. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
0e0fd81563
commit
53d415fddb
60 changed files with 5850 additions and 72 deletions
12
.env.example
12
.env.example
|
|
@ -21,3 +21,15 @@ JWT_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
|
||||||
# over plain HTTP a Secure cookie is silently never sent back by the
|
# over plain HTTP a Secure cookie is silently never sent back by the
|
||||||
# browser, so login "succeeds" but every subsequent request 401s.
|
# browser, so login "succeeds" but every subsequent request 401s.
|
||||||
# COOKIE_SECURE=false
|
# COOKIE_SECURE=false
|
||||||
|
|
||||||
|
# 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
|
||||||
|
# out/unset to run without it.
|
||||||
|
# INTERNAL_WORKER_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
|
||||||
|
|
||||||
|
# Optional — cron expression (node-cron syntax) the worker wakes up on to
|
||||||
|
# run its audit/feedback-loop jobs. Default: weekly, Sunday 03:00 — a
|
||||||
|
# provisional floor, not a calibrated value (see
|
||||||
|
# services/tech-step-llm-worker/README.md).
|
||||||
|
# TECH_STEP_WORKER_CRON=0 3 * * 0
|
||||||
|
|
|
||||||
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
|
|
@ -15,6 +15,10 @@ env:
|
||||||
DATABASE_URL: "postgresql://ci:ci@localhost:5432/batchcooking_ci?schema=public"
|
DATABASE_URL: "postgresql://ci:ci@localhost:5432/batchcooking_ci?schema=public"
|
||||||
# Test-only secret, never used outside CI — real deployments must set their own.
|
# Test-only secret, never used outside CI — real deployments must set their own.
|
||||||
JWT_SECRET: "ci-only-secret-not-used-anywhere-else-32chars+"
|
JWT_SECRET: "ci-only-secret-not-used-anywhere-else-32chars+"
|
||||||
|
# Same reasoning as JWT_SECRET above — lets tech-step-worker.routes.test.ts
|
||||||
|
# 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+"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# Four independent jobs, no needs: between them — each starts in parallel
|
# Four independent jobs, no needs: between them — each starts in parallel
|
||||||
|
|
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -157,3 +157,7 @@ tmp-mockups/
|
||||||
|
|
||||||
# IA
|
# IA
|
||||||
.claude/
|
.claude/
|
||||||
|
|
||||||
|
# Cypress run artifacts — regenerated locally/in CI, never meant to be committed
|
||||||
|
apps/web/cypress/screenshots/
|
||||||
|
apps/web/cypress/videos/
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,20 @@
|
||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
PORT=3000
|
PORT=3000
|
||||||
# Match whatever you set in the root .env (POSTGRES_USER/PASSWORD/DB) —
|
# Match whatever you set in the root .env (POSTGRES_USER/PASSWORD/DB) —
|
||||||
# do not commit the real value.
|
# do not commit the real value.
|
||||||
DATABASE_URL="postgresql://changeme:changeme@localhost:5432/batchcooking?schema=public"
|
DATABASE_URL="postgresql://changeme:changeme@localhost:5432/batchcooking?schema=public"
|
||||||
|
|
||||||
# Required, no default on purpose — generate your own, e.g.:
|
# Required, no default on purpose — generate your own, e.g.:
|
||||||
# node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"
|
# node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"
|
||||||
JWT_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
|
JWT_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
|
||||||
|
|
||||||
# Optional — defaults shown (see src/config/env.ts)
|
# Optional — defaults shown (see src/config/env.ts)
|
||||||
# JWT_EXPIRES_IN=7d
|
# JWT_EXPIRES_IN=7d
|
||||||
# AUTH_COOKIE_NAME=session
|
# AUTH_COOKIE_NAME=session
|
||||||
# CORS_ORIGIN=http://localhost:5173
|
# CORS_ORIGIN=http://localhost:5173
|
||||||
|
|
||||||
|
# Only needed if you're running services/tech-step-llm-worker locally —
|
||||||
|
# every /internal/tech-steps/* request is rejected outright while unset.
|
||||||
|
# Generate your own the same way as JWT_SECRET above; must match the
|
||||||
|
# worker's own INTERNAL_WORKER_SECRET.
|
||||||
|
# INTERNAL_WORKER_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
|
||||||
|
|
|
||||||
|
|
@ -13,3 +13,8 @@ DATABASE_URL="postgresql://changeme:changeme@localhost:5432/batchcooking_test?sc
|
||||||
# Required, no default on purpose — generate your own, e.g.:
|
# Required, no default on purpose — generate your own, e.g.:
|
||||||
# node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"
|
# node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"
|
||||||
JWT_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
|
JWT_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.
|
||||||
|
# INTERNAL_WORKER_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "step_tech_step_correction" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"step_id" INTEGER NOT NULL,
|
||||||
|
"corrector_id" INTEGER NOT NULL,
|
||||||
|
"start" INTEGER NOT NULL,
|
||||||
|
"end" INTEGER NOT NULL,
|
||||||
|
"previous_tech_step_id" INTEGER,
|
||||||
|
"corrected_tech_step_id" INTEGER,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"consumed_at" TIMESTAMP(3),
|
||||||
|
|
||||||
|
CONSTRAINT "step_tech_step_correction_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "tech_step_training_suggestion" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"tech_step_id" INTEGER NOT NULL,
|
||||||
|
"locale" TEXT NOT NULL,
|
||||||
|
"suggested_synonyms" TEXT[],
|
||||||
|
"suggested_utterances" TEXT[],
|
||||||
|
"source_type" TEXT NOT NULL,
|
||||||
|
"source_correction_id" INTEGER,
|
||||||
|
"status" TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "tech_step_training_suggestion_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "step_tech_step_correction" ADD CONSTRAINT "step_tech_step_correction_step_id_fkey" FOREIGN KEY ("step_id") REFERENCES "step"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "step_tech_step_correction" ADD CONSTRAINT "step_tech_step_correction_corrector_id_fkey" FOREIGN KEY ("corrector_id") REFERENCES "user_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "step_tech_step_correction" ADD CONSTRAINT "step_tech_step_correction_previous_tech_step_id_fkey" FOREIGN KEY ("previous_tech_step_id") REFERENCES "tech_step"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "step_tech_step_correction" ADD CONSTRAINT "step_tech_step_correction_corrected_tech_step_id_fkey" FOREIGN KEY ("corrected_tech_step_id") REFERENCES "tech_step"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "tech_step_training_suggestion" ADD CONSTRAINT "tech_step_training_suggestion_tech_step_id_fkey" FOREIGN KEY ("tech_step_id") REFERENCES "tech_step"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "tech_step_training_suggestion" ADD CONSTRAINT "tech_step_training_suggestion_source_correction_id_fkey" FOREIGN KEY ("source_correction_id") REFERENCES "step_tech_step_correction"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
@ -121,6 +121,10 @@ model UserProfile {
|
||||||
/// list regardless of that real-world cardinality.
|
/// list regardless of that real-world cardinality.
|
||||||
administeredHouses House[] @relation("HouseAdmin")
|
administeredHouses House[] @relation("HouseAdmin")
|
||||||
preferences UserPreference?
|
preferences UserPreference?
|
||||||
|
/// Tech-step corrections this profile has submitted (any profile that can
|
||||||
|
/// view a recipe may correct its tech-step matches, not just its author —
|
||||||
|
/// see `StepTechStepCorrection.correctorId`).
|
||||||
|
techStepCorrections StepTechStepCorrection[]
|
||||||
|
|
||||||
@@map("user_profiles")
|
@@map("user_profiles")
|
||||||
}
|
}
|
||||||
|
|
@ -634,7 +638,16 @@ model TechStep {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
key String @unique
|
key String @unique
|
||||||
|
|
||||||
steps StepTechStep[]
|
steps StepTechStep[]
|
||||||
|
/// Corrections where this technique was the *previous* (possibly wrong)
|
||||||
|
/// match — see `StepTechStepCorrection.previousTechStepId`.
|
||||||
|
correctionsAsPrevious StepTechStepCorrection[] @relation("PreviousTechStep")
|
||||||
|
/// Corrections where this technique was the *corrected* (user-asserted)
|
||||||
|
/// match — see `StepTechStepCorrection.correctedTechStepId`.
|
||||||
|
correctionsAsCorrected StepTechStepCorrection[] @relation("CorrectedTechStep")
|
||||||
|
/// Training-corpus suggestions targeting this technique — see
|
||||||
|
/// `TechStepTrainingSuggestion`.
|
||||||
|
trainingSuggestions TechStepTrainingSuggestion[]
|
||||||
|
|
||||||
@@map("tech_step")
|
@@map("tech_step")
|
||||||
}
|
}
|
||||||
|
|
@ -650,8 +663,11 @@ model Step {
|
||||||
picture String?
|
picture String?
|
||||||
order Int
|
order Int
|
||||||
|
|
||||||
recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade)
|
recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade)
|
||||||
techSteps StepTechStep[]
|
techSteps StepTechStep[]
|
||||||
|
/// User-submitted corrections to this step's detected techniques — see
|
||||||
|
/// `StepTechStepCorrection`.
|
||||||
|
corrections StepTechStepCorrection[]
|
||||||
|
|
||||||
@@map("step")
|
@@map("step")
|
||||||
}
|
}
|
||||||
|
|
@ -683,8 +699,8 @@ model Step {
|
||||||
/// and recreates every `Step`/`StepTechStep`, never a partial patch) —
|
/// and recreates every `Step`/`StepTechStep`, never a partial patch) —
|
||||||
/// graceful degradation, not a permanent gap.
|
/// graceful degradation, not a permanent gap.
|
||||||
model StepTechStep {
|
model StepTechStep {
|
||||||
stepId Int @map("step_id")
|
stepId Int @map("step_id")
|
||||||
techStepId Int @map("tech_step_id")
|
techStepId Int @map("tech_step_id")
|
||||||
order Int
|
order Int
|
||||||
start Int?
|
start Int?
|
||||||
end Int?
|
end Int?
|
||||||
|
|
@ -697,3 +713,87 @@ model StepTechStep {
|
||||||
@@id([stepId, order])
|
@@id([stepId, order])
|
||||||
@@map("step_tech_step")
|
@@map("step_tech_step")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One user-submitted correction to a `Step`'s detected techniques —
|
||||||
|
/// captures ADD (a missing technique the classifier didn't find),
|
||||||
|
/// REMOVE (a wrong technique it did), or RELABEL (both) as a single shape:
|
||||||
|
/// `previousTechStepId` is the (possibly absent) match being corrected,
|
||||||
|
/// `correctedTechStepId` is what the user asserts instead (absent means
|
||||||
|
/// "no technique belongs here"). Both `null` at once is invalid (nothing
|
||||||
|
/// would have changed) — enforced service-side, not by the schema, same
|
||||||
|
/// posture as other cross-field invariants in this codebase (e.g.
|
||||||
|
/// `RecipeIngredientView`'s no-duplicate-ingredient check).
|
||||||
|
///
|
||||||
|
/// `start`/`end` are the user's selected `[start, end)` span within
|
||||||
|
/// `Step.description` (`String.prototype.slice` convention, same as
|
||||||
|
/// `StepTechStep`) — what they highlighted before assigning a technique to
|
||||||
|
/// it, not necessarily identical to any existing `StepTechStep` span.
|
||||||
|
///
|
||||||
|
/// Never edited/deleted once created (an audit trail of what was actually
|
||||||
|
/// submitted) — only `consumedAt` changes, stamped once
|
||||||
|
/// `services/tech-step-llm-worker` has turned this correction into a
|
||||||
|
/// `TechStepTrainingSuggestion` for a maintainer to review, so the same
|
||||||
|
/// correction isn't proposed twice on the next scheduled run.
|
||||||
|
model StepTechStepCorrection {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
stepId Int @map("step_id")
|
||||||
|
/// Any profile that could *view* the recipe when they submitted this, not
|
||||||
|
/// necessarily its author — see `assertRecipeVisible`,
|
||||||
|
/// `recipe.service.ts`.
|
||||||
|
correctorId Int @map("corrector_id")
|
||||||
|
start Int
|
||||||
|
end Int
|
||||||
|
previousTechStepId Int? @map("previous_tech_step_id")
|
||||||
|
correctedTechStepId Int? @map("corrected_tech_step_id")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
consumedAt DateTime? @map("consumed_at")
|
||||||
|
|
||||||
|
step Step @relation(fields: [stepId], references: [id], onDelete: Cascade)
|
||||||
|
corrector UserProfile @relation(fields: [correctorId], references: [id], onDelete: Cascade)
|
||||||
|
previousTechStep TechStep? @relation("PreviousTechStep", fields: [previousTechStepId], references: [id], onDelete: SetNull)
|
||||||
|
correctedTechStep TechStep? @relation("CorrectedTechStep", fields: [correctedTechStepId], references: [id], onDelete: SetNull)
|
||||||
|
trainingSuggestions TechStepTrainingSuggestion[]
|
||||||
|
|
||||||
|
@@map("step_tech_step_correction")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A candidate addition to `TECH_STEP_TRAINING_DATA`
|
||||||
|
/// (`tech-step-training-data.ts`), proposed by `services/tech-step-llm-worker`
|
||||||
|
/// from one of two sources (`sourceType`):
|
||||||
|
///
|
||||||
|
/// - `"correction"` — a user's `StepTechStepCorrection`, turned into
|
||||||
|
/// suggested synonyms/utterances by the worker's LLM
|
||||||
|
/// (`transform-corrections` job).
|
||||||
|
/// - `"llm_audit"` — a low-confidence NLP clause on an *existing* recipe the
|
||||||
|
/// worker periodically samples and re-judges with its LLM
|
||||||
|
/// (`audit-low-confidence` job); no `sourceCorrectionId` in this case.
|
||||||
|
///
|
||||||
|
/// Deliberately never auto-applied to `tech-step-training-data.ts` — a
|
||||||
|
/// maintainer reviews `status: "pending"` rows (see
|
||||||
|
/// `list-pending-training-suggestions.ts`) and edits that file by hand,
|
||||||
|
/// same "generated suggestion, human-reviewed source of truth" split as a
|
||||||
|
/// linter's autofix vs. a human-authored diff. `retrain-tech-steps.ts` then
|
||||||
|
/// flips `status` to `"applied"`/`"rejected"` once a maintainer has acted on
|
||||||
|
/// a batch, so the same suggestion isn't reviewed twice.
|
||||||
|
///
|
||||||
|
/// `suggestedSynonyms`/`suggestedUtterances` are native Postgres arrays
|
||||||
|
/// (`String[]`), not a join table — unlike this schema's other list-shaped
|
||||||
|
/// data (`RecipeDiet`, `UserProfileAllergy`...), these strings are free text
|
||||||
|
/// proposed once for a human to read, not ids referencing another catalog
|
||||||
|
/// table, so there's nothing for a join table to normalize against.
|
||||||
|
model TechStepTrainingSuggestion {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
techStepId Int @map("tech_step_id")
|
||||||
|
locale String
|
||||||
|
suggestedSynonyms String[] @map("suggested_synonyms")
|
||||||
|
suggestedUtterances String[] @map("suggested_utterances")
|
||||||
|
sourceType String @map("source_type")
|
||||||
|
sourceCorrectionId Int? @map("source_correction_id")
|
||||||
|
status String @default("pending")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
techStep TechStep @relation(fields: [techStepId], references: [id])
|
||||||
|
sourceCorrection StepTechStepCorrection? @relation(fields: [sourceCorrectionId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
|
@@map("tech_step_training_suggestion")
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { errorLogger } from "./middlewares/error-logger.js";
|
||||||
import { requestLogger } from "./middlewares/request-logger.js";
|
import { requestLogger } from "./middlewares/request-logger.js";
|
||||||
import { authRouter } from "./modules/auth/auth.routes.js";
|
import { authRouter } from "./modules/auth/auth.routes.js";
|
||||||
import { houseRouter } from "./modules/house/house.routes.js";
|
import { houseRouter } from "./modules/house/house.routes.js";
|
||||||
|
import { techStepWorkerRouter } from "./modules/internal/tech-step-worker.routes.js";
|
||||||
import { planningRouter } from "./modules/planning/planning.routes.js";
|
import { planningRouter } from "./modules/planning/planning.routes.js";
|
||||||
import { preferencesRouter } from "./modules/preferences/preferences.routes.js";
|
import { preferencesRouter } from "./modules/preferences/preferences.routes.js";
|
||||||
import { profileRouter } from "./modules/profile/profile.routes.js";
|
import { profileRouter } from "./modules/profile/profile.routes.js";
|
||||||
|
|
@ -38,6 +39,12 @@ export function createServer(): ExpressServer {
|
||||||
|
|
||||||
server.mountRouter("/auth", authRouter);
|
server.mountRouter("/auth", authRouter);
|
||||||
server.mountRouter("/house", houseRouter);
|
server.mountRouter("/house", houseRouter);
|
||||||
|
// Not user-facing — `services/tech-step-llm-worker` only, guarded by
|
||||||
|
// `requireInternalWorker` on every route within (see that router's own
|
||||||
|
// doc comment), never `requireAuth`. Mounted alongside the other routers
|
||||||
|
// rather than nested under one of them since it isn't scoped to a single
|
||||||
|
// recipe/step the way `recipeRouter`'s own correction routes are.
|
||||||
|
server.mountRouter("/internal/tech-steps", techStepWorkerRouter);
|
||||||
server.mountRouter("/planning", planningRouter);
|
server.mountRouter("/planning", planningRouter);
|
||||||
server.mountRouter("/preferences", preferencesRouter);
|
server.mountRouter("/preferences", preferencesRouter);
|
||||||
server.mountRouter("/profile", profileRouter);
|
server.mountRouter("/profile", profileRouter);
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,17 @@ const envSchema = z.object({
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
.transform((value) => (value === undefined || value === "" ? undefined : value === "true")),
|
.transform((value) => (value === undefined || value === "" ? undefined : value === "true")),
|
||||||
|
/**
|
||||||
|
* Shared secret `services/tech-step-llm-worker` sends as an
|
||||||
|
* `X-Internal-Worker-Secret` header on every call to `/internal/tech-steps/*`
|
||||||
|
* (`requireInternalWorker`, `middlewares/require-internal-worker.ts`).
|
||||||
|
* Optional with no default in the schema itself (unlike `JWT_SECRET`) so
|
||||||
|
* an environment that doesn't run the worker at all (e.g. this repo's
|
||||||
|
* existing test suite) never needs to set it — but `requireInternalWorker`
|
||||||
|
* itself rejects every request outright when it's unset, so the surface
|
||||||
|
* fails closed rather than open if a real deployment forgets to set it.
|
||||||
|
*/
|
||||||
|
INTERNAL_WORKER_SECRET: z.string().min(32).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Parsed, validated environment — import this instead of reading `process.env` directly anywhere else. */
|
/** Parsed, validated environment — import this instead of reading `process.env` directly anywhere else. */
|
||||||
|
|
|
||||||
265
apps/api/src/lib/recipe-matching/tech-step-eval-dataset.ts
Normal file
265
apps/api/src/lib/recipe-matching/tech-step-eval-dataset.ts
Normal file
|
|
@ -0,0 +1,265 @@
|
||||||
|
/**
|
||||||
|
* 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).
|
||||||
|
*
|
||||||
|
* Deliberately *not* reusing `TECH_STEP_TRAINING_DATA`'s own `utterances`
|
||||||
|
* verbatim — scoring the classifier against the exact sentences it was
|
||||||
|
* trained on would measure memorization, not generalization. Every
|
||||||
|
* description below is original phrasing; where a case still needs to name
|
||||||
|
* a technique's own verb to be labeled with confidence (most of them, see
|
||||||
|
* this file's own limits below), it's at least a different sentence shape
|
||||||
|
* than anything in the training corpus.
|
||||||
|
*
|
||||||
|
* `expectedKeys` is a multiset in reading order (see
|
||||||
|
* `TechStepEvalOutcome`'s doc comment in `tech-step-evaluator.ts` for why
|
||||||
|
* order isn't scored but repetition is) of `TechStep.key`s — resolved to
|
||||||
|
* real DB ids and back by `tech-step-eval-runner.ts`, this file only ever
|
||||||
|
* deals in stable keys so it doesn't need DB access to author or read.
|
||||||
|
*
|
||||||
|
* Known limit of this dataset: most cases anchor on a technique's own
|
||||||
|
* registered synonym (verb form), which `_classifyClause` always falls
|
||||||
|
* back to labeling correctly even when the intent classifier itself isn't
|
||||||
|
* confident (see `tech-step-matcher.ts`'s doc comment, point 3) — so this
|
||||||
|
* dataset mainly measures precision (wrong/duplicate matches, false
|
||||||
|
* positives from a synonym overlapping another technique's vocabulary) and
|
||||||
|
* breadth of coverage across all ~26 techniques, not the classifier's
|
||||||
|
* ability to recognize a technique described without ever naming it
|
||||||
|
* (`tech-step-matcher.test.ts` already covers a few of those specific,
|
||||||
|
* verified cases at the unit level — e.g. "jusqu'à ce que le beurre ait
|
||||||
|
* disparu dans la poêle" for `melt`). Extending this dataset with more
|
||||||
|
* paraphrase-only cases is valuable future work, but each one needs to be
|
||||||
|
* verified against a real trained classifier before being added (a wrong
|
||||||
|
* expected label here fails the regression gate for the wrong reason) —
|
||||||
|
* see this feature's plan document for the current gap in this session's
|
||||||
|
* ability to run the classifier locally.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface TechStepEvalCase {
|
||||||
|
description: string;
|
||||||
|
locale: "fr" | "en";
|
||||||
|
expectedKeys: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TECH_STEP_EVAL_DATASET: TechStepEvalCase[] = [
|
||||||
|
// --- One straightforward case per technique (fr), covering all 26 ---
|
||||||
|
{
|
||||||
|
description: "Faites cuire les pâtes al dente dans une grande casserole d'eau bien salée.",
|
||||||
|
locale: "fr",
|
||||||
|
expectedKeys: ["cook"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Faites bouillir l'eau dans une grande casserole avant d'y plonger les pâtes.",
|
||||||
|
locale: "fr",
|
||||||
|
expectedKeys: ["boil"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Plongez les beignets dans l'huile très chaude pour les faire frire.",
|
||||||
|
locale: "fr",
|
||||||
|
expectedKeys: ["fry"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Faites fondre le chocolat noir au bain-marie en remuant.",
|
||||||
|
locale: "fr",
|
||||||
|
expectedKeys: ["melt"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Déglacez la casserole avec un trait de vinaigre balsamique.",
|
||||||
|
locale: "fr",
|
||||||
|
expectedKeys: ["deglaze"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Laissez frémir la sauce tomate vingt minutes à couvert.",
|
||||||
|
locale: "fr",
|
||||||
|
expectedKeys: ["simmer"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Faites rôtir la volaille entière sur la broche du four.",
|
||||||
|
locale: "fr",
|
||||||
|
expectedKeys: ["roast"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Faites griller les brochettes de poulet quelques minutes de chaque côté.",
|
||||||
|
locale: "fr",
|
||||||
|
expectedKeys: ["grill"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Faites sauter les champignons à feu vif dans une poêle très chaude.",
|
||||||
|
locale: "fr",
|
||||||
|
expectedKeys: ["panFry"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Blanchissez les haricots verts trois minutes avant de les refroidir.",
|
||||||
|
locale: "fr",
|
||||||
|
expectedKeys: ["blanch"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Laissez mariner les brochettes de poulet deux heures au frais.",
|
||||||
|
locale: "fr",
|
||||||
|
expectedKeys: ["marinate"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Hachez grossièrement le persil frais avant de le parsemer.",
|
||||||
|
locale: "fr",
|
||||||
|
expectedKeys: ["chop"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Épluchez les carottes avant de les couper en rondelles.",
|
||||||
|
locale: "fr",
|
||||||
|
expectedKeys: ["peel"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Émincez finement l'échalote pour la vinaigrette.",
|
||||||
|
locale: "fr",
|
||||||
|
expectedKeys: ["mince"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Mélangez la farine, le sucre et les œufs dans un grand saladier.",
|
||||||
|
locale: "fr",
|
||||||
|
expectedKeys: ["mix"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Fouettez énergiquement la crème jusqu'à ce qu'elle épaississe.",
|
||||||
|
locale: "fr",
|
||||||
|
expectedKeys: ["whisk"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Incorporez délicatement la farine tamisée à la préparation.",
|
||||||
|
locale: "fr",
|
||||||
|
expectedKeys: ["foldIn"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Réservez la pâte au réfrigérateur pendant que vous préparez la garniture.",
|
||||||
|
locale: "fr",
|
||||||
|
expectedKeys: ["setAside"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Assaisonnez le poisson avec du sel, du poivre et un filet de citron.",
|
||||||
|
locale: "fr",
|
||||||
|
expectedKeys: ["season"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Égouttez soigneusement le riz dans une passoire fine.",
|
||||||
|
locale: "fr",
|
||||||
|
expectedKeys: ["drain"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description:
|
||||||
|
"Faites dorer les morceaux de veau sur toutes leurs faces avant de mouiller avec le bouillon.",
|
||||||
|
locale: "fr",
|
||||||
|
expectedKeys: ["brown"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Laissez reposer la viande dix minutes avant de la trancher.",
|
||||||
|
locale: "fr",
|
||||||
|
expectedKeys: ["rest"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Préchauffez le four à 200 degrés avant d'y glisser le gratin.",
|
||||||
|
locale: "fr",
|
||||||
|
expectedKeys: ["preheat"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Enfournez la tarte pendant trente-cinq minutes jusqu'à ce qu'elle soit dorée.",
|
||||||
|
locale: "fr",
|
||||||
|
expectedKeys: ["bake"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Dressez harmonieusement les légumes autour de la pièce de viande.",
|
||||||
|
locale: "fr",
|
||||||
|
expectedKeys: ["plate"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Nappez le fond du moule d'une fine couche de caramel.",
|
||||||
|
locale: "fr",
|
||||||
|
expectedKeys: ["coat"],
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- English coverage (same technique verbs, distinct sentences) ---
|
||||||
|
{
|
||||||
|
description: "Simmer the stock gently for forty minutes, skimming occasionally.",
|
||||||
|
locale: "en",
|
||||||
|
expectedKeys: ["simmer"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Peel the potatoes and rinse them under cold water.",
|
||||||
|
locale: "en",
|
||||||
|
expectedKeys: ["peel"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Whisk the eggs with a pinch of salt until frothy.",
|
||||||
|
locale: "en",
|
||||||
|
expectedKeys: ["whisk"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Season the soup generously with black pepper before serving.",
|
||||||
|
locale: "en",
|
||||||
|
expectedKeys: ["season"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Make sure the chicken is cooked through before serving.",
|
||||||
|
locale: "en",
|
||||||
|
expectedKeys: ["cook"],
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Multi-technique sentences, in reading order ---
|
||||||
|
{
|
||||||
|
description: "Préchauffez le four, puis faites rôtir le poulet pendant une heure.",
|
||||||
|
locale: "fr",
|
||||||
|
expectedKeys: ["preheat", "roast"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Faites revenir les oignons, puis déglacez la poêle avec du vin blanc.",
|
||||||
|
locale: "fr",
|
||||||
|
expectedKeys: ["brown", "deglaze"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description:
|
||||||
|
"Faites cuire les légumes à la vapeur, puis assaisonnez-les avec des herbes fraîches.",
|
||||||
|
locale: "fr",
|
||||||
|
expectedKeys: ["cook", "season"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description:
|
||||||
|
"Émincez l'oignon, faites-le suer, puis mouillez avec le bouillon et laissez mijoter.",
|
||||||
|
locale: "fr",
|
||||||
|
expectedKeys: ["mince", "simmer"],
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- No technique mentioned at all ---
|
||||||
|
{
|
||||||
|
description: "Répartissez les convives autour de la table avant de commencer le repas.",
|
||||||
|
locale: "fr",
|
||||||
|
expectedKeys: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Rangez les couverts propres dans le tiroir de la cuisine.",
|
||||||
|
locale: "fr",
|
||||||
|
expectedKeys: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Take the plates and glasses out of the cupboard.",
|
||||||
|
locale: "en",
|
||||||
|
expectedKeys: [],
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- 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).
|
||||||
|
{
|
||||||
|
description: "This recipe calls for two tablespoons of brown sugar.",
|
||||||
|
locale: "en",
|
||||||
|
expectedKeys: [],
|
||||||
|
},
|
||||||
|
// `rest`'s EN synonyms are anchored phrases ("let it rest"/"resting
|
||||||
|
// for"...), not bare "rest" — so a sentence using the word in its
|
||||||
|
// "remainder" sense must not anchor `rest` at all.
|
||||||
|
{
|
||||||
|
description: "There is no time to rest before the guests arrive.",
|
||||||
|
locale: "en",
|
||||||
|
expectedKeys: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
59
apps/api/src/lib/recipe-matching/tech-step-eval-runner.ts
Normal file
59
apps/api/src/lib/recipe-matching/tech-step-eval-runner.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
import { prisma } from "../../db/prisma.js";
|
||||||
|
import { TECH_STEP_EVAL_DATASET } from "./tech-step-eval-dataset.js";
|
||||||
|
import {
|
||||||
|
computeTechStepMetrics,
|
||||||
|
type TechStepEvalOutcome,
|
||||||
|
type TechStepEvalResult,
|
||||||
|
} from "./tech-step-evaluator.js";
|
||||||
|
import { techStepClassifier } from "./tech-step-matcher.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provisional floor (not a target) both `runTechStepEvalSuite`'s
|
||||||
|
* consumers gate on — see `test/recipe-matching/tech-step-eval.test.ts`'s
|
||||||
|
* own doc comment for the full reasoning behind this specific value and
|
||||||
|
* when to tighten it. Exported from here (not defined separately in each
|
||||||
|
* consumer) so the CI regression gate and `scripts/retrain-tech-steps.ts`'s
|
||||||
|
* pre-backfill gate can never silently drift to different thresholds.
|
||||||
|
*/
|
||||||
|
export const MIN_OVERALL_F1 = 0.8;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs {@link TECH_STEP_EVAL_DATASET} against the real, currently-trained
|
||||||
|
* `techStepClassifier` and returns the aggregate/per-technique metrics
|
||||||
|
* (`computeTechStepMetrics`, `tech-step-evaluator.ts`) — the one place this
|
||||||
|
* DB-touching "resolve ids to keys, then score" logic lives, shared by
|
||||||
|
* `test/recipe-matching/tech-step-eval.test.ts` (this feature's CI
|
||||||
|
* regression gate) and `scripts/retrain-tech-steps.ts` (the same gate, run
|
||||||
|
* by a maintainer before applying a corpus change). Kept out of
|
||||||
|
* `tech-step-evaluator.ts` itself, which is deliberately pure/DB-free (see
|
||||||
|
* that module's own doc comment) so its scoring logic stays unit-testable
|
||||||
|
* without a database.
|
||||||
|
*/
|
||||||
|
export async function runTechStepEvalSuite(): Promise<TechStepEvalResult> {
|
||||||
|
const techSteps = await prisma.techStep.findMany({ select: { id: true, key: true } });
|
||||||
|
const keyById = new Map(techSteps.map((techStep) => [techStep.id, techStep.key]));
|
||||||
|
|
||||||
|
const outcomes: TechStepEvalOutcome[] = [];
|
||||||
|
for (const evalCase of TECH_STEP_EVAL_DATASET) {
|
||||||
|
const techStepIds = await techStepClassifier.matchTechSteps(
|
||||||
|
evalCase.description,
|
||||||
|
evalCase.locale,
|
||||||
|
);
|
||||||
|
const actualKeys = techStepIds.map((id) => {
|
||||||
|
const key = keyById.get(id);
|
||||||
|
// A `techStepId` the classifier resolved that isn't in the seeded
|
||||||
|
// catalog would be a bug in the classifier or the seed data, not
|
||||||
|
// this dataset — fail loudly rather than silently dropping it (see
|
||||||
|
// `_train`'s own comment in `tech-step-matcher.ts` on the
|
||||||
|
// equivalent, deliberately silent `undefined` case it has to
|
||||||
|
// tolerate for a different reason).
|
||||||
|
if (key === undefined) {
|
||||||
|
throw new Error(`Unknown TechStep id ${id} returned for "${evalCase.description}"`);
|
||||||
|
}
|
||||||
|
return key;
|
||||||
|
});
|
||||||
|
outcomes.push({ expectedKeys: evalCase.expectedKeys, actualKeys });
|
||||||
|
}
|
||||||
|
|
||||||
|
return computeTechStepMetrics(outcomes);
|
||||||
|
}
|
||||||
134
apps/api/src/lib/recipe-matching/tech-step-evaluator.ts
Normal file
134
apps/api/src/lib/recipe-matching/tech-step-evaluator.ts
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
/**
|
||||||
|
* Precision/recall/F1 for {@link techStepClassifier}'s output against a
|
||||||
|
* 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
|
||||||
|
* 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,
|
||||||
|
* not get merged on the strength of a few manually-checked examples.
|
||||||
|
*
|
||||||
|
* Pure (no DB/model access) so it's unit-testable on its own — same
|
||||||
|
* convention as `tech-step-matcher.ts`'s own pure helpers (`normalizeText`,
|
||||||
|
* `splitIntoClauses`): this module only ever receives already-resolved
|
||||||
|
* `TechStep.key` strings, never DB ids or a live classifier instance, so it
|
||||||
|
* has nothing to mock to test.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** True/false-positive/negative counts for one technique (or the aggregate across all of them), plus the precision/recall/F1 derived from them. */
|
||||||
|
export interface TechStepMetrics {
|
||||||
|
truePositives: number;
|
||||||
|
falsePositives: number;
|
||||||
|
falseNegatives: number;
|
||||||
|
precision: number;
|
||||||
|
recall: number;
|
||||||
|
f1: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One evaluation case's outcome — what {@link TechStepEvalCase.expectedKeys}
|
||||||
|
* said should be found, against what the classifier actually returned for
|
||||||
|
* that case (already mapped from `TechStepMatch.techStepId` back to
|
||||||
|
* `TechStep.key`, see `tech-step-eval.test.ts`).
|
||||||
|
*
|
||||||
|
* Both lists are *multisets*, not sets — a description that names the same
|
||||||
|
* technique twice (rare, but not impossible: "faire cuire, puis... remettre
|
||||||
|
* à cuire") is expected to produce two matches, and comparing as plain sets
|
||||||
|
* would silently treat a classifier that only found one of them as a
|
||||||
|
* perfect match.
|
||||||
|
*/
|
||||||
|
export interface TechStepEvalOutcome {
|
||||||
|
expectedKeys: string[];
|
||||||
|
actualKeys: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** {@link computeTechStepMetrics}'s result — the aggregate across every case, plus a breakdown per technique so a regression hiding behind a healthy overall F1 (one technique's recall collapsing, offset by another's improving) is still visible. */
|
||||||
|
export interface TechStepEvalResult {
|
||||||
|
overall: TechStepMetrics;
|
||||||
|
byKey: Record<string, TechStepMetrics>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawCounts {
|
||||||
|
tp: number;
|
||||||
|
fp: number;
|
||||||
|
fn: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyCounts(): RawCounts {
|
||||||
|
return { tp: 0, fp: 0, fn: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Counts occurrences of each key in a multiset, e.g. `["cook", "cook", "bake"]` -> `{cook: 2, bake: 1}`. */
|
||||||
|
function countByKey(keys: string[]): Map<string, number> {
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
for (const key of keys) {
|
||||||
|
counts.set(key, (counts.get(key) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
return counts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standard vacuous-truth convention for the `0/0` cases: precision defaults
|
||||||
|
* to `1` when nothing was predicted for a key (`tp + fp === 0` — no false
|
||||||
|
* accusation to be precise about), recall defaults to `1` when nothing was
|
||||||
|
* expected (`tp + fn === 0` — nothing to have missed). Neither inflates F1
|
||||||
|
* on its own: a technique the classifier fully misses still has `recall =
|
||||||
|
* 0` (there *were* expected occurrences, just none matched), which is what
|
||||||
|
* pulls F1 down to `0` for that case regardless of precision's vacuous `1`.
|
||||||
|
*/
|
||||||
|
function toMetrics(counts: RawCounts): TechStepMetrics {
|
||||||
|
const { tp, fp, fn } = counts;
|
||||||
|
const precision = tp + fp === 0 ? 1 : tp / (tp + fp);
|
||||||
|
const recall = tp + fn === 0 ? 1 : tp / (tp + fn);
|
||||||
|
const f1 = precision + recall === 0 ? 0 : (2 * precision * recall) / (precision + recall);
|
||||||
|
return { truePositives: tp, falsePositives: fp, falseNegatives: fn, precision, recall, f1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aggregates every {@link TechStepEvalOutcome} into one overall
|
||||||
|
* precision/recall/F1 plus a per-technique breakdown.
|
||||||
|
*
|
||||||
|
* Counted key by key, multiset-style, per outcome: for a given technique,
|
||||||
|
* `min(expectedCount, actualCount)` true positives, any actual occurrences
|
||||||
|
* beyond that are false positives, any expected occurrences short of that
|
||||||
|
* are false negatives — generalizes the usual set-based TP/FP/FN definition
|
||||||
|
* to handle a technique mentioned (or matched) more than once in the same
|
||||||
|
* step without over- or under-counting it.
|
||||||
|
*/
|
||||||
|
export function computeTechStepMetrics(outcomes: TechStepEvalOutcome[]): TechStepEvalResult {
|
||||||
|
const overallCounts = emptyCounts();
|
||||||
|
const countsByKey = new Map<string, RawCounts>();
|
||||||
|
|
||||||
|
for (const outcome of outcomes) {
|
||||||
|
const expectedCounts = countByKey(outcome.expectedKeys);
|
||||||
|
const actualCounts = countByKey(outcome.actualKeys);
|
||||||
|
const allKeys = new Set([...expectedCounts.keys(), ...actualCounts.keys()]);
|
||||||
|
|
||||||
|
for (const key of allKeys) {
|
||||||
|
const expected = expectedCounts.get(key) ?? 0;
|
||||||
|
const actual = actualCounts.get(key) ?? 0;
|
||||||
|
const tp = Math.min(expected, actual);
|
||||||
|
const fp = Math.max(0, actual - expected);
|
||||||
|
const fn = Math.max(0, expected - actual);
|
||||||
|
|
||||||
|
overallCounts.tp += tp;
|
||||||
|
overallCounts.fp += fp;
|
||||||
|
overallCounts.fn += fn;
|
||||||
|
|
||||||
|
const keyCounts = countsByKey.get(key) ?? emptyCounts();
|
||||||
|
keyCounts.tp += tp;
|
||||||
|
keyCounts.fp += fp;
|
||||||
|
keyCounts.fn += fn;
|
||||||
|
countsByKey.set(key, keyCounts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const byKey: Record<string, TechStepMetrics> = {};
|
||||||
|
for (const [key, counts] of countsByKey) {
|
||||||
|
byKey[key] = toMetrics(counts);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { overall: toMetrics(overallCounts), byKey };
|
||||||
|
}
|
||||||
|
|
@ -252,7 +252,32 @@ export function splitIntoClauses(
|
||||||
* — `0.75` sits comfortably above the noise floor and below every genuine
|
* — `0.75` sits comfortably above the noise floor and below every genuine
|
||||||
* match seen so far.
|
* match seen so far.
|
||||||
*/
|
*/
|
||||||
const CONFIDENCE_THRESHOLD = 0.75;
|
export const CONFIDENCE_THRESHOLD = 0.75;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One clause's full classification detail — the finer-grained sibling of
|
||||||
|
* {@link TechStepMatch}, exposing the raw intent/score
|
||||||
|
* `TechStepClassifierService`'s private `_classifyClause` normally
|
||||||
|
* collapses into a single accepted-or-fallback verdict. Nothing on the
|
||||||
|
* interactive save/read path needs this (that's exactly what
|
||||||
|
* `_classifyClause`'s threshold + fallback logic is for) — it exists for
|
||||||
|
* `services/tech-step-llm-worker`'s "audit low-confidence clauses" job
|
||||||
|
* (`modules/internal/tech-step-worker.service.ts`'s `getAuditBatch`), which
|
||||||
|
* needs to see *which* clauses the classifier itself wasn't sure about, not
|
||||||
|
* just its final best-effort verdict.
|
||||||
|
*/
|
||||||
|
export interface TechStepClauseClassification {
|
||||||
|
/** The clause's own text (`description.slice(start, end)`, trimmed). */
|
||||||
|
clauseText: string;
|
||||||
|
start: number;
|
||||||
|
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. */
|
||||||
|
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} —
|
* Trains and owns the `node-nlp` model behind {@link matchTechStepSpans} —
|
||||||
|
|
@ -388,6 +413,66 @@ export class TechStepClassifierService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Splits `description` into clauses exactly like {@link matchTechStepSpans}
|
||||||
|
* does, but returns each clause's *raw* classification detail
|
||||||
|
* ({@link TechStepClauseClassification}) instead of the threshold-applied,
|
||||||
|
* anchor-fallback-resolved `TechStepMatch` — see that type's doc comment
|
||||||
|
* for why/who needs this. Deliberately a separate traversal rather than a
|
||||||
|
* shared refactor with `matchTechStepSpans`/`_classifyClause`: this method
|
||||||
|
* exists purely to add a new, additive read path without risking a
|
||||||
|
* behavior change to the two already-relied-on methods above.
|
||||||
|
*/
|
||||||
|
public async classifyClauses(
|
||||||
|
description: string,
|
||||||
|
locale: string,
|
||||||
|
): Promise<TechStepClauseClassification[]> {
|
||||||
|
try {
|
||||||
|
await this._ensureTrained();
|
||||||
|
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 clauses = splitIntoClauses(description, candidates);
|
||||||
|
const results: TechStepClauseClassification[] = [];
|
||||||
|
for (const clause of clauses) {
|
||||||
|
const clauseText = description.slice(clause.start, clause.end).trim();
|
||||||
|
const anchorUid = clause.anchor?.uid ?? null;
|
||||||
|
if (clauseText.length === 0) {
|
||||||
|
results.push({
|
||||||
|
clauseText,
|
||||||
|
start: clause.start,
|
||||||
|
end: clause.end,
|
||||||
|
anchorUid,
|
||||||
|
intentUid: null,
|
||||||
|
score: 0,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const result = await this._manager.process(locale, clauseText);
|
||||||
|
const intentUid = result.intent !== "None" ? result.intent : null;
|
||||||
|
results.push({
|
||||||
|
clauseText,
|
||||||
|
start: clause.start,
|
||||||
|
end: clause.end,
|
||||||
|
anchorUid,
|
||||||
|
intentUid,
|
||||||
|
score: intentUid === null ? 0 : result.score,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
} catch (err) {
|
||||||
|
throw err; // see matchTechStepSpans()'s catch comment above
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convenience wrapper around {@link matchTechStepSpans} for callers that
|
* Convenience wrapper around {@link matchTechStepSpans} for callers that
|
||||||
* only care about *which* techniques matched, not where — e.g.
|
* only care about *which* techniques matched, not where — e.g.
|
||||||
|
|
|
||||||
50
apps/api/src/middlewares/require-internal-worker.ts
Normal file
50
apps/api/src/middlewares/require-internal-worker.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
import { timingSafeEqual } from "node:crypto";
|
||||||
|
import { HttpError } from "@batch-cooking/error-tools";
|
||||||
|
import { ErrorCode } from "@batch-cooking/shared";
|
||||||
|
import type { NextFunction, Request, Response } from "express";
|
||||||
|
import { env } from "../config/env.js";
|
||||||
|
|
||||||
|
/** Header `services/tech-step-llm-worker` sends its shared secret on. Not `Authorization`/a bearer scheme — this isn't a user session, just one internal caller authenticating to another, same "one flat shared secret" shape as e.g. a webhook signing header. */
|
||||||
|
const INTERNAL_WORKER_SECRET_HEADER = "x-internal-worker-secret";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Express middleware guarding `/internal/tech-steps/*` — the surface
|
||||||
|
* `services/tech-step-llm-worker` (a process outside this monorepo, no
|
||||||
|
* Prisma access of its own, see that service's own README) reads
|
||||||
|
* low-confidence NLP clauses and pending `StepTechStepCorrection`s from,
|
||||||
|
* and posts `TechStepTrainingSuggestion`s back to. Never reachable by an
|
||||||
|
* end user's session cookie — deliberately a *different* auth mechanism
|
||||||
|
* than {@link requireAuth} (`require-auth.ts`), not layered on top of it,
|
||||||
|
* since the worker has no `UserProfile`/session of its own to authenticate
|
||||||
|
* as.
|
||||||
|
*
|
||||||
|
* Fails closed: an unset `INTERNAL_WORKER_SECRET` (the default in any
|
||||||
|
* environment that doesn't run the worker, see `config/env.ts`) rejects
|
||||||
|
* every request rather than leaving the surface open, same posture as a
|
||||||
|
* misconfigured `JWT_SECRET` would if it had a working fallback.
|
||||||
|
*
|
||||||
|
* @throws {HttpError} `401 NOT_AUTHENTICATED` if the header is missing,
|
||||||
|
* wrong, or the server has no secret configured at all — never
|
||||||
|
* distinguishes the reason, same posture as {@link requireAuth}.
|
||||||
|
*/
|
||||||
|
export function requireInternalWorker(req: Request, _res: Response, next: NextFunction): void {
|
||||||
|
const provided = req.header(INTERNAL_WORKER_SECRET_HEADER);
|
||||||
|
if (env.INTERNAL_WORKER_SECRET === undefined || provided === undefined) {
|
||||||
|
next(new HttpError(401, ErrorCode.NOT_AUTHENTICATED, "Not authenticated"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// `timingSafeEqual` throws on mismatched buffer lengths rather than
|
||||||
|
// returning `false` — checked separately first. A length mismatch alone
|
||||||
|
// already means "not equal", so this loses no timing-attack protection
|
||||||
|
// (an attacker learns nothing beyond what a differing length itself
|
||||||
|
// already reveals, no different from `!==` on the common case where the
|
||||||
|
// secret's real length isn't a secret worth protecting).
|
||||||
|
const expected = Buffer.from(env.INTERNAL_WORKER_SECRET);
|
||||||
|
const actual = Buffer.from(provided);
|
||||||
|
if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) {
|
||||||
|
next(new HttpError(401, ErrorCode.NOT_AUTHENTICATED, "Not authenticated"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
}
|
||||||
49
apps/api/src/modules/internal/tech-step-worker.routes.ts
Normal file
49
apps/api/src/modules/internal/tech-step-worker.routes.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
||||||
|
import {
|
||||||
|
auditBatchQuerySchema,
|
||||||
|
submitTrainingSuggestionsSchema,
|
||||||
|
workerBatchQuerySchema,
|
||||||
|
} from "@batch-cooking/shared";
|
||||||
|
import { Router } from "express";
|
||||||
|
import { requireInternalWorker } from "../../middlewares/require-internal-worker.js";
|
||||||
|
import {
|
||||||
|
getAuditBatch,
|
||||||
|
getPendingCorrections,
|
||||||
|
submitTrainingSuggestions,
|
||||||
|
} from "./tech-step-worker.service.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Router mounted at `/internal/tech-steps` in app.ts — every route requires
|
||||||
|
* {@link requireInternalWorker}, never {@link requireAuth}
|
||||||
|
* (`middlewares/require-auth.ts`): this is `services/tech-step-llm-worker`
|
||||||
|
* authenticating as itself, not a user session. See that middleware's own
|
||||||
|
* doc comment for why the two are deliberately separate mechanisms.
|
||||||
|
*/
|
||||||
|
export const techStepWorkerRouter = Router();
|
||||||
|
|
||||||
|
techStepWorkerRouter.get(
|
||||||
|
"/audit-batch",
|
||||||
|
requireInternalWorker,
|
||||||
|
wrapAsyncHandler(async (req, res) => {
|
||||||
|
const input = auditBatchQuerySchema.parse(req.query);
|
||||||
|
res.status(200).json(await getAuditBatch(input.locale, input.limit));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
techStepWorkerRouter.get(
|
||||||
|
"/pending-corrections",
|
||||||
|
requireInternalWorker,
|
||||||
|
wrapAsyncHandler(async (req, res) => {
|
||||||
|
const input = workerBatchQuerySchema.parse(req.query);
|
||||||
|
res.status(200).json(await getPendingCorrections(input.limit));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
techStepWorkerRouter.post(
|
||||||
|
"/training-suggestions",
|
||||||
|
requireInternalWorker,
|
||||||
|
wrapAsyncHandler(async (req, res) => {
|
||||||
|
const input = submitTrainingSuggestionsSchema.parse(req.body);
|
||||||
|
res.status(201).json(await submitTrainingSuggestions(input));
|
||||||
|
}),
|
||||||
|
);
|
||||||
201
apps/api/src/modules/internal/tech-step-worker.service.ts
Normal file
201
apps/api/src/modules/internal/tech-step-worker.service.ts
Normal file
|
|
@ -0,0 +1,201 @@
|
||||||
|
import { HttpError } from "@batch-cooking/error-tools";
|
||||||
|
import {
|
||||||
|
ErrorCode,
|
||||||
|
type PendingTechStepCorrectionView,
|
||||||
|
type SubmitTrainingSuggestionsInput,
|
||||||
|
type TechStepAuditClauseView,
|
||||||
|
} from "@batch-cooking/shared";
|
||||||
|
import { prisma } from "../../db/prisma.js";
|
||||||
|
import {
|
||||||
|
CONFIDENCE_THRESHOLD,
|
||||||
|
techStepClassifier,
|
||||||
|
} from "../../lib/recipe-matching/tech-step-matcher.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read/write surface `services/tech-step-llm-worker` calls through
|
||||||
|
* `/internal/tech-steps/*` (`tech-step-worker.routes.ts`, guarded by
|
||||||
|
* `requireInternalWorker`) — the worker has no Prisma client or database
|
||||||
|
* credentials of its own (see that service's own README), so every
|
||||||
|
* corrections/audit-sample read and every suggestion write goes through
|
||||||
|
* here rather than the worker touching this schema directly. Keeps
|
||||||
|
* `apps/api` the single owner of the schema/migrations, and keeps the
|
||||||
|
* worker a pure "read some text, run inference, post a suggestion" process
|
||||||
|
* with nothing to keep in sync if the schema changes shape.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many of the most recently created `Step`s {@link getAuditBatch} scans
|
||||||
|
* per call before filtering down to low-confidence clauses — a fixed
|
||||||
|
* recency-biased sample, not every `Step` in the database, to keep this
|
||||||
|
* endpoint's cost bounded regardless of how large the recipe catalog gets.
|
||||||
|
* Recently-added steps are also the steps most likely to still use
|
||||||
|
* vocabulary the training corpus hasn't caught up with yet, which is
|
||||||
|
* exactly what this audit is for. A smarter sampling strategy (e.g.
|
||||||
|
* weighted by how often a recipe is actually viewed/planned) is future
|
||||||
|
* work, not needed for this feature's first version.
|
||||||
|
*/
|
||||||
|
const AUDIT_SAMPLE_SIZE = 200;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every low-confidence clause found across a recency-biased sample of
|
||||||
|
* existing `Step`s (see {@link AUDIT_SAMPLE_SIZE}), for
|
||||||
|
* `services/tech-step-llm-worker`'s `audit-low-confidence` job to get a
|
||||||
|
* second opinion on. "Low-confidence" mirrors exactly what
|
||||||
|
* `TechStepClassifierService._classifyClause` itself distrusts (a clause
|
||||||
|
* with an NER anchor but a classifier score under
|
||||||
|
* {@link CONFIDENCE_THRESHOLD}) — the same clauses that pipeline already
|
||||||
|
* has to fall back to keyword-anchor guessing for, not an arbitrary
|
||||||
|
* separate cutoff.
|
||||||
|
*/
|
||||||
|
export async function getAuditBatch(
|
||||||
|
locale: string,
|
||||||
|
limit: number,
|
||||||
|
): Promise<TechStepAuditClauseView[]> {
|
||||||
|
try {
|
||||||
|
const steps = await prisma.step.findMany({
|
||||||
|
orderBy: { id: "desc" },
|
||||||
|
take: AUDIT_SAMPLE_SIZE,
|
||||||
|
select: { id: true, recipeId: true, description: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const results: TechStepAuditClauseView[] = [];
|
||||||
|
for (const step of steps) {
|
||||||
|
if (results.length >= limit) break;
|
||||||
|
const clauses = await techStepClassifier.classifyClauses(step.description, locale);
|
||||||
|
for (const clause of clauses) {
|
||||||
|
if (results.length >= limit) break;
|
||||||
|
const isLowConfidence = clause.anchorUid !== null && clause.score < CONFIDENCE_THRESHOLD;
|
||||||
|
if (!isLowConfidence) continue;
|
||||||
|
results.push({
|
||||||
|
stepId: step.id,
|
||||||
|
recipeId: step.recipeId,
|
||||||
|
clauseText: clause.clauseText,
|
||||||
|
anchorKey: clause.anchorUid,
|
||||||
|
intentKey: clause.intentUid,
|
||||||
|
score: clause.score,
|
||||||
|
locale,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
} catch (err) {
|
||||||
|
throw err; // see recipe.service.ts's equivalent catch comment
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every `StepTechStepCorrection` not yet turned into a
|
||||||
|
* `TechStepTrainingSuggestion` (`consumedAt IS NULL`), oldest first — a
|
||||||
|
* FIFO queue the worker's `transform-corrections` job drains, `limit` at a
|
||||||
|
* time.
|
||||||
|
*
|
||||||
|
* `correctedTechStepId IS NOT NULL` on top of `consumedAt IS NULL`: a
|
||||||
|
* correction that *removes* a match ("no technique belongs here",
|
||||||
|
* `correctedTechStepId: null` — see `StepTechStepCorrection`'s schema doc
|
||||||
|
* comment) has no technique to propose new positive training data *for*.
|
||||||
|
* Surfacing it here would leave it permanently unconsumable (the worker
|
||||||
|
* has nothing to submit a suggestion for, so it would never stamp
|
||||||
|
* `consumedAt`, and it would keep re-appearing in every future batch
|
||||||
|
* forever) — excluded at the source instead, not filtered/skipped
|
||||||
|
* downstream by the worker.
|
||||||
|
*/
|
||||||
|
export async function getPendingCorrections(
|
||||||
|
limit: number,
|
||||||
|
): Promise<PendingTechStepCorrectionView[]> {
|
||||||
|
try {
|
||||||
|
const corrections = await prisma.stepTechStepCorrection.findMany({
|
||||||
|
where: { consumedAt: null, correctedTechStepId: { not: null } },
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
take: limit,
|
||||||
|
include: {
|
||||||
|
step: { select: { id: true, recipeId: true, description: true } },
|
||||||
|
previousTechStep: { select: { key: true } },
|
||||||
|
correctedTechStep: { select: { key: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return corrections.map((correction) => ({
|
||||||
|
id: correction.id,
|
||||||
|
stepId: correction.step.id,
|
||||||
|
recipeId: correction.step.recipeId,
|
||||||
|
clauseText: correction.step.description.slice(correction.start, correction.end),
|
||||||
|
start: correction.start,
|
||||||
|
end: correction.end,
|
||||||
|
previousTechStepKey: correction.previousTechStep?.key ?? null,
|
||||||
|
correctedTechStepKey: correction.correctedTechStep?.key ?? null,
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
throw err; // see recipe.service.ts's equivalent catch comment
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persists a batch of `TechStepTrainingSuggestion`s and, for every
|
||||||
|
* suggestion sourced from a correction, stamps that correction's
|
||||||
|
* `consumedAt` in the same transaction — so a worker run that crashes
|
||||||
|
* partway through never leaves a correction consumed with no matching
|
||||||
|
* suggestion, or a suggestion created against a correction still (wrongly)
|
||||||
|
* eligible to be picked up again by the next run.
|
||||||
|
*
|
||||||
|
* @throws {HttpError} `404 TECH_STEP_NOT_FOUND` if any `techStepKey` in the
|
||||||
|
* batch doesn't match a reference `TechStep` — rejects the *whole* batch
|
||||||
|
* rather than skipping the bad entries, on the theory that a worker
|
||||||
|
* sending an unknown key is more likely a version-skew bug (its own
|
||||||
|
* taxonomy copy, `services/tech-step-llm-worker/src/tech-step-taxonomy.ts`,
|
||||||
|
* drifting from this API's `TechStep` catalog) than a one-off it should
|
||||||
|
* silently tolerate.
|
||||||
|
*/
|
||||||
|
export async function submitTrainingSuggestions(
|
||||||
|
input: SubmitTrainingSuggestionsInput,
|
||||||
|
): Promise<{ created: number }> {
|
||||||
|
try {
|
||||||
|
const techStepKeys = [
|
||||||
|
...new Set(input.suggestions.map((suggestion) => suggestion.techStepKey)),
|
||||||
|
];
|
||||||
|
const techSteps = await prisma.techStep.findMany({
|
||||||
|
where: { key: { in: techStepKeys } },
|
||||||
|
select: { id: true, key: true },
|
||||||
|
});
|
||||||
|
const techStepIdByKey = new Map(techSteps.map((techStep) => [techStep.key, techStep.id]));
|
||||||
|
const missingKeys = techStepKeys.filter((key) => !techStepIdByKey.has(key));
|
||||||
|
if (missingKeys.length > 0) {
|
||||||
|
throw new HttpError(
|
||||||
|
404,
|
||||||
|
ErrorCode.TECH_STEP_NOT_FOUND,
|
||||||
|
`Unknown techStepKey(s): ${missingKeys.join(", ")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
for (const suggestion of input.suggestions) {
|
||||||
|
// Non-null by construction — every key in `input.suggestions` was
|
||||||
|
// just confirmed present in `techStepIdByKey` above (the `missingKeys`
|
||||||
|
// check would have thrown otherwise).
|
||||||
|
const techStepId = techStepIdByKey.get(suggestion.techStepKey);
|
||||||
|
if (techStepId === undefined) continue;
|
||||||
|
|
||||||
|
await tx.techStepTrainingSuggestion.create({
|
||||||
|
data: {
|
||||||
|
techStepId,
|
||||||
|
locale: suggestion.locale,
|
||||||
|
suggestedSynonyms: suggestion.suggestedSynonyms,
|
||||||
|
suggestedUtterances: suggestion.suggestedUtterances,
|
||||||
|
sourceType: suggestion.sourceType,
|
||||||
|
sourceCorrectionId: suggestion.sourceCorrectionId ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (suggestion.sourceCorrectionId !== null && suggestion.sourceCorrectionId !== undefined) {
|
||||||
|
await tx.stepTechStepCorrection.update({
|
||||||
|
where: { id: suggestion.sourceCorrectionId },
|
||||||
|
data: { consumedAt: new Date() },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { created: input.suggestions.length };
|
||||||
|
} catch (err) {
|
||||||
|
throw err; // see recipe.service.ts's equivalent catch comment
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,180 @@
|
||||||
|
import { HttpError } from "@batch-cooking/error-tools";
|
||||||
|
import {
|
||||||
|
ErrorCode,
|
||||||
|
type StepTechStepCorrectionView,
|
||||||
|
type SubmitTechStepCorrectionInput,
|
||||||
|
} from "@batch-cooking/shared";
|
||||||
|
import type { Prisma } from "@prisma/client";
|
||||||
|
import { prisma } from "../../db/prisma.js";
|
||||||
|
import { assertRecipeVisible } from "./recipe.service.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User-submitted corrections to a step's detected techniques
|
||||||
|
* (`StepTechStepCorrection` in schema.prisma) — kept in its own module
|
||||||
|
* rather than folded into `recipe.service.ts`, same "one file per concern"
|
||||||
|
* split that file itself follows for `tech-step-matcher.ts`. Deliberately
|
||||||
|
* open to *any* viewer who can see the recipe, not just its author (unlike
|
||||||
|
* every write path in `recipe.service.ts`, which uses `assertIsAuthor`) —
|
||||||
|
* correcting a mislabeled technique isn't editing the recipe's own
|
||||||
|
* content, and restricting it to authors would starve the training-data
|
||||||
|
* feedback loop (`services/tech-step-llm-worker`) of the volume it needs.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type CorrectionWithTechSteps = Prisma.StepTechStepCorrectionGetPayload<{
|
||||||
|
include: { previousTechStep: true; correctedTechStep: true };
|
||||||
|
}>;
|
||||||
|
|
||||||
|
const correctionInclude = {
|
||||||
|
previousTechStep: true,
|
||||||
|
correctedTechStep: true,
|
||||||
|
} satisfies Prisma.StepTechStepCorrectionInclude;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads `stepId`'s current `description` length (the only thing a
|
||||||
|
* correction needs from the step itself), or throws — `404 STEP_NOT_FOUND`
|
||||||
|
* if no such step exists, or if it exists but doesn't belong to `recipeId`
|
||||||
|
* (the route's own `:id`/`:stepId` nesting is meaningless otherwise — a
|
||||||
|
* request naming a real step under the wrong recipe should look identical
|
||||||
|
* to naming one that doesn't exist, same "don't leak which part was wrong"
|
||||||
|
* posture `assertRecipeVisible` already has for visibility). Otherwise
|
||||||
|
* whatever {@link assertRecipeVisible} throws (`404 RECIPE_NOT_FOUND`,
|
||||||
|
* never `403`) if the recipe exists but isn't visible to the viewer.
|
||||||
|
*/
|
||||||
|
async function loadVisibleStepOrThrow(
|
||||||
|
recipeId: number,
|
||||||
|
stepId: number,
|
||||||
|
viewerId: number,
|
||||||
|
viewerHouseId: number | null,
|
||||||
|
): Promise<{ id: number; descriptionLength: number }> {
|
||||||
|
try {
|
||||||
|
const step = await prisma.step.findUnique({
|
||||||
|
where: { id: stepId },
|
||||||
|
select: { id: true, recipeId: true, description: true },
|
||||||
|
});
|
||||||
|
if (!step || step.recipeId !== recipeId) {
|
||||||
|
throw new HttpError(404, ErrorCode.STEP_NOT_FOUND, `Step ${stepId} not found`);
|
||||||
|
}
|
||||||
|
await assertRecipeVisible(step.recipeId, viewerId, viewerHouseId);
|
||||||
|
return { id: step.id, descriptionLength: step.description.length };
|
||||||
|
} catch (err) {
|
||||||
|
throw err; // see recipe.service.ts's equivalent catch comment
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Throws `404 TECH_STEP_NOT_FOUND` if any id in `ids` doesn't match a reference `TechStep` row — same shape as `recipe.service.ts`'s `assertIngredientsExist`/`assertUnitsExist` for the recipe payload's own reference ids. */
|
||||||
|
async function assertTechStepsExist(ids: number[]): Promise<void> {
|
||||||
|
try {
|
||||||
|
if (ids.length === 0) return;
|
||||||
|
const found = await prisma.techStep.findMany({
|
||||||
|
where: { id: { in: ids } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
const foundIds = new Set(found.map((techStep) => techStep.id));
|
||||||
|
const missing = ids.filter((id) => !foundIds.has(id));
|
||||||
|
if (missing.length > 0) {
|
||||||
|
throw new HttpError(
|
||||||
|
404,
|
||||||
|
ErrorCode.TECH_STEP_NOT_FOUND,
|
||||||
|
`TechStep ids not found: ${missing.join(", ")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
throw err; // see loadVisibleStepOrThrow's catch comment
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toCorrectionView(correction: CorrectionWithTechSteps): StepTechStepCorrectionView {
|
||||||
|
return {
|
||||||
|
id: correction.id,
|
||||||
|
start: correction.start,
|
||||||
|
end: correction.end,
|
||||||
|
previousTechStep: correction.previousTechStep
|
||||||
|
? { id: correction.previousTechStep.id, key: correction.previousTechStep.key }
|
||||||
|
: null,
|
||||||
|
correctedTechStep: correction.correctedTechStep
|
||||||
|
? { id: correction.correctedTechStep.id, key: correction.correctedTechStep.key }
|
||||||
|
: null,
|
||||||
|
createdAt: correction.createdAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records one correction to `stepId`'s detected techniques, submitted by
|
||||||
|
* `correctorId` — see {@link SubmitTechStepCorrectionInput}'s doc comment
|
||||||
|
* (`packages/shared`) for what `previousTechStepId`/`correctedTechStepId`
|
||||||
|
* each mean. Never edited/deleted afterward (see `StepTechStepCorrection`'s
|
||||||
|
* schema doc comment) — this is a pure insert.
|
||||||
|
*
|
||||||
|
* @throws {HttpError} `404 STEP_NOT_FOUND`/`404 RECIPE_NOT_FOUND` — see
|
||||||
|
* {@link loadVisibleStepOrThrow}. `400 INVALID_CORRECTION_SPAN` if
|
||||||
|
* `start`/`end` fall outside the step's current `description` (it may
|
||||||
|
* have been edited since the user last saw it). `404 TECH_STEP_NOT_FOUND`
|
||||||
|
* if either tech-step id doesn't exist.
|
||||||
|
*/
|
||||||
|
export async function submitTechStepCorrection(
|
||||||
|
recipeId: number,
|
||||||
|
stepId: number,
|
||||||
|
input: SubmitTechStepCorrectionInput,
|
||||||
|
correctorId: number,
|
||||||
|
viewerHouseId: number | null,
|
||||||
|
): Promise<StepTechStepCorrectionView> {
|
||||||
|
try {
|
||||||
|
const step = await loadVisibleStepOrThrow(recipeId, stepId, correctorId, viewerHouseId);
|
||||||
|
|
||||||
|
if (input.start >= step.descriptionLength || input.end > step.descriptionLength) {
|
||||||
|
throw new HttpError(
|
||||||
|
400,
|
||||||
|
ErrorCode.INVALID_CORRECTION_SPAN,
|
||||||
|
`Span [${input.start}, ${input.end}) falls outside step ${stepId}'s description (length ${step.descriptionLength})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const techStepIds = [input.previousTechStepId, input.correctedTechStepId].filter(
|
||||||
|
(id): id is number => id !== null && id !== undefined,
|
||||||
|
);
|
||||||
|
await assertTechStepsExist(techStepIds);
|
||||||
|
|
||||||
|
const created = await prisma.stepTechStepCorrection.create({
|
||||||
|
data: {
|
||||||
|
stepId: step.id,
|
||||||
|
correctorId,
|
||||||
|
start: input.start,
|
||||||
|
end: input.end,
|
||||||
|
previousTechStepId: input.previousTechStepId ?? null,
|
||||||
|
correctedTechStepId: input.correctedTechStepId ?? null,
|
||||||
|
},
|
||||||
|
include: correctionInclude,
|
||||||
|
});
|
||||||
|
|
||||||
|
return toCorrectionView(created);
|
||||||
|
} catch (err) {
|
||||||
|
throw err; // see loadVisibleStepOrThrow's catch comment
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every correction submitted so far for `stepId`, most recent first —
|
||||||
|
* mainly useful for a user checking what's already been submitted (by
|
||||||
|
* anyone) for a span before adding another (see `StepTechStepCorrectionView`'s
|
||||||
|
* doc comment, `packages/shared`).
|
||||||
|
*
|
||||||
|
* @throws {HttpError} `404 STEP_NOT_FOUND`/`404 RECIPE_NOT_FOUND` — see {@link loadVisibleStepOrThrow}.
|
||||||
|
*/
|
||||||
|
export async function listTechStepCorrections(
|
||||||
|
recipeId: number,
|
||||||
|
stepId: number,
|
||||||
|
viewerId: number,
|
||||||
|
viewerHouseId: number | null,
|
||||||
|
): Promise<StepTechStepCorrectionView[]> {
|
||||||
|
try {
|
||||||
|
const step = await loadVisibleStepOrThrow(recipeId, stepId, viewerId, viewerHouseId);
|
||||||
|
const corrections = await prisma.stepTechStepCorrection.findMany({
|
||||||
|
where: { stepId: step.id },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
include: correctionInclude,
|
||||||
|
});
|
||||||
|
return corrections.map(toCorrectionView);
|
||||||
|
} catch (err) {
|
||||||
|
throw err; // see loadVisibleStepOrThrow's catch comment
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@ import {
|
||||||
createRecipeSchema,
|
createRecipeSchema,
|
||||||
ErrorCode,
|
ErrorCode,
|
||||||
listRecipesSchema,
|
listRecipesSchema,
|
||||||
|
submitTechStepCorrectionSchema,
|
||||||
updateRecipeSchema,
|
updateRecipeSchema,
|
||||||
} from "@batch-cooking/shared";
|
} from "@batch-cooking/shared";
|
||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
|
|
@ -17,6 +18,10 @@ import {
|
||||||
removeFavorite,
|
removeFavorite,
|
||||||
updateRecipe,
|
updateRecipe,
|
||||||
} from "./recipe.service.js";
|
} from "./recipe.service.js";
|
||||||
|
import {
|
||||||
|
listTechStepCorrections,
|
||||||
|
submitTechStepCorrection,
|
||||||
|
} from "./recipe-tech-step-correction.service.js";
|
||||||
|
|
||||||
/** Router mounted at `/recipes` in app.ts. Every route requires a session — the catalog is shared across households, not public (same reasoning as `planning`/`house`: it's app content, not signup-time reference data). */
|
/** Router mounted at `/recipes` in app.ts. Every route requires a session — the catalog is shared across households, not public (same reasoning as `planning`/`house`: it's app content, not signup-time reference data). */
|
||||||
export const recipeRouter = Router();
|
export const recipeRouter = Router();
|
||||||
|
|
@ -30,6 +35,15 @@ function parseRecipeId(rawId: string | undefined): number {
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Same shape as {@link parseRecipeId}, for the `:stepId` route param of the tech-step-correction routes below — a distinct function only so the error message names the right param. */
|
||||||
|
function parseStepId(rawId: string | undefined): number {
|
||||||
|
const id = Number(rawId);
|
||||||
|
if (!Number.isInteger(id)) {
|
||||||
|
throw new HttpError(400, ErrorCode.VALIDATION_ERROR, "stepId must be an integer");
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
recipeRouter.get(
|
recipeRouter.get(
|
||||||
"/",
|
"/",
|
||||||
requireAuth,
|
requireAuth,
|
||||||
|
|
@ -109,3 +123,29 @@ recipeRouter.delete(
|
||||||
res.status(204).end();
|
res.status(204).end();
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Open to any authenticated viewer who can see the recipe, not just its
|
||||||
|
// author — see recipe-tech-step-correction.service.ts's own doc comment
|
||||||
|
// for why.
|
||||||
|
recipeRouter.post(
|
||||||
|
"/:id/steps/:stepId/corrections",
|
||||||
|
requireAuth,
|
||||||
|
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
|
||||||
|
const id = parseRecipeId(req.params.id);
|
||||||
|
const stepId = parseStepId(req.params.stepId);
|
||||||
|
const input = submitTechStepCorrectionSchema.parse(req.body);
|
||||||
|
const { id: correctorId, houseId } = res.locals.userProfile;
|
||||||
|
res.status(201).json(await submitTechStepCorrection(id, stepId, input, correctorId, houseId));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
recipeRouter.get(
|
||||||
|
"/:id/steps/:stepId/corrections",
|
||||||
|
requireAuth,
|
||||||
|
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
|
||||||
|
const id = parseRecipeId(req.params.id);
|
||||||
|
const stepId = parseStepId(req.params.stepId);
|
||||||
|
const { id: viewerId, houseId } = res.locals.userProfile;
|
||||||
|
res.status(200).json(await listTechStepCorrections(id, stepId, viewerId, houseId));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -2,29 +2,29 @@ import { prisma } from "../db/prisma.js";
|
||||||
import { techStepClassifier } from "../lib/recipe-matching/tech-step-matcher.js";
|
import { techStepClassifier } from "../lib/recipe-matching/tech-step-matcher.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One-off maintenance script: recomputes every existing `Step`'s
|
* Recomputes every existing `Step`'s `StepTechStep` sequence against the
|
||||||
* `StepTechStep` sequence against the *current* classifier
|
* *current* classifier (`tech-step-matcher.ts`/`tech-step-training-data.ts`),
|
||||||
* (`tech-step-matcher.ts`/`tech-step-training-data.ts`), the same way
|
* the same way `updateRecipe` does when a user resaves a recipe through the
|
||||||
* `updateRecipe` does when a user resaves a recipe through the UI —
|
* UI — always `"fr"` (`DEFAULT_TECH_STEP_LOCALE` in `recipe.service.ts`;
|
||||||
* always `"fr"` (`DEFAULT_TECH_STEP_LOCALE` in `recipe.service.ts`; there's
|
* there's no persisted per-recipe locale to recover for a step that already
|
||||||
* no persisted per-recipe locale to recover for a step that already exists,
|
* exists, so this matches real resave behavior exactly rather than
|
||||||
* so this matches real resave behavior exactly rather than guessing).
|
* guessing).
|
||||||
*
|
*
|
||||||
* Needed because tech-step detection only ever runs at create/update time
|
* Needed because tech-step detection only ever runs at create/update time
|
||||||
* (`recipe.service.ts`'s `matchStepsTechSteps`), never retroactively — a
|
* (`recipe.service.ts`'s `matchStepsTechSteps`), never retroactively — a
|
||||||
* step saved before a classifier/corpus change (new vocabulary, or the
|
* step saved before a classifier/corpus change (new vocabulary, or the
|
||||||
* `contextStart`/`contextEnd` columns this same session added) keeps
|
* `contextStart`/`contextEnd` columns a previous session added) keeps
|
||||||
* whatever it was matched with at the time until it's next resaved. Run
|
* whatever it was matched with at the time until it's next resaved.
|
||||||
* this after a corpus change to bring every existing step in sync without
|
|
||||||
* asking users to open and resave every recipe by hand:
|
|
||||||
*
|
*
|
||||||
* pnpm --filter api exec tsx src/scripts/backfill-tech-steps.ts
|
* Exported (not just called from this file's own CLI guard below) so
|
||||||
|
* `retrain-tech-steps.ts` can run it as one step of its own larger
|
||||||
|
* maintainer workflow, without shelling out to a second process.
|
||||||
*
|
*
|
||||||
* Safe to re-run: each step's technique sequence is fully replaced (delete
|
* Safe to re-run: each step's technique sequence is fully replaced (delete
|
||||||
* + recreate) from the classifier's current output, same as a real edit —
|
* + recreate) from the classifier's current output, same as a real edit —
|
||||||
* running it twice in a row with no corpus change in between is a no-op.
|
* running it twice in a row with no corpus change in between is a no-op.
|
||||||
*/
|
*/
|
||||||
async function backfillTechSteps(): Promise<void> {
|
export async function backfillTechSteps(): Promise<{ total: number; changed: number }> {
|
||||||
const steps = await prisma.step.findMany({ select: { id: true, description: true } });
|
const steps = await prisma.step.findMany({ select: { id: true, description: true } });
|
||||||
console.info(`Recomputing tech steps for ${steps.length} step(s)...`);
|
console.info(`Recomputing tech steps for ${steps.length} step(s)...`);
|
||||||
|
|
||||||
|
|
@ -49,12 +49,22 @@ async function backfillTechSteps(): Promise<void> {
|
||||||
}
|
}
|
||||||
|
|
||||||
console.info(`Done — ${changed} step(s) recomputed.`);
|
console.info(`Done — ${changed} step(s) recomputed.`);
|
||||||
|
return { total: steps.length, changed };
|
||||||
}
|
}
|
||||||
|
|
||||||
backfillTechSteps()
|
// Only runs when this file is executed directly (`tsx
|
||||||
.then(() => prisma.$disconnect())
|
// src/scripts/backfill-tech-steps.ts`), not when `retrain-tech-steps.ts`
|
||||||
.catch(async (err) => {
|
// imports `backfillTechSteps` above — the standard ESM "is this the entry
|
||||||
console.error(err);
|
// module" check, first needed in this codebase by that new script; every
|
||||||
await prisma.$disconnect();
|
// prior script here (`seed-runtime.ts`) was always only ever run directly,
|
||||||
process.exit(1);
|
// never imported.
|
||||||
});
|
const isMainModule = import.meta.url === `file://${process.argv[1]}`;
|
||||||
|
if (isMainModule) {
|
||||||
|
backfillTechSteps()
|
||||||
|
.then(() => prisma.$disconnect())
|
||||||
|
.catch(async (err) => {
|
||||||
|
console.error(err);
|
||||||
|
await prisma.$disconnect();
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
||||||
71
apps/api/src/scripts/list-pending-training-suggestions.ts
Normal file
71
apps/api/src/scripts/list-pending-training-suggestions.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
import { prisma } from "../db/prisma.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maintainer-facing report of every `TechStepTrainingSuggestion` still
|
||||||
|
* `status: "pending"` (`TechStepTrainingSuggestion`'s own schema doc
|
||||||
|
* 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:
|
||||||
|
*
|
||||||
|
* 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).
|
||||||
|
*/
|
||||||
|
async function listPendingTrainingSuggestions(): Promise<void> {
|
||||||
|
const suggestions = await prisma.techStepTrainingSuggestion.findMany({
|
||||||
|
where: { status: "pending" },
|
||||||
|
orderBy: [{ techStepId: "asc" }, { createdAt: "asc" }],
|
||||||
|
include: { techStep: { select: { key: true } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (suggestions.length === 0) {
|
||||||
|
console.info("No pending training suggestions.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const byTechStepKey = new Map<string, typeof suggestions>();
|
||||||
|
for (const suggestion of suggestions) {
|
||||||
|
const key = suggestion.techStep.key;
|
||||||
|
const group = byTechStepKey.get(key);
|
||||||
|
if (group) {
|
||||||
|
group.push(suggestion);
|
||||||
|
} else {
|
||||||
|
byTechStepKey.set(key, [suggestion]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines: string[] = [`# Pending tech-step training suggestions (${suggestions.length})`, ""];
|
||||||
|
for (const [techStepKey, group] of byTechStepKey) {
|
||||||
|
lines.push(`## ${techStepKey}`, "");
|
||||||
|
for (const suggestion of group) {
|
||||||
|
const source =
|
||||||
|
suggestion.sourceCorrectionId !== null
|
||||||
|
? `${suggestion.sourceType} (correction #${suggestion.sourceCorrectionId})`
|
||||||
|
: suggestion.sourceType;
|
||||||
|
lines.push(`- id ${suggestion.id} · locale ${suggestion.locale} · source: ${source}`);
|
||||||
|
if (suggestion.suggestedSynonyms.length > 0) {
|
||||||
|
lines.push(` - synonyms: ${suggestion.suggestedSynonyms.join(", ")}`);
|
||||||
|
}
|
||||||
|
if (suggestion.suggestedUtterances.length > 0) {
|
||||||
|
lines.push(
|
||||||
|
` - utterances: ${suggestion.suggestedUtterances.map((u) => `"${u}"`).join(", ")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lines.push("");
|
||||||
|
}
|
||||||
|
|
||||||
|
console.info(lines.join("\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
listPendingTrainingSuggestions()
|
||||||
|
.then(() => prisma.$disconnect())
|
||||||
|
.catch(async (err) => {
|
||||||
|
console.error(err);
|
||||||
|
await prisma.$disconnect();
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
94
apps/api/src/scripts/retrain-tech-steps.ts
Normal file
94
apps/api/src/scripts/retrain-tech-steps.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
import { prisma } from "../db/prisma.js";
|
||||||
|
import {
|
||||||
|
MIN_OVERALL_F1,
|
||||||
|
runTechStepEvalSuite,
|
||||||
|
} from "../lib/recipe-matching/tech-step-eval-runner.js";
|
||||||
|
import { backfillTechSteps } from "./backfill-tech-steps.js";
|
||||||
|
|
||||||
|
/** Parses `--applied=1,2,3`/`--rejected=4,5` from argv into id arrays — both optional, both empty by default (a run with neither flag only re-gates + backfills, doesn't touch any suggestion's status). */
|
||||||
|
function parseSuggestionIds(flag: "applied" | "rejected"): number[] {
|
||||||
|
const prefix = `--${flag}=`;
|
||||||
|
const arg = process.argv.find((value) => value.startsWith(prefix));
|
||||||
|
if (arg === undefined) return [];
|
||||||
|
return arg
|
||||||
|
.slice(prefix.length)
|
||||||
|
.split(",")
|
||||||
|
.map((value) => value.trim())
|
||||||
|
.filter((value) => value.length > 0)
|
||||||
|
.map((value) => {
|
||||||
|
const id = Number(value);
|
||||||
|
if (!Number.isInteger(id)) {
|
||||||
|
throw new Error(`--${flag}: "${value}" is not a valid integer id`);
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
* decided which `TechStepTrainingSuggestion` ids they incorporated
|
||||||
|
* (`--applied=`) or explicitly discarded (`--rejected=`).
|
||||||
|
* 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
|
||||||
|
* the floor, so a bad edit never reaches every existing recipe.
|
||||||
|
* 3. Backfills every `Step`'s `StepTechStep` sequence against the new
|
||||||
|
* corpus ({@link backfillTechSteps}).
|
||||||
|
* 4. Marks the given suggestion ids `applied`/`rejected`, so
|
||||||
|
* `list-pending-training-suggestions.ts`'s next report doesn't
|
||||||
|
* surface them again.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
*
|
||||||
|
* pnpm --filter api exec tsx src/scripts/retrain-tech-steps.ts --applied=12,13 --rejected=14
|
||||||
|
*
|
||||||
|
* `--applied`/`--rejected` are both optional — omitting both still runs
|
||||||
|
* the gate + backfill, just leaves every suggestion's `status` untouched
|
||||||
|
* (useful for re-running the backfill alone after a corpus edit made with
|
||||||
|
* no suggestions involved at all).
|
||||||
|
*/
|
||||||
|
async function retrainTechSteps(): Promise<void> {
|
||||||
|
const appliedIds = parseSuggestionIds("applied");
|
||||||
|
const rejectedIds = parseSuggestionIds("rejected");
|
||||||
|
|
||||||
|
console.info("Evaluating the current classifier against the labeled evaluation set...");
|
||||||
|
const { overall } = await runTechStepEvalSuite();
|
||||||
|
console.info(
|
||||||
|
`F1 ${overall.f1.toFixed(3)} (precision ${overall.precision.toFixed(3)}, recall ${overall.recall.toFixed(3)})`,
|
||||||
|
);
|
||||||
|
if (overall.f1 < MIN_OVERALL_F1) {
|
||||||
|
throw new Error(
|
||||||
|
`Aggregate F1 ${overall.f1.toFixed(3)} is below the ${MIN_OVERALL_F1} regression floor — refusing to backfill. Revert or fix the corpus change and re-run.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { total, changed } = await backfillTechSteps();
|
||||||
|
console.info(`Backfilled ${changed}/${total} step(s).`);
|
||||||
|
|
||||||
|
if (appliedIds.length > 0) {
|
||||||
|
await prisma.techStepTrainingSuggestion.updateMany({
|
||||||
|
where: { id: { in: appliedIds } },
|
||||||
|
data: { status: "applied" },
|
||||||
|
});
|
||||||
|
console.info(`Marked ${appliedIds.length} suggestion(s) as applied.`);
|
||||||
|
}
|
||||||
|
if (rejectedIds.length > 0) {
|
||||||
|
await prisma.techStepTrainingSuggestion.updateMany({
|
||||||
|
where: { id: { in: rejectedIds } },
|
||||||
|
data: { status: "rejected" },
|
||||||
|
});
|
||||||
|
console.info(`Marked ${rejectedIds.length} suggestion(s) as rejected.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
retrainTechSteps()
|
||||||
|
.then(() => prisma.$disconnect())
|
||||||
|
.catch(async (err) => {
|
||||||
|
console.error(err);
|
||||||
|
await prisma.$disconnect();
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
306
apps/api/test/internal/tech-step-worker.routes.test.ts
Normal file
306
apps/api/test/internal/tech-step-worker.routes.test.ts
Normal file
|
|
@ -0,0 +1,306 @@
|
||||||
|
import { ErrorCode } from "@batch-cooking/shared";
|
||||||
|
import { expect } from "chai";
|
||||||
|
import request from "supertest";
|
||||||
|
import { createApp } from "../../src/app.js";
|
||||||
|
import { env } from "../../src/config/env.js";
|
||||||
|
import { prisma } from "../../src/db/prisma.js";
|
||||||
|
import { resetDatabase } from "../../test-support/reset-db.js";
|
||||||
|
|
||||||
|
const SECRET_HEADER = "X-Internal-Worker-Secret";
|
||||||
|
|
||||||
|
/** Resolves a reference tech step's id by its `reference-seed-data.ts` uid (also its DB `key`) — mirrors `recipe.test.ts`'s own `techStepId` helper. */
|
||||||
|
async function techStepId(key: string): Promise<number> {
|
||||||
|
const techStep = await prisma.techStep.findFirstOrThrow({ where: { key } });
|
||||||
|
return techStep.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A minimal author + recipe + step fixture — these routes have no notion of a session/viewer, so nothing here needs to go through `/auth/signup` the way `recipe.test.ts`'s fixtures do. */
|
||||||
|
async function createRecipeWithStep(
|
||||||
|
description = "Faire mijoter la sauce.",
|
||||||
|
): Promise<{ stepId: number; recipeId: number }> {
|
||||||
|
const author = await prisma.userProfile.create({
|
||||||
|
data: {
|
||||||
|
firstName: "Test",
|
||||||
|
lastName: "Author",
|
||||||
|
email: `${crypto.randomUUID()}@example.test`,
|
||||||
|
passwordHash: "not-a-real-hash",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const recipe = await prisma.recipe.create({
|
||||||
|
data: {
|
||||||
|
name: "Recette",
|
||||||
|
authorId: author.id,
|
||||||
|
portions: 4,
|
||||||
|
steps: { create: [{ description, order: 0 }] },
|
||||||
|
},
|
||||||
|
include: { steps: true },
|
||||||
|
});
|
||||||
|
const step = recipe.steps[0];
|
||||||
|
if (!step) throw new Error("expected the fixture recipe to have one step");
|
||||||
|
return { stepId: step.id, recipeId: recipe.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Internal tech-step worker routes", () => {
|
||||||
|
const app = createApp();
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await resetDatabase();
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("requireInternalWorker", () => {
|
||||||
|
it("rejects a request with no secret header with 401 NOT_AUTHENTICATED", async () => {
|
||||||
|
const res = await request(app).get("/internal/tech-steps/audit-batch");
|
||||||
|
|
||||||
|
expect(res.status).to.equal(401);
|
||||||
|
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a request with the wrong secret with 401 NOT_AUTHENTICATED", async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.get("/internal/tech-steps/audit-batch")
|
||||||
|
.set(SECRET_HEADER, "definitely-not-the-right-secret");
|
||||||
|
|
||||||
|
expect(res.status).to.equal(401);
|
||||||
|
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects even the correct secret with 401 NOT_AUTHENTICATED on a plain user-facing route (no bypass of requireAuth)", async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.get("/recipes")
|
||||||
|
.query({ tab: "publique" })
|
||||||
|
.set(SECRET_HEADER, env.INTERNAL_WORKER_SECRET ?? "irrelevant-unset-in-this-env");
|
||||||
|
|
||||||
|
expect(res.status).to.equal(401);
|
||||||
|
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Every test below needs a real configured secret to exercise the success
|
||||||
|
// path — skipped (not failed) in an environment that hasn't set one, same
|
||||||
|
// "optional, but the surface fails closed without it" posture
|
||||||
|
// `INTERNAL_WORKER_SECRET` itself has (see config/env.ts). Both this
|
||||||
|
// repo's `.env.test.example` and `.github/workflows/ci.yml` set one, so
|
||||||
|
// this only actually skips in an environment that deliberately diverges
|
||||||
|
// from both.
|
||||||
|
describe("with a configured secret", () => {
|
||||||
|
before(function skipWithoutConfiguredSecret() {
|
||||||
|
if (env.INTERNAL_WORKER_SECRET === undefined) {
|
||||||
|
// biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed without @types/mocha (not a dependency here) — same untyped-`this` shape a plain JS mocha callback would have.
|
||||||
|
(this as any).skip();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function withSecret(req: request.Test): request.Test {
|
||||||
|
return req.set(SECRET_HEADER, env.INTERNAL_WORKER_SECRET as string);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("GET /internal/tech-steps/audit-batch", () => {
|
||||||
|
// A true low-confidence positive case can't be asserted here without
|
||||||
|
// a live trained classifier to verify the exact sentence against
|
||||||
|
// first — same limitation `tech-step-eval-dataset.ts` documents for
|
||||||
|
// the same reason (no local Postgres was reachable in the session
|
||||||
|
// that introduced this file). This test instead covers the
|
||||||
|
// deterministic negative: a step the classifier confidently resolves
|
||||||
|
// (proven by `tech-step-matcher.test.ts`'s own identical-sentence
|
||||||
|
// case) must produce zero audit entries — nothing here should ever
|
||||||
|
// flag a confident match as worth a second opinion.
|
||||||
|
it("finds nothing to audit in a step the classifier confidently resolves", async () => {
|
||||||
|
await createRecipeWithStep("Faire mijoter à feu doux");
|
||||||
|
|
||||||
|
const res = await withSecret(
|
||||||
|
request(app).get("/internal/tech-steps/audit-batch").query({ locale: "fr" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).to.equal(200);
|
||||||
|
expect(res.body).to.deep.equal([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("finds nothing to audit in a step naming no technique at all", async () => {
|
||||||
|
await createRecipeWithStep("Ranger les couverts dans le tiroir");
|
||||||
|
|
||||||
|
const res = await withSecret(
|
||||||
|
request(app).get("/internal/tech-steps/audit-batch").query({ locale: "fr" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).to.equal(200);
|
||||||
|
expect(res.body).to.deep.equal([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a non-positive limit with 400 VALIDATION_ERROR", async () => {
|
||||||
|
const res = await withSecret(
|
||||||
|
request(app).get("/internal/tech-steps/audit-batch").query({ limit: 0 }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).to.equal(400);
|
||||||
|
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GET /internal/tech-steps/pending-corrections", () => {
|
||||||
|
it("returns unconsumed corrections, oldest first, excluding already-consumed ones", async () => {
|
||||||
|
const { stepId, recipeId } = await createRecipeWithStep();
|
||||||
|
const simmerId = await techStepId("simmer");
|
||||||
|
const author = await prisma.recipe
|
||||||
|
.findUniqueOrThrow({ where: { id: recipeId } })
|
||||||
|
.then((recipe) => recipe.authorId);
|
||||||
|
|
||||||
|
const older = await prisma.stepTechStepCorrection.create({
|
||||||
|
data: { stepId, correctorId: author, start: 6, end: 13, correctedTechStepId: simmerId },
|
||||||
|
});
|
||||||
|
const consumed = await prisma.stepTechStepCorrection.create({
|
||||||
|
data: {
|
||||||
|
stepId,
|
||||||
|
correctorId: author,
|
||||||
|
start: 0,
|
||||||
|
end: 5,
|
||||||
|
correctedTechStepId: simmerId,
|
||||||
|
consumedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const newer = await prisma.stepTechStepCorrection.create({
|
||||||
|
data: { stepId, correctorId: author, start: 6, end: 13, correctedTechStepId: simmerId },
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await withSecret(request(app).get("/internal/tech-steps/pending-corrections"));
|
||||||
|
|
||||||
|
expect(res.status).to.equal(200);
|
||||||
|
const ids = (res.body as Array<{ id: number }>).map((entry) => entry.id);
|
||||||
|
expect(ids).to.deep.equal([older.id, newer.id]);
|
||||||
|
expect(ids).to.not.include(consumed.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("respects ?limit=", async () => {
|
||||||
|
const { stepId, recipeId } = await createRecipeWithStep();
|
||||||
|
const simmerId = await techStepId("simmer");
|
||||||
|
const author = await prisma.recipe
|
||||||
|
.findUniqueOrThrow({ where: { id: recipeId } })
|
||||||
|
.then((recipe) => recipe.authorId);
|
||||||
|
await prisma.stepTechStepCorrection.createMany({
|
||||||
|
data: [
|
||||||
|
{ stepId, correctorId: author, start: 0, end: 5, correctedTechStepId: simmerId },
|
||||||
|
{ stepId, correctorId: author, start: 6, end: 13, correctedTechStepId: simmerId },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await withSecret(
|
||||||
|
request(app).get("/internal/tech-steps/pending-corrections").query({ limit: 1 }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).to.equal(200);
|
||||||
|
expect(res.body).to.have.length(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("POST /internal/tech-steps/training-suggestions", () => {
|
||||||
|
it("creates a suggestion and marks its source correction consumed", async () => {
|
||||||
|
const { stepId, recipeId } = await createRecipeWithStep();
|
||||||
|
const simmerId = await techStepId("simmer");
|
||||||
|
const author = await prisma.recipe
|
||||||
|
.findUniqueOrThrow({ where: { id: recipeId } })
|
||||||
|
.then((recipe) => recipe.authorId);
|
||||||
|
const correction = await prisma.stepTechStepCorrection.create({
|
||||||
|
data: { stepId, correctorId: author, start: 6, end: 13, correctedTechStepId: simmerId },
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await withSecret(
|
||||||
|
request(app)
|
||||||
|
.post("/internal/tech-steps/training-suggestions")
|
||||||
|
.send({
|
||||||
|
suggestions: [
|
||||||
|
{
|
||||||
|
techStepKey: "simmer",
|
||||||
|
locale: "fr",
|
||||||
|
suggestedSynonyms: ["frémissonner"],
|
||||||
|
suggestedUtterances: ["laisser frémissonner à feu très doux"],
|
||||||
|
sourceType: "correction",
|
||||||
|
sourceCorrectionId: correction.id,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).to.equal(201);
|
||||||
|
expect(res.body).to.deep.equal({ created: 1 });
|
||||||
|
|
||||||
|
const suggestions = await prisma.techStepTrainingSuggestion.findMany({
|
||||||
|
where: { techStepId: simmerId },
|
||||||
|
});
|
||||||
|
expect(suggestions).to.have.length(1);
|
||||||
|
expect(suggestions[0]?.sourceCorrectionId).to.equal(correction.id);
|
||||||
|
expect(suggestions[0]?.status).to.equal("pending");
|
||||||
|
|
||||||
|
const updatedCorrection = await prisma.stepTechStepCorrection.findUniqueOrThrow({
|
||||||
|
where: { id: correction.id },
|
||||||
|
});
|
||||||
|
expect(updatedCorrection.consumedAt).to.not.equal(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts an llm_audit suggestion with no sourceCorrectionId", async () => {
|
||||||
|
const res = await withSecret(
|
||||||
|
request(app)
|
||||||
|
.post("/internal/tech-steps/training-suggestions")
|
||||||
|
.send({
|
||||||
|
suggestions: [
|
||||||
|
{
|
||||||
|
techStepKey: "boil",
|
||||||
|
locale: "fr",
|
||||||
|
suggestedSynonyms: ["bouillonner"],
|
||||||
|
suggestedUtterances: [],
|
||||||
|
sourceType: "llm_audit",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).to.equal(201);
|
||||||
|
expect(res.body).to.deep.equal({ created: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects sourceType 'correction' with no sourceCorrectionId with 400 VALIDATION_ERROR", async () => {
|
||||||
|
const res = await withSecret(
|
||||||
|
request(app)
|
||||||
|
.post("/internal/tech-steps/training-suggestions")
|
||||||
|
.send({
|
||||||
|
suggestions: [
|
||||||
|
{
|
||||||
|
techStepKey: "boil",
|
||||||
|
locale: "fr",
|
||||||
|
suggestedSynonyms: ["bouillonner"],
|
||||||
|
suggestedUtterances: [],
|
||||||
|
sourceType: "correction",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).to.equal(400);
|
||||||
|
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an unknown techStepKey with 404 TECH_STEP_NOT_FOUND", async () => {
|
||||||
|
const res = await withSecret(
|
||||||
|
request(app)
|
||||||
|
.post("/internal/tech-steps/training-suggestions")
|
||||||
|
.send({
|
||||||
|
suggestions: [
|
||||||
|
{
|
||||||
|
techStepKey: "not-a-real-tech-step",
|
||||||
|
locale: "fr",
|
||||||
|
suggestedSynonyms: [],
|
||||||
|
suggestedUtterances: [],
|
||||||
|
sourceType: "llm_audit",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).to.equal(404);
|
||||||
|
expect(res.body.code).to.equal(ErrorCode.TECH_STEP_NOT_FOUND);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
50
apps/api/test/recipe-matching/tech-step-eval.test.ts
Normal file
50
apps/api/test/recipe-matching/tech-step-eval.test.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
import { expect } from "chai";
|
||||||
|
import { prisma } from "../../src/db/prisma.js";
|
||||||
|
import {
|
||||||
|
MIN_OVERALL_F1,
|
||||||
|
runTechStepEvalSuite,
|
||||||
|
} from "../../src/lib/recipe-matching/tech-step-eval-runner.js";
|
||||||
|
import { resetDatabase } from "../../test-support/reset-db.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regression gate for `TECH_STEP_TRAINING_DATA` — every change to that
|
||||||
|
* corpus (including a maintainer applying suggestions from
|
||||||
|
* `TechStepTrainingSuggestion`, see `scripts/retrain-tech-steps.ts`) must
|
||||||
|
* keep this suite green. Runs {@link runTechStepEvalSuite} (the real
|
||||||
|
* trained classifier against `tech-step-eval-dataset.ts`) and asserts the
|
||||||
|
* aggregate F1 doesn't fall below {@link MIN_OVERALL_F1}.
|
||||||
|
*
|
||||||
|
* `MIN_OVERALL_F1` (`tech-step-eval-runner.ts`) is a provisional floor,
|
||||||
|
* not a target: most of the dataset's cases are built around a
|
||||||
|
* technique's own registered synonym, which `_classifyClause` always
|
||||||
|
* resolves correctly via its NER-anchor fallback even when the intent
|
||||||
|
* classifier itself scores under `CONFIDENCE_THRESHOLD` (see
|
||||||
|
* `tech-step-matcher.ts`'s doc comment, point 3) — so a healthy run should
|
||||||
|
* land well above this floor. It's set low enough to tolerate the residual
|
||||||
|
* uncertainty in a dataset authored without being able to run it against a
|
||||||
|
* live trained classifier first (no local Postgres was reachable in the
|
||||||
|
* session that introduced this file — see this feature's plan document).
|
||||||
|
* Once this suite has actually run once (locally or in CI) and produced
|
||||||
|
* real numbers, tighten that constant to just below the observed F1, so a
|
||||||
|
* real future regression still fails loudly instead of hiding under a
|
||||||
|
* floor that's too forgiving.
|
||||||
|
*/
|
||||||
|
|
||||||
|
describe("tech-step-eval", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await resetDatabase();
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`scores at least ${MIN_OVERALL_F1} aggregate F1 against the labeled evaluation set`, async () => {
|
||||||
|
const { overall, byKey } = await runTechStepEvalSuite();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
overall.f1,
|
||||||
|
`aggregate F1 ${overall.f1.toFixed(3)} (precision ${overall.precision.toFixed(3)}, recall ${overall.recall.toFixed(3)}) fell below the ${MIN_OVERALL_F1} floor — per-technique breakdown: ${JSON.stringify(byKey)}`,
|
||||||
|
).to.be.at.least(MIN_OVERALL_F1);
|
||||||
|
});
|
||||||
|
});
|
||||||
241
apps/api/test/recipe/recipe-tech-step-correction.test.ts
Normal file
241
apps/api/test/recipe/recipe-tech-step-correction.test.ts
Normal file
|
|
@ -0,0 +1,241 @@
|
||||||
|
import type { SignupInput } from "@batch-cooking/shared";
|
||||||
|
import { ErrorCode } from "@batch-cooking/shared";
|
||||||
|
import { faker } from "@faker-js/faker";
|
||||||
|
import { expect } from "chai";
|
||||||
|
import request from "supertest";
|
||||||
|
import { createApp } from "../../src/app.js";
|
||||||
|
import { prisma } from "../../src/db/prisma.js";
|
||||||
|
import { resetDatabase } from "../../test-support/reset-db.js";
|
||||||
|
|
||||||
|
/** See `auth.test.ts` — generated rather than hardcoded, no test fixture looks like a real person's data. */
|
||||||
|
function buildSignupPayload(): SignupInput {
|
||||||
|
const firstName = faker.person.firstName();
|
||||||
|
const lastName = faker.person.lastName();
|
||||||
|
return {
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
email: faker.internet.email({ firstName, lastName }).toLowerCase(),
|
||||||
|
password: faker.internet.password({ length: 16 }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolves a reference tech step's id by its `reference-seed-data.ts` uid (also its DB `key`) — mirrors `recipe.test.ts`'s own `techStepId` helper. */
|
||||||
|
async function techStepId(key: string): Promise<number> {
|
||||||
|
const techStep = await prisma.techStep.findFirstOrThrow({ where: { key } });
|
||||||
|
return techStep.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Recipe tech-step corrections", () => {
|
||||||
|
const app = createApp();
|
||||||
|
|
||||||
|
async function signup(): Promise<{ agent: ReturnType<typeof request.agent>; profileId: number }> {
|
||||||
|
const agent = request.agent(app);
|
||||||
|
const res = await agent.post("/auth/signup").send(buildSignupPayload());
|
||||||
|
return { agent, profileId: res.body.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A `PUBLIC` recipe with one step — every viewer can see this, so most tests below don't need to juggle visibility on top of the correction logic itself. */
|
||||||
|
async function createPublicRecipeWithStep(
|
||||||
|
authorId: number,
|
||||||
|
description = "Faire mijoter la sauce.",
|
||||||
|
): Promise<{ recipeId: number; stepId: number }> {
|
||||||
|
const recipe = await prisma.recipe.create({
|
||||||
|
data: {
|
||||||
|
name: "Recette",
|
||||||
|
authorId,
|
||||||
|
visibility: "PUBLIC",
|
||||||
|
portions: 4,
|
||||||
|
steps: { create: [{ description, order: 0 }] },
|
||||||
|
},
|
||||||
|
include: { steps: true },
|
||||||
|
});
|
||||||
|
const step = recipe.steps[0];
|
||||||
|
if (!step) throw new Error("expected the fixture recipe to have one step");
|
||||||
|
return { recipeId: recipe.id, stepId: step.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await resetDatabase();
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("POST /recipes/:id/steps/:stepId/corrections", () => {
|
||||||
|
it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => {
|
||||||
|
const { profileId } = await signup();
|
||||||
|
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/recipes/${recipeId}/steps/${stepId}/corrections`)
|
||||||
|
.send({ start: 0, end: 5, correctedTechStepId: await techStepId("simmer") });
|
||||||
|
|
||||||
|
expect(res.status).to.equal(401);
|
||||||
|
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("records a correction adding a missing technique (no previousTechStepId)", async () => {
|
||||||
|
const { agent, profileId } = await signup();
|
||||||
|
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
|
||||||
|
const simmerId = await techStepId("simmer");
|
||||||
|
|
||||||
|
const res = await agent
|
||||||
|
.post(`/recipes/${recipeId}/steps/${stepId}/corrections`)
|
||||||
|
.send({ start: 6, end: 13, correctedTechStepId: simmerId });
|
||||||
|
|
||||||
|
expect(res.status).to.equal(201);
|
||||||
|
expect(res.body.previousTechStep).to.equal(null);
|
||||||
|
expect(res.body.correctedTechStep).to.deep.equal({ id: simmerId, key: "simmer" });
|
||||||
|
expect(res.body.start).to.equal(6);
|
||||||
|
expect(res.body.end).to.equal(13);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("records a correction relabeling an existing match (both ids set)", async () => {
|
||||||
|
const { agent, profileId } = await signup();
|
||||||
|
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
|
||||||
|
const simmerId = await techStepId("simmer");
|
||||||
|
const boilId = await techStepId("boil");
|
||||||
|
|
||||||
|
const res = await agent
|
||||||
|
.post(`/recipes/${recipeId}/steps/${stepId}/corrections`)
|
||||||
|
.send({ start: 6, end: 13, previousTechStepId: simmerId, correctedTechStepId: boilId });
|
||||||
|
|
||||||
|
expect(res.status).to.equal(201);
|
||||||
|
expect(res.body.previousTechStep).to.deep.equal({ id: simmerId, key: "simmer" });
|
||||||
|
expect(res.body.correctedTechStep).to.deep.equal({ id: boilId, key: "boil" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is not restricted to the recipe's author — any viewer who can see it may correct it", async () => {
|
||||||
|
const { profileId: authorId } = await signup();
|
||||||
|
const { agent: otherAgent } = await signup();
|
||||||
|
const { recipeId, stepId } = await createPublicRecipeWithStep(authorId);
|
||||||
|
|
||||||
|
const res = await otherAgent
|
||||||
|
.post(`/recipes/${recipeId}/steps/${stepId}/corrections`)
|
||||||
|
.send({ start: 6, end: 13, correctedTechStepId: await techStepId("simmer") });
|
||||||
|
|
||||||
|
expect(res.status).to.equal(201);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects both previousTechStepId and correctedTechStepId absent with 400 VALIDATION_ERROR", async () => {
|
||||||
|
const { agent, profileId } = await signup();
|
||||||
|
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
|
||||||
|
|
||||||
|
const res = await agent
|
||||||
|
.post(`/recipes/${recipeId}/steps/${stepId}/corrections`)
|
||||||
|
.send({ start: 0, end: 5 });
|
||||||
|
|
||||||
|
expect(res.status).to.equal(400);
|
||||||
|
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects end <= start with 400 VALIDATION_ERROR", async () => {
|
||||||
|
const { agent, profileId } = await signup();
|
||||||
|
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
|
||||||
|
|
||||||
|
const res = await agent
|
||||||
|
.post(`/recipes/${recipeId}/steps/${stepId}/corrections`)
|
||||||
|
.send({ start: 5, end: 5, correctedTechStepId: await techStepId("simmer") });
|
||||||
|
|
||||||
|
expect(res.status).to.equal(400);
|
||||||
|
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a span past the end of the step's description with 400 INVALID_CORRECTION_SPAN", async () => {
|
||||||
|
const { agent, profileId } = await signup();
|
||||||
|
const description = "Court.";
|
||||||
|
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId, description);
|
||||||
|
|
||||||
|
const res = await agent.post(`/recipes/${recipeId}/steps/${stepId}/corrections`).send({
|
||||||
|
start: 0,
|
||||||
|
end: description.length + 10,
|
||||||
|
correctedTechStepId: await techStepId("simmer"),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).to.equal(400);
|
||||||
|
expect(res.body.code).to.equal(ErrorCode.INVALID_CORRECTION_SPAN);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an unknown correctedTechStepId with 404 TECH_STEP_NOT_FOUND", async () => {
|
||||||
|
const { agent, profileId } = await signup();
|
||||||
|
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
|
||||||
|
|
||||||
|
const res = await agent
|
||||||
|
.post(`/recipes/${recipeId}/steps/${stepId}/corrections`)
|
||||||
|
.send({ start: 0, end: 5, correctedTechStepId: 999_999 });
|
||||||
|
|
||||||
|
expect(res.status).to.equal(404);
|
||||||
|
expect(res.body.code).to.equal(ErrorCode.TECH_STEP_NOT_FOUND);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a step that exists but isn't visible to the viewer with 404 RECIPE_NOT_FOUND", async () => {
|
||||||
|
const { profileId: authorId } = await signup();
|
||||||
|
const { agent: otherAgent } = await signup();
|
||||||
|
const recipe = await prisma.recipe.create({
|
||||||
|
data: {
|
||||||
|
name: "Secrète",
|
||||||
|
authorId,
|
||||||
|
portions: 4,
|
||||||
|
steps: { create: [{ description: "Faire mijoter la sauce.", order: 0 }] },
|
||||||
|
},
|
||||||
|
include: { steps: true },
|
||||||
|
});
|
||||||
|
const step = recipe.steps[0];
|
||||||
|
if (!step) throw new Error("expected the fixture recipe to have one step");
|
||||||
|
|
||||||
|
const res = await otherAgent
|
||||||
|
.post(`/recipes/${recipe.id}/steps/${step.id}/corrections`)
|
||||||
|
.send({ start: 0, end: 5, correctedTechStepId: await techStepId("simmer") });
|
||||||
|
|
||||||
|
expect(res.status).to.equal(404);
|
||||||
|
expect(res.body.code).to.equal(ErrorCode.RECIPE_NOT_FOUND);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a stepId that belongs to a different recipe than the URL's :id with 404 STEP_NOT_FOUND", async () => {
|
||||||
|
const { agent, profileId } = await signup();
|
||||||
|
const { recipeId: otherRecipeId } = await createPublicRecipeWithStep(profileId);
|
||||||
|
const { stepId } = await createPublicRecipeWithStep(profileId);
|
||||||
|
|
||||||
|
const res = await agent
|
||||||
|
.post(`/recipes/${otherRecipeId}/steps/${stepId}/corrections`)
|
||||||
|
.send({ start: 0, end: 5, correctedTechStepId: await techStepId("simmer") });
|
||||||
|
|
||||||
|
expect(res.status).to.equal(404);
|
||||||
|
expect(res.body.code).to.equal(ErrorCode.STEP_NOT_FOUND);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GET /recipes/:id/steps/:stepId/corrections", () => {
|
||||||
|
it("returns every correction submitted for the step, most recent first", async () => {
|
||||||
|
const { agent, profileId } = await signup();
|
||||||
|
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
|
||||||
|
const simmerId = await techStepId("simmer");
|
||||||
|
const boilId = await techStepId("boil");
|
||||||
|
|
||||||
|
await agent
|
||||||
|
.post(`/recipes/${recipeId}/steps/${stepId}/corrections`)
|
||||||
|
.send({ start: 6, end: 13, correctedTechStepId: simmerId });
|
||||||
|
await agent
|
||||||
|
.post(`/recipes/${recipeId}/steps/${stepId}/corrections`)
|
||||||
|
.send({ start: 6, end: 13, previousTechStepId: simmerId, correctedTechStepId: boilId });
|
||||||
|
|
||||||
|
const res = await agent.get(`/recipes/${recipeId}/steps/${stepId}/corrections`);
|
||||||
|
|
||||||
|
expect(res.status).to.equal(200);
|
||||||
|
expect(res.body).to.have.length(2);
|
||||||
|
expect(res.body[0].correctedTechStep).to.deep.equal({ id: boilId, key: "boil" });
|
||||||
|
expect(res.body[1].correctedTechStep).to.deep.equal({ id: simmerId, key: "simmer" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an empty list when nothing has been submitted yet", async () => {
|
||||||
|
const { agent, profileId } = await signup();
|
||||||
|
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
|
||||||
|
|
||||||
|
const res = await agent.get(`/recipes/${recipeId}/steps/${stepId}/corrections`);
|
||||||
|
|
||||||
|
expect(res.status).to.equal(200);
|
||||||
|
expect(res.body).to.deep.equal([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
121
apps/web/cypress/component/TechStepCorrectionPopover.cy.tsx
Normal file
121
apps/web/cypress/component/TechStepCorrectionPopover.cy.tsx
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
import "../../src/i18n/i18n";
|
||||||
|
import { TechStepCorrectionPopover } from "../../src/features/recipes/steps/TechStepCorrectionPopover";
|
||||||
|
|
||||||
|
// Mounts the popover in isolation (no StepDescription/selection plumbing
|
||||||
|
// around it) — same "generic component test" posture as CheckboxOption.cy.tsx,
|
||||||
|
// but this one needs `../../src/i18n/i18n` imported for its side effect
|
||||||
|
// (initializes the default i18next instance `useTranslation` falls back to
|
||||||
|
// with no `<I18nextProvider>` in the tree — see that module's own doc
|
||||||
|
// comment) since, unlike Checkbox/Radio, this component calls
|
||||||
|
// `useTranslation()`.
|
||||||
|
|
||||||
|
const cook = { id: 1, key: "cook" };
|
||||||
|
const simmer = { id: 3, key: "simmer" };
|
||||||
|
|
||||||
|
function mountPopover(
|
||||||
|
overrides: Partial<{
|
||||||
|
previousTechStepId: number | null;
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmitted: (correction: unknown) => void;
|
||||||
|
}> = {},
|
||||||
|
) {
|
||||||
|
cy.mount(
|
||||||
|
<div>
|
||||||
|
{/* A genuinely separate sibling to click for the "outside click closes it" test — clicking blindly at a viewport coordinate would risk still landing inside the popover, which fills most of the mounted area on its own. */}
|
||||||
|
<div data-testid="outside-popover" style={{ height: 20 }} />
|
||||||
|
<TechStepCorrectionPopover
|
||||||
|
recipeId={2}
|
||||||
|
stepId={2}
|
||||||
|
selectedText="Cuire"
|
||||||
|
range={{ start: 0, end: 5 }}
|
||||||
|
previousTechStepId={overrides.previousTechStepId ?? null}
|
||||||
|
onClose={overrides.onClose ?? (() => {})}
|
||||||
|
onSubmitted={overrides.onSubmitted ?? (() => {})}
|
||||||
|
/>
|
||||||
|
</div>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("TechStepCorrectionPopover", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
cy.intercept("GET", "**/reference/tech-steps", { statusCode: 200, body: [cook, simmer] }).as(
|
||||||
|
"getTechSteps",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the selected text and every technique option once loaded", () => {
|
||||||
|
mountPopover();
|
||||||
|
cy.wait("@getTechSteps");
|
||||||
|
|
||||||
|
cy.contains(".tech-step-correction-popover__selection", "Cuire").should("be.visible");
|
||||||
|
cy.get(".tech-step-correction-popover__list button").should("have.length", 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers a 'no technique here' option only when correcting an existing match", () => {
|
||||||
|
mountPopover({ previousTechStepId: null });
|
||||||
|
cy.wait("@getTechSteps");
|
||||||
|
cy.get(".tech-step-correction-popover__remove").should("not.exist");
|
||||||
|
|
||||||
|
mountPopover({ previousTechStepId: cook.id });
|
||||||
|
cy.wait("@getTechSteps");
|
||||||
|
cy.get(".tech-step-correction-popover__remove").should("exist");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("submits the selected technique and calls onSubmitted", () => {
|
||||||
|
// Asserting on the resolved `@submitCorrection` interception below,
|
||||||
|
// rather than inside this handler — a Chai assertion failing *inside*
|
||||||
|
// a `cy.intercept` callback surfaces as an opaque "onResponse cannot be
|
||||||
|
// called twice" Cypress internal error instead of a normal assertion
|
||||||
|
// failure, found while writing this exact test.
|
||||||
|
cy.intercept("POST", "**/recipes/2/steps/2/corrections", {
|
||||||
|
statusCode: 201,
|
||||||
|
body: {
|
||||||
|
id: 1,
|
||||||
|
start: 0,
|
||||||
|
end: 5,
|
||||||
|
previousTechStep: null,
|
||||||
|
correctedTechStep: simmer,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
}).as("submitCorrection");
|
||||||
|
const onSubmitted = cy.stub().as("onSubmitted");
|
||||||
|
mountPopover({ onSubmitted });
|
||||||
|
cy.wait("@getTechSteps");
|
||||||
|
|
||||||
|
cy.contains(".tech-step-correction-popover__list button", "Mijoter").click();
|
||||||
|
|
||||||
|
cy.wait("@submitCorrection").its("request.body").should("deep.equal", {
|
||||||
|
start: 0,
|
||||||
|
end: 5,
|
||||||
|
previousTechStepId: null,
|
||||||
|
correctedTechStepId: simmer.id,
|
||||||
|
});
|
||||||
|
cy.get("@onSubmitted").should("have.been.calledOnce");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows an error message and stays open when the submission fails", () => {
|
||||||
|
cy.intercept("POST", "**/recipes/2/steps/2/corrections", {
|
||||||
|
statusCode: 404,
|
||||||
|
body: { code: 4051, message: "TechStep not found" },
|
||||||
|
}).as("submitCorrection");
|
||||||
|
const onClose = cy.stub().as("onClose");
|
||||||
|
mountPopover({ onClose });
|
||||||
|
cy.wait("@getTechSteps");
|
||||||
|
|
||||||
|
cy.contains(".tech-step-correction-popover__list button", "Cuire").click();
|
||||||
|
|
||||||
|
cy.wait("@submitCorrection");
|
||||||
|
cy.get(".field-error").should("be.visible");
|
||||||
|
cy.get("@onClose").should("not.have.been.called");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onClose on an outside click", () => {
|
||||||
|
const onClose = cy.stub().as("onClose");
|
||||||
|
mountPopover({ onClose });
|
||||||
|
cy.wait("@getTechSteps");
|
||||||
|
|
||||||
|
cy.get('[data-testid="outside-popover"]').click();
|
||||||
|
|
||||||
|
cy.get("@onClose").should("have.been.calledOnce");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -27,6 +27,18 @@ Feature: Managing a recipe from the catalog
|
||||||
When I focus the highlighted technique "Cuire"
|
When I focus the highlighted technique "Cuire"
|
||||||
Then the tooltip should show "Cuire"
|
Then the tooltip should show "Cuire"
|
||||||
|
|
||||||
|
Scenario: Corrects a detected technique from its highlight
|
||||||
|
Given the recipe catalog contains "Omelette"
|
||||||
|
And recipe 2's detail is available
|
||||||
|
And the tech steps reference list has options
|
||||||
|
And correcting step 2's "Cuire" match will succeed
|
||||||
|
When I visit "/recettes/2"
|
||||||
|
And I click the highlighted technique "Cuire"
|
||||||
|
Then I should see the technique correction options
|
||||||
|
When I choose "Mijoter" as the correct technique
|
||||||
|
Then the correction request should have been made
|
||||||
|
And I should see "Merci, votre correction a été enregistrée."
|
||||||
|
|
||||||
Scenario: Deletes a recipe after a two-step confirmation, then clears the selection
|
Scenario: Deletes a recipe after a two-step confirmation, then clears the selection
|
||||||
Given the recipe catalog contains "Omelette"
|
Given the recipe catalog contains "Omelette"
|
||||||
And recipe 2's detail is available
|
And recipe 2's detail is available
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,41 @@ Given("deleting recipe 2 will succeed", () => {
|
||||||
cy.intercept("DELETE", "**/recipes/2", { statusCode: 204 }).as("deleteRecipe");
|
cy.intercept("DELETE", "**/recipes/2", { statusCode: 204 }).as("deleteRecipe");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Step 2 is `omeletteDetail`'s "Cuire à la poêle." step, whose only
|
||||||
|
// existing match is `cook` (id 1) — see that fixture above. The response
|
||||||
|
// mirrors `StepTechStepCorrectionView` (packages/shared), reassigning the
|
||||||
|
// match to `simmer` (id 3, "Mijoter" — see `the tech steps reference list
|
||||||
|
// has options`, reference-data.steps.ts).
|
||||||
|
Given('correcting step 2\'s "Cuire" match will succeed', () => {
|
||||||
|
cy.intercept("POST", "**/recipes/2/steps/2/corrections", {
|
||||||
|
statusCode: 201,
|
||||||
|
body: {
|
||||||
|
id: 1,
|
||||||
|
start: 0,
|
||||||
|
end: 5,
|
||||||
|
previousTechStep: { id: 1, key: "cook" },
|
||||||
|
correctedTechStep: { id: 3, key: "simmer" },
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
}).as("correction");
|
||||||
|
});
|
||||||
|
|
||||||
|
When("I click the highlighted technique {string}", (text: string) => {
|
||||||
|
cy.contains(".step-tech-step", text).click();
|
||||||
|
});
|
||||||
|
|
||||||
|
Then("I should see the technique correction options", () => {
|
||||||
|
cy.get(".tech-step-correction-popover").should("be.visible");
|
||||||
|
});
|
||||||
|
|
||||||
|
When("I choose {string} as the correct technique", (label: string) => {
|
||||||
|
cy.contains(".tech-step-correction-popover__list button", label).click();
|
||||||
|
});
|
||||||
|
|
||||||
|
Then("the correction request should have been made", () => {
|
||||||
|
cy.wait("@correction");
|
||||||
|
});
|
||||||
|
|
||||||
Then("the recipe {string} should not be visible in the table", (name: string) => {
|
Then("the recipe {string} should not be visible in the table", (name: string) => {
|
||||||
cy.contains(".recipe-table__name", name).should("not.exist");
|
cy.contains(".recipe-table__name", name).should("not.exist");
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,21 @@ Given("the sources reference list is empty", () => {
|
||||||
cy.intercept("GET", "**/reference/sources", { statusCode: 200, body: [] });
|
cy.intercept("GET", "**/reference/sources", { statusCode: 200, body: [] });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// `id`/`key` pairs mirror `recipes.ts`'s `omeletteDetail` fixture (`cook`,
|
||||||
|
// id 1, is the step's existing match) plus a second option
|
||||||
|
// (`simmer`/"Mijoter") for `recipes.feature`'s correction scenario to
|
||||||
|
// re-assign to — TechStepCorrectionPopover's own picker needs at least two
|
||||||
|
// choices for that scenario to be a meaningful correction, not a no-op.
|
||||||
|
Given("the tech steps reference list has options", () => {
|
||||||
|
cy.intercept("GET", "**/reference/tech-steps", {
|
||||||
|
statusCode: 200,
|
||||||
|
body: [
|
||||||
|
{ id: 1, key: "cook" },
|
||||||
|
{ id: 3, key: "simmer" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// The real first entry (`theMealDb`) mirrors what's actually seeded
|
// The real first entry (`theMealDb`) mirrors what's actually seeded
|
||||||
// (`reference-seed-data.ts`'s `registerAllRecipeSources`/
|
// (`reference-seed-data.ts`'s `registerAllRecipeSources`/
|
||||||
// `syncRecipeSources`); the second is illustrative only — a future
|
// `syncRecipeSources`); the second is illustrative only — a future
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,9 @@ import {
|
||||||
type SafeUserProfile,
|
type SafeUserProfile,
|
||||||
type SignupInput,
|
type SignupInput,
|
||||||
type SourceView,
|
type SourceView,
|
||||||
|
type StepTechStepCorrectionView,
|
||||||
|
type SubmitTechStepCorrectionInput,
|
||||||
|
type TechStepView,
|
||||||
type ThemePreference,
|
type ThemePreference,
|
||||||
type UnitView,
|
type UnitView,
|
||||||
type UpdateRecipeInput,
|
type UpdateRecipeInput,
|
||||||
|
|
@ -179,6 +182,11 @@ export class ApiClient {
|
||||||
return this._request("/reference/units");
|
return this._request("/reference/units");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Reference list of detected cooking techniques — static, non-administrable (`TechStepCorrectionPopover`'s technique picker). Public — no session required. */
|
||||||
|
public getTechSteps(): Promise<TechStepView[]> {
|
||||||
|
return this._request("/reference/tech-steps");
|
||||||
|
}
|
||||||
|
|
||||||
/** Reference list of implemented recipe sources (onboarding wizard's source step, `/parametres/foyer`) — empty until a concrete source is registered. Public — no session required. */
|
/** Reference list of implemented recipe sources (onboarding wizard's source step, `/parametres/foyer`) — empty until a concrete source is registered. Public — no session required. */
|
||||||
public getSources(): Promise<SourceView[]> {
|
public getSources(): Promise<SourceView[]> {
|
||||||
return this._request("/reference/sources");
|
return this._request("/reference/sources");
|
||||||
|
|
@ -267,6 +275,26 @@ export class ApiClient {
|
||||||
return this._request(`/recipes/${id}/favorite`, { method: "DELETE" });
|
return this._request(`/recipes/${id}/favorite`, { method: "DELETE" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Submits a correction to one of `stepId`'s detected techniques — see `SubmitTechStepCorrectionInput`'s doc comment (`packages/shared`) for what `previousTechStepId`/`correctedTechStepId` each mean. Open to any viewer who can see the recipe, not just its author. */
|
||||||
|
public submitTechStepCorrection(
|
||||||
|
recipeId: number,
|
||||||
|
stepId: number,
|
||||||
|
input: SubmitTechStepCorrectionInput,
|
||||||
|
): Promise<StepTechStepCorrectionView> {
|
||||||
|
return this._request(`/recipes/${recipeId}/steps/${stepId}/corrections`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every correction submitted so far for `stepId`, most recent first. */
|
||||||
|
public getTechStepCorrections(
|
||||||
|
recipeId: number,
|
||||||
|
stepId: number,
|
||||||
|
): Promise<StepTechStepCorrectionView[]> {
|
||||||
|
return this._request(`/recipes/${recipeId}/steps/${stepId}/corrections`);
|
||||||
|
}
|
||||||
|
|
||||||
/** Fetches the current user's household (with its member list), or `null` if they don't have one yet. */
|
/** Fetches the current user's household (with its member list), or `null` if they don't have one yet. */
|
||||||
public getCurrentHouse(): Promise<HouseView | null> {
|
public getCurrentHouse(): Promise<HouseView | null> {
|
||||||
return this._request("/house/current");
|
return this._request("/house/current");
|
||||||
|
|
|
||||||
|
|
@ -209,7 +209,20 @@ export function RecipeDetailPanel({
|
||||||
{recipe.steps.map((step) => (
|
{recipe.steps.map((step) => (
|
||||||
<li key={step.id}>
|
<li key={step.id}>
|
||||||
{step.picture && <img src={step.picture} alt="" />}
|
{step.picture && <img src={step.picture} alt="" />}
|
||||||
<StepDescription description={step.description} techSteps={step.techSteps} />
|
{/* Tied to `showActions` (not unconditionally on): that flag
|
||||||
|
already distinguishes a full recipe view from a lightweight
|
||||||
|
preview (`RecipePickerDialog`'s browsing step, `showActions={false}`)
|
||||||
|
— offering technique corrections in a quick "pick a recipe
|
||||||
|
for planning" preview would be more distracting than
|
||||||
|
useful there, even though the API itself allows it for any
|
||||||
|
viewer who can see the recipe. */}
|
||||||
|
<StepDescription
|
||||||
|
description={step.description}
|
||||||
|
techSteps={step.techSteps}
|
||||||
|
editable={showActions}
|
||||||
|
recipeId={recipe.id}
|
||||||
|
stepId={step.id}
|
||||||
|
/>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ol>
|
</ol>
|
||||||
|
|
|
||||||
|
|
@ -633,6 +633,78 @@
|
||||||
// computes and persists `contextStart`/`contextEnd`, this file just no
|
// computes and persists `contextStart`/`contextEnd`, this file just no
|
||||||
// longer gives that class any styling to render with.
|
// longer gives that class any styling to render with.
|
||||||
|
|
||||||
|
// --- Tech-step correction (StepDescription.tsx editable mode) ---------------
|
||||||
|
|
||||||
|
// Deliberately *not* `position: absolute` (unlike `.calendar-popover`) — see
|
||||||
|
// `TechStepCorrectionPopover.tsx`'s doc comment for why this renders inline
|
||||||
|
// in the document flow right below the step's own description instead of
|
||||||
|
// floating anchored at the selection.
|
||||||
|
.tech-step-correction-popover {
|
||||||
|
margin-top: var(--space-xs);
|
||||||
|
padding: var(--space-md);
|
||||||
|
background: var(--color-surface-alt);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
|
||||||
|
&__selection {
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0 0 var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
list-style: none;
|
||||||
|
margin: 0 0 var(--space-sm);
|
||||||
|
padding: 0;
|
||||||
|
|
||||||
|
button {
|
||||||
|
padding: 0.3rem 0.6rem;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--color-text);
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: color-mix(in srgb, var(--color-primary) 14%, transparent);
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nested (rather than a sibling `&__remove` block) so its border-color
|
||||||
|
// wins over the plain `button` rule above by class-count specificity,
|
||||||
|
// no `!important` needed.
|
||||||
|
.tech-step-correction-popover__remove {
|
||||||
|
color: var(--color-error);
|
||||||
|
border-color: var(--color-error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__cancel {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
text-decoration: underline;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-tech-step-correction-confirmation {
|
||||||
|
margin-top: var(--space-xs);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--color-success, var(--color-primary));
|
||||||
|
}
|
||||||
|
|
||||||
// --- Favorite star toggle (detail panel header) -----------------------------
|
// --- Favorite star toggle (detail panel header) -----------------------------
|
||||||
.favorite-star-button {
|
.favorite-star-button {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,13 @@
|
||||||
import type { StepTechStepView } from "@batch-cooking/shared";
|
import type { StepTechStepCorrectionView, StepTechStepView } from "@batch-cooking/shared";
|
||||||
import { Fragment } from "react";
|
import { Fragment, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Tooltip } from "../../../components/ui/Tooltip";
|
import { Tooltip } from "../../../components/ui/Tooltip";
|
||||||
import { splitDescriptionByTechSteps } from "./highlight-tech-steps";
|
import { splitDescriptionByTechSteps } from "./highlight-tech-steps";
|
||||||
|
import { TechStepCorrectionPopover } from "./TechStepCorrectionPopover";
|
||||||
|
import { type TextSelectionRange, useTextSelection } from "./use-text-selection";
|
||||||
|
|
||||||
|
/** How long the post-submit confirmation message stays visible — long enough to read, short enough not to linger once the user has moved on. */
|
||||||
|
const CONFIRMATION_DISPLAY_MS = 4000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A recipe step's description, with every detected technique's exact
|
* A recipe step's description, with every detected technique's exact
|
||||||
|
|
@ -24,47 +29,136 @@ import { splitDescriptionByTechSteps } from "./highlight-tech-steps";
|
||||||
* `techStep.key` resolves its tooltip label through `catalog.techSteps.<key>`
|
* `techStep.key` resolves its tooltip label through `catalog.techSteps.<key>`
|
||||||
* i18n, the same pattern every other reference catalog (diets, units, …)
|
* i18n, the same pattern every other reference catalog (diets, units, …)
|
||||||
* uses for its display text.
|
* uses for its display text.
|
||||||
|
*
|
||||||
|
* `editable` (off by default) additionally lets the viewer select text or
|
||||||
|
* click an existing highlight to open a {@link TechStepCorrectionPopover} —
|
||||||
|
* see `use-text-selection.ts` for how a browser selection is translated
|
||||||
|
* into an absolute `[start, end)` span. When `!editable`, every segment
|
||||||
|
* renders exactly as before (no extra wrapping elements, no `data-offset`,
|
||||||
|
* no click handlers) — this mode is purely additive, not a rewrite of the
|
||||||
|
* read-only rendering.
|
||||||
*/
|
*/
|
||||||
export function StepDescription({
|
export function StepDescription({
|
||||||
description,
|
description,
|
||||||
techSteps,
|
techSteps,
|
||||||
|
editable = false,
|
||||||
|
recipeId,
|
||||||
|
stepId,
|
||||||
}: {
|
}: {
|
||||||
description: string;
|
description: string;
|
||||||
techSteps: StepTechStepView[];
|
techSteps: StepTechStepView[];
|
||||||
|
/** Requires `recipeId`/`stepId` when `true` — omit (or leave `false`) for a read-only view with nothing real to correct against yet (e.g. `RecipeDetailPanel`'s `"loaded-draft"` unsaved-preview branch). */
|
||||||
|
editable?: boolean;
|
||||||
|
recipeId?: number;
|
||||||
|
stepId?: number;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const segments = splitDescriptionByTechSteps(description, techSteps);
|
const segments = splitDescriptionByTechSteps(description, techSteps);
|
||||||
|
const containerRef = useRef<HTMLParagraphElement>(null);
|
||||||
|
const { getSelectionRange } = useTextSelection(containerRef);
|
||||||
|
|
||||||
|
const [activeCorrection, setActiveCorrection] = useState<{
|
||||||
|
range: TextSelectionRange;
|
||||||
|
selectedText: string;
|
||||||
|
previousTechStepId: number | null;
|
||||||
|
} | null>(null);
|
||||||
|
const [showConfirmation, setShowConfirmation] = useState(false);
|
||||||
|
|
||||||
|
function handleMouseUp() {
|
||||||
|
if (!editable) return;
|
||||||
|
const range = getSelectionRange();
|
||||||
|
if (!range) return;
|
||||||
|
setActiveCorrection({
|
||||||
|
range,
|
||||||
|
selectedText: description.slice(range.start, range.end),
|
||||||
|
previousTechStepId: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSubmitted(_correction: StepTechStepCorrectionView) {
|
||||||
|
setShowConfirmation(true);
|
||||||
|
window.setTimeout(() => setShowConfirmation(false), CONFIRMATION_DISPLAY_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tracks each segment's own absolute start offset into `description` as
|
||||||
|
// the map below walks them in order — segments are contiguous and cover
|
||||||
|
// the whole description (see `splitDescriptionByTechSteps`'s doc
|
||||||
|
// comment), so a running total is exact, no re-derivation needed.
|
||||||
|
let offset = 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<p>
|
<>
|
||||||
{segments.map((segment, index) => {
|
<p ref={containerRef} onMouseUp={handleMouseUp}>
|
||||||
// A segment's own text/techStep don't uniquely identify it (the
|
{segments.map((segment, index) => {
|
||||||
// same word can appear twice in one description) — index is the
|
const start = offset;
|
||||||
// only thing that does, but this list is fully regenerated from
|
offset += segment.text.length;
|
||||||
// `description`/`techSteps` on every render (never reordered or
|
// A segment's own text/techStep don't uniquely identify it (the
|
||||||
// spliced in place), so using it as part of the key is safe here.
|
// same word can appear twice in one description) — index is the
|
||||||
const key = `${index}-${segment.text}`;
|
// only thing that does, but this list is fully regenerated from
|
||||||
if (!segment.techStep) return <Fragment key={key}>{segment.text}</Fragment>;
|
// `description`/`techSteps` on every render (never reordered or
|
||||||
|
// spliced in place), so using it as part of the key is safe here.
|
||||||
|
const key = `${index}-${segment.text}`;
|
||||||
|
|
||||||
if (!segment.isKeyword) {
|
if (!segment.techStep || !segment.isKeyword) {
|
||||||
// Context-only run — rendered as plain text, same as a segment
|
// Context-only or plain run — rendered as plain text in
|
||||||
// with no technique at all (see this component's doc comment for
|
// read-only mode, same as before this component supported
|
||||||
// why the wider-clause highlight was turned back off).
|
// `editable` at all (see this component's doc comment for why
|
||||||
return <Fragment key={key}>{segment.text}</Fragment>;
|
// the wider-clause highlight itself was turned back off).
|
||||||
}
|
// Editable mode still wraps it in a `data-offset` span so a
|
||||||
return (
|
// selection starting/ending in plain text resolves correctly.
|
||||||
<Tooltip key={key} content={t(`catalog.techSteps.${segment.techStep.key}`)}>
|
if (!editable) return <Fragment key={key}>{segment.text}</Fragment>;
|
||||||
{/* A real <button>, not a <mark>, so it's natively focusable
|
return (
|
||||||
(keyboard/screen-reader users can reach the tooltip) without
|
<span key={key} data-offset={start}>
|
||||||
fighting the "non-interactive element" a11y lint a bare
|
{segment.text}
|
||||||
tabIndex on <mark> would trip — styled to read as inline
|
</span>
|
||||||
highlighted text, not as a button (see .step-tech-step). */}
|
);
|
||||||
<button type="button" className="step-tech-step">
|
}
|
||||||
{segment.text}
|
|
||||||
</button>
|
const techStep = segment.techStep;
|
||||||
</Tooltip>
|
return (
|
||||||
);
|
<Tooltip key={key} content={t(`catalog.techSteps.${techStep.key}`)}>
|
||||||
})}
|
{/* A real <button>, not a <mark>, so it's natively focusable
|
||||||
</p>
|
(keyboard/screen-reader users can reach the tooltip) without
|
||||||
|
fighting the "non-interactive element" a11y lint a bare
|
||||||
|
tabIndex on <mark> would trip — styled to read as inline
|
||||||
|
highlighted text, not as a button (see .step-tech-step). */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="step-tech-step"
|
||||||
|
data-offset={editable ? start : undefined}
|
||||||
|
onClick={
|
||||||
|
editable
|
||||||
|
? () =>
|
||||||
|
setActiveCorrection({
|
||||||
|
range: { start, end: offset },
|
||||||
|
selectedText: segment.text,
|
||||||
|
previousTechStepId: techStep.id,
|
||||||
|
})
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{segment.text}
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
{showConfirmation && (
|
||||||
|
<p className="step-tech-step-correction-confirmation">
|
||||||
|
{t("recipes.techStepCorrection.confirmation")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{editable && activeCorrection && recipeId !== undefined && stepId !== undefined && (
|
||||||
|
<TechStepCorrectionPopover
|
||||||
|
recipeId={recipeId}
|
||||||
|
stepId={stepId}
|
||||||
|
range={activeCorrection.range}
|
||||||
|
selectedText={activeCorrection.selectedText}
|
||||||
|
previousTechStepId={activeCorrection.previousTechStepId}
|
||||||
|
onClose={() => setActiveCorrection(null)}
|
||||||
|
onSubmitted={handleSubmitted}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,146 @@
|
||||||
|
import {
|
||||||
|
ErrorCode,
|
||||||
|
type StepTechStepCorrectionView,
|
||||||
|
type TechStepView,
|
||||||
|
} from "@batch-cooking/shared";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { ApiError, apiClient } from "../../../api/client";
|
||||||
|
import { errorMessageService } from "../../../services/error-message.service";
|
||||||
|
import type { TextSelectionRange } from "./use-text-selection";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Small non-modal popover letting a viewer assign a technique to a selected
|
||||||
|
* span of a step's description, or clear/relabel an existing match —
|
||||||
|
* opened by `StepDescription`'s editable mode. Same `mousedown`-outside-
|
||||||
|
* close pattern as `PlanningPage`'s `CalendarPopover`, not `Dialog.tsx`'s
|
||||||
|
* native `<dialog>` — this is a small, contextual pick-one-option surface,
|
||||||
|
* not a page-blocking modal.
|
||||||
|
*
|
||||||
|
* Rendered inline right below the step's own description block (see
|
||||||
|
* `StepDescription.tsx`), not floating anchored at the selection's exact
|
||||||
|
* position — simpler and more robust than tracking a caret-anchored
|
||||||
|
* position across scroll/resize, at the cost of a little visual distance
|
||||||
|
* from the selected text itself.
|
||||||
|
*
|
||||||
|
* Submitting never changes what's currently highlighted — a correction is
|
||||||
|
* only ever consumed later, offline, by `services/tech-step-llm-worker`
|
||||||
|
* and a maintainer's review (see `StepTechStepCorrection`'s schema doc
|
||||||
|
* comment) — so this only ever confirms the submission, it doesn't try to
|
||||||
|
* (and can't correctly) predict what the classifier will conclude next.
|
||||||
|
*/
|
||||||
|
export function TechStepCorrectionPopover({
|
||||||
|
recipeId,
|
||||||
|
stepId,
|
||||||
|
selectedText,
|
||||||
|
range,
|
||||||
|
previousTechStepId,
|
||||||
|
onClose,
|
||||||
|
onSubmitted,
|
||||||
|
}: {
|
||||||
|
recipeId: number;
|
||||||
|
stepId: number;
|
||||||
|
/** The selected span's own text — shown so the user confirms what they're tagging before picking a technique. */
|
||||||
|
selectedText: string;
|
||||||
|
range: TextSelectionRange;
|
||||||
|
/** Set when correcting an already-detected match (opened from clicking its highlight) rather than a fresh selection — passed through as-is on submit, and offers a "remove" option `null` doesn't. */
|
||||||
|
previousTechStepId: number | null;
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmitted: (correction: StepTechStepCorrectionView) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const popoverRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [techSteps, setTechSteps] = useState<TechStepView[] | null>(null);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
apiClient
|
||||||
|
.getTechSteps()
|
||||||
|
.then((list) => {
|
||||||
|
if (!cancelled) setTechSteps(list);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setTechSteps([]);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleClickOutside(e: MouseEvent) {
|
||||||
|
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
async function submit(correctedTechStepId: number | null) {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const correction = await apiClient.submitTechStepCorrection(recipeId, stepId, {
|
||||||
|
start: range.start,
|
||||||
|
end: range.end,
|
||||||
|
previousTechStepId,
|
||||||
|
correctedTechStepId,
|
||||||
|
});
|
||||||
|
onSubmitted(correction);
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
|
||||||
|
setError(errorMessageService.getLabel(code));
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="tech-step-correction-popover" ref={popoverRef}>
|
||||||
|
<p className="tech-step-correction-popover__selection">
|
||||||
|
{t("recipes.techStepCorrection.selectionLabel", { text: selectedText })}
|
||||||
|
</p>
|
||||||
|
{techSteps === null ? (
|
||||||
|
<p>{t("recipes.loading")}</p>
|
||||||
|
) : (
|
||||||
|
<ul className="tech-step-correction-popover__list">
|
||||||
|
{previousTechStepId !== null && (
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
onClick={() => submit(null)}
|
||||||
|
className="tech-step-correction-popover__remove"
|
||||||
|
>
|
||||||
|
{t("recipes.techStepCorrection.removeMatch")}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
{techSteps.map((techStep) => (
|
||||||
|
<li key={techStep.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={isSubmitting || techStep.id === previousTechStepId}
|
||||||
|
onClick={() => submit(techStep.id)}
|
||||||
|
>
|
||||||
|
{t(`catalog.techSteps.${techStep.key}`)}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
{error && <p className="field-error">{error}</p>}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="tech-step-correction-popover__cancel"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
{t("recipes.techStepCorrection.cancel")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
84
apps/web/src/features/recipes/steps/use-text-selection.ts
Normal file
84
apps/web/src/features/recipes/steps/use-text-selection.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
import { type RefObject, useCallback } from "react";
|
||||||
|
|
||||||
|
/** A `[start, end)` character range into a step's original `description` string — same convention as `StepTechStepView.start`/`end`. */
|
||||||
|
export interface TextSelectionRange {
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the browser's current text selection, translated into a
|
||||||
|
* {@link TextSelectionRange} into a step's original `description` string —
|
||||||
|
* the shape `POST /recipes/:id/steps/:stepId/corrections` expects (see
|
||||||
|
* `SubmitTechStepCorrectionInput`, `packages/shared`).
|
||||||
|
*
|
||||||
|
* Works by walking up from each end of the selection's `Range` to the
|
||||||
|
* nearest ancestor carrying a `data-offset` attribute — set by
|
||||||
|
* `StepDescription`'s editable mode on every {@link DescriptionSegment}'s
|
||||||
|
* own wrapping element (`<span>`/`<button>`, `StepDescription.tsx`), each
|
||||||
|
* wrapping exactly its own text run and nothing else. `data-offset`'s value
|
||||||
|
* is that segment's own absolute start offset into `description`; added to
|
||||||
|
* the in-node offset the `Range` reports, this gives an exact absolute
|
||||||
|
* offset without needing to serialize/re-measure any text.
|
||||||
|
*
|
||||||
|
* Deliberately *not* using the more common `Range.toString().length`-from-
|
||||||
|
* the-container's-start technique for this problem — `StepDescription`
|
||||||
|
* always renders a `Tooltip` bubble alongside a keyword segment's own
|
||||||
|
* `<button>` (`Tooltip.tsx`, hidden via CSS, not removed from the DOM),
|
||||||
|
* whose text would silently pad that count past any keyword segment,
|
||||||
|
* corrupting every offset downstream of one.
|
||||||
|
*/
|
||||||
|
export function useTextSelection(containerRef: RefObject<HTMLElement | null>): {
|
||||||
|
getSelectionRange: () => TextSelectionRange | null;
|
||||||
|
} {
|
||||||
|
const getSelectionRange = useCallback((): TextSelectionRange | null => {
|
||||||
|
const selection = window.getSelection();
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!selection || selection.isCollapsed || selection.rangeCount === 0 || !container) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const range = selection.getRangeAt(0);
|
||||||
|
if (!container.contains(range.commonAncestorContainer)) return null;
|
||||||
|
|
||||||
|
const start = resolveOffset(container, range.startContainer, range.startOffset);
|
||||||
|
const end = resolveOffset(container, range.endContainer, range.endOffset);
|
||||||
|
if (start === null || end === null || start === end) return null;
|
||||||
|
|
||||||
|
return start < end ? { start, end } : { start: end, end: start };
|
||||||
|
}, [containerRef]);
|
||||||
|
|
||||||
|
return { getSelectionRange };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves `(node, nodeOffset)` (one end of a DOM `Range`, in the DOM's own
|
||||||
|
* mixed node/character-index convention) to an absolute character offset
|
||||||
|
* into `description`, or `null` if `node` isn't inside a segment
|
||||||
|
* `StepDescription` wrapped with `data-offset` at all — a selection edge
|
||||||
|
* that lands on whitespace/structure outside any segment shouldn't occur
|
||||||
|
* given every segment is wrapped, but this degrades to "no valid
|
||||||
|
* selection" rather than a wrong span or a crash if it somehow does.
|
||||||
|
*/
|
||||||
|
function resolveOffset(container: HTMLElement, node: Node, nodeOffset: number): number | null {
|
||||||
|
// A `Range` boundary that lands exactly on a segment's own wrapping
|
||||||
|
// element (rather than diving into its single Text child) reports
|
||||||
|
// `nodeOffset` as a *child index* (`0` or `1`, since every segment wraps
|
||||||
|
// exactly one Text node) — not a character offset. Resolved to the
|
||||||
|
// matching character offset (segment start vs. segment end) up front, so
|
||||||
|
// the walk below only ever deals in character offsets from here on.
|
||||||
|
let charOffset = nodeOffset;
|
||||||
|
if (node instanceof HTMLElement) {
|
||||||
|
const textChild = node.firstChild;
|
||||||
|
charOffset = nodeOffset > 0 ? (textChild?.textContent?.length ?? 0) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let current: Node | null = node;
|
||||||
|
while (current && current !== container) {
|
||||||
|
if (current instanceof HTMLElement && current.dataset.offset !== undefined) {
|
||||||
|
return Number(current.dataset.offset) + charOffset;
|
||||||
|
}
|
||||||
|
current = current.parentNode;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
@ -23,6 +23,9 @@
|
||||||
"INGREDIENT_NOT_FOUND": "Un des ingrédients sélectionnés n'existe pas",
|
"INGREDIENT_NOT_FOUND": "Un des ingrédients sélectionnés n'existe pas",
|
||||||
"UNIT_NOT_FOUND": "Une des unités sélectionnées n'existe pas",
|
"UNIT_NOT_FOUND": "Une des unités sélectionnées n'existe pas",
|
||||||
"SOURCE_NOT_FOUND": "Une des sources sélectionnées n'existe pas",
|
"SOURCE_NOT_FOUND": "Une des sources sélectionnées n'existe pas",
|
||||||
|
"STEP_NOT_FOUND": "Cette étape n'existe pas",
|
||||||
|
"TECH_STEP_NOT_FOUND": "Cette technique n'existe pas",
|
||||||
|
"INVALID_CORRECTION_SPAN": "La sélection ne correspond plus au texte de l'étape",
|
||||||
"INTERNAL_ERROR": "Une erreur est survenue, réessayez plus tard"
|
"INTERNAL_ERROR": "Une erreur est survenue, réessayez plus tard"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
|
|
@ -160,6 +163,12 @@
|
||||||
"cancelDeleteButton": "Annuler",
|
"cancelDeleteButton": "Annuler",
|
||||||
"ingredientsTitle": "Ingrédients",
|
"ingredientsTitle": "Ingrédients",
|
||||||
"stepsTitle": "Préparation",
|
"stepsTitle": "Préparation",
|
||||||
|
"techStepCorrection": {
|
||||||
|
"selectionLabel": "« {{text}} »",
|
||||||
|
"removeMatch": "Aucune technique ici",
|
||||||
|
"cancel": "Annuler",
|
||||||
|
"confirmation": "Merci, votre correction a été enregistrée."
|
||||||
|
},
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"favoris": "Favoris",
|
"favoris": "Favoris",
|
||||||
"perso": "Perso",
|
"perso": "Perso",
|
||||||
|
|
|
||||||
|
|
@ -45,8 +45,37 @@ services:
|
||||||
# authenticated request 401s despite login succeeding. See its doc
|
# authenticated request 401s despite login succeeding. See its doc
|
||||||
# comment in apps/api/src/config/env.ts.
|
# comment in apps/api/src/config/env.ts.
|
||||||
COOKIE_SECURE: ${COOKIE_SECURE:-}
|
COOKIE_SECURE: ${COOKIE_SECURE:-}
|
||||||
|
# Shared with the `tech-step-llm-worker` service below — see
|
||||||
|
# requireInternalWorker's doc comment
|
||||||
|
# (apps/api/src/middlewares/require-internal-worker.ts). Unset by
|
||||||
|
# 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:-}
|
||||||
ports:
|
ports:
|
||||||
- "${APP_PORT:-3000}:3000"
|
- "${APP_PORT:-3000}:3000"
|
||||||
|
|
||||||
|
# Deliberately its own image, not built into `app`'s (see
|
||||||
|
# services/tech-step-llm-worker/Dockerfile's own doc comment) — a
|
||||||
|
# long-lived process with no exposed port (nothing ever calls *into* it,
|
||||||
|
# it only ever calls out to `app`). Optional: an `INTERNAL_WORKER_SECRET`-
|
||||||
|
# less deployment can omit this service entirely and `app` still runs
|
||||||
|
# fine, just without the offline audit/feedback-loop jobs.
|
||||||
|
tech-step-llm-worker:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: services/tech-step-llm-worker/Dockerfile
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- app
|
||||||
|
environment:
|
||||||
|
API_BASE_URL: "http://app:3000"
|
||||||
|
INTERNAL_WORKER_SECRET: ${INTERNAL_WORKER_SECRET:?set INTERNAL_WORKER_SECRET in .env to run this service}
|
||||||
|
TECH_STEP_WORKER_CRON: ${TECH_STEP_WORKER_CRON:-0 3 * * 0}
|
||||||
|
volumes:
|
||||||
|
# GGUF weights persist across restarts — see this service's own
|
||||||
|
# Dockerfile doc comment on its VOLUME declaration.
|
||||||
|
- tech_step_llm_worker_models:/worker/models
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
|
tech_step_llm_worker_models:
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,12 @@ export enum ErrorCode {
|
||||||
UNIT_NOT_FOUND = 4048,
|
UNIT_NOT_FOUND = 4048,
|
||||||
/** `PATCH /house/current/sources`'s `sourceIds` contains one that doesn't match any reference `Source` row. */
|
/** `PATCH /house/current/sources`'s `sourceIds` contains one that doesn't match any reference `Source` row. */
|
||||||
SOURCE_NOT_FOUND = 4049,
|
SOURCE_NOT_FOUND = 4049,
|
||||||
|
/** `POST /recipes/:id/steps/:stepId/corrections` given a `stepId` that doesn't belong to a recipe visible to the caller. */
|
||||||
|
STEP_NOT_FOUND = 4050,
|
||||||
|
/** A tech-step correction's `previousTechStepId`/`correctedTechStepId` doesn't match any reference `TechStep` row. */
|
||||||
|
TECH_STEP_NOT_FOUND = 4051,
|
||||||
|
/** A tech-step correction's `start`/`end` span falls outside the target step's `description`, or `start >= end`. */
|
||||||
|
INVALID_CORRECTION_SPAN = 4002,
|
||||||
/** Unexpected/unhandled failure — the catch-all, always logged server-side. */
|
/** Unexpected/unhandled failure — the catch-all, always logged server-side. */
|
||||||
INTERNAL_ERROR = 5000,
|
INTERNAL_ERROR = 5000,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ export * from "./schemas/preferences.js";
|
||||||
export * from "./schemas/profile.js";
|
export * from "./schemas/profile.js";
|
||||||
export * from "./schemas/recipe.js";
|
export * from "./schemas/recipe.js";
|
||||||
export * from "./schemas/sources.js";
|
export * from "./schemas/sources.js";
|
||||||
|
export * from "./schemas/tech-step-worker.js";
|
||||||
export * from "./tools/assert-is-never.js";
|
export * from "./tools/assert-is-never.js";
|
||||||
export * from "./types/household.js";
|
export * from "./types/household.js";
|
||||||
export * from "./types/planning.js";
|
export * from "./types/planning.js";
|
||||||
|
|
@ -20,4 +21,5 @@ export * from "./types/preferences.js";
|
||||||
export * from "./types/recipe.js";
|
export * from "./types/recipe.js";
|
||||||
export * from "./types/reference.js";
|
export * from "./types/reference.js";
|
||||||
export * from "./types/sources.js";
|
export * from "./types/sources.js";
|
||||||
|
export * from "./types/tech-step-worker.js";
|
||||||
export * from "./types/user-profile.js";
|
export * from "./types/user-profile.js";
|
||||||
|
|
|
||||||
|
|
@ -116,3 +116,38 @@ export const listRecipesSchema = z.object({
|
||||||
});
|
});
|
||||||
/** Inferred TS type for {@link listRecipesSchema}'s validated output. */
|
/** Inferred TS type for {@link listRecipesSchema}'s validated output. */
|
||||||
export type ListRecipesInput = z.infer<typeof listRecipesSchema>;
|
export type ListRecipesInput = z.infer<typeof listRecipesSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Payload accepted by `POST /recipes/:id/steps/:stepId/corrections` — a
|
||||||
|
* user asserting what technique a `[start, end)` span of a step's
|
||||||
|
* `description` should (or shouldn't) be tagged with. `previousTechStepId`
|
||||||
|
* is the existing match being corrected (omit/`null` when the user is
|
||||||
|
* flagging a technique the classifier missed entirely — nothing to
|
||||||
|
* correct, just to add); `correctedTechStepId` is what they assert instead
|
||||||
|
* (omit/`null` means "no technique belongs here", i.e. removing a wrong
|
||||||
|
* match). Rejecting both being absent at once happens service-side
|
||||||
|
* (`recipe-tech-step-correction.service.ts`) — needs the target step's
|
||||||
|
* `description` length to validate `start`/`end` against, which this shape
|
||||||
|
* alone can't see.
|
||||||
|
*/
|
||||||
|
export const submitTechStepCorrectionSchema = z
|
||||||
|
.object({
|
||||||
|
start: z.number().int().nonnegative(),
|
||||||
|
end: z.number().int().nonnegative(),
|
||||||
|
previousTechStepId: z.number().int().positive().nullable().optional(),
|
||||||
|
correctedTechStepId: z.number().int().positive().nullable().optional(),
|
||||||
|
})
|
||||||
|
.refine((input) => input.end > input.start, {
|
||||||
|
message: "end must be greater than start",
|
||||||
|
path: ["end"],
|
||||||
|
})
|
||||||
|
.refine(
|
||||||
|
(input) =>
|
||||||
|
(input.previousTechStepId ?? null) !== null || (input.correctedTechStepId ?? null) !== null,
|
||||||
|
{
|
||||||
|
message: "at least one of previousTechStepId/correctedTechStepId is required",
|
||||||
|
path: ["correctedTechStepId"],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
/** Inferred TS type for {@link submitTechStepCorrectionSchema}'s validated output. */
|
||||||
|
export type SubmitTechStepCorrectionInput = z.infer<typeof submitTechStepCorrectionSchema>;
|
||||||
|
|
|
||||||
54
packages/shared/src/schemas/tech-step-worker.ts
Normal file
54
packages/shared/src/schemas/tech-step-worker.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
/** Query params accepted by `GET /internal/tech-steps/audit-batch` and `GET /internal/tech-steps/pending-corrections` — both just a bound on how much work one call asks for, so the worker controls its own batch size rather than the server guessing. */
|
||||||
|
export const workerBatchQuerySchema = z.object({
|
||||||
|
limit: z.coerce.number().int().positive().max(500).default(50),
|
||||||
|
});
|
||||||
|
/** Inferred TS type for {@link workerBatchQuerySchema}'s validated output. */
|
||||||
|
export type WorkerBatchQueryInput = z.infer<typeof workerBatchQuerySchema>;
|
||||||
|
|
||||||
|
/** `locale` param `GET /internal/tech-steps/audit-batch` also accepts, on top of {@link workerBatchQuerySchema} — which of `TECH_STEP_TRAINING_DATA`'s locales to sample steps' clauses against (see `tech-step-matcher.ts`'s `matchTechStepSpans` for the same parameter on the read side). No closed enum here (unlike `recipeVisibilitySchema`) — the training data's own locale list can grow without a schema change. */
|
||||||
|
export const auditBatchQuerySchema = workerBatchQuerySchema.extend({
|
||||||
|
locale: z.string().min(2).default("fr"),
|
||||||
|
});
|
||||||
|
/** Inferred TS type for {@link auditBatchQuerySchema}'s validated output. */
|
||||||
|
export type AuditBatchQueryInput = z.infer<typeof auditBatchQuerySchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One suggestion in the batch `POST /internal/tech-steps/training-suggestions`
|
||||||
|
* accepts — `techStepKey` (not an id) since the worker never has direct DB
|
||||||
|
* access to resolve one itself; the API resolves it, and rejects the whole
|
||||||
|
* batch with `TECH_STEP_NOT_FOUND` if any key is unknown (see
|
||||||
|
* `tech-step-worker.service.ts`). `sourceCorrectionId` is required when
|
||||||
|
* `sourceType` is `"correction"` (that's the whole point of that source —
|
||||||
|
* it exists *because of* one specific correction) and must be absent
|
||||||
|
* otherwise — enforced by the refinement below, not by two separate
|
||||||
|
* schemas, so the error message can point at exactly which field is wrong.
|
||||||
|
*/
|
||||||
|
const trainingSuggestionSchema = z
|
||||||
|
.object({
|
||||||
|
techStepKey: z.string().min(1),
|
||||||
|
locale: z.string().min(2),
|
||||||
|
suggestedSynonyms: z.array(z.string().trim().min(1)),
|
||||||
|
suggestedUtterances: z.array(z.string().trim().min(1)),
|
||||||
|
sourceType: z.enum(["correction", "llm_audit"]),
|
||||||
|
sourceCorrectionId: z.number().int().positive().nullable().optional(),
|
||||||
|
})
|
||||||
|
.refine(
|
||||||
|
(input) =>
|
||||||
|
input.sourceType === "correction"
|
||||||
|
? input.sourceCorrectionId !== null && input.sourceCorrectionId !== undefined
|
||||||
|
: input.sourceCorrectionId === null || input.sourceCorrectionId === undefined,
|
||||||
|
{
|
||||||
|
message:
|
||||||
|
"sourceCorrectionId is required when sourceType is 'correction', and must be absent otherwise",
|
||||||
|
path: ["sourceCorrectionId"],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Payload accepted by `POST /internal/tech-steps/training-suggestions` — a batch, not one suggestion per call, since the worker's audit/correction jobs naturally produce several at once per run and there's no reason to round-trip once per suggestion. */
|
||||||
|
export const submitTrainingSuggestionsSchema = z.object({
|
||||||
|
suggestions: z.array(trainingSuggestionSchema).min(1),
|
||||||
|
});
|
||||||
|
/** Inferred TS type for {@link submitTrainingSuggestionsSchema}'s validated output. */
|
||||||
|
export type SubmitTrainingSuggestionsInput = z.infer<typeof submitTrainingSuggestionsSchema>;
|
||||||
|
|
@ -99,3 +99,25 @@ export interface RecipeView extends RecipeSummaryView {
|
||||||
ingredients: RecipeIngredientView[];
|
ingredients: RecipeIngredientView[];
|
||||||
steps: StepView[];
|
steps: StepView[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One user-submitted correction to a step's detected techniques, as
|
||||||
|
* returned by `GET /recipes/:id/steps/:stepId/corrections` — the read side
|
||||||
|
* of `POST` on the same route (`submitTechStepCorrectionSchema`,
|
||||||
|
* `packages/shared/src/schemas/recipe.ts`). `previousTechStep`/
|
||||||
|
* `correctedTechStep` are resolved to their reference data (same "resolve
|
||||||
|
* at read time" treatment as {@link StepTechStepView.techStep}) rather than
|
||||||
|
* bare ids — `null` carries the same "missing"/"none" meaning documented on
|
||||||
|
* `StepTechStepCorrection` in schema.prisma. Not shown to *every* viewer of
|
||||||
|
* a step by default in `apps/web` today (see `StepDescription.tsx`) —
|
||||||
|
* mainly useful for a user checking what they (or others) already
|
||||||
|
* submitted before adding another correction to the same span.
|
||||||
|
*/
|
||||||
|
export interface StepTechStepCorrectionView {
|
||||||
|
id: number;
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
previousTechStep: TechStepView | null;
|
||||||
|
correctedTechStep: TechStepView | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
|
||||||
38
packages/shared/src/types/tech-step-worker.ts
Normal file
38
packages/shared/src/types/tech-step-worker.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
/**
|
||||||
|
* Contract between `apps/api`'s `/internal/tech-steps/*` routes
|
||||||
|
* (`modules/internal/tech-step-worker.routes.ts`) and
|
||||||
|
* `services/tech-step-llm-worker` — a process outside this monorepo with no
|
||||||
|
* Prisma access of its own (see that service's own README). Deliberately
|
||||||
|
* kept separate from `types/recipe.ts`/`schemas/recipe.ts`: this is an
|
||||||
|
* internal machine-to-machine protocol, not part of the `apps/web` API
|
||||||
|
* contract those files describe, even though it references the same
|
||||||
|
* `TechStep` catalog.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** One low-confidence clause `GET /internal/tech-steps/audit-batch` found by re-running `techStepClassifier.classifyClauses` against a sample of existing `Step`s — see `TechStepClauseClassification` in `tech-step-matcher.ts` for what "low-confidence" means here. */
|
||||||
|
export interface TechStepAuditClauseView {
|
||||||
|
stepId: number;
|
||||||
|
recipeId: number;
|
||||||
|
clauseText: string;
|
||||||
|
/** The NER anchor's own implied technique key, if the clause had one — `null` means the clause matched no known technique's vocabulary at all, yet still scored high enough elsewhere to be worth a second opinion. */
|
||||||
|
anchorKey: string | null;
|
||||||
|
/** The classifier's own top guess for this clause's technique, `null` if it found none. */
|
||||||
|
intentKey: string | null;
|
||||||
|
score: number;
|
||||||
|
locale: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One not-yet-processed correction `GET /internal/tech-steps/pending-corrections` returns — the worker's raw material for its "transform corrections into training suggestions" job. `clauseText` is the corrected span's own text (`Step.description.slice(start, end)`), resolved server-side since the worker never reads `Step` rows directly. */
|
||||||
|
export interface PendingTechStepCorrectionView {
|
||||||
|
id: number;
|
||||||
|
stepId: number;
|
||||||
|
recipeId: number;
|
||||||
|
clauseText: string;
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
previousTechStepKey: string | null;
|
||||||
|
correctedTechStepKey: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where a `TechStepTrainingSuggestion` came from — mirrors the `sourceType` column in schema.prisma (kept as a plain string column there, not a Prisma enum, so a future source doesn't need a migration to add). */
|
||||||
|
export type TrainingSuggestionSourceType = "correction" | "llm_audit";
|
||||||
18
services/tech-step-llm-worker/.env.example
Normal file
18
services/tech-step-llm-worker/.env.example
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
# Only needed running this worker outside docker-compose.yml (which sets
|
||||||
|
# API_BASE_URL/INTERNAL_WORKER_SECRET itself — see the root .env.example).
|
||||||
|
|
||||||
|
# Required, no default — must match apps/api's own INTERNAL_WORKER_SECRET
|
||||||
|
# (apps/api/.env / apps/api/.env.example).
|
||||||
|
INTERNAL_WORKER_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
|
||||||
|
|
||||||
|
# Defaults to the "app" service's compose hostname — override for a
|
||||||
|
# native `pnpm dev:api` API running on localhost instead.
|
||||||
|
# API_BASE_URL=http://localhost:3000
|
||||||
|
|
||||||
|
# Optional — see src/config.ts for every other variable and its default.
|
||||||
|
# TECH_STEP_WORKER_CRON=0 3 * * 0
|
||||||
|
# TECH_STEP_LLM_MODEL_URI=hf:Qwen/Qwen2.5-1.5B-Instruct-GGUF:Q4_K_M
|
||||||
|
|
||||||
|
# Set to run both jobs once and exit, instead of starting the cron loop —
|
||||||
|
# useful for a manual/CI-triggered run.
|
||||||
|
# RUN_ONCE=true
|
||||||
5
services/tech-step-llm-worker/.env.test.example
Normal file
5
services/tech-step-llm-worker/.env.test.example
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
NODE_ENV=test
|
||||||
|
# Dummy value — this worker's own tests mock api-client.ts's HTTP calls
|
||||||
|
# directly (see test/jobs/*.test.ts), so nothing here ever reaches a real
|
||||||
|
# apps/api. Only exists to satisfy config.ts's envSchema at import time.
|
||||||
|
INTERNAL_WORKER_SECRET=local-test-only-worker-secret-not-committed-32chars+
|
||||||
6
services/tech-step-llm-worker/.mocharc.json
Normal file
6
services/tech-step-llm-worker/.mocharc.json
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"extension": ["ts"],
|
||||||
|
"spec": "test/**/*.test.ts",
|
||||||
|
"node-option": ["import=tsx"],
|
||||||
|
"timeout": 10000
|
||||||
|
}
|
||||||
38
services/tech-step-llm-worker/Dockerfile
Normal file
38
services/tech-step-llm-worker/Dockerfile
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
# Standalone image for services/tech-step-llm-worker — deliberately *not*
|
||||||
|
# built as part of apps/api's own Dockerfile/image (see this package's own
|
||||||
|
# package.json doc comment): node-llama-cpp's native binding must never be
|
||||||
|
# compiled into the API's image, and this worker shares no dependencies or
|
||||||
|
# code with it (see api-client.ts's own doc comment on why its types are
|
||||||
|
# duplicated rather than imported from @batch-cooking/shared).
|
||||||
|
FROM node:22-slim AS base
|
||||||
|
# node-llama-cpp's postinstall builds/downloads a native binding — basic
|
||||||
|
# build tooling covers the (rare) case a prebuilt binary isn't available
|
||||||
|
# for this platform; ca-certificates is needed for the HTTPS download of
|
||||||
|
# both that binary and the GGUF model weights (resolveModelFile,
|
||||||
|
# llm-verdict.ts).
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends python3 make g++ ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||||
|
RUN corepack enable
|
||||||
|
WORKDIR /worker
|
||||||
|
|
||||||
|
FROM base AS build
|
||||||
|
# `pnpm-lock.yaml` is committed for this package (unlike
|
||||||
|
# experiments/llm-tech-step-poc, which has none) — `--frozen-lockfile`
|
||||||
|
# means a build fails loudly on any drift instead of silently resolving
|
||||||
|
# different versions than what's on disk/CI.
|
||||||
|
COPY services/tech-step-llm-worker/package.json services/tech-step-llm-worker/pnpm-lock.yaml ./
|
||||||
|
RUN pnpm install --ignore-workspace --frozen-lockfile
|
||||||
|
COPY services/tech-step-llm-worker/tsconfig.json ./tsconfig.json
|
||||||
|
COPY services/tech-step-llm-worker/src ./src
|
||||||
|
RUN pnpm exec tsc -p tsconfig.json
|
||||||
|
|
||||||
|
FROM base AS runtime
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
COPY --from=build /worker/node_modules ./node_modules
|
||||||
|
COPY --from=build /worker/package.json ./package.json
|
||||||
|
COPY --from=build /worker/dist ./dist
|
||||||
|
# GGUF weights download on first run into ./models (see llm-verdict.ts's
|
||||||
|
# MODELS_DIRECTORY) — mounted as a named volume in docker-compose.yml so a
|
||||||
|
# container restart doesn't re-download several hundred MB to a GB every
|
||||||
|
# time.
|
||||||
|
VOLUME ["/worker/models"]
|
||||||
|
CMD ["node", "dist/index.js"]
|
||||||
47
services/tech-step-llm-worker/README.md
Normal file
47
services/tech-step-llm-worker/README.md
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
# tech-step-llm-worker
|
||||||
|
|
||||||
|
Standalone scheduled worker for the tech-step detection reliability feature (see the repo root's feature plan). Periodically:
|
||||||
|
|
||||||
|
1. **`audit-low-confidence`** — samples clauses `apps/api`'s NLP classifier (`tech-step-matcher.ts`) itself scored below its own confidence threshold, asks a local LLM for a second opinion, and proposes a new training utterance whenever the LLM disagrees with what the NLP anchor already implied.
|
||||||
|
2. **`transform-corrections`** — drains user-submitted tech-step corrections (`StepDescription.tsx`'s editable mode, `apps/web`) not yet processed, and asks the LLM to propose new synonyms/example utterances from each one.
|
||||||
|
|
||||||
|
Both jobs only ever **propose** `TechStepTrainingSuggestion` rows for a maintainer to review — nothing here edits `tech-step-training-data.ts` automatically. See `apps/api/src/scripts/retrain-tech-steps.ts` for the maintainer-driven step that actually applies reviewed suggestions.
|
||||||
|
|
||||||
|
## Why this lives outside the pnpm workspace
|
||||||
|
|
||||||
|
Same reasoning as `experiments/llm-tech-step-poc`: `node-llama-cpp`'s native binding must never end up compiled into `apps/api`'s own install/Docker build. This package has its own `package.json`/lockfile-less install, entirely separate from `pnpm-workspace.yaml` (which only covers `apps/*`/`packages/*`).
|
||||||
|
|
||||||
|
It also has **no Prisma client and no direct database access** — every read/write goes through `apps/api`'s `/internal/tech-steps/*` routes (`api-client.ts`), authenticated with a shared secret (`INTERNAL_WORKER_SECRET`, must match `apps/api`'s own). This keeps `apps/api` the single owner of the schema, and keeps this worker a simple "read some text over HTTP, run local inference, POST a suggestion" process with nothing to keep in sync if the schema changes shape.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd services/tech-step-llm-worker
|
||||||
|
pnpm install --ignore-workspace
|
||||||
|
cp .env.example .env
|
||||||
|
# edit .env: set INTERNAL_WORKER_SECRET to match apps/api's own
|
||||||
|
pnpm start # runs the cron loop
|
||||||
|
# or:
|
||||||
|
RUN_ONCE=true pnpm start # runs both jobs once and exits
|
||||||
|
```
|
||||||
|
|
||||||
|
The GGUF model (`qwen2.5-1.5b` by default, `Q4_K_M`, ~1GB) downloads on first run into `./models/` (gitignored) and is cached there for subsequent runs — expect the very first run to take noticeably longer than later ones. See `src/config.ts` for every environment variable this reads, including `TECH_STEP_LLM_MODEL_PATH` to point at an already-downloaded GGUF file instead (useful offline, or when a mid-deploy network download isn't wanted).
|
||||||
|
|
||||||
|
## Running via Docker Compose
|
||||||
|
|
||||||
|
`docker-compose.yml` (repo root) defines a `tech-step-llm-worker` service alongside `app`/`postgres` — it's optional: set `INTERNAL_WORKER_SECRET` in the root `.env` to enable it, leave it unset and the service simply won't start (its `environment:` block fails loudly if referenced without a value, same posture as the other required secrets in that file).
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm test
|
||||||
|
```
|
||||||
|
|
||||||
|
Unit tests (`test/jobs/*.test.ts`) mock `api-client.ts`'s HTTP calls and a fake `TechStepLlmService`-shaped object directly — no real network calls, no real model loaded, no real `apps/api` needed. There is currently no integration test exercising a real model against a real `apps/api` instance; that would need to be run manually (see "Setup" above) before merging any future change to the prompts/schemas in `llm-verdict.ts`.
|
||||||
|
|
||||||
|
## Known limitations (first version of this feature)
|
||||||
|
|
||||||
|
- **Scheduler cadence** (`TECH_STEP_WORKER_CRON`, default weekly) is a provisional floor, not a calibrated value — see the feature's plan document for what it should be tuned against (recipe/correction volume, server resources).
|
||||||
|
- **Sampling in `audit-low-confidence`** only looks at the `AUDIT_SAMPLE_SIZE` (`apps/api`'s `tech-step-worker.service.ts`) most-recently-created steps, not the whole recipe catalog — a smarter sampling strategy (e.g. weighted by how often a recipe is actually viewed/planned) is future work.
|
||||||
|
- **No per-key technique definitions** are sent to the LLM today — just the bare `TechStep.key` list (`GET /reference/tech-steps`, e.g. `"panFry"`, `"foldIn"`). Adding a short human-readable gloss per technique (a new `TechStepView.description` field) would likely improve `judgeClause`'s accuracy but is out of scope for this version.
|
||||||
|
- **Locale is always assumed `"fr"`** in `transform-corrections` — no recipe/step in the app carries its own locale field yet (see `recipe.service.ts`'s `DEFAULT_TECH_STEP_LOCALE` comment on the API side).
|
||||||
34
services/tech-step-llm-worker/package.json
Normal file
34
services/tech-step-llm-worker/package.json
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
{
|
||||||
|
"name": "tech-step-llm-worker",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "Standalone scheduled worker — periodically audits low-confidence tech-step NLP matches and transforms user corrections into TechStepTrainingSuggestion rows, both via a local LLM (node-llama-cpp). Talks to apps/api exclusively through /internal/tech-steps/* (no direct DB access, no Prisma client of its own). Deliberately outside the pnpm monorepo workspace (pnpm-workspace.yaml only covers apps/*/packages/*) — same reasoning as experiments/llm-tech-step-poc: node-llama-cpp's native binding must never be compiled as part of apps/api's own install/Docker build.",
|
||||||
|
"scripts": {
|
||||||
|
"start": "tsx src/index.ts",
|
||||||
|
"dev": "tsx watch src/index.ts",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test": "cross-env NODE_ENV=test mocha"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"dotenv": "^16.4.5",
|
||||||
|
"node-llama-cpp": "^3.20.0",
|
||||||
|
"node-cron": "^3.0.3",
|
||||||
|
"zod": "^3.23.8"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.9.0",
|
||||||
|
"@types/node-cron": "^3.0.11",
|
||||||
|
"chai": "^5.1.2",
|
||||||
|
"cross-env": "^7.0.3",
|
||||||
|
"mocha": "^10.8.2",
|
||||||
|
"tsx": "^4.19.2",
|
||||||
|
"typescript": "^5.7.2"
|
||||||
|
},
|
||||||
|
"pnpm": {
|
||||||
|
"onlyBuiltDependencies": [
|
||||||
|
"esbuild",
|
||||||
|
"node-llama-cpp"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
1932
services/tech-step-llm-worker/pnpm-lock.yaml
Normal file
1932
services/tech-step-llm-worker/pnpm-lock.yaml
Normal file
File diff suppressed because it is too large
Load diff
106
services/tech-step-llm-worker/src/api-client.ts
Normal file
106
services/tech-step-llm-worker/src/api-client.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
import { env } from "./config.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thin fetch wrapper around `apps/api`'s `/internal/tech-steps/*` and
|
||||||
|
* `/reference/tech-steps` — the only two surfaces this worker ever talks
|
||||||
|
* to (see `tech-step-worker.service.ts`/`tech-step-worker.routes.ts` on
|
||||||
|
* that side). No Prisma client, no direct database access at all: every
|
||||||
|
* read/write goes through here, over HTTP, authenticated with
|
||||||
|
* `INTERNAL_WORKER_SECRET` — see `requireInternalWorker`
|
||||||
|
* (`apps/api/src/middlewares/require-internal-worker.ts`) for why that's a
|
||||||
|
* separate mechanism from a user session.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** One reference technique, as returned by the public `GET /reference/tech-steps` — this worker's only source of the taxonomy it audits/labels against, never a hardcoded copy (see `tech-step-taxonomy.ts`). */
|
||||||
|
export interface TechStepReference {
|
||||||
|
id: number;
|
||||||
|
key: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mirrors `TechStepAuditClauseView` (`packages/shared`) — duplicated here rather than importing from `@batch-cooking/shared`, since this worker deliberately lives outside the pnpm workspace (see `package.json`'s own doc comment) and so can't depend on a workspace package. */
|
||||||
|
export interface AuditClause {
|
||||||
|
stepId: number;
|
||||||
|
recipeId: number;
|
||||||
|
clauseText: string;
|
||||||
|
anchorKey: string | null;
|
||||||
|
intentKey: string | null;
|
||||||
|
score: number;
|
||||||
|
locale: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mirrors `PendingTechStepCorrectionView` (`packages/shared`) — same "duplicated, not imported" reasoning as {@link AuditClause}. */
|
||||||
|
export interface PendingCorrection {
|
||||||
|
id: number;
|
||||||
|
stepId: number;
|
||||||
|
recipeId: number;
|
||||||
|
clauseText: string;
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
previousTechStepKey: string | null;
|
||||||
|
correctedTechStepKey: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One suggestion `postTrainingSuggestions` submits — mirrors one entry of `SubmitTrainingSuggestionsInput["suggestions"]` (`packages/shared`). */
|
||||||
|
export interface TrainingSuggestionInput {
|
||||||
|
techStepKey: string;
|
||||||
|
locale: string;
|
||||||
|
suggestedSynonyms: string[];
|
||||||
|
suggestedUtterances: string[];
|
||||||
|
sourceType: "correction" | "llm_audit";
|
||||||
|
sourceCorrectionId?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request<TResponseBody>(
|
||||||
|
path: string,
|
||||||
|
init: RequestInit = {},
|
||||||
|
): Promise<TResponseBody> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${env.API_BASE_URL}${path}`, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Internal-Worker-Secret": env.INTERNAL_WORKER_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 (the scheduler's per-job try/catch,
|
||||||
|
// see `scheduler.ts`) already decides what to do with a failed run;
|
||||||
|
// this is just the one place the `await` itself has to sit inside a
|
||||||
|
// try/catch, same convention `apps/api` follows for the same reason.
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The full reference technique catalog — `apps/api`'s `TechStep` table, read fresh (never cached beyond one process's lifetime) so a catalog change is picked up on the worker's next restart without a code change here. */
|
||||||
|
export function getTechStepReference(): Promise<TechStepReference[]> {
|
||||||
|
return request("/reference/tech-steps");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Low-confidence clauses sampled from existing recipes — `audit-low-confidence`'s raw material. */
|
||||||
|
export function getAuditBatch(locale: string, limit: number): Promise<AuditClause[]> {
|
||||||
|
const params = new URLSearchParams({ locale, limit: String(limit) });
|
||||||
|
return request(`/internal/tech-steps/audit-batch?${params}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Corrections not yet turned into a suggestion — `transform-corrections`'s raw material. */
|
||||||
|
export function getPendingCorrections(limit: number): Promise<PendingCorrection[]> {
|
||||||
|
const params = new URLSearchParams({ limit: String(limit) });
|
||||||
|
return request(`/internal/tech-steps/pending-corrections?${params}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Submits a batch of suggestions — a no-op (resolves immediately) if `suggestions` is empty, so a job with nothing to report doesn't need its own guard at every call site. */
|
||||||
|
export function postTrainingSuggestions(
|
||||||
|
suggestions: TrainingSuggestionInput[],
|
||||||
|
): Promise<{ created: number }> {
|
||||||
|
if (suggestions.length === 0) return Promise.resolve({ created: 0 });
|
||||||
|
return request("/internal/tech-steps/training-suggestions", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ suggestions }),
|
||||||
|
});
|
||||||
|
}
|
||||||
65
services/tech-step-llm-worker/src/config.ts
Normal file
65
services/tech-step-llm-worker/src/config.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
import dotenv from "dotenv";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
// Loads `.env.test` under Mocha (see `.mocharc.json`'s `NODE_ENV=test`,
|
||||||
|
// package.json's `test` script) instead of `.env` — same reasoning as
|
||||||
|
// `apps/api/src/config/env.ts`'s identical guard: `test/*.test.ts` needs a
|
||||||
|
// real (if dummy) `INTERNAL_WORKER_SECRET` to satisfy `envSchema` below
|
||||||
|
// without requiring every test file to set `process.env` by hand before
|
||||||
|
// importing anything that (transitively) imports this module.
|
||||||
|
dotenv.config({ path: process.env.NODE_ENV === "test" ? ".env.test" : ".env" });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every environment variable this worker reads. Parsing (below) fails fast
|
||||||
|
* at startup if something required is missing/invalid — same "no silent
|
||||||
|
* fallback" posture as `apps/api/src/config/env.ts`, this worker's closest
|
||||||
|
* analog even though it isn't part of that workspace.
|
||||||
|
*/
|
||||||
|
const envSchema = z.object({
|
||||||
|
/** Base URL of `apps/api` — `http://app:3000` (the `app` service's own docker-compose hostname) is the right default inside the compose network; override for local dev against a host-run API. */
|
||||||
|
API_BASE_URL: z.string().url().default("http://app:3000"),
|
||||||
|
/** Must match `apps/api`'s own `INTERNAL_WORKER_SECRET` (`config/env.ts`) — required, no default, same reasoning as that variable's own doc comment. */
|
||||||
|
INTERNAL_WORKER_SECRET: z.string().min(32),
|
||||||
|
/**
|
||||||
|
* `hf:<repo>:<quant>` URI `resolveModelFile` (node-llama-cpp) resolves
|
||||||
|
* and downloads — defaults to the model this feature's plan settled on
|
||||||
|
* (`qwen2.5-1.5b`, `Q4_K_M`): best empirically observed FR/EN robustness
|
||||||
|
* and JSON-structuring instruction-following among the small models
|
||||||
|
* `experiments/llm-tech-step-poc` benchmarked, and not slower than the
|
||||||
|
* smaller alternatives there despite having more parameters. See that
|
||||||
|
* PoC's `README.md` for the fuller comparison this default is based on.
|
||||||
|
*/
|
||||||
|
TECH_STEP_LLM_MODEL_URI: z.string().default("hf:Qwen/Qwen2.5-1.5B-Instruct-GGUF:Q4_K_M"),
|
||||||
|
/** Explicit local GGUF path, bypassing the HF download above — same escape hatch `experiments/llm-tech-step-poc`'s own `LLM_TECH_STEP_MODEL_PATH` provides, useful offline or when a network download mid-deploy isn't wanted. */
|
||||||
|
TECH_STEP_LLM_MODEL_PATH: z.string().optional(),
|
||||||
|
/**
|
||||||
|
* Cron expression (`node-cron` syntax) the scheduler wakes up on to run
|
||||||
|
* both jobs — default weekly (Sunday 03:00) is a provisional floor, not
|
||||||
|
* a calibrated value: this feature's plan explicitly flags the real
|
||||||
|
* cadence as needing to be set from observed recipe/correction volume
|
||||||
|
* and server resources once this is actually deployed (see that plan's
|
||||||
|
* "Risques" section).
|
||||||
|
*/
|
||||||
|
TECH_STEP_WORKER_CRON: z.string().default("0 3 * * 0"),
|
||||||
|
/** Which of `TECH_STEP_TRAINING_DATA`'s locales `audit-low-confidence` samples against — see that job's own doc comment for why this can't just be discovered per-`Step` (the app has no per-recipe locale field yet). */
|
||||||
|
TECH_STEP_WORKER_LOCALE: z.string().default("fr"),
|
||||||
|
/** Upper bound passed as `?limit=` to both `GET /internal/tech-steps/audit-batch` and `GET /internal/tech-steps/pending-corrections` per run — keeps one scheduled run's LLM inference cost bounded regardless of backlog size; a larger backlog just takes more scheduled runs to drain, not one slower one. */
|
||||||
|
TECH_STEP_WORKER_BATCH_LIMIT: z.coerce.number().int().positive().default(50),
|
||||||
|
/**
|
||||||
|
* Runs both jobs once immediately and exits, instead of starting the
|
||||||
|
* cron loop — for a manual/CI-triggered run (`pnpm start`) rather than
|
||||||
|
* the long-lived container process. `z.coerce.boolean()` is deliberately
|
||||||
|
* *not* used here — it coerces via `Boolean(value)`, which makes the
|
||||||
|
* literal string `"false"` coerce to `true` (any non-empty string does),
|
||||||
|
* a real footgun for an env var — same explicit string-comparison
|
||||||
|
* transform `apps/api/src/config/env.ts`'s `COOKIE_SECURE` already uses
|
||||||
|
* for the identical reason.
|
||||||
|
*/
|
||||||
|
RUN_ONCE: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.transform((value) => value === "true"),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Parsed, validated environment — import this instead of reading `process.env` directly anywhere else. */
|
||||||
|
export const env = envSchema.parse(process.env);
|
||||||
19
services/tech-step-llm-worker/src/index.ts
Normal file
19
services/tech-step-llm-worker/src/index.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { env } from "./config.js";
|
||||||
|
import { runOnce, startScheduler } from "./scheduler.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entrypoint — `RUN_ONCE=true` runs both jobs a single time and exits
|
||||||
|
* (manual/CI-triggered invocation, `pnpm start`), otherwise starts the
|
||||||
|
* long-lived cron loop (the container's normal mode, see `Dockerfile`).
|
||||||
|
*/
|
||||||
|
if (env.RUN_ONCE) {
|
||||||
|
try {
|
||||||
|
await runOnce();
|
||||||
|
process.exit(0);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[tech-step-llm-worker] run failed:", err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
startScheduler();
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
import {
|
||||||
|
getAuditBatch,
|
||||||
|
postTrainingSuggestions,
|
||||||
|
type TrainingSuggestionInput,
|
||||||
|
} from "../api-client.js";
|
||||||
|
import type { TechStepLlmService } from "../llm-verdict.js";
|
||||||
|
|
||||||
|
/** The one `TechStepLlmService` method this job needs — accepted structurally rather than the full class, so a test can pass a plain fake object instead of a real, model-loaded instance. */
|
||||||
|
type ClauseJudge = Pick<TechStepLlmService, "judgeClause">;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Point 2 of this feature's plan ("combiner NLP+LLM pour fiabiliser le
|
||||||
|
* NLP"), realized entirely offline: samples clauses `tech-step-matcher.ts`'s
|
||||||
|
* classifier itself was least confident about (`GET
|
||||||
|
* /internal/tech-steps/audit-batch` — see that route's own doc comment for
|
||||||
|
* exactly what "low-confidence" means there), asks the LLM for its own
|
||||||
|
* verdict on each, and — only when the LLM disagrees with what the NLP
|
||||||
|
* anchor already implied — proposes that clause as a new training
|
||||||
|
* utterance for the technique the LLM preferred.
|
||||||
|
*
|
||||||
|
* Never touches the interactive recipe-save/read path — this feature's
|
||||||
|
* plan explicitly chose offline-only for the LLM (no infra here to run an
|
||||||
|
* LLM call within a request's own latency budget without risking it), so
|
||||||
|
* the NLP classifier alone stays responsible for what a viewer sees
|
||||||
|
* immediately; this job only ever improves the *corpus* it's trained on,
|
||||||
|
* for future saves/backfills to benefit from.
|
||||||
|
*
|
||||||
|
* @returns How many suggestions this run produced — used only for the
|
||||||
|
* scheduler's own log line (`scheduler.ts`), not asserted on by anything.
|
||||||
|
*/
|
||||||
|
export async function runAuditLowConfidenceJob(
|
||||||
|
llm: ClauseJudge,
|
||||||
|
options: { locale: string; limit: number },
|
||||||
|
): Promise<number> {
|
||||||
|
const clauses = await getAuditBatch(options.locale, options.limit);
|
||||||
|
|
||||||
|
const suggestions: TrainingSuggestionInput[] = [];
|
||||||
|
for (const clause of clauses) {
|
||||||
|
const verdictKey = await llm.judgeClause(clause.clauseText);
|
||||||
|
// No opinion, or agrees with what the NLP anchor already implied —
|
||||||
|
// nothing new to propose either way. A clause with *no* anchor at all
|
||||||
|
// never reaches this job in the first place (`getAuditBatch`'s own
|
||||||
|
// filter, `tech-step-worker.service.ts`).
|
||||||
|
if (verdictKey === null || verdictKey === clause.anchorKey) continue;
|
||||||
|
|
||||||
|
suggestions.push({
|
||||||
|
techStepKey: verdictKey,
|
||||||
|
locale: clause.locale,
|
||||||
|
// Only the clause itself, as one more training utterance — this job
|
||||||
|
// has exactly one confirmed disagreement per clause, not enough to
|
||||||
|
// responsibly invent new synonym *words* from (that needs the
|
||||||
|
// richer signal `transform-corrections` has: a human explicitly
|
||||||
|
// confirming the label, not just the LLM's own second opinion).
|
||||||
|
suggestedSynonyms: [],
|
||||||
|
suggestedUtterances: [clause.clauseText],
|
||||||
|
sourceType: "llm_audit",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await postTrainingSuggestions(suggestions);
|
||||||
|
return suggestions.length;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
import {
|
||||||
|
getPendingCorrections,
|
||||||
|
postTrainingSuggestions,
|
||||||
|
type TrainingSuggestionInput,
|
||||||
|
} from "../api-client.js";
|
||||||
|
import type { TechStepLlmService } from "../llm-verdict.js";
|
||||||
|
|
||||||
|
/** The one `TechStepLlmService` method this job needs — same "structural, not the full class" reasoning as `audit-low-confidence.ts`'s own `ClauseJudge`. */
|
||||||
|
type TrainingDataSuggester = Pick<TechStepLlmService, "suggestTrainingData">;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Point 3 of this feature's plan ("boucle de rétro-action... entraîner le
|
||||||
|
* NLP selon les retours utilisateurs"): drains `StepTechStepCorrection`
|
||||||
|
* rows a viewer submitted (`StepDescription.tsx`'s editable mode,
|
||||||
|
* `apps/web`) that haven't been turned into a suggestion yet (`GET
|
||||||
|
* /internal/tech-steps/pending-corrections`), and asks the LLM to propose
|
||||||
|
* new training synonyms/utterances for each one's asserted technique.
|
||||||
|
*
|
||||||
|
* `getPendingCorrections` never returns a pure-removal correction
|
||||||
|
* (`correctedTechStepKey: null`, "no technique belongs here") in the first
|
||||||
|
* place — see that route's own doc comment (`tech-step-worker.service.ts`)
|
||||||
|
* for why: there's no technique to propose new positive training data
|
||||||
|
* *for* from a removal alone, and letting one through here would leave it
|
||||||
|
* permanently stuck unconsumed. The `continue` below is a defensive
|
||||||
|
* backstop against that invariant changing later, not the primary
|
||||||
|
* filtering mechanism.
|
||||||
|
*
|
||||||
|
* @returns How many suggestions this run produced — same "log line only" purpose as `runAuditLowConfidenceJob`'s own return value.
|
||||||
|
*/
|
||||||
|
export async function runTransformCorrectionsJob(
|
||||||
|
llm: TrainingDataSuggester,
|
||||||
|
options: { limit: number },
|
||||||
|
): Promise<number> {
|
||||||
|
const corrections = await getPendingCorrections(options.limit);
|
||||||
|
|
||||||
|
const suggestions: TrainingSuggestionInput[] = [];
|
||||||
|
for (const correction of corrections) {
|
||||||
|
if (correction.correctedTechStepKey === null) continue;
|
||||||
|
|
||||||
|
const result = await llm.suggestTrainingData(
|
||||||
|
correction.clauseText,
|
||||||
|
correction.correctedTechStepKey,
|
||||||
|
// No per-recipe locale field exists yet anywhere in the app (see
|
||||||
|
// `recipe.service.ts`'s own `DEFAULT_TECH_STEP_LOCALE` comment) —
|
||||||
|
// "fr" is the only locale a real correction can meaningfully be in
|
||||||
|
// today.
|
||||||
|
"fr",
|
||||||
|
);
|
||||||
|
if (result.suggestedSynonyms.length === 0 && result.suggestedUtterances.length === 0) {
|
||||||
|
// A run that produced nothing usable for this correction still
|
||||||
|
// leaves it unconsumed (no suggestion was posted for it) — it's
|
||||||
|
// retried on the next scheduled run rather than silently dropped,
|
||||||
|
// on the theory that a transient bad generation shouldn't
|
||||||
|
// permanently forfeit a real user correction's training value.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
suggestions.push({
|
||||||
|
techStepKey: correction.correctedTechStepKey,
|
||||||
|
locale: "fr",
|
||||||
|
suggestedSynonyms: result.suggestedSynonyms,
|
||||||
|
suggestedUtterances: result.suggestedUtterances,
|
||||||
|
sourceType: "correction",
|
||||||
|
sourceCorrectionId: correction.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await postTrainingSuggestions(suggestions);
|
||||||
|
return suggestions.length;
|
||||||
|
}
|
||||||
190
services/tech-step-llm-worker/src/llm-verdict.ts
Normal file
190
services/tech-step-llm-worker/src/llm-verdict.ts
Normal file
|
|
@ -0,0 +1,190 @@
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import {
|
||||||
|
getLlama,
|
||||||
|
type Llama,
|
||||||
|
LlamaChatSession,
|
||||||
|
type LlamaContext,
|
||||||
|
type LlamaJsonSchemaGrammar,
|
||||||
|
type LlamaModel,
|
||||||
|
resolveModelFile,
|
||||||
|
} from "node-llama-cpp";
|
||||||
|
import { env } from "./config.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Local LLM inference for this worker's two jobs — model loading/grammar
|
||||||
|
* compilation ported from `experiments/llm-tech-step-poc/src/llm-tech-step-poc.ts`'s
|
||||||
|
* `LocalLlmStepAnalyzer` (same `node-llama-cpp` API: `getLlama()` ->
|
||||||
|
* `loadModel()` -> `createContext()` -> `createGrammarForJsonSchema()`, a
|
||||||
|
* fresh `LlamaContextSequence` allocated and disposed per call rather than
|
||||||
|
* a shared `LlamaChatSession` growing its own history across calls), *not*
|
||||||
|
* copied wholesale — this worker judges against the real ~26-key `TechStep`
|
||||||
|
* taxonomy (fetched at runtime, see `tech-step-taxonomy.ts`), not that
|
||||||
|
* PoC's own fixed 7-category `KitchenActionType`, and needs two distinct
|
||||||
|
* tasks (clause verdict, training-data suggestion) rather than that PoC's
|
||||||
|
* one full-step structuring task — so the schemas/prompts here are new,
|
||||||
|
* only the surrounding model-lifecycle mechanics are reused.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Directory GGUF weights are downloaded/cached in — gitignored, same convention as the PoC's own `models/` directory next to it. */
|
||||||
|
const MODELS_DIRECTORY = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "models");
|
||||||
|
|
||||||
|
async function resolveModelPath(): Promise<string> {
|
||||||
|
if (env.TECH_STEP_LLM_MODEL_PATH !== undefined && env.TECH_STEP_LLM_MODEL_PATH.length > 0) {
|
||||||
|
return env.TECH_STEP_LLM_MODEL_PATH;
|
||||||
|
}
|
||||||
|
return await resolveModelFile(env.TECH_STEP_LLM_MODEL_URI, MODELS_DIRECTORY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** JSON shape `judgeClause` asks the model for — `techStepKey` constrained (via {@link buildClauseVerdictSchema}) to exactly the taxonomy's own keys, plus `null` for "none of them". */
|
||||||
|
interface ClauseVerdictResult {
|
||||||
|
techStepKey: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Builds the JSON schema constraining `judgeClause`'s output to one of `techStepKeys`, or `null` — compiled fresh per {@link TechStepLlmService.initialize} call since the taxonomy (and so the valid `enum` values) is only known once fetched from the API, not at module-load time. */
|
||||||
|
function buildClauseVerdictSchema(techStepKeys: string[]) {
|
||||||
|
return {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
techStepKey: { oneOf: [{ type: "null" }, { enum: techStepKeys }] },
|
||||||
|
},
|
||||||
|
required: ["techStepKey"],
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** JSON shape `suggestTrainingData` asks the model for. Array sizes are steered by the prompt (`buildSuggestionSystemPrompt`'s "at most 3"/"at most 2"), not the grammar itself — `experiments/llm-tech-step-poc`'s own schemas never constrained array length either, and adding an unfamiliar JSON-schema keyword here risked breaking grammar compilation for no proven benefit. */
|
||||||
|
const TRAINING_SUGGESTION_JSON_SCHEMA = {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
suggestedSynonyms: { type: "array", items: { type: "string" } },
|
||||||
|
suggestedUtterances: { type: "array", items: { type: "string" } },
|
||||||
|
},
|
||||||
|
required: ["suggestedSynonyms", "suggestedUtterances"],
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** Result shape for {@link TechStepLlmService.suggestTrainingData}. */
|
||||||
|
export interface TrainingSuggestionResult {
|
||||||
|
suggestedSynonyms: string[];
|
||||||
|
suggestedUtterances: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildClauseVerdictSystemPrompt(techStepKeys: string[]): string {
|
||||||
|
return `You are a culinary technique classifier. You receive one short clause from a recipe step, written in French or English, and a fixed list of known technique keys. Decide which single technique from the list the clause most likely describes — including when it describes the technique without ever naming it (e.g. "until the butter has disappeared into the pan" means "melt"). If the clause doesn't clearly describe any technique in the list, answer null. Respond with ONLY the JSON object required by the schema — no prose, no markdown.
|
||||||
|
|
||||||
|
Known technique keys: ${techStepKeys.join(", ")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** French clitic-pronoun lesson from `experiments/llm-tech-step-poc/src/nlp-tech-step-poc.ts` (e.g. "faites-les revenir" breaking a "faire revenir" match) encoded directly into the prompt — a suggestion generated without this steer would reproduce the exact multi-word-synonym trap that PoC found and this feature's plan calls out. */
|
||||||
|
function buildSuggestionSystemPrompt(techStepKey: string, locale: string): string {
|
||||||
|
return `You are helping expand a training corpus (locale "${locale}") for a cooking-technique detector. You receive a real recipe clause a human has confirmed means the technique "${techStepKey}". Suggest at most 3 short new synonym words/phrases for this technique, and at most 2 example training sentences that use it in context (paraphrases are welcome, not just the literal clause). Prefer single-word verb forms over multi-word phrases when both would work — a multi-word phrase like "faire revenir" can silently fail to match a real sentence like "faites-les revenir" (French object pronoun inserted between the two words), while the bare verb "revenir" still would. Respond with ONLY the JSON object required by the schema — no prose, no markdown.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owns the loaded model/context/compiled grammars for this worker's whole
|
||||||
|
* run — a real class (not a plain object), same "holds real, expensive-to-
|
||||||
|
* rebuild state" reasoning as `TechStepClassifierService`
|
||||||
|
* (`apps/api/src/lib/recipe-matching/tech-step-matcher.ts`) and the PoC's
|
||||||
|
* own `LocalLlmStepAnalyzer`.
|
||||||
|
*/
|
||||||
|
export class TechStepLlmService {
|
||||||
|
private _llama: Llama | undefined;
|
||||||
|
private _model: LlamaModel | undefined;
|
||||||
|
private _context: LlamaContext | undefined;
|
||||||
|
private _verdictGrammar:
|
||||||
|
| LlamaJsonSchemaGrammar<ReturnType<typeof buildClauseVerdictSchema>>
|
||||||
|
| undefined;
|
||||||
|
private _suggestionGrammar:
|
||||||
|
| LlamaJsonSchemaGrammar<typeof TRAINING_SUGGESTION_JSON_SCHEMA>
|
||||||
|
| undefined;
|
||||||
|
private _techStepKeys: string[] = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads the model, creates its inference context, and compiles both
|
||||||
|
* grammars — `techStepKeys` (from `loadTechStepTaxonomy`) is what the
|
||||||
|
* verdict grammar's `enum` is built from, so it must be known before this
|
||||||
|
* can complete (see {@link buildClauseVerdictSchema}).
|
||||||
|
*/
|
||||||
|
public async initialize(techStepKeys: string[]): Promise<void> {
|
||||||
|
this._techStepKeys = techStepKeys;
|
||||||
|
const modelPath = await resolveModelPath();
|
||||||
|
this._llama = await getLlama();
|
||||||
|
this._model = await this._llama.loadModel({ modelPath });
|
||||||
|
this._context = await this._model.createContext({ contextSize: 4096 });
|
||||||
|
this._verdictGrammar = await this._llama.createGrammarForJsonSchema(
|
||||||
|
buildClauseVerdictSchema(techStepKeys),
|
||||||
|
);
|
||||||
|
this._suggestionGrammar = await this._llama.createGrammarForJsonSchema(
|
||||||
|
TRAINING_SUGGESTION_JSON_SCHEMA,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Judges one clause against the taxonomy `initialize` was given, and
|
||||||
|
* returns its verdict's technique `key`, or `null` for "none of them
|
||||||
|
* clearly". A grammar-invalid or empty response degrades to `null`
|
||||||
|
* (treated as "no opinion") rather than throwing — one bad generation
|
||||||
|
* shouldn't abort the whole scheduled run over a single clause.
|
||||||
|
*/
|
||||||
|
public async judgeClause(clauseText: string): Promise<string | null> {
|
||||||
|
if (this._context === undefined || this._verdictGrammar === undefined) {
|
||||||
|
throw new Error("TechStepLlmService.initialize() must be awaited before judgeClause().");
|
||||||
|
}
|
||||||
|
const context = this._context;
|
||||||
|
const grammar = this._verdictGrammar;
|
||||||
|
const sequence = context.getSequence();
|
||||||
|
try {
|
||||||
|
const session = new LlamaChatSession({
|
||||||
|
contextSequence: sequence,
|
||||||
|
systemPrompt: buildClauseVerdictSystemPrompt(this._techStepKeys),
|
||||||
|
});
|
||||||
|
const response = await session.prompt(clauseText, { grammar });
|
||||||
|
const parsed = grammar.parse(response) as ClauseVerdictResult;
|
||||||
|
return parsed.techStepKey;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
await sequence.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proposes candidate synonyms/utterances for `techStepKey` from one
|
||||||
|
* confirmed clause. An invalid/empty response degrades to an empty
|
||||||
|
* suggestion (`{ suggestedSynonyms: [], suggestedUtterances: [] }`) —
|
||||||
|
* `transform-corrections` skips posting a suggestion that came back
|
||||||
|
* empty on both arrays, rather than treating a bad generation as a
|
||||||
|
* job-ending failure.
|
||||||
|
*/
|
||||||
|
public async suggestTrainingData(
|
||||||
|
clauseText: string,
|
||||||
|
techStepKey: string,
|
||||||
|
locale: string,
|
||||||
|
): Promise<TrainingSuggestionResult> {
|
||||||
|
if (this._context === undefined || this._suggestionGrammar === undefined) {
|
||||||
|
throw new Error(
|
||||||
|
"TechStepLlmService.initialize() must be awaited before suggestTrainingData().",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const context = this._context;
|
||||||
|
const grammar = this._suggestionGrammar;
|
||||||
|
const sequence = context.getSequence();
|
||||||
|
try {
|
||||||
|
const session = new LlamaChatSession({
|
||||||
|
contextSequence: sequence,
|
||||||
|
systemPrompt: buildSuggestionSystemPrompt(techStepKey, locale),
|
||||||
|
});
|
||||||
|
const response = await session.prompt(clauseText, { grammar });
|
||||||
|
return grammar.parse(response) as TrainingSuggestionResult;
|
||||||
|
} catch {
|
||||||
|
return { suggestedSynonyms: [], suggestedUtterances: [] };
|
||||||
|
} finally {
|
||||||
|
await sequence.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Releases the model/context — native memory, not managed by V8's GC. Called once per scheduled run (see `scheduler.ts`) rather than kept loaded between runs, so the process's RAM footprint returns to idle between them. */
|
||||||
|
public async dispose(): Promise<void> {
|
||||||
|
await this._context?.dispose();
|
||||||
|
await this._model?.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
56
services/tech-step-llm-worker/src/scheduler.ts
Normal file
56
services/tech-step-llm-worker/src/scheduler.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
import cron from "node-cron";
|
||||||
|
import { env } from "./config.js";
|
||||||
|
import { runAuditLowConfidenceJob } from "./jobs/audit-low-confidence.js";
|
||||||
|
import { runTransformCorrectionsJob } from "./jobs/transform-corrections.js";
|
||||||
|
import { TechStepLlmService } from "./llm-verdict.js";
|
||||||
|
import { loadTechStepTaxonomy } from "./tech-step-taxonomy.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs one full cycle: fetch the taxonomy, load the model, run both jobs,
|
||||||
|
* dispose the model. The model is never kept loaded between scheduled
|
||||||
|
* runs (see `llm-verdict.ts`'s `dispose()` doc comment) — this function's
|
||||||
|
* own duration (model load/dispose easily adds several seconds) is an
|
||||||
|
* accepted cost of keeping this process's idle RAM footprint low between
|
||||||
|
* runs, not something to optimize away.
|
||||||
|
*/
|
||||||
|
export async function runOnce(): Promise<void> {
|
||||||
|
console.info("[tech-step-llm-worker] starting scheduled run...");
|
||||||
|
const taxonomy = await loadTechStepTaxonomy();
|
||||||
|
const techStepKeys = taxonomy.map((techStep) => techStep.key);
|
||||||
|
|
||||||
|
const llm = new TechStepLlmService();
|
||||||
|
try {
|
||||||
|
await llm.initialize(techStepKeys);
|
||||||
|
|
||||||
|
const auditCount = await runAuditLowConfidenceJob(llm, {
|
||||||
|
locale: env.TECH_STEP_WORKER_LOCALE,
|
||||||
|
limit: env.TECH_STEP_WORKER_BATCH_LIMIT,
|
||||||
|
});
|
||||||
|
console.info(`[tech-step-llm-worker] audit-low-confidence: ${auditCount} suggestion(s)`);
|
||||||
|
|
||||||
|
const correctionCount = await runTransformCorrectionsJob(llm, {
|
||||||
|
limit: env.TECH_STEP_WORKER_BATCH_LIMIT,
|
||||||
|
});
|
||||||
|
console.info(`[tech-step-llm-worker] transform-corrections: ${correctionCount} suggestion(s)`);
|
||||||
|
} finally {
|
||||||
|
await llm.dispose();
|
||||||
|
}
|
||||||
|
console.info("[tech-step-llm-worker] scheduled run complete.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts the long-lived cron loop — {@link runOnce} fires on
|
||||||
|
* `env.TECH_STEP_WORKER_CRON`'s schedule, indefinitely, until the process
|
||||||
|
* is stopped. A run that throws is logged, not left to crash the process —
|
||||||
|
* the next scheduled fire still happens; a transient API/model failure on
|
||||||
|
* one run shouldn't permanently kill the worker until someone notices and
|
||||||
|
* manually restarts its container.
|
||||||
|
*/
|
||||||
|
export function startScheduler(): void {
|
||||||
|
console.info(`[tech-step-llm-worker] scheduling runs on "${env.TECH_STEP_WORKER_CRON}"`);
|
||||||
|
cron.schedule(env.TECH_STEP_WORKER_CRON, () => {
|
||||||
|
runOnce().catch((err: unknown) => {
|
||||||
|
console.error("[tech-step-llm-worker] scheduled run failed:", err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
27
services/tech-step-llm-worker/src/tech-step-taxonomy.ts
Normal file
27
services/tech-step-llm-worker/src/tech-step-taxonomy.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { getTechStepReference, type TechStepReference } from "./api-client.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads the `TechStep` taxonomy the worker judges/labels clauses against —
|
||||||
|
* always from `GET /reference/tech-steps` (`api-client.ts`), never a
|
||||||
|
* hardcoded local copy. `experiments/llm-tech-step-poc`'s own taxonomy
|
||||||
|
* (`shared/kitchen-action.ts`'s 7-category `KitchenActionType`) is
|
||||||
|
* deliberately *not* reused here: this worker judges against the real
|
||||||
|
* production `TechStep` catalog (~26 techniques), a different, finer-
|
||||||
|
* grained taxonomy that PoC never tested — reading it fresh from the API
|
||||||
|
* is what keeps this worker from ever silently drifting out of sync with
|
||||||
|
* whatever `apps/api`'s `TechStep` table actually contains.
|
||||||
|
*
|
||||||
|
* Fetched once per process (the scheduler's "load model, run jobs, dispose"
|
||||||
|
* cycle — see `scheduler.ts` — already re-fetches this on every scheduled
|
||||||
|
* wake-up, so a catalog change is picked up within one cycle without
|
||||||
|
* needing its own cache invalidation).
|
||||||
|
*/
|
||||||
|
export async function loadTechStepTaxonomy(): Promise<TechStepReference[]> {
|
||||||
|
const techSteps = await getTechStepReference();
|
||||||
|
if (techSteps.length === 0) {
|
||||||
|
throw new Error(
|
||||||
|
"GET /reference/tech-steps returned an empty catalog — refusing to judge clauses against no known techniques at all.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return techSteps;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,123 @@
|
||||||
|
import { expect } from "chai";
|
||||||
|
import { runAuditLowConfidenceJob } from "../../src/jobs/audit-low-confidence.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stubs `globalThis.fetch` directly (this worker's own `api-client.ts` is
|
||||||
|
* a thin wrapper around it) — same convention `apps/api`'s
|
||||||
|
* `the-meal-db.ts` test uses for the same reason: no real network call,
|
||||||
|
* no mocking library needed for a single-function dependency.
|
||||||
|
*/
|
||||||
|
function stubFetch(responses: Record<string, unknown>): { url: string; body: unknown }[] {
|
||||||
|
const calls: { url: string; body: unknown }[] = [];
|
||||||
|
globalThis.fetch = (async (url: string | URL, init?: RequestInit) => {
|
||||||
|
const href = String(url);
|
||||||
|
const body = typeof init?.body === "string" ? JSON.parse(init.body) : undefined;
|
||||||
|
calls.push({ url: href, body });
|
||||||
|
for (const [pathFragment, response] of Object.entries(responses)) {
|
||||||
|
if (href.includes(pathFragment)) {
|
||||||
|
return new Response(JSON.stringify(response), { status: 200 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(`stubFetch: no response configured for ${href}`);
|
||||||
|
}) as typeof fetch;
|
||||||
|
return calls;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("runAuditLowConfidenceJob", () => {
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("proposes a suggestion only when the LLM disagrees with the NLP anchor", async () => {
|
||||||
|
const calls = stubFetch({
|
||||||
|
"audit-batch": [
|
||||||
|
{
|
||||||
|
stepId: 1,
|
||||||
|
recipeId: 1,
|
||||||
|
clauseText: "jusqu'à ce que ce soit doré",
|
||||||
|
anchorKey: "fry",
|
||||||
|
intentKey: null,
|
||||||
|
score: 0.5,
|
||||||
|
locale: "fr",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
stepId: 2,
|
||||||
|
recipeId: 1,
|
||||||
|
clauseText: "laisser reposer un instant",
|
||||||
|
anchorKey: "rest",
|
||||||
|
intentKey: "rest",
|
||||||
|
score: 0.6,
|
||||||
|
locale: "fr",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"training-suggestions": { created: 1 },
|
||||||
|
});
|
||||||
|
const llm = {
|
||||||
|
judgeClause: async (text: string) => (text.includes("doré") ? "roast" : "rest"),
|
||||||
|
};
|
||||||
|
|
||||||
|
const count = await runAuditLowConfidenceJob(llm, { locale: "fr", limit: 10 });
|
||||||
|
|
||||||
|
expect(count).to.equal(1);
|
||||||
|
const postCall = calls.find((call) => call.url.includes("training-suggestions"));
|
||||||
|
if (!postCall) throw new Error("expected a POST to training-suggestions");
|
||||||
|
expect(postCall.body).to.deep.equal({
|
||||||
|
suggestions: [
|
||||||
|
{
|
||||||
|
techStepKey: "roast",
|
||||||
|
locale: "fr",
|
||||||
|
suggestedSynonyms: [],
|
||||||
|
suggestedUtterances: ["jusqu'à ce que ce soit doré"],
|
||||||
|
sourceType: "llm_audit",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("posts nothing when the LLM agrees with the anchor or has no opinion", async () => {
|
||||||
|
const calls = stubFetch({
|
||||||
|
"audit-batch": [
|
||||||
|
{
|
||||||
|
stepId: 1,
|
||||||
|
recipeId: 1,
|
||||||
|
clauseText: "agrees",
|
||||||
|
anchorKey: "cook",
|
||||||
|
intentKey: "cook",
|
||||||
|
score: 0.5,
|
||||||
|
locale: "fr",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
stepId: 2,
|
||||||
|
recipeId: 1,
|
||||||
|
clauseText: "no opinion",
|
||||||
|
anchorKey: "boil",
|
||||||
|
intentKey: null,
|
||||||
|
score: 0.4,
|
||||||
|
locale: "fr",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const llm = {
|
||||||
|
judgeClause: async (text: string) => (text === "agrees" ? "cook" : null),
|
||||||
|
};
|
||||||
|
|
||||||
|
const count = await runAuditLowConfidenceJob(llm, { locale: "fr", limit: 10 });
|
||||||
|
|
||||||
|
expect(count).to.equal(0);
|
||||||
|
expect(calls.some((call) => call.url.includes("training-suggestions"))).to.equal(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes locale/limit through to GET /internal/tech-steps/audit-batch", async () => {
|
||||||
|
const calls = stubFetch({ "audit-batch": [] });
|
||||||
|
const llm = { judgeClause: async () => null };
|
||||||
|
|
||||||
|
await runAuditLowConfidenceJob(llm, { locale: "en", limit: 7 });
|
||||||
|
|
||||||
|
const getCall = calls.find((call) => call.url.includes("audit-batch"));
|
||||||
|
if (!getCall) throw new Error("expected a GET to audit-batch");
|
||||||
|
expect(getCall.url).to.include("locale=en");
|
||||||
|
expect(getCall.url).to.include("limit=7");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,123 @@
|
||||||
|
import { expect } from "chai";
|
||||||
|
import { runTransformCorrectionsJob } from "../../src/jobs/transform-corrections.js";
|
||||||
|
|
||||||
|
/** Same stubbing approach as `audit-low-confidence.test.ts` — see that file's own doc comment. */
|
||||||
|
function stubFetch(responses: Record<string, unknown>): { url: string; body: unknown }[] {
|
||||||
|
const calls: { url: string; body: unknown }[] = [];
|
||||||
|
globalThis.fetch = (async (url: string | URL, init?: RequestInit) => {
|
||||||
|
const href = String(url);
|
||||||
|
const body = typeof init?.body === "string" ? JSON.parse(init.body) : undefined;
|
||||||
|
calls.push({ url: href, body });
|
||||||
|
for (const [pathFragment, response] of Object.entries(responses)) {
|
||||||
|
if (href.includes(pathFragment)) {
|
||||||
|
return new Response(JSON.stringify(response), { status: 200 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(`stubFetch: no response configured for ${href}`);
|
||||||
|
}) as typeof fetch;
|
||||||
|
return calls;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("runTransformCorrectionsJob", () => {
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("submits a suggestion for each correction the LLM produces usable output for", async () => {
|
||||||
|
const calls = stubFetch({
|
||||||
|
"pending-corrections": [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
stepId: 1,
|
||||||
|
recipeId: 1,
|
||||||
|
clauseText: "faites-les revenir",
|
||||||
|
start: 0,
|
||||||
|
end: 19,
|
||||||
|
previousTechStepKey: null,
|
||||||
|
correctedTechStepKey: "brown",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"training-suggestions": { created: 1 },
|
||||||
|
});
|
||||||
|
const llm = {
|
||||||
|
suggestTrainingData: async () => ({
|
||||||
|
suggestedSynonyms: ["revenir"],
|
||||||
|
suggestedUtterances: ["faites-les revenir cinq minutes"],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const count = await runTransformCorrectionsJob(llm, { limit: 10 });
|
||||||
|
|
||||||
|
expect(count).to.equal(1);
|
||||||
|
const postCall = calls.find((call) => call.url.includes("training-suggestions"));
|
||||||
|
if (!postCall) throw new Error("expected a POST to training-suggestions");
|
||||||
|
expect(postCall.body).to.deep.equal({
|
||||||
|
suggestions: [
|
||||||
|
{
|
||||||
|
techStepKey: "brown",
|
||||||
|
locale: "fr",
|
||||||
|
suggestedSynonyms: ["revenir"],
|
||||||
|
suggestedUtterances: ["faites-les revenir cinq minutes"],
|
||||||
|
sourceType: "correction",
|
||||||
|
sourceCorrectionId: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("posts nothing when the LLM's suggestion is empty on both arrays", async () => {
|
||||||
|
const calls = stubFetch({
|
||||||
|
"pending-corrections": [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
stepId: 1,
|
||||||
|
recipeId: 1,
|
||||||
|
clauseText: "x",
|
||||||
|
start: 0,
|
||||||
|
end: 1,
|
||||||
|
previousTechStepKey: null,
|
||||||
|
correctedTechStepKey: "simmer",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const llm = {
|
||||||
|
suggestTrainingData: async () => ({ suggestedSynonyms: [], suggestedUtterances: [] }),
|
||||||
|
};
|
||||||
|
|
||||||
|
const count = await runTransformCorrectionsJob(llm, { limit: 10 });
|
||||||
|
|
||||||
|
expect(count).to.equal(0);
|
||||||
|
expect(calls.some((call) => call.url.includes("training-suggestions"))).to.equal(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips a correction with no corrected technique (defensive backstop — the API itself never returns one)", async () => {
|
||||||
|
stubFetch({
|
||||||
|
"pending-corrections": [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
stepId: 1,
|
||||||
|
recipeId: 1,
|
||||||
|
clauseText: "x",
|
||||||
|
start: 0,
|
||||||
|
end: 1,
|
||||||
|
previousTechStepKey: "cook",
|
||||||
|
correctedTechStepKey: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
let called = false;
|
||||||
|
const llm = {
|
||||||
|
suggestTrainingData: async () => {
|
||||||
|
called = true;
|
||||||
|
return { suggestedSynonyms: [], suggestedUtterances: [] };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const count = await runTransformCorrectionsJob(llm, { limit: 10 });
|
||||||
|
|
||||||
|
expect(count).to.equal(0);
|
||||||
|
expect(called).to.equal(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
11
services/tech-step-llm-worker/tsconfig.json
Normal file
11
services/tech-step-llm-worker/tsconfig.json
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue