PR 4 du chantier admin. Board de sante temps reel des dependances.
- POST /internal/tech-steps/heartbeat (requireInternalWorker) ->
recordWorkerHeartbeat : upsert WorkerHeartbeat (cle fixe
"tech-step-llm-worker"), lastRunAt/lastResult pour un ping "job".
Schema workerHeartbeatSchema dans packages/shared.
- services/tech-step-llm-worker : api-client.postHeartbeat (best-effort,
ne throw jamais) appele au boot (index.ts), a chaque tick et apres
chaque job (scheduler.ts, avec job/ok/counts).
- admin-monitoring.service.ts + GET /admin/monitoring (requireAdmin) :
sonde active bornee (~2 s) de Postgres (SELECT 1), l'API (uptime/RSS),
tech-step-intent-service (/health), et le worker via son heartbeat.
Statut up/degraded/down/unknown ; une sonde down ne casse ni les autres
ni l'endpoint. Seuils worker : > 8 j degraded, > 21 j down.
- MonitoringView / ServiceHealthView dans packages/shared.
- Front : MonitoringPage (grille de cartes coloree par statut, re-poll
15 s), logique pure monitoring.ts, i18n admin.monitoring.*,
AdminApiClient.getMonitoring.
- Tests : Mocha admin-monitoring.test.ts (heartbeat 401/400/upsert
job+boot ; GET /admin/monitoring 401, board 4 cibles, worker unknown
sans heartbeat puis up apres) ; Cypress monitoring.cy.ts (2 verts).
Worker mocha : 6/6 toujours verts.
- specs/backend-architecture.md : section monitoring. .gitignore :
apps/admin-web/cypress/{screenshots,videos,downloads}.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
244 lines
9.4 KiB
TypeScript
244 lines
9.4 KiB
TypeScript
import { HttpError } from "@batch-cooking/error-tools";
|
|
import {
|
|
ErrorCode,
|
|
type PendingTechStepCorrectionView,
|
|
type SubmitTrainingSuggestionsInput,
|
|
type TechStepAuditClauseView,
|
|
type WorkerHeartbeatInput,
|
|
} from "@batch-cooking/shared";
|
|
import type { Prisma } from "@prisma/client";
|
|
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
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The one worker with a `WorkerHeartbeat` row today — a fixed key, not
|
|
* something the caller supplies (only one worker exists, and letting it
|
|
* name itself would just be a spoofing surface behind the same shared
|
|
* secret).
|
|
*/
|
|
const WORKER_KEY = "tech-step-llm-worker";
|
|
|
|
/**
|
|
* Upserts `services/tech-step-llm-worker`'s heartbeat row (see
|
|
* `POST /internal/tech-steps/heartbeat`). Every ping bumps `lastSeenAt`; a
|
|
* `"job"` ping also records `lastRunAt` + a small `lastResult` summary so
|
|
* the admin monitoring board can show what the worker last did and whether
|
|
* it worked.
|
|
*/
|
|
export async function recordWorkerHeartbeat(input: WorkerHeartbeatInput): Promise<void> {
|
|
try {
|
|
const now = new Date();
|
|
const jobResult: Prisma.InputJsonValue | undefined =
|
|
input.event === "job"
|
|
? { job: input.job ?? null, ok: input.ok ?? null, counts: input.counts ?? {} }
|
|
: undefined;
|
|
|
|
await prisma.workerHeartbeat.upsert({
|
|
where: { workerKey: WORKER_KEY },
|
|
create: {
|
|
workerKey: WORKER_KEY,
|
|
lastSeenAt: now,
|
|
lastRunAt: input.event === "job" ? now : null,
|
|
lastResult: jobResult,
|
|
},
|
|
update: {
|
|
lastSeenAt: now,
|
|
...(input.event === "job" ? { lastRunAt: now, lastResult: jobResult } : {}),
|
|
},
|
|
});
|
|
} catch (err) {
|
|
throw err; // see recipe.service.ts's equivalent catch comment
|
|
}
|
|
}
|