From 957fa936e8536ab834dab3ecdf157367563856d2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 8 Oct 2026 18:45:14 +0000 Subject: [PATCH] fix(blindaje): no frenar datos que viven en las instrucciones o en una tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit En modo negaciones, una respuesta con una negación y un dato que solo está en custom_instructions (o en una tool de ese turno) se reemplazaba por la frase de espera. Esas fuentes ahora cuentan. Un dato que no está en ninguna sigue abriendo ticket. Ticket #8AF60E. Co-authored-by: santmun --- src/agent.ts | 39 +++- src/blindaje/sources.ts | 144 +++++++++++++++ src/blindaje/verify.ts | 332 +++++++++++++++++++++++++++++++++++ src/db/settings.ts | 2 + src/env.ts | 5 + src/llm/runTurn.ts | 12 ++ src/settings-loader.ts | 6 + src/tools/index.ts | 5 +- src/tools/searchKb.ts | 14 +- test/blindaje/verify.test.ts | 207 ++++++++++++++++++++++ test/tools/searchKb.test.ts | 4 + wrangler.toml | 4 + 12 files changed, 769 insertions(+), 5 deletions(-) create mode 100644 src/blindaje/sources.ts create mode 100644 src/blindaje/verify.ts create mode 100644 test/blindaje/verify.test.ts diff --git a/src/agent.ts b/src/agent.ts index 654abc19..20cc811a 100644 --- a/src/agent.ts +++ b/src/agent.ts @@ -17,6 +17,7 @@ import { CustomerFactsRepo } from "./db/facts"; import { createModel } from "./llm/provider"; import { formatLlmError } from "./llm/errorDetail"; import { runLlmTurn } from "./llm/runTurn"; +import type { SearchKbResult } from "./tools/searchKb"; import { costOfUsage } from "./pricing"; import type { ChannelId } from "./channels/shared"; import { maskTelegramToken, unmaskTelegramToken } from "./telegramFiles"; @@ -261,10 +262,18 @@ export class SupportAgent extends Agent { } } - // Build tools registry (tier-gated in buildTools) + // Pasajes de searchKb de este turno. El Blindaje los usa como fuente; + // el callback corre dentro de la tool, antes de verificar la respuesta. + const turnKbPassages: SearchKbResult[] = []; const tools = buildTools({ env: this.env, getConversationId: () => convId, + onSearchKbResults: (results) => { + turnKbPassages.push(...results); + if (turnKbPassages.length > 10) { + turnKbPassages.splice(0, turnKbPassages.length - 10); + } + }, }); const toolNames = Object.keys(tools); @@ -347,6 +356,7 @@ export class SupportAgent extends Agent { let cachedTokens = 0; let toolCallCount = 0; let toolCallsMade: { toolName: string; input: unknown }[] = []; + let turnToolResults: { tool: string; output: string }[] = []; let usedModelId = modelId; // Corre el loop del LLM con un modelo dado; deja los resultados en las vars. @@ -367,6 +377,7 @@ export class SupportAgent extends Agent { // Persist what the agent DID (not just what it said): tool name + input, // feeding the dashboard's thread chips, stats and the Mi Agente counters. toolCallsMade = turn.toolCallsMade; + turnToolResults = turn.toolResults; }; try { @@ -418,6 +429,32 @@ export class SupportAgent extends Agent { } } + // Blindaje (Pro): si la respuesta niega algo o afirma un dato, se contrasta + // contra la KB, el contexto, las instrucciones del dueño y las tools del + // turno. Sin respaldo → frase de espera + ticket. FAIL-OPEN. + const llmFailed = assistantText === "Algo falló de mi lado, intenta de nuevo en un momento."; + const blindajeOff = (this.env.BLINDAJE_MODE ?? "").trim().toLowerCase() === "off"; + if (assistantText && !llmFailed && isPro(this.env) && !blindajeOff) { + try { + const { guardReply } = await import("./blindaje/verify"); + const guard = await guardReply(this.env, { + replyText: assistantText, + turnUsedKb: turnKbPassages.length > 0, + kbPassages: turnKbPassages, + toolResults: turnToolResults, + businessContext: cfg.businessContext, + systemPrompt: cfg.systemPrompt, + customInstructions: cfg.customInstructions, + conversationId: convId, + channel: this.state.channel, + llm: cfg.llm, + }); + if (guard.action === "replaced") assistantText = guard.finalText; + } catch (e) { + console.warn("[blindaje] guard falló — fail-open, va la respuesta original:", e); + } + } + // Persist assistant message (with usage + model_used + tool calls) await msgs.append(convId, "assistant", assistantText, { modelUsed: usedModelId, diff --git a/src/blindaje/sources.ts b/src/blindaje/sources.ts new file mode 100644 index 00000000..37f375a7 --- /dev/null +++ b/src/blindaje/sources.ts @@ -0,0 +1,144 @@ +/** + * Fuentes que el Blindaje acepta como respaldo de un dato. + * + * Desde 1.0.77 searchKb pide returnMetadata:"all" y el verificador compara la + * respuesta contra el texto de la KB. Un dato que el dueño escribió solo en + * custom_instructions (o que devolvió una tool en este turno) no está en esos + * pasajes. En modo "negaciones" la negación dispara la verificación y ese dato + * se marca "sin respaldo", aunque sea cierto. + * + * El corpus de abajo es la lista cerrada de fuentes del turno. No incluye el + * system prompt entero: ahí hay reglas de estilo ("cambia CÓMO atiendes") que + * no son hechos del negocio y que hacían que el juez ignorara la promo escrita + * en las instrucciones. + */ + +export interface ToolResultSource { + tool: string; + output: string; +} + +export interface KbPassageSource { + title?: string; + content: string; +} + +export interface SourceInput { + kbPassages?: KbPassageSource[]; + businessContext?: string; + /** Texto crudo del setting custom_instructions (también si hay prompt manual). */ + customInstructions?: string; + /** System prompt activo: se extrae solo el bloque . */ + systemPrompt?: string; + toolResults?: ToolResultSource[]; +} + +/** Misma familia que DENIAL_PATTERN en verify.ts, más verbos de servicio que + * el ejemplo del ticket usa ("no damos / no atendemos a domicilio"). */ +export const DENIAL_CLAUSE = + /\bno\s+(?:lo\s+|la\s+|los\s+|las\s+|te\s+|se\s+)?(?:vend|ofrec|manej|trabaj|distribu|fabric|surt|damos|atend|entreg|tenemos|contamos|disponemos|hacemos)|no\s+(?:contamos|trabajamos|disponemos)\s+con|we\s+do\s?n[’']?t\s+(?:sell|offer|carry|stock|do|make|handle|have)|n[ãa]o\s+(?:vendemos|temos|oferecemos|trabalhamos|fazemos)/i; + +const FACT_HINT = + /\d|promo|regal|inclu|cuesta|precio|gratis|descuent|env[ií]o|bono|horario|disponib|stock|\bpesos?\b|d[oó]lar|\$|€|£|%/i; + +export function normalizeForMatch(s: string): string { + return s + .toLowerCase() + .normalize("NFD") + .replace(/\p{M}/gu, "") + .replace(/[^\p{L}\p{N}]+/gu, " ") + .replace(/\s+/g, " ") + .trim(); +} + +export function extractCustomInstructions(systemPrompt: string | undefined): string { + if (!systemPrompt) return ""; + const blocks: string[] = []; + const re = /([\s\S]*?)<\/custom_instructions>/gi; + let m: RegExpExecArray | null; + while ((m = re.exec(systemPrompt))) blocks.push(m[1]); + return blocks.join("\n"); +} + +export function sourceCorpus(input: SourceInput): string { + const parts: string[] = []; + const push = (s: string | undefined) => { + const t = s?.trim(); + if (t) parts.push(t); + }; + push(input.businessContext); + push(input.customInstructions); + push(extractCustomInstructions(input.systemPrompt)); + for (const p of input.kbPassages ?? []) { + push([p.title, p.content].filter(Boolean).join(" — ")); + } + for (const t of input.toolResults ?? []) { + push(t.output); + } + return parts.join("\n"); +} + +/** Corta en oraciones. No parte miles ("1,500") ni decimales. */ +export function splitClauses(text: string): string[] { + return text + .split(/[.;!?\n]+|,(?!\d)/) + .map((s) => s.trim()) + .filter((s) => s.length > 0); +} + +export function isDenialClause(clause: string): boolean { + return DENIAL_CLAUSE.test(clause); +} + +/** Una cláusula afirma un dato del negocio (precio, promo, incluye, etc.). */ +export function isPositiveFactClause(clause: string): boolean { + if (isDenialClause(clause)) return false; + const t = clause.trim(); + if (t.length < 8) return false; + return FACT_HINT.test(t); +} + +function numbersOf(normalized: string): string[] { + return normalized.match(/\d+(?:\s\d{3})*/g) ?? []; +} + +/** + * El dato está en el corpus si la frase aparece (normalizada) o si cada + * palabra de contenido y cada número de la cláusula están en las fuentes. + * Un número que no está (el corte a $80 cuando la fuente dice $150) no pasa. + */ +export function clauseGrounded(clause: string, corpus: string): boolean { + const c = normalizeForMatch(clause); + const src = normalizeForMatch(corpus); + if (!c || !src) return false; + if (src.includes(c)) return true; + const nums = numbersOf(c); + if (nums.some((n) => !src.includes(n))) return false; + const tokens = c.split(" ").filter((t) => t.length >= 4); + if (tokens.length === 0) return false; + return tokens.every((t) => src.includes(t)); +} + +export type NegacionesDecision = "deliver" | "block" | "ask"; + +/** + * Modo negaciones, una vez que la respuesta ya trae una negación: + * - deliver: cada dato positivo está en las fuentes (instrucciones, KB, + * contexto o tool del turno). La negación disparó el chequeo pero no hay + * un dato inventado que tapar. + * - block: hay un dato positivo que no está en ninguna fuente. + * - ask: la respuesta solo niega; el modelo decide si esa negación tiene + * respaldo explícito (el caso "no vendemos repuestos"). + */ +export function decideNegaciones(reply: string, corpus: string): NegacionesDecision { + const positives = splitClauses(reply).filter(isPositiveFactClause); + if (positives.length === 0) return "ask"; + if (positives.every((c) => clauseGrounded(c, corpus))) return "deliver"; + return "block"; +} + +export function firstUngroundedFact(reply: string, corpus: string): string | undefined { + return splitClauses(reply) + .filter(isPositiveFactClause) + .find((c) => !clauseGrounded(c, corpus)); +} diff --git a/src/blindaje/verify.ts b/src/blindaje/verify.ts new file mode 100644 index 00000000..010ef34e --- /dev/null +++ b/src/blindaje/verify.ts @@ -0,0 +1,332 @@ +/** + * Blindaje anti-invento — verificador pre-envío (Pro). + * + * Antes de mandar una respuesta que afirma datos del negocio, se contrasta + * contra las fuentes del turno: KB, contexto, custom_instructions y las tools + * que se llamaron. Sin respaldo → frase de espera + ticket. FAIL-OPEN: si el + * modelo del verificador truena o no llega, sale la respuesta original. + * + * Modo `negaciones` (BLINDAJE_MODE): solo entra cuando la respuesta niega que + * el negocio ofrezca algo. Los datos positivos se aceptan si están en alguna + * fuente — incluidas las instrucciones del dueño y las tools del turno. Un + * dato positivo que no está en ninguna sigue bloqueado. Una negación sola + * (sin dato positivo) la juzga el modelo: la ausencia no prueba que no se venda. + */ +import type { Env } from "../env"; +import { isPro } from "../config"; +import type { LlmOverrides } from "../llm/provider"; +import type { SearchKbResult } from "../tools/searchKb"; +import { Db } from "../db/client"; +import { SettingsRepo, SETTING_KEYS } from "../db/settings"; +import { TicketsRepo } from "../db/tickets"; +import { ConversationsRepo } from "../db/conversations"; +import { notifyOwner } from "../tools/handoffHuman"; +import { + DENIAL_CLAUSE, + decideNegaciones, + firstUngroundedFact, + sourceCorpus, + type ToolResultSource, +} from "./sources"; + +/** Tope duro del verificador: si el modelo no contesta a tiempo, fail-open. */ +export const VERIFY_TIMEOUT_MS = 4000; + +const CLAIM_PATTERN = + /[0-9$€£%]|\bpesos?\b|\bd[oó]lares?\b|\bmxn\b|\busd\b|\bgratis\b|\bfree\b|\bcancel|\breagend|\brescheduled?\b/i; + +export const DENIAL_PATTERN = DENIAL_CLAUSE; + +export type BlindajeMode = "off" | "negaciones" | "full"; + +export function blindajeMode(env: Env): BlindajeMode { + const v = (env.BLINDAJE_MODE ?? "").trim().toLowerCase(); + if (v === "off") return "off"; + if (v === "negaciones" || v === "negations") return "negaciones"; + return "full"; +} + +export function shouldVerify(replyText: string, turnUsedKb: boolean): boolean { + if (turnUsedKb) return true; + return CLAIM_PATTERN.test(replyText) || DENIAL_PATTERN.test(replyText); +} + +const SAFE_REPLY_BY_LANG: Record = { + "es-419": + "Esa me la confirma el equipo — dame un momento y te digo bien, para no darte un dato equivocado.", + "es-ES": + "Eso me lo confirma el equipo — dame un momento y te digo seguro, para no darte un dato equivocado.", + en: "Let me double-check that with the team so I don't give you the wrong info — I'll get back to you in a moment.", + "pt-BR": "Deixa eu confirmar isso com a equipe para não te passar um dado errado — já te retorno.", +}; + +const SAFE_REPLY_WEB_BY_LANG: Record = { + "es-419": + "Esa me la confirma el equipo. Para avisarte en cuanto la tenga, ¿me dejas tu correo o WhatsApp? Así no se te pierde la respuesta.", + "es-ES": + "Eso me lo confirma el equipo. Para avisarte en cuanto lo tenga, ¿me dejas tu correo o WhatsApp? Así no se te pierde la respuesta.", + en: "Let me double-check that with the team. So I can get back to you, could you leave your email or WhatsApp? That way you won't miss the answer.", + "pt-BR": + "Deixa eu confirmar isso com a equipe. Para te avisar assim que tiver, você me deixa seu e-mail ou WhatsApp? Assim você não perde a resposta.", +}; + +/** + * Frase de espera. `BLINDAJE_HOLDING_PHRASE` (var del worker) la reemplaza + * entera si el dueño quiere otro tono — la de es-419 en web pide un contacto + * que en WhatsApp ya se tiene. + */ +export function safeConfirmReply(lang: string | undefined, channel?: string, override?: string): string { + const custom = override?.trim(); + if (custom) return custom; + const table = channel === "web" ? SAFE_REPLY_WEB_BY_LANG : SAFE_REPLY_BY_LANG; + const v = (lang ?? "").trim().toLowerCase(); + if (v === "es-es" || v === "es_es") return table["es-ES"]; + if (v.startsWith("pt")) return table["pt-BR"]; + if (v.startsWith("en")) return table.en; + return table["es-419"]; +} + +async function withTimeout(p: Promise, ms: number): Promise { + let timer: ReturnType | undefined; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`[blindaje] verify timeout tras ${ms}ms`)), ms); + }); + try { + return await Promise.race([p, timeout]); + } finally { + clearTimeout(timer); + } +} + +export interface VerifyVerdict { + supported: boolean; + unsupported_claim?: string; +} + +export interface VerifyOptions { + llm?: LlmOverrides; + timeoutMs?: number; + systemPrompt?: string; + customInstructions?: string; + toolResults?: ToolResultSource[]; + /** En modo negaciones el modelo solo ve negaciones puras (sin dato positivo). */ + negationsOnly?: boolean; +} + +export async function verifyReply( + env: Env, + replyText: string, + kbPassages: SearchKbResult[], + businessContext: string, + opts: VerifyOptions = {}, +): Promise { + const { createModel } = await import("../llm/provider"); + const { generateText } = await import("ai"); + const { model } = createModel(env, "fast", opts.llm ?? {}); + + const passagesBlock = + kbPassages.length > 0 + ? kbPassages + .map((p, i) => `[${i + 1}] ${p.title ? `${p.title} — ` : ""}${p.content.slice(0, 1500)}`) + .join("\n") + : "(no se consultó la base de conocimiento este turno)"; + + const toolResultsBlock = + (opts.toolResults?.length ?? 0) > 0 + ? opts.toolResults!.map((t, i) => `[T${i + 1}] tool ${t.tool}: ${t.output.slice(0, 8000)}`).join("\n") + : "(no se llamaron otras herramientas este turno)"; + + const custom = (opts.customInstructions ?? "").trim(); + const scopeRule = opts.negationsOnly + ? `\nEsta respuesta NIEGA que el negocio ofrezca algo y no trae otro dato positivo. supported=false si ninguna fuente dice EXPLÍCITAMENTE que no se ofrece. Que las fuentes no lo mencionen NO respalda la negación.\n` + : ""; + + const result = await withTimeout( + generateText({ + model, + prompt: `Eres un verificador de datos para el bot de atención de ${env.BUSINESS_NAME}. +Tu ÚNICO trabajo: decidir si lo que la respuesta dice sobre el negocio está respaldado por las fuentes. Juzgas la VERDAD de los datos, no el estilo. +${scopeRule} +Reglas: +- Saludos, cortesía y preguntas al cliente no son afirmaciones. +- Un dato positivo (precio, horario, promo, regalo, política) está respaldado si APARECE en cualquiera de las fuentes, aunque sea parafraseado. Cambiar un número no está respaldado. +- son hechos que escribió el dueño. Cuentan IGUAL que la KB y que las tools. No son "solo estilo": si ahí dice que la promoción incluye un regalo, esa promo está respaldada aunque la KB no la mencione. +- Los RESULTADOS DE HERRAMIENTAS de este turno son fuente oficial. Un dato que devolvió una tool no es invención. +- Una NEGACIÓN ("no vendemos X", "no ofrecemos eso") solo está respaldada si una fuente lo dice explícitamente. La ausencia no alcanza. +- Si no hay ningún dato ni ninguna negación, supported=true. + +FUENTES (todas cuentan igual): + +${businessContext || "(vacío)"} + + +${custom || "(sin instrucciones del dueño)"} + + +${(opts.systemPrompt ?? "").slice(0, 24000) || "(sin instrucciones)"} + + +${passagesBlock} + + +${toolResultsBlock} + + +RESPUESTA DEL BOT A VERIFICAR: + +${replyText} + + +Responde SOLO con JSON válido, sin markdown ni explicación: +{"supported": true|false, "unsupported_claim": "la afirmación exacta sin respaldo (solo si supported=false)"}`, + }), + opts.timeoutMs ?? VERIFY_TIMEOUT_MS, + ); + + const raw = (result.text ?? "").trim(); + const jsonMatch = raw.match(/\{[\s\S]*\}/); + if (!jsonMatch) throw new Error(`[blindaje] el verificador no devolvió JSON: ${raw.slice(0, 120)}`); + const parsed = JSON.parse(jsonMatch[0]); + if (typeof parsed.supported !== "boolean") { + throw new Error("[blindaje] veredicto sin campo booleano 'supported'"); + } + return { + supported: parsed.supported, + unsupported_claim: + typeof parsed.unsupported_claim === "string" && parsed.unsupported_claim.trim() !== "" + ? parsed.unsupported_claim + : undefined, + }; +} + +async function bumpCounter(env: Env, key: string): Promise { + try { + const repo = new SettingsRepo(new Db(env.DB)); + const current = parseInt((await repo.get(key)) ?? "0", 10) || 0; + await repo.set(key, String(current + 1)); + } catch { + /* contador es cosmético */ + } +} + +export type GuardAction = + | "skipped-free" + | "skipped-mode-off" + | "skipped-no-claims" + | "sent-original" + | "replaced" + | "fail-open"; + +export interface GuardOptions { + replyText: string; + turnUsedKb: boolean; + kbPassages: SearchKbResult[]; + toolResults?: ToolResultSource[]; + businessContext?: string; + systemPrompt?: string; + /** Setting custom_instructions, aparte del system prompt (sobrevive al override manual y al corte de 24k). */ + customInstructions?: string; + mode?: BlindajeMode; + conversationId: string | null; + channel?: string; + llm?: LlmOverrides; + timeoutMs?: number; +} + +export interface GuardResult { + finalText: string; + action: GuardAction; + unsupportedClaim?: string; +} + +async function replaceWithTicket( + env: Env, + opts: GuardOptions, + claim: string, +): Promise { + try { + const db = new Db(env.DB); + const ticketId = await new TicketsRepo(db).create({ + conversationId: opts.conversationId, + category: "other", + summary: `[dato sin respaldo] El bot iba a decirle al cliente algo que tu información no respalda: "${claim.slice(0, 200)}". Se le dijo que lo confirmas tú.`, + transcript: "", + }); + if (opts.conversationId) { + await new ConversationsRepo(db).setOpenTicket(opts.conversationId, ticketId); + } + await notifyOwner(env, { + reason: "dato sin respaldo", + summary: claim.slice(0, 200), + ticketId, + }); + } catch (e) { + console.error("[blindaje] no se pudo crear el ticket/aviso:", e); + } + await bumpCounter(env, SETTING_KEYS.blindajeBlocked); + const holding = safeConfirmReply(env.BOT_LANGUAGE, opts.channel, env.BLINDAJE_HOLDING_PHRASE); + return { finalText: holding, action: "replaced", unsupportedClaim: claim.slice(0, 200) }; +} + +export async function guardReply(env: Env, opts: GuardOptions): Promise { + const original = opts.replyText; + if (!isPro(env)) return { finalText: original, action: "skipped-free" }; + + const mode = opts.mode ?? blindajeMode(env); + if (mode === "off") return { finalText: original, action: "skipped-mode-off" }; + + const corpus = sourceCorpus({ + kbPassages: opts.kbPassages, + businessContext: opts.businessContext, + customInstructions: opts.customInstructions, + systemPrompt: opts.systemPrompt, + toolResults: opts.toolResults, + }); + + let negationsOnly = false; + if (mode === "negaciones") { + if (!DENIAL_PATTERN.test(original)) { + return { finalText: original, action: "skipped-no-claims" }; + } + const decision = decideNegaciones(original, corpus); + if (decision === "deliver") { + return { finalText: original, action: "sent-original" }; + } + if (decision === "block") { + await bumpCounter(env, SETTING_KEYS.blindajeChecks); + const claim = firstUngroundedFact(original, corpus) ?? original; + return replaceWithTicket(env, opts, claim); + } + negationsOnly = true; + } else if (!shouldVerify(original, opts.turnUsedKb)) { + return { finalText: original, action: "skipped-no-claims" }; + } + + let verdict: VerifyVerdict; + try { + verdict = await verifyReply(env, original, opts.kbPassages, opts.businessContext ?? "", { + llm: opts.llm, + timeoutMs: opts.timeoutMs, + systemPrompt: opts.systemPrompt, + customInstructions: opts.customInstructions, + toolResults: opts.toolResults, + negationsOnly, + }); + } catch (e) { + console.warn("[blindaje] verificador falló — fail-open, va la respuesta original:", e); + return { finalText: original, action: "fail-open" }; + } + + await bumpCounter(env, SETTING_KEYS.blindajeChecks); + if (verdict.supported) return { finalText: original, action: "sent-original" }; + + // El modelo marcó la respuesta, pero cada dato positivo sí está en las + // fuentes. No se tira la respuesta por un hecho que el dueño escribió o que + // una tool devolvió. Una negación sola (sin dato positivo) no se rescata. + if (decideNegaciones(original, corpus) === "deliver") { + return { finalText: original, action: "sent-original" }; + } + + const claim = verdict.unsupported_claim ?? original; + return replaceWithTicket(env, opts, claim); +} diff --git a/src/db/settings.ts b/src/db/settings.ts index a48a76d2..aa0cd986 100644 --- a/src/db/settings.ts +++ b/src/db/settings.ts @@ -28,6 +28,8 @@ export const SETTING_KEYS = { llmProvider: "llm_provider", // "" (auto) | anthropic | openai llmApiKey: "llm_api_key", // owner's API key; empty = use the env key llmModel: "llm_model", // concrete model id; empty = auto tiers (fast⇄smart) + blindajeChecks: "blindaje_checks", // cuántas veces el verificador juzgó una respuesta + blindajeBlocked: "blindaje_blocked", // cuántas se reemplazaron por dato sin respaldo } as const; export type SettingKey = (typeof SETTING_KEYS)[keyof typeof SETTING_KEYS]; diff --git a/src/env.ts b/src/env.ts index f67c4678..0f25435e 100644 --- a/src/env.ts +++ b/src/env.ts @@ -17,6 +17,11 @@ export interface Env { BUSINESS_NAME: string; BOT_LANGUAGE: string; BOT_TIER: "free" | "pro"; + // Blindaje anti-invento (Pro). Sin valor = "full". "negaciones" solo vigila + // negaciones de existencia. "off" lo apaga. BLINDAJE_HOLDING_PHRASE reemplaza + // la frase de espera si el dueño quiere otro tono. + BLINDAJE_MODE?: string; + BLINDAJE_HOLDING_PHRASE?: string; // Nicho del bot (restaurante, inmobiliaria…). Selecciona el "niche pack" que // re-etiqueta el dashboard, aporta el playbook del giro y sus columnas. // Ausente/desconocido → pack genérico (comportamiento actual). Ver src/niches/. diff --git a/src/llm/runTurn.ts b/src/llm/runTurn.ts index 78933644..ccd31f97 100644 --- a/src/llm/runTurn.ts +++ b/src/llm/runTurn.ts @@ -17,6 +17,8 @@ export interface LlmTurnResult { cachedTokens: number; toolCallCount: number; toolCallsMade: { toolName: string; input: unknown }[]; + /** Salidas de las tools del turno, salvo searchKb (esa va en los pasajes de KB). */ + toolResults: { tool: string; output: string }[]; } function callArgs(args: LlmTurnArgs) { @@ -48,6 +50,16 @@ function fromSteps(steps: any[] | undefined) { input: tc.input, })), ), + toolResults: list + .flatMap((s) => s.toolResults ?? []) + .filter((tr: any) => tr?.toolName && tr.toolName !== "searchKb") + .map((tr: any) => ({ + tool: String(tr.toolName), + output: (typeof tr.output === "string" + ? tr.output + : JSON.stringify(tr.output ?? tr.result ?? "") + ).slice(0, 8000), + })), }; } diff --git a/src/settings-loader.ts b/src/settings-loader.ts index 3aa53e22..fb9df45b 100644 --- a/src/settings-loader.ts +++ b/src/settings-loader.ts @@ -24,6 +24,10 @@ export interface AgentConfig { monthlyBudgetUsd?: number; /** BYO-LLM del dashboard (proveedor / API key / modelo). */ llm: LlmOverrides; + /** Hechos y reglas que el dueño escribió. El Blindaje las trata como fuente. */ + customInstructions?: string; + /** Contexto del negocio (horarios, precios, servicios). */ + businessContext?: string; } /** Extract the BYO-LLM overrides from a settings snapshot. */ @@ -160,5 +164,7 @@ export async function resolveAgentConfig(env: Env, toolNames: string[]): Promise temperature, monthlyBudgetUsd, llm: llmOverridesFrom(settings), + customInstructions, + businessContext, }; } diff --git a/src/tools/index.ts b/src/tools/index.ts index d71558dc..b4a3eb8f 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -1,6 +1,6 @@ import type { Env } from "../env"; import { isPro } from "../config"; -import { searchKbTool } from "./searchKb"; +import { searchKbTool, type SearchKbOnResults } from "./searchKb"; import { handoffHumanTool } from "./handoffHuman"; import { pauseBotTool } from "./pauseBot"; import { snoozeUserTool } from "./snoozeUser"; @@ -11,6 +11,7 @@ import { catalogQueryTool } from "./catalogQuery"; export interface ToolContext { env: Env; getConversationId: () => string | null; + onSearchKbResults?: SearchKbOnResults; } export function buildTools(ctx: ToolContext) { @@ -19,7 +20,7 @@ export function buildTools(ctx: ToolContext) { // propia cuenta/llave, sin costo para Forja, así que es valor central sin gate. Lo Pro // es consultar catálogo/inventario y las tools avanzadas por nicho. const tools: Record = { - searchKb: searchKbTool(ctx.env), + searchKb: searchKbTool(ctx.env, ctx.onSearchKbResults), handoffHuman: handoffHumanTool(ctx.env, ctx.getConversationId), pauseBot: pauseBotTool(ctx.env, ctx.getConversationId), snoozeUser: snoozeUserTool(ctx.env, ctx.getConversationId), diff --git a/src/tools/searchKb.ts b/src/tools/searchKb.ts index 1a701cff..ff3e9a8b 100644 --- a/src/tools/searchKb.ts +++ b/src/tools/searchKb.ts @@ -8,7 +8,10 @@ export interface SearchKbResult { score: number; } -export function searchKbTool(env: Env) { +/** El agente guarda los pasajes del turno para el Blindaje. */ +export type SearchKbOnResults = (results: SearchKbResult[]) => void; + +export function searchKbTool(env: Env, onResults?: SearchKbOnResults) { return tool({ description: "Busca en el knowledge base del negocio. Devuelve top-5 chunks con score 0-1. Si top-1 score < 0.7 no hay match útil — escala.", @@ -24,12 +27,19 @@ export function searchKbTool(env: Env) { if (!Array.isArray(vec)) { return { error: "transient" as const, message: "embedding shape unexpected" }; } - const matches = await env.KB.query(vec, { topK: 5 }); + // Sin returnMetadata:"all", Vectorize devuelve el score pero title y + // content vacíos. El modelo (y el Blindaje) no pueden usar el texto. + const matches = await env.KB.query(vec, { topK: 5, returnMetadata: "all" }); const results: SearchKbResult[] = (matches.matches ?? []).map((m: any) => ({ title: (m.metadata?.title as string) ?? "", content: (m.metadata?.content as string) ?? "", score: m.score ?? 0, })); + try { + onResults?.(results); + } catch { + /* el callback no puede tumbar la búsqueda */ + } return { results }; } catch (e: any) { return { error: "transient" as const, message: String(e?.message ?? e) }; diff --git a/test/blindaje/verify.test.ts b/test/blindaje/verify.test.ts new file mode 100644 index 00000000..40598186 --- /dev/null +++ b/test/blindaje/verify.test.ts @@ -0,0 +1,207 @@ +/** + * Blindaje, modo negaciones (ticket #8AF60E): + * • un dato que solo está en custom_instructions se entrega + * • un dato que devolvió una tool en este turno se entrega + * • un dato que no está en ninguna fuente, junto con una negación, se frena + * • una negación sola (sin dato positivo) sigue yendo al verificador + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +const generateTextMock = vi.fn(); + +vi.mock("ai", () => ({ + generateText: (...args: unknown[]) => generateTextMock(...args), + tool: (def: unknown) => def, +})); + +vi.mock("../../src/llm/provider", () => ({ + createModel: () => ({ + provider: "anthropic", + modelId: "modelo-test", + model: {}, + supportsPromptCache: true, + }), +})); + +import { createTestMiniflare } from "../helpers/miniflareSetup"; +import { Db } from "../../src/db/client"; +import { ConversationsRepo } from "../../src/db/conversations"; +import { TicketsRepo } from "../../src/db/tickets"; +import { guardReply, safeConfirmReply } from "../../src/blindaje/verify"; +import { decideNegaciones, clauseGrounded } from "../../src/blindaje/sources"; +import type { Env } from "../../src/env"; +import type { SearchKbResult } from "../../src/tools/searchKb"; + +const KB: SearchKbResult[] = [ + { title: "Horarios", content: "Abrimos de lunes a viernes de 10 a 19.", score: 0.86 }, +]; + +const PROMO = "la promoción incluye X de regalo"; +const MIXED = `No ofrecemos servicio a domicilio; ${PROMO}`; + +let env: Env; +let convId: string; + +beforeEach(async () => { + const mf = await createTestMiniflare(); + const d1 = (await mf.getD1Database("DB")) as any; + const conv = await new ConversationsRepo(new Db(d1)).getOrCreate("whatsapp", "u1", "Cliente"); + convId = conv.id; + env = { + DB: d1, + BOT_NAME: "Testi", + BUSINESS_NAME: "Tienda", + BOT_LANGUAGE: "es", + BOT_TIER: "pro", + BLINDAJE_MODE: "negaciones", + BUFFER_SECONDS: "8", + DASHBOARD_BASE_URL: "https://dash.test", + } as unknown as Env; + generateTextMock.mockReset(); +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe("fuentes del turno", () => { + it("un dato que solo está en custom_instructions pasa, aunque la KB no lo tenga", async () => { + const r = await guardReply(env, { + replyText: MIXED, + turnUsedKb: true, + kbPassages: KB, + businessContext: "Vendemos café.", + customInstructions: PROMO, + mode: "negaciones", + conversationId: convId, + channel: "whatsapp", + }); + + expect(r.action).toBe("sent-original"); + expect(r.finalText).toBe(MIXED); + expect(generateTextMock).not.toHaveBeenCalled(); + expect(await new TicketsRepo(new Db(env.DB)).listOpen()).toHaveLength(0); + }); + + it("también lo acepta si el dato vive dentro del bloque del system prompt", async () => { + const r = await guardReply(env, { + replyText: `No damos servicio a domicilio; ${PROMO}`, + turnUsedKb: true, + kbPassages: KB, + systemPrompt: `\n${PROMO}\n`, + mode: "negaciones", + conversationId: convId, + channel: "whatsapp", + }); + expect(r.action).toBe("sent-original"); + expect(r.finalText).toContain("X de regalo"); + }); + + it("un dato que devolvió una tool en este turno pasa", async () => { + const reply = "No ofrecemos servicio a domicilio; el combo incluye envío gratis"; + const r = await guardReply(env, { + replyText: reply, + turnUsedKb: true, + kbPassages: KB, + toolResults: [{ tool: "catalogQuery", output: "el combo incluye envío gratis a todo el país" }], + mode: "negaciones", + conversationId: convId, + channel: "whatsapp", + }); + expect(r.action).toBe("sent-original"); + expect(r.finalText).toBe(reply); + expect(generateTextMock).not.toHaveBeenCalled(); + }); + + it("un dato sin respaldo, junto con una negación, sigue frenándose y abre ticket", async () => { + const reply = "No ofrecemos servicio a domicilio; la promoción incluye un yate de regalo"; + const r = await guardReply(env, { + replyText: reply, + turnUsedKb: true, + kbPassages: KB, + customInstructions: PROMO, + mode: "negaciones", + conversationId: convId, + channel: "whatsapp", + }); + + expect(r.action).toBe("replaced"); + expect(r.finalText).toBe(safeConfirmReply("es", "whatsapp")); + expect(r.finalText).not.toContain("yate"); + expect(r.unsupportedClaim).toMatch(/yate/i); + + const tickets = await new TicketsRepo(new Db(env.DB)).listOpen(); + expect(tickets).toHaveLength(1); + expect(tickets[0].summary).toContain("dato sin respaldo"); + expect(tickets[0].summary).toMatch(/yate/i); + expect(generateTextMock).not.toHaveBeenCalled(); + }); + + it("una negación sola, sin dato positivo, sigue yendo al verificador", async () => { + generateTextMock.mockResolvedValue({ + text: JSON.stringify({ supported: false, unsupported_claim: "no vendemos repuestos" }), + }); + const reply = "No, no vendemos repuestos."; + const r = await guardReply(env, { + replyText: reply, + turnUsedKb: false, + kbPassages: [], + customInstructions: PROMO, + mode: "negaciones", + conversationId: convId, + channel: "whatsapp", + }); + expect(generateTextMock).toHaveBeenCalledTimes(1); + const prompt = generateTextMock.mock.calls[0][0].prompt as string; + expect(prompt).toContain(""); + expect(prompt).toContain(PROMO); + expect(r.action).toBe("replaced"); + expect(r.finalText).not.toBe(reply); + }); + + it("en modo full el modelo no puede tumbar un dato que sí está en las instrucciones", async () => { + generateTextMock.mockResolvedValue({ + text: JSON.stringify({ supported: false, unsupported_claim: PROMO }), + }); + const r = await guardReply(env, { + replyText: MIXED, + turnUsedKb: true, + kbPassages: KB, + customInstructions: PROMO, + mode: "full", + conversationId: convId, + channel: "whatsapp", + }); + expect(r.action).toBe("sent-original"); + expect(r.finalText).toBe(MIXED); + expect(await new TicketsRepo(new Db(env.DB)).listOpen()).toHaveLength(0); + }); + + it("BLINDAJE_HOLDING_PHRASE reemplaza la frase de espera", async () => { + const custom = "Te lo confirmo en un momento."; + const r = await guardReply( + { ...env, BLINDAJE_HOLDING_PHRASE: custom } as Env, + { + replyText: "No ofrecemos servicio a domicilio; la promoción incluye un yate de regalo", + turnUsedKb: false, + kbPassages: [], + mode: "negaciones", + conversationId: convId, + channel: "whatsapp", + }, + ); + expect(r.action).toBe("replaced"); + expect(r.finalText).toBe(custom); + }); +}); + +describe("decideNegaciones", () => { + it("entrega si el dato positivo está en el corpus y frena si no", () => { + const corpus = `Horarios: 10 a 19.\n${PROMO}`; + expect(decideNegaciones(MIXED, corpus)).toBe("deliver"); + expect(decideNegaciones("No ofrecemos servicio a domicilio; la promoción incluye un yate de regalo", corpus)).toBe("block"); + expect(decideNegaciones("No, no vendemos repuestos.", corpus)).toBe("ask"); + expect(clauseGrounded("el corte cuesta $80", "Corte de cabello: $150")).toBe(false); + expect(clauseGrounded("Corte de cabello: $150", "Corte de cabello: $150")).toBe(true); + }); +}); diff --git a/test/tools/searchKb.test.ts b/test/tools/searchKb.test.ts index 9c081803..a2ea37df 100644 --- a/test/tools/searchKb.test.ts +++ b/test/tools/searchKb.test.ts @@ -16,6 +16,10 @@ describe("searchKbTool", () => { expect(result.results).toHaveLength(2); expect(result.results[0].title).toBe("Embebar wall"); expect(result.results[0].score).toBe(0.91); + expect(fakeEnv.KB.query).toHaveBeenCalledWith( + [0.1, 0.2, 0.3], + expect.objectContaining({ topK: 5, returnMetadata: "all" }), + ); }); it("returns empty results when KB throws", async () => { diff --git a/wrangler.toml b/wrangler.toml index c820f9f2..c3be3169 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -37,6 +37,10 @@ BOT_LANGUAGE = "{{BOT_LANGUAGE}}" BOT_TIER = "{{BOT_TIER}}" # 'free' | 'pro' BUFFER_SECONDS = "15" DASHBOARD_BASE_URL = "https://horizontes-bot-{{BOT_SLUG}}.workers.dev" +# BLINDAJE_MODE = "full" # Pro: 'off' | 'negaciones' | 'full'. Sin línea = full. +# 'negaciones' solo frena negaciones sin respaldo y datos positivos que no estén +# en la KB, el contexto, las instrucciones del negocio o una tool de ese turno. +# BLINDAJE_HOLDING_PHRASE = "Te lo confirmo en un momento." # opcional; reemplaza la frase de espera # Optional egress (ticket 7E5F40): Worker→Anthropic/Zernio 400 vacío. # AGENT_LOCATION_HINT = "wnam" # pin DOs near US (only on FIRST create)