Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions cli/bin/cli.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
117 changes: 116 additions & 1 deletion cli/bin/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 };
65 changes: 64 additions & 1 deletion scripts/deploy-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>();
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<string, string | undefined>; exit(code: number): never };
Expand All @@ -63,13 +122,17 @@ 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");
const toml = readFileSync("wrangler.toml", "utf8");
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 {
Expand All @@ -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.");
Expand Down
10 changes: 8 additions & 2 deletions src/admin/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 ---------------------------------
Expand Down
13 changes: 10 additions & 3 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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));
Expand Down
81 changes: 80 additions & 1 deletion src/kb/reindex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<unknown>();
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<number | null> {
const describe = (kb as Env["KB"] & { describe?: () => Promise<IndexDetails> }).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[],
Expand All @@ -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 {
Expand All @@ -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;
}
}
Expand Down
Loading
Loading