move_detect/server/src/Dashboard/ActionsController.php
유창욱 6b69f22275 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).
2026-08-07 07:44:00 +09:00

78 lines
3.6 KiB
PHP

<?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);
}
}