Initial commit: digital reception monorepo (M1-M11 + demo extensions)

This commit is contained in:
2026-05-25 12:59:54 +05:00
commit b9f88194d9
182 changed files with 20578 additions and 0 deletions
+40
View File
@@ -0,0 +1,40 @@
# fixtures-runner
E2E-сценарии для Фазы 1. Генерирует синтетические треки с детерминированными эмбеддингами (через persona seed) и шлёт их через `apps/api /ingest/*` + `face-service /track-embeddings/raw`. Не требует реальных видео.
## Запуск
```bash
# Список сценариев
pnpm fixtures:run --list
# Запуск
pnpm fixtures:run --scenario=new-patient
pnpm fixtures:run --scenario=returning-patient
pnpm fixtures:run --scenario=left-without-service
# Realtime — с задержками между событиями (как «живой» поток)
pnpm fixtures:run --scenario=new-patient --mode=realtime
```
## Сценарии
| Имя | Описание |
|---|---|
| `new-patient` | A → B → C. Создаёт unmatched-трек для ручного enrollment в web-admin. |
| `returning-patient` | Тот же `personaSeed=1001` как у new-patient. После enrollment персона должна быть узнана автоматически → создаётся Visit. **Это критерий завершения Ф1.** |
| `left-without-service` | A → B → уход. Триггерит событие `left_without_service`. |
## Как это работает
- `personaSeed` → детерминированный 512-d L2-нормализованный вектор. Тот же seed = тот же вектор (с малым jitter между эмбеддингами одного трека). После enrollment в `new-patient` вектор привязан к `patient_id`. В `returning-patient` тот же seed → match.
- Эмбеддинги пишутся через face-service `/track-embeddings/raw` (без InsightFace-детекции, эмбеддинг сразу передаётся).
- События — через `apps/api /ingest/track-events`.
- При `triggerRecognition=true` после прогона runner вызывает `/recognize/embedding` и при match создаёт Visit напрямую через Prisma.
## Критерий приёмки M11
После прогона:
1. `pnpm fixtures:run --scenario=new-patient` — в БД появился unmatched-трек.
2. Через web-admin (или curl) — `POST /enrollment` для этого трека.
3. `pnpm fixtures:run --scenario=returning-patient``runner.visitCreated !== null`, в БД есть новый Visit с привязкой к тому же `patientId`.
+22
View File
@@ -0,0 +1,22 @@
{
"name": "@reception/fixtures-runner",
"version": "0.0.1",
"private": true,
"type": "module",
"scripts": {
"start": "tsx src/main.ts",
"lint": "eslint src --ext .ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@reception/db": "workspace:*",
"dotenv": "^16.4.7"
},
"devDependencies": {
"@reception/eslint-config": "workspace:*",
"@reception/tsconfig": "workspace:*",
"@types/node": "^22.9.0",
"tsx": "^4.19.2",
"typescript": "^5.6.3"
}
}
@@ -0,0 +1,12 @@
{
"name": "left-without-service",
"description": "Пациент входит, ждёт, уходит без обслуживания (триггер left_without_service).",
"personaSeed": 2002,
"embeddingsPerCamera": 2,
"triggerRecognition": false,
"events": [
{ "type": "arrived", "cameraName": "cam-entrance", "zoneCode": "A", "offsetSec": 0 },
{ "type": "waiting", "cameraName": "cam-corridor", "zoneCode": "B", "offsetSec": 15 },
{ "type": "left_without_service", "cameraName": "cam-corridor", "zoneCode": "B", "offsetSec": 600 }
]
}
@@ -0,0 +1,13 @@
{
"name": "new-patient",
"description": "Новый пациент: входит (A) → ждёт (B) → обслуживается у стойки (C). Создаёт unmatched-трек для ручного enrollment.",
"personaSeed": 1001,
"embeddingsPerCamera": 3,
"triggerRecognition": false,
"events": [
{ "type": "arrived", "cameraName": "cam-entrance", "zoneCode": "A", "offsetSec": 0 },
{ "type": "waiting", "cameraName": "cam-corridor", "zoneCode": "B", "offsetSec": 20 },
{ "type": "service_started", "cameraName": "cam-reception", "zoneCode": "C", "offsetSec": 180 },
{ "type": "service_ended", "cameraName": "cam-reception", "zoneCode": "C", "offsetSec": 480 }
]
}
@@ -0,0 +1,11 @@
{
"name": "returning-patient",
"description": "Тот же пациент (тот же personaSeed) приходит повторно. После enrollment в new-patient — system должна узнать его, создать Visit автоматически. Критерий завершения Ф1.",
"personaSeed": 1001,
"embeddingsPerCamera": 2,
"triggerRecognition": true,
"events": [
{ "type": "arrived", "cameraName": "cam-entrance", "zoneCode": "A", "offsetSec": 0 },
{ "type": "waiting", "cameraName": "cam-corridor", "zoneCode": "B", "offsetSec": 25 }
]
}
+85
View File
@@ -0,0 +1,85 @@
export interface IngestTrackResponse {
trackId: string;
cameraId: string;
zoneId: string;
}
export class ApiClient {
constructor(private readonly baseUrl: string) {}
async createTrack(cameraName: string, firstSeenAt: Date): Promise<IngestTrackResponse> {
return this.post('/ingest/tracks', { cameraName, firstSeenAt: firstSeenAt.toISOString() });
}
async addEvent(opts: {
trackId: string;
type: string;
cameraName: string;
zoneCode: string;
occurredAt: Date;
}) {
return this.post('/ingest/track-events', {
trackId: opts.trackId,
type: opts.type,
cameraName: opts.cameraName,
zoneCode: opts.zoneCode,
occurredAt: opts.occurredAt.toISOString(),
});
}
private async post<T>(path: string, body: unknown): Promise<T> {
const res = await fetch(`${this.baseUrl}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`POST ${path} ${res.status}: ${text}`);
}
return (await res.json()) as T;
}
}
export interface RecognizeResult {
patient_id: string;
confidence: number;
distance: number;
}
export class FaceServiceClient {
constructor(private readonly baseUrl: string) {}
async saveRawEmbedding(opts: {
embedding: number[];
trackId: string;
cameraId: string;
capturedAt: Date;
quality?: number;
}): Promise<{ id: string }> {
return this.post('/track-embeddings/raw', {
embedding: opts.embedding,
track_id: opts.trackId,
camera_id: opts.cameraId,
captured_at: opts.capturedAt.toISOString(),
quality: opts.quality ?? 0.9,
});
}
async recognizeByEmbedding(embedding: number[]): Promise<RecognizeResult | null> {
return this.post('/recognize/embedding', { embedding });
}
private async post<T>(path: string, body: unknown): Promise<T> {
const res = await fetch(`${this.baseUrl}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`POST ${path} ${res.status}: ${text}`);
}
return (await res.json()) as T;
}
}
+40
View File
@@ -0,0 +1,40 @@
/** Детерминированный 512-d L2-нормализованный псевдо-эмбеддинг. */
import { createHash } from 'node:crypto';
function seededRandom(seed: number): () => number {
let s = seed >>> 0;
return () => {
s = (s * 1664525 + 1013904223) >>> 0;
return s / 0xffffffff;
};
}
export function makeEmbedding(seed: number, jitter = 0): number[] {
// Гауссово приближение через 12-tap uniform.
const rng = seededRandom(seed);
const jitterRng = seededRandom(seed + Math.floor(jitter * 1000) + 7);
const vec = new Array<number>(512);
let norm = 0;
for (let i = 0; i < 512; i++) {
let s = 0;
for (let k = 0; k < 12; k++) s += rng();
const base = s - 6;
let noise = 0;
if (jitter > 0) {
let s2 = 0;
for (let k = 0; k < 12; k++) s2 += jitterRng();
noise = (s2 - 6) * jitter;
}
const v = base + noise;
vec[i] = v;
norm += v * v;
}
norm = Math.sqrt(norm) || 1;
for (let i = 0; i < 512; i++) vec[i] /= norm;
return vec;
}
/** Хеш для логов. */
export function embeddingFingerprint(embedding: number[]): string {
return createHash('sha1').update(embedding.slice(0, 32).join(',')).digest('hex').slice(0, 12);
}
+76
View File
@@ -0,0 +1,76 @@
import 'dotenv/config';
import { readFileSync, existsSync, readdirSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { PrismaClient } from '@reception/db';
import { runScenario } from './runner.js';
import type { Scenario } from './types.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const SCENARIOS_DIR = resolve(__dirname, '..', 'scenarios');
function parseArgs(argv: string[]): { scenario?: string; mode: 'realtime' | 'fast'; list: boolean } {
let scenario: string | undefined;
let mode: 'realtime' | 'fast' = 'fast';
let list = false;
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg === '--list') list = true;
else if (arg?.startsWith('--scenario=')) scenario = arg.slice('--scenario='.length);
else if (arg === '--scenario') scenario = argv[++i];
else if (arg?.startsWith('--mode=')) mode = arg.slice('--mode='.length) as 'realtime' | 'fast';
else if (arg === '--mode') mode = argv[++i] as 'realtime' | 'fast';
}
return { scenario, mode, list };
}
function loadScenario(name: string): Scenario {
const path = join(SCENARIOS_DIR, `${name}.json`);
if (!existsSync(path)) {
throw new Error(`Сценарий "${name}" не найден в ${SCENARIOS_DIR}`);
}
return JSON.parse(readFileSync(path, 'utf-8')) as Scenario;
}
async function main() {
const { scenario, mode, list } = parseArgs(process.argv.slice(2));
if (list || !scenario) {
const files = readdirSync(SCENARIOS_DIR).filter((f) => f.endsWith('.json'));
console.log('Доступные сценарии:');
for (const f of files) {
const s = JSON.parse(readFileSync(join(SCENARIOS_DIR, f), 'utf-8')) as Scenario;
console.log(` ${s.name.padEnd(24)}${s.description}`);
}
if (!scenario) {
console.log('\nИспользование: pnpm fixtures:run --scenario=new-patient [--mode=realtime|fast]');
// --list — нормальный код выхода 0. Если просто отсутствует --scenario, тоже 0.
return;
}
if (list) return; // --list + --scenario — показать список и продолжить выполнение? нет, выйти.
}
const apiBaseUrl = process.env.API_BASE_URL ?? 'http://localhost:4000';
const faceServiceUrl = process.env.FACE_SERVICE_URL ?? 'http://localhost:8001';
console.log(`Загружаю сценарий ${scenario}, mode=${mode}, api=${apiBaseUrl}, face=${faceServiceUrl}`);
const s = loadScenario(scenario);
const prisma = new PrismaClient();
try {
const result = await runScenario({
scenario: s,
apiBaseUrl,
faceServiceUrl,
prisma,
realtime: mode === 'realtime',
});
console.log('\nИтог:', JSON.stringify(result, null, 2));
} finally {
await prisma.$disconnect();
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
+123
View File
@@ -0,0 +1,123 @@
import { PrismaClient } from '@reception/db';
import { ApiClient, FaceServiceClient } from './clients.js';
import { makeEmbedding, embeddingFingerprint } from './embedding.js';
import type { Scenario } from './types.js';
export interface RunOptions {
scenario: Scenario;
apiBaseUrl: string;
faceServiceUrl: string;
prisma: PrismaClient;
realtime: boolean;
}
export interface RunResult {
trackIds: string[];
embeddingsSaved: number;
eventsSent: number;
visitCreated?: { visitId: string; patientId: string; confidence: number } | null;
}
/**
* Прогоняет сценарий: создаёт треки (по одному на камеру в сценарии),
* сохраняет эмбеддинги, шлёт события. Если scenario.triggerRecognition —
* после всех событий пытается узнать persona и создать Visit.
*/
export async function runScenario(opts: RunOptions): Promise<RunResult> {
const { scenario, apiBaseUrl, faceServiceUrl, prisma } = opts;
const api = new ApiClient(apiBaseUrl);
const face = new FaceServiceClient(faceServiceUrl);
const baseTime = new Date();
const personaEmbedding = makeEmbedding(scenario.personaSeed, 0);
console.log(
`[scenario:${scenario.name}] persona seed=${scenario.personaSeed}, fp=${embeddingFingerprint(personaEmbedding)}`,
);
const tracksByCamera = new Map<string, { trackId: string; cameraId: string; zoneCode: string }>();
const result: RunResult = { trackIds: [], embeddingsSaved: 0, eventsSent: 0, visitCreated: null };
for (const event of scenario.events) {
const occurredAt = new Date(baseTime.getTime() + event.offsetSec * 1000);
let track = tracksByCamera.get(event.cameraName);
if (!track) {
const created = await api.createTrack(event.cameraName, occurredAt);
track = { trackId: created.trackId, cameraId: created.cameraId, zoneCode: event.zoneCode };
tracksByCamera.set(event.cameraName, track);
result.trackIds.push(track.trackId);
// Сохраняем N эмбеддингов с малым jitter — чтобы было что искать.
for (let i = 0; i < scenario.embeddingsPerCamera; i++) {
const jittered = makeEmbedding(scenario.personaSeed, 0.0001 * i);
await face.saveRawEmbedding({
embedding: jittered,
trackId: track.trackId,
cameraId: track.cameraId,
capturedAt: new Date(occurredAt.getTime() + i * 100),
});
result.embeddingsSaved += 1;
}
}
if (opts.realtime && event.offsetSec > 0) {
await sleep(Math.min(event.offsetSec * 1000, 5000));
}
await api.addEvent({
trackId: track.trackId,
type: event.type,
cameraName: event.cameraName,
zoneCode: event.zoneCode,
occurredAt,
});
result.eventsSent += 1;
console.log(
`[scenario:${scenario.name}] event ${event.type} on ${event.cameraName} at +${event.offsetSec}s`,
);
}
if (scenario.triggerRecognition) {
const match = await face.recognizeByEmbedding(personaEmbedding);
if (match) {
console.log(
`[scenario:${scenario.name}] recognized patient ${match.patient_id} (confidence=${match.confidence})`,
);
// Если уже есть Visit для этого пациента в окне ~5 мин — переиспользуем (idempotent).
const recentVisit = await prisma.visit.findFirst({
where: {
patientId: match.patient_id,
arrivedAt: { gte: new Date(Date.now() - 5 * 60 * 1000) },
},
});
if (recentVisit) {
result.visitCreated = {
visitId: recentVisit.id,
patientId: match.patient_id,
confidence: match.confidence,
};
} else {
const visit = await prisma.visit.create({
data: { patientId: match.patient_id, arrivedAt: baseTime },
});
result.visitCreated = {
visitId: visit.id,
patientId: match.patient_id,
confidence: match.confidence,
};
}
} else {
console.log(`[scenario:${scenario.name}] persona не узнан (нет enrolled-эмбеддинга)`);
}
}
console.log(
`[scenario:${scenario.name}] DONE — tracks=${result.trackIds.length}, embeddings=${result.embeddingsSaved}, events=${result.eventsSent}, visit=${result.visitCreated ? 'created' : 'none'}`,
);
return result;
}
function sleep(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms));
}
+24
View File
@@ -0,0 +1,24 @@
export type ZoneCode = 'A' | 'B' | 'C';
export type TrackEventType =
| 'arrived'
| 'waiting'
| 'service_started'
| 'service_ended'
| 'left_without_service';
export interface ScenarioEvent {
type: TrackEventType;
cameraName: string;
zoneCode: ZoneCode;
offsetSec: number; // секунды от начала сценария
}
export interface Scenario {
name: string;
description: string;
personaSeed: number; // одинаковый seed → одинаковый эмбеддинг (узнавание персонажа)
embeddingsPerCamera: number;
events: ScenarioEvent[];
/** Если true, после прогона ходим в /face-service/recognize/embedding и при match создаём Visit. */
triggerRecognition: boolean;
}
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "@reception/tsconfig/base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"noEmit": false,
"noUncheckedIndexedAccess": false
},
"include": ["src/**/*.ts"]
}