3e type de metadonnee de clause, a cote des ingredients et des ustensiles : temperature en °C (« 180°C », « 200 degres », °F converti), numero de thermostat (« th. 6 ») et intensite qualitative (« feu doux/moyen/vif » -> low/medium/high). services/tech-step-intent-service : - `temperature_extraction.py` : `extract_temperatures(text, locale)` pur, a base de regex (independant du modele spaCy et de l'entrainement). - `ProcessResult` / `ProcessResponse` gagnent `temperatures` (ajout additif au contrat ; cle `gas_mark` en snake_case sur le fil). `process()` les extrait meme pour une locale pas encore entrainee. - Tests `test_temperature_extraction.py` (10) ; `test_routes_process.py` : 2 assertions d'egalite stricte gagnent `"temperatures": []`, +1 cas. apps/api : - `IntentServiceTemperature` (mappe `gas_mark` -> `gasMark` a la frontiere). - `TechStepMatch.temperatures` : filtrees par appartenance de span a la clause, meme regle que les ustensiles. - `model StepTechStepTemperature` (aucune FK — la valeur structuree EST la donnee) + migration manuelle `20260829120000_step_tech_step_temperature` (cascade via le TRUNCATE de `step_tech_step`, rien a ajouter a reset-db.ts). Persistance + lecture dans `recipe.service.ts` (`recipeInclude`, `toStepTechStepViews`) et l'include de sequence fraiche du service de correction ; `sources.service.ts` (apercu d'import) les fait transiter. packages/shared : `StepTechStepTemperatureView` + `temperatures` sur `StepTechStepView`. apps/web : - `splitDescriptionSegments` enrobe `splitDescriptionByTechSteps` et redecoupe les segments non-keyword autour des spans de temperature (la logique technique intriquee reste intacte). `?? []` tolere une payload d'avant `temperatures`. - `StepDescription` : surlignage `.step-temperature` + Tooltip via `temperatureLabel` ; i18n `recipes.temperature.*`. - Tests composants +4 (22 verts) ; pas d'UI de correction (v1, comme les ustensiles). Verifie : biome + tsc + `pnpm -r build` ; web 102/103 e2e (l'echec est le flake pre-existant recipe-form.feature, sans rapport) + 49/49 composants ; pytest temperature + routes 15/15. `pnpm --filter api test` (Postgres + intent-service requis) non lance ici. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
135 lines
5.9 KiB
TypeScript
135 lines
5.9 KiB
TypeScript
import { env } from "../../config/env.js";
|
|
|
|
/**
|
|
* Thin fetch wrapper around `services/tech-step-intent-service`'s HTTP
|
|
* contract (`POST /v1/process`) — the microservice
|
|
* {@link TechStepClassifierService} (`tech-step-matcher.ts`) delegates NER +
|
|
* intent classification to, in place of the `node-nlp` `NlpManager` it used
|
|
* to own directly. See that service's own README for the full contract and
|
|
* why it never touches Postgres itself — it also owns its own training
|
|
* corpus now (`training_data.py`), trained once at its own startup, so
|
|
* `apps/api` never pushes anything to it; `process()` below is this
|
|
* client's only method.
|
|
*
|
|
* Authenticated with `INTENT_SERVICE_SECRET` — the inverse direction of
|
|
* `requireInternalWorker`'s `INTERNAL_WORKER_SECRET` (this time `apps/api`
|
|
* is the caller, not the callee), but the same "one flat shared secret"
|
|
* shape.
|
|
*/
|
|
|
|
/** One candidate mention one of the service's two `PhraseMatcher`s found — offsets `[start, end)`, same convention as `String.prototype.slice`. Mirrors `EntityPayload` (Python `schemas.py`). `kind` distinguishes a technique mention (`self._matcher`, the corpus-trained one) from a utensil mention (`self._utensil_matcher`, static — see `utensil_vocabulary.py`) — `tech-step-matcher.ts` resolves each against a different catalog (`TechStep`/`Utensil`). */
|
|
export interface IntentServiceEntity {
|
|
uid: string;
|
|
start: number;
|
|
end: number;
|
|
kind: "technique" | "utensil";
|
|
}
|
|
|
|
/**
|
|
* A temperature the service recognized in the text — mirrors
|
|
* `TemperaturePayload` (Python `schemas.py`), `gas_mark` camelCased to
|
|
* `gasMark`. A separate list from {@link IntentServiceEntity} (not another
|
|
* `kind`) because it carries structured fields that don't fit an entity's
|
|
* `uid`. At least one of `celsius` / `gasMark` / `qualitative` is non-null.
|
|
*/
|
|
export interface IntentServiceTemperature {
|
|
start: number;
|
|
end: number;
|
|
celsius: number | null;
|
|
gasMark: number | null;
|
|
qualitative: "low" | "medium" | "high" | null;
|
|
raw: string;
|
|
}
|
|
|
|
/** The full result of a `POST /v1/process` call — mirrors `ProcessResponse` (Python `schemas.py`). `intent` is `null` only when `locale` isn't one this service trains for, or `text` is blank; otherwise always a real `uid` (the Python service's `textcat` has no "None" sentinel, unlike node-nlp — see that service's README). `temperatures` is regex-extracted and independent of the model/training (empty when none, not omitted). */
|
|
export interface IntentServiceProcessResult {
|
|
entities: IntentServiceEntity[];
|
|
intent: string | null;
|
|
score: number;
|
|
temperatures: IntentServiceTemperature[];
|
|
}
|
|
|
|
/**
|
|
* Client for `services/tech-step-intent-service` — a real class (not a
|
|
* plain object of functions) per this repo's service-style-logic
|
|
* convention, even though it holds no state of its own: it's used as the
|
|
* one shared {@link intentServiceClient} singleton below, same reasoning as
|
|
* `TechStepClassifierService` itself.
|
|
*/
|
|
export class IntentServiceClient {
|
|
/**
|
|
* Performs a JSON request against the intent service and returns the
|
|
* parsed body.
|
|
*
|
|
* @throws {Error} if the response status is not in the 2xx range, or the
|
|
* request itself fails (network error, service down) — left as a plain
|
|
* `Error` rather than a typed `HttpError`: this is an internal
|
|
* service-to-service call, not a request `apps/api`'s own HTTP layer
|
|
* needs to map to a client-facing status code (see
|
|
* `TechStepClassifierService.warmUp`'s retry in `server.ts` for how a
|
|
* failure here is actually handled).
|
|
*/
|
|
private async _request<TResponseBody>(
|
|
path: string,
|
|
init: RequestInit = {},
|
|
): Promise<TResponseBody> {
|
|
try {
|
|
const response = await fetch(`${env.INTENT_SERVICE_BASE_URL}${path}`, {
|
|
...init,
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
"X-Intent-Service-Secret": env.INTENT_SERVICE_SECRET,
|
|
...init.headers,
|
|
},
|
|
});
|
|
if (!response.ok) {
|
|
const body = await response.text().catch(() => "");
|
|
throw new Error(`${init.method ?? "GET"} ${path} failed: ${response.status} ${body}`);
|
|
}
|
|
return (await response.json()) as TResponseBody;
|
|
} catch (err) {
|
|
// Rethrown as-is — every caller (`TechStepClassifierService`) already
|
|
// wraps its own `await`s per the repo's try/catch convention; this is
|
|
// just where the `await` itself has to sit inside one.
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Equivalent to the old `NlpManager.process(locale, text)` — returns every
|
|
* candidate technique mention (NER) plus the intent classifier's verdict
|
|
* for `text` as a whole, whether `text` is a full step description or a
|
|
* single clause `TechStepClassifierService` already cut out of one (this
|
|
* service doesn't know or care which, exactly like `NlpManager` before
|
|
* it).
|
|
*/
|
|
public async process(locale: string, text: string): Promise<IntentServiceProcessResult> {
|
|
try {
|
|
// The wire shape uses `gas_mark` (Python field name); everything else
|
|
// is already camelCase / single-word. Map that one key at the
|
|
// boundary so the rest of `apps/api` only ever sees `gasMark`.
|
|
const wire = await this._request<
|
|
Omit<IntentServiceProcessResult, "temperatures"> & {
|
|
temperatures: (Omit<IntentServiceTemperature, "gasMark"> & { gas_mark: number | null })[];
|
|
}
|
|
>("/v1/process", {
|
|
method: "POST",
|
|
body: JSON.stringify({ locale, text }),
|
|
});
|
|
return {
|
|
entities: wire.entities,
|
|
intent: wire.intent,
|
|
score: wire.score,
|
|
temperatures: (wire.temperatures ?? []).map(({ gas_mark, ...rest }) => ({
|
|
...rest,
|
|
gasMark: gas_mark,
|
|
})),
|
|
};
|
|
} catch (err) {
|
|
throw err;
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Single shared instance — stateless, no reason for more than one (same reasoning as `techStepClassifier`/`prisma`). */
|
|
export const intentServiceClient = new IntentServiceClient();
|