feat(data-asset): labeled-dataset export + improvement-action effect tracking

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).
This commit is contained in:
유창욱 2026-08-07 07:44:00 +09:00
parent d8b8e1f381
commit 6b69f22275
20 changed files with 573 additions and 5 deletions

View file

@ -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/

View file

@ -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)

View file

@ -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;

View file

@ -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; }

View file

@ -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();
})();

View file

@ -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));

View file

@ -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,

View file

@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace App\Dashboard;
use App\Http\{Request, Response};
use App\Auth\{SessionStore, Rbac};
use App\Security\Csrf;
use App\Repository\{ActionRepository, EventRepository};
/**
* Improvement actions (개선 조치): record a site fix against a camera, and let the data
* answer "did it work?" — the report shows detection density before vs after the fix
* over equal windows. This is the only path where a safety investment's ROI comes out
* of the system as a number.
*/
final class ActionsController
{
private const LIST_LIMIT = 20;
private const EFFECT_WINDOW_S = 30 * 86400; // 비교 창 상한: 조치 전후 각 30일
private const EFFECT_MIN_S = 3 * 86400; // 시행 3일 미만이면 "측정 중" — 성급한 결론 방지
private const MAX_NOTE = 500;
public function __construct(
private ActionRepository $actions, private EventRepository $events,
private SessionStore $sess
) {}
public function save(Request $req): Response
{
if ($this->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);
}
}

View file

@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace App\Dashboard;
use App\Http\{Request, Response};
use App\Auth\{SessionStore, Rbac};
use App\Repository\{EventRepository, SnapshotRepository};
/**
* GET /api/export/labels?days=N -> 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));
}
}

View file

@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace App\Repository;
interface ActionRepository
{
/** {camera_id, note, taken_at, created_by} -> action_id */
public function insert(array $action): int;
/** Most recent actions, newest taken_at first, capped at $limit. */
public function recent(int $limit): array;
}

View file

@ -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;
}

View file

@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Repository\Pdo;
use App\Repository\ActionRepository;
use PDO;
final class PdoActionRepository implements ActionRepository
{
public function __construct(private PDO $pdo) {}
public function insert(array $action): int
{
$st = $this->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());
}
}

View file

@ -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 [

View file

@ -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

View file

@ -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;
}

View file

@ -44,9 +44,22 @@ render_header('안전 리포트');
<h2>시간 패턴 (요일 × 시간)</h2>
<div id="report-pattern"></div>
</section>
<section class="report-card report-card-wide">
<h2>개선 조치 효과</h2>
<!-- 조치를 기록하면 시스템이 전후 동일 길이 창의 감지 밀도를 비교한다 —
안전 투자의 효과가 숫자로 나오는 자리. -->
<form id="action-form" class="action-form">
<input type="text" id="action-camera" placeholder="카메라 ID" maxlength="64" aria-label="카메라 ID">
<input type="text" id="action-note" placeholder="조치 내용 (예: 미끄럼 방지 매트 설치)" maxlength="500" aria-label="조치 내용">
<button type="submit">조치 기록</button>
</form>
<div id="report-actions"></div>
</section>
</div>
<p id="report-foot" class="report-foot"></p>
<p class="report-foot"><a id="export-labels" href="#">라벨 데이터셋 내려받기 (JSONL, 관리자)</a>
— 확인/오탐 판정이 붙은 감지 이력. 평가셋·임계 튜닝의 원료입니다.</p>
</div>
<script src="<?= view_url('/assets/report.js') ?>"></script>
<?php

View file

@ -161,6 +161,7 @@ final class DashboardTest extends TestCase
public function eventIdByUuid(string $u): ?int { return $u === $this->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());

View file

@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
namespace App\Tests;
use App\Http\Request;
use App\Auth\ArraySessionStore;
use App\Dashboard\{ExportController, ActionsController};
use App\Tests\Support\{InMemoryEventRepository, InMemorySnapshotRepository, InMemoryActionRepository};
use PHPUnit\Framework\TestCase;
final class ExportActionsTest extends TestCase
{
private function sess(string $role = 'admin', ?string $site = null): ArraySessionStore
{
$s = new ArraySessionStore();
$s->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일 미만: 측정 중
}
}

View file

@ -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']),

View file

@ -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] ?? []; }