Initial commit: digital reception monorepo (M1-M11 + demo extensions)
This commit is contained in:
@@ -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`.
|
||||
@@ -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 }
|
||||
]
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "@reception/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"noEmit": false,
|
||||
"noUncheckedIndexedAccess": false
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user