feat: 실사용 투입 전 T0 스탬프 4종 + 임베딩 정책 개정
두 갈래의 작업이 한 커밋에 들어 있다. 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).
This commit is contained in:
parent
8b5b3f5cf9
commit
4f140efd9b
30 changed files with 2434 additions and 53 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -12,6 +12,10 @@ htmlcov/
|
||||||
# Offline dependency wheelhouse (built on staging machine, not committed)
|
# Offline dependency wheelhouse (built on staging machine, not committed)
|
||||||
wheelhouse/
|
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)
|
# Secrets & local environment (keep .env.example)
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
# Project INDEX
|
# Project INDEX
|
||||||
|
|
||||||
- 2026-07-24 · 멀티에이전트 프로젝트 점검(에이전트 109, 적대적 검증 포함) — pytest 377/379, 런타임 스모크 9/9 통과, 신규 CONFIRMED 43건(HIGH 7) + 7/11 체크리스트 중 3건 실수정 확인 + 기능 제안 상위 5 선정 → [docs/reviews/2026-07-24-project-review.md](reviews/2026-07-24-project-review.md)
|
- 2026-07-24 · 멀티에이전트 프로젝트 점검(에이전트 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)
|
- 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)
|
||||||
|
|
|
||||||
|
|
@ -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<n>]`로 **시작**), 라벨 없는 줄만 드롭, 미라벨 줄은 렌더하지 않음(취소선 금지) | 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건이 영구히 비어 있는 화면으로 남는다.
|
||||||
|
|
@ -13,11 +13,37 @@
|
||||||
- Do not automate Naver web search UI or scrape Naver result pages.
|
- Do not automate Naver web search UI or scrape Naver result pages.
|
||||||
- Do not send internal originals to external APIs.
|
- 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 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 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 expose automated scores, evidence, or provider details to applicants.
|
||||||
- Do not let the filter change review status automatically.
|
- 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
|
## Disable External Calls
|
||||||
|
|
||||||
Set the external API policy to disabled. Batches should continue internal-only and record external-skipped evidence reasons for affected submissions.
|
Set the external API policy to disabled. Batches should continue internal-only and record external-skipped evidence reasons for affected submissions.
|
||||||
|
|
|
||||||
|
|
@ -36,10 +36,45 @@ pip-compile --generate-hashes -o requirements.lock requirements.txt
|
||||||
|
|
||||||
Then install on the target with `--require-hashes` (step 3).
|
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
|
## 2. Transfer
|
||||||
|
|
||||||
Copy both the repository and the `wheelhouse/` directory to the target via the
|
Copy the repository, the `wheelhouse/` directory, and the `models/` directory to
|
||||||
approved air-gap transfer process (e.g. removable media).
|
the target via the approved air-gap transfer process (e.g. removable media).
|
||||||
|
|
||||||
## 3. On the target machine (no internet)
|
## 3. On the target machine (no internet)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -104,9 +104,10 @@ The LLM output is stored as a source-linked summary evidence item. It helps oper
|
||||||
Important boundary:
|
Important boundary:
|
||||||
|
|
||||||
- it does not identify a person;
|
- 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 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
|
### 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 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 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 expose internal risk scores or evidence details to applicants.
|
||||||
- Do not let automated analysis change final review status.
|
- Do not let automated analysis change final review status.
|
||||||
- Do not treat LLM output as standalone evidence unless it links back to source evidence.
|
- Do not treat LLM output as standalone evidence unless it links back to source evidence.
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from rights_filter.analysis.preprocessing import ImagePayload
|
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
|
# Emit the "OpenCV build lacks CascadeClassifier" warning at most once per
|
||||||
# process instead of on every image so operator logs stay readable.
|
# process instead of on every image so operator logs stay readable.
|
||||||
_warned_missing_cascade = False
|
_warned_missing_cascade = False
|
||||||
|
_warned_missing_model = False
|
||||||
|
|
||||||
FaceBox = tuple[int, int, int, int]
|
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)
|
@dataclass(frozen=True)
|
||||||
class FacePersonSignal:
|
class FacePersonSignal:
|
||||||
face_count: int
|
face_count: int
|
||||||
person_count: int
|
person_count: int
|
||||||
face_boxes: tuple[FaceBox, ...] = ()
|
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
|
@property
|
||||||
def present(self) -> bool:
|
def present(self) -> bool:
|
||||||
return self.face_count > 0 or self.person_count > 0
|
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:
|
class HeuristicFacePersonDetector:
|
||||||
"""Presence-only detector used by the standalone implementation.
|
"""Presence-only detector used by the standalone implementation.
|
||||||
|
|
||||||
Target applications should replace this with their approved local detector or
|
Prefers YuNet (OpenCV Zoo) when its weights are present and degrades to the
|
||||||
internal service. It intentionally emits no identity or embedding data.
|
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:
|
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:
|
try:
|
||||||
import cv2
|
import cv2
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
@ -50,6 +235,10 @@ class HeuristicFacePersonDetector:
|
||||||
if decoded is None:
|
if decoded is None:
|
||||||
return FacePersonSignal(face_count=0, person_count=0)
|
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
|
# 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
|
# cascade API. Detect that up front and degrade to a presence-negative
|
||||||
# signal instead of letting an AttributeError escape into the seed/
|
# signal instead of letting an AttributeError escape into the seed/
|
||||||
|
|
@ -97,11 +286,11 @@ class HeuristicFacePersonDetector:
|
||||||
# rather than crashing the caller.
|
# rather than crashing the caller.
|
||||||
return FacePersonSignal(face_count=0, person_count=0)
|
return FacePersonSignal(face_count=0, person_count=0)
|
||||||
|
|
||||||
face_count = len(face_boxes)
|
return _signal_from_boxes(
|
||||||
return FacePersonSignal(
|
face_boxes,
|
||||||
face_count=face_count,
|
HAAR_FALLBACK_CONFIDENCE,
|
||||||
person_count=face_count,
|
"haar",
|
||||||
face_boxes=tuple(face_boxes),
|
max(0, int(width) * int(height)),
|
||||||
)
|
)
|
||||||
|
|
||||||
def _boxes_overlap(a: FaceBox, b: FaceBox, threshold: float = 0.5) -> bool:
|
def _boxes_overlap(a: FaceBox, b: FaceBox, threshold: float = 0.5) -> bool:
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,17 @@
|
||||||
from __future__ import annotations
|
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.fingerprints import FingerprintService
|
||||||
from rights_filter.analysis.preprocessing import ImagePayload
|
from rights_filter.analysis.preprocessing import ImagePayload
|
||||||
from rights_filter.domain.records import (
|
from rights_filter.domain.records import (
|
||||||
Evidence,
|
Evidence,
|
||||||
EvidenceSource,
|
EvidenceSource,
|
||||||
InMemoryRightsFilterRepository,
|
InMemoryRightsFilterRepository,
|
||||||
|
KnowledgeEntryType,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -46,11 +51,13 @@ class InternalAnalyzer:
|
||||||
)
|
)
|
||||||
if similarity >= self.similarity_threshold:
|
if similarity >= self.similarity_threshold:
|
||||||
entry_status = entry.entry_status or "confirmed"
|
entry_status = entry.entry_status or "confirmed"
|
||||||
reason = (
|
entry_type = str(entry.entry_type)
|
||||||
f"주의 후보 이미지 유사도 {similarity:.2f}"
|
if entry_type == KnowledgeEntryType.CLEARED_REFERENCE:
|
||||||
if entry_status == "watchlist"
|
reason = f"이전에 클리어된 이미지와 일치 {similarity:.2f}"
|
||||||
else f"Knowledge base image similarity {similarity:.2f}"
|
elif entry_status == "watchlist":
|
||||||
)
|
reason = f"주의 후보 이미지 유사도 {similarity:.2f}"
|
||||||
|
else:
|
||||||
|
reason = f"Knowledge base image similarity {similarity:.2f}"
|
||||||
evidence.append(
|
evidence.append(
|
||||||
Evidence(
|
Evidence(
|
||||||
source=EvidenceSource.FINGERPRINT,
|
source=EvidenceSource.FINGERPRINT,
|
||||||
|
|
@ -60,6 +67,9 @@ class InternalAnalyzer:
|
||||||
"knowledge_entry_id": entry.id,
|
"knowledge_entry_id": entry.id,
|
||||||
"knowledge_name": entry.name,
|
"knowledge_name": entry.name,
|
||||||
"knowledge_entry_status": entry_status,
|
"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,
|
"source_submission_id": entry.source_submission_id,
|
||||||
"similarity": similarity,
|
"similarity": similarity,
|
||||||
"provenance": entry.provenance,
|
"provenance": entry.provenance,
|
||||||
|
|
@ -72,12 +82,11 @@ class InternalAnalyzer:
|
||||||
evidence.append(
|
evidence.append(
|
||||||
Evidence(
|
Evidence(
|
||||||
source=EvidenceSource.FACE_PERSON,
|
source=EvidenceSource.FACE_PERSON,
|
||||||
reason="Face/person detected",
|
reason=face_person_reason(signal),
|
||||||
confidence=0.8,
|
# The detector's own score, not a hardcoded 0.8: a Haar
|
||||||
data={
|
# fallback must not claim the confidence YuNet earns.
|
||||||
"face_count": signal.face_count,
|
confidence=signal.confidence,
|
||||||
"person_count": signal.person_count,
|
data=face_person_evidence_data(signal),
|
||||||
},
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,35 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any
|
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.domain.records import Evidence, EvidenceSource
|
||||||
from rights_filter.integrations.http_json import UrllibJsonTransport
|
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
|
@dataclass
|
||||||
class FakeInternalLlmClient:
|
class FakeInternalLlmClient:
|
||||||
summary: str = ""
|
summary: str = ""
|
||||||
error: Exception | None = None
|
error: Exception | None = None
|
||||||
|
model: str = "fake-llm"
|
||||||
|
|
||||||
def summarize_evidence(self, evidence: list[Evidence]) -> str:
|
def summarize_evidence(self, evidence: list[Evidence]) -> str:
|
||||||
if self.error:
|
if self.error:
|
||||||
|
|
@ -36,24 +55,33 @@ class OllamaGenerateLlmClient:
|
||||||
self.timeout = timeout
|
self.timeout = timeout
|
||||||
|
|
||||||
def summarize_evidence(self, evidence: list[Evidence]) -> str:
|
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 = {
|
payload = {
|
||||||
"model": self.model,
|
"model": self.model,
|
||||||
"system": (
|
"system": SYSTEM_PROMPT,
|
||||||
"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."
|
|
||||||
),
|
|
||||||
"prompt": _evidence_prompt(evidence),
|
"prompt": _evidence_prompt(evidence),
|
||||||
"stream": False,
|
"stream": False,
|
||||||
"options": {"temperature": 0.1},
|
"options": {"temperature": 0.1},
|
||||||
}
|
}
|
||||||
|
started = time.monotonic()
|
||||||
response = self.transport.request_json(
|
response = self.transport.request_json(
|
||||||
"POST",
|
"POST",
|
||||||
self._generate_url(),
|
self._generate_url(),
|
||||||
payload=payload,
|
payload=payload,
|
||||||
timeout=self.timeout,
|
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:
|
def _generate_url(self) -> str:
|
||||||
if self.base_url.endswith("/api"):
|
if self.base_url.endswith("/api"):
|
||||||
|
|
@ -67,7 +95,7 @@ class InternalLlmAssistant:
|
||||||
|
|
||||||
def summarize(self, submission_id: str, evidence: list[Evidence]) -> Evidence:
|
def summarize(self, submission_id: str, evidence: list[Evidence]) -> Evidence:
|
||||||
try:
|
try:
|
||||||
summary = self.client.summarize_evidence(evidence)
|
result = self._summarize(evidence)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return Evidence(
|
return Evidence(
|
||||||
source=EvidenceSource.ENRICHMENT_FAILURE,
|
source=EvidenceSource.ENRICHMENT_FAILURE,
|
||||||
|
|
@ -76,6 +104,17 @@ class InternalLlmAssistant:
|
||||||
data={"submission_id": submission_id},
|
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_urls = _source_urls(evidence)
|
||||||
source_evidence_ids = _source_evidence_ids(evidence)
|
source_evidence_ids = _source_evidence_ids(evidence)
|
||||||
if not source_urls and not source_evidence_ids:
|
if not source_urls and not source_evidence_ids:
|
||||||
|
|
@ -85,10 +124,11 @@ class InternalLlmAssistant:
|
||||||
confidence=0.0,
|
confidence=0.0,
|
||||||
data={
|
data={
|
||||||
"submission_id": submission_id,
|
"submission_id": submission_id,
|
||||||
"summary": summary,
|
"summary": result.text,
|
||||||
"source_urls": [],
|
"source_urls": [],
|
||||||
"source_evidence_ids": [],
|
"source_evidence_ids": [],
|
||||||
"verified": False,
|
"verified": False,
|
||||||
|
**stamp,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -98,13 +138,26 @@ class InternalLlmAssistant:
|
||||||
confidence=0.0,
|
confidence=0.0,
|
||||||
data={
|
data={
|
||||||
"submission_id": submission_id,
|
"submission_id": submission_id,
|
||||||
"summary": summary,
|
"summary": result.text,
|
||||||
"source_urls": source_urls,
|
"source_urls": source_urls,
|
||||||
"source_evidence_ids": source_evidence_ids,
|
"source_evidence_ids": source_evidence_ids,
|
||||||
"verified": True,
|
"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]:
|
def _source_urls(evidence: list[Evidence]) -> list[str]:
|
||||||
urls: list[str] = []
|
urls: list[str] = []
|
||||||
|
|
|
||||||
30
src/rights_filter/analysis/llm_prompts.py
Normal file
30
src/rights_filter/analysis/llm_prompts.py
Normal file
|
|
@ -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."
|
||||||
|
)
|
||||||
|
|
@ -1,7 +1,17 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from rights_filter.analysis.face_person_detection import TIER_BACKGROUND
|
||||||
from rights_filter.domain.records import Evidence, EvidenceSource, ScoreResult
|
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:
|
class RiskScorer:
|
||||||
def score(self, evidence: list[Evidence]) -> ScoreResult:
|
def score(self, evidence: list[Evidence]) -> ScoreResult:
|
||||||
|
|
@ -17,6 +27,15 @@ class RiskScorer:
|
||||||
if item.source == EvidenceSource.FINGERPRINT:
|
if item.source == EvidenceSource.FINGERPRINT:
|
||||||
if item.data.get("contributed") is False or item.data.get("status") == "queued":
|
if item.data.get("contributed") is False or item.data.get("status") == "queued":
|
||||||
continue
|
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))
|
similarity = _safe_float(item.data.get("similarity", 0))
|
||||||
if similarity >= 0.9:
|
if similarity >= 0.9:
|
||||||
score += 80
|
score += 80
|
||||||
|
|
@ -32,7 +51,7 @@ class RiskScorer:
|
||||||
if counted_face_person:
|
if counted_face_person:
|
||||||
continue
|
continue
|
||||||
counted_face_person = True
|
counted_face_person = True
|
||||||
score += 35
|
score += _face_person_points(item)
|
||||||
reasons.append(item.reason)
|
reasons.append(item.reason)
|
||||||
elif item.source == EvidenceSource.WEB_DETECTION:
|
elif item.source == EvidenceSource.WEB_DETECTION:
|
||||||
bucket = _web_detection_bucket(item)
|
bucket = _web_detection_bucket(item)
|
||||||
|
|
@ -90,6 +109,21 @@ class RiskScorer:
|
||||||
return ScoreResult(score=score, band=band, reasons=_unique(reasons))
|
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:
|
def _web_detection_points(item: Evidence) -> int:
|
||||||
if item.data.get("weak_hint"):
|
if item.data.get("weak_hint"):
|
||||||
return 0
|
return 0
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,10 @@ class KnowledgeEntryType(StrEnum):
|
||||||
WEBTOON = "webtoon"
|
WEBTOON = "webtoon"
|
||||||
GAME = "game"
|
GAME = "game"
|
||||||
REJECTED_IMAGE = "rejected_image"
|
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"
|
OTHER = "other"
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -58,6 +62,12 @@ class DataClass(StrEnum):
|
||||||
LLM_SUMMARY = "llm_summary"
|
LLM_SUMMARY = "llm_summary"
|
||||||
PROVIDER_METADATA = "provider_metadata"
|
PROVIDER_METADATA = "provider_metadata"
|
||||||
OPERATOR_NOTE = "operator_note"
|
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)
|
@dataclass(frozen=True)
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,16 @@ class GovernancePolicyRegistry:
|
||||||
"delete with decision audit policy",
|
"delete with decision audit policy",
|
||||||
"append correction rather than overwriting audit trail",
|
"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]
|
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:
|
def assert_no_biometric_template(payload: dict[str, Any]) -> None:
|
||||||
forbidden_keys = {"embedding", "face_embedding", "biometric_template"}
|
if _BIOMETRIC_KEYS.intersection(payload):
|
||||||
if forbidden_keys.intersection(payload):
|
|
||||||
raise ValueError("biometric template storage is not allowed")
|
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(
|
def assert_operator_evidence_payload_allowed(
|
||||||
data_class: DataClass, payload: dict[str, Any]
|
data_class: DataClass, payload: dict[str, Any]
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
|
||||||
|
|
@ -127,6 +127,8 @@ def build_server(
|
||||||
)
|
)
|
||||||
elif path == "/api/admin/backups":
|
elif path == "/api/admin/backups":
|
||||||
self._json(store.backups())
|
self._json(store.backups())
|
||||||
|
elif path == "/api/stats/decisions":
|
||||||
|
self._json(store.decision_stats())
|
||||||
elif path.startswith("/media/"):
|
elif path.startswith("/media/"):
|
||||||
self._file(active_store().media_path(unquote(path.removeprefix("/media/"))), untrusted=True)
|
self._file(active_store().media_path(unquote(path.removeprefix("/media/"))), untrusted=True)
|
||||||
elif path.startswith("/knowledge-media/"):
|
elif path.startswith("/knowledge-media/"):
|
||||||
|
|
@ -265,6 +267,16 @@ def build_server(
|
||||||
self._json(store.delete_decision_memo_preset(preset_id))
|
self._json(store.delete_decision_memo_preset(preset_id))
|
||||||
elif path == "/api/admin/backup":
|
elif path == "/api/admin/backup":
|
||||||
self._json(store.create_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":
|
elif path == "/api/providers/emergency-disable":
|
||||||
self._json(store.emergency_disable_external_providers())
|
self._json(store.emergency_disable_external_providers())
|
||||||
else:
|
else:
|
||||||
|
|
|
||||||
|
|
@ -686,10 +686,17 @@ class CopyrighterStore(
|
||||||
# so an operator decision is not silently reverted by a concurrent
|
# so an operator decision is not silently reverted by a concurrent
|
||||||
# lock-holding re-put of a stale submission payload.
|
# lock-holding re-put of a stale submission payload.
|
||||||
with self._write_lock:
|
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:
|
with self._transaction() as conn:
|
||||||
submission = self._get("submissions", submission_id, conn=conn)
|
submission = self._get("submissions", submission_id, conn=conn)
|
||||||
submission["decisionStatus"] = decision
|
submission["decisionStatus"] = decision
|
||||||
submission["applicantStatus"] = "승인 대기" if decision == "approved" else "반려 예정" if decision == "rejected" else "검토 중"
|
submission["applicantStatus"] = "승인 대기" if decision == "approved" else "반려 예정" if decision == "rejected" else "검토 중"
|
||||||
|
submission["decisionSnapshot"] = snapshot
|
||||||
self._put("submissions", submission_id, submission, conn=conn)
|
self._put("submissions", submission_id, submission, conn=conn)
|
||||||
|
|
||||||
if decision in {"held", "rejected"}:
|
if decision in {"held", "rejected"}:
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,11 @@ from typing import Any
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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.fingerprints import FingerprintService
|
||||||
from rights_filter.analysis.internal_analyzer import InternalAnalyzer
|
from rights_filter.analysis.internal_analyzer import InternalAnalyzer
|
||||||
from rights_filter.analysis.preprocessing import build_external_derivative, build_face_crop_derivatives
|
from rights_filter.analysis.preprocessing import build_external_derivative, build_face_crop_derivatives
|
||||||
|
|
@ -152,12 +156,9 @@ class StoreEnrichmentMixin:
|
||||||
|
|
||||||
evidence = Evidence(
|
evidence = Evidence(
|
||||||
source=EvidenceSource.FACE_PERSON,
|
source=EvidenceSource.FACE_PERSON,
|
||||||
reason="Face/person detected",
|
reason=face_person_reason(signal),
|
||||||
confidence=0.8,
|
confidence=signal.confidence,
|
||||||
data={
|
data=face_person_evidence_data(signal),
|
||||||
"face_count": signal.face_count,
|
|
||||||
"person_count": signal.person_count,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
self._put(
|
self._put(
|
||||||
"evidence",
|
"evidence",
|
||||||
|
|
|
||||||
|
|
@ -203,6 +203,66 @@ class StoreOperationsMixin:
|
||||||
return []
|
return []
|
||||||
return [fingerprints.perceptual]
|
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(
|
def rerun_enrichment(
|
||||||
self,
|
self,
|
||||||
submission_id: str,
|
submission_id: str,
|
||||||
|
|
|
||||||
|
|
@ -725,9 +725,19 @@ class StoreSearchCandidatesMixin:
|
||||||
for item in self._evidence_for_submission(submission_id)
|
for item in self._evidence_for_submission(submission_id)
|
||||||
]
|
]
|
||||||
score = RiskScorer().score(evidence)
|
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["riskScore"] = score.score
|
||||||
submission["riskBand"] = score.band
|
submission["riskBand"] = score.band
|
||||||
submission["reasons"] = score.reasons or ["분석 근거 없음"]
|
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)
|
self._put("submissions", submission_id, submission)
|
||||||
|
|
||||||
def _rescore_all_submissions(self, queue_id: str | None = None) -> None:
|
def _rescore_all_submissions(self, queue_id: str | None = None) -> None:
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
from rights_filter.analysis.risk_scoring import SCORER_VERSION
|
||||||
from rights_filter.domain.records import (
|
from rights_filter.domain.records import (
|
||||||
Evidence,
|
Evidence,
|
||||||
EvidenceSource,
|
EvidenceSource,
|
||||||
|
|
@ -109,6 +110,15 @@ def _submission_payload(
|
||||||
"asset": record["asset"],
|
"asset": record["asset"],
|
||||||
"riskScore": score,
|
"riskScore": score,
|
||||||
"riskBand": band,
|
"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,
|
"submittedAt": submitted_at,
|
||||||
"submittedEpoch": submitted_epoch,
|
"submittedEpoch": submitted_epoch,
|
||||||
"lastAnalysis": _now_label(),
|
"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", "")),
|
"knowledgeEntryId": str(evidence.data.get("knowledge_entry_id", "")),
|
||||||
"knowledgeEntryName": str(evidence.data.get("knowledge_name", "")),
|
"knowledgeEntryName": str(evidence.data.get("knowledge_name", "")),
|
||||||
"knowledgeEntryStatus": knowledge_entry_status,
|
"knowledgeEntryStatus": knowledge_entry_status,
|
||||||
|
"knowledgeEntryType": str(evidence.data.get("knowledge_entry_type", "")),
|
||||||
"sourceSubmissionId": str(evidence.data.get("source_submission_id", "")),
|
"sourceSubmissionId": str(evidence.data.get("source_submission_id", "")),
|
||||||
"similarity": evidence.data.get("similarity", ""),
|
"similarity": evidence.data.get("similarity", ""),
|
||||||
"retrievedAt": _now_label(),
|
"retrievedAt": _now_label(),
|
||||||
"contributed": source not in {"llm", "failure"} and not evidence.data.get("weak_hint", False),
|
"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", []),
|
"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",
|
"status": "active",
|
||||||
"submission_id": submission_id,
|
"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_entry_id": payload.get("knowledgeEntryId", ""),
|
||||||
"knowledge_name": payload.get("knowledgeEntryName", ""),
|
"knowledge_name": payload.get("knowledgeEntryName", ""),
|
||||||
"knowledge_entry_status": payload.get("knowledgeEntryStatus", ""),
|
"knowledge_entry_status": payload.get("knowledgeEntryStatus", ""),
|
||||||
|
"knowledge_entry_type": payload.get("knowledgeEntryType", ""),
|
||||||
"source_submission_id": payload.get("sourceSubmissionId", ""),
|
"source_submission_id": payload.get("sourceSubmissionId", ""),
|
||||||
"similarity": payload.get("similarity", ""),
|
"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
|
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:
|
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]
|
return "ev-" + hashlib.sha256(base.encode("utf-8")).hexdigest()[:24]
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,52 @@ INTERNAL_EXPORT_FIELDS = (
|
||||||
|
|
||||||
|
|
||||||
class StoreWorkflowMixin:
|
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 ------------------------------------------------
|
# ---- batch decisions ------------------------------------------------
|
||||||
|
|
||||||
def record_decision_batch(
|
def record_decision_batch(
|
||||||
|
|
@ -142,6 +188,76 @@ class StoreWorkflowMixin:
|
||||||
)
|
)
|
||||||
return self.decision_memo_presets()
|
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 -------------------------------------------------
|
# ---- decision export -------------------------------------------------
|
||||||
|
|
||||||
def export_decisions(
|
def export_decisions(
|
||||||
|
|
@ -275,6 +391,10 @@ class StoreWorkflowMixin:
|
||||||
return memo
|
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:
|
def _rows_to_csv(rows: list[dict[str, Any]], fields: tuple[str, ...]) -> str:
|
||||||
buffer = io.StringIO()
|
buffer = io.StringIO()
|
||||||
writer = csv.DictWriter(buffer, fieldnames=list(fields), extrasaction="ignore")
|
writer = csv.DictWriter(buffer, fieldnames=list(fields), extrasaction="ignore")
|
||||||
|
|
|
||||||
214
tests/rights_filter/analysis/test_face_person_tiers.py
Normal file
214
tests/rights_filter/analysis/test_face_person_tiers.py
Normal file
|
|
@ -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
|
||||||
|
|
@ -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.domain.records import Evidence, EvidenceSource
|
||||||
|
from rights_filter.server.store_serialization import _evidence_id
|
||||||
|
|
||||||
|
|
||||||
def test_llm_summary_cites_source_urls_and_evidence_ids():
|
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 evidence.source == EvidenceSource.ENRICHMENT_FAILURE
|
||||||
assert "LLM assistance failed" in evidence.reason
|
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
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,27 @@
|
||||||
|
import pytest
|
||||||
|
|
||||||
from rights_filter.domain.records import DataClass
|
from rights_filter.domain.records import DataClass
|
||||||
from rights_filter.governance.policies import (
|
from rights_filter.governance.policies import (
|
||||||
|
MAX_PERSON_AREA_RATIO,
|
||||||
GovernancePolicyRegistry,
|
GovernancePolicyRegistry,
|
||||||
assert_no_biometric_template,
|
assert_no_biometric_template,
|
||||||
assert_operator_evidence_payload_allowed,
|
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():
|
def test_each_data_class_has_access_retention_delete_and_correction_policy():
|
||||||
registry = GovernancePolicyRegistry.default()
|
registry = GovernancePolicyRegistry.default()
|
||||||
|
|
||||||
|
|
@ -42,6 +58,57 @@ def test_search_evidence_rejects_image_payloads_sent_to_naver():
|
||||||
raise AssertionError("expected Naver image payload rejection")
|
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():
|
def test_llm_summary_must_reference_source_evidence():
|
||||||
try:
|
try:
|
||||||
assert_operator_evidence_payload_allowed(
|
assert_operator_evidence_payload_allowed(
|
||||||
|
|
|
||||||
125
tests/rights_filter/server/test_pre_golive_stamps.py
Normal file
125
tests/rights_filter/server/test_pre_golive_stamps.py
Normal file
|
|
@ -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
|
||||||
153
tests/rights_filter/server/test_review_intelligence.py
Normal file
153
tests/rights_filter/server/test_review_intelligence.py
Normal file
|
|
@ -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"]
|
||||||
|
|
@ -7,7 +7,11 @@ from io import BytesIO
|
||||||
|
|
||||||
import pytest
|
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.analysis.preprocessing import ImagePayload, build_face_crop_derivatives
|
||||||
from rights_filter.domain.records import Evidence, EvidenceSource
|
from rights_filter.domain.records import Evidence, EvidenceSource
|
||||||
from rights_filter.server.image_store import LocalSubmissionImageStore
|
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
|
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:
|
class FakeJsonTransport:
|
||||||
def __init__(self, response):
|
def __init__(self, response):
|
||||||
self.response = 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["riskScore"] == 35
|
||||||
assert review["riskBand"] == "medium"
|
assert review["riskBand"] == "medium"
|
||||||
assert "Face/person detected" in review["reasons"]
|
assert IDENTIFIABLE_FACE_REASON in review["reasons"]
|
||||||
assert "재분석 요청이 접수됨" not 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["riskScore"] == 35
|
||||||
assert review["riskBand"] == "medium"
|
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["title"] == "Google weak label gentleman"
|
||||||
assert weak_label["contributed"] is False
|
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["riskScore"] == 35
|
||||||
assert review["riskBand"] == "medium"
|
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 "Best guess label gentleman" not in review["reasons"]
|
||||||
assert weak_label["title"] == "Google weak label gentleman"
|
assert weak_label["title"] == "Google weak label gentleman"
|
||||||
assert weak_label["contributed"] is False
|
assert weak_label["contributed"] is False
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ def test_planned_module_boundaries_are_importable():
|
||||||
"rights_filter.analysis.risk_scoring",
|
"rights_filter.analysis.risk_scoring",
|
||||||
"rights_filter.analysis.reason_builder",
|
"rights_filter.analysis.reason_builder",
|
||||||
"rights_filter.analysis.llm_assistance",
|
"rights_filter.analysis.llm_assistance",
|
||||||
|
"rights_filter.analysis.llm_prompts",
|
||||||
"rights_filter.analysis.search_query_generation",
|
"rights_filter.analysis.search_query_generation",
|
||||||
"rights_filter.analysis.search_result_promoter",
|
"rights_filter.analysis.search_result_promoter",
|
||||||
"rights_filter.integrations.external_policy",
|
"rights_filter.integrations.external_policy",
|
||||||
|
|
|
||||||
|
|
@ -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.
|
// against nothing.
|
||||||
const nextActions = document.getElementById("evidence-next-actions");
|
const nextActions = document.getElementById("evidence-next-actions");
|
||||||
if (nextActions) nextActions.innerHTML = "";
|
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}`;
|
document.getElementById("case-title").textContent = `${submission.id} · ${submission.title}`;
|
||||||
|
|
||||||
const caseImageElement = document.getElementById("case-image");
|
const caseImageElement = document.getElementById("case-image");
|
||||||
|
|
@ -2398,6 +2405,15 @@ function switchView(view, updateHash = true) {
|
||||||
|
|
||||||
state.currentView = view;
|
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) => {
|
document.querySelectorAll(".nav-button").forEach((button) => {
|
||||||
|
|
||||||
const active = button.dataset.view === view;
|
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 = `
|
||||||
|
|
||||||
|
<div class="llm-draft-head">
|
||||||
|
|
||||||
|
<span class="llm-draft-tag">내부 요약(초안)</span>
|
||||||
|
|
||||||
|
<span class="muted small">${escapeHtml(sourceNote)} · 최종 판정은 운영자</span>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="llm-draft-body">${escapeHtml(currentLlmDraftText)}</p>
|
||||||
|
|
||||||
|
<button type="button" class="llm-draft-use" id="llm-draft-use">메모 초안으로 넣기</button>
|
||||||
|
|
||||||
|
`;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
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 = `<div class="empty-state">통계를 불러오는 중…</div>`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
|
||||||
|
const stats = await apiJson("/api/stats/decisions");
|
||||||
|
|
||||||
|
renderDecisionStats(stats);
|
||||||
|
|
||||||
|
} catch (errorValue) {
|
||||||
|
|
||||||
|
body.innerHTML = `<div class="empty-state">통계를 불러오지 못했습니다: ${escapeHtml(errorValue.message)}</div>`;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
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 = `
|
||||||
|
|
||||||
|
<div class="stat-tiles">
|
||||||
|
|
||||||
|
<article class="stat-tile tone-${overTone}">
|
||||||
|
|
||||||
|
<span class="stat-tile-label">과탐 — 고위험인데 승인</span>
|
||||||
|
|
||||||
|
<strong class="stat-tile-value">${formatPercent(cal.highBandApprovalRate)}</strong>
|
||||||
|
|
||||||
|
<span class="stat-tile-sub">고위험 판정 ${cal.highBandDecided || 0}건 기준</span>
|
||||||
|
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="stat-tile tone-${underTone}">
|
||||||
|
|
||||||
|
<span class="stat-tile-label">미탐 — 저위험인데 반려</span>
|
||||||
|
|
||||||
|
<strong class="stat-tile-value">${formatPercent(cal.lowBandRejectionRate)}</strong>
|
||||||
|
|
||||||
|
<span class="stat-tile-sub">저위험 판정 ${cal.lowBandDecided || 0}건 기준</span>
|
||||||
|
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="stat-tile">
|
||||||
|
|
||||||
|
<span class="stat-tile-label">미심사 백로그</span>
|
||||||
|
|
||||||
|
<strong class="stat-tile-value">${backlog.unreviewed}</strong>
|
||||||
|
|
||||||
|
<span class="stat-tile-sub">전체 ${backlog.total}건 중</span>
|
||||||
|
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="stat-tile">
|
||||||
|
|
||||||
|
<span class="stat-tile-label">누적 판정</span>
|
||||||
|
|
||||||
|
<strong class="stat-tile-value">${throughput.totalDecisions || 0}</strong>
|
||||||
|
|
||||||
|
<span class="stat-tile-sub">감사 로그 기준</span>
|
||||||
|
|
||||||
|
</article>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
`;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
body.innerHTML = `
|
||||||
|
|
||||||
|
${tiles}
|
||||||
|
|
||||||
|
<div class="stats-columns">
|
||||||
|
|
||||||
|
<section class="stats-card">
|
||||||
|
|
||||||
|
<h2>밴드 × 판정 매트릭스</h2>
|
||||||
|
|
||||||
|
<p class="muted small">각 위험 밴드가 실제로 어떻게 판정됐는지. 대각선을 벗어난 값이 보정 오차입니다.</p>
|
||||||
|
|
||||||
|
${renderStatsMatrix(stats)}
|
||||||
|
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="stats-card">
|
||||||
|
|
||||||
|
<h2>최근 결정 추이</h2>
|
||||||
|
|
||||||
|
<p class="muted small">최근 14일 일별 판정 건수.</p>
|
||||||
|
|
||||||
|
${renderThroughputBars(throughput.byDay || [])}
|
||||||
|
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
`;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
function renderStatsMatrix(stats) {
|
||||||
|
|
||||||
|
const bands = stats.bands || [];
|
||||||
|
|
||||||
|
const decisions = stats.decisions || [];
|
||||||
|
|
||||||
|
const matrix = stats.matrix || {};
|
||||||
|
|
||||||
|
const head = decisions.map((d) => `<th scope="col">${escapeHtml(DECISION_STAT_LABELS[d] || d)}</th>`).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 `<td class="matrix-cell${strong}">${value}</td>`;
|
||||||
|
|
||||||
|
})
|
||||||
|
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
return `<tr><th scope="row"><span class="risk-badge ${riskClass(band)}">${escapeHtml(BAND_STAT_LABELS[band] || band)}</span></th>${cells}<td class="matrix-total">${total}</td></tr>`;
|
||||||
|
|
||||||
|
})
|
||||||
|
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
return `
|
||||||
|
|
||||||
|
<div class="table-shell">
|
||||||
|
|
||||||
|
<table class="stats-matrix">
|
||||||
|
|
||||||
|
<thead><tr><th scope="col">밴드 \\ 판정</th>${head}<th scope="col">합계</th></tr></thead>
|
||||||
|
|
||||||
|
<tbody>${rows}</tbody>
|
||||||
|
|
||||||
|
</table>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
`;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
function renderThroughputBars(byDay) {
|
||||||
|
|
||||||
|
if (!byDay.length) {
|
||||||
|
|
||||||
|
return `<div class="empty-state">아직 기록된 판정이 없습니다.</div>`;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
const max = Math.max(...byDay.map((entry) => entry.count), 1);
|
||||||
|
|
||||||
|
const bars = byDay
|
||||||
|
|
||||||
|
.map((entry) => {
|
||||||
|
|
||||||
|
const height = Math.round((entry.count / max) * 100);
|
||||||
|
|
||||||
|
return `
|
||||||
|
|
||||||
|
<div class="throughput-bar" title="${escapeHtml(entry.day)} · ${entry.count}건">
|
||||||
|
|
||||||
|
<div class="throughput-bar-fill" style="height:${height}%"></div>
|
||||||
|
|
||||||
|
<span class="throughput-bar-count">${entry.count}</span>
|
||||||
|
|
||||||
|
<span class="throughput-bar-day">${escapeHtml(entry.day.slice(5))}</span>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
`;
|
||||||
|
|
||||||
|
})
|
||||||
|
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
return `<div class="throughput-chart" role="img" aria-label="일별 판정 건수">${bars}</div>`;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
function formatPercent(rate) {
|
||||||
|
|
||||||
|
return `${Math.round((Number(rate) || 0) * 100)}%`;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
async function rerunSelectedEnrichment() {
|
async function rerunSelectedEnrichment() {
|
||||||
|
|
||||||
const ids = selectedBulkSubmissionIds();
|
const ids = selectedBulkSubmissionIds();
|
||||||
|
|
@ -4691,6 +5131,14 @@ function bindEvents() {
|
||||||
|
|
||||||
document.getElementById("create-backup").addEventListener("click", createBackupSnapshot);
|
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) => {
|
document.getElementById("memo-presets").addEventListener("click", (event) => {
|
||||||
|
|
||||||
if (event.target.closest("#memo-preset-save")) {
|
if (event.target.closest("#memo-preset-save")) {
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,11 @@
|
||||||
<span aria-hidden="true">P</span>
|
<span aria-hidden="true">P</span>
|
||||||
<span>외부 검색 tool 활용</span>
|
<span>외부 검색 tool 활용</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="nav-button" type="button" data-view="stats">
|
||||||
|
<span aria-hidden="true">S</span>
|
||||||
|
<span>통계</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
<button class="nav-button" type="button" data-view="audit">
|
<button class="nav-button" type="button" data-view="audit">
|
||||||
<span aria-hidden="true">A</span>
|
<span aria-hidden="true">A</span>
|
||||||
<span>감사 로그</span>
|
<span>감사 로그</span>
|
||||||
|
|
@ -225,6 +230,7 @@
|
||||||
<span class="score-pill risk-pending" id="floating-case-score">대기</span>
|
<span class="score-pill risk-pending" id="floating-case-score">대기</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="recommendation-box" id="recommendation-box"></div>
|
<div class="recommendation-box" id="recommendation-box"></div>
|
||||||
|
<div class="llm-draft" id="llm-draft" hidden></div>
|
||||||
<label class="memo-field" for="decision-memo">
|
<label class="memo-field" for="decision-memo">
|
||||||
<span>결정 메모</span>
|
<span>결정 메모</span>
|
||||||
<textarea id="decision-memo" rows="3" placeholder="반려 또는 보정 시 메모 필수"></textarea>
|
<textarea id="decision-memo" rows="3" placeholder="반려 또는 보정 시 메모 필수"></textarea>
|
||||||
|
|
@ -243,6 +249,7 @@
|
||||||
<button type="button" id="add-from-case">주의 후보 보기</button>
|
<button type="button" id="add-from-case">주의 후보 보기</button>
|
||||||
<button type="button" id="mark-irrelevant">증거 미사용 처리</button>
|
<button type="button" id="mark-irrelevant">증거 미사용 처리</button>
|
||||||
<button type="button" id="disable-derived">자동 항목 비활성</button>
|
<button type="button" id="disable-derived">자동 항목 비활성</button>
|
||||||
|
<button type="button" id="clear-reference">클리어 기준 등록</button>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
@ -497,6 +504,17 @@
|
||||||
<div id="providers-list" class="provider-list"></div>
|
<div id="providers-list" class="provider-list"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="view" id="stats-view" data-view-panel="stats" aria-labelledby="stats-title" hidden>
|
||||||
|
<div class="section-header">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">스코어 보정 · 처리량</p>
|
||||||
|
<h1 id="stats-title">판정 통계</h1>
|
||||||
|
<p class="section-sub">룰 기반 위험 점수가 실제 운영자 판정과 얼마나 맞는지, 백로그가 어디서 쌓이는지 봅니다.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="stats-body" class="stats-body"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="view" id="audit-view" data-view-panel="audit" aria-labelledby="audit-title" hidden>
|
<section class="view" id="audit-view" data-view-panel="audit" aria-labelledby="audit-title" hidden>
|
||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
|
|
@ -3005,3 +3005,144 @@ tbody tr.selected-row,
|
||||||
background: var(--red-tint);
|
background: var(--red-tint);
|
||||||
border-color: var(--red-line);
|
border-color: var(--red-line);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- LLM draft (grounded summary + pull into memo) -------------------- */
|
||||||
|
.llm-draft {
|
||||||
|
margin: 8px 0;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: var(--violet-tint);
|
||||||
|
border: 1px solid var(--violet-line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.llm-draft[hidden] { display: none; }
|
||||||
|
|
||||||
|
.llm-draft-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.llm-draft-tag {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
color: var(--violet);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.llm-draft-body {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 0.84rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--ink);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.llm-draft-use {
|
||||||
|
padding: 4px 10px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--violet);
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--violet-line);
|
||||||
|
border-radius: var(--r-pill);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Stats / calibration view ---------------------------------------- */
|
||||||
|
.section-sub {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-body { display: flex; flex-direction: column; gap: 18px; }
|
||||||
|
|
||||||
|
.stat-tiles {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-tile {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
background: var(--card-raised);
|
||||||
|
border: 1px solid var(--hair);
|
||||||
|
border-left: 3px solid var(--hair-strong);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-tile-label { font-size: 0.76rem; font-weight: 600; color: var(--ink-soft); }
|
||||||
|
.stat-tile-value { font-size: 1.7rem; font-weight: 700; color: var(--ink); font-variant-numeric: tabular-nums; letter-spacing: -0.02em; }
|
||||||
|
.stat-tile-sub { font-size: 0.72rem; color: var(--ink-faint); }
|
||||||
|
|
||||||
|
.stat-tile.tone-good { border-left-color: var(--green); }
|
||||||
|
.stat-tile.tone-good .stat-tile-value { color: var(--green); }
|
||||||
|
.stat-tile.tone-warn { border-left-color: var(--amber); }
|
||||||
|
.stat-tile.tone-warn .stat-tile-value { color: var(--amber); }
|
||||||
|
.stat-tile.tone-bad { border-left-color: var(--red); }
|
||||||
|
.stat-tile.tone-bad .stat-tile-value { color: var(--red); }
|
||||||
|
.stat-tile.tone-neutral { border-left-color: var(--slate-line); }
|
||||||
|
|
||||||
|
.stats-columns {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.stats-columns { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-card {
|
||||||
|
padding: 16px;
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--hair);
|
||||||
|
border-radius: var(--r-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-card h2 { margin: 0 0 2px; font-size: 0.95rem; color: var(--ink); }
|
||||||
|
|
||||||
|
.stats-matrix { width: 100%; border-collapse: collapse; margin-top: 10px; font-size: 0.82rem; }
|
||||||
|
.stats-matrix th, .stats-matrix td { padding: 7px 10px; text-align: center; border-bottom: 1px solid var(--hair-soft); }
|
||||||
|
.stats-matrix thead th { color: var(--ink-soft); font-weight: 600; font-size: 0.76rem; }
|
||||||
|
.stats-matrix th[scope="row"] { text-align: left; }
|
||||||
|
.matrix-cell { color: var(--ink-faint); font-variant-numeric: tabular-nums; }
|
||||||
|
.matrix-cell.has-value { color: var(--ink); font-weight: 600; }
|
||||||
|
.matrix-total { color: var(--ink-soft); font-weight: 700; font-variant-numeric: tabular-nums; }
|
||||||
|
|
||||||
|
.throughput-chart {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 6px;
|
||||||
|
height: 160px;
|
||||||
|
margin-top: 12px;
|
||||||
|
padding-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.throughput-bar {
|
||||||
|
flex: 1 1 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 2px;
|
||||||
|
height: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.throughput-bar-fill {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 22px;
|
||||||
|
min-height: 2px;
|
||||||
|
background: var(--teal);
|
||||||
|
border-radius: 4px 4px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.throughput-bar-count { font-size: 0.68rem; color: var(--ink-soft); font-variant-numeric: tabular-nums; }
|
||||||
|
.throughput-bar-day { font-size: 0.62rem; color: var(--ink-faint); white-space: nowrap; }
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue