docs: 2026-07-24 review report, implementation checklist, spec revision
- Add the 2026-07-24 multi-agent review report (43 confirmed findings, runtime smoke, feature proposals with judge scores) and the implementation checklist tracking what was done vs. deliberately deferred (with reasons). - Record the batch-decision decision: the v1 "no bulk approve/reject" GUI-spec restriction is revised, not silently overridden, and lists the guardrails that made lifting it acceptable. - Fix the README `python -m rights_filter.server` invocation (needs PYTHONPATH=src) and start a docs/INDEX.md continuity log. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E6GD9gxUtfUiVB3JHejGpU
This commit is contained in:
parent
a2d0ef7a36
commit
8b5b3f5cf9
5 changed files with 352 additions and 2 deletions
|
|
@ -34,10 +34,14 @@ cp .env.example .env
|
|||
|
||||
```bash
|
||||
python run_copyrighter_server.py
|
||||
# 또는
|
||||
python -m rights_filter.server --host 127.0.0.1 --port 9500
|
||||
# 옵션은 런처에 그대로 전달됩니다
|
||||
python run_copyrighter_server.py --host 127.0.0.1 --port 9500
|
||||
```
|
||||
|
||||
> `python -m rights_filter.server` 로 직접 실행하려면 `src/` 가 import 경로에 있어야 합니다
|
||||
> (`PYTHONPATH=src`, Windows PowerShell 은 `$env:PYTHONPATH="src"`). 런처는 이 경로 설정과
|
||||
> 작업 디렉터리 고정을 대신 해 주므로 `run_copyrighter_server.py` 사용을 권장합니다.
|
||||
|
||||
주요 옵션: `--host`, `--port`, `--db`(SQLite 경로), `--images`(제출 이미지 폴더), `--static`(운영자 GUI 경로), `--env`(.env 경로).
|
||||
|
||||
기본값으로 실행하면 `http://127.0.0.1:9500` 에서 운영자 콘솔이 열립니다.
|
||||
|
|
|
|||
4
docs/INDEX.md
Normal file
4
docs/INDEX.md
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
# Project INDEX
|
||||
|
||||
- 2026-07-24 · 멀티에이전트 프로젝트 점검(에이전트 109, 적대적 검증 포함) — pytest 377/379, 런타임 스모크 9/9 통과, 신규 CONFIRMED 43건(HIGH 7) + 7/11 체크리스트 중 3건 실수정 확인 + 기능 제안 상위 5 선정 → [docs/reviews/2026-07-24-project-review.md](reviews/2026-07-24-project-review.md)
|
||||
- 2026-07-24 · 위 점검의 전체 remediation + 신규 기능 5종 구현 — HIGH 7·MEDIUM 15·LOW 21 및 F1~F5(키보드 심사·일괄 판정/메모 프리셋·지문 중복 인덱스·판정 내보내기·백업 복구) 완료. cv2 환경 드리프트 복구(OpenCV 5.0.0 제거). **pytest 457 passed**(착수 전 377/2 failed), 실서버 스모크 및 백업 복구 왕복 검증. GUI 스펙의 "일괄 승인/반려 금지(v1)" 제약은 가드레일 조건부로 개정 이력 남기고 해제 → [docs/reviews/2026-07-24-implementation-checklist.md](reviews/2026-07-24-implementation-checklist.md)
|
||||
85
docs/reviews/2026-07-24-implementation-checklist.md
Normal file
85
docs/reviews/2026-07-24-implementation-checklist.md
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
# 2026-07-24 구현 체크리스트 (전체 개선 + 신규 기능)
|
||||
|
||||
> 기준: `docs/reviews/2026-07-24-project-review.md`.
|
||||
> 최종 검증: `pytest tests/rights_filter tests/operator_gui` → **457 passed** (착수 전 377 passed / 2 failed).
|
||||
> 실서버 스모크(포트 9561): 인증·시드·중복인덱스·일괄판정·프리셋·내보내기·백업·복구 CLI 실동작 확인.
|
||||
|
||||
## 배치 0 — 저장소 위생 ✅
|
||||
- [x] 루트 `Microsoft/` 삭제 + `.gitignore` 에 `Microsoft/` 추가
|
||||
- [x] 루트 `tmp_*` 4건 삭제 (git 미추적 확인 후)
|
||||
- [x] 저장소 전체 stale `__pycache__` 삭제 (옛 경로 `Desktop\complete\` 내장분)
|
||||
- [x] `data/logs/edge-profile-static/` 삭제 (612엔트리·27MB)
|
||||
|
||||
## 배치 1 — HIGH ✅
|
||||
- [x] H1 `face_person_detection.py` cv2 호환 가드(경고 1회) + `batch_analyzer` 제출물단위 예외 격리 + **환경 정리**(opencv-python 5.0.0 제거 → 핀 4.13.0.92 복구)
|
||||
- [x] H2 `risk_scoring.py` `match=="search_image"` 명시적 0점 — 왕복 시 0→45 인플레 차단
|
||||
- [x] H3 `store_serialization.py` SEARCH_SKIPPED/ENRICHMENT_FAILURE 를 dedup 시그니처에서 제외
|
||||
- [x] H4 `store_operations.py` rerun 예외 격리 + 'queued' 마커 finally 정리 + `lastRerunDiff.error` 노출
|
||||
- [x] H5 `external_policy.py` 날짜 윈도우 리셋 + Lock (SearchApiPolicy 패턴 미러)
|
||||
- [x] H6 `image_store.py` 매니페스트 행 단위 예외 격리 + width/height 안전 변환
|
||||
- [x] H7 `app.js`/`index.html`/`styles.css` 인증 게이트(401→토큰 입력→헤더 재시도→세션 쿠키)
|
||||
|
||||
## 배치 2 — 큐/스토어 정합성 ✅
|
||||
- [x] `sqlite_store.py` 큐 전역 id 충돌 시 스킵 + 1회성 감사 이벤트(판정 소실·증거 오염 차단)
|
||||
- [x] Vision **실패**만 provider 귀속(`provider: google`) + 레거시 매처 확장. **스킵은 의도적으로 미귀속** — 정책 거부는 '실패'가 아니라 '미실행'
|
||||
- [x] `_search_coverage` 실패-만 제출물을 커버리지에서 제외
|
||||
|
||||
## 배치 3 — enrichment/operations ✅
|
||||
- [x] Vision 정책거부 시 사용량 오계상 + **허위 생체정보 외부전송 감사기록** 제거
|
||||
- [x] 재분석 FACE_PERSON +35 중첩 → 스코어러 단일 버킷
|
||||
- [x] `lastFailure` 를 실제 실패 항목에서 선택 (manual_search / collect 2곳)
|
||||
- [x] 전부 스킵 시 기존 수집 후보 보존(무조건 삭제 제거) + provider 상태는 영속
|
||||
- [x] 수집 후보 카운트 인플레 → id 기준 dedup
|
||||
|
||||
## 배치 4 — HTTP/보안/부팅 ✅
|
||||
- [x] 비-객체/비-UTF8 JSON 바디 → 400 (핸들러 AttributeError 크래시 제거)
|
||||
- [x] 비ASCII Authorization/쿠키 → bytes 비교로 401 (무응답 종료 제거)
|
||||
- [x] `_file()` OSError → 404
|
||||
- [x] `env_file.py` 비인용 값의 인라인 주석 절단(인용 값은 원문 보존)
|
||||
- [x] `__main__.py` stdout 라인 버퍼링 — 리다이렉트 기동 시 보안 경고가 즉시 로그에 남음
|
||||
|
||||
## 배치 5 — analysis ✅
|
||||
- [x] EXIF Orientation 정규화 — 외부 파생물·얼굴크롭·pHash·PIL 폴백 디코드 (파생물 크기를 정규화 이미지 기준으로 재계산). cv2 기본 디코드는 이미 적용됨을 실측 확인
|
||||
- [x] aHash 저엔트로피 근접매칭 차단 (동일 해시는 1.0 유지 — 동일 이미지 매칭 보존)
|
||||
- [x] 프로모터 마커 스캔에서 URL 제외 (title/description 한정)
|
||||
|
||||
## 배치 6 — integrations ✅
|
||||
- [x] Vision 응답 매핑을 예외 경계 안으로 + bestGuessLabels 비-dict 항목 무시
|
||||
- [x] `http_json.py` HTTPError 본문 4KiB 캡
|
||||
- [x] `_probe_connect` 프록시 적용 대상이면 프로브 생략
|
||||
- [x] `search_policy.py` `try_reserve`/`release` 예약형 회계 (쿼터 의미 보존: fetch 실패=환불, 매핑 실패=소비)
|
||||
|
||||
## 배치 7 — domain/admin/governance ✅
|
||||
- [x] LLM_SUMMARY 거버넌스 게이트를 실제 저장 경로에 적용(미근거 요약 폐기)
|
||||
- [x] admin 4개 모듈에 '운영 서버 미연결' docstring 경고
|
||||
- [x] 정상 스킵을 `skipped` 그룹으로 분리 (failures 오분류 제거)
|
||||
- [x] `register_manual_entry` 미지원 필드 조용한 폐기 → ValueError
|
||||
- [x] 반복 반려 시 rejected_image 엔트리 upsert (무한 누적 제거)
|
||||
|
||||
## 배치 8 — 프론트엔드 ✅
|
||||
- [x] 케이스 전환 시 결정 메모/오류/수동쿼리 초기화 (`resetCaseScopedInputs`)
|
||||
- [x] 추천 쿼리 요약 — 실근거만 집계, 스킵/무결과 구분 표기
|
||||
- [x] '주의 후보 보기' → 등록 탭 + watchlist 필터
|
||||
- [x] 케이스 없음 시 `evidence-next-actions` 초기화
|
||||
- [x] hashchange `#corrections` 진입 시 지식 탭 실제 전환
|
||||
- [x] 수동검색 완료 메시지에 대상 케이스 id 명시
|
||||
|
||||
## 배치 9 — 문서 ✅
|
||||
- [x] README `python -m rights_filter.server` PYTHONPATH 안내로 정정
|
||||
- [x] GUI 설계 스펙에 일괄 판정 제약 해제 **개정 이력** 명시
|
||||
|
||||
## 배치 10 — 신규 기능 ✅
|
||||
- [x] **F1 키보드 연속 심사** — J/K 이동, A/H 판정 후 다음 미심사 자동 이동, R 메모 포커스, N 다음 미심사, ? 도움말, 진행 표시. 입력 중에는 비활성
|
||||
- [x] **F2 일괄 판정 + 메모 프리셋** — `POST /api/submissions/decision-batch`(케이스별 트랜잭션·감사·부분실패 보고, 최대 200건, 고위험 확인, 반려 메모 필수) / `GET·POST /api/decision-memo-presets`
|
||||
- [x] **F3 제출물 지문 중복·선례 인덱스** — 시드 시 지문 백필(멱등), exact+pHash≥0.97 클러스터, 큐 배지·그룹 일괄선택, 선례 판정·**판정 불일치** 표시
|
||||
- [x] **F4 판정 내보내기** — `GET /api/decisions/export`(internal / applicant 화이트리스트 분리, CSV+BOM)
|
||||
- [x] **F5 온라인 백업·복구** — `POST /api/admin/backup`(sqlite backup API, WAL 안전) + 보존 로테이션 + `--restore` 오프라인 CLI(복구 전 원본 보존)
|
||||
|
||||
## 의도적 비구현 (사유)
|
||||
- #7 쿠키 Secure — localhost HTTP 에서 인증 붕괴, HTTPS 전제 필요
|
||||
- #8 rate limit — 로컬 단일 운영자 콘솔, 인증으로 대체 커버
|
||||
- #20 `ScoreResult.reasons` tuple — 다수 테스트 `== []` 단언과 충돌
|
||||
- 운영자 역할분리 / 통계 대시보드 — 차점 기능, 다음 사이클
|
||||
- google_search 수동검색 허용 — 2026-06-11 승인된 설계 결정(유지)
|
||||
- aHash → DCT pHash 교체 — 저장된 지문 무효화 + 임계 재보정 필요. 근접매칭 가드로 완화, 별도 마이그레이션 과제
|
||||
- `#12`/M4 락 협소화, M7 스키마 coerce, M9 이관 검증, M8 id 마이그레이션 — 7/11 연기 사유 유효
|
||||
252
docs/reviews/2026-07-24-project-review.md
Normal file
252
docs/reviews/2026-07-24-project-review.md
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
# 2026-07-24 프로젝트 점검 리포트 (멀티에이전트)
|
||||
|
||||
> 세션: 감사자 8 + 런타임 스모크 1 + 연기항목 현황 1 → 발견별 적대적 검증 2~3인 → 기능 제안 4렌즈 16건 → 심사 3인.
|
||||
> 총 에이전트 109. 기준 HEAD: 2c607e0 (fix/2026-07-11-review-remediation).
|
||||
|
||||
## 0. 전체 요약
|
||||
|
||||
- **pytest**: `tests/rights_filter` **377 passed, 2 failed** (실패 2건 모두 cv2 5.0 환경 드리프트 — H-4 참조).
|
||||
- **런타임 스모크**: 실서버 기동(포트 9557) 후 9개 항목 **전부 통과** — 앱셸/보안헤더 3종/정적자산/무토큰 401/인증 GET/케이스 상세/미디어 서빙/404/path traversal 5변형 거부.
|
||||
- **신규 발견**: CONFIRMED 43건 (HIGH 7 · MEDIUM 15 · LOW 21), REFUTED 2건. 모든 발견은 독립 검증자 2인(상충 시 3인)의 반증 시도를 통과함.
|
||||
- **7/11 체크리스트 현황**: 미해결로 기재된 항목 중 3건은 실제로는 이미 수정됨(naver 멀티페이지 쿼터, #10 Origin/Referer CSRF 폴백, #11 잔여 XSS sinks) — 체크리스트 갱신 필요. 완료 표본 검증 5건 전부 실수정 확인.
|
||||
|
||||
## 1. HIGH 발견 (7건) — 상세
|
||||
|
||||
### H-1. cv2 5.0 환경에서 CascadeClassifier AttributeError가 무방비로 전파되어 시드/리로드/임포트/업로드/재분석 전부가 응답 없이 죽음
|
||||
- 위치: `src/rights_filter/analysis/face_person_detection.py:56` (감사: analysis)
|
||||
- 내용: face_person_detection.py:56의 `cv2.CascadeClassifier(f"{cv2.data.haarcascades}...")`는 어떤 try/except에도 보호되지 않는다(33–37행 try는 import만 커버, 디코드 실패는 그 전에 조기 반환하므로 디코드 가능한 모든 이미지에서 도달). OpenCV 5.0.0(사용자 site-packages가 핀 버전 4.13.0.92를 가림)에는 CascadeClassifier가 없어 AttributeError가 발생하고, 이 예외는 서버 최상위까지 아무도 잡지 않는다. 전파 경로: (1) detect → internal_analyzer.py:70 analyze(무보호) → jobs/batch_analyzer.py:70 run(82행이 PreprocessingError만 catch — AttributeError 통과) → sqlite_store.py:282 batch.run → seed_from_image_store → http_app.py:170(/api/submissions/reload)·181(/import-folder)·188(/upload-image). (2) 이미 시드된 DB에서는 그보다 먼저 sqlite_store.py:245 → store_enrichment.py:149 _refresh_existing_local_face_evidence에서 face 증거 없는 첫 제출물에 대해 폭발. (3) sqlite_store.py:248/327·store_enrichment.py:299 → store_enrichment.py:184 _sync_face_crops(176–182행 try는 image_payload만 보호). (4) http_app.py:168 rerun-enrichment → store_operations.py:232 → store_enrichment.py:288 _rerun_internal_analysis. http_app.py do_POST의 except 체인(236–243행)은 _PayloadTooLarge/sqlite3.OperationalError/KeyError/ValueError만 잡으므로 AttributeError는 핸들러를 탈출하고, http.server 표준 동작상 traceback이 서버 콘솔에 찍히며 HTTP 응답 없이 커넥션이 닫힌다. 운영자 증상: 500 JSON도, 분석 실패 밴드도 아닌 브라우저 수준 'Failed to fetch' — 조회/헬스 라우트는 정상이라 앱이 살아있는 것처럼 보이지만 모든 신규 유입·재분석이 무음으로 전멸한다. 부수 피해: rerun은 크래시 전에 lastAnalysis 갱신 + '재분석 요청이 접수됨'(status=queued) 마커 증거를 이미 영속화(store_operations.py:213–229, 타임스탬프 id)하므로 실패한 재시도마다 queued 마커 행이 GUI 증거 목록에 누적되고, 시드는 batch 이전 단계 쓰기(_sync_provider_payloads 등)가 부분 커밋된 채 중단된다. 신규 제출물은 첫 레코드 분석에서 죽으므로 1건도 저장되지 않는다.
|
||||
- 제안 수정: (a) 환경 정리: C:/Users/USER/AppData/Roaming/Python/Python313/site-packages의 OpenCV 5.0.0 제거(pip uninstall opencv-python) 후 핀 버전 opencv-python-headless==4.13.0.92가 보이게 하거나, 서버 실행을 venv + PYTHONNOUSERSITE=1(또는 python -s)로 격리해 user-site 가림을 차단. (b) 코드 방어: _detect_with_opencv에서 `if not hasattr(cv2, "CascadeClassifier") or not hasattr(cv2, "data"): logger.warning(한 번); return FacePersonSignal(0, 0)` 가드 또는 cascade 루프 전체를 try/except Exception으로 감싸 import 실패 폴백과 동일하게 빈 신호로 강등(무음 미탐 방지 위해 warning 로그 필수). 추가로 batch_analyzer/시드 루프에 제출물 단위 예외 격리(기존 'failed' submission 영속화 패턴 재사용)를 두면 한 이미지의 예기치 못한 예외가 시드 전체를 중단시키지 않는다.
|
||||
|
||||
### H-2. 직렬화 라운드트립이 result_url을 url로 별칭해 Google 커스텀 이미지 검색 결과가 리스코어 시 0점→+45점으로 변하고, 텍스트 검색 결과가 무게이트 +35/+45로 밴드를 인플레이션
|
||||
- 위치: `src/rights_filter/analysis/risk_scoring.py:94` (감사: analysis)
|
||||
- 내용: RiskScorer._web_detection_points는 `item.data.get("url")`이 있으면 무조건 45점을 준다(risk_scoring.py:94–95). Google 커스텀 이미지 검색 결과(match="search_image", google_custom_search.py:235)는 신선한 Evidence에는 "url" 키가 없어 0점으로 설계돼 있으나, 영속화 시 _evidence_payload가 result_url을 "url" 필드로 별칭하고(store_serialization.py:251) _domain_evidence_from_ui가 그것을 data["url"]로 복원하므로(store_serialization.py:326–327), 시드 말미의 _rescore_submission(sqlite_store.py:332) 이후에는 동일 증거가 +45점이 된다 — store_serialization.py:264–268 주석이 명시한 '라운드트립은 원 점수를 재현해야 한다' 불변식 위반. 또한 커스텀 웹 검색 결과는 어댑터가 match="page"를 박아(google_custom_search.py:289) Vision의 '이미지 일치 페이지'와 동일한 +35를 받는다. Naver 쪽은 프로모터가 무게이트 텍스트 검색 결과를 0점(context-only)으로 강등하는 것과 정반대로, Google 텍스트 쿼리 결과는 관련성 검증 없이 결과 존재만으로 +45(url 버킷)+35(page 버킷)=+80 → 자동 쿼리가 아무 결과나 반환하는 모든 제출물이 고위험 밴드(≥70)로 부양된다. 오탐 밴드가 운영자 트리아지 가치를 훼손.
|
||||
- 제안 수정: 채점 쪽에서 search_type/provider를 보고 커스텀 검색 결과(match="search_image"·provider=google_custom_search의 match="page")를 명시적으로 0점 처리(유사도 FINGERPRINT 증거가 점수를 담당)하거나, 최소한 _domain_evidence_from_ui가 result_url을 "url"로 복원하지 않도록 분리(payload에 원 data의 url 존재 여부를 별도 보존). 수정 후 기존 DB는 전체 리스코어 1회 필요.
|
||||
|
||||
### H-3. 쿼터로 스킵된(미실행) 쿼리가 dedup 시그니처에 영구 등재되어 auto search가 해당 쿼리를 영원히 재시도하지 않음
|
||||
- 위치: `src/rights_filter/server/store_enrichment.py:636` (감사: enrichment-ops)
|
||||
- 내용: 네이버/구글 어댑터는 쿼터 소진 시 HTTP 요청 없이 SEARCH_SKIPPED evidence를 반환하는데, 이 evidence에도 query_signature가 포함되고(naver_search.py:176, google_custom_search.py:117) _auto_naver_search/_auto_google_custom_search는 이를 그대로 evidence 테이블에 저장한다(store_enrichment.py:661-664, 485-487). SEARCH_SKIPPED는 UI source 'failure'로 직렬화되고(store_serialization.py:422-423), dedup 집합 함수 _existing_naver_query_signatures(store_serialization.py:481-486)와 _existing_google_custom_query_signatures(489-497)는 source 'failure' 행을 포함한다. 결과적으로 다음 auto search/재분석 실행 시 같은 쿼리는 'if signature in existing_signatures: continue'(store_enrichment.py:636-637, 460-461, 블로그/웹 분기 698-701·751-754 동일)로 영구 스킵된다 — 쿼리가 한 번도 실제 실행된 적이 없는데도. ENRICHMENT_FAILURE(일시적 네트워크 오류) 행도 같은 경로로 영구 재시도 차단된다.
|
||||
- 제안 수정: _existing_naver_query_signatures/_existing_google_custom_query_signatures에서 domainSource가 SEARCH_SKIPPED(및 필요 시 ENRICHMENT_FAILURE)인 행을 제외하거나, 스킵 evidence를 저장할 때 querySignature를 비워 기록한다.
|
||||
|
||||
### H-4. 이미지 결손 submission에서 rerun_enrichment가 부분 상태 오염(lastAnalysis 갱신 + 영구 'queued' 마커) 후 미처리 예외로 실패
|
||||
- 위치: `src/rights_filter/server/store_operations.py:232` (감사: enrichment-ops)
|
||||
- 내용: rerun_enrichment는 lastAnalysis를 먼저 갱신하고(213-214) '재분석 요청이 접수됨' status='queued' 마커 evidence를 저장한(216-229) 뒤에야 _rerun_internal_analysis(232)를 호출하는데, 이 함수는 image_store.image_payload(submission_id)를 보호 없이 호출한다(store_enrichment.py:288-291, _rerun_google_image_search:310도 동일). image_payload는 manifest 레코드가 없으면 KeyError(image_store.py:65), 파일이 삭제되었으면 FileNotFoundError를 던진다. KeyError는 http_app.py:240-241에서 404 'not found'로 응답되어(submission은 존재하는데) 오해를 유발하고, FileNotFoundError는 do_POST에서 포착되지 않아 응답 없이 커넥션이 끊긴다. 어느 쪽이든 마커 evidence는 영구 'queued' 상태로 잔존하고(타임스탬프 id라 재시도마다 1개씩 누적), lastRerunDiff는 기록되지 않는다. 같은 코드베이스의 _sync_face_crops(store_enrichment.py:176-181)는 동일 상황을 'expected for some rows'라며 명시적으로 처리하고 있어 이미지 결손 행의 존재는 실제로 예상되는 조건이다.
|
||||
- 제안 수정: rerun_enrichment에서 image_payload 로드를 사전 검증(try/except 후 마커를 실패 상태로 갱신하고 ValueError로 변환)하거나, 마커 저장 전에 이미지 가용성을 확인한다.
|
||||
|
||||
### H-5. 인증 토큰 설정 시 운영자 콘솔 전체 동작 불능 — 프론트에 인증 수단 부재
|
||||
- 위치: `web/operator-gui/app.js:118` (감사: frontend)
|
||||
- 내용: apiJson()은 Content-Type 헤더만 설정하고 Authorization 헤더를 절대 보내지 않으며, index.html/app.js 어디에도 토큰 입력 UI가 없다(web/operator-gui 전체에서 'Authorization|Bearer|token' grep 0건). 백엔드 http_app.py:280-310은 COPYRIGHTER_AUTH_TOKEN이 설정되면 /api/* 와 /media/* 등 모든 데이터 라우트에 Bearer 헤더 또는 세션 쿠키를 요구하는데, 세션 쿠키는 '헤더 인증에 성공한 요청'의 응답에서만 발급된다(http_app.py:291-296). 즉 브라우저가 쿠키를 얻을 경로가 제품 안에 존재하지 않는다. README는 단일 호스트 개발 외에는 반드시 토큰을 설정하라고 지시하므로, 문서대로 배포하면 /api/bootstrap가 401을 반환해 큐·썸네일·케이스 화면 전부가 'API 연결 실패: unauthorized' 상태로 죽는다. http_app.py:287의 주석('so the operator can enter the token')이 전제하는 토큰 입력 흐름이 프론트에 구현된 적이 없다.
|
||||
- 제안 수정: 최소 구현: GUI 최초 로드 시 401이면 토큰 입력 프롬프트/화면을 띄우고, 입력 토큰으로 Authorization 헤더를 붙여 /api/bootstrap 1회 호출해 세션 쿠키를 발급받은 뒤(이후 요청은 쿠키로 동작) sessionStorage 등에 보관하지 않는 방식으로 처리.
|
||||
|
||||
### H-6. ExternalApiPolicy 일일 한도 윈도우 리셋 부재 — 한도 도달 시 Vision 영구 비활성화
|
||||
- 위치: `src/rights_filter/integrations/external_policy.py:24` (감사: integrations)
|
||||
- 내용: ExternalApiPolicy.calls_made 는 단조 증가만 하고 날짜 경계 리셋이 없다. COPYRIGHTER_GOOGLE_DAILY_LIMIT 에 도달하면 다음날에도 can_call() 이 계속 'external API usage limit reached' 를 반환해 Google Vision Web Detection 이 서버 재기동 전까지 영구 비활성화된다. 이 정책 객체는 서버 기동 시 1회 생성되는 싱글턴이다(env_clients.py:110, server/__main__.py:42, sqlite_store.py:83)이고 store_enrichment.py:315,402 의 모든 Vision 호출이 이 인스턴스를 공유한다. SearchApiPolicy 는 정확히 같은 버그를 _roll_window_locked() 로 이미 수정했고(search_policy.py:21-25 주석: 'once the limit is hit, the provider stays disabled until the process restarts'), ExternalApiPolicy 만 누락됐다. 부수적으로 SearchApiPolicy 와 달리 락도 없어 record_call() 의 calls_made += 1 이 멀티스레드 서버에서 증분 유실될 수 있다.
|
||||
- 제안 수정: SearchApiPolicy 패턴 미러링: window_day 필드 + date.today() 비교 리셋을 can_call/record_call 양쪽에 추가하고 threading.Lock 으로 감싼다.
|
||||
|
||||
### H-7. 큐 전환/폴더 임포트 시 동일 id(파일명 stem) 제출물이 다른 큐의 판정·이력을 덮어쓰고 증거가 교차 오염됨
|
||||
- 위치: `src/rights_filter/server/sqlite_store.py:253` (감사: store-core)
|
||||
- 내용: _seed_from_image_store_locked 는 신규 여부를 '현재 큐의 제출물 id 집합'(sqlite_store.py:253-260, _all(queue_id=queue_id))으로만 판정하지만, submissions 테이블의 PK 는 큐와 무관한 전역 id 이고 _put 은 on conflict(id) do update 업서트(store_persistence.py:67-92)다. 운영자가 /api/submissions/import-folder 로 다른 폴더(큐 B)를 임포트할 때 큐 A 에 이미 존재하는 id(파일명 stem 또는 manifest id)와 같은 제출물이 있으면 '신규'로 분류되어 새 분석 payload 로 기존 행을 통째로 덮어쓴다. 이때 (1) 큐 A 에서 기록한 decisionStatus(승인/반려/보류)·memo·queryHistory 가 unreviewed 초기값으로 소실되고, (2) 행이 삭제가 아니라 갱신이므로 큐 A 이미지에서 나온 기존 evidence 행들이 그대로 남아 같은 id 의 큐 B 제출물(다른 이미지)에 붙어 표시·재채점(_rescore_submission)에 섞여 들어가 잘못된 위험 점수를 만든다. 큐를 다시 A 로 전환하면 같은 메커니즘으로 재분석·재덮어쓰기가 반복된다. 테스트(test_sqlite_store_switches_active_queue_when_importing_from_different_folders)는 서로 다른 id 만 사용해 이 케이스를 커버하지 않는다. 알려진 M8(id=path.stem 유지, image_store stem-충돌 접미어 되돌림)과 인접하지만, M8 은 한 폴더 내 stem 충돌 항목이고 이 건은 seed 의 큐-단위 중복 판정 vs 전역 PK 업서트 불일치라는 별도 경로다.
|
||||
- 제안 수정: seed 에서 records 필터 시 큐 무관 전역 id 존재 여부를 함께 조회해, 다른 큐에 같은 id 가 살아 있으면 (a) 신규 id 로 접미어를 부여해 삽입하거나 (b) 해당 레코드를 건너뛰고 감사 이벤트로 충돌을 기록한다. 최소한 덮어쓰기 전 기존 행의 queue_id 가 다르면 기존 evidence 를 삭제/이관하고 판정 상태를 보존해야 한다.
|
||||
|
||||
## 2. MEDIUM 발견 (15건)
|
||||
|
||||
- **EXIF Orientation을 어디서도 적용하지 않음 — 회전 저장된 스마트폰 사진의 얼굴 미탐 + 외부 파생물이 옆으로 누운 채 태그까지 제거되어 전송** — `src/rights_filter/analysis/preprocessing.py:139`
|
||||
- 파이프라인 전체가 EXIF Orientation을 무시한다: (1) face_person_detection.py:40 cv2.imdecode는 EXIF를 적용하지 않으므로(imread와 달리) Orientation 6/8로 저장된 세로 폰 사진의 얼굴이 90° 누운 픽셀로 Haar frontal/profile 캐스케이드에 들어가 대부분 미탐 → FACE_PERSON 증거(+35점)·얼굴 크롭·face-crop 웹 검색이 전부 누락. (2) _reencode_without_metadata(preprocessing.py:139–153)는 ImageOps.exif_transpose 없이 재인코딩하면서 Orientation 태그를 포함한 EXIF를 제거하므로, 외부 API(Google Vision)에는 회전 보정 불가능한 옆으로 누운 이미지가 전송돼 웹 매칭 품질이 저하된다. (3) pHash도 원시 방향 픽셀로 계산되므로 웹에서 가져온(대개 방향이 구워진) 검색 결과 이미지와의 유사도 비교(_sync_search_result_image_similarity)가 회전 불일치로 미탐된다. 스마트폰 원본 사진은 Orientation 태그 저장이 일반적이라 발생 빈도가 높
|
||||
- 수정: 디코드 직후 한 곳에서 방향 정규화: _decode 경로(face_person_detection)·_reencode_without_metadata·_perceptual_hash에서 PIL 열기 시 ImageOps.exif_transpose(image) 적용(재인코딩은 이미 태그를 제거하므로 transpose 후 저장이면 충분). cv2 경로는 PIL 폴백을 우선하거나 EXIF Orientation을 읽어 cv2.rotate로 보정.
|
||||
- **'phash'가 실제로는 8x8 평균 해시(aHash)라 균일/저디테일 이미지끼리 유사도 1.0으로 충돌 — 지식베이스·검색결과 유사도 +80 고위험 오탐** — `src/rights_filter/analysis/fingerprints.py:47`
|
||||
- _perceptual_hash(fingerprints.py:34–51)는 DCT 기반 pHash가 아니라 8x8 평균 해시다. 픽셀이 평균과 같으면 비트 1이 되므로(50행 `pixel >= average`) 완전 균일 이미지는 색과 무관하게 전부 0xffffffffffffffff로 해시된다 — 검은 단색과 흰 단색이 유사도 1.0. 저디테일 이미지(흰 배경 제품컷, 플레이스홀더/빈 썸네일, 단순 로고)끼리도 상위 비트 대부분이 일치해 0.9 임계(internal_analyzer.py:19 threshold, risk_scoring.py:20 `similarity >= 0.9` → +80)를 쉽게 넘는다. 결과: (1) 지식베이스에 배경이 밋밋한 샘플이 있으면 무관한 플랫 이미지 제출물이 'Knowledge base image similarity 1.00' +80 → 즉시 고위험 밴드 오탐. (2) 검색 결과 이미지 유사도(store_search_candidates.py:386, provider_runtime 임계 기반)도 빈/플레이스홀더 썸네일과 플랫 제출물 간 허위 매치 생성.
|
||||
- 수정: 최소 방어로 해시 엔트로피 가드 추가(비트 popcount가 0/64에 가까우면 — 예: ≤4 또는 ≥60 — 유사도 비교에서 제외하거나 0 반환). 근본적으로는 DCT pHash(32x32→8x8 DCT 저주파) 또는 dHash로 교체하고 명칭 'phash' 접두어를 실제 알고리즘과 일치시켜 오해를 제거. 임계 0.9는 알고리즘 교체 후 재보정.
|
||||
- **재분석 시 검출 결과가 달라지면 구 FACE_PERSON/유사도 증거가 남은 채 신규 행이 추가되고 스코어러에 FACE_PERSON 중복 제거가 없어 +35가 중첩** — `src/rights_filter/server/store_enrichment.py:292`
|
||||
- _rerun_internal_analysis(store_enrichment.py:288–295)는 analyze() 결과를 _put만 하고 이전 internal 증거를 무효화하지 않는다. 증거 id는 (source, reason, data) 해시(store_serialization.py:410–412)이므로 face_count가 바뀌면(데이터 dict 변경) 새 행이 생기고 구 행은 active로 남는다. RiskScorer의 FACE_PERSON 분기(risk_scoring.py:26–28)는 WEB_DETECTION/NAVER처럼 버킷 dedup이 없어 행마다 +35를 더한다 — 얼굴만 있는 무해한 사진이 재분석 1회로 35(중간)→70(고위험 경계)이 된다. reason은 동일 문자열이라 _unique로 합쳐져 운영자에게는 사유 1줄에 점수만 뛴 것으로 보인다. 지식베이스 유사도 증거도 reason에 유사도 2자리가 박혀('similarity 0.93' vs '0.91') 같은 방식으로 중복 +80이 쌓인다(상한 100이라 밴드 영향은 제한적). 트리거: 운영자가 동일 파일명으로 이미지를 교체 후 재분석(id=path.stem 유지 — M8), 또는 O
|
||||
- 수정: _rerun_internal_analysis에서 신규 증거 _put 전에 해당 제출물의 source in {fingerprint(knowledge/face 관련), face} 기존 internal 증거를 superseded 상태로 전환하거나 삭제(LLM 요약의 _delete 패턴 재사용). 또는 RiskScorer FACE_PERSON에 단일 버킷을 도입해 행 수와 무관하게 1회만 +35.
|
||||
- **LLM_SUMMARY 저장 게이트가 프로덕션에서 호출되지 않고, 실제 검증은 항상 참이 되도록 구현되어 정책이 무력화됨** — `src/rights_filter/governance/policies.py:110`
|
||||
- assert_operator_evidence_payload_allowed()의 LLM_SUMMARY 분기(source_urls/source_evidence_ids 없는 요약 저장을 ValueError로 금지, test_policies.py:47이 이를 고정)는 src 전역에서 DataClass.LLM_SUMMARY로 호출되는 곳이 한 곳도 없다(호출은 전부 SEARCH_EVIDENCE: naver_search.py:330,386,439 / google_custom_search.py:238,292). 실제 저장 경로는 두 갈래 모두 게이트를 우회한다: (a) analysis/llm_assistance.py:81-93은 근거 없는 요약을 verified=False Evidence로 만들어 반환하고, server/store_operations.py:406-411(_ensure_llm_summary)은 게이트 호출 없이 그대로 evidence 테이블에 저장한다. (b) 프로덕션에서는 _domain_evidence_from_ui(store_serialization.py:319)가 모든 source evidence에 evidence_id(sqlite 저장 id, _ev
|
||||
- 수정: _ensure_llm_summary 저장 직전에 assert_operator_evidence_payload_allowed(DataClass.LLM_SUMMARY, llm_evidence.data) 호출을 추가하고, verified 판정은 evidence_id 존재가 아니라 URL 근거(source_urls) 유무 기준으로 좁히거나 정책 함수 쪽 주석/테스트를 실제 완화된 규칙(verified 플래그 저장 허용)으로 갱신해 정의-런타임 불일치를 해소.
|
||||
- **admin 결정·정정·프레젠터 계층 전체가 운영 서버에 미연결된 병렬 구현으로 남아 실서버 동작과 의미가 갈라짐** — `src/rights_filter/admin/review_handlers.py:42`
|
||||
- record_operator_decision, correct_rejected_decision, detailed_review_for, register_manual_entry, operator_summary_for의 호출자는 tests/ 뿐이며(프로젝트 전역 grep 확인) 운영 서버는 server/store_operations.py에 별도 구현을 갖는다. 이미 의미가 갈라져 있다: (a) admin 계층은 REJECTED에서만 지식 엔트리를 만들고 HELD는 아무것도 만들지 않지만, 실서버는 held/rejected 모두 watchlist 엔트리를 upsert한다(store_operations.py:170-176). (b) admin 계층은 결정·정정 시 감사 이벤트를 전혀 남기지 않지만 실서버는 add_audit_event를 기록한다. (c) admin 계층은 반려마다 새 id의 엔트리를 무한 생성하지만(아래 별도 발견) 실서버는 _stable_id로 1개를 upsert한다(store_operations.py:151). 트리거: 2026-05-25 plan 문서가 이 계층을 '타깃 admin 앱이 그대로 렌더/이식할 계약(contract)'으로 규정하고 있어
|
||||
- 수정: admin 계층을 (1) 삭제하고 테스트를 실서버 경로로 이관하거나, (2) 유지한다면 각 모듈 docstring에 '운영 서버 미사용 — store_operations.py가 사실 소스' 경고를 명시하고 HELD/감사 의미를 실서버와 일치시킬 것.
|
||||
- **Google Vision 호출이 정책 거부(EXTERNAL_SKIPPED)로 실행되지 않아도 사용량 +1 계상되고, 생체 이미지가 '외부 전송됨'이라는 허위 감사 이벤트가 기록됨** — `src/rights_filter/server/store_enrichment.py:398`
|
||||
- CloudVisionWebDetectionAdapter.detect는 policy.can_call()이 거부하면 HTTP 요청 없이 EXTERNAL_SKIPPED evidence만 반환한다(cloud_vision_web_detection.py:108-117). 그런데 _rerun_google_image_search는 무조건 call_count = 1로 시작하고(317), _google_face_crop_web_detection은 detect 결과를 보지 않고 크롭마다 call_count += 1 한다(398). 두 카운트 모두 _apply_provider_usage_delta('google', ...)로 usage에 가산된다(351). 반면 _auto_naver_search/_auto_google_custom_search는 'any(item.source != SEARCH_SKIPPED)'로 실제 요청 여부를 확인한 뒤에만 계상한다(469, 645). 더 나쁜 것은 face crop 경로에서 call_count>0이면 'Personal image sent to external provider' 감사 이벤트가 기록되는데(415-425), 정책이 거부해 크롭이 실
|
||||
- 수정: naver/google_custom 경로와 동일하게 `any(item.source != EvidenceSource.EXTERNAL_SKIPPED for item in ...)`일 때만 call_count를 증가시키고, 감사 이벤트도 실제 전송된 크롭 수 기준으로 기록한다.
|
||||
- **manual_search·collect_keyword_candidates의 lastFailure에 실패 항목이 아닌 domain_evidence[0](대개 성공 결과)의 reason이 기록됨** — `src/rights_filter/server/store_operations.py:359`
|
||||
- manual_search(359)와 collect_keyword_candidates(660)는 evidence 중 하나라도 SEARCH_SKIPPED/ENRICHMENT_FAILURE면 `provider_payload['lastFailure'] = domain_evidence[0].reason`을 기록한다. 그러나 domain_evidence[0]은 캐스케이드의 첫 항목(주 검색의 첫 성공 결과)이지 실패 항목이 아니다. _auto_naver_search(823-830)와 _auto_google_custom_search(581-588)는 `next(item.reason for item in ... if item.source in 실패집합)`으로 올바르게 실패 항목의 reason을 찾는 것과 대비된다.
|
||||
- 수정: auto 경로와 동일하게 next()로 실패 소스 항목의 reason을 선택한다.
|
||||
- **collect_keyword_candidates: 검색이 전부 쿼터 스킵/실패해 수집 결과가 0건이어도 기존 collection_candidates 전량 무조건 삭제** — `src/rights_filter/server/store_operations.py:667`
|
||||
- 수집 트랜잭션은 `delete from collection_candidates`를 무조건 실행한 뒤 pending을 재삽입한다(666-669). 검색 캐스케이드가 전부 SEARCH_SKIPPED(쿼터 소진)나 실패로 끝나면 pending이 비어 있으므로, 운영자가 이전에 수집해 검토 중이던 후보 목록(promoted 이력 행 포함)이 대체 결과 없이 전량 삭제된다. '이전 쿼리의 후보를 새 수집으로 교체'하는 설계 의도(596-599 주석)는 새 결과가 있을 때만 성립하는데, 실패 경로에서도 동일하게 청소가 실행된다. 특히 쿼터 소진 시점에는 재수집으로 복구할 수도 없다.
|
||||
- 수정: pending이 비어 있고 provider_call_count가 0(또는 전부 스킵)이면 삭제 없이 오류/빈 결과만 반환하거나, 최소한 스킵으로 인한 0건일 때는 기존 행을 보존한다.
|
||||
- **케이스 전환 시 결정 메모·메모 오류 미초기화 — 타 케이스 메모가 반려/보정 사유로 기록됨** — `web/operator-gui/app.js:2343`
|
||||
- selectCase()는 suggestedQueryRunSummary만 리셋하고 #decision-memo textarea와 #memo-error를 초기화하지 않는다. setDecision()은 전송 성공 후에만 memo를 비우므로(app.js:2408), 운영자가 케이스 A의 반려 사유를 적다가 제출 전에 큐에서 케이스 B를 클릭하면 A용 메모가 그대로 남아 있고, B에서 '반려'를 누르면 requiresMemo 검증을 A의 메모가 통과시켜 케이스 B의 결정 레코드·감사 로그에 A의 사유가 영구 기록된다.
|
||||
- 수정: selectCase()에서 document.getElementById('decision-memo').value = '' 와 memo-error 초기화를 추가.
|
||||
- **추천 쿼리 실행 요약이 스킵/실패/결과없음 행을 '근거 N건 완료'로 오보고 (skipped 분기 dead code)** — `web/operator-gui/app.js:1048`
|
||||
- executeSuggestedQueries()는 응답 queryHistory[0].count를 근거 수로 보고하는데, 백엔드 count는 len(domain_evidence)로 SEARCH_SKIPPED(쿼터 차단)·ENRICHMENT_FAILURE·'returned no results' 마커 Evidence 행을 모두 포함한다(store_operations.py:344-353). naver 어댑터는 어떤 경우에도 최소 1개의 마커 행을 반환하므로(naver_search.py:166-179 스킵, 292-306 결과없음) resultCount는 항상 ≥1이고, resultCount===0일 때만 실행되는 skipped 감지 분기(app.js:1049-1052)는 사실상 도달 불가다. 결과: 네이버 쿼터가 소진된 상태에서 '모두 실행'을 누르면 이미지→블로그→웹문서 3연쇄 스킵으로 count=3이 되어 요약에 '"쿼리" 완료(근거 3건)'이 표시되고, 결과가 실제로 없어도 '완료(근거 1건)'으로 표시된다. 판정 보조 도구가 근거 확보 성공을 허위로 보고하는 셈이다. 쿼리 이력 행의 'N건' 표시(app.js:1857)도 같은 의미 왜곡을 공유한다.
|
||||
- 수정: review.evidence에서 해당 query와 일치하는 항목 중 source!=='failure'이고 'returned no results'가 아닌 실제 근거만 세어 보고하거나, 백엔드 count를 실근거 수로 바꾸고 skip/failure 여부를 별도 필드로 반환.
|
||||
- **'주의 후보 보기' 버튼이 수동 등록 탭으로 이동 — 라벨과 동작 불일치** — `web/operator-gui/app.js:3279`
|
||||
- 워크벤치 보조 작업의 #add-from-case 버튼 라벨은 '주의 후보 보기'(index.html:217)지만 핸들러 addKnowledgeEntryFromCase()는 knowledge 뷰의 'manual'(수동 기준 등록) 탭으로 이동한다. watchlist(주의 후보) 항목은 '등록된 기준' 탭에서 상태 필터로 보는 구조이므로, 운영자는 주의 후보 목록 대신 빈 수동 등록 폼을 보게 된다. 함수명이 원래 용도(케이스에서 기준 등록)를 드러내는 것으로 보아 라벨 개편 시 핸들러 목적지가 갱신되지 않은 것이다.
|
||||
- 수정: switchKnowledgeTab('registered') + knowledgeFilters.status='watchlist' 설정 후 renderKnowledgeBase() 호출(및 #knowledge-status-filter 값 동기화)로 변경하거나, 버튼 라벨을 실제 동작에 맞게 되돌린다.
|
||||
- **JSON 바디가 객체가 아니면(리스트/문자열/null) 핸들러가 AttributeError로 크래시 — 응답 없이 연결 종료** — `src/rights_filter/server/http_app.py:341`
|
||||
- _body()는 json.loads() 결과를 dict 검증 없이 그대로 반환한다(-> dict[str, object] 타입 표기만 있고 실제 검사 없음). 유효한 JSON이지만 객체가 아닌 바디(`[]`, `"x"`, `123`, `null`)를 보내면 do_POST/do_PATCH의 body.get(...) 또는 store.register_manual_knowledge_entry(body) 등에서 AttributeError가 발생하는데, 핸들러는 _PayloadTooLarge/OperationalError/KeyError/ValueError만 잡으므로 예외가 socketserver까지 전파되어 클라이언트는 HTTP 응답 없이 연결이 끊기고 서버 콘솔에는 트레이스백이 찍힌다. 잘못된 JSON 문법은 400으로 처리되는 것(test_malformed_json_body_returns_400)과 비대칭인 경계 검증 구멍.
|
||||
- 수정: _body()에서 `parsed = json.loads(...)` 후 `if not isinstance(parsed, dict): raise ValueError("JSON object body required")` 추가 — 기존 400 경로로 수렴.
|
||||
- **submissions.json 매니페스트의 잘못된 행 1개(루트 밖 경로)가 큐 전체의 불러오기/재분석을 ValueError로 마비시킴** — `src/rights_filter/server/image_store.py:38`
|
||||
- submission_records()의 리스트 컴프리헨션은 각 매니페스트 레코드에 대해 _safe_path(record["file"])를 호출하는데, 파일이 없는 레코드는 .exists() 필터로 조용히 건너뛰면서 루트 밖을 가리키는 레코드는 _safe_path가 ValueError("manifest path points outside image store")를 던져 전체 호출이 실패한다. submissions.json은 운영자가 손으로 편집하는 문서화된 워크플로(docs/operations/copyrighter-operation-worklist.md:129 'submissions.json에 ID, 제목… 직접 등록')이므로, 절대 경로(예: 원본 위치 C:\Users\...\a.jpg)를 붙여넣는 실수 하나로 '새 제출 불러오기'(/api/submissions/reload·upload-image·import-folder 경유 seed_from_image_store)와 rerun-enrichment(image_payload)가 모두 400으로 실패한다. 에러 메시지에 문제 레코드가 명시되지 않아 어느 행이 원인인지도 알 수 없다 — 결함 행만 스킵하는 기존
|
||||
- 수정: 컴프리헨션 대신 루프에서 레코드별 try/except ValueError로 결함 행을 스킵(로그로 해당 record["file"] 명시)하고, width/height도 안전 변환(int 실패 시 1) 적용.
|
||||
- **README 대체 실행 커맨드 `python -m rights_filter.server` 가 그대로는 실행 불가 (ModuleNotFoundError)** — `C:/Users/USER/Desktop/munsang/copyrighter/README.md:38`
|
||||
- README 실행 섹션이 `python run_copyrighter_server.py` 의 대안으로 `python -m rights_filter.server --host 127.0.0.1 --port 9500` 을 안내하지만, 프로젝트는 src/ 레이아웃이고 pyproject.toml/setup.py/setup.cfg 가 없으며 rights_filter 는 어떤 site-packages 에도 설치되어 있지 않다. 따라서 repo 루트에서 이 커맨드를 그대로 실행하면 즉시 ModuleNotFoundError 로 실패한다. PYTHONPATH=src 가 필요하다는 언급이 문서 어디에도 없어, 문서만 보고 따라 하는 운영자/납품 검수자가 실행 실패를 겪는다. 나머지 문서 항목은 정확함: CLI 옵션 6종(--host/--port/--db/--images/--static/--env)은 src/rights_filter/server/__main__.py:28-33 과 일치, .env.example 43개 변수는 src 코드 참조와 양방향 완전 일치(코드에만 있거나 예시에만 있는 변수 0건), Pretendard 폰트 번들(web/operator-gui/assets/fo
|
||||
- 수정: README.md:38 을 `PYTHONPATH=src python -m rights_filter.server ...` (Windows: `set PYTHONPATH=src`) 로 수정하거나, 해당 대안 커맨드 줄을 제거하고 `python run_copyrighter_server.py` 에 동일 옵션을 전달하는 예시로 통일.
|
||||
- **Vision 응답 매핑이 어댑터 try 경계 밖 — 비정형 bestGuessLabels 시 쿼터 차감 후 요청 전체 중단** — `src/rights_filter/integrations/cloud_vision_web_detection.py:134`
|
||||
- CloudVisionWebDetectionAdapter.detect 는 client.detect_web 만 try 로 감싸고, policy.record_call() 후의 _map_response() 는 try 밖에서 실행한다. detect_web 이 정규화하는 필드들과 달리 best_guess_labels 는 API 응답의 bestGuessLabels 를 원본 그대로 통과시키므로(97행), 리스트에 dict 가 아닌 항목이 섞이면 _map_response 211행 `label.get("label", "")` 에서 AttributeError 가 발생해 어댑터 밖으로 전파된다. 호출부 _rerun_google_image_search(store_enrichment.py:312)에도 국소 try 가 없어 enrichment 요청 전체가 중단되며, 쿼터는 이미 record_call 로 차감된 상태다. google_custom_search.py:125-126 은 정확히 이 실패 모드를 막으려고 매핑을 try 안으로 옮겼다는 주석까지 있는데(‘Map inside the try so a malformed payload becomes ENRICHMENT_FAILURE ins
|
||||
- 수정: google_custom_search 와 동일하게 record_call 후 매핑을 try 안으로 이동하거나, detect_web 에서 bestGuessLabels 도 dict 항목만 필터링해 정규화한다.
|
||||
- **Google Vision 실패/스킵 증거가 provider 에 귀속되지 않아 providerState 가 '미실행'으로 오표시됨** — `src/rights_filter/server/store_serialization.py:281`
|
||||
- CloudVisionWebDetectionAdapter 는 FAILURE/EXTERNAL_SKIPPED 증거의 data 에 submission_id 만 넣는다(cloud_vision_web_detection.py:110-129). _evidence_payload 의 domain 은 data.get("provider", data.get("domain", "internal")) 폴백이라 이 증거들의 UI payload 가 source="failure", domain="internal" 로 저장된다. _evidence_matches_provider(store_serialization.py:183-194)의 google 분기는 source == "google" 을 요구하므로 Vision 호출 실패 증거는 어느 provider 에도 매칭되지 않는다(네이버는 domain, google_search 는 domain=google_custom_search 로 매칭돼 실패도 귀속되는 것과 비대칭). 결과: (1) _external_provider_state_for_submission 이 매칭 증거 0건으로 'not_run' 을 반환해 Vision API 가 실제로 실패한 제출
|
||||
- 수정: cloud_vision_web_detection.py 의 FAILURE/EXTERNAL_SKIPPED 증거 data 에 "provider": "google" 을 포함시키거나, _evidence_matches_provider 의 google 분기에서 source == "failure" and domain == "google" 도 매칭하도록 확장(기존 저장 행 호환을 위해 후자 병행 권장).
|
||||
|
||||
## 3. LOW 발견 (21건)
|
||||
|
||||
- **프로모터가 result_url을 마커 스캔에 포함해 URL 경로/호스트 토큰(game, cover, album 등)만으로 무관한 결과를 0.8 승격 → +40점** — `src/rights_filter/analysis/search_result_promoter.py:32`: _promote_item의 마커 스캔 텍스트에 result_url이 포함된다(search_result_promoter.py:32–35). ASCII 마커는 \b 경계 매칭인데 URL에서는 '.'과 '/'가 단어 경계라 'game.naver.com', '/cover/', '/album/', '/character/' 같은 흔한 경로·호스트 토큰이 마커 'game'/'cover'/'album'/'character'에 걸린다. 본문·제목에 권리 신호가 전혀 없는 결과도 URL 구조만으로 promoted=true, confidence 0.8이 되어 _naver_search_points에서 round(50*0.8)=+40점과 'n
|
||||
- **record_operator_decision 반복 반려 시 동일 제출물의 rejected_image 지식 엔트리가 무한 누적** — `src/rights_filter/admin/review_handlers.py:56`: 동일 submission_id에 REJECTED 결정을 재기록할 때마다(재검토, 정정 후 재반려 등) create_rejected_image_entry(domain/records.py:242-253)가 매번 새 id('knowledge-N')의 엔트리를 무조건 생성한다. 기존 활성 rejected_image 엔트리 존재 여부를 확인하지 않아 동일 지문의 활성 엔트리가 중복 축적되고, internal_analyzer(analysis/internal_analyzer.py:42-68)는 엔트리별로 별도 FINGERPRINT evidence를 만들어 동일 매치가 N중으로 표시된다(RiskScorer의 FINGERPRINT 경로
|
||||
- **detailed_review_presenter가 정책상 정상인 스킵(EXTERNAL_SKIPPED/SEARCH_SKIPPED)을 'failures' 증거 그룹으로 오분류** — `src/rights_filter/admin/detailed_review_presenter.py:64`: _group_name()이 명시 열거되지 않은 모든 소스를 'failures'로 귀속시켜, 쿼터 도달·제공자 비활성 같은 의도된 운영 스킵(EXTERNAL_SKIPPED, SEARCH_SKIPPED)이 실패 증거 그룹에 표시된다. 같은 항목이 _provider_status()에도 다시 나타나 이중 표기된다. 실서버 스코어러는 동일 소스를 '위험 신호 아님·운영 상태'로 명시 구분하는 것(analysis/risk_scoring.py:70-72 주석 및 0점 처리)과 상반된 표시 의미론이다. 트리거: 이 프레젠터 출력을 렌더링하는 소비자(계약상 타깃 admin UI)는 '외부 API 비활성'을 분석 실패로 오인해 불필요한
|
||||
- **register_manual_entry가 **payload 추가 인자를 검증 후 조용히 폐기** — `src/rights_filter/admin/knowledge_base_handlers.py:23`: **payload로 받은 추가 키는 assert_no_biometric_template 검사에만 쓰이고 엔트리 생성(create_manual_knowledge_entry)에 전달되지 않아 무예외로 소실된다. 트리거: 호출자가 명명 인자 오타(예: policy_note=... 또는 alias=...)를 내면 TypeError 대신 payload로 흡수돼 해당 데이터가 등록 엔트리에서 조용히 누락 — 운영자는 메모/별칭이 저장됐다고 오인한다. 현재 테스트 전용 경로라 LOW.
|
||||
- **수집 후보 카운트 인플레이션: 동일 candidate_id가 pending에 중복 적재되어 collected 수가 실제 저장 행 수보다 크게 보고됨** — `src/rights_filter/server/store_operations.py:615`: candidate_id는 _stable_id('cand', provider, type, query, image_url, thumbnail_url, result_url)로 생성되며 rank/title이 포함되지 않는다(store_search_candidates.py:615). 같은 이미지 URL이 검색 결과 여러 순위에 등장하면(검색 결과에서 흔함) 동일 id의 후보가 pending에 2회 이상 적재되어 collected가 그만큼 증가하지만, 트랜잭션의 _put은 upsert라 실제로는 1행만 저장된다. GUI에 반환되는 payload['collected']와 감사 이벤트의 'N candidates'가 실제 후보 목록 행
|
||||
- **케이스 없음 렌더 시 '근거 보강 추천' 패널 잔존 (evidence-next-actions 미초기화)** — `web/operator-gui/app.js:1309`: renderNoSelectedCase()는 file-facts·similar-strip·evidence-groups 등은 비우지만 #evidence-next-actions는 초기화하지 않는다. 제출 목록이 비게 되면(빈 폴더로 import-folder 전환, 또는 초기 API 실패 후 clearRuntimeData) '표시할 케이스가 없습니다' 옆에 이전 케이스의 추천 쿼리 패널('바로 실행'/'모두 실행' 버튼 포함)이 그대로 남는다. 버튼을 눌러도 getSelectedCase()가 undefined라 아무 일도 일어나지 않아 죽은 컨트롤이 노출된다.
|
||||
- **hashchange 경유 #corrections 진입 시 지식 탭 패널이 전환되지 않음** — `web/operator-gui/app.js:4013`: hashchange 리스너는 switchView()만 호출한다. switchView('corrections')는 state.knowledgeTab='corrections'로 상태만 바꾸고 switchKnowledgeTab()을 호출하지 않아(app.js:2229-2235), 페이지 리로드 없이 URL 해시를 #corrections로 바꾸면 knowledge 뷰는 열리지만 탭 패널은 기존 탭(기본 '후보 수집')에 머문다. 이후 상태 변수(knowledgeTab)와 실제 표시 패널이 어긋난다. 최초 로드 시에는 init()이 마지막에 switchKnowledgeTab을 호출해 정상이다.
|
||||
- **수동 검색 완료 메시지가 케이스 전환 후 도착하면 다른 케이스 문맥으로 표시** — `web/operator-gui/app.js:2492`: runManualSearch()는 시작 시점의 케이스에 결과를 귀속시키지만(정상), 응답 도착 시 '쿼리 결과가 현재 케이스 증거에 추가되었습니다'를 #manual-query-status(케이스별로 리렌더되지 않는 고정 엘리먼트)에 쓴다. 검색은 외부 API+LLM 요약으로 수 초 걸리므로 그 사이 운영자가 케이스 B로 전환하면, B의 검색 이력 탭을 보는 중에 A에 추가된 결과를 '현재 케이스'에 추가했다는 메시지가 표시되고 B용으로 입력하던 쿼리 입력값도 지워진다(queryInput.value=''). selectCase()가 manual-query/manual-query-status를 리셋하지 않는 것도 같은 잔존
|
||||
- **비ASCII Authorization 헤더가 hmac.compare_digest TypeError를 유발 — 401 대신 무응답 연결 종료 (인증 전 원격 트리거)** — `src/rights_filter/server/http_app.py:291`: COPYRIGHTER_AUTH_TOKEN이 설정된 상태에서 보호 라우트로 비ASCII 바이트가 포함된 Authorization 헤더를 보내면(http.server는 헤더를 latin-1로 디코드하므로 0x80-0xFF 바이트가 비ASCII str이 됨) hmac.compare_digest(str, str)가 TypeError('comparing strings with non-ASCII characters is not supported')를 던진다. 이 예외는 _require_auth 어디에서도 잡히지 않아 클라이언트는 401 대신 빈 응답(연결 리셋)을 받고 서버 콘솔에 트레이스백이 반복 출력된다. 인증 없이 원격에서
|
||||
- **미디어/정적 파일 서빙 중 OSError(파일 잠금·삭제 레이스·권한) 미처리 — 404/503 대신 연결 종료** — `src/rights_filter/server/http_app.py:377`: _file()은 path.exists() 확인 후 path.read_bytes()를 호출하는데, 그 사이 파일이 삭제되거나(face-crop 보존기간 purge, 운영자 폴더 정리) Windows에서 다른 프로세스가 파일을 배타 잠금한 경우(대용량 이미지를 data/submissions로 복사하는 중 GUI가 /media 폴링, 백신 스캔 등) PermissionError/OSError가 발생한다. do_GET은 ValueError/KeyError/OperationalError만 잡으므로 예외가 전파되어 응답 없이 연결이 끊기고 트레이스백이 출력된다. 운영 콘솔에서는 이미지 타일이 깨진 채 원인 없는 네트워크 오류로 보
|
||||
- **.env 값 뒤 인라인 주석(# ...)이 값에 그대로 포함됨 — 토큰/키가 조용히 오염** — `src/rights_filter/server/env_file.py:23`: load_env_file은 `KEY=value # comment` 형식에서 `# comment`를 값의 일부로 저장한다(일반 dotenv 구현은 비인용 값 뒤 인라인 주석을 제거). 운영자가 `COPYRIGHTER_AUTH_TOKEN=abc123 # 운영용` 처럼 주석을 붙이면 토큰이 'abc123 # 운영용'이 되어 GUI 토큰 입력이 이유 없이 401로 실패하고, API 키면 외부 검색이 인증 오류로 실패한다 — 경고·에러 없이 조용히 어긋나므로 원인 추적이 어렵다. 인용값+주석(`"abc" # x`)이면 첫/끝 문자가 달라 따옴표 제거도 무산되어 따옴표까지 값에 남는다. .env.example에는 인라인 주석이 없
|
||||
- **루트 미추적 Microsoft/Windows/PowerShell/ModuleAnalysisCache — PowerShell 캐시 잔재, ignore 미커버로 커밋 오염 위험** — `C:/Users/USER/Desktop/munsang/copyrighter/Microsoft/Windows/PowerShell/ModuleAnalysisCache:?`: 정체: PowerShell 모듈 분석 캐시(6,072바이트 바이너리, 2026-07-11 08:19 생성, 디렉터리 내 유일 파일). 정상 위치는 %LOCALAPPDATA%\Microsoft\Windows\PowerShell\ModuleAnalysisCache 인데, PSModuleAnalysisCachePath 가 상대경로로 잡혔거나 LOCALAPPDATA 가 비어 있는 환경에서 powershell 이 당시 cwd(repo 루트) 기준으로 캐시를 쓴 잔재다(생성일이 7/11 커밋 작업일과 일치 — 자동화 도구가 환경변수 없이 powershell 을 띄운 흔적). 문제: git status 에 ?? Microsoft/
|
||||
- **__pycache__ stale: 테스트 pyc 19개가 옛 경로 Desktop\complete\copyrighter 를 내장, mtime 보존으로 pytest 가 계속 재사용** — `C:/Users/USER/Desktop/munsang/copyrighter/tests/rights_filter/admin/__pycache__/test_correction_handlers.cpython-313-pytest-8.4.2.pyc:?`: tests/ 하위 19개 pytest assertion-rewrite 캐시(.cpython-313-pytest-8.4.2.pyc)가 co_filename 으로 이전 프로젝트 경로 `C:\Users\USER\Desktop\complete\copyrighter\...` 를 내장하고 있다(src/ 쪽 pyc 는 0건 — 이후 재생성됨). 소스 .py 와 pyc 의 mtime 이 동일(예: 둘 다 5/25 09:43)해 프로젝트 이동 시 mtime 이 보존되었고, pytest 캐시 유효성 검사(소스 mtime+size)가 통과하므로 캐시가 계속 재사용된다. 트리거 시나리오: 해당 19개 파일(test_naver_search,
|
||||
- **루트 tmp_* 잔재 4건 — git 미추적(ignore 정상), 로컬 정리만 필요** — `C:/Users/USER/Desktop/munsang/copyrighter/tmp_fail3.sqlite3:?`: tmp_dbg_img/(images/ + submissions.json, 6/10), tmp_fail3.sqlite3(106KB, 5/27), tmp_server_err.log·tmp_server_out.log(둘 다 0바이트, 5/28) 네 건 모두 git 추적되지 않고 .gitignore 가 정확히 커버한다(tmp_* 및 tmp_dbg_img/ 패턴, *.sqlite3·*.log 로도 이중 커버). 저장소 오염 없음 — 순수 로컬 디스크 클러터로 삭제해도 무방하다. 유일한 주의점: tmp_fail3.sqlite3 는 5/27 실패 재현용 DB 스냅샷으로 보이므로 아직 참조하는 디버깅 절차가 없는지 확인 후 삭제.
|
||||
- **data/logs/edge-profile-static/ 612엔트리·27MB 브라우저 프로필 — git 미추적 확인(data/ 전체 ignore), 디스크 정리 대상** — `C:/Users/USER/Desktop/munsang/copyrighter/data/logs/edge-profile-static:?`: data/logs/edge-profile-static/ 는 Edge 브라우저 프로필 전체(Default/, extensions_crx_cache/, Local State, Variations 등 총 612 엔트리, 27MB)로 2026-05-27 00:33 브라우저 자동화(E2E/스크린샷) 실행이 남긴 잔재다. 우려했던 git 추적 오염은 없음: .gitignore:23 의 `data/` 가 디렉터리 전체를 커버하고 `git ls-files data/` 는 0건이다. 따라서 저장소 위생 문제가 아니라 27MB 로컬 디스크 점유 문제이며 삭제해도 무방하다(다음 브라우저 실행 시 재생성). 참고: 브라우저 프로필에는 세션
|
||||
- **HTTPError 응답 본문 읽기에 크기 캡 미적용 — 에러 경로에서 10MiB 캡 우회** — `src/rights_filter/integrations/http_json.py:55`: 성공 경로는 response.read(_MAX_RESPONSE_BYTES + 1) 로 10MiB 캡을 강제하지만, HTTPError 처리(53-58행)의 `exc.read()` 는 무제한으로 전체 본문을 메모리에 적재한다. 이 캡은 커밋 이력상 '적대적/오동작 API 의 메모리 고갈 방지' 목적으로 도입됐는데(17행 주석) 4xx/5xx 응답에는 적용되지 않아 위협 모델이 절반만 커버된다. 또한 읽은 detail 전문이 HttpJsonError 메시지 → Evidence.reason 으로 그대로 흘러가 초대형 에러 본문이 DB/GUI 에 저장될 수 있다.
|
||||
- **SearchApiPolicy 쿼터 check-then-act 비원자 — 동시 요청 버스트 시 daily_limit 초과 가능** — `src/rights_filter/integrations/search_policy.py:30`: can_call() 과 record_call() 은 각각 락을 잡지만 예약(reserve) 개념이 없어, can_call 통과 → 외부 HTTP 요청(수 초) → record_call 사이 창에서 다른 스레드가 같은 잔여 쿼터로 can_call 을 통과할 수 있다. 잔여 1건 상태에서 N개의 동시 enrichment/수동 검색이 모두 통과하면 daily_limit 를 N-1 만큼 초과해 실제 과금/제공자 쿼터를 소모한다. 연기 항목 #12(store_operations rerun_enrichment/run_auto_search 락 부재)와 결합하면 실제로 도달 가능한 경로이나, 정책 객체 자체의 결함이므로 별도 기재한다
|
||||
- **_probe_connect 가 시스템 프록시를 우회 — 프록시 필수망에서 원격 Ollama 오탐 실패** — `src/rights_filter/integrations/http_json.py:78`: connect_timeout 설정 시 수행되는 사전 TCP 프로브가 socket.create_connection 으로 대상 호스트에 직결을 시도한다. 실제 요청을 수행하는 urlopen 은 HTTP(S)_PROXY 환경변수를 따르므로, 아웃바운드가 프록시로만 허용된 망에서 원격 호스트를 가리키면 urlopen 으로는 성공할 요청이 프로브 단계에서 'connection to host:port failed' 로 전부 실패한다. 현재 connect_timeout 을 쓰는 곳은 llm_assistance.py:35 의 Ollama 클라이언트(기본 127.0.0.1:11434)뿐이라 기본 구성에서는 무해하지만, OLLAMA_BA
|
||||
- **stdout 리다이렉트 기동 시 시작 진단 로그가 전혀 기록되지 않음 (블록 버퍼링, flush 부재)** — `src/rights_filter/server/__main__.py:41`: __main__.py 의 시작 print 6종(41행 .env 경로, 43행 provider readiness, 51/53행 COPYRIGHTER_AUTH_TOKEN 경고, 60행 static 경고, 73-75행 listening/DB/이미지 경로)이 flush 없이 출력된다. stdout 이 tty 가 아닌 파일로 리다이렉트되면(서비스형 백그라운드 기동, Task Scheduler, `> log` 등) Python 이 블록 버퍼링(8KB)을 적용해 이 ~500바이트가 버퍼에 갇히고, 서버가 정상 가동 중에도 로그 파일이 0바이트로 남는다. 프로세스가 강제 종료되면(비정상 크래시, taskkill) 버퍼가 폐기되어 영구
|
||||
- **_search_coverage 가 검색 실패 증거만 있는 제출물도 '커버됨'으로 집계** — `src/rights_filter/server/sqlite_store.py:462`: _search_coverage 는 naver/google/google_search 커버리지를 _evidence_matches_provider 매칭 존재 여부만으로 판정한다(sqlite_store.py:462-467). 네이버·구글 맞춤 검색의 실패 증거(source="failure", domain="naver"/"google_custom_search")도 매칭되므로, 해당 provider 검색이 전부 실패한 제출물이 evidenceSubmissions 와 coverageSubmissions(커버리지율)에 '검색 근거 확보'로 포함된다. 같은 함수의 llm 버킷은 _provider_item_has_result 로 실제 결
|
||||
|
||||
## 4. 반증되어 기각된 발견 (기록용)
|
||||
|
||||
- **검색 결과 title/description이 이스케이프 없이 LLM 프롬프트에 삽입되는 프롬프트 인젝션 표면 — 운영자 요약문 조작 가능** (`src/rights_filter/analysis/llm_assistance.py`) — REFUTED: REFUTED: 인젝션 "유입"은 부분적으로 실재하지만, 발견이 주장하는 핵심 증상 — 조작된 LLM 요약문이 GUI에 운영자 판단 보조로 표시되어 오해를 유발 — 은 현재 코드에서 도달 불가능하다.
|
||||
|
||||
1) 조작된 요약문은 운영자에게 절대 표시되지 않음 (핵심 반증):
|
||||
- LLM 출력은 llm_assistance.py:99-105에서 Evidence.data["summary"]에 담기지만, 영속화 시 store_operations.py:407-411이 _evidence_payload(store_serialization.py:249-309)로 직렬화하는데 이 payload에는 "summary" 키가 존재하지 않는다. 즉 LLM이 생성한 텍스트는 DB에 저장되는 순간 폐기된다. grep 결과 "summary"
|
||||
- **correct_rejected_decision이 존재하지 않거나 무관한 decision_id에 대해 조용히 성공 형태(빈 리스트)로 반환** (`src/rights_filter/admin/correction_handlers.py`) — REFUTED: REFUTED: 코드 동작 자체는 발견 기술과 일치함을 확인했다: correction_handlers.py:9-14는 repository.operator_decision() 존재 확인도 REJECTED 상태 확인도 없는 단일 위임이고, domain/records.py:227-234는 매칭 엔트리 0건이면 예외 없이 빈 리스트를 반환하며 231-233에서 이미 비활성인 엔트리의 deactivation_reason도 무조건 덮어쓴다. OperatorDecision(records.py:152-168)에 정정 표식 필드도 없다. 그러나 발견이 제시한 트리거 시나리오는 도달 불가능하다. 전 저장소 grep 결과 correct_rejected_decision의 호출자는 tests/rights_filter/admin/t
|
||||
|
||||
## 5. 7/11 리메디에이션 체크리스트 현황 재확인
|
||||
|
||||
| 항목 | 상태 | 비고 |
|
||||
|---|---|---|
|
||||
| #12 rerun_enrichment/run_auto_search 락 | still-open | store_operations.py:202-312 — 두 메가함수 모두 _write_lock 없이 submission RMW(_get→수정→_put) 수행, 락은 manual_search(334)부터만 사용 |
|
||||
| M4 seed 락 협소화 | still-open | sqlite_store.py:229-234 — seed_from_image_store 전체가 여전히 _write_lock 하나로 감싸져 있음(협소화 미착수, 의도된 연기 상태 유지) |
|
||||
| M7 store_schema.py 제약 재구축 위반 행 coerce/격리 | still-open | store_schema.py:246-251 — insert..select 단일 복사만 있고 위반 행 coerce/격리 없음, 레거시 위반 행 존재 시 마이그레이션 전체가 raise로 중단(원본은 보존) |
|
||||
| M9 store_queue.py 레거시 이관 파일 존재 검증 | still-open | store_queue.py:53-61 — _migrate_legacy_submissions_to_queue가 queue_id='' 행을 파일 존재 확인 없이 무조건 새 큐로 update |
|
||||
| M8 image_store.py stem-충돌 되돌림(incumbent-id 보존 마이그레이션) | still-open | image_store.py:71,81-85 — id=path.stem 복귀 상태 유지, 동일-stem 형제 충돌 및 시드 incumbent-id 보존 마이그레이션 미착수(주석으로 문서화됨) |
|
||||
| M naver_search.py 멀티페이지 쿼터 실제 요청 수 | fixed | naver_search.py:164-183,206-226,250-270 — _requested_calls(client, '*_pages')를 policy.can_call/record_call에 전달해 멀티페이지가 쿼터에 반영됨(빈 페이지 조기중단 시 계획 페이지 수만큼 보수적 과금) |
|
||||
| M store_remote_fetch.py SSRF DNS-rebinding TOCTOU | still-open | store_remote_fetch.py:76-111 — getaddrinfo로 검증 후 urlopen이 별도로 재해석(IP 미고정, TOCTOU 창 존재); 리다이렉트 재검증(88-93)은 추가됐으나 rebinding은 미해결 |
|
||||
| LOW EUC-KR 스니핑/dead code | still-open | store_page_scrape.py:842,850 — decode('utf-8', errors='replace')만 사용, EUC-KR/cp949 스니핑 코드 없음(레포 전체 grep 무매치); dead code는 레이아웃 테스트 고정으로 유지 결정대로 |
|
||||
| #7 쿠키 분리+Secure | still-open | http_app.py:349-350 — 단일 세션 쿠키에 HttpOnly; SameSite=Strict만 있고 Secure 속성 없음(HTTPS 전제 연기 사유 그대로) |
|
||||
| #8 rate limit | still-open | http_app.py — rate/throttle/429 관련 코드 전무, 엔드포인트 속도 제한 미구현 |
|
||||
| #10 Origin/Referer CSRF 폴백 | fixed | http_app.py:303-324 — 쿠키 인증 상태변경 메서드에 _origin_ok() 검사 구현(Origin 우선, Referer 폴백, 둘 다 없으면 허용하는 명시적 설계) |
|
||||
| #11 잔여 XSS sinks(similar-strip/face-crop safeUrl, innerHTML label) | fixed | app.js:1374(similar-strip img src=escapeHtml(safeUrl)), 1389-1390(face-crop safeUrl), 684·1360 등 label 보간도 escapeHtml 적용 — 잔여 sink grep에서 미이스케이프 동적 label 무발견 |
|
||||
| #24 window.prompt → 인라인 | still-open | app.js:3433,3449 — 비활성/재활성 사유 입력에 여전히 window.prompt 사용 |
|
||||
| M21 8개 delegated mutation 공용 in-flight 가드 | still-open | app.js:3772 이하 — toggleProvider/deactivate*/setEvidenceStatus/promote* 등 delegated 핸들러에 공용 가드 없음, in-flight 가드는 setDecision(2358-2418)에만 존재(#17 범위) |
|
||||
| env_clients.py LLM assistant OLLAMA_* 게이팅 | still-open | env_clients.py:244-250 — _llm_assistant가 OLLAMA_* 미설정이어도 기본값(127.0.0.1:11434)으로 무조건 생성(연기 사유였던 env={} llm 테스트 의존 그대로) |
|
||||
| package.json 정리 | still-open | package.json은 initial commit 이후 무변경({private,type:module}), node_modules/(@oai, lucide) 잔존 — 정리 미착수 |
|
||||
| #14 잔여 무음 예외 swallow(page scrape 등) | still-open | store_page_scrape.py:308,386,431 — except Exception: return [] 무로깅 swallow 3곳 잔존(873 fingerprint 검사 포함 시 4곳) |
|
||||
| #20 ScoreResult.reasons tuple | still-open | src/rights_filter/domain/records.py:75 — reasons: list[str] = field(default_factory=list) 그대로(연기 사유였던 == [] 테스트 단언 의존 유지) |
|
||||
| checklist 하단 중복 Batch 7/8 섹션(94-107행) | fixed | 체크리스트 94-107행의 [ ] 항목들은 상단 [x] 항목의 낡은 중복 — 예: env_file.py:12-14 utf-8-sig 실적용 확인. 문서 정리만 필요(코드 이슈 아님) |
|
||||
| spot:H6 store_serialization promoted/promotionReason | fixed | store_serialization.py:269-271 영속화, 340-341 복원 + domainSource(269, 364) 및 레거시 폴백(371) 확인 |
|
||||
| spot:H2 store_persistence usage delta 병합 | fixed | store_persistence.py:231-246 — _write_lock 안에서 fresh row 재읽기 후 델타 필드만 병합, stale payload 되쓰기 제거 확인 |
|
||||
| spot:M5 업로드 TOCTOU 배타 생성 | fixed | http_app.py:460-474 — open(candidate, 'xb') 배타 생성 + FileExistsError 시 -N 접미어 재시도 루프 확인 |
|
||||
| spot:#6 앱셸 CSP/X-Frame-Options/nosniff | fixed | http_app.py:393-411 — 정적/앱셸 응답에 nosniff, X-Frame-Options: DENY, Content-Security-Policy 송출 확인 |
|
||||
| spot:H9 dismiss 서버 영속화 엔드포인트 | fixed | http_app.py:217-219 — /api/collections/candidates/{id}/dismiss 라우트가 store.dismiss_collection_candidate 호출로 서버 영속화 확인 |
|
||||
|
||||
## 6. 런타임 스모크 테스트
|
||||
|
||||
서버 런타임 스모크 테스트 완료 (포트 9557, PID 23864, 테스트 후 자체 종료). 9개 검증 항목 전부 통과: 기동 성공, 앱셸 200 + 보안 헤더 3종(CSP/X-Frame-Options/nosniff), 정적 자산 정상 서빙, 무토큰 데이터 라우트 401 거부, 토큰 인증 시 bootstrap(245KB)·review-queue(188KB)·케이스 상세(36KB) 정상 JSON, /media 이미지 서빙 정상(untrusted 미디어 전용 sandbox CSP + no-store + no-referrer 헤더 확인), 미존재 라우트 404, path traversal 전 변형(원문 /../, %2F 인코딩, %2e%2e 인코딩, media/static 양쪽) 거부. 신규 발견 1건(LOW): stdout 을 파일로 리다이렉트해 백그라운드 기동하면 Python 블록 버퍼링 때문에 시작 진단 출력(COPYRIGHTER_AUTH_TOKEN 미설정 경고 포함)이 로그 파일에 전혀 기록되지 않음 — 서버 가동 1분 이상 경과 후에도 로그 0바이트를 실측으로 확인.
|
||||
|
||||
| 체크 | 결과 |
|
||||
|---|---|
|
||||
| 서버 기동 성공 | PASS — python run_copyrighter_server.py --port 9557 (COPYRIGHTER_AUTH_TOKEN 54자 설정) → PID 23864 이 127.0.0.1:9557 LISTENING, /he |
|
||||
| GET / 앱셸 200 + 보안 헤더 | PASS — 200 text/html (25695바이트, index.html). X-Content-Type-Options: nosniff, X-Frame-Options: DENY, Content-Security-Policy(de |
|
||||
| 정적 자산 로드 | PASS — /app.js 200 text/javascript; charset=utf-8 (103404바이트, 파일 크기와 일치) + 앱셸 CSP/XFO/nosniff 헤더 동일 적용. /styles.css 200 text/cs |
|
||||
| 토큰 없이 데이터 라우트 거부 | PASS — 무토큰 GET /api/bootstrap → 401 {"error":"unauthorized"}. 무토큰 GET /media/x.png → 401. 보호 프리픽스(/api/, /media/)가 실동작으로 확인됨. |
|
||||
| 토큰 포함 부트스트랩/큐 GET | PASS — Authorization: Bearer 헤더로 /api/bootstrap 200 (245825바이트, 유효 JSON — submissions 6건, providers 5건, 키: auditEvents/collecti |
|
||||
| 케이스 상세 GET | PASS — /api/submissions/3/review → 200 유효 JSON (36594바이트, id=3, evidence/queryHistory/providerState/fileFacts 등 12개 키). |
|
||||
| 이미지 서빙 엔드포인트 | PASS — /media/images/3.jpg → 200 image/jpeg 5056바이트. untrusted 미디어 전용 헤더 확인: nosniff + sandbox CSP(default-src 'none') + Cache- |
|
||||
| 미존재 라우트 404 | PASS — /api/no-such-route → 404 {"error":"not found"}. |
|
||||
| path traversal 거부 | PASS — 5개 변형 모두 거부, 파일 유출 없음(size=0): /media/../copyrighter.sqlite3(--path-as-is) → 400, /media/..%2Fcopyrighter.sqlite3 → 400, |
|
||||
| 기동 프로세스 정리 | PASS — 저장한 PID 23864 를 python 프로세스임을 확인 후 해당 PID 만 Stop-Process. 종료 후 9557 LISTENER 없음(TIME_WAIT 잔여만). 파괴적 POST 미실행 — GET + 앱셸만 |
|
||||
|
||||
## 7. 신규 기능 제안 — 심사 통과 상위 5
|
||||
|
||||
제안 16건을 심사자 3인(운영자/기술 리드/제품 책임자 렌즈)이 채점. 지문(pHash) 계열 3건은 3인 만장일치로 단일 로드맵 병합 권고.
|
||||
|
||||
### ① 키보드 연속 심사 모드 (M)
|
||||
**키보드 연속 심사 모드** [operator-efficiency]
|
||||
- 무엇: 케이스 심사 화면에 키보드 단축키 레이어를 추가한다. J/K(또는 방향키)로 현재 큐 필터·정렬 순서 그대로 이전/다음 케이스 이동, A/H/R로 승인·보류·반려(반려는 메모 필드로 포커스 이동), 판정 저장 성공 시 다음 미심사 케이스로 자동 이동. 상단에 '현재 n/전체 · 남은 미심사 m건' 진행 표시와 ? 키 도움말 오버레이를 넣고, 입력 필드 포커스 중에는 단축키를 비활성화한다.
|
||||
- 가치: 지금은 케이스 하나 판정할 때마다 큐 화면 복귀 → 행 클릭 → 판정 버튼 클릭의 마우스 왕복이 고정 비용이다. 이를 키 2번(판정 키 → 자동 다음 이동)으로 줄여, 하루 수십~수백 건 연속 처리에서 케이스 간 이동 시간을 거의 제거한다.
|
||||
- 인사이트: web/operator-gui 전체를 grep해도 keydown 핸들러가 단 하나도 없고, 판정 후 화면이 해당 케이스에 그대로 머문다는 사실을 확인했다. filteredSubmissions()·selectCase()·setDecision()이 이미 완결된 형태로 존재하므로 서버 변경 없이 순수 프론트 작업만으로 연속 심사 플로우를 완성할 수 있다 — 효율 대비 구현 리스크가 가장 낮은 항목.
|
||||
- 기반: web/operator-gui/app.js의 filteredSubmissions()/selectCase()(line 2345)/setDecision() 및 index.html decision-pane의 data-decision 버튼. 서버 변경 없음 — 기존 POST /api/submissions/{id}/decision 재사용.
|
||||
|
||||
### ② 선택 케이스 일괄 판정 + 판정 메모 프리셋 (M+S)
|
||||
**선택 케이스 일괄 판정 (가드레일 포함)** [operator-efficiency]
|
||||
- 무엇: 큐에 이미 있는 선택 체크박스(data-bulk-id)를 판정에도 연결한다. POST /api/submissions/decision-batch 엔드포인트를 추가해 선택 케이스들에 공통 메모와 함께 승인/보류/반려를 일괄 적용하고, 케이스별 성공/실패 목록을 반환한다. 가드레일: 보류/반려는 기존 record_decision의 케이스별 watchlist 생성 로직을 그대로 통과시키고, 선택에 high 위험 케이스가 섞여 있으면 개수를 명시한 확인 단계를 거친다.
|
||||
- 가치: '내부 근거만 있고 외부 매칭 0건' 같은 명백한 저위험 무리를 한 번에 승인할 수 있다. 수십 건에 대해 케이스 진입 → 판정 클릭을 반복하던 것이 체크 → 일괄 판정 1회로 줄고, 부분 실패 시 어느 건이 남았는지 즉시 보인다.
|
||||
- 인사이트: 체크박스 선택 인프라(selectedBulkSubmissionIds, app.js:2578)와 일괄 '재분석'은 이미 있는데 일괄 '판정'만 빠져 있다 — UI 절반이 이미 깔려 있는 상태. 서버 쪽도 record_decision(sqlite_store.py:580)이 케이스 단위 락·감사이벤트·watchlist 생성을 완결적으로 캡슐화하고 있어, 배치 엔드포인트는 검증 + 루프 + 결과 집계만 추가하면 된다.
|
||||
- 기반: src/rights_filter/server/sqlite_store.py record_decision(케이스별 트랜잭션·감사·watchlist 로직 재사용), src/rights_filter/server/http_app.py do_POST 라우팅, web/operator-gui/app.js selectedBulkSubmissionIds()와 큐 체크박스, index.html decision-actions 패턴.
|
||||
|
||||
**판정 메모 프리셋 (자주 쓰는 사유 원클릭)** [operator-efficiency]
|
||||
- 무엇: 결정 메모 textarea 위에 프리셋 칩 줄을 추가한다. 운영팀 공용 프리셋(제목+본문)을 DB에 저장하는 GET/POST /api/decision-memo-presets 소형 라우트를 만들고, 칩 클릭 시 메모에 삽입, '현재 메모를 프리셋으로 저장' 버튼을 제공한다. 반려 시 메모 필수 검증(memo-error)과 자연 연동되어 프리셋 선택만으로 반려 요건을 충족한다.
|
||||
- 가치: 반려는 메모가 서버에서 강제되는데 실제 사유는 '연예인 초상 포함', '방송 캡처', '기준DB 반려 이미지와 동일' 등 소수 패턴의 반복이다. 케이스당 타이핑을 클릭 1회로 줄이고, 팀 내 사유 문구가 표준화되어 감사 로그와 보정 기록의 가독성도 함께 올라간다.
|
||||
- 인사이트: record_decision(sqlite_store.py:589)이 rejected에 memo.strip() 비어있음 거부를 강제하는데 GUI는 빈 textarea만 제공한다 — 반복 사유 타이핑이 반려 처리의 실질 병목임을 코드로 확인했다. audit_events에 'decision · memo' 형태로 과거 메모가 이미 쌓여 있어, 초기 프리셋 후보를 과거 반려 메모 빈도에서 자동 추출해 시딩하는 확장도 가능하다.
|
||||
- 기반: web/operator-gui/index.html decision-pane(#decision-memo, #memo-error), app.js setDecision 및 메모 검증 로직, src/rights_filter/server/http_app.py 신규 소형 라우트 2개, sqlite_store의 기존 JSON payload 테이블 패턴(providers/app_state와 동일한 키-값 저장 재사용).
|
||||
|
||||
### ③ 제출물 지문 중복·선례 인덱스 (L, 3제안 병합 로드맵)
|
||||
**동일·유사 제출 자동 묶음 (중복 그룹)** [operator-efficiency]
|
||||
- 무엇: seed/bootstrap 시 미심사 제출물끼리 SHA-256 완전일치와 pHash 고유사(기본 임계 0.97 이상)를 버킷팅해 submission payload에 duplicateGroup 필드로 직렬화한다. 큐 행에 '동일 이미지 N건' 배지를 표시하고, 배지 클릭 시 그룹 전체의 체크박스를 자동 선택해 기존 선택 재분석·일괄 판정(기능 2)으로 바로 이어지게 한다. 케이스 심사 화면에도 '같은 이미지 제출 N건' 링크를 노출한다.
|
||||
- 가치: 같은 스톡/연예인 이미지를 여러 신청자가 반복 제출하는 패턴에서 첫 건만 제대로 심사하고 나머지는 그룹 단위로 처리한다. 중복 건마다 외부 근거를 처음부터 다시 읽는 시간이 사라지고, 같은 이미지에 운영자별로 상반된 판정이 나가는 일관성 사고도 구조적으로 방지된다.
|
||||
- 인사이트: pHash 비교가 기준DB 대조와 검색결과 이미지 대조에는 쓰이면서 정작 큐 안의 제출물끼리는 한 번도 비교되지 않는다는 비대칭을 확인했다. store_search_candidates.py의 _submission_perceptual_fingerprint(line 193)가 제출 이미지 지문 계산 경로를 이미 제공하므로, 실제 신규 작업은 큐 스코프 버킷팅(수백 건 O(n²) 해밍 비교로 충분)과 직렬화 필드 하나다.
|
||||
- 기반: src/rights_filter/analysis/fingerprints.py FingerprintService, src/rights_filter/server/store_search_candidates.py _submission_perceptual_fingerprint 패턴, sqlite_store.seed_from_image_store/bootstrap 및 store_serialization.py _submission_payload 직렬화, web/operator-gui/app.js 큐 행 렌더링(app.js:1220대)과 data-bulk-id 체크박스.
|
||||
|
||||
**재제출·중복 제출 선례 자동 연결 (prior-case dedupe link)** [automation-integration]
|
||||
- 무엇: seed 시 신규 제출의 SHA-256/pHash 지문을 기존 제출 건들의 저장된 fingerprint 증거와 대조해, 일치(exact 동일 또는 pHash≥임계치) 시 '이전 케이스 연결' 증거를 자동 생성한다. 증거에는 선행 케이스 ID·당시 판정·메모 링크를 담고, 케이스 심사 화면과 큐 행에 '선례: SUB-xxxx 승인/반려' 칩을 표시한다. exact 동일 재제출은 외부 검색(Google Vision/Naver) 재실행을 건너뛰고 선행 케이스의 증거를 참조하도록 해 쿼터를 절약한다. 최종 판정 자동 변경은 하지 않는다(운영 경계 유지).
|
||||
- 가치: 현재 지식DB 매칭은 보류/반려로 만들어진 주의 후보만 커버한다. 승인됐던 이미지나 아직 미결인 케이스와 동일한 이미지가 다시 들어오면 전체 분석이 다시 돌고(외부 API 쿼터 소모) 운영자는 선례를 모른 채 처음부터 재심사한다. 선례 칩 하나로 '지난주에 이미 승인한 그 이미지'를 즉시 식별하고 판정 일관성도 확보된다.
|
||||
- 인사이트: internal_analyzer 가 exact(SHA-256)·perceptual 지문을 이미 모든 제출의 evidence 로 저장하고 있어(internal_analyzer.py:36-37, evidence 테이블 idx_evidence_source 인덱스 존재) 새 분석 없이 저장된 데이터 간 조인만으로 구현된다. 즉 이것은 탐지 모델 고도화가 아니라 이미 계산된 신호를 케이스 간에 연결하는 순수 워크플로 기능이며, exact-dupe 외부 검색 스킵은 문서에 명시된 '페이지 수만큼 API 호출량 증가' 비용 문제를 직접 줄인다.
|
||||
- 기반: src/rights_filter/server/sqlite_store.py seed 경로(_seed_from_image_store_locked)와 evidence 테이블, src/rights_filter/analysis/internal_analyzer.py + fingerprints.py 의 저장 지문, src/rights_filter/analysis/risk_scoring.py (선례 증거 가중), web/operator-gui/app.js 큐/케이스 렌더링
|
||||
|
||||
**재제출·중복 클러스터 감지 (지문 유사도 매트릭스)** [insight-analytics]
|
||||
- 무엇: 전 제출의 pHash를 상호 비교(64bit 해밍 거리, 수천 건 규모에서 전수 비교 가능)해 유사도 임계 이상 클러스터를 만들고, (a) 보류·반려된 이미지가 파일명·인코딩만 바꿔 재제출된 케이스, (b) 동일/유사 이미지에 상반된 판정(하나는 승인, 하나는 반려)이 난 케이스를 신규 API와 큐 화면 배지 + 클러스터 목록 뷰로 하이라이트한다. pHash는 현재 evidence 직렬화에서 exact/perceptual 키가 탈락해 DB에 없으므로, seed_from_image_store 시 submission payload에 지문을 백필·저장하는 단계가 선행돼야 한다.
|
||||
- 가치: 같은 이미지를 두 번 심사하는 낭비를 제거하고, 반려 후 우회 재제출을 자동으로 잡아내며, 운영자 간 판정 불일치를 관리자가 볼 수 있게 된다.
|
||||
- 인사이트: 현재 내부 분석기는 지식DB sampleFingerprints와만 비교하므로, 반려 시 watchlist 엔트리가 생성된 경우에만 재제출이 잡힌다. 승인된 이미지의 재제출, 판정 전 중복 제출, 유사 이미지 간 판정 불일치는 지금 아무도 보지 못하는 사각지대다.
|
||||
- 기반: src/rights_filter/analysis/fingerprints.py (FingerprintService.similarity), src/rights_filter/server/store_search_candidates.py (_submission_perceptual_fingerprint 재계산 경로), src/rights_filter/server/sqlite_store.py (seed_from_image_store에 지문 백필 추가, submissions payload), src/rights_filter/server/image_store.py, src/rights_filter/server/http_app.py (신규 라우트), web/operator-gui/app.js (queue 행 배지 + 신규 패널)
|
||||
|
||||
### ④ 판정 결과 일괄 내보내기 + 통보 초안 (S)
|
||||
**판정 결과 일괄 내보내기 + 통보 초안 (decision export)** [automation-integration]
|
||||
- 무엇: GET /api/decisions/export?since=&status= 라우트를 추가해 판정 완료 케이스를 CSV/JSON 으로 내려받게 한다. 두 가지 뷰를 분리한다: (1) 내부 아카이브용 — 케이스 ID·제목·판정·메모·판단에 사용된 증거 제목과 출처 URL·판정 시각(감사 이벤트 기준), (2) 신청 채널 전달용 — 판정·applicantStatus·운영자 메모만 포함하고 위험 점수·증거 상세는 절대 포함하지 않는다(운영 경계 준수). GUI 심사 큐 상단에 '판정 결과 내보내기' 버튼과 기간/상태 선택을 추가한다.
|
||||
- 가치: 지금은 record_decision 이 applicantStatus('반려 예정' 등)까지 세팅하지만 그 값이 시스템 밖으로 나가는 경로가 없어, 운영자가 판정 결과를 접수 채널·상급자에게 전달하려면 큐 화면을 보며 수기로 옮겨 적어야 한다. 하루치 판정을 버튼 한 번으로 정리된 파일로 뽑으면 판정 후 후속 업무(결과 통보, 일일 보고)가 케이스당 수 분에서 수 초로 준다.
|
||||
- 인사이트: 필요한 데이터가 이미 전부 저장돼 있다 — submissions.decisionStatus/applicantStatus, 증거의 operatorStatus(판단에 사용 여부), audit_events 의 'Operator decision created' 이벤트(판정 시각·메모). 새 스키마 없이 읽기 전용 GET 라우트 하나로 완결되므로 기존 CSRF/락 구조에 영향이 없다. 핵심 설계 판단은 '신청자용 뷰에서 내부 증거·점수를 구조적으로 차단'하는 필드 화이트리스트이며, 이것이 문서화된 운영 경계(Do not expose internal risk scores to applicants)를 코드로 보증하게 된다.
|
||||
- 기반: src/rights_filter/server/http_app.py (GET 라우트 추가), src/rights_filter/server/sqlite_store.py 의 record_decision/audit_events/bootstrap 데이터, web/operator-gui/index.html + app.js 큐 액션 영역
|
||||
|
||||
### ⑤ 온라인 스냅샷 백업·복구 (M)
|
||||
**온라인 스냅샷 백업·복구 (DB+미디어 번들)** [essential-foundation]
|
||||
- 무엇: 서버 실행 중에도 안전한 백업을 만드는 관리 기능. sqlite3의 온라인 backup API로 WAL 모드 DB(data/copyrighter.sqlite3)의 일관된 스냅샷을 뜨고, 미디어 4종 디렉터리(data/submissions/images, data/knowledge/images, data/collections/images, data/face-crops)를 묶어 타임스탬프 zip으로 data/backups/에 생성하는 POST /api/admin/backup 엔드포인트 + GUI 버튼(마지막 백업 시각 표시). 복구는 오프라인 CLI(python -m rights_filter.server --restore <zip>)로 수행하고 _ensure_schema_version으로 스키마 버전을 검증한 뒤 기동. 백업/복구 실행은 add_audit_event로 감사 기록. 보존 개수 로테이션 포함.
|
||||
- 가치: 판정 이력·기준 DB·감사 로그 전체가 단일 SQLite 파일에 있는데 현재 백업 코드가 0줄이다. 디스크 손상·실수 삭제 한 번이면 운영 자산 전체가 유실되고, 폐쇄망 납품 환경에는 클라우드 백업이 없으므로 로컬 스냅샷+반출이 유일한 복구 수단이다. 운영자가 버튼 한 번으로 복구 지점을 만들 수 있게 된다.
|
||||
- 인사이트: WAL 모드(sqlite_store.py initialize의 pragma journal_mode=wal)에서는 서버 실행 중 파일 복사만으로는 -wal 사이드카가 빠진 손상 스냅샷이 나온다. '파일 하나 복사하면 되는 백업'이라는 가정이 이 구조에서는 성립하지 않으며, 반드시 sqlite3 backup API 경유가 필요하다. 운영 문서(copyrighter-operation-worklist.md '아직 안 된 것' 절)도 백업·복구를 명시적 미완 항목으로 남겨두고 있다.
|
||||
- 기반: src/rights_filter/server/store_persistence.py(_connect — sqlite3.Connection.backup 사용 지점), src/rights_filter/server/sqlite_store.py(db_path·knowledge_image_dir·collection_image_dir·face_crop_image_dir 경로 필드, add_audit_event), src/rights_filter/server/store_schema.py(_ensure_schema_version 복구 검증), src/rights_filter/server/http_app.py(do_POST 라우팅), src/rights_filter/server/__main__.py(--restore CLI 진입점)
|
||||
|
||||
### 차점 후보 (다음 사이클 권장)
|
||||
- **판정 통계·스코어 캘리브레이션 대시보드**: 운영자는 백로그가 어디서 쌓이는지(오래된 미심사 건, 처리량 저하 요일)를 즉시 보고, 관리자는 룰 기반 리스크 스코어의 임계값 조정과 인력 배치를 감이 아닌 수치로 결정할 수 있다.
|
||||
- **운영자 식별·역할 분리 (이름 붙은 토큰 + admin 게이트 + 감사 실명화)**: 복수 운영자가 같은 콘솔을 쓰는 순간 현재 감사 로그는 책임 추적 기능을 상실한다 — 반려·기준 DB 편입·provider 중지 같은 판단 행위가 전부 'rights.ops'로 기록되기 때문. 이름 붙은 토큰만으로 SSO 없이(폐쇄망 제약) '누가 언제 무엇을'이 남고, provider 제어·파기·백업 같은 위험 조작은 관리자만 가능해진다.
|
||||
|
||||
### 심사 점수 (3인 평균 상위)
|
||||
|
||||
| 제안 | 평균 | 개별 |
|
||||
|---|---|---|
|
||||
| 키보드 연속 심사 모드 | 9.07 | 9, 9.2, 9 |
|
||||
| 선택 케이스 일괄 판정 (가드레일 포함) | 8.23 | 8.5, 8.2, 8 |
|
||||
| 판정 메모 프리셋 (자주 쓰는 사유 원클릭) | 8.20 | 8, 8.6, 8 |
|
||||
| 판정 결과 일괄 내보내기 + 통보 초안 (decision export) | 7.50 | 7.5, 7.5, 7.5 |
|
||||
| 온라인 스냅샷 백업·복구 (DB+미디어 번들) | 7.50 | 7.5, 8.5, 6.5 |
|
||||
| 동일·유사 제출 자동 묶음 (중복 그룹) | 6.90 | 7, 7.2, 6.5 |
|
||||
| 운영자 식별·역할 분리 (이름 붙은 토큰 + admin 게이트 + 감사 실명화) | 6.77 | 7, 7.8, 5.5 |
|
||||
| 주의 후보 자동 큐레이션 트리아지 (watchlist auto-curation) | 6.30 | 6.5, 6.4, 6 |
|
||||
| 판정 통계·스코어 캘리브레이션 대시보드 | 6.10 | 6, 7, 5.3 |
|
||||
| 기준 DB 적중률·큐레이션 추천 리포트 | 6.10 | 6.5, 6.8, 5 |
|
||||
|
|
@ -53,6 +53,11 @@ Required controls:
|
|||
- Decision filter: unreviewed, held, rejected, approved, corrected
|
||||
- Sort by risk, newest, oldest, analysis failure, provider failure
|
||||
- Bulk selection only for non-decisive actions such as re-run analysis or assign reviewer; no bulk approve/reject in v1
|
||||
- **개정 2026-07-24**: v1 제약을 해제하고 일괄 승인/보류/반려를 가드레일과 함께 허용한다.
|
||||
근거: 2026-07-24 리뷰에서 반복 판정이 운영자 처리량의 주 병목으로 확인됨.
|
||||
필수 가드레일 — 선택에 고위험(high) 케이스가 포함되면 개수를 명시한 확인 단계,
|
||||
반려는 메모 필수, 1회 최대 200건, 케이스별 개별 트랜잭션·감사 이벤트,
|
||||
부분 실패 시 실패 케이스 목록 반환. 자동 판정은 여전히 금지(운영자 명시 조작만).
|
||||
|
||||
UX rule: the queue should never require opening every item just to understand why it is high risk.
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue