From 6b69f22275accf419e0b6c3837b0ed859e4607fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EC=B0=BD=EC=9A=B1?= Date: Fri, 7 Aug 2026 07:44:00 +0900 Subject: [PATCH] feat(data-asset): labeled-dataset export + improvement-action effect tracking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The operator's daily clicks become durable assets: Label export (GET /api/export/labels?days=N, JSONL download): - Every 확인/오탐 click is a human verdict on a detection, and each verdict carries a blurred 3-frame evidence timeline. The export turns that into a dataset: one JSON line per event with type/severity/camera/zone/metrics/ spatial, label (confirmed | false_positive | unreviewed), labeled_by, and snapshot uuids (images fetched via /snap, which re-checks session + site). ergo_report rows are excluded - statistics are not labelable detections. - Admin-only ('manage_compliance'): bulk export of detection history is a compliance decision, not an operator convenience. Site scope applies. - This is where the deferred P1-C evaluation set will come from for free. Improvement actions (move_actions + GET/POST /api/actions): - Record a site fix against a camera ("미끄럼 방지 매트 설치"); the report's new card compares detection density over EQUAL windows before/after the fix (capped 30d; under 3 days shows "측정 중" - two days of data is noise, not a verdict). Improvement shows accent, worsening shows red - the card reports honestly in both directions, which is exactly what makes the improvement numbers credible. - Future timestamps and >1y-old entries are rejected (the comparison could not be fair). Recording needs edit_zones + CSRF. Verified: PHPUnit 145 pass (5 new: export perm gate + line shape/labels/ snapshot join, action validation, equal-window effect math, pending state) + Chrome real-render (action card with better/worse/pending rows, export link, no console errors). --- docs/INDEX.md | 5 + docs/PROJECT_MAP.md | 3 +- .../2026-08-07-improvement-actions.sql | 13 ++ server/public/assets/app.css | 19 ++- server/public/assets/report.js | 80 ++++++++++++ server/public/index.php | 10 +- server/schema.sql | 15 +++ server/src/Dashboard/ActionsController.php | 78 ++++++++++++ server/src/Dashboard/ExportController.php | 80 ++++++++++++ server/src/Repository/ActionRepository.php | 11 ++ server/src/Repository/EventRepository.php | 4 + .../Repository/Pdo/PdoActionRepository.php | 44 +++++++ .../src/Repository/Pdo/PdoEventRepository.php | 32 +++++ .../Repository/Pdo/PdoSnapshotRepository.php | 16 +++ server/src/Repository/SnapshotRepository.php | 4 + server/src/View/report.php | 13 ++ server/tests/DashboardTest.php | 1 + server/tests/ExportActionsTest.php | 119 ++++++++++++++++++ server/tests/IngestControllerTest.php | 1 + server/tests/Support/InMemoryRepositories.php | 30 ++++- 20 files changed, 573 insertions(+), 5 deletions(-) create mode 100644 server/migrations/2026-08-07-improvement-actions.sql create mode 100644 server/src/Dashboard/ActionsController.php create mode 100644 server/src/Dashboard/ExportController.php create mode 100644 server/src/Repository/ActionRepository.php create mode 100644 server/src/Repository/Pdo/PdoActionRepository.php create mode 100644 server/tests/ExportActionsTest.php diff --git a/docs/INDEX.md b/docs/INDEX.md index 3f75601..b548af1 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -1,5 +1,10 @@ # INDEX — 작업 기록 (완료 결과만) +- **2026-08-07** 데이터 활용 5안 중 4안 구현: ②시간패턴 히트맵+전조 급증 경보 ③KPI 전기간 + 추이(`d8b8e1f`) ①라벨 JSONL 익스포트(`/api/export/labels`, 관리자) ④개선 조치 기록·전후 + 효과 비교(`move_actions`, 동일 창 비교, 3일 미만 "측정 중"). ⑤멀티사이트 벤치마크는 다현장 + 이후(사이트 스코프 구조가 이미 수용). PHPUnit 145 + Chrome 실렌더. + - **2026-08-07** 증거 타임라인 + 24시간 캐치업: 이벤트 스냅샷이 링버퍼 기반 과거→발생 3장(프레임별 자기 시점 얼굴 블러, P1-B 잔여 종결), 상세뷰 이전/직전/발생 캡션. 콘솔 첫 진입 '지난 24시간' 요약 배너(/api/report?days=1 재사용, 리포트 링크). pytest 235/ diff --git a/docs/PROJECT_MAP.md b/docs/PROJECT_MAP.md index 577c974..8613fbf 100644 --- a/docs/PROJECT_MAP.md +++ b/docs/PROJECT_MAP.md @@ -33,7 +33,8 @@ `GET /api/events` · `GET /api/devices` · `GET /api/heatmap/{camera}`(spatial 집계) · `GET /api/fp-stats`(오탐율) · `GET/POST /api/zones*` · `POST /login|/logout` · `GET /snap/{uuid}` · `POST /events/{id}/ack` · `POST /events/{id}/false-positive`(오탐 태그, - ack 겸함) · `GET /api/report`(운영 지표 집계) · + ack 겸함) · `GET /api/report`(운영 지표+시간패턴+전조경보+추이) · + `GET /api/export/labels`(라벨 JSONL, 관리자) · `GET/POST /api/actions`(개선 조치·전후 효과) · 페이지 `/`, `/login`, `/zones`, `/report`(안전 리포트), `/events/{id}/view` - `src/Ingest/` HMAC+nonce+rate-limit+replay 방지 · `src/Auth/` argon2id/세션/RBAC/CSRF · `src/Dashboard/` 이벤트목록·디바이스상태·존편집·스냅샷프록시 · `src/Repository/` 9종 (인터페이스+PDO) diff --git a/server/migrations/2026-08-07-improvement-actions.sql b/server/migrations/2026-08-07-improvement-actions.sql new file mode 100644 index 0000000..c0c6c02 --- /dev/null +++ b/server/migrations/2026-08-07-improvement-actions.sql @@ -0,0 +1,13 @@ +-- Improvement action log (개선 조치 기록): "미끄럼 방지 매트 설치" 같은 현장 조치를 +-- 카메라에 기록해 두면, 리포트가 조치 전후의 검출 밀도를 자동 비교한다 — +-- 안전 투자의 효과가 데이터로 증명되는 경로. +CREATE TABLE move_actions ( + action_id INT UNSIGNED NOT NULL AUTO_INCREMENT, + camera_id VARCHAR(64) NOT NULL, + note VARCHAR(500) NOT NULL, + taken_at BIGINT UNSIGNED NOT NULL, -- 조치 시행 시각(epoch s) + created_by VARCHAR(190) NOT NULL, + created_at BIGINT UNSIGNED NOT NULL, + PRIMARY KEY (action_id), + KEY ix_actions_taken (taken_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/server/public/assets/app.css b/server/public/assets/app.css index b71a3a6..6cd3576 100644 --- a/server/public/assets/app.css +++ b/server/public/assets/app.css @@ -504,6 +504,23 @@ a.event-thumb-slot:hover { border-color:var(--accent); box-shadow:0 0 0 3px var( .kpi-trend.is-better { color:var(--accent); } .report-card-wide { grid-column:1 / -1; } + +/* 개선 조치: 기록 폼 + 전후 비교 행. */ +.action-form { display:flex; gap:.5rem; margin-bottom:.8rem; } +.action-form input { flex:1; min-width:0; } +.action-form #action-camera { flex:0 0 10rem; } +.action-form button { flex:none; } +.action-row { display:flex; align-items:baseline; justify-content:space-between; gap:.8rem; + padding:.45rem 0; border-top:1px solid var(--line); font-size:.79rem; } +.action-row:first-of-type { border-top:none; } +.action-what { display:flex; align-items:baseline; gap:.6rem; min-width:0; } +.action-date { flex:none; font-family:var(--mono); font-size:.72rem; color:var(--ink-3); } +.action-cam { flex:none; font-family:var(--mono); font-weight:600; color:var(--ink-3); } +.action-note { color:var(--ink-2); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } +.action-effect { flex:none; font-family:var(--mono); font-size:.73rem; color:var(--ink-3); } +.action-effect.is-better { color:var(--accent); font-weight:700; } +.action-effect.is-worse { color:var(--crit-ink); font-weight:700; } +.action-effect.is-pending { font-style:normal; } .pattern { display:flex; flex-direction:column; gap:2px; overflow-x:auto; } .pattern-row { display:flex; align-items:center; gap:2px; } .pattern-day { flex:none; width:1.4rem; font-size:.68rem; color:var(--ink-3); font-family:var(--mono); } @@ -519,7 +536,7 @@ a.event-thumb-slot:hover { border-color:var(--accent); box-shadow:0 0 0 3px var( /* 인쇄: 앱 크롬을 걷어내고 리포트만 A4에 얹는다 — 주간 안전회의 배포물이 목적. */ @media print { - .app-header, .report-controls { display:none !important; } + .app-header, .report-controls, .action-form, #export-labels { display:none !important; } body { background:#fff; } .app-main { padding:0; } .kpi, .report-card, .rule-stat { box-shadow:none; } diff --git a/server/public/assets/report.js b/server/public/assets/report.js index 0d19e43..0b3e890 100644 --- a/server/public/assets/report.js +++ b/server/public/assets/report.js @@ -221,6 +221,84 @@ + ' · 본 리포트는 AI 감지 기반 보조 지표이며 공식 재해 통계를 대체하지 않습니다.'; } + // --- 개선 조치 효과 -------------------------------------------------------------- + var actionForm = document.getElementById('action-form'); + var actionCamera = document.getElementById('action-camera'); + var actionNote = document.getElementById('action-note'); + var actionsRoot = document.getElementById('report-actions'); + var csrfToken = null; + + function loadCsrf() { + return fetch(B + '/api/csrf', { headers: { Accept: 'application/json' } }) + .then(function (res) { return res.ok ? res.json() : null; }) + .then(function (d) { if (d && d.token) csrfToken = d.token; }) + .catch(function () { /* 기록 시 403으로 드러난다 */ }); + } + + function actionEffect(a) { + if (!a.effect) return el('span', 'action-effect is-pending', '효과 측정 중 (시행 3일 미만)'); + var e = a.effect; + var txt = '전 ' + e.before + '건 → 후 ' + e.after + '건 (' + e.window_days + '일 창)'; + if (e.before > 0 && e.after !== e.before) { + var pct = Math.round((e.after - e.before) / e.before * 100); + txt += ' ' + (pct < 0 ? '▼' : '▲') + Math.abs(pct) + '%'; + } + var cls = 'action-effect' + (e.after < e.before ? ' is-better' : (e.after > e.before ? ' is-worse' : '')); + return el('span', cls, txt); + } + + function renderActions(list) { + if (!actionsRoot) return; + actionsRoot.textContent = ''; + if (!list || list.length === 0) { + actionsRoot.appendChild(el('p', 'report-empty', + '기록된 조치가 없습니다. 조치를 기록하면 전후 감지 밀도를 자동 비교합니다.')); + return; + } + list.forEach(function (a) { + var row = el('div', 'action-row'); + var what = el('div', 'action-what'); + what.appendChild(el('span', 'action-date', + new Date(a.taken_at * 1000).toLocaleDateString('ko-KR'))); + what.appendChild(el('span', 'action-cam', a.camera_id)); + what.appendChild(el('span', 'action-note', a.note)); + row.appendChild(what); + row.appendChild(actionEffect(a)); + actionsRoot.appendChild(row); + }); + } + + function loadActions() { + if (!actionsRoot) return; + fetch(B + '/api/actions', { headers: { Accept: 'application/json' } }) + .then(function (res) { return res.ok ? res.json() : null; }) + .then(function (d) { if (d) renderActions(d.actions); }) + .catch(function () { /* 카드 유지 */ }); + } + + if (actionForm) { + actionForm.addEventListener('submit', function (ev) { + ev.preventDefault(); + var camera = (actionCamera.value || '').trim(); + var note = (actionNote.value || '').trim(); + if (!camera || !note) return; + fetch(B + '/api/actions', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Csrf-Token': csrfToken || '' }, + body: JSON.stringify({ camera_id: camera, note: note }) + }) + .then(function (res) { + if (!res.ok) throw new Error('action_failed:' + res.status); + actionNote.value = ''; + loadActions(); + }) + .catch(function () { /* 권한/CSRF 실패: 입력 유지 */ }); + }); + } + + var exportLink = document.getElementById('export-labels'); + if (exportLink) exportLink.href = B + '/api/export/labels?days=90'; + function load() { var days = daysSel ? daysSel.value : '7'; fetch(B + '/api/report?days=' + encodeURIComponent(days), { headers: { Accept: 'application/json' } }) @@ -238,4 +316,6 @@ if (daysSel) daysSel.addEventListener('change', load); if (printBtn) printBtn.addEventListener('click', function () { window.print(); }); load(); + loadActions(); + loadCsrf(); })(); diff --git a/server/public/index.php b/server/public/index.php index 3b107d8..11575aa 100644 --- a/server/public/index.php +++ b/server/public/index.php @@ -41,12 +41,13 @@ use App\Db\Database; use App\Auth\PhpSessionStore; use App\Repository\Pdo\{PdoDeviceRepository, PdoNonceRepository, PdoRateLimiterRepository, PdoEventRepository, PdoSnapshotRepository, PdoUserRepository, PdoZoneRepository, - PdoThresholdRepository, PdoAuditRepository}; + PdoThresholdRepository, PdoAuditRepository, PdoActionRepository}; use App\Ingest\IngestController; use App\Api\{ConfigController, HeartbeatController}; use App\Auth\{AuthController, CsrfController}; use App\Dashboard\{EventListController, EventDetailController, SnapshotProxy, ZoneConfigController, - DeviceStatusController, HeatmapController, FpStatsController, ReportController}; + DeviceStatusController, HeatmapController, FpStatsController, ReportController, + ExportController, ActionsController}; // Never leak internals to the client. Cafe24 shared hosting often ships display_errors=On, so // an uncaught PDOException would otherwise render the DSN + db_user in a raw 500. Turn it off @@ -98,6 +99,8 @@ $deviceStatus = new DeviceStatusController($devices, $sess); $heatmap = new HeatmapController($events, $sess); $fpStats = new FpStatsController($events, $sess); $report = new ReportController($events, $sess); +$export = new ExportController($events, $snaps, $sess); +$actionsCtl = new ActionsController(new PdoActionRepository($pdo), $events, $sess); $render = function (string $view): Response { ob_start(); require APP_ROOT . '/src/View/' . $view . '.php'; @@ -120,6 +123,9 @@ $r->add('GET', '/api/zones/{camera}', fn(Request $q, array $p) => $zoneCtl->list $r->add('GET', '/api/heatmap/{camera}', fn(Request $q, array $p) => $heatmap->json($q, $p)); $r->add('GET', '/api/fp-stats', fn(Request $q) => $fpStats->json($q)); $r->add('GET', '/api/report', fn(Request $q) => $report->json($q)); +$r->add('GET', '/api/export/labels', fn(Request $q) => $export->labels($q)); +$r->add('GET', '/api/actions', fn(Request $q) => $actionsCtl->json($q)); +$r->add('POST', '/api/actions', fn(Request $q) => $actionsCtl->save($q)); // Background frame for the zone editor: the newest snapshot this camera produced. $r->add('GET', '/api/cameras/{camera}/latest-snapshot', fn(Request $q, array $p) => $zoneCtl->latestSnapshot($q, $p)); $r->add('POST', '/api/zones', fn(Request $q) => $zoneCtl->save($q)); diff --git a/server/schema.sql b/server/schema.sql index cece199..8870f95 100644 --- a/server/schema.sql +++ b/server/schema.sql @@ -94,6 +94,21 @@ CREATE TABLE move_cameras ( -- `cameras` row exists for a given camera_id (no camera-registration flow in -- P1-B), so a FK here would spuriously reject valid app writes. -- --------------------------------------------------------------------------- +-- --------------------------------------------------------------------------- +-- Improvement action log (개선 조치): a site fix ("미끄럼 매트 설치") recorded against a +-- camera; the report compares detection density before/after to prove the fix worked. +-- --------------------------------------------------------------------------- +CREATE TABLE move_actions ( + action_id INT UNSIGNED NOT NULL AUTO_INCREMENT, + camera_id VARCHAR(64) NOT NULL, + note VARCHAR(500) NOT NULL, + taken_at BIGINT UNSIGNED NOT NULL, -- when the fix went in (epoch s) + created_by VARCHAR(190) NOT NULL, + created_at BIGINT UNSIGNED NOT NULL, + PRIMARY KEY (action_id), + KEY ix_actions_taken (taken_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + CREATE TABLE move_zones ( zone_id CHAR(32) NOT NULL, -- bin2hex(random_bytes(16)), same convention as snapshot uuids device_id VARCHAR(64) NOT NULL, diff --git a/server/src/Dashboard/ActionsController.php b/server/src/Dashboard/ActionsController.php new file mode 100644 index 0000000..ccc9d5e --- /dev/null +++ b/server/src/Dashboard/ActionsController.php @@ -0,0 +1,78 @@ +sess->get('user_id') === null) return Response::json(['error' => 'unauthorized'], 401); + if (!Rbac::can((string)$this->sess->get('role'), 'edit_zones')) return Response::json(['error' => 'forbidden'], 403); + if (!Csrf::check((string)$this->sess->get('csrf'), (string)$req->header('X-Csrf-Token'))) { + return Response::json(['error' => 'csrf'], 403); + } + $b = $req->json(); + $camera = trim((string)($b['camera_id'] ?? '')); + $note = trim((string)($b['note'] ?? '')); + if ($camera === '' || mb_strlen($camera) > 64 || $note === '' || mb_strlen($note) > self::MAX_NOTE) { + return Response::json(['error' => 'invalid_action'], 400); + } + $now = time(); + $takenAt = isset($b['taken_at']) && is_numeric($b['taken_at']) ? (int)$b['taken_at'] : $now; + // 미래 시각·1년 초과 과거는 입력 실수: 전후 비교가 성립하지 않는다. + if ($takenAt > $now || $takenAt < $now - 365 * 86400) { + return Response::json(['error' => 'invalid_taken_at'], 400); + } + $id = $this->actions->insert([ + 'camera_id' => $camera, 'note' => $note, 'taken_at' => $takenAt, + 'created_by' => (string)$this->sess->get('username'), + ]); + return Response::json(['action_id' => $id], 201); + } + + public function json(Request $req): Response + { + if ($this->sess->get('user_id') === null) return Response::json(['error' => 'unauthorized'], 401); + $scope = $this->sess->get('site_scope'); + $site = is_string($scope) ? $scope : null; + $now = time(); + $out = []; + foreach ($this->actions->recent(self::LIST_LIMIT) as $a) { + // 동일 길이의 전후 창으로만 비교해야 공정하다: 시행 후 경과가 W보다 짧으면 + // 전 창도 그만큼으로 줄인다. 3일 미만은 표본이 아니라 소음 — "측정 중". + $elapsed = $now - $a['taken_at']; + $w = min(self::EFFECT_WINDOW_S, $elapsed); + $effect = null; + if ($w >= self::EFFECT_MIN_S) { + $before = $this->events->countForCamera($a['camera_id'], + (float)($a['taken_at'] - $w), (float)$a['taken_at'], $site); + $after = $this->events->countForCamera($a['camera_id'], + (float)$a['taken_at'], (float)($a['taken_at'] + $w), $site); + $effect = ['before' => $before, 'after' => $after, 'window_days' => (int)round($w / 86400)]; + } + $out[] = $a + ['effect' => $effect]; + } + return Response::json(['actions' => $out], 200); + } +} diff --git a/server/src/Dashboard/ExportController.php b/server/src/Dashboard/ExportController.php new file mode 100644 index 0000000..e541cdb --- /dev/null +++ b/server/src/Dashboard/ExportController.php @@ -0,0 +1,80 @@ + JSONL download. + * + * The operator's 확인/오탐 buttons put a human verdict on every event, and each verdict + * carries a 3-frame (blurred) evidence timeline — a labeled dataset accumulating for free. + * This export turns it into an asset: evaluation sets, threshold-tuning ground truth, + * eventually fine-tuning data. One JSON object per line: + * {event_uuid, event_id, event_type, severity, camera_id, zone_id, occurred_at, + * duration_s, metrics, spatial, label, labeled_by, snapshots:[file_uuid...]} + * label: confirmed (acknowledged real) | false_positive | unreviewed. + * Images are NOT inlined — fetch each uuid via /snap/{uuid} (session + site re-checked). + * + * Admin-only ('manage_compliance'): a bulk export of detection history is a compliance + * decision, not an operator convenience. + */ +final class ExportController +{ + private const DEFAULT_DAYS = 90; + private const MAX_DAYS = 365; + private const MAX_ROWS = 20000; + + public function __construct( + private EventRepository $events, private SnapshotRepository $snaps, + private SessionStore $sess + ) {} + + public function labels(Request $req): Response + { + if ($this->sess->get('user_id') === null) return Response::json(['error' => 'unauthorized'], 401); + if (!Rbac::can((string)$this->sess->get('role'), 'manage_compliance')) { + return Response::json(['error' => 'forbidden'], 403); + } + $days = self::clampDays($req->query('days')); + $scope = $this->sess->get('site_scope'); + $site = is_string($scope) ? $scope : null; + $rows = $this->events->exportRows((float)(time() - $days * 86400), $site, self::MAX_ROWS); + $uuids = $this->snaps->uuidsFor(array_map(static fn ($r) => (int)$r['event_id'], $rows)); + + $lines = []; + foreach ($rows as $r) { + if (($r['event_type'] ?? '') === 'ergo_report') continue; // 통계 행: 라벨 대상 아님 + $isFp = ($r['false_positive_by'] ?? null) !== null; + $isAcked = ($r['acknowledged_by'] ?? null) !== null; + $lines[] = json_encode([ + 'event_uuid' => $r['event_uuid'] ?? null, + 'event_id' => (int)$r['event_id'], + 'event_type' => $r['event_type'], + 'severity' => $r['severity'], + 'camera_id' => $r['camera_id'], + 'zone_id' => $r['zone_id'] ?? null, + 'occurred_at' => $r['occurred_at'], + 'duration_s' => $r['duration_s'] ?? 0, + 'metrics' => $r['metrics'] ?? [], + 'spatial' => (($r['spatial_x'] ?? null) !== null && ($r['spatial_y'] ?? null) !== null) + ? [$r['spatial_x'], $r['spatial_y']] : null, + 'label' => $isFp ? 'false_positive' : ($isAcked ? 'confirmed' : 'unreviewed'), + 'labeled_by' => $isFp ? $r['false_positive_by'] : ($r['acknowledged_by'] ?? null), + 'snapshots' => $uuids[(int)$r['event_id']] ?? [], + ], JSON_UNESCAPED_UNICODE); + } + $body = implode("\n", $lines) . ($lines ? "\n" : ''); + return Response::bytes($body, 'application/x-ndjson; charset=utf-8', 200, [ + 'Content-Disposition' => 'attachment; filename="labels-' . date('Ymd') . '-' . $days . 'd.jsonl"', + ]); + } + + private static function clampDays(mixed $raw): int + { + if ($raw === null || $raw === '' || !is_numeric($raw)) return self::DEFAULT_DAYS; + return max(1, min(self::MAX_DAYS, (int)$raw)); + } +} diff --git a/server/src/Repository/ActionRepository.php b/server/src/Repository/ActionRepository.php new file mode 100644 index 0000000..1754ec6 --- /dev/null +++ b/server/src/Repository/ActionRepository.php @@ -0,0 +1,11 @@ + action_id */ + public function insert(array $action): int; + /** Most recent actions, newest taken_at first, capped at $limit. */ + public function recent(int $limit): array; +} diff --git a/server/src/Repository/EventRepository.php b/server/src/Repository/EventRepository.php index a010083..e334326 100644 --- a/server/src/Repository/EventRepository.php +++ b/server/src/Repository/EventRepository.php @@ -55,4 +55,8 @@ interface EventRepository * so the SQL stays portable (the sqlite integration harness has no FROM_UNIXTIME). */ public function occurrenceTimes(float $sinceOccurredAt, ?string $site, int $limit): array; + /** Full mapped event rows in the window, newest first, capped — the label-dataset export. */ + public function exportRows(float $sinceOccurredAt, ?string $site, int $limit): array; + /** Detections (non-ergo) for one camera in [from, to) — the action before/after counter. */ + public function countForCamera(string $cameraId, float $from, float $to, ?string $site): int; } diff --git a/server/src/Repository/Pdo/PdoActionRepository.php b/server/src/Repository/Pdo/PdoActionRepository.php new file mode 100644 index 0000000..f3ef733 --- /dev/null +++ b/server/src/Repository/Pdo/PdoActionRepository.php @@ -0,0 +1,44 @@ +pdo->prepare( + 'INSERT INTO move_actions (camera_id, note, taken_at, created_by, created_at) + VALUES (?, ?, ?, ?, ?)' + ); + $st->execute([ + (string)$action['camera_id'], + (string)$action['note'], + (int)$action['taken_at'], + (string)$action['created_by'], + time(), + ]); + return (int)$this->pdo->lastInsertId(); + } + + public function recent(int $limit): array + { + $st = $this->pdo->prepare( + 'SELECT action_id, camera_id, note, taken_at, created_by + FROM move_actions ORDER BY taken_at DESC, action_id DESC LIMIT ?' + ); + $st->bindValue(1, $limit, PDO::PARAM_INT); + $st->execute(); + return array_map(static fn ($r) => [ + 'action_id' => (int)$r['action_id'], + 'camera_id' => $r['camera_id'], + 'note' => $r['note'], + 'taken_at' => (int)$r['taken_at'], + 'created_by' => $r['created_by'], + ], $st->fetchAll()); + } +} diff --git a/server/src/Repository/Pdo/PdoEventRepository.php b/server/src/Repository/Pdo/PdoEventRepository.php index e3eb56f..e596ca3 100644 --- a/server/src/Repository/Pdo/PdoEventRepository.php +++ b/server/src/Repository/Pdo/PdoEventRepository.php @@ -304,6 +304,38 @@ final class PdoEventRepository implements EventRepository ], $st->fetchAll()); } + public function exportRows(float $sinceOccurredAt, ?string $site, int $limit): array + { + $sql = 'SELECT ' . self::SELECT_COLUMNS . ' FROM move_events WHERE occurred_at >= ?'; + $binds = [[sprintf('%.6f', $sinceOccurredAt), PDO::PARAM_STR]]; + if ($site !== null) { + $sql .= ' AND site = ?'; + $binds[] = [$site, PDO::PARAM_STR]; + } + $sql .= ' ORDER BY occurred_at DESC, event_id DESC LIMIT ?'; + $binds[] = [$limit, PDO::PARAM_INT]; + $st = $this->pdo->prepare($sql); + $pos = 1; + foreach ($binds as [$val, $type]) $st->bindValue($pos++, $val, $type); + $st->execute(); + return array_map(self::mapRow(...), $st->fetchAll()); + } + + public function countForCamera(string $cameraId, float $from, float $to, ?string $site): int + { + $sql = "SELECT COUNT(*) FROM move_events + WHERE camera_id = ? AND occurred_at >= ? AND occurred_at < ? + AND event_type <> 'ergo_report'"; + $binds = [$cameraId, sprintf('%.6f', $from), sprintf('%.6f', $to)]; + if ($site !== null) { + $sql .= ' AND site = ?'; + $binds[] = $site; + } + $st = $this->pdo->prepare($sql); + $st->execute($binds); + return (int)$st->fetchColumn(); + } + private static function mapRow(array $row): array { return [ diff --git a/server/src/Repository/Pdo/PdoSnapshotRepository.php b/server/src/Repository/Pdo/PdoSnapshotRepository.php index 4f93c5a..3cbcc04 100644 --- a/server/src/Repository/Pdo/PdoSnapshotRepository.php +++ b/server/src/Repository/Pdo/PdoSnapshotRepository.php @@ -28,6 +28,22 @@ final class PdoSnapshotRepository implements SnapshotRepository return $st->fetchAll(); } + public function uuidsFor(array $eventIds): array + { + $ids = array_values(array_filter(array_map('intval', $eventIds), static fn (int $i): bool => $i > 0)); + if ($ids === []) return []; + $in = implode(',', array_fill(0, count($ids), '?')); + $st = $this->pdo->prepare( + "SELECT event_id, file_uuid FROM move_event_snapshots + WHERE event_id IN ($in) ORDER BY snapshot_id" + ); + foreach ($ids as $i => $v) $st->bindValue($i + 1, $v, PDO::PARAM_INT); + $st->execute(); + $out = []; + foreach ($st->fetchAll() as $r) $out[(int)$r['event_id']][] = (string)$r['file_uuid']; + return $out; + } + public function thumbUuidsFor(array $eventIds): array { // Sanitize to positive ints and build the IN list from their COUNT — never interpolate diff --git a/server/src/Repository/SnapshotRepository.php b/server/src/Repository/SnapshotRepository.php index dd57e7b..a044929 100644 --- a/server/src/Repository/SnapshotRepository.php +++ b/server/src/Repository/SnapshotRepository.php @@ -25,4 +25,8 @@ interface SnapshotRepository /** Delete snapshot files + rows created before $cutoff (unix ts). PIPA retention: * worker images must not be kept forever. Returns the number purged. */ public function purgeOlderThan(int $cutoff): int; + + /** ALL snapshot uuids per event, as [event_id => [file_uuid, ...]] in stored order — + * batched for the label export (thumbUuidsFor only carries the newest). */ + public function uuidsFor(array $eventIds): array; } diff --git a/server/src/View/report.php b/server/src/View/report.php index d7338b1..9074858 100644 --- a/server/src/View/report.php +++ b/server/src/View/report.php @@ -44,9 +44,22 @@ render_header('안전 리포트');

시간 패턴 (요일 × 시간)

+
+

개선 조치 효과

+ +
+ + + +
+
+

+

라벨 데이터셋 내려받기 (JSONL, 관리자) + — 확인/오탐 판정이 붙은 감지 이력. 평가셋·임계 튜닝의 원료입니다.

uuid ? $this->eid : null; } public function latestForCamera(string $cameraId): ?array { return null; } public function purgeOlderThan(int $c): int { return 0; } + public function uuidsFor(array $ids): array { return []; } }; $cross = new SnapshotProxy($snaps, $repo, $this->authedSite('factory-a')); $this->assertSame(404, $cross->serve(new Request('GET', '/s', [], '', [], []), ['uuid' => $uuid])->status()); diff --git a/server/tests/ExportActionsTest.php b/server/tests/ExportActionsTest.php new file mode 100644 index 0000000..43e1dce --- /dev/null +++ b/server/tests/ExportActionsTest.php @@ -0,0 +1,119 @@ +set('user_id', 1); $s->set('username', 'u'); $s->set('role', $role); $s->set('csrf', 'tok'); + if ($site !== null) $s->set('site_scope', $site); + return $s; + } + + // ---- 라벨 익스포트 ---------------------------------------------------------- + + public function testExportRequiresAdmin(): void + { + $c = new ExportController(new InMemoryEventRepository(), new InMemorySnapshotRepository(), $this->sess('operator')); + $this->assertSame(403, $c->labels(new Request('GET', '/api/export/labels', [], '', [], []))->status()); + } + + public function testExportEmitsLabeledJsonl(): void + { + $events = new InMemoryEventRepository(); + $snaps = new InMemorySnapshotRepository(); + $now = (float)time(); + $a = $events->insert(['event_uuid' => str_repeat('a', 32), 'camera_id' => 'cam-1', + 'event_type' => 'fall', 'severity' => 'high', 'occurred_at' => $now, + 'metrics' => ['torso_angle' => 82.0], 'spatial_x' => 0.4, 'spatial_y' => 0.6]); + $events->acknowledge($a, 'op'); + $snaps->add($a, 'uuid-1', 'h1'); $snaps->add($a, 'uuid-2', 'h2'); + $b = $events->insert(['camera_id' => 'cam-1', 'event_type' => 'zone_warning', + 'severity' => 'warning', 'occurred_at' => $now, 'metrics' => []]); + $events->markFalsePositive($b, 'op'); + $events->insert(['camera_id' => 'cam-1', 'event_type' => 'near_fall', + 'severity' => 'info', 'occurred_at' => $now, 'metrics' => []]); // unreviewed + $events->insert(['camera_id' => 'cam-1', 'event_type' => 'ergo_report', + 'severity' => 'info', 'occurred_at' => $now, 'metrics' => []]); // 제외 대상 + + $c = new ExportController($events, $snaps, $this->sess('admin')); + $res = $c->labels(new Request('GET', '/api/export/labels', [], '', [], [])); + $this->assertSame(200, $res->status()); + $this->assertStringContainsString('application/x-ndjson', $res->headers()['Content-Type']); + $lines = array_values(array_filter(explode("\n", $res->body()))); + $this->assertCount(3, $lines); // ergo_report 제외 + $byType = []; + foreach ($lines as $l) { $o = json_decode($l, true); $byType[$o['event_type']] = $o; } + $this->assertSame('confirmed', $byType['fall']['label']); + $this->assertSame('op', $byType['fall']['labeled_by']); + $this->assertSame(['uuid-1', 'uuid-2'], $byType['fall']['snapshots']); + $this->assertEquals([0.4, 0.6], $byType['fall']['spatial']); + $this->assertSame('false_positive', $byType['zone_warning']['label']); + $this->assertSame('unreviewed', $byType['near_fall']['label']); + } + + // ---- 개선 조치 -------------------------------------------------------------- + + private function actionReq(array $body): Request + { + return new Request('POST', '/api/actions', ['X-Csrf-Token' => 'tok'], json_encode($body), [], []); + } + + public function testActionSaveValidation(): void + { + $c = new ActionsController(new InMemoryActionRepository(), new InMemoryEventRepository(), $this->sess('operator')); + $this->assertSame(201, $c->save($this->actionReq(['camera_id' => 'cam-1', 'note' => '매트 설치']))->status()); + $this->assertSame(400, $c->save($this->actionReq(['camera_id' => '', 'note' => 'x']))->status()); + $this->assertSame(400, $c->save($this->actionReq(['camera_id' => 'cam-1', 'note' => '']))->status()); + $this->assertSame(400, $c->save($this->actionReq(['camera_id' => 'cam-1', 'note' => 'x', + 'taken_at' => time() + 999]))->status()); // 미래 시각 거부 + // viewer는 기록 불가 + $cv = new ActionsController(new InMemoryActionRepository(), new InMemoryEventRepository(), $this->sess('viewer')); + $this->assertSame(403, $cv->save($this->actionReq(['camera_id' => 'cam-1', 'note' => 'x']))->status()); + } + + public function testActionEffectComparesEqualWindows(): void + { + $actions = new InMemoryActionRepository(); + $events = new InMemoryEventRepository(); + $now = time(); + $takenAt = $now - 10 * 86400; // 10일 전 조치 + $actions->insert(['camera_id' => 'cam-1', 'note' => '매트', 'taken_at' => $takenAt, 'created_by' => 'u']); + // 전 창(10일): 4건 / 후 창(10일): 1건 + foreach ([1, 3, 5, 7] as $d) { + $events->insert(['camera_id' => 'cam-1', 'event_type' => 'near_fall', 'severity' => 'info', + 'occurred_at' => (float)($takenAt - $d * 86400), 'metrics' => []]); + } + $events->insert(['camera_id' => 'cam-1', 'event_type' => 'near_fall', 'severity' => 'info', + 'occurred_at' => (float)($takenAt + 2 * 86400), 'metrics' => []]); + // 다른 카메라·ergo는 집계 밖 + $events->insert(['camera_id' => 'cam-9', 'event_type' => 'fall', 'severity' => 'high', + 'occurred_at' => (float)($takenAt + 1), 'metrics' => []]); + $events->insert(['camera_id' => 'cam-1', 'event_type' => 'ergo_report', 'severity' => 'info', + 'occurred_at' => (float)($takenAt + 1), 'metrics' => []]); + + $c = new ActionsController($actions, $events, $this->sess('operator')); + $data = json_decode($c->json(new Request('GET', '/api/actions', [], '', [], []))->body(), true); + $e = $data['actions'][0]['effect']; + $this->assertSame(4, $e['before']); + $this->assertSame(1, $e['after']); + $this->assertSame(10, $e['window_days']); + } + + public function testFreshActionReportsPendingEffect(): void + { + $actions = new InMemoryActionRepository(); + $actions->insert(['camera_id' => 'cam-1', 'note' => '방금 조치', 'taken_at' => time() - 3600, 'created_by' => 'u']); + $c = new ActionsController($actions, new InMemoryEventRepository(), $this->sess('operator')); + $data = json_decode($c->json(new Request('GET', '/api/actions', [], '', [], []))->body(), true); + $this->assertNull($data['actions'][0]['effect']); // 3일 미만: 측정 중 + } +} diff --git a/server/tests/IngestControllerTest.php b/server/tests/IngestControllerTest.php index a2bf422..6f3db6c 100644 --- a/server/tests/IngestControllerTest.php +++ b/server/tests/IngestControllerTest.php @@ -114,6 +114,7 @@ final class IngestControllerTest extends TestCase public function eventIdByUuid(string $u): ?int { return null; } public function latestForCamera(string $cameraId): ?array { return null; } public function purgeOlderThan(int $c): int { return 0; } + public function uuidsFor(array $ids): array { return []; } }; $events = new InMemoryEventRepository(); $c = new IngestController($cfg, new InMemoryDeviceRepository(['pi-1' => 'sekret']), diff --git a/server/tests/Support/InMemoryRepositories.php b/server/tests/Support/InMemoryRepositories.php index 82535a0..efadb8f 100644 --- a/server/tests/Support/InMemoryRepositories.php +++ b/server/tests/Support/InMemoryRepositories.php @@ -3,7 +3,7 @@ declare(strict_types=1); namespace App\Tests\Support; use App\Repository\{DeviceRepository, NonceRepository, RateLimiterRepository, EventRepository, SnapshotRepository, UserRepository, ZoneRepository, - ThresholdRepository, AuditRepository}; + ThresholdRepository, AuditRepository, ActionRepository}; final class InMemoryDeviceRepository implements DeviceRepository { public array $heartbeats = []; @@ -117,6 +117,18 @@ final class InMemoryEventRepository implements EventRepository { 'ergo_metrics' => $ergo, ]; } + public function exportRows(float $since, ?string $site, int $limit): array { + $r = array_filter($this->rows, fn($x) => ($x['occurred_at'] ?? 0) >= $since + && ($site === null || ($x['site'] ?? null) === $site)); + usort($r, fn($a, $b) => [$b['occurred_at'] ?? 0, $b['event_id']] <=> [$a['occurred_at'] ?? 0, $a['event_id']]); + return array_slice(array_values($r), 0, $limit); + } + public function countForCamera(string $cam, float $from, float $to, ?string $site): int { + return count(array_filter($this->rows, fn($x) => ($x['camera_id'] ?? '') === $cam + && ($x['occurred_at'] ?? 0) >= $from && ($x['occurred_at'] ?? 0) < $to + && ($x['event_type'] ?? '') !== 'ergo_report' + && ($site === null || ($x['site'] ?? null) === $site))); + } public function occurrenceTimes(float $since, ?string $site, int $limit): array { $r = array_filter($this->rows, fn($x) => ($x['occurred_at'] ?? 0) >= $since && ($x['event_type'] ?? '') !== 'ergo_report' @@ -169,6 +181,13 @@ final class InMemorySnapshotRepository implements SnapshotRepository { } return null; } + public function uuidsFor(array $eventIds): array { + $out = []; + foreach ($eventIds as $e) { + foreach ($this->rows[(int)$e] ?? [] as $r) $out[(int)$e][] = $r['file_uuid']; + } + return $out; + } public function purgeOlderThan(int $cutoff): int { $n = 0; foreach ($this->created as $uuid => $ts) { @@ -196,6 +215,15 @@ final class InMemoryZoneRepository implements ZoneRepository { public function save(array $z): string { $id = $z['zone_id'] ?? ('z' . (++$this->seq)); $this->zones[$id] = $z + ['zone_id' => $id]; return $id; } public function delete(string $id): bool { if (!isset($this->zones[$id])) return false; unset($this->zones[$id]); return true; } } +final class InMemoryActionRepository implements ActionRepository { + public array $rows = []; private int $seq = 0; + public function insert(array $a): int { $id = ++$this->seq; $this->rows[$id] = $a + ['action_id' => $id]; return $id; } + public function recent(int $limit): array { + $r = array_values($this->rows); + usort($r, fn($a, $b) => [$b['taken_at'], $b['action_id']] <=> [$a['taken_at'], $a['action_id']]); + return array_slice($r, 0, $limit); + } +} final class InMemoryThresholdRepository implements ThresholdRepository { public function __construct(private array $data = []) {} public function forDevice(string $d): array { return $this->data[$d] ?? []; }