openvibers/openvibe.vision README.md

README.md

main 30.2 KB · 332 lines · markdown

1# OpenVibe.Vision
2
3> See with the best model for the job.
4
5**Live site:** https://openvibe.vision · **API docs:** https://openvibe.services · **For AI:** https://openvibe.vision/llms.txt
6**Run it:** `npm ci && npm run dev` (Node 22 or newer) · **Tests:** `npm test`
7
8**Status:** v1, the §5.8b faces work and the network tools written and tested (see [STATUS.json](STATUS.json)).
9**Domain:** `openvibe.vision` · **Port:** 5130 · **Service id:** `vision` · **Env prefix:** `VISION`
10**License:** AGPL-3.0-only (same as every OpenVibe service).
11
12OpenVibe.Vision answers questions about a picture. The cheapest engine goes first: the local Tesseract reader
13answers a read question for nothing; otherwise OpenVibe.AI's routed vision model answers. The same image can be run
14through the local reader and up to three AI model hints side by side, with the model that actually answered, its
15cost and its measured latency. The UI check reads HTML, a public URL or a screenshot on this host and returns
16overflow, cut-off text, low contrast, overlap and layering faults as JSON. Face tools run locally; identifying matches require separate, purpose-scoped consent.
17
18## v1 scope (plan §5.8)
19
20- **Ask a picture.** Describe, read, identify, count, translate. Upload a file, send a data URL, or paste a public
21 image URL. Every answer reports the engine, model, provider, route, why it was chosen, the cost and the latency.
22- **OCR with a local engine first.** Tesseract 5 runs on this host; no image leaves it and no model is called. An
23 AI fallback is opt-in (`fallback=1`) and runs only when the local pass found no usable text.
24- **Compare models side by side.** One image, the local reader and one to three AI model hints. AI's router is the
25 authority: `requested_model` is only the hint, `model`/`provider` are what AI says answered. Each sample records
26 cost and latency; `GET /api/v1/benchmarks` aggregates them with no subject or answer text.
27- **The UI check as an API.** `POST /api/v1/ui-check` is what Design's Arena will call, with a service token holding
28 `vision.ui.check`. It also works for a signed-in person and, for HTML/URL/screenshot, anonymously.
29- **Consent-scoped faces, no looks in AI text.** Face detection and blur are open privacy tools. Matching requires a separate signed consent for each purpose. Public AI text is passed through `noLooks`;
30 an answer that names a person's looks is replaced by a neutral sentence.
31- **Privacy.** General answers store no image bytes: a sha256, media type and byte count are stored. Opt-in face uploads, vectors and templates are encrypted and private. Answers, compare samples, UI checks and every face row are exported and erased with the person's account.
32- **Not in v1.** Public Arena leaderboards, video, computer-use benchmarks and OpenCV jobs on Run (v2/v3). v1 has no
33 public records, so it writes no Search document and has no outbox.
34
35## Faces with consent (§5.8b)
36
37`npm run models:faces` downloads pinned OpenCV Zoo YuNet and SFace weights into `DATA_DIR/models`, checking a SHA-256 before installation. They are not in git. YuNet's directory is MIT licensed; SFace's directory is Apache 2.0 licensed. **Open question before production distribution:** [SFace weight and training-data provenance](https://github.com/opencv/opencv_zoo/issues/318) needs upstream clarification. The service starts without weights or `VISION_FACE_KEY`; `/api/ready` reports an optional degraded face check. Detect/blur need only YuNet; enrolment, verification and cast matching also need SFace and the key, and answer 503 until they exist. All face inference uses `onnxruntime-node` on this host, never a cloud model.
38
39The key is `VISION_FACE_KEY` (base64 encoding of 32 random bytes). AES-256-GCM encrypts templates, Vision uploads and face vectors under the person's Network subject. Losing this key makes stored face data unreadable; rotate it only with a re-encryption migration. `VISION_FACE_MATCH_THRESHOLD` defaults to the OpenCV SFace cosine example threshold 0.363 and needs calibration before consequential use. `VISION_FACE_LIBRARY_REGIONS` is a comma-separated allowlist; empty disables library grouping everywhere. `VISION_FACE_PRIVACY_RATE_LIMIT_PER_MIN` limits anonymous detect/blur, alongside the API address limit. No face template enters Search, model prompts or Bench.
40
41The four signed text versions are exposed by `GET /api/v1/faces/consents/text`. A Network person signs one purpose at a time by sending `signed_name` and the current `text_version` to `POST /api/v1/faces/consents/:feature` (`verify`, `find`, `cast`, `library`). Cast also needs `adult: true` and a named `channel`; library needs an allowed `region`. The signed name and server time are kept in `face_consents`. `DELETE /api/v1/faces/consents/:feature` revokes and immediately deletes that purpose's template; cast revocation also removes past tags. `/people` has the same choices and `/people/privacy` explains face retention. The site's shared legal privacy policy remains at `/privacy`.
42
43| Route | Result and scope |
44|---|---|
45| `POST /api/v1/faces/detect`, `/blur` | Anyone; boxes/count or image/png redaction, no templates or tracking. |
46| `POST /api/v1/faces/enrol/:feature` | Network person only; one face image after that feature's consent (`verify`, `find`, `cast`). |
47| `POST /api/v1/faces/verify` | Person only; `{score,match,threshold,liveness:"not_checked"}` against their own verify template. A match alone must never unlock anything: use a tested liveness component, second factor and non-biometric route before any consequential use. |
48| `POST /api/v1/faces/media`, `GET /api/v1/faces/media/:id`, `POST /api/v1/faces/find` | Person only; encrypted own Vision uploads, owner-only links and self-search matches. Media objects are deferred until a source-owner grant and open-time access check exist. |
49| `POST /api/v1/faces/cast/match` | Live service token with `vision.face.cast.match` and signed `channel_id` claim; compares only that channel's enrolled roster. Requires on-stream notice from Live. Person can inspect their own tags at `GET /api/v1/faces/cast/tags`. |
50| `POST /api/v1/faces/library/cluster`, `GET /api/v1/faces/library/groups`, `POST /api/v1/faces/library/correct` | Person only, separate opt-in and allowed region. Unknown groups have no label. Corrections: `exclude`, `merge`, `split`; switching off erases groups. |
51| `GET/POST /api/v1/faces/libraries`, `GET/PATCH/DELETE /api/v1/faces/libraries/:id` | Person only; the owner's own familiar-face library and its camera ids/region. |
52| `POST /api/v1/faces/libraries/:id/enroll`, `/match`, `GET /people`, `PATCH/DELETE /people/:personId` | Person only; name a crop, match only this library, rename/mute or delete a name. Strict mode answers `pending_consent` with no template until consent. |
53| `POST /api/v1/faces/libraries/:id/people/:personId/request`, `/consent-check`, `POST /api/v1/faces/consent-requests/:token/accept` · `/deny` | Person only; the private consent link and the registry re-check. |
54
55### Familiar faces (F1)
56
57A camera owner turns on Familiar faces for a camera and names the people that camera sees. `POST /api/v1/faces/libraries` creates the owner's own library (`camera_ids`, `region`), `POST /api/v1/faces/libraries/:id/enroll` names a face from the crop the camera saw, and `POST /api/v1/faces/libraries/:id/match` compares only that library. `DELETE /api/v1/faces/libraries/:id` and `DELETE /api/v1/faces/libraries/:id/people/:personId` delete the templates and the encrypted crops at once.
58
59The region is the camera's own setting. `VISION_FACE_STRICT_REGIONS` (default `US-IL,US-TX,US-WA,EU,EU-*,GB,UK,UNKNOWN`) puts a library in strict mode: the named person's `face.recognize` choice is checked in Network's consent registry, and until it allows the `library` feature the person is `pending_consent` with no template. The owner can re-issue a private link (`POST …/people/:personId/request`), the person accepts it at `POST /api/v1/faces/consent-requests/:token/accept` (or denies it), and `POST …/consent-check` re-reads the registry for pending people. A name can be muted (still matched, alerts held). A library whose region is outside the strict list (or explicitly `region_mode: "open"`) names a face freely.
60
61F1 adds `face_libraries`, `face_library_people`, `face_library_crops` and `face_consent_requests` in `migrations/0005_face_libraries.sql`, and `face_templates.library_id`. The templates and crops use the same AES-256-GCM key handling as every other face row; a crop is stored only so a strict-mode template can be made after consent, and a delete removes it. `VISION_FACE_CONSENT_REQUEST_DAYS` (default 14) sets the link lifetime.
62
63`face_consents`, `face_templates`, `face_media`, `face_tags` and `face_clusters` are created by `migrations/0003_faces.sql`. A daily job (off in `VISION_DRILL`) deletes inactive templates at three years since last use and revoked consent audit rows within one year of purpose end. ADR-033 export and account deletion include all five tables. The account export carries encrypted template and upload ciphertext; it does not expose the server key.
64
65## What runs where
66
67| Step | Where | Cost | Leaves the host? |
68|---|---|---|---|
69| Image decode, rotate, resize, sha256 | this host, `sharp` | 0 | no |
70| OCR | this host, Tesseract 5 (`OMP_THREAD_LIMIT=1`) | 0 | no |
71| UI check (HTML/URL) | this host, static parser + WCAG maths | 0 | the URL fetch only (public HTTP) |
72| UI check (screenshot) | this host, OCR boxes + `sharp` pixels | 0 | no |
73| Face detect, blur and consented matching | this host, YuNet + SFace + ONNX Runtime | 0 | no |
74| Ask / describe / identify / count / translate | OpenVibe.AI's routed vision model | AI's own accounting | the normalised image, to OpenVibe.AI |
75| OCR AI fallback | OpenVibe.AI's routed vision model | AI's own accounting | only after an empty local pass and with consent |
76| Compare | local + AI hints | local free; AI's own accounting | the normalised image, to OpenVibe.AI |
77
78Vision holds no provider key, adds no provider and calls no paid provider directly. `ai.run.create` is the only
79outbound capability; AI's quotas and its own route decide whether a run is allowed.
80
81## Owns
82
83| Table | Holds | Migration |
84|---|---|---|
85| `vision_answers` | `vis_` + ULID, subject (`usr_…` or null), requester, task `ask\|describe\|ocr\|compare`, question, mode, source, public `source_url`, image sha256/mime/bytes, cleaned `text`, `answer` jsonb (engine, reason, words, samples, run id), model/provider/route, cost, latency | `migrations/0001_initial.sql` |
86| `vision_compare_samples` | `vcm_` + ULID, answer, subject, task, engine `local\|ai`, requested model, answered model/provider/route, ok, text, cost, latency, error code | `0001_initial.sql` |
87| `vision_ui_checks` | `vuc_` + ULID, subject, requester, source `html\|url\|screenshot`, URL, image sha256, viewport, engine `static-html\|screenshot`, findings jsonb, summary jsonb | `0001_initial.sql` |
88| `account_data_events` | openvibe-sdk/account-data's export/deletion receipts | `0001_initial.sql` |
89| `telemetry_samples` | aggregated request telemetry, pruned after 14 days | `0001_initial.sql` |
90| `face_consents` | subject, feature `verify\|find\|cast\|library`, channel scope, signed text version/name, granted_at, revoked_at, last_used_at, purpose_ends_at | `migrations/0003_faces.sql` |
91| `face_templates` | subject, feature, scope, AES-GCM ciphertext of the SFace vector, created/last_used times | `0003_faces.sql` |
92| `face_media` | owner, AES-GCM ciphertext of an opt-in Vision upload and its face vectors, media type | `0003_faces.sql` |
93| `face_tags` | consenting subject, channel, private media reference and time; removed on cast revocation | `0003_faces.sql` |
94| `face_clusters` | owner, media, encrypted vector, group id, optional label (null while unknown), excluded flag | `0003_faces.sql` |
95| `event_outbox` | the events Vision publishes wait here until OpenVibe.Events takes them: the envelope, its own event id (the idempotency key), the attempts and the last error; sent rows are pruned after 7 days | `migrations/0004_event_outbox.sql` |
96
97General answer tables store no image bytes, token or provider key. The opt-in face library stores encrypted uploads and vectors.
98
99## Does not own
100
101- **Models, routing, quotas and provider keys** — OpenVibe.AI. Vision makes first-party `ai.run.create` runs with
102 namespace `ai.*`; AI decides the provider/model and reports the provenance.
103- **Identity, roles and accounts** — OpenVibe.Network. Vision verifies session and service tokens against its JWKS.
104- **The local OCR binary and language data** — the host (Tesseract under `/usr/bin` and `/usr/share`).
105- **Search** — OpenVibe.Search. v1 has no public records to index.
106- **Events, the host and the database** — OpenVibe.Events, OpenVibe.Host.
107
108## Capabilities and grants
109
110Contracts registers Vision's capabilities; the review routes add three more. The service checks each machine token's grant on its route;
111private media and face identity operations also require the verified owner or the person's purpose-scoped consent.
112
113| Capability | Routes | Typical caller |
114|---|---|---|
115| `vision.image.ask` | `POST /api/v1/ask`, `GET /api/v1/answers/:id` | a first-party service or a person |
116| `vision.text.read` | `POST /api/v1/ocr` (the AI fallback also needs a person or this grant) | a first-party service or a person |
117| `vision.model.compare` | `POST /api/v1/compare` | a first-party service or a person |
118| `vision.ui.check` | `POST /api/v1/ui-check`, `GET /api/v1/ui-checks/:id` | **OpenVibe.Design's Arena**, or a person |
119| `vision.face.cast.match` | `POST /api/v1/faces/cast/match` (a signed `channel_id` claim must match the request) | **OpenVibe.Live**, or a person inspecting their own tags |
120| `vision.alt.write` | `POST /api/v1/alt` | Pics, Media and Chat |
121| `vision.media.find` | `POST /api/v1/index`, `GET`/`POST /api/v1/find` (owner user token) | Media and Search |
122| `vision.receipt.read` | `POST /api/v1/receipt` | Food |
123| `vision.product.match` | `POST /api/v1/product` | Deals |
124| `vision.camera.watch` | `POST /api/v1/watch` (owner user token) | Cam |
125| `vision.video.moments` | `POST /api/v1/moments` (owner user token) | Live and Media |
126| `vision.face.detect`, `vision.face.blur` | `POST /api/v1/faces/detect`, `/blur` | Public privacy tools; a service token needs the matching grant |
127| `vision.face.consent.manage`, `vision.face.verify.self`, `vision.face.find.self`, `vision.face.library`, `vision.face.cast.read` | `/api/v1/faces/*` | The person themselves, with separate signed consent for each matching purpose |
128| `vision.models.read`, `vision.benchmarks.read` | `GET /api/v1/models`, `/benchmarks` | Public reads; a service token needs the matching grant |
129| `vision.image.review` | `POST /api/v1/review` | Media, Live and Community (the unsafe-image review) |
130| `vision.review.read`, `vision.review.decide` | `GET /api/v1/review`, `GET /api/v1/review/:id`, `POST /api/v1/review/:id/decide`, `GET /api/v1/review/reports` | OpenVibe staff, and an operator's own service token |
131
132Outgoing: **`ai.run.create`** to OpenVibe.AI (audience `openvibe.ai`, namespace `ai.*`), minted by Network for the
133client `vision`. No new AI capability, no provider key, no direct paid provider.
134
135## MCP and events
136
137`POST /mcp` serves Streamable HTTP JSON-RPC 2.0 (`initialize`, `ping`, `tools/list`, `tools/call`).
138`/.well-known/oauth-protected-resource` advertises its Network authorization server; a private tool without a
139token responds with a `WWW-Authenticate` challenge pointing to that metadata. The tools are `vision.ask_image`,
140`vision.read_text`, `vision.translate_sign`, `vision.find_similar`, `vision.search_my_media`,
141`vision.describe_clip`, `vision.detect_objects`, `vision.blur_faces`, `vision.check_ui`,
142`vision.compare_models` and `vision.get_job`, and five that run one prompted task each through the routed model
143(VIS-6; also `task=` on `POST /api/v1/ask`, no question needed): `vision.meal` (`meal`: foods, ingredients, portions,
144rough calories), `vision.describe_surroundings` (`surroundings`), `vision.identify_plant` (`plant`),
145`vision.identify_landmark` (`landmark`) and `vision.screenshot_components` (`components`). Face enrolment, verification, cast matching and consent mutation
146are available only through their person or Live-specific API routes, never through MCP.
147
148When configured with Events and a Network client secret, successful owner results publish `vision.result.ready`
149with provenance, cost and retention, without image or answer text. A watch result with `source_ref` and detections
150publishes `vision.alert` with classes and confidence. Publishing failures are logged without exposing payloads.
151
152## API
153
154JSON under `/api/v1`; errors are `application/problem+json` with a `code`. An image is a multipart `image` field, a
155JSON `image` data URL, or a JSON `image_url` public URL. A person is the `vision_at` session cookie or a Network user
156token as a Bearer. A session write must come from `https://openvibe.vision` (403 `vision.cross_site`). A service token
157must hold the route's capability (403 `capability.denied`).
158
159| Route | What it does |
160|---|---|
161| `POST /api/v1/ask` (alias `POST /api/v1/describe`) | `{ task?, question, mode?, lang?, model?, image_url? }` or multipart `image`; returns `{ answer }`. A read question is local-first, otherwise AI. |
162| `POST /api/v1/ocr` | multipart/data URL/URL; `{ fallback?, lang?, model? }`; returns the OCR row with `text`, `answer.confidence`, `answer.words`, cost 0. |
163| `POST /api/v1/compare` | `{ task?, question?, models?: string[] }` plus an image; returns `{ answer, compare }`, one row per engine. |
164| `POST /api/v1/ui-check` | `{ html }`, `{ url }`, or a screenshot plus `{ viewport: { w, h } }`; returns `{ check }` with `findings`, `counts`, `severity`, `warnings`. |
165| `GET /api/v1/answers/:id` | one saved answer: its owner, or a service holding `vision.image.ask`. |
166| `GET /api/v1/ui-checks/:id` | one saved UI check: its owner, or a service holding `vision.ui.check`. |
167| `GET /api/v1/models` | AI's offered `provider:model` hints and the local engine. No subject data. |
168| `GET /api/v1/benchmarks?task=&limit=` | aggregate compare samples: task, engine, provider, model, samples, mean latency, mean cost. No subject or answer text. |
169| `GET /api/v1/ping`, `/api/health`, `/api/ready`, `/release.json`, `/metrics` | service endpoints; readiness and metrics are truthful, metrics loopback-only. |
170| `POST /api/v1/review` | `{ image_url }`, an image, or `{ text }` plus `{ object_type, object_id, owner_subject?, preview_url? }`; returns `{ category, plain_words, action, public, status, review, report? }`. Exactly one of `fine`, `sex_acts`, `minor_sexual`. |
171| `GET /api/v1/review?category=&status=` | the operator's queue (staff or `vision.review.read`); `GET /api/v1/review/:id` is the owner's or staff's single row. |
172| `POST /api/v1/review/:id/decide` | staff or `vision.review.decide`: `{ category }` must be one of the same three. |
173| `POST /api/v1/review/:id/reveal` | staff only: one 60-second Media operator view, logged on the report. |
174| `GET /api/v1/review/reports?status=` | the operator report rows made by category 3. |
175
176Example — the call Design's Arena makes:
177
178```sh
179curl -s -X POST https://openvibe.vision/api/v1/ui-check \
180 -H "Authorization: Bearer $VISION_SERVICE_TOKEN" \
181 -H 'Content-Type: application/json' \
182 -d '{"html":"<!doctype html><style>.card{width:1400px}</style><div class=card>x</div>","viewport":{"w":390,"h":844}}'
183```
184
185Answer (abridged):
186
187```json
188{ "check": {
189 "engine": "static-html",
190 "viewport": { "w": 390, "h": 844 },
191 "counts": { "overflow": 1, "contrast": 1 },
192 "severity": { "high": 1, "medium": 1 },
193 "findings": [
194 { "kind": "overflow", "severity": "medium", "selector": "div.card", "detail": "the box is 1400px wide and reaches 1400px in a 390px viewport", "evidence": { "width": 1400, "right": 1400, "viewport": 390 } }
195 ]
196}}
197```
198
199Validation refusals: `vision.invalid`, `vision.invalid_task`, `vision.invalid_mode`, `vision.question_required`,
200`vision.image_required`, `vision.image_too_large`, `vision.image_private_url`, `vision.image_unsupported`,
201`vision.text.read_unavailable`, `vision.ai_unavailable`, `vision.not_found`, `vision.rate_limited`, and the face codes
202`faces.consent_required`, `faces.signature_required`, `faces.adult_required`, `faces.region_disabled`,
203`faces.enrolment_required`, `faces.one_face_required`, `faces.model_unavailable`, `faces.key_unavailable`,
204`faces.live_grant_required`.
205
206## Unsafe-image review (VIS-6)
207
208An image review sorts into exactly three categories, in the owner's words:
209
210| Category | Plain words | Action |
211|---|---|---|
212| `fine` | Fine — OK to show, including nudity | `allow`; nothing changes |
213| `sex_acts` | Sex acts or porn — never public; kept for its owner | `hide`; every public surface is closed and the object stays with its owner |
214| `minor_sexual` | Sexual content involving a minor — always blocked and reported to the operator | `quarantine_and_report`; the object is blocked, a report row is made for the operator, and nothing is sent outside OpenVibe |
215
216`POST /api/v1/review` accepts an image (multipart `image`, data URL, or `image_url`) or text. Text is classified by the
217same rule Live's auto-clip guard uses: nudity alone is fine; sex acts and porn are not; a child with nudity or a sex
218act is the hard line. An image is classified by OpenVibe.AI with one strict JSON question; the text answer can make
219the result more severe but never less. Category 3 always creates one `vision_operator_reports` row. The staff pages
220`/review`, `/review/:id` and `/review/reports` show the queue and let an operator decide one of the same three
221categories; a decision writes `reviewed_by`/`reviewed_at` and dismisses a report the operator overrules. A decision on
222a Media object also calls Media's `review-decision` route, which releases or places Media's evidence hold.
223
224An image the reviewer cannot judge is never cleared by harmless text: if the image pass is unavailable or returns no
225usable JSON, text may answer only when it is already `sex_acts` or `minor_sexual`; otherwise the answer is a 503 and
226the caller keeps the object pending. `preview_url` must be `https` on `openvibe.vision`, `openvibe.network` or
227`openvibe.media` (the origins its CSP already trusts); anything else is a 400. The page escapes it in `src` and `href`.
228
229Category 2 and 3 pictures are withheld from the operator by default. The queue shows no picture for category 3 and
230blurs a category-2 thumbnail; the detail page shows a blurred or withheld placeholder, not the bytes. "Show the
231picture" asks Media for a 60-second `media.object.review.view` URL (a separate signature purpose that opens a
232quarantine), logs the operator and time in `vision_review_reveals`, and shows that one picture. The warning on the
233page says the picture is evidence for the report and must not be saved or shared. Child-safety hash matching
234(Cloudflare's CSAM Scanning Tool, NCMEC lists) is a separate service and not this language model.
235
236No image bytes are stored. The review row keeps the caller's object reference (`object_app`, and the Media
237`media_object_id`/`media_app` when there is one), a sha256, the category, the plain words, the action, the model
238provenance and an optional first-party preview URL. A review does not publish a `vision.result.ready` event; the
239caller already has the row.
240
241## Pages
242
243All server-rendered through the OpenVibe Frame (`openvibe-shared/shell`), readable without JavaScript, semantic HTML
244with stable `vi-` ids/classes. Public pages are `public, max-age=60` with `Vary: Cookie`; a page drawn for a signed-in
245person is `private, no-store`.
246
247| Page | What it is |
248|---|---|
249| `/` | the room: hero, ask, compare and UI-check scenes, and a try section |
250| `/ask` | upload or URL + a question (POST → `/a/:id`) |
251| `/ocr` | local OCR, with an inline reading for a guest and a saved answer for a person |
252| `/compare` | local OCR vs AI model hints (POST → `/a/:id`) |
253| `/ui-check` | HTML, URL or screenshot; inline for a guest, saved at `/ui/:id` for a person |
254| `/a/:id` | one saved answer (owner or staff) |
255| `/ui/:id` | one saved UI check (owner or staff) |
256| `/find` | private visual search over a person's own indexed images |
257| `/read` | the receipt reader |
258| `/watch` | watched objects and scene change in a camera still |
259| `/people` | the four face purposes, each with its own signed switch, enrolment and off control |
260| `/people/privacy` | the face notice: retention, erasure, liveness and what never happens |
261| `/privacy` | the shared legal privacy policy |
262| `/developers` | the API, capabilities and what leaves the host |
263| `/review` | the unsafe-image queue, staff only: three category tabs and the report count |
264| `/review/:id` | one review: the image, its provenance, the three plain choices and the operator report |
265| `/review/reports` | the operator report rows (open, resolved, dismissed) |
266| 404 | the not-found page |
267
268`public/css/vision.css` and `public/js/vision.js` are one-comment placeholder files: the pages work without them and
269the main session designs them. `robots.txt`, `sitemap.xml`, `llms.txt` and `llms-full.txt` list only the public
270pages; `/a/`, `/ui/`, `/auth/`, `/api/` and `/internal` are not indexed.
271
272## The v1 proof
273
274`node scripts/proof.js` is offline: it renders a receipt, reads it with local Tesseract, and checks a page with a
275planted 1400 px overflow, a clipped nowrap line and low contrast. It exits non-zero if the local reader misses the
276total or the UI check misses a fault. The tests do the same over the API, and `test/compare.test.js` runs two AI
277hints and the local reader on one image with measured cost and latency.
278
279## Run and test
280
281```sh
282npm install
283npm test # PGlite; every file in its own process, no network needed
284npm run test:pg # the same through PostgreSQL + PgBouncer (openvibe-sdk scripts/test-services.sh up)
285OV_ALLOW_SUITE=1 npm test
286npm start # PORT 5130, loopback; DATABASE_URL or an embedded PGlite in development
287node scripts/proof.js # local OCR + a planted UI overflow, no network and no model
288```
289
290The tests use a mock Network, a mock OpenVibe.AI and local image/page fixtures: migration, SSO and readiness, the
291API and its capability guards, local OCR and its fallback, compare and benchmark aggregation, the UI check (a
292planted overflow, cut-off line, low contrast, overlap and layering fault), noLooks, pages/cache, account
293export/deletion, the image SSRF guard, and faces with a fake ONNX model (all four consent gates, opt-out erasure,
294cast-roster-only matching, AES-GCM at rest, the retention job, the region gate and ADR-033 export/deletion).
295
296## Deploy
297
298`deploy/systemd/openvibe-vision.service` (127.0.0.1:5130, `/etc/openvibe/vision.env`) and
299`deploy/nginx/openvibe.vision.conf` (TLS, www → apex, `/auth/me` on the API zone, a forms zone for `/ask`, `/ocr`,
300`/compare` and `/ui-check`, `/internal` and `/metrics` 404). Not deployed by this change.
301
302## License
303
304AGPL-3.0-only. See [LICENSE](LICENSE).
305
306## Network tools, wave 1
307
308All six tools are JSON APIs. Successful `answer` objects carry a `receipt` with engine, model, `cost_usd`, `ms`, confidence and retention. An image is multipart `image` or JSON `image` data URL unless a route says otherwise. Vision does not retain image bytes.
309
310| Room call | Capability | Notes |
311|---|---|---|
312| Pics, Media, Chat → `POST /api/v1/alt` | `vision.alt.write` | Routed VLM through OpenVibe.AI; short editable `alt_text`; noLooks in the prompt and output. |
313| Media, Search → `POST /api/v1/index`, `GET /api/v1/find?q=`, `POST /api/v1/find` | `vision.media.find` | Owner's verified user token; index `{item_id,image,result_url?}`; search by text or image. Owner is in every index read predicate. |
314| Food → `POST /api/v1/receipt` | `vision.receipt.read` | Tesseract first; `fallback=true` permits routed VLM if local extraction is incomplete. |
315| Deals → `POST /api/v1/product` | `vision.product.match` | CLIP vector and routed product description; `allow_rerank=true` permits another routed VLM call to rerank candidate offers. |
316| Cam → `POST /api/v1/watch` | `vision.camera.watch` | Owner's verified user token; upload still and `classes` array; optional `previous_image` data URL for scene change. |
317| Live, Media → `POST /api/v1/moments` | `vision.video.moments` | Owner's verified user token; `{frames:[{timestamp_ms,image|image_url}],prompts?}`. Scene histogram and CLIP prompt matching. |
318
319For private index, watch and moments, a service capability alone does not assert a source owner's grant. The room should call with the owner's delegated Network user token. Private index and watch reject `image_url`; the owner must supply bytes or a data URL. Moments accepts public frame URLs under Vision's existing guarded image fetch, or frame data URLs. The room that opens a returned `result_url` must check its own source grant again.
320
321`npm run models:fetch` is an explicit, revision-pinned download into `${DATA_DIR:-data}/models`. It fetches quantized ONNX CLIP ViT-B/32 (`Xenova/clip-vit-base-patch32`, derived from OpenAI CLIP) and DETR ResNet-50 (`Xenova/detr-resnet-50`, derived from Facebook DETR). The local model loader never downloads weights during a request. Missing weights return 503 for model-dependent operations; histogram-only scene change remains available. CLIP code and model are listed as MIT, but the original checkpoint's weight licence has an [open clarification request](https://huggingface.co/openai/clip-vit-base-patch32/discussions/72); confirm this before commercial distribution. DETR model and weights are [Apache 2.0](https://huggingface.co/facebook/detr-resnet-50). We use DETR rather than Ultralytics (AGPL) or RF-DETR Nano because a supported local Transformers.js ONNX export exists for DETR. `@huggingface/transformers` is Apache 2.0. Tesseract is Apache 2.0.
322
323Private embeddings are JSON vectors in PostgreSQL, scored in JavaScript after a mandatory `owner` filter. Vision has no pgvector migration or extension requirement. The v1 search cap is 5,000 items per owner.
324
325Offer discovery is configured by `VISION_DEALS_SEARCH_URL` and `VISION_SHOES_SEARCH_URL`, both fixed operator URLs returning JSON `{offers:[{title,url,price?}]}` or `{results:[...]}`. Deals currently exposes public `GET /api/v1/offers` without a query filter, and its `/search?q=` is an HTML page; open.shoes has no implemented API in this workspace yet. Until those rooms expose a structured search endpoint, `/product` returns the description and embedding with an empty `offers` list and a warning. It does not fabricate matches.
326
327<!-- versions:start -->
328- openvibe-contracts: v0.419.0
329- openvibe-sdk: v0.161.0
330- openvibe-shared: v4.241.0
331<!-- versions:end -->
332