openvibers/openvibe.blog Commits

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

OP OpenVibers committed 11 days ago · 1165ef5e9c · 6 files, +47 −189

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

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

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

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

Changes

ddiff --git a/server/patch-notes/explicit.js b/server/patch-notes/explicit.js
nnew file mode 100644
iindex 0000000..ce2424f
---- /dev/null
++++ b/server/patch-notes/explicit.js
@@@ -0,0 +1,10 @@
++'use strict';
++/**
++ * Blog's publishing gate: a Patch notes draft whose text says a scene shows sex acts or pornography is never
++ * published (owner, 2026-10-31: nudity is art, describing people is fine; only sex and porn stay gated).
++ */
++const EXPLICIT = /\b(?:sexual(?:ly)?\s+(?:acts?|activity|intercourse)|sex\s+acts?|having\s+sex|(?:pornographic|sexually\s+explicit|explicit\s+adult)\s+(?:content|material|footage|scenes?|imagery|videos?|acts?)|adult\s+(?:films?|videos?)|porn(?:o|ography|ographic)?|masturbat\w*|intercourse|oral\s+sex|penetrat\w*)\b/i;
++function isExplicit(...texts) {
++ return texts.some((t) => t != null && EXPLICIT.test(String(t)));
++}
++module.exports = { isExplicit, EXPLICIT };
ddiff --git a/server/patch-notes/no-looks.js b/server/patch-notes/no-looks.js
ddeleted file mode 100644
iindex 46e05ed..0000000
---- a/server/patch-notes/no-looks.js
++++ /dev/null
@@@ -1,165 +0,0 @@
--'use strict';
--/**
-- * Blog's copy of the owner's looks rule: public AI text never describes a person — no looks, body, clothing, hair,
-- * face, pose, age or guessed gender. Prompts say so; models still do it, so public AI text passes noLooks(). The
-- * rules and the test corpus are openvibe-sdk/no-looks (OpenVibe.Live, Live#298–#302, moved into the SDK); Blog pins
-- * an older openvibe-sdk that does not carry the module yet, so this is the same implementation, unchanged, and the
-- * next SDK pin replaces it with a require of openvibe-sdk/no-looks.
-- *
-- * - noLooks(text): "A shirtless man sits…" → "The streamer sits…"; "a man in a kitchen" → "a person in a kitchen";
-- * "two men" → "two people"; a clause about clothes, hair or a pose goes.
-- * - noLooksTags(tags): drops tags that name clothing, hair, face, age or a person by gender.
-- * - hasLooks(text): true when a short text names looks at all (a title is replaced, not repaired).
-- * - isExplicit(...texts): a scene description that says sexual content (never published at all).
-- * - RULES_VERSION: a stable hash of the rules.
-- */
--
--
--const _LOOKS = 'shirtless|topless|bare-chested|half-naked|semi-naked|naked|nude|semi-nude|undressed|unclothed|young|younger|teenage|teen|little|bearded|bald|blonde?|(?:dark|long|short|curly|red|gr[ae]y|black|brown)-haired|tattooed|muscular|heavyset|older|elderly|middle-aged|skinny|slim|chubby|attractive|pretty|handsome|cute';
--const _PEOPLE = 'man|woman|boy|girl|guy|gal|kid|child|teen|teenager|lady|gentleman|male|female|person|individual|streamer|youth';
--const _AGE_NOUN = /^(?:boy|girl|kid|child|teen|teenager|youth)$/i;
--const _LOOKED_AT = new RegExp(`\\b(a|an|the|one)\\s+((?:(?:${_LOOKS})[\\s,]+(?:and\\s+)?)*)(${_PEOPLE})\\b`, 'gi');
--const _LOOKED_AT_BARE = new RegExp(`\\b(?:(?:${_LOOKS})[\\s,]+(?:and\\s+)?)+(${_PEOPLE})\\b`, 'gi');
--const _cap = (m, s) => (/^[A-Z]/.test(m) ? s[0].toUpperCase() + s.slice(1) : s);
--// What someone wears or how their hair and face look ("wearing a black beanie and hoodie", "in a red shirt",
--// "with blond hair and a mustache", "a headset-wearing host") goes; the sentence keeps everything else. Quoted
--// speech ("Heard: "I like your hair"") is what was said and stays word for word.
--const _ART = '(?:(?:a|an|the|his|her|their|some|matching)\\s+)?';
--const _WORN = 't-shirts?|shirts?|tees?|tank tops?|hoodies?|sweatshirts?|sweaters?|jackets?|coats?|vests?|beanies?|baseball caps?|caps?|hats?|helmets?|headsets?|headphones|sunglasses|glasses|outfits?|dress(?:es)?|jerseys?|suits?|robes?|bandanas?|scarf|scarves|masks?|apparel|attire';
--const _FACE = 'beards?|mustaches?|moustaches?|goatees?|stubble|sunglasses|glasses|tattoos?|hair|ponytails?|braids|dreadlocks|piercings?|freckles|makeup|expressions?|beanies?|backward caps?|baseball caps?|hoodies?';
--const _WORN_ANY = `${_WORN}|microphones?|mics?|earbuds?|earphones?|belts?|gloves|boots|shoes|sneakers|jeans|pants|shorts|necklaces?|chains?|earrings|watch(?:es)?|uniforms?|clothes|clothing|gear|costumes?|wigs?|crowns?|goggles|visors?|lanyards?|tops?|neckties?|bow ties?|ties?|headbands?|garments?|turbans?`;
--const _WORN_NP = `${_ART}(?:[\\w'-]+\\s+){0,3}?\\b(?:${_WORN_ANY})\\b(?:\\s+(?:on|around|over|across)\\s+(?:his|her|their|the|its)\\s+(?:head|neck|face|forehead|shoulders?|ears?|eyes|chest))?(?:\\s+with\\s+(?:(?:a|an)\\s+)?(?:[\\w'-]+\\s+){0,2}?(?:zippers?|logos?|prints?|patterns?|designs?|graphics?|hoods?|stripes?|collars?|text|eyes|ears|horns|patch(?:es)?|badges?|stickers?|emblems?|lettering|writing|trim|buttons|accents|embellishments|details|sleeves))?`;
--const _WEARING = new RegExp(`,?\\s*(?:who\\s+(?:is\\s+)?|while\\s+|and\\s+)?(?<!-)\\b(?:wearing|wears|dressed in|sporting)\\s+${_WORN_NP}(?:\\s*(?:,|and|or|with|over|under)\\s+${_WORN_NP})*`, 'gi');
--// Clothes below the waist say what someone wears as much ("The streamer in shorts kneels on grass…", a Community
--// summary from 2026-04 that every repair left), but "in the pants department" is a place: a shop word after it keeps it.
--const _LOWER = 'shorts|jeans|pants|trousers|leggings|skirts?|sweatpants|joggers|pajamas|pyjamas|swimsuits?|swimwear|bikinis?|underwear|lingerie|overalls|tights|dungarees';
--const _IN_WORN = new RegExp(`\\s+in\\s+(?:(?:a|an|the|his|her|their)\\s+)?(?:[\\w'-]+\\s+){0,3}?\\b(?:${_WORN}|(?:${_LOWER})(?!\\s+(?:department|store|shop|section|aisle|pockets?|drawer)))\\b(?:\\s+(?:and|or)\\s+${_ART}(?:[\\w'-]+\\s+){0,2}?\\b(?:${_WORN_ANY})\\b)*`, 'gi');
--const _PERSON_WITH_WORN = new RegExp(`\\b((?:${_PEOPLE}|broadcaster|host|guy|gamer|player|viewer|someone|people|men|women|speaker|presenter)\\s+)with\\s+${_ART}(?:[\\w'-]+\\s+){0,3}?\\b(?:${_WORN_ANY}|${_FACE})\\b(?:\\s*(?:,|and|or)\\s+${_ART}(?:[\\w'-]+\\s+){0,2}?\\b(?:${_WORN_ANY}|${_FACE})\\b)*\\s*`, 'gi');
--const _WITH_FACE = new RegExp(`\\s+with\\s+${_ART}(?:[\\w'-]+\\s+){0,2}?\\b(?:${_FACE})\\b(?:\\s*(?:,|and|or)\\s+${_ART}(?:[\\w'-]+\\s+){0,2}?\\b(?:${_FACE}|${_WORN})\\b)*`, 'gi');
--const _WEARS_AND = new RegExp(`(?<!-)\\bwears\\s+${_WORN_NP}(?:\\s*(?:,|and|or|with|over|under)\\s+${_WORN_NP})*\\s+and\\s+(?=\\w)`, 'gi');
--// "…, while the other wears a dusty t-shirt;" goes as a whole clause.
--const _WEARS_CLAUSE = new RegExp(`,?\\s+(?:while|and|as|whereas)\\s+(?:the other|one|another|someone|he|she|they|the streamer|(?:a|the) (?:man|woman|person|guy))\\s+wears\\s+${_WORN_NP}(?:\\s*(?:,|and|or|with|over|under)\\s+${_WORN_NP})*(?=\\s*(?:[;,.!?]|$))`, 'gi');
--// "a plaid/checkered shirt": a slashed pair of words before what is worn reads as one word, so the rules below see it.
--const _SLASHED_WORN = new RegExp(`\\b([\\w'-]+)/[\\w'-]+(?=\\s+(?:[\\w'-]+\\s+){0,2}?(?:${_WORN_ANY})\\b)`, 'gi');
--// ", with a blue jacket partially visible": a comma-led clause about clothes, whoever wears them, goes (clothes only:
--// "a desk with a headset" stays).
--const _WITH_CLOTHES = /,\s*with\s+(?:(?:a|an|the|their|his|her)\s+)?(?:[\w'-]+\s+){0,2}?(?:t-shirts?|shirts?|tees?|tank tops?|hoodies?|sweatshirts?|sweaters?|jackets?|coats?|vests?|outfits?|dress(?:es)?|jerseys?|suits?|robes?|outerwear)\b[^,.;!?]*(?:,(?=\s+(?:is|are|was|were)\s))?/gi;
--function _noClothing(text) {
-- return text.split(/("[^"]*"?|“[^”]*”?)/).map((part, i) => (i % 2 ? part : part
-- .replace(_SLASHED_WORN, '$1')
-- .replace(_WITH_CLOTHES, '')
-- // A parenthetical clause taken out of the middle ("A third person, with a red cap, is shown") leaves no ",,".
-- .replace(/\s*,\s*,\s*/g, ' ')
-- .replace(/\b[\w-]+-wearing\s+/gi, '')
-- .replace(_WEARS_AND, '')
-- .replace(_WEARS_CLAUSE, '')
-- .replace(/\b(?:casually|smartly|neatly|warmly|brightly|formally|well)[\s-]+dressed\s+/gi, '')
-- .replace(_PERSON_WITH_WORN, '$1')
-- .replace(_WEARING, '')
-- .replace(_IN_WORN, '')
-- .replace(_WITH_FACE, '')
-- .replace(/\s+([,.;:!?])/g, '$1')
-- .replace(/,(?=[.;:!?])/g, '')
-- .replace(/([:;]),/g, '$1')
-- .replace(/ {2,}/g, ' '))).join('');
--}
--// Tags shown with public AI items ("red-beanie", "bearded", "man", "tanktop") follow the same rule: a tag naming
--// clothing, hair, face, age or a person by gender is dropped.
--const _LOOKS_TAG = new RegExp(`(?:^|[\\s_-])(?:${_WORN}|${_FACE}|${_LOOKS}|men|women|man|woman|boy|girl|guy|gal|kid|child|teen|teenager|lady|male|female|youth|shirtless|topless|tanktop|selfie)(?=$|[\\s_-])`, 'i');
--// A short AI title that names looks ("Orange Shirt Kitchen Chat") cannot be repaired word by word: it is not used.
--const _LOOKS_WORD = new RegExp(`\\b(?:${_WORN}|${_FACE}|${_LOOKS}|faces?|smil(?:e|ing)|body|skin|men|women|man|woman|boy|girl|guy|kid|child|teen|lady|male|female|shirtless|topless)\\b`, 'i');
--// Names are not people (as noLooks spares them): "Iron Man Speedrun", "Pac-Man Marathon", "Spider-Man 2" are titles
--// about a game or a character, so a hyphenated name and the few two-word ones are skipped before the word test.
--const _NAMED = /\b(?:iron|mega|pac|ant|spider|bat|he|ray)[\s-]+(?:man|men|woman|women)\b|-(?:man|men|woman|women|boy|girl)\b/gi;
--const _hasLooks = (t) => _LOOKS_WORD.test(String(t || '').replace(_NAMED, ' '));
--function _noLooksTags(tags) {
-- return (Array.isArray(tags) ? tags : []).filter((t) => typeof t === 'string' && t.trim() && !_LOOKS_TAG.test(t.trim()));
--}
--// Body language is the person too ("reacting with a thoughtful pose", "in a relaxed posture"): the phrase goes.
--const _POSE = /,?\s*(?:(?:appearing|seeming|seemingly)\s+(?:to\s+be\s+)?)?(?:(?:reacting|sitting|standing|leaning|posing|looking|resting)\s+)?(?:with|in)\s+(?:an?|their|his|her)\s+(?:[\w-]+\s+){0,2}(?:pose|posture|expression|facial expression|stance)\b/gi;
--// A guessed gender is a description of the person too (owner's rule: no looks, body, clothing, hair, age or guessed
--// gender): "a man in a kitchen" is "a person in a kitchen", "two men" are "two people", "a male or female host" is "a
--// host". Only after an article or a count, so names (Iron Man, Pac-Man, Spice Girls) and quoted speech stay.
--const _GENDERED = 'man|woman|guy|gal|lady|gentleman';
--const _GENDERED_ONE = new RegExp(`\\b(a|an|the|one|another|this|that|each|every)\\s+(?:${_GENDERED})\\b(?!-)`, 'gi');
--const _GENDERED_MANY = new RegExp(`\\b(two|three|four|five|several|some|the|both|many|other|few|these|those|\\d+)\\s+(?:men|women|guys|gals|ladies|gentlemen)\\b`, 'gi');
--// A pronoun is a guessed gender too: he and she are "the person", his and her (before a word) "their", him and her
--// (after a verb or a preposition, or at the end) "them", himself and herself "themself", hers "theirs". Outside
--// quotes only: what someone said stays word for word. "The person" keeps the verb as written ("he looks" → "the person
--// looks"), which "they" would not.
--const _HE_SHE = /\b(?:he|she)\b(?:'(s|d|ll)\b)?/gi;
--const _HER_OBJECT = /\bher\b(?=\s*(?:[.,;:!?)\]"”]|$)|\s+(?:and|or|but|to|from|with|in|on|at|for|as|while|when|because|so|if|the|a|an|this|that|is|was|again|back|up|down|out|off|away|over|now|too|here|there|more|about|into|onto)\b)/gi;
--const _PRONOUNS = [
-- [/\b(?:himself|herself)\b/gi, 'themself'],
-- [/\bhers\b/gi, 'theirs'],
-- [/\bhim\b/gi, 'them'],
-- [_HER_OBJECT, 'them'],
-- [/\b(?:his|her)\b(?=\s+[\w'-])/gi, 'their'],
-- [/\bhis\b/gi, 'theirs'],
--];
--const _CONTRACTED = { s: "'s", d: ' would', ll: ' will' };
--function _noPronouns(part) {
-- let out = part.replace(_HE_SHE, (m, c) => _cap(m, `the person${c ? _CONTRACTED[c.toLowerCase()] : ''}`));
-- for (const [re, word] of _PRONOUNS) out = out.replace(re, (m) => _cap(m, word));
-- return out;
--}
--const _GENDER_ADJ = /\b(?:(?:a|an)\s+)?(?:male|female)\s+(?:or|and|\/)\s+(?:male|female)\s+|\b(?:male|female)\s+(?=(?:hosts?|streamers?|players?|speakers?|presenters?|gamers?|voices?|narrators?|commentators?|figures?|characters?|broadcasters?|viewers?|individuals?|persons?|people)\b)/gi;
--// What is left of a sentence about someone's hair, clothes or body ("a person adjusts their hair or clothing near the
--// counter") cannot be repaired word by word: the sentence goes.
--const _GROOMING = new RegExp('\\b(?:(?:their|his|her)\\s+(?:own\\s+)?(?:hair|beard|face|makeup|clothing|clothes|outfit|appearance|body|figure|looks)'
-- // The face and body described ("smiling broadly, displaying white teeth and a beard") and clothes the word lists do
-- // not name ("blue outerwear", "in outdoor gear"): the sentence goes. Things a person holds or reviews (headphones, a
-- // headset on a desk) are not here.
-- + '|beards?|mustaches?|moustaches?|teeth|grin(?:s|ning)?|smil(?:e|es|ing)\\s+(?:broadly|widely|warmly|at)|freckles|complexion|wrinkles'
-- + '|hairstyles?|haircuts?|outerwear|(?:in|wearing)\\s+(?:[\\w-]+\\s+){0,2}gear|face\\s+shields?)\\b', 'i');
--function _noGender(text) {
-- return text.split(/("[^"]*"?|“[^”]*”?)/).map((part, i) => (i % 2 ? part : part
-- .replace(_GENDER_ADJ, (m) => (/^an?\s/i.test(m) ? m.slice(0, m.indexOf(' ') + 1) : ''))
-- .replace(_GENDERED_ONE, (m, art) => _cap(m, `${/^an$/i.test(art) ? 'a' : art.toLowerCase()} person`))
-- .replace(_GENDERED_MANY, (m, n) => _cap(m, `${n.toLowerCase()} people`))
-- .replace(/\ba\s+(?=[aeiou]\w*\s+(?:host|streamer|player|speaker|presenter|gamer|figure|character|broadcaster|person)\b)/gi, (m) => _cap(m, 'an '))
-- .replace(/ {2,}/g, ' '))).map((part, i) => (i % 2 ? part : _noPronouns(part))).join('');
--}
--function _noGrooming(text) {
-- const sentences = text.match(/[^.!?]+(?:[.!?]+(?:["”])?|$)\s*/g) || [text];
-- return sentences.filter((s) => !_GROOMING.test(s.replace(/"[^"]*"?|“[^”]*”?/g, ''))).join('').trim();
--}
--function _noLooks(text) {
-- if (!text || typeof text !== 'string') return text;
-- return _noGrooming(_noGender(_noLooksWords(text)));
--}
--function _noLooksWords(text) {
-- return _noClothing(text).replace(_POSE, '').replace(/\s+([,.;:!?])/g, '$1').replace(/ {2,}/g, ' ')
-- // Looks named: the person on camera. Only an age ("a child sits nearby"): someone, who may not be the streamer.
-- .replace(_LOOKED_AT, (m, art, looks, noun) => (looks ? _cap(m, 'the streamer') : _AGE_NOUN.test(noun) ? _cap(m, /^the$/i.test(art) ? 'the person' : 'someone') : m))
-- .replace(_LOOKED_AT_BARE, (m) => _cap(m, 'streamer'))
-- .replace(/\s+(?:is|are)\s+(?:shirtless|topless|bare-chested|half-naked|semi-naked|naked|nude|semi-nude|undressed|unclothed)\s+and\s+/gi, ' ')
-- .replace(/\b(?:shirtless|topless|bare-chested|half-naked|semi-naked|naked|nude|semi-nude|undressed|unclothed)\s+and\s+/gi, '')
-- .replace(/\b(one|the other|another),?\s+and\s+(?=\w+ing\b)/gi, '$1 ')
-- .replace(/\s+(?:is|are)\s+(?:shirtless|topless|bare-chested|half-naked|semi-naked|naked|nude|semi-nude|undressed|unclothed)\b/gi, ' is on camera')
-- .replace(/\s*,\s*(?:shirtless|topless|bare-chested|half-naked|semi-naked|naked|nude|semi-nude|undressed|unclothed)\s*,/gi, '')
-- .replace(/\b(?:shirtless|topless|bare-chested|half-naked|semi-naked|naked|nude|semi-nude|undressed|unclothed)\b,?\s*/gi, '');
--}
--
--// FNV-1a over the rules' source, twice with different offsets: a stable 16-hex-digit version.
--function _hash(text) {
-- let a = 0x811c9dc5, b = 0x01000193 ^ 0x5bd1e995;
-- for (let i = 0; i < text.length; i++) {
-- const c = text.charCodeAt(i);
-- a = Math.imul(a ^ c, 0x01000193) >>> 0;
-- b = Math.imul(b ^ c, 0x01000193 + 2) >>> 0;
-- }
-- return a.toString(16).padStart(8, '0') + b.toString(16).padStart(8, '0');
--}
--// What a scene shows, not how someone looks: the looks rules take out an adjective ("a naked man … reaching toward
--// their genitals" → "a man … reaching toward their genitals"), so a service asks isExplicit() before it publishes AI text
--// at all. Live's AI auto-clip 483 went public with such a description on 2026-10-23 (Live#344 had its own copy first).
--const _EXPLICIT = /\b(?:genital(?:s|ia)?|penis(?:es)?|vaginas?|vulvas?|sexual(?:ly)?\s+(?:acts?|activity|content|material|explicit|intercourse)|sex\s+acts?|(?:explicit|adult|pornographic|sexual)\s+(?:content|material|footage|scenes?|imagery|videos?|acts?)|porn(?:o|ography|ographic)?|masturbat\w*|intercourse|nudity|erotica?)\b/i;
--function _isExplicit(...texts) {
-- return texts.some((t) => t != null && _EXPLICIT.test(String(t)));
--}
--
--const RULES_VERSION = _hash([_EXPLICIT, _NAMED, _LOOKS, _PEOPLE, _WORN, _LOWER, _WORN_ANY, _FACE, _WITH_CLOTHES, _GENDER_ADJ, _GROOMING, _LOOKS_TAG, _LOOKS_WORD, _POSE,
-- _noLooks, _noLooksWords, _noGender, _noGrooming, _noClothing, _HE_SHE, _HER_OBJECT, _PRONOUNS.map(([re, w]) => `${re}${w}`).join(','), _noPronouns].map(String).join('|'));
--
--module.exports = { noLooks: _noLooks, noLooksTags: _noLooksTags, hasLooks: _hasLooks, isExplicit: _isExplicit, RULES_VERSION };
ddiff --git a/server/patch-notes/review.js b/server/patch-notes/review.js
iindex 1ef8861..f9cb270 100644
---- a/server/patch-notes/review.js
++++ b/server/patch-notes/review.js
@@@ -4,7 +4,7 @@
*
* evaluate() runs the checks on the draft and returns what the reviewer sees: the story map, the blocking issues,
* the warnings, and a checklist. Hard gates block publishing — a banned phrase (unless the editor overrode it with
-- * a reason), a looks word, an unresolved [TODO], a broken link, an attached image without alt text, AI-only text
++ * a reason), sex acts or porn, an unresolved [TODO], a broken link, an attached image without alt text, AI-only text
* without a person's approval, or no approval at all. Warnings do not block: a title with a count, a section with
* no screenshot, a commit subject in the story.
*
@@@ -12,7 +12,7 @@
* draft made inside the worker does not, so a GitHub hiccup never stops the draft from being filed).
*/
const { scan, proseOf } = require('./slop');
--const { hasLooks, isExplicit } = require('./no-looks');
++const { isExplicit } = require('./explicit');
const H2 = /^##\s+(.+)$/gm;
const LINK = /\[([^\]]*)\]\(([^)\s]+)\)/g;
@@@ -56,7 +56,7 @@ async function evaluate(draft) {
const checklist = [];
const add = (list, code, message, extra = {}) => list.push({ code, message, ...extra });
-- // ── slop and looks ──
++ // ── slop, sex acts and porn ──
const slop = scan({ title, dek, body, tags, captions });
const forgiven = new Set(((override && override.phrases) || []).map((p) => String(p).toLowerCase()));
for (const b of slop.blocking) {
@@@ -64,11 +64,8 @@ async function evaluate(draft) {
else blocking.push(b);
}
warnings.push(...slop.warnings);
-- // The full list holds exact commit subjects (a "token cap" is not a hat); the story is what describes people.
-- const looksText = [title, dek, proseOf(body), ...tags, ...captions].join('\n');
-- if (hasLooks(looksText)) add(blocking, 'looks.person', 'The text describes a person (looks, body, clothing, hair, age or a guessed gender). Public AI text never does. Say "the person" or "the streamer", or remove the sentence.');
-- if (isExplicit(title, dek, body)) add(blocking, 'looks.explicit', 'The text describes sexual content. It cannot be published.');
-- checklist.push({ code: 'voice', label: 'The voice card is followed; no banned phrases; no looks language', ok: !blocking.some((b) => /^(slop|looks)\./.test(b.code)), detail: blocking.filter((b) => /^(slop|looks)\./.test(b.code)).length ? `${blocking.filter((b) => /^(slop|looks)\./.test(b.code)).length} blocking` : 'clean' });
++ if (isExplicit(title, dek, proseOf(body))) add(blocking, 'explicit.sex', 'The text describes sex acts or pornography. It cannot be published.');
++ checklist.push({ code: 'voice', label: 'The voice card is followed; no banned phrases; no sex acts or porn', ok: !blocking.some((b) => /^(slop|explicit)\./.test(b.code)), detail: blocking.filter((b) => /^(slop|explicit)\./.test(b.code)).length ? `${blocking.filter((b) => /^(slop|explicit)\./.test(b.code)).length} blocking` : 'clean' });
// ── unresolved placeholders ──
if (TODO.test(body) || TODO.test(title) || TODO.test(dek)) add(blocking, 'draft.todo', 'An unresolved [TODO] is still in the draft.');
ddiff --git a/server/patch-notes/slop.js b/server/patch-notes/slop.js
iindex 06a4a95..6b83021 100644
---- a/server/patch-notes/slop.js
++++ b/server/patch-notes/slop.js
@@@ -3,7 +3,7 @@
* The slop check (devblog §3.7, §4.5): a deterministic copy editor, not an AI detector.
*
* A word is not banned because a model likes it; it is banned as filler ("seamless warping" is a feature name,
-- * "a seamless experience" is slop). Blocking: a phrase from the list, and a looks word (no-looks). Warnings: the
++ * "a seamless experience" is slop). Blocking: a phrase from the list. Warnings: the
* structures that give generated prose away — "not only X, but also Y", "it's not just X, it's Y", a three-item
* list in every paragraph, one length for every paragraph, an em dash every other sentence, a conclusion that
* restates the intro.
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/patch-notes.test.js b/test/patch-notes.test.js
iindex 2978644..b09f78f 100644
---- a/test/patch-notes.test.js
++++ b/test/patch-notes.test.js
@@@ -10,7 +10,7 @@ const { compose, briefFor, mergeAi, nextItems } = require('../server/patch-notes
const { seedCard, OFFICIAL_TEXT } = require('../server/patch-notes/voice');
const { scan } = require('../server/patch-notes/slop');
const { evaluate } = require('../server/patch-notes/review');
--const { hasLooks, noLooks, noLooksTags } = require('../server/patch-notes/no-looks');
++const { isExplicit } = require('../server/patch-notes/explicit');
const { intentOf } = require('../server/patch-notes/text');
const fixture = require('./fixtures/changelog-2026-10-30.json');
@@@ -137,15 +137,13 @@ const entry = (service, sha, subject, body = null) => ({
assert.strictEqual(scan({ body }).blocking.length, 0);
});
-- await check('no looks: a person is described by the role, never by looks; tags follow', async () => {
-- assert.strictEqual(hasLooks('A shirtless man sits in a room.'), true);
-- assert.strictEqual(noLooks('A shirtless man sits in a room with framed pictures on the wall.'), 'The streamer sits in a room with framed pictures on the wall.');
-- assert.strictEqual(noLooks('A man in a gray tank top sits against a plain light background.'), 'A person sits against a plain light background.');
-- assert.deepStrictEqual(noLooksTags(['red-beanie', 'bearded', 'man', 'tanktop', 'hula hoop', 'laptop']), ['hula hoop', 'laptop']);
++ await check('describing people is fine; sex acts and porn still block, nudity does not', async () => {
++ assert.strictEqual(isExplicit('The draft says explicit adult content follows.'), true);
++ assert.strictEqual(isExplicit('A nude figure study hangs on the wall.'), false);
const r = await evaluate({ title: 'A fix', dek: 'A plain dek about the page and what it does for you.', body: 'A shirtless man sits at a desk.', review: null, authorship: { mode: 'human' } });
-- assert.ok(r.blocking.some((b) => b.code === 'looks.person'));
-- const ok = await evaluate({ title: 'A fix', dek: 'A plain dek about the page and what it does for you.', body: '## Story\n\nA change.\n\n<details>\n<summary>All</summary>\n\n- Generation: a token cap per showcase kind\n\n</details>', review: null, authorship: { mode: 'human' } });
-- assert.ok(!ok.blocking.some((b) => b.code === 'looks.person'), 'a commit subject in the full list is not a person');
++ assert.ok(!r.blocking.some((b) => b.code === 'explicit.sex'), 'describing a person is fine');
++ const sex = await evaluate({ title: 'A fix', dek: 'A plain dek about the page and what it does for you.', body: 'The clip shows sex acts.', review: null, authorship: { mode: 'human' } });
++ assert.ok(sex.blocking.some((b) => b.code === 'explicit.sex'));
});
// ── the service: a draft, the gates, a person ───────────
@@@ -203,25 +201,25 @@ const entry = (service, sha, subject, body = null) => ({
assert.strictEqual((await t.ctx.posts.get(post.id)).state, 'draft');
});
-- await check('a banned phrase blocks; its override is recorded and the looks rule still blocks', async () => {
++ await check('a banned phrase blocks; its override is recorded and a sex act still blocks', async () => {
let post = await t.ctx.posts.get(draft.post.id);
let head = await t.ctx.posts.head(post);
-- await t.ctx.posts.update(owner, post, { body: `${head.content}\n\nWe leverage the new module. A shirtless man sits at a desk.`, expectedRevision: head.number });
++ await t.ctx.posts.update(owner, post, { body: `${head.content}\n\nWe leverage the new module. The clip shows sex acts.`, expectedRevision: head.number });
const c1 = await t.ctx.patchNotes.check(post.id);
assert.ok(c1.result.blocking.some((b) => b.code === 'slop.phrase' && b.phrase === 'leverage'));
-- assert.ok(c1.result.blocking.some((b) => b.code === 'looks.person'));
++ assert.ok(c1.result.blocking.some((b) => b.code === 'explicit.sex'));
await t.ctx.patchNotes.setOverride(post.id, { reason: 'the word is the feature name here', phrases: ['leverage'], by: t.official.subject });
const c2 = await t.ctx.patchNotes.check(post.id);
assert.ok(!c2.result.blocking.some((b) => b.code === 'slop.phrase'), 'the phrase is overridden');
assert.ok(c2.result.warnings.some((w) => w.code === 'slop.overridden'));
-- assert.ok(c2.result.blocking.some((b) => b.code === 'looks.person'), 'looks cannot be overridden');
++ assert.ok(c2.result.blocking.some((b) => b.code === 'explicit.sex'), 'sex acts cannot be overridden');
await assert.rejects(async () => t.ctx.posts.publish(owner, await t.ctx.posts.get(post.id), {}), /blocking/i);
});
-- await check('after the looks sentence goes and a person approves the revision, it publishes', async () => {
++ await check('after the sex-act sentence goes and a person approves the revision, it publishes', async () => {
let post = await t.ctx.posts.get(draft.post.id);
let head = await t.ctx.posts.head(post);
-- await t.ctx.posts.update(owner, post, { body: head.content.replace(' A shirtless man sits at a desk.', ''), expectedRevision: head.number });
++ await t.ctx.posts.update(owner, post, { body: head.content.replace(' The clip shows sex acts.', ''), expectedRevision: head.number });
post = await t.ctx.posts.get(post.id);
head = await t.ctx.posts.head(post);
await t.ctx.posts.review(owner, post, { revision: head.number, decision: 'approved', note: 'checked' });