added NLU v3 functionality with JSON schema validation and error handling in OpenAI service
This commit is contained in:
54
package-lock.json
generated
54
package-lock.json
generated
@@ -9,6 +9,7 @@
|
||||
"version": "1.0.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ajv": "^8.17.1",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^4.19.2",
|
||||
@@ -133,6 +134,22 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "8.17.1",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
|
||||
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
"json-schema-traverse": "^1.0.0",
|
||||
"require-from-string": "^2.0.2"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/anymatch": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
|
||||
@@ -519,6 +536,28 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-uri": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
|
||||
"integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/fill-range": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||
@@ -788,6 +827,12 @@
|
||||
"node": ">=0.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/json-schema-traverse": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
@@ -1231,6 +1276,15 @@
|
||||
"node": ">=8.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-from-string": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"author": "Lucas Tettamanti",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ajv": "^8.17.1",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^4.19.2",
|
||||
|
||||
@@ -10,7 +10,7 @@ export function createApp({ tenantId }) {
|
||||
const app = express();
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.json({ limit: "1mb" }));
|
||||
app.use(express.json({ limit: "1mb" }));
|
||||
|
||||
// Serve /public as static (UI + webcomponents)
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
|
||||
153
src/services/fsm.js
Normal file
153
src/services/fsm.js
Normal file
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* FSM autoritativa (server-side) para el flujo conversacional.
|
||||
*
|
||||
* Principios:
|
||||
* - El LLM NO decide estados. Solo NLU.
|
||||
* - El backend deriva el estado objetivo a partir del contexto + acciones.
|
||||
* - Validamos transiciones y, si algo queda inconsistente, caemos a ERROR_RECOVERY.
|
||||
*/
|
||||
|
||||
export const ConversationState = Object.freeze({
|
||||
IDLE: "IDLE",
|
||||
BROWSING: "BROWSING",
|
||||
AWAITING_QUANTITY: "AWAITING_QUANTITY",
|
||||
CART_ACTIVE: "CART_ACTIVE",
|
||||
AWAITING_ADDRESS: "AWAITING_ADDRESS",
|
||||
AWAITING_PAYMENT: "AWAITING_PAYMENT",
|
||||
COMPLETED: "COMPLETED",
|
||||
ERROR_RECOVERY: "ERROR_RECOVERY",
|
||||
});
|
||||
|
||||
export const ALL_STATES = Object.freeze(Object.values(ConversationState));
|
||||
|
||||
function hasBasketItems(ctx) {
|
||||
const items = ctx?.basket?.items || ctx?.order_basket?.items;
|
||||
return Array.isArray(items) && items.length > 0;
|
||||
}
|
||||
|
||||
function hasPendingClarification(ctx) {
|
||||
const pc = ctx?.pending_clarification;
|
||||
return Boolean(pc?.candidates?.length) || Boolean(pc?.options?.length);
|
||||
}
|
||||
|
||||
function hasPendingItem(ctx) {
|
||||
return Boolean(ctx?.pending_item?.product_id || ctx?.pending_item?.sku);
|
||||
}
|
||||
|
||||
function hasAddress(ctx) {
|
||||
return Boolean(ctx?.delivery_address?.text || ctx?.address?.text || ctx?.address_text);
|
||||
}
|
||||
|
||||
function hasWooOrder(ctx) {
|
||||
return Boolean(ctx?.woo_order_id || ctx?.last_order_id);
|
||||
}
|
||||
|
||||
function hasPaymentLink(ctx) {
|
||||
return Boolean(ctx?.mp?.init_point || ctx?.payment?.init_point || ctx?.payment_link);
|
||||
}
|
||||
|
||||
function isPaid(ctx) {
|
||||
const st =
|
||||
ctx?.mp?.payment_status ||
|
||||
ctx?.payment?.status ||
|
||||
ctx?.payment_status ||
|
||||
null;
|
||||
return st === "approved" || st === "paid";
|
||||
}
|
||||
|
||||
/**
|
||||
* Deriva el estado objetivo según el contexto actual y señales del turno.
|
||||
* `signals` es información determinística del motor del turno (no del LLM),
|
||||
* por ejemplo: { requested_checkout: true }.
|
||||
*/
|
||||
export function deriveNextState(prevState, ctx = {}, signals = {}) {
|
||||
// Regla 1: pago confirmado gana siempre
|
||||
if (isPaid(ctx)) return ConversationState.COMPLETED;
|
||||
|
||||
// Regla 2: si ya existe orden + link de pago, estamos esperando pago
|
||||
if (hasWooOrder(ctx) && hasPaymentLink(ctx)) return ConversationState.AWAITING_PAYMENT;
|
||||
|
||||
// Regla 3: si intentó checkout pero falta dirección
|
||||
if ((signals.requested_checkout || signals.requested_address) && hasBasketItems(ctx) && !hasAddress(ctx)) {
|
||||
return ConversationState.AWAITING_ADDRESS;
|
||||
}
|
||||
|
||||
// Regla 4: si hay item pendiente sin completar cantidad
|
||||
if (hasPendingItem(ctx) && !signals.pending_item_completed) {
|
||||
return ConversationState.AWAITING_QUANTITY;
|
||||
}
|
||||
|
||||
// Regla 5: si hay carrito activo
|
||||
if (hasBasketItems(ctx)) return ConversationState.CART_ACTIVE;
|
||||
|
||||
// Regla 6: si estamos mostrando opciones / esperando selección
|
||||
if (hasPendingClarification(ctx) || signals.did_show_options || signals.is_browsing) {
|
||||
return ConversationState.BROWSING;
|
||||
}
|
||||
|
||||
return ConversationState.IDLE;
|
||||
}
|
||||
|
||||
const ALLOWED = Object.freeze({
|
||||
[ConversationState.IDLE]: [
|
||||
ConversationState.IDLE,
|
||||
ConversationState.BROWSING,
|
||||
ConversationState.AWAITING_QUANTITY,
|
||||
ConversationState.CART_ACTIVE,
|
||||
ConversationState.ERROR_RECOVERY,
|
||||
],
|
||||
[ConversationState.BROWSING]: [
|
||||
ConversationState.BROWSING,
|
||||
ConversationState.AWAITING_QUANTITY,
|
||||
ConversationState.CART_ACTIVE,
|
||||
ConversationState.IDLE,
|
||||
ConversationState.ERROR_RECOVERY,
|
||||
],
|
||||
[ConversationState.AWAITING_QUANTITY]: [
|
||||
ConversationState.AWAITING_QUANTITY,
|
||||
ConversationState.CART_ACTIVE,
|
||||
ConversationState.BROWSING,
|
||||
ConversationState.ERROR_RECOVERY,
|
||||
],
|
||||
[ConversationState.CART_ACTIVE]: [
|
||||
ConversationState.CART_ACTIVE,
|
||||
ConversationState.AWAITING_ADDRESS,
|
||||
ConversationState.AWAITING_PAYMENT,
|
||||
ConversationState.ERROR_RECOVERY,
|
||||
ConversationState.BROWSING,
|
||||
],
|
||||
[ConversationState.AWAITING_ADDRESS]: [
|
||||
ConversationState.AWAITING_ADDRESS,
|
||||
ConversationState.AWAITING_PAYMENT,
|
||||
ConversationState.CART_ACTIVE,
|
||||
ConversationState.ERROR_RECOVERY,
|
||||
],
|
||||
[ConversationState.AWAITING_PAYMENT]: [
|
||||
ConversationState.AWAITING_PAYMENT,
|
||||
ConversationState.COMPLETED,
|
||||
ConversationState.ERROR_RECOVERY,
|
||||
],
|
||||
[ConversationState.COMPLETED]: [
|
||||
ConversationState.COMPLETED,
|
||||
ConversationState.IDLE, // nueva conversación / reinicio natural
|
||||
ConversationState.ERROR_RECOVERY,
|
||||
],
|
||||
[ConversationState.ERROR_RECOVERY]: ALL_STATES,
|
||||
});
|
||||
|
||||
export function validateTransition(prevState, nextState) {
|
||||
const p = prevState || ConversationState.IDLE;
|
||||
const n = nextState || ConversationState.IDLE;
|
||||
if (!ALLOWED[p]) return { ok: false, reason: "unknown_prev_state", prev: p, next: n };
|
||||
if (!ALL_STATES.includes(n)) return { ok: false, reason: "unknown_next_state", prev: p, next: n };
|
||||
const ok = ALLOWED[p].includes(n);
|
||||
return ok ? { ok: true } : { ok: false, reason: "invalid_transition", prev: p, next: n };
|
||||
}
|
||||
|
||||
export function safeNextState(prevState, ctx, signals) {
|
||||
const desired = deriveNextState(prevState, ctx, signals);
|
||||
const v = validateTransition(prevState, desired);
|
||||
if (v.ok) return { next_state: desired, validation: v };
|
||||
return { next_state: ConversationState.ERROR_RECOVERY, validation: v };
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import OpenAI from "openai";
|
||||
import { z } from "zod";
|
||||
import Ajv from "ajv";
|
||||
import { debug as dbg } from "./debug.js";
|
||||
|
||||
let _client = null;
|
||||
@@ -126,6 +127,141 @@ async function jsonCompletion({ system, user, model }) {
|
||||
return { parsed, raw_text: text, model: chosenModel, usage: resp?.usage || null };
|
||||
}
|
||||
|
||||
// --- NLU v3 (single-step, schema-strict) ---
|
||||
|
||||
const NluV3JsonSchema = {
|
||||
$id: "NluV3",
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["intent", "confidence", "language", "entities", "needs"],
|
||||
properties: {
|
||||
intent: {
|
||||
type: "string",
|
||||
enum: ["price_query", "browse", "add_to_cart", "remove_from_cart", "checkout", "greeting", "other"],
|
||||
},
|
||||
confidence: { type: "number", minimum: 0, maximum: 1 },
|
||||
language: { type: "string" },
|
||||
entities: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["product_query", "quantity", "unit", "selection", "attributes", "preparation"],
|
||||
properties: {
|
||||
product_query: { anyOf: [{ type: "string" }, { type: "null" }] },
|
||||
quantity: { anyOf: [{ type: "number" }, { type: "null" }] },
|
||||
unit: { anyOf: [{ type: "string", enum: ["kg", "g", "unidad"] }, { type: "null" }] },
|
||||
selection: {
|
||||
anyOf: [
|
||||
{ type: "null" },
|
||||
{
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["type", "value"],
|
||||
properties: {
|
||||
type: { type: "string", enum: ["index", "text", "sku"] },
|
||||
value: { type: "string", minLength: 1 },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
attributes: { type: "array", items: { type: "string" } },
|
||||
preparation: { type: "array", items: { type: "string" } },
|
||||
},
|
||||
},
|
||||
needs: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["catalog_lookup", "knowledge_lookup"],
|
||||
properties: {
|
||||
catalog_lookup: { type: "boolean" },
|
||||
knowledge_lookup: { type: "boolean" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const ajv = new Ajv({ allErrors: true, strict: true });
|
||||
const validateNluV3 = ajv.compile(NluV3JsonSchema);
|
||||
|
||||
function nluV3Fallback() {
|
||||
return {
|
||||
intent: "other",
|
||||
confidence: 0,
|
||||
language: "es-AR",
|
||||
entities: {
|
||||
product_query: null,
|
||||
quantity: null,
|
||||
unit: null,
|
||||
selection: null,
|
||||
attributes: [],
|
||||
preparation: [],
|
||||
},
|
||||
needs: { catalog_lookup: false, knowledge_lookup: false },
|
||||
};
|
||||
}
|
||||
|
||||
function nluV3Errors() {
|
||||
const errs = validateNluV3.errors || [];
|
||||
return errs.map((e) => ({
|
||||
instancePath: e.instancePath,
|
||||
schemaPath: e.schemaPath,
|
||||
keyword: e.keyword,
|
||||
message: e.message,
|
||||
params: e.params,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function llmNluV3({ input, model } = {}) {
|
||||
const systemBase =
|
||||
"Sos un servicio NLU (es-AR). Extraés intención y entidades del mensaje del usuario.\n" +
|
||||
"IMPORTANTE:\n" +
|
||||
"- NO decidas estados (FSM), NO planifiques acciones, NO inventes productos ni precios.\n" +
|
||||
"- Respondé SOLO con JSON válido, EXACTAMENTE con las keys del contrato. additionalProperties=false.\n" +
|
||||
"- Si hay opciones mostradas y el usuario responde con un número/ordinal ('el segundo'), eso es entities.selection {type:'index'}.\n" +
|
||||
"- Si el usuario responde 'mostrame más', poné intent='browse' y entities.selection=null (la paginación la maneja el servidor).\n" +
|
||||
"- needs.catalog_lookup debe ser true para intents price_query|browse|add_to_cart si NO es una pura selección sobre opciones ya mostradas.\n";
|
||||
|
||||
const user = JSON.stringify(input ?? {});
|
||||
|
||||
// intento 1
|
||||
const first = await jsonCompletion({ system: systemBase, user, model });
|
||||
if (validateNluV3(first.parsed)) {
|
||||
return { nlu: first.parsed, raw_text: first.raw_text, model: first.model, usage: first.usage, schema: "v3", validation: { ok: true } };
|
||||
}
|
||||
|
||||
const errors1 = nluV3Errors();
|
||||
|
||||
// retry 1 vez
|
||||
const systemRetry =
|
||||
systemBase +
|
||||
"\nTu respuesta anterior no validó el JSON Schema. Corregí el JSON para que cumpla estrictamente.\n" +
|
||||
`Errores: ${JSON.stringify(errors1).slice(0, 1800)}\n`;
|
||||
|
||||
try {
|
||||
const second = await jsonCompletion({ system: systemRetry, user, model });
|
||||
if (validateNluV3(second.parsed)) {
|
||||
return { nlu: second.parsed, raw_text: second.raw_text, model: second.model, usage: second.usage, schema: "v3", validation: { ok: true, retried: true } };
|
||||
}
|
||||
const errors2 = nluV3Errors();
|
||||
return {
|
||||
nlu: nluV3Fallback(),
|
||||
raw_text: second.raw_text,
|
||||
model: second.model,
|
||||
usage: second.usage,
|
||||
schema: "v3",
|
||||
validation: { ok: false, retried: true, errors: errors2 },
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
nlu: nluV3Fallback(),
|
||||
raw_text: first.raw_text,
|
||||
model: first.model,
|
||||
usage: first.usage,
|
||||
schema: "v3",
|
||||
validation: { ok: false, retried: true, error: String(e?.message || e), errors: errors1 },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Genera un "plan" de conversación (salida estructurada) usando OpenAI.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user