openvibers/openvibe.community Commits

AI text may describe people; only sex acts and porn stay gated (#197)

OP OpenVibers committed 11 days ago · 99a0934236 · 9 files, +34 −190

* AI text may describe people; only sex acts and porn stay gated

* Store AI paste titles and summaries as written

* openvibe-sdk v0.81.0 (was v0.74.0)

Opened by scripts/pin-bump.js in OpenVibers/OpenVibe.Shared after the v0.81.0 release.

* Merge remote-tracking branch 'origin/main' into refresh-197

# Conflicts:
#	README.md
#	STATUS.json
#	package-lock.json
#	package.json

* Merge remote-tracking branch 'origin/main' into refresh-197

* Merge the SDK 0.81.0 pin (applied by hand on main's Shared 4.94.0)

* openvibe-sdk v0.81.0 (was v0.74.0), with the C2 change it needs

Changes

ddiff --git a/README.md b/README.md
iindex 2ebb774..a180636 100644
---- a/README.md
++++ b/README.md
@@@ -41,7 +41,7 @@ little progressive JavaScript for pagination, copy buttons and the upload path.
- OpenVibe.Media (screenshot and attachment uploads), OpenVibe.VIP (members-only gates), OpenVibe.Chat
(a space's room), OpenVibe.Events (the outbox relay and the Pulse and account subscriptions)
- OpenVibe.Live only for old VOD/clip comment imports (it reads and writes no pastes here any more)
--- `openvibe-contracts` v0.112.0, `openvibe-sdk` v0.74.0 (events outbox, per-actor limits, no-looks),
++- `openvibe-contracts` v0.112.0, `openvibe-sdk` v0.81.0 (events outbox, per-actor limits, no-looks),
`openvibe-shared` v4.94.0, pinned by release tarball
## How it fits the network
@@@ -851,6 +851,6 @@ release after paste comments moved onto the typed comment threads. [Submissions]
<!-- versions:start -->
- openvibe-contracts: v0.222.0
--- openvibe-sdk: v0.74.0
++- openvibe-sdk: v0.81.0
- openvibe-shared: v4.94.0
<!-- versions:end -->
ddiff --git a/STATUS.json b/STATUS.json
iindex 9ea2906..75a51c7 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -8,7 +8,7 @@
"port": 4200,
"domain": "openvibe.community",
"contracts": "openvibe-contracts v0.222.0",
-- "sdk": "openvibe-sdk v0.74.0",
++ "sdk": "openvibe-sdk v0.81.0",
"shared": "openvibe-shared v4.94.0",
"capabilities": [
"community.paste.create",
ddiff --git a/package-lock.json b/package-lock.json
iindex 151b948..e103806 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -18,7 +18,7 @@
"iovalkey": "^0.4.0",
"multer": "^2.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.74.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.81.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.94.0",
"pg": "^8.23.0"
},
@@@ -1208,9 +1208,9 @@
}
},
"node_modules/openvibe-sdk": {
-- "version": "0.74.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.74.0",
-- "integrity": "sha512-DO9KVPnUriY/I4lX6ZEJtXaUvRDbJyqnqJxvlpl+zkNGFILpmuBXVJogNOCfK071Qci1gg7C4Iw1Xrseklcajg==",
++ "version": "0.81.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.81.0",
++ "integrity": "sha512-GWD+qkkKiOyer1SATAaVs8qdWR1Z4WIRvtcYQU0NijzoL9BDRmkaXzLHjwwkRezdkHmdOgtvNIU8GAFaUaS8Ow==",
"license": "MIT",
"bin": {
"openvibe-download": "bin/openvibe-download.js"
ddiff --git a/package.json b/package.json
iindex 8522caa..3364f7e 100644
---- a/package.json
++++ b/package.json
@@@ -29,7 +29,7 @@
"iovalkey": "^0.4.0",
"multer": "^2.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.74.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.81.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.94.0",
"pg": "^8.23.0"
},
ddiff --git a/server/index.js b/server/index.js
iindex 1978626..6b949ca 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -38,9 +38,6 @@ const { createPgTelemetrySink, startTelemetryRetention } = require('openvibe-sdk
console.log(`[Community] pastes: this site is the authority (PostgreSQL), identity via ${config.networkUrl}`);
});
server.keepAliveTimeout = 65_000;
-- // Public AI text never describes a person: rows stored before the rule are repaired once listening
-- // (server/pastes/no-looks.js; a boot with nothing to repair only reads).
-- require('./pastes/no-looks').repairStored(require('./db').getDb()).catch((err) => console.warn('[Pastes] AI text repair skipped:', err.message));
// Request telemetry (openvibe-sdk/service, plan T1): one aggregated sample per route template, method and status
// class each minute, stored in this service's telemetry_samples table; rows older than 14 days go daily.
telemetry.init({ service: 'community', sink: createPgTelemetrySink({ db: require('./db').getDb(), log: console }), skipPrefixes: ['/shared'], intervalMs: 60_000 });
ddiff --git a/server/pastes/no-looks.js b/server/pastes/no-looks.js
ddeleted file mode 100644
iindex b3adde2..0000000
---- a/server/pastes/no-looks.js
++++ /dev/null
@@@ -1,82 +0,0 @@
--'use strict';
--/**
-- * Public AI text never describes a person (the owner's rule; openvibe-sdk/no-looks): no looks, body, clothing, hair,
-- * face, pose, age or guessed gender. Services send Community AI text (Live's picture descriptions of screenshots, its
-- * AI moment pastes), and models ignore the rule in their prompts about one time in ten, so Community applies it to
-- * every AI field it stores:
-- *
-- * - a paste's ai_summary and ai_tags, whoever owns the paste;
-- * - the title and body of an AI paste (origin 'ai'). The body's footer ("🔴 Caught live on @x's stream — …",
-- * "▶ Watch this moment on …") is links, not description, and stays as written, except "on @null's stream",
-- * which reads "on the stream".
-- *
-- * repairStored() rewrites rows stored before (187 of 1,207 summaries named people on 2026-10-22). It runs at boot
-- * and changes only rows whose text the rules change, so a later boot finds nothing. Search's hourly refresh
-- * (server/search/documents.js) re-sends the repaired pastes. A person's own title and text are never touched.
-- */
--const { noLooks, noLooksTags } = require('openvibe-sdk/no-looks');
--
--// Live writes the footer after a blank line, CRLF on every AI moment paste (2026-10-22: all 478 '@null' ones).
--const FOOTER = /(\r?\n\r?\n(?:🔴|▶)[\s\S]*)$/u;
--
--const summary = (s) => (typeof s === 'string' ? noLooks(s).trim() : s);
--
--/** ai_tags as stored (a JSON array string, an array, or comma text) → the same shape without looks tags. */
--function tags(t) {
-- if (t == null) return t;
-- if (Array.isArray(t)) return noLooksTags(t);
-- const s = String(t);
-- try {
-- const arr = JSON.parse(s);
-- if (Array.isArray(arr)) return JSON.stringify(noLooksTags(arr));
-- } catch { /* comma text */ }
-- return noLooksTags(s.split(',').map((x) => x.trim())).join(', ');
--}
--
--/** An AI paste's body: the description before its footer. */
--function body(content) {
-- if (typeof content !== 'string') return content;
-- const m = FOOTER.exec(content);
-- const text = m ? content.slice(0, m.index) : content;
-- const clean = noLooks(text).trim();
-- // Live wrote "on @null's stream" when Media's list named no owner (478 pastes, 2026-08-18 to 10-22; fixed in Live).
-- const footer = m ? m[1].replace(/ on @null's stream:/, ' on the stream:') : '';
-- // A description that was all looks leaves the footer alone.
-- return clean ? clean + footer : footer.trim();
--}
--
--/** An AI paste's title; '' when it was all looks (store.sanitizeTitle then gives the default). */
--function title(t) {
-- if (typeof t !== 'string') return t;
-- return noLooks(t).trim();
--}
--
--/** The AI fields of a row as Community stores them: { ai_summary, ai_tags } and, for an AI paste, title and content. */
--function cleanRow(row) {
-- const out = {};
-- if (row.ai_summary != null) out.ai_summary = summary(row.ai_summary);
-- if (row.ai_tags != null) out.ai_tags = tags(row.ai_tags);
-- if (row.origin === 'ai') {
-- if (row.title != null) out.title = title(row.title) || 'Untitled';
-- if (row.content != null) out.content = body(row.content);
-- }
-- return out;
--}
--
--/** Rewrite stored rows the rules change; returns how many changed. */
--async function repairStored(db, { log = console } = {}) {
-- const rows = await db.prepare(`SELECT id, origin, title, content, ai_summary, ai_tags FROM pastes
-- WHERE deleted_at IS NULL AND (ai_summary IS NOT NULL OR ai_tags IS NOT NULL OR origin = 'ai')`).all();
-- let changed = 0;
-- for (const row of rows) {
-- const clean = cleanRow(row);
-- const cols = Object.keys(clean).filter((c) => clean[c] !== row[c]);
-- if (!cols.length) continue;
-- await db.prepare(`UPDATE pastes SET ${cols.map((c) => `${c} = ?`).join(', ')} WHERE id = ?`).run(...cols.map((c) => clean[c]), row.id);
-- changed++;
-- }
-- if (changed && log) log.log(`[Pastes] public AI text no longer describes people: ${changed} paste(s) repaired`);
-- return changed;
--}
--
--module.exports = { summary, tags, body, title, cleanRow, repairStored };
ddiff --git a/server/pastes/service.js b/server/pastes/service.js
iindex 7edad45..0e854ec 100644
---- a/server/pastes/service.js
++++ b/server/pastes/service.js
@@@ -18,7 +18,6 @@
const crypto = require('crypto');
const events = require('../events');
const store = require('./store');
--const looks = require('./no-looks');
const spam = require('./spam');
const commentStore = require('../comments/store');
const { discussionModerator } = require('../identity/capabilities');
@@@ -300,8 +299,8 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
function aiFields(v, body) {
if (!isService(v) || body.ai_summary == null) return {};
const tags = body.ai_tags == null ? null : (typeof body.ai_tags === 'string' ? body.ai_tags : JSON.stringify(body.ai_tags)).slice(0, 2000);
-- // Public AI text never describes a person (./no-looks.js).
-- return { ai_summary: looks.summary(String(body.ai_summary).slice(0, 2000)), ai_tags: looks.tags(tags), ai_analyzed_at: new Date().toISOString().replace('T', ' ').slice(0, 19) };
++ // AI text is stored as written: describing people is fine (owner, 2026-10-31).
++ return { ai_summary: String(body.ai_summary).slice(0, 2000), ai_tags: tags, ai_analyzed_at: new Date().toISOString().replace('T', ' ').slice(0, 19) };
}
/**
@@@ -432,8 +431,8 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
origin: v.origin === 'ai' ? 'ai' : 'user',
type: 'paste',
// An AI paste's title and body are AI text too; a person's are theirs, as written.
-- title: store.sanitizeTitle(v.origin === 'ai' ? looks.title(body.title) : body.title),
-- content: v.origin === 'ai' ? looks.body(content.trim()) : content.trim(),
++ title: store.sanitizeTitle(body.title),
++ content: content.trim(),
language: store.detectLanguage(content, body.language),
visibility,
stream_ref: streamRefFor(v, body),
@@@ -479,8 +478,8 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
owner_subject: v.subject || null,
origin: v.origin === 'ai' ? 'ai' : 'user',
type: 'screenshot',
-- title: store.sanitizeTitle(v.origin === 'ai' ? looks.title(body.title || 'Screenshot') : (body.title || 'Screenshot')),
-- content: v.origin === 'ai' ? looks.body(String(body.description || body.content || '')) : String(body.description || body.content || ''),
++ title: store.sanitizeTitle(body.title || 'Screenshot'),
++ content: String(body.description || body.content || ''),
language: 'text',
visibility,
screenshot_url: stored.url,
@@@ -734,8 +733,8 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
async setAi(v, slug, body = {}) {
const p = await store.getBySlug(db, String(slug));
if (!p) fail(404, 'Paste not found');
-- const summary = body.ai_summary == null ? null : looks.summary(String(body.ai_summary).slice(0, 2000));
-- const tags = body.ai_tags == null ? null : looks.tags((typeof body.ai_tags === 'string' ? body.ai_tags : JSON.stringify(body.ai_tags)).slice(0, 2000));
++ const summary = body.ai_summary == null ? null : String(body.ai_summary).slice(0, 2000);
++ const tags = body.ai_tags == null ? null : (typeof body.ai_tags === 'string' ? body.ai_tags : JSON.stringify(body.ai_tags)).slice(0, 2000);
const row = await store.setAi(db, p.id, summary, tags);
pingIndexNow(row);
return { ok: true, paste: await shapeOne(row, v) };
ddiff --git a/test/no-looks-guard.test.js b/test/no-looks-guard.test.js
nnew file mode 100644
iindex 0000000..5587d93
---- /dev/null
++++ b/test/no-looks-guard.test.js
@@@ -0,0 +1,18 @@
++'use strict';
++// Owner, 2026-10-31: AI may describe people; only sex acts and porn stay gated. No server file may call the old
++// looks filter again.
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++
++const root = path.join(__dirname, '..');
++const offenders = [];
++(function walk(dir) {
++ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
++ const p = path.join(dir, e.name);
++ if (e.isDirectory()) walk(p);
++ else if (e.name.endsWith('.js') && /\b(noLooks|noLooksTags|hasLooks)\s*\(|openvibe-sdk\/no-looks/.test(fs.readFileSync(p, 'utf8'))) offenders.push(path.relative(root, p));
++ }
++})(path.join(root, 'server'));
++assert.deepStrictEqual(offenders, [], 'no looks filter in server/');
++console.log('No looks filter: AI text describes people as written.');
ddiff --git a/test/pastes-no-looks.test.js b/test/pastes-no-looks.test.js
ddeleted file mode 100644
iindex c5f4fa2..0000000
---- a/test/pastes-no-looks.test.js
++++ /dev/null
@@@ -1,88 +0,0 @@
--'use strict';
--/**
-- * Public AI text never describes a person (server/pastes/no-looks.js, openvibe-sdk/no-looks): a service's AI summary
-- * and tags, and an AI paste's title and body, are stored without looks or a guessed gender; a person's own title and
-- * text are theirs as written; rows stored before are repaired at boot, once.
-- */
--const assert = require('assert');
--const { boot, check, done } = require('./helpers/app');
--const looks = require('../server/pastes/no-looks');
--
--(async () => {
-- const t = await boot({ authority: 'community', pasteLimits: { cooldownSeconds: 0, commentCooldownSeconds: 0 } });
-- const net = t.network;
-- const alex = net.addUser({ network_user_id: 7, username: 'alex', display_name: 'Alex' });
-- const alexJwt = net.sign({ id: 7, subject_id: alex.subject_id, username: 'alex', display_name: 'Alex', role: 'user' });
-- const svc = (cap) => net.signService({ cap });
-- const call = (path, { method = 'GET', token, cookie, headers = {}, json } = {}) => {
-- const h = { ...headers };
-- if (token) h.authorization = `Bearer ${token}`;
-- let body;
-- if (json !== undefined) { h['content-type'] = 'application/json'; body = JSON.stringify(json); }
-- return t.get(path, { method, headers: h, body, cookies: cookie ? [`ov_token=${cookie}`] : [] });
-- };
--
-- await check('an AI paste from a service: title, body, summary and tags carry no looks; the footer stays', async () => {
-- const r = await call('/api/pastes', {
-- method: 'POST', token: svc(['community.paste.create']), headers: { 'x-ov-origin': 'ai' },
-- json: {
-- title: 'A Shirtless Man Cooks', slug: 'cooks-1',
-- content: 'A shirtless man in a red hoodie cooks pasta.\n\n🔴 Caught live on @chef\'s stream — https://openvibe.live/@chef',
-- ai_summary: 'A man wearing a hat cooks pasta.', ai_tags: ['man', 'pasta', 'red-hoodie'],
-- },
-- });
-- assert.strictEqual(r.status, 201, r.text);
-- const p = r.json().paste;
-- assert.strictEqual(p.title, 'The streamer Cooks');
-- assert.strictEqual(p.content, 'The streamer cooks pasta.\n\n🔴 Caught live on @chef\'s stream — https://openvibe.live/@chef');
-- assert.strictEqual(p.ai_summary, 'A person cooks pasta.');
-- assert.deepStrictEqual(JSON.parse(p.ai_tags), ['pasta']);
-- });
--
-- await check('a person\'s paste: their title and text as written; the AI summary of it follows the rule', async () => {
-- const r = await call('/api/pastes', { method: 'POST', cookie: alexJwt, json: { title: 'A man and his dog', content: 'A man in a red shirt walks his dog.' } });
-- assert.strictEqual(r.status, 201, r.text);
-- const slug = r.json().slug;
-- assert.strictEqual(r.json().paste.title, 'A man and his dog');
-- assert.strictEqual(r.json().paste.content, 'A man in a red shirt walks his dog.');
-- const w = await call(`/api/pastes/${slug}/ai`, { method: 'POST', token: svc(['community.paste.moderate']), json: { ai_summary: 'Close-up selfie of a man, taken outdoors.', ai_tags: '["selfie","outdoors","beard"]' } });
-- assert.strictEqual(w.status, 200, w.text);
-- assert.strictEqual(w.json().paste.ai_summary, 'Close-up selfie of a person, taken outdoors.');
-- assert.deepStrictEqual(JSON.parse(w.json().paste.ai_tags), ['outdoors']);
-- const row = await t.db.prepare('SELECT title, content FROM pastes WHERE slug = ?').get(slug);
-- assert.deepStrictEqual([row.title, row.content], ['A man and his dog', 'A man in a red shirt walks his dog.']);
-- });
--
-- await check('rows stored before the rule are repaired once; a person\'s text is untouched; a second pass changes nothing', async () => {
-- const ins = (origin, title, content, summary, tags) => t.db.prepare(`INSERT INTO pastes (slug, origin, type, title, content, language, visibility, ai_summary, ai_tags)
-- VALUES (?, ?, 'paste', ?, ?, 'text', 'public', ?, ?)`).run(`old-${Math.random().toString(36).slice(2, 10)}`, origin, title, content, summary, tags);
-- await ins('ai', 'A Close-Up Selfie Moment Where A Man Looks Straight', 'Close-up selfie of a man, taken outdoors with trees in the background.\n\n▶ Watch this moment on @x\'s stream: https://openvibe.video/v/1', 'Close-up selfie of a man, taken outdoors with trees in the background.', '["man","trees"]');
-- await ins('user', 'Two men at a desk', 'Two men at a desk.', 'A man lounging on a sofa, holding a remote.', 'sofa, man');
-- await ins('user', 'Notes', 'my notes', 'A list of notes about a game.', null);
-- const n = await looks.repairStored(t.db, { log: null });
-- assert.strictEqual(n, 2);
-- const ai = await t.db.prepare("SELECT title, content, ai_summary, ai_tags FROM pastes WHERE title LIKE 'A Close-Up%'").get();
-- assert.strictEqual(ai.title, 'A Close-Up Selfie Moment Where A person Looks Straight');
-- assert.strictEqual(ai.content, 'Close-up selfie of a person, taken outdoors with trees in the background.\n\n▶ Watch this moment on @x\'s stream: https://openvibe.video/v/1');
-- assert.strictEqual(ai.ai_summary, 'Close-up selfie of a person, taken outdoors with trees in the background.');
-- assert.strictEqual(ai.ai_tags, '["trees"]');
-- const user = await t.db.prepare("SELECT title, content, ai_summary, ai_tags FROM pastes WHERE title = 'Two men at a desk'").get();
-- assert.deepStrictEqual([user.title, user.content], ['Two men at a desk', 'Two men at a desk.'], 'a person\'s own words stay');
-- assert.strictEqual(user.ai_summary, 'A person lounging on a sofa, holding a remote.');
-- assert.strictEqual(user.ai_tags, 'sofa');
-- assert.strictEqual(await looks.repairStored(t.db, { log: null }), 0, 'nothing left to repair');
-- });
--
-- await check('helpers: non-strings pass through; a body or title that is all looks leaves nothing of it', async () => {
-- assert.strictEqual(looks.summary(null), null);
-- assert.strictEqual(looks.tags(null), null);
-- assert.deepStrictEqual(looks.tags(['hat', 'game']), ['game']);
-- assert.strictEqual(looks.body('A person adjusts their hair.\n\n🔴 Caught live on @x\'s stream'), '🔴 Caught live on @x\'s stream', 'a description that was all looks leaves the footer');
-- assert.strictEqual(looks.body('A person adjusts their hair.'), '');
-- assert.strictEqual(looks.body("A desk.\n\n▶ Watch this moment on @null's stream: https://openvibe.video/v/9"), 'A desk.\n\n▶ Watch this moment on the stream: https://openvibe.video/v/9');
-- assert.strictEqual(looks.body("A man at a desk.\r\n\r\n▶ Watch this moment on @null's stream: https://openvibe.video/v/9"), 'A person at a desk.\r\n\r\n▶ Watch this moment on the stream: https://openvibe.video/v/9', 'CRLF, as Live writes it');
-- assert.strictEqual(looks.cleanRow({ origin: 'ai', title: 'A person adjusts their hair' }).title, 'Untitled');
-- });
--
-- done();
--})().catch((err) => { console.error(err); process.exit(1); });