Remplace TechStepClassifierService's node-nlp (NlpManager) par services/tech-step-intent-service, un microservice FastAPI/spaCy dedie (PhraseMatcher pour le NER par synonymes, textcat pour la classification d'intention). Corpus (TECH_STEP_TRAINING_DATA) toujours possede par apps/api, pousse au service via POST /v1/train a chaque warm-up ; le service ne touche jamais Postgres (meme posture que services/tech-step-llm-worker). Cote apps/api : - intent-service-client.ts : client HTTP vers le nouveau service - tech-step-matcher.ts : delegue NER + intent classification au client, logique pure (splitIntoClauses, seuil/fallback) inchangee - env.ts : INTENT_SERVICE_BASE_URL/INTENT_SERVICE_SECRET (secret requis, service coeur non optionnel) - server.ts : warm-up avec retry/backoff (service Python demarre a part) - scripts/calibrate-tech-step-threshold.ts : recalibration empirique de CONFIDENCE_THRESHOLD contre le jeu d'eval existant - node-nlp retire (package.json, node-nlp.d.ts, model.nlp du .gitignore) docker-compose.yml : nouveau service tech-step-intent-service (pas de port expose, healthcheck, app en depend). CI : job intent-service-test (pytest) + le job test demarre le service en arriere-plan avant la suite Mocha (jamais de mock d'un service interne, cf specs/dev-conventions.md). Verifie : 26/26 tests pytest du service (dont les offsets caracteres exacts de tech-step-matcher.test.ts), lint + build complets du monorepo, smoke test HTTP reel bout en bout. La suite Mocha et docker compose build/up n'ont pas pu etre executes dans cet environnement (pas de Postgres/Docker disponibles ici) — a confirmer via la CI et en local. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
101 lines
4.4 KiB
TypeScript
101 lines
4.4 KiB
TypeScript
import { prisma } from "../db/prisma.js";
|
|
import { TECH_STEP_EVAL_DATASET } from "../lib/recipe-matching/tech-step-eval-dataset.js";
|
|
import {
|
|
computeTechStepMetrics,
|
|
type TechStepEvalOutcome,
|
|
} from "../lib/recipe-matching/tech-step-evaluator.js";
|
|
import { techStepClassifier } from "../lib/recipe-matching/tech-step-matcher.js";
|
|
|
|
/**
|
|
* Candidate thresholds to sweep, `0.05` to `0.95` in `0.05` steps — fine
|
|
* enough to find a good value without an unreasonable number of full
|
|
* `TECH_STEP_EVAL_DATASET` passes (each threshold only needs one
|
|
* {@link techStepClassifier.classifyClauses} call per eval case, not a
|
|
* retrain — see this file's own doc comment for why).
|
|
*/
|
|
const CANDIDATE_THRESHOLDS = Array.from({ length: 19 }, (_, i) => Math.round((i + 1) * 5) / 100);
|
|
|
|
/**
|
|
* One-off maintainer tool for recalibrating `CONFIDENCE_THRESHOLD`
|
|
* (`tech-step-matcher.ts`) after a change to the underlying intent
|
|
* classifier — most notably, the migration from `node-nlp` to
|
|
* `services/tech-step-intent-service` (spaCy): a different model produces a
|
|
* differently-shaped confidence score distribution, so a threshold tuned
|
|
* against the old classifier has no reason to still be the right cutoff for
|
|
* the new one.
|
|
*
|
|
* Reuses `techStepClassifier.classifyClauses` — already public, and
|
|
* deliberately *not* threshold-applied (see that method's own doc comment)
|
|
* — to get every eval case's raw `{anchorUid, intentUid, score}` per clause
|
|
* exactly once, then replays `_classifyClause`'s own decision rule
|
|
* (`intentUid` if confident enough, `anchorUid` otherwise) locally in this
|
|
* script for every candidate threshold. This is what makes a full sweep
|
|
* cheap: one classifier pass per eval case regardless of how many
|
|
* thresholds are being compared, rather than one full pass *per threshold*.
|
|
*
|
|
* Prints a threshold -> precision/recall/F1 table and the threshold that
|
|
* maximizes aggregate F1 — does **not** edit `tech-step-matcher.ts` itself.
|
|
* A maintainer reads the table, updates `CONFIDENCE_THRESHOLD` by hand (with
|
|
* an updated doc comment recording what run/F1 the new value was calibrated
|
|
* against, same as the existing comment's own format), then re-runs
|
|
* `retrain-tech-steps.ts` to confirm the change clears `MIN_OVERALL_F1`.
|
|
*
|
|
* Usage:
|
|
*
|
|
* pnpm --filter api exec tsx src/scripts/calibrate-tech-step-threshold.ts
|
|
*/
|
|
async function calibrateTechStepThreshold(): Promise<void> {
|
|
console.info(`Classifying ${TECH_STEP_EVAL_DATASET.length} eval case(s)...`);
|
|
|
|
// One classifier pass per eval case, all clauses' raw verdicts kept
|
|
// alongside the case's own `expectedKeys` — reused for every candidate
|
|
// threshold in the loop below.
|
|
const casesWithClauses = await Promise.all(
|
|
TECH_STEP_EVAL_DATASET.map(async (evalCase) => ({
|
|
expectedKeys: evalCase.expectedKeys,
|
|
clauses: await techStepClassifier.classifyClauses(evalCase.description, evalCase.locale),
|
|
})),
|
|
);
|
|
|
|
console.info("\nthreshold precision recall f1");
|
|
let bestThreshold = CANDIDATE_THRESHOLDS[0] ?? 0;
|
|
let bestF1 = -1;
|
|
|
|
for (const threshold of CANDIDATE_THRESHOLDS) {
|
|
const outcomes: TechStepEvalOutcome[] = casesWithClauses.map(({ expectedKeys, clauses }) => {
|
|
const actualKeys = clauses
|
|
// Mirrors `_classifyClause`'s own decision rule exactly (see that
|
|
// method, `tech-step-matcher.ts`) — the classifier's own verdict
|
|
// when confident enough, otherwise its clause's NER anchor, `null`
|
|
// when neither applies (no keyword, no confident classification).
|
|
.map((clause) =>
|
|
clause.intentUid !== null && clause.score >= threshold
|
|
? clause.intentUid
|
|
: clause.anchorUid,
|
|
)
|
|
.filter((key): key is string => key !== null);
|
|
return { expectedKeys, actualKeys };
|
|
});
|
|
|
|
const { overall } = computeTechStepMetrics(outcomes);
|
|
console.info(
|
|
`${threshold.toFixed(2)} ${overall.precision.toFixed(3)} ${overall.recall.toFixed(3)} ${overall.f1.toFixed(3)}`,
|
|
);
|
|
if (overall.f1 > bestF1) {
|
|
bestF1 = overall.f1;
|
|
bestThreshold = threshold;
|
|
}
|
|
}
|
|
|
|
console.info(
|
|
`\nBest aggregate F1 ${bestF1.toFixed(3)} at threshold ${bestThreshold.toFixed(2)} — update CONFIDENCE_THRESHOLD in tech-step-matcher.ts by hand if this differs from the current value.`,
|
|
);
|
|
}
|
|
|
|
calibrateTechStepThreshold()
|
|
.then(() => prisma.$disconnect())
|
|
.catch(async (err) => {
|
|
console.error(err);
|
|
await prisma.$disconnect();
|
|
process.exit(1);
|
|
});
|