From fbdd4f20293dfea58490791a11d77aa09ab2e3a8 Mon Sep 17 00:00:00 2001 From: ErickHub192 Date: Mon, 10 Aug 2026 11:04:32 -0600 Subject: [PATCH] Un reporte de si Multi se esta usando Sin datos, "ver si lo usan" se queda en corazonadas. `npm run uso` lee la BD del server y contesta lo que importa: cuantas salas se crearon, cuantas llegaron a usar un agente, quien volvio otro dia, y en cuantas escribio mas de una persona. Esas son las preguntas del producto, no las de una analitica web. Las visitas no dicen si Multi sirve; que alguien vuelva al dia siguiente, si. Y en cuantas salas hay dos personas es la promesa del producto cumpliendose o no: si sale cero, lo estan usando como un chat de IA cualquiera. Sale de la BD que ya existe, sin dependencias ni servicios nuevos. Se abre con `immutable=1`, no readOnly: asi no toma bloqueos y no puede estorbar al server aunque se corra con Multi encendido (y de paso funciona sobre el disco de Windows montado en WSL, donde un readOnly normal muere con "disk I/O error"). A cambio no aplica el WAL, asi que pueden faltar los ultimos minutos. Para contar uso da igual. Lo que NO ve: cuanta gente abrio el link y se fue sin crear nada. Ese lado del embudo necesitaria analitica de verdad. Co-Authored-By: Claude Opus 5 --- package.json | 3 +- server/package.json | 3 +- server/src/demos/uso.ts | 214 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 218 insertions(+), 2 deletions(-) create mode 100644 server/src/demos/uso.ts diff --git a/package.json b/package.json index 75b02d5..39c3923 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,8 @@ "dev:test": "npm run dev:test -w server", "demo:presupuesto": "npm run demo:presupuesto -w server", "demo:turno-cortado": "npm run demo:turno-cortado -w server", - "demo:exportar": "npm run demo:exportar -w server" + "demo:exportar": "npm run demo:exportar -w server", + "uso": "npm run uso -w server --" }, "engines": { "node": ">=22" diff --git a/server/package.json b/server/package.json index 5e89834..f224a4e 100644 --- a/server/package.json +++ b/server/package.json @@ -25,7 +25,8 @@ "demo:turno-cortado": "tsx src/demos/turno-cortado.ts", "demo:imagenes": "tsx src/demos/imagenes.ts", "demo:proxy-rutas": "tsx src/demos/proxy-rutas.ts", - "demo:exportar": "tsx src/demos/exportar.ts" + "demo:exportar": "tsx src/demos/exportar.ts", + "uso": "tsx --no-warnings src/demos/uso.ts" }, "dependencies": { "@fastify/cors": "^11.3.0", diff --git a/server/src/demos/uso.ts b/server/src/demos/uso.ts new file mode 100644 index 0000000..8276e9c --- /dev/null +++ b/server/src/demos/uso.ts @@ -0,0 +1,214 @@ +import { DatabaseSync } from "node:sqlite"; +import { join } from "node:path"; +import { existsSync } from "node:fs"; + +/** + * Qué está pasando en Multi: quién entra, quién construye, quién vuelve. + * Uso: npm run uso (los últimos 7 días) + * npm run uso -- 30 (los últimos 30) + * + * Sale de la misma BD que usa el server, en solo lectura. No es analítica web: + * las visitas no dicen si Multi sirve. Lo que importa es si la gente le pide + * cosas a un agente y si vuelve al día siguiente, y eso ya está guardado. + * + * Lo que NO se puede saber desde aquí: cuánta gente abrió el link y se fue sin + * crear nada. Para ese lado del embudo haría falta analítica de verdad. + */ + +const DB_PATH = process.env.MULTI_DB ?? join(process.cwd(), "..", "workspaces", "multi.db"); +const DIAS = Number(process.argv[2] ?? 7); +const DESDE = Date.now() - DIAS * 24 * 60 * 60 * 1000; +const DIA = 24 * 60 * 60 * 1000; + +function main() { + if (!existsSync(DB_PATH)) { + console.error(`no encontré la base en ${DB_PATH}`); + console.error("(¿lo estás corriendo desde la raíz del repo?)"); + process.exit(1); + } + + /** + * `immutable=1`, no un simple readOnly. + * + * Le dice a SQLite que el archivo no cambia, así que no toma bloqueos ni + * intenta recuperar el WAL. Dos cosas se ganan: este reporte no puede + * estorbar al server aunque corra con Multi encendido, y funciona sobre el + * disco de Windows montado en WSL, donde la memoria compartida del WAL no + * anda y un `readOnly` normal muere con "disk I/O error". + * + * Lo que se paga: lee el .db sin aplicar el WAL, así que los últimos minutos + * pueden faltar si el server acaba de escribir. Para contar uso da igual; + * para algo que dependa del dato exacto de hace un segundo, no serviría. + */ + // `enableURI` existe en node:sqlite pero todavía no en sus tipos (el módulo + // sigue marcado como experimental), así que el cast es contra el desfase, no + // contra la API. + const db = new DatabaseSync(`file:${DB_PATH}?immutable=1`, { + readOnly: true, + enableURI: true, + } as ConstructorParameters[1]); + const uno = (sql: string, ...args: unknown[]): T => + (db.prepare(sql).get(...(args as never[])) as T) ?? ({} as T); + const todos = (sql: string, ...args: unknown[]): T[] => + db.prepare(sql).all(...(args as never[])) as T[]; + + console.log(`\n=== Multi, últimos ${DIAS} días ===\n`); + + // ── Lo que se creó ──────────────────────────────────────────────────────── + const salas = uno<{ c: number }>(`SELECT COUNT(*) c FROM rooms WHERE created_at >= ?`, DESDE).c; + const salasTotal = uno<{ c: number }>(`SELECT COUNT(*) c FROM rooms`).c; + + /** + * Una sala "usada" es donde alguien le habló a un agente. + * + * Crear una sala es un click; pedirle algo a un agente es la primera vez que + * Multi hace lo que promete. La diferencia entre los dos números es la gente + * que entró, miró y se fue. + */ + const salasUsadas = uno<{ c: number }>( + `SELECT COUNT(DISTINCT room_id) c FROM messages WHERE role = 'agent' AND created_at >= ?`, + DESDE, + ).c; + + console.log(`Salas creadas: ${salas} (${salasTotal} en total)`); + console.log(`Salas con un agente: ${salasUsadas}${salas > 0 ? ` (${Math.round((salasUsadas / salas) * 100)}% de las nuevas)` : ""}`); + + // ── La gente ────────────────────────────────────────────────────────────── + /** + * Sin cuentas, la identidad es el nombre que cada quien escribe al entrar. + * Dos personas que pongan "juan" cuentan como una, y quien se cambie el + * nombre cuenta doble. Para saber si Multi se usa alcanza; para cobrar, no. + */ + const personas = todos<{ author: string; mensajes: number; salas: number; ultimo: number }>( + `SELECT author, + COUNT(*) mensajes, + COUNT(DISTINCT room_id) salas, + MAX(created_at) ultimo + FROM messages + WHERE role = 'human' AND created_at >= ? + GROUP BY author + ORDER BY mensajes DESC`, + DESDE, + ); + + console.log(`Personas distintas: ${personas.length}`); + + // ── Lo que se le pidió a los agentes ────────────────────────────────────── + const turnos = uno<{ c: number }>( + `SELECT COUNT(*) c FROM messages WHERE role = 'agent' AND created_at >= ?`, + DESDE, + ).c; + /** + * `adjuntos` llegó después, y una BD de antes no la tiene: el server la + * agrega al arrancar, pero este reporte abre en immutable y no migra nada. + * Se pregunta por el esquema en vez de suponerlo, para que una base vieja no + * tumbe el reporte entero por una sola línea. + */ + const tieneAdjuntos = todos<{ name: string }>(`PRAGMA table_info(messages)`).some( + (c) => c.name === "adjuntos", + ); + const conImagen = tieneAdjuntos + ? uno<{ c: number }>( + `SELECT COUNT(*) c FROM messages WHERE adjuntos IS NOT NULL AND created_at >= ?`, + DESDE, + ).c + : null; + + console.log(`Respuestas de agentes: ${turnos}`); + if (conImagen !== null) console.log(`Mensajes con imagen: ${conImagen}`); + + // ── Quién volvió ────────────────────────────────────────────────────────── + /** + * La métrica que de verdad contesta "¿les sirvió?". + * + * Volver otro día es la señal más honesta que hay: nadie regresa por + * compromiso a una herramienta que no le resolvió nada. Se cuentan días + * distintos y no sesiones, porque entrar dos veces la misma tarde es la misma + * ocasión de uso. + */ + const vueltas = todos<{ author: string; dias: number }>( + `SELECT author, COUNT(DISTINCT CAST(created_at / ? AS INTEGER)) dias + FROM messages + WHERE role = 'human' AND created_at >= ? + GROUP BY author + HAVING dias > 1 + ORDER BY dias DESC`, + DIA, + DESDE, + ); + + console.log(`Volvieron otro día: ${vueltas.length} de ${personas.length}`); + + // ── Salas compartidas ───────────────────────────────────────────────────── + /** + * Salas donde escribió más de una persona: la promesa de Multi cumpliéndose. + * Si este número es cero, la gente lo está usando como un chat de IA + * cualquiera y el multijugador no está pasando. + */ + const compartidas = uno<{ c: number }>( + `SELECT COUNT(*) c FROM ( + SELECT room_id + FROM messages + WHERE role = 'human' AND created_at >= ? + GROUP BY room_id + HAVING COUNT(DISTINCT author) > 1 + )`, + DESDE, + ).c; + + console.log(`Salas con 2+ personas: ${compartidas}${salasUsadas > 0 ? ` (de ${salasUsadas} usadas)` : ""}`); + + // ── El detalle ──────────────────────────────────────────────────────────── + if (personas.length > 0) { + console.log(`\n--- quién ---`); + for (const p of personas.slice(0, 15)) { + const dias = vueltas.find((v) => v.author === p.author)?.dias ?? 1; + console.log( + ` ${p.author.padEnd(16)} ${String(p.mensajes).padStart(4)} mensajes ` + + `${String(p.salas).padStart(2)} sala(s) ${dias} día(s) · ${hace(p.ultimo)}`, + ); + } + } + + // Por día, para ver si crece o se apaga. + const porDia = todos<{ dia: number; mensajes: number; gente: number }>( + `SELECT CAST(created_at / ? AS INTEGER) dia, + COUNT(*) mensajes, + COUNT(DISTINCT author) gente + FROM messages + WHERE role = 'human' AND created_at >= ? + GROUP BY dia + ORDER BY dia`, + DIA, + DESDE, + ); + + if (porDia.length > 0) { + console.log(`\n--- por día ---`); + const tope = Math.max(...porDia.map((d) => d.mensajes)); + for (const d of porDia) { + const fecha = new Date(d.dia * DIA).toISOString().slice(0, 10); + // Una barra de texto dice de un vistazo lo que una columna de números no. + const barra = "#".repeat(Math.max(1, Math.round((d.mensajes / tope) * 30))); + console.log(` ${fecha} ${barra} ${d.mensajes} (${d.gente} personas)`); + } + } + + if (salas === 0) { + console.log(`\nNadie ha creado una sala en estos ${DIAS} días.`); + } + + console.log(); + db.close(); +} + +/** Cuánto hace, en palabras. Una fecha exacta aquí no dice nada útil. */ +function hace(ms: number): string { + const min = Math.floor((Date.now() - ms) / 60000); + if (min < 60) return `hace ${min} min`; + const h = Math.floor(min / 60); + if (h < 24) return `hace ${h} h`; + return `hace ${Math.floor(h / 24)} día(s)`; +} + +main();