diff --git a/docs/sessions/2026-11-02-r16-verificacion.md b/docs/sessions/2026-11-02-r16-verificacion.md new file mode 100644 index 0000000..c82b3c9 --- /dev/null +++ b/docs/sessions/2026-11-02-r16-verificacion.md @@ -0,0 +1,25 @@ +# Ronda 16 — Verificación (OpenCode/GLM en m1, operador patternn) + +## Hallazgos +- EXPOSICION previa: secretos en juego (nombres, nunca valores): core .env → ZAI_API_KEY, PGURL; mail .env → MAIL_PASS, GITEA_TOKEN. Rotación PENDIENTE (decisión del operador). Token de Gitea también visible en `git remote get-url origin` de cayce-ops (remote con credencial embebida). +- VOZ: main y despliegue = ff2cd822 (v2 vieja). PR #23 (voz v3) OPEN, sin merge. identity_path real verificado en outbound_drafts. +- PUBLICADO 48h: 4 piezas nostr con event id (2 verificables en relays: 99cc75f4, 449a39bf; 8a81c688 y 09eb4e9a NO están en relays configurados) + hardcap article_note/backlog. La opinion 04:01 (BTCPay) tenia fuente de WPR (planta de gas Wisconsin): mala atribución real. +- actions_log de core.mjs --posts registra "ok" aunque el guard rechace (falso ok). Bug abierto. + +## Cambios (desplegados y con tests 27/27) +- llm.mjs (core + copia cayce-nostr): SOLO Gemini, fuera zai/glm-4.6/4.7. La copia del daemon apuntaba a /srv/hardcap-backend/.env inexistente (sin fallback). Daemon cayce-nostr reiniciado (guard/llm nuevos activos). +- guard.mjs R9 reescrito: terminos ESPECIFICOS de la fuente (slug del titular + sourceTitle: nombres propios, cifras ≥3 dígitos, keywords ≥5 chars con stopwords), ids opacos excluidos. Fuera vocabulario DOMAIN genérico. guardCheck acepta sourceTitle; outbox lo pasa desde meta. +- Tests: R16a (caso real BTCPay/Wisconsin → rechazado), R16b (id opaco no bloquea), R16c (sourceTitle verifica). 27/27 verde. +- Prueba en vivo: pieza generada (BTCPay) con fuente theblock.co/etf-binance → RECHAZADA por R9 en tiempo real. + +## Timing ciclo posts (Gemini-only) +- selectSource 61ms · selectBatch 2ms · fetch artículos 333ms · gemini 10.0s · guard 4ms = 10.4s/post (instrumentado). +- Run systemd real: opinion 50.3s (start 04:08:27.257 → spacing 04:09:17.528), rechazado por tope 3/3 + aforismo. +- Ciclo --posts completo = 3 posts + 2 esperas de 45 MIN por diseño (regla supervisor) ≈ 92 min. El objetivo <5 min aplica al procesamiento (✓ 10-50s/post), no al ciclo con espaciado. + +## Pendientes +- Rotar ZAI_API_KEY, PGURL, MAIL_PASS, GITEA_TOKEN (y token embebido en remote de cayce-ops). +- Merges Gitea: #23 (voz v3), #25. +- Decidir qué hacer con /srv/cayce-entity (copia muerta: sin servicio, guard sin R9, mtime 21:08 Nov1). +- Bug falso-ok en core.mjs --posts (no chequea resultado del gate). +- cayce-promo y cayce-cycle-monitor en failed. diff --git a/runtime/guard.mjs b/runtime/guard.mjs index d82818a..61af78b 100644 --- a/runtime/guard.mjs +++ b/runtime/guard.mjs @@ -130,16 +130,55 @@ async function r5_caps(pool, channel, type, out) { } -// R9 (ronda-14): la pieza debe compartir terminos con su fuente -function r9_atribucion(full, reasons) { +// R9 (ronda-14 → ronda-16): la pieza debe compartir TERMINOS ESPECIFICOS de su +// fuente: nombres propios, cifras y palabras clave del titular (slug del URL y, +// si se pasa, sourceTitle). El vocabulario generico ("wallet", "chain") no +// verifica nada: un texto de BTCPay con fuente de una planta de gas en +// Wisconsin paso el guard el 2026-11-02 porque el cuerpo tenia palabras de +// dominio genericas. Ahora el cuerpo debe contener al menos un termino +// distintivo DE ESA FUENTE; si no, se rechaza. +const R9_STOP = new Set(['https', 'http', 'www', 'html', 'php', 'asp', 'com', 'net', 'org', 'co', 'io', 'gov', 'edu', 'news', 'article', 'articles', 'story', 'stories', 'post', 'posts', 'blog', 'index', 'page', 'pages', 'url', 'link', 'video', 'watch', 'pdf', 'summary', 'summaries', 'en', 'es', 'the', 'and', 'for', 'with', 'from', 'this', 'that', 'what', 'when', 'where', 'which', 'while', 'would', 'could', 'should', 'about', 'after', 'before', 'over', 'under', 'between', 'through', 'during', 'their', 'there', 'these', 'those', 'other', 'more', 'most', 'than', 'then', 'they', 'them', 'were', 'have', 'having', 'been', 'being', 'into', 'onto', 'upon', 'says', 'said', 'amid', 'after', 'because', 'still', 'just', 'also', 'first', 'last']) +function r9_sourceTerms(url, title) { + const terms = new Set() + // palabras clave del slug del URL (derivadas del titular): se descartan + // dominio, stopwords y ids opacos (letras+digitos, largos: no son titulares) + const slugWords = [] + for (const part of String(url || '').split(/[/?#]/).slice(3)) { + for (const w of part.split(/[-_.+]+/)) { + const t = w.toLowerCase() + if (!t || t.length < 5 || R9_STOP.has(t) || /^(19|20)\d{2}$/.test(t)) continue + if (/\d/.test(t) && /[a-z]/.test(t) && t.length >= 10) continue // id opaco + slugWords.push(t) + } + } + // cifras distintivas (>=3 digitos): del titular o del slug ya filtrado — + // nunca de dentro de un id opaco (sus digitos no son del titular) + for (const m of `${title || ''} ${slugWords.join(' ')}`.matchAll(/\d[\d.,]*/g)) { + const raw = m[0].replace(/[.,]/g, '') + if (raw.length >= 3) terms.add(raw) + } + for (const t of slugWords) terms.add(t) + // nombres propios y palabras del titular (si el ciclo lo pasa como sourceTitle) + for (const w of String(title || '').split(/[^A-Za-z0-9]+/)) { + if (!w) continue + const t = w.toLowerCase() + if (/^(19|20)\d{2}$/.test(t)) continue + if ((/^[A-Z]/.test(w) && w.length >= 3 || t.length >= 5) && !R9_STOP.has(t)) terms.add(t) + } + return terms +} +function r9_atribucion(full, reasons, sourceTitle = null) { const idx = full.indexOf('\n\n\u2014 fuente: ') if (idx === -1) return - const text = full.slice(0, idx).toLowerCase() - const words = text.split(/[^a-z0-9]+/).filter(w => w.length > 4) + const body = full.slice(0, idx).toLowerCase() + const fuente = full.slice(idx) + const words = body.split(/[^a-z0-9]+/).filter(w => w.length > 4) if (words.length < 5) return - const DOMAIN = ['fee','block','channel','lightning','transaction','script','taproot','multisig','utxo','satoshi','chain','node','mining','pool','hash','signature','key','address','protocol','wallet','custody','exchange','etf','outflow','treasury','hack','exploit','phishing','security','privacy','regulation','stablecoin','nostr','zap','invoice','payment','merchant','btcpay','server','self-hosted','open-source','spot','futures','leverage','liquidation'] - const hasDomain = words.some(w => DOMAIN.includes(w) || w.includes('bitcoin') || w.includes('lightning') || w.includes('chain') || w.includes('taproot')) - if (!hasDomain && words.length > 12) { + const url = (fuente.match(/https?:\/\/[^\s·]+/) || [''])[0] + const terms = r9_sourceTerms(url, sourceTitle) + if (!terms.size) return // fuente sin terminos distinguibles (shortener/id): R9 no puede verificar + const bodyTokens = new Set(body.split(/[^a-z0-9]+/).filter(Boolean)) + if (![...terms].some(t => bodyTokens.has(t))) { reasons.push('R9-atribucion-no-verificable') } } @@ -154,7 +193,7 @@ export function ensureGuardTables(pool) { reasons text NOT NULL, content text NOT NULL )`) } -export async function guardCheck(pool, { channel, type, content }) { +export async function guardCheck(pool, { channel, type, content, sourceTitle = null }) { const reasons = [] const full = String(content ?? '') // ronda-14: separar el texto del LLM de la línea de atribución @@ -169,7 +208,7 @@ export async function guardCheck(pool, { channel, type, content }) { r3_hechos(full, reasons) r7_frescura(channel, full, reasons) r8_cierre(body, reasons) - r9_atribucion(full, reasons) + r9_atribucion(full, reasons, sourceTitle) await r4_casiDuplicado(pool, body, reasons) await r5_caps(pool, channel, type, reasons) }