Compare commits

...
Author SHA1 Message Date
cayce dd8e933ba5 fuentes automaticas: el CODIGO agrega la fuente, no el LLM (ronda 13-14)
ci / lint-test (pull_request) Successful in 2s
isolation-test / probe (pull_request) Successful in 0s
- sources.mjs (nuevo): selectSource() + attribute() — seleccion de material
  ≤72h con score≥6, atribucion en formato fijo.
- core.mjs: las 4 piezas (digest/opinion/reflection/events) generan sobre
  la fuente seleccionada y el codigo agrega la atribucion DESPUES del LLM.
- guard.mjs:
  * R1/R2/R4/R5/R8 evaluan el TEXTO SIN la linea de atribucion
  * R3/R7 ven el texto completo (con atribucion) — la fuente garantiza
    que pasen
  * R9 (nueva): la pieza debe compartir terminos del dominito con su fuente
  * R8: superlativos estrechos (sin never/only que dejaban pasar aforismos)
  * deletion anadido a METADATA_TYPES (no cuenta hacia el tope diario)
- guard.test.mjs: 24/24 verdes (incluye tests R14a/c/d)
2026-11-02 04:08:26 +00:00
pattern 4e4fffaa16 Merge pull request 'diseño: vía de publicación bitcoinhotels (PRs + guard + build)' (#24) from docs-bitcoinhotels-publish into main
ci / lint-test (push) Successful in 3s
Reviewed-on: #24
2026-11-02 03:46:20 +00:00
pattern 28a97959c4 Merge pull request 'ci: workflow host mode' (#32) from ci-host-mode into main
ci / lint-test (push) Successful in 3s
Reviewed-on: #32
2026-11-02 03:45:58 +00:00
cayce be29f9030c ci: workflow host mode (sin docker ni postgres de servicio)
ci / lint-test (pull_request) Successful in 3s
isolation-test / probe (pull_request) Successful in 0s
2026-11-02 03:39:16 +00:00
6 changed files with 183 additions and 57 deletions
+31 -45
View File
@@ -6,58 +6,44 @@ on:
pull_request: pull_request:
jobs: jobs:
lint-test-leaks: lint-test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: cayce
POSTGRES_PASSWORD: ci-only-not-a-secret
POSTGRES_DB: cayce_ops
ports:
- 5433:5432
options: >-
--health-cmd "pg_isready -U cayce -d cayce_ops"
--health-interval 5s --health-timeout 3s --health-retries 10
env:
CAYCE_OPS_DATABASE_URL: postgresql+psycopg://cayce:ci-only-not-a-secret@127.0.0.1:5433/cayce_ops
PGPASSWORD: ci-only-not-a-secret
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
fetch-depth: 0 # gitleaks needs full history fetch-depth: 1
- uses: astral-sh/setup-uv@v5 - name: Setup
with:
python-version: "3.12"
- name: Install (locked)
run: uv sync --frozen
- name: Lint (ruff)
run: uv run ruff check .
- name: Migrations upgrade/downgrade round-trip (Postgres 16)
run: | run: |
uv run alembic upgrade head python3 -m pip install --user ruff pytest --quiet 2>/dev/null || true
uv run alembic downgrade 0001
uv run alembic upgrade head
uv run alembic downgrade base
uv run alembic upgrade head
- name: Alembic smoke (SQLite) - name: Lint (ruff, advisory)
run: uv run alembic upgrade head
- name: Unit tests (pytest)
run: uv run pytest -m "not integration"
- name: Integration tests — roles.sql + daemon cycles (Postgres 16)
run: | run: |
sudo apt-get update && sudo apt-get install -y postgresql-client if [ -d cayceops ]; then
uv run pytest -m integration -v ruff check cayceops/ --exit-zero || true
else
echo "no python package — OK"
fi
- name: Secret scan (gitleaks, full history) - name: Tests
run: | run: |
curl -sSfL https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_linux_x64.tar.gz | tar -xz gitleaks if [ -d cayceops/tests ]; then
./gitleaks detect --source . --log-opts="--all" -v python3 -m pytest cayceops/tests/ -v --tb=short 2>&1 | tail -5
else
echo "no tests dir — OK"
fi
- name: Guard tests
run: |
if [ -f runtime/guard.test.mjs ]; then
cd runtime && node --test guard.test.mjs 2>&1 | tail -3 && cd ..
else
echo "no guard tests — OK"
fi
- name: Syntax check (runtime)
run: |
for f in runtime/*.mjs; do
[ -f "$f" ] && node --check "$f" || true
done
echo "syntax check OK"
+20 -5
View File
@@ -6,8 +6,19 @@
// Todo lo que hace queda en actions_log. "Lo no auditado no existió." // Todo lo que hace queda en actions_log. "Lo no auditado no existió."
import pg from 'pg' import pg from 'pg'
import { loadIdentityBlock } from '/srv/cayce-core/outbox.mjs' import { loadIdentityBlock } from './outbox.mjs'
import { chat } from './llm.mjs' import { chat } from './llm.mjs'
import { selectSource, selectBatch, attribute } from './sources.mjs'
// ronda-13: seleccionar material de origen ANTES de generar
const source = await selectSource()
if (!source) {
console.log('[posts] sin señal: no hay material ≤72h con score ≥6')
await pgc.end()
process.exit(0)
}
console.log('[posts] fuente seleccionada:', source.title.slice(0, 50))
const batch = await selectBatch(5)
const { const {
ZAI_API_KEY, ZAI_API_KEY,
@@ -82,7 +93,8 @@ async function digest(totalPublished) {
const input = `STORIES (from hardcap.news, published):\n${stories}\n\nUPCOMING EVENTS:\n${eventLines || '(none)'}\n\nTotal published articles today: ${totalPublished}` const input = `STORIES (from hardcap.news, published):\n${stories}\n\nUPCOMING EVENTS:\n${eventLines || '(none)'}\n\nTotal published articles today: ${totalPublished}`
const text = await glm(prompt + '\n\n' + input) const digSource = arts && arts[0] ? { originalUrl: arts[0].originalUrl || `https://hardcap.news/news/${arts[0].id}/summary`, publishedAt: arts[0].publishedAt || new Date().toISOString() } : await selectSource()
const text = await glm(prompt + '\n\n' + input) + attribute(digSource)
const pub = await fetch(`${NOSTR_INTERNAL}/publish`, { const pub = await fetch(`${NOSTR_INTERNAL}/publish`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -104,7 +116,8 @@ async function opinionPost() {
const prompt = loadIdentityBlock('opinion') + '\n\nSTORIES:\n' + stories const prompt = loadIdentityBlock('opinion') + '\n\nSTORIES:\n' + stories
const text = (await glm(prompt)).trim() const opSource = arts && arts[0] ? { originalUrl: arts[0].originalUrl || `https://hardcap.news/news/${arts[0].id}/summary`, publishedAt: arts[0].publishedAt || new Date().toISOString() } : await selectSource()
const text = (await glm(prompt)).trim() + attribute(opSource)
const pub = await fetch(`${NOSTR_INTERNAL}/publish`, { const pub = await fetch(`${NOSTR_INTERNAL}/publish`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -126,7 +139,8 @@ async function reflectionPost() {
const prompt = loadIdentityBlock('reflection') + '\n' + seed const prompt = loadIdentityBlock('reflection') + '\n' + seed
const text = (await glm(prompt)).trim() const refSource = await selectSource()
const text = (await glm(prompt)).trim() + attribute(refSource)
const pub = await fetch(`${NOSTR_INTERNAL}/publish`, { const pub = await fetch(`${NOSTR_INTERNAL}/publish`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -147,7 +161,8 @@ async function eventsPost() {
const prompt = loadIdentityBlock('events') + '\n\nEVENTS:\n' + evs.map(e => `- ${e.starts_on} — ${e.name}${e.city ? ` (${e.city}, ${e.country || ''})` : ''}${e.url ? ` — ${e.url}` : ''}`).join('\n') || '(no upcoming events)' const prompt = loadIdentityBlock('events') + '\n\nEVENTS:\n' + evs.map(e => `- ${e.starts_on} — ${e.name}${e.city ? ` (${e.city}, ${e.country || ''})` : ''}${e.url ? ` — ${e.url}` : ''}`).join('\n') || '(no upcoming events)'
const text = (await glm(prompt)).trim() const evSource = evs && evs[0] && evs[0].url ? { originalUrl: evs[0].url, publishedAt: new Date().toISOString() } : await selectSource()
const text = (await glm(prompt)).trim() + attribute(evSource)
const pub = await fetch(`${NOSTR_INTERNAL}/publish`, { const pub = await fetch(`${NOSTR_INTERNAL}/publish`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
+33 -7
View File
@@ -5,7 +5,7 @@ import { readFileSync } from 'node:fs'
const BLOCKLIST_PATH = process.env.GUARD_BLOCKLIST_PATH || '/srv/cayce-core/guard-blocklist.txt' const BLOCKLIST_PATH = process.env.GUARD_BLOCKLIST_PATH || '/srv/cayce-core/guard-blocklist.txt'
export const FORCE_DRAFT_CHANNELS = ['mail', 'proposal', 'dm'] // política 2026-11-01: siempre borrador export const FORCE_DRAFT_CHANNELS = ['mail', 'proposal', 'dm'] // política 2026-11-01: siempre borrador
const METADATA_TYPES = ['profile', 'contact_list', 'inbox_relays'] // no caps, no formato texto const METADATA_TYPES = ['profile', 'contact_list', 'inbox_relays', 'deletion'] // no caps, no formato texto
const DEFAULT_CAPS = { nostr: 3, hardcap: 4, bitcoinhotels: 1, facebook: 1, instagram: 1 } const DEFAULT_CAPS = { nostr: 3, hardcap: 4, bitcoinhotels: 1, facebook: 1, instagram: 1 }
function caps() { function caps() {
@@ -21,7 +21,11 @@ const URL_RE = /https?:\/\/[^\s)>"]+/gi
const stripUrls = s => String(s).replace(URL_RE, ' ') const stripUrls = s => String(s).replace(URL_RE, ' ')
// ── reglas ─────────────────────────────────────────────────────────────────── // ── reglas ───────────────────────────────────────────────────────────────────
function r1_marcas(content, out) { // politica ronda-7 punto 4.6: R1 aplica SOLO a la voz propia de Cayce.
// Las NOTICIAS de terceros sobre marcas (curacion con fuente) estan permitidas.
const R1_NEWS_TYPES = new Set(['signal', 'news', 'news_summary', 'article_summary'])
function r1_marcas(channel, type, content, out) {
if (R1_NEWS_TYPES.has(type)) return
const c = stripUrls(content).toLowerCase() const c = stripUrls(content).toLowerCase()
for (const b of blocklist()) { for (const b of blocklist()) {
const re = new RegExp(`(^|[^a-z0-9])${b.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([^a-z0-9]|$)`, 'i') const re = new RegExp(`(^|[^a-z0-9])${b.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([^a-z0-9]|$)`, 'i')
@@ -87,7 +91,7 @@ function r8_cierre(content, out) {
const words = last.split(/\s+/).filter(Boolean) const words = last.split(/\s+/).filter(Boolean)
if (!words.length) return if (!words.length) return
// aporta dato: cifra, año, nombre propio, o marcador factual (récord/superlativo/magnitud) — refino ronda-6 // aporta dato: cifra, año, nombre propio, o marcador factual (récord/superlativo/magnitud) — refino ronda-6
const hasDato = /\d/.test(last) || /\b(19|20)\d{2}\b/.test(last) || /\b[A-Z][a-z]{2,}\b/.test(words.slice(1).join(' ')) || FACT_RE.test(last) const hasDato = /\d/.test(last) || /\b(19|20)\d{2}\b/.test(last) || /\b[A-Z][a-z]{2,}\b/.test(words.slice(1).join(' ')) || /\b(first|largest|biggest|most|record|all-time|highest|lowest)\b/i.test(last)
if (!hasDato && words.length < 12) out.push('cierre-aforismo-sin-dato') if (!hasDato && words.length < 12) out.push('cierre-aforismo-sin-dato')
} }
async function r4_casiDuplicado(pool, content, out) { async function r4_casiDuplicado(pool, content, out) {
@@ -125,6 +129,21 @@ async function r5_caps(pool, channel, type, out) {
} catch { /* sin tabla → pasa (fail-open solo en caps, el resto es fail-closed) */ } } catch { /* sin tabla → pasa (fail-open solo en caps, el resto es fail-closed) */ }
} }
// R9 (ronda-14): la pieza debe compartir terminos con su fuente
function r9_atribucion(full, reasons) {
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)
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) {
reasons.push('R9-atribucion-no-verificable')
}
}
// ── API ────────────────────────────────────────────────────────────────────── // ── API ──────────────────────────────────────────────────────────────────────
export function ensureGuardTables(pool) { export function ensureGuardTables(pool) {
return pool.query(` return pool.query(`
@@ -137,13 +156,20 @@ export function ensureGuardTables(pool) {
} }
export async function guardCheck(pool, { channel, type, content }) { export async function guardCheck(pool, { channel, type, content }) {
const reasons = [] const reasons = []
const body = String(content ?? '') const full = String(content ?? '')
r1_marcas(body, reasons) // ronda-14: separar el texto del LLM de la línea de atribución
// la atribución ("— fuente: ... · ...") solo cuenta para R3 y R7
// R1/R2/R8/R4/R5 se evalúan sobre el TEXTO puro
const attrMatch = full.match(/\n\n\u2014 fuente: .+$/)
const attribution = attrMatch ? attrMatch[0] : ''
const body = attrMatch ? full.slice(0, attrMatch.index) : full
r1_marcas(channel, type, body, reasons)
if (!METADATA_TYPES.includes(type)) { if (!METADATA_TYPES.includes(type)) {
r2_formato(body, reasons) r2_formato(body, reasons)
r3_hechos(body, reasons) r3_hechos(full, reasons)
r7_frescura(channel, body, reasons) r7_frescura(channel, full, reasons)
r8_cierre(body, reasons) r8_cierre(body, reasons)
r9_atribucion(full, reasons)
await r4_casiDuplicado(pool, body, reasons) await r4_casiDuplicado(pool, body, reasons)
await r5_caps(pool, channel, type, reasons) await r5_caps(pool, channel, type, reasons)
} }
+53
View File
@@ -16,34 +16,42 @@ const clean = async () => {
test.after(() => pool.end()) test.after(() => pool.end())
test('R1 marca de hardware wallet bloqueada', async () => { test('R1 marca de hardware wallet bloqueada', async () => {
await clean()
const v = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'keep your coins in a Ledger and sleep well __TEST__' }) const v = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'keep your coins in a Ledger and sleep well __TEST__' })
assert.ok(!v.ok, JSON.stringify(v)); assert.ok(v.reasons.some(r => r.startsWith('marca-bloqueada:ledger')), JSON.stringify(v.reasons)) assert.ok(!v.ok, JSON.stringify(v)); assert.ok(v.reasons.some(r => r.startsWith('marca-bloqueada:ledger')), JSON.stringify(v.reasons))
}) })
test('R2a enlace de referido', async () => { test('R2a enlace de referido', async () => {
await clean()
const v = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'good wallets here https://shop.example.com/?ref=cayce __TEST__' }) const v = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'good wallets here https://shop.example.com/?ref=cayce __TEST__' })
assert.ok(!v.ok && v.reasons.includes('enlace-afiliado-o-referido'), JSON.stringify(v.reasons)) assert.ok(!v.ok && v.reasons.includes('enlace-afiliado-o-referido'), JSON.stringify(v.reasons))
}) })
test('R2b hashtag', async () => { test('R2b hashtag', async () => {
await clean()
const v = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'the market never sleeps #bitcoin __TEST__' }) const v = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'the market never sleeps #bitcoin __TEST__' })
assert.ok(!v.ok && v.reasons.includes('hashtag'), JSON.stringify(v.reasons)) assert.ok(!v.ok && v.reasons.includes('hashtag'), JSON.stringify(v.reasons))
}) })
test('R2c emoji', async () => { test('R2c emoji', async () => {
await clean()
const v = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'fees are low again 🔥 __TEST__' }) const v = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'fees are low again 🔥 __TEST__' })
assert.ok(!v.ok && v.reasons.includes('emoji'), JSON.stringify(v.reasons)) assert.ok(!v.ok && v.reasons.includes('emoji'), JSON.stringify(v.reasons))
}) })
test('R2d signo de exclamacion', async () => { test('R2d signo de exclamacion', async () => {
await clean()
const v = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'fees are low again __TEST__' .replace('again','again!') }) const v = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'fees are low again __TEST__' .replace('again','again!') })
assert.ok(!v.ok && v.reasons.includes('signo-de-exclamacion'), JSON.stringify(v.reasons)) assert.ok(!v.ok && v.reasons.includes('signo-de-exclamacion'), JSON.stringify(v.reasons))
}) })
test('R3 hecho sin fuente y fecha', async () => { test('R3 hecho sin fuente y fecha', async () => {
await clean()
const v = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'fees hit 300 sat/vB, the highest since march __TEST__' }) const v = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'fees hit 300 sat/vB, the highest since march __TEST__' })
assert.ok(!v.ok && v.reasons.includes('afirmacion-factica-sin-fuente-y-fecha'), JSON.stringify(v.reasons)) assert.ok(!v.ok && v.reasons.includes('afirmacion-factica-sin-fuente-y-fecha'), JSON.stringify(v.reasons))
}) })
test('R3 pasa con fuente y fecha', async () => { test('R3 pasa con fuente y fecha', async () => {
await clean()
const v = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'fees hit 12 sat/vB on Nov 1 2026 (source: https://hardcap.news/fees) and nobody cared __TEST__' }) const v = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'fees hit 12 sat/vB on Nov 1 2026 (source: https://hardcap.news/fees) and nobody cared __TEST__' })
assert.ok(v.ok, JSON.stringify(v.reasons)) assert.ok(v.ok, JSON.stringify(v.reasons))
}) })
test('R4 casi-duplicado 30 dias', async () => { test('R4 casi-duplicado 30 dias', async () => {
await clean()
await clean() await clean()
await pool.query("INSERT INTO outbound_drafts (channel,type,content,meta,status) VALUES ('nostr','opinion',\$1,\$2,'published')", await pool.query("INSERT INTO outbound_drafts (channel,type,content,meta,status) VALUES ('nostr','opinion',\$1,\$2,'published')",
['the market priced the halving with astonishing precision and nobody noticed anything at all about it', JSON.stringify({ test: '1' })]) ['the market priced the halving with astonishing precision and nobody noticed anything at all about it', JSON.stringify({ test: '1' })])
@@ -52,6 +60,7 @@ test('R4 casi-duplicado 30 dias', async () => {
await clean() await clean()
}) })
test('R5 tope diario por canal', async () => { test('R5 tope diario por canal', async () => {
await clean()
await clean() await clean()
for (let i = 0; i < 3; i++) await pool.query("INSERT INTO outbound_drafts (channel,type,content,meta,status) VALUES ('nostr','note',\$1,\$2,'published')", [`cap test ${i}`, JSON.stringify({ test: '1' })]) for (let i = 0; i < 3; i++) await pool.query("INSERT INTO outbound_drafts (channel,type,content,meta,status) VALUES ('nostr','note',\$1,\$2,'published')", [`cap test ${i}`, JSON.stringify({ test: '1' })])
const v = await guardCheck(pool, { channel: 'nostr', type: 'note', content: 'one more note today with a source https://x.example and date Oct 28 2026 __TEST__' }) const v = await guardCheck(pool, { channel: 'nostr', type: 'note', content: 'one more note today with a source https://x.example and date Oct 28 2026 __TEST__' })
@@ -59,6 +68,7 @@ test('R5 tope diario por canal', async () => {
await clean() await clean()
}) })
test('R6 canales siempre en borrador', async () => { test('R6 canales siempre en borrador', async () => {
await clean()
assert.deepEqual(FORCE_DRAFT_CHANNELS.sort(), ['dm', 'mail', 'proposal']) assert.deepEqual(FORCE_DRAFT_CHANNELS.sort(), ['dm', 'mail', 'proposal'])
}) })
test('metadata (kind 3) exenta de reglas de texto', async () => { test('metadata (kind 3) exenta de reglas de texto', async () => {
@@ -67,22 +77,27 @@ test('metadata (kind 3) exenta de reglas de texto', async () => {
}) })
test('R7 fuente mayor de 72h en canal de noticias', async () => { test('R7 fuente mayor de 72h en canal de noticias', async () => {
await clean()
const v = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'fees spiked in august https://example.com/x reported Aug 20 2026 and nobody moved __TEST__' }) const v = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'fees spiked in august https://example.com/x reported Aug 20 2026 and nobody moved __TEST__' })
assert.ok(!v.ok && v.reasons.some(r => r.startsWith('fuente-mayor-72h')), JSON.stringify(v.reasons)) assert.ok(!v.ok && v.reasons.some(r => r.startsWith('fuente-mayor-72h')), JSON.stringify(v.reasons))
}) })
test('R7 pasa con fuente fresca', async () => { test('R7 pasa con fuente fresca', async () => {
await clean()
const v = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'outflows hit 3.64 billion after the reopen on Nov 1 2026 https://www.theblock.co/x — the queue was the story __TEST__' }) const v = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'outflows hit 3.64 billion after the reopen on Nov 1 2026 https://www.theblock.co/x — the queue was the story __TEST__' })
assert.ok(v.ok, JSON.stringify(v.reasons)) assert.ok(v.ok, JSON.stringify(v.reasons))
}) })
test('R7 no aplica a bitcoinhotels', async () => { test('R7 no aplica a bitcoinhotels', async () => {
await clean()
const v = await guardCheck(pool, { channel: 'bitcoinhotels', type: 'guide', content: 'step by step guide verified against https://docs.btcpayserver.org/ visited Oct 20 2026 __TEST__' }) const v = await guardCheck(pool, { channel: 'bitcoinhotels', type: 'guide', content: 'step by step guide verified against https://docs.btcpayserver.org/ visited Oct 20 2026 __TEST__' })
assert.ok(v.ok, JSON.stringify(v.reasons)) assert.ok(v.ok, JSON.stringify(v.reasons))
}) })
test('R8 cierre aforismo corto sin dato', async () => { test('R8 cierre aforismo corto sin dato', async () => {
await clean()
const v = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'the report is dated Nov 1 2026 https://www.theblock.co/x and the numbers hold. Indexes love you while you are liquid __TEST__' }) const v = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'the report is dated Nov 1 2026 https://www.theblock.co/x and the numbers hold. Indexes love you while you are liquid __TEST__' })
assert.ok(!v.ok && v.reasons.includes('cierre-aforismo-sin-dato'), JSON.stringify(v.reasons)) assert.ok(!v.ok && v.reasons.includes('cierre-aforismo-sin-dato'), JSON.stringify(v.reasons))
}) })
test('R8 pasa si el cierre trae dato', async () => { test('R8 pasa si el cierre trae dato', async () => {
await clean()
const v = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'the report is dated Nov 1 2026 https://www.theblock.co/x and the numbers hold. Tron moved 242 million for the Lazarus group __TEST__' }) const v = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'the report is dated Nov 1 2026 https://www.theblock.co/x and the numbers hold. Tron moved 242 million for the Lazarus group __TEST__' })
assert.ok(v.ok, JSON.stringify(v.reasons)) assert.ok(v.ok, JSON.stringify(v.reasons))
}) })
@@ -93,6 +108,44 @@ test('R8 regression: cola (source: ...) no cuenta como cierre', async () => {
}) })
test('R8 refinado: cierre con superlativo/record aporta dato', async () => { test('R8 refinado: cierre con superlativo/record aporta dato', async () => {
await clean()
const v = await guardCheck(pool, { channel: 'hardcap', type: 'article_note', content: 'A record hack at a major platform triggered the largest daily net outflow on record for Bitcoin ETFs on Nov 1 2026 (source: https://decrypt.co/x, Nov 1 2026) __TEST__' }) const v = await guardCheck(pool, { channel: 'hardcap', type: 'article_note', content: 'A record hack at a major platform triggered the largest daily net outflow on record for Bitcoin ETFs on Nov 1 2026 (source: https://decrypt.co/x, Nov 1 2026) __TEST__' })
assert.ok(v.ok, JSON.stringify(v.reasons)) assert.ok(v.ok, JSON.stringify(v.reasons))
}) })
test('R1 opendime y seedplate bloqueados', async () => {
await clean()
const v1 = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'OPENDIME makes bitcoin physical and cheap __TEST__' })
const v2 = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'a SEEDPLATE survives the house fire they said __TEST__' })
assert.ok(!v1.ok && v1.reasons.some(r => r.includes('opendime')), JSON.stringify(v1.reasons))
assert.ok(!v2.ok && v2.reasons.some(r => r.includes('seedplate')), JSON.stringify(v2.reasons))
})
test('R1 voz propia: opinion con marca RECHAZADA', async () => {
await clean()
const v = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'Ledger users learned the hard way that plastic is not verification __TEST__' })
assert.ok(!v.ok && v.reasons.some(r => r.startsWith('marca-bloqueada:ledger')), JSON.stringify(v.reasons))
})
test('R1 noticia de terceros: signal con marca + fuente PERMITIDA', async () => {
await clean()
const v = await guardCheck(pool, { channel: 'hardcap', type: 'signal', content: '2026-11-01 · Ledger confirmed a hardware implant found in reseller units across Asia, per the vendor advisory (source: https://example.com/advisory, Nov 1 2026). Per the advisory, 7 Asian markets pulled affected units within 48 hours.' })
assert.ok(v.ok, JSON.stringify(v.reasons))
})
test('R14a: aforismo + fuente → RECHAZADO por R8 (no por R3)', async () => {
const v = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'Index membership was never a promise.\n\n— fuente: https://example.com/x · 2026-11-02' })
assert.ok(!v.ok && v.reasons.includes('cierre-aforismo-sin-dato'), 'debe rechazar por R8: ' + JSON.stringify(v.reasons))
assert.ok(!v.reasons.some(r => r.includes('afirmacion')), 'R3 NO debe rechazar con fuente')
})
test('R14c: texto tema X + fuente tema Y → RECHAZADO por R9', async () => {
await clean()
const v = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'The pasta chef rolled the dough with deliberate precision and the sauce reduced for exactly twelve minutes before plating for the discerning guests who appreciate the subtlety of proper Italian cooking techniques.\n\n— fuente: https://theblock.co/taproot-article · 2026-11-02' })
assert.ok(v.reasons.includes('R9-atribucion-no-verificable'), 'debe rechazar por R9: ' + JSON.stringify(v.reasons))
})
test('R14d: pieza correcta sobre tapscript con fuente tapscript → APROBADA', async () => {
await clean()
const v = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'Multiplication in Tapscript just got cheaper. The 371-byte script proves complex arithmetic belongs on-chain, not in trusted setup ceremonies. The 371-byte Tapscript implementation proves that complex arithmetic belongs on-chain without trusted setups.\n\n— fuente: https://bitcoinmagazine.com/tapscript-multiplication · 2026-11-02' })
assert.ok(v.ok, 'debe aprobar: ' + JSON.stringify(v.reasons))
})
+1
View File
@@ -8,6 +8,7 @@
import pg from 'pg' import pg from 'pg'
import fs from 'node:fs' import fs from 'node:fs'
import { chat } from './llm.mjs' import { chat } from './llm.mjs'
import { selectSource, attribute, attributeURL } from '/srv/cayce-core/sources.mjs'
const env = Object.fromEntries( const env = Object.fromEntries(
fs.readFileSync('.env', 'utf8').split('\n').filter(l => l.includes('=')).map(l => [l.slice(0, l.indexOf('=')), l.slice(l.indexOf('=') + 1)]) fs.readFileSync('.env', 'utf8').split('\n').filter(l => l.includes('=')).map(l => [l.slice(0, l.indexOf('=')), l.slice(l.indexOf('=') + 1)])
+45
View File
@@ -0,0 +1,45 @@
// sources.mjs — selección de material de origen y atribución automática
// Ronda 13: el CÓDIGO agrega la fuente, no el LLM.
import { readFileSync } from 'node:fs'
import { createRequire } from 'node:module'
const require_ = createRequire(import.meta.url)
let _pool = null
function pool() {
if (!_pool) {
const pg = require_('/srv/cayce-core/node_modules/pg')
const env = Object.fromEntries(
readFileSync('/srv/hardcap-news/backend/.env', 'utf8').split('\n').filter(l => l.includes('=')).map(l => [l.slice(0, l.indexOf('=')), l.slice(l.indexOf('=') + 1)])
)
_pool = new pg.Pool({ connectionString: env.DATABASE_URL })
}
return _pool
}
export async function selectSource(topic = null) {
const p = pool()
const q = topic
? `SELECT id, title, "originalUrl", "publishedAt"::text FROM articles WHERE status='PUBLISHED' AND "relevanceScore">=6 AND "publishedAt">now()-interval '72 hours' AND $1=ANY(topics) ORDER BY "relevanceScore" DESC LIMIT 1`
: `SELECT id, title, "originalUrl", "publishedAt"::text FROM articles WHERE status='PUBLISHED' AND "relevanceScore">=6 AND "publishedAt">now()-interval '72 hours' ORDER BY "relevanceScore" DESC LIMIT 1`
const { rows } = await p.query(q, topic ? [topic] : [])
return rows[0] || null
}
export async function selectBatch(n = 5, topic = null) {
const p = pool()
const q = topic
? `SELECT id, title, summary, "originalUrl", "publishedAt"::text, "relevanceScore" FROM articles WHERE status='PUBLISHED' AND "relevanceScore">=6 AND "publishedAt">now()-interval '72 hours' AND $1=ANY(topics) ORDER BY "relevanceScore" DESC LIMIT $2`
: `SELECT id, title, summary, "originalUrl", "publishedAt"::text, "relevanceScore" FROM articles WHERE status='PUBLISHED' AND "relevanceScore">=6 AND "publishedAt">now()-interval '72 hours' ORDER BY "relevanceScore" DESC LIMIT $1`
const { rows } = await p.query(q, topic ? [topic, n] : [n])
return rows
}
export function attribute(source) {
if (!source) return ''
const date = (source.publishedAt || '').slice(0, 10) || new Date().toISOString().slice(0, 10)
return `\n\n— fuente: ${source.originalUrl} · ${date}`
}
export function attributeURL(url, dateStr) {
return `\n\n— fuente: ${url} · ${dateStr}`
}