diff --git a/cli/bin/cli.d.ts b/cli/bin/cli.d.ts index f8f24b4d..97c7dca5 100644 --- a/cli/bin/cli.d.ts +++ b/cli/bin/cli.d.ts @@ -25,3 +25,9 @@ export function execTarArchive( ): string | Buffer; export function backupIsUsable(backupPath: string | null | undefined): boolean; export function backupBeforeUpdate(dir: string, fromVer: string): string | null; +export function parseVectorizeDimensions(output: string, indexName?: string): number | null; +export function vectorizeDimensionIssue( + dimensions: number | null, + indexName?: string, + lang?: string, +): { message: string; hint: string } | null; diff --git a/cli/bin/cli.js b/cli/bin/cli.js index 0ba409da..7262cede 100755 --- a/cli/bin/cli.js +++ b/cli/bin/cli.js @@ -1502,6 +1502,117 @@ async function cmdUpdate(dirArg, flags) { console.log(C.dim(" ") + C.cyan(t().updPublishCmd) + C.dim(" (pnpm install && pnpm deploy)\n")); } +// bge-m3 (@cf/baai/bge-m3) — la misma dimensión que exige /kb/reindex. +const EXPECTED_VECTORIZE_DIMENSIONS = 1024; + +function numberDim(row) { + if (!row || typeof row !== "object") return null; + const raw = row.dimensions ?? row.config?.dimensions; + const n = typeof raw === "number" ? raw : typeof raw === "string" && /^\d+$/.test(raw) ? Number(raw) : NaN; + return Number.isInteger(n) && n > 0 ? n : null; +} + +/** Lee `dimensions` de `wrangler vectorize info --json` o de la tabla de texto. */ +function parseVectorizeDimensions(output, indexName) { + if (typeof output !== "string" || !output.trim()) return null; + const candidates = [output.trim()]; + const brace = output.search(/[\[{]/); + if (brace > 0) candidates.push(output.slice(brace).trim()); + for (const c of candidates) { + try { + const parsed = JSON.parse(c); + const row = Array.isArray(parsed) + ? (indexName && parsed.find((x) => x && x.name === indexName)) || parsed[0] + : parsed; + const dim = numberDim(row); + if (dim != null) return dim; + } catch { /* puede ser la tabla, no JSON */ } + } + const lines = output.split(/\r?\n/); + for (let i = 0; i < lines.length; i++) { + const header = splitTableRow(lines[i]); + if (!header) continue; + const col = header.findIndex((cell) => /^dimensions?$/i.test(cell)); + if (col < 0) continue; + for (let j = i + 1; j < lines.length; j++) { + const cells = splitTableRow(lines[j]); + if (!cells || cells[col] == null) continue; + const n = Number(cells[col]); + if (Number.isInteger(n) && n > 0) return n; + } + } + const labeled = output.match(/dimensions?\s*[:=]\s*(\d+)/i); + return labeled ? Number(labeled[1]) : null; +} + +function splitTableRow(line) { + if (typeof line !== "string") return null; + const trimmed = line.trim(); + if (!trimmed.includes("│") && !trimmed.includes("|")) return null; + if (/^[├└┌┤┐┬┴┼─\-|+\s]+$/.test(trimmed)) return null; + const parts = trimmed.split(/[│|]/).map((s) => s.trim()).filter((s) => s.length > 0); + return parts.length ? parts : null; +} + +/** null si no hay problema (1024 o dimensión desconocida). */ +function vectorizeDimensionIssue(dimensions, indexName, lang) { + if (typeof dimensions !== "number" || !Number.isFinite(dimensions)) return null; + if (dimensions === EXPECTED_VECTORIZE_DIMENSIONS) return null; + const en = lang === "en"; + const name = indexName || "KB"; + return { + message: en + ? `Vectorize index ${name} has ${dimensions} dimensions, not ${EXPECTED_VECTORIZE_DIMENSIONS}` + : `El índice Vectorize ${name} tiene ${dimensions} dimensiones, no ${EXPECTED_VECTORIZE_DIMENSIONS}`, + hint: en + ? `bge-m3 embeds at ${EXPECTED_VECTORIZE_DIMENSIONS}. Delete it and recreate: npx wrangler vectorize delete ${name} -y && npx wrangler vectorize create ${name} --dimensions=${EXPECTED_VECTORIZE_DIMENSIONS} --metric=cosine` + : `bge-m3 genera vectores de ${EXPECTED_VECTORIZE_DIMENSIONS}. Bórralo y recréalo: npx wrangler vectorize delete ${name} -y && npx wrangler vectorize create ${name} --dimensions=${EXPECTED_VECTORIZE_DIMENSIONS} --metric=cosine`, + }; +} + +// Revisa la dimensión del índice KB. 1 si no coincide con 1024; 0 si está bien +// o si no se pudo consultar (eso es un aviso, no tumba el diagnóstico). +async function doctorVectorize(dir, wt) { + const ok = (m) => console.log(" " + C.green("✓") + " " + m); + const warn = (m, hint) => { console.log(" " + C.yellow("⚠") + " " + m); if (hint) console.log(" " + C.dim(hint)); }; + const kbName = (wt.match(/index_name\s*=\s*["']([^"']+)/) || [])[1] || ""; + if (!kbName || !/^[A-Za-z0-9_-]+$/.test(kbName)) return 0; + const en = L === "en"; + let raw = ""; + try { + raw = execFileSync("npx", ["wrangler", "vectorize", "info", kbName, "--json"], { + cwd: dir, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + timeout: 20_000, + shell: process.platform === "win32", + }); + } catch { + warn( + en ? "Couldn't check the Vectorize index dimension" : "No pude revisar la dimensión del índice Vectorize", + en + ? `When you can: npx wrangler vectorize info ${kbName} --json (bge-m3 needs ${EXPECTED_VECTORIZE_DIMENSIONS})` + : `Cuando puedas: npx wrangler vectorize info ${kbName} --json (bge-m3 necesita ${EXPECTED_VECTORIZE_DIMENSIONS})`, + ); + return 0; + } + const dim = parseVectorizeDimensions(String(raw), kbName); + const issue = vectorizeDimensionIssue(dim, kbName, L); + if (issue) { + warn(issue.message, issue.hint); + return 1; + } + if (dim === EXPECTED_VECTORIZE_DIMENSIONS) { + ok(en ? `Vectorize index ${kbName}: ${EXPECTED_VECTORIZE_DIMENSIONS} dimensions` : `Índice Vectorize ${kbName}: ${EXPECTED_VECTORIZE_DIMENSIONS} dimensiones`); + return 0; + } + warn( + en ? "Couldn't read the Vectorize index dimension" : "No pude leer la dimensión del índice Vectorize", + `npx wrangler vectorize info ${kbName} --json`, + ); + return 0; +} + // doctor — diagnostica el bot instalado: config local, versión, licencia y si el // worker responde. Uso recurrente: corre `npx forjabot doctor` cuando algo falle. async function cmdDoctor(dirArg, flags) { @@ -1536,6 +1647,10 @@ async function cmdDoctor(dirArg, flags) { if (botName) ok(`Nombre del negocio: ${C.cyan(botName)}`); else warn("BOT_NAME sin definir", "El bot no sabe cómo se llama tu negocio."); if (botNiche) ok(`Giro (nicho): ${C.cyan(botNiche)}`); else warn("BOT_NICHE sin definir", "El panel usará el genérico en vez del de tu giro."); + // 3b) dimensión del índice Vectorize (bge-m3 = 1024). Un índice recreado a + // mano en 768 hace que /kb/reindex falle. + problems += await doctorVectorize(dir, wt); + // 4) versión vs catálogo try { const bot = (await catalog()).find((x) => x.slug === marker.slug); @@ -2415,4 +2530,4 @@ if (IS_MAIN) { } // Exports para pruebas (no afectan el uso como CLI). -export { renderMemberConfig, stampBrandAndBrain, writeStarterConfig, select, forgeSplash, installAgentSkill, parseFlags, starterOnboarding, loadCreds, saveCreds, normalizeWorkerUrl, listenLoopback, stampBotConfig, applyBusinessFlags, writeManifest, detectLocalMods, preservedByUpdate, engineOverwriteRisk, riskyUpdateGate, gnuTarTreatsAsRemote, tarLocalPath, buildTarArchiveArgv, execTarArchive, backupIsUsable, backupBeforeUpdate }; +export { renderMemberConfig, stampBrandAndBrain, writeStarterConfig, select, forgeSplash, installAgentSkill, parseFlags, starterOnboarding, loadCreds, saveCreds, normalizeWorkerUrl, listenLoopback, stampBotConfig, applyBusinessFlags, writeManifest, detectLocalMods, preservedByUpdate, engineOverwriteRisk, riskyUpdateGate, gnuTarTreatsAsRemote, tarLocalPath, buildTarArchiveArgv, execTarArchive, backupIsUsable, backupBeforeUpdate, parseVectorizeDimensions, vectorizeDimensionIssue }; diff --git a/scripts/deploy-check.ts b/scripts/deploy-check.ts index 536a6a99..fba8c347 100644 --- a/scripts/deploy-check.ts +++ b/scripts/deploy-check.ts @@ -48,6 +48,65 @@ export function validateDeployConfig(cfg: DeployConfig): DeployCheckResult { return { ok: errors.length === 0, errors }; } +/** + * Quita comentarios TOML: la línea entera si (tras espacios) empieza con `#`, + * y el `# ...` final cuando el `#` no está dentro de comillas. Un placeholder + * que solo vive en un comentario no es config sin llenar. + */ +export function tomlWithoutComments(toml: string): string { + return toml + .split(/\r?\n/) + .map((line) => { + if (line.trimStart().startsWith("#")) return ""; + let inSingle = false; + let inDouble = false; + let escaped = false; + for (let i = 0; i < line.length; i++) { + const ch = line[i]; + if (escaped) { + escaped = false; + continue; + } + if (ch === "\\" && inDouble) { + escaped = true; + continue; + } + if (ch === '"' && !inSingle) { + inDouble = !inDouble; + continue; + } + if (ch === "'" && !inDouble) { + inSingle = !inSingle; + continue; + } + if (ch === "#" && !inSingle && !inDouble) return line.slice(0, i); + } + return line; + }) + .join("\n"); +} + +/** Placeholders `{{NAME}}` que siguen en valores, no solo en comentarios. */ +export function unfilledPlaceholders(toml: string): string[] { + const code = tomlWithoutComments(toml); + const seen = new Set(); + const out: string[] = []; + for (const m of code.matchAll(/\{\{[A-Za-z0-9_]+\}\}/g)) { + if (seen.has(m[0])) continue; + seen.add(m[0]); + out.push(m[0]); + } + return out; +} + +export function wranglerPlaceholderErrors(toml: string): string[] { + const found = unfilledPlaceholders(toml); + if (found.length === 0) return []; + return [ + `Quedan placeholders sin llenar en wrangler.toml: ${found.join(", ")}. Reemplázalos por el valor real — un comentario que los mencione no cuenta.`, + ]; +} + // Run as a script: read from process.env and exit non-zero on failure. // Guarded so importing this module in tests does not call process.exit. declare const process: { env: Record; exit(code: number): never }; @@ -63,6 +122,7 @@ if (isMain) { // del primer deploy (FASE 3) y la llave de IA puede ponerse desde el panel — // así que canal/llave son AVISOS, no errores que bloqueen. const cfg: DeployConfig = { ...process.env }; + let placeholderErrors: string[] = []; try { const { readFileSync } = await import("node:fs"); @@ -70,6 +130,9 @@ if (isMain) { for (const k of ["BOT_NAME", "BOT_TIER"] as const) { if (!cfg[k]) cfg[k] = toml.match(new RegExp(`^${k}\\s*=\\s*"([^"]*)"`, "m"))?.[1]; } + // Busca {{...}} solo fuera de comentarios. Una línea `# ... {{D1_DATABASE_ID}}` + // (o un `#` final) no bloquea el deploy si el valor real ya está puesto. + placeholderErrors = wranglerPlaceholderErrors(toml); } catch { /* sin wrangler.toml: lo reportará el error de BOT_TIER */ } try { @@ -80,7 +143,7 @@ if (isMain) { } } catch { /* sin red o sin login: seguimos solo con lo local */ } - const errors: string[] = []; + const errors: string[] = [...placeholderErrors]; const warnings: string[] = []; if (!cfg.BOT_NAME) errors.push("Falta BOT_NAME en wrangler.toml."); if (!cfg.BOT_TIER) errors.push("Falta BOT_TIER ('free' | 'pro') en wrangler.toml."); diff --git a/src/admin/routes.ts b/src/admin/routes.ts index 05219fb4..90c78728 100644 --- a/src/admin/routes.ts +++ b/src/admin/routes.ts @@ -39,6 +39,7 @@ import { analyzeConversations } from "../insights/analyzer"; import { renderAgentePage, renderAgenteCanvas, renderNodeModal, toggleTool, toastOob } from "./views/agente"; import { renderKbList, renderKbEditor } from "./views/kb"; import { KbDocsRepo, indexDoc, removeDocVectors, reindexAll, MAX_DOC_CHARS } from "../kb/docs"; +import { VectorizeDimensionError } from "../kb/reindex"; import { renderMejoras } from "./views/mejoras"; import { runFlywheel, getLessons, saveLessons } from "../flywheel/detect"; import { applySuggestion, dismissSuggestion } from "../flywheel/apply"; @@ -162,8 +163,13 @@ adminApp.post("/kb/:id/delete", async (c) => { // Global reindex: repo fixtures + every dashboard doc. adminApp.post("/kb/reindex", async (c) => { - const r = await reindexAll(c.env); - return c.redirect(`/admin/kb?reindexed=${r.indexed}`); + try { + const r = await reindexAll(c.env); + return c.redirect(`/admin/kb?reindexed=${r.indexed}`); + } catch (e) { + if (e instanceof VectorizeDimensionError) return c.text(e.message, 400); + throw e; + } }); // --- Handoff: plantilla HSM del aviso al dueño --------------------------------- diff --git a/src/index.ts b/src/index.ts index 45c61d68..5a49f49b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,7 +9,7 @@ import { parseWhatsAppEvents, serveWhatsAppMedia } from "./channels/whatsapp"; import { adminApp } from "./admin/routes"; import { purgeOldMessages } from "./crons/purgeOldMessages"; import { DAILY_CRON, isNightlyTick } from "./crons/schedule"; -import { reindexKb } from "./kb/reindex"; +import { reindexKb, VectorizeDimensionError } from "./kb/reindex"; import { analyzeConversations } from "./insights/analyzer"; import { Db } from "./db/client"; import { SettingsRepo, SETTING_KEYS } from "./db/settings"; @@ -239,8 +239,15 @@ app.post("/kb/reindex", async (c) => { if (!tokensMatch(provided, expected)) { return c.json({ ok: false, error: "unauthorized" }, 401); } - const r = await reindexKb(c.env); - return c.json({ ok: true, indexed: r.indexed }, 200); + try { + const r = await reindexKb(c.env); + return c.json({ ok: true, indexed: r.indexed }, 200); + } catch (e) { + if (e instanceof VectorizeDimensionError) { + return c.json({ ok: false, error: "dimension_mismatch", message: e.message }, 400); + } + throw e; + } }); app.notFound((c) => c.text("not found", 404)); diff --git a/src/kb/reindex.ts b/src/kb/reindex.ts index ca1ff4c8..fc7eb0de 100644 --- a/src/kb/reindex.ts +++ b/src/kb/reindex.ts @@ -26,6 +26,77 @@ export interface KbChunk { const BATCH_SIZE = 100; +/** bge-m3 (`@cf/baai/bge-m3`) siempre devuelve vectores de este largo. */ +export const EMBEDDING_DIMENSIONS = 1024; + +export class VectorizeDimensionError extends Error { + constructor(message: string) { + super(message); + this.name = "VectorizeDimensionError"; + } +} + +/** Junta message/cause para reconocer el error aunque Vectorize lo anide. */ +export function errorText(err: unknown): string { + const parts: string[] = []; + const seen = new Set(); + let cur: unknown = err; + while (cur != null && !seen.has(cur) && parts.length < 6) { + seen.add(cur); + if (typeof cur === "string") { + parts.push(cur); + break; + } + if (cur instanceof Error) { + parts.push(cur.message); + cur = cur.cause; + continue; + } + if (typeof cur === "object") { + const o = cur as { message?: unknown; error?: unknown; cause?: unknown }; + if (typeof o.message === "string") parts.push(o.message); + else if (typeof o.error === "string") parts.push(o.error); + cur = o.cause; + continue; + } + parts.push(String(cur)); + break; + } + return parts.join(" "); +} + +export function isVectorizeDimensionMismatch(err: unknown): boolean { + const msg = errorText(err); + if (!/dimension/i.test(msg)) return false; + return /mismatch|do not match|does not match|don't match|no coincide|expected\s+\d+|got\s+\d+/i.test(msg); +} + +/** Mensaje accionable: la dimensión del índice no es la de bge-m3. */ +export function dimensionMismatchMessage(err?: unknown): string { + const raw = err == null ? "" : errorText(err); + const expected = raw.match(/expected\s+(\d+)/i)?.[1]; + const detail = + expected && expected !== String(EMBEDDING_DIMENSIONS) + ? `tiene ${expected} dimensiones y no coincide con las ${EMBEDDING_DIMENSIONS} de bge-m3` + : `no coincide con las ${EMBEDDING_DIMENSIONS} dimensiones de bge-m3`; + return `El índice de Vectorize ${detail}. Recréalo con --dimensions=${EMBEDDING_DIMENSIONS} --metric=cosine.`; +} + +type IndexDetails = { dimensions?: number; config?: { dimensions?: number } }; + +/** `describe()` existe en el binding de Vectorize; los mocks de test no siempre. */ +async function readIndexDimensions(kb: Env["KB"]): Promise { + const describe = (kb as Env["KB"] & { describe?: () => Promise }).describe; + if (typeof describe !== "function") return null; + try { + const info = await describe.call(kb); + const raw = info?.dimensions ?? info?.config?.dimensions; + return typeof raw === "number" ? raw : null; + } catch { + return null; + } +} + export async function reindexKb( env: Env, chunks: KbChunk[] = kbChunks as KbChunk[], @@ -36,6 +107,11 @@ export async function reindexKb( let indexed = 0; + const dim = await readIndexDimensions(env.KB); + if (dim != null && dim !== EMBEDDING_DIMENSIONS) { + throw new VectorizeDimensionError(dimensionMismatchMessage(`expected ${dim}`)); + } + for (let start = 0; start < chunks.length; start += BATCH_SIZE) { const batch = chunks.slice(start, start + BATCH_SIZE); try { @@ -53,10 +129,13 @@ export async function reindexKb( await env.KB.upsert(vectors); indexed += vectors.length; } catch (e: unknown) { - const msg = e instanceof Error ? e.message : String(e); + const msg = errorText(e); console.error( `reindexKb: batch at offset ${start} (size ${batch.length}) failed: ${msg}`, ); + if (isVectorizeDimensionMismatch(e)) { + throw new VectorizeDimensionError(dimensionMismatchMessage(e)); + } throw e; } } diff --git a/test/admin/kb-routes.test.ts b/test/admin/kb-routes.test.ts index 1914fdd1..9ab52fc4 100644 --- a/test/admin/kb-routes.test.ts +++ b/test/admin/kb-routes.test.ts @@ -139,6 +139,22 @@ describe("KB tab", () => { const res = await adminApp.request("/kb", {}, env); expect(res.status).toBe(401); }); + + it("reindex tells you to recreate the index when the dimension does not match", async () => { + await adminApp.request( + "/kb/save", + { method: "POST", headers: FORM, body: new URLSearchParams({ title: "Horarios", content: "Abrimos 9 a 7." }) }, + env, + ); + kbUpsert.mockRejectedValueOnce(new Error("Vector dimension mismatch: expected 768, got 1024")); + + const res = await adminApp.request("/kb/reindex", { method: "POST", headers: AUTH }, env); + expect(res.status).toBe(400); + const text = await res.text(); + expect(text).toContain("768"); + expect(text).toContain("--dimensions=1024 --metric=cosine"); + expect(text).not.toContain("Internal Server Error"); + }); }); describe("budget save route", () => { diff --git a/test/cli/vectorize-dimension.test.ts b/test/cli/vectorize-dimension.test.ts new file mode 100644 index 00000000..c471df61 --- /dev/null +++ b/test/cli/vectorize-dimension.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect } from "vitest"; +import { parseVectorizeDimensions, vectorizeDimensionIssue } from "../../cli/bin/cli.js"; + +const INFO_JSON = JSON.stringify({ + dimensions: 768, + processedUpToDatetime: "2024-07-19T13:11:44.064Z", + processedUpToMutation: "7f11d6e5-d126-4f76-936e-fbfec079e0be", + vectorCount: 12, +}); + +const INFO_TABLE = ` +📋 Fetching index info... +┌────────────┬─────────────┬──────────────────────────────────────┬──────────────────────────┐ +│ dimensions │ vectorCount │ processedUpToMutation │ processedUpToDatetime │ +├────────────┼─────────────┼──────────────────────────────────────┼──────────────────────────┤ +│ 768 │ 12 │ 7f11d6e5-d126-4f76-936e-fbfec079e0be │ 2024-07-19T13:11:44.064Z │ +└────────────┴─────────────┴──────────────────────────────────────┴──────────────────────────┘ +`; + +describe("parseVectorizeDimensions", () => { + it("reads dimensions from wrangler vectorize info --json", () => { + expect(parseVectorizeDimensions(INFO_JSON, "horizontes_bot_kb")).toBe(768); + }); + + it("reads dimensions from the text table", () => { + expect(parseVectorizeDimensions(INFO_TABLE)).toBe(768); + }); + + it("picks the named index from a list payload", () => { + const list = JSON.stringify([ + { name: "otro", dimensions: 1536 }, + { name: "horizontes_bot_kb", dimensions: 1024 }, + ]); + expect(parseVectorizeDimensions(list, "horizontes_bot_kb")).toBe(1024); + }); + + it("returns null when the output has no dimension", () => { + expect(parseVectorizeDimensions("not logged in")).toBeNull(); + }); +}); + +describe("vectorizeDimensionIssue", () => { + it("warns when the index is not 1024 and says how to recreate it", () => { + const issue = vectorizeDimensionIssue(768, "horizontes_bot_kb", "es"); + expect(issue?.message).toContain("768"); + expect(issue?.message).toContain("1024"); + expect(issue?.hint).toContain("--dimensions=1024 --metric=cosine"); + expect(issue?.hint).toContain("horizontes_bot_kb"); + }); + + it("stays quiet when the index is already 1024", () => { + expect(vectorizeDimensionIssue(1024, "horizontes_bot_kb", "es")).toBeNull(); + }); + + it("stays quiet when the dimension is unknown", () => { + expect(vectorizeDimensionIssue(null, "horizontes_bot_kb", "es")).toBeNull(); + }); +}); diff --git a/test/index.test.ts b/test/index.test.ts index d5a3150a..b68c28fc 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -9,6 +9,7 @@ import { describe, it, expect, vi } from "vitest"; vi.mock("agents", () => ({ Agent: class {} })); import worker from "../src/index"; +import { VectorizeDimensionError } from "../src/kb/reindex"; describe("Worker entry", () => { const env = { @@ -65,5 +66,40 @@ describe("Worker entry", () => { expect(warn).not.toHaveBeenCalled(); warn.mockRestore(); }); + + it("devuelve un mensaje claro si el índice de Vectorize no es de 1024", async () => { + const spy = vi.spyOn(await import("../src/kb/reindex"), "reindexKb").mockRejectedValueOnce( + new VectorizeDimensionError( + "El índice de Vectorize tiene 768 dimensiones y no coincide con las 1024 de bge-m3. Recréalo con --dimensions=1024 --metric=cosine.", + ), + ); + const res = await pedir( + { "X-Reindex-Token": "el-bueno" }, + { ...env, KB_REINDEX_TOKEN: "el-bueno" }, + ); + + expect(res.status).toBe(400); + const body = (await res.json()) as { ok: boolean; error: string; message: string }; + expect(body.ok).toBe(false); + expect(body.error).toBe("dimension_mismatch"); + expect(body.message).toContain("1024"); + expect(body.message).toContain("--dimensions=1024 --metric=cosine"); + expect(body.message).not.toBe("Internal Server Error"); + spy.mockRestore(); + }); + + it("sigue respondiendo 500 si el reindex falla por otra cosa", async () => { + const spy = vi.spyOn(await import("../src/kb/reindex"), "reindexKb").mockRejectedValueOnce( + new Error("network down"), + ); + const err = vi.spyOn(console, "error").mockImplementation(() => {}); + const res = await pedir( + { "X-Reindex-Token": "el-bueno" }, + { ...env, KB_REINDEX_TOKEN: "el-bueno" }, + ); + expect(res.status).toBe(500); + spy.mockRestore(); + err.mockRestore(); + }); }); }); diff --git a/test/kb/reindex.test.ts b/test/kb/reindex.test.ts index fb7691dc..223f69cb 100644 --- a/test/kb/reindex.test.ts +++ b/test/kb/reindex.test.ts @@ -1,5 +1,10 @@ import { describe, it, expect, vi } from "vitest"; -import { reindexKb, type KbChunk } from "../../src/kb/reindex"; +import { + reindexKb, + VectorizeDimensionError, + isVectorizeDimensionMismatch, + type KbChunk, +} from "../../src/kb/reindex"; import type { Env } from "../../src/env"; const DIM = 1024; @@ -102,4 +107,54 @@ describe("reindexKb", () => { expect(upsert.mock.calls[1][0].length).toBe(100); expect(upsert.mock.calls[2][0].length).toBe(50); }); + + it("accepts describe() when the index is already 1024", async () => { + const { env, upsert } = makeEnv(); + (env.KB as unknown as { describe: () => Promise<{ dimensions: number }> }).describe = + async () => ({ dimensions: 1024 }); + await expect(reindexKb(env, makeChunks(1))).resolves.toEqual({ indexed: 1 }); + expect(upsert).toHaveBeenCalledTimes(1); + }); + + it("reads dimensions from the beta describe() shape (config.dimensions)", async () => { + const { env, upsert } = makeEnv(); + (env.KB as unknown as { describe: () => Promise<{ config: { dimensions: number } }> }).describe = + async () => ({ config: { dimensions: 768 } }); + await expect(reindexKb(env, makeChunks(1))).rejects.toThrow(/768/); + expect(upsert).not.toHaveBeenCalled(); + }); + + it("rejects before upsert when describe() says the index is not 1024", async () => { + const { env, upsert } = makeEnv(); + (env.KB as unknown as { describe: () => Promise<{ dimensions: number }> }).describe = + async () => ({ dimensions: 768 }); + + await expect(reindexKb(env, makeChunks(1))).rejects.toBeInstanceOf(VectorizeDimensionError); + await expect(reindexKb(env, makeChunks(1))).rejects.toThrow(/768/); + await expect(reindexKb(env, makeChunks(1))).rejects.toThrow(/--dimensions=1024 --metric=cosine/); + expect(upsert).not.toHaveBeenCalled(); + }); + + it("translates a Vectorize upsert dimension mismatch into an actionable error", async () => { + const { env, upsert } = makeEnv(); + upsert.mockRejectedValue(new Error("Vector dimension mismatch: expected 768, got 1024")); + + const err = await reindexKb(env, makeChunks(2)).catch((e: unknown) => e); + expect(err).toBeInstanceOf(VectorizeDimensionError); + expect((err as Error).message).toContain("768"); + expect((err as Error).message).toContain("--dimensions=1024 --metric=cosine"); + }); + + it("recognizes a nested 'dimensions do not match' error", () => { + const err = new Error("upsert failed", { + cause: new Error("Vector dimensions do not match index configuration"), + }); + expect(isVectorizeDimensionMismatch(err)).toBe(true); + }); + + it("still rethrows errors that are not a dimension mismatch", async () => { + const { env, upsert } = makeEnv(); + upsert.mockRejectedValueOnce(new Error("network down")); + await expect(reindexKb(env, makeChunks(1))).rejects.toThrow("network down"); + }); }); diff --git a/test/scripts/deploy-check.test.ts b/test/scripts/deploy-check.test.ts index 90466256..d2e81713 100644 --- a/test/scripts/deploy-check.test.ts +++ b/test/scripts/deploy-check.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { validateDeployConfig } from "../../scripts/deploy-check"; +import { validateDeployConfig, unfilledPlaceholders, wranglerPlaceholderErrors } from "../../scripts/deploy-check"; describe("validateDeployConfig", () => { const full = { @@ -40,3 +40,42 @@ describe("validateDeployConfig", () => { expect(r.errors.join(" ")).toContain("DASHBOARD_PASSWORD"); }); }); + +const UUID = "11111111-2222-4333-8444-555555555555"; + +describe("unfilledPlaceholders", () => { + it("ignores a placeholder that only appears in a full-line comment", () => { + const toml = `# El skill menciona {{D1_DATABASE_ID}} en este comentario\ndatabase_id = "${UUID}"\n`; + expect(unfilledPlaceholders(toml)).toEqual([]); + expect(wranglerPlaceholderErrors(toml)).toEqual([]); + }); + + it("ignores an indented comment and a trailing comment", () => { + const toml = [ + ` # no reemplazar aquí {{D1_DATABASE_ID}}`, + `database_id = "${UUID}" # antes era {{D1_DATABASE_ID}}`, + `BOT_NAME = "Tacos Ana"`, + ].join("\n"); + expect(unfilledPlaceholders(toml)).toEqual([]); + }); + + it("still flags a real unfilled placeholder, including one with a trailing comment", () => { + const toml = [ + `# comentario {{BOT_SLUG}}`, + `database_id = "{{D1_DATABASE_ID}}" # filled in by skill at deploy time`, + `BOT_NAME = "{{BOT_NAME}}"`, + `BOT_NAME = "{{BOT_NAME}}"`, + ].join("\n"); + expect(unfilledPlaceholders(toml)).toEqual(["{{D1_DATABASE_ID}}", "{{BOT_NAME}}"]); + const errors = wranglerPlaceholderErrors(toml); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain("{{D1_DATABASE_ID}}"); + expect(errors[0]).toContain("{{BOT_NAME}}"); + expect(errors[0]).not.toContain("{{BOT_SLUG}}"); + }); + + it("does not treat a # inside a quoted value as a comment", () => { + const toml = `note = "mira {{BOT_NAME}} # esto sigue siendo el valor"\n`; + expect(unfilledPlaceholders(toml)).toEqual(["{{BOT_NAME}}"]); + }); +});