feat(fp): operator false-positive tag + per-camera FP-rate report
The feedback loop, per product decision (option a): tags feed STATISTICS only.
A human reads the rate and re-tunes a camera deliberately - the tag never
adjusts edge sensitivity on its own (no silent behavior change in a safety
system).
- POST /events/{id}/false-positive: same RBAC ('acknowledge') + CSRF + site
scope as ack. Tagging also acknowledges (an FP is a handled event - leaving
it unacknowledged would keep it alarming). COALESCE keeps the FIRST tagger:
a second tag never rewrites who made the original audit-relevant call.
- move_events.false_positive_by/at (migration included), audit-logged.
- GET /api/fp-stats?days=N: per-(camera, event_type) totals + FP counts,
FP>0 groups only, fp-desc, fail-closed site scoping.
- Console: each open row gets a visually-secondary 오탐 button beside 확인
(can't be fat-fingered as the default); resolved rows show a mono verdict
("오탐 (user)" struck through vs "확인됨 (user)"). New rail panel "오탐율 ·
30일" lists top offenders - 30%+ highlighted as re-tuning candidates -
refreshed on a slow 60s cadence plus immediately after a tag. Detail view
shows the tag.
Verified: PHPUnit 135 pass / pytest unchanged + Chrome real-render (tag ->
row flips to struck-through verdict + drops out of unacked KPI/tree counts ->
FP panel updates immediately; no console errors).
This commit is contained in:
parent
9e4225a338
commit
47ab1143e1
17 changed files with 382 additions and 15 deletions
|
|
@ -1,5 +1,9 @@
|
||||||
# INDEX — 작업 기록 (완료 결과만)
|
# INDEX — 작업 기록 (완료 결과만)
|
||||||
|
|
||||||
|
- **2026-08-06** 오탐 피드백 (결정 a: 통계만, 자동 튜닝 없음): 이벤트 오탐 태그
|
||||||
|
(`POST /events/{id}/false-positive`, ack 겸함, 최초 태거 보존) + `GET /api/fp-stats` +
|
||||||
|
콘솔 오탐율 패널(30일, 30%+ 강조). PHPUnit 135 + Chrome 실렌더(태그→행 전환→패널 즉시 갱신).
|
||||||
|
|
||||||
- **2026-08-06** 대시보드 장치→카메라 트리 레일 + (device,camera) 쌍 카메라 필터 —
|
- **2026-08-06** 대시보드 장치→카메라 트리 레일 + (device,camera) 쌍 카메라 필터 —
|
||||||
`eaff176`. PHPUnit 119 통과 + Chrome 실렌더 검증(트리 상태·필터·접기·콘솔 무오류).
|
`eaff176`. PHPUnit 119 통과 + Chrome 실렌더 검증(트리 상태·필터·접기·콘솔 무오류).
|
||||||
- **2026-08-06** 감사 잔여 P2-6/7/8 종결 (ID 스왑 게이트 · sustained-prone FALLING ·
|
- **2026-08-06** 감사 잔여 P2-6/7/8 종결 (ID 스왑 게이트 · sustained-prone FALLING ·
|
||||||
|
|
|
||||||
|
|
@ -31,8 +31,9 @@
|
||||||
- 진입: `public/index.php` (라우트 전부 여기), 뷰: `src/View/{layout,events,login,zones,event_detail}.php`
|
- 진입: `public/index.php` (라우트 전부 여기), 뷰: `src/View/{layout,events,login,zones,event_detail}.php`
|
||||||
- 라우트: `POST /api/events`(ingest, HMAC) · `POST /api/heartbeat` · `GET /api/config` ·
|
- 라우트: `POST /api/events`(ingest, HMAC) · `POST /api/heartbeat` · `GET /api/config` ·
|
||||||
`GET /api/events` · `GET /api/devices` · `GET /api/heatmap/{camera}`(spatial 집계) ·
|
`GET /api/events` · `GET /api/devices` · `GET /api/heatmap/{camera}`(spatial 집계) ·
|
||||||
`GET/POST /api/zones*` · `POST /login|/logout` · `GET /snap/{uuid}` ·
|
`GET /api/fp-stats`(오탐율) · `GET/POST /api/zones*` · `POST /login|/logout` ·
|
||||||
`POST /events/{id}/ack` · 페이지 `/`, `/login`, `/zones`, `/events/{id}/view`
|
`GET /snap/{uuid}` · `POST /events/{id}/ack` · `POST /events/{id}/false-positive`(오탐 태그,
|
||||||
|
ack 겸함) · 페이지 `/`, `/login`, `/zones`, `/events/{id}/view`
|
||||||
- `src/Ingest/` HMAC+nonce+rate-limit+replay 방지 · `src/Auth/` argon2id/세션/RBAC/CSRF ·
|
- `src/Ingest/` HMAC+nonce+rate-limit+replay 방지 · `src/Auth/` argon2id/세션/RBAC/CSRF ·
|
||||||
`src/Dashboard/` 이벤트목록·디바이스상태·존편집·스냅샷프록시 · `src/Repository/` 9종 (인터페이스+PDO)
|
`src/Dashboard/` 이벤트목록·디바이스상태·존편집·스냅샷프록시 · `src/Repository/` 9종 (인터페이스+PDO)
|
||||||
- 프런트: `public/assets/app.js`(이벤트 콘솔 폴링·필터) · `device-tree.js`(장치→카메라 트리 레일) ·
|
- 프런트: `public/assets/app.js`(이벤트 콘솔 폴링·필터) · `device-tree.js`(장치→카메라 트리 레일) ·
|
||||||
|
|
|
||||||
6
server/migrations/2026-08-06-false-positive-tag.sql
Normal file
6
server/migrations/2026-08-06-false-positive-tag.sql
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
-- Operator false-positive tag (오탐). Feeds per-camera/type FP-rate statistics only —
|
||||||
|
-- by product decision the tag never auto-tunes edge thresholds; a human reads the rate
|
||||||
|
-- and adjusts deliberately. Tagging also acknowledges the event (an FP is handled).
|
||||||
|
ALTER TABLE move_events
|
||||||
|
ADD COLUMN false_positive_by VARCHAR(190) NULL AFTER acknowledged_at,
|
||||||
|
ADD COLUMN false_positive_at BIGINT UNSIGNED NULL AFTER false_positive_by;
|
||||||
|
|
@ -334,8 +334,25 @@ a.event-thumb-slot:hover { border-color:var(--accent); box-shadow:0 0 0 3px var(
|
||||||
.event-detail-link:hover, .event-detail-link:focus-visible { color:var(--accent); text-decoration:underline; text-underline-offset:3px; }
|
.event-detail-link:hover, .event-detail-link:focus-visible { color:var(--accent); text-decoration:underline; text-underline-offset:3px; }
|
||||||
.chip { font-size:.68rem; font-weight:700; color:#fff; background:var(--crit-ink); padding:.14rem .5rem; border-radius:999px; letter-spacing:.01em; }
|
.chip { font-size:.68rem; font-weight:700; color:#fff; background:var(--crit-ink); padding:.14rem .5rem; border-radius:999px; letter-spacing:.01em; }
|
||||||
|
|
||||||
.ack-btn { margin-left:auto; flex:none; min-width:4.5rem; text-align:center; }
|
/* Actions column: [확인][오탐] while open; a single mono verdict line once resolved. */
|
||||||
.ack-btn:disabled { color:var(--ink-3); background:none; border-color:transparent; box-shadow:none; font-family:var(--mono); font-size:.75rem; }
|
.row-actions { margin-left:auto; flex:none; display:flex; align-items:center; gap:.4rem; }
|
||||||
|
.ack-btn { flex:none; min-width:4.5rem; text-align:center; }
|
||||||
|
/* 오탐 is the rarer, corrective action — visually secondary to 확인 so it can't be
|
||||||
|
fat-fingered as the default. */
|
||||||
|
.fp-btn { flex:none; padding:.32rem .55rem; font-size:.75rem; color:var(--ink-3);
|
||||||
|
background:none; border-color:transparent; box-shadow:none; }
|
||||||
|
.fp-btn:hover { color:var(--ink-2); border-color:var(--line); background:#fff; }
|
||||||
|
.ack-done, .fp-done { color:var(--ink-3); font-family:var(--mono); font-size:.75rem; white-space:nowrap; }
|
||||||
|
.fp-done { text-decoration:line-through; text-decoration-color:#c9d1da; }
|
||||||
|
|
||||||
|
/* Rail: FP-rate report rows. */
|
||||||
|
.fp-stats { display:flex; flex-direction:column; gap:.34rem; font-size:.74rem; }
|
||||||
|
.fp-stats.is-empty { color:var(--ink-3); }
|
||||||
|
.fp-row { display:flex; align-items:baseline; justify-content:space-between; gap:.6rem; min-width:0; }
|
||||||
|
.fp-what { color:var(--ink-2); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||||
|
.fp-what .fp-cam { font-family:var(--mono); font-weight:600; color:var(--ink-3); margin-right:.15rem; }
|
||||||
|
.fp-rate { flex:none; font-family:var(--mono); color:var(--ink-3); }
|
||||||
|
.fp-rate.is-hot { color:var(--warn-ink); font-weight:700; }
|
||||||
|
|
||||||
/* "new events arrived while paging" reload affordance — honest about a possible gap. */
|
/* "new events arrived while paging" reload affordance — honest about a possible gap. */
|
||||||
.page-gap-hint { display:flex; align-items:center; gap:.6rem; margin:0 0 .6rem; padding:.45rem .7rem;
|
.page-gap-hint { display:flex; align-items:center; gap:.6rem; margin:0 0 .6rem; padding:.45rem .7rem;
|
||||||
|
|
|
||||||
|
|
@ -234,17 +234,38 @@
|
||||||
|
|
||||||
row.appendChild(meta);
|
row.appendChild(meta);
|
||||||
|
|
||||||
|
// Actions column. Resolution states are terminal and mutually exclusive in display:
|
||||||
|
// an FP tag wins (it is the more specific verdict — the event was also acknowledged
|
||||||
|
// server-side), then a plain acknowledgement, else the two action buttons.
|
||||||
|
var actions = document.createElement('div');
|
||||||
|
actions.className = 'row-actions';
|
||||||
|
var isFp = ev.false_positive_by !== null && ev.false_positive_by !== undefined && ev.false_positive_by !== '';
|
||||||
|
if (isFp) {
|
||||||
|
var fpDone = document.createElement('span');
|
||||||
|
fpDone.className = 'fp-done';
|
||||||
|
fpDone.textContent = '오탐 (' + String(ev.false_positive_by) + ')';
|
||||||
|
actions.appendChild(fpDone);
|
||||||
|
} else if (isAcked) {
|
||||||
|
var ackDone = document.createElement('span');
|
||||||
|
ackDone.className = 'ack-done';
|
||||||
|
ackDone.textContent = '확인됨 (' + String(ev.acknowledged_by) + ')';
|
||||||
|
actions.appendChild(ackDone);
|
||||||
|
} else {
|
||||||
var btn = document.createElement('button');
|
var btn = document.createElement('button');
|
||||||
btn.type = 'button';
|
btn.type = 'button';
|
||||||
btn.className = 'ack-btn';
|
btn.className = 'ack-btn';
|
||||||
if (isAcked) {
|
|
||||||
btn.textContent = '확인됨 (' + String(ev.acknowledged_by) + ')';
|
|
||||||
btn.disabled = true;
|
|
||||||
} else {
|
|
||||||
btn.textContent = '확인';
|
btn.textContent = '확인';
|
||||||
btn.addEventListener('click', function () { acknowledge(ev.event_id, btn); });
|
btn.addEventListener('click', function () { acknowledge(ev.event_id, btn); });
|
||||||
|
actions.appendChild(btn);
|
||||||
|
var fpBtn = document.createElement('button');
|
||||||
|
fpBtn.type = 'button';
|
||||||
|
fpBtn.className = 'fp-btn';
|
||||||
|
fpBtn.textContent = '오탐';
|
||||||
|
fpBtn.setAttribute('aria-label', '오탐으로 표시 (확인 처리됨, 오탐 통계에 집계)');
|
||||||
|
fpBtn.addEventListener('click', function () { markFalsePositive(ev.event_id, fpBtn, btn); });
|
||||||
|
actions.appendChild(fpBtn);
|
||||||
}
|
}
|
||||||
row.appendChild(btn);
|
row.appendChild(actions);
|
||||||
|
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
@ -269,16 +290,40 @@
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function patchOlderAck(eventId) {
|
function patchOlderAck(eventId, alsoFp) {
|
||||||
for (var i = 0; i < olderEvents.length; i++) {
|
for (var i = 0; i < olderEvents.length; i++) {
|
||||||
var ev = olderEvents[i];
|
var ev = olderEvents[i];
|
||||||
if (ev && ev.event_id === eventId && !isAcked(ev)) {
|
if (ev && ev.event_id === eventId && !isAcked(ev)) {
|
||||||
// New object, never mutate the existing one (keeps combinedEvents' dedup predictable).
|
// New object, never mutate the existing one (keeps combinedEvents' dedup predictable).
|
||||||
olderEvents[i] = Object.assign({}, ev, { acknowledged_by: window.APP_USER || '확인' });
|
var patch = { acknowledged_by: window.APP_USER || '확인' };
|
||||||
|
if (alsoFp) patch.false_positive_by = window.APP_USER || '오탐';
|
||||||
|
olderEvents[i] = Object.assign({}, ev, patch);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The 오탐 tag: same terminality as an ack (the server also acknowledges), plus the event
|
||||||
|
// feeds the per-camera FP-rate panel. By product decision this is where the loop ENDS —
|
||||||
|
// the tag never auto-tunes edge thresholds; a human reads the rate and adjusts deliberately.
|
||||||
|
function markFalsePositive(eventId, fpBtn, ackBtn) {
|
||||||
|
fpBtn.disabled = true;
|
||||||
|
if (ackBtn) ackBtn.disabled = true;
|
||||||
|
fetch(B + '/events/' + encodeURIComponent(eventId) + '/false-positive', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'X-Csrf-Token': csrfToken || '' }
|
||||||
|
})
|
||||||
|
.then(function (res) {
|
||||||
|
if (!res.ok) throw new Error('fp_failed:' + res.status);
|
||||||
|
patchOlderAck(eventId, true);
|
||||||
|
loadFpStats(); // the panel should reflect the tag immediately, not in 60s
|
||||||
|
return loadEvents();
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
fpBtn.disabled = false;
|
||||||
|
if (ackBtn) ackBtn.disabled = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Status strip: count UNACKNOWLEDGED events per severity from the same array the
|
// Status strip: count UNACKNOWLEDGED events per severity from the same array the
|
||||||
// list renders. Stat tiles (not a chart): small integers a desk operator scans.
|
// list renders. Stat tiles (not a chart): small integers a desk operator scans.
|
||||||
// Every tier tile is always drawn (zero -> muted) so the strip never reflows.
|
// Every tier tile is always drawn (zero -> muted) so the strip never reflows.
|
||||||
|
|
@ -600,6 +645,51 @@
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- rail: FP-rate panel ----------------------------------------------------------
|
||||||
|
// Per-(camera, type) false-positive rates over 30 days. Refreshed on its own slow cadence
|
||||||
|
// (60s) — tag volume changes slowly — plus immediately after the operator tags one.
|
||||||
|
var fpStatsRoot = document.getElementById('fp-stats-root');
|
||||||
|
var FP_STATS_INTERVAL_MS = 60000;
|
||||||
|
var FP_STATS_SHOWN = 5;
|
||||||
|
|
||||||
|
function renderFpStats(stats) {
|
||||||
|
if (!fpStatsRoot) return;
|
||||||
|
fpStatsRoot.textContent = '';
|
||||||
|
if (!stats || stats.length === 0) {
|
||||||
|
fpStatsRoot.textContent = '오탐 태그가 없습니다.';
|
||||||
|
fpStatsRoot.className = 'fp-stats is-empty';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fpStatsRoot.className = 'fp-stats';
|
||||||
|
stats.slice(0, FP_STATS_SHOWN).forEach(function (s) {
|
||||||
|
var row = document.createElement('div');
|
||||||
|
row.className = 'fp-row';
|
||||||
|
var what = document.createElement('span');
|
||||||
|
what.className = 'fp-what';
|
||||||
|
var cam = document.createElement('span');
|
||||||
|
cam.className = 'fp-cam';
|
||||||
|
cam.textContent = s.camera_id;
|
||||||
|
what.appendChild(cam);
|
||||||
|
what.appendChild(document.createTextNode(' ' + (TYPE_LABELS[s.event_type] || s.event_type)));
|
||||||
|
row.appendChild(what);
|
||||||
|
var rate = document.createElement('span');
|
||||||
|
var pct = Math.round((s.fp / Math.max(1, s.total)) * 100);
|
||||||
|
rate.className = 'fp-rate' + (pct >= 30 ? ' is-hot' : '');
|
||||||
|
rate.textContent = pct + '% (' + s.fp + '/' + s.total + ')';
|
||||||
|
// 30%+ = 그 카메라·유형은 재튜닝 후보라는 뜻의 시각 신호 (텍스트가 이미 수치를 말한다).
|
||||||
|
row.appendChild(rate);
|
||||||
|
fpStatsRoot.appendChild(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadFpStats() {
|
||||||
|
if (!fpStatsRoot) return;
|
||||||
|
fetch(B + '/api/fp-stats?days=30', { headers: { Accept: 'application/json' } })
|
||||||
|
.then(function (res) { return res.ok ? res.json() : null; })
|
||||||
|
.then(function (data) { if (data) renderFpStats(data.stats); })
|
||||||
|
.catch(function () { /* 패널은 보조 정보 — 실패 시 마지막 상태 유지 */ });
|
||||||
|
}
|
||||||
|
|
||||||
document.addEventListener('click', unlockAudio);
|
document.addEventListener('click', unlockAudio);
|
||||||
|
|
||||||
// Filter bar (client-side) + "더 보기" (server-side cursor).
|
// Filter bar (client-side) + "더 보기" (server-side cursor).
|
||||||
|
|
@ -613,5 +703,7 @@
|
||||||
loadCsrfToken();
|
loadCsrfToken();
|
||||||
loadEvents();
|
loadEvents();
|
||||||
loadDevices();
|
loadDevices();
|
||||||
|
loadFpStats();
|
||||||
setInterval(function () { loadEvents(); loadDevices(); }, POLL_INTERVAL_MS);
|
setInterval(function () { loadEvents(); loadDevices(); }, POLL_INTERVAL_MS);
|
||||||
|
setInterval(loadFpStats, FP_STATS_INTERVAL_MS);
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -98,6 +98,7 @@
|
||||||
addRow('발생 시각', textNode(formatTime(ev.occurred_at)));
|
addRow('발생 시각', textNode(formatTime(ev.occurred_at)));
|
||||||
addRow('지속(초)', textNode(ev.duration_s));
|
addRow('지속(초)', textNode(ev.duration_s));
|
||||||
addRow('확인', textNode(ev.acknowledged_by || '미확인'));
|
addRow('확인', textNode(ev.acknowledged_by || '미확인'));
|
||||||
|
if (ev.false_positive_by) addRow('오탐', textNode(ev.false_positive_by + ' 태그'));
|
||||||
|
|
||||||
if (!snapBox) return;
|
if (!snapBox) return;
|
||||||
snapBox.textContent = '';
|
snapBox.textContent = '';
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,7 @@ use App\Ingest\IngestController;
|
||||||
use App\Api\{ConfigController, HeartbeatController};
|
use App\Api\{ConfigController, HeartbeatController};
|
||||||
use App\Auth\{AuthController, CsrfController};
|
use App\Auth\{AuthController, CsrfController};
|
||||||
use App\Dashboard\{EventListController, EventDetailController, SnapshotProxy, ZoneConfigController,
|
use App\Dashboard\{EventListController, EventDetailController, SnapshotProxy, ZoneConfigController,
|
||||||
DeviceStatusController, HeatmapController};
|
DeviceStatusController, HeatmapController, FpStatsController};
|
||||||
|
|
||||||
// Never leak internals to the client. Cafe24 shared hosting often ships display_errors=On, so
|
// 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
|
// an uncaught PDOException would otherwise render the DSN + db_user in a raw 500. Turn it off
|
||||||
|
|
@ -96,6 +96,7 @@ $proxy = new SnapshotProxy($snaps, $events, $sess);
|
||||||
$zoneCtl = new ZoneConfigController($zones, $devices, $audit, $sess, $snaps);
|
$zoneCtl = new ZoneConfigController($zones, $devices, $audit, $sess, $snaps);
|
||||||
$deviceStatus = new DeviceStatusController($devices, $sess);
|
$deviceStatus = new DeviceStatusController($devices, $sess);
|
||||||
$heatmap = new HeatmapController($events, $sess);
|
$heatmap = new HeatmapController($events, $sess);
|
||||||
|
$fpStats = new FpStatsController($events, $sess);
|
||||||
|
|
||||||
$render = function (string $view): Response {
|
$render = function (string $view): Response {
|
||||||
ob_start(); require APP_ROOT . '/src/View/' . $view . '.php';
|
ob_start(); require APP_ROOT . '/src/View/' . $view . '.php';
|
||||||
|
|
@ -113,8 +114,10 @@ $r->add('GET', '/api/events', fn(Request $q) => $list->json($q));
|
||||||
$r->add('GET', '/api/devices', fn(Request $q) => $deviceStatus->json($q, $now));
|
$r->add('GET', '/api/devices', fn(Request $q) => $deviceStatus->json($q, $now));
|
||||||
$r->add('GET', '/events/{id}', fn(Request $q, array $p) => $detail->show($q, $p));
|
$r->add('GET', '/events/{id}', fn(Request $q, array $p) => $detail->show($q, $p));
|
||||||
$r->add('POST', '/events/{id}/ack', fn(Request $q, array $p) => $detail->acknowledge($q, $p));
|
$r->add('POST', '/events/{id}/ack', fn(Request $q, array $p) => $detail->acknowledge($q, $p));
|
||||||
|
$r->add('POST', '/events/{id}/false-positive', fn(Request $q, array $p) => $detail->falsePositive($q, $p));
|
||||||
$r->add('GET', '/api/zones/{camera}', fn(Request $q, array $p) => $zoneCtl->list($q, $p));
|
$r->add('GET', '/api/zones/{camera}', fn(Request $q, array $p) => $zoneCtl->list($q, $p));
|
||||||
$r->add('GET', '/api/heatmap/{camera}', fn(Request $q, array $p) => $heatmap->json($q, $p));
|
$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));
|
||||||
// Background frame for the zone editor: the newest snapshot this camera produced.
|
// 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('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));
|
$r->add('POST', '/api/zones', fn(Request $q) => $zoneCtl->save($q));
|
||||||
|
|
|
||||||
|
|
@ -130,6 +130,8 @@ CREATE TABLE move_events (
|
||||||
spatial_y DOUBLE NULL, -- heatmap raw material; NULL for legacy/deviceless events
|
spatial_y DOUBLE NULL, -- heatmap raw material; NULL for legacy/deviceless events
|
||||||
acknowledged_by VARCHAR(190) NULL,
|
acknowledged_by VARCHAR(190) NULL,
|
||||||
acknowledged_at BIGINT UNSIGNED NULL,
|
acknowledged_at BIGINT UNSIGNED NULL,
|
||||||
|
false_positive_by VARCHAR(190) NULL, -- operator's 오탐 tag (per-camera FP-rate stats; no auto-tuning)
|
||||||
|
false_positive_at BIGINT UNSIGNED NULL,
|
||||||
received_at BIGINT UNSIGNED NOT NULL, -- server receipt time (PHP time()), for clock-skew flagging (design doc §8)
|
received_at BIGINT UNSIGNED NOT NULL, -- server receipt time (PHP time()), for clock-skew flagging (design doc §8)
|
||||||
PRIMARY KEY (event_id),
|
PRIMARY KEY (event_id),
|
||||||
-- Idempotency: a UNIQUE key on event_uuid makes a re-sent event (POST succeeded server-side
|
-- Idempotency: a UNIQUE key on event_uuid makes a re-sent event (POST succeeded server-side
|
||||||
|
|
|
||||||
|
|
@ -48,4 +48,24 @@ final class EventDetailController
|
||||||
$this->audit->append($user, 'acknowledge', "event:$id", [], []);
|
$this->audit->append($user, 'acknowledge', "event:$id", [], []);
|
||||||
return Response::json(['ok' => true], 200);
|
return Response::json(['ok' => true], 200);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** POST /events/{id}/false-positive — operator's 오탐 tag. Also acknowledges (an FP is a
|
||||||
|
* handled event; leaving it unacknowledged would keep it alarming). Feeds FP-rate stats
|
||||||
|
* only — by product decision it never auto-tunes edge thresholds. */
|
||||||
|
public function falsePositive(Request $req, array $params): Response
|
||||||
|
{
|
||||||
|
if ($this->sess->get('user_id') === null) return Response::json(['error' => 'unauthorized'], 401);
|
||||||
|
// Same permission as acknowledge: tagging FP is the same "I handled this" judgement call.
|
||||||
|
if (!Rbac::can((string)$this->sess->get('role'), 'acknowledge')) 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);
|
||||||
|
}
|
||||||
|
$id = (int)($params['id'] ?? 0);
|
||||||
|
$event = $this->events->find($id);
|
||||||
|
if ($event === null || $this->outOfScope($event)) return Response::json(['error' => 'not_found'], 404);
|
||||||
|
$user = (string)$this->sess->get('username');
|
||||||
|
if (!$this->events->markFalsePositive($id, $user)) return Response::json(['error' => 'not_found'], 404);
|
||||||
|
$this->audit->append($user, 'false_positive', "event:$id", [], []);
|
||||||
|
return Response::json(['ok' => true], 200);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
39
server/src/Dashboard/FpStatsController.php
Normal file
39
server/src/Dashboard/FpStatsController.php
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
namespace App\Dashboard;
|
||||||
|
|
||||||
|
use App\Http\{Request, Response};
|
||||||
|
use App\Auth\SessionStore;
|
||||||
|
use App\Repository\EventRepository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/fp-stats?days=N -> {stats: [{camera_id, event_type, total, fp}], days}
|
||||||
|
*
|
||||||
|
* Per-(camera, event type) false-positive rates for the console's FP panel. This is the
|
||||||
|
* whole feedback loop by design: a human reads the rate and re-tunes thresholds
|
||||||
|
* deliberately — the tag never adjusts edge sensitivity on its own.
|
||||||
|
*/
|
||||||
|
final class FpStatsController
|
||||||
|
{
|
||||||
|
private const DEFAULT_DAYS = 30;
|
||||||
|
private const MAX_DAYS = 90;
|
||||||
|
|
||||||
|
public function __construct(private EventRepository $events, private SessionStore $sess) {}
|
||||||
|
|
||||||
|
public function json(Request $req): Response
|
||||||
|
{
|
||||||
|
if ($this->sess->get('user_id') === null) return Response::json(['error' => 'unauthorized'], 401);
|
||||||
|
$days = self::clampDays($req->query('days'));
|
||||||
|
// Fail-closed site scoping, mirroring the event list.
|
||||||
|
$scope = $this->sess->get('site_scope');
|
||||||
|
$site = is_string($scope) ? $scope : null;
|
||||||
|
$stats = $this->events->fpStats((float)(time() - $days * 86400), $site);
|
||||||
|
return Response::json(['stats' => $stats, 'days' => $days], 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -26,4 +26,17 @@ interface EventRepository
|
||||||
* site-scope filter as page(). Rows: {x, y, severity, event_type, occurred_at}.
|
* site-scope filter as page(). Rows: {x, y, severity, event_type, occurred_at}.
|
||||||
*/
|
*/
|
||||||
public function heatmapPoints(string $cameraId, float $sinceOccurredAt, ?string $site, int $limit): array;
|
public function heatmapPoints(string $cameraId, float $sinceOccurredAt, ?string $site, int $limit): array;
|
||||||
|
/**
|
||||||
|
* Tag an event as a false positive AND acknowledge it (an FP is a handled event —
|
||||||
|
* leaving it unacknowledged would keep it alarming). Idempotent on the FP flag:
|
||||||
|
* the FIRST tagger is preserved. Returns false only when the event does not exist.
|
||||||
|
*/
|
||||||
|
public function markFalsePositive(int $id, string $user): bool;
|
||||||
|
/**
|
||||||
|
* FP-rate aggregation for the stats panel: per (camera_id, event_type) totals and
|
||||||
|
* false-positive counts since $sinceOccurredAt, only groups with at least one FP.
|
||||||
|
* $site applies the same fail-closed scope filter as page().
|
||||||
|
* Rows: {camera_id, event_type, total, fp}.
|
||||||
|
*/
|
||||||
|
public function fpStats(float $sinceOccurredAt, ?string $site): array;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,8 @@ final class PdoEventRepository implements EventRepository
|
||||||
{
|
{
|
||||||
private const SELECT_COLUMNS =
|
private const SELECT_COLUMNS =
|
||||||
'event_id, event_uuid, device_id, site, camera_id, event_type, severity, track_id, zone_id,
|
'event_id, event_uuid, device_id, site, camera_id, event_type, severity, track_id, zone_id,
|
||||||
occurred_at, duration_s, metrics_json, spatial_x, spatial_y, acknowledged_by, acknowledged_at';
|
occurred_at, duration_s, metrics_json, spatial_x, spatial_y, acknowledged_by, acknowledged_at,
|
||||||
|
false_positive_by, false_positive_at';
|
||||||
|
|
||||||
public function __construct(private PDO $pdo) {}
|
public function __construct(private PDO $pdo) {}
|
||||||
|
|
||||||
|
|
@ -160,6 +161,52 @@ final class PdoEventRepository implements EventRepository
|
||||||
], $st->fetchAll());
|
], $st->fetchAll());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function markFalsePositive(int $id, string $user): bool
|
||||||
|
{
|
||||||
|
// Same matched-vs-changed discipline as acknowledge(): existence checked separately,
|
||||||
|
// because a no-op UPDATE reports 0 affected rows on MySQL's default PDO settings.
|
||||||
|
$exists = $this->pdo->prepare('SELECT 1 FROM move_events WHERE event_id = ? LIMIT 1');
|
||||||
|
$exists->execute([$id]);
|
||||||
|
if ($exists->fetchColumn() === false) return false;
|
||||||
|
$now = time();
|
||||||
|
// COALESCE keeps the FIRST tagger/acknowledger — a second tag must not rewrite who
|
||||||
|
// made the original call (it is an audit-relevant judgement).
|
||||||
|
$st = $this->pdo->prepare(
|
||||||
|
'UPDATE move_events
|
||||||
|
SET false_positive_by = COALESCE(false_positive_by, ?),
|
||||||
|
false_positive_at = COALESCE(false_positive_at, ?),
|
||||||
|
acknowledged_by = COALESCE(acknowledged_by, ?),
|
||||||
|
acknowledged_at = COALESCE(acknowledged_at, ?)
|
||||||
|
WHERE event_id = ?'
|
||||||
|
);
|
||||||
|
$st->execute([$user, $now, $user, $now, $id]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function fpStats(float $sinceOccurredAt, ?string $site): array
|
||||||
|
{
|
||||||
|
$sql = 'SELECT camera_id, event_type, COUNT(*) AS total,
|
||||||
|
SUM(false_positive_by IS NOT NULL) AS fp
|
||||||
|
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 .= ' GROUP BY camera_id, event_type HAVING fp > 0 ORDER BY fp DESC';
|
||||||
|
$st = $this->pdo->prepare($sql);
|
||||||
|
$pos = 1;
|
||||||
|
foreach ($binds as [$val, $type]) $st->bindValue($pos++, $val, $type);
|
||||||
|
$st->execute();
|
||||||
|
return array_map(static fn ($r) => [
|
||||||
|
'camera_id' => $r['camera_id'],
|
||||||
|
'event_type' => $r['event_type'],
|
||||||
|
'total' => (int)$r['total'],
|
||||||
|
'fp' => (int)$r['fp'],
|
||||||
|
], $st->fetchAll());
|
||||||
|
}
|
||||||
|
|
||||||
private static function mapRow(array $row): array
|
private static function mapRow(array $row): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
|
@ -177,6 +224,8 @@ final class PdoEventRepository implements EventRepository
|
||||||
'metrics' => json_decode((string)($row['metrics_json'] ?? '[]'), true) ?? [],
|
'metrics' => json_decode((string)($row['metrics_json'] ?? '[]'), true) ?? [],
|
||||||
'spatial_x' => $row['spatial_x'] !== null ? (float)$row['spatial_x'] : null,
|
'spatial_x' => $row['spatial_x'] !== null ? (float)$row['spatial_x'] : null,
|
||||||
'spatial_y' => $row['spatial_y'] !== null ? (float)$row['spatial_y'] : null,
|
'spatial_y' => $row['spatial_y'] !== null ? (float)$row['spatial_y'] : null,
|
||||||
|
'false_positive_by' => $row['false_positive_by'] ?? null,
|
||||||
|
'false_positive_at' => ($row['false_positive_at'] ?? null) !== null ? (int)$row['false_positive_at'] : null,
|
||||||
'acknowledged_by' => $row['acknowledged_by'],
|
'acknowledged_by' => $row['acknowledged_by'],
|
||||||
'acknowledged_at' => $row['acknowledged_at'] !== null ? (int)$row['acknowledged_at'] : null,
|
'acknowledged_at' => $row['acknowledged_at'] !== null ? (int)$row['acknowledged_at'] : null,
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,12 @@ render_header('이벤트');
|
||||||
<label class="filter-check"><input type="checkbox" id="unacked-only"> 미확인만 보기</label>
|
<label class="filter-check"><input type="checkbox" id="unacked-only"> 미확인만 보기</label>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
<section class="rail-block">
|
||||||
|
<h2 class="rail-title">오탐율 · 30일</h2>
|
||||||
|
<!-- Read-only report: the 오탐 tags end here, as numbers a human reads before re-tuning
|
||||||
|
a camera — the tag never adjusts edge sensitivity on its own. -->
|
||||||
|
<div id="fp-stats-root" class="fp-stats" aria-label="카메라별 오탐율">집계 중…</div>
|
||||||
|
</section>
|
||||||
</aside>
|
</aside>
|
||||||
<section class="stream">
|
<section class="stream">
|
||||||
<header class="stream-head">
|
<header class="stream-head">
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,33 @@ final class DashboardTest extends TestCase
|
||||||
// acknowledge did not proceed
|
// acknowledge did not proceed
|
||||||
$this->assertNull($repo->find($id)['acknowledged_by']);
|
$this->assertNull($repo->find($id)['acknowledged_by']);
|
||||||
}
|
}
|
||||||
|
public function testFalsePositiveTagAcknowledgesAndKeepsFirstTagger(): void {
|
||||||
|
$repo = new InMemoryEventRepository();
|
||||||
|
$id = $repo->insert(['severity' => 'warning', 'event_type' => 'zone_warning']);
|
||||||
|
$req = new Request('POST', "/events/$id/false-positive", ['X-Csrf-Token' => 'tok'], '', [], []);
|
||||||
|
// viewer denied (same 'acknowledge' permission as ack)
|
||||||
|
$cv = new EventDetailController($repo, new InMemorySnapshotRepository(), new InMemoryAuditRepository(), $this->authed('viewer'));
|
||||||
|
$this->assertSame(403, $cv->falsePositive($req, ['id' => (string)$id])->status());
|
||||||
|
// operator tags: FP set AND acknowledged (an FP is a handled event)
|
||||||
|
$co = new EventDetailController($repo, new InMemorySnapshotRepository(), new InMemoryAuditRepository(), $this->authed('operator'));
|
||||||
|
$this->assertSame(200, $co->falsePositive($req, ['id' => (string)$id])->status());
|
||||||
|
$row = $repo->find($id);
|
||||||
|
$this->assertSame('u', $row['false_positive_by']);
|
||||||
|
$this->assertSame('u', $row['acknowledged_by']);
|
||||||
|
// a second tag by another user must NOT rewrite the original judgement
|
||||||
|
$s2 = $this->authed('operator'); $s2->set('username', 'u2');
|
||||||
|
$c2 = new EventDetailController($repo, new InMemorySnapshotRepository(), new InMemoryAuditRepository(), $s2);
|
||||||
|
$this->assertSame(200, $c2->falsePositive($req, ['id' => (string)$id])->status());
|
||||||
|
$this->assertSame('u', $repo->find($id)['false_positive_by']);
|
||||||
|
}
|
||||||
|
public function testFalsePositiveRejectsBadCsrf(): void {
|
||||||
|
$repo = new InMemoryEventRepository();
|
||||||
|
$id = $repo->insert(['severity' => 'warning', 'event_type' => 'zone_warning']);
|
||||||
|
$c = new EventDetailController($repo, new InMemorySnapshotRepository(), new InMemoryAuditRepository(), $this->authed('operator'));
|
||||||
|
$wrong = new Request('POST', "/events/$id/false-positive", ['X-Csrf-Token' => 'nope'], '', [], []);
|
||||||
|
$this->assertSame(403, $c->falsePositive($wrong, ['id' => (string)$id])->status());
|
||||||
|
$this->assertNull($repo->find($id)['false_positive_by'] ?? null);
|
||||||
|
}
|
||||||
public function testSnapshotProxyAuthAnd404(): void {
|
public function testSnapshotProxyAuthAnd404(): void {
|
||||||
$snaps = new InMemorySnapshotRepository();
|
$snaps = new InMemorySnapshotRepository();
|
||||||
$events = new InMemoryEventRepository();
|
$events = new InMemoryEventRepository();
|
||||||
|
|
|
||||||
62
server/tests/FpStatsTest.php
Normal file
62
server/tests/FpStatsTest.php
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
namespace App\Tests;
|
||||||
|
|
||||||
|
use App\Http\Request;
|
||||||
|
use App\Auth\ArraySessionStore;
|
||||||
|
use App\Dashboard\FpStatsController;
|
||||||
|
use App\Tests\Support\InMemoryEventRepository;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class FpStatsTest extends TestCase
|
||||||
|
{
|
||||||
|
private function sess(?string $site = null): ArraySessionStore
|
||||||
|
{
|
||||||
|
$s = new ArraySessionStore();
|
||||||
|
$s->set('user_id', 1);
|
||||||
|
if ($site !== null) $s->set('site_scope', $site);
|
||||||
|
return $s;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function repo(): InMemoryEventRepository
|
||||||
|
{
|
||||||
|
$r = new InMemoryEventRepository();
|
||||||
|
$now = (float)time();
|
||||||
|
// cam-1 zone_warning: 3 events, 2 FP-tagged
|
||||||
|
$a = $r->insert(['camera_id' => 'cam-1', 'event_type' => 'zone_warning', 'severity' => 'warning', 'site' => 'a', 'occurred_at' => $now]);
|
||||||
|
$b = $r->insert(['camera_id' => 'cam-1', 'event_type' => 'zone_warning', 'severity' => 'warning', 'site' => 'a', 'occurred_at' => $now]);
|
||||||
|
$r->insert(['camera_id' => 'cam-1', 'event_type' => 'zone_warning', 'severity' => 'warning', 'site' => 'a', 'occurred_at' => $now]);
|
||||||
|
$r->markFalsePositive($a, 'u');
|
||||||
|
$r->markFalsePositive($b, 'u');
|
||||||
|
// cam-2 fall on another site: 1 event, 1 FP
|
||||||
|
$c = $r->insert(['camera_id' => 'cam-2', 'event_type' => 'fall', 'severity' => 'high', 'site' => 'b', 'occurred_at' => $now]);
|
||||||
|
$r->markFalsePositive($c, 'u');
|
||||||
|
// cam-3: events but no FP tags -> not in the report
|
||||||
|
$r->insert(['camera_id' => 'cam-3', 'event_type' => 'fall', 'severity' => 'high', 'site' => 'a', 'occurred_at' => $now]);
|
||||||
|
return $r;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRequiresLogin(): void
|
||||||
|
{
|
||||||
|
$c = new FpStatsController($this->repo(), new ArraySessionStore());
|
||||||
|
$this->assertSame(401, $c->json(new Request('GET', '/api/fp-stats', [], '', [], []))->status());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAggregatesPerCameraAndType(): void
|
||||||
|
{
|
||||||
|
$c = new FpStatsController($this->repo(), $this->sess());
|
||||||
|
$data = json_decode($c->json(new Request('GET', '/api/fp-stats', [], '', [], []))->body(), true);
|
||||||
|
$this->assertSame(30, $data['days']);
|
||||||
|
$this->assertCount(2, $data['stats']); // cam-3 (no FP) excluded
|
||||||
|
$this->assertSame(['camera_id' => 'cam-1', 'event_type' => 'zone_warning', 'total' => 3, 'fp' => 2],
|
||||||
|
$data['stats'][0]); // sorted by fp desc
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSiteScopeFiltersStats(): void
|
||||||
|
{
|
||||||
|
$c = new FpStatsController($this->repo(), $this->sess('a'));
|
||||||
|
$data = json_decode($c->json(new Request('GET', '/api/fp-stats', [], '', [], []))->body(), true);
|
||||||
|
$this->assertCount(1, $data['stats']);
|
||||||
|
$this->assertSame('cam-1', $data['stats'][0]['camera_id']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -170,6 +170,8 @@ final class PdoIntegrationTest extends TestCase
|
||||||
spatial_y REAL,
|
spatial_y REAL,
|
||||||
acknowledged_by TEXT,
|
acknowledged_by TEXT,
|
||||||
acknowledged_at INTEGER,
|
acknowledged_at INTEGER,
|
||||||
|
false_positive_by TEXT,
|
||||||
|
false_positive_at INTEGER,
|
||||||
received_at INTEGER NOT NULL
|
received_at INTEGER NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,29 @@ final class InMemoryEventRepository implements EventRepository {
|
||||||
}
|
}
|
||||||
public function find(int $id): ?array { return $this->rows[$id] ?? null; }
|
public function find(int $id): ?array { return $this->rows[$id] ?? null; }
|
||||||
public function acknowledge(int $id, string $u): bool { if (!isset($this->rows[$id])) return false; $this->rows[$id]['acknowledged_by'] = $u; return true; }
|
public function acknowledge(int $id, string $u): bool { if (!isset($this->rows[$id])) return false; $this->rows[$id]['acknowledged_by'] = $u; return true; }
|
||||||
|
public function markFalsePositive(int $id, string $user): bool {
|
||||||
|
if (!isset($this->rows[$id])) return false;
|
||||||
|
$r = $this->rows[$id];
|
||||||
|
$r['false_positive_by'] = $r['false_positive_by'] ?? $user;
|
||||||
|
$r['false_positive_at'] = $r['false_positive_at'] ?? time();
|
||||||
|
$r['acknowledged_by'] = ($r['acknowledged_by'] ?? null) !== null ? $r['acknowledged_by'] : $user;
|
||||||
|
$this->rows[$id] = $r;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
public function fpStats(float $since, ?string $site): array {
|
||||||
|
$groups = [];
|
||||||
|
foreach ($this->rows as $r) {
|
||||||
|
if (($r['occurred_at'] ?? 0) < $since) continue;
|
||||||
|
if ($site !== null && ($r['site'] ?? null) !== $site) continue;
|
||||||
|
$key = ($r['camera_id'] ?? '') . '|' . ($r['event_type'] ?? '');
|
||||||
|
$groups[$key] = $groups[$key] ?? ['camera_id' => $r['camera_id'], 'event_type' => $r['event_type'], 'total' => 0, 'fp' => 0];
|
||||||
|
$groups[$key]['total']++;
|
||||||
|
if (($r['false_positive_by'] ?? null) !== null) $groups[$key]['fp']++;
|
||||||
|
}
|
||||||
|
$out = array_values(array_filter($groups, fn($g) => $g['fp'] > 0));
|
||||||
|
usort($out, fn($a, $b) => $b['fp'] <=> $a['fp']);
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
public function heatmapPoints(string $cameraId, float $since, ?string $site, int $limit): array {
|
public function heatmapPoints(string $cameraId, float $since, ?string $site, int $limit): array {
|
||||||
$r = array_filter($this->rows, fn($x) => ($x['camera_id'] ?? '') === $cameraId
|
$r = array_filter($this->rows, fn($x) => ($x['camera_id'] ?? '') === $cameraId
|
||||||
&& ($x['occurred_at'] ?? 0) >= $since
|
&& ($x['occurred_at'] ?? 0) >= $since
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue