AI CCTV 목적을 원론부터 재점검한 결과. 셋 다 새 추론 모델 없이 지금 있는 자세추정·구역·트랙 이력만으로 구현했다. ■ 추락 (fall_from_height) 잡고 있던 것은 같은 높이의 '넘어짐'이었고, 사망 1위 유형인 '떨어짐'은 못 잡았다. GhostIncidentDetector 가 트랙이 이미 위험 자세일 때만 무장하므로 (_DANGEROUS), 발판에 서 있다가 떨어지는 사람은 소실 순간 STANDING 이라 아무것도 안 걸렸다. 판정의 핵심은 bbox 중심이 아니라 하단(y2)이다. 쭈그려 앉으면 중심은 내려가도 발은 그 자리이고, 추락은 발 밑선이 통째로 내려간다 — 중심으로 재면 쭈그려 앉아 작업하는 사람이 전부 추락이 된다. 원근 접근은 y2 도 내려가므로 높이 증가 가드로 따로 막는다. Track.frames 가 90프레임을 들고 있어 별도 버퍼가 없다. 발보는 두 갈래로 나뉘되 한 사고는 한 건이다: 프레임 안에서 넘어짐이 확정되면 유형을 승격하고, 화각 밖으로 사라지면 ghost 무장을 넓혀 그쪽으로 낸다. 심각도 CRITICAL — 척추·두부 손상 주의가 통보에 실리려면 넘어짐(HIGH)과 등급이 갈라져야 한다. 고소 구역은 rules_json 의 elevated 키로 붙어 스키마 변경이 없고, 구역이 없어도 하강 신호만으로 동작한다. ■ 동시 다발 쓰러짐 (multi_down) 같은 구역에서 짧은 시간에 둘이 쓰러진 것은 넘어짐 두 건이 아니라 질식· 가스·폭발이다. 이 기능의 본체는 감지가 아니라 통보 문구다 — 밀폐공간 사망자의 상당수가 구조하러 들어간 사람이므로, 링크를 열기 전에 메일 본문에서 "공기호흡기 없이 진입하지 마십시오"가 읽혀야 한다. Channels::guidance 로 유형별 안내를 붙였고 문구를 테스트로 고정했다(문구가 사라지면 기능이 사라진 것과 같다). 서버에 둔 이유: 가스는 카메라 화각을 존중하지 않아 노드를 넘는 판단이 필요하고, 현장 즉시 대응은 첫 쓰러짐의 경광등이 이미 확보한다. 오탐 방어의 핵심은 근접성 — 넓은 공장에서 2분 안의 무관한 넘어짐 두 건은 실제로 일어나므로 같은 구역이거나 좌표가 가까워야 묶는다. 한 사람이 두 번 넘어진 것은 한 명으로 센다. ■ 구역 프리셋 6종 + max_workers:0 수정 밀폐공간 감시에 필요한 것(제한구역+2인1조+체류상한)은 이미 전부 있었는데 화면이 "최소 인원을 넣으세요"라고만 물어 아무도 그렇게 쓰지 못했다. 안전관리자는 min_workers 가 아니라 밀폐공간을 생각한다. 그 과정에서 잠복 결함 하나: max_workers:0 이 falsy 로 걸러져 "아무도 있으면 안 되는 구역"(크레인 짐 아래·굴착기 선회 반경 = 깔림 사고의 전형)이 통째로 꺼져 있었다. 운영자가 저장해 놓고도 아무 일이 안 일어났다. 감지기·입력란 (min=1)·저장 직렬화(>0)·목록 표시 네 곳에서 막혀 있었다. ■ 검증 pytest 322 passed / phpunit 263 passed. 신규 테스트 30건. 이벤트 유형 2종 추가는 test_event_type_parity 가 강제하는 5곳(엣지 enum · EventValidator · schema CHECK · 프런트 라벨 5파일 · 필터 옵션) 전부 동기화했고 기존 설치용 마이그레이션을 추가했다. ■ Pi 번들 1.1.0 과 달리 이번에는 edge/ 가 바뀌었으므로 Pi 번들 재빌드가 필수다. 낡은 1.0.0 번들은 dist/ 에서 제거했다 — 두면 추락 감지가 빠진 노드가 그대로 납품된다. arm64 + 네트워크 빌드 머신에서 재빌드해야 Pi 납품이 가능하다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EBjuR7DW6PNNMows4KnbYU
240 lines
11 KiB
JavaScript
240 lines
11 KiB
JavaScript
/**
|
||
* 사고조사 패키지.
|
||
*
|
||
* 사고가 난 다음 날 요구되는 것은 "그 사건 하나"가 아니라 그 사건의 맥락 전부다:
|
||
* 무슨 일이 있었는지, 그 전후에 같은 카메라가 무엇을 봤는지, 누구에게 언제 알려졌는지,
|
||
* 이미 어떤 조치가 있었는지, 그 위험요인을 무엇으로 통제하고 있었는지. 손으로 모으면
|
||
* 반나절이고, 사고 직후에 반나절을 쓸 수 있는 사람은 없다.
|
||
*
|
||
* 인쇄 CSS 가 앱 크롬을 걷어내므로 이 화면 그대로 PDF 로 저장해 제출할 수 있다.
|
||
*
|
||
* XSS-safe: 모든 동적 값은 textContent.
|
||
*/
|
||
(function () {
|
||
'use strict';
|
||
|
||
var B = window.APP_BASE || '';
|
||
var root = document.getElementById('package-root');
|
||
if (!root) return;
|
||
|
||
var m = location.pathname.match(/\/events\/(\d+)\/package/);
|
||
if (!m) { root.textContent = '잘못된 주소입니다.'; return; }
|
||
var id = m[1];
|
||
|
||
var TYPE_LABELS = {
|
||
fall: '넘어짐', fall_from_height: '추락', no_motion: '넘어짐 후 미동 없음', immobility: '장시간 부동',
|
||
ghost: '위험 상태 소실', zone_warning: '경고구역 진입', zone_restricted: '제한구역 침입',
|
||
pinch: '협착구역 침입', monitoring_degraded: '감시 성능 저하', device_fault: '장치 이상',
|
||
lone_worker: '위험구역 단독 작업', overcapacity: '정원 초과', overstay: '체류 상한 초과',
|
||
near_fall: '넘어질 뻔함 (니어미스)', ergo_report: '작업자세 부담 리포트',
|
||
camera_moved: '카메라 화각 변경', camera_obstructed: '카메라 가림', low_light: '조도 저하',
|
||
unacked_escalation: '미확인 경보 에스컬레이션', multi_down: '동시 다발 쓰러짐',
|
||
ppe_missing: '보호구 미착용', proximity: '중장비 근접', fire_smoke: '화재·연기'
|
||
};
|
||
var SEV_LABELS = { critical: '위급', high: '높음', warning: '경고', info: '정보' };
|
||
var ALERT_STATUS = {
|
||
sent: '발송됨', pending: '발송 대기', failed: '발송 실패', suppressed: '억제됨 (정비 중)'
|
||
};
|
||
|
||
function el(tag, cls, text) {
|
||
var n = document.createElement(tag);
|
||
if (cls) n.className = cls;
|
||
if (text !== undefined && text !== null) n.textContent = text;
|
||
return n;
|
||
}
|
||
function pad2(n) { return n < 10 ? '0' + n : String(n); }
|
||
function stamp(ts) {
|
||
var d = new Date(Number(ts) * 1000);
|
||
if (isNaN(d.getTime())) return '-';
|
||
return d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate())
|
||
+ ' ' + pad2(d.getHours()) + ':' + pad2(d.getMinutes()) + ':' + pad2(d.getSeconds());
|
||
}
|
||
function clockOnly(ts) {
|
||
var d = new Date(Number(ts) * 1000);
|
||
return pad2(d.getHours()) + ':' + pad2(d.getMinutes()) + ':' + pad2(d.getSeconds());
|
||
}
|
||
|
||
function section(title) {
|
||
var s = el('section', 'pkg-section');
|
||
s.appendChild(el('h2', 'pkg-h2', title));
|
||
return s;
|
||
}
|
||
function kv(parent, label, value) {
|
||
var row = el('div', 'pkg-kv');
|
||
row.appendChild(el('dt', null, label));
|
||
row.appendChild(el('dd', null, value === null || value === undefined || value === '' ? '-' : String(value)));
|
||
parent.appendChild(row);
|
||
}
|
||
|
||
function render(d) {
|
||
var ev = d.event || {};
|
||
root.textContent = '';
|
||
|
||
// ---- 표지 -----------------------------------------------------------------
|
||
var head = el('header', 'pkg-head');
|
||
head.appendChild(el('p', 'pkg-doc-kind', '작업장 안전 사고조사 패키지'));
|
||
head.appendChild(el('h1', 'pkg-title', TYPE_LABELS[ev.event_type] || ev.event_type));
|
||
var sub = el('p', 'pkg-sub');
|
||
sub.textContent = [SEV_LABELS[ev.severity] || ev.severity, DeviceScope.label(ev.camera_id), stamp(ev.occurred_at)]
|
||
.filter(Boolean).join(' · ');
|
||
head.appendChild(sub);
|
||
root.appendChild(head);
|
||
|
||
// ---- 사건 개요 -------------------------------------------------------------
|
||
var facts = section('사건 개요');
|
||
var dl = el('dl', 'pkg-facts');
|
||
kv(dl, '사건 번호', ev.event_id);
|
||
kv(dl, '유형', TYPE_LABELS[ev.event_type] || ev.event_type);
|
||
kv(dl, '심각도', SEV_LABELS[ev.severity] || ev.severity);
|
||
kv(dl, '장치', ev.device_id);
|
||
kv(dl, '카메라', DeviceScope.label(ev.camera_id));
|
||
kv(dl, '현장', ev.site);
|
||
kv(dl, '발생 시각', stamp(ev.occurred_at));
|
||
kv(dl, '지속', (ev.duration_s || 0) + '초');
|
||
kv(dl, '처리 상태', ev.false_positive_by ? ('오탐 판정 · ' + ev.false_positive_by)
|
||
: (ev.acknowledged_by ? ('확인 · ' + ev.acknowledged_by + ' · ' + stamp(ev.acknowledged_at))
|
||
: '미확인'));
|
||
// 대응 시간은 조사에서 가장 먼저 묻는 숫자다.
|
||
if (ev.acknowledged_at) {
|
||
var tta = Math.max(0, Math.round(ev.acknowledged_at - ev.occurred_at));
|
||
kv(dl, '대응 소요', tta < 60 ? tta + '초' : Math.floor(tta / 60) + '분 ' + (tta % 60) + '초');
|
||
}
|
||
facts.appendChild(dl);
|
||
root.appendChild(facts);
|
||
|
||
// ---- 증거 프레임 -----------------------------------------------------------
|
||
var evidence = section('증거 프레임');
|
||
var snaps = (d.snapshots || []).filter(function (s) { return s && s.file_uuid; });
|
||
if (!snaps.length) {
|
||
evidence.appendChild(el('p', 'pkg-empty',
|
||
'저장된 스냅샷이 없습니다 (미촬영이거나 개인정보 보존기간이 지나 삭제됨).'));
|
||
} else {
|
||
var strip = el('div', 'pkg-frames');
|
||
var CAPTIONS = ['이전', '직전', '발생 시점'];
|
||
snaps.forEach(function (s, i) {
|
||
var fig = el('figure', 'pkg-frame');
|
||
var img = document.createElement('img');
|
||
img.src = B + '/snap/' + encodeURIComponent(s.file_uuid);
|
||
img.alt = '사고 스냅샷';
|
||
img.loading = 'lazy';
|
||
fig.appendChild(img);
|
||
fig.appendChild(el('figcaption', null,
|
||
snaps.length > 1 ? CAPTIONS[Math.max(0, CAPTIONS.length - snaps.length + i)] : '발생 시점'));
|
||
strip.appendChild(fig);
|
||
});
|
||
evidence.appendChild(strip);
|
||
evidence.appendChild(el('p', 'pkg-note',
|
||
'얼굴은 개인정보 보호를 위해 촬영 시점에 블러 처리되어 저장됩니다. 프레임 간격은 엣지의 스냅샷 주기 설정을 따릅니다.'));
|
||
}
|
||
root.appendChild(evidence);
|
||
|
||
// ---- 전후 타임라인 ---------------------------------------------------------
|
||
var win = Math.round((d.context_window_s || 1800) / 60);
|
||
var tl = section('같은 카메라의 전후 ' + win + '분');
|
||
var rows = d.timeline || [];
|
||
if (!rows.length) {
|
||
tl.appendChild(el('p', 'pkg-empty', '해당 구간에 다른 감지가 없습니다.'));
|
||
} else {
|
||
var list = el('ol', 'pkg-timeline');
|
||
rows.forEach(function (r) {
|
||
var li = el('li', 'pkg-tl-row' + (r.event_id === ev.event_id ? ' is-subject' : ''));
|
||
li.appendChild(el('span', 'pkg-tl-time', clockOnly(r.occurred_at)));
|
||
li.appendChild(el('span', 'pkg-tl-sev sev-' + r.severity, SEV_LABELS[r.severity] || r.severity));
|
||
li.appendChild(el('span', 'pkg-tl-type', TYPE_LABELS[r.event_type] || r.event_type));
|
||
li.appendChild(el('span', 'pkg-tl-state',
|
||
r.false_positive_by ? '오탐' : (r.acknowledged_by ? '확인됨' : '미확인')));
|
||
list.appendChild(li);
|
||
});
|
||
tl.appendChild(list);
|
||
}
|
||
root.appendChild(tl);
|
||
|
||
// ---- 통보 이력 -------------------------------------------------------------
|
||
// 시스템이 "봤다"는 것만 증명하고 사람에게 "닿았는가"를 비워두면 조사의 절반이 빈다.
|
||
var notif = section('통보 이력');
|
||
var alerts = d.notifications || [];
|
||
if (!alerts.length) {
|
||
notif.appendChild(el('p', 'pkg-empty',
|
||
'이 사건에 대한 통보 기록이 없습니다. 통보 경로가 설정되지 않았거나, 심각도가 발송 기준에 못 미쳤습니다.'));
|
||
} else {
|
||
var nt = el('ul', 'pkg-alerts');
|
||
alerts.forEach(function (a) {
|
||
var li = el('li', 'pkg-alert is-' + a.status);
|
||
li.appendChild(el('span', 'pkg-alert-ch', (a.channel_name || '삭제된 채널')));
|
||
li.appendChild(el('span', 'pkg-alert-status', ALERT_STATUS[a.status] || a.status));
|
||
li.appendChild(el('span', 'pkg-alert-when',
|
||
a.sent_at ? stamp(a.sent_at) : ('접수 ' + stamp(a.queued_at))));
|
||
if (a.last_error) li.appendChild(el('span', 'pkg-alert-err', a.last_error));
|
||
nt.appendChild(li);
|
||
});
|
||
notif.appendChild(nt);
|
||
}
|
||
root.appendChild(notif);
|
||
|
||
// ---- 위험성평가 -------------------------------------------------------------
|
||
var risk = section('해당 위험요인 · 통제 대책');
|
||
if (!d.risk_control) {
|
||
risk.appendChild(el('p', 'pkg-empty',
|
||
'이 감지 유형에 등록된 위험요인이 없습니다. [안전 관리] 화면에서 등록하면 다음 조사부터 함께 실립니다.'));
|
||
} else {
|
||
var rc = d.risk_control;
|
||
var rdl = el('dl', 'pkg-facts');
|
||
kv(rdl, '위험요인', rc.hazard);
|
||
kv(rdl, '현재 대책', rc.control);
|
||
kv(rdl, '위험도', rc.risk_score + ' (강도 ' + rc.severity_rating + ' × 빈도 ' + rc.likelihood_rating + ')');
|
||
kv(rdl, '최종 검토', rc.reviewed_by ? (rc.reviewed_by + ' · ' + stamp(rc.reviewed_at)) : '-');
|
||
risk.appendChild(rdl);
|
||
}
|
||
root.appendChild(risk);
|
||
|
||
// ---- 조치 이력 -------------------------------------------------------------
|
||
var acts = section('이 카메라의 개선 조치 이력');
|
||
var actions = d.actions || [];
|
||
if (!actions.length) {
|
||
acts.appendChild(el('p', 'pkg-empty', '기록된 조치가 없습니다.'));
|
||
} else {
|
||
var al = el('ul', 'pkg-actions');
|
||
actions.forEach(function (a) {
|
||
var li = el('li', null);
|
||
li.appendChild(el('span', 'pkg-act-date', stamp(a.taken_at).slice(0, 10)));
|
||
li.appendChild(el('span', 'pkg-act-note', a.note));
|
||
li.appendChild(el('span', 'pkg-act-by', a.created_by));
|
||
al.appendChild(li);
|
||
});
|
||
acts.appendChild(al);
|
||
}
|
||
root.appendChild(acts);
|
||
|
||
// ---- 서명 -----------------------------------------------------------------
|
||
var sign = section('확인');
|
||
var grid = el('div', 'pkg-sign');
|
||
['조사자', '안전관리자', '사업주 · 대리인'].forEach(function (role) {
|
||
var box = el('div', 'pkg-sign-box');
|
||
box.appendChild(el('span', 'pkg-sign-role', role));
|
||
box.appendChild(el('span', 'pkg-sign-line', ''));
|
||
grid.appendChild(box);
|
||
});
|
||
sign.appendChild(grid);
|
||
root.appendChild(sign);
|
||
|
||
var foot = el('footer', 'pkg-foot');
|
||
foot.textContent = '생성 ' + stamp(d.generated_at) + ' · 작성 ' + (d.generated_by || '-')
|
||
+ ' · 본 문서는 AI 감지 기록에 근거한 보조 자료이며 공식 재해조사 결과를 대체하지 않습니다.';
|
||
root.appendChild(foot);
|
||
|
||
document.title = '사고조사 #' + ev.event_id + ' · AI SAFETY CCTV';
|
||
}
|
||
|
||
var printBtn = document.getElementById('package-print');
|
||
if (printBtn) printBtn.addEventListener('click', function () { window.print(); });
|
||
|
||
fetch(B + '/api/events/' + encodeURIComponent(id) + '/package', { headers: { Accept: 'application/json' } })
|
||
.then(function (r) {
|
||
if (r.status === 401) { window.location.href = B + '/login'; return null; }
|
||
if (r.status === 404) { root.textContent = '해당 사건을 찾을 수 없습니다.'; return null; }
|
||
if (!r.ok) throw new Error('package_failed');
|
||
return r.json();
|
||
})
|
||
.then(function (d) { if (d) render(d); })
|
||
.catch(function () { root.textContent = '패키지를 불러오지 못했습니다.'; });
|
||
})();
|