From 34506e150d461285f0bf5a36bbc2640351efa665 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EC=B0=BD=EC=9A=B1?= Date: Fri, 18 Sep 2026 22:59:17 +0900 Subject: [PATCH] =?UTF-8?q?docs:=20=EC=84=A4=EA=B3=84=20=EB=8F=84=ED=95=B4?= =?UTF-8?q?=203=EC=A2=85=20(=EA=B5=AC=EC=84=B1=20=C2=B7=20=EB=82=99?= =?UTF-8?q?=EC=83=81=20=EC=8B=9C=ED=80=80=EC=8A=A4=20=C2=B7=20=EB=82=99?= =?UTF-8?q?=EC=83=81=20=EC=83=81=ED=83=9C=EA=B8=B0=EA=B3=84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01EBjuR7DW6PNNMows4KnbYU --- .gitignore | 6 + docs/INDEX.md | 5 + docs/PROJECT_MAP.md | 7 + docs/architecture.archify.json | 343 +++++++++++++++++++++++++++++++ docs/fall-lifecycle.archify.json | 217 +++++++++++++++++++ docs/fall-sequence.archify.json | 258 +++++++++++++++++++++++ 6 files changed, 836 insertions(+) create mode 100644 docs/architecture.archify.json create mode 100644 docs/fall-lifecycle.archify.json create mode 100644 docs/fall-sequence.archify.json diff --git a/.gitignore b/.gitignore index 88b297b..b7026a7 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,9 @@ docs/deck/clips/* # 프로비저닝 자격증명 — 납품 폴더(dist/) 밖에 둔다. # dist/ 안에 있으면 폴더째 압축할 때 같이 나간다. /secrets/ + +# archify 생성물. 소스는 docs/*.archify.json 이고 `archify deliver` 로 언제든 다시 만든다. +# 사용설명서와 같은 이유로 커밋하지 않는다 — 소스가 바뀐 뒤에도 낡은 HTML 이 최신처럼 보인다. +# visual-check 는 검증 증거(PNG·리포트)라 저장소가 아니라 그때의 판정 기록으로만 쓴다. +docs/move-detect-*.html +docs/move-detect-*.visual-check.* diff --git a/docs/INDEX.md b/docs/INDEX.md index 63b1d6e..74e821f 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -168,3 +168,8 @@ - 검증: pytest 322 passed(5 skip: 환경·Pi번들 미빌드) · phpunit 263 passed - 2026-09-05 — 1.2.0 4차 재감사: 확정 23건 전량 해소, 반증 5건 기록. CRITICAL 4건은 전부 "구현·단위테스트는 초록인데 납품 경로에서 무동작"이었다 — 추락 승격이 기본 설정에서 미발화(테스트가 prone_confirm_s=0.5 사용), max_workers:0·elevated 가 상류 허용 목록 두 곳에서 차단, 동시 다발이 트래커 재획득으로 한 사람을 두 명으로 셈(구조 지연). 재발 방지 테스트 추가: BUILD-INFO↔HEAD, README↔마이그레이션, 시더 심각도↔엣지, 프리셋 왕복. 문서 "17종"→실제 20종 정정. 산출물 `move-detect-cafe24-1.2.0-20260905.zip`(BUILD-INFO=93f896a). 검증 pytest 338 / phpunit 269. `93f896a` + +- 2026-09-18 — 설계 도해 3종 작성(archify, showcase 9/9 · 오류 0 · 경고 0, 1440~2048 4해상도 양 테마 브라우저 검증): + 시스템 구성 · 낙상 시퀀스 · 낙상 상태기계. 시퀀스를 그리다 확인한 사실 — **경광등(3초)과 메일(최대 63초)은 + 같은 "통보"가 아니다.** 상태기계 쪽은 확정 창 3초와 추락 연결 창 3초가 겹쳐 있음을 한 장으로 보여준다 + (4차 감사의 CRITICAL, 시간을 그렸다면 코드보다 먼저 보였을 충돌). HTML 은 gitignore, 소스 JSON 만 커밋. diff --git a/docs/PROJECT_MAP.md b/docs/PROJECT_MAP.md index e9fa447..333851c 100644 --- a/docs/PROJECT_MAP.md +++ b/docs/PROJECT_MAP.md @@ -100,3 +100,10 @@ `delivery-audit.md`(감사 43건, 전부 완료 — OFF-HW 실장비 검증만 잔여) · `pilot-runbook.md` · `hardware-bringup.md` · `INDEX.md`(작업 기록) + +도해 3종. 소스는 `*.archify.json` 이고 HTML 은 생성물이라 커밋하지 않는다 +(`node ~/.claude/skills/archify/bin/archify.mjs deliver <소스> <출력> --quality showcase`): + +- `architecture.archify.json` — 시스템 구성(현장 ↔ Cafe24). 무엇이 경계를 넘는가. +- `fall-sequence.archify.json` — 낙상 1건의 시간축. 경광등 즉시 vs 통보 최대 60초의 간격. +- `fall-lifecycle.archify.json` — 낙상 판정 상태기계. 확정·회복 양쪽이 지속을 요구하는 구조. diff --git a/docs/architecture.archify.json b/docs/architecture.archify.json new file mode 100644 index 0000000..a555609 --- /dev/null +++ b/docs/architecture.archify.json @@ -0,0 +1,343 @@ +{ + "schema_version": 1, + "diagram_type": "architecture", + "meta": { + "title": "작업장 안전 AI CCTV — 시스템 구성", + "output": "move-detect-architecture.html", + "quality_profile": "showcase", + "views": [ + { + "id": "detection-chain", + "label": "감지 → 대응", + "focus": [ + "cameras", + "edge", + "beacon" + ], + "note": "카메라에서 경광등까지. 서버도 인터넷도 거치지 않는 즉시 대응 경로." + }, + { + "id": "privacy-path", + "label": "프라이버시 경로", + "focus": [ + "edge", + "blur", + "ingest", + "db" + ], + "note": "영상은 나가지 않는다. 얼굴 블러 후 스냅샷 3장만, 7일 뒤 삭제." + }, + { + "id": "server-issued", + "label": "서버가 발행하는 사건", + "focus": [ + "cron", + "db", + "notify" + ], + "note": "죽은 노드는 자기가 죽었다고 신고할 수 없다 — 하트비트의 부재로 판정한다." + } + ] + }, + "components": [ + { + "id": "cameras", + "type": "external", + "label": "카메라", + "sublabel": "CSI · USB", + "tag": "노드당 2~4대", + "pos": [ + 40, + 340 + ], + "size": [ + 130, + 64 + ] + }, + { + "id": "edge", + "type": "backend", + "label": "엣지 노드", + "sublabel": "Pi 5 + Hailo-8", + "tag": "판정 16종", + "pos": [ + 230, + 332 + ], + "size": [ + 165, + 80 + ] + }, + { + "id": "beacon", + "type": "external", + "label": "현장 경광등", + "sublabel": "GPIO 직결", + "tag": "네트워크 불필요", + "pos": [ + 230, + 180 + ], + "size": [ + 165, + 64 + ] + }, + { + "id": "queue", + "type": "messagebus", + "label": "오프라인 큐", + "sublabel": "단절 시 보관", + "pos": [ + 230, + 500 + ], + "size": [ + 165, + 64 + ] + }, + { + "id": "blur", + "type": "security", + "label": "얼굴 블러", + "sublabel": "전송 전", + "tag": "영상 녹화 없음", + "pos": [ + 465, + 336 + ], + "size": [ + 160, + 72 + ] + }, + { + "id": "ingest", + "type": "security", + "label": "수집 API", + "sublabel": "HMAC + nonce", + "tag": "재전송 차단", + "pos": [ + 715, + 332 + ], + "size": [ + 155, + 80 + ] + }, + { + "id": "app", + "type": "backend", + "label": "대시보드 서버", + "sublabel": "PHP 8", + "pos": [ + 960, + 332 + ], + "size": [ + 160, + 80 + ] + }, + { + "id": "db", + "type": "database", + "label": "MySQL", + "sublabel": "move_* · 7일", + "pos": [ + 1150, + 340 + ], + "size": [ + 160, + 64 + ] + }, + { + "id": "cron", + "type": "backend", + "label": "1분 크론", + "sublabel": "이벤트 발행", + "tag": "서버 발행 4종", + "pos": [ + 1150, + 480 + ], + "size": [ + 160, + 76 + ] + }, + { + "id": "console", + "type": "frontend", + "label": "운영 콘솔", + "sublabel": "바닐라 JS", + "tag": "라이브뷰 없음", + "pos": [ + 960, + 180 + ], + "size": [ + 160, + 76 + ] + }, + { + "id": "notify", + "type": "external", + "label": "메일 · 사내 웹훅", + "sublabel": "링크만 전송", + "pos": [ + 1150, + 630 + ], + "size": [ + 160, + 72 + ] + } + ], + "boundaries": [ + { + "kind": "region", + "label": "현장 (폐쇄망 가능)", + "wraps": [ + "cameras", + "edge", + "beacon", + "queue", + "blur" + ] + }, + { + "kind": "region", + "label": "Cafe24 공유호스팅", + "wraps": [ + "ingest", + "app", + "db", + "cron" + ] + } + ], + "connections": [ + { + "id": "capture", + "from": "cameras", + "to": "edge", + "label": "프레임" + }, + { + "id": "local-beacon", + "from": "edge", + "to": "beacon", + "label": "즉시 점등", + "variant": "emphasis", + "fromSide": "top", + "toSide": "bottom" + }, + { + "id": "evidence", + "from": "edge", + "to": "blur", + "label": "이벤트 시" + }, + { + "id": "spool", + "from": "edge", + "to": "queue", + "label": "회선 단절 시", + "variant": "dashed", + "fromSide": "bottom", + "toSide": "top", + "labelDy": 30 + }, + { + "id": "upload", + "from": "blur", + "to": "ingest", + "label": "HMAC POST", + "variant": "emphasis" + }, + { + "id": "resend", + "from": "queue", + "to": "ingest", + "label": "복구 후 재전송", + "variant": "dashed", + "fromSide": "right", + "toSide": "bottom" + }, + { + "id": "accept", + "from": "ingest", + "to": "app", + "label": "검증 통과" + }, + { + "id": "persist", + "from": "app", + "to": "db", + "label": "SQL" + }, + { + "id": "cron-db", + "from": "cron", + "to": "db", + "label": "하트비트 부재로 판정", + "fromSide": "top", + "toSide": "bottom" + }, + { + "id": "dispatch", + "from": "cron", + "to": "notify", + "label": "1분 주기 통보", + "fromSide": "bottom", + "toSide": "top", + "labelDy": 30 + }, + { + "id": "serve", + "from": "app", + "to": "console", + "label": "HTTPS · 역할 기반 접근", + "fromSide": "top", + "toSide": "bottom" + } + ], + "cards": [ + { + "dot": "cyan", + "title": "현장에서 끝나는 대응", + "items": [ + "판정은 엣지에서 완결 — 서버 왕복 없음", + "경광등은 GPIO 직결이라 회선·서버가 죽어도 울린다", + "끊긴 동안의 이벤트는 큐에 쌓였다가 복구 시 재전송" + ] + }, + { + "dot": "rose", + "title": "영상을 남기지 않는다", + "items": [ + "링버퍼는 RAM 7.5초 — 디스크에 쓰지 않는다", + "이벤트 순간에만 3프레임, 얼굴 블러는 전송 전 노드에서", + "스냅샷 7일 후 자동 삭제 · 라이브뷰 화면 자체가 없음" + ] + }, + { + "dot": "emerald", + "title": "서버가 보는 것", + "items": [ + "HMAC + nonce 로 재전송 공격 차단", + "죽은 노드는 자기가 죽었다고 신고할 수 없다 — 크론이 하트비트의 부재로 판정", + "통보는 링크만 — 현장 이미지는 메일로 나가지 않는다" + ] + } + ] +} diff --git a/docs/fall-lifecycle.archify.json b/docs/fall-lifecycle.archify.json new file mode 100644 index 0000000..7001d26 --- /dev/null +++ b/docs/fall-lifecycle.archify.json @@ -0,0 +1,217 @@ +{ + "schema_version": 1, + "diagram_type": "lifecycle", + "meta": { + "title": "낙상 판정 상태기계 — 지속을 요구하는 이유", + "output": "move-detect-fall-lifecycle.html", + "quality_profile": "showcase", + "views": [ + { + "id": "main-path", + "label": "확정까지", + "focus": [ + "standing", + "falling", + "fallen", + "nomotion" + ], + "note": "한 프레임으로는 아무것도 확정하지 않는다. 1초는 위험 자세, 3초라야 넘어짐." + }, + { + "id": "recovery", + "label": "회복 경로", + "focus": [ + "falling", + "nearfall", + "fallen", + "recovered" + ], + "note": "회복도 지속을 요구한다. 확정 전에 일어서면 니어미스로만 남는다." + }, + { + "id": "lost-track", + "label": "사라진 사람", + "focus": [ + "fallen", + "ghost" + ], + "note": "위험 상태에서 트랙이 사라지면 10초 기다린 뒤 별도 사건으로 올린다." + } + ], + "viewBox": [ + 1085, + 620 + ] + }, + "lanes": [ + { + "id": "main", + "label": "자세 상태" + }, + { + "id": "recovery", + "label": "회복" + }, + { + "id": "terminal", + "label": "종료" + } + ], + "states": [ + { + "id": "standing", + "type": "start", + "label": "정상", + "sublabel": "기립 · 보행", + "lane": "main", + "col": 0, + "step": "01" + }, + { + "id": "falling", + "type": "active", + "label": "위험 자세", + "sublabel": "쓰러진 자세 1초", + "lane": "main", + "col": 1, + "step": "02", + "tag": "알람 없음" + }, + { + "id": "fallen", + "type": "failure", + "label": "넘어짐 확정", + "sublabel": "3초 지속", + "lane": "main", + "col": 2, + "step": "03", + "tag": "경광등 + 통보" + }, + { + "id": "nomotion", + "type": "failure", + "label": "미동 없음", + "sublabel": "정지 10초", + "lane": "main", + "col": 3, + "step": "04", + "tag": "최상위 위급" + }, + { + "id": "ghost", + "type": "failure", + "label": "위험 상태 소실", + "sublabel": "트랙이 사라짐", + "lane": "terminal", + "col": 2, + "tag": "10초 대기", + "yOffset": -16 + }, + { + "id": "nearfall", + "type": "neutral", + "label": "니어미스", + "sublabel": "확정 전 회복", + "lane": "recovery", + "col": 0, + "tag": "기록만" + }, + { + "id": "recovered", + "type": "success", + "label": "정상 복귀", + "sublabel": "정상 자세 1초", + "lane": "recovery", + "col": 1 + } + ], + "transitions": [ + { + "id": "to-falling", + "from": "standing", + "to": "falling", + "label": "쓰러진 자세 1초", + "labelDy": -46, + "route": "straight" + }, + { + "id": "to-fallen", + "from": "falling", + "to": "fallen", + "label": "3초 지속", + "variant": "emphasis", + "labelDy": -46, + "route": "straight" + }, + { + "id": "to-nomotion", + "from": "fallen", + "to": "nomotion", + "label": "정지 10초", + "variant": "emphasis", + "labelDy": -46, + "route": "straight" + }, + { + "id": "to-ghost", + "from": "nomotion", + "to": "ghost", + "label": "재출현 없음", + "variant": "security", + "route": "right-channel", + "channelX": 720, + "labelSegment": 0 + }, + { + "id": "near-miss", + "from": "falling", + "to": "nearfall", + "label": "정상 1초", + "route": "drop" + }, + { + "id": "fallen-recover", + "from": "fallen", + "to": "recovered", + "label": "정상 1초", + "route": "drop" + }, + { + "id": "back-from-nearmiss", + "from": "nearfall", + "to": "standing", + "label": "복귀", + "variant": "dashed", + "route": "bottom-channel" + } + ], + "cards": [ + { + "dot": "emerald", + "title": "지속을 요구하는 이유", + "items": [ + "한 프레임으로는 확정하지 않는다", + "1초는 위험 자세 — 알람 없음", + "앉는 동작과 가르는 조건이 이것이다" + ] + }, + { + "dot": "rose", + "title": "회복도 지속을 요구한다", + "items": [ + "정상 자세 1초라야 회복", + "가려지면 카운트다운이 멈춘다", + "확정 전 회복은 니어미스로만" + ] + }, + { + "dot": "amber", + "title": "같은 3초가 두 번", + "items": [ + "확정 창 3초 · 추락 연결 창 3초", + "겹쳐서 추락 승격이 미발화했다", + "시간을 그렸다면 보였을 충돌" + ] + } + ] +} diff --git a/docs/fall-sequence.archify.json b/docs/fall-sequence.archify.json new file mode 100644 index 0000000..866f51b --- /dev/null +++ b/docs/fall-sequence.archify.json @@ -0,0 +1,258 @@ +{ + "schema_version": 1, + "diagram_type": "sequence", + "meta": { + "title": "낙상 1건 — 감지에서 통보까지", + "output": "move-detect-fall-sequence.html", + "quality_profile": "showcase", + "column_fit": "spread", + "views": [ + { + "id": "on-site", + "label": "현장에서 끝나는 3초", + "focus": [ + "cam", + "edge", + "beacon" + ], + "note": "확정부터 점등까지 서버를 거치지 않는다. 회선이 끊겨도 여기까지는 동작한다." + }, + { + "id": "what-leaves", + "label": "밖으로 나가는 것", + "focus": [ + "edge", + "api", + "db" + ], + "note": "영상이 아니라 얼굴 블러된 3프레임. 블러는 전송 전, 노드에서." + }, + { + "id": "notify-lag", + "label": "통보까지의 실제 지연", + "focus": [ + "cron", + "db", + "staff" + ], + "note": "경광등은 즉시, 메일은 크론 주기만큼 늦다. 그 간격이 이 그림의 요점이다." + } + ], + "viewBox": [ + 1060, + 560 + ] + }, + "participants": [ + { + "id": "cam", + "type": "external", + "label": "카메라", + "sublabel": "12fps" + }, + { + "id": "edge", + "type": "backend", + "label": "엣지 노드", + "sublabel": "Pi 5 + Hailo-8" + }, + { + "id": "beacon", + "type": "external", + "label": "경광등", + "sublabel": "GPIO" + }, + { + "id": "api", + "type": "security", + "label": "수집 API", + "sublabel": "HMAC 검증" + }, + { + "id": "db", + "type": "database", + "label": "MySQL", + "sublabel": "move_events" + }, + { + "id": "cron", + "type": "backend", + "label": "1분 크론", + "sublabel": "dispatch_alerts" + }, + { + "id": "staff", + "type": "external", + "label": "담당자", + "sublabel": "메일 · 웹훅" + } + ], + "segments": [ + { + "from": 152, + "to": 190, + "label": "감지 — 자세 3초" + }, + { + "from": 198, + "to": 320, + "label": "현장 대응 · 기록" + }, + { + "from": 330, + "to": 366, + "label": "미동 없음 13초" + }, + { + "from": 374, + "to": 466, + "label": "통보 — 크론 주기 지연" + } + ], + "messages": [ + { + "id": "frames", + "from": "cam", + "to": "edge", + "y": 168, + "label": "프레임 · 12fps", + "variant": "default", + "note": "0.0초 쓰러짐 → 1초 위험 자세 → 3초 확정" + }, + { + "id": "gpio", + "from": "edge", + "to": "beacon", + "y": 206, + "label": "넘어짐 확정(3초) → GPIO 점등", + "variant": "emphasis", + "note": "3.0초 · 서버도 인터넷도 거치지 않는다" + }, + { + "id": "post", + "from": "edge", + "to": "api", + "y": 244, + "label": "HMAC POST · 3프레임", + "variant": "security", + "note": "얼굴 블러는 전송 전, 노드에서" + }, + { + "id": "insert", + "from": "api", + "to": "db", + "y": 276, + "label": "INSERT", + "variant": "default" + }, + { + "id": "accepted", + "from": "api", + "to": "edge", + "y": 306, + "label": "202 Accepted", + "variant": "return" + }, + { + "id": "nomotion", + "from": "edge", + "to": "api", + "y": 344, + "label": "미동 없음 · 10초", + "variant": "emphasis", + "note": "13.0초 — 최상위 위급" + }, + { + "id": "poll", + "from": "cron", + "to": "db", + "y": 384, + "label": "통보 대상 조회 · 최대 60초 뒤", + "variant": "default", + "note": "최대 60초 뒤 — 크론이 1분마다 돈다" + }, + { + "id": "send", + "from": "cron", + "to": "staff", + "y": 418, + "label": "메일 · 웹훅 (링크만)", + "variant": "emphasis" + }, + { + "id": "escalate", + "from": "cron", + "to": "db", + "y": 452, + "label": "5분 미확인 → 상위 확대", + "variant": "dashed" + } + ], + "activations": [ + { + "participant": "edge", + "from": 196, + "to": 352, + "type": "backend" + }, + { + "participant": "beacon", + "from": 201, + "to": 232, + "type": "external" + }, + { + "participant": "api", + "from": 239, + "to": 312, + "type": "security" + }, + { + "participant": "db", + "from": 271, + "to": 300, + "type": "database" + }, + { + "participant": "cron", + "from": 379, + "to": 458, + "type": "backend" + }, + { + "participant": "staff", + "from": 413, + "to": 442, + "type": "external" + } + ], + "cards": [ + { + "dot": "cyan", + "title": "현장에서 끝나는 3초", + "items": [ + "자세가 3초 지속돼야 확정한다", + "확정 즉시 GPIO 점등 — 서버 불필요", + "서버가 죽어도 여기까지는 동작" + ] + }, + { + "dot": "rose", + "title": "밖으로 나가는 것", + "items": [ + "영상이 아니라 3프레임", + "얼굴 블러는 전송 전, 노드에서", + "블러 전 이미지는 서버에 없다" + ] + }, + { + "dot": "amber", + "title": "두 지연은 다르다", + "items": [ + "경광등은 즉시", + "메일은 크론 주기만큼 늦다", + "\"1분 통보\"는 대기를 포함한 값" + ] + } + ] +}