POSA_Copyrighter/docs/PROJECT_MAP.md
changukyu 93a4a82bff test: 다이어그램 드리프트 게이트 신설, 재생성 절차를 실행 가능하게
감사에서 나온 결론은 "그림은 지금 정확하지만 정확함을 지킬 장치가 없다" 였다.
코드 유래 리터럴 18개가 전부 코드와 일치하고 deliver 는 바이트 결정적인데,
드리프트를 막는 게이트·테스트·훅이 0개였고 문서에 적힌 재생성 절차는 그대로는
실행되지 않았다 — `archify` 실행 파일은 PATH 에 없고 실제로는 저장소 밖 전역
스킬을 node 로 불러야 한다. 다음 사람은 첫 명령에서 막혔을 것이다.

  scripts/rebuild-diagrams.ps1       validate -> deliver -> visual-check -> manifest
  scripts/update_diagram_manifest.py 수신증의 단일 작성자
  docs/diagrams/manifest.json        spec/html sha256 + generator 버전
  tests/test_diagram_sync.py         게이트 14건

게이트는 브라우저도 Node 도 archify 도 없이 돈다. 산출물이 바이트 결정적이라
수신증 대조만으로 충분하고, 그래서 폐쇄망 CI 에서도 돈다. 두 사고 시나리오를
실제로 만들어 차단을 확인했다 — HTML 의 단어 하나를 고치면 html sha 가,
spec 의 태그 하나를 고치면 spec sha 가 어긋나 verify 가 실패한다. 노드 id 가
HTML 에 data-node-id 로 박히므로 의미적 연결도 함께 본다.

git 위생 두 건도 같이 정리했다. *.visual-check.json 이 개발 머신 절대 경로와
크롬 설치 경로를 평문으로 담은 채 미추적·비ignore 상태였다(24개) — 납품
저장소가 removable media 로 건너가는 구조라 한 번 커밋되면 제거가 어렵다.
그리고 15,000줄 생성 HTML 을 텍스트로 머지하려 드는 것을 막았다.

재생성해도 4종 sha256 이 전부 불변이라 바이트 결정성을 다시 확인했다.

감사에서 나온 나머지 부채(한글 폰트 미번들, 영어 뷰어 UI, architecture 좌표
하드코딩, lifecycle 손튜닝 8개소, 콘솔 CSP 비호환)는 PROJECT_MAP 부채 표와
다이어그램 절에 등재했다. 폰트와 UI 는 archify 쪽 한계라 spec 으로는 못 고친다.

검증: ruff clean, pytest 600 passed (게이트 +14).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BStHFrjQj2VypecSG9ex1V
2026-09-21 10:51:55 +09:00

215 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# PROJECT MAP
> 어디에 무엇이 있는지에 대한 참조 문서. **작업 전 여기서 위치를 찾고 필요한 부분만 연다**
> (전체 소스 재스캔 금지). 새 파일·라우트·스키마가 생기면 해당 섹션을 갱신한다.
>
> 무엇을 했는지의 기록은 [INDEX.md](INDEX.md)에 있다. 이 문서는 **현재 구조**만 다룬다.
## 한 줄 요약
이미지 저작권 위험을 자동 분석하고, 운영자가 콘솔에서 근거를 확인해 승인/보류/반려를
판정하는 폐쇄망(air-gapped) 도구. Python 3.13 표준 `http.server` + SQLite + 정적 SPA.
런타임 서드파티는 이미지 처리 3종(`numpy`/`opencv-python-headless`/`pillow`)뿐.
## 검증 (단일 명령)
```powershell
.\verify.ps1 # ruff + pytest 전체 — 커밋 전 게이트, CI도 이 명령
.\verify.ps1 -SkipLint # 빠른 내부 루프
.\verify.ps1 -Coverage # src/ 커버리지 포함
```
설정은 `pytest.ini`(testpaths·pythonpath·timeout)와 `ruff.toml`(py313·line-length 120)에 있다.
## 실행
```powershell
python run_copyrighter_server.py # 기본 127.0.0.1:9500
```
런처가 `PYTHONPATH=src`와 작업 디렉터리를 고정한다. 옵션: `--host --port --db --images --static --env`.
---
## 소스 지도 (`src/rights_filter/`)
### `analysis/` — 이미지 분석과 위험도 산출
| 파일 | 역할 |
|---|---|
| `preprocessing.py` | 디코드, 외부 전송용 파생물 생성(메타데이터 제거), 얼굴 크롭 파생물 |
| `fingerprints.py` | 이미지 지문. `_perceptual_hash`는 **DCT pHash가 아니라 8x8 평균해시(aHash)** — 이름과 실제가 다름(알려진 부채) |
| `face_person_detection.py` | YuNet(ONNX) 얼굴 검출 + 인물 등급(`identifiable`/`background`). 가중치는 `models/`(gitignore) |
| `internal_analyzer.py` | 내부 분석 오케스트레이션 |
| `risk_scoring.py` | `RiskScorer.score()` — 점수·밴드 산출. **`SCORER_VERSION` 은 가중치/버킷/밴드 변경 시 반드시 올린다** |
| `evidence_normalizer.py`, `evidence_enrichment.py` | 증거 정규화·보강 |
| `search_query_generation.py`, `search_result_promoter.py` | 자동 검색 쿼리 생성, 검색 결과의 증거 승격 |
| `llm_assistance.py`, `llm_prompts.py` | LLM을 **요약기가 아닌 증거 판독기**로 사용. 입력 화이트리스트 9필드 + 상위 12건 상한, `format=json` 구조화 인용, 라벨 집합 포함 검사로 미인용 claim 폐기 |
| `provenance.py` | 출처 신호 — EXIF/IPTC 권리 표기 추출(화이트리스트 6필드), 방송캡처 포렌식. **둘 다 현재 0점 라벨**(캘리브레이션 전) |
| `derivatives.py`, `reason_builder.py` | 파생물 기록, 사유 문자열 조립 |
### `server/` — HTTP + 저장소
`http_app.py` (559줄) 가 라우팅·인증·보안 헤더를 담당하고, 저장소는 `CopyrighterStore`
하나를 **믹스인 8개**로 조립한다 (`sqlite_store.py:77`).
| 파일 | 역할 |
|---|---|
| `http_app.py` | `build_server()`, `CopyrighterHandler`. 라우팅 / Bearer+세션쿠키 인증 / CSP·CSRF / 바디 상한 10MB |
| `auth_rate_limit.py` | 실패한 인증 시도 스로틀(고정 윈도우 10회/60초, 클라이언트 주소별) |
| `sqlite_store.py` | `CopyrighterStore` 조립 + 시드·리스코어 |
| `store_schema.py` | DDL·제약·타입 컬럼 마이그레이션. `SCHEMA_VERSION` |
| `store_persistence.py` | `_put`/`_all` 등 저수준 upsert (PK 충돌 시 update) |
| `store_serialization.py` | 도메인 ↔ DB payload 왕복. **라운드트립은 원 점수를 재현해야 한다**(과거 위반 이력 있음) |
| `store_enrichment.py` | provider 상태 동기화, 얼굴 증거/크롭, 재분석, 자동 Naver/Google 검색 드라이버 |
| `store_operations.py` | 운영자 액션(판정·메모·재분석 요청) |
| `store_workflow.py` | 일괄 판정(`MAX_BATCH_DECISIONS`), 워크플로 |
| `store_search_candidates.py` | 검색 후보 수집·유사도 비교 |
| `store_page_scrape.py` | 후보 페이지/스타일시트 파싱 |
| `store_remote_fetch.py` | **네트워크 경계**. SSRF 가드 — 사전 URL 검사 + `_guarded_create_connection`(단일 DNS 해석으로 rebinding TOCTOU 차단) + 리다이렉트 홉 재검증 |
| `store_queue.py`, `store_duplicates.py`, `store_backup.py`, `store_text.py`, `store_url_utils.py`, `store_constants.py` | 큐, 지문 중복, 백업/복구, 텍스트·URL 유틸, 상수 |
| `image_store.py` | 제출 이미지 파일 저장소 + manifest |
| `env_file.py` | `.env` 로더 (utf-8-sig — BOM이 첫 키에 붙지 않도록) |
### `integrations/` — 외부 어댑터
`naver_search.py`, `google_custom_search.py`, `cloud_vision_web_detection.py`,
`web_detection_result_mapper.py`, `http_json.py`.
쿼터/정책은 `search_policy.py`(`SearchApiPolicy` — `try_reserve`/`release` 로 원자적 예약)와
`external_policy.py`(`ExternalApiPolicy`). **둘 다 날짜 경계 윈도우 리셋 있음**.
`env_clients.py` 가 `.env` → 어댑터/정책 조립(`build_provider_runtime`).
### `domain/` · `governance/` · `jobs/` · `admin/`
- `domain/records.py` — `Evidence`, `EvidenceSource`, `ScoreResult` 등 핵심 레코드
- `governance/` — 접근·보정·보존 정책 (얼굴 크롭 보존기간 등)
- `jobs/batch_analyzer.py` — 배치 분석, `retry_policy.py`, `usage_limits.py`
- `admin/` — 운영자 리뷰 핸들러·프레젠터
---
## 운영자 콘솔 (`web/operator-gui/`)
빌드 스텝 없음. 전부 클래식 스크립트(모듈 아님)라 **하나의 전역 스코프를 공유**한다.
`index.html` → `styles.css` + 보조 스크립트 4개(`operator-labels.js`, `submission-import.js`,
`evidence-guidance.js`, `operator-search.js`) → **`app-*.js` 15개**.
### `app-*.js` 로드 순서 (변경 금지 — 순서가 곧 계약)
```
app-state.js 상태·라벨 임포트 (나머지 전부가 읽는다)
app-api.js 토큰 프롬프트, fetch 래퍼, bootstrap/refresh
app-format.js 커버리지 계산, HTML/URL 이스케이프, 라벨 포맷터
app-selectors.js 상태 파생 조회 (선택 케이스, 위험/출처 클래스, 증거 필터)
app-render-queue.js 큐 목록, 임포트 상태, 케이스 리뷰 셸
app-render-evidence.js 증거 그룹화·정렬·마크업
app-render-panels.js 지식DB·후보·보정·provider·감사 패널, renderAll
app-navigation.js 뷰/탭 전환, 케이스 선택, 판정, 수동 검색, 재분석
app-keyboard-batch.js 키보드 심사 모드, 중복 그룹, 일괄 판정
app-decision-tools.js 메모 프리셋, 판정 내보내기, 백업, cleared reference, LLM 초안
app-stats.js 판정 통계·캘리브레이션
app-submissions.js 제출물 리로드/임포트/업로드, 지식 항목 생성
app-collections.js 후보 수집·기각·선택·승격
app-admin.js 지식/보정/provider/증거상태 관리 액션
app-bootstrap.js bindEvents, init, DOM 리스너 ← 유일하게 로드 시점에 실행됨
```
이 순서는 `tests/operator_gui/test_static_workbench.py` 의
`test_index_loads_every_app_part_exactly_once_in_declared_order` 가 고정한다.
파일을 추가·제거·재배치하면 그 테스트의 `APP_SCRIPT_NAMES` 와 `index.html` 을 함께 고친다.
---
## HTTP 라우트 (`http_app.py`)
인증: `COPYRIGHTER_AUTH_TOKEN` 설정 시 `/api/*` 와 미디어 라우트는 Bearer 헤더 또는 세션 쿠키
필요. 정적 셸과 `/health` 는 열려 있다(운영자가 토큰을 입력할 수 있어야 하므로).
**GET** — `/health` · `/api/bootstrap` · `/api/review-queue` · `/api/submissions/{id}/review` ·
`/api/providers` · `/api/providers/health` · `/api/audit-events` · `/api/decision-memo-presets` ·
`/api/decisions/export` · `/api/admin/backups` · `/api/stats/decisions` ·
`/media/` · `/knowledge-media/` · `/collected-media/` · `/face-crop-media/`
**POST** — `/api/submissions/{id}/search-auto|rerun-enrichment|decision|clear-reference` ·
`/api/submissions/reload|import-folder|upload-image|decision-batch` ·
`/api/evidence/{id}/status` · `/api/search/manual` · `/api/knowledge/manual` ·
`/api/knowledge/{id}/promote-watchlist|exclude-watchlist|deactivate|reactivate` ·
`/api/collections/keyword` · `/api/collections/candidates/promote-batch` ·
`/api/collections/candidates/{id}/promote|dismiss` ·
`/api/decision-memo-presets` (+ `/{id}/delete`) · `/api/admin/backup` ·
`/api/providers/emergency-disable`
**PATCH** — `/api/knowledge/{id}`
---
## SQLite 스키마 (`store_schema.py`, `SCHEMA_VERSION = 2`)
제약 있는 테이블 7개 — `submissions`, `evidence`, `providers`, `knowledge_entries`,
`collection_candidates`, `corrections`, `audit_events`. 추가로 `submission_queues`.
각 테이블은 조회·정렬용 타입 컬럼 + 전체 도메인 객체를 담은 `payload` JSON 컬럼 구조.
`evidence.submission_id` 는 `submissions(id)` 에 `on delete cascade`.
WAL 적용됨. DB 기본 경로는 `data/copyrighter.sqlite3`(gitignore).
---
## 테스트 (`tests/`, 562건)
- `tests/rights_filter/` — 단위·통합. `conftest.py` 가 `src` 를 sys.path 에 넣는다
- `tests/operator_gui/` — 정적 검증 + Playwright E2E.
- Page Object: `pages/workbench_page.py` (`OperatorWorkbench`). 업로드 대기는
`UPLOAD_ANALYSIS_TIMEOUT_MS`(30초) — 업로드는 서버에서 실분석을 돌리므로 일반 대기보다 길다
- Chromium 없으면 **자동 skip**(`importorskip`)
- 정적 테스트는 `app-*.js` 전체를 이어붙여(`_read_app()`) 내용을 검사한다 —
어느 파일에 있는지가 아니라 콘솔에 그 동작이 있는지를 본다
---
## 다이어그램
소스 JSON 과 산출물 HTML 이 같은 폴더에 쌍으로 있다. **HTML 직접 수정 금지** —
고칠 것은 언제나 spec JSON 이고, 재생성은 스크립트 하나다:
```powershell
.\scripts
ebuild-diagrams.ps1 # validate -> deliver -> visual-check -> manifest
.\scripts
ebuild-diagrams.ps1 -SkipVisualCheck # 브라우저 없는 호스트
```
`tests/test_diagram_sync.py` 가 `manifest.json` 의 sha256 으로 이 계약을 강제한다
(브라우저·Node·archify 없이 도는 순수 파일 비교). HTML 을 직접 고치거나 spec 만
고치고 재생성을 잊으면 `verify.ps1` 에서 막힌다. 두 시나리오 모두 실증 확인됨.
**문서 재생성 전용 요구사항** (런타임 아님, 폐쇄망 타깃에는 불필요):
Node 18+ 와 archify 스킬(`$env:ARCHIFY_HOME`, 기본값 `~/.claude/skills/archify`),
visual-check 단계에만 Chrome/Chromium. 산출 당시 generator 는 `archify 2.17.0-dev.1`
이고 `manifest.json` 에 기록된다 — 스킬 버전이 바뀌면 같은 spec 에서도 바이트가
달라지므로, 해시가 깨졌을 때 코드 변경과 툴 변경을 구분하는 근거가 된다.
모두 자체 완결 HTML 이라 외부 요청이 없다(9/21 감사에서 실측: src/href/@import/fetch
모두 0건). 단 **file:// 로 여는 산출물**이다 — 운영자 콘솔의 static_root 로 서빙하면
app-shell CSP(`script-src 'self'`, `http_app.py:514`)가 인라인 스크립트 4개를 차단해
상호작용이 죽는다. 서빙이 필요하면 CSP 완화가 아니라 별도 라우트에서 nonce 정책을 쓴다.
| 파일 | 내용 | 타입 |
|---|---|---|
| `evidence-dataflow.html` | 증거 데이터 흐름 5단(수집·경계 검사·정규화/채점·영속화·소비). 라운드트립 불변식이 중심 | dataflow |
| `system-architecture.html` | 시스템 구성. 폐쇄망 경계와 그 밖의 외부 검색 API, SSRF 가드 앞의 호출 게이트 | architecture |
| `operator-review-workflow.html` | 운영자 심사 4레인(제출·자동·심사·차단). 비상 차단 시 심사가 이어지는 경로 포함 | workflow v2 |
| `submission-lifecycle.html` | 제출물 상태 전이. 분석 실패·보류가 종료가 아니라 분석으로 복귀하는 두 고리 | lifecycle |
## 알려진 부채
| 항목 | 위치 | 비고 |
|---|---|---|
| `_perceptual_hash` 가 pHash가 아닌 aHash | `analysis/fingerprints.py:47` | 균일/저디테일 이미지 유사도 오탐. 교체 시 0.9 임계 재보정 필요 |
| src 4파일이 800줄 초과 | `store_page_scrape.py`(972) `store_operations.py`(968) `sqlite_store.py`(904) `store_enrichment.py`(853) | 저장소 코어라 분할 리스크 있음. 별도 작업으로 |
| 밴드 임계·풀 상한 미캘리브레이션 | `analysis/risk_scoring.py` | 판정 이력 0건이라 70/30 임계가 실제 판정과 대조된 적 없다. v3 의 두 풀(`WEB_PRESENCE_MAX_POINTS` 60 은 **제공자별** / `WEB_ENTITY_MAX_POINTS` 20)은 신호의 구조적 성질로 정한 값이고 상관 검증 전이다 — 판정 30~50건 후 `decision_stats` 교차표가 첫 근거가 된다 |
| 출처 신호 2종 0점 | `analysis/provenance.py` | 캘리브레이션 전. 실측 코퍼스에서 가치 미입증. 방송캡처는 `MIN_CAPTURE_CATEGORIES=2` 에서 geometry 가 1개 카테고리라 자막 없는 캡처는 구조적으로 도달 불가 |
| 테스트가 로컬 LLM 에 실접속 | `integrations/env_clients.py:260` | `_llm_assistant` 가 항상 `OLLAMA_BASE_URL` 기본값(`127.0.0.1:11434`)으로 클라이언트를 만들므로, provider_runtime 없이 만든 store 를 쓰는 테스트는 **ollama 가 떠 있으면 실호출**을 한다. `test_http_app.py` 3건이 그 상태에서 소켓 타임아웃으로 실패한다(`OLLAMA_BASE_URL=http://127.0.0.1:1` 로 돌리면 통과). 폐쇄망 프로젝트의 테스트가 외부 서비스의 **부재**에 의존하는 상태 |
| 다이어그램에 한글 폰트 미번들 | `docs/diagrams/*.html` | 임베드된 JetBrains Mono 의 unicode-range 6개에 **한글 구간이 없다**(`U+AC00` 매치 0). 폴백 스택은 중국어 폰트뿐(`Noto Sans Mono CJK SC`/`PingFang SC`/`Microsoft YaHei`). 렌더 텍스트의 40~72% 가 한글이라 타깃 폰트에 의존한다 — 한국어 폰트 없는 최소 설치 호스트에서는 두부(tofu). archify 에 저자용 폰트 옵션이 없어 spec 으로는 못 고친다(레이아웃 폭은 전각으로 측정되므로 깨짐이 아니라 글자 문제). 고객 인수물로 확정되면 blocker |
| 뷰어 UI 가 영어 고정 | `docs/diagrams/*.html` | `locale` enum 이 `en`/`zh-CN` 뿐이고 템플릿이 `<html lang="en">` 하드코딩. 저자 내용은 한국어인데 조작 UI·aria 라벨·범례 제목은 영어. 스크린리더가 한글 본문을 영어로 읽는다 |
| architecture spec 만 좌표 하드코딩 | `docs/diagrams/system-architecture.architecture.json` | 컴포넌트 11개 전부 `pos`+`size` 수기, `size` 는 [124,62] 고정. 라벨 21자에서 validate 하드 실패(현재 `store_remote_fetch` 18자 → 여유 2글자). workflow spec 은 기하 하드코딩 0개이므로 같은 저장소에 더 나은 패턴이 있다 |
| lifecycle 기하 손튜닝 8개소 | `docs/diagrams/submission-lifecycle.lifecycle.json` | `labelDy`×5·`channelX`×2·`yOffset`×1 이 현재 배치 전제의 절대 픽셀이다. 상태를 하나 추가하면 **손대지 않은 기존 전이까지** 검증 실패(실증: 오류 5건 중 4건이 기존 `t-reject`). 상태 추가 시 전량 재검증 필요 |
| 7/11 리뷰 연기 항목 | [reviews/2026-07-11-remediation-checklist.md](reviews/2026-07-11-remediation-checklist.md) | 각 항목에 연기 근거 기재 |