Face Comparison: data model, verdict & threshold model, videochat vs self-service
Source-verified reference for how the vuer_oss faceComparisons table is written, linked, threshold-resolved, and verdict-classified — including the videochat (operator) vs self-service-v2 split that a self-service-only view misses. Produced during FKITDEV-8827 design work; cite-checked against the local vuer_oss worktree on branch customization/raiffeisen.
For Agents
This is the canonical model note for face comparison. Companion to face-comparison-different-face-db-query (the read-only SQL recipe for one customer). This note supersedes/corrects two implicit assumptions in that earlier note: (1) PION is a videochat flow, not self-service-v2; (2) operator/videochat comparisons are not verdict-classified at runtime — only the operator room view shows raw distance. All paths below are
file:lineinvuer_oss.
TL;DR
- A
faceComparisonsrow is keyed by EITHERroomId(videochat/operator) ORselfServiceRoomId(self-service-v2). No DB constraint enforces mutual exclusivity. euclideanDistance(FLOAT, nullable) is misnamed — it holds cosine distance, range 0–2.- The verdict is derived at read time, never stored.
statusis only{created, failed, success}. - Threshold sourcing differs by link type: self-service rooms can override per-room; videochat/operator rooms have no per-room config path and aren’t classified at runtime at all.
1. Two comparison linkages
faceComparisons rows attach to a flow via one of two FK columns:
| Link | Column | Producing flow |
|---|---|---|
| Videochat / operator | roomId | operator-assisted videochat; written by the videochat:close hook |
| Self-service v2 | selfServiceRoomId | mobile self-service-v2 |
No mutual-exclusivity constraint
Nothing in the schema forbids both FKs being set, or both being null (orphan). A general export must classify
linkTypedefensively:roomId && !selfServiceRoomId→ videochat;selfServiceRoomId && !roomId→ self-service; neither →orphan; both → treat as videochat with a logged warning.
Model — server/db/model/faceComparison.js, registered in server/db/models.js:298-309:
status— STRING enum['created', 'failed', 'success'], default'created'.euclideanDistance— FLOAT, nullable, = cosine distance (0–2) despite the legacy name.- FKs:
roomId,selfServiceRoomId,customerId,userId,recognitionFromId,recognitionToId(last two →faceRecognitions). - Scope
withRecognitions(models.js:298-309) joinsrecognitionFrom/recognitionTo(belongsTo, notrequired— so it stayslimit/offset-safe for chunked pagination). - No
flowId/taskId/ step column — portrait-vs-liveness-vs-document is only inferrable from the joinedFaceRecognition.imageCategory(a heuristic, config-dependent).
2. PION is a videochat flow, NOT self-service
This is the load-bearing correction for FKITDEV-8827 (Raiffeisen PION face-comparison export).
- The
customization/flow/pion-online-verification-phase-1/2protos declarevideochatand setcompareFaceWithnowhere. - Therefore PION comparisons are not produced by the self-service-v2 liveness path. They are produced by the
videochat:closehook (server/faceRecognition/faceRecognitionHooks.js:26-44), which compares the configuredgetComparisonPair('default')categories and writes a row keyed byroomId. - That hook is gated by deployment config
faceRecognition.comparisonPairs(server/service/FaceRecognitionService.js:7,20) — not visible in the repo.
A self-service-only query returns ZERO PION rows
Filtering
faceComparisonsonselfServiceRoomId(the obvious “mobile” approach) will return nothing for PION. PION rows live underroomId. Whether any PION rows exist depends on the deployment’sfaceRecognition.comparisonPairs.default— confirm with the deployment / ticket owner before promising data; an empty result is a valid, honest answer.
3. Comparison call sites (4) — gating differs
Three of the four sites are gated by task.options.recognitionOptions.compareFaceWith; the videochat hook is gated by deployment config instead.
| # | Step | Location | Gate |
|---|---|---|---|
| 1 | Liveness v2 | server/service/SelfServiceV2Service.js:1418 | if (recognitionOptions.compareFaceWith) — base V2 proto does not set it |
| 2 | Portrait / ID-document | server/flow/FlowService.js:2899-2943 | gated at :2902 by compareFaceWith |
| 3 | Videochat-close hook | server/faceRecognition/faceRecognitionHooks.js:26-44 | config faceRecognition.comparisonPairs; writes roomId |
| 4 | Self-service v1 | via the same compareFaceDetections | — |
For Agents
Note path #2 is
server/flow/FlowService.js, notserver/service/. This was a recurring stale fact in the triage agents’_shared-context.md.
4. Verdict is DERIVED, not stored
status is {created, failed, success}. The human-meaningful verdict (different_face etc.) is computed at read time by SelfServiceCheckerService.getFaceComparisonResult (server/service/SelfServiceCheckerService.js:132-154), which returns 3 outcomes (operators are <=):
| condition | outcome |
|---|---|
distance <= perfect | CHECK_SUCCESS |
distance <= probable | CHECK_PROBABLE (the match tier collapses here because the default match: null) |
distance > probable | CHECK_FAILURE (i.e. different_face) |
Code-default thresholds (:32-48): { perfect: 0.5, match: null, probable: 0.6 }. So by default different_face ≈ distance > 0.6 — but a room may override this.
Videochat/operator rows are NOT verdict-classified at runtime
getFaceComparisonResultis a self-service path. For operator/videochat comparisons there is no runtime verdict —server/web/routes/room.endpoint.js:144shows the raw distance only. Any verdict applied to aroomId-linked row (e.g. in an export) is a report-computed classification, not what the system used. Always carry the rawdistance+ theperfect/probableactually applied so the call is auditable.
5. Threshold sourcing differs by link type
| Link type | Threshold source | Merge semantics |
|---|---|---|
| Self-service | per-room selfService:v2:config:state activity, falling back to global | REPLACES, not merges (see caveat) |
| Videochat / operator | global only (no per-room config path) | — |
- Per-room (self-service): read via
getActivityLog(server/db/helpers.js:13-33) —Activityfiltered bytype+selfServiceRoomId, ordered ASC, take the oldest entry[0]. Pullcontent.settings.faceComparison.euclideanDistances. MatchesSelfServiceCheckerService:122-130. - Global:
Settingtable, keyfaceComparison,value.euclideanDistances.SettingsService.init()(server/service/SettingsService.js) persists the code defaults into theSettingtable at startup, so the global row normally exists even with no manual config. - Code defaults:
{ perfect: 0.5, match: null, probable: 0.6 }.
REPLACE vs MERGE — read carefully
The runtime self-service path replaces the threshold set with the room’s config (it does not merge per-key onto the global). A partial room config therefore does not inherit missing keys from the global at runtime. (Note: the FKITDEV-8827 export design deliberately chooses to merge partial room config onto defaults so partial overrides keep inherited keys — that’s a tool-level decision, not a mirror of runtime. Don’t conflate the two.)
6. No createdAt index
faceComparisons has only FK indexes — there is no index on createdAt. Any date-range export (the whole point of FKITDEV-8827) risks a full table scan. For stable pagination, order by id (PK), not createdAt (non-unique, unindexed).
Recommended core change (deferred)
Add a
createdAtindex onfaceComparisonsfor large deployments. This is adevelcore change, intentionally out of scope for the Raiffeisen customization bin.
7. Report bin pattern — and a real latent bug
The house pattern for an on-demand report bin:
bin → ReportsService subclass → _exportReport(aoaRows, null, null, format, null) → Buffer → fs.writeFile
Note this is the direct Buffer + fs.writeFile path, NOT the _prepareDownload / Download-model / storage path.
Latent bug in the mirrored template
customization/bin/raiffeisen-selfservice-failed-reports.js:106callsprepareReportData, whichNrtFailureReportServicenever implements — it’s abstract inReportsService:297. It only avoids throwing because its cron is gatedactive: false(customization/cron/FailedSelfserviceRoomsReport.js:73), so the broken path is never reached. Treat this as a latent bug: any new report service copied from this template must implement the data-prep method it calls (do not copy the bug).
8. DB dialect — Postgres now, MySQL supported
- The dev DB is Postgres (
config/dev.json). - BUT MySQL is a supported dialect — models guard with
sequelize.dialect.name !== 'mysql'.
Prefer portable SQL in customization queries
Use portable
LOWER(col) LIKE LOWER(:pattern)instead of Postgres-onlyILIKEin customization queries (e.g. flow-name prefix filters), so the code survives a MySQL deployment.
9. Design decision — FKITDEV-8827 general both-paths export
FKITDEV-8827 builds a general, both-paths face-comparison export bin (not self-service-only, precisely because PION is videochat):
customization/bin/raiffeisen-facecomparison-export.js— CLI: args, bootstrap, call service, write0o600file, exit.customization/service/ReportServices/FaceComparisonExportService.js— extendsserver/service/ReportsService; query (both link types, no link-type filter, order byid, 1000/page) + threshold resolution + report-computed verdict + row build; returns{ report, rows }.- Unit test mirrors
RaiffeisenNrtFailureReportService.test.js. - CLI options include
--flow <prefix>(e.g.pion) →Flow.findAllcollectingroomId/selfServiceRoomId, thenOp.oron both keys;-n/--withnamedecrypts customer name (PII, off by default; needsCustomer.data+keyloaded or every name falls back to#<id>). - Verdict honesty: for videochat rows the verdict is report-computed (no runtime equivalent); every row carries raw
distance+ appliedthresholdPerfect/thresholdProbable.
Full spec (external, in the worktree, not the vault):
/Users/levander/coding/facekom/.worktrees/vuer_oss-FKITDEV-8827/docs/superpowers/specs/2026-06-03-raiffeisen-facecomparison-export-design.md
Related
- FKITDEV-8827 — Raiffeisen PION face-comparison export (parent ASSRAFIPI-119); the design this note grounds
- face-comparison-different-face-db-query — companion read-only SQL recipe (customer “P”); see corrections noted above re PION = videochat and operator rows not being verdict-classified
- vuer_oss — host service:
faceComparisons/faceRecognitions,FaceRecognitionService,SelfServiceV2Service,FlowService,SelfServiceCheckerService,SettingsService,ReportsService - vuer_cv — computer-vision service producing the recognitions compared here
- database-schema — Postgres schema reference
- FaceKom — platform overview