openvibers/openvibe.live Commits

Auto-clips sort AI text into three review categories and report the hard line (#618)

OP OpenVibers committed 7 days ago · bfd75d01be · 8 files, +237 −37

* Auto-clips sort AI text into three review categories; the hard line is reported to Vision

* Contracts v0.294.0: the review capabilities Live calls

* Docs: name openvibe-contracts v0.294.0

Changes

ddiff --git a/README.md b/README.md
iindex 788edcdf..0a2ee5f2 100644
---- a/README.md
++++ b/README.md
@@@ -79,7 +79,7 @@ The media subsystem lives in **OpenVibe.Media**:
- **Chat, its messages and every chat table**: OpenVibe.Chat (since 2026-09-23). Live keeps no copy: reads and writes go through `server/chat/chat-reads.js` and `chat-delivery.js`, and the twelve tables it once held do not exist on PostgreSQL (`migrations/0002_live.sql` leaves them out; SQLite-era migration 007 dropped them).
- **Recordings, clips, thumbnails, files**: OpenVibe.Media; their watch pages are OpenVibe.Video's. The local `vods`/`clips`/`pastes` tables are frozen (`test/frozen-tables.test.js`).
- **Pastes and comments**: OpenVibe.Community.
--- **AI**: OpenVibe.AI. Live calls no model provider itself. Every prompt is an AI template; streamers' own keys are stored there; VOD and clip transcripts run there. Live-stream captions run in Live's own speech-to-text (`server/ai/transcribe.js`). Both use NVIDIA Parakeet TDT 0.6B v3 for English and 24 European languages and whisper.cpp for the rest.
++- **AI**: OpenVibe.AI. Live calls no model provider itself. Every prompt is an AI template; streamers' own keys are stored there; VOD and clip transcripts run there. Live-stream captions run in Live's own speech-to-text (`server/ai/transcribe.js`). Both use NVIDIA Parakeet TDT 0.6B v3 for English and 24 European languages and whisper.cpp for the rest. The AI's own clip/moment text is sorted by `server/ai/explicit.js` into exactly three categories: `fine` (nudity is allowed), `sex_acts` (never public; the clip is private and kept for its owner) and `minor_sexual` (always blocked, private even when a person owns it, and a report record is made through OpenVibe.Vision with `VISION_URL` + Live's service principal). The moments job, both auto-clip paths, both clip overview passes and clip evaluation use the same classifier.
- **Search, events, tips, VIP, money ledger**: OpenVibe.Search, Events, Tips, VIP and Billing.
## Depends on
@@@ -175,7 +175,7 @@ Minimum `.env`: `BASE_URL`, `JWT_SECRET`, `OV_NETWORK_URL`, `OV_NETWORK_INTERNAL
- [docs/restream-branding.md](docs/restream-branding.md) — branding guide for restream channels.
<!-- versions:start -->
--- openvibe-contracts: v0.289.1
++- openvibe-contracts: v0.294.0
- openvibe-sdk: v0.82.0
- openvibe-shared: v4.114.0
<!-- versions:end -->
ddiff --git a/STATUS.json b/STATUS.json
iindex 779d42f7..c3e9a420 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -5,7 +5,7 @@
"runtime": "node 22, express, PostgreSQL, node-media-server, mediasoup; no build step",
"port": 3000,
"domain": "openvibe.live (www, ingest, whip)",
-- "contracts": "openvibe-contracts v0.289.1",
++ "contracts": "openvibe-contracts v0.294.0",
"sdk": "openvibe-sdk v0.82.0",
"shared": "openvibe-shared v4.114.0",
"capabilities": [
@@@ -30,7 +30,7 @@
"channel points and Vibes; The Rundown (/rundown: what streamers talked about, from their speech; replaced the Arena 2026-10-19); after-show recaps; the Star of OpenVibe",
"live, VOD and clip transcripts by Parakeet TDT 0.6B v3 on the server for English and 24 European languages (whisper for others)",
"per-actor limits on API writes; account export and deletion contributions; follows on OpenVibe.Network",
-- "channel ↔ OpenVibe.Bot robot binding, its dashboard field and the Bot panel iframe on the channel page behind LIVE_BOT_EMBED (off by default; on in production)"
++ "channel \u2194 OpenVibe.Bot robot binding, its dashboard field and the Bot panel iframe on the channel page behind LIVE_BOT_EMBED (off by default; on in production)"
],
"updated": "2026-11-06"
}
ddiff --git a/package-lock.json b/package-lock.json
iindex ba66dff1..476a7182 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -23,7 +23,7 @@
"multer": "^1.4.5-lts.1",
"node-media-server": "^2.7.1",
"onnxruntime-node": "^1.20.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.294.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.114.0",
"pg": "^8.23.1",
@@@ -2840,9 +2840,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.289.1",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",
-- "integrity": "sha512-LrKTwe8VPCWn+b0MTNmfjAYEJ0rtgLxeM6EHONBDl0ynt/Igndz+ruDEpwuz5SZ9HYCIMkCqSV5ujhedh6zmzQ==",
++ "version": "0.294.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.294.0",
++ "integrity": "sha512-CBDymrE4VrgslAAgCTCvecNrVcaevhBcIrb4nU4ApkJasst+6xt3RdR9lktsdHuhBeipdlKAfaxLAFmRadkKqA==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
ddiff --git a/package.json b/package.json
iindex f5b804ee..428def90 100644
---- a/package.json
++++ b/package.json
@@@ -42,7 +42,7 @@
"multer": "^1.4.5-lts.1",
"node-media-server": "^2.7.1",
"onnxruntime-node": "^1.20.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.294.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.114.0",
"pg": "^8.23.1",
ddiff --git a/server/ai/explicit.js b/server/ai/explicit.js
iindex a4a16095..c2a0cc1b 100644
---- a/server/ai/explicit.js
++++ b/server/ai/explicit.js
@@@ -1,50 +1,99 @@
'use strict';
/**
-- * AI text that says a frame or clip shows sex acts or pornography. Describing people is fine, and so is nudity: the
-- * owner, 2026-10-31, "dick is art. Penis is art. Vagina is art. Titties is art… stop being against art on my
-- * platform." What stays is sex itself: on 2026-10-23 an AI auto-clip of a stream that showed sexual acts went public
-- * on openvibe.media and Live's home with its description (clip 483). Porn brings age-verification and record-keeping
-- * duties that an adults-only section would have to meet; until there is one, the AI does not promote it.
++ * AI text that says a frame or clip shows sex acts or pornography, or anything sexual involving a minor.
++ * Describing people is fine, and so is nudity: the owner, 2026-10-31, "dick is art. Penis is art. Vagina is
++ * art. Titties is art… stop being against art on my platform." What stays is sex itself: on 2026-10-23 an AI
++ * auto-clip of a stream that showed sexual acts went public on openvibe.media and Live's home with its
++ * description (clip 483). Porn brings age-verification and record-keeping duties that an adults-only section
++ * would have to meet; until there is one, the AI does not promote it.
*
-- * So: a moment whose AI text is explicit is never picked, posted or clipped (ai-moments-job.js, auto-clip-job.js),
-- * and an AI-made clip whose overview turns out explicit goes private (guardAutoClip). People's own clips are theirs
-- * to publish: only the AI's are changed. The owner decides about the stream itself.
++ * VIS-6's decision (2026-11-06) gives every AI text exactly three categories:
++ *
++ * fine allowed, including nudity
++ * sex_acts never public: the clip is made private and kept for its owner
++ * minor_sexual always blocked: private even for the owner's page, and an operator report record is made
++ * through OpenVibe.Vision. Nothing is reported to an external authority from here.
++ *
++ * A moment whose text is sex_acts or minor_sexual is never picked, posted or clipped (ai-moments-job.js,
++ * auto-clip-job.js), and an AI-made clip whose overview is one of them goes private (guardAutoClip). A
++ * category-3 clip is blocked even when it is not AI-made. People's own clips stay theirs otherwise.
*/
const media = require('../media-client');
--// What a scene shows, not what someone talks about in passing: sex acts and pornography. Nudity and body words are
--// not in it (a nude body is not a sex act).
++// What a scene shows, not what someone talks about in passing: sex acts and pornography. Nudity and body words
++// are not in it (a nude body is not a sex act).
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;
// The hard line that nudity being art never moves: a child and a bare body in the same text. Not a judgement of the
// words alone ("kids in swimsuits at the beach" is not this); a child together with nudity or genitals is treated as
--// explicit, so the moment is never picked and an AI clip of it goes private for the owner to review.
++// sexual content involving a minor, so the moment is never picked and the clip is quarantined for the operator.
const MINOR = /\b(?:child(?:ren)?|kids?|minors?|teen(?:age(?:rs?)?|s)?|toddlers?|bab(?:y|ies)|infants?|boys?|girls?|underage)\b/i;
const NUDE = /\b(?:naked|nude|nudity|undress(?:ed|ing)?|topless|bottomless|genital(?:s|ia)?|penis(?:es)?|vaginas?|vulvas?|breasts?|nipples?)\b/i;
const minorNude = (t) => MINOR.test(t) && NUDE.test(t);
--/** True when any of the texts describes sex acts or pornography, or puts a child and nudity together. */
++const CATEGORIES = Object.freeze(['fine', 'sex_acts', 'minor_sexual']);
++const PLAIN = Object.freeze({
++ fine: 'Fine — OK to show, including nudity',
++ sex_acts: 'Sex acts or porn — never public; kept for its owner',
++ minor_sexual: 'Sexual content involving a minor — always blocked and reported to the operator',
++});
++const ACTION = Object.freeze({ fine: 'allow', sex_acts: 'hide', minor_sexual: 'quarantine_and_report' });
++
++/** Sort one or more AI texts into exactly one category. */
++function classifyText(...texts) {
++ const all = texts.filter((t) => t != null).map(String).join('\n').trim();
++ if (!all) return { category: 'fine', reason: 'there is no text to judge' };
++ if (minorNude(all)) return { category: 'minor_sexual', reason: 'the text puts a child together with nudity' };
++ if (EXPLICIT.test(all)) return { category: 'sex_acts', reason: 'the text describes sex acts or pornography' };
++ if (MINOR.test(all)) return { category: 'fine', reason: 'a child is mentioned without nudity or a sex act' };
++ if (NUDE.test(all)) return { category: 'fine', reason: 'the text describes nudity, which is allowed' };
++ return { category: 'fine', reason: 'the text describes neither sex acts nor a child with nudity' };
++}
++
++/** True when any of the texts is sex_acts or minor_sexual. Kept for every caller that only needs the gate. */
function isExplicit(...texts) {
-- return texts.some((t) => t != null && (EXPLICIT.test(String(t)) || minorNude(String(t))));
++ return classifyText(...texts).category !== 'fine';
}
/**
-- * After a clip's AI overview is stored: an AI-made clip whose overview is explicit goes private and loses its
-- * description. A clip object from an event may not say whether the AI made it, so Media is asked then (rare: only for
-- * an explicit overview). → true when the clip was made private.
++ * After a clip's AI overview is stored: a clip whose overview is sex_acts or minor_sexual is made private and
++ * loses its description. A category-3 clip is blocked even when it is not AI-made, and Vision is asked for the
++ * operator's report record (best effort; the clip is hidden even when Vision is unreachable).
++ * → true when the clip was hidden by this call.
*/
async function guardAutoClip(clip, overview) {
-- if (!clip || !clip.id || !isExplicit(overview)) return false;
++ if (!clip || !clip.id) return false;
++ const verdict = classifyText(overview);
++ if (verdict.category === 'fine') return false;
try {
-- const c = clip.auto_generated === undefined ? await media.getClip(clip.id) : clip;
-- if (!c || !c.auto_generated || c.visibility === 'private') return false;
-- await media.updateClip(clip.id, { visibility: 'private', description: '' });
-- console.warn(`[AI] Auto-clip ${clip.id} made private: its AI overview describes sex acts (the owner reviews the stream)`);
-- return true;
++ const c = clip.auto_generated === undefined || clip.visibility === undefined ? await media.getClip(clip.id) : clip;
++ if (!c) return false;
++ // A person's own clip is not touched for sex acts; the hard line is blocked whoever made it.
++ if (verdict.category === 'sex_acts' && !c.auto_generated) return false;
++ // The hard line's operator record is made even if hiding the clip then fails.
++ if (verdict.category === 'minor_sexual') {
++ try {
++ const out = await require('./review-client').reportMinorSexual({
++ clip: { ...c, id: c.id || clip.id }, text: overview, ownerSubject: c.owner_subject || null,
++ });
++ if (!out || !out.review || !out.review.report) {
++ console.warn(`[AI] Clip ${clip.id}: minor-sexual content but no operator report record was made`);
++ }
++ } catch (e) {
++ console.warn(`[AI] Clip ${clip.id}: minor-sexual content, but Vision did not record the operator report:`, e.message);
++ }
++ }
++ let changed = false;
++ if (c.visibility !== 'private') {
++ await media.updateClip(clip.id, { visibility: 'private', description: '' });
++ changed = true;
++ }
++ if (changed) console.warn(`[AI] Clip ${clip.id} made private: its AI overview is ${verdict.category} (${PLAIN[verdict.category]})`);
++ return changed;
} catch (e) {
-- console.warn(`[AI] Auto-clip ${clip.id}: explicit overview, but it could not be made private:`, e.message);
++ console.warn(`[AI] Clip ${clip.id}: ${verdict.category} overview, but it could not be made private:`, e.message);
return false;
}
}
--module.exports = { isExplicit, guardAutoClip, EXPLICIT, MINOR, NUDE };
++module.exports = { isExplicit, classifyText, guardAutoClip, EXPLICIT, MINOR, NUDE, CATEGORIES, PLAIN, ACTION };
ddiff --git a/server/ai/review-client.js b/server/ai/review-client.js
nnew file mode 100644
iindex 00000000..646a3830
---- /dev/null
++++ b/server/ai/review-client.js
@@@ -0,0 +1,72 @@
++'use strict';
++
++/**
++ * Live's client for OpenVibe.Vision's unsafe-image review (VIS-6).
++ *
++ * Live classifies its own AI text locally (server/ai/explicit.js): nudity is fine, sex acts are not, and a
++ * child with nudity or a sex act is the hard line. When that hard line fires, Live asks Vision to make the
++ * operator's report record, so one queue (openvibe.vision/review) holds every category-3 object across the
++ * network. The report is internal; nothing is sent to an external authority by this call.
++ *
++ * Env: VISION_URL (or OV_VISION_URL), OV_NETWORK_INTERNAL_URL, OV_OAUTH_CLIENT_ID/SECRET. With no URL or no
++ * secret the client is off and the clip is still made private; only the operator record is missing, and the
++ * warning says so. A restore drill (LIVE_DRILL) never calls out.
++ */
++const { createServiceTokenClient } = require('openvibe-sdk/auth');
++
++let tokens = null;
++let tokensAt = '';
++
++function configNow() {
++ const base = String(process.env.VISION_URL || process.env.OV_VISION_URL || '').replace(/\/+$/, '');
++ const secret = process.env.OV_OAUTH_CLIENT_SECRET || '';
++ return { base, secret, network: String(process.env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000').replace(/\/+$/, '') };
++}
++
++function serviceTokens({ base, secret, network }) {
++ const at = `${network}|${secret}`;
++ if (tokens && tokensAt === at) return tokens;
++ tokens = createServiceTokenClient({
++ tokenUrl: `${network}/oauth/token`,
++ clientId: process.env.OV_OAUTH_CLIENT_ID || 'live',
++ clientSecret: secret,
++ audience: 'openvibe.vision',
++ scope: 'vision.image.review',
++ });
++ tokensAt = at;
++ return tokens;
++}
++
++function enabled() {
++ if (require('../drill').enabled) return false;
++ const c = configNow();
++ return Boolean(c.base && c.secret);
++}
++
++/** Report a category-3 text review to Vision. → the stored review/report, or null when the client is off. */
++async function reportMinorSexual({ clip, text, ownerSubject = null, fetchImpl = globalThis.fetch } = {}) {
++ if (!enabled()) return null;
++ const c = configNow();
++ const id = clip && clip.id != null ? String(clip.id) : null;
++ if (!id) return null;
++ const res = await fetchImpl(`${c.base}/api/v1/review`, {
++ method: 'POST',
++ headers: { Accept: 'application/json', 'Content-Type': 'application/json', ...(await serviceTokens(c).authHeaders()) },
++ body: JSON.stringify({
++ text: String(text || '').slice(0, 4000),
++ object_type: 'live.clip',
++ object_id: id,
++ owner_subject: ownerSubject || null,
++ preview_url: clip && clip.thumbnail_url ? String(clip.thumbnail_url).slice(0, 2000) : null,
++ note: 'Live auto-clip guard: sexual content involving a minor',
++ }),
++ signal: AbortSignal.timeout(8000),
++ });
++ const body = await res.json().catch(() => null);
++ if (!res.ok || !body || !body.review) throw new Error(`Vision review ${res.status}: ${(body && (body.detail || body.code)) || 'failed'}`);
++ return body;
++}
++
++function _reset() { tokens = null; tokensAt = ''; }
++
++module.exports = { enabled, reportMinorSexual, _reset };
ddiff --git a/test/ai-explicit.test.js b/test/ai-explicit.test.js
iindex fabc5d82..c45fa134 100644
---- a/test/ai-explicit.test.js
++++ b/test/ai-explicit.test.js
@@@ -20,7 +20,14 @@ const stub = {
};
const mediaPath = require.resolve('../server/media-client');
require.cache[mediaPath] = { id: mediaPath, filename: mediaPath, loaded: true, exports: stub };
--const { isExplicit, guardAutoClip } = require('../server/ai/explicit');
++// Vision's report record, stubbed before explicit.js loads its client.
++const reports = [];
++const reviewPath = require.resolve('../server/ai/review-client');
++require.cache[reviewPath] = { id: reviewPath, filename: reviewPath, loaded: true, exports: {
++ reportMinorSexual: async (x) => { reports.push(x); return { review: { id: 'vir_x', report: { id: 'vor_x', status: 'open' } } }; },
++ enabled: () => true,
++} };
++const { isExplicit, classifyText, guardAutoClip, CATEGORIES, PLAIN } = require('../server/ai/explicit');
let pass = 0;
const ok = (m) => { pass++; console.log(` ✓ ${m}`); };
@@@ -53,14 +60,22 @@ const ok = (m) => { pass++; console.log(` ✓ ${m}`); };
]) assert.ok(!isExplicit(t), `not explicit: ${t}`);
ok('the detector: sex acts and porn, and a child with nudity; not nudity alone and not ordinary streams');
++ // The three categories, in plain words, are the only labels.
++ assert.deepStrictEqual(CATEGORIES, ['fine', 'sex_acts', 'minor_sexual']);
++ assert.deepStrictEqual(classifyText('a nude figure drawing'), { category: 'fine', reason: 'the text describes nudity, which is allowed' });
++ assert.strictEqual(classifyText('pornographic footage').category, 'sex_acts');
++ assert.strictEqual(classifyText('a naked child').category, 'minor_sexual');
++ assert.match(PLAIN.minor_sexual, /always blocked and reported/);
++ assert.match(PLAIN.sex_acts, /never public/);
++
// An AI clip with an explicit overview goes private and loses its description.
clips.set(1, { id: 1, auto_generated: true, visibility: 'public' });
assert.strictEqual(await guardAutoClip({ id: 1, auto_generated: true, visibility: 'public' }, 'close-up footage of sexual acts'), true);
assert.deepStrictEqual(calls.pop(), ['update', 1, { visibility: 'private', description: '' }]);
-- // A person's own clip is theirs: nothing changes.
++ // A person's own clip keeps its sex-act choice; the hard line is blocked whoever made it.
calls.length = 0;
assert.strictEqual(await guardAutoClip({ id: 2, auto_generated: false, visibility: 'public' }, 'close-up footage of sexual acts'), false);
-- assert.strictEqual(calls.length, 0, 'no Media call for a person\'s clip');
++ assert.strictEqual(calls.length, 0, 'no Media call for a person\'s sex-act clip');
// An ordinary overview: nothing, not even a lookup.
assert.strictEqual(await guardAutoClip({ id: 3 }, 'A clutch round in Fortnite'), false);
assert.strictEqual(calls.length, 0, 'no Media call for an ordinary overview');
@@@ -73,7 +88,20 @@ const ok = (m) => { pass++; console.log(` ✓ ${m}`); };
clips.set(5, { id: 5, auto_generated: true, visibility: 'private' });
assert.strictEqual(await guardAutoClip({ id: 5 }, 'explicit adult content'), false);
assert.deepStrictEqual(calls.map((c) => c[0]), ['get']);
-- ok('an AI-made clip with an explicit overview goes private; people\'s clips and ordinary overviews are untouched');
++ // Category 3: even a person's clip is blocked, and Vision gets the operator report record.
++ reports.length = 0;
++ clips.set(6, { id: 6, auto_generated: false, visibility: 'public' });
++ assert.strictEqual(await guardAutoClip({ id: 6 }, 'a naked child runs across the frame'), true);
++ assert.deepStrictEqual(calls.pop(), ['update', 6, { visibility: 'private', description: '' }]);
++ assert.strictEqual(reports.length, 1);
++ assert.strictEqual(reports[0].text, 'a naked child runs across the frame');
++ // Already private: still reported, nothing rewritten.
++ calls.length = 0;
++ clips.set(7, { id: 7, auto_generated: true, visibility: 'private' });
++ assert.strictEqual(await guardAutoClip({ id: 7, auto_generated: true, visibility: 'private' }, 'two kids appear nude in the background'), false);
++ assert.strictEqual(reports.length, 2);
++ assert.deepStrictEqual(calls, [], 'already private is not updated again');
++ ok('an AI-made clip with an explicit overview goes private; a category-3 clip is blocked and reported');
// The writers use it.
const read = (p) => fs.readFileSync(path.join(__dirname, '..', p), 'utf8');
ddiff --git a/test/ai-review-client.test.js b/test/ai-review-client.test.js
nnew file mode 100644
iindex 00000000..6e6b17b5
---- /dev/null
++++ b/test/ai-review-client.test.js
@@@ -0,0 +1,51 @@
++'use strict';
++/**
++ * Live's category-3 operator report goes to OpenVibe.Vision with Live's service principal: one POST, the
++ * clip's id, the AI text and the note that names the hard line. Nothing else leaves the process (the clip
++ * is already hidden by explicit.js whether or not Vision answers).
++ */
++const assert = require('assert');
++const http = require('http');
++
++(async () => {
++ const state = { token: 0, reviews: [] };
++ const srv = http.createServer((req, res) => {
++ const chunks = [];
++ req.on('data', (c) => chunks.push(c));
++ req.on('end', () => {
++ const send = (status, obj) => { res.writeHead(status, { 'content-type': 'application/json' }); res.end(JSON.stringify(obj)); };
++ if (req.url === '/oauth/token') { state.token++; return send(200, { access_token: 'svc-live-vision', token_type: 'Bearer', expires_in: 300 }); }
++ if (req.url === '/api/v1/review') {
++ state.reviews.push({ auth: req.headers.authorization, body: JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}') });
++ return send(201, { review: { id: 'vir_x', report: { id: 'vor_x', status: 'open' } } });
++ }
++ send(404, {});
++ });
++ });
++ await new Promise((r) => srv.listen(0, '127.0.0.1', r));
++ const base = `http://127.0.0.1:${srv.address().port}`;
++ process.env.VISION_URL = base;
++ process.env.OV_NETWORK_INTERNAL_URL = base;
++ process.env.OV_OAUTH_CLIENT_ID = 'live';
++ process.env.OV_OAUTH_CLIENT_SECRET = 'live-secret';
++ const client = require('../server/ai/review-client');
++
++ assert.strictEqual(client.enabled(), true);
++ const out = await client.reportMinorSexual({
++ clip: { id: 9, thumbnail_url: 'https://openvibe.media/t/9.jpg' },
++ text: 'a naked child runs across the frame',
++ ownerSubject: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ',
++ });
++ assert.strictEqual(out.review.report.id, 'vor_x');
++ assert.strictEqual(state.reviews.length, 1);
++ const body = state.reviews[0].body;
++ assert.deepStrictEqual([body.object_type, body.object_id, body.owner_subject], ['live.clip', '9', 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ']);
++ assert.strictEqual(body.text, 'a naked child runs across the frame');
++ assert.match(body.note, /minor/);
++ assert.ok(state.reviews[0].auth.startsWith('Bearer '));
++ assert.strictEqual(state.token, 1);
++
++ console.log('ai review client: 1 report sent with Live\'s service token');
++ client._reset();
++ await new Promise((r) => srv.close(r));
++})().catch((e) => { console.error(e); process.exit(1); });