openvibers/openvibe.community Commits

Public AI text never describes a person: every AI summary, tag and AI paste title/body passes openvibe-sdk/no-looks (v0.61.0), and stored rows are repaired once (187 summaries named people; 478 '@null' footers) (#117)

OP OpenVibers committed 22 days ago · 5f45093564 · 8 files, +189 −15

Changes

ddiff --git a/README.md b/README.md
iindex d14db29..9be5b1c 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.60.0 (events outbox, per-actor limits),
++- `openvibe-contracts` v0.112.0, `openvibe-sdk` v0.61.0 (events outbox, per-actor limits, no-looks),
`openvibe-shared` v4.4.0, pinned by release tarball
## How it fits the network
@@@ -845,6 +845,6 @@ release after paste comments moved onto the typed comment threads. [Submissions]
<!-- versions:start -->
- openvibe-contracts: v0.209.0
--- openvibe-sdk: v0.60.0
++- openvibe-sdk: v0.61.0
- openvibe-shared: v4.4.0
<!-- versions:end -->
ddiff --git a/STATUS.json b/STATUS.json
iindex ed34f71..2c15387 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -8,7 +8,7 @@
"port": 4200,
"domain": "openvibe.community",
"contracts": "openvibe-contracts v0.209.0",
-- "sdk": "openvibe-sdk v0.60.0",
++ "sdk": "openvibe-sdk v0.61.0",
"shared": "openvibe-shared v4.4.0",
"capabilities": [
"community.paste.create",
ddiff --git a/package-lock.json b/package-lock.json
iindex 0226bd1..afff8f1 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.209.0",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.60.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.61.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.4.0",
"pg": "^8.23.0"
},
@@@ -933,9 +933,9 @@
}
},
"node_modules/openvibe-sdk": {
-- "version": "0.60.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.60.0",
-- "integrity": "sha512-hhgpGeEZsoGh+mGWcY22ZOXL+0vTNX+q7PtaAzxwNtr57K2XgCCHl2WqRpjHjrzyxPsjOOr9LBo3VYnmNv18bw==",
++ "version": "0.61.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.61.0",
++ "integrity": "sha512-rhQKwM+yzVjhgUPTeqMOkL9N38fbSy8JYiEbJ8SwZk8LGcusde6LoQNhQ8UUxMJIwyY7xR1Rp5NVd6XRaVgTQQ==",
"license": "MIT",
"bin": {
"openvibe-download": "bin/openvibe-download.js"
ddiff --git a/package.json b/package.json
iindex 78ac3c7..e0ae871 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.209.0",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.60.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.61.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.4.0",
"pg": "^8.23.0"
},
ddiff --git a/server/index.js b/server/index.js
iindex 42909bf..ed3f42e 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -35,6 +35,9 @@ 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
nnew file mode 100644
iindex 0000000..31c7a6d
---- /dev/null
++++ b/server/pastes/no-looks.js
@@@ -0,0 +1,81 @@
++'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');
++
++const FOOTER = /(\n\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 65b85c0..e5e965c 100644
---- a/server/pastes/service.js
++++ b/server/pastes/service.js
@@@ -18,6 +18,7 @@
const crypto = require('crypto');
const events = require('../events');
const store = require('./store');
++const looks = require('./no-looks');
const commentStore = require('../comments/store');
const { discussionModerator } = require('../identity/capabilities');
const { stripImageMetadata } = require('../media/strip-metadata');
@@@ -284,7 +285,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);
-- return { ai_summary: String(body.ai_summary).slice(0, 2000), ai_tags: tags, ai_analyzed_at: new Date().toISOString().replace('T', ' ').slice(0, 19) };
++ // 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) };
}
/**
@@@ -410,8 +412,9 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
owner_subject: v.subject || null,
origin: v.origin === 'ai' ? 'ai' : 'user',
type: 'paste',
-- title: store.sanitizeTitle(body.title),
-- content: content.trim(),
++ // 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(),
language: store.detectLanguage(content, body.language),
visibility,
stream_ref: streamRefFor(v, body),
@@@ -457,8 +460,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(body.title || 'Screenshot'),
-- content: String(body.description || body.content || ''),
++ 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 || ''),
language: 'text',
visibility,
screenshot_url: stored.url,
@@@ -712,8 +715,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 : 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 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 row = await store.setAi(db, p.id, summary, tags);
pingIndexNow(row);
return { ok: true, paste: await shapeOne(row, v) };
ddiff --git a/test/pastes-no-looks.test.js b/test/pastes-no-looks.test.js
nnew file mode 100644
iindex 0000000..cd42968
---- /dev/null
++++ b/test/pastes-no-looks.test.js
@@@ -0,0 +1,87 @@
++'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.cleanRow({ origin: 'ai', title: 'A person adjusts their hair' }).title, 'Untitled');
++ });
++
++ done();
++})().catch((err) => { console.error(err); process.exit(1); });