Commit graph

13 commits

Author SHA1 Message Date
유창욱
3aca674a78 feat: 이해관계자 페르소나 검증 4건 — 열람 감사 · 증거 보존 · 크론 생존 · 증거 정책 (1.3.0)
Some checks failed
verify / edge (push) Has been cancelled
verify / server (push) Has been cancelled
앞선 감사 네 번은 "이 코드가 제대로 도는가"를 물었다. 이번엔 작업자·노조·안전관리자·
공장장·설치기사 5인 관점으로 3단논법을 세우고 전제를 코드로 확인한 뒤 반문으로 깼다.
5인 중 2인의 논증이 완전히, 2인이 부분적으로 무너졌다.

읽기 감사 — 이 시스템은 쓰기를 전부 감사했다. 임계값 한 번 바꾼 것까지 해시 체인에
들어간다. 그런데 작업자 사진을 열어보는 것은 하나도 남지 않았다. 감시 시스템과 안전
시스템을 가르는 선이 정확히 거기라서, 나머지 프라이버시 설계(RAM 링버퍼 7.5초·전송 전
얼굴 블러·라이브뷰 부재)가 전부 이 한 줄이 없어서 검증 불가능한 주장에 머물러 있었다.
거부된 열람은 기록하지 않는다 — 그러면 감사 로그가 "본 사람"이 아니라 "요청한 사람"
목록이 되어 정작 누가 봤는지를 묻는 데 못 쓴다.

크론 생존 — 죽은 노드는 스스로 신고할 수 없어서 크론이 하트비트의 부재로 판정한다.
그런데 그 크론이 멈추면 아무도 판정하지 않는다. 통보·5분 확대·장치 이상 발행이 전부 그
한 프로세스에 걸려 있어서, 멈춘 순간부터 화면은 조용해지고 그 침묵은 "사고 없음"과
구별되지 않는다. 감시자의 생존은 감시자가 아닌 쪽이 읽어야 한다.

증거 보존 — 스냅샷 삭제가 나이 하나만 봤다. 기본 7일인데 중대재해 조사는 몇 주에서 몇
달이라, 우리가 내건 목적("사고 후 원인 분석")이 8일째에 스스로 사라졌다. 확인 처리로
자동 연장하지 않는 이유는 자동이면 사실상 전량 보존이 되어 7일 약속이 무너지기 때문이다.

증거 정책 — 프레임이 ergo_report 하나만 빼고 모든 이벤트에 붙었다. 체류 상한 초과도
포함이라, 밀폐공간 프리셋 기본값(30분) 기준으로 정상 작업자가 규정대로 일하는 동안
주기적으로 본인 사진이 서버로 갔다. 위험해서가 아니라 오래 있어서. 기준을 명문화했다:
시간 경과 자체가 조건인 이벤트에는 사진을 붙이지 않는다.

마이그레이션 2026-09-25-evidence-hold-and-view-audit.sql (README-UPLOAD 10번).

검증: phpunit 302 passed(신규 18, 스킵 0) · pytest(신규 18)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EBjuR7DW6PNNMows4KnbYU
2026-09-25 18:34:26 +09:00
유창욱
6b6f29c447 feat: 납품 잔여 3건 해소 (Pi 번들 · MoveNet 폴백 · 자격증명 회전)
Pi 번들 — arm64 빌드 머신이 필요하다고 적어 둔 것은 과한 가정이었다.
--cross 모드에서 pip 이 태그로 aarch64/cp311 휠을 받으므로 네트워크만
있으면 된다. 휠 11개 전부 aarch64. "Pi 번들 미빌드"로 스킵되던 테스트가
이제 실제로 돈다.

MoveNet — P3-3 의 잔여였던 죽은 원본을 유효한 tf2onnx 변환본으로 교체하고
sha256+size 로 고정했다. 고정 전에 어댑터 계약과 대조했다: 입력
[1,192,192,3] int32 NHWC, 출력 [1,1,17,3]. 레이아웃이 다른 변환본은
onnxruntime 에 그냥 로드되고 좌표만 틀리는 실패라, 파일이 바뀌었는지가
아니라 계약이 맞는지를 테스트로 박았다.

자격증명 회전 — 손으로 적던 SQL 절차를 명령 하나로 바꿨다. DB 에 접속하지
않고 붙여넣을 SQL 만 낸다(운영자의 창구가 phpMyAdmin 이므로 접속 정보 없이
어느 머신에서나 돌아야 한다). 비밀번호는 STDIN 으로만 받는다 — 명령행은
ps 와 셸 히스토리에 남는다. 테스트가 잠그는 것은 서버 키와 Pi 키의 일치,
그리고 앱이 그 해시를 검증하는지 두 가지다. 회전 실행 자체는 운영 계정
조작이라 저장소에서 수행하지 않는다.

검증: phpunit 276 passed(스킵 0, 신규 7) · pytest 스킵 2(환경)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EBjuR7DW6PNNMows4KnbYU
2026-09-21 10:47:16 +09:00
유창욱
34506e150d docs: 설계 도해 3종 (구성 · 낙상 시퀀스 · 낙상 상태기계)
archify 소스 JSON 만 커밋한다. HTML 은 생성물이라 사용설명서와 같은 이유로
gitignore — 소스가 바뀐 뒤에도 낡은 사본이 최신처럼 보이기 때문.

시퀀스를 그리면서 드러난 것: 경광등은 확정 즉시(3초)지만 메일·웹훅은
크론 주기를 기다려 최대 63초다. 문서가 둘을 "통보"로 묶어 쓰던 표현이
실제로는 20배 차이나는 두 경로였다.

상태기계는 확정 창 3초와 추락 연결 창 3초가 겹쳐 있는 구조를 한 장에
담았다 — 4차 감사에서 잡은 CRITICAL(추락 승격 미발화)의 원인이고,
시간축을 그렸다면 코드를 읽기 전에 보였을 충돌이다.

검증: deliver 9/9 · 오류 0 · 경고 0, visual-check 1440x900 / 1600x1000 /
1920x1080 / 2048x1320 양 테마 containment·가독성 통과.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EBjuR7DW6PNNMows4KnbYU
2026-09-18 22:59:17 +09:00
유창욱
93f896af87 fix(audit): 1.2.0 4차 재감사 — 확정 23건 전량 해소
4개 관점 병렬 감사 후 각 발견을 반증 시도해 확정. 반증 5건은 문서에 남겼다.

이번 감사의 요지: **구현도 단위테스트도 초록인데 납품 경로에서 동작하지 않는**
결함이 셋 있었다. 전부 같은 모양이다 — 테스트가 기본값이 아닌 설정을 썼거나,
값이 존재해도 되는지 결정하는 상류 허용 목록을 보지 않았다.

CRITICAL 4건:

R4-1 추락 승격이 기본 설정에서 한 번도 발화하지 않았다. link_s(3.0)를 now
기준으로 쟀는데 넘어짐은 prone_confirm_s(3.0) 지속 후 확정되므로 확정 시점에는
항상 3초 넘게 지나 있다. 다른 테스트가 전부 prone_confirm_s=0.5 를 써서
초록이었다. 착지(prone 시작) 시각과 비교하도록 고치고, 기본값 그대로 도는
회귀 테스트를 넣었다.

R4-2 max_workers:0 이 상류 두 곳에서 막혀 있었다 — 서버 규칙 경계가 [1,50]
이라 저장이 400 이고, 엣지 파서가 v<=0 으로 규칙 전체를 무효화해 같이 넣은
2인1조까지 사라졌다. 앞 커밋에서 고친 네 곳은 전부 이 둘보다 하류라 도달
불가였다.

R4-3 고소작업 프리셋(elevated)이 완전히 무동작이었다. 서버 경계에 키가 없어
400, 통과해도 엣지 파서가 침묵 삭제. 프리셋 안내문과 커밋 메시지가 시스템이
하지 않는 일을 약속하고 있었다.

R4-4 동시 다발이 한 사람을 두 명으로 셀 수 있었다. 트래커는 1초 가려짐에
트랙을 버리고 새 id 를 주고(max_age=1.0) 누운 사람은 새 트랙에서도 즉시
prone 이라 두 번째 fall 이 난다. 그러면 다친 한 사람에게 "공기호흡기 없이
진입 금지"가 나가 구조가 늦어진다 — 기능의 가치가 정반대로 뒤집힌다.
ghost 를 인원 집계에서 빼고, 거의 같은 자리의 사건은 같은 사람으로 본다.

HIGH 5건: 구역 그룹핑 사문화(엣지가 zone_id 를 안 실어 카메라가 다른 두
희생자가 절대 안 묶였다 — 밀폐공간이 정확히 그 상황), 창 미끄러짐으로 한
사고가 매 분 새 위급 건으로 쏟아지고 인원이 줄면 앞선 알림을 스스로 뒤집던
문제, 무장 미만료·forget 누락·이중 발보, 업그레이드 안내의 마이그레이션 2개
누락(신규 유형 INSERT 가 전부 DB CHECK 에 걸린다), BUILD-INFO 커밋 스탬프.

BUILD-INFO 는 1.1.0 에서 고친 R3-6 의 재발이다. 절차로 막으려다 두 번
실패했으므로 HEAD 일치를 검사하는 테스트로 막았다. README 마이그레이션
목록도 같은 이유로 테스트를 붙였다.

MEDIUM 14건 중 눈에 띄는 것: 정비 억제가 SCBA 경고를 삼켰고(허가가 발급된
그 구역·그 시간이 질식이 일어나는 자리다) 위급 3종을 억제 예외로 뺐다.
데모 시더 심각도가 제품과 7종 어긋나 fall 이 위급으로 찍혀 1.2.0 의 요점인
넘어짐/추락 구분이 데모에서 지워져 있었다 — 라벨과 달리 화면이 안 깨져
눈으로는 못 잡으므로 엣지를 단일 출처로 강제하는 테스트를 붙였다.
문서의 "17종"은 실제 20종(엣지 16 + 서버 4)으로 정정하고 신규 2종을
소개서 감지 표에 넣었다.

검증: pytest 335 passed / phpunit 269 passed. 신규 테스트 30건.
dist 는 이 커밋 이후 재생성한다(BUILD-INFO 가 HEAD 를 가리켜야 한다).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EBjuR7DW6PNNMows4KnbYU
2026-09-05 23:11:57 +09:00
유창욱
9ee3dab378 docs(deck): 경영진 보고 덱 + 영상 브리프, 소개서에 도판 11장 배치
받은 이미지 11장을 docs/images/ 로 최적화 배치(37.6MB -> 3.3MB: 사진은
JPEG q88, 평면 다이어그램은 PNG-8, 최대폭 1600). 원본은 image/ 에 그대로 둔다.

소개서: 매니페스트(docs/소개서-이미지.json)로 11장을 절에 배치해 HTML 재생성
(4.6MB 단일 파일). 표지 컷은 1장 앞에 before-heading 으로.

경영진 보고 덱(docs/deck/): 14장. 매뉴얼 생성기와 같은 구조 —
deck.json(내용) + build_deck.py(생성기) + clips/(3초 클립). 손으로 쓴 HTML 은
두 번째 수정부터 낡고, 낡은 덱은 회의실에서 틀린 숫자를 띄운다.

클립이 없으면 포스터 스틸로 자동 대체되므로 지금 상태로도 완성본이다.
비교 슬라이드는 클립이 하나도 없으면 이미 좌우 비교인 09 스틸 1장으로
바뀐다 — 좌우 패널에 쪼개 넣으면 같은 비교가 두 겹이 된다.

모든 클립·스틸에 '재현' 배지를 템플릿에 박았다. 이 영상들은 제품 동작
녹화가 아니라 연출된 재현이고, 실제 동작처럼 보여주면 허위 시연이 된다.
배지 문구는 매체를 따른다(영상/이미지) — 스틸에 "재현 영상"은 그 자체가
작은 거짓말이다. 실화면 캡처 8장에는 배지가 붙지 않는다.

실렌더로 확인하며 잡은 결함:
- 마지막 슬라이드가 백지였다. :nth-child(even) 이 section.closing 보다
  명시도가 높아 네이비 배경을 덮고, 흰 글자가 흰 배경에 얹혔다(대비 1:1).
- 한국어 제목이 어절 중간에서 끊겼다("설명할 수 있습/니다") — CJK 기본
  줄바꿈이 글자 단위다. word-break:keep-all.
- 슬라이드 이동이 전혀 동작하지 않았다. scroll-snap-type:y mandatory 인
  컨테이너에서 behavior:'smooth' 가 취소되고 원위치로 스냅된다
  (측정: smooth->0, auto->905). 즉시 전환으로 변경 — 덱에서는 컷 전환이
  오히려 슬라이드답다. 키보드가 항상 잡히는 것은 아니라 이전/다음 버튼도 추가.
- 도판이 커서 하단 주석이 화면 밖으로 밀렸다(max-height 하향).
- 표지 베일이 무거워 경광등 빨강이 죽었다 — 이 제품의 상징색이라 살렸다.
- 4번과 5번이 같은 도판을 썼다(포스터 교체).

접근성 게이트(측정): 대비 실패 0건. figcaption 4.35:1 미달로 --ink-3 을
#5C6474 로, 네이비 위 슬라이드 번호 2.0:1 을 #8FB2E2 로 교정. 레일 점은
7x7 이라 발표 중 못 맞춘다 — 점은 그대로 두고 히트영역 24x24(WCAG 2.5.8),
이전/다음 버튼 46x46.

영상 브리프(docs/영상-브리프.md): 3초 클립 6종. 스틸이 못 하는 것만 발주
했고, 클립마다 0.0~3.0초 타임라인·프레임 안에 반드시 있어야 할 것·피할 것을
적었다. 대시보드 녹화는 제외 — 실화면 캡처가 이미 있고 그건 진짜다.

생성물(경영진-보고.html)과 clips/ 는 gitignore — 사용설명서.html 과 같은 이유.
2026-08-25 11:03:50 +09:00
유창욱
9c0cfd2fea feat(alert): 아무도 발행하지 않던 device_fault·monitoring_degraded 구현
두 유형은 타입 정의(edge/events.py)·서버 검증(EventValidator)·콘솔 필터·
통보 라벨까지 전 구간에 있었으나 생성하는 코드가 어디에도 없었다. 실제로
값을 넣는 것은 seed_demo.php 뿐이었고, 소개서가 내세운 "17종"은 15종이었다.

앞서 고친 EventValidator 누락과 정확히 반대 방향의 같은 병이다. 그때는
감지기가 있는데 서버가 막았고, 이번엔 서버·UI가 열려 있는데 감지기가 없다.
어느 쪽이든 화면은 멀쩡해 보인다.

엣지가 낼 수 없는 유형이라 서버에 둔다 — 전원·회선이 끊긴 노드는 자기가
죽었다고 신고할 수 없다. 하트비트의 부재로만 관측된다.

App\Alert\DeviceHealthWatch:
- dispatch_alerts.php 1분 크론의 1단계. 에스컬레이션·통보보다 앞에 두어
  같은 실행 안에서 발행된 장치 이벤트가 곧바로 통보 대상이 되게 했다.
- 멱등은 EscalationWatch와 같은 방식(event_uuid UNIQUE 충돌)이되, 장치
  건전성은 연속 상태라 uuid 앵커를 '장애 구간의 시작'으로 잡았다.
  device_fault는 last_heartbeat_at(장애 내내 불변) → 장애당 정확히 1건.
  monitoring_degraded는 창 안의 마지막 정상 하트비트 ts. 창 전체가 저하면
  시작을 볼 수 없으므로 창 버킷으로 폴백해 시간당 1회 재고지한다 —
  의도된 리마인더이고, 매 틱 재발행하는 앵커 드리프트가 아니다.
- 성능 저하는 지속을 요구(기본 5분). 한 번 튄 값에 울리면 운영자가 이
  유형을 무시하게 되고, 그러면 감시가 멈춘 날에도 아무도 보지 않는다.
  엣지 감지기들이 쓰는 규율과 같다.
- 하트비트를 한 번도 보낸 적 없는 장치는 장애가 아니다(미설치 노드).
  새 현장 첫날이 빨간 벽이 되면 그 사이트는 이 유형을 영영 신뢰하지 않는다.
- 죽은 노드에 degraded를 겹쳐 내지 않는다 — 한 고장에 두 알람은 소음이다.
- fps NULL을 0으로 강압하지 않는다(측정 실패 ≠ 정지). PdoDeviceMetric
  Repository가 NULL을 지키는 이유와 같다.
- 억제(정비 모드)는 여기서 보지 않는다. 기록은 남기고 통보 여부는
  AlertDispatcher가 정한다 — 정비 창은 전화를 끄는 것이지 노드가 죽었던
  기록을 지우는 것이 아니다.

camera_id는 NOT NULL인데 장치 단위 이벤트에는 카메라가 없다. '__device__'
센티넬을 쓰고, 콘솔은 "장치 전체"로 표시, 구역 편집기 카메라 목록에서는
제외한다(카메라 목록은 보고된 이벤트에서 파생되므로, 두면 존재하지 않는
카메라에 구역을 그리려는 일이 생긴다).

설정 4종 추가(device_stale_seconds 180 · degraded_sustain_seconds 300 ·
degraded_fps_ratio 0.6 · target_fps 12) — 두 config 템플릿에 한국어 설명과
함께. 낮은 프레임으로 운용하는 현장이 target_fps를 안 맞추면 전 노드가
영구 저하로 보고되므로 그 경고를 템플릿에 적었다.

검증: pytest 308 passed(3 skip: 모델 미동봉·shellcheck 미설치·AF_UNIX,
기존과 동일) · phpunit 240 passed(신규 16). dist 재생성으로 패리티 회복.
2026-08-25 10:36:48 +09:00
유창욱
e00ad69aa3 feat(safety,ux): 통보·억제·증거 체계 신설 + UI/UX 감사 21건 반영
UI/UX 점검에서 시작해 "이 제품이 안전 시스템으로서 무엇이 없나"까지 내려간 작업.
두 덩어리를 한 커밋으로 둔다 — 뒤쪽 기능들이 앞쪽에서 고친 화면 위에 얹혀 있다.

━━ 1부: UI/UX 감사 21건 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

동선 — 확인(ack) 경로가 위험했다
- 스트림 정렬을 '미확인 우선'에서 엄격 시간순으로 되돌린다. 확인 상태가 연대기를
  뒤집어 사고 재구성이 불가능했고, 확인하면 그 행이 목록 밖으로 사라지며 아래 행이
  밀려 올라와 같은 좌표를 두 번 누르면 다른 이벤트가 확인 처리됐다. 이제 제자리에서
  '확인됨'으로 전환되고 확정 토스트가 뜬다. 처리 큐는 정렬이 아니라 뷰모드 필터로 분리
  (전환해도 행끼리 상대 순서는 절대 안 바뀐다).
- 실행취소는 넣지 않았다. acknowledged_by는 감사로그가 함께 남는 추가 전용 기록이라
  무음 되돌리기가 안전 시스템에서 더 나쁜 속성이다. 대신 오탐·구역삭제를 2단계 확정으로.
- 이벤트 상세에 확인/오탐 신설. 판단하는 화면에 판단 도구가 없었다.
- 구역 설정에 장치·카메라 선택기. 시스템이 아는 값을 사람에게 묻고 있었고,
  플레이스홀더(device-01)마저 실제와 다른 포맷을 가르쳤다.
- 날짜 조회(키셋 커서 시딩). 사고 조사 진입로가 아예 없어 '더 보기'를 반복해야 했다.
- 경보음 세션 음소거(새 위급 도착 시 자동 해제). 끌 수 없는 반복음은 안전 콘솔이
  통째로 꺼지는 경로다.

전달 — 같은 단어가 화면마다 다른 숫자를 가리켰다
- KPI '미확인'(최근 200건)과 24시간 브리프 '미확인'이 40px 간격으로 충돌했다.
  KPI에 실제 집계 범위를 명시. 중대=위급+높음을 인라인 정의.
- 페이지 명칭 3종·날짜 포맷 3종 통일. '확인 필요' 칩을 전 심각도로 확장
  (그전엔 확인 여부가 먼 컬럼의 버튼 유무로만 구분됐다).
- 표본 게이트: 오탐율 '100% (1/1)'이 '33% (5/15)'보다 위에 뜨던 것, 최다 3건짜리
  시간패턴 히트맵, 6→8건에 붙던 빨간 ▲33%. 개선조치 효과는 푸아송 잡음 범위(√before)를
  넘어야 방향을 주장한다 — 안전회의에서 한 번 반박당하면 카드 전체가 신뢰를 잃는다.
- 카메라 위험 순위가 중대>0이면 전부 빨강이라 순위 노릇을 못 했다. 상대 기준으로.

시각 — 화면마다 완성도가 달랐다
- 구역 설정·이벤트 상세를 카드 셸로 재구성. 둘 다 스타일 없는 기본 폼이었고,
  납품 심사에서 "덜 만든 티"는 정확히 거기서 났다.
- 히트맵 토글은 aria-pressed만 있고 CSS 규칙이 없어 켜졌는지 눈으로 알 수 없었다.
- 존 색을 팔레트와 정합(팔레트 어디에도 없던 보라 제거), 채움+라벨칩+협착 점선.
  캔버스에 1px 외곽선만 그리면 실제 공장 프레임에서 사라진다.

━━ 2부: 안전 플랫폼 (마이그레이션 2026-08-09-safety-platform.sql) ━━━

통보 — 이 제품의 정의에 해당하는 구멍
  지금까지 감지는 브라우저 탭이 열려 있고 사람이 보고 있어야만 전달됐다. 새벽 3시에
  넘어진 작업자는 DB에 행 하나를 남기고 아침을 맞았다.
- 엣지 GPIO 경광등(edge/beacon.py). 인터넷·서버 없이 동작하는 유일한 경로라
  어떤 현장이든 이것부터 설정한다. /api/config의 beacons로 배선을 받고, 하드웨어가
  없거나 핀이 안 열리면 NullBeacon으로 떨어지되 그 사실은 반드시 로그로 남긴다.
- 메일·웹훅 채널 + 크론 디스패처(tools/dispatch_alerts.php). 중복 발송은 읽고-쓰기가
  아니라 (event,channel) UNIQUE로 막는다 — 크론 중첩 실행은 공유호스팅의 상수다.
- 미확인 위급은 unacked_escalation 이벤트가 되어 상위 채널로 올라간다(event_uuid로 멱등).
  '아무도 보지 않았다'는 사실 자체를 사건으로 만든다.
- 통보에 이미지는 싣지 않는다. 스냅샷은 PIPA 대상이고 메일링 리스트로 나가는 순간
  인증 프록시가 지키던 통제를 벗어난다 — 링크만 보낸다.

조용한 실패 제거
- 카메라 화각 이동·가림·조도 저하 감지(detectors/integrity.py). 존 판정은 화각 고정을
  전제로 서 있는데, 삼각대를 스치면 제한구역이 엉뚱한 바닥을 가리키면서 시스템은 계속
  초록불이었다. numpy만 사용하고, 지나가는 사람과 구분하기 위해 지속시간을 요구한다.
- 스토리지 여유 감시. 디스크가 차면 이미지 저장만 조용히 실패하고 보드는 멀쩡해 보인다.

끊겨 있던 배선 2건
- thresholds_from_config()가 정의만 있고 어디서도 호출되지 않았다. 서버에 표가 있고
  /api/config가 성실히 실어 보내고 엣지가 파싱한 뒤 버렸다 — 관리자가 무엇을 바꿔도
  장비는 부팅 시 edge.env 값으로 영원히 돌았다(edge/tuning.py).
- 존·임계값 저장이 config_version을 올리지 않아 '설정 지연' 표시가 애초에 켜질 수
  없었다. 지표는 있는데 그것을 켜는 사건이 없었다.

관리 화면 (그전에는 phpMyAdmin이 유일한 방법이었다)
- 임계값 편집: 자유 텍스트가 아니라 카탈로그(범위·단위·설명)로 렌더한다. 범위 밖 값은
  엣지가 기동 시 거부하는데 그때는 이미 공장 안 장비가 재시작 루프다. 키보드 앞에서 막는다.
  목록에 없는 키는 저장 불가 — 장비가 조용히 무시할 값이 '설정됨'으로 보이면 안 된다.
- 정비 억제: 사유 필수, 최대 8시간(한 교대), 자동 만료. 감지와 기록은 그대로 계속되고
  통보만 보류되며 그 보류도 원장에 status='suppressed'로 남는다 — 조사에서 '경보가 났지만
  의도적으로 withheld됐다'를 볼 수 있어야 한다.
- 교대 인수인계, 위험성평가, 설비 성능 추이, 설치 점검(/setup — 실제 상태 조회 기반).

증거·규정
- 감사로그 해시체인. 공유호스팅은 append-only 권한을 줄 수 없으니 '막는' 대신 '숨길 수
  없게' 만든다. 수정·삭제·우회삽입이 전부 탐지되고(tools/verify_audit_chain.php, 손상 시
  exit 1), 체인 이전 행은 손상이 아니라 '검증 불가 구간'으로 따로 센다 — 정상 시스템이
  매번 빨간불을 켜면 아무도 이 도구를 안 본다.
- 사고조사 패키지 1클릭: 전후 타임라인 + 통보 이력(누구에게 닿았는가) + 위험요인 +
  조치 이력 + 서명란. 인쇄 CSS로 그대로 제출 가능. 사고 다음 날 반나절을 쓸 사람은 없다.
- 이벤트→조치 직접 기록, 현장별 롤업(전역 계정만).

운영
- 일괄 확인(위급은 의도적으로 제외·2단계 확정), 키보드 트리아지(J/K/A/F/Enter),
  모바일 레이아웃(44px 타깃), 보존기간 정리(tools/maintain.php),
  백업/복구(tools/backup.php — www 안 출력은 거부), 데모 시드(tools/seed_demo.php).
- tools/는 전부 CLI 전용이고 납품 시 move_detect_app/tools/에 www 밖으로 배치된다.

미착수 4건 (모델·하드웨어가 선행 조건)
- 보호구·중장비 근접·화재 감지는 AGPL 아닌 가중치 확보 + HEF 컴파일이 관문이다.
  감지기 배선·이벤트 계약·심각도·쿨다운·테스트까지 만들고 기본 비활성으로 둔다
  (edge/detectors/pending.py). enabled=True인데 model=None이면 아무것도 하지 않도록
  명시적으로 막았다 — "곧 됩니다" 상태로 켜 두면 현장은 감지되고 있다고 믿는다.
- PLC 접점 협착 게이팅도 같은 이유로 인터페이스만.

검증: pytest 통과(skip 3 = 하드웨어·환경) / PHPUnit 221 (skip 0, +59).
Chrome 실렌더로 신규 4화면 + 임계값 저장→config_version 증가→감사 기록→체인 검증→
행 변조→탐지(exit 1) 전 경로 확인. cafe24 번들 + Pi 오프라인 번들(aarch64 휠 11개)
재생성, 소스 패리티 통과. 보안 자체점검: SQL 문자열 보간 0, innerHTML 0,
상태 변경 핸들러 전부 CSRF+RBAC.
2026-08-18 07:51:23 +09:00
유창욱
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
유창욱
d8cef54fe4 feat(report): safety operations report page + event-type filter
The benefit made visible: /report folds everything the system collects into
the one printable page a 안전관리자 takes into the weekly safety meeting.

- GET /api/report?days=N (7/30/90): severity totals, per-type counts,
  worst-camera ranking, response time (TTA - acknowledged critical/high only,
  FP-tagged rows excluded: dismissing a false alarm is not an emergency
  response; clock-skew guarded), the near-miss pyramid (near_fall vs
  fall/no_motion - the Heinrich argument in one ratio), FP rate, summed
  posture-load exposure, occupancy-rule violation counts. ergo_report rows
  feed the sums but are excluded from "감지 N건" (bookkeeping, not detection).
  Fail-closed site scoping throughout.
- /report view: KPI band (중대 이벤트 / 평균 대응 / 니어미스:사고 / 오탐율,
  each with its one-line reading), bar lists for types and cameras (critical
  presence highlighted), ergo bands as share-of-observed-time, rule tiles.
  Print CSS strips the app chrome - the 인쇄 button produces the meeting
  handout. New 리포트 nav tab.
- Events console: event-type filter beside severity - the INFO-volume types
  (near_fall, ergo_report) can now be folded out of the live stream, or a
  single type isolated for investigation.

Verified: PHPUnit 138 pass (3 new: aggregation math incl. TTA/FP-exclusion/
ergo sums, site scope, auth) + Chrome real-render of the full report (KPIs,
bar lists, rule tiles, footer) and the type filter isolating one type.
2026-08-07 07:17:48 +09:00
유창욱
47ab1143e1 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).
2026-08-06 20:38:36 +09:00
유창욱
9e4225a338 feat(heatmap): near-miss events + spatial ingestion + camera risk-heatmap overlay
Heinrich's-law play: the signals we already compute but used to throw away
become a spatial risk map of the workplace, drawn over the camera's own frame.

Edge:
- near_fall (INFO): a track that reached FALLING (sustained prone) but recovered
  before the FALLEN confirm. Treated as a full event per product decision -
  same ingest, snapshots, dedup, ack flow as incidents. Fix en route: prone_s
  used a truthiness check that turned prone_since=0.0 into "no timer".
- Every per-track event (fall/no_motion/near_fall/immobility/zone hits) now
  carries the foot point in the zones' normalized 0..1 space (reusing the zone
  detector's own coordinate math); ghost's pixel-space loss point is normalized
  at emit. to_payload omits the key when absent, so an old server ignores it.

Server:
- move_events.spatial_x/spatial_y (nullable DOUBLE) + validator: optional
  [x, y] finite-number pair; range deliberately NOT enforced (an off-by-a-hair
  foot must never reject a FALL alert - renderers clamp).
- MIGRATION FIX: chk_events_type would have REJECTED the previous commit's
  lone_worker/overcapacity/overstay inserts on MySQL 8.0.16+ - the constraint
  is rebuilt with all 13 types (incl. near_fall).
- GET /api/heatmap/{camera}?days=N (1..90, default 30, 5000-point cap):
  spatial points with the same fail-closed site scoping as the event list.

UI (zone editor): "위험 히트맵" toggle + day window. Additive radial blots in
an offscreen layer under the zone polygons; severity weighs the alpha so a
cluster of near-misses reads lighter than one confirmed fall at the same spot.
Events console/detail label near_fall in Korean.

Verified: pytest 222 pass / PHPUnit 130 pass + Chrome real-render (blue
near-miss cluster + red incident hotspot inside the restricted zone, amber
cluster in the warning zone, zones drawn on top; no console errors).
2026-08-06 20:19:09 +09:00
유창욱
a203c42bf3 feat(zones): occupancy rule engine - lone-worker / capacity / dwell limits
Per-zone declarative safety rules, set in the zone editor and enforced on the
edge from the tracking the pipeline already does (no new geometry, no new model):

- min_workers (2인 1조): 0 < count < minimum sustained 30s -> lone_worker/HIGH.
  A solo worker in a buddy-rule zone is a compliance violation and a rescue-
  delay risk, so it outranks the other two.
- max_workers: count > limit sustained 10s -> overcapacity/WARNING.
- max_dwell_s: one track's continuous stay past the limit -> overstay/WARNING.
  A short detection dropout (<=5s grace) does NOT reset the dwell timer -
  without that, run_once's stale-track filter would quietly neuter every
  10-minute rule at real-world detection rates.

Edge: detectors/occupancy.py consumes Zone.rules; the engine flips the zone
detector's per-track membership into per-zone headcounts (stale tracks already
excluded), so counts always match what was actually detected this tick. Fired
states clear on condition recovery - re-alerts need a fresh violation, not a
cooldown expiry. Malformed rules_json keeps the zone's intrusion detection
alive and logs the rules as inactive (never fail dark).

Server: move_zones.rules_json (nullable JSON, migration included) validated
strictly in ZoneConfigController (bounds, min<=max, unknown keys rejected -
a silently dropped rule is one the operator believes is active). Delivered to
the edge unchanged via GET /api/config. EventValidator accepts the 3 new types.

UI: zone editor gains the three rule fields (dwell entered in minutes, wired
in seconds) and shows each zone's rules in its list row; the events console
and detail view now render every event type in Korean (wire codes fall back).

Verified: pytest 216 pass / PHPUnit 123 pass + real-render harness in Chrome
(editor save payload, list labels, console type labels).
2026-08-06 19:33:12 +09:00
유창욱
01d36d0a1c docs: add CLAUDE.md + PROJECT_MAP/INDEX for session continuity 2026-08-06 17:02:39 +09:00