r16: guard R9 con terminos de la fuente (no dominio generico) + doc de sesion

This commit is contained in:
patternn
2026-11-02 04:50:38 +00:00
parent dd8e933ba5
commit 6a16c64f45
2 changed files with 73 additions and 9 deletions
+48 -9
View File
@@ -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)
}