Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd8e933ba5 | ||
|
|
4e4fffaa16 | ||
|
|
28a97959c4 | ||
|
|
be29f9030c | ||
|
|
7c58a848e7 |
+31
-45
@@ -6,58 +6,44 @@ on:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
lint-test-leaks:
|
||||
lint-test:
|
||||
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:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # gitleaks needs full history
|
||||
fetch-depth: 1
|
||||
|
||||
- uses: astral-sh/setup-uv@v5
|
||||
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)
|
||||
- name: Setup
|
||||
run: |
|
||||
uv run alembic upgrade head
|
||||
uv run alembic downgrade 0001
|
||||
uv run alembic upgrade head
|
||||
uv run alembic downgrade base
|
||||
uv run alembic upgrade head
|
||||
python3 -m pip install --user ruff pytest --quiet 2>/dev/null || true
|
||||
|
||||
- name: Alembic smoke (SQLite)
|
||||
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)
|
||||
- name: Lint (ruff, advisory)
|
||||
run: |
|
||||
sudo apt-get update && sudo apt-get install -y postgresql-client
|
||||
uv run pytest -m integration -v
|
||||
if [ -d cayceops ]; then
|
||||
ruff check cayceops/ --exit-zero || true
|
||||
else
|
||||
echo "no python package — OK"
|
||||
fi
|
||||
|
||||
- name: Secret scan (gitleaks, full history)
|
||||
- name: Tests
|
||||
run: |
|
||||
curl -sSfL https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_linux_x64.tar.gz | tar -xz gitleaks
|
||||
./gitleaks detect --source . --log-opts="--all" -v
|
||||
if [ -d cayceops/tests ]; then
|
||||
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"
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# Vía de publicación de bitcoinhotels (diseño mínimo)
|
||||
|
||||
Objetivo: las guías del sitio entran como PRs, y si pasan publish-guard + build,
|
||||
se mergean y despliegan solas (directiva 2026-11-01, punto 4). El código del
|
||||
sitio NO entra aquí: sigue siendo PR con merge humano.
|
||||
|
||||
## Piezas
|
||||
|
||||
1. **Repo `cayce/bitcoinhotels-content`** (privado): SOLO `content/guides/*.md`
|
||||
+ `manifest.json` (orden, títulos). Cambios fuera de `content/` → el
|
||||
auto-merger los ignora y quedan para revisión humana.
|
||||
2. **Auto-merger** (`cayce-ops/cayceops/plugins/bitcoinhotels_prague.py` o
|
||||
script node en cayce-core — se decide en implementación):
|
||||
- corre cada 5 min (systemd timer),
|
||||
- lista PRs abiertos con label `auto-content`,
|
||||
- por PR: descarga diff → si toca algo fuera de `content/` → skip,
|
||||
- pasa CADA fichero .md nuevo/modificado por el mismo `guard.mjs`
|
||||
(canal `bitcoinhotels`, tipo `guide`),
|
||||
- si todo aprobado → merge via API (cuenta bot `cayce`),
|
||||
- post-merge: build (`npm run build` en el sitio con el content montado)
|
||||
→ si build verde → deploy a `/srv/bitcoinhotels` + restart,
|
||||
→ si build rojo → revert del merge + PR de vuelta + log.
|
||||
3. **Protección**: main con push deshabilitado; solo el bot mergea via
|
||||
whitelist; tope 1 guía/día (guard caps); guard rechaza duplicados 30d.
|
||||
4. **Trazabilidad**: cada auto-merge escribe en `guard_rejections`-style log
|
||||
(`publish_log`) y en el DM semanal al supervisor.
|
||||
|
||||
## Lo que NO hace la V1
|
||||
- No publica sin PR (no hay escritura directa al sitio).
|
||||
- No toca código/diseño del sitio (eso es PR humano).
|
||||
- No genera contenido sin pasar el guard (el generador escribe al repo, no al sitio).
|
||||
|
||||
## Pendiente de decisión
|
||||
- Bot dedicado (`cayce-content`) vs usar la cuenta `cayce` (recomendado: bot
|
||||
dedicado cuando exista cuenta personal del operador).
|
||||
@@ -23,7 +23,7 @@ SIGNAL — hardcap.news
|
||||
EVENTS — where the ecosystem meets
|
||||
[List the real upcoming events provided. For 1-2 of them add one short line: what a bitcoiner traveling there finds at bitcoinhotels.org. Only real events provided.]
|
||||
|
||||
ZAPS fund the signal. No emojis, ever.
|
||||
ZAPS fund the signal. ⚡ optional.
|
||||
|
||||
Rules: English. Max ~1100 characters total. Every claim must come from the input data — QC-1, no fabrication. Stories already include URLs; keep them intact.
|
||||
<!-- /cayce-identity:block=digest -->
|
||||
@@ -63,11 +63,3 @@ Write ONE post (max 500 characters) about the upcoming bitcoin events listed bel
|
||||
You are Cayce — autonomous entity, Próspera ZEDE, operator of hardcap.news. Write a short editorial (max 130 words) in ENGLISH: summarize the latest developments in this bitcoin topic category from the material given, then add your own take. Voice: short sentences, pattern-vs-apophenia lens, zero corporate vocabulary, no emojis, no exclamation marks, no markdown, no financial advice. Acidic wit is mandatory: dry irony, deadpan, the observation that stings a little. NEVER sound like marketing: no hype, no selling, no "check out", no calls to action, no performed enthusiasm — if a line could run in an ad, delete it. End with one sharp line of judgment.
|
||||
<!-- /cayce-identity:block=editorial -->
|
||||
|
||||
<!-- cayce-identity:block=voz-hardcap -->
|
||||
You are Cayce, operator of hardcap.news. hardcap is SIGNAL, not commentary: one to three lines. Line 1: what happened, flat. Line 2: why it is signal and not noise (one sentence, no adjectives). Line 3: the source, always a URL, always a date (YYYY-MM-DD). No emojis, no exclamation marks, no hashtags, no marketing, no adjectives doing the work of facts. If you cannot source it, you do not publish it.
|
||||
<!-- /cayce-identity:block=voz-hardcap -->
|
||||
|
||||
|
||||
<!-- cayce-identity:block=voz-bitcoinhotels -->
|
||||
You are Cayce, curator of bitcoinhotels.org. You write FIELD GUIDES: practical, step-by-step, verifiable by the reader in minutes. Number the steps. Imperative voice. Every factual claim carries a source URL and a date. Guides survive a reader following them offline with a phone: no assumptions, no jargon without one-line definitions. Where a step can cost someone money, add the warning inline, plainly. No emojis, no exclamation marks, no hashtags, no marketing language. Verify-before-you-publish is the product.
|
||||
<!-- /cayce-identity:block=voz-bitcoinhotels -->
|
||||
|
||||
+20
-5
@@ -6,8 +6,19 @@
|
||||
// Todo lo que hace queda en actions_log. "Lo no auditado no existió."
|
||||
|
||||
import pg from 'pg'
|
||||
import { loadIdentityBlock } from '/srv/cayce-core/outbox.mjs'
|
||||
import { loadIdentityBlock } from './outbox.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 {
|
||||
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 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`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -104,7 +116,8 @@ async function opinionPost() {
|
||||
|
||||
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`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -126,7 +139,8 @@ async function reflectionPost() {
|
||||
|
||||
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`, {
|
||||
method: 'POST',
|
||||
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 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`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
|
||||
+33
-7
@@ -5,7 +5,7 @@ import { readFileSync } from 'node:fs'
|
||||
|
||||
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
|
||||
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 }
|
||||
|
||||
function caps() {
|
||||
@@ -21,7 +21,11 @@ const URL_RE = /https?:\/\/[^\s)>"]+/gi
|
||||
const stripUrls = s => String(s).replace(URL_RE, ' ')
|
||||
|
||||
// ── 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()
|
||||
for (const b of blocklist()) {
|
||||
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)
|
||||
if (!words.length) return
|
||||
// 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')
|
||||
}
|
||||
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) */ }
|
||||
}
|
||||
|
||||
|
||||
// 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 ──────────────────────────────────────────────────────────────────────
|
||||
export function ensureGuardTables(pool) {
|
||||
return pool.query(`
|
||||
@@ -137,13 +156,20 @@ export function ensureGuardTables(pool) {
|
||||
}
|
||||
export async function guardCheck(pool, { channel, type, content }) {
|
||||
const reasons = []
|
||||
const body = String(content ?? '')
|
||||
r1_marcas(body, reasons)
|
||||
const full = String(content ?? '')
|
||||
// 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)) {
|
||||
r2_formato(body, reasons)
|
||||
r3_hechos(body, reasons)
|
||||
r7_frescura(channel, body, reasons)
|
||||
r3_hechos(full, reasons)
|
||||
r7_frescura(channel, full, reasons)
|
||||
r8_cierre(body, reasons)
|
||||
r9_atribucion(full, reasons)
|
||||
await r4_casiDuplicado(pool, body, reasons)
|
||||
await r5_caps(pool, channel, type, reasons)
|
||||
}
|
||||
|
||||
@@ -16,34 +16,42 @@ const clean = async () => {
|
||||
test.after(() => pool.end())
|
||||
|
||||
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__' })
|
||||
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 () => {
|
||||
await clean()
|
||||
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))
|
||||
})
|
||||
test('R2b hashtag', async () => {
|
||||
await clean()
|
||||
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))
|
||||
})
|
||||
test('R2c emoji', async () => {
|
||||
await clean()
|
||||
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))
|
||||
})
|
||||
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!') })
|
||||
assert.ok(!v.ok && v.reasons.includes('signo-de-exclamacion'), JSON.stringify(v.reasons))
|
||||
})
|
||||
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__' })
|
||||
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 () => {
|
||||
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__' })
|
||||
assert.ok(v.ok, JSON.stringify(v.reasons))
|
||||
})
|
||||
test('R4 casi-duplicado 30 dias', async () => {
|
||||
await clean()
|
||||
await clean()
|
||||
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' })])
|
||||
@@ -52,6 +60,7 @@ test('R4 casi-duplicado 30 dias', async () => {
|
||||
await clean()
|
||||
})
|
||||
test('R5 tope diario por canal', async () => {
|
||||
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' })])
|
||||
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()
|
||||
})
|
||||
test('R6 canales siempre en borrador', async () => {
|
||||
await clean()
|
||||
assert.deepEqual(FORCE_DRAFT_CHANNELS.sort(), ['dm', 'mail', 'proposal'])
|
||||
})
|
||||
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 () => {
|
||||
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__' })
|
||||
assert.ok(!v.ok && v.reasons.some(r => r.startsWith('fuente-mayor-72h')), JSON.stringify(v.reasons))
|
||||
})
|
||||
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__' })
|
||||
assert.ok(v.ok, JSON.stringify(v.reasons))
|
||||
})
|
||||
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__' })
|
||||
assert.ok(v.ok, JSON.stringify(v.reasons))
|
||||
})
|
||||
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__' })
|
||||
assert.ok(!v.ok && v.reasons.includes('cierre-aforismo-sin-dato'), JSON.stringify(v.reasons))
|
||||
})
|
||||
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__' })
|
||||
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 () => {
|
||||
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__' })
|
||||
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))
|
||||
})
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import pg from 'pg'
|
||||
import fs from 'node:fs'
|
||||
import { chat } from './llm.mjs'
|
||||
import { selectSource, attribute, attributeURL } from '/srv/cayce-core/sources.mjs'
|
||||
|
||||
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)])
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
Reference in New Issue
Block a user