openvibers/openvibe.vision Commits

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

OP OpenVibers committed 11 days ago · f6d4260d42 · 18 files, +54 −118

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

* Fix the covering test for AI text as written

* Vision: product prompts may describe people too

* 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-26

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

* Merge remote-tracking branch 'origin/pin-bump/openvibe-sdk-v0.81.0' into refresh-26

Changes

ddiff --git a/README.md b/README.md
iindex 3c39d36..7138a89 100644
---- a/README.md
++++ b/README.md
@@@ -275,6 +275,6 @@ Offer discovery is configured by `VISION_DEALS_SEARCH_URL` and `VISION_SHOES_SEA
<!-- versions:start -->
- openvibe-contracts: v0.240.0
--- openvibe-sdk: v0.74.0
++- openvibe-sdk: v0.81.0
- openvibe-shared: v4.93.2
<!-- versions:end -->
ddiff --git a/SECURITY.md b/SECURITY.md
iindex ac0fd70..258f679 100644
---- a/SECURITY.md
++++ b/SECURITY.md
@@@ -44,7 +44,7 @@ unknown or forged token is refused, never downgraded to the session.
## Faces with scoped consent
--Detect and blur do not identify anyone. Face matching uses local YuNet/SFace ONNX models and separately signed, purpose-specific consent. Templates and opt-in uploads are encrypted with `VISION_FACE_KEY` (AES-256-GCM) and private; a person can withdraw a purpose and erase its template immediately. A cast matcher reads only the named channel's consenting roster, and revoking a grant stops matches and deletes past tags. Library grouping is off unless `VISION_FACE_LIBRARY_REGIONS` allows the region. Vision never identifies strangers on the open web, builds no public face index, and a match alone never unlocks anything (liveness is `not_checked` until a tested component exists). Public AI text never describes a person's looks: the model prompt carries the rule and every AI answer is passed through `noLooks`. A report that any rule was broken is a security report for this repository.
++Detect and blur do not identify anyone. Face matching uses local YuNet/SFace ONNX models and separately signed, purpose-specific consent. Templates and opt-in uploads are encrypted with `VISION_FACE_KEY` (AES-256-GCM) and private; a person can withdraw a purpose and erase its template immediately. A cast matcher reads only the named channel's consenting roster, and revoking a grant stops matches and deletes past tags. Library grouping is off unless `VISION_FACE_LIBRARY_REGIONS` allows the region. Vision never identifies strangers on the open web, builds no public face index, and a match alone never unlocks anything (liveness is `not_checked` until a tested component exists). AI may describe people, including how they look; the model prompt forbids naming or recognising a stranger, and no looks filter runs. A report that any rule was broken is a security report for this repository.
## Supported versions
ddiff --git a/STATUS.json b/STATUS.json
iindex e235e8e..6bc2f82 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -9,11 +9,11 @@
"port": "5130",
"domain": "openvibe.vision",
"contracts": "openvibe-contracts v0.240.0",
-- "sdk": "openvibe-sdk v0.74.0",
++ "sdk": "openvibe-sdk v0.81.0",
"shared": "openvibe-shared v4.93.2",
"packages": [
"openvibe-contracts v0.240.0",
-- "openvibe-sdk v0.74.0",
++ "openvibe-sdk v0.81.0",
"openvibe-shared v4.93.2"
],
"capabilities": [
ddiff --git a/migrations/0001_initial.sql b/migrations/0001_initial.sql
iindex 2c41233..048e843 100644
---- a/migrations/0001_initial.sql
++++ b/migrations/0001_initial.sql
@@@ -17,7 +17,7 @@ CREATE TABLE vision_answers (
image_sha256 text COLLATE "C" NOT NULL,
image_mime text COLLATE "C" NOT NULL,
image_bytes integer NOT NULL,
-- text text, -- the noLooks-cleaned answer
++ text text, -- the AI answer, stored as written
answer jsonb NOT NULL DEFAULT '{}'::jsonb, -- model details, OCR lines, compare rows
model text COLLATE "C",
provider text COLLATE "C",
ddiff --git a/package-lock.json b/package-lock.json
iindex b2f359c..197a7a2 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -19,7 +19,7 @@
"multer": "^2.5.0",
"onnxruntime-node": "^1.31.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.240.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.93.2",
"pg": "^8.23.0",
"sharp": "^0.33.5"
@@@ -2641,9 +2641,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 d3a79f5..d0e5c4e 100644
---- a/package.json
++++ b/package.json
@@@ -29,7 +29,7 @@
"multer": "^2.5.0",
"onnxruntime-node": "^1.31.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.240.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.93.2",
"pg": "^8.23.0",
"sharp": "^0.33.5"
ddiff --git a/server/http/discovery.js b/server/http/discovery.js
iindex b4b14aa..0993965 100644
---- a/server/http/discovery.js
++++ b/server/http/discovery.js
@@@ -67,7 +67,7 @@ function createDiscoveryRoutes(ctx) {
r.get('/llms.txt', (_req, res) => {
res.type('text/plain').set('Cache-Control', TEXT).send(seo.llmsTxt({
name: SITE_NAME,
-- summary: 'OpenVibe.Vision answers questions about a picture, reads text locally and checks UI faults. Local face detection and blur need no identity; self verification, self search, cast tagging and library grouping each require separate consent. Public AI text never describes people\'s looks.',
++ summary: 'OpenVibe.Vision answers questions about a picture, reads text locally and checks UI faults. Local face detection and blur need no identity; self verification, self search, cast tagging and library grouping each require separate consent. AI may describe people, including how they look; a stranger is never looked up.',
details: 'POST /api/v1/ask (multipart image, image data URL or image_url) asks a question. POST /api/v1/ocr reads text locally first. POST /api/v1/compare runs the same image through local OCR and up to three AI hints. POST /api/v1/ui-check takes html, url or a screenshot and returns findings. GET /api/v1/benchmarks aggregates compare samples without subjects. Face detection/blur are open at POST /api/v1/faces/detect and /blur; matching needs separate signed consent per purpose (verify, find, cast, library). A person is the openvibe.vision session or a Network user token; a service token needs vision.ask, vision.ocr, vision.compare, vision.ui.check or vision.face.cast.match.',
sections: [
{ title: 'Start here', links: [
@@@ -107,7 +107,7 @@ function createDiscoveryRoutes(ctx) {
sections: [
{ title: 'Pages', pages: pages.map((p) => ({ url: p.path, title: p.title, text: p.text })) },
{ title: 'Rules', pages: [
-- { url: '/people/privacy', title: 'Faces with consent', text: 'Face matching is local and limited to a separately consented purpose. Public AI text never describes a person\'s looks.' },
++ { url: '/people/privacy', title: 'Faces with consent', text: 'Face matching is local and limited to a separately consented purpose. AI may describe people; a stranger is never looked up.' },
{ url: '/developers', title: 'No stored general images', text: 'General answers store a sha256, the media type and the cleaned text, never the image bytes. Only an opt-in face library keeps encrypted uploads.' },
] },
],
ddiff --git a/server/http/mcp.js b/server/http/mcp.js
iindex b2178f2..77e78a3 100644
---- a/server/http/mcp.js
++++ b/server/http/mcp.js
@@@ -4,7 +4,6 @@ const express = require('express');
const rateLimit = require('express-rate-limit');
const { loadImage } = require('../vision/images');
const { check } = require('../vision/ui-check');
--const { cleanAnswer } = require('../text/no-looks');
const { sameOrigin } = require('./principal');
const VERSION = '2025-06-18';
@@@ -48,7 +47,7 @@ function toolResult(id, value) {
{ type: 'image', mimeType: 'image/png', data: value.image.slice('data:image/png;base64,'.length) },
], structuredContent: { count: value.count }, isError: false });
}
-- return rpcResult(id, { content: [{ type: 'text', text: cleanAnswer(JSON.stringify(value)) }], structuredContent: value, isError: false });
++ return rpcResult(id, { content: [{ type: 'text', text: JSON.stringify(value) }], structuredContent: value, isError: false });
}
function toolError(id, message) { return rpcResult(id, { content: [{ type: 'text', text: message }], isError: true }); }
function owner(req) { return req.principal && req.principal.kind === 'user' ? req.principal.subject : null; }
ddiff --git a/server/text/no-looks.js b/server/text/no-looks.js
ddeleted file mode 100644
iindex f7f99e1..0000000
---- a/server/text/no-looks.js
++++ /dev/null
@@@ -1,41 +0,0 @@
--'use strict';
--
--/**
-- * No looks in public AI text (the network rule, openvibe-sdk/no-looks, shared with Live, Community and MediaHub):
-- * an AI-written summary never describes a person's body, clothing, hair, face or guessed gender. A summary that
-- * names looks at all is not repaired word by word — it is replaced by the entry's title, as Meme's plan says.
-- * Verbatim quotes are never passed through this: rewriting a quote would falsify the citation.
-- */
--const { noLooks, hasLooks } = require('openvibe-sdk/no-looks');
--
--/** The summary to store for an AI-written entry summary. */
--function cleanSummary(title, summary) {
-- const t = String(title || '').trim() || 'Untitled item';
-- const s = String(summary == null ? '' : summary).trim();
-- if (!s) return t;
-- if (hasLooks(s)) return t;
-- const cleaned = String(noLooks(s) || '').trim();
-- return cleaned || t;
--}
--
--module.exports = { cleanSummary, noLooks, hasLooks };
--
--/**
-- * Public AI text in Vision: an answer that names a person's looks at all is replaced by a neutral sentence,
-- * never repaired word by word. The prompt already forbids it; this is the guarantee after the model speaks.
-- */
--const SAFE_ANSWER = 'I can describe the picture without describing anyone\'s appearance. Ask about the scene, the text, the objects or what is happening.';
--
--function cleanAnswer(text, { fallback = SAFE_ANSWER } = {}) {
-- const s = String(text == null ? '' : text).trim();
-- if (!s) return fallback;
-- // Live's rule: a sentence about someone's looks goes whole (noLooks can leave "has long blonde hair" behind
-- // once it has neutralised the pronoun); the rest still passes noLooks. If nothing look-free remains, the
-- // neutral sentence is the answer.
-- const kept = s.split(/(?<=[.!?])\s+/).filter((sentence) => !hasLooks(sentence)).join(' ').trim();
-- if (!kept) return fallback;
-- const cleaned = String(noLooks(kept) || '').trim();
-- return cleaned && !hasLooks(cleaned) ? cleaned : fallback;
--}
--
--module.exports = { cleanSummary, cleanAnswer, noLooks, hasLooks, SAFE_ANSWER };
ddiff --git a/server/views/index.js b/server/views/index.js
iindex d438fc0..7e90ec6 100644
---- a/server/views/index.js
++++ b/server/views/index.js
@@@ -74,7 +74,7 @@ ${signedIn ? '' : '<p class="vi-fine">Reading, watching and the page check work
},
],
faq: { items: [
-- ['Does Vision recognise faces?', 'Only for a person who separately opts into a specific purpose: self verification, self search, cast tagging or private library grouping. Detect and blur never identify anyone. Public AI text never describes a person\'s looks.'],
++ ['Does Vision recognise faces?', 'Only for a person who separately opts into a specific purpose: self verification, self search, cast tagging or private library grouping. Detect and blur never identify anyone. AI may describe what it sees, people included, but it never names a stranger or uses a face as identity.'],
['Which model answers?', 'Every answer names the model and provider that actually answered and the reason it was chosen. Local OCR is first for a read question; otherwise OpenVibe.AI routes the run and its provenance is what Vision reports.'],
['Are my images stored?', 'General answers keep no image bytes. An opt-in face library stores encrypted uploads and vectors privately; turning its purposes off erases derived data. Account export and deletion include those rows.'],
] },
@@@ -307,7 +307,7 @@ Content-Type: application/json
{ "html": "&lt;!doctype html&gt;…", "viewport": { "w": 390, "h": 844 } }</pre>
</section>
<section id="vi-limits"><h2>What leaves this host</h2>
--<p class="vi-fine">The local OCR engine, UI check and all face tools run on this host. Only an ask, compare or OCR fallback sends the normalised image to OpenVibe.AI; face images and templates never go there. General answers store no image bytes; opt-in face uploads are encrypted and private. Public AI text is passed through <code>noLooks</code>; a look-naming answer is replaced.</p>
++<p class="vi-fine">The local OCR engine, UI check and all face tools run on this host. Only an ask, compare or OCR fallback sends the normalised image to OpenVibe.AI; face images and templates never go there. General answers store no image bytes; opt-in face uploads are encrypted and private. AI may describe people, including how they look; it never names a stranger or uses a face as identity.</p>
<p class="vi-fine">AI on this host: ${ai ? 'configured' : 'not configured (local engines only)'}. Model hints: ${esc((models || []).join(', ') || 'from AI\'s routes at request time')}.</p>
</section>
<section id="vi-search"><h2>Search</h2><p class="vi-fine">v1 has no public objects: answers and UI checks belong to the caller and are not indexed, so Vision writes no Search document and has no outbox. The v2 Arena's public leaderboards are where public records will begin.</p></section>
ddiff --git a/server/views/people.js b/server/views/people.js
iindex 1b7e097..7659f1f 100644
---- a/server/views/people.js
++++ b/server/views/people.js
@@@ -58,7 +58,7 @@ function privacy() {
return `<div class="vi-wrap vi-page-pad vi-narrow" id="vi-privacy"><header class="vi-page-h"><p class="vi-kicker">Privacy</p><h1>Faces stay in your chosen purpose</h1></header>
<section class="vi-card"><h2>What happens</h2><p>Detect, count and blur use local YuNet without identity templates or tracking. Verification compares only your enrolled template with your own attempt. Find yourself searches only your own Vision uploads. Cast tagging checks a named channel roster of consenting adults. Library groups are private, region gated, and unknown faces remain unlabelled.</p></section>
<section class="vi-card"><h2>Retention and control</h2><p>Templates and derived tags are erased when you switch a purpose off. A daily job removes active templates at the latest three years after your last use. Revoked consent audit records are removed within one year after the purpose ends. Account export and deletion include face templates, consents, uploads, groups and tags.</p></section>
--<section class="vi-card"><h2>What never happens</h2><p>No face or template is sent to a cloud model, public Search, model prompts or Bench. No stranger identification against the open web. No public face index. AI text never describes a person's looks. A face match alone never unlocks anything: liveness is not checked and a second factor and non-biometric route are required for consequential use.</p></section>
++<section class="vi-card"><h2>What never happens</h2><p>No face or template is sent to a cloud model, public Search, model prompts or Bench. No stranger identification against the open web. No public face index. AI may describe people, including how they look; a stranger is never looked up. Your cameras can learn the faces of people you name, and nobody else's face is ever looked up. A face match alone never unlocks anything: liveness is not checked and a second factor and non-biometric route are required for consequential use.</p></section>
<p><a href="/privacy">The full OpenVibe.Vision privacy policy</a> · <a href="/people">Manage your choices</a></p></div>`;
}
module.exports = { people, privacy };
ddiff --git a/server/vision/ai.js b/server/vision/ai.js
iindex 75da02a..66d0a3b 100644
---- a/server/vision/ai.js
++++ b/server/vision/ai.js
@@@ -11,7 +11,6 @@
* The provider and model Vision reports are always the ones AI says answered, never the hint.
*/
const { serviceAuth } = require('openvibe-contracts');
--const { cleanAnswer } = require('../text/no-looks');
class AiError extends Error {
constructor(status, code, detail) {
@@@ -25,7 +24,7 @@ class AiError extends Error {
const TASK_PROMPTS = {
ask: 'Answer the question about the image precisely. Say when the image does not show the answer. If the answer is a count, give the number and name what you counted.',
-- describe: 'Describe the image for someone who cannot see it: the scene, the objects, any visible text, and what is happening. Do not describe anyone\'s appearance.',
++ describe: 'Describe the image for someone who cannot see it: the scene, the objects, any visible text, and what is happening, people included.',
identify: 'Identify what the image shows. Give the most specific answer the picture supports, and say what is uncertain.',
count: 'Count what the question asks about. Give the number first, then list each item you counted.',
translate: 'Read the visible text and translate it into the target language. Give the original text, then the translation.',
@@@ -33,7 +32,7 @@ const TASK_PROMPTS = {
compare: 'Answer the question about the image precisely, for a side-by-side comparison. Say when the image does not show the answer.',
};
--const NO_LOOKS = 'Never describe a person\'s body, face, hair, clothing, age, race, gender, disability or guessed identity. Never identify, name or recognise a person, and never use a face as identity. If the answer would need that, say you cannot describe people\'s appearance and answer about the rest of the picture.';
++const NO_IDENTITY = 'Never identify, name or recognise a person, and never use a face as identity.';
function textOf(content) {
if (typeof content === 'string') return content;
@@@ -127,7 +126,7 @@ function createAi({ config, fetchImpl = globalThis.fetch, log = console } = {})
// `model` is documented as a hint; AI's router picks and reports. Including it keeps the request honest.
...(model ? { model: String(model).slice(0, 120) } : {}),
messages: [
-- { role: 'system', content: `You are OpenVibe.Vision's image reader. ${prompt}\n${NO_LOOKS}\nAnswer plainly and briefly; never invent details the picture does not show.` },
++ { role: 'system', content: `You are OpenVibe.Vision's image reader. ${prompt}\n${NO_IDENTITY}\nAnswer plainly and briefly; never invent details the picture does not show.` },
{ role: 'user', content: [
{ type: 'text', text: asked || 'Describe this image.' },
{ type: 'image_url', image_url: { url: dataUrl } },
@@@ -144,7 +143,7 @@ function createAi({ config, fetchImpl = globalThis.fetch, log = console } = {})
const x = out.x_openvibe || {};
const usage = out.usage || {};
return {
-- text: cleanAnswer(raw),
++ text: raw,
model: String(out.model || (x.model || '') || (model || 'openvibe')).slice(0, 120),
provider: String(x.provider || '').slice(0, 80) || null,
route: 'default.chat',
@@@ -153,7 +152,7 @@ function createAi({ config, fetchImpl = globalThis.fetch, log = console } = {})
runId: x.run_id || null,
synthetic: Boolean(x.synthetic),
usage: { tokens_in: Number(usage.prompt_tokens) || 0, tokens_out: Number(usage.completion_tokens) || 0 },
-- reason: `OpenVibe.AI routed a ${task} run to ${x.provider || 'its pool'}${out.model ? `:${out.model}` : ''}; the answer is cleaned by noLooks`,
++ reason: `OpenVibe.AI routed a ${task} run to ${x.provider || 'its pool'}${out.model ? `:${out.model}` : ''}; the answer is stored as written`,
target: target || null,
};
}
@@@ -161,4 +160,4 @@ function createAi({ config, fetchImpl = globalThis.fetch, log = console } = {})
return { enabled, answer, offeredModels, call, tokenClient };
}
--module.exports = { createAi, AiError, TASK_PROMPTS, NO_LOOKS };
++module.exports = { createAi, AiError, TASK_PROMPTS, NO_IDENTITY };
ddiff --git a/server/vision/network-tools.js b/server/vision/network-tools.js
iindex 9482e95..94acf8c 100644
---- a/server/vision/network-tools.js
++++ b/server/vision/network-tools.js
@@@ -1,7 +1,6 @@
'use strict';
const { VisionError } = require('./service');
const { forModel } = require('./images');
--const { cleanAnswer } = require('../text/no-looks');
const receipt = (engine, model, ms, confidence, retention = 'not stored', cost = 0) => ({ engine, model, cost_usd: cost, ms, confidence, retention });
function parseReceipt(text) {
@@@ -26,8 +25,8 @@ function createNetworkTools({ s, ai, ocr, models, config, fetchImpl = fetch }) {
catch (e) { throw new VisionError(e.status || 502, e.code || 'vision.ai_failed', e.detail || 'OpenVibe.AI could not answer'); }
}
async function alt(image) {
-- const a = await routed(image, 'describe', 'Write one short, editable alt text sentence about the scene, objects and action. Never name a person or describe any person’s appearance.');
-- const text = cleanAnswer(a.text).replace(/\s+/g, ' ').slice(0, 240);
++ const a = await routed(image, 'describe', 'Write one short, editable alt text sentence about the scene, objects and action. Describe people if they are in it, but never name someone or guess who they are.');
++ const text = String(a.text).replace(/\s+/g, ' ').slice(0, 240);
return { alt_text: text, editable: true, receipt: receipt('routed', a.model, a.ms, null, 'not stored', a.costUsd) };
}
async function index(owner, itemId, image, url) {
@@@ -67,8 +66,8 @@ function createNetworkTools({ s, ai, ocr, models, config, fetchImpl = fetch }) {
async function product(image, allowRerank) {
const start = Date.now();
const vector = await (await models.clip()).image(image.buffer);
-- const a = await routed(image, 'identify', 'Describe this product only: visible type, brand if legible, colour and identifying features. Do not describe people. One short sentence.');
-- const description = cleanAnswer(a.text).slice(0, 300);
++ const a = await routed(image, 'identify', 'Describe this product only: visible type, brand if legible, colour and identifying features. One short sentence.');
++ const description = String(a.text).slice(0, 300);
const offers = []; const warnings = [];
for (const [name, base] of [['deals', config.offers.dealsUrl], ['open.shoes', config.offers.shoesUrl]]) {
if (!base) { warnings.push(`${name} search API not configured`); continue; }
ddiff --git a/server/vision/service.js b/server/vision/service.js
iindex c24dc9e..607f132 100644
---- a/server/vision/service.js
++++ b/server/vision/service.js
@@@ -10,7 +10,6 @@
*/
const { AiError } = require('./ai');
const { forModel } = require('./images');
--const { cleanAnswer } = require('../text/no-looks');
class VisionError extends Error {
constructor(status, code, detail, extra = {}) {
@@@ -127,7 +126,7 @@ function createVision({ config, s, images, ocr, ai, store, log = console }) {
...(chosen.runId ? { run_id: chosen.runId } : {}),
...(chosen.usage ? { usage: chosen.usage } : {}),
};
-- const text = chosen.engine === 'local' ? chosen.text : cleanAnswer(chosen.text);
++ const text = chosen.text;
const saved = await store.saveAnswer({
subject, requester, task: t, question: q || null, mode, image,
text, answer, model: chosen.model, provider: chosen.provider, route: chosen.route, reason: answer.reason,
@@@ -149,7 +148,7 @@ function createVision({ config, s, images, ocr, ai, store, log = console }) {
const status = chosen.errorCode === 'vision.ai_unavailable' || chosen.errorCode === 'vision.ocr_unavailable' ? 503 : 502;
throw new VisionError(status, chosen.errorCode || 'vision.ocr_failed', chosen.detail || 'no local OCR text was found');
}
-- const text = chosen.engine === 'local' ? chosen.text : cleanAnswer(chosen.text);
++ const text = chosen.text;
const answer = {
engine: chosen.engine, model: chosen.model, provider: chosen.provider, route: chosen.route,
reason: chosen.reason || (chosen.engine === 'local' ? 'the local OCR reader answered first' : 'the local OCR reader found no text, so OpenVibe.AI was asked'),
@@@ -188,7 +187,7 @@ function createVision({ config, s, images, ocr, ai, store, log = console }) {
}
for (const hint of hints) {
const a = await aiResult(image, t, { question: q, model: hint });
-- samples.push({ engine: 'ai', requestedModel: hint, model: a.model, provider: a.provider, route: a.route, ok: a.ok, text: a.ok ? cleanAnswer(a.text) : null, costUsd: a.costUsd, ms: a.ms, errorCode: a.errorCode });
++ samples.push({ engine: 'ai', requestedModel: hint, model: a.model, provider: a.provider, route: a.route, ok: a.ok, text: a.ok ? a.text : null, costUsd: a.costUsd, ms: a.ms, errorCode: a.errorCode });
}
}
@@@ -196,7 +195,7 @@ function createVision({ config, s, images, ocr, ai, store, log = console }) {
return {
...saved.answer,
engine: 'compare',
-- compare: saved.samples.map((x) => ({ ...x, text: x.text == null ? null : (x.engine === 'local' ? x.text : cleanAnswer(x.text)) })),
++ compare: saved.samples.map((x) => ({ ...x, text: x.text == null ? null : (x.text) })),
local: { ok: local.ok, model: local.model, confidence: local.confidence == null ? null : local.confidence, error_code: local.errorCode || null },
reason: `${saved.samples.length} engines ran on the same image; local OCR is free and AI runs report their own cost and latency`,
};
ddiff --git a/test/api.test.js b/test/api.test.js
iindex 0c76866..f1623fc 100644
---- a/test/api.test.js
++++ b/test/api.test.js
@@@ -61,14 +61,13 @@ const { boot, check, done } = require('./helpers/boot');
assert.strictEqual(ok.json().answer.engine, 'local');
});
-- await check('the AI answer is passed through noLooks even when the model names looks', async () => {
++ await check('the AI answer describes people as written', async () => {
t.aiServer.setText('A shirtless man in a kitchen laughs.');
const r = await t.get('/api/v1/ask', { as: user, multipart: { fields: { task: 'describe', question: 'What happens?' }, image: png } });
assert.strictEqual(r.status, 201);
-- assert.match(r.json().answer.text, /without describing anyone's appearance/);
-- assert.ok(!/shirtless/.test(r.json().answer.text));
++ assert.strictEqual(r.json().answer.text, 'A shirtless man in a kitchen laughs.');
const sent = t.aiServer.requests.filter((x) => x.path === '/v1/chat/completions').pop();
-- assert.match(JSON.stringify(sent.body), /Never describe a person's body/);
++ assert.doesNotMatch(JSON.stringify(sent.body), /Never describe a person's body/);
});
await check('POST /compare runs local and AI, reports requested vs answered model, cost and latency', async () => {
ddiff --git a/test/network-tools.test.js b/test/network-tools.test.js
iindex 37002f9..a1a6765 100644
---- a/test/network-tools.test.js
++++ b/test/network-tools.test.js
@@@ -28,15 +28,15 @@ const { parseReceipt } = require('../server/vision/network-tools');
const byImage = await t.get('/api/v1/find', { as: owner, json: { image } });
assert.strictEqual(byImage.json().answer.results[0].item_id, 'photo-1');
});
-- await check('alt text sends noLooks and cleans the answer', async () => {
++ await check('alt text describes people as written', async () => {
t.aiServer.setText('A shirtless man holds a bicycle.');
const r = await t.get('/api/v1/alt', { as: owner, json: { image } });
assert.strictEqual(r.status, 201, r.text);
-- assert.ok(!/shirtless/.test(r.json().answer.alt_text));
++ assert.strictEqual(r.json().answer.alt_text, 'A shirtless man holds a bicycle.');
assert.ok(r.json().answer.editable);
assert.strictEqual(r.json().answer.receipt.engine, 'routed');
const sent = t.aiServer.requests.filter((x) => x.path === '/v1/chat/completions').at(-1);
-- assert.match(JSON.stringify(sent.body), /Never describe a person's body/);
++ assert.doesNotMatch(JSON.stringify(sent.body), /Never describe a person's body/);
});
await check('receipt parser and local route return structured lines and receipt', async () => {
assert.deepStrictEqual(parseReceipt('MARKET\n2 x Apples 3.40\nTOTAL 3.40').items[0], { name: 'Apples', qty: 2, price: 3.4 });
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/no-looks.test.js b/test/no-looks.test.js
ddeleted file mode 100644
iindex 1f2cefa..0000000
---- a/test/no-looks.test.js
++++ /dev/null
@@@ -1,36 +0,0 @@
--'use strict';
--/**
-- * No looks in public AI text: a summary that names looks becomes the title; an answer that names looks is replaced,
-- * never repaired word by word; a normal answer survives; and the prompt carries the rule.
-- */
--const assert = require('assert');
--const { cleanAnswer, cleanSummary, hasLooks, SAFE_ANSWER } = require('../server/text/no-looks');
--const { boot, check, done } = require('./helpers/boot');
--
--(async () => {
-- await check('an answer that names looks is replaced by the neutral sentence', async () => {
-- assert.strictEqual(cleanAnswer('A shirtless man in a kitchen laughs.'), SAFE_ANSWER);
-- assert.ok(hasLooks('A woman with long blonde hair smiles.'));
-- assert.strictEqual(cleanAnswer('A red bicycle leans on a wall.'), 'A red bicycle leans on a wall.');
-- assert.strictEqual(cleanSummary('Robot is offline', 'A shirtless man reacts.'), 'Robot is offline');
-- });
--
-- const t = await boot();
-- try {
-- await check('the AI prompt forbids looks and the stored answer is cleaned', async () => {
-- t.aiServer.setText('A tall woman in a red dress holds a sign.');
-- const user = t.network.addUser('kim');
-- const png = await t.textImage('HELLO');
-- const r = await t.get('/api/v1/ask', { as: user, multipart: { fields: { task: 'describe', question: 'What is happening?' }, image: png } });
-- assert.strictEqual(r.status, 201, r.text);
-- assert.strictEqual(r.json().answer.text, SAFE_ANSWER);
-- const stored = await t.ctx.s.db.value('SELECT text FROM vision_answers WHERE id = $1', [r.json().answer.id]);
-- assert.strictEqual(stored, SAFE_ANSWER);
-- const sent = t.aiServer.requests.filter((x) => x.path === '/v1/chat/completions').pop();
-- assert.match(JSON.stringify(sent.body), /Never describe a person's body/);
-- });
-- } finally {
-- await t.close();
-- }
-- done();
--})();