batchCooking/services/tech-step-llm-worker/src/api-client.ts
Nicolas b61636f73d feat(admin): monitoring des microservices + heartbeat du worker LLM
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>
2026-08-28 19:05:41 +02:00

135 lines
5.4 KiB
TypeScript

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}`);
}
/** Payload of `POST /internal/tech-steps/heartbeat` — mirrors `workerHeartbeatSchema` (`packages/shared`), duplicated here for the same "outside the workspace" reason as {@link AuditClause}. */
export interface HeartbeatPayload {
event: "boot" | "tick" | "job";
job?: string;
ok?: boolean;
counts?: Record<string, number>;
}
/**
* Best-effort liveness ping to `apps/api` so the admin monitoring board can
* see this worker (which has no inbound HTTP surface of its own). Sent on
* boot, on every scheduler tick, and after each job. **Never throws** — a
* failed heartbeat must never break or abort a run; it's logged and
* swallowed here.
*/
export async function postHeartbeat(payload: HeartbeatPayload): Promise<void> {
try {
await request("/internal/tech-steps/heartbeat", {
method: "POST",
body: JSON.stringify(payload),
});
} catch (err) {
console.warn(
"[tech-step-llm-worker] heartbeat failed:",
err instanceof Error ? err.message : err,
);
}
}
/** 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 }),
});
}