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:
parent
d8b8e1f381
commit
6b69f22275
20 changed files with 573 additions and 5 deletions
|
|
@ -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/
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
13
server/migrations/2026-08-07-improvement-actions.sql
Normal file
13
server/migrations/2026-08-07-improvement-actions.sql
Normal 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;
|
||||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
78
server/src/Dashboard/ActionsController.php
Normal file
78
server/src/Dashboard/ActionsController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
80
server/src/Dashboard/ExportController.php
Normal file
80
server/src/Dashboard/ExportController.php
Normal 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));
|
||||
}
|
||||
}
|
||||
11
server/src/Repository/ActionRepository.php
Normal file
11
server/src/Repository/ActionRepository.php
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
44
server/src/Repository/Pdo/PdoActionRepository.php
Normal file
44
server/src/Repository/Pdo/PdoActionRepository.php
Normal 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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 [
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
119
server/tests/ExportActionsTest.php
Normal file
119
server/tests/ExportActionsTest.php
Normal 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일 미만: 측정 중
|
||||
}
|
||||
}
|
||||
|
|
@ -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']),
|
||||
|
|
|
|||
|
|
@ -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] ?? []; }
|
||||
|
|
|
|||
Loading…
Reference in a new issue