From 4f140efd9b423fe28031f7578a06e98ec70982a4 Mon Sep 17 00:00:00 2001 From: changukyu Date: Sat, 25 Jul 2026 23:08:19 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=8B=A4=EC=82=AC=EC=9A=A9=20=ED=88=AC?= =?UTF-8?q?=EC=9E=85=20=EC=A0=84=20T0=20=EC=8A=A4=ED=83=AC=ED=94=84=204?= =?UTF-8?q?=EC=A2=85=20+=20=EC=9E=84=EB=B2=A0=EB=94=A9=20=EC=A0=95?= =?UTF-8?q?=EC=B1=85=20=EA=B0=9C=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 두 갈래의 작업이 한 커밋에 들어 있다. llm_assistance.py 에서 T0-4 가 앞선 작업의 프롬프트 수정을 대체했기 때문에(프롬프트 리터럴이 llm_prompts.py 로 이동), 분리하려면 실제로 존재한 적 없는 중간 상태를 만들어야 한다. ## (1) 앞서 진행 중이던 review-intelligence 작업 - decision_stats(): 위험밴드 x 실제판정 교차표 + 캘리브레이션 지표 - cleared_reference 지식엔트리 타입: 이전에 클리어된 이미지 매칭은 +80 이 아니라 0점 긍정 신호 - LLM 요약 본문 영속화(_evidence_payload 에 summary/sourceUrls) — 그전까지 요약이 저장되지 않아 운영자에게 한 번도 보인 적이 없었다 - 운영자 GUI 반영 ## (2) 실사용 투입 전 T0 — 첫 판정 이후에는 소급 불가한 것들 - T0-1 initialRiskScore/initialRiskBand/scorerVersion 각인. _rescore_submission 이 12곳에서 riskScore 를 덮어쓰므로 저장된 점수는 늘 왕복 점수였고, 그것을 재채점과 비교하는 테스트는 항상 통과하는 빈 테스트다. 레거시 행은 scorerVersion="legacy" 로 표시만 하고 점수를 날조하지 않는다. - T0-2 얼굴검출 Haar -> YuNet, 인물 등급(identifiable/background) 도입. 기존의 평평한 +35 는 사람이 찍힌 모든 사진을 medium 문턱 위로 올려 밴드 분포를 붕괴시켰다(실측 high 4 / medium 2 / low 0). 등급은 payload 라운드 트립으로 보존한다 — 잃으면 재채점마다 조용히 +35 로 되돌아간다. 가중치는 models/(gitignore) 에 두고 offline-install.md 에 sha256 반입 절차. 가중치가 없으면 Haar 로 강등하고 1회만 경고한다. - T0-3 판정 시점 피처 스냅샷. 큐가 riskScore 내림차순이고 화면이 점수를 먼저 보여준 뒤 판정을 받으므로, 스냅샷 없이 모은 라벨은 "이 근거가 반려를 예측한다"가 아니라 "운영자가 내가 보여준 점수에 동의했다"를 기록한다. - T0-4 모델/프롬프트 버전 + latency_ms 스탬프. latency_ms 는 _evidence_id 해시에서 제외한다 — 포함하면 같은 요약이 재생성될 때마다 id 가 바뀐다. ## (3) 임베딩 저장 금지 정책 개정 금지의 취지는 생체 식별이지 이미지 유사도가 아니므로 범위를 좁힌다. 장면 임베딩(전체 이미지 의미 벡터)을 이미지 유사도 판정에 한해 허용하되 얼굴 마스킹 / 인물 지배 프레임 제외 / 목적 제한 / 별도 저장 클래스 / 연쇄 삭제 5조건을 assert_scene_embedding_allowed 로 강제한다. 마스킹 증명이 없으면 거부한다 — 누락은 마스킹을 건너뛴 것과 구별할 수 없다. 얼굴 임베딩 금지는 landmarks/face_descriptor/face_encoding 추가로 강화. pytest 502 passed (착수 전 463). --- .gitignore | 4 + docs/INDEX.md | 2 + ...7-25-ai-capability-expansion-brainstorm.md | 395 +++++++++++++++ docs/operations/image-rights-risk-filter.md | 28 +- docs/operations/offline-install.md | 39 +- ...troduction-and-technical-implementation.md | 7 +- .../analysis/face_person_detection.py | 207 +++++++- .../analysis/internal_analyzer.py | 33 +- src/rights_filter/analysis/llm_assistance.py | 71 ++- src/rights_filter/analysis/llm_prompts.py | 30 ++ src/rights_filter/analysis/risk_scoring.py | 36 +- src/rights_filter/domain/records.py | 10 + src/rights_filter/governance/policies.py | 82 +++- src/rights_filter/server/http_app.py | 12 + src/rights_filter/server/sqlite_store.py | 7 + src/rights_filter/server/store_enrichment.py | 15 +- src/rights_filter/server/store_operations.py | 60 +++ .../server/store_search_candidates.py | 10 + .../server/store_serialization.py | 58 ++- src/rights_filter/server/store_workflow.py | 120 +++++ .../analysis/test_face_person_tiers.py | 214 +++++++++ .../analysis/test_llm_assistance.py | 73 ++- .../rights_filter/governance/test_policies.py | 67 +++ .../server/test_pre_golive_stamps.py | 125 +++++ .../server/test_review_intelligence.py | 153 ++++++ .../rights_filter/server/test_sqlite_store.py | 19 +- .../test_public_module_layout.py | 1 + web/operator-gui/app.js | 450 +++++++++++++++++- web/operator-gui/index.html | 18 + web/operator-gui/styles.css | 141 ++++++ 30 files changed, 2434 insertions(+), 53 deletions(-) create mode 100644 docs/brainstorms/2026-07-25-ai-capability-expansion-brainstorm.md create mode 100644 src/rights_filter/analysis/llm_prompts.py create mode 100644 tests/rights_filter/analysis/test_face_person_tiers.py create mode 100644 tests/rights_filter/server/test_pre_golive_stamps.py create mode 100644 tests/rights_filter/server/test_review_intelligence.py diff --git a/.gitignore b/.gitignore index 8e147b0..4b20c85 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,10 @@ htmlcov/ # Offline dependency wheelhouse (built on staging machine, not committed) wheelhouse/ +# Local model weights (fetched on the build machine, carried across the air gap +# with the wheelhouse — never committed). See docs/operations/offline-install.md. +models/ + # Secrets & local environment (keep .env.example) .env .env.local diff --git a/docs/INDEX.md b/docs/INDEX.md index 6ec4794..eaae2b9 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -1,4 +1,6 @@ # 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-25 · 실사용 투입 전 T0 4종 + 임베딩 정책 개정 구현 — (T0-1) `initialRiskScore`/`initialRiskBand`/`scorerVersion` 각인, (T0-2) YuNet 얼굴검출 교체 + 인물 등급(identifiable/background) 도입 및 라운드트립 보존, (T0-3) 판정 시점 피처 스냅샷(라벨 순환 차단), (T0-4) 모델·프롬프트 버전 + `latency_ms` 스탬프. Scene Embedding Policy 신설로 임베딩 금지 범위를 생체 식별로 축소(얼굴 임베딩 금지는 landmark·descriptor 추가로 강화). YuNet 가중치는 `models/`(gitignore) + `offline-install.md`에 sha256 반입 절차. **pytest 502 passed**(착수 전 463). 실측: 6장 기준 검출 378ms(YuNet) vs 654ms(Haar), 2장에서 등급 판정이 갈림 +- 2026-07-25 · AI 기능 확장 브레인스토밍(에이전트 14: 5렌즈 발산 30건 → 적대적 실현가능성 검증 6건 REFUTED → 심사 3인 채점) — 1위 「LLM 예산·계측·입력 경계」 7.90(만장일치). 검증 중 실측으로 확인: 판정 라벨 0건·`operatorStatus` 292행 전부 None·밴드 high 4/medium 2/low 0(4건 100점 포화)·`_perceptual_hash`는 pHash가 아닌 8x8 평균해시. 문서의 WAL 미설정 주장은 검증 결과 오류로 정정(이미 적용됨) → [docs/brainstorms/2026-07-25-ai-capability-expansion-brainstorm.md](brainstorms/2026-07-25-ai-capability-expansion-brainstorm.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) diff --git a/docs/brainstorms/2026-07-25-ai-capability-expansion-brainstorm.md b/docs/brainstorms/2026-07-25-ai-capability-expansion-brainstorm.md new file mode 100644 index 0000000..66c0336 --- /dev/null +++ b/docs/brainstorms/2026-07-25-ai-capability-expansion-brainstorm.md @@ -0,0 +1,395 @@ +# 2026-07-25 AI 기능 확장 브레인스토밍 + +> 세션: 5렌즈 발산 **30건** → 항목별 적대적 실현가능성 검증(코드·DB 실측 동반, **6건 REFUTED 탈락**) → +> 생존 24건을 심사 3인(운영자·기술리드·제품책임자)이 0~10 채점. +> 기준 HEAD: 8b5b3f5 (fix/2026-07-11-review-remediation). 작성일 2026-07-25. + +이 시스템에서 "AI"라고 부를 수 있는 것은 사실상 하나뿐이다 — `analysis/llm_assistance.py`의 Ollama `qwen2.5:0.5b-instruct` 요약이고, 그것마저 `risk_scoring.py`가 `EvidenceSource.LLM_SUMMARY`를 명시적으로 0점 처리해 판정에 기여하지 않는다. 나머지 지능은 전부 손으로 쓴 규칙이다: 검색 쿼리 생성 321줄 템플릿(`analysis/search_query_generation.py`), 위험 점수 174줄 정수 가중치(`analysis/risk_scoring.py`), 2001년식 Haar cascade 얼굴 검출(`analysis/face_person_detection.py`). 이 문서는 "더 큰 모델을 붙이자"가 아니라 **지금 이미 켜져 있는 AI가 조용히 고장나 있는 지점을 먼저 고치고**(프롬프트가 증거의 90%를 잘라먹는 문제, 시드 경로가 쓰기 락을 잡은 채 LLM을 도는 문제), 그 위에 의존성 0짜리 낭독형 신호(EXIF/IPTC 권리 필드, YuNet 검출 신뢰도)를 얹은 뒤, 판정 라벨이 실제로 쌓인 다음에야 학습·자동화로 간다는 3단계 순서를 제안한다. 심사 3인은 각각 다른 렌즈(운영자·기술리드·제품책임자)에서 출발했지만 **1순위가 만장일치로 같았다** — 「LLM 예산·계측·입력 경계」(평균 7.90, 3인 중 최고점 단독 1위). + +**이 심사가 준 가장 중요한 신호**: "새 AI 모델을 붙이는" 계열은 전부 하위권으로 내려갔고(의미지문 인덱스 2.70, 판정예측 트리아지 2.30, 워터마크 검출기 4.50), 상위권은 **이미 있는 AI를 정직하게 만드는 것**과 **모델 없이 얻는 신호**(EXIF 권리필드 7.23, YuNet 7.30, 방송캡처 포렌식 6.53)가 차지했다. "AI를 적극 활용한다"의 이 코드베이스에서의 실제 번역은 *더 큰 모델*이 아니라 **① 지금 켜져 있는 LLM이 근거를 실제로 읽게 만들고 ② 모델 없이 얻을 수 있는 신호를 먼저 전부 줍고 ③ 판정 라벨이 쌓인 뒤에 학습을 켠다**는 순서다. + +## 현재 AI 표면 진단 + +| 축 | 현재 구현 | 실제 성격 | 사각지대 | +|---|---|---|---| +| 생성형 LLM | `analysis/llm_assistance.py:22` `OllamaGenerateLlmClient` (qwen2.5:0.5b, temp 0.1, timeout 30s) | 요약 1종. `risk_scoring.py`에서 0점 | `options`에 `num_ctx` 없음 → 증거 대부분이 모델에 도달 전 절단. 저장된 llm 증거 9행에 **`summary` 키 자체가 없다** — 요약 본문이 영속화되지 않아 운영자에게 한 번도 보인 적이 없다 ※ | + +> ※ **이 한 줄은 미커밋 작업 트리에서 이미 고쳐지고 있다.** `store_serialization.py`의 `_evidence_payload`에 `summary`/`sourceUrls`가 추가돼 있다(현재 `git diff`에 존재). DB의 9행이 비어 있는 것은 2026-05-26에 생성된 **과거 데이터**다. 다만 이 수정은 문제의 방향을 바꾼다 — 요약이 **드디어 화면에 보이기 시작하므로**, 잘린 증거로 만든 요약의 품질 결함이 이제 운영자 눈앞에 노출된다. 1위 항목(입력 경계)의 시급성은 이 수정으로 **낮아지는 게 아니라 높아진다.** +| 프롬프트 | `llm_assistance.py:133 _evidence_prompt` | `data={item.data}` 딕셔너리 통째 f-string | 제출건당 13,733~62,291자(최대 63행). 근거 링크는 잘리지 않은 전체 목록에서 뽑혀 붙음 | +| 위험 점수 | `analysis/risk_scoring.py` 174줄 | 손튜닝 정수 가중치 + 밴드 70/30 하드코딩 | 학습·캘리브레이션 0. 실측 밴드 분포 high 4 / medium 2 / low 0 — 트리아지 변별력 붕괴 | +| 쿼리 생성 | `analysis/search_query_generation.py` 321줄 | 한/영 템플릿 규칙 + generic 블록리스트 | `plan()`의 `priority`가 API 표면에 안 나옴. `_is_generic_local_query` 필터 결과가 GUI 추천에 반영 안 됨 | +| 로컬 CV(얼굴) | `analysis/face_person_detection.py:_detect_with_opencv` | Haar frontal + profile 2종 | `person_count = face_count` — 사람 축이 사실상 부재. confidence는 `internal_analyzer.py`에서 0.8 하드코딩. 제출 1건당 detect가 **2회** 호출됨(`internal_analyzer.py:78`, `store_enrichment.py:184`) | +| 로컬 CV(지문) | `analysis/fingerprints.py:_perceptual_hash` | 이름은 pHash, 실제는 8x8 average hash | 크롭 한 번에 무력화. 실측: 무관한 제출 이미지 쌍이 이미 0.25~0.781을 냄 | +| 텍스트/로고 | 없음 | — | 이미지 표면에 저작권자가 직접 써 놓은 워터마크·ⓒ를 읽는 경로가 0줄 | +| 원본 메타데이터 | `analysis/preprocessing.py:169-171` | "raw pixel 재구성으로 EXIF/GPS/ICC 전량 파기" | 파기 **전에 읽는 코드가 없음**. `getexif`/`getiptcinfo` src 전역 0건 | +| 사람 라벨 활용 | `risk_scoring.py:15` 하드 스킵 + export 컬럼 1개 | operator_status를 점수에서 빼는 용도만 | 통계로 되먹이는 코드 0줄 | +| 계측·재현성 | 없음 | — | model/prompt/latency 필드가 증거 payload 40여 개 중 0개. `COPYRIGHTER_LLM_DAILY_LIMIT`은 화면 표시용이고 아무것도 막지 않음 | +| 죽은 코드 | `analysis/evidence_enrichment.py:EvidenceEnricher`, `jobs/review_enrichment_job.py` | 테스트에서만 인스턴스화 | 실제 LLM 호출 지점은 `server/store_operations.py:471 _ensure_llm_summary` 한 곳 — 발산 단계 아이디어 3건이 이 죽은 코드를 수정 대상으로 오지목했다 | + +**검증 단계에서 확정된 데이터 실측치** (이 문서의 모든 라벨 의존 판단의 근거): + +- `submissions` 6건 — **전부 `decision_status='unreviewed'`**. `'Operator decision created'` 감사 이벤트 **0건**. +- `evidence` 292행 — **`operatorStatus` 전부 `None`**. 백업 DB에 `false_positive` 2행이 전 이력. +- `queryStrategy` 유의미 값 0행(None 288 / '' 4). `entity` 0행, `promoted` 0행, `domainSource` 전부 None. +- 즉 **"사람이 만든 라벨과 근거 페어가 이미 축적 중"이라는 전제는 이 레포에서 확인되지 않는다.** + +## 제약 조건 + +1. **폐쇄망 번들** — 런타임 서드파티는 `numpy` / `opencv-python-headless` / `pillow` 3개뿐이고 `requirements.txt` 헤더가 그것을 명시 선언한다. 4번째 의존성 추가(onnxruntime 40~55MB, defusedxml 25KB)는 문서 한 줄 수정이 아니라 **반입 심사 안건**이다. 모델 자산은 별도: 3B Q4_K_M +약 1.9GB, 7B +4.7GB, OCR ONNX 56~72MB(제안된 "12MB"는 약 5배 과소), 롤백 태그 유지 시 +379MiB. +2. **CPU 지연** — 발산 단계 6개 아이디어가 전부 **프리필(prompt eval)을 무시**했다. 생성 속도만 대역폭으로 추정했으나 프리필은 연산 바운드다. 실측: 현행 프롬프트·증거 10건에서 20스레드 5.9초 / 2스레드 20.6초, 증거 20건이면 28.2초 / 72.7초로 `timeout=30`을 확정적으로 초과. 3B Q4는 압축 프롬프트에서도 60~180초. **7B는 현재 근거로 인터랙티브 불가.** +3. **거버넌스** — (a) `governance/policies.py:86 assert_no_biometric_template`은 top-level 키 3개만 보는 얕은 검사지만, 하드 바운더리는 코드가 아니라 사양 문서 두 곳(`docs/operations/image-rights-risk-filter.md:16`, `docs/project-introduction-and-technical-implementation.md:209`)의 "Do not store face embeddings ... or biometric templates" 문구다. 전용 테이블 우회는 코드는 통과해도 사양은 통과하지 못한다. (b) `DataClass.LLM_SUMMARY`의 근거링크 강제는 **현재 형식적으로만 통과 중** — 링크는 전체 증거에서, 요약은 잘린 증거에서 나온다. (c) 신청자 노출 경계: `memo`가 `APPLICANT_EXPORT_FIELDS`에 포함되므로 LLM 출력이 `decision_memo_presets`에 착지하면 신청자에게 그대로 나간다. +4. **판정 주체는 사람** — 프롬프트 계약(`llm_assistance.py:43-49`)에 명시. 개별 판정뿐 아니라 **판정 규칙 생성**도 이 계약에 걸린다(규칙은 판정의 집합 형태). +5. **배관 비용** — 새 `EvidenceSource` 1종을 추가하려면 `records.py` / `_ui_source` / `_ui_group` / `_evidence_payload` / `_domain_evidence_from_ui` / `_domain_source_from_ui` / `risk_scoring` 분기 / `app.js` 3곳 / `store_operations.py:488` LLM 입력 필터 / `operator-labels.js` — **12곳을 한 커밋에** 고쳐야 하고, 라운드트립 점수 보존 테스트가 선행돼야 한다. `_ui_source`의 폴백이 `"fingerprint"`라 누락 시 조용히 오분류된다. +6. **인프라 부재** — `src` 전역에 백그라운드 스레드 0개, 잡 큐·상태 테이블·폴링 엔드포인트 0개. 463개 테스트 중 스레드를 다루는 것 0개. SQLite는 WAL이 아니다(`store_persistence.py:19`). `rerun_enrichment`는 명시적으로 동기다. **"비동기 경로에 태운다"는 경로 변경이 아니라 없는 서브시스템을 만드는 것이다.** + *(정정: 발산·검증 단계에서 "SQLite가 WAL이 아니다"라는 서술이 나왔으나 **사실이 아니다.** `sqlite_store.py:162 initialize()`가 `pragma journal_mode = wal`을 설정하고 이 설정은 DB 파일에 영속되므로 `_connect()`가 매번 다시 걸 필요가 없다 — 실제 `data/copyrighter.sqlite3`도 `wal`을 반환한다. 워커 쓰기가 HTTP 읽기를 블록하는 위험은 **이미 해소돼 있다.**)* + +## 후보 전체 (심사 점수순) + +심사 3인(운영자 / 기술리드 / 제품책임자)이 각각 독립으로 0~10 채점한 평균. 점수 인플레 금지 조건을 걸어 전체 평균은 4.7이다. + +| 순위 | 아이디어 | 렌즈 | 평균 | 운영자 | 기술 | 제품 | 검증 verdict | 공수(원안→수정후) | 오늘 착수 | +|---:|---|---|---:|---:|---:|---:|---|---|---| +| 1 | **LLM 예산·계측·입력 경계** | 비용·쿼터·감사 | **7.90** | 7.5 | 8.2 | 8.0 | **FEASIBLE** | M (입력경계 단독 S) | 가능 | +| 2 | **YuNet 초상권 정밀화** | 로컬 비전 | **7.30** | 7.0 | 7.4 | 7.5 | FWC | M | 가능 | +| 3 | **원본 메타 권리추출** | 로컬 비전 | **7.23** | 7.2 | 7.0 | 7.5 | FWC | S→M (EXIF+IPTC만 S) | 가능 | +| 4 | 방송캡처 포렌식 | 로컬 비전 | 6.53 | 6.8 | 6.8 | 6.0 | FWC | S→M | 가능(0점 라벨) | +| 5 | 모델 번들 무결성 검증 | 폐쇄망 번들링 | 6.10 | 5.0 | 6.8 | 6.5 | FWC | M | 가능 | +| 6 | AI 선연산 잡 큐 | 지연·배치 | 6.07 | 6.2 | 6.0 | 6.0 | FWC | M | 부분(락 이동만) | +| 7 | 근거공백 액션 플래너 | 자동 다음 액션 | 6.00 | 5.8 | 6.2 | 6.0 | FWC | M→**S** | 가능 | +| 8 | 작업별 모델 라우터 | LLM 파이프라인 | 5.73 | 5.5 | 6.2 | 5.5 | FWC | M→L (1단계 S) | 부분 | +| 9 | 판정 골든셋 회귀게이트 | 사람 판정 학습 | 5.63 | 4.5 | 6.4 | 6.0 | FWC | M | 부분(각인만) | +| 10 | 모델·프롬프트 버전 스탬프 | 재현성·감사 | 5.37 | 3.5 | 6.6 | 6.0 | **FEASIBLE** | S | 가능 | +| 11 | 증거 영역 오버레이 | 증거 하이라이트 | 5.33 | 5.0 | 6.0 | 5.0 | FWC | M→L | 좌표계 수정 선행 | +| 12 | 골든 케이스 회귀 하네스 | 오프라인 평가 | 5.03 | 3.8 | 5.8 | 5.5 | FWC | M→**S** | 가능 | +| 13 | 요약 상태 배지(5단→3단) | AI 실패 UX | 4.90 | 4.8 | 5.4 | 4.5 | FWC | M | 가능 | +| 14 | 인용형 증거팩 요약 | LLM 파이프라인 | 4.50 | 4.2 | 4.8 | 4.5 | FWC | M→L | 1위 선행 필수 | +| 15 | 워터마크·ⓒ 검출기 | 로컬 비전 | 4.50 | 4.8 | **3.2** | **5.5** | FWC | L→**XL** | 모델 반입 심사 | +| 16 | 밴드 임계값 스윕·섀도 리스코어 | 사람 판정 학습 | 4.30 | 2.8 | 4.6 | 5.5 | **검증 미수행** | M(원안) | 라벨 대기 | +| 17 | 페이지 권리표기 추출 | LLM 파이프라인 | 3.87 | 4.0 | 3.6 | 4.0 | FWC | L→**XL** | 페치 재설계 선행 | +| 18 | 근거 신뢰도 재보정 | 사람 판정 학습 | 3.57 | 3.2 | 4.0 | 3.5 | FWC | S | 가능(n=0 안전) | +| 19 | 문장별 근거 인용 검증기 | 그라운딩 검증 | 3.03 | 2.3 | 3.8 | 3.0 | FWC | M→L | 1위 선행 필수 | +| 20 | 근거한정 케이스 Q&A | LLM 파이프라인 | 2.97 | 2.5 | 3.4 | 3.0 | FWC | L | 라우터·캐시 선행 | +| 21 | AI 수용률 계기판 | AI 신뢰도 측정 | 2.87 | 2.2 | 3.4 | 3.0 | FWC | M→**S** | 라벨 대기 | +| 22 | 의미지문 인덱스 | 로컬 비전 | 2.70 | 3.0 | 2.6 | 2.5 | FWC | L→**XL** | **거버넌스 승인 필요** | +| 23 | 판정예측 트리아지 레인 | 트리아지 | 2.30 | 2.7 | 2.2 | 2.0 | FWC | L→**XL** | 라벨 대기 | +| 24 | 근거프로파일 일관성 감시 | 사람 판정 학습 | 2.27 | 2.0 | 2.8 | 2.0 | FWC | L | 라벨 대기 | + +FWC = FEASIBLE_WITH_CHANGES(수정 조건부 실현가능). 24건 중 verdict가 무조건 FEASIBLE인 것은 1위와 10위 둘뿐이다. 16위는 검증 단계에서 판정이 기록되지 않았다 — **미검증 상태**로 표기하며, 착수 전 별도 검증이 필요하다. + +**심사자 의견이 갈린 항목**(참고용 — 착수 결정 시 이 갈림이 곧 리스크다): + +- **15위 워터마크·ⓒ 검출기** — 제품 5.5 vs 기술 **3.2**. 제품 렌즈는 "이미지 표면의 저작권 표기를 못 읽는 것이 가장 큰 탐지 공백"으로 보고, 기술 렌즈는 실측 템플릿 매칭 2,347ms/장과 로고 수에 선형인 비용을 근거로 반대했다. +- **10위 모델·프롬프트 버전 스탬프** — 기술 6.6 vs 운영자 **3.5**. 운영자에게는 화면에 글씨 하나 느는 일이고, 기술 렌즈에는 이후 모든 모델 변경 판단의 전제다. +- **12위 골든 케이스 회귀 하네스 / 9위 판정 골든셋 회귀게이트** — 두 항목 모두 기술·제품 대비 운영자 점수가 1.5~2.0점 낮다. 품질 기반 작업의 전형적 패턴으로, 운영자 체감이 0이라는 뜻이지 가치가 없다는 뜻은 아니다. + +## 상위 후보 상세 + +> 배열 순서는 **점수순이 아니라 착수 순서**(선행조건 의존 관계)다. 각 제목에 심사 순위와 평균을 함께 표기했다. +> 점수 상위권 중 이 절에 상세가 없는 3건(4위 방송캡처 포렌식, 5위 모델 번들 무결성 검증, 8위 작업별 모델 라우터)은 +> 바로 아래 「상위권 중 상세 미수록 3건」에 요약했고, 로드맵 2단계에 항목으로 들어가 있다. + +### 1. LLM 예산·계측·입력 경계 (**심사 1위 · 평균 7.90 · 1순위 만장일치 3/3**, 공수 M → 입력경계 단독 S) + +**무엇을** — 세 가지 묶음이지만 **입력 경계만 떼어 먼저 낸다**(검증자 권고). (a) `_evidence_prompt`가 `data` 딕셔너리를 통째로 f-string에 박는 것을 화이트리스트(`reason`/`match`/`similarity`/`page_title`/`domain`/`query`)로 교체, (b) 증거 상한(위험 기여 상위 12건), (c) `options`에 `num_ctx` 명시. 이어서 별건으로 예산 집행(LlmCallPolicy)과 계측(p95 링버퍼). + +**왜** — 지연 감소가 아니라 **거버넌스 결함 수정**이다. 프롬프트는 잘리는데 `source_urls`/`source_evidence_ids`는 잘리지 않은 전체 증거에서 뽑혀 붙는다. 결과적으로 `assert_operator_evidence_payload_allowed(LLM_SUMMARY)`는 통과하지만 화면의 "연결 근거 N건"에서 N은 거짓이다. 모델이 본 적 없는 근거를 근거라고 표시하는 상태이며, 다른 23건 중 어느 것도 이 구멍을 닫지 않는다. + +**AI 구성요소·모델·지연** — 모델 변경 없음(qwen2.5:0.5b 유지). 추론 추가 0. **24건 중 유일하게 지연을 줄인다**: 실측 20.6초 → 1.5~8.3초(중앙값 2~3초). 부수 효과로 축약 프롬프트에서 모델이 한국어 2단('요약:'/'확인 필요:') 형식을 지키기 시작했다(현행 프롬프트에서는 형식·언어 계약을 전부 무시하고 영어로 답했다). + +**코드 근거** — `llm_assistance.py:133 _evidence_prompt`(data 통째 덤프) · `llm_assistance.py:52-53`(options에 temperature만) · `env_clients.py:57`(`COPYRIGHTER_LLM_DAILY_LIMIT`이 파싱만 되고 naver/google과 달리 `daily_limit=`으로 배선되지 않음) · `store_persistence.py:231-246 _apply_provider_usage_delta`(usage 누적만, 상한 대조 없음) · `store_operations.py:471 _ensure_llm_summary`(실제 유일한 호출 지점). + +**폐쇄망 번들** — 증분 0바이트, 신규 의존성 0, 스키마 마이그레이션 0, 새 EvidenceSource 0. 텔레메트리 송출 없음(전부 로컬 SQLite). + +**가드레일** — `face_crop_search`/`crop_index`/`privacy_note`/`thumbnail_url`을 모델 입력에서 제외해 초상 경계를 프롬프트 레벨에서도 지킨다(`assert_no_biometric_template`과 정합). 쿼터 소진은 오류가 아니라 강등(`SEARCH_SKIPPED` 처리 방식과 동일). + +**검증자가 지적한 조건** — (1) 세 가지를 한 커밋에 묶지 말 것. (2) 화이트리스트만으로 부족 — 축약해도 56건이면 3,000토큰대라 **증거 상한이 필수**이고, `num_ctx`만 올리고 상한을 안 두면 2스레드 기준 프리필에만 약 17초가 들어 **순증가**한다. (3) `promotion_reason`은 화이트리스트에서 뺀다 — 모델이 그대로 베껴 요약이 증거 목록 복붙이 된다(실측 출력이 정확히 그랬다). (4) 예산 카운터의 진실원은 `ExternalApiPolicy` 복제가 아니라 SQLite provider payload로 — 복제하면 프로세스 메모리 카운터라 재시작 시 0으로 리셋돼 화면 usage와 집행 기준이 갈라진다. (5) p95 표본은 최근 200건 링버퍼로 상한 명시. + +**함께 넣을 것** — 심사자 3인이 공통 지목한 5줄 수술: `_seed_from_image_store_locked`(`sqlite_store.py:310`) 안의 `_ensure_llm_summaries_for_existing_source_evidence` 호출을 `_write_lock` **밖으로** 이동. `record_decision`도 같은 락을 쓰므로, 지금은 폴더 재적재 중에 운영자가 판정 버튼을 눌러도 락 대기에 걸린다. `store_operations.py:457-458`이 이미 "락 밖에서 LLM 호출" 선례를 갖고 있다. + +### 2. 원본 메타 권리추출 (**심사 3위 · 평균 7.23**, 공수 S→M / EXIF+IPTC만이면 S) + +**무엇을** — 외부 전송용 파생본을 만들며 메타데이터를 파기하기 **전에**, 원본 바이트에서 권리 필드만 화이트리스트로 읽어 증거로 만든다. EXIF `0x8298 Copyright` / `0x013B Artist` + IPTC `CopyrightNotice`(2:116)/`Credit`(2:110)/`Source`(2:115). + +**왜** — 저작권자가 파일 안에 직접 써 넣은 정답을 지금 100% 버리고 있다. 추론이 아니라 낭독이라 오탐 가능성이 사실상 없고, 히트하면 외부 API 왕복(수 초 + 쿼터) 없이 판정 근거가 선다. 미스하면 섹션이 아예 안 뜬다 — **리스크 구조가 이 목록에서 가장 깨끗하다**(운영자 심사자 평). + +**AI 구성요소·모델·지연** — AI 모델 없음(컨테이너 파싱). 실측 0.1ms 미만/장. 픽셀 기반 AI-생성 판별기는 의도적으로 배제 — CPU/폐쇄망에서 도메인 시프트에 취약하고 반증 가능성이 없다. + +**코드 근거** — `preprocessing.py:169-171 _upright_derivative`("Rebuild from raw pixels so NO metadata survives") · `preprocessing.py:build_external_derivative` · `image_store.py:60-68`(metadata가 submission_id/title/file/format 4개뿐) · `governance/policies.py:82 policy_for`(미등록 DataClass는 KeyError → 신규 `RIGHTS_METADATA` 등록 강제). + +**폐쇄망 번들** — EXIF+IPTC는 pillow 12.2.0에 이미 포함, 증분 0. **단 XMP는 폐쇄망에서 조용히 죽는다**: Pillow의 `Image.getxmp()`는 `defusedxml`이 없으면 경고와 함께 빈 dict를 반환하는데(직접 차단 실측), 이 개발 머신의 defusedxml은 anaconda/nbconvert/Scrapy가 끌고 온 것이고 `requirements.txt`에는 없다. **납품 타깃에서 XMP `dc:rights`·`digitalSourceType`·C2PA 선언 탐지는 전부 no-op**이 된다. + +**가드레일** — GPS·카메라 시리얼·소유자명·XMP-mwg-rs Face regions는 읽지도 저장하지도 않는다(화이트리스트를 코드 상수로 두고 테스트로 고정). 메타데이터는 위조 가능하므로 "자기신고 정보"로 라벨링하고 자동 반려하지 않는다. + +**검증자가 지적한 조건** — (1) XMP/C2PA는 `defusedxml` 반입 안건으로 **분리**하고, 추가 전까지는 "이 배포에서는 XMP 미판독"을 기동 로그에 명시. (2) **스톡 자산 ID +70(단독 high)은 논리적으로 틀렸다** — Getty/Adobe 자산 ID는 정품 라이선스 구매본에도 그대로 박혀 배포된다. 올바른 귀결은 반려가 아니라 "라이선스 증빙 요구"이므로 +30(medium)으로 낮춘다. (3) recall이 낮다 — 카카오톡/인스타/트위터를 거친 이미지는 메타가 이미 제거돼 있어 히트율이 한 자릿수~십몇 %일 가능성. 따라서 **GUI 섹션은 추출값이 하나라도 있을 때만 렌더**(빈 섹션 금지). (4) 추출 훅은 `InternalAnalyzer.analyze` 진입부(원본 `ImagePayload.content`가 살아 있는 지점)에 둔다 — 파이프라인 순서 변경 불필요. (5) `.svg`가 `SUPPORTED_IMAGE_SUFFIXES`에 있으나 Pillow가 못 여니 추출 실패를 예외가 아니라 "메타 없음"으로 degrade. + +### 3. YuNet 초상권 정밀화 (**심사 2위 · 평균 7.30**, 공수 M) + +**무엇을** — Haar cascade를 `cv2.FaceDetectorYN`(YuNet)로 교체하고 검출 신뢰도를 실제 값으로 채운다. 얼굴 면적 비율 + 검출 수로 "식별 가능한 근접 인물"과 "배경 군중"을 등급 분리. `FacePersonSignal` 인터페이스와 `face_boxes` 계약은 유지해 크롭·얼굴 웹탐지 경로 무변경. + +**왜** — 두 방향 동시 이득. 놓침: 뒷모습·마스크·군중·저조도 인물이 지금 전부 신호 0. 낭비: Haar 오탐 박스가 `+35`와 face_crop 파일을 소비한다. 그리고 **밴드 분포가 이미 붕괴했다**(실측 high 4 / medium 2 / low 0) — `FACE_PERSON +35`가 사람 있는 사진 전부에 붙어 medium 문턱을 혼자 넘기기 때문이다. 등급화는 트리아지 변별력을 직접 회복시킨다. + +**AI 구성요소·모델·지연** — YuNet ONNX 약 230~345KB, CPU 640x480 약 10ms. **현재보다 빨라진다**: 실측 Haar 2캐스케이드 1920x1080 = 218ms/호출 × 건당 2회 = 약 440ms → YuNet 약 20ms. **순감 약 0.4초/장.** + +**코드 근거** — `face_person_detection.py:_detect_with_opencv`(scaleFactor 1.1 / minNeighbors 4 고정) · `face_person_detection.py:103`(`person_count = face_count`) · `face_person_detection.py:57-67`(OpenCV 5.x가 CascadeClassifier를 제거한다는 경고가 이미 코드에 있음 — 이 교체가 그 미래 호환 문제까지 해소) · `internal_analyzer.py:78`과 `store_enrichment.py:184`(detect 중복 호출) · `internal_analyzer.py`(confidence 0.8 하드코딩). + +**폐쇄망 번들** — 파이썬 의존성 0(설치본 opencv-python-headless 4.13.0에 `cv2.FaceDetectorYN` 존재를 직접 확인). 모델 자산 345KB로 사실상 무시 가능. + +**가드레일** — YuNet이 반환하는 5점 랜드마크는 **읽는 즉시 폐기**하고 저장하지 않는다. 나아가 `assert_no_biometric_template` 금지 키에 `landmarks`/`face_landmarks`/`face_descriptor` 추가(호출부가 2곳뿐이라 기존 데이터 로드 무영향 — 정책 강화 방향, 무충돌). + +**검증자가 지적한 조건** — (1) **사람 검출기(YOLOv8n/MobileNet-SSD)는 별건으로 분리**. cv2.dnn으로 YOLOv8 ONNX를 쓰려면 NMS를 직접 구현해야 하고 저사양 CPU에서 0.3~1.5s/장이다. YuNet 345KB/10ms와 같은 묶음으로 취급하면 안 된다. (2) 같은 커밋에서 `detect()` **중복 호출을 제거**한다. (3) `tier`(등급 라벨)와 실제 신뢰도를 `_evidence_payload`와 `_domain_evidence_from_ui` **양쪽**에 추가하고 라운드트립 점수 보존 테스트를 먼저 작성 — 안 하면 재채점 때마다 전 건이 +35로 복귀한다. (4) 등급화는 기존 medium 다수를 low로 이동시켜 `decision_stats`의 캘리브레이션 기준선을 리셋한다. **판정 0건인 지금이 이 비용을 치르기 가장 싼 시점**이다(나중이면 판정 이력과 기준선이 어긋난다). + +### 4. 판정 골든셋 회귀게이트 (심사 9위 · 평균 5.63 — 운영자 4.5 vs 기술 6.4, 공수 M / **각인 부분만 오늘 즉시**) + +**무엇을** — 원안은 결정 케이스를 evidence 스냅샷과 함께 내보내 verify에서 재채점하는 하네스. 검증 결과 **원안의 절반은 오늘 성립하지 않고, 나머지 절반은 오늘 당장 실효가 있다.** + +**왜 (오늘 유효한 절반)** — 검증자가 24건 전체에서 "가장 실효가 큰 한 줄짜리 지적"으로 꼽은 것: `_rescore_submission`이 12곳에서 `riskScore`를 덮어쓰므로 **저장된 점수는 이미 왕복 점수**다. 그것을 다시 왕복 채점과 비교하면 항상 일치한다. 즉 각인 없이 만든 점수 재현 테스트는 **항상 통과하는 빈 테스트**다. 막으려는 실제 버그(`search_image` 왕복 뒤집힘, `risk_scoring.py:105-112` 주석)는 원본 채점 vs 왕복 채점의 차이인데 원본 점수가 어디에도 저장되지 않는다. + +**AI 구성요소·모델·지연** — 모델 없음(평가 하네스). verify 단계에서만 도는 오프라인 하네스라 운영자 체감 0. 실측 1000건 전량 재채점 0.3초 미만. + +**코드 근거** — `store_search_candidates.py:721-731 _rescore_submission` 및 호출 12곳(`sqlite_store.py:309,406,743` / `store_enrichment.py:125,167,298,358` / `store_operations.py:324,385,462` / `store_search_candidates.py:160`) · `store_serialization.py:124`(`fileFacts.analysis`가 리터럴 'v1', `AnalysisRun.analysis_version`은 submission payload에 각인되지 않음) · `store_serialization.py:271-275`(라운드트립 불변식이 주석으로만 존재). + +**폐쇄망 번들** — 신규 의존성 0. **비용은 번들이 아니라 반출 리스크**: 실측 evidence 평균 632바이트 × 케이스당 약 49행 = 약 31KB/건, 1000건이면 약 31MB짜리 파일이 result URL·pageTitle·thumbnailUrl·내부 riskScore를 통째로 담는다. + +**가드레일** — 골든셋은 `APPLICANT_EXPORT_FIELDS` 경로와 **물리적으로 다른 모듈**에 두고, `.gitignore` + 납품 번들 생성 스크립트 제외 목록 + `offline-install.md`에 3중 명시. + +**검증자가 지적한 조건** — (1) **선행 필수**: `initialRiskScore` / `initialRiskBand` / `scorerVersion`을 `_submission_payload`(`store_serialization.py:106`)에 추가하고 `_rescore_submission`이 절대 덮어쓰지 않게 한다. (2) 골든셋 생성에 `export_decisions`를 재사용하지 말 것 — 호출마다 'Decisions exported' 감사이벤트를 남기고 `audit_events()`는 최신 500행으로 잘리므로 verify가 돌 때마다 운영자 감사 화면이 노이즈로 밀려난다. (3) 지표 회귀는 CI 실패가 아니라 **차이 리포트 출력만** — `highBandApprovalRate`는 코드 변경뿐 아니라 케이스 믹스 변화로도 움직인다. (4) 판정 100건 미만이면 계산하지 않는다. (5) 왕복 복원의 취약 지점(`weak_hint`가 저장되지 않고 제목 접두사 'Best guess label ' 문자열로 재구성됨, `domainSource` 미기록 레거시 행)을 스키마에 명시 필드로 굳혀 제목 문자열 의존을 끊는다. (6) **판정 0건이어도 (1)+(5)만으로 오늘 즉시 가치가 있다** — 라벨 없이 실제 버그를 잡을 수 있는 유일한 학습계 항목. + +### 5. 모델·프롬프트 버전 스탬프 (심사 10위 · 평균 5.37 — 기술 6.6 vs 운영자 3.5, 공수 S · **verdict FEASIBLE**) + +**무엇을** — 프롬프트 리터럴을 `analysis/llm_prompts.py`로 분리하고 `PROMPT_ID`/`PROMPT_VERSION` 부여. `/api/generate` 응답의 model과 `/api/tags`의 digest를 Evidence.data에 심고, `_evidence_payload`에 `modelVersion`/`promptVersion`/`latencyMs`를 추가해 GUI에 "모델 qwen2.5:0.5b · 프롬프트 ko-v2 · 1.4s"를 작은 글씨로 노출. + +**왜** — 재현성 필드가 코드 전체에 **단 하나도 없다**. 모델을 올리거나 프롬프트를 고친 뒤 과거 요약과 신규 요약이 DB에 섞여도 구분이 불가능하다. 모델을 바꾸자는 모든 후속 제안이 이 스탬프 없이는 전후 비교를 못 한다. 부수 효과로 `latency_ms`가 쌓여 **운영자 대기시간 실측치가 처음으로 생긴다**. + +**AI 구성요소·모델·지연** — 모델 변경 없음. 응답 메타데이터 파싱만. 추론 증가 0, `/api/tags`는 프로세스당 1회 수십 ms. + +**코드 근거** — `llm_assistance.py:38-61 summarize_evidence`(모델명을 보내지만 응답의 model을 `_response_text:143-154`가 버림) · `llm_assistance.py:41-50`(버전 없는 시스템 프롬프트 리터럴) · `store_serialization.py:256-322 _evidence_payload`(40여 필드 중 모델/프롬프트 0개) · `store_serialization.py:428-430 _evidence_id`. + +**폐쇄망 번들** — 증분 0. 프롬프트 파일도 코드 동봉. + +**가드레일** — 스탬프는 메타데이터일 뿐 판정 무영향(LLM_SUMMARY 0점 유지). 모델명은 admin 전용 `PROVIDER_METADATA` 정책과 정합하게 노출. + +**검증자가 지적한 조건** — (1) **id 해시 오염 주의**: `_evidence_id`는 data 전체의 sha256이라 `latency_ms`/`generated_at`을 data에 넣으면 버전이 바뀔 때가 아니라 **매 호출마다** id가 바뀐다. `model`/`model_digest`/`prompt_version`만 data에, 시간 관련 필드는 `_evidence_payload` 전용으로 분리. (2) digest는 `/api/generate` 응답에 없다 — `/api/tags`에만 있고(실측 `a8b0c51577010a27...`, 397,821,319바이트, Q4_K_M) 그것도 blob 해시가 아니라 매니페스트 sha256이므로 "매니페스트 digest"라고 정직하게 명명. (3) 정책 필수화는 신규 생성 경로에만 적용하고 legacy 행은 `prompt_version='legacy'`로 1회 마이그레이션. (4) 반환 타입을 dataclass로 바꾸면 `FakeInternalLlmClient`와 기존 테스트 3건이 함께 바뀐다. + +### 6. 근거공백 액션 플래너 (심사 7위 · 평균 6.00, 공수 M→**S**) + +**무엇을** — 원안은 신규 엔드포인트 + LLM 재랭킹이었으나 **검증 결과 둘 다 제거**되고, 서버 `plan()` 결과를 `(query, strategy, source, priority)`로 기존 `review()` 응답에 실어 보내고 `_is_generic_local_query`가 걸러낸 쿼리를 화면에서 숨기는 것으로 축소됐다. + +**왜** — 파일명이 `IMG_2831`인 케이스에 GUI가 추천하는 쿼리는 `IMG_2831 저작권`이다. 실행 버튼이 그대로 노출되어 운영자가 헛검색을 돌리고 외부 쿼터를 태운다. `_is_generic_local_query`의 정규식 `(img|image|photo|picture|portrait|screenshot|download|copy|scan|file)[\s_-]*\d*`가 이 계열을 정확히 잡는데, 그 필터가 서버에만 있고 GUI 추천에는 반영되지 않는다. + +**AI 구성요소·모델·지연** — **LLM 없음**(검증자 권고로 재랭킹 제거). `plan()`은 순수 정규식으로 μs 단위, `review()` 응답에 얹으면 체감 0. + +**코드 근거** — `search_query_generation.py:19 plan()` · `search_query_generation.py:295 _is_generic_local_query` · `evidence-guidance.js:suggestedEvidenceQueries`(서버와 무관하게 seed+['저작권','공식','이미지 출처'] 템플릿) · `app.js:1083 renderEvidenceNextActions` · `store_serialization.py:285`(`query_priority`가 data엔 들어가지만 `_evidence_payload`가 매핑 안 함). + +**폐쇄망 번들** — 증분 0. + +**가드레일** — 추천 쿼리 실행은 여전히 운영자 클릭이 필요하고 `SearchApiPolicy` 쿼터를 그대로 통과. + +**검증자가 지적한 조건** — (1) 원안의 "`plan()`의 strategy가 API 표면에 나온 적 없다"는 **사실이 아니다** — `store_enrichment.py:447,620`이 이미 `plan()`을 호출하고 `queryStrategy`로 직렬화해 `app.js:1886`이 화면에 그린다. 실제로 빠진 것은 `priority` 하나뿐이다. (2) 인용된 `evidence_enrichment.py:58 enrich_latest`는 **죽은 코드**다. (3) `knowledge_entries`를 `plan()`에 넘기면 오히려 나빠진다 — 관련성 검사 없이 활성 엔트리 전부에 대해 priority 70/68 후보를 만들어 로컬 메타(54)를 밀어낸다. 프로덕션 두 호출부가 모두 `[]`를 넘기는 것은 우연이 아니다. (4) LLM 재랭킹은 후보 총량이 애초에 3~5개라 정보 이득이 사실상 0인 전형적 과설계. (5) 폴백 전제가 거짓 — `env_clients._llm_assistant()`는 도달성 검사 없이 무조건 어시스턴트를 반환하므로 `llm_assistant is None` 분기는 **절대 실행되지 않는다**. + +### 7. 골든 케이스 회귀 하네스 (심사 12위 · 평균 5.03 — 운영자 3.8, 공수 M→**S**) + +**무엇을** — 원안 3축 중 **축(2) 라운드트립만** 남기되 케이스 고정이 아니라 **속성 기반 테스트**로 만든다: `match`/`promoted`/`weak_hint`/`domainSource`/`knowledge_entry_type`/`similarity` 조합을 열거해 `_evidence_payload → _domain_evidence_from_ui → RiskScorer` 점수 보존을 전수 검사. + +**왜** — `risk_scoring.py`와 `store_serialization.py` 주석이 기록한 실제 회귀 이력이 전부 이 조합에서 나왔다(FACE_PERSON 중복 +35, naver 중복 +40, search_image 왕복 0점→+45). 고정 케이스 몇 개로는 조합 폭발을 못 덮는다. + +**AI 구성요소·모델·지연** — 모델 없음. 평시 추론 0초, 밀리초 단위. + +**코드 근거** — `risk_scoring.py:7-99`(밴드 임계 70/30 하드코딩, 캘리브레이션 테스트 없음) · `tests/rights_filter/analysis/test_llm_assistance.py`(전체 47줄, FakeInternalLlmClient만 사용 — 실제 모델 출력 회귀 0건) · `store_serialization.py:271-275`(불변식이 주석으로만 존재). + +**폐쇄망 번들** — 증분 0(pytest는 requirements-dev에 이미 있음). 픽스처는 합성 JSON 수십 KB. + +**가드레일** — 픽스처는 운영 DB 익명화 추출이 **아니라 합성**한다 — 점수 기여 키만 필요하고 실제 URL·블로거명은 필요 없다. 원안의 "이미지가 안 들어가니 안전"은 틀렸다(evidence payload에 `bloggerName`/`bloggerLink`/`pageTitle`/`url`이 그대로 들어간다). + +**검증자가 지적한 조건** — (1) 축(1) 밴드 정확도는 **정답 라벨의 출처가 없다** — 사람이 기대 밴드를 손으로 적으면 "손튜닝 가중치를 손으로 만든 기대값에 맞추는" 동어반복. "정확도 임계"가 아니라 **"가중치 변경 시 전 제출건 밴드 변동 diff"**로 바꾼다. (2) 축(3) 환각률은 14번(인용 검증기)이 안정화되기 전에는 만들지 않고, 만들 때도 `test_llm_output_snapshot`으로 정직하게 명명한다(녹화 재생은 환각률 회귀가 아니라 문자열 diff이고, 실측 편차가 임계 0.1보다 크다). + +### 8. AI 선연산 잡 큐 (심사 6위 · 평균 6.07, 공수 M / **락 이동 5줄만 오늘 즉시**) + +**무엇을** — 원안은 `ai_jobs` 테이블 + 백그라운드 워커. 검증 결과 **"잡 큐보다 먼저 5줄로 얻을 것을 얻어라"**로 축소 권고: 시드의 LLM 순회를 `_write_lock` 밖으로 옮기고 시드 1회당 처리 건수 상한(예: 20건)을 둔다. + +**왜** — 현재 `seed_from_image_store`가 `_write_lock`을 잡은 채 제출건마다 Ollama를 부르고, `record_decision`도 같은 락을 쓴다. 즉 **폴더 재적재 중에는 판정 버튼이 락 대기에 걸린다.** 실측 1건당 20.6초 기준 제출 100건이면 약 34분간 모든 쓰기가 잠긴다. 게다가 Ollama가 아예 없어도 비용이 든다 — 이 Windows 환경 실측에서 127.0.0.1:11434 연결은 즉시 refuse가 아니라 2초 타임아웃이었다(100건 × 2초 = 200초 락 점유). + +**AI 구성요소·모델·지연** — 모델 변경 없음. 바뀌는 것은 추론의 위치와 시점. + +**코드 근거** — `sqlite_store.py:293` `seed_from_image_store`가 `_write_lock` 획득 → `:310` LLM 순회 · `sqlite_store.py:688 record_decision`이 같은 락 · `store_operations.py:457-458`(수동 검색은 이미 "락 밖에서 호출"을 명시 — 같은 레포에 상반된 두 패턴) · `store_serialization.py:220 _provider_item_has_result`(source=='failure'에 False를 반환해 `only_if_missing`이 실패 행을 "요약 없음"으로 봄 → **매 reload마다 실패 전건 무한 재시도**) · `store_persistence.py:19 _connect()`(WAL 미설정). + +**폐쇄망 번들** — 증분 0(threading + sqlite3 stdlib). 새 `ai_jobs` 테이블은 `store_schema.py` DDL + `store_constants.py:18 STORE_TABLES` 등록 필요(`store_serialization.py:480 _validate_table`이 미등록 테이블을 차단). 백업은 `Connection.backup`이 파일 전체를 뜨므로 자동 포함. + +**가드레일** — 워커는 생성(HTTP) 구간을 반드시 락 밖에서 수행하고 저장 순간에만 락을 잡는다. '생성 중' 상태가 판정 버튼을 막지 않는다. + +**검증자가 지적한 조건** — (1) **1번(프롬프트 축소)이 선행이다** — 축소 없이 잡 큐만 넣으면 '생성 중' 칩이 영구히 붙어 있는 화면이 된다. (2) 실패 백오프 추가(현재는 매 reload마다 전건 재시도). (3) `env_clients._llm_assistant()`에 도달성 프로브 추가 — 여러 아이디어가 "None이면 폴백"을 전제하는데 그 전제가 현재 거짓이다. (4) ~~`_connect()`에 `journal_mode=WAL` 추가~~ — **이 조건은 취소한다. 확인 결과 WAL은 이미 켜져 있다**(`sqlite_store.py:162 initialize()`, DB 파일에 영속). `store_backup.py` docstring이 WAL을 전제로 서술한 것도 정확했다. 워커 쓰기가 HTTP 읽기를 블록하는 위험은 존재하지 않는다. (5) 워커는 `run_once()`를 노출해 스레드 없이 테스트 가능하게 하고, 부팅 시 `running→pending` 리셋을 넣는다. + +### 상위권 중 상세 미수록 3건 (요약) + +**4위 · 방송캡처 포렌식 (평균 6.53, 공수 S→M)** — 방송 화면·앱 스크린샷을 결정적 영상처리(휘도 투영 프로파일, Canny 엣지 밀도, HSV 채도, 히스토그램 균일도)만으로 판별해 라벨을 붙인다. **ML 모델 0, 의존성 0, 실측 2ms/장.** 지금 코드에 캡처를 식별하는 줄이 하나도 없고, 오히려 `_is_generic_best_guess_label`이 'screenshot'을 블록리스트로 버린다. 심사 3인이 공통으로 "관측 가능성이 로컬비전 6건 중 최고"(오탐 원인이 사람이 읽는 규칙으로 남는다)라고 평가했다. 조건: **1차는 반드시 0점 라벨로 출시**하고 `decision_stats()` 교차표로 실제 반려와의 상관을 본 뒤 점수를 부여할 것. 레터박스·하단 텍스트밴드는 시네마틱 크롭·밈·PPT 캡처와 구분되지 않으므로 "서로 독립인 카테고리 2개 이상"을 검출 조건으로 둘 것. → 로드맵 2-f. + +**5위 · 모델 번들 무결성 검증 (평균 6.10, 공수 M)** — `/api/tags` 응답 기반 `models.lock.json` + **비차단** 프리플라이트 + `enabled`와 분리된 `modelReady` 필드. 핵심 발견: `env_clients._llm_assistant()`가 **도달성 검사 없이 무조건 어시스턴트를 반환**해 llm provider가 항상 `enabled=True`이고, `offline-install.md`는 정반대로 서술한다. 즉 **provider 패널이 지금 거짓말을 하고 있다.** 이 산출식 하나를 고치면 다른 4건이 공통으로 깔고 있는 "LLM이 없으면 폴백" 거짓 전제까지 한 번에 참이 된다. 조건: 프리플라이트가 기동을 막으면 안 되고(DROP 방화벽에서 기동 정지), 운영자 긴급 비활성 토글인 `enabled`를 자동 강등이 덮어써서도 안 된다. → 로드맵 1-f + 2-b. + +**8위 · 작업별 모델 라우터 (평균 5.73, 공수 M→L / 1단계 S)** — 태스크별 Ollama 파라미터 프로필과 모델 등급 분리(0.5B 분류 / 3B 주력 / 7B 옵션). 심사 3인이 공통으로 "**태스크가 문자 그대로 하나뿐인 지금 라우터는 전형적 선행 추상화**"라고 감점했고, 동시에 그 1단계(`num_ctx`/`num_predict`/`keep_alive`/`timeout`을 환경변수로 노출 + 시드 LLM 호출을 `_write_lock` 밖으로)는 "최우선급 수술"로 평가했다. **결론: 라우터가 아니라 1단계만 떼어내 쓴다**(로드맵 1-b·2-d). 7B(+4.7GB)는 1단계 실측 tok/s가 나오기 전 번들 반입 금지. + +## 반증되어 제외된 것 + +### 통째로 REFUTED — 6건 (심사 대상에서 제외) + +발산 30건 중 6건이 적대적 검증에서 전제 자체가 무너져 심사에 올라가지 못했다. **이 6건은 "나중에 하자"가 아니라 "이 형태로는 하지 말자"이다.** + +| 탈락 아이디어 | 렌즈 | 무너진 전제 | +|---|---|---| +| **LLM 쿼리 리라이터** | LLM 파이프라인 | 입력으로 쓰겠다는 지식엔트리가 파이프라인에 안 들어온다 — 프로덕션 두 호출부가 `plan(evidence, [], ...)`로 **빈 리스트**를 넘긴다(`store_enrichment.py:447,620`). 그런데 `plan()`은 이미 `entry.aliases`로 별칭 쿼리를 만든다. **별칭 확장 기계는 이미 있고 입력선만 끊겨 있다** — 한 줄 수정이 LLM보다 먼저다. 또 가드레일("입력 토큰 1개 이상 공유")과 가치(활동명↔본명·로마자 = 입력에 없는 외부 지식)가 서로를 무효화한다 | +| **반려 메모 초안기** | LLM 파이프라인 | 코퍼스가 존재하지 않는다(판정 메모 0건). 그리고 `memo`는 `APPLICANT_EXPORT_FIELDS`에 포함돼 **LLM 생성 텍스트가 신청자에게 나가는 경로를 새로 여는 유일한 아이디어**다. 반려 사유는 분쟁 시 문서 증거다. 효용도 역전 — 3B 초안 대기 40~90초 vs 수작업 타이핑 30~90초 | +| **로컬 VLM 쿼리 시드** | 로컬 비전 | "배치라 대기 경로 밖"이 거짓(시드는 `_write_lock` 안 동기 POST). 환각이 점수로 새는 경로 2개 실재 — VLM priority 58이 유료 쿼터를 잠식하고, 헛쿼리 검색 실패는 `FAILURE +30`으로 **환각이 곧바로 위험점수를 올린다**. 지연도 과소추정(이미지 프리필만 15~50초 > `timeout=30`) | +| **판정역산 가중치 학습기** | 사람 판정 학습 | n=0. 더 근본적으로 **라벨 순환이 구조적**이다 — 큐가 `riskScore` 내림차순 정렬이고 화면이 점수·근거를 먼저 보여준 뒤 판정을 받으므로, 학습된 계수는 "이 근거가 반려를 예측한다"가 아니라 "운영자가 내가 보여준 점수에 동의했다"를 재학습한다. 제안 피처 3개는 현 코퍼스에서 상수 0 컬럼 | +| **라벨가치 우선 큐** | 사람 판정 학습 | 입력인 p(반려)가 없다(위 항목이 REFUTED). 능동학습의 경제학이 성립하지 않는다 — **큐의 모든 케이스가 결국 판정되므로** 재정렬은 라벨이 언제 오는지만 바꾸고 몇 개 오는지는 못 바꾼다. 게다가 `riskScore` 내림차순은 정보 순서가 아니라 **위험 트리아지 순서**이고 미탐 비용이 비대칭이라 정당하다. 가치 있는 절반(중복그룹 우선)은 `duplicateGroup`으로 이미 클라이언트에 도착해 있다 | +| **시리즈 묶음 심사 카드** | 운영자 효율 | 0.88~0.97 밴드가 목표를 못 맞춘다(실측 반증). 실제 제출 이미지로 측정: 20% 크롭 0.812~0.859, 좌우반전 0.406~0.844로 **목표는 밴드 아래로 빠지는데**, 무관한 이미지 쌍이 이미 0.781을 낸다. 원인은 `fingerprints.py:47`이 DCT pHash가 아니라 **8x8 평균해시**라는 것. 게다가 묶음 LLM 요약은 8건 전체에서 근거 링크를 뽑으면서 실제로는 1건 남짓만 읽어 **근거 없는 요약 하나를 8건에 확산**시킨다 | + +> 이 6건이 남긴 **재사용 가능한 교훈 3가지**: ① 이 레포에서 "배치니까 괜찮다"는 거짓이다 — 시드가 `_write_lock` 안에서 돈다. ② 실패 증거에 `+30`이 붙으므로 **AI가 틀리면 위험점수가 올라간다**(환각→가점 경로). ③ `_perceptual_hash`는 이름만 pHash이고 실제로는 평균해시라, 유사도 임계값에 기대는 모든 설계가 이 위에서 흔들린다. + +### 살아남은 아이디어에서 잘려나간 구성요소 + +아래는 항목 자체는 살았지만 **내부의 특정 주장·구성요소가 반증되어 잘려나간 것들**이다. 착수 시 되살리면 안 된다. + +| 잘려나간 것 | 소속 | 반증 근거 | +|---|---|---| +| **ⓒ/© OCR 매칭** | 워터마크 검출기 | OpenCV 배포 CRNN `crnn.onnx`의 알파벳은 `alphabet_36`(0-9, a-z)뿐이라 대문자·괄호·기호가 없다. 94자 `crnn_cs.onnx`에도 U+00A9가 없다. `'(C) 2024'` 정규식 매칭률 0, `stock.adobe.com`은 `stockadobecom`으로 복원 | +| **전체 프레임 로고 템플릿 매칭** | 워터마크 검출기 | 개발 머신 실측 1920x1080 · 로고 10개 × 6스케일 = **2,347ms**. KB에 로고 50개면 ×5 → 저사양 30~100초/장. 비용이 "운영자가 등록한 로고 수"에 선형이고 상한이 없다 | +| **타일 aHash 부분매칭 국소화** | 증거 영역 오버레이 | `match='partial'`은 Google Vision의 원격 URL 목록에서만 생성되며 **우리는 그 픽셀을 갖고 있지 않다**. 게다가 8x8 average hash는 실측에서 무관한 제출 이미지 쌍이 이미 0.25~0.781을 낸다 — 1/16 타일은 더 심하다. **틀린 영역을 칠하는 것은 아무것도 안 칠하는 것보다 나쁘다** | +| **LLM 정책메모 초안** | 근거프로파일 일관성 감시 | 착지점 `decision_memo_presets`의 본문이 `memo`를 거쳐 `APPLICANT_EXPORT_FIELDS`로 신청자에게 나간다. 또 "이 조합에서는 …인 경우 반려"는 시스템 프롬프트가 금지하는 판정 권고의 집합 형태 | +| **LLM 후보 재랭킹** | 근거공백 액션 플래너 | 후보 총량이 3~5개(쿼터 상한). 5개 중 3개 고르기에 0.5B + 파싱 폴백 + 범위 검증 + 배지를 붙이는 과설계. `priority`가 이미 순서를 준다 | +| **"추가 네트워크 비용 0"** | 페이지 권리표기 추출 | `page_fetcher` 호출부는 유사도 비교의 **네 번째 폴백**이라, 이미지가 실제로 걸린 페이지는 애초에 HTML을 받지 않는다. 게다가 페치 바이트는 어디에도 캐시되지 않는다 | +| **"screenshot 블록리스트는 자해"** | 방송캡처 포렌식 | 과장된 오독. `_is_generic_best_guess_label`은 검색 쿼리 적합성 판단 함수이고 'screenshot' 한 단어로 검색하는 것은 실제로 무의미하다. 차단은 옳은 설계 | +| **"수천 번 클릭한 오탐 라벨"** | 근거 신뢰도 재보정 | 실측 evidence 292행 전부 `operatorStatus=None`, 백업 DB에 `false_positive` 2행이 전 이력 | +| **"pending 프리마킹은 점수 불변이 코드로 보장"** | 근거 신뢰도 재보정 | `mark_evidence_status`(`sqlite_store.py:731-734`)가 NON_CONTRIBUTING이 아니면 `contributed`를 True로 복원하고 재채점한다. 이전에 false_positive였던 FINGERPRINT 행을 pending으로 바꾸면 +80이 되살아난다 | +| **"운영자별 편향 감지"** | 근거프로파일 일관성 감시 | `actor='rights.ops'` 상수가 문제가 아니라 **시스템에 사용자 개념 자체가 없다**(`_require_auth`는 단일 공유 bearer 토큰 하나). 이 선행 작업 없이 편향 감지를 주장하면 허위 | +| **인용 검증기의 "환각 차단" 가치** | 문장별 인용 검증기 | 실측 환각은 서술어(의미)에 있고 제안된 검사는 개체(URL·숫자·연도)에 있다. 실측 출력 'confirming the image's authenticity [E3]' 같은 근거 없는 단정이 화이트리스트 대조를 100% 통과 → **오히려 환각 문장에 "근거 확인됨" 도장을 찍는다** | +| **"문장 끝 라벨" 파서 계약** | 문장별 인용 검증기 | 실측 5회 전부 라벨이 **불릿 앞**에 붙었고 문장 끝은 0건. 출력이 마침표 문장이 아니라 개행 덩어리라 한국어 문장 분해가 성립하지 않는다 | +| **"검증된 폐기 경로 재사용"** | 문장별 인용 검증기 | `_source_evidence_ids`는 항상 비지 않으므로 `verified`는 프로덕션에서 항상 True. `policies.py:110-114`의 LLM_SUMMARY 분기와 폐기 분기는 **한 번도 실행된 적이 없다** | +| **"llm_assistant가 None이면 폴백"** | 6·9·16·23 공통 | `env_clients._llm_assistant()`는 도달성 검사 없이 무조건 반환. `is None` 분기는 절대 실행되지 않고 llm provider는 항상 `enabled=True` | +| **`EvidenceEnricher` / `ReviewEnrichmentJob` 수정** | 6·9·16 공통 | 프로덕션 미배선 죽은 코드. 실제 호출 지점은 `store_operations.py:471`. 테스트 2건 + `test_public_module_layout.py` 임포트 검사가 붙어 있어 이 범위에서 손대면 안 됨 | +| **"7B로 메모 초안"** | 작업별 모델 라우터 | CPU 프리필 100초+ 생성 40~50초. 1단계 실측 tok/s가 나오기 전에는 번들에 넣지 말 것(+4.7GB) | +| **"새 저장 인프라 없음"** | 의미지문 인덱스 | 신규 테이블은 `STORE_TABLES`·`CONSTRAINED_TABLE_SCHEMAS`·`LEGACY_REBUILD_ORDER`·`TYPED_COLUMNS`·`SCHEMA_VERSION`·`_validate_table`을 전부 건드리고, 벡터 BLOB이 백업 zip 10개에 곱해져 들어간다 | + +**미검증 1건**: 16위 「밴드 임계값 스윕·섀도 리스코어」는 적대적 검증 단계에서 판정이 기록되지 않았다(`verdict: null`). 다른 항목과 같은 수준의 반증을 거치지 않았으므로 착수 전 별도 검증이 필요하다. 특히 원안이 전제하는 "결정된 케이스 전체"가 현재 0건이라는 점, `_rescore_all_submissions`가 항상 쓰기까지 한다는 점(read-only 경로 분리 필요)이 검증 대상이다. + +## 단계별 로드맵 제안 + +> 이 로드맵은 심사 점수와 독립적으로 **선행조건 의존 관계**로 짰지만, 결과적으로 정합한다: +> 1단계는 심사 1·3·6·7·10·12위를 포함하고, 라벨 의존 항목(21·23·24위)은 전부 3단계로 밀린다. +> 점수 하위권이 3단계로 가는 이유는 "가치가 없어서"가 아니라 **지금은 입력이 없어서**다. + +### 1단계 — 지금 당장(라벨 0건·의존성 0·모델 반입 0에서 작동) + +한 스프린트짜리 "기반 정리" 묶음. **이 단계는 AI를 늘리지 않는다. 이미 켜져 있는 AI의 거짓말과 세금을 끄는 작업이다.** + +| 항목 | 내용 | 공수 | +|---|---|---| +| 1-a | **LLM 입력 경계 단독 커밋** — 프롬프트 화이트리스트 + 증거 상한 12건 + `num_ctx` 명시 (`promotion_reason` 제외) | S | +| 1-b | **시드 LLM 순회를 `_write_lock` 밖으로** + 시드당 처리 상한 20건 + 실패 백오프 | S | +| 1-c | **`initialRiskScore`/`initialRiskBand`/`scorerVersion` 각인** + 라운드트립 속성 기반 테스트 | S | +| 1-d | **모델·프롬프트 버전 스탬프** (id 해시 오염 회피 규칙 준수) | S | +| 1-e | **원본 메타 권리추출 EXIF+IPTC만** (XMP는 별건, 스톡 ID는 +30 라벨) | S | +| 1-f | **`env_clients._llm_assistant()` 도달성 프로브** + provider `enabled` 산출식 정정 + `offline-install.md`의 잘못된 서술 정정 | S | +| 1-g | **요약 상태 3단 배지**(정상 / 생성 안 됨+원인 / 실패) — 5단은 과설계 | S | +| 1-h | **액션 플래너 축소판** — `plan()` 결과를 `review()` 응답에 싣고 `_is_generic_local_query` 탈락 쿼리 숨김 + `query_priority` 매핑 1줄 | S | +| 1-i | `app.js:726-746` 영→한 if-체인을 사전 테이블로 리팩터 *(같은 항목에 있던 "`_connect()`에 WAL 추가"는 **이미 적용돼 있어 삭제**)* | S | + +**2단계로 가기 위한 선행조건** +- 1-d의 `latency_ms`가 실제 타깃 하드웨어에서 2주 이상 쌓여 **케이스당 실측 지연 분포**가 나올 것. +- 1-c의 라운드트립 속성 테스트가 그린이고, 새 `EvidenceSource` 배관 12곳 체크리스트가 문서로 확정될 것. +- 타깃 하드웨어 사양(코어/스레드/메모리대역/GPU 유무)이 확정될 것 — 실측이 2스레드 20.6초 vs 20스레드 5.9초로 갈렸다. 이 값 없이는 3B 채택 여부를 결정할 수 없다. +- 모델 번들 크기 상한이 승인될 것. + +### 2단계 — 모델 도입을 수반하는 중기 + +| 항목 | 내용 | 비고 | +|---|---|---| +| 2-a | **YuNet 교체** + detect 중복 호출 제거 + tier 라운드트립 | 모델 345KB, 지연 순감. 밴드 재캘리브레이션 비용을 판정 라벨이 쌓이기 전에 치른다 | +| 2-b | **모델 번들 무결성 검증** — `models.lock.json`(`/api/tags` 응답 기반) + **비차단** 프리플라이트 + `enabled`와 분리된 `modelReady` 필드 | 차단형 프리플라이트 금지(DROP 방화벽에서 기동 정지) | +| 2-c | **잡 큐 + 백그라운드 워커** (`run_once()` 테스트 모드, 부팅 시 running→pending 리셋) | 1-b로 급한 불을 끈 뒤, 태스크가 2개 이상 생긴 다음 | +| 2-d | **모델 라우터 1단계** — `num_ctx`/`num_predict`/`keep_alive`/`timeout`을 환경변수 프로필로 노출 + 3B 실험 + 감사 로깅(모델명·소요시간·토큰수) | 7B는 실측 tok/s 확인 전 번들 금지 | +| 2-e | **증거팩 직렬화 + 인용 칩** (3B) — 줄 단위 출력(`[E]`로 **시작**), 라벨 없는 줄만 드롭, 미라벨 줄은 렌더하지 않음(취소선 금지) | 1-a·2-d 선행 필수 | +| 2-f | **방송캡처 포렌식** — 실측 2.0ms/장, 모델 0. 단 **1차는 0점 라벨로만** 출시하고 워터마크와 같은 EvidenceSource·같은 GUI 그룹으로 배관 비용을 한 번만 지불 | 독립 카테고리 2개 이상 성립 규칙 | +| 2-g | **워터마크 로고 템플릿 매칭** — ROI를 4모서리 + 하단 20%로 한정, 활성 로고 12개 상한, 스케일 3단계, 점수 +45(단독 high 금지), 크롭은 face-crop과 **분리된 보관 경로** | OCR(56~72MB) 반입은 로고 경로 검출률을 본 뒤 결정 | + +**3단계로 가기 위한 선행조건** +- **판정 라벨이 실제로 축적될 것** — 이것이 절대적 게이트다. 현재 0건. 프로파일 군집은 자체 규칙(군집 5건 이상)까지 감안하면 수백 건이 필요하고, 쿼리전략별 통계는 `queryStrategy`가 채워지기 시작한 이후 수개월이 걸린다. +- `decision_stats()`의 밴드×판정 교차표가 유의미한 표본(최소 100건)을 가질 것. +- 골든셋 diff 하네스가 돌아 "가중치를 바꾸면 큐가 어떻게 변하는가"를 숫자로 낼 수 있을 것. +- 임베딩 저장 금지 정책의 개정 여부가 **사용자 결정**으로 확정될 것(아래 열린 질문 참조). + +### 3단계 — 학습·자동화로 가는 장기 + +| 항목 | 내용 | 진입 조건 | +|---|---|---| +| 3-a | **근거 신뢰도 카운트 배지** — (source, matchType, domainSource) 3키 집계, n≥15에서만 표시. isotonic 보정표·confidence 주입은 **만들지 않는다** | 사실상 오늘 출시 가능(n=0에서 조용히 사라짐). 오히려 라벨 수집 인센티브 | +| 3-b | **AI 수용률 축소판** — 새 테이블·새 화면 없이 `decision_stats`에 `llmDraftPulled`/`llmDraftMedianRetention` 두 필드. 단 `_latest_decision_memo`의 `' · '` partition 버그 선수정 | 판정 라벨 축적 | +| 3-c | **밴드 임계값 스윕 + 섀도 리스코어** — 쓰기를 타지 않는 read-only 경로로 분리, 적용은 항상 사람 클릭 + 감사 이벤트 | 결정 케이스 100건 + 별도 검증(현재 미검증) | +| 3-d | **근거프로파일 일관성 감시(A안만)** — 시그니처는 현행 코드가 실제로 채우는 필드로만. LLM 정책메모 초안은 **폐기**. actor/다중 사용자 인증은 **분리 발주** | 판정 수백 건 | +| 3-e | **트리아지 레인 — 규칙 우선** — 1차는 모델 없이 `decision_stats` 교차표 규칙("같은 밴드+같은 providerState에서 과거 승인률>80% & n≥10")으로, 동결 라벨 200건 초과 시 로지스틱 교체 | 피처 동결(판정 시점 스냅샷) + 워치리스트 역류 제외가 선행 | +| 3-f | **의미지문 인덱스** — 1단계는 제출물+KB 참조만(검색 후보 제외), cv2.dnn + CNN 백본, 벡터는 SQLite BLOB이 아니라 `.npy` 외부 저장, 점수 0 + '변형 의심' 라벨 | **거버넌스 사전 승인 없이는 착수 금지** | +| 3-g | **페이지 권리표기 추출** — 유사도 폴백 체인에 얹지 말고 "점수 기여 상위 N개 URL"에 대한 독립 페치 단계(케이스당 2건)로. 1차는 정규식 전용, LLM 없음 | 페치 커버리지 재설계 | +| 3-h | **케이스 Q&A — 고정 질문 사전 캐시** — 자유 질문 동기 대기(40~120초)는 기능의 사망 원인. 증거 확정 시점에 3~5개 고정 질문을 미리 생성해 클릭 시 0초 | 잡 큐(2-c) + 별도 캐시 테이블(TTL, 감사 대상 아님) | + +## 결정 반영 실행안 (2026-07-25 확정) + +아래 열린 질문 중 4개가 답변되었다. **이 답변들이 위 로드맵의 전제를 바꾸므로, 실행 시에는 이 절이 로드맵보다 우선한다.** + +| 질문 | 결정 | +|---|---| +| LLM 역할 범위 | **증거 판독 + 구조화 판단** (요약만 유지 아님) — 3B 이상 승급 | +| 타깃 하드웨어 | **GPU 있음** | +| 새 신호의 점수 개입 | **0점 라벨로 시작**, `decision_stats` 상관 확인 후 가중치 부여 | +| 실사용 투입 | **곧 투입 예정** | + +### 이 결정들이 무효화한 분석 + +1. **CPU 지연 분석 전체가 이 배포에는 적용되지 않는다.** 제약 2절의 실측(2스레드 20.6초 / 20스레드 5.9초, 3B 60~180초, "7B는 인터랙티브 불가")은 CPU-only 전제였다. GPU에서는 3B가 인터랙티브이고 7B도 후보다. **다만 지연이 사라져도 `_write_lock` 점유 문제는 남는다** — 락은 추론 시간에 비례할 뿐 원인이 아니다. +2. **"잡 큐/배치 선연산이 필수"라는 전제가 약해졌다.** 2-c(백그라운드 워커)의 우선순위를 낮추고, 1단계의 락 이동(5줄)만으로 당분간 버틴다. +3. **워터마크 OCR의 재검토 여지가 생겼다.** 단 반증된 결함(배포 CRNN 알파벳에 `©`·대문자 없음)은 하드웨어와 무관하므로, 재검토한다면 **다른 OCR 모델 선정이 전제**다. +4. **의미지문 인덱스는 여전히 막혀 있다.** 막는 것이 성능이 아니라 거버넌스(생체 템플릿 저장 금지 사양)이기 때문. GPU는 이 항목을 풀어주지 않는다. + +### 🔴 실사용 투입 **전에** 끝내야 하는 것 — 되돌릴 수 없는 창 + +**"곧 투입"이 만든 가장 중요한 제약이다.** 아래 4건은 운영자가 첫 판정을 누르는 순간 **소급이 불가능하거나 비용이 급등**한다. + +| # | 항목 | 왜 투입 전이어야 하는가 | +|---|---|---| +| **T0-1** | `initialRiskScore` / `initialRiskBand` / `scorerVersion` 각인 (`_submission_payload`) | `_rescore_submission`이 12곳에서 `riskScore`를 덮어쓰므로 저장된 점수는 이미 왕복 점수다. 원본 점수를 지금 각인하지 않으면 **판정이 시작된 뒤에는 영원히 복원할 수 없고**, 모든 점수 회귀 테스트가 항상 통과하는 빈 테스트로 남는다 | +| **T0-2** | YuNet 교체 + 밴드 재캘리브레이션 | 등급화는 기존 medium 다수를 low로 옮겨 밴드 분포를 바꾼다. 판정 이력이 쌓인 뒤에 하면 `decision_stats`의 캘리브레이션 기준선이 판정 이력과 어긋난다. **판정 0건인 지금이 이 비용이 가장 싼 시점이고, 그 시점은 곧 닫힌다** | +| **T0-3** | **판정 시점 피처 스냅샷 각인** (라벨 순환 차단) | 큐가 `riskScore` 내림차순 정렬이고(`app.js:1267`) 화면이 점수·밴드·reasons를 보여준 뒤 판정을 받는다. 이 구조에서 모은 라벨은 "이 근거가 반려를 예측한다"가 아니라 **"운영자가 내가 보여준 점수에 동의했다"**를 기록한다. 판정 순간 운영자가 실제로 본 것(점수·밴드·표시된 근거 목록)을 스냅샷으로 남겨야 나중에 순환 보정이 가능하다. **투입 후에 붙이면 그 이전 라벨은 전부 용도 불명이 된다** | +| **T0-4** | 모델·프롬프트 버전 + `latency_ms` 스탬프 | 투입 후 모델을 3B로 올리면 과거 요약과 신규 요약이 DB에 섞이는데 구분할 필드가 없다. 지금 스탬프를 넣으면 승급 전후 비교가 성립한다 | + +> T0-3은 이번 심사에서 나온 항목이 아니라, **"곧 투입"이라는 답변이 새로 만든 요구사항**이다. 학습 계열 7건 전부의 미래 가치가 여기 걸려 있고, 비용은 S다. + +### 실행 순서 (확정) + +**A. 투입 전 (T0)** — `initialRiskScore` 각인 · YuNet + 재밴딩 · 판정 스냅샷 각인 · 버전/지연 스탬프 + +**B. LLM을 판독기로** — GPU 전제로 공격적으로 간다 +- B-1 입력 경계(화이트리스트 + 상위 12건 + `num_ctx`) — `promotion_reason` 제외 필수 +- B-2 3B 승급 + GPU 프로필(`num_ctx`/`num_predict`/`keep_alive`/`num_gpu`), 7B는 B-2 실측 후 판단 +- B-3 시드 LLM 호출을 `_write_lock` 밖으로 +- B-4 **구조화 증거 판독** — `format=json`으로 `{claims:[{text, evidence_ids[], kind}], needs_check:[...]}` 스키마 강제. + **인용 검증은 문장 파싱이 아니라 스키마 필드 검증으로 한다** — `evidence_ids`가 실제로 프롬프트에 들어간 증거 id 집합의 부분집합인지만 대조하고, 아니면 그 claim을 렌더하지 않는다. + *이 설계는 심사에서 3.03점을 받은 「문장별 근거 인용 검증기」의 반증 사유를 우회한다*: 그 안은 자유 텍스트를 문장 분해해 개체(URL·숫자)를 대조하는 방식이라 `'confirming the image's authenticity [E3]'` 같은 **근거 없는 서술어에 "확인됨" 도장을 찍는** 문제가 있었다. 스키마 필드 대조는 모델이 claim 단위로 id를 스스로 붙이게 하므로 이 실패 모드가 구조적으로 발생하지 않는다. + +**C. 모델 없는 신호 (전부 0점 라벨)** — EXIF/IPTC 권리추출 · 방송캡처 포렌식. 두 항목은 **같은 `EvidenceSource`·같은 GUI 그룹**으로 묶어 배관 12곳 비용을 한 번만 지불한다. + +**D. 투입 후 라벨 축적 뒤** — `decision_stats` 교차표로 0점 라벨들의 실제 반려 상관 측정 → 가중치 부여 → 임계값 스윕. 학습 모델은 T0-3 스냅샷이 수백 건 쌓인 뒤. + +### 남은 결정 1개 + +**임베딩 저장 금지 정책의 개정 여부** — 의미지문 인덱스(22위)만 이 결정에 걸려 있고, 나머지 A~D는 전부 이 결정 없이 진행된다. 급하지 않으므로 D 시점에 재론해도 된다. + +## 열린 질문 (사용자 결정 필요) + +아래는 근거로 답할 수 없고 **사람이 정해줘야 다음 단계가 진행되는** 항목이다. + +1. **타깃 하드웨어 사양** — CPU 코어/스레드 수, 메모리 대역폭, GPU 유무. 실측이 2스레드에서 20.6초, 20스레드에서 5.9초로 3배 이상 갈렸다. 이 값 없이는 3B 채택 여부(2-d)도, "케이스당 몇 초까지 허용되는가"도 결정할 수 없다. **현재 문서의 모든 저사양 추정치는 개발 머신 실측의 외삽이며 추정이다.** +2. **임베딩 저장 금지 정책의 개정 여부** — 3-f(의미지문 인덱스)는 사양 문서 두 곳(`docs/operations/image-rights-risk-filter.md:16`, `docs/project-introduction-and-technical-implementation.md:209`)의 "생체 템플릿 저장 금지" 문구와 정면으로 만난다. 전용 테이블 우회는 코드는 통과해도 사양은 통과하지 못한다. 결정 옵션: (a) 개정하지 않고 3-f 영구 배제, (b) "장면 벡터는 SEMANTIC_INDEX로 별도 관리"를 명문화하되 얼굴 박스 검은 마스킹 + 인물이 프레임 N% 이상이면 인덱싱 스킵 + 원본 삭제 시 연쇄 삭제를 조건으로. **(b)를 택하면 사양 문서 2곳 동시 개정이 필요하다.** +3. **케이스당 허용 가능한 추가 지연** — 동기 요청 경로에서 몇 초까지 운영자가 기다릴 수 있는가? 현재 `timeout=30`은 0.5B조차 여유가 없는 값이다. 이 숫자가 정해져야 (a) 어느 기능이 동기이고 어느 것이 배치인지, (b) 3B를 쓸 수 있는지가 결정된다. +4. **모델 번들 크기 상한** — 후보별 증분: 3B Q4_K_M +약 1.9GB / 7B +4.7GB / OCR ONNX 56~72MB / YuNet 345KB / 롤백 태그 유지 +379MiB. Ollama 런타임과 0.5B는 현행 구성이 이미 요구하므로 증분이 아니다. **반입 매체와 절차 기준으로 상한을 정해달라.** +5. **4번째 런타임 의존성 반입 승인 여부** — `requirements.txt` 헤더가 "서드파티는 PIL/cv2/numpy 뿐"이라고 선언하고 있다. 후보: `defusedxml`(25KB, XMP/C2PA 판독에 필수 — 없으면 폐쇄망에서 조용히 no-op), `onnxruntime`(40~55MB, CLIP/ViT 계열에 필수). 각각 별도 심사 안건이다. +6. **AI 제안의 판정 개입 범위** — 새 신호를 (a) 0점 라벨로만 낼 것인가, (b) 캘리브레이션 후 점수를 줄 것인가, (c) 준다면 단독 high 승격을 허용할 것인가. 검증자들은 워터마크 +70, 스톡 자산 ID +70, 방송캡처 +30, 의미유사도 +30을 **전부 낮추거나 0점 라벨로 시작하라**고 권고했다. 판정 라벨이 0건인 현재로선 어떤 가중치도 근거가 없다. +7. **새 `EvidenceSource` 배관 12곳을 언제 한 번에 지불할 것인가** — 워터마크·방송캡처·권리메타·권리표기가 각각 새 소스를 요구한다. 운영자 관점에서는 넷 다 같은 질문("이 이미지가 어디서 왔나")에 답하므로 **하나의 소스·하나의 GUI 그룹으로 묶어 배관 비용을 한 번만 지불**하는 안이 검증 단계에서 제시됐다. 묶을지 나눌지는 화면 설계 결정이다. +8. **다중 사용자 인증(actor)을 별도 발주할 것인가** — 현재 시스템에 사용자 개념이 없어(단일 공유 bearer 토큰) "운영자별 편향 감지"·"프리마킹과 사람 라벨 구분"이 원리적으로 불가능하다. 계정·로그인·세션→actor 전파(`add_audit_event` 호출부 약 10곳) + 감사 로그 마이그레이션은 별도 프로젝트이며 보안 리뷰 대상이다. +9. **골든셋 파일의 반출 통제 수준** — 1000건 기준 약 31MB, 내부 riskScore·result URL·pageTitle을 담는다. `.gitignore`만으로 충분한가, 아니면 납품 번들 생성 스크립트 제외 목록 + `offline-install.md` + delivery-audit 체크리스트까지 3중으로 갈 것인가. +10. **판정 라벨을 언제까지 얼마나 모을 것인가** — 3단계 전체(3-a~3-h)가 이 값에 걸려 있다. "몇 건이 모이면 학습계를 켠다"는 목표치를 정하지 않으면, 라벨 의존 기능 7건이 영구히 비어 있는 화면으로 남는다. diff --git a/docs/operations/image-rights-risk-filter.md b/docs/operations/image-rights-risk-filter.md index 08a4d3d..b397f4e 100644 --- a/docs/operations/image-rights-risk-filter.md +++ b/docs/operations/image-rights-risk-filter.md @@ -13,11 +13,37 @@ - Do not automate Naver web search UI or scrape Naver result pages. - Do not send internal originals to external APIs. - Do not send original images or analysis derivatives to Naver search; use text queries only. -- Do not store face embeddings, celebrity identity matches from faces, or biometric templates. +- Do not store face embeddings, face landmarks, face descriptors, celebrity identity matches from faces, or biometric templates. - Do not use an LLM output as standalone evidence unless it is tied back to source evidence. - Do not expose automated scores, evidence, or provider details to applicants. - Do not let the filter change review status automatically. +## Scene Embedding Policy + +> **2026-07-25 개정.** 이전 규정은 "임베딩 저장 금지"를 포괄적으로 서술해, 인물 식별과 무관한 +> **장면 임베딩**(전체 이미지의 의미 벡터)까지 함께 금지되는 것으로 읽혔다. 금지의 취지는 +> **생체 식별**이지 이미지 유사도 판정이 아니므로, 아래 조건을 만족하는 장면 임베딩을 허용하도록 +> 범위를 좁힌다. 얼굴 임베딩 금지는 그대로 유지되며 오히려 강화되었다(landmark·descriptor 추가). + +Scene embeddings (whole-image semantic vectors) are permitted **only** for image-to-image +similarity matching — detecting that a submission is a crop, retouch, or re-encode of an image +already in the knowledge base or of another submission. They are never used to identify a person. + +Every scene embedding must satisfy all of the following: + +1. **Face masking** — detected face regions are filled before the vector is computed. The vector + never encodes facial pixels. +2. **Person-dominant exclusion** — if detected face/person regions cover more than the configured + share of the frame, the image is not indexed at all. A portrait is excluded rather than masked. +3. **Purpose restriction** — the vector may only be compared against other scene embeddings for + similarity. Identity lookup, clustering by person, and any face-keyed index are prohibited. +4. **Separate storage class** — vectors are stored under `DataClass.SCENE_EMBEDDING`, never inside + operator evidence payloads. Evidence rows carry the similarity result, not the vector. +5. **Cascade deletion** — deleting or detaching the source image deletes its vector. + +`governance/policies.py` enforces (3)(4)(5) structurally and asserts that (1)(2) were applied +before a vector is accepted. A vector missing the masking/exclusion attestation is rejected. + ## Disable External Calls Set the external API policy to disabled. Batches should continue internal-only and record external-skipped evidence reasons for affected submissions. diff --git a/docs/operations/offline-install.md b/docs/operations/offline-install.md index e051315..64bf52f 100644 --- a/docs/operations/offline-install.md +++ b/docs/operations/offline-install.md @@ -36,10 +36,45 @@ pip-compile --generate-hashes -o requirements.lock requirements.txt Then install on the target with `--require-hashes` (step 3). +### Model weights (`models/`) + +Some analysis paths use pre-trained weights that are **not** pip packages and are +**not** committed to the repository (`models/` is gitignored, same treatment as +`wheelhouse/`). Fetch them on the build machine: + +```bash +mkdir -p models +# YuNet face detector (OpenCV Zoo, MIT). Used by face/person presence detection; +# cv2.FaceDetectorYN itself ships inside opencv-python-headless, only the weights +# are external. +curl -sSL -o models/face_detection_yunet_2023mar.onnx \ + https://github.com/opencv/opencv_zoo/raw/main/models/face_detection_yunet/face_detection_yunet_2023mar.onnx +``` + +Verify before transferring — a truncated or substituted model must not cross the +air gap unnoticed: + +| file | bytes | sha256 | +|---|---:|---| +| `face_detection_yunet_2023mar.onnx` | 232589 | `8f2383e4dd3cfbb4553ea8718107fc0423210dc964f9f4280604804ed2552fa4` | + +```bash +python - <<'PY' +import hashlib, pathlib +p = pathlib.Path("models/face_detection_yunet_2023mar.onnx") +print(len(p.read_bytes()), hashlib.sha256(p.read_bytes()).hexdigest()) +PY +``` + +If `models/` is absent on the target the server still starts: face detection +falls back to the bundled Haar cascades and logs the downgrade once. It does not +fail closed, because a missing optional weight must not stop an operator from +reviewing. + ## 2. Transfer -Copy both the repository and the `wheelhouse/` directory to the target via the -approved air-gap transfer process (e.g. removable media). +Copy the repository, the `wheelhouse/` directory, and the `models/` directory to +the target via the approved air-gap transfer process (e.g. removable media). ## 3. On the target machine (no internet) diff --git a/docs/project-introduction-and-technical-implementation.md b/docs/project-introduction-and-technical-implementation.md index ea61d21..ceb7cc0 100644 --- a/docs/project-introduction-and-technical-implementation.md +++ b/docs/project-introduction-and-technical-implementation.md @@ -104,9 +104,10 @@ The LLM output is stored as a source-linked summary evidence item. It helps oper Important boundary: - it does not identify a person; -- it does not store face embeddings; +- it does not store face embeddings, landmarks, or descriptors; - it does not perform biometric matching; -- it is used only as a review-priority signal. +- it is used only as a review-priority signal — and, when scene embeddings are enabled, to supply + the face regions that must be masked out before a scene vector is computed. ### 6.4 Image Fingerprints and pHash Similarity @@ -206,7 +207,7 @@ The project intentionally keeps several strict boundaries: - Do not automate Google Image Search, Google Lens, Naver web UI, or scrape result pages. - Do not send original images to Naver; Naver is used through text queries. -- Do not store biometric templates, face embeddings, or celebrity identity matches from faces. +- Do not store biometric templates, face embeddings, face landmarks/descriptors, or celebrity identity matches from faces. Scene embeddings (whole-image semantic vectors, face-masked and person-dominant-excluded) are permitted for image-similarity matching only — see "Scene Embedding Policy" in `docs/operations/image-rights-risk-filter.md` (2026-07-25 개정). - Do not expose internal risk scores or evidence details to applicants. - Do not let automated analysis change final review status. - Do not treat LLM output as standalone evidence unless it links back to source evidence. diff --git a/src/rights_filter/analysis/face_person_detection.py b/src/rights_filter/analysis/face_person_detection.py index 46b0817..a90bf27 100644 --- a/src/rights_filter/analysis/face_person_detection.py +++ b/src/rights_filter/analysis/face_person_detection.py @@ -1,8 +1,11 @@ from __future__ import annotations import logging +import os +import threading from io import BytesIO from dataclasses import dataclass +from pathlib import Path from rights_filter.analysis.preprocessing import ImagePayload @@ -11,32 +14,214 @@ logger = logging.getLogger(__name__) # Emit the "OpenCV build lacks CascadeClassifier" warning at most once per # process instead of on every image so operator logs stay readable. _warned_missing_cascade = False +_warned_missing_model = False FaceBox = tuple[int, int, int, int] +# Weights live outside the repo (gitignored, carried across the air gap with the +# wheelhouse). Absent weights are a downgrade, not a failure — see +# docs/operations/offline-install.md. +DEFAULT_FACE_MODEL_PATH = ( + Path(__file__).resolve().parents[3] / "models" / "face_detection_yunet_2023mar.onnx" +) +FACE_MODEL_PATH_ENV = "COPYRIGHTER_FACE_MODEL_PATH" + +YUNET_SCORE_THRESHOLD = 0.6 +YUNET_NMS_THRESHOLD = 0.3 + +# A face covering at least this share of the frame is a recognizable subject: the +# portrait-rights question is about that person. Anything smaller is a bystander +# in a scene, which is a much weaker signal — treating both as one flat +35 is +# what pushed every photo containing a human into the medium band. +IDENTIFIABLE_FACE_AREA_RATIO = 0.05 + +# Haar reports no per-detection score, so a Haar signal cannot claim the +# confidence a YuNet one can. Keep it explicitly middling and labelled. +HAAR_FALLBACK_CONFIDENCE = 0.5 + +TIER_NONE = "none" +TIER_BACKGROUND = "background" +TIER_IDENTIFIABLE = "identifiable" + +# One detector per thread: ThreadingHTTPServer serves concurrent operators, and +# FaceDetectorYN carries mutable input-size state that must not be shared. +_detector_state = threading.local() + @dataclass(frozen=True) class FacePersonSignal: face_count: int person_count: int face_boxes: tuple[FaceBox, ...] = () + # Highest per-detection score in the frame. 0.0 when nothing was found. + confidence: float = 0.0 + tier: str = TIER_NONE + # Which implementation produced this, so a downgraded run is visible in the + # evidence rather than silently looking like a confident one. + detector: str = "none" + # Largest face box area as a share of the frame — the input to the tier. + face_area_ratio: float = 0.0 @property def present(self) -> bool: return self.face_count > 0 or self.person_count > 0 +def _face_model_path() -> Path: + configured = os.environ.get(FACE_MODEL_PATH_ENV, "").strip() + return Path(configured) if configured else DEFAULT_FACE_MODEL_PATH + + +def _yunet_detector(cv2: object) -> object | None: + """Per-thread YuNet instance, or None when the weights are unavailable.""" + global _warned_missing_model + # Checked before the cache, not after: an OpenCV build without FaceDetectorYN + # must degrade to Haar every time, and a cached instance from a build that + # had it must not be handed back for a module that does not. Mirrors the + # CascadeClassifier guard below. + if not hasattr(cv2, "FaceDetectorYN"): + return None + if getattr(_detector_state, "yunet_failed", False): + return None + detector = getattr(_detector_state, "yunet", None) + if detector is not None: + return detector + + model_path = _face_model_path() + if not model_path.is_file(): + _detector_state.yunet_failed = True + if not _warned_missing_model: + _warned_missing_model = True + logger.warning( + "face detector weights not found at %s — falling back to Haar " + "cascades (see docs/operations/offline-install.md)", + model_path, + ) + return None + + try: + detector = cv2.FaceDetectorYN.create( + str(model_path), + "", + (320, 320), + YUNET_SCORE_THRESHOLD, + YUNET_NMS_THRESHOLD, + ) + except Exception: + _detector_state.yunet_failed = True + logger.warning("face detector failed to load from %s", model_path, exc_info=True) + return None + + _detector_state.yunet = detector + return detector + + +def _detect_with_yunet(cv2: object, decoded: object) -> FacePersonSignal | None: + detector = _yunet_detector(cv2) + if detector is None: + return None + + height, width = decoded.shape[:2] + try: + detector.setInputSize((width, height)) + _, faces = detector.detect(decoded) + except Exception: + logger.warning("face detection failed; degrading to no-face signal", exc_info=True) + return FacePersonSignal(face_count=0, person_count=0, detector="yunet") + + if faces is None or len(faces) == 0: + return FacePersonSignal(face_count=0, person_count=0, detector="yunet") + + face_boxes: list[FaceBox] = [] + best_confidence = 0.0 + for face in faces: + # Columns are [x, y, w, h, 10 landmark coordinates, score]. The landmarks + # are face-derived identity features: read the geometry and the score, + # never the landmarks. Storing them is prohibited outright by + # governance.policies.assert_no_biometric_template. + x, y, box_width, box_height = (int(round(float(value))) for value in face[:4]) + score = float(face[-1]) + face_boxes.append((max(0, x), max(0, y), max(0, box_width), max(0, box_height))) + best_confidence = max(best_confidence, score) + + return _signal_from_boxes(face_boxes, best_confidence, "yunet", width * height) + + +def _signal_from_boxes( + face_boxes: list[FaceBox], + confidence: float, + detector: str, + frame_area: int, +) -> FacePersonSignal: + if not face_boxes: + return FacePersonSignal(face_count=0, person_count=0, detector=detector) + + largest = max(width * height for _, _, width, height in face_boxes) + if frame_area <= 0: + # Unknown frame size: the ratio cannot be computed, so fall back to the + # pre-tier weight. A risk filter must not under-score on missing data. + return FacePersonSignal( + face_count=len(face_boxes), + person_count=len(face_boxes), + face_boxes=tuple(face_boxes), + confidence=round(confidence, 4), + tier=TIER_IDENTIFIABLE, + detector=detector, + ) + + ratio = largest / frame_area + tier = TIER_IDENTIFIABLE if ratio >= IDENTIFIABLE_FACE_AREA_RATIO else TIER_BACKGROUND + return FacePersonSignal( + face_count=len(face_boxes), + person_count=len(face_boxes), + face_boxes=tuple(face_boxes), + confidence=round(confidence, 4), + tier=tier, + detector=detector, + face_area_ratio=round(ratio, 4), + ) + + +def face_person_evidence_data(signal: FacePersonSignal) -> dict[str, object]: + """Evidence payload for a face/person signal. + + Shared by the analysis and enrichment paths, which build the same row from + two call sites — without one builder they drift, and a tier missing on one + side silently scores as the legacy flat weight. + """ + return { + "face_count": signal.face_count, + "person_count": signal.person_count, + "tier": signal.tier, + "detector": signal.detector, + "face_area_ratio": signal.face_area_ratio, + } + + +def face_person_reason(signal: FacePersonSignal) -> str: + if signal.tier == TIER_BACKGROUND: + return "배경 인물 검출 (초상권 신호 약함)" + return "식별 가능한 인물 검출" + + class HeuristicFacePersonDetector: """Presence-only detector used by the standalone implementation. - Target applications should replace this with their approved local detector or - internal service. It intentionally emits no identity or embedding data. + Prefers YuNet (OpenCV Zoo) when its weights are present and degrades to the + bundled Haar cascades when they are not. It intentionally emits no identity + or embedding data: face landmarks returned by the model are read for nothing + and never leave this module. """ def detect(self, image: ImagePayload) -> FacePersonSignal: - return self._detect_with_opencv(image.content) + # Frame size comes from the payload rather than the decoded array: the + # Haar path never needs the array's shape, and probing it would couple + # tiering to the decode backend. + return self._detect_with_opencv(image.content, image.width, image.height) - def _detect_with_opencv(self, content: bytes) -> FacePersonSignal: + def _detect_with_opencv( + self, content: bytes, width: int = 0, height: int = 0 + ) -> FacePersonSignal: try: import cv2 import numpy as np @@ -50,6 +235,10 @@ class HeuristicFacePersonDetector: if decoded is None: return FacePersonSignal(face_count=0, person_count=0) + yunet_signal = _detect_with_yunet(cv2, decoded) + if yunet_signal is not None: + return yunet_signal + # Some OpenCV builds (e.g. the 5.x preview wheels) drop the legacy Haar # cascade API. Detect that up front and degrade to a presence-negative # signal instead of letting an AttributeError escape into the seed/ @@ -97,11 +286,11 @@ class HeuristicFacePersonDetector: # rather than crashing the caller. return FacePersonSignal(face_count=0, person_count=0) - face_count = len(face_boxes) - return FacePersonSignal( - face_count=face_count, - person_count=face_count, - face_boxes=tuple(face_boxes), + return _signal_from_boxes( + face_boxes, + HAAR_FALLBACK_CONFIDENCE, + "haar", + max(0, int(width) * int(height)), ) def _boxes_overlap(a: FaceBox, b: FaceBox, threshold: float = 0.5) -> bool: diff --git a/src/rights_filter/analysis/internal_analyzer.py b/src/rights_filter/analysis/internal_analyzer.py index 5913cbd..f650e3e 100644 --- a/src/rights_filter/analysis/internal_analyzer.py +++ b/src/rights_filter/analysis/internal_analyzer.py @@ -1,12 +1,17 @@ from __future__ import annotations -from rights_filter.analysis.face_person_detection import HeuristicFacePersonDetector +from rights_filter.analysis.face_person_detection import ( + HeuristicFacePersonDetector, + face_person_evidence_data, + face_person_reason, +) from rights_filter.analysis.fingerprints import FingerprintService from rights_filter.analysis.preprocessing import ImagePayload from rights_filter.domain.records import ( Evidence, EvidenceSource, InMemoryRightsFilterRepository, + KnowledgeEntryType, ) @@ -46,11 +51,13 @@ class InternalAnalyzer: ) if similarity >= self.similarity_threshold: entry_status = entry.entry_status or "confirmed" - reason = ( - f"주의 후보 이미지 유사도 {similarity:.2f}" - if entry_status == "watchlist" - else f"Knowledge base image similarity {similarity:.2f}" - ) + entry_type = str(entry.entry_type) + if entry_type == KnowledgeEntryType.CLEARED_REFERENCE: + reason = f"이전에 클리어된 이미지와 일치 {similarity:.2f}" + elif entry_status == "watchlist": + reason = f"주의 후보 이미지 유사도 {similarity:.2f}" + else: + reason = f"Knowledge base image similarity {similarity:.2f}" evidence.append( Evidence( source=EvidenceSource.FINGERPRINT, @@ -60,6 +67,9 @@ class InternalAnalyzer: "knowledge_entry_id": entry.id, "knowledge_name": entry.name, "knowledge_entry_status": entry_status, + # Carry the type so the scorer can tell a cleared + # (positive) match from a risk match. + "knowledge_entry_type": entry_type, "source_submission_id": entry.source_submission_id, "similarity": similarity, "provenance": entry.provenance, @@ -72,12 +82,11 @@ class InternalAnalyzer: evidence.append( Evidence( source=EvidenceSource.FACE_PERSON, - reason="Face/person detected", - confidence=0.8, - data={ - "face_count": signal.face_count, - "person_count": signal.person_count, - }, + reason=face_person_reason(signal), + # The detector's own score, not a hardcoded 0.8: a Haar + # fallback must not claim the confidence YuNet earns. + confidence=signal.confidence, + data=face_person_evidence_data(signal), ) ) diff --git a/src/rights_filter/analysis/llm_assistance.py b/src/rights_filter/analysis/llm_assistance.py index f9d5aa4..9ba1c54 100644 --- a/src/rights_filter/analysis/llm_assistance.py +++ b/src/rights_filter/analysis/llm_assistance.py @@ -1,16 +1,35 @@ from __future__ import annotations +import time from dataclasses import dataclass from typing import Any +from rights_filter.analysis.llm_prompts import ( + LEGACY_PROMPT_VERSION, + PROMPT_ID, + PROMPT_VERSION, + SYSTEM_PROMPT, +) from rights_filter.domain.records import Evidence, EvidenceSource from rights_filter.integrations.http_json import UrllibJsonTransport +@dataclass(frozen=True) +class LlmSummaryResult: + """A summary plus the provenance needed to reproduce or compare it.""" + + text: str + model: str = "" + prompt_id: str = "" + prompt_version: str = LEGACY_PROMPT_VERSION + latency_ms: int = 0 + + @dataclass class FakeInternalLlmClient: summary: str = "" error: Exception | None = None + model: str = "fake-llm" def summarize_evidence(self, evidence: list[Evidence]) -> str: if self.error: @@ -36,24 +55,33 @@ class OllamaGenerateLlmClient: self.timeout = timeout def summarize_evidence(self, evidence: list[Evidence]) -> str: + return self.summarize_evidence_detailed(evidence).text + + def summarize_evidence_detailed(self, evidence: list[Evidence]) -> LlmSummaryResult: payload = { "model": self.model, - "system": ( - "Summarize only the provided source evidence for an internal image " - "rights review operator. Do not make a final decision. Do not add " - "claims that are not grounded in source evidence." - ), + "system": SYSTEM_PROMPT, "prompt": _evidence_prompt(evidence), "stream": False, "options": {"temperature": 0.1}, } + started = time.monotonic() response = self.transport.request_json( "POST", self._generate_url(), payload=payload, timeout=self.timeout, ) - return _response_text(response) + elapsed_ms = int((time.monotonic() - started) * 1000) + return LlmSummaryResult( + text=_response_text(response), + # Prefer the model the server says it ran: a tag can resolve to a + # different blob than the one configured here. + model=str(response.get("model") or self.model), + prompt_id=PROMPT_ID, + prompt_version=PROMPT_VERSION, + latency_ms=elapsed_ms, + ) def _generate_url(self) -> str: if self.base_url.endswith("/api"): @@ -67,7 +95,7 @@ class InternalLlmAssistant: def summarize(self, submission_id: str, evidence: list[Evidence]) -> Evidence: try: - summary = self.client.summarize_evidence(evidence) + result = self._summarize(evidence) except Exception as exc: return Evidence( source=EvidenceSource.ENRICHMENT_FAILURE, @@ -76,6 +104,17 @@ class InternalLlmAssistant: data={"submission_id": submission_id}, ) + # Provenance for every summary row: which model and which prompt made it, + # and how long the operator would have waited. Without these, swapping the + # model leaves old and new summaries mixed in the table with no way to + # tell them apart or to measure whether the swap helped. + stamp = { + "model": result.model, + "prompt_id": result.prompt_id, + "prompt_version": result.prompt_version, + "latency_ms": result.latency_ms, + } + source_urls = _source_urls(evidence) source_evidence_ids = _source_evidence_ids(evidence) if not source_urls and not source_evidence_ids: @@ -85,10 +124,11 @@ class InternalLlmAssistant: confidence=0.0, data={ "submission_id": submission_id, - "summary": summary, + "summary": result.text, "source_urls": [], "source_evidence_ids": [], "verified": False, + **stamp, }, ) @@ -98,13 +138,26 @@ class InternalLlmAssistant: confidence=0.0, data={ "submission_id": submission_id, - "summary": summary, + "summary": result.text, "source_urls": source_urls, "source_evidence_ids": source_evidence_ids, "verified": True, + **stamp, }, ) + def _summarize(self, evidence: list[Evidence]) -> LlmSummaryResult: + # Clients that predate provenance (and the test fakes) only implement + # summarize_evidence. Accept them and mark the row as unversioned rather + # than forcing every client to grow a new method. + detailed = getattr(self.client, "summarize_evidence_detailed", None) + if callable(detailed): + return detailed(evidence) + return LlmSummaryResult( + text=self.client.summarize_evidence(evidence), + model=str(getattr(self.client, "model", "") or ""), + ) + def _source_urls(evidence: list[Evidence]) -> list[str]: urls: list[str] = [] diff --git a/src/rights_filter/analysis/llm_prompts.py b/src/rights_filter/analysis/llm_prompts.py new file mode 100644 index 0000000..1264f38 --- /dev/null +++ b/src/rights_filter/analysis/llm_prompts.py @@ -0,0 +1,30 @@ +"""Versioned prompt text for the internal LLM assistant. + +Separated from the client so the prompt has an identity that can be stamped onto +the evidence it produced. Without a version on the row, summaries written before +and after a prompt (or model) change are indistinguishable in the database, and +"did the new prompt help?" becomes unanswerable after the fact. + +Bump PROMPT_VERSION whenever SYSTEM_PROMPT changes in a way that could alter +output — wording, format contract, or language. +""" + +from __future__ import annotations + +PROMPT_ID = "operator-review-note-ko" +PROMPT_VERSION = "ko-v2" + +# Marks rows written before prompt versioning existed. Never assume the text +# below produced them. +LEGACY_PROMPT_VERSION = "legacy" + +SYSTEM_PROMPT = ( + "You draft review notes for an internal image-rights operator, " + "grounded ONLY in the provided source evidence. Reply in Korean. " + "Produce two short parts: (1) '요약:' one or two sentences on what " + "the evidence shows; (2) '확인 필요:' a brief list of what the " + "operator should verify before deciding. Do NOT state or recommend " + "a final decision (approve/hold/reject). Do NOT add any claim that " + "is not supported by the source evidence; if evidence is thin, say " + "so plainly." +) diff --git a/src/rights_filter/analysis/risk_scoring.py b/src/rights_filter/analysis/risk_scoring.py index 82952c3..e9d4300 100644 --- a/src/rights_filter/analysis/risk_scoring.py +++ b/src/rights_filter/analysis/risk_scoring.py @@ -1,7 +1,17 @@ from __future__ import annotations +from rights_filter.analysis.face_person_detection import TIER_BACKGROUND from rights_filter.domain.records import Evidence, EvidenceSource, ScoreResult +FACE_PERSON_IDENTIFIABLE_POINTS = 35 +FACE_PERSON_BACKGROUND_POINTS = 10 + +# Stamped onto every submission at creation so a later weight change is +# distinguishable from a scoring bug. Bump this whenever the weights, the bucket +# rules, or the band thresholds below change — the stamp is what lets +# initialRiskScore be compared against a rescore and mean something. +SCORER_VERSION = "v1" + class RiskScorer: def score(self, evidence: list[Evidence]) -> ScoreResult: @@ -17,6 +27,15 @@ class RiskScorer: if item.source == EvidenceSource.FINGERPRINT: if item.data.get("contributed") is False or item.data.get("status") == "queued": continue + # A match against a CLEARED reference is a positive signal: the + # team already confirmed this image is safe to use. It must not + # add risk (a +80 here would push a known-safe image to the high + # band). Surface it as a reason so the operator sees the prior + # clearance, but contribute no points. + if item.data.get("knowledge_entry_type") == "cleared_reference": + if item.reason: + reasons.append(item.reason) + continue similarity = _safe_float(item.data.get("similarity", 0)) if similarity >= 0.9: score += 80 @@ -32,7 +51,7 @@ class RiskScorer: if counted_face_person: continue counted_face_person = True - score += 35 + score += _face_person_points(item) reasons.append(item.reason) elif item.source == EvidenceSource.WEB_DETECTION: bucket = _web_detection_bucket(item) @@ -90,6 +109,21 @@ class RiskScorer: return ScoreResult(score=score, band=band, reasons=_unique(reasons)) +def _face_person_points(item: Evidence) -> int: + # A recognizable subject and a bystander in a crowd are not the same portrait + # risk. The old flat +35 gave every photo containing any human enough to + # clear the medium threshold on its own, which is why the band distribution + # collapsed with nothing in the low band. + tier = str(item.data.get("tier", "")).strip() + if not tier: + # Rows written before the tier existed. Keep them at the original weight + # rather than silently rescoring history against a rule they predate. + return FACE_PERSON_IDENTIFIABLE_POINTS + if tier == TIER_BACKGROUND: + return FACE_PERSON_BACKGROUND_POINTS + return FACE_PERSON_IDENTIFIABLE_POINTS + + def _web_detection_points(item: Evidence) -> int: if item.data.get("weak_hint"): return 0 diff --git a/src/rights_filter/domain/records.py b/src/rights_filter/domain/records.py index 502a677..b9fd9cd 100644 --- a/src/rights_filter/domain/records.py +++ b/src/rights_filter/domain/records.py @@ -33,6 +33,10 @@ class KnowledgeEntryType(StrEnum): WEBTOON = "webtoon" GAME = "game" REJECTED_IMAGE = "rejected_image" + # A positive reference: an image the team has confirmed is safe to use + # (licensed / owned / public domain). A match argues FOR fast approval, not + # against it, so the scorer treats it as a cleared signal rather than +80. + CLEARED_REFERENCE = "cleared_reference" OTHER = "other" @@ -58,6 +62,12 @@ class DataClass(StrEnum): LLM_SUMMARY = "llm_summary" PROVIDER_METADATA = "provider_metadata" OPERATOR_NOTE = "operator_note" + # Whole-image semantic vector used ONLY for image-to-image similarity + # (crop / retouch / re-encode detection). Not a biometric template: faces are + # masked out before the vector is computed and person-dominant frames are + # excluded entirely. See the Scene Embedding Policy in + # docs/operations/image-rights-risk-filter.md (2026-07-25 개정). + SCENE_EMBEDDING = "scene_embedding" @dataclass(frozen=True) diff --git a/src/rights_filter/governance/policies.py b/src/rights_filter/governance/policies.py index 7e0a11e..710edb2 100644 --- a/src/rights_filter/governance/policies.py +++ b/src/rights_filter/governance/policies.py @@ -76,6 +76,16 @@ class GovernancePolicyRegistry: "delete with decision audit policy", "append correction rather than overwriting audit trail", ), + DataClass.SCENE_EMBEDDING: DataClassPolicy( + # Operators never read raw vectors — they read the similarity + # result, which is ordinary evidence. Keeping the vector out + # of the operator role is what makes "no identity lookup" + # enforceable rather than merely promised. + {"system", "admin"}, + "derived-index retention policy: lives only as long as its source image", + "delete with the source image (cascade)", + "recompute from the corrected original", + ), } ) @@ -83,12 +93,80 @@ class GovernancePolicyRegistry: return self._policies[data_class] +# A frame this dominated by people is a portrait: masking the faces would leave a +# vector that still describes the person (pose, body, hair, clothing), so it is +# excluded from indexing entirely rather than masked. Policy knob, not a tuning +# parameter — raising it widens what the Scene Embedding Policy permits. +MAX_PERSON_AREA_RATIO = 0.35 + +# Purposes a scene vector may be created for. Identity lookup is absent by +# construction: there is no value that authorizes it. +ALLOWED_SCENE_EMBEDDING_PURPOSES = frozenset({"image_similarity"}) + +_BIOMETRIC_KEYS = frozenset( + { + "embedding", + "face_embedding", + "biometric_template", + # Landmarks and descriptors are face-derived identity features too. YuNet + # returns 5-point landmarks alongside every detection, so name them + # explicitly rather than relying on callers to discard them quietly. + "landmarks", + "face_landmarks", + "face_descriptor", + "face_encoding", + } +) + + def assert_no_biometric_template(payload: dict[str, Any]) -> None: - forbidden_keys = {"embedding", "face_embedding", "biometric_template"} - if forbidden_keys.intersection(payload): + if _BIOMETRIC_KEYS.intersection(payload): raise ValueError("biometric template storage is not allowed") +def assert_scene_embedding_allowed(record: dict[str, Any]) -> None: + """Gate a scene-embedding record against the Scene Embedding Policy. + + The policy permits whole-image semantic vectors for image-to-image similarity + only. This asserts the two conditions that must have been applied upstream + (face masking, person-dominant exclusion) and the two that are structural + (purpose restriction, cascade-deletable source). It deliberately refuses a + record that merely omits an attestation: a vector whose provenance cannot be + stated is indistinguishable from one that skipped the masking step. + """ + if record.get("vector") is None: + raise ValueError("scene embedding record must carry a vector") + + if not record.get("face_masked"): + raise ValueError("scene embedding requires face regions to be masked first") + + ratio = record.get("person_area_ratio") + if ratio is None: + raise ValueError("scene embedding requires a measured person_area_ratio") + try: + ratio_value = float(ratio) + except (TypeError, ValueError) as exc: + raise ValueError("person_area_ratio must be numeric") from exc + if ratio_value > MAX_PERSON_AREA_RATIO: + raise ValueError( + "person-dominant image must not be indexed " + f"({ratio_value:.2f} > {MAX_PERSON_AREA_RATIO:.2f})" + ) + + purpose = str(record.get("purpose", "")) + if purpose not in ALLOWED_SCENE_EMBEDDING_PURPOSES: + raise ValueError(f"scene embedding purpose not allowed: {purpose or 'unset'}") + + if not str(record.get("source_image_id", "")).strip(): + # Without a source link the cascade delete in the policy cannot run, so + # the vector would outlive the image it was derived from. + raise ValueError("scene embedding must reference its source image") + + # A scene vector lives in its own store; it must never be smuggled in as a + # face-keyed structure under one of the biometric names. + assert_no_biometric_template({key: value for key, value in record.items() if key != "vector"}) + + def assert_operator_evidence_payload_allowed( data_class: DataClass, payload: dict[str, Any] ) -> None: diff --git a/src/rights_filter/server/http_app.py b/src/rights_filter/server/http_app.py index 0dac217..730481d 100644 --- a/src/rights_filter/server/http_app.py +++ b/src/rights_filter/server/http_app.py @@ -127,6 +127,8 @@ def build_server( ) elif path == "/api/admin/backups": self._json(store.backups()) + elif path == "/api/stats/decisions": + self._json(store.decision_stats()) elif path.startswith("/media/"): self._file(active_store().media_path(unquote(path.removeprefix("/media/"))), untrusted=True) elif path.startswith("/knowledge-media/"): @@ -265,6 +267,16 @@ def build_server( self._json(store.delete_decision_memo_preset(preset_id)) elif path == "/api/admin/backup": self._json(store.create_backup()) + elif path.startswith("/api/submissions/") and path.endswith("/clear-reference"): + submission_id = unquote(path.split("/")[3]) + self._json( + store.register_cleared_reference( + submission_id, + str(body.get("label", "")), + str(body.get("note", "")), + active_store(), + ) + ) elif path == "/api/providers/emergency-disable": self._json(store.emergency_disable_external_providers()) else: diff --git a/src/rights_filter/server/sqlite_store.py b/src/rights_filter/server/sqlite_store.py index 81b8d02..458c1f8 100644 --- a/src/rights_filter/server/sqlite_store.py +++ b/src/rights_filter/server/sqlite_store.py @@ -686,10 +686,17 @@ class CopyrighterStore( # so an operator decision is not silently reverted by a concurrent # lock-holding re-put of a stale submission payload. with self._write_lock: + # Capture the displayed state before the decision changes anything. + # Read on its own connection but inside the write lock, so no + # concurrent writer can move it under us and the snapshot is exactly + # what the operator was looking at. See decision_feature_snapshot for + # why this cannot be reconstructed after the fact. + snapshot = self.decision_feature_snapshot(submission_id) with self._transaction() as conn: submission = self._get("submissions", submission_id, conn=conn) submission["decisionStatus"] = decision submission["applicantStatus"] = "승인 대기" if decision == "approved" else "반려 예정" if decision == "rejected" else "검토 중" + submission["decisionSnapshot"] = snapshot self._put("submissions", submission_id, submission, conn=conn) if decision in {"held", "rejected"}: diff --git a/src/rights_filter/server/store_enrichment.py b/src/rights_filter/server/store_enrichment.py index 2986b2e..3ed6f7f 100644 --- a/src/rights_filter/server/store_enrichment.py +++ b/src/rights_filter/server/store_enrichment.py @@ -17,7 +17,11 @@ from typing import Any logger = logging.getLogger(__name__) -from rights_filter.analysis.face_person_detection import HeuristicFacePersonDetector +from rights_filter.analysis.face_person_detection import ( + HeuristicFacePersonDetector, + face_person_evidence_data, + face_person_reason, +) from rights_filter.analysis.fingerprints import FingerprintService from rights_filter.analysis.internal_analyzer import InternalAnalyzer from rights_filter.analysis.preprocessing import build_external_derivative, build_face_crop_derivatives @@ -152,12 +156,9 @@ class StoreEnrichmentMixin: evidence = Evidence( source=EvidenceSource.FACE_PERSON, - reason="Face/person detected", - confidence=0.8, - data={ - "face_count": signal.face_count, - "person_count": signal.person_count, - }, + reason=face_person_reason(signal), + confidence=signal.confidence, + data=face_person_evidence_data(signal), ) self._put( "evidence", diff --git a/src/rights_filter/server/store_operations.py b/src/rights_filter/server/store_operations.py index 7c297f0..fdc8c6c 100644 --- a/src/rights_filter/server/store_operations.py +++ b/src/rights_filter/server/store_operations.py @@ -203,6 +203,66 @@ class StoreOperationsMixin: return [] return [fingerprints.perceptual] + def register_cleared_reference( + self, + submission_id: str, + label: str = "", + note: str = "", + image_store: LocalSubmissionImageStore | None = None, + ) -> dict[str, Any]: + """Record an approved image as a positive (cleared) reference. + + Future submissions matching this fingerprint get a "previously cleared" + signal instead of being re-reviewed from scratch. Only images the + operator explicitly marks become references — approval alone does not, so + one-off approvals do not pollute the reference set. + """ + submission = self._get("submissions", submission_id) + sample_fingerprints = self._watchlist_fingerprints(submission_id, image_store) + if not sample_fingerprints: + stored = (submission.get("fingerprints") or {}).get("perceptual", "") + if stored: + sample_fingerprints = [str(stored)] + if not sample_fingerprints: + raise ValueError("이미지 지문을 계산할 수 없어 클리어 기준으로 등록할 수 없습니다") + + entry_id = _stable_id("kb-cleared", submission_id) + with self._write_lock: + with self._transaction() as conn: + try: + existing = self._get("knowledge_entries", entry_id, conn=conn) + except KeyError: + existing = {} + entry = { + **existing, + "id": entry_id, + "name": label.strip() + or f"클리어: {submission.get('title', submission_id)} / {submission_id}", + "type": "cleared_reference", + "aliases": _unique_texts([submission_id, str(submission.get("title", ""))]), + "keywords": _text_list(existing.get("keywords")), + "memo": note.strip() or "승인 케이스에서 클리어 기준으로 등록", + "provenance": "manual", + "active": True, + # entry_status stays 'confirmed' to satisfy the schema CHECK; + # the cleared disposition is carried by the entry TYPE. + "entryStatus": "confirmed", + "sourceSubmissionId": submission_id, + "sampleFingerprints": sample_fingerprints, + "imageAsset": str(submission.get("asset", "")), + "imageFacts": submission.get("fileFacts", {}), + "registeredAt": _now_label(), + } + self._put("knowledge_entries", entry_id, entry, conn=conn) + self.add_audit_event( + "rights.ops", + "Cleared reference registered", + submission_id, + entry["name"], + conn=conn, + ) + return self.bootstrap() + def rerun_enrichment( self, submission_id: str, diff --git a/src/rights_filter/server/store_search_candidates.py b/src/rights_filter/server/store_search_candidates.py index 6f26a58..ef50bf2 100644 --- a/src/rights_filter/server/store_search_candidates.py +++ b/src/rights_filter/server/store_search_candidates.py @@ -725,9 +725,19 @@ class StoreSearchCandidatesMixin: for item in self._evidence_for_submission(submission_id) ] score = RiskScorer().score(evidence) + # Only the live score moves. initialRiskScore / initialRiskBand / + # scorerVersion are the as-created stamp and must survive every rescore — + # they are the other half of the comparison that makes score-preservation + # tests non-vacuous. submission["riskScore"] = score.score submission["riskBand"] = score.band submission["reasons"] = score.reasons or ["분석 근거 없음"] + if "initialRiskScore" not in submission: + # Rows created before the stamp existed. Their original score was + # already overwritten by this same method, so it is unrecoverable — + # mark them instead of fabricating one, so a later calibration can + # exclude them rather than silently trust a round-tripped value. + submission["scorerVersion"] = "legacy" self._put("submissions", submission_id, submission) def _rescore_all_submissions(self, queue_id: str | None = None) -> None: diff --git a/src/rights_filter/server/store_serialization.py b/src/rights_filter/server/store_serialization.py index 4471608..fbb2506 100644 --- a/src/rights_filter/server/store_serialization.py +++ b/src/rights_filter/server/store_serialization.py @@ -18,6 +18,7 @@ from pathlib import Path from typing import Any from urllib.parse import urlparse +from rights_filter.analysis.risk_scoring import SCORER_VERSION from rights_filter.domain.records import ( Evidence, EvidenceSource, @@ -109,6 +110,15 @@ def _submission_payload( "asset": record["asset"], "riskScore": score, "riskBand": band, + # The score as first computed, never touched again. _rescore_submission + # overwrites riskScore/riskBand from 12 call sites, so without this stamp + # the stored score is always a round-tripped score and comparing it to a + # fresh rescore is tautological — every score-preservation test would + # pass vacuously. Once an operator decides a case the original is + # unrecoverable, so it has to be captured at creation. + "initialRiskScore": score, + "initialRiskBand": band, + "scorerVersion": SCORER_VERSION, "submittedAt": submitted_at, "submittedEpoch": submitted_epoch, "lastAnalysis": _now_label(), @@ -306,11 +316,32 @@ def _evidence_payload(submission_id: str, evidence: Evidence) -> dict[str, Any]: "knowledgeEntryId": str(evidence.data.get("knowledge_entry_id", "")), "knowledgeEntryName": str(evidence.data.get("knowledge_name", "")), "knowledgeEntryStatus": knowledge_entry_status, + "knowledgeEntryType": str(evidence.data.get("knowledge_entry_type", "")), "sourceSubmissionId": str(evidence.data.get("source_submission_id", "")), "similarity": evidence.data.get("similarity", ""), "retrievedAt": _now_label(), "contributed": source not in {"llm", "failure"} and not evidence.data.get("weak_hint", False), + # The LLM narration body previously lived only in the domain evidence and + # was dropped here, so the feature meant to help operators read the case + # faster produced text that was never shown. Persist it (and its source + # links) so the GUI can render the summary. + "summary": str(evidence.data.get("summary", "")), + "sourceUrls": list(evidence.data.get("source_urls", []) or []), "sourceEvidenceIds": evidence.data.get("source_evidence_ids", []), + # Face signal (face rows only; empty elsewhere). faceTier drives the + # score, so losing it on the round trip would silently revert every + # background-face row to the identifiable weight on the next rescore. + "faceTier": str(evidence.data.get("tier", "")), + "faceDetector": str(evidence.data.get("detector", "")), + "faceAreaRatio": evidence.data.get("face_area_ratio", ""), + "faceCount": evidence.data.get("face_count", ""), + "personCount": evidence.data.get("person_count", ""), + # Reproducibility stamp (LLM rows only; empty elsewhere). latencyMs is the + # first real measurement of how long an operator waits on the assistant. + "modelVersion": str(evidence.data.get("model", "")), + "promptId": str(evidence.data.get("prompt_id", "")), + "promptVersion": str(evidence.data.get("prompt_version", "")), + "latencyMs": evidence.data.get("latency_ms", ""), "status": "active", "submission_id": submission_id, } @@ -358,8 +389,21 @@ def _domain_evidence_from_ui(payload: dict[str, Any]) -> Evidence: "knowledge_entry_id": payload.get("knowledgeEntryId", ""), "knowledge_name": payload.get("knowledgeEntryName", ""), "knowledge_entry_status": payload.get("knowledgeEntryStatus", ""), + "knowledge_entry_type": payload.get("knowledgeEntryType", ""), "source_submission_id": payload.get("sourceSubmissionId", ""), "similarity": payload.get("similarity", ""), + "summary": payload.get("summary", ""), + "source_urls": payload.get("sourceUrls", []), + "source_evidence_ids": payload.get("sourceEvidenceIds", []), + "model": payload.get("modelVersion", ""), + "prompt_id": payload.get("promptId", ""), + "prompt_version": payload.get("promptVersion", ""), + "latency_ms": payload.get("latencyMs", ""), + "tier": payload.get("faceTier", ""), + "detector": payload.get("faceDetector", ""), + "face_area_ratio": payload.get("faceAreaRatio", ""), + "face_count": payload.get("faceCount", ""), + "person_count": payload.get("personCount", ""), }, ) @@ -414,8 +458,20 @@ def _google_weak_label_title(title: str) -> str: return title +# Data keys that move on every call without changing what the evidence asserts. +# Hashing them would hand the same summary a new id on each regeneration, so the +# id would encode "when it ran" instead of "what it says". Existing rows carry +# none of these keys, so excluding them leaves every stored id unchanged. +_VOLATILE_EVIDENCE_DATA_KEYS = frozenset({"latency_ms"}) + + def _evidence_id(submission_id: str, evidence: Evidence) -> str: - base = f"{submission_id}:{evidence.source}:{evidence.reason}:{json.dumps(evidence.data, sort_keys=True, default=str)}" + stable_data = { + key: value + for key, value in evidence.data.items() + if key not in _VOLATILE_EVIDENCE_DATA_KEYS + } + base = f"{submission_id}:{evidence.source}:{evidence.reason}:{json.dumps(stable_data, sort_keys=True, default=str)}" return "ev-" + hashlib.sha256(base.encode("utf-8")).hexdigest()[:24] diff --git a/src/rights_filter/server/store_workflow.py b/src/rights_filter/server/store_workflow.py index 39b2edf..c7e60da 100644 --- a/src/rights_filter/server/store_workflow.py +++ b/src/rights_filter/server/store_workflow.py @@ -38,6 +38,52 @@ INTERNAL_EXPORT_FIELDS = ( class StoreWorkflowMixin: + # ---- decision-time feature snapshot --------------------------------- + + def decision_feature_snapshot(self, submission_id: str) -> dict[str, Any]: + """Record what the operator actually saw at the moment they decided. + + The queue sorts by riskScore descending and the case screen shows the + score, band and reasons before the decision buttons. A label collected + under that layout does not mean "this evidence predicts rejection"; it + means "the operator agreed with the score I showed them". Learning from + such labels re-learns the existing weights. + + Capturing the displayed state at decision time is what makes that + circularity correctable later: with the snapshot, a model can condition + on (or hold out) what was shown. Without it, the labels are + indistinguishable from copies of the score, permanently. It has to be + written from the first decision onward — it cannot be reconstructed. + """ + submission = self._get("submissions", submission_id) + evidence = self._evidence_for_submission(submission_id) + shown = [ + { + "id": str(item.get("id", "")), + "source": str(item.get("source", "")), + "matchType": str(item.get("matchType", "")), + "contributed": bool(item.get("contributed", False)), + "operatorStatus": str(item.get("operatorStatus") or ""), + } + for item in evidence + ] + return { + "capturedAt": _now_label(), + # The score the operator was looking at — the queue's sort key. + "riskScore": submission.get("riskScore"), + "riskBand": submission.get("riskBand"), + "reasons": list(submission.get("reasons") or []), + # The as-created stamp, so a later analysis can tell whether the + # displayed score had already drifted from the original. + "initialRiskScore": submission.get("initialRiskScore"), + "initialRiskBand": submission.get("initialRiskBand"), + "scorerVersion": submission.get("scorerVersion", "legacy"), + "providerState": dict(submission.get("providerState") or {}), + "evidenceCount": len(shown), + "contributedCount": sum(1 for item in shown if item["contributed"]), + "evidence": shown, + } + # ---- batch decisions ------------------------------------------------ def record_decision_batch( @@ -142,6 +188,76 @@ class StoreWorkflowMixin: ) return self.decision_memo_presets() + # ---- calibration / decision stats ----------------------------------- + + def decision_stats(self) -> dict[str, Any]: + """Cross-tabulate risk band against the operator's actual decision. + + The rule-based risk score is hand-tuned and has never been checked + against real decisions. This exposes the mismatch so it can be: a + high-risk case the operator approved is over-detection; a low-risk case + they rejected is under-detection. Everything is read from data already + stored (submissions + the decision audit trail). + """ + bands = ["high", "medium", "low", "failed", "pending"] + decisions = ["approved", "held", "rejected", "corrected", "unreviewed"] + matrix = {band: {decision: 0 for decision in decisions} for band in bands} + matrix_other: dict[str, int] = {decision: 0 for decision in decisions} + + submissions = self._all("submissions") + unreviewed_epochs: list[int] = [] + for submission in submissions: + band = str(submission.get("riskBand", "pending")) + decision = str(submission.get("decisionStatus", "unreviewed")) + if decision not in decisions: + decision = "unreviewed" + bucket = matrix.get(band, matrix_other) + bucket[decision] = bucket.get(decision, 0) + 1 + if decision == "unreviewed": + unreviewed_epochs.append(int(submission.get("submittedEpoch", 0) or 0)) + + def _decided(band: str) -> int: + row = matrix.get(band, {}) + return sum(row[d] for d in ("approved", "held", "rejected", "corrected")) + + high_decided = _decided("high") + low_decided = _decided("low") + calibration = { + # High band the operator ended up approving -> the score cried wolf. + "highBandApprovalRate": _ratio(matrix["high"]["approved"], high_decided), + "highBandDecided": high_decided, + # Low band the operator rejected -> the score missed real risk. + "lowBandRejectionRate": _ratio(matrix["low"]["rejected"], low_decided), + "lowBandDecided": low_decided, + } + + return { + "bands": bands, + "decisions": decisions, + "matrix": matrix, + "matrixOther": matrix_other, + "calibration": calibration, + "throughput": self._decision_throughput(), + "backlog": { + "unreviewed": len(unreviewed_epochs), + "total": len(submissions), + }, + } + + def _decision_throughput(self) -> dict[str, Any]: + # Group the decision audit events by calendar day (the timestamp label is + # "YYYY-MM-DD HH:MM:SS", so the date is a stable prefix). + by_day: dict[str, int] = {} + for event in self._decision_events(): + day = str(event["timestamp"])[:10] + if day: + by_day[day] = by_day.get(day, 0) + 1 + recent = sorted(by_day.items(), reverse=True)[:14] + return { + "byDay": [{"day": day, "count": count} for day, count in reversed(recent)], + "totalDecisions": sum(by_day.values()), + } + # ---- decision export ------------------------------------------------- def export_decisions( @@ -275,6 +391,10 @@ class StoreWorkflowMixin: return memo +def _ratio(numerator: int, denominator: int) -> float: + return round(numerator / denominator, 3) if denominator else 0.0 + + def _rows_to_csv(rows: list[dict[str, Any]], fields: tuple[str, ...]) -> str: buffer = io.StringIO() writer = csv.DictWriter(buffer, fieldnames=list(fields), extrasaction="ignore") diff --git a/tests/rights_filter/analysis/test_face_person_tiers.py b/tests/rights_filter/analysis/test_face_person_tiers.py new file mode 100644 index 0000000..72b0aa3 --- /dev/null +++ b/tests/rights_filter/analysis/test_face_person_tiers.py @@ -0,0 +1,214 @@ +"""Face signal tiering: a recognizable subject vs a bystander in a scene. + +The old detector emitted one flat "a face is present" signal worth +35, which +every photo containing any human cleared the medium threshold on. These tests +pin the tier split, the legacy default, and — most importantly — that the tier +survives the payload round trip. A tier lost in serialization reverts silently +to the identifiable weight on the next rescore, which is invisible until the +band distribution drifts back. +""" + +import numpy as np +import pytest + +from rights_filter.analysis.face_person_detection import ( + HAAR_FALLBACK_CONFIDENCE, + IDENTIFIABLE_FACE_AREA_RATIO, + TIER_BACKGROUND, + TIER_IDENTIFIABLE, + TIER_NONE, + DEFAULT_FACE_MODEL_PATH, + FACE_MODEL_PATH_ENV, + FacePersonSignal, + HeuristicFacePersonDetector, + _detector_state, + _signal_from_boxes, + face_person_evidence_data, + face_person_reason, +) +from rights_filter.analysis.preprocessing import ImagePayload +from rights_filter.analysis.risk_scoring import ( + FACE_PERSON_BACKGROUND_POINTS, + FACE_PERSON_IDENTIFIABLE_POINTS, + RiskScorer, +) +from rights_filter.domain.records import Evidence, EvidenceSource +from rights_filter.governance.policies import assert_no_biometric_template +from rights_filter.server.store_serialization import ( + _domain_evidence_from_ui, + _evidence_payload, +) + +FRAME = 1000 * 1000 + + +@pytest.fixture(autouse=True) +def _reset_detector_cache(): + # The detector is cached per thread; tests that change the model path must + # not inherit an instance built under the previous one. + for attribute in ("yunet", "yunet_failed"): + if hasattr(_detector_state, attribute): + delattr(_detector_state, attribute) + yield + + +def _face_evidence(**data) -> Evidence: + return Evidence( + source=EvidenceSource.FACE_PERSON, + reason="얼굴", + confidence=0.9, + data={"face_count": 1, "person_count": 1, **data}, + ) + + +# --- tier classification ----------------------------------------------------- + + +def test_large_face_is_an_identifiable_subject(): + # 10% of the frame — comfortably above the threshold. + signal = _signal_from_boxes([(0, 0, 316, 316)], 0.95, "yunet", FRAME) + + assert signal.tier == TIER_IDENTIFIABLE + assert signal.face_area_ratio >= IDENTIFIABLE_FACE_AREA_RATIO + assert signal.confidence == 0.95 + + +def test_small_face_is_a_background_person(): + signal = _signal_from_boxes([(0, 0, 40, 40)], 0.8, "yunet", FRAME) + + assert signal.tier == TIER_BACKGROUND + assert signal.present + + +def test_no_faces_is_tier_none_and_not_present(): + signal = _signal_from_boxes([], 0.0, "yunet", FRAME) + + assert signal.tier == TIER_NONE + assert not signal.present + + +def test_tier_uses_the_largest_face_not_the_count(): + # A crowd of small faces plus one close subject is an identifiable case. + signal = _signal_from_boxes( + [(0, 0, 20, 20), (30, 30, 20, 20), (60, 60, 300, 300)], 0.9, "yunet", FRAME + ) + + assert signal.face_count == 3 + assert signal.tier == TIER_IDENTIFIABLE + + +# --- scoring ----------------------------------------------------------------- + + +def test_identifiable_face_scores_more_than_a_background_person(): + identifiable = RiskScorer().score([_face_evidence(tier=TIER_IDENTIFIABLE)]) + background = RiskScorer().score([_face_evidence(tier=TIER_BACKGROUND)]) + + assert identifiable.score == FACE_PERSON_IDENTIFIABLE_POINTS + assert background.score == FACE_PERSON_BACKGROUND_POINTS + assert background.score < identifiable.score + + +def test_row_without_a_tier_keeps_the_original_weight(): + # Evidence written before tiering existed must not be rescored against a + # rule it predates. + legacy = RiskScorer().score([_face_evidence()]) + + assert legacy.score == FACE_PERSON_IDENTIFIABLE_POINTS + + +# --- round trip -------------------------------------------------------------- + + +@pytest.mark.parametrize( + "data, expected", + [ + ({"tier": TIER_IDENTIFIABLE}, FACE_PERSON_IDENTIFIABLE_POINTS), + ({"tier": TIER_BACKGROUND}, FACE_PERSON_BACKGROUND_POINTS), + ({}, FACE_PERSON_IDENTIFIABLE_POINTS), + ], +) +def test_face_score_survives_the_payload_round_trip(data, expected): + evidence = _face_evidence(**data) + direct = RiskScorer().score([evidence]) + + payload = _evidence_payload("SUB-1", evidence) + restored = RiskScorer().score([_domain_evidence_from_ui(payload)]) + + assert direct.score == expected + assert restored.score == expected + + +def test_face_detail_survives_the_payload_round_trip(): + evidence = _face_evidence( + tier=TIER_BACKGROUND, detector="yunet", face_area_ratio=0.01, face_count=4, person_count=4 + ) + + restored = _domain_evidence_from_ui(_evidence_payload("SUB-1", evidence)) + + assert restored.data["tier"] == TIER_BACKGROUND + assert restored.data["detector"] == "yunet" + assert restored.data["face_count"] == 4 + + +# --- governance -------------------------------------------------------------- + + +def test_face_evidence_never_carries_landmarks_or_descriptors(): + # YuNet returns 5-point landmarks on every detection. They are read for + # nothing and must not reach any payload. + signal = _signal_from_boxes([(0, 0, 300, 300)], 0.9, "yunet", FRAME) + data = face_person_evidence_data(signal) + + assert_no_biometric_template(data) + assert_no_biometric_template(_evidence_payload("SUB-1", _face_evidence(**data))) + + +def test_reason_distinguishes_the_two_tiers(): + identifiable = face_person_reason(FacePersonSignal(1, 1, tier=TIER_IDENTIFIABLE)) + background = face_person_reason(FacePersonSignal(1, 1, tier=TIER_BACKGROUND)) + + assert identifiable != background + + +# --- detector selection ------------------------------------------------------ + + +def _blank_image() -> ImagePayload: + import cv2 + + blank = np.full((240, 320, 3), 200, dtype=np.uint8) + return ImagePayload( + content=cv2.imencode(".png", blank)[1].tobytes(), + width=320, + height=240, + metadata={}, + ) + + +@pytest.mark.skipif( + not DEFAULT_FACE_MODEL_PATH.is_file(), + reason="YuNet weights absent (fetched on the build machine, see offline-install.md)", +) +def test_yunet_runs_when_the_weights_are_present(): + signal = HeuristicFacePersonDetector().detect(_blank_image()) + + assert signal.detector == "yunet" + assert not signal.present + + +def test_missing_weights_degrade_to_haar_instead_of_failing(monkeypatch, tmp_path): + # A missing optional weight must not stop an operator from reviewing. + monkeypatch.setenv(FACE_MODEL_PATH_ENV, str(tmp_path / "absent.onnx")) + + signal = HeuristicFacePersonDetector().detect(_blank_image()) + + assert signal.detector in {"haar", "none"} + assert not signal.present + + +def test_haar_signal_does_not_claim_yunet_confidence(): + signal = _signal_from_boxes([(0, 0, 300, 300)], HAAR_FALLBACK_CONFIDENCE, "haar", FRAME) + + assert signal.detector == "haar" + assert signal.confidence == HAAR_FALLBACK_CONFIDENCE diff --git a/tests/rights_filter/analysis/test_llm_assistance.py b/tests/rights_filter/analysis/test_llm_assistance.py index 83db584..f7d61bf 100644 --- a/tests/rights_filter/analysis/test_llm_assistance.py +++ b/tests/rights_filter/analysis/test_llm_assistance.py @@ -1,5 +1,12 @@ -from rights_filter.analysis.llm_assistance import FakeInternalLlmClient, InternalLlmAssistant +from rights_filter.analysis.llm_assistance import ( + FakeInternalLlmClient, + InternalLlmAssistant, + LlmSummaryResult, + OllamaGenerateLlmClient, +) +from rights_filter.analysis.llm_prompts import PROMPT_ID, PROMPT_VERSION, SYSTEM_PROMPT from rights_filter.domain.records import Evidence, EvidenceSource +from rights_filter.server.store_serialization import _evidence_id def test_llm_summary_cites_source_urls_and_evidence_ids(): @@ -45,3 +52,67 @@ def test_llm_failure_becomes_enrichment_failure_evidence(): assert evidence.source == EvidenceSource.ENRICHMENT_FAILURE assert "LLM assistance failed" in evidence.reason + + +# --- reproducibility stamp --------------------------------------------------- + + +class _StampingClient: + def summarize_evidence_detailed(self, evidence): + return LlmSummaryResult( + text="요약: 근거 1건.", + model="qwen2.5:3b-instruct", + prompt_id=PROMPT_ID, + prompt_version=PROMPT_VERSION, + latency_ms=1420, + ) + + +def test_summary_evidence_carries_model_and_prompt_version(): + # Without this stamp, summaries written before and after a model swap are + # indistinguishable in the table and the swap cannot be evaluated. + evidence = InternalLlmAssistant(_StampingClient()).summarize("submission-1", []) + + assert evidence.data["model"] == "qwen2.5:3b-instruct" + assert evidence.data["prompt_id"] == PROMPT_ID + assert evidence.data["prompt_version"] == PROMPT_VERSION + assert evidence.data["latency_ms"] == 1420 + + +def test_client_without_the_detailed_method_still_summarizes(): + # Older clients (and the test fake) only implement summarize_evidence. + evidence = InternalLlmAssistant( + FakeInternalLlmClient(summary="요약: 근거 없음.") + ).summarize("submission-1", []) + + assert evidence.data["summary"] == "요약: 근거 없음." + assert evidence.data["prompt_version"] == "legacy" + + +def test_latency_does_not_change_the_evidence_id(): + # latency_ms moves on every call. If it fed the id hash, regenerating the + # same summary would mint a new id each time and the id would encode when it + # ran rather than what it says. + base = {"submission_id": "s1", "summary": "요약", "source_evidence_ids": ["e1"]} + without = Evidence(EvidenceSource.LLM_SUMMARY, "r", 0.0, dict(base)) + with_latency = Evidence( + EvidenceSource.LLM_SUMMARY, "r", 0.0, {**base, "latency_ms": 9999} + ) + + assert _evidence_id("s1", without) == _evidence_id("s1", with_latency) + + +def test_ollama_client_sends_the_versioned_system_prompt(): + captured = {} + + class _Transport: + def request_json(self, method, url, payload=None, timeout=None): + captured.update(payload or {}) + return {"response": "요약: ok", "model": "qwen2.5:0.5b-instruct"} + + result = OllamaGenerateLlmClient(transport=_Transport()).summarize_evidence_detailed([]) + + assert captured["system"] == SYSTEM_PROMPT + assert result.prompt_version == PROMPT_VERSION + assert result.model == "qwen2.5:0.5b-instruct" + assert result.latency_ms >= 0 diff --git a/tests/rights_filter/governance/test_policies.py b/tests/rights_filter/governance/test_policies.py index 0ad8db2..127cde4 100644 --- a/tests/rights_filter/governance/test_policies.py +++ b/tests/rights_filter/governance/test_policies.py @@ -1,11 +1,27 @@ +import pytest + from rights_filter.domain.records import DataClass from rights_filter.governance.policies import ( + MAX_PERSON_AREA_RATIO, GovernancePolicyRegistry, assert_no_biometric_template, assert_operator_evidence_payload_allowed, + assert_scene_embedding_allowed, ) +def _scene_record(**overrides): + record = { + "vector": [0.1, 0.2, 0.3], + "face_masked": True, + "person_area_ratio": 0.05, + "purpose": "image_similarity", + "source_image_id": "SUB-1", + } + record.update(overrides) + return record + + def test_each_data_class_has_access_retention_delete_and_correction_policy(): registry = GovernancePolicyRegistry.default() @@ -42,6 +58,57 @@ def test_search_evidence_rejects_image_payloads_sent_to_naver(): raise AssertionError("expected Naver image payload rejection") +@pytest.mark.parametrize( + "key", + ["embedding", "face_embedding", "biometric_template", "landmarks", "face_landmarks", "face_descriptor", "face_encoding"], +) +def test_face_derived_identity_features_are_all_rejected(key): + with pytest.raises(ValueError, match="biometric template"): + assert_no_biometric_template({key: [0.1, 0.2]}) + + +def test_scene_embedding_is_allowed_when_face_masked_and_scene_dominant(): + # The 2026-07-25 policy revision permits whole-image similarity vectors; this + # is the shape that must pass, or the revision has no effect. + assert_scene_embedding_allowed(_scene_record()) + + +def test_scene_embedding_requires_face_masking(): + with pytest.raises(ValueError, match="masked"): + assert_scene_embedding_allowed(_scene_record(face_masked=False)) + + +def test_scene_embedding_rejects_person_dominant_frame(): + with pytest.raises(ValueError, match="person-dominant"): + assert_scene_embedding_allowed( + _scene_record(person_area_ratio=MAX_PERSON_AREA_RATIO + 0.01) + ) + + +def test_scene_embedding_requires_a_measured_person_area_ratio(): + # An unmeasured frame is not the same as a scene-dominant one: without the + # measurement the exclusion rule never ran. + record = _scene_record() + del record["person_area_ratio"] + with pytest.raises(ValueError, match="person_area_ratio"): + assert_scene_embedding_allowed(record) + + +def test_scene_embedding_rejects_identity_lookup_purpose(): + with pytest.raises(ValueError, match="purpose"): + assert_scene_embedding_allowed(_scene_record(purpose="identity_lookup")) + + +def test_scene_embedding_requires_source_image_for_cascade_delete(): + with pytest.raises(ValueError, match="source image"): + assert_scene_embedding_allowed(_scene_record(source_image_id="")) + + +def test_scene_embedding_cannot_smuggle_a_face_embedding(): + with pytest.raises(ValueError, match="biometric template"): + assert_scene_embedding_allowed(_scene_record(face_embedding=[0.9, 0.8])) + + def test_llm_summary_must_reference_source_evidence(): try: assert_operator_evidence_payload_allowed( diff --git a/tests/rights_filter/server/test_pre_golive_stamps.py b/tests/rights_filter/server/test_pre_golive_stamps.py new file mode 100644 index 0000000..981bcff --- /dev/null +++ b/tests/rights_filter/server/test_pre_golive_stamps.py @@ -0,0 +1,125 @@ +"""Tests for the stamps that must exist before the first operator decision. + +Each one records something that becomes unrecoverable once real reviewing +starts: the as-created risk score (rescore overwrites the live one from a dozen +call sites) and the state the operator was actually looking at when they decided +(without it, a decision label is indistinguishable from a copy of the score the +screen showed them). +""" + +from pathlib import Path + +from rights_filter.analysis.risk_scoring import SCORER_VERSION +from rights_filter.integrations.env_clients import build_provider_runtime +from rights_filter.server.sqlite_store import CopyrighterStore + +from tests.rights_filter.server.test_sqlite_store import FakeJsonTransport +from tests.rights_filter.server.test_store_workflow import _photo_store + + +def _store(tmp_path: Path) -> CopyrighterStore: + store = CopyrighterStore( + tmp_path / "copyrighter.sqlite3", + provider_runtime=build_provider_runtime(env={}, transport=FakeJsonTransport({})), + ) + store.initialize() + store.seed_from_image_store(_photo_store(tmp_path / "submissions")) + return store + + +# --- initial score stamp ----------------------------------------------------- + + +def test_new_submission_stamps_its_as_created_score_and_scorer_version(tmp_path: Path): + store = _store(tmp_path) + + for submission in store.bootstrap()["submissions"]: + assert submission["initialRiskScore"] == submission["riskScore"] + assert submission["initialRiskBand"] == submission["riskBand"] + assert submission["scorerVersion"] == SCORER_VERSION + + +def test_rescore_moves_the_live_score_but_never_the_initial_stamp(tmp_path: Path): + store = _store(tmp_path) + submission = store.bootstrap()["submissions"][0] + submission_id = str(submission["id"]) + initial_score = submission["initialRiskScore"] + initial_band = submission["initialRiskBand"] + + # Drift the live score the way a weight change or an evidence edit would, + # then rescore: the live value must be recomputed and the stamp must not. + submission["riskScore"] = 93 + submission["riskBand"] = "high" + store._put("submissions", submission_id, submission) + + store._rescore_submission(submission_id) + + rescored = store._get("submissions", submission_id) + assert rescored["riskScore"] != 93 + assert rescored["initialRiskScore"] == initial_score + assert rescored["initialRiskBand"] == initial_band + assert rescored["scorerVersion"] == SCORER_VERSION + + +def test_legacy_submission_is_marked_not_backfilled_with_a_fabricated_score(tmp_path: Path): + # A row created before the stamp existed: its original score was already + # overwritten, so inventing one would silently pass off a round-tripped score + # as the original. + store = _store(tmp_path) + submission = store.bootstrap()["submissions"][0] + submission_id = str(submission["id"]) + del submission["initialRiskScore"] + del submission["initialRiskBand"] + del submission["scorerVersion"] + store._put("submissions", submission_id, submission) + + store._rescore_submission(submission_id) + + stored = store._get("submissions", submission_id) + assert stored["scorerVersion"] == "legacy" + assert "initialRiskScore" not in stored + + +# --- decision-time snapshot -------------------------------------------------- + + +def test_decision_records_the_score_and_evidence_the_operator_saw(tmp_path: Path): + store = _store(tmp_path) + submission = store.bootstrap()["submissions"][0] + submission_id = str(submission["id"]) + shown_score = submission["riskScore"] + shown_evidence = store.review(submission_id)["evidence"] + + store.record_decision(submission_id, "approved") + + snapshot = store._get("submissions", submission_id)["decisionSnapshot"] + assert snapshot["riskScore"] == shown_score + assert snapshot["riskBand"] == submission["riskBand"] + assert snapshot["scorerVersion"] == SCORER_VERSION + assert snapshot["evidenceCount"] == len(shown_evidence) + assert {item["id"] for item in snapshot["evidence"]} == { + str(item["id"]) for item in shown_evidence + } + + +def test_decision_snapshot_captures_state_before_the_decision_is_applied(tmp_path: Path): + store = _store(tmp_path) + submission_id = str(store.bootstrap()["submissions"][0]["id"]) + + store.record_decision(submission_id, "rejected", "반려 사유") + + stored = store._get("submissions", submission_id) + assert stored["decisionStatus"] == "rejected" + # The snapshot is the pre-decision view, so it must not carry the decision + # it triggered — otherwise it records the outcome instead of the input. + assert "decisionStatus" not in stored["decisionSnapshot"] + + +def test_batch_decisions_snapshot_every_case(tmp_path: Path): + store = _store(tmp_path) + ids = [str(item["id"]) for item in store.bootstrap()["submissions"]][:2] + + store.record_decision_batch(ids, "approved") + + for submission_id in ids: + assert store._get("submissions", submission_id)["decisionSnapshot"]["evidence"] is not None diff --git a/tests/rights_filter/server/test_review_intelligence.py b/tests/rights_filter/server/test_review_intelligence.py new file mode 100644 index 0000000..1844190 --- /dev/null +++ b/tests/rights_filter/server/test_review_intelligence.py @@ -0,0 +1,153 @@ +"""Tests for the 2026-07-24 'three-punch' review-intelligence features: +calibration stats, cleared references, and the persisted LLM draft. +""" + +from pathlib import Path + +from rights_filter.analysis.risk_scoring import RiskScorer +from rights_filter.domain.records import Evidence, EvidenceSource +from rights_filter.integrations.env_clients import build_provider_runtime +from rights_filter.server.sqlite_store import CopyrighterStore +from rights_filter.server.store_serialization import ( + _domain_evidence_from_ui, + _evidence_payload, +) + +from tests.rights_filter.server.test_store_workflow import _photo_store +from tests.rights_filter.server.test_sqlite_store import FakeJsonTransport + + +def _store(tmp_path: Path, *, duplicate: bool = False) -> CopyrighterStore: + store = CopyrighterStore( + tmp_path / "copyrighter.sqlite3", + provider_runtime=build_provider_runtime(env={}, transport=FakeJsonTransport({})), + ) + store.initialize() + store.seed_from_image_store(_photo_store(tmp_path / "submissions", duplicate=duplicate)) + return store + + +# --- Calibration stats ------------------------------------------------------- + + +def test_decision_stats_cross_tabulates_band_against_decision(tmp_path: Path): + store = _store(tmp_path) + ids = [str(item["id"]) for item in store.bootstrap()["submissions"]] + store.record_decision(ids[0], "approved") + store.record_decision(ids[1], "rejected", "반려 사유") + + stats = store.decision_stats() + + total_decided = sum( + stats["matrix"][band][decision] + for band in stats["bands"] + for decision in ("approved", "held", "rejected", "corrected") + ) + assert total_decided == 2 + # Backlog reflects the still-unreviewed submissions. + assert stats["backlog"]["unreviewed"] == len(ids) - 2 + assert "highBandApprovalRate" in stats["calibration"] + assert "lowBandRejectionRate" in stats["calibration"] + + +def test_decision_stats_calibration_flags_high_band_approvals(tmp_path: Path): + store = _store(tmp_path) + # Force both submissions into the high band, then approve both: 100% of + # high-band decisions were approvals -> maximal over-detection signal. + for submission in store.bootstrap()["submissions"]: + submission["riskBand"] = "high" + store._put("submissions", str(submission["id"]), submission) + for submission in store.bootstrap()["submissions"]: + store.record_decision(str(submission["id"]), "approved") + + stats = store.decision_stats() + + assert stats["calibration"]["highBandApprovalRate"] == 1.0 + assert stats["calibration"]["highBandDecided"] == 2 + + +# --- Cleared reference ------------------------------------------------------- + + +def test_cleared_reference_match_scores_zero_and_is_surfaced(): + match = Evidence( + source=EvidenceSource.FINGERPRINT, + reason="이전에 클리어된 이미지와 일치 0.99", + confidence=0.99, + data={ + "knowledge_entry_id": "kb-cleared-1", + "knowledge_entry_type": "cleared_reference", + "similarity": 0.99, + }, + ) + scorer = RiskScorer() + + result = scorer.score([match]) + + # A cleared match must not add risk (a raw >=0.9 fingerprint match is +80). + assert result.score == 0 + assert any("클리어" in reason for reason in result.reasons) + # And it survives the persistence round-trip as a cleared match, not a +80. + restored = _domain_evidence_from_ui(_evidence_payload("SUB-1", match)) + assert scorer.score([restored]).score == 0 + + +def test_register_cleared_reference_creates_a_positive_knowledge_entry(tmp_path: Path): + store = _store(tmp_path, duplicate=True) + store.record_decision("alpha", "approved") + + store.register_cleared_reference("alpha", "자사 보유 스톡", "라이선스 확인됨") + + entry = next( + item + for item in store.bootstrap()["knowledgeEntries"] + if item.get("type") == "cleared_reference" + ) + assert entry["sourceSubmissionId"] == "alpha" + assert entry["sampleFingerprints"] + # entry_status stays within the schema CHECK; disposition is on the type. + assert entry["entryStatus"] == "confirmed" + + +def test_cleared_reference_downranks_a_matching_resubmission(tmp_path: Path): + # alpha and alpha-copy are the same image. Clearing alpha should make a fresh + # analysis of the copy carry a cleared (0-point) match, not a +80 risk match. + store = _store(tmp_path, duplicate=True) + store.register_cleared_reference("alpha", "클리어 기준") + + store.rerun_enrichment("alpha-copy", store.active_submission_image_store( + (tmp_path / "submissions") + )) + + review = store.review("alpha-copy") + cleared = [ + item + for item in review["evidence"] + if item.get("knowledgeEntryType") == "cleared_reference" + ] + assert cleared, "expected a cleared-reference match on the resubmission" + + +# --- LLM draft persistence --------------------------------------------------- + + +def test_llm_summary_body_survives_serialization(): + summary = Evidence( + source=EvidenceSource.LLM_SUMMARY, + reason="Assistant summarized source-linked evidence", + confidence=0.0, + data={ + "submission_id": "SUB-1", + "summary": "요약: 이 이미지는 웹에서 여러 페이지와 일치합니다.", + "source_urls": ["https://example.test/a"], + "source_evidence_ids": ["ev-1"], + "verified": True, + }, + ) + + payload = _evidence_payload("SUB-1", summary) + + assert payload["summary"].startswith("요약:") + assert payload["sourceUrls"] == ["https://example.test/a"] + restored = _domain_evidence_from_ui(payload) + assert restored.data["summary"] == summary.data["summary"] diff --git a/tests/rights_filter/server/test_sqlite_store.py b/tests/rights_filter/server/test_sqlite_store.py index 902781c..c31eb75 100644 --- a/tests/rights_filter/server/test_sqlite_store.py +++ b/tests/rights_filter/server/test_sqlite_store.py @@ -7,7 +7,11 @@ from io import BytesIO import pytest -from rights_filter.analysis.face_person_detection import FacePersonSignal +from rights_filter.analysis.face_person_detection import ( + TIER_IDENTIFIABLE, + FacePersonSignal, + face_person_reason, +) from rights_filter.analysis.preprocessing import ImagePayload, build_face_crop_derivatives from rights_filter.domain.records import Evidence, EvidenceSource from rights_filter.server.image_store import LocalSubmissionImageStore @@ -19,6 +23,13 @@ from rights_filter.server.sqlite_store import CopyrighterStore from rights_filter.integrations.env_clients import build_provider_runtime +# The reason the store writes for a close, recognizable face. Derived from the +# detector so a wording change does not have to be chased through assertions. +IDENTIFIABLE_FACE_REASON = face_person_reason( + FacePersonSignal(face_count=1, person_count=1, tier=TIER_IDENTIFIABLE) +) + + class FakeJsonTransport: def __init__(self, response): self.response = response @@ -1060,7 +1071,7 @@ def test_sqlite_store_rescore_ignores_non_contributing_queued_rerun_marker(tmp_p assert review["riskScore"] == 35 assert review["riskBand"] == "medium" - assert "Face/person detected" in review["reasons"] + assert IDENTIFIABLE_FACE_REASON in review["reasons"] assert "재분석 요청이 접수됨" not in review["reasons"] @@ -8008,7 +8019,7 @@ def test_sqlite_store_migrates_google_best_guess_labels_to_non_scoring_hints(tmp assert review["riskScore"] == 35 assert review["riskBand"] == "medium" - assert review["reasons"] == ["Face/person detected"] + assert review["reasons"] == [IDENTIFIABLE_FACE_REASON] assert weak_label["title"] == "Google weak label gentleman" assert weak_label["contributed"] is False @@ -8052,7 +8063,7 @@ def test_sqlite_store_rescores_existing_saved_google_best_guess_labels(tmp_path: assert review["riskScore"] == 35 assert review["riskBand"] == "medium" - assert "Face/person detected" in review["reasons"] + assert IDENTIFIABLE_FACE_REASON in review["reasons"] assert "Best guess label gentleman" not in review["reasons"] assert weak_label["title"] == "Google weak label gentleman" assert weak_label["contributed"] is False diff --git a/tests/rights_filter/test_public_module_layout.py b/tests/rights_filter/test_public_module_layout.py index f7208a3..2c9be0f 100644 --- a/tests/rights_filter/test_public_module_layout.py +++ b/tests/rights_filter/test_public_module_layout.py @@ -17,6 +17,7 @@ def test_planned_module_boundaries_are_importable(): "rights_filter.analysis.risk_scoring", "rights_filter.analysis.reason_builder", "rights_filter.analysis.llm_assistance", + "rights_filter.analysis.llm_prompts", "rights_filter.analysis.search_query_generation", "rights_filter.analysis.search_result_promoter", "rights_filter.integrations.external_policy", diff --git a/web/operator-gui/app.js b/web/operator-gui/app.js index 3a678dc..e7f6860 100644 --- a/web/operator-gui/app.js +++ b/web/operator-gui/app.js @@ -113,7 +113,7 @@ const state = { -const viewNames = new Set(["queue", "workbench", "knowledge", "providers", "audit"]); +const viewNames = new Set(["queue", "workbench", "knowledge", "providers", "stats", "audit"]); @@ -1463,6 +1463,11 @@ function renderNoSelectedCase() { // against nothing. const nextActions = document.getElementById("evidence-next-actions"); if (nextActions) nextActions.innerHTML = ""; + const llmDraft = document.getElementById("llm-draft"); + if (llmDraft) { + llmDraft.hidden = true; + llmDraft.innerHTML = ""; + } } @@ -1478,6 +1483,8 @@ function renderCaseReview() { } + renderLlmDraft(submission); + document.getElementById("case-title").textContent = `${submission.id} · ${submission.title}`; const caseImageElement = document.getElementById("case-image"); @@ -2398,6 +2405,15 @@ function switchView(view, updateHash = true) { state.currentView = view; + // Stats are read on demand (a fresh cross-tab each open) rather than shipped + // in every bootstrap payload. + + if (view === "stats") { + + void loadDecisionStats(); + + } + document.querySelectorAll(".nav-button").forEach((button) => { const active = button.dataset.view === view; @@ -3467,6 +3483,430 @@ async function createBackupSnapshot() { +// --- Cleared reference (positive knowledge) ----------------------------- + + + +async function registerClearedReference() { + + const submission = getSelectedCase(); + + if (!submission) return; + + const label = window.prompt( + + "클리어 기준 이름 (예: 자사 보유 스톡 · 라이선스 확인됨)", + + submission.title || submission.id, + + ); + + if (label === null) return; + + const note = window.prompt("메모(선택) — 왜 클리어인지", "") || ""; + + try { + + const payload = await apiJson( + + `/api/submissions/${encodeURIComponent(submission.id)}/clear-reference`, + + { method: "POST", body: JSON.stringify({ label: label.trim(), note }) }, + + ); + + applyBootstrap(payload); + + renderAll(); + + } catch (errorValue) { + + showApiError(errorValue.message); + + } + +} + + + +// --- LLM draft (persist + show + pull into memo) ------------------------ + + + +function llmSummaryFor(submission) { + + const evidence = submission && Array.isArray(submission.evidence) ? submission.evidence : []; + + // The freshest LLM summary that actually carries body text. + + return ( + + evidence + + .filter((item) => item.source === "llm" && String(item.summary || "").trim()) + + .slice(-1)[0] || null + + ); + +} + + + +let currentLlmDraftText = ""; + + + +function renderLlmDraft(submission) { + + const box = document.getElementById("llm-draft"); + + if (!box) return; + + const summary = llmSummaryFor(submission); + + if (!summary) { + + box.hidden = true; + + box.innerHTML = ""; + + currentLlmDraftText = ""; + + return; + + } + + currentLlmDraftText = String(summary.summary || ""); + + const urls = Array.isArray(summary.sourceUrls) ? summary.sourceUrls : []; + + const sourceNote = urls.length ? `연결 근거 ${urls.length}건` : "연결 근거 없음"; + + box.hidden = false; + + box.innerHTML = ` + +
+ + 내부 요약(초안) + + ${escapeHtml(sourceNote)} · 최종 판정은 운영자 + +
+ +

${escapeHtml(currentLlmDraftText)}

+ + + + `; + +} + + + +function pullLlmDraftIntoMemo() { + + const memo = document.getElementById("decision-memo"); + + if (!memo || !currentLlmDraftText) return; + + memo.value = memo.value.trim() + + ? `${memo.value.trim()}\n${currentLlmDraftText}` + + : currentLlmDraftText; + + memo.focus(); + +} + + + +// --- Decision statistics / calibration ---------------------------------- + + + +const DECISION_STAT_LABELS = { + + approved: "승인", + + held: "보류", + + rejected: "반려", + + corrected: "보정", + + unreviewed: "미심사", + +}; + +const BAND_STAT_LABELS = { + + high: "고위험", + + medium: "중위험", + + low: "저위험", + + failed: "분석실패", + + pending: "대기", + +}; + + + +async function loadDecisionStats() { + + const body = document.getElementById("stats-body"); + + if (!body) return; + + body.innerHTML = `
통계를 불러오는 중…
`; + + try { + + const stats = await apiJson("/api/stats/decisions"); + + renderDecisionStats(stats); + + } catch (errorValue) { + + body.innerHTML = `
통계를 불러오지 못했습니다: ${escapeHtml(errorValue.message)}
`; + + } + +} + + + +function calibrationTone(rate, decided) { + + // Not enough decisions to judge -> neutral, don't cry miscalibration on n=1. + + if (decided < 5) return "neutral"; + + if (rate >= 0.4) return "bad"; + + if (rate >= 0.2) return "warn"; + + return "good"; + +} + + + +function renderDecisionStats(stats) { + + const body = document.getElementById("stats-body"); + + if (!body) return; + + const cal = stats.calibration || {}; + + const throughput = stats.throughput || { byDay: [] }; + + const backlog = stats.backlog || { unreviewed: 0, total: 0 }; + + + + const overTone = calibrationTone(cal.highBandApprovalRate || 0, cal.highBandDecided || 0); + + const underTone = calibrationTone(cal.lowBandRejectionRate || 0, cal.lowBandDecided || 0); + + + + const tiles = ` + +
+ +
+ + 과탐 — 고위험인데 승인 + + ${formatPercent(cal.highBandApprovalRate)} + + 고위험 판정 ${cal.highBandDecided || 0}건 기준 + +
+ +
+ + 미탐 — 저위험인데 반려 + + ${formatPercent(cal.lowBandRejectionRate)} + + 저위험 판정 ${cal.lowBandDecided || 0}건 기준 + +
+ +
+ + 미심사 백로그 + + ${backlog.unreviewed} + + 전체 ${backlog.total}건 중 + +
+ +
+ + 누적 판정 + + ${throughput.totalDecisions || 0} + + 감사 로그 기준 + +
+ +
+ + `; + + + + body.innerHTML = ` + + ${tiles} + +
+ +
+ +

밴드 × 판정 매트릭스

+ +

각 위험 밴드가 실제로 어떻게 판정됐는지. 대각선을 벗어난 값이 보정 오차입니다.

+ + ${renderStatsMatrix(stats)} + +
+ +
+ +

최근 결정 추이

+ +

최근 14일 일별 판정 건수.

+ + ${renderThroughputBars(throughput.byDay || [])} + +
+ +
+ + `; + +} + + + +function renderStatsMatrix(stats) { + + const bands = stats.bands || []; + + const decisions = stats.decisions || []; + + const matrix = stats.matrix || {}; + + const head = decisions.map((d) => `${escapeHtml(DECISION_STAT_LABELS[d] || d)}`).join(""); + + const rows = bands + + .map((band) => { + + const row = matrix[band] || {}; + + const total = decisions.reduce((sum, d) => sum + (row[d] || 0), 0); + + const cells = decisions + + .map((d) => { + + const value = row[d] || 0; + + const strong = value > 0 ? " has-value" : ""; + + return `${value}`; + + }) + + .join(""); + + return `${escapeHtml(BAND_STAT_LABELS[band] || band)}${cells}${total}`; + + }) + + .join(""); + + return ` + +
+ + + + ${head} + + ${rows} + +
밴드 \\ 판정합계
+ +
+ + `; + +} + + + +function renderThroughputBars(byDay) { + + if (!byDay.length) { + + return `
아직 기록된 판정이 없습니다.
`; + + } + + const max = Math.max(...byDay.map((entry) => entry.count), 1); + + const bars = byDay + + .map((entry) => { + + const height = Math.round((entry.count / max) * 100); + + return ` + +
+ +
+ + ${entry.count} + + ${escapeHtml(entry.day.slice(5))} + +
+ + `; + + }) + + .join(""); + + return ``; + +} + + + +function formatPercent(rate) { + + return `${Math.round((Number(rate) || 0) * 100)}%`; + +} + + + async function rerunSelectedEnrichment() { const ids = selectedBulkSubmissionIds(); @@ -4691,6 +5131,14 @@ function bindEvents() { document.getElementById("create-backup").addEventListener("click", createBackupSnapshot); + document.getElementById("clear-reference").addEventListener("click", registerClearedReference); + + document.getElementById("llm-draft").addEventListener("click", (event) => { + + if (event.target.closest("#llm-draft-use")) pullLlmDraftIntoMemo(); + + }); + document.getElementById("memo-presets").addEventListener("click", (event) => { if (event.target.closest("#memo-preset-save")) { diff --git a/web/operator-gui/index.html b/web/operator-gui/index.html index a9e92bd..2a60f26 100644 --- a/web/operator-gui/index.html +++ b/web/operator-gui/index.html @@ -53,6 +53,11 @@ 외부 검색 tool 활용 + + + @@ -497,6 +504,17 @@
+ +