refactor(workflow): contract shared core (#33)
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
// artifact-contracts.js -- PURE soft-validators for the v2 gate payloads (WS2).
|
||||
//
|
||||
// No I/O, no TypeBox: hand-rolled, permissive on extra fields. Each validator
|
||||
// returns { ok: boolean, errors: string[] } with actionable messages so the model
|
||||
// can self-correct (the gate returns them as a textResult, never a hard TypeBox
|
||||
// failure -- models loop when validation fails BEFORE execute()).
|
||||
//
|
||||
// A payload without `schema_version === 2` is NOT an error: it returns
|
||||
// { ok: true, legacy: true } and the caller proceeds on the legacy path.
|
||||
|
||||
"use strict";
|
||||
|
||||
function isNonEmptyString(v) {
|
||||
return typeof v === "string" && v.length > 0;
|
||||
}
|
||||
|
||||
function isArray(v) {
|
||||
return Array.isArray(v);
|
||||
}
|
||||
|
||||
const VALID_CHECK_STATUS = new Set(["ok", "warn", "fail"]);
|
||||
|
||||
// Contract A -- cte_plan v2. Validates the model-owned skeleton; catalog-derived
|
||||
// fields (index, table/filter descriptions) are filled later by enrich, so they
|
||||
// are NOT required here.
|
||||
function validateCtePlanV2(data) {
|
||||
if (!data || typeof data !== "object" || data.schema_version !== 2) {
|
||||
return { ok: true, legacy: true };
|
||||
}
|
||||
const errors = [];
|
||||
if (!isArray(data.ctes) || data.ctes.length === 0) {
|
||||
errors.push("ctes deve essere un array non vuoto.");
|
||||
return { ok: false, errors };
|
||||
}
|
||||
const seen = [];
|
||||
data.ctes.forEach((c, i) => {
|
||||
if (!c || typeof c !== "object") {
|
||||
errors.push(`ctes[${i}] deve essere un oggetto.`);
|
||||
return;
|
||||
}
|
||||
if (!isNonEmptyString(c.name)) {
|
||||
errors.push(`ctes[${i}].name mancante o vuoto.`);
|
||||
}
|
||||
if (isArray(c.depends_on)) {
|
||||
for (const dep of c.depends_on) {
|
||||
if (!seen.includes(dep)) {
|
||||
errors.push(
|
||||
`ctes[${i}].depends_on contiene '${dep}' che non è un CTE precedente.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isNonEmptyString(c.name)) seen.push(c.name);
|
||||
});
|
||||
return { ok: errors.length === 0, errors };
|
||||
}
|
||||
|
||||
// Contract B -- cte_result THIN (what the model sends). The gate builds the full
|
||||
// payload from `tht cte info`; the model only supplies {purpose?, rationale?, note?}.
|
||||
// Everything is optional, so a thin v2 artifact is valid as long as it declares v2.
|
||||
function validateCteResultThin(data) {
|
||||
if (!data || typeof data !== "object" || data.schema_version !== 2) {
|
||||
return { ok: true, legacy: true };
|
||||
}
|
||||
const errors = [];
|
||||
for (const field of ["purpose", "rationale", "note"]) {
|
||||
if (data[field] !== undefined && typeof data[field] !== "string") {
|
||||
errors.push(`${field} deve essere una stringa se presente.`);
|
||||
}
|
||||
}
|
||||
return { ok: errors.length === 0, errors };
|
||||
}
|
||||
|
||||
// Contract C -- phase summary v2.
|
||||
function validatePhaseSummaryV2(data) {
|
||||
if (!data || typeof data !== "object" || data.schema_version !== 2) {
|
||||
return { ok: true, legacy: true };
|
||||
}
|
||||
const errors = [];
|
||||
if (!isNonEmptyString(data.summary)) {
|
||||
errors.push("summary mancante o vuoto.");
|
||||
}
|
||||
if (data.checks !== undefined) {
|
||||
if (!isArray(data.checks)) {
|
||||
errors.push("checks deve essere un array se presente.");
|
||||
} else {
|
||||
data.checks.forEach((c, i) => {
|
||||
if (!c || typeof c !== "object" || !isNonEmptyString(c.label)) {
|
||||
errors.push(`checks[${i}].label mancante o vuoto.`);
|
||||
}
|
||||
if (!c || !VALID_CHECK_STATUS.has(c.status)) {
|
||||
errors.push(`checks[${i}].status deve essere uno tra ok|warn|fail.`);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
if (data.sections !== undefined) {
|
||||
if (!isArray(data.sections)) {
|
||||
errors.push("sections deve essere un array se presente.");
|
||||
} else {
|
||||
data.sections.forEach((s, i) => {
|
||||
if (!s || typeof s !== "object") {
|
||||
errors.push(`sections[${i}] deve essere un oggetto.`);
|
||||
return;
|
||||
}
|
||||
if (!isNonEmptyString(s.title)) {
|
||||
errors.push(`sections[${i}].title mancante o vuoto.`);
|
||||
}
|
||||
if (s.items !== undefined && !isArray(s.items)) {
|
||||
errors.push(`sections[${i}].items deve essere un array se presente.`);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
if (data.tables !== undefined && !isArray(data.tables)) {
|
||||
errors.push("tables deve essere un array se presente.");
|
||||
}
|
||||
if (data.open_questions !== undefined) {
|
||||
if (!isArray(data.open_questions)) {
|
||||
errors.push("open_questions deve essere un array di stringhe se presente.");
|
||||
} else {
|
||||
data.open_questions.forEach((question, i) => {
|
||||
if (typeof question !== "string") {
|
||||
errors.push(`open_questions[${i}] deve essere una stringa.`);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
return { ok: errors.length === 0, errors };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
validateCtePlanV2,
|
||||
validateCteResultThin,
|
||||
validatePhaseSummaryV2,
|
||||
};
|
||||
@@ -0,0 +1,219 @@
|
||||
// Pure widget-descriptor builders (spec D2/D4, §4.1).
|
||||
//
|
||||
// Each function turns plain params into a ui_request descriptor object. No Pi
|
||||
// context, no I/O -- this is the part of the gate that is fully testable in L1
|
||||
// (in JS, in-language, no Python mirror). The glue (emission via ctx.sendRaw,
|
||||
// anti-bypass, the no-limbo loop) is in tht-gate.js and is verified at L2.
|
||||
//
|
||||
// The 6 widget kinds: info, select, multiselect, freetext, artifact-gate, artifact.
|
||||
// `widget` is an open field (a new kind needs a renderer, not infra changes);
|
||||
// these are the initial 6. reserved = framework escape hatches always present on
|
||||
// blocking widgets: back / exit / other.
|
||||
|
||||
"use strict";
|
||||
|
||||
const SCHEMA_VERSION = 1;
|
||||
const RESERVED = ["back", "exit", "other"];
|
||||
|
||||
const VALID_ACTION_KINDS = new Set(["confirm", "approve_reject", "view_only"]);
|
||||
const VALID_INFO_LEVELS = new Set(["info", "warning", "error"]);
|
||||
|
||||
function requireString(value, name, ctx) {
|
||||
if (typeof value !== "string" || value.length === 0) {
|
||||
throw new Error(`builders: ${ctx} requires a non-empty ${name}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireArray(value, name, ctx) {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new Error(`builders: ${ctx} requires ${name} to be an array`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
// Build a `select` ui_request (single-pick). The framework escape hatches
|
||||
// (Back/Exit/Other) come from `reserved` (rendered by ReservedControls, which owns
|
||||
// the free-text linkage on "Other"). `recommended` is the id of the option to
|
||||
// highlight as "(consigliato)".
|
||||
function buildSelectRequest({
|
||||
id, phase, title, options, intro = null, recommended = null,
|
||||
}) {
|
||||
requireString(title, "title", "select");
|
||||
const opts = requireArray(options, "options", "select");
|
||||
const out = {
|
||||
type: "ui_request",
|
||||
id,
|
||||
phase,
|
||||
schema_version: SCHEMA_VERSION,
|
||||
widget: "select",
|
||||
title,
|
||||
intro,
|
||||
recommended,
|
||||
options: [...opts],
|
||||
reserved: RESERVED,
|
||||
};
|
||||
return out;
|
||||
}
|
||||
|
||||
// Build a `multiselect` ui_request (multi-pick). `content` is the scrollable
|
||||
// context (e.g. schema candidates); `selected` the pre-checked option ids;
|
||||
// `allowEmpty:false` with zero options is a broken widget and throws.
|
||||
function buildMultiselectRequest({
|
||||
id, phase, title, options, content = null, selected = [], allowEmpty = false,
|
||||
selectionLabel, confirmLabel,
|
||||
}) {
|
||||
requireString(title, "title", "multiselect");
|
||||
const opts = requireArray(options, "options", "multiselect");
|
||||
if (!allowEmpty && opts.length === 0) {
|
||||
throw new Error(
|
||||
"builders: multiselect with allow_empty:false requires at least one option",
|
||||
);
|
||||
}
|
||||
return {
|
||||
type: "ui_request",
|
||||
id,
|
||||
phase,
|
||||
schema_version: SCHEMA_VERSION,
|
||||
widget: "multiselect",
|
||||
title,
|
||||
allow_empty: allowEmpty,
|
||||
options: opts.map((option) => ({ ...option, selected: selected.includes(option.id) })),
|
||||
selected: [...selected],
|
||||
content,
|
||||
reserved: RESERVED,
|
||||
...(selectionLabel ? { selection_label: selectionLabel } : {}),
|
||||
...(confirmLabel ? { confirm_label: confirmLabel } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
// Build an `artifact-gate` ui_request: a rendered artifact + a disposition list
|
||||
// (confirm / approve_reject / view_only). The decision is load-bearing on the
|
||||
// document, so the artifact is never shown alone (spec §4.1).
|
||||
function buildArtifactGate({ id, phase, title, artifact, action }) {
|
||||
requireString(title, "title", "artifact-gate");
|
||||
if (!artifact || typeof artifact !== "object") {
|
||||
throw new Error("builders: artifact-gate requires an artifact object");
|
||||
}
|
||||
if (!artifact.kind) {
|
||||
throw new Error("builders: artifact-gate artifact requires a kind");
|
||||
}
|
||||
if (!action || !action.kind || !VALID_ACTION_KINDS.has(action.kind)) {
|
||||
throw new Error(
|
||||
`builders: artifact-gate action.kind must be one of ${[...VALID_ACTION_KINDS].join(", ")}`,
|
||||
);
|
||||
}
|
||||
const ACTION_OPTIONS = {
|
||||
approve_reject: [
|
||||
{ id: "approve", label: "Salva e procedi", recommended: true },
|
||||
{ id: "reject", label: "Rifiuta" },
|
||||
],
|
||||
confirm: [{ id: "approve", label: "Salva e procedi", recommended: true }],
|
||||
view_only: [],
|
||||
};
|
||||
return {
|
||||
type: "ui_request",
|
||||
id,
|
||||
phase,
|
||||
schema_version: SCHEMA_VERSION,
|
||||
widget: "artifact-gate",
|
||||
title,
|
||||
artifact,
|
||||
action,
|
||||
options: ACTION_OPTIONS[action.kind],
|
||||
reserved: RESERVED,
|
||||
};
|
||||
}
|
||||
|
||||
// Build an `info` ui_request: fire-and-forget notification (no response expected,
|
||||
// no reserved hatches, no correlated id).
|
||||
function buildInfoRequest({ phase, level, text }) {
|
||||
if (!level || !VALID_INFO_LEVELS.has(level)) {
|
||||
throw new Error(
|
||||
`builders: info level must be one of ${[...VALID_INFO_LEVELS].join(", ")}`,
|
||||
);
|
||||
}
|
||||
requireString(text, "text", "info");
|
||||
return {
|
||||
type: "ui_request",
|
||||
phase,
|
||||
schema_version: SCHEMA_VERSION,
|
||||
widget: "info",
|
||||
level,
|
||||
text,
|
||||
};
|
||||
}
|
||||
|
||||
// Build a `freetext` ui_request: free-text input, always a child of
|
||||
// select/artifact-gate (via Altro/Rifiuta) or via the ambient steering channel.
|
||||
function buildFreetextRequest({ id, phase, title }) {
|
||||
requireString(title, "title", "freetext");
|
||||
return {
|
||||
type: "ui_request",
|
||||
id,
|
||||
phase,
|
||||
schema_version: SCHEMA_VERSION,
|
||||
widget: "freetext",
|
||||
title,
|
||||
reserved: RESERVED,
|
||||
};
|
||||
}
|
||||
|
||||
// Build a `schema-linking` ui_request: table rows (promote/exclude) each carrying
|
||||
// their full catalog columns; the frontend renders rows + a per-table columns
|
||||
// modal (suggested pre-selected). `tables` is validated shallowly here.
|
||||
function buildSchemaLinkingRequest({ id, phase, title, tables }) {
|
||||
requireString(title, "title", "schema-linking");
|
||||
const tabs = requireArray(tables, "tables", "schema-linking");
|
||||
return {
|
||||
type: "ui_request",
|
||||
id,
|
||||
phase,
|
||||
schema_version: SCHEMA_VERSION,
|
||||
widget: "schema-linking",
|
||||
title,
|
||||
tables: [...tabs],
|
||||
reserved: RESERVED,
|
||||
};
|
||||
}
|
||||
|
||||
// Build a blocking, read-only join review. The reviewer can accept the complete
|
||||
// proposal or use Other to request a textual correction; individual joins are
|
||||
// deliberately not selectable because omitting one could create a Cartesian product.
|
||||
function buildJoinReviewRequest({ id, phase, title, options }) {
|
||||
requireString(title, "title", "join-review");
|
||||
const opts = requireArray(options, "options", "join-review");
|
||||
if (opts.length === 0) {
|
||||
throw new Error("builders: join-review requires at least one option");
|
||||
}
|
||||
return {
|
||||
type: "ui_request",
|
||||
id,
|
||||
phase,
|
||||
schema_version: SCHEMA_VERSION,
|
||||
widget: "join-review",
|
||||
title,
|
||||
options: [...opts],
|
||||
confirm_label: "Continue",
|
||||
reserved: RESERVED,
|
||||
};
|
||||
}
|
||||
|
||||
// Attach a child-widget spec to an option (linkage, §4.2). Returns the option.
|
||||
function withChildLinkage(option, widgetSpec) {
|
||||
option.opens = widgetSpec;
|
||||
return option;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildSelectRequest,
|
||||
buildMultiselectRequest,
|
||||
buildArtifactGate,
|
||||
buildInfoRequest,
|
||||
buildFreetextRequest,
|
||||
buildSchemaLinkingRequest,
|
||||
buildJoinReviewRequest,
|
||||
withChildLinkage,
|
||||
SCHEMA_VERSION,
|
||||
RESERVED,
|
||||
};
|
||||
@@ -0,0 +1,182 @@
|
||||
// enrich.js -- PURE merge/enrichment for the v2 gate payloads (WS2).
|
||||
//
|
||||
// Catalog lookups are INJECTED as `getColumns` params (for testability): a function
|
||||
// (tableName) => { description, columns: [{ name, description, ... }] } | null.
|
||||
// A catalog miss (null) yields empty descriptions, never an error (the hard-fail
|
||||
// stays only in reviewer_schema_linking).
|
||||
//
|
||||
// No dependency on tht-gate.js: importable and testable in isolation.
|
||||
|
||||
"use strict";
|
||||
|
||||
// Memoize a raw catalog lookup so repeated table hits do a single I/O call. The
|
||||
// gate wraps its `tht schema columns <t> --json` (try/catch -> null on miss) and
|
||||
// passes it here; enrich itself stays pure.
|
||||
function memoizeGetColumns(rawGetColumns) {
|
||||
const cache = new Map();
|
||||
return function getColumns(tableName) {
|
||||
if (cache.has(tableName)) return cache.get(tableName);
|
||||
const v = rawGetColumns(tableName);
|
||||
cache.set(tableName, v);
|
||||
return v;
|
||||
};
|
||||
}
|
||||
|
||||
function columnDescription(cat, colName) {
|
||||
if (!cat || !Array.isArray(cat.columns)) return "";
|
||||
const col = cat.columns.find((c) => c.name === colName);
|
||||
return (col && col.description) || "";
|
||||
}
|
||||
|
||||
// Contract A. Fills index (1-based), tables[].description and filters[].description
|
||||
// via getColumns. For filters, `column: "tab.col"` resolves to the column's catalog
|
||||
// description. Returns a NEW object; model-owned fields pass through unchanged.
|
||||
function enrichCtePlanV2(data, getColumns) {
|
||||
const ctes = (data.ctes || []).map((cte, i) => {
|
||||
const out = { ...cte, index: i + 1 };
|
||||
if (Array.isArray(cte.tables)) {
|
||||
out.tables = cte.tables.map((t) => {
|
||||
const cat = getColumns(t.name);
|
||||
return { ...t, description: (cat && cat.description) || "" };
|
||||
});
|
||||
}
|
||||
if (Array.isArray(cte.filters)) {
|
||||
out.filters = cte.filters.map((f) => {
|
||||
const out2 = { ...f };
|
||||
if (typeof f.column === "string" && f.column.includes(".")) {
|
||||
const idx = f.column.indexOf(".");
|
||||
const tableName = f.column.slice(0, idx);
|
||||
const colName = f.column.slice(idx + 1);
|
||||
out2.description = columnDescription(getColumns(tableName), colName);
|
||||
} else {
|
||||
out2.description = "";
|
||||
}
|
||||
return out2;
|
||||
});
|
||||
}
|
||||
return out;
|
||||
});
|
||||
return { ...data, ctes };
|
||||
}
|
||||
|
||||
// Contract B. Fuses the model's thin data with `tht cte info` output.
|
||||
// name/index/total/sql <- cteInfo
|
||||
// purpose/rationale/depends_on <- cteInfo.doc (fallback: thinData)
|
||||
// status/execution_ms/row_sample/warnings/sql_hash/preview <- cteInfo.last_test
|
||||
// note <- thinData
|
||||
// preview = { columns: last_test.columns, rows: last_test.preview_rows }.
|
||||
function buildCteResultV2(thinData, cteInfo) {
|
||||
const thin = thinData || {};
|
||||
const doc = cteInfo.doc || {};
|
||||
const lt = cteInfo.last_test || {};
|
||||
const pick = (docVal, thinVal) => (docVal !== undefined ? docVal : thinVal);
|
||||
return {
|
||||
schema_version: 2,
|
||||
name: cteInfo.name,
|
||||
index: cteInfo.index,
|
||||
total: cteInfo.total,
|
||||
purpose: pick(doc.purpose, thin.purpose),
|
||||
rationale: pick(doc.rationale, thin.rationale),
|
||||
depends_on: pick(doc.depends_on, thin.depends_on),
|
||||
sql: cteInfo.sql,
|
||||
status: lt.status,
|
||||
execution_ms: lt.execution_ms,
|
||||
row_sample: lt.row_sample,
|
||||
warnings: lt.warnings || [],
|
||||
sql_hash: lt.sql_hash,
|
||||
columns: (lt.columns || []).map((name) => ({ name, description: "" })),
|
||||
preview: { columns: lt.columns || [], rows: lt.preview_rows || [] },
|
||||
note: thin.note,
|
||||
};
|
||||
}
|
||||
|
||||
// Contract C. Fills `phase` from phaseMeta and the `description` fields in
|
||||
// tables[]/tables[].columns[]/sections[].items[] via getColumns. phaseMeta is
|
||||
// { id, num, name }. Returns a NEW object.
|
||||
function enrichPhaseSummaryV2(data, phaseMeta, getColumns) {
|
||||
const out = { ...data, phase: phaseMeta };
|
||||
if (Array.isArray(data.tables)) {
|
||||
out.tables = data.tables.map((t) => {
|
||||
const cat = getColumns(t.name);
|
||||
const enriched = { ...t, description: (cat && cat.description) || "" };
|
||||
if (Array.isArray(t.columns)) {
|
||||
enriched.columns = t.columns.map((c) => ({
|
||||
...c,
|
||||
description: columnDescription(cat, c.name),
|
||||
}));
|
||||
}
|
||||
return enriched;
|
||||
});
|
||||
}
|
||||
if (Array.isArray(data.sections)) {
|
||||
out.sections = data.sections.map((s) => {
|
||||
if (!Array.isArray(s.items)) return { ...s };
|
||||
return {
|
||||
...s,
|
||||
items: s.items.map((it) => {
|
||||
// An item's description resolves against its declared table+column.
|
||||
let desc = "";
|
||||
if (typeof it.table === "string" && typeof it.column === "string") {
|
||||
desc = columnDescription(getColumns(it.table), it.column);
|
||||
} else if (typeof it.table === "string") {
|
||||
const cat = getColumns(it.table);
|
||||
desc = (cat && cat.description) || "";
|
||||
}
|
||||
return { ...it, description: desc };
|
||||
}),
|
||||
};
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Fills columns[].description of a built cte_result payload by matching each column
|
||||
// name against the catalog of the plan's tables (`planTables`: string[]). First
|
||||
// table whose catalog carries the column wins; unresolved -> empty description.
|
||||
// Mutates a copy of payload.columns; returns the payload.
|
||||
function enrichCteResultColumns(payload, planTables, getColumns) {
|
||||
if (!Array.isArray(payload.columns)) return payload;
|
||||
const tables = Array.isArray(planTables) ? planTables : [];
|
||||
const columns = payload.columns.map((col) => {
|
||||
let desc = "";
|
||||
for (const t of tables) {
|
||||
const d = columnDescription(getColumns(t), col.name);
|
||||
if (d) {
|
||||
desc = d;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return { ...col, description: desc };
|
||||
});
|
||||
return { ...payload, columns };
|
||||
}
|
||||
|
||||
// Contract C bis. Appends a deterministic "decisions of this phase" section built
|
||||
// from the session ledger, so the model's recap can stay thin (Discipline 6 exactness
|
||||
// comes from persisted state, not model prose). `decisions` is session-show's ledger
|
||||
// dump; `emits` is the phase's substantive decision-type list from workflow.yaml.
|
||||
// No matching decisions -> data returned unchanged. Pure: returns a NEW object.
|
||||
function appendLedgerSection(data, decisions, emits) {
|
||||
const emitSet = new Set(emits || []);
|
||||
const rows = (decisions || []).filter((d) => d && emitSet.has(d.type));
|
||||
if (rows.length === 0) return data;
|
||||
const section = {
|
||||
title: "Decisioni registrate in questa fase (dal ledger)",
|
||||
items: rows.map((d) => ({
|
||||
label: `${d.type}: ${d.subject}`,
|
||||
value: d.detail || "",
|
||||
kind: "decision",
|
||||
rationale: d.rationale || "",
|
||||
})),
|
||||
};
|
||||
return { ...data, sections: [...(data.sections || []), section] };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
memoizeGetColumns,
|
||||
enrichCtePlanV2,
|
||||
buildCteResultV2,
|
||||
enrichCteResultColumns,
|
||||
enrichPhaseSummaryV2,
|
||||
appendLedgerSection,
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
// Etichette riservate del gate HITL e helper di deduplica. Modulo PURO (nessuna
|
||||
// dipendenza da pi-tui): condiviso da tht-gate.js e testabile in isolamento con
|
||||
// node --test .pi/extensions/test_reserved_labels.mjs
|
||||
// `Altro — specifica…` e le due vie di fuga (torna indietro / esci) vengono
|
||||
// SEMPRE aggiunte dal gate alle opzioni in arrivo: se il modello ri-propone uno
|
||||
// step con queste etichette gia' presenti, stripReserved evita il duplicato.
|
||||
|
||||
export const ALTRO = "Altro — specifica…";
|
||||
export const QUIT_LABEL = "Esci da Pi (/quit)";
|
||||
export const BACK_LABEL = "Torna indietro (fase precedente)";
|
||||
export const CONTROL_LABELS = new Set([QUIT_LABEL, BACK_LABEL]);
|
||||
|
||||
// Normalize a label to lowercase ASCII tokens: strip diacritics, turn every run
|
||||
// of punctuation/space into a single space, trim. "Altro — specifica…" -> "altro specifica".
|
||||
function normalize(label) {
|
||||
return String(label)
|
||||
.normalize("NFD")
|
||||
.replace(/[̀-ͯ]/g, "")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
const NORM_QUIT = normalize(QUIT_LABEL);
|
||||
const NORM_BACK = normalize(BACK_LABEL);
|
||||
|
||||
// true if the label is one the gate adds itself. Robust to the variants models
|
||||
// emit ("Altro - specificare", "altro", English "Other — specify"): reserved when
|
||||
// the FIRST normalized token is exactly "altro"/"other", or the whole normalized
|
||||
// label equals the canonical quit/back labels. First-token match keeps real
|
||||
// options like "altrove" out of the reserved set.
|
||||
export function isReserved(label) {
|
||||
const n = normalize(label);
|
||||
if (!n) return false;
|
||||
const first = n.split(" ")[0];
|
||||
return first === "altro" || first === "other" || n === NORM_QUIT || n === NORM_BACK;
|
||||
}
|
||||
|
||||
// removes every reserved entry from a list of labels, preserving order and normal
|
||||
// entries. Idempotent.
|
||||
export function stripReserved(labels) {
|
||||
return labels.filter((label) => !isReserved(label));
|
||||
}
|
||||
Reference in New Issue
Block a user