Compare commits
No commits in common. "fa00f382e28460256abeafe99ad5699eacb06689" and "db9ee9a7ca39d1e16ac950eb7506247ec78d181a" have entirely different histories.
fa00f382e2
...
db9ee9a7ca
48 changed files with 118 additions and 6286 deletions
15
.env.example
15
.env.example
|
|
@ -45,17 +45,4 @@ COPYRIGHTER_GOOGLE_CUSTOM_SEARCH_DAILY_LIMIT=100
|
||||||
COPYRIGHTER_LLM_DAILY_LIMIT=100
|
COPYRIGHTER_LLM_DAILY_LIMIT=100
|
||||||
|
|
||||||
OLLAMA_BASE_URL=http://localhost:11434
|
OLLAMA_BASE_URL=http://localhost:11434
|
||||||
# Measured against real stored evidence on the GPU target:
|
OLLAMA_MODEL=qwen2.5:0.5b-instruct
|
||||||
# qwen2.5:0.5b-instruct 55.7s — returned the prompt's shape example verbatim
|
|
||||||
# qwen2.5:7b 8.8s — held the contract <- default
|
|
||||||
# qwen3:8b 35.7s — held the contract, 4x slower (thinking model)
|
|
||||||
# Pull on the build machine: `ollama pull qwen2.5:7b`.
|
|
||||||
OLLAMA_MODEL=qwen2.5:7b
|
|
||||||
# Context window. Ollama defaults to a couple of thousand tokens and silently
|
|
||||||
# truncates past it — the summary would then cite evidence the model never read.
|
|
||||||
OLLAMA_NUM_CTX=8192
|
|
||||||
OLLAMA_NUM_PREDICT=512
|
|
||||||
# Seconds. Structured output on a 3B model needs more headroom than the old 30s.
|
|
||||||
OLLAMA_TIMEOUT=120
|
|
||||||
# Keep weights resident between cases so a batch does not reload per case.
|
|
||||||
OLLAMA_KEEP_ALIVE=10m
|
|
||||||
|
|
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -12,10 +12,6 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -34,14 +34,10 @@ cp .env.example .env
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python run_copyrighter_server.py
|
python run_copyrighter_server.py
|
||||||
# 옵션은 런처에 그대로 전달됩니다
|
# 또는
|
||||||
python run_copyrighter_server.py --host 127.0.0.1 --port 9500
|
python -m rights_filter.server --host 127.0.0.1 --port 9500
|
||||||
```
|
```
|
||||||
|
|
||||||
> `python -m rights_filter.server` 로 직접 실행하려면 `src/` 가 import 경로에 있어야 합니다
|
|
||||||
> (`PYTHONPATH=src`, Windows PowerShell 은 `$env:PYTHONPATH="src"`). 런처는 이 경로 설정과
|
|
||||||
> 작업 디렉터리 고정을 대신 해 주므로 `run_copyrighter_server.py` 사용을 권장합니다.
|
|
||||||
|
|
||||||
주요 옵션: `--host`, `--port`, `--db`(SQLite 경로), `--images`(제출 이미지 폴더), `--static`(운영자 GUI 경로), `--env`(.env 경로).
|
주요 옵션: `--host`, `--port`, `--db`(SQLite 경로), `--images`(제출 이미지 폴더), `--static`(운영자 GUI 경로), `--env`(.env 경로).
|
||||||
|
|
||||||
기본값으로 실행하면 `http://127.0.0.1:9500` 에서 운영자 콘솔이 열립니다.
|
기본값으로 실행하면 `http://127.0.0.1:9500` 에서 운영자 콘솔이 열립니다.
|
||||||
|
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
# 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-26 · 운영자 콘솔 실렌더 검증 + 결함 5종 수정 (`d20e6d6`) — 서버 기동 불가(cp949에서 em-dash `UnicodeEncodeError`로 프로세스 사망, 납품 환경 치명) · 요약이 모델이 못 본 78건을 "연결 근거"로 주장(B단계가 절반만 막았음) · `grounded/droppedClaims` 미영속화 · app.js가 서버 그룹 분류를 자체 매핑으로 덮어씀 · 기본 모델이 클라이언트/배선에서 불일치(미설치 모델 지목). 실렌더 확인: 출처 표기 그룹 자체 섹션 렌더, `[E1,E2...]` 인용 2단 요약, `sourceIds` 78→10, qwen2.5:7b 6.0초. **pytest 534 passed**
|
|
||||||
- 2026-07-25 · C단계: 출처 신호 2종 (`5a9fcc0`) — EXIF/IPTC 권리 표기 추출(화이트리스트 6필드, GPS·시리얼·Make/Model 미판독) + 방송캡처 포렌식(레터박스·자막밴드·프레임크기, 독립 카테고리 2개 이상). 하나의 `EvidenceSource.PROVENANCE`·하나의 GUI 그룹으로 배관 12곳 비용 1회 지불. **둘 다 0점 라벨**(캘리브레이션 전). 실측: 실제 이미지 6장에서 캡처 오탐 0·권리메타 히트 0(정밀도 확인, 가치는 이 코퍼스에서 미입증). XMP/C2PA는 `defusedxml` 미반입으로 제외. **pytest 531 passed**
|
|
||||||
- 2026-07-25 · 실모델 프로브로 형식 예시 반향 결함 발견·차단 (`6bbaaf6`) — qwen2.5:0.5b가 프롬프트의 형식 예시를 그대로 출력하며 실제 라벨 12개를 전부 인용해 집합 포함 검사를 통과. 가짜 transport 테스트로는 불가능한 발견. 기본 모델을 실측으로 `qwen2.5:7b` 확정(0.5b 55.7s 계약위반 / 7b 8.8s 준수 / qwen3:8b 35.7s 준수)
|
|
||||||
- 2026-07-25 · B단계: LLM을 요약기에서 증거 판독기로 (`6be263a`) — 입력 화이트리스트 9필드+상위 12건 상한(`promotion_reason` 제외), `format=json` 구조화 인용 + 라벨 집합 포함 검사로 미인용 claim 폐기, 기본 모델 0.5B→3B 및 `num_ctx`/`num_predict`/`keep_alive`/`timeout` 노출, 시드 LLM 호출을 `_write_lock` 밖으로(시드당 20건 상한). 잠금 이동이 만든 provider 커버리지 회귀(요약 존재하는데 `not_run` 보고)를 나레이션 후 재계산으로 수정. **pytest 515 passed**
|
|
||||||
- 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)
|
|
||||||
|
|
@ -1,418 +0,0 @@
|
||||||
# 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 스냅샷이 수백 건 쌓인 뒤.
|
|
||||||
|
|
||||||
### 실기동 실측 (2026-07-25, GPU 타깃) — 추정치를 대체함
|
|
||||||
|
|
||||||
이 문서의 지연 수치는 전부 CPU 개발 머신 실측의 외삽이었다. 실제 GPU 타깃에서
|
|
||||||
저장된 실제 증거(제출물 #3, 증거 54행 중 12행 선별, 프롬프트 1,507자)로 재측정한 결과:
|
|
||||||
|
|
||||||
| 모델 | 지연 | 계약 준수 | 비고 |
|
|
||||||
|---|---:|---|---|
|
|
||||||
| `qwen2.5:0.5b-instruct` | 55.7s | ❌ | **프롬프트의 형식 예시(`<한국어 문장>`)를 그대로 출력**하면서 실제 라벨 12개를 전부 인용 |
|
|
||||||
| `qwen2.5:7b` | **8.8s** | ✅ | 근거 claim 2건 + 확인필요 1건. **채택** |
|
|
||||||
| `qwen3:8b` | 35.7s | ✅ | claim 4건이지만 4배 느림(thinking 모델) |
|
|
||||||
|
|
||||||
**이 실측이 바꾼 것 3가지**:
|
|
||||||
|
|
||||||
1. **기본 모델을 3B가 아니라 `qwen2.5:7b`로 정했다.** 3B는 이 빌드 머신에 없고 7B는
|
|
||||||
이미 있어서 에어갭 반입 비용이 0이며, GPU에서 8.8초면 인터랙티브다. 문서 상단의
|
|
||||||
"7B는 현재 근거로 인터랙티브 불가"는 CPU 전제였고 이 배포에는 적용되지 않는다.
|
|
||||||
2. **인용 검증만으로는 부족하다는 것이 드러났다.** 0.5B의 출력은 실제 라벨을 인용했으므로
|
|
||||||
집합 포함 검사를 **완벽히 통과**했다 — 완전히 비어 있는데도 "근거 있음"으로 판정됐다.
|
|
||||||
형식 예시 문자열을 상수로 두고 그 반향(echo)을 거부하는 가드를 추가했다.
|
|
||||||
*교훈: 그라운딩 검증은 "인용이 진짜인가"와 "내용이 진짜인가"를 둘 다 봐야 한다.*
|
|
||||||
3. **가짜 transport 테스트만으로는 이 결함을 절대 못 잡았다.** 스크립트된 응답은 언제나
|
|
||||||
잘 만들어진 JSON이기 때문이다. 실모델 프로브가 필요했다.
|
|
||||||
|
|
||||||
### 남은 결정 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,37 +13,11 @@
|
||||||
- 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, face landmarks, face descriptors, celebrity identity matches from faces, or biometric templates.
|
- Do not store face embeddings, 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,45 +36,10 @@ 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 the repository, the `wheelhouse/` directory, and the `models/` directory to
|
Copy both the repository and the `wheelhouse/` directory to the target via the
|
||||||
the target via the approved air-gap transfer process (e.g. removable media).
|
approved air-gap transfer process (e.g. removable media).
|
||||||
|
|
||||||
## 3. On the target machine (no internet)
|
## 3. On the target machine (no internet)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -104,10 +104,9 @@ 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, landmarks, or descriptors;
|
- it does not store face embeddings;
|
||||||
- it does not perform biometric matching;
|
- it does not perform biometric matching;
|
||||||
- it is used only as a review-priority signal — and, when scene embeddings are enabled, to supply
|
- it is used only as a review-priority signal.
|
||||||
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
|
||||||
|
|
||||||
|
|
@ -207,7 +206,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, 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 store biometric templates, face embeddings, or celebrity identity matches from faces.
|
||||||
- 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,85 +0,0 @@
|
||||||
# 2026-07-24 구현 체크리스트 (전체 개선 + 신규 기능)
|
|
||||||
|
|
||||||
> 기준: `docs/reviews/2026-07-24-project-review.md`.
|
|
||||||
> 최종 검증: `pytest tests/rights_filter tests/operator_gui` → **457 passed** (착수 전 377 passed / 2 failed).
|
|
||||||
> 실서버 스모크(포트 9561): 인증·시드·중복인덱스·일괄판정·프리셋·내보내기·백업·복구 CLI 실동작 확인.
|
|
||||||
|
|
||||||
## 배치 0 — 저장소 위생 ✅
|
|
||||||
- [x] 루트 `Microsoft/` 삭제 + `.gitignore` 에 `Microsoft/` 추가
|
|
||||||
- [x] 루트 `tmp_*` 4건 삭제 (git 미추적 확인 후)
|
|
||||||
- [x] 저장소 전체 stale `__pycache__` 삭제 (옛 경로 `Desktop\complete\` 내장분)
|
|
||||||
- [x] `data/logs/edge-profile-static/` 삭제 (612엔트리·27MB)
|
|
||||||
|
|
||||||
## 배치 1 — HIGH ✅
|
|
||||||
- [x] H1 `face_person_detection.py` cv2 호환 가드(경고 1회) + `batch_analyzer` 제출물단위 예외 격리 + **환경 정리**(opencv-python 5.0.0 제거 → 핀 4.13.0.92 복구)
|
|
||||||
- [x] H2 `risk_scoring.py` `match=="search_image"` 명시적 0점 — 왕복 시 0→45 인플레 차단
|
|
||||||
- [x] H3 `store_serialization.py` SEARCH_SKIPPED/ENRICHMENT_FAILURE 를 dedup 시그니처에서 제외
|
|
||||||
- [x] H4 `store_operations.py` rerun 예외 격리 + 'queued' 마커 finally 정리 + `lastRerunDiff.error` 노출
|
|
||||||
- [x] H5 `external_policy.py` 날짜 윈도우 리셋 + Lock (SearchApiPolicy 패턴 미러)
|
|
||||||
- [x] H6 `image_store.py` 매니페스트 행 단위 예외 격리 + width/height 안전 변환
|
|
||||||
- [x] H7 `app.js`/`index.html`/`styles.css` 인증 게이트(401→토큰 입력→헤더 재시도→세션 쿠키)
|
|
||||||
|
|
||||||
## 배치 2 — 큐/스토어 정합성 ✅
|
|
||||||
- [x] `sqlite_store.py` 큐 전역 id 충돌 시 스킵 + 1회성 감사 이벤트(판정 소실·증거 오염 차단)
|
|
||||||
- [x] Vision **실패**만 provider 귀속(`provider: google`) + 레거시 매처 확장. **스킵은 의도적으로 미귀속** — 정책 거부는 '실패'가 아니라 '미실행'
|
|
||||||
- [x] `_search_coverage` 실패-만 제출물을 커버리지에서 제외
|
|
||||||
|
|
||||||
## 배치 3 — enrichment/operations ✅
|
|
||||||
- [x] Vision 정책거부 시 사용량 오계상 + **허위 생체정보 외부전송 감사기록** 제거
|
|
||||||
- [x] 재분석 FACE_PERSON +35 중첩 → 스코어러 단일 버킷
|
|
||||||
- [x] `lastFailure` 를 실제 실패 항목에서 선택 (manual_search / collect 2곳)
|
|
||||||
- [x] 전부 스킵 시 기존 수집 후보 보존(무조건 삭제 제거) + provider 상태는 영속
|
|
||||||
- [x] 수집 후보 카운트 인플레 → id 기준 dedup
|
|
||||||
|
|
||||||
## 배치 4 — HTTP/보안/부팅 ✅
|
|
||||||
- [x] 비-객체/비-UTF8 JSON 바디 → 400 (핸들러 AttributeError 크래시 제거)
|
|
||||||
- [x] 비ASCII Authorization/쿠키 → bytes 비교로 401 (무응답 종료 제거)
|
|
||||||
- [x] `_file()` OSError → 404
|
|
||||||
- [x] `env_file.py` 비인용 값의 인라인 주석 절단(인용 값은 원문 보존)
|
|
||||||
- [x] `__main__.py` stdout 라인 버퍼링 — 리다이렉트 기동 시 보안 경고가 즉시 로그에 남음
|
|
||||||
|
|
||||||
## 배치 5 — analysis ✅
|
|
||||||
- [x] EXIF Orientation 정규화 — 외부 파생물·얼굴크롭·pHash·PIL 폴백 디코드 (파생물 크기를 정규화 이미지 기준으로 재계산). cv2 기본 디코드는 이미 적용됨을 실측 확인
|
|
||||||
- [x] aHash 저엔트로피 근접매칭 차단 (동일 해시는 1.0 유지 — 동일 이미지 매칭 보존)
|
|
||||||
- [x] 프로모터 마커 스캔에서 URL 제외 (title/description 한정)
|
|
||||||
|
|
||||||
## 배치 6 — integrations ✅
|
|
||||||
- [x] Vision 응답 매핑을 예외 경계 안으로 + bestGuessLabels 비-dict 항목 무시
|
|
||||||
- [x] `http_json.py` HTTPError 본문 4KiB 캡
|
|
||||||
- [x] `_probe_connect` 프록시 적용 대상이면 프로브 생략
|
|
||||||
- [x] `search_policy.py` `try_reserve`/`release` 예약형 회계 (쿼터 의미 보존: fetch 실패=환불, 매핑 실패=소비)
|
|
||||||
|
|
||||||
## 배치 7 — domain/admin/governance ✅
|
|
||||||
- [x] LLM_SUMMARY 거버넌스 게이트를 실제 저장 경로에 적용(미근거 요약 폐기)
|
|
||||||
- [x] admin 4개 모듈에 '운영 서버 미연결' docstring 경고
|
|
||||||
- [x] 정상 스킵을 `skipped` 그룹으로 분리 (failures 오분류 제거)
|
|
||||||
- [x] `register_manual_entry` 미지원 필드 조용한 폐기 → ValueError
|
|
||||||
- [x] 반복 반려 시 rejected_image 엔트리 upsert (무한 누적 제거)
|
|
||||||
|
|
||||||
## 배치 8 — 프론트엔드 ✅
|
|
||||||
- [x] 케이스 전환 시 결정 메모/오류/수동쿼리 초기화 (`resetCaseScopedInputs`)
|
|
||||||
- [x] 추천 쿼리 요약 — 실근거만 집계, 스킵/무결과 구분 표기
|
|
||||||
- [x] '주의 후보 보기' → 등록 탭 + watchlist 필터
|
|
||||||
- [x] 케이스 없음 시 `evidence-next-actions` 초기화
|
|
||||||
- [x] hashchange `#corrections` 진입 시 지식 탭 실제 전환
|
|
||||||
- [x] 수동검색 완료 메시지에 대상 케이스 id 명시
|
|
||||||
|
|
||||||
## 배치 9 — 문서 ✅
|
|
||||||
- [x] README `python -m rights_filter.server` PYTHONPATH 안내로 정정
|
|
||||||
- [x] GUI 설계 스펙에 일괄 판정 제약 해제 **개정 이력** 명시
|
|
||||||
|
|
||||||
## 배치 10 — 신규 기능 ✅
|
|
||||||
- [x] **F1 키보드 연속 심사** — J/K 이동, A/H 판정 후 다음 미심사 자동 이동, R 메모 포커스, N 다음 미심사, ? 도움말, 진행 표시. 입력 중에는 비활성
|
|
||||||
- [x] **F2 일괄 판정 + 메모 프리셋** — `POST /api/submissions/decision-batch`(케이스별 트랜잭션·감사·부분실패 보고, 최대 200건, 고위험 확인, 반려 메모 필수) / `GET·POST /api/decision-memo-presets`
|
|
||||||
- [x] **F3 제출물 지문 중복·선례 인덱스** — 시드 시 지문 백필(멱등), exact+pHash≥0.97 클러스터, 큐 배지·그룹 일괄선택, 선례 판정·**판정 불일치** 표시
|
|
||||||
- [x] **F4 판정 내보내기** — `GET /api/decisions/export`(internal / applicant 화이트리스트 분리, CSV+BOM)
|
|
||||||
- [x] **F5 온라인 백업·복구** — `POST /api/admin/backup`(sqlite backup API, WAL 안전) + 보존 로테이션 + `--restore` 오프라인 CLI(복구 전 원본 보존)
|
|
||||||
|
|
||||||
## 의도적 비구현 (사유)
|
|
||||||
- #7 쿠키 Secure — localhost HTTP 에서 인증 붕괴, HTTPS 전제 필요
|
|
||||||
- #8 rate limit — 로컬 단일 운영자 콘솔, 인증으로 대체 커버
|
|
||||||
- #20 `ScoreResult.reasons` tuple — 다수 테스트 `== []` 단언과 충돌
|
|
||||||
- 운영자 역할분리 / 통계 대시보드 — 차점 기능, 다음 사이클
|
|
||||||
- google_search 수동검색 허용 — 2026-06-11 승인된 설계 결정(유지)
|
|
||||||
- aHash → DCT pHash 교체 — 저장된 지문 무효화 + 임계 재보정 필요. 근접매칭 가드로 완화, 별도 마이그레이션 과제
|
|
||||||
- `#12`/M4 락 협소화, M7 스키마 coerce, M9 이관 검증, M8 id 마이그레이션 — 7/11 연기 사유 유효
|
|
||||||
|
|
@ -1,252 +0,0 @@
|
||||||
# 2026-07-24 프로젝트 점검 리포트 (멀티에이전트)
|
|
||||||
|
|
||||||
> 세션: 감사자 8 + 런타임 스모크 1 + 연기항목 현황 1 → 발견별 적대적 검증 2~3인 → 기능 제안 4렌즈 16건 → 심사 3인.
|
|
||||||
> 총 에이전트 109. 기준 HEAD: 2c607e0 (fix/2026-07-11-review-remediation).
|
|
||||||
|
|
||||||
## 0. 전체 요약
|
|
||||||
|
|
||||||
- **pytest**: `tests/rights_filter` **377 passed, 2 failed** (실패 2건 모두 cv2 5.0 환경 드리프트 — H-4 참조).
|
|
||||||
- **런타임 스모크**: 실서버 기동(포트 9557) 후 9개 항목 **전부 통과** — 앱셸/보안헤더 3종/정적자산/무토큰 401/인증 GET/케이스 상세/미디어 서빙/404/path traversal 5변형 거부.
|
|
||||||
- **신규 발견**: CONFIRMED 43건 (HIGH 7 · MEDIUM 15 · LOW 21), REFUTED 2건. 모든 발견은 독립 검증자 2인(상충 시 3인)의 반증 시도를 통과함.
|
|
||||||
- **7/11 체크리스트 현황**: 미해결로 기재된 항목 중 3건은 실제로는 이미 수정됨(naver 멀티페이지 쿼터, #10 Origin/Referer CSRF 폴백, #11 잔여 XSS sinks) — 체크리스트 갱신 필요. 완료 표본 검증 5건 전부 실수정 확인.
|
|
||||||
|
|
||||||
## 1. HIGH 발견 (7건) — 상세
|
|
||||||
|
|
||||||
### H-1. cv2 5.0 환경에서 CascadeClassifier AttributeError가 무방비로 전파되어 시드/리로드/임포트/업로드/재분석 전부가 응답 없이 죽음
|
|
||||||
- 위치: `src/rights_filter/analysis/face_person_detection.py:56` (감사: analysis)
|
|
||||||
- 내용: face_person_detection.py:56의 `cv2.CascadeClassifier(f"{cv2.data.haarcascades}...")`는 어떤 try/except에도 보호되지 않는다(33–37행 try는 import만 커버, 디코드 실패는 그 전에 조기 반환하므로 디코드 가능한 모든 이미지에서 도달). OpenCV 5.0.0(사용자 site-packages가 핀 버전 4.13.0.92를 가림)에는 CascadeClassifier가 없어 AttributeError가 발생하고, 이 예외는 서버 최상위까지 아무도 잡지 않는다. 전파 경로: (1) detect → internal_analyzer.py:70 analyze(무보호) → jobs/batch_analyzer.py:70 run(82행이 PreprocessingError만 catch — AttributeError 통과) → sqlite_store.py:282 batch.run → seed_from_image_store → http_app.py:170(/api/submissions/reload)·181(/import-folder)·188(/upload-image). (2) 이미 시드된 DB에서는 그보다 먼저 sqlite_store.py:245 → store_enrichment.py:149 _refresh_existing_local_face_evidence에서 face 증거 없는 첫 제출물에 대해 폭발. (3) sqlite_store.py:248/327·store_enrichment.py:299 → store_enrichment.py:184 _sync_face_crops(176–182행 try는 image_payload만 보호). (4) http_app.py:168 rerun-enrichment → store_operations.py:232 → store_enrichment.py:288 _rerun_internal_analysis. http_app.py do_POST의 except 체인(236–243행)은 _PayloadTooLarge/sqlite3.OperationalError/KeyError/ValueError만 잡으므로 AttributeError는 핸들러를 탈출하고, http.server 표준 동작상 traceback이 서버 콘솔에 찍히며 HTTP 응답 없이 커넥션이 닫힌다. 운영자 증상: 500 JSON도, 분석 실패 밴드도 아닌 브라우저 수준 'Failed to fetch' — 조회/헬스 라우트는 정상이라 앱이 살아있는 것처럼 보이지만 모든 신규 유입·재분석이 무음으로 전멸한다. 부수 피해: rerun은 크래시 전에 lastAnalysis 갱신 + '재분석 요청이 접수됨'(status=queued) 마커 증거를 이미 영속화(store_operations.py:213–229, 타임스탬프 id)하므로 실패한 재시도마다 queued 마커 행이 GUI 증거 목록에 누적되고, 시드는 batch 이전 단계 쓰기(_sync_provider_payloads 등)가 부분 커밋된 채 중단된다. 신규 제출물은 첫 레코드 분석에서 죽으므로 1건도 저장되지 않는다.
|
|
||||||
- 제안 수정: (a) 환경 정리: C:/Users/USER/AppData/Roaming/Python/Python313/site-packages의 OpenCV 5.0.0 제거(pip uninstall opencv-python) 후 핀 버전 opencv-python-headless==4.13.0.92가 보이게 하거나, 서버 실행을 venv + PYTHONNOUSERSITE=1(또는 python -s)로 격리해 user-site 가림을 차단. (b) 코드 방어: _detect_with_opencv에서 `if not hasattr(cv2, "CascadeClassifier") or not hasattr(cv2, "data"): logger.warning(한 번); return FacePersonSignal(0, 0)` 가드 또는 cascade 루프 전체를 try/except Exception으로 감싸 import 실패 폴백과 동일하게 빈 신호로 강등(무음 미탐 방지 위해 warning 로그 필수). 추가로 batch_analyzer/시드 루프에 제출물 단위 예외 격리(기존 'failed' submission 영속화 패턴 재사용)를 두면 한 이미지의 예기치 못한 예외가 시드 전체를 중단시키지 않는다.
|
|
||||||
|
|
||||||
### H-2. 직렬화 라운드트립이 result_url을 url로 별칭해 Google 커스텀 이미지 검색 결과가 리스코어 시 0점→+45점으로 변하고, 텍스트 검색 결과가 무게이트 +35/+45로 밴드를 인플레이션
|
|
||||||
- 위치: `src/rights_filter/analysis/risk_scoring.py:94` (감사: analysis)
|
|
||||||
- 내용: RiskScorer._web_detection_points는 `item.data.get("url")`이 있으면 무조건 45점을 준다(risk_scoring.py:94–95). Google 커스텀 이미지 검색 결과(match="search_image", google_custom_search.py:235)는 신선한 Evidence에는 "url" 키가 없어 0점으로 설계돼 있으나, 영속화 시 _evidence_payload가 result_url을 "url" 필드로 별칭하고(store_serialization.py:251) _domain_evidence_from_ui가 그것을 data["url"]로 복원하므로(store_serialization.py:326–327), 시드 말미의 _rescore_submission(sqlite_store.py:332) 이후에는 동일 증거가 +45점이 된다 — store_serialization.py:264–268 주석이 명시한 '라운드트립은 원 점수를 재현해야 한다' 불변식 위반. 또한 커스텀 웹 검색 결과는 어댑터가 match="page"를 박아(google_custom_search.py:289) Vision의 '이미지 일치 페이지'와 동일한 +35를 받는다. Naver 쪽은 프로모터가 무게이트 텍스트 검색 결과를 0점(context-only)으로 강등하는 것과 정반대로, Google 텍스트 쿼리 결과는 관련성 검증 없이 결과 존재만으로 +45(url 버킷)+35(page 버킷)=+80 → 자동 쿼리가 아무 결과나 반환하는 모든 제출물이 고위험 밴드(≥70)로 부양된다. 오탐 밴드가 운영자 트리아지 가치를 훼손.
|
|
||||||
- 제안 수정: 채점 쪽에서 search_type/provider를 보고 커스텀 검색 결과(match="search_image"·provider=google_custom_search의 match="page")를 명시적으로 0점 처리(유사도 FINGERPRINT 증거가 점수를 담당)하거나, 최소한 _domain_evidence_from_ui가 result_url을 "url"로 복원하지 않도록 분리(payload에 원 data의 url 존재 여부를 별도 보존). 수정 후 기존 DB는 전체 리스코어 1회 필요.
|
|
||||||
|
|
||||||
### H-3. 쿼터로 스킵된(미실행) 쿼리가 dedup 시그니처에 영구 등재되어 auto search가 해당 쿼리를 영원히 재시도하지 않음
|
|
||||||
- 위치: `src/rights_filter/server/store_enrichment.py:636` (감사: enrichment-ops)
|
|
||||||
- 내용: 네이버/구글 어댑터는 쿼터 소진 시 HTTP 요청 없이 SEARCH_SKIPPED evidence를 반환하는데, 이 evidence에도 query_signature가 포함되고(naver_search.py:176, google_custom_search.py:117) _auto_naver_search/_auto_google_custom_search는 이를 그대로 evidence 테이블에 저장한다(store_enrichment.py:661-664, 485-487). SEARCH_SKIPPED는 UI source 'failure'로 직렬화되고(store_serialization.py:422-423), dedup 집합 함수 _existing_naver_query_signatures(store_serialization.py:481-486)와 _existing_google_custom_query_signatures(489-497)는 source 'failure' 행을 포함한다. 결과적으로 다음 auto search/재분석 실행 시 같은 쿼리는 'if signature in existing_signatures: continue'(store_enrichment.py:636-637, 460-461, 블로그/웹 분기 698-701·751-754 동일)로 영구 스킵된다 — 쿼리가 한 번도 실제 실행된 적이 없는데도. ENRICHMENT_FAILURE(일시적 네트워크 오류) 행도 같은 경로로 영구 재시도 차단된다.
|
|
||||||
- 제안 수정: _existing_naver_query_signatures/_existing_google_custom_query_signatures에서 domainSource가 SEARCH_SKIPPED(및 필요 시 ENRICHMENT_FAILURE)인 행을 제외하거나, 스킵 evidence를 저장할 때 querySignature를 비워 기록한다.
|
|
||||||
|
|
||||||
### H-4. 이미지 결손 submission에서 rerun_enrichment가 부분 상태 오염(lastAnalysis 갱신 + 영구 'queued' 마커) 후 미처리 예외로 실패
|
|
||||||
- 위치: `src/rights_filter/server/store_operations.py:232` (감사: enrichment-ops)
|
|
||||||
- 내용: rerun_enrichment는 lastAnalysis를 먼저 갱신하고(213-214) '재분석 요청이 접수됨' status='queued' 마커 evidence를 저장한(216-229) 뒤에야 _rerun_internal_analysis(232)를 호출하는데, 이 함수는 image_store.image_payload(submission_id)를 보호 없이 호출한다(store_enrichment.py:288-291, _rerun_google_image_search:310도 동일). image_payload는 manifest 레코드가 없으면 KeyError(image_store.py:65), 파일이 삭제되었으면 FileNotFoundError를 던진다. KeyError는 http_app.py:240-241에서 404 'not found'로 응답되어(submission은 존재하는데) 오해를 유발하고, FileNotFoundError는 do_POST에서 포착되지 않아 응답 없이 커넥션이 끊긴다. 어느 쪽이든 마커 evidence는 영구 'queued' 상태로 잔존하고(타임스탬프 id라 재시도마다 1개씩 누적), lastRerunDiff는 기록되지 않는다. 같은 코드베이스의 _sync_face_crops(store_enrichment.py:176-181)는 동일 상황을 'expected for some rows'라며 명시적으로 처리하고 있어 이미지 결손 행의 존재는 실제로 예상되는 조건이다.
|
|
||||||
- 제안 수정: rerun_enrichment에서 image_payload 로드를 사전 검증(try/except 후 마커를 실패 상태로 갱신하고 ValueError로 변환)하거나, 마커 저장 전에 이미지 가용성을 확인한다.
|
|
||||||
|
|
||||||
### H-5. 인증 토큰 설정 시 운영자 콘솔 전체 동작 불능 — 프론트에 인증 수단 부재
|
|
||||||
- 위치: `web/operator-gui/app.js:118` (감사: frontend)
|
|
||||||
- 내용: apiJson()은 Content-Type 헤더만 설정하고 Authorization 헤더를 절대 보내지 않으며, index.html/app.js 어디에도 토큰 입력 UI가 없다(web/operator-gui 전체에서 'Authorization|Bearer|token' grep 0건). 백엔드 http_app.py:280-310은 COPYRIGHTER_AUTH_TOKEN이 설정되면 /api/* 와 /media/* 등 모든 데이터 라우트에 Bearer 헤더 또는 세션 쿠키를 요구하는데, 세션 쿠키는 '헤더 인증에 성공한 요청'의 응답에서만 발급된다(http_app.py:291-296). 즉 브라우저가 쿠키를 얻을 경로가 제품 안에 존재하지 않는다. README는 단일 호스트 개발 외에는 반드시 토큰을 설정하라고 지시하므로, 문서대로 배포하면 /api/bootstrap가 401을 반환해 큐·썸네일·케이스 화면 전부가 'API 연결 실패: unauthorized' 상태로 죽는다. http_app.py:287의 주석('so the operator can enter the token')이 전제하는 토큰 입력 흐름이 프론트에 구현된 적이 없다.
|
|
||||||
- 제안 수정: 최소 구현: GUI 최초 로드 시 401이면 토큰 입력 프롬프트/화면을 띄우고, 입력 토큰으로 Authorization 헤더를 붙여 /api/bootstrap 1회 호출해 세션 쿠키를 발급받은 뒤(이후 요청은 쿠키로 동작) sessionStorage 등에 보관하지 않는 방식으로 처리.
|
|
||||||
|
|
||||||
### H-6. ExternalApiPolicy 일일 한도 윈도우 리셋 부재 — 한도 도달 시 Vision 영구 비활성화
|
|
||||||
- 위치: `src/rights_filter/integrations/external_policy.py:24` (감사: integrations)
|
|
||||||
- 내용: ExternalApiPolicy.calls_made 는 단조 증가만 하고 날짜 경계 리셋이 없다. COPYRIGHTER_GOOGLE_DAILY_LIMIT 에 도달하면 다음날에도 can_call() 이 계속 'external API usage limit reached' 를 반환해 Google Vision Web Detection 이 서버 재기동 전까지 영구 비활성화된다. 이 정책 객체는 서버 기동 시 1회 생성되는 싱글턴이다(env_clients.py:110, server/__main__.py:42, sqlite_store.py:83)이고 store_enrichment.py:315,402 의 모든 Vision 호출이 이 인스턴스를 공유한다. SearchApiPolicy 는 정확히 같은 버그를 _roll_window_locked() 로 이미 수정했고(search_policy.py:21-25 주석: 'once the limit is hit, the provider stays disabled until the process restarts'), ExternalApiPolicy 만 누락됐다. 부수적으로 SearchApiPolicy 와 달리 락도 없어 record_call() 의 calls_made += 1 이 멀티스레드 서버에서 증분 유실될 수 있다.
|
|
||||||
- 제안 수정: SearchApiPolicy 패턴 미러링: window_day 필드 + date.today() 비교 리셋을 can_call/record_call 양쪽에 추가하고 threading.Lock 으로 감싼다.
|
|
||||||
|
|
||||||
### H-7. 큐 전환/폴더 임포트 시 동일 id(파일명 stem) 제출물이 다른 큐의 판정·이력을 덮어쓰고 증거가 교차 오염됨
|
|
||||||
- 위치: `src/rights_filter/server/sqlite_store.py:253` (감사: store-core)
|
|
||||||
- 내용: _seed_from_image_store_locked 는 신규 여부를 '현재 큐의 제출물 id 집합'(sqlite_store.py:253-260, _all(queue_id=queue_id))으로만 판정하지만, submissions 테이블의 PK 는 큐와 무관한 전역 id 이고 _put 은 on conflict(id) do update 업서트(store_persistence.py:67-92)다. 운영자가 /api/submissions/import-folder 로 다른 폴더(큐 B)를 임포트할 때 큐 A 에 이미 존재하는 id(파일명 stem 또는 manifest id)와 같은 제출물이 있으면 '신규'로 분류되어 새 분석 payload 로 기존 행을 통째로 덮어쓴다. 이때 (1) 큐 A 에서 기록한 decisionStatus(승인/반려/보류)·memo·queryHistory 가 unreviewed 초기값으로 소실되고, (2) 행이 삭제가 아니라 갱신이므로 큐 A 이미지에서 나온 기존 evidence 행들이 그대로 남아 같은 id 의 큐 B 제출물(다른 이미지)에 붙어 표시·재채점(_rescore_submission)에 섞여 들어가 잘못된 위험 점수를 만든다. 큐를 다시 A 로 전환하면 같은 메커니즘으로 재분석·재덮어쓰기가 반복된다. 테스트(test_sqlite_store_switches_active_queue_when_importing_from_different_folders)는 서로 다른 id 만 사용해 이 케이스를 커버하지 않는다. 알려진 M8(id=path.stem 유지, image_store stem-충돌 접미어 되돌림)과 인접하지만, M8 은 한 폴더 내 stem 충돌 항목이고 이 건은 seed 의 큐-단위 중복 판정 vs 전역 PK 업서트 불일치라는 별도 경로다.
|
|
||||||
- 제안 수정: seed 에서 records 필터 시 큐 무관 전역 id 존재 여부를 함께 조회해, 다른 큐에 같은 id 가 살아 있으면 (a) 신규 id 로 접미어를 부여해 삽입하거나 (b) 해당 레코드를 건너뛰고 감사 이벤트로 충돌을 기록한다. 최소한 덮어쓰기 전 기존 행의 queue_id 가 다르면 기존 evidence 를 삭제/이관하고 판정 상태를 보존해야 한다.
|
|
||||||
|
|
||||||
## 2. MEDIUM 발견 (15건)
|
|
||||||
|
|
||||||
- **EXIF Orientation을 어디서도 적용하지 않음 — 회전 저장된 스마트폰 사진의 얼굴 미탐 + 외부 파생물이 옆으로 누운 채 태그까지 제거되어 전송** — `src/rights_filter/analysis/preprocessing.py:139`
|
|
||||||
- 파이프라인 전체가 EXIF Orientation을 무시한다: (1) face_person_detection.py:40 cv2.imdecode는 EXIF를 적용하지 않으므로(imread와 달리) Orientation 6/8로 저장된 세로 폰 사진의 얼굴이 90° 누운 픽셀로 Haar frontal/profile 캐스케이드에 들어가 대부분 미탐 → FACE_PERSON 증거(+35점)·얼굴 크롭·face-crop 웹 검색이 전부 누락. (2) _reencode_without_metadata(preprocessing.py:139–153)는 ImageOps.exif_transpose 없이 재인코딩하면서 Orientation 태그를 포함한 EXIF를 제거하므로, 외부 API(Google Vision)에는 회전 보정 불가능한 옆으로 누운 이미지가 전송돼 웹 매칭 품질이 저하된다. (3) pHash도 원시 방향 픽셀로 계산되므로 웹에서 가져온(대개 방향이 구워진) 검색 결과 이미지와의 유사도 비교(_sync_search_result_image_similarity)가 회전 불일치로 미탐된다. 스마트폰 원본 사진은 Orientation 태그 저장이 일반적이라 발생 빈도가 높
|
|
||||||
- 수정: 디코드 직후 한 곳에서 방향 정규화: _decode 경로(face_person_detection)·_reencode_without_metadata·_perceptual_hash에서 PIL 열기 시 ImageOps.exif_transpose(image) 적용(재인코딩은 이미 태그를 제거하므로 transpose 후 저장이면 충분). cv2 경로는 PIL 폴백을 우선하거나 EXIF Orientation을 읽어 cv2.rotate로 보정.
|
|
||||||
- **'phash'가 실제로는 8x8 평균 해시(aHash)라 균일/저디테일 이미지끼리 유사도 1.0으로 충돌 — 지식베이스·검색결과 유사도 +80 고위험 오탐** — `src/rights_filter/analysis/fingerprints.py:47`
|
|
||||||
- _perceptual_hash(fingerprints.py:34–51)는 DCT 기반 pHash가 아니라 8x8 평균 해시다. 픽셀이 평균과 같으면 비트 1이 되므로(50행 `pixel >= average`) 완전 균일 이미지는 색과 무관하게 전부 0xffffffffffffffff로 해시된다 — 검은 단색과 흰 단색이 유사도 1.0. 저디테일 이미지(흰 배경 제품컷, 플레이스홀더/빈 썸네일, 단순 로고)끼리도 상위 비트 대부분이 일치해 0.9 임계(internal_analyzer.py:19 threshold, risk_scoring.py:20 `similarity >= 0.9` → +80)를 쉽게 넘는다. 결과: (1) 지식베이스에 배경이 밋밋한 샘플이 있으면 무관한 플랫 이미지 제출물이 'Knowledge base image similarity 1.00' +80 → 즉시 고위험 밴드 오탐. (2) 검색 결과 이미지 유사도(store_search_candidates.py:386, provider_runtime 임계 기반)도 빈/플레이스홀더 썸네일과 플랫 제출물 간 허위 매치 생성.
|
|
||||||
- 수정: 최소 방어로 해시 엔트로피 가드 추가(비트 popcount가 0/64에 가까우면 — 예: ≤4 또는 ≥60 — 유사도 비교에서 제외하거나 0 반환). 근본적으로는 DCT pHash(32x32→8x8 DCT 저주파) 또는 dHash로 교체하고 명칭 'phash' 접두어를 실제 알고리즘과 일치시켜 오해를 제거. 임계 0.9는 알고리즘 교체 후 재보정.
|
|
||||||
- **재분석 시 검출 결과가 달라지면 구 FACE_PERSON/유사도 증거가 남은 채 신규 행이 추가되고 스코어러에 FACE_PERSON 중복 제거가 없어 +35가 중첩** — `src/rights_filter/server/store_enrichment.py:292`
|
|
||||||
- _rerun_internal_analysis(store_enrichment.py:288–295)는 analyze() 결과를 _put만 하고 이전 internal 증거를 무효화하지 않는다. 증거 id는 (source, reason, data) 해시(store_serialization.py:410–412)이므로 face_count가 바뀌면(데이터 dict 변경) 새 행이 생기고 구 행은 active로 남는다. RiskScorer의 FACE_PERSON 분기(risk_scoring.py:26–28)는 WEB_DETECTION/NAVER처럼 버킷 dedup이 없어 행마다 +35를 더한다 — 얼굴만 있는 무해한 사진이 재분석 1회로 35(중간)→70(고위험 경계)이 된다. reason은 동일 문자열이라 _unique로 합쳐져 운영자에게는 사유 1줄에 점수만 뛴 것으로 보인다. 지식베이스 유사도 증거도 reason에 유사도 2자리가 박혀('similarity 0.93' vs '0.91') 같은 방식으로 중복 +80이 쌓인다(상한 100이라 밴드 영향은 제한적). 트리거: 운영자가 동일 파일명으로 이미지를 교체 후 재분석(id=path.stem 유지 — M8), 또는 O
|
|
||||||
- 수정: _rerun_internal_analysis에서 신규 증거 _put 전에 해당 제출물의 source in {fingerprint(knowledge/face 관련), face} 기존 internal 증거를 superseded 상태로 전환하거나 삭제(LLM 요약의 _delete 패턴 재사용). 또는 RiskScorer FACE_PERSON에 단일 버킷을 도입해 행 수와 무관하게 1회만 +35.
|
|
||||||
- **LLM_SUMMARY 저장 게이트가 프로덕션에서 호출되지 않고, 실제 검증은 항상 참이 되도록 구현되어 정책이 무력화됨** — `src/rights_filter/governance/policies.py:110`
|
|
||||||
- assert_operator_evidence_payload_allowed()의 LLM_SUMMARY 분기(source_urls/source_evidence_ids 없는 요약 저장을 ValueError로 금지, test_policies.py:47이 이를 고정)는 src 전역에서 DataClass.LLM_SUMMARY로 호출되는 곳이 한 곳도 없다(호출은 전부 SEARCH_EVIDENCE: naver_search.py:330,386,439 / google_custom_search.py:238,292). 실제 저장 경로는 두 갈래 모두 게이트를 우회한다: (a) analysis/llm_assistance.py:81-93은 근거 없는 요약을 verified=False Evidence로 만들어 반환하고, server/store_operations.py:406-411(_ensure_llm_summary)은 게이트 호출 없이 그대로 evidence 테이블에 저장한다. (b) 프로덕션에서는 _domain_evidence_from_ui(store_serialization.py:319)가 모든 source evidence에 evidence_id(sqlite 저장 id, _ev
|
|
||||||
- 수정: _ensure_llm_summary 저장 직전에 assert_operator_evidence_payload_allowed(DataClass.LLM_SUMMARY, llm_evidence.data) 호출을 추가하고, verified 판정은 evidence_id 존재가 아니라 URL 근거(source_urls) 유무 기준으로 좁히거나 정책 함수 쪽 주석/테스트를 실제 완화된 규칙(verified 플래그 저장 허용)으로 갱신해 정의-런타임 불일치를 해소.
|
|
||||||
- **admin 결정·정정·프레젠터 계층 전체가 운영 서버에 미연결된 병렬 구현으로 남아 실서버 동작과 의미가 갈라짐** — `src/rights_filter/admin/review_handlers.py:42`
|
|
||||||
- record_operator_decision, correct_rejected_decision, detailed_review_for, register_manual_entry, operator_summary_for의 호출자는 tests/ 뿐이며(프로젝트 전역 grep 확인) 운영 서버는 server/store_operations.py에 별도 구현을 갖는다. 이미 의미가 갈라져 있다: (a) admin 계층은 REJECTED에서만 지식 엔트리를 만들고 HELD는 아무것도 만들지 않지만, 실서버는 held/rejected 모두 watchlist 엔트리를 upsert한다(store_operations.py:170-176). (b) admin 계층은 결정·정정 시 감사 이벤트를 전혀 남기지 않지만 실서버는 add_audit_event를 기록한다. (c) admin 계층은 반려마다 새 id의 엔트리를 무한 생성하지만(아래 별도 발견) 실서버는 _stable_id로 1개를 upsert한다(store_operations.py:151). 트리거: 2026-05-25 plan 문서가 이 계층을 '타깃 admin 앱이 그대로 렌더/이식할 계약(contract)'으로 규정하고 있어
|
|
||||||
- 수정: admin 계층을 (1) 삭제하고 테스트를 실서버 경로로 이관하거나, (2) 유지한다면 각 모듈 docstring에 '운영 서버 미사용 — store_operations.py가 사실 소스' 경고를 명시하고 HELD/감사 의미를 실서버와 일치시킬 것.
|
|
||||||
- **Google Vision 호출이 정책 거부(EXTERNAL_SKIPPED)로 실행되지 않아도 사용량 +1 계상되고, 생체 이미지가 '외부 전송됨'이라는 허위 감사 이벤트가 기록됨** — `src/rights_filter/server/store_enrichment.py:398`
|
|
||||||
- CloudVisionWebDetectionAdapter.detect는 policy.can_call()이 거부하면 HTTP 요청 없이 EXTERNAL_SKIPPED evidence만 반환한다(cloud_vision_web_detection.py:108-117). 그런데 _rerun_google_image_search는 무조건 call_count = 1로 시작하고(317), _google_face_crop_web_detection은 detect 결과를 보지 않고 크롭마다 call_count += 1 한다(398). 두 카운트 모두 _apply_provider_usage_delta('google', ...)로 usage에 가산된다(351). 반면 _auto_naver_search/_auto_google_custom_search는 'any(item.source != SEARCH_SKIPPED)'로 실제 요청 여부를 확인한 뒤에만 계상한다(469, 645). 더 나쁜 것은 face crop 경로에서 call_count>0이면 'Personal image sent to external provider' 감사 이벤트가 기록되는데(415-425), 정책이 거부해 크롭이 실
|
|
||||||
- 수정: naver/google_custom 경로와 동일하게 `any(item.source != EvidenceSource.EXTERNAL_SKIPPED for item in ...)`일 때만 call_count를 증가시키고, 감사 이벤트도 실제 전송된 크롭 수 기준으로 기록한다.
|
|
||||||
- **manual_search·collect_keyword_candidates의 lastFailure에 실패 항목이 아닌 domain_evidence[0](대개 성공 결과)의 reason이 기록됨** — `src/rights_filter/server/store_operations.py:359`
|
|
||||||
- manual_search(359)와 collect_keyword_candidates(660)는 evidence 중 하나라도 SEARCH_SKIPPED/ENRICHMENT_FAILURE면 `provider_payload['lastFailure'] = domain_evidence[0].reason`을 기록한다. 그러나 domain_evidence[0]은 캐스케이드의 첫 항목(주 검색의 첫 성공 결과)이지 실패 항목이 아니다. _auto_naver_search(823-830)와 _auto_google_custom_search(581-588)는 `next(item.reason for item in ... if item.source in 실패집합)`으로 올바르게 실패 항목의 reason을 찾는 것과 대비된다.
|
|
||||||
- 수정: auto 경로와 동일하게 next()로 실패 소스 항목의 reason을 선택한다.
|
|
||||||
- **collect_keyword_candidates: 검색이 전부 쿼터 스킵/실패해 수집 결과가 0건이어도 기존 collection_candidates 전량 무조건 삭제** — `src/rights_filter/server/store_operations.py:667`
|
|
||||||
- 수집 트랜잭션은 `delete from collection_candidates`를 무조건 실행한 뒤 pending을 재삽입한다(666-669). 검색 캐스케이드가 전부 SEARCH_SKIPPED(쿼터 소진)나 실패로 끝나면 pending이 비어 있으므로, 운영자가 이전에 수집해 검토 중이던 후보 목록(promoted 이력 행 포함)이 대체 결과 없이 전량 삭제된다. '이전 쿼리의 후보를 새 수집으로 교체'하는 설계 의도(596-599 주석)는 새 결과가 있을 때만 성립하는데, 실패 경로에서도 동일하게 청소가 실행된다. 특히 쿼터 소진 시점에는 재수집으로 복구할 수도 없다.
|
|
||||||
- 수정: pending이 비어 있고 provider_call_count가 0(또는 전부 스킵)이면 삭제 없이 오류/빈 결과만 반환하거나, 최소한 스킵으로 인한 0건일 때는 기존 행을 보존한다.
|
|
||||||
- **케이스 전환 시 결정 메모·메모 오류 미초기화 — 타 케이스 메모가 반려/보정 사유로 기록됨** — `web/operator-gui/app.js:2343`
|
|
||||||
- selectCase()는 suggestedQueryRunSummary만 리셋하고 #decision-memo textarea와 #memo-error를 초기화하지 않는다. setDecision()은 전송 성공 후에만 memo를 비우므로(app.js:2408), 운영자가 케이스 A의 반려 사유를 적다가 제출 전에 큐에서 케이스 B를 클릭하면 A용 메모가 그대로 남아 있고, B에서 '반려'를 누르면 requiresMemo 검증을 A의 메모가 통과시켜 케이스 B의 결정 레코드·감사 로그에 A의 사유가 영구 기록된다.
|
|
||||||
- 수정: selectCase()에서 document.getElementById('decision-memo').value = '' 와 memo-error 초기화를 추가.
|
|
||||||
- **추천 쿼리 실행 요약이 스킵/실패/결과없음 행을 '근거 N건 완료'로 오보고 (skipped 분기 dead code)** — `web/operator-gui/app.js:1048`
|
|
||||||
- executeSuggestedQueries()는 응답 queryHistory[0].count를 근거 수로 보고하는데, 백엔드 count는 len(domain_evidence)로 SEARCH_SKIPPED(쿼터 차단)·ENRICHMENT_FAILURE·'returned no results' 마커 Evidence 행을 모두 포함한다(store_operations.py:344-353). naver 어댑터는 어떤 경우에도 최소 1개의 마커 행을 반환하므로(naver_search.py:166-179 스킵, 292-306 결과없음) resultCount는 항상 ≥1이고, resultCount===0일 때만 실행되는 skipped 감지 분기(app.js:1049-1052)는 사실상 도달 불가다. 결과: 네이버 쿼터가 소진된 상태에서 '모두 실행'을 누르면 이미지→블로그→웹문서 3연쇄 스킵으로 count=3이 되어 요약에 '"쿼리" 완료(근거 3건)'이 표시되고, 결과가 실제로 없어도 '완료(근거 1건)'으로 표시된다. 판정 보조 도구가 근거 확보 성공을 허위로 보고하는 셈이다. 쿼리 이력 행의 'N건' 표시(app.js:1857)도 같은 의미 왜곡을 공유한다.
|
|
||||||
- 수정: review.evidence에서 해당 query와 일치하는 항목 중 source!=='failure'이고 'returned no results'가 아닌 실제 근거만 세어 보고하거나, 백엔드 count를 실근거 수로 바꾸고 skip/failure 여부를 별도 필드로 반환.
|
|
||||||
- **'주의 후보 보기' 버튼이 수동 등록 탭으로 이동 — 라벨과 동작 불일치** — `web/operator-gui/app.js:3279`
|
|
||||||
- 워크벤치 보조 작업의 #add-from-case 버튼 라벨은 '주의 후보 보기'(index.html:217)지만 핸들러 addKnowledgeEntryFromCase()는 knowledge 뷰의 'manual'(수동 기준 등록) 탭으로 이동한다. watchlist(주의 후보) 항목은 '등록된 기준' 탭에서 상태 필터로 보는 구조이므로, 운영자는 주의 후보 목록 대신 빈 수동 등록 폼을 보게 된다. 함수명이 원래 용도(케이스에서 기준 등록)를 드러내는 것으로 보아 라벨 개편 시 핸들러 목적지가 갱신되지 않은 것이다.
|
|
||||||
- 수정: switchKnowledgeTab('registered') + knowledgeFilters.status='watchlist' 설정 후 renderKnowledgeBase() 호출(및 #knowledge-status-filter 값 동기화)로 변경하거나, 버튼 라벨을 실제 동작에 맞게 되돌린다.
|
|
||||||
- **JSON 바디가 객체가 아니면(리스트/문자열/null) 핸들러가 AttributeError로 크래시 — 응답 없이 연결 종료** — `src/rights_filter/server/http_app.py:341`
|
|
||||||
- _body()는 json.loads() 결과를 dict 검증 없이 그대로 반환한다(-> dict[str, object] 타입 표기만 있고 실제 검사 없음). 유효한 JSON이지만 객체가 아닌 바디(`[]`, `"x"`, `123`, `null`)를 보내면 do_POST/do_PATCH의 body.get(...) 또는 store.register_manual_knowledge_entry(body) 등에서 AttributeError가 발생하는데, 핸들러는 _PayloadTooLarge/OperationalError/KeyError/ValueError만 잡으므로 예외가 socketserver까지 전파되어 클라이언트는 HTTP 응답 없이 연결이 끊기고 서버 콘솔에는 트레이스백이 찍힌다. 잘못된 JSON 문법은 400으로 처리되는 것(test_malformed_json_body_returns_400)과 비대칭인 경계 검증 구멍.
|
|
||||||
- 수정: _body()에서 `parsed = json.loads(...)` 후 `if not isinstance(parsed, dict): raise ValueError("JSON object body required")` 추가 — 기존 400 경로로 수렴.
|
|
||||||
- **submissions.json 매니페스트의 잘못된 행 1개(루트 밖 경로)가 큐 전체의 불러오기/재분석을 ValueError로 마비시킴** — `src/rights_filter/server/image_store.py:38`
|
|
||||||
- submission_records()의 리스트 컴프리헨션은 각 매니페스트 레코드에 대해 _safe_path(record["file"])를 호출하는데, 파일이 없는 레코드는 .exists() 필터로 조용히 건너뛰면서 루트 밖을 가리키는 레코드는 _safe_path가 ValueError("manifest path points outside image store")를 던져 전체 호출이 실패한다. submissions.json은 운영자가 손으로 편집하는 문서화된 워크플로(docs/operations/copyrighter-operation-worklist.md:129 'submissions.json에 ID, 제목… 직접 등록')이므로, 절대 경로(예: 원본 위치 C:\Users\...\a.jpg)를 붙여넣는 실수 하나로 '새 제출 불러오기'(/api/submissions/reload·upload-image·import-folder 경유 seed_from_image_store)와 rerun-enrichment(image_payload)가 모두 400으로 실패한다. 에러 메시지에 문제 레코드가 명시되지 않아 어느 행이 원인인지도 알 수 없다 — 결함 행만 스킵하는 기존
|
|
||||||
- 수정: 컴프리헨션 대신 루프에서 레코드별 try/except ValueError로 결함 행을 스킵(로그로 해당 record["file"] 명시)하고, width/height도 안전 변환(int 실패 시 1) 적용.
|
|
||||||
- **README 대체 실행 커맨드 `python -m rights_filter.server` 가 그대로는 실행 불가 (ModuleNotFoundError)** — `C:/Users/USER/Desktop/munsang/copyrighter/README.md:38`
|
|
||||||
- README 실행 섹션이 `python run_copyrighter_server.py` 의 대안으로 `python -m rights_filter.server --host 127.0.0.1 --port 9500` 을 안내하지만, 프로젝트는 src/ 레이아웃이고 pyproject.toml/setup.py/setup.cfg 가 없으며 rights_filter 는 어떤 site-packages 에도 설치되어 있지 않다. 따라서 repo 루트에서 이 커맨드를 그대로 실행하면 즉시 ModuleNotFoundError 로 실패한다. PYTHONPATH=src 가 필요하다는 언급이 문서 어디에도 없어, 문서만 보고 따라 하는 운영자/납품 검수자가 실행 실패를 겪는다. 나머지 문서 항목은 정확함: CLI 옵션 6종(--host/--port/--db/--images/--static/--env)은 src/rights_filter/server/__main__.py:28-33 과 일치, .env.example 43개 변수는 src 코드 참조와 양방향 완전 일치(코드에만 있거나 예시에만 있는 변수 0건), Pretendard 폰트 번들(web/operator-gui/assets/fo
|
|
||||||
- 수정: README.md:38 을 `PYTHONPATH=src python -m rights_filter.server ...` (Windows: `set PYTHONPATH=src`) 로 수정하거나, 해당 대안 커맨드 줄을 제거하고 `python run_copyrighter_server.py` 에 동일 옵션을 전달하는 예시로 통일.
|
|
||||||
- **Vision 응답 매핑이 어댑터 try 경계 밖 — 비정형 bestGuessLabels 시 쿼터 차감 후 요청 전체 중단** — `src/rights_filter/integrations/cloud_vision_web_detection.py:134`
|
|
||||||
- CloudVisionWebDetectionAdapter.detect 는 client.detect_web 만 try 로 감싸고, policy.record_call() 후의 _map_response() 는 try 밖에서 실행한다. detect_web 이 정규화하는 필드들과 달리 best_guess_labels 는 API 응답의 bestGuessLabels 를 원본 그대로 통과시키므로(97행), 리스트에 dict 가 아닌 항목이 섞이면 _map_response 211행 `label.get("label", "")` 에서 AttributeError 가 발생해 어댑터 밖으로 전파된다. 호출부 _rerun_google_image_search(store_enrichment.py:312)에도 국소 try 가 없어 enrichment 요청 전체가 중단되며, 쿼터는 이미 record_call 로 차감된 상태다. google_custom_search.py:125-126 은 정확히 이 실패 모드를 막으려고 매핑을 try 안으로 옮겼다는 주석까지 있는데(‘Map inside the try so a malformed payload becomes ENRICHMENT_FAILURE ins
|
|
||||||
- 수정: google_custom_search 와 동일하게 record_call 후 매핑을 try 안으로 이동하거나, detect_web 에서 bestGuessLabels 도 dict 항목만 필터링해 정규화한다.
|
|
||||||
- **Google Vision 실패/스킵 증거가 provider 에 귀속되지 않아 providerState 가 '미실행'으로 오표시됨** — `src/rights_filter/server/store_serialization.py:281`
|
|
||||||
- CloudVisionWebDetectionAdapter 는 FAILURE/EXTERNAL_SKIPPED 증거의 data 에 submission_id 만 넣는다(cloud_vision_web_detection.py:110-129). _evidence_payload 의 domain 은 data.get("provider", data.get("domain", "internal")) 폴백이라 이 증거들의 UI payload 가 source="failure", domain="internal" 로 저장된다. _evidence_matches_provider(store_serialization.py:183-194)의 google 분기는 source == "google" 을 요구하므로 Vision 호출 실패 증거는 어느 provider 에도 매칭되지 않는다(네이버는 domain, google_search 는 domain=google_custom_search 로 매칭돼 실패도 귀속되는 것과 비대칭). 결과: (1) _external_provider_state_for_submission 이 매칭 증거 0건으로 'not_run' 을 반환해 Vision API 가 실제로 실패한 제출
|
|
||||||
- 수정: cloud_vision_web_detection.py 의 FAILURE/EXTERNAL_SKIPPED 증거 data 에 "provider": "google" 을 포함시키거나, _evidence_matches_provider 의 google 분기에서 source == "failure" and domain == "google" 도 매칭하도록 확장(기존 저장 행 호환을 위해 후자 병행 권장).
|
|
||||||
|
|
||||||
## 3. LOW 발견 (21건)
|
|
||||||
|
|
||||||
- **프로모터가 result_url을 마커 스캔에 포함해 URL 경로/호스트 토큰(game, cover, album 등)만으로 무관한 결과를 0.8 승격 → +40점** — `src/rights_filter/analysis/search_result_promoter.py:32`: _promote_item의 마커 스캔 텍스트에 result_url이 포함된다(search_result_promoter.py:32–35). ASCII 마커는 \b 경계 매칭인데 URL에서는 '.'과 '/'가 단어 경계라 'game.naver.com', '/cover/', '/album/', '/character/' 같은 흔한 경로·호스트 토큰이 마커 'game'/'cover'/'album'/'character'에 걸린다. 본문·제목에 권리 신호가 전혀 없는 결과도 URL 구조만으로 promoted=true, confidence 0.8이 되어 _naver_search_points에서 round(50*0.8)=+40점과 'n
|
|
||||||
- **record_operator_decision 반복 반려 시 동일 제출물의 rejected_image 지식 엔트리가 무한 누적** — `src/rights_filter/admin/review_handlers.py:56`: 동일 submission_id에 REJECTED 결정을 재기록할 때마다(재검토, 정정 후 재반려 등) create_rejected_image_entry(domain/records.py:242-253)가 매번 새 id('knowledge-N')의 엔트리를 무조건 생성한다. 기존 활성 rejected_image 엔트리 존재 여부를 확인하지 않아 동일 지문의 활성 엔트리가 중복 축적되고, internal_analyzer(analysis/internal_analyzer.py:42-68)는 엔트리별로 별도 FINGERPRINT evidence를 만들어 동일 매치가 N중으로 표시된다(RiskScorer의 FINGERPRINT 경로
|
|
||||||
- **detailed_review_presenter가 정책상 정상인 스킵(EXTERNAL_SKIPPED/SEARCH_SKIPPED)을 'failures' 증거 그룹으로 오분류** — `src/rights_filter/admin/detailed_review_presenter.py:64`: _group_name()이 명시 열거되지 않은 모든 소스를 'failures'로 귀속시켜, 쿼터 도달·제공자 비활성 같은 의도된 운영 스킵(EXTERNAL_SKIPPED, SEARCH_SKIPPED)이 실패 증거 그룹에 표시된다. 같은 항목이 _provider_status()에도 다시 나타나 이중 표기된다. 실서버 스코어러는 동일 소스를 '위험 신호 아님·운영 상태'로 명시 구분하는 것(analysis/risk_scoring.py:70-72 주석 및 0점 처리)과 상반된 표시 의미론이다. 트리거: 이 프레젠터 출력을 렌더링하는 소비자(계약상 타깃 admin UI)는 '외부 API 비활성'을 분석 실패로 오인해 불필요한
|
|
||||||
- **register_manual_entry가 **payload 추가 인자를 검증 후 조용히 폐기** — `src/rights_filter/admin/knowledge_base_handlers.py:23`: **payload로 받은 추가 키는 assert_no_biometric_template 검사에만 쓰이고 엔트리 생성(create_manual_knowledge_entry)에 전달되지 않아 무예외로 소실된다. 트리거: 호출자가 명명 인자 오타(예: policy_note=... 또는 alias=...)를 내면 TypeError 대신 payload로 흡수돼 해당 데이터가 등록 엔트리에서 조용히 누락 — 운영자는 메모/별칭이 저장됐다고 오인한다. 현재 테스트 전용 경로라 LOW.
|
|
||||||
- **수집 후보 카운트 인플레이션: 동일 candidate_id가 pending에 중복 적재되어 collected 수가 실제 저장 행 수보다 크게 보고됨** — `src/rights_filter/server/store_operations.py:615`: candidate_id는 _stable_id('cand', provider, type, query, image_url, thumbnail_url, result_url)로 생성되며 rank/title이 포함되지 않는다(store_search_candidates.py:615). 같은 이미지 URL이 검색 결과 여러 순위에 등장하면(검색 결과에서 흔함) 동일 id의 후보가 pending에 2회 이상 적재되어 collected가 그만큼 증가하지만, 트랜잭션의 _put은 upsert라 실제로는 1행만 저장된다. GUI에 반환되는 payload['collected']와 감사 이벤트의 'N candidates'가 실제 후보 목록 행
|
|
||||||
- **케이스 없음 렌더 시 '근거 보강 추천' 패널 잔존 (evidence-next-actions 미초기화)** — `web/operator-gui/app.js:1309`: renderNoSelectedCase()는 file-facts·similar-strip·evidence-groups 등은 비우지만 #evidence-next-actions는 초기화하지 않는다. 제출 목록이 비게 되면(빈 폴더로 import-folder 전환, 또는 초기 API 실패 후 clearRuntimeData) '표시할 케이스가 없습니다' 옆에 이전 케이스의 추천 쿼리 패널('바로 실행'/'모두 실행' 버튼 포함)이 그대로 남는다. 버튼을 눌러도 getSelectedCase()가 undefined라 아무 일도 일어나지 않아 죽은 컨트롤이 노출된다.
|
|
||||||
- **hashchange 경유 #corrections 진입 시 지식 탭 패널이 전환되지 않음** — `web/operator-gui/app.js:4013`: hashchange 리스너는 switchView()만 호출한다. switchView('corrections')는 state.knowledgeTab='corrections'로 상태만 바꾸고 switchKnowledgeTab()을 호출하지 않아(app.js:2229-2235), 페이지 리로드 없이 URL 해시를 #corrections로 바꾸면 knowledge 뷰는 열리지만 탭 패널은 기존 탭(기본 '후보 수집')에 머문다. 이후 상태 변수(knowledgeTab)와 실제 표시 패널이 어긋난다. 최초 로드 시에는 init()이 마지막에 switchKnowledgeTab을 호출해 정상이다.
|
|
||||||
- **수동 검색 완료 메시지가 케이스 전환 후 도착하면 다른 케이스 문맥으로 표시** — `web/operator-gui/app.js:2492`: runManualSearch()는 시작 시점의 케이스에 결과를 귀속시키지만(정상), 응답 도착 시 '쿼리 결과가 현재 케이스 증거에 추가되었습니다'를 #manual-query-status(케이스별로 리렌더되지 않는 고정 엘리먼트)에 쓴다. 검색은 외부 API+LLM 요약으로 수 초 걸리므로 그 사이 운영자가 케이스 B로 전환하면, B의 검색 이력 탭을 보는 중에 A에 추가된 결과를 '현재 케이스'에 추가했다는 메시지가 표시되고 B용으로 입력하던 쿼리 입력값도 지워진다(queryInput.value=''). selectCase()가 manual-query/manual-query-status를 리셋하지 않는 것도 같은 잔존
|
|
||||||
- **비ASCII Authorization 헤더가 hmac.compare_digest TypeError를 유발 — 401 대신 무응답 연결 종료 (인증 전 원격 트리거)** — `src/rights_filter/server/http_app.py:291`: COPYRIGHTER_AUTH_TOKEN이 설정된 상태에서 보호 라우트로 비ASCII 바이트가 포함된 Authorization 헤더를 보내면(http.server는 헤더를 latin-1로 디코드하므로 0x80-0xFF 바이트가 비ASCII str이 됨) hmac.compare_digest(str, str)가 TypeError('comparing strings with non-ASCII characters is not supported')를 던진다. 이 예외는 _require_auth 어디에서도 잡히지 않아 클라이언트는 401 대신 빈 응답(연결 리셋)을 받고 서버 콘솔에 트레이스백이 반복 출력된다. 인증 없이 원격에서
|
|
||||||
- **미디어/정적 파일 서빙 중 OSError(파일 잠금·삭제 레이스·권한) 미처리 — 404/503 대신 연결 종료** — `src/rights_filter/server/http_app.py:377`: _file()은 path.exists() 확인 후 path.read_bytes()를 호출하는데, 그 사이 파일이 삭제되거나(face-crop 보존기간 purge, 운영자 폴더 정리) Windows에서 다른 프로세스가 파일을 배타 잠금한 경우(대용량 이미지를 data/submissions로 복사하는 중 GUI가 /media 폴링, 백신 스캔 등) PermissionError/OSError가 발생한다. do_GET은 ValueError/KeyError/OperationalError만 잡으므로 예외가 전파되어 응답 없이 연결이 끊기고 트레이스백이 출력된다. 운영 콘솔에서는 이미지 타일이 깨진 채 원인 없는 네트워크 오류로 보
|
|
||||||
- **.env 값 뒤 인라인 주석(# ...)이 값에 그대로 포함됨 — 토큰/키가 조용히 오염** — `src/rights_filter/server/env_file.py:23`: load_env_file은 `KEY=value # comment` 형식에서 `# comment`를 값의 일부로 저장한다(일반 dotenv 구현은 비인용 값 뒤 인라인 주석을 제거). 운영자가 `COPYRIGHTER_AUTH_TOKEN=abc123 # 운영용` 처럼 주석을 붙이면 토큰이 'abc123 # 운영용'이 되어 GUI 토큰 입력이 이유 없이 401로 실패하고, API 키면 외부 검색이 인증 오류로 실패한다 — 경고·에러 없이 조용히 어긋나므로 원인 추적이 어렵다. 인용값+주석(`"abc" # x`)이면 첫/끝 문자가 달라 따옴표 제거도 무산되어 따옴표까지 값에 남는다. .env.example에는 인라인 주석이 없
|
|
||||||
- **루트 미추적 Microsoft/Windows/PowerShell/ModuleAnalysisCache — PowerShell 캐시 잔재, ignore 미커버로 커밋 오염 위험** — `C:/Users/USER/Desktop/munsang/copyrighter/Microsoft/Windows/PowerShell/ModuleAnalysisCache:?`: 정체: PowerShell 모듈 분석 캐시(6,072바이트 바이너리, 2026-07-11 08:19 생성, 디렉터리 내 유일 파일). 정상 위치는 %LOCALAPPDATA%\Microsoft\Windows\PowerShell\ModuleAnalysisCache 인데, PSModuleAnalysisCachePath 가 상대경로로 잡혔거나 LOCALAPPDATA 가 비어 있는 환경에서 powershell 이 당시 cwd(repo 루트) 기준으로 캐시를 쓴 잔재다(생성일이 7/11 커밋 작업일과 일치 — 자동화 도구가 환경변수 없이 powershell 을 띄운 흔적). 문제: git status 에 ?? Microsoft/
|
|
||||||
- **__pycache__ stale: 테스트 pyc 19개가 옛 경로 Desktop\complete\copyrighter 를 내장, mtime 보존으로 pytest 가 계속 재사용** — `C:/Users/USER/Desktop/munsang/copyrighter/tests/rights_filter/admin/__pycache__/test_correction_handlers.cpython-313-pytest-8.4.2.pyc:?`: tests/ 하위 19개 pytest assertion-rewrite 캐시(.cpython-313-pytest-8.4.2.pyc)가 co_filename 으로 이전 프로젝트 경로 `C:\Users\USER\Desktop\complete\copyrighter\...` 를 내장하고 있다(src/ 쪽 pyc 는 0건 — 이후 재생성됨). 소스 .py 와 pyc 의 mtime 이 동일(예: 둘 다 5/25 09:43)해 프로젝트 이동 시 mtime 이 보존되었고, pytest 캐시 유효성 검사(소스 mtime+size)가 통과하므로 캐시가 계속 재사용된다. 트리거 시나리오: 해당 19개 파일(test_naver_search,
|
|
||||||
- **루트 tmp_* 잔재 4건 — git 미추적(ignore 정상), 로컬 정리만 필요** — `C:/Users/USER/Desktop/munsang/copyrighter/tmp_fail3.sqlite3:?`: tmp_dbg_img/(images/ + submissions.json, 6/10), tmp_fail3.sqlite3(106KB, 5/27), tmp_server_err.log·tmp_server_out.log(둘 다 0바이트, 5/28) 네 건 모두 git 추적되지 않고 .gitignore 가 정확히 커버한다(tmp_* 및 tmp_dbg_img/ 패턴, *.sqlite3·*.log 로도 이중 커버). 저장소 오염 없음 — 순수 로컬 디스크 클러터로 삭제해도 무방하다. 유일한 주의점: tmp_fail3.sqlite3 는 5/27 실패 재현용 DB 스냅샷으로 보이므로 아직 참조하는 디버깅 절차가 없는지 확인 후 삭제.
|
|
||||||
- **data/logs/edge-profile-static/ 612엔트리·27MB 브라우저 프로필 — git 미추적 확인(data/ 전체 ignore), 디스크 정리 대상** — `C:/Users/USER/Desktop/munsang/copyrighter/data/logs/edge-profile-static:?`: data/logs/edge-profile-static/ 는 Edge 브라우저 프로필 전체(Default/, extensions_crx_cache/, Local State, Variations 등 총 612 엔트리, 27MB)로 2026-05-27 00:33 브라우저 자동화(E2E/스크린샷) 실행이 남긴 잔재다. 우려했던 git 추적 오염은 없음: .gitignore:23 의 `data/` 가 디렉터리 전체를 커버하고 `git ls-files data/` 는 0건이다. 따라서 저장소 위생 문제가 아니라 27MB 로컬 디스크 점유 문제이며 삭제해도 무방하다(다음 브라우저 실행 시 재생성). 참고: 브라우저 프로필에는 세션
|
|
||||||
- **HTTPError 응답 본문 읽기에 크기 캡 미적용 — 에러 경로에서 10MiB 캡 우회** — `src/rights_filter/integrations/http_json.py:55`: 성공 경로는 response.read(_MAX_RESPONSE_BYTES + 1) 로 10MiB 캡을 강제하지만, HTTPError 처리(53-58행)의 `exc.read()` 는 무제한으로 전체 본문을 메모리에 적재한다. 이 캡은 커밋 이력상 '적대적/오동작 API 의 메모리 고갈 방지' 목적으로 도입됐는데(17행 주석) 4xx/5xx 응답에는 적용되지 않아 위협 모델이 절반만 커버된다. 또한 읽은 detail 전문이 HttpJsonError 메시지 → Evidence.reason 으로 그대로 흘러가 초대형 에러 본문이 DB/GUI 에 저장될 수 있다.
|
|
||||||
- **SearchApiPolicy 쿼터 check-then-act 비원자 — 동시 요청 버스트 시 daily_limit 초과 가능** — `src/rights_filter/integrations/search_policy.py:30`: can_call() 과 record_call() 은 각각 락을 잡지만 예약(reserve) 개념이 없어, can_call 통과 → 외부 HTTP 요청(수 초) → record_call 사이 창에서 다른 스레드가 같은 잔여 쿼터로 can_call 을 통과할 수 있다. 잔여 1건 상태에서 N개의 동시 enrichment/수동 검색이 모두 통과하면 daily_limit 를 N-1 만큼 초과해 실제 과금/제공자 쿼터를 소모한다. 연기 항목 #12(store_operations rerun_enrichment/run_auto_search 락 부재)와 결합하면 실제로 도달 가능한 경로이나, 정책 객체 자체의 결함이므로 별도 기재한다
|
|
||||||
- **_probe_connect 가 시스템 프록시를 우회 — 프록시 필수망에서 원격 Ollama 오탐 실패** — `src/rights_filter/integrations/http_json.py:78`: connect_timeout 설정 시 수행되는 사전 TCP 프로브가 socket.create_connection 으로 대상 호스트에 직결을 시도한다. 실제 요청을 수행하는 urlopen 은 HTTP(S)_PROXY 환경변수를 따르므로, 아웃바운드가 프록시로만 허용된 망에서 원격 호스트를 가리키면 urlopen 으로는 성공할 요청이 프로브 단계에서 'connection to host:port failed' 로 전부 실패한다. 현재 connect_timeout 을 쓰는 곳은 llm_assistance.py:35 의 Ollama 클라이언트(기본 127.0.0.1:11434)뿐이라 기본 구성에서는 무해하지만, OLLAMA_BA
|
|
||||||
- **stdout 리다이렉트 기동 시 시작 진단 로그가 전혀 기록되지 않음 (블록 버퍼링, flush 부재)** — `src/rights_filter/server/__main__.py:41`: __main__.py 의 시작 print 6종(41행 .env 경로, 43행 provider readiness, 51/53행 COPYRIGHTER_AUTH_TOKEN 경고, 60행 static 경고, 73-75행 listening/DB/이미지 경로)이 flush 없이 출력된다. stdout 이 tty 가 아닌 파일로 리다이렉트되면(서비스형 백그라운드 기동, Task Scheduler, `> log` 등) Python 이 블록 버퍼링(8KB)을 적용해 이 ~500바이트가 버퍼에 갇히고, 서버가 정상 가동 중에도 로그 파일이 0바이트로 남는다. 프로세스가 강제 종료되면(비정상 크래시, taskkill) 버퍼가 폐기되어 영구
|
|
||||||
- **_search_coverage 가 검색 실패 증거만 있는 제출물도 '커버됨'으로 집계** — `src/rights_filter/server/sqlite_store.py:462`: _search_coverage 는 naver/google/google_search 커버리지를 _evidence_matches_provider 매칭 존재 여부만으로 판정한다(sqlite_store.py:462-467). 네이버·구글 맞춤 검색의 실패 증거(source="failure", domain="naver"/"google_custom_search")도 매칭되므로, 해당 provider 검색이 전부 실패한 제출물이 evidenceSubmissions 와 coverageSubmissions(커버리지율)에 '검색 근거 확보'로 포함된다. 같은 함수의 llm 버킷은 _provider_item_has_result 로 실제 결
|
|
||||||
|
|
||||||
## 4. 반증되어 기각된 발견 (기록용)
|
|
||||||
|
|
||||||
- **검색 결과 title/description이 이스케이프 없이 LLM 프롬프트에 삽입되는 프롬프트 인젝션 표면 — 운영자 요약문 조작 가능** (`src/rights_filter/analysis/llm_assistance.py`) — REFUTED: REFUTED: 인젝션 "유입"은 부분적으로 실재하지만, 발견이 주장하는 핵심 증상 — 조작된 LLM 요약문이 GUI에 운영자 판단 보조로 표시되어 오해를 유발 — 은 현재 코드에서 도달 불가능하다.
|
|
||||||
|
|
||||||
1) 조작된 요약문은 운영자에게 절대 표시되지 않음 (핵심 반증):
|
|
||||||
- LLM 출력은 llm_assistance.py:99-105에서 Evidence.data["summary"]에 담기지만, 영속화 시 store_operations.py:407-411이 _evidence_payload(store_serialization.py:249-309)로 직렬화하는데 이 payload에는 "summary" 키가 존재하지 않는다. 즉 LLM이 생성한 텍스트는 DB에 저장되는 순간 폐기된다. grep 결과 "summary"
|
|
||||||
- **correct_rejected_decision이 존재하지 않거나 무관한 decision_id에 대해 조용히 성공 형태(빈 리스트)로 반환** (`src/rights_filter/admin/correction_handlers.py`) — REFUTED: REFUTED: 코드 동작 자체는 발견 기술과 일치함을 확인했다: correction_handlers.py:9-14는 repository.operator_decision() 존재 확인도 REJECTED 상태 확인도 없는 단일 위임이고, domain/records.py:227-234는 매칭 엔트리 0건이면 예외 없이 빈 리스트를 반환하며 231-233에서 이미 비활성인 엔트리의 deactivation_reason도 무조건 덮어쓴다. OperatorDecision(records.py:152-168)에 정정 표식 필드도 없다. 그러나 발견이 제시한 트리거 시나리오는 도달 불가능하다. 전 저장소 grep 결과 correct_rejected_decision의 호출자는 tests/rights_filter/admin/t
|
|
||||||
|
|
||||||
## 5. 7/11 리메디에이션 체크리스트 현황 재확인
|
|
||||||
|
|
||||||
| 항목 | 상태 | 비고 |
|
|
||||||
|---|---|---|
|
|
||||||
| #12 rerun_enrichment/run_auto_search 락 | still-open | store_operations.py:202-312 — 두 메가함수 모두 _write_lock 없이 submission RMW(_get→수정→_put) 수행, 락은 manual_search(334)부터만 사용 |
|
|
||||||
| M4 seed 락 협소화 | still-open | sqlite_store.py:229-234 — seed_from_image_store 전체가 여전히 _write_lock 하나로 감싸져 있음(협소화 미착수, 의도된 연기 상태 유지) |
|
|
||||||
| M7 store_schema.py 제약 재구축 위반 행 coerce/격리 | still-open | store_schema.py:246-251 — insert..select 단일 복사만 있고 위반 행 coerce/격리 없음, 레거시 위반 행 존재 시 마이그레이션 전체가 raise로 중단(원본은 보존) |
|
|
||||||
| M9 store_queue.py 레거시 이관 파일 존재 검증 | still-open | store_queue.py:53-61 — _migrate_legacy_submissions_to_queue가 queue_id='' 행을 파일 존재 확인 없이 무조건 새 큐로 update |
|
|
||||||
| M8 image_store.py stem-충돌 되돌림(incumbent-id 보존 마이그레이션) | still-open | image_store.py:71,81-85 — id=path.stem 복귀 상태 유지, 동일-stem 형제 충돌 및 시드 incumbent-id 보존 마이그레이션 미착수(주석으로 문서화됨) |
|
|
||||||
| M naver_search.py 멀티페이지 쿼터 실제 요청 수 | fixed | naver_search.py:164-183,206-226,250-270 — _requested_calls(client, '*_pages')를 policy.can_call/record_call에 전달해 멀티페이지가 쿼터에 반영됨(빈 페이지 조기중단 시 계획 페이지 수만큼 보수적 과금) |
|
|
||||||
| M store_remote_fetch.py SSRF DNS-rebinding TOCTOU | still-open | store_remote_fetch.py:76-111 — getaddrinfo로 검증 후 urlopen이 별도로 재해석(IP 미고정, TOCTOU 창 존재); 리다이렉트 재검증(88-93)은 추가됐으나 rebinding은 미해결 |
|
|
||||||
| LOW EUC-KR 스니핑/dead code | still-open | store_page_scrape.py:842,850 — decode('utf-8', errors='replace')만 사용, EUC-KR/cp949 스니핑 코드 없음(레포 전체 grep 무매치); dead code는 레이아웃 테스트 고정으로 유지 결정대로 |
|
|
||||||
| #7 쿠키 분리+Secure | still-open | http_app.py:349-350 — 단일 세션 쿠키에 HttpOnly; SameSite=Strict만 있고 Secure 속성 없음(HTTPS 전제 연기 사유 그대로) |
|
|
||||||
| #8 rate limit | still-open | http_app.py — rate/throttle/429 관련 코드 전무, 엔드포인트 속도 제한 미구현 |
|
|
||||||
| #10 Origin/Referer CSRF 폴백 | fixed | http_app.py:303-324 — 쿠키 인증 상태변경 메서드에 _origin_ok() 검사 구현(Origin 우선, Referer 폴백, 둘 다 없으면 허용하는 명시적 설계) |
|
|
||||||
| #11 잔여 XSS sinks(similar-strip/face-crop safeUrl, innerHTML label) | fixed | app.js:1374(similar-strip img src=escapeHtml(safeUrl)), 1389-1390(face-crop safeUrl), 684·1360 등 label 보간도 escapeHtml 적용 — 잔여 sink grep에서 미이스케이프 동적 label 무발견 |
|
|
||||||
| #24 window.prompt → 인라인 | still-open | app.js:3433,3449 — 비활성/재활성 사유 입력에 여전히 window.prompt 사용 |
|
|
||||||
| M21 8개 delegated mutation 공용 in-flight 가드 | still-open | app.js:3772 이하 — toggleProvider/deactivate*/setEvidenceStatus/promote* 등 delegated 핸들러에 공용 가드 없음, in-flight 가드는 setDecision(2358-2418)에만 존재(#17 범위) |
|
|
||||||
| env_clients.py LLM assistant OLLAMA_* 게이팅 | still-open | env_clients.py:244-250 — _llm_assistant가 OLLAMA_* 미설정이어도 기본값(127.0.0.1:11434)으로 무조건 생성(연기 사유였던 env={} llm 테스트 의존 그대로) |
|
|
||||||
| package.json 정리 | still-open | package.json은 initial commit 이후 무변경({private,type:module}), node_modules/(@oai, lucide) 잔존 — 정리 미착수 |
|
|
||||||
| #14 잔여 무음 예외 swallow(page scrape 등) | still-open | store_page_scrape.py:308,386,431 — except Exception: return [] 무로깅 swallow 3곳 잔존(873 fingerprint 검사 포함 시 4곳) |
|
|
||||||
| #20 ScoreResult.reasons tuple | still-open | src/rights_filter/domain/records.py:75 — reasons: list[str] = field(default_factory=list) 그대로(연기 사유였던 == [] 테스트 단언 의존 유지) |
|
|
||||||
| checklist 하단 중복 Batch 7/8 섹션(94-107행) | fixed | 체크리스트 94-107행의 [ ] 항목들은 상단 [x] 항목의 낡은 중복 — 예: env_file.py:12-14 utf-8-sig 실적용 확인. 문서 정리만 필요(코드 이슈 아님) |
|
|
||||||
| spot:H6 store_serialization promoted/promotionReason | fixed | store_serialization.py:269-271 영속화, 340-341 복원 + domainSource(269, 364) 및 레거시 폴백(371) 확인 |
|
|
||||||
| spot:H2 store_persistence usage delta 병합 | fixed | store_persistence.py:231-246 — _write_lock 안에서 fresh row 재읽기 후 델타 필드만 병합, stale payload 되쓰기 제거 확인 |
|
|
||||||
| spot:M5 업로드 TOCTOU 배타 생성 | fixed | http_app.py:460-474 — open(candidate, 'xb') 배타 생성 + FileExistsError 시 -N 접미어 재시도 루프 확인 |
|
|
||||||
| spot:#6 앱셸 CSP/X-Frame-Options/nosniff | fixed | http_app.py:393-411 — 정적/앱셸 응답에 nosniff, X-Frame-Options: DENY, Content-Security-Policy 송출 확인 |
|
|
||||||
| spot:H9 dismiss 서버 영속화 엔드포인트 | fixed | http_app.py:217-219 — /api/collections/candidates/{id}/dismiss 라우트가 store.dismiss_collection_candidate 호출로 서버 영속화 확인 |
|
|
||||||
|
|
||||||
## 6. 런타임 스모크 테스트
|
|
||||||
|
|
||||||
서버 런타임 스모크 테스트 완료 (포트 9557, PID 23864, 테스트 후 자체 종료). 9개 검증 항목 전부 통과: 기동 성공, 앱셸 200 + 보안 헤더 3종(CSP/X-Frame-Options/nosniff), 정적 자산 정상 서빙, 무토큰 데이터 라우트 401 거부, 토큰 인증 시 bootstrap(245KB)·review-queue(188KB)·케이스 상세(36KB) 정상 JSON, /media 이미지 서빙 정상(untrusted 미디어 전용 sandbox CSP + no-store + no-referrer 헤더 확인), 미존재 라우트 404, path traversal 전 변형(원문 /../, %2F 인코딩, %2e%2e 인코딩, media/static 양쪽) 거부. 신규 발견 1건(LOW): stdout 을 파일로 리다이렉트해 백그라운드 기동하면 Python 블록 버퍼링 때문에 시작 진단 출력(COPYRIGHTER_AUTH_TOKEN 미설정 경고 포함)이 로그 파일에 전혀 기록되지 않음 — 서버 가동 1분 이상 경과 후에도 로그 0바이트를 실측으로 확인.
|
|
||||||
|
|
||||||
| 체크 | 결과 |
|
|
||||||
|---|---|
|
|
||||||
| 서버 기동 성공 | PASS — python run_copyrighter_server.py --port 9557 (COPYRIGHTER_AUTH_TOKEN 54자 설정) → PID 23864 이 127.0.0.1:9557 LISTENING, /he |
|
|
||||||
| GET / 앱셸 200 + 보안 헤더 | PASS — 200 text/html (25695바이트, index.html). X-Content-Type-Options: nosniff, X-Frame-Options: DENY, Content-Security-Policy(de |
|
|
||||||
| 정적 자산 로드 | PASS — /app.js 200 text/javascript; charset=utf-8 (103404바이트, 파일 크기와 일치) + 앱셸 CSP/XFO/nosniff 헤더 동일 적용. /styles.css 200 text/cs |
|
|
||||||
| 토큰 없이 데이터 라우트 거부 | PASS — 무토큰 GET /api/bootstrap → 401 {"error":"unauthorized"}. 무토큰 GET /media/x.png → 401. 보호 프리픽스(/api/, /media/)가 실동작으로 확인됨. |
|
|
||||||
| 토큰 포함 부트스트랩/큐 GET | PASS — Authorization: Bearer 헤더로 /api/bootstrap 200 (245825바이트, 유효 JSON — submissions 6건, providers 5건, 키: auditEvents/collecti |
|
|
||||||
| 케이스 상세 GET | PASS — /api/submissions/3/review → 200 유효 JSON (36594바이트, id=3, evidence/queryHistory/providerState/fileFacts 등 12개 키). |
|
|
||||||
| 이미지 서빙 엔드포인트 | PASS — /media/images/3.jpg → 200 image/jpeg 5056바이트. untrusted 미디어 전용 헤더 확인: nosniff + sandbox CSP(default-src 'none') + Cache- |
|
|
||||||
| 미존재 라우트 404 | PASS — /api/no-such-route → 404 {"error":"not found"}. |
|
|
||||||
| path traversal 거부 | PASS — 5개 변형 모두 거부, 파일 유출 없음(size=0): /media/../copyrighter.sqlite3(--path-as-is) → 400, /media/..%2Fcopyrighter.sqlite3 → 400, |
|
|
||||||
| 기동 프로세스 정리 | PASS — 저장한 PID 23864 를 python 프로세스임을 확인 후 해당 PID 만 Stop-Process. 종료 후 9557 LISTENER 없음(TIME_WAIT 잔여만). 파괴적 POST 미실행 — GET + 앱셸만 |
|
|
||||||
|
|
||||||
## 7. 신규 기능 제안 — 심사 통과 상위 5
|
|
||||||
|
|
||||||
제안 16건을 심사자 3인(운영자/기술 리드/제품 책임자 렌즈)이 채점. 지문(pHash) 계열 3건은 3인 만장일치로 단일 로드맵 병합 권고.
|
|
||||||
|
|
||||||
### ① 키보드 연속 심사 모드 (M)
|
|
||||||
**키보드 연속 심사 모드** [operator-efficiency]
|
|
||||||
- 무엇: 케이스 심사 화면에 키보드 단축키 레이어를 추가한다. J/K(또는 방향키)로 현재 큐 필터·정렬 순서 그대로 이전/다음 케이스 이동, A/H/R로 승인·보류·반려(반려는 메모 필드로 포커스 이동), 판정 저장 성공 시 다음 미심사 케이스로 자동 이동. 상단에 '현재 n/전체 · 남은 미심사 m건' 진행 표시와 ? 키 도움말 오버레이를 넣고, 입력 필드 포커스 중에는 단축키를 비활성화한다.
|
|
||||||
- 가치: 지금은 케이스 하나 판정할 때마다 큐 화면 복귀 → 행 클릭 → 판정 버튼 클릭의 마우스 왕복이 고정 비용이다. 이를 키 2번(판정 키 → 자동 다음 이동)으로 줄여, 하루 수십~수백 건 연속 처리에서 케이스 간 이동 시간을 거의 제거한다.
|
|
||||||
- 인사이트: web/operator-gui 전체를 grep해도 keydown 핸들러가 단 하나도 없고, 판정 후 화면이 해당 케이스에 그대로 머문다는 사실을 확인했다. filteredSubmissions()·selectCase()·setDecision()이 이미 완결된 형태로 존재하므로 서버 변경 없이 순수 프론트 작업만으로 연속 심사 플로우를 완성할 수 있다 — 효율 대비 구현 리스크가 가장 낮은 항목.
|
|
||||||
- 기반: web/operator-gui/app.js의 filteredSubmissions()/selectCase()(line 2345)/setDecision() 및 index.html decision-pane의 data-decision 버튼. 서버 변경 없음 — 기존 POST /api/submissions/{id}/decision 재사용.
|
|
||||||
|
|
||||||
### ② 선택 케이스 일괄 판정 + 판정 메모 프리셋 (M+S)
|
|
||||||
**선택 케이스 일괄 판정 (가드레일 포함)** [operator-efficiency]
|
|
||||||
- 무엇: 큐에 이미 있는 선택 체크박스(data-bulk-id)를 판정에도 연결한다. POST /api/submissions/decision-batch 엔드포인트를 추가해 선택 케이스들에 공통 메모와 함께 승인/보류/반려를 일괄 적용하고, 케이스별 성공/실패 목록을 반환한다. 가드레일: 보류/반려는 기존 record_decision의 케이스별 watchlist 생성 로직을 그대로 통과시키고, 선택에 high 위험 케이스가 섞여 있으면 개수를 명시한 확인 단계를 거친다.
|
|
||||||
- 가치: '내부 근거만 있고 외부 매칭 0건' 같은 명백한 저위험 무리를 한 번에 승인할 수 있다. 수십 건에 대해 케이스 진입 → 판정 클릭을 반복하던 것이 체크 → 일괄 판정 1회로 줄고, 부분 실패 시 어느 건이 남았는지 즉시 보인다.
|
|
||||||
- 인사이트: 체크박스 선택 인프라(selectedBulkSubmissionIds, app.js:2578)와 일괄 '재분석'은 이미 있는데 일괄 '판정'만 빠져 있다 — UI 절반이 이미 깔려 있는 상태. 서버 쪽도 record_decision(sqlite_store.py:580)이 케이스 단위 락·감사이벤트·watchlist 생성을 완결적으로 캡슐화하고 있어, 배치 엔드포인트는 검증 + 루프 + 결과 집계만 추가하면 된다.
|
|
||||||
- 기반: src/rights_filter/server/sqlite_store.py record_decision(케이스별 트랜잭션·감사·watchlist 로직 재사용), src/rights_filter/server/http_app.py do_POST 라우팅, web/operator-gui/app.js selectedBulkSubmissionIds()와 큐 체크박스, index.html decision-actions 패턴.
|
|
||||||
|
|
||||||
**판정 메모 프리셋 (자주 쓰는 사유 원클릭)** [operator-efficiency]
|
|
||||||
- 무엇: 결정 메모 textarea 위에 프리셋 칩 줄을 추가한다. 운영팀 공용 프리셋(제목+본문)을 DB에 저장하는 GET/POST /api/decision-memo-presets 소형 라우트를 만들고, 칩 클릭 시 메모에 삽입, '현재 메모를 프리셋으로 저장' 버튼을 제공한다. 반려 시 메모 필수 검증(memo-error)과 자연 연동되어 프리셋 선택만으로 반려 요건을 충족한다.
|
|
||||||
- 가치: 반려는 메모가 서버에서 강제되는데 실제 사유는 '연예인 초상 포함', '방송 캡처', '기준DB 반려 이미지와 동일' 등 소수 패턴의 반복이다. 케이스당 타이핑을 클릭 1회로 줄이고, 팀 내 사유 문구가 표준화되어 감사 로그와 보정 기록의 가독성도 함께 올라간다.
|
|
||||||
- 인사이트: record_decision(sqlite_store.py:589)이 rejected에 memo.strip() 비어있음 거부를 강제하는데 GUI는 빈 textarea만 제공한다 — 반복 사유 타이핑이 반려 처리의 실질 병목임을 코드로 확인했다. audit_events에 'decision · memo' 형태로 과거 메모가 이미 쌓여 있어, 초기 프리셋 후보를 과거 반려 메모 빈도에서 자동 추출해 시딩하는 확장도 가능하다.
|
|
||||||
- 기반: web/operator-gui/index.html decision-pane(#decision-memo, #memo-error), app.js setDecision 및 메모 검증 로직, src/rights_filter/server/http_app.py 신규 소형 라우트 2개, sqlite_store의 기존 JSON payload 테이블 패턴(providers/app_state와 동일한 키-값 저장 재사용).
|
|
||||||
|
|
||||||
### ③ 제출물 지문 중복·선례 인덱스 (L, 3제안 병합 로드맵)
|
|
||||||
**동일·유사 제출 자동 묶음 (중복 그룹)** [operator-efficiency]
|
|
||||||
- 무엇: seed/bootstrap 시 미심사 제출물끼리 SHA-256 완전일치와 pHash 고유사(기본 임계 0.97 이상)를 버킷팅해 submission payload에 duplicateGroup 필드로 직렬화한다. 큐 행에 '동일 이미지 N건' 배지를 표시하고, 배지 클릭 시 그룹 전체의 체크박스를 자동 선택해 기존 선택 재분석·일괄 판정(기능 2)으로 바로 이어지게 한다. 케이스 심사 화면에도 '같은 이미지 제출 N건' 링크를 노출한다.
|
|
||||||
- 가치: 같은 스톡/연예인 이미지를 여러 신청자가 반복 제출하는 패턴에서 첫 건만 제대로 심사하고 나머지는 그룹 단위로 처리한다. 중복 건마다 외부 근거를 처음부터 다시 읽는 시간이 사라지고, 같은 이미지에 운영자별로 상반된 판정이 나가는 일관성 사고도 구조적으로 방지된다.
|
|
||||||
- 인사이트: pHash 비교가 기준DB 대조와 검색결과 이미지 대조에는 쓰이면서 정작 큐 안의 제출물끼리는 한 번도 비교되지 않는다는 비대칭을 확인했다. store_search_candidates.py의 _submission_perceptual_fingerprint(line 193)가 제출 이미지 지문 계산 경로를 이미 제공하므로, 실제 신규 작업은 큐 스코프 버킷팅(수백 건 O(n²) 해밍 비교로 충분)과 직렬화 필드 하나다.
|
|
||||||
- 기반: src/rights_filter/analysis/fingerprints.py FingerprintService, src/rights_filter/server/store_search_candidates.py _submission_perceptual_fingerprint 패턴, sqlite_store.seed_from_image_store/bootstrap 및 store_serialization.py _submission_payload 직렬화, web/operator-gui/app.js 큐 행 렌더링(app.js:1220대)과 data-bulk-id 체크박스.
|
|
||||||
|
|
||||||
**재제출·중복 제출 선례 자동 연결 (prior-case dedupe link)** [automation-integration]
|
|
||||||
- 무엇: seed 시 신규 제출의 SHA-256/pHash 지문을 기존 제출 건들의 저장된 fingerprint 증거와 대조해, 일치(exact 동일 또는 pHash≥임계치) 시 '이전 케이스 연결' 증거를 자동 생성한다. 증거에는 선행 케이스 ID·당시 판정·메모 링크를 담고, 케이스 심사 화면과 큐 행에 '선례: SUB-xxxx 승인/반려' 칩을 표시한다. exact 동일 재제출은 외부 검색(Google Vision/Naver) 재실행을 건너뛰고 선행 케이스의 증거를 참조하도록 해 쿼터를 절약한다. 최종 판정 자동 변경은 하지 않는다(운영 경계 유지).
|
|
||||||
- 가치: 현재 지식DB 매칭은 보류/반려로 만들어진 주의 후보만 커버한다. 승인됐던 이미지나 아직 미결인 케이스와 동일한 이미지가 다시 들어오면 전체 분석이 다시 돌고(외부 API 쿼터 소모) 운영자는 선례를 모른 채 처음부터 재심사한다. 선례 칩 하나로 '지난주에 이미 승인한 그 이미지'를 즉시 식별하고 판정 일관성도 확보된다.
|
|
||||||
- 인사이트: internal_analyzer 가 exact(SHA-256)·perceptual 지문을 이미 모든 제출의 evidence 로 저장하고 있어(internal_analyzer.py:36-37, evidence 테이블 idx_evidence_source 인덱스 존재) 새 분석 없이 저장된 데이터 간 조인만으로 구현된다. 즉 이것은 탐지 모델 고도화가 아니라 이미 계산된 신호를 케이스 간에 연결하는 순수 워크플로 기능이며, exact-dupe 외부 검색 스킵은 문서에 명시된 '페이지 수만큼 API 호출량 증가' 비용 문제를 직접 줄인다.
|
|
||||||
- 기반: src/rights_filter/server/sqlite_store.py seed 경로(_seed_from_image_store_locked)와 evidence 테이블, src/rights_filter/analysis/internal_analyzer.py + fingerprints.py 의 저장 지문, src/rights_filter/analysis/risk_scoring.py (선례 증거 가중), web/operator-gui/app.js 큐/케이스 렌더링
|
|
||||||
|
|
||||||
**재제출·중복 클러스터 감지 (지문 유사도 매트릭스)** [insight-analytics]
|
|
||||||
- 무엇: 전 제출의 pHash를 상호 비교(64bit 해밍 거리, 수천 건 규모에서 전수 비교 가능)해 유사도 임계 이상 클러스터를 만들고, (a) 보류·반려된 이미지가 파일명·인코딩만 바꿔 재제출된 케이스, (b) 동일/유사 이미지에 상반된 판정(하나는 승인, 하나는 반려)이 난 케이스를 신규 API와 큐 화면 배지 + 클러스터 목록 뷰로 하이라이트한다. pHash는 현재 evidence 직렬화에서 exact/perceptual 키가 탈락해 DB에 없으므로, seed_from_image_store 시 submission payload에 지문을 백필·저장하는 단계가 선행돼야 한다.
|
|
||||||
- 가치: 같은 이미지를 두 번 심사하는 낭비를 제거하고, 반려 후 우회 재제출을 자동으로 잡아내며, 운영자 간 판정 불일치를 관리자가 볼 수 있게 된다.
|
|
||||||
- 인사이트: 현재 내부 분석기는 지식DB sampleFingerprints와만 비교하므로, 반려 시 watchlist 엔트리가 생성된 경우에만 재제출이 잡힌다. 승인된 이미지의 재제출, 판정 전 중복 제출, 유사 이미지 간 판정 불일치는 지금 아무도 보지 못하는 사각지대다.
|
|
||||||
- 기반: src/rights_filter/analysis/fingerprints.py (FingerprintService.similarity), src/rights_filter/server/store_search_candidates.py (_submission_perceptual_fingerprint 재계산 경로), src/rights_filter/server/sqlite_store.py (seed_from_image_store에 지문 백필 추가, submissions payload), src/rights_filter/server/image_store.py, src/rights_filter/server/http_app.py (신규 라우트), web/operator-gui/app.js (queue 행 배지 + 신규 패널)
|
|
||||||
|
|
||||||
### ④ 판정 결과 일괄 내보내기 + 통보 초안 (S)
|
|
||||||
**판정 결과 일괄 내보내기 + 통보 초안 (decision export)** [automation-integration]
|
|
||||||
- 무엇: GET /api/decisions/export?since=&status= 라우트를 추가해 판정 완료 케이스를 CSV/JSON 으로 내려받게 한다. 두 가지 뷰를 분리한다: (1) 내부 아카이브용 — 케이스 ID·제목·판정·메모·판단에 사용된 증거 제목과 출처 URL·판정 시각(감사 이벤트 기준), (2) 신청 채널 전달용 — 판정·applicantStatus·운영자 메모만 포함하고 위험 점수·증거 상세는 절대 포함하지 않는다(운영 경계 준수). GUI 심사 큐 상단에 '판정 결과 내보내기' 버튼과 기간/상태 선택을 추가한다.
|
|
||||||
- 가치: 지금은 record_decision 이 applicantStatus('반려 예정' 등)까지 세팅하지만 그 값이 시스템 밖으로 나가는 경로가 없어, 운영자가 판정 결과를 접수 채널·상급자에게 전달하려면 큐 화면을 보며 수기로 옮겨 적어야 한다. 하루치 판정을 버튼 한 번으로 정리된 파일로 뽑으면 판정 후 후속 업무(결과 통보, 일일 보고)가 케이스당 수 분에서 수 초로 준다.
|
|
||||||
- 인사이트: 필요한 데이터가 이미 전부 저장돼 있다 — submissions.decisionStatus/applicantStatus, 증거의 operatorStatus(판단에 사용 여부), audit_events 의 'Operator decision created' 이벤트(판정 시각·메모). 새 스키마 없이 읽기 전용 GET 라우트 하나로 완결되므로 기존 CSRF/락 구조에 영향이 없다. 핵심 설계 판단은 '신청자용 뷰에서 내부 증거·점수를 구조적으로 차단'하는 필드 화이트리스트이며, 이것이 문서화된 운영 경계(Do not expose internal risk scores to applicants)를 코드로 보증하게 된다.
|
|
||||||
- 기반: src/rights_filter/server/http_app.py (GET 라우트 추가), src/rights_filter/server/sqlite_store.py 의 record_decision/audit_events/bootstrap 데이터, web/operator-gui/index.html + app.js 큐 액션 영역
|
|
||||||
|
|
||||||
### ⑤ 온라인 스냅샷 백업·복구 (M)
|
|
||||||
**온라인 스냅샷 백업·복구 (DB+미디어 번들)** [essential-foundation]
|
|
||||||
- 무엇: 서버 실행 중에도 안전한 백업을 만드는 관리 기능. sqlite3의 온라인 backup API로 WAL 모드 DB(data/copyrighter.sqlite3)의 일관된 스냅샷을 뜨고, 미디어 4종 디렉터리(data/submissions/images, data/knowledge/images, data/collections/images, data/face-crops)를 묶어 타임스탬프 zip으로 data/backups/에 생성하는 POST /api/admin/backup 엔드포인트 + GUI 버튼(마지막 백업 시각 표시). 복구는 오프라인 CLI(python -m rights_filter.server --restore <zip>)로 수행하고 _ensure_schema_version으로 스키마 버전을 검증한 뒤 기동. 백업/복구 실행은 add_audit_event로 감사 기록. 보존 개수 로테이션 포함.
|
|
||||||
- 가치: 판정 이력·기준 DB·감사 로그 전체가 단일 SQLite 파일에 있는데 현재 백업 코드가 0줄이다. 디스크 손상·실수 삭제 한 번이면 운영 자산 전체가 유실되고, 폐쇄망 납품 환경에는 클라우드 백업이 없으므로 로컬 스냅샷+반출이 유일한 복구 수단이다. 운영자가 버튼 한 번으로 복구 지점을 만들 수 있게 된다.
|
|
||||||
- 인사이트: WAL 모드(sqlite_store.py initialize의 pragma journal_mode=wal)에서는 서버 실행 중 파일 복사만으로는 -wal 사이드카가 빠진 손상 스냅샷이 나온다. '파일 하나 복사하면 되는 백업'이라는 가정이 이 구조에서는 성립하지 않으며, 반드시 sqlite3 backup API 경유가 필요하다. 운영 문서(copyrighter-operation-worklist.md '아직 안 된 것' 절)도 백업·복구를 명시적 미완 항목으로 남겨두고 있다.
|
|
||||||
- 기반: src/rights_filter/server/store_persistence.py(_connect — sqlite3.Connection.backup 사용 지점), src/rights_filter/server/sqlite_store.py(db_path·knowledge_image_dir·collection_image_dir·face_crop_image_dir 경로 필드, add_audit_event), src/rights_filter/server/store_schema.py(_ensure_schema_version 복구 검증), src/rights_filter/server/http_app.py(do_POST 라우팅), src/rights_filter/server/__main__.py(--restore CLI 진입점)
|
|
||||||
|
|
||||||
### 차점 후보 (다음 사이클 권장)
|
|
||||||
- **판정 통계·스코어 캘리브레이션 대시보드**: 운영자는 백로그가 어디서 쌓이는지(오래된 미심사 건, 처리량 저하 요일)를 즉시 보고, 관리자는 룰 기반 리스크 스코어의 임계값 조정과 인력 배치를 감이 아닌 수치로 결정할 수 있다.
|
|
||||||
- **운영자 식별·역할 분리 (이름 붙은 토큰 + admin 게이트 + 감사 실명화)**: 복수 운영자가 같은 콘솔을 쓰는 순간 현재 감사 로그는 책임 추적 기능을 상실한다 — 반려·기준 DB 편입·provider 중지 같은 판단 행위가 전부 'rights.ops'로 기록되기 때문. 이름 붙은 토큰만으로 SSO 없이(폐쇄망 제약) '누가 언제 무엇을'이 남고, provider 제어·파기·백업 같은 위험 조작은 관리자만 가능해진다.
|
|
||||||
|
|
||||||
### 심사 점수 (3인 평균 상위)
|
|
||||||
|
|
||||||
| 제안 | 평균 | 개별 |
|
|
||||||
|---|---|---|
|
|
||||||
| 키보드 연속 심사 모드 | 9.07 | 9, 9.2, 9 |
|
|
||||||
| 선택 케이스 일괄 판정 (가드레일 포함) | 8.23 | 8.5, 8.2, 8 |
|
|
||||||
| 판정 메모 프리셋 (자주 쓰는 사유 원클릭) | 8.20 | 8, 8.6, 8 |
|
|
||||||
| 판정 결과 일괄 내보내기 + 통보 초안 (decision export) | 7.50 | 7.5, 7.5, 7.5 |
|
|
||||||
| 온라인 스냅샷 백업·복구 (DB+미디어 번들) | 7.50 | 7.5, 8.5, 6.5 |
|
|
||||||
| 동일·유사 제출 자동 묶음 (중복 그룹) | 6.90 | 7, 7.2, 6.5 |
|
|
||||||
| 운영자 식별·역할 분리 (이름 붙은 토큰 + admin 게이트 + 감사 실명화) | 6.77 | 7, 7.8, 5.5 |
|
|
||||||
| 주의 후보 자동 큐레이션 트리아지 (watchlist auto-curation) | 6.30 | 6.5, 6.4, 6 |
|
|
||||||
| 판정 통계·스코어 캘리브레이션 대시보드 | 6.10 | 6, 7, 5.3 |
|
|
||||||
| 기준 DB 적중률·큐레이션 추천 리포트 | 6.10 | 6.5, 6.8, 5 |
|
|
||||||
|
|
@ -53,11 +53,6 @@ Required controls:
|
||||||
- Decision filter: unreviewed, held, rejected, approved, corrected
|
- Decision filter: unreviewed, held, rejected, approved, corrected
|
||||||
- Sort by risk, newest, oldest, analysis failure, provider failure
|
- Sort by risk, newest, oldest, analysis failure, provider failure
|
||||||
- Bulk selection only for non-decisive actions such as re-run analysis or assign reviewer; no bulk approve/reject in v1
|
- Bulk selection only for non-decisive actions such as re-run analysis or assign reviewer; no bulk approve/reject in v1
|
||||||
- **개정 2026-07-24**: v1 제약을 해제하고 일괄 승인/보류/반려를 가드레일과 함께 허용한다.
|
|
||||||
근거: 2026-07-24 리뷰에서 반복 판정이 운영자 처리량의 주 병목으로 확인됨.
|
|
||||||
필수 가드레일 — 선택에 고위험(high) 케이스가 포함되면 개수를 명시한 확인 단계,
|
|
||||||
반려는 메모 필수, 1회 최대 200건, 케이스별 개별 트랜잭션·감사 이벤트,
|
|
||||||
부분 실패 시 실패 케이스 목록 반환. 자동 판정은 여전히 금지(운영자 명시 조작만).
|
|
||||||
|
|
||||||
UX rule: the queue should never require opening every item just to understand why it is high risk.
|
UX rule: the queue should never require opening every item just to understand why it is high risk.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import sys
|
||||||
# surfaces this friendly message instead of a raw SyntaxError/ImportError.
|
# surfaces this friendly message instead of a raw SyntaxError/ImportError.
|
||||||
if sys.version_info < (3, 13):
|
if sys.version_info < (3, 13):
|
||||||
sys.exit(
|
sys.exit(
|
||||||
"Copyrighter requires Python 3.13+ - activate the offline venv "
|
"Copyrighter requires Python 3.13+ — activate the offline venv "
|
||||||
"(see docs/operations/offline-install.md)."
|
"(see docs/operations/offline-install.md)."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,8 @@
|
||||||
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
|
||||||
|
|
||||||
|
|
@ -14,214 +11,32 @@ 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.
|
||||||
|
|
||||||
Prefers YuNet (OpenCV Zoo) when its weights are present and degrades to the
|
Target applications should replace this with their approved local detector or
|
||||||
bundled Haar cascades when they are not. It intentionally emits no identity
|
internal service. It intentionally emits no identity or embedding data.
|
||||||
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:
|
||||||
# Frame size comes from the payload rather than the decoded array: the
|
return self._detect_with_opencv(image.content)
|
||||||
# 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(
|
def _detect_with_opencv(self, content: bytes) -> FacePersonSignal:
|
||||||
self, content: bytes, width: int = 0, height: int = 0
|
|
||||||
) -> FacePersonSignal:
|
|
||||||
try:
|
try:
|
||||||
import cv2
|
import cv2
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
@ -235,10 +50,6 @@ 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/
|
||||||
|
|
@ -286,11 +97,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)
|
||||||
|
|
||||||
return _signal_from_boxes(
|
face_count = len(face_boxes)
|
||||||
face_boxes,
|
return FacePersonSignal(
|
||||||
HAAR_FALLBACK_CONFIDENCE,
|
face_count=face_count,
|
||||||
"haar",
|
person_count=face_count,
|
||||||
max(0, int(width) * int(height)),
|
face_boxes=tuple(face_boxes),
|
||||||
)
|
)
|
||||||
|
|
||||||
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,18 +1,12 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from rights_filter.analysis.face_person_detection import (
|
from rights_filter.analysis.face_person_detection import HeuristicFacePersonDetector
|
||||||
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.analysis.provenance import provenance_evidence
|
|
||||||
from rights_filter.domain.records import (
|
from rights_filter.domain.records import (
|
||||||
Evidence,
|
Evidence,
|
||||||
EvidenceSource,
|
EvidenceSource,
|
||||||
InMemoryRightsFilterRepository,
|
InMemoryRightsFilterRepository,
|
||||||
KnowledgeEntryType,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -45,11 +39,6 @@ class InternalAnalyzer:
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Read the original bytes here, while they are still in hand: the
|
|
||||||
# external-derivative build destroys all metadata and nothing read the
|
|
||||||
# rights fields first.
|
|
||||||
evidence.extend(provenance_evidence(image.content))
|
|
||||||
|
|
||||||
for entry in self.repository.active_knowledge_entries():
|
for entry in self.repository.active_knowledge_entries():
|
||||||
for sample in entry.sample_fingerprints:
|
for sample in entry.sample_fingerprints:
|
||||||
similarity = self.fingerprint_service.similarity(
|
similarity = self.fingerprint_service.similarity(
|
||||||
|
|
@ -57,13 +46,11 @@ 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"
|
||||||
entry_type = str(entry.entry_type)
|
reason = (
|
||||||
if entry_type == KnowledgeEntryType.CLEARED_REFERENCE:
|
f"주의 후보 이미지 유사도 {similarity:.2f}"
|
||||||
reason = f"이전에 클리어된 이미지와 일치 {similarity:.2f}"
|
if entry_status == "watchlist"
|
||||||
elif entry_status == "watchlist":
|
else f"Knowledge base image similarity {similarity:.2f}"
|
||||||
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,
|
||||||
|
|
@ -73,9 +60,6 @@ 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,
|
||||||
|
|
@ -88,11 +72,12 @@ class InternalAnalyzer:
|
||||||
evidence.append(
|
evidence.append(
|
||||||
Evidence(
|
Evidence(
|
||||||
source=EvidenceSource.FACE_PERSON,
|
source=EvidenceSource.FACE_PERSON,
|
||||||
reason=face_person_reason(signal),
|
reason="Face/person detected",
|
||||||
# The detector's own score, not a hardcoded 0.8: a Haar
|
confidence=0.8,
|
||||||
# fallback must not claim the confidence YuNet earns.
|
data={
|
||||||
confidence=signal.confidence,
|
"face_count": signal.face_count,
|
||||||
data=face_person_evidence_data(signal),
|
"person_count": signal.person_count,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,79 +1,16 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
from dataclasses import dataclass
|
||||||
import logging
|
|
||||||
import time
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from rights_filter.analysis.llm_prompts import (
|
|
||||||
CLAIM_TEXT_PLACEHOLDER,
|
|
||||||
EMPTY_SUMMARY_TEXT,
|
|
||||||
EVIDENCE_LABEL_PREFIX,
|
|
||||||
LEGACY_PROMPT_VERSION,
|
|
||||||
NEEDS_CHECK_HEADING,
|
|
||||||
NEEDS_CHECK_PLACEHOLDER,
|
|
||||||
PROMPT_ID,
|
|
||||||
PROMPT_VERSION,
|
|
||||||
SUMMARY_HEADING,
|
|
||||||
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
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# The model reads at most this many evidence rows. Real cases carry ~49 rows and
|
|
||||||
# dumping all of them produced 13k-62k character prompts that overran the context
|
|
||||||
# window: the model saw a fraction while the stored summary still claimed every
|
|
||||||
# row as its source. Capping the input is what makes the citation honest.
|
|
||||||
MAX_PROMPT_EVIDENCE = 12
|
|
||||||
|
|
||||||
# Single source of truth for the model. env_clients re-exports this rather than
|
|
||||||
# declaring its own: two defaults that disagree is how the client ended up
|
|
||||||
# pointing at a model that was never installed while the wiring pointed
|
|
||||||
# elsewhere, and nothing failed until a request 404'd at runtime.
|
|
||||||
DEFAULT_MODEL = "qwen2.5:7b"
|
|
||||||
|
|
||||||
# Data fields the model is allowed to see. A whitelist rather than a blocklist:
|
|
||||||
# evidence payloads grow, and a field nobody vetted must not reach the prompt by
|
|
||||||
# default. Notably absent — promotion_reason (the model copies it verbatim and
|
|
||||||
# the "summary" becomes a reprint of the evidence list) and the face-crop fields
|
|
||||||
# (crop geometry and privacy notes are not summarization input).
|
|
||||||
PROMPT_DATA_FIELDS = (
|
|
||||||
"match",
|
|
||||||
"similarity",
|
|
||||||
"page_title",
|
|
||||||
"domain",
|
|
||||||
"query",
|
|
||||||
"entity",
|
|
||||||
"label",
|
|
||||||
"knowledge_name",
|
|
||||||
"tier",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@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
|
|
||||||
# Claims that cited at least one real evidence label. Anything the model
|
|
||||||
# asserted without a valid citation never reaches this list.
|
|
||||||
claims: tuple[dict[str, Any], ...] = ()
|
|
||||||
dropped_claims: int = 0
|
|
||||||
needs_check: tuple[str, ...] = field(default=())
|
|
||||||
|
|
||||||
|
|
||||||
@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:
|
||||||
|
|
@ -85,13 +22,10 @@ class OllamaGenerateLlmClient:
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
base_url: str = "http://127.0.0.1:11434",
|
base_url: str = "http://127.0.0.1:11434",
|
||||||
model: str = DEFAULT_MODEL,
|
model: str = "qwen2.5:0.5b-instruct",
|
||||||
transport: Any | None = None,
|
transport: Any | None = None,
|
||||||
timeout: int = 120,
|
timeout: int = 30,
|
||||||
connect_timeout: float = 2,
|
connect_timeout: float = 2,
|
||||||
num_ctx: int = 8192,
|
|
||||||
num_predict: int = 512,
|
|
||||||
keep_alive: str = "10m",
|
|
||||||
) -> None:
|
) -> None:
|
||||||
self.base_url = base_url.rstrip("/")
|
self.base_url = base_url.rstrip("/")
|
||||||
self.model = model
|
self.model = model
|
||||||
|
|
@ -100,59 +34,26 @@ class OllamaGenerateLlmClient:
|
||||||
# generation still gets the full `timeout`.
|
# generation still gets the full `timeout`.
|
||||||
self.transport = transport or UrllibJsonTransport(connect_timeout=connect_timeout)
|
self.transport = transport or UrllibJsonTransport(connect_timeout=connect_timeout)
|
||||||
self.timeout = timeout
|
self.timeout = timeout
|
||||||
# Ollama defaults the context window to a couple of thousand tokens and
|
|
||||||
# silently truncates past it. Setting it explicitly is what makes "the
|
|
||||||
# model read the evidence we cited" true rather than assumed.
|
|
||||||
self.num_ctx = num_ctx
|
|
||||||
self.num_predict = num_predict
|
|
||||||
# Keep the weights resident between cases so the second case in a batch
|
|
||||||
# does not pay the load again.
|
|
||||||
self.keep_alive = keep_alive
|
|
||||||
|
|
||||||
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:
|
|
||||||
selected = _select_prompt_evidence(evidence)
|
|
||||||
labels = _evidence_labels(selected)
|
|
||||||
payload = {
|
payload = {
|
||||||
"model": self.model,
|
"model": self.model,
|
||||||
"system": SYSTEM_PROMPT,
|
"system": (
|
||||||
"prompt": _evidence_prompt(selected),
|
"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),
|
||||||
"stream": False,
|
"stream": False,
|
||||||
# Constrain decoding to JSON so the citation field is a field, not a
|
"options": {"temperature": 0.1},
|
||||||
# string the reader has to guess at.
|
|
||||||
"format": "json",
|
|
||||||
"keep_alive": self.keep_alive,
|
|
||||||
"options": {
|
|
||||||
"temperature": 0.1,
|
|
||||||
"num_ctx": self.num_ctx,
|
|
||||||
"num_predict": self.num_predict,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
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,
|
||||||
)
|
)
|
||||||
elapsed_ms = int((time.monotonic() - started) * 1000)
|
return _response_text(response)
|
||||||
|
|
||||||
raw = _response_text(response)
|
|
||||||
claims, dropped, needs_check = _verified_claims(raw, set(labels))
|
|
||||||
return LlmSummaryResult(
|
|
||||||
text=_render_summary(claims, needs_check),
|
|
||||||
# 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,
|
|
||||||
claims=claims,
|
|
||||||
dropped_claims=dropped,
|
|
||||||
needs_check=needs_check,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _generate_url(self) -> str:
|
def _generate_url(self) -> str:
|
||||||
if self.base_url.endswith("/api"):
|
if self.base_url.endswith("/api"):
|
||||||
|
|
@ -166,7 +67,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:
|
||||||
result = self._summarize(evidence)
|
summary = self.client.summarize_evidence(evidence)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return Evidence(
|
return Evidence(
|
||||||
source=EvidenceSource.ENRICHMENT_FAILURE,
|
source=EvidenceSource.ENRICHMENT_FAILURE,
|
||||||
|
|
@ -175,42 +76,8 @@ class InternalLlmAssistant:
|
||||||
data={"submission_id": submission_id},
|
data={"submission_id": submission_id},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Provenance for every summary row: which model and which prompt made it,
|
source_urls = _source_urls(evidence)
|
||||||
# and how long the operator would have waited. Without these, swapping the
|
source_evidence_ids = _source_evidence_ids(evidence)
|
||||||
# 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,
|
|
||||||
# How much of what the model said survived citation checking. A
|
|
||||||
# rising dropped count is the signal that the model or the prompt
|
|
||||||
# has stopped working — without it, fabrication is invisible.
|
|
||||||
"grounded_claims": len(result.claims),
|
|
||||||
"dropped_claims": result.dropped_claims,
|
|
||||||
}
|
|
||||||
|
|
||||||
# The linked sources must be what the summary actually rests on, not the
|
|
||||||
# whole case. Deriving them from the full evidence list was the original
|
|
||||||
# defect: the prompt is capped at MAX_PROMPT_EVIDENCE, so a case with 79
|
|
||||||
# rows produced a summary read from 12 while the UI reported 78 "linked
|
|
||||||
# sources". The policy check passed and the number was fiction.
|
|
||||||
#
|
|
||||||
# Cited rows first (what the verified claims point at); the shown rows as
|
|
||||||
# a fallback when nothing verified, so the row is still storable and the
|
|
||||||
# count still describes something the model actually read.
|
|
||||||
shown = _select_prompt_evidence(evidence)
|
|
||||||
labels = _evidence_labels(shown)
|
|
||||||
cited_labels = {
|
|
||||||
label for claim in result.claims for label in claim.get("evidence", [])
|
|
||||||
}
|
|
||||||
cited = [labels[label] for label in sorted(cited_labels) if label in labels]
|
|
||||||
grounded = bool(cited)
|
|
||||||
source_evidence = cited or shown
|
|
||||||
|
|
||||||
source_urls = _source_urls(source_evidence)
|
|
||||||
source_evidence_ids = _source_evidence_ids(source_evidence)
|
|
||||||
if not source_urls and not source_evidence_ids:
|
if not source_urls and not source_evidence_ids:
|
||||||
return Evidence(
|
return Evidence(
|
||||||
source=EvidenceSource.LLM_SUMMARY,
|
source=EvidenceSource.LLM_SUMMARY,
|
||||||
|
|
@ -218,11 +85,10 @@ class InternalLlmAssistant:
|
||||||
confidence=0.0,
|
confidence=0.0,
|
||||||
data={
|
data={
|
||||||
"submission_id": submission_id,
|
"submission_id": submission_id,
|
||||||
"summary": result.text,
|
"summary": summary,
|
||||||
"source_urls": [],
|
"source_urls": [],
|
||||||
"source_evidence_ids": [],
|
"source_evidence_ids": [],
|
||||||
"verified": False,
|
"verified": False,
|
||||||
**stamp,
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -232,30 +98,13 @@ class InternalLlmAssistant:
|
||||||
confidence=0.0,
|
confidence=0.0,
|
||||||
data={
|
data={
|
||||||
"submission_id": submission_id,
|
"submission_id": submission_id,
|
||||||
"summary": result.text,
|
"summary": summary,
|
||||||
"source_urls": source_urls,
|
"source_urls": source_urls,
|
||||||
"source_evidence_ids": source_evidence_ids,
|
"source_evidence_ids": source_evidence_ids,
|
||||||
# True only when a claim survived citation checking. Previously
|
"verified": True,
|
||||||
# this was True whenever any source existed, which made it a
|
|
||||||
# restatement of "the case has evidence" rather than a statement
|
|
||||||
# about the summary.
|
|
||||||
"verified": grounded,
|
|
||||||
**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] = []
|
||||||
|
|
@ -276,149 +125,13 @@ def _source_evidence_ids(evidence: list[Evidence]) -> list[str]:
|
||||||
return ids
|
return ids
|
||||||
|
|
||||||
|
|
||||||
def _prompt_priority(item: Evidence) -> int:
|
def _evidence_prompt(evidence: list[Evidence]) -> str:
|
||||||
"""Lower sorts first. Keeps the rows that carry risk when the cap bites."""
|
|
||||||
data = item.data
|
|
||||||
if item.source == EvidenceSource.FINGERPRINT:
|
|
||||||
try:
|
|
||||||
if float(data.get("similarity") or 0) >= 0.9:
|
|
||||||
return 0
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
pass
|
|
||||||
return 3
|
|
||||||
if item.source == EvidenceSource.WEB_DETECTION:
|
|
||||||
return 1 if data.get("match") in {"full", "partial", "page"} else 3
|
|
||||||
if item.source == EvidenceSource.NAVER_SEARCH:
|
|
||||||
return 1 if data.get("promoted") else 3
|
|
||||||
if item.source == EvidenceSource.FACE_PERSON:
|
|
||||||
return 2
|
|
||||||
return 4
|
|
||||||
|
|
||||||
|
|
||||||
def _select_prompt_evidence(evidence: list[Evidence]) -> list[Evidence]:
|
|
||||||
ordered = sorted(
|
|
||||||
(item for item in evidence if item.source != EvidenceSource.LLM_SUMMARY),
|
|
||||||
key=lambda item: (_prompt_priority(item), -float(item.confidence or 0)),
|
|
||||||
)
|
|
||||||
return ordered[:MAX_PROMPT_EVIDENCE]
|
|
||||||
|
|
||||||
|
|
||||||
def _evidence_labels(selected: list[Evidence]) -> dict[str, Evidence]:
|
|
||||||
return {
|
|
||||||
f"{EVIDENCE_LABEL_PREFIX}{index}": item
|
|
||||||
for index, item in enumerate(selected, start=1)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _evidence_prompt(selected: list[Evidence]) -> str:
|
|
||||||
lines = ["Source evidence:"]
|
lines = ["Source evidence:"]
|
||||||
for label, item in _evidence_labels(selected).items():
|
for index, item in enumerate(evidence, start=1):
|
||||||
fields = [f"source={item.source.value}", f"reason={item.reason}"]
|
lines.append(
|
||||||
for key in PROMPT_DATA_FIELDS:
|
f"{index}. source={item.source.value}; reason={item.reason}; "
|
||||||
value = item.data.get(key)
|
f"confidence={item.confidence}; data={item.data}"
|
||||||
if value not in (None, "", [], {}):
|
)
|
||||||
fields.append(f"{key}={value}")
|
|
||||||
lines.append(f"[{label}] " + "; ".join(fields))
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
|
|
||||||
def _verified_claims(
|
|
||||||
raw: str, valid_labels: set[str]
|
|
||||||
) -> tuple[tuple[dict[str, Any], ...], int, tuple[str, ...]]:
|
|
||||||
"""Keep only the claims that cite evidence the model was actually given.
|
|
||||||
|
|
||||||
Verification is a set-membership test on a JSON field, not an attempt to
|
|
||||||
parse prose. An earlier design checked entities (URLs, numbers) inside free
|
|
||||||
text, which passes any confident-sounding sentence that happens to mention a
|
|
||||||
real URL — and so stamps "근거 확인됨" onto exactly the hallucinations it was
|
|
||||||
meant to catch.
|
|
||||||
"""
|
|
||||||
document = _load_json_object(raw)
|
|
||||||
if document is None:
|
|
||||||
logger.warning("LLM returned unparseable output; dropping the summary")
|
|
||||||
return (), 0, ()
|
|
||||||
|
|
||||||
claims: list[dict[str, Any]] = []
|
|
||||||
dropped = 0
|
|
||||||
for entry in document.get("claims") or []:
|
|
||||||
if not isinstance(entry, dict):
|
|
||||||
dropped += 1
|
|
||||||
continue
|
|
||||||
text = str(entry.get("text", "")).strip()
|
|
||||||
cited = _normalized_labels(entry.get("evidence"))
|
|
||||||
grounded = [label for label in cited if label in valid_labels]
|
|
||||||
if not text or not grounded or _is_template_echo(text):
|
|
||||||
# No text, every cited label was invented, or the model copied the
|
|
||||||
# shape example through instead of filling it in. Either way there is
|
|
||||||
# nothing the operator can check it against.
|
|
||||||
dropped += 1
|
|
||||||
continue
|
|
||||||
claims.append({"text": text, "evidence": grounded})
|
|
||||||
|
|
||||||
needs_check = tuple(
|
|
||||||
str(item).strip()
|
|
||||||
for item in (document.get("needs_check") or [])
|
|
||||||
if str(item).strip() and not _is_template_echo(str(item))
|
|
||||||
)
|
|
||||||
return tuple(claims), dropped, needs_check
|
|
||||||
|
|
||||||
|
|
||||||
def _is_template_echo(text: str) -> bool:
|
|
||||||
"""True when the model returned our own shape example instead of content.
|
|
||||||
|
|
||||||
Citation checking alone does not catch this: qwen2.5:0.5b echoed the
|
|
||||||
placeholder as the claim text while citing every real label, so the claim was
|
|
||||||
perfectly "grounded" and completely empty. The placeholder is ours, so the
|
|
||||||
check can be exact rather than heuristic.
|
|
||||||
"""
|
|
||||||
stripped = text.strip()
|
|
||||||
if CLAIM_TEXT_PLACEHOLDER in stripped or NEEDS_CHECK_PLACEHOLDER in stripped:
|
|
||||||
return True
|
|
||||||
# Any other angle-bracket-wrapped stand-in is the same failure.
|
|
||||||
return stripped.startswith("<") and stripped.endswith(">")
|
|
||||||
|
|
||||||
|
|
||||||
def _load_json_object(raw: str) -> dict[str, Any] | None:
|
|
||||||
text = (raw or "").strip()
|
|
||||||
if not text:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
parsed = json.loads(text)
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
# Some models wrap the object in prose despite format=json. Take the
|
|
||||||
# outermost brace pair rather than giving up.
|
|
||||||
start, end = text.find("{"), text.rfind("}")
|
|
||||||
if start < 0 or end <= start:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
parsed = json.loads(text[start : end + 1])
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
return None
|
|
||||||
return parsed if isinstance(parsed, dict) else None
|
|
||||||
|
|
||||||
|
|
||||||
def _normalized_labels(value: Any) -> list[str]:
|
|
||||||
if isinstance(value, str):
|
|
||||||
value = [value]
|
|
||||||
if not isinstance(value, (list, tuple)):
|
|
||||||
return []
|
|
||||||
return [str(item).strip().strip("[]").upper() for item in value if str(item).strip()]
|
|
||||||
|
|
||||||
|
|
||||||
def _render_summary(
|
|
||||||
claims: tuple[dict[str, Any], ...], needs_check: tuple[str, ...]
|
|
||||||
) -> str:
|
|
||||||
if not claims and not needs_check:
|
|
||||||
return EMPTY_SUMMARY_TEXT
|
|
||||||
|
|
||||||
lines: list[str] = [SUMMARY_HEADING]
|
|
||||||
for claim in claims:
|
|
||||||
lines.append(f"- {claim['text']} [{', '.join(claim['evidence'])}]")
|
|
||||||
if not claims:
|
|
||||||
lines.append("- 인용이 확인된 근거가 없습니다.")
|
|
||||||
if needs_check:
|
|
||||||
lines.append(NEEDS_CHECK_HEADING)
|
|
||||||
lines.extend(f"- {item}" for item in needs_check)
|
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,59 +0,0 @@
|
||||||
"""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-v3-structured"
|
|
||||||
|
|
||||||
# Marks rows written before prompt versioning existed. Never assume the text
|
|
||||||
# below produced them.
|
|
||||||
LEGACY_PROMPT_VERSION = "legacy"
|
|
||||||
|
|
||||||
# Evidence is labelled [E1]..[En] in the prompt and the model cites those labels
|
|
||||||
# rather than the real evidence ids. The ids are 24-hex-char hashes; asking a
|
|
||||||
# small model to echo them verbatim invites transcription errors that are
|
|
||||||
# indistinguishable from fabricated citations.
|
|
||||||
EVIDENCE_LABEL_PREFIX = "E"
|
|
||||||
|
|
||||||
# Asking for JSON rather than prose is what makes grounding checkable. A claim
|
|
||||||
# carries the labels it rests on as a field, so verification is a set-membership
|
|
||||||
# test on that field — not an attempt to parse Korean sentences and guess which
|
|
||||||
# words a trailing citation covers.
|
|
||||||
# The placeholders used in the shape example below. A small model can copy them
|
|
||||||
# through verbatim instead of filling them in — observed with qwen2.5:0.5b, which
|
|
||||||
# returned "<한국어 문장>" as the claim text while citing every real label, so the
|
|
||||||
# citation check passed it. Naming them here lets the verifier reject the echo.
|
|
||||||
CLAIM_TEXT_PLACEHOLDER = "<한국어 문장>"
|
|
||||||
NEEDS_CHECK_PLACEHOLDER = "<한국어 구절>"
|
|
||||||
|
|
||||||
SYSTEM_PROMPT = (
|
|
||||||
"You draft review notes for an internal image-rights operator, grounded "
|
|
||||||
"ONLY in the labelled source evidence given to you.\n"
|
|
||||||
"Reply with a JSON object and nothing else, in exactly this shape:\n"
|
|
||||||
'{"claims": [{"text": "' + CLAIM_TEXT_PLACEHOLDER + '", "evidence": ["E1"]}], '
|
|
||||||
'"needs_check": ["' + NEEDS_CHECK_PLACEHOLDER + '"]}\n'
|
|
||||||
"Rules:\n"
|
|
||||||
"- Write every 'text' value in Korean.\n"
|
|
||||||
"- Every claim MUST cite at least one evidence label it rests on, using "
|
|
||||||
"only the labels provided. Never invent a label.\n"
|
|
||||||
"- Never assert anything the cited evidence does not support. If the "
|
|
||||||
"evidence is thin, say so plainly in a claim citing what little exists.\n"
|
|
||||||
"- Do NOT state or recommend a final decision (approve / hold / reject).\n"
|
|
||||||
"- 'needs_check' lists what the operator should verify before deciding.\n"
|
|
||||||
"- At most 4 claims and 4 needs_check items."
|
|
||||||
)
|
|
||||||
|
|
||||||
# Rendered headings. Kept here so the prompt contract and the text the operator
|
|
||||||
# reads move together.
|
|
||||||
SUMMARY_HEADING = "요약:"
|
|
||||||
NEEDS_CHECK_HEADING = "확인 필요:"
|
|
||||||
EMPTY_SUMMARY_TEXT = "요약: 근거를 요약할 수 없습니다."
|
|
||||||
|
|
@ -1,279 +0,0 @@
|
||||||
"""Where did this image come from — read from the file itself, no model.
|
|
||||||
|
|
||||||
Two signals share one evidence source because they answer the same operator
|
|
||||||
question ("이 이미지가 어디서 왔나") and because a new EvidenceSource costs a
|
|
||||||
dozen plumbing edits; paying that once for both is the point.
|
|
||||||
|
|
||||||
1. Rights metadata. The copyright holder often writes the answer into the file
|
|
||||||
(EXIF Copyright/Artist, IPTC CopyrightNotice/Credit/Source). The pipeline
|
|
||||||
destroys all metadata when it builds the external derivative, and nothing
|
|
||||||
read it first — so the most authoritative field available was discarded
|
|
||||||
unread on every submission. This is transcription, not inference.
|
|
||||||
|
|
||||||
2. Screen-capture forensics. Deterministic image processing only: letterbox
|
|
||||||
bars, a subtitle band, broadcast frame sizes. No model, no weights. Tuned for
|
|
||||||
precision over recall — it ships as a zero-point label, so a miss costs
|
|
||||||
nothing while a false label costs operator trust before there is any
|
|
||||||
calibration data to correct it.
|
|
||||||
|
|
||||||
Both are read from the ORIGINAL bytes, so the hook has to sit where the original
|
|
||||||
is still in hand (InternalAnalyzer.analyze), not after derivative building.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from io import BytesIO
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from rights_filter.domain.records import Evidence, EvidenceSource
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# Operator-facing names for the rights fields.
|
|
||||||
RIGHTS_FIELD_LABELS = {
|
|
||||||
"copyright": "저작권",
|
|
||||||
"artist": "작성자",
|
|
||||||
"copyright_notice": "저작권 고지",
|
|
||||||
"credit": "크레딧",
|
|
||||||
"source": "출처",
|
|
||||||
"byline": "작성자",
|
|
||||||
}
|
|
||||||
|
|
||||||
# EXIF tags that state rights. Deliberately short: GPS, camera serial, owner
|
|
||||||
# name and lens data are NOT read, let alone stored. The whitelist is the
|
|
||||||
# privacy boundary, so it lives as a constant and is pinned by a test.
|
|
||||||
RIGHTS_EXIF_TAGS = {
|
|
||||||
0x8298: "copyright",
|
|
||||||
0x013B: "artist",
|
|
||||||
}
|
|
||||||
|
|
||||||
# IPTC IIM datasets, keyed (record, dataset).
|
|
||||||
RIGHTS_IPTC_TAGS = {
|
|
||||||
(2, 116): "copyright_notice",
|
|
||||||
(2, 110): "credit",
|
|
||||||
(2, 115): "source",
|
|
||||||
(2, 80): "byline",
|
|
||||||
}
|
|
||||||
|
|
||||||
# Frame sizes that essentially only occur as broadcast / screen output. On their
|
|
||||||
# own they mean little, which is why geometry alone never triggers the label.
|
|
||||||
BROADCAST_FRAME_SIZES = {
|
|
||||||
(1920, 1080),
|
|
||||||
(1280, 720),
|
|
||||||
(3840, 2160),
|
|
||||||
(720, 480),
|
|
||||||
(1440, 1080),
|
|
||||||
}
|
|
||||||
|
|
||||||
# A letterbox bar has to be this dark and this uniform to count.
|
|
||||||
LETTERBOX_MAX_MEAN = 24.0
|
|
||||||
LETTERBOX_MAX_STD = 8.0
|
|
||||||
# ...and cover at least this share of the frame height, top and bottom.
|
|
||||||
LETTERBOX_MIN_BAR_RATIO = 0.04
|
|
||||||
|
|
||||||
# Subtitle band: the bottom strip carries far more edge energy than the frame as
|
|
||||||
# a whole when burned-in text is present.
|
|
||||||
SUBTITLE_BAND_TOP = 0.72
|
|
||||||
SUBTITLE_EDGE_RATIO = 2.5
|
|
||||||
# Floor only — "there is actually something in the band", so an almost featureless
|
|
||||||
# frame cannot pass on a ratio computed from near-zero noise. The RATIO is the
|
|
||||||
# discriminator; a high floor here would reject real single-line subtitles, which
|
|
||||||
# occupy a small fraction of a band this tall.
|
|
||||||
SUBTITLE_MIN_EDGE_DENSITY = 0.01
|
|
||||||
|
|
||||||
# Independent evidence categories required before the label is emitted. Geometry
|
|
||||||
# (letterbox + broadcast size) is ONE category: a letterboxed 1920x1080 frame is
|
|
||||||
# one observation stated twice, and counting it twice would let a cinematic crop
|
|
||||||
# alone trigger the label.
|
|
||||||
MIN_CAPTURE_CATEGORIES = 2
|
|
||||||
|
|
||||||
|
|
||||||
def extract_rights_metadata(content: bytes) -> dict[str, str]:
|
|
||||||
"""Rights fields the author wrote into the file. Empty dict when there are none."""
|
|
||||||
if not content:
|
|
||||||
return {}
|
|
||||||
try:
|
|
||||||
from PIL import Image
|
|
||||||
except Exception:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
found: dict[str, str] = {}
|
|
||||||
try:
|
|
||||||
with Image.open(BytesIO(content)) as opened:
|
|
||||||
found.update(_exif_rights(opened))
|
|
||||||
found.update(_iptc_rights(opened))
|
|
||||||
except Exception:
|
|
||||||
# Unreadable container (e.g. .svg, which the store accepts but Pillow
|
|
||||||
# cannot open) is "no metadata", not an error worth failing analysis for.
|
|
||||||
logger.debug("rights metadata extraction skipped", exc_info=True)
|
|
||||||
return {}
|
|
||||||
return {key: value for key, value in found.items() if value}
|
|
||||||
|
|
||||||
|
|
||||||
def _exif_rights(opened: Any) -> dict[str, str]:
|
|
||||||
try:
|
|
||||||
exif = opened.getexif()
|
|
||||||
except Exception:
|
|
||||||
return {}
|
|
||||||
if not exif:
|
|
||||||
return {}
|
|
||||||
values: dict[str, str] = {}
|
|
||||||
for tag, name in RIGHTS_EXIF_TAGS.items():
|
|
||||||
value = exif.get(tag)
|
|
||||||
if value:
|
|
||||||
values[name] = _clean_text(value)
|
|
||||||
return values
|
|
||||||
|
|
||||||
|
|
||||||
def _iptc_rights(opened: Any) -> dict[str, str]:
|
|
||||||
try:
|
|
||||||
from PIL import IptcImagePlugin
|
|
||||||
|
|
||||||
info = IptcImagePlugin.getiptcinfo(opened)
|
|
||||||
except Exception:
|
|
||||||
return {}
|
|
||||||
if not info:
|
|
||||||
return {}
|
|
||||||
values: dict[str, str] = {}
|
|
||||||
for key, name in RIGHTS_IPTC_TAGS.items():
|
|
||||||
value = info.get(key)
|
|
||||||
if isinstance(value, (list, tuple)):
|
|
||||||
value = value[0] if value else None
|
|
||||||
if value:
|
|
||||||
values[name] = _clean_text(value)
|
|
||||||
return values
|
|
||||||
|
|
||||||
|
|
||||||
def _clean_text(value: Any) -> str:
|
|
||||||
if isinstance(value, bytes):
|
|
||||||
value = value.decode("utf-8", errors="replace")
|
|
||||||
return " ".join(str(value).split())[:200]
|
|
||||||
|
|
||||||
|
|
||||||
def detect_screen_capture(content: bytes, width: int = 0, height: int = 0) -> dict[str, Any]:
|
|
||||||
"""Deterministic screen/broadcast capture signals. No model involved."""
|
|
||||||
decoded = _decode_gray(content)
|
|
||||||
if decoded is None:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
frame_height, frame_width = decoded.shape[:2]
|
|
||||||
categories: dict[str, Any] = {}
|
|
||||||
|
|
||||||
geometry: list[str] = []
|
|
||||||
if _has_letterbox(decoded):
|
|
||||||
geometry.append("letterbox")
|
|
||||||
if (frame_width, frame_height) in BROADCAST_FRAME_SIZES:
|
|
||||||
geometry.append("broadcast_frame_size")
|
|
||||||
if geometry:
|
|
||||||
categories["geometry"] = geometry
|
|
||||||
|
|
||||||
if _has_subtitle_band(decoded):
|
|
||||||
categories["subtitle_band"] = True
|
|
||||||
|
|
||||||
if len(categories) < MIN_CAPTURE_CATEGORIES:
|
|
||||||
return {}
|
|
||||||
return {
|
|
||||||
"categories": sorted(categories),
|
|
||||||
"detail": categories,
|
|
||||||
"frame": f"{frame_width} x {frame_height}",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _decode_gray(content: bytes) -> Any | None:
|
|
||||||
if not content:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
import cv2
|
|
||||||
import numpy as np
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
decoded = cv2.imdecode(np.frombuffer(content, dtype=np.uint8), cv2.IMREAD_GRAYSCALE)
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
if decoded is None or decoded.size == 0:
|
|
||||||
return None
|
|
||||||
return decoded
|
|
||||||
|
|
||||||
|
|
||||||
def _has_letterbox(gray: Any) -> bool:
|
|
||||||
height = gray.shape[0]
|
|
||||||
bar = max(1, int(height * LETTERBOX_MIN_BAR_RATIO))
|
|
||||||
if height <= bar * 3:
|
|
||||||
return False
|
|
||||||
top, bottom = gray[:bar], gray[-bar:]
|
|
||||||
return _is_flat_dark(top) and _is_flat_dark(bottom)
|
|
||||||
|
|
||||||
|
|
||||||
def _is_flat_dark(band: Any) -> bool:
|
|
||||||
return float(band.mean()) <= LETTERBOX_MAX_MEAN and float(band.std()) <= LETTERBOX_MAX_STD
|
|
||||||
|
|
||||||
|
|
||||||
def _has_subtitle_band(gray: Any) -> bool:
|
|
||||||
try:
|
|
||||||
import cv2
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
height = gray.shape[0]
|
|
||||||
start = int(height * SUBTITLE_BAND_TOP)
|
|
||||||
if height - start < 8:
|
|
||||||
return False
|
|
||||||
edges = cv2.Canny(gray, 100, 200)
|
|
||||||
band_density = float(edges[start:].mean()) / 255.0
|
|
||||||
frame_density = float(edges.mean()) / 255.0
|
|
||||||
if band_density < SUBTITLE_MIN_EDGE_DENSITY:
|
|
||||||
return False
|
|
||||||
if frame_density <= 0:
|
|
||||||
return False
|
|
||||||
return (band_density / frame_density) >= SUBTITLE_EDGE_RATIO
|
|
||||||
|
|
||||||
|
|
||||||
def provenance_evidence(content: bytes) -> list[Evidence]:
|
|
||||||
"""Both provenance signals for one image, as evidence rows.
|
|
||||||
|
|
||||||
Emitted at zero score (see RiskScorer): they ship before any calibration
|
|
||||||
data exists, so no weight would be defensible yet. The operator sees them
|
|
||||||
and decides; decision_stats later shows whether they predict rejections.
|
|
||||||
"""
|
|
||||||
evidence: list[Evidence] = []
|
|
||||||
|
|
||||||
rights = extract_rights_metadata(content)
|
|
||||||
if rights:
|
|
||||||
evidence.append(
|
|
||||||
Evidence(
|
|
||||||
source=EvidenceSource.PROVENANCE,
|
|
||||||
reason=_rights_reason(rights),
|
|
||||||
# Transcription, not inference — the field either is in the file
|
|
||||||
# or it is not. That the field itself may be wrong is a separate
|
|
||||||
# question, which is why it is labelled self-reported.
|
|
||||||
confidence=1.0,
|
|
||||||
data={"provenance_kind": "rights_metadata", "rights_fields": rights},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
capture = detect_screen_capture(content)
|
|
||||||
if capture:
|
|
||||||
evidence.append(
|
|
||||||
Evidence(
|
|
||||||
source=EvidenceSource.PROVENANCE,
|
|
||||||
reason=f"화면/방송 캡처 추정 ({'·'.join(capture['categories'])})",
|
|
||||||
confidence=0.6,
|
|
||||||
data={
|
|
||||||
"provenance_kind": "screen_capture",
|
|
||||||
"capture_categories": capture["categories"],
|
|
||||||
"frame": capture["frame"],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return evidence
|
|
||||||
|
|
||||||
|
|
||||||
def _rights_reason(rights: dict[str, str]) -> str:
|
|
||||||
parts = [
|
|
||||||
f"{RIGHTS_FIELD_LABELS.get(key, key)} {value}"
|
|
||||||
for key, value in rights.items()
|
|
||||||
]
|
|
||||||
# Self-reported is not a hedge, it is the accurate description: metadata is
|
|
||||||
# trivially forgeable, so this must never read as proof of clearance.
|
|
||||||
return "원본 파일 권리 표기(자기신고): " + " / ".join(parts)
|
|
||||||
|
|
@ -1,17 +1,7 @@
|
||||||
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:
|
||||||
|
|
@ -27,15 +17,6 @@ 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
|
||||||
|
|
@ -51,7 +32,7 @@ class RiskScorer:
|
||||||
if counted_face_person:
|
if counted_face_person:
|
||||||
continue
|
continue
|
||||||
counted_face_person = True
|
counted_face_person = True
|
||||||
score += _face_person_points(item)
|
score += 35
|
||||||
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)
|
||||||
|
|
@ -77,15 +58,6 @@ class RiskScorer:
|
||||||
reasons.append(item.reason)
|
reasons.append(item.reason)
|
||||||
if bucket:
|
if bucket:
|
||||||
counted_naver_buckets.add(bucket)
|
counted_naver_buckets.add(bucket)
|
||||||
elif item.source == EvidenceSource.PROVENANCE:
|
|
||||||
# Zero-point label by design. These signals ship before any
|
|
||||||
# calibration data exists, so no weight would be defensible yet:
|
|
||||||
# measure how they correlate with real rejections in
|
|
||||||
# decision_stats first, then assign points. Surface the reason so
|
|
||||||
# the operator can act on it even at zero.
|
|
||||||
if item.reason:
|
|
||||||
reasons.append(item.reason)
|
|
||||||
continue
|
|
||||||
elif item.source == EvidenceSource.LLM_SUMMARY:
|
elif item.source == EvidenceSource.LLM_SUMMARY:
|
||||||
continue
|
continue
|
||||||
elif item.source in {
|
elif item.source in {
|
||||||
|
|
@ -118,21 +90,6 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -16,10 +16,6 @@ def _new_id(prefix: str) -> str:
|
||||||
class EvidenceSource(StrEnum):
|
class EvidenceSource(StrEnum):
|
||||||
FINGERPRINT = "fingerprint"
|
FINGERPRINT = "fingerprint"
|
||||||
FACE_PERSON = "face_person"
|
FACE_PERSON = "face_person"
|
||||||
# Where the image came from, read from the file itself: rights metadata the
|
|
||||||
# author embedded, and deterministic screen-capture signals. One source for
|
|
||||||
# both because they answer the same operator question.
|
|
||||||
PROVENANCE = "provenance"
|
|
||||||
WEB_DETECTION = "web_detection"
|
WEB_DETECTION = "web_detection"
|
||||||
NAVER_SEARCH = "naver_search"
|
NAVER_SEARCH = "naver_search"
|
||||||
LLM_SUMMARY = "llm_summary"
|
LLM_SUMMARY = "llm_summary"
|
||||||
|
|
@ -37,10 +33,6 @@ 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"
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -66,16 +58,6 @@ 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"
|
||||||
# Rights fields transcribed from the submitted file (EXIF/IPTC) plus
|
|
||||||
# deterministic capture signals. Self-reported by whoever made the file, so
|
|
||||||
# it is treated as a claim to verify, never as proof.
|
|
||||||
RIGHTS_METADATA = "rights_metadata"
|
|
||||||
# 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,22 +76,6 @@ 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.RIGHTS_METADATA: DataClassPolicy(
|
|
||||||
{"operator", "admin"},
|
|
||||||
"evidence retention policy",
|
|
||||||
"delete with analysis evidence policy",
|
|
||||||
"re-extract from the corrected original",
|
|
||||||
),
|
|
||||||
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",
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -99,80 +83,12 @@ 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:
|
||||||
if _BIOMETRIC_KEYS.intersection(payload):
|
forbidden_keys = {"embedding", "face_embedding", "biometric_template"}
|
||||||
|
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:
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,7 @@ from typing import Any, Mapping
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
from rights_filter.analysis.llm_assistance import (
|
from rights_filter.analysis.llm_assistance import InternalLlmAssistant, OllamaGenerateLlmClient
|
||||||
DEFAULT_MODEL,
|
|
||||||
InternalLlmAssistant,
|
|
||||||
OllamaGenerateLlmClient,
|
|
||||||
)
|
|
||||||
from rights_filter.integrations.cloud_vision_web_detection import (
|
from rights_filter.integrations.cloud_vision_web_detection import (
|
||||||
CloudVisionWebDetectionAdapter,
|
CloudVisionWebDetectionAdapter,
|
||||||
GoogleVisionRestClient,
|
GoogleVisionRestClient,
|
||||||
|
|
@ -24,17 +20,6 @@ from rights_filter.integrations.google_custom_search import (
|
||||||
from rights_filter.integrations.naver_search import NaverOpenApiSearchClient, NaverSearchAdapter
|
from rights_filter.integrations.naver_search import NaverOpenApiSearchClient, NaverSearchAdapter
|
||||||
from rights_filter.integrations.search_policy import SearchApiPolicy
|
from rights_filter.integrations.search_policy import SearchApiPolicy
|
||||||
|
|
||||||
# Chosen by measurement against real stored evidence (54 rows, 12 selected) on
|
|
||||||
# the GPU target, not by parameter count:
|
|
||||||
# qwen2.5:0.5b-instruct 55.7s — echoed the shape example as its claim text
|
|
||||||
# qwen2.5:7b 8.8s — held the contract, 2 grounded claims
|
|
||||||
# qwen3:8b 35.7s — held the contract but 4x slower (thinking)
|
|
||||||
# 7b is also already present on the build machine, so the default costs no
|
|
||||||
# additional model transfer across the air gap.
|
|
||||||
#
|
|
||||||
# Re-exported, not redeclared: the client owns the value so the two cannot drift.
|
|
||||||
DEFAULT_OLLAMA_MODEL = DEFAULT_MODEL
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ProviderRuntime:
|
class ProviderRuntime:
|
||||||
|
|
@ -193,7 +178,7 @@ def build_provider_runtime(
|
||||||
"llm",
|
"llm",
|
||||||
"Ollama local LLM",
|
"Ollama local LLM",
|
||||||
llm_assistant is not None,
|
llm_assistant is not None,
|
||||||
f"Ollama local API configured ({env.get('OLLAMA_MODEL', DEFAULT_OLLAMA_MODEL)})",
|
f"Ollama local API configured ({env.get('OLLAMA_MODEL', 'qwen2.5:0.5b-instruct')})",
|
||||||
llm_limit,
|
llm_limit,
|
||||||
"없음",
|
"없음",
|
||||||
"source-linked summary only",
|
"source-linked summary only",
|
||||||
|
|
@ -259,15 +244,8 @@ def _google_custom_search_adapter(
|
||||||
def _llm_assistant(env: Mapping[str, str], transport: Any | None) -> InternalLlmAssistant | None:
|
def _llm_assistant(env: Mapping[str, str], transport: Any | None) -> InternalLlmAssistant | None:
|
||||||
client = OllamaGenerateLlmClient(
|
client = OllamaGenerateLlmClient(
|
||||||
base_url=env.get("OLLAMA_BASE_URL", "http://127.0.0.1:11434"),
|
base_url=env.get("OLLAMA_BASE_URL", "http://127.0.0.1:11434"),
|
||||||
# 0.5B could not hold the output contract: it answered in English and
|
model=env.get("OLLAMA_MODEL", "qwen2.5:0.5b-instruct"),
|
||||||
# reprinted the evidence list instead of summarizing it. Reading evidence
|
|
||||||
# and citing it needs a 3B-class model.
|
|
||||||
model=env.get("OLLAMA_MODEL", DEFAULT_OLLAMA_MODEL),
|
|
||||||
transport=transport,
|
transport=transport,
|
||||||
timeout=_bounded_int(env.get("OLLAMA_TIMEOUT"), default=120, minimum=5, maximum=600),
|
|
||||||
num_ctx=_bounded_int(env.get("OLLAMA_NUM_CTX"), default=8192, minimum=1024, maximum=131072),
|
|
||||||
num_predict=_bounded_int(env.get("OLLAMA_NUM_PREDICT"), default=512, minimum=64, maximum=4096),
|
|
||||||
keep_alive=env.get("OLLAMA_KEEP_ALIVE", "10m"),
|
|
||||||
)
|
)
|
||||||
return InternalLlmAssistant(client)
|
return InternalLlmAssistant(client)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,12 @@ from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import os
|
import os
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from rights_filter.integrations.env_clients import build_provider_runtime
|
from rights_filter.integrations.env_clients import build_provider_runtime
|
||||||
from rights_filter.server.env_file import load_env_file
|
from rights_filter.server.env_file import load_env_file
|
||||||
from rights_filter.server.http_app import build_server
|
from rights_filter.server.http_app import build_server
|
||||||
from rights_filter.server.image_store import LocalSubmissionImageStore
|
from rights_filter.server.image_store import LocalSubmissionImageStore
|
||||||
from rights_filter.server.store_backup import restore_backup
|
|
||||||
from rights_filter.server.sqlite_store import CopyrighterStore
|
from rights_filter.server.sqlite_store import CopyrighterStore
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -26,24 +24,6 @@ def _provider_readiness(runtime) -> str:
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
# When the server is started as a service with stdout redirected to a file,
|
|
||||||
# Python switches to block buffering and the startup diagnostics below -
|
|
||||||
# including the "token not set" security warning - sit in the buffer
|
|
||||||
# indefinitely (the process never exits). Line-buffer so they reach the log
|
|
||||||
# immediately.
|
|
||||||
#
|
|
||||||
# errors="replace" is not cosmetic: the delivery target is a Korean Windows
|
|
||||||
# host whose console codepage is cp949, and a single character outside it
|
|
||||||
# (an em dash in a warning string was enough) raised UnicodeEncodeError from
|
|
||||||
# print() and killed the server before it ever bound the port. A diagnostic
|
|
||||||
# message must never be able to prevent startup - degrade the character, not
|
|
||||||
# the process.
|
|
||||||
try:
|
|
||||||
sys.stdout.reconfigure(line_buffering=True, errors="replace")
|
|
||||||
sys.stderr.reconfigure(errors="replace")
|
|
||||||
except (AttributeError, OSError, ValueError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(description="Run the local Copyrighter API server.")
|
parser = argparse.ArgumentParser(description="Run the local Copyrighter API server.")
|
||||||
parser.add_argument("--host", default="127.0.0.1")
|
parser.add_argument("--host", default="127.0.0.1")
|
||||||
parser.add_argument("--port", type=int, default=9500)
|
parser.add_argument("--port", type=int, default=9500)
|
||||||
|
|
@ -51,25 +31,12 @@ def main() -> None:
|
||||||
parser.add_argument("--images", default="data/submissions")
|
parser.add_argument("--images", default="data/submissions")
|
||||||
parser.add_argument("--static", default="web/operator-gui")
|
parser.add_argument("--static", default="web/operator-gui")
|
||||||
parser.add_argument("--env", default=".env")
|
parser.add_argument("--env", default=".env")
|
||||||
parser.add_argument(
|
|
||||||
"--restore",
|
|
||||||
metavar="SNAPSHOT_ZIP",
|
|
||||||
help="Restore a backup snapshot into --db and exit (server must be stopped).",
|
|
||||||
)
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
if args.restore:
|
|
||||||
# Restore is offline on purpose: swapping the database under a running
|
|
||||||
# server would leave open connections on a file that no longer exists.
|
|
||||||
result = restore_backup(args.restore, Path(args.db))
|
|
||||||
print(f"Restored database: {result['database']}")
|
|
||||||
print(f"Restored media files: {result['mediaFiles']}")
|
|
||||||
return
|
|
||||||
|
|
||||||
env_path = Path(args.env)
|
env_path = Path(args.env)
|
||||||
load_env_file(env_path, os.environ)
|
load_env_file(env_path, os.environ)
|
||||||
# Log the resolved env path (and whether it was found) so an operator running
|
# Log the resolved env path (and whether it was found) so an operator running
|
||||||
# from an unexpected directory can see which .env - if any - was actually
|
# from an unexpected directory can see which .env — if any — was actually
|
||||||
# loaded, rather than silently getting no token / disabled providers.
|
# loaded, rather than silently getting no token / disabled providers.
|
||||||
print(f".env: {env_path.resolve()} ({'loaded' if env_path.exists() else 'not found'})")
|
print(f".env: {env_path.resolve()} ({'loaded' if env_path.exists() else 'not found'})")
|
||||||
provider_runtime = build_provider_runtime(os.environ)
|
provider_runtime = build_provider_runtime(os.environ)
|
||||||
|
|
@ -81,17 +48,17 @@ def main() -> None:
|
||||||
|
|
||||||
auth_token = os.environ.get("COPYRIGHTER_AUTH_TOKEN", "").strip() or None
|
auth_token = os.environ.get("COPYRIGHTER_AUTH_TOKEN", "").strip() or None
|
||||||
if auth_token is None:
|
if auth_token is None:
|
||||||
print("WARNING: COPYRIGHTER_AUTH_TOKEN not set - data routes are unauthenticated.")
|
print("WARNING: COPYRIGHTER_AUTH_TOKEN not set — data routes are unauthenticated.")
|
||||||
elif len(auth_token) < 32:
|
elif len(auth_token) < 32:
|
||||||
print(
|
print(
|
||||||
"WARNING: COPYRIGHTER_AUTH_TOKEN is shorter than 32 characters - "
|
"WARNING: COPYRIGHTER_AUTH_TOKEN is shorter than 32 characters — "
|
||||||
"use a longer, high-entropy token (e.g. `python -c \"import secrets; print(secrets.token_urlsafe(32))\"`)."
|
"use a longer, high-entropy token (e.g. `python -c \"import secrets; print(secrets.token_urlsafe(32))\"`)."
|
||||||
)
|
)
|
||||||
|
|
||||||
static_path = Path(args.static)
|
static_path = Path(args.static)
|
||||||
if not static_path.exists():
|
if not static_path.exists():
|
||||||
print(
|
print(
|
||||||
f"WARNING: static GUI directory not found: {static_path.resolve()} - "
|
f"WARNING: static GUI directory not found: {static_path.resolve()} — "
|
||||||
"the operator UI will return 404. Pass --static or run from the repo root."
|
"the operator UI will return 404. Pass --static or run from the repo root."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ from http import HTTPStatus
|
||||||
from http.cookies import SimpleCookie
|
from http.cookies import SimpleCookie
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from urllib.parse import parse_qs, unquote, urlparse
|
from urllib.parse import unquote, urlparse
|
||||||
|
|
||||||
from rights_filter.server.image_store import LocalSubmissionImageStore, SUPPORTED_IMAGE_SUFFIXES
|
from rights_filter.server.image_store import LocalSubmissionImageStore, SUPPORTED_IMAGE_SUFFIXES
|
||||||
from rights_filter.server.sqlite_store import CopyrighterStore
|
from rights_filter.server.sqlite_store import CopyrighterStore
|
||||||
|
|
@ -114,21 +114,6 @@ def build_server(
|
||||||
self._json(store.providers())
|
self._json(store.providers())
|
||||||
elif path == "/api/audit-events":
|
elif path == "/api/audit-events":
|
||||||
self._json(store.audit_events())
|
self._json(store.audit_events())
|
||||||
elif path == "/api/decision-memo-presets":
|
|
||||||
self._json(store.decision_memo_presets())
|
|
||||||
elif path == "/api/decisions/export":
|
|
||||||
query = parse_qs(urlparse(self.path).query)
|
|
||||||
self._json(
|
|
||||||
store.export_decisions(
|
|
||||||
audience=(query.get("audience", ["internal"])[0]),
|
|
||||||
status=(query.get("status", [""])[0]),
|
|
||||||
since=(query.get("since", [""])[0]),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
elif path == "/api/admin/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/"):
|
||||||
|
|
@ -244,39 +229,6 @@ def build_server(
|
||||||
elif path.startswith("/api/knowledge/") and path.endswith("/reactivate"):
|
elif path.startswith("/api/knowledge/") and path.endswith("/reactivate"):
|
||||||
entry_id = unquote(path.split("/")[3])
|
entry_id = unquote(path.split("/")[3])
|
||||||
self._json(store.reactivate_knowledge_entry(entry_id, str(body.get("reason", ""))))
|
self._json(store.reactivate_knowledge_entry(entry_id, str(body.get("reason", ""))))
|
||||||
elif path == "/api/submissions/decision-batch":
|
|
||||||
raw_ids = body.get("submission_ids", [])
|
|
||||||
if not isinstance(raw_ids, list):
|
|
||||||
raise ValueError("submission_ids must be a list")
|
|
||||||
self._json(
|
|
||||||
store.record_decision_batch(
|
|
||||||
[str(value) for value in raw_ids],
|
|
||||||
str(body.get("decision", "")),
|
|
||||||
str(body.get("memo", "")),
|
|
||||||
active_store(),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
elif path == "/api/decision-memo-presets":
|
|
||||||
self._json(
|
|
||||||
store.save_decision_memo_preset(
|
|
||||||
str(body.get("label", "")), str(body.get("body", ""))
|
|
||||||
)
|
|
||||||
)
|
|
||||||
elif path.startswith("/api/decision-memo-presets/") and path.endswith("/delete"):
|
|
||||||
preset_id = unquote(path.split("/")[3])
|
|
||||||
self._json(store.delete_decision_memo_preset(preset_id))
|
|
||||||
elif path == "/api/admin/backup":
|
|
||||||
self._json(store.create_backup())
|
|
||||||
elif path.startswith("/api/submissions/") and path.endswith("/clear-reference"):
|
|
||||||
submission_id = unquote(path.split("/")[3])
|
|
||||||
self._json(
|
|
||||||
store.register_cleared_reference(
|
|
||||||
submission_id,
|
|
||||||
str(body.get("label", "")),
|
|
||||||
str(body.get("note", "")),
|
|
||||||
active_store(),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
elif path == "/api/providers/emergency-disable":
|
elif path == "/api/providers/emergency-disable":
|
||||||
self._json(store.emergency_disable_external_providers())
|
self._json(store.emergency_disable_external_providers())
|
||||||
else:
|
else:
|
||||||
|
|
@ -336,13 +288,7 @@ def build_server(
|
||||||
if not any(path.startswith(prefix) for prefix in _PROTECTED_PREFIXES):
|
if not any(path.startswith(prefix) for prefix in _PROTECTED_PREFIXES):
|
||||||
return True
|
return True
|
||||||
header = self.headers.get("Authorization", "")
|
header = self.headers.get("Authorization", "")
|
||||||
# Compare as bytes: hmac.compare_digest raises TypeError on str inputs
|
if hmac.compare_digest(header, f"Bearer {auth_token}"):
|
||||||
# containing non-ASCII, and that exception escapes before any response
|
|
||||||
# is written — an unauthenticated remote client could hang the request
|
|
||||||
# by sending a single non-ASCII byte in the header.
|
|
||||||
if hmac.compare_digest(
|
|
||||||
header.encode("utf-8", "replace"), f"Bearer {auth_token}".encode("utf-8")
|
|
||||||
):
|
|
||||||
# First header-authenticated request mints an HttpOnly session
|
# First header-authenticated request mints an HttpOnly session
|
||||||
# cookie so the browser can load <img>/media (which cannot send an
|
# cookie so the browser can load <img>/media (which cannot send an
|
||||||
# Authorization header) without ever putting the token in a URL.
|
# Authorization header) without ever putting the token in a URL.
|
||||||
|
|
@ -353,10 +299,7 @@ def build_server(
|
||||||
jar = SimpleCookie()
|
jar = SimpleCookie()
|
||||||
jar.load(cookie_header)
|
jar.load(cookie_header)
|
||||||
morsel = jar.get(_SESSION_COOKIE)
|
morsel = jar.get(_SESSION_COOKIE)
|
||||||
if morsel is not None and hmac.compare_digest(
|
if morsel is not None and hmac.compare_digest(morsel.value, _session_tag(auth_token)):
|
||||||
morsel.value.encode("utf-8", "replace"),
|
|
||||||
_session_tag(auth_token).encode("utf-8"),
|
|
||||||
):
|
|
||||||
# Cookie-authenticated state changes are CSRF-forgeable, so
|
# Cookie-authenticated state changes are CSRF-forgeable, so
|
||||||
# require a same-origin Origin/Referer for them.
|
# require a same-origin Origin/Referer for them.
|
||||||
if self.command in _STATE_CHANGING_METHODS and not self._origin_ok():
|
if self.command in _STATE_CHANGING_METHODS and not self._origin_ok():
|
||||||
|
|
@ -395,18 +338,9 @@ def build_server(
|
||||||
if not length:
|
if not length:
|
||||||
return {}
|
return {}
|
||||||
try:
|
try:
|
||||||
parsed = json.loads(self.rfile.read(length).decode("utf-8"))
|
return json.loads(self.rfile.read(length).decode("utf-8"))
|
||||||
except json.JSONDecodeError as exc:
|
except json.JSONDecodeError as exc:
|
||||||
raise ValueError("invalid JSON body") from exc
|
raise ValueError("invalid JSON body") from exc
|
||||||
except UnicodeDecodeError as exc:
|
|
||||||
raise ValueError("body must be UTF-8") from exc
|
|
||||||
# Every handler treats the body as a mapping (`payload.get(...)`). A
|
|
||||||
# valid-JSON list/string/null would sail past the decode and blow up
|
|
||||||
# with AttributeError inside the handler, which escapes the do_POST
|
|
||||||
# except-chain and closes the connection with no response at all.
|
|
||||||
if not isinstance(parsed, dict):
|
|
||||||
raise ValueError("JSON object body required")
|
|
||||||
return parsed
|
|
||||||
|
|
||||||
def _maybe_set_session_cookie(self) -> None:
|
def _maybe_set_session_cookie(self) -> None:
|
||||||
if getattr(self, "_issue_session_cookie", False):
|
if getattr(self, "_issue_session_cookie", False):
|
||||||
|
|
@ -440,14 +374,7 @@ def build_server(
|
||||||
if not path.exists() or path.is_dir():
|
if not path.exists() or path.is_dir():
|
||||||
self._json({"error": "not found"}, HTTPStatus.NOT_FOUND)
|
self._json({"error": "not found"}, HTTPStatus.NOT_FOUND)
|
||||||
return
|
return
|
||||||
# exists() -> read_bytes() is a TOCTOU window, and the file can also be
|
data = path.read_bytes()
|
||||||
# locked (Windows), deleted mid-request, or unreadable. Answer with a
|
|
||||||
# real status instead of letting OSError close the connection silently.
|
|
||||||
try:
|
|
||||||
data = path.read_bytes()
|
|
||||||
except OSError:
|
|
||||||
self._json({"error": "not found"}, HTTPStatus.NOT_FOUND)
|
|
||||||
return
|
|
||||||
content_type = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
|
content_type = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
|
||||||
if content_type.startswith("text/") or content_type in {"application/javascript", "application/json"}:
|
if content_type.startswith("text/") or content_type in {"application/javascript", "application/json"}:
|
||||||
content_type = f"{content_type}; charset=utf-8"
|
content_type = f"{content_type}; charset=utf-8"
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
|
@ -30,7 +29,6 @@ from rights_filter.server.store_constants import (
|
||||||
DEFAULT_QUERY_COVERAGE_WARN_THRESHOLD,
|
DEFAULT_QUERY_COVERAGE_WARN_THRESHOLD,
|
||||||
EVIDENCE_OPERATOR_STATUSES,
|
EVIDENCE_OPERATOR_STATUSES,
|
||||||
MAX_COVERAGE_THRESHOLD,
|
MAX_COVERAGE_THRESHOLD,
|
||||||
MAX_LLM_SUMMARIES_PER_SEED,
|
|
||||||
MIN_COVERAGE_THRESHOLD,
|
MIN_COVERAGE_THRESHOLD,
|
||||||
NON_CONTRIBUTING_OPERATOR_STATUSES,
|
NON_CONTRIBUTING_OPERATOR_STATUSES,
|
||||||
_bounded_int_env,
|
_bounded_int_env,
|
||||||
|
|
@ -40,13 +38,10 @@ from rights_filter.server.store_remote_fetch import (
|
||||||
_fetch_stylesheet_url_bytes,
|
_fetch_stylesheet_url_bytes,
|
||||||
_fetch_url_bytes,
|
_fetch_url_bytes,
|
||||||
)
|
)
|
||||||
from rights_filter.server.store_backup import StoreBackupMixin
|
|
||||||
from rights_filter.server.store_duplicates import StoreDuplicatesMixin
|
|
||||||
from rights_filter.server.store_enrichment import StoreEnrichmentMixin
|
from rights_filter.server.store_enrichment import StoreEnrichmentMixin
|
||||||
from rights_filter.server.store_operations import StoreOperationsMixin
|
from rights_filter.server.store_operations import StoreOperationsMixin
|
||||||
from rights_filter.server.store_persistence import StorePersistenceMixin
|
from rights_filter.server.store_persistence import StorePersistenceMixin
|
||||||
from rights_filter.server.store_queue import StoreQueueMixin
|
from rights_filter.server.store_queue import StoreQueueMixin
|
||||||
from rights_filter.server.store_workflow import StoreWorkflowMixin
|
|
||||||
from rights_filter.server.store_search_candidates import StoreSearchCandidatesMixin
|
from rights_filter.server.store_search_candidates import StoreSearchCandidatesMixin
|
||||||
from rights_filter.server.store_schema import (
|
from rights_filter.server.store_schema import (
|
||||||
_ensure_constrained_schema,
|
_ensure_constrained_schema,
|
||||||
|
|
@ -66,13 +61,6 @@ from rights_filter.server.store_serialization import (
|
||||||
_submission_search_hint_evidence,
|
_submission_search_hint_evidence,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# Audit event name for a cross-queue submission id collision (same file stem in
|
|
||||||
# two imported folders). Kept as a constant so the "log it once" lookup and the
|
|
||||||
# write cannot drift apart.
|
|
||||||
_QUEUE_ID_CONFLICT_EVENT = "Submission id conflict"
|
|
||||||
|
|
||||||
|
|
||||||
class CopyrighterStore(
|
class CopyrighterStore(
|
||||||
StorePersistenceMixin,
|
StorePersistenceMixin,
|
||||||
|
|
@ -80,9 +68,6 @@ class CopyrighterStore(
|
||||||
StoreSearchCandidatesMixin,
|
StoreSearchCandidatesMixin,
|
||||||
StoreEnrichmentMixin,
|
StoreEnrichmentMixin,
|
||||||
StoreOperationsMixin,
|
StoreOperationsMixin,
|
||||||
StoreDuplicatesMixin,
|
|
||||||
StoreWorkflowMixin,
|
|
||||||
StoreBackupMixin,
|
|
||||||
):
|
):
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
|
|
@ -222,10 +207,6 @@ class CopyrighterStore(
|
||||||
object_ref text not null default '',
|
object_ref text not null default '',
|
||||||
payload text not null check (json_valid(payload))
|
payload text not null check (json_valid(payload))
|
||||||
);
|
);
|
||||||
create table if not exists decision_memo_presets (
|
|
||||||
id text primary key,
|
|
||||||
payload text not null check (json_valid(payload))
|
|
||||||
);
|
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
_ensure_typed_columns(conn)
|
_ensure_typed_columns(conn)
|
||||||
|
|
@ -245,89 +226,14 @@ class CopyrighterStore(
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
def _submission_queue_owners(self, *, exclude_queue_id: str) -> dict[str, str]:
|
|
||||||
"""Map submission id -> owning queue id, for queues other than the given one."""
|
|
||||||
with self._connect() as conn:
|
|
||||||
rows = conn.execute(
|
|
||||||
"""
|
|
||||||
select id, queue_id from submissions
|
|
||||||
where queue_id is not null and queue_id != '' and queue_id != ?
|
|
||||||
""",
|
|
||||||
(exclude_queue_id,),
|
|
||||||
).fetchall()
|
|
||||||
return {str(row["id"]): str(row["queue_id"]) for row in rows}
|
|
||||||
|
|
||||||
def _note_queue_id_conflict(
|
|
||||||
self, submission_id: str, queue_id: str, owner_queue_id: str
|
|
||||||
) -> None:
|
|
||||||
logger.warning(
|
|
||||||
"Submission id %r in queue %s is already owned by queue %s — skipping "
|
|
||||||
"import to avoid overwriting the existing decision.",
|
|
||||||
submission_id,
|
|
||||||
queue_id,
|
|
||||||
owner_queue_id,
|
|
||||||
)
|
|
||||||
# Seeding runs on every reload/import/upload, so only record the conflict
|
|
||||||
# the first time it is seen for this submission instead of spamming the
|
|
||||||
# audit log on each pass.
|
|
||||||
with self._connect() as conn:
|
|
||||||
already_logged = conn.execute(
|
|
||||||
"select 1 from audit_events where event = ? and object_ref = ? limit 1",
|
|
||||||
(_QUEUE_ID_CONFLICT_EVENT, submission_id),
|
|
||||||
).fetchone()
|
|
||||||
if already_logged is not None:
|
|
||||||
return
|
|
||||||
self.add_audit_event(
|
|
||||||
"rights.system",
|
|
||||||
_QUEUE_ID_CONFLICT_EVENT,
|
|
||||||
submission_id,
|
|
||||||
(
|
|
||||||
f"동일 id가 다른 큐({owner_queue_id})에 이미 존재해 이 큐({queue_id})"
|
|
||||||
" 임포트를 건너뜀 — 파일명을 변경한 뒤 다시 불러오세요."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
def seed_from_image_store(self, image_store: LocalSubmissionImageStore) -> int:
|
def seed_from_image_store(self, image_store: LocalSubmissionImageStore) -> int:
|
||||||
# Serialize seeding so two concurrent reload/import/upload requests can't
|
# Serialize seeding so two concurrent reload/import/upload requests can't
|
||||||
# both classify the same record as new and double-run analysis, external
|
# both classify the same record as new and double-run analysis, external
|
||||||
# searches, and audit writes for it.
|
# searches, and audit writes for it.
|
||||||
with self._write_lock:
|
with self._write_lock:
|
||||||
created, queue_id = self._seed_from_image_store_locked(image_store)
|
return self._seed_from_image_store_locked(image_store)
|
||||||
# LLM narration calls out to Ollama: seconds per case with a real model,
|
|
||||||
# and a 2s connect timeout per case even when Ollama is not running.
|
|
||||||
# Doing it under _write_lock meant a folder reload blocked every other
|
|
||||||
# write for the whole batch — including record_decision, which takes the
|
|
||||||
# same lock. So the operator's decision button stalled behind narration
|
|
||||||
# of images they were not even looking at. Narration is only-if-missing
|
|
||||||
# and therefore safe to finish on a later reload.
|
|
||||||
if self._narrate_seeded_submissions(queue_id):
|
|
||||||
# Provider coverage is derived from the evidence rows and is computed
|
|
||||||
# inside the locked section — i.e. before narration now runs. Without
|
|
||||||
# this refresh the llm provider reports "not_run" for summaries that
|
|
||||||
# exist, which is the coverage panel lying about work it did.
|
|
||||||
with self._write_lock:
|
|
||||||
self._sync_submission_provider_state(queue_id=queue_id)
|
|
||||||
return created
|
|
||||||
|
|
||||||
def _narrate_seeded_submissions(self, queue_id: str) -> int:
|
def _seed_from_image_store_locked(self, image_store: LocalSubmissionImageStore) -> int:
|
||||||
if self.provider_runtime.llm_assistant is None:
|
|
||||||
return 0
|
|
||||||
narrated = 0
|
|
||||||
for submission in self._all("submissions", queue_id=queue_id):
|
|
||||||
if narrated >= MAX_LLM_SUMMARIES_PER_SEED:
|
|
||||||
logger.info(
|
|
||||||
"LLM narration capped at %s per seed; remaining submissions "
|
|
||||||
"will be narrated on the next reload",
|
|
||||||
MAX_LLM_SUMMARIES_PER_SEED,
|
|
||||||
)
|
|
||||||
break
|
|
||||||
if self._ensure_llm_summary(str(submission["id"]), only_if_missing=True):
|
|
||||||
narrated += 1
|
|
||||||
return narrated
|
|
||||||
|
|
||||||
def _seed_from_image_store_locked(
|
|
||||||
self, image_store: LocalSubmissionImageStore
|
|
||||||
) -> tuple[int, str]:
|
|
||||||
self.purge_expired_face_crops()
|
self.purge_expired_face_crops()
|
||||||
queue = self.ensure_queue(image_store.root)
|
queue = self.ensure_queue(image_store.root)
|
||||||
queue_id = str(queue["id"])
|
queue_id = str(queue["id"])
|
||||||
|
|
@ -341,34 +247,19 @@ class CopyrighterStore(
|
||||||
if "faceCrops" not in submission:
|
if "faceCrops" not in submission:
|
||||||
self._sync_face_crops(str(submission["id"]), image_store)
|
self._sync_face_crops(str(submission["id"]), image_store)
|
||||||
self._rescore_all_submissions(queue_id=queue_id)
|
self._rescore_all_submissions(queue_id=queue_id)
|
||||||
|
self._ensure_llm_summaries_for_existing_source_evidence(queue_id=queue_id)
|
||||||
self._sync_submission_provider_state(queue_id=queue_id)
|
self._sync_submission_provider_state(queue_id=queue_id)
|
||||||
|
|
||||||
existing_submission_ids = {
|
existing_submission_ids = {
|
||||||
submission["id"] for submission in self._all("submissions", queue_id=queue_id)
|
submission["id"] for submission in self._all("submissions", queue_id=queue_id)
|
||||||
}
|
}
|
||||||
# The "is this new?" test above is queue-scoped, but the submissions PK is
|
records = [
|
||||||
# global and _put() upserts on it. A record whose id (the file stem) is
|
record
|
||||||
# already owned by a DIFFERENT queue would therefore be classified as new
|
for record in image_store.submission_records()
|
||||||
# and overwrite that row: the other queue's decision/memo/query history
|
if record["id"] not in existing_submission_ids
|
||||||
# resets to unreviewed, the row's queue_id moves, and the old evidence
|
]
|
||||||
# (linked by submission_id alone) stays attached and pollutes the new
|
|
||||||
# case's score. Skip the colliding record and make the conflict visible
|
|
||||||
# rather than destroying a decided submission.
|
|
||||||
foreign_queue_owners = self._submission_queue_owners(exclude_queue_id=queue_id)
|
|
||||||
records = []
|
|
||||||
for record in image_store.submission_records():
|
|
||||||
record_id = str(record["id"])
|
|
||||||
if record_id in existing_submission_ids:
|
|
||||||
continue
|
|
||||||
owner_queue_id = foreign_queue_owners.get(record_id)
|
|
||||||
if owner_queue_id:
|
|
||||||
self._note_queue_id_conflict(record_id, queue_id, owner_queue_id)
|
|
||||||
continue
|
|
||||||
records.append(record)
|
|
||||||
if not records:
|
if not records:
|
||||||
# Nothing new to analyze, but existing rows may still be missing a
|
return 0
|
||||||
# narration — the caller runs that outside the lock.
|
|
||||||
return 0, queue_id
|
|
||||||
|
|
||||||
repository = self._knowledge_repository()
|
repository = self._knowledge_repository()
|
||||||
analyzer = InternalAnalyzer(
|
analyzer = InternalAnalyzer(
|
||||||
|
|
@ -436,6 +327,7 @@ class CopyrighterStore(
|
||||||
self._sync_face_crops(record["id"], image_store)
|
self._sync_face_crops(record["id"], image_store)
|
||||||
self._auto_naver_search(record["id"], query_source_evidence, image_store)
|
self._auto_naver_search(record["id"], query_source_evidence, image_store)
|
||||||
self._auto_google_custom_search(record["id"], query_source_evidence, image_store)
|
self._auto_google_custom_search(record["id"], query_source_evidence, image_store)
|
||||||
|
self._ensure_llm_summary(record["id"])
|
||||||
self._increment_knowledge_contribution_counts(record["id"], run.evidence)
|
self._increment_knowledge_contribution_counts(record["id"], run.evidence)
|
||||||
self._rescore_submission(record["id"])
|
self._rescore_submission(record["id"])
|
||||||
self.add_audit_event(
|
self.add_audit_event(
|
||||||
|
|
@ -446,10 +338,6 @@ class CopyrighterStore(
|
||||||
)
|
)
|
||||||
self._sync_submission_provider_state(queue_id=queue_id)
|
self._sync_submission_provider_state(queue_id=queue_id)
|
||||||
self._normalize_saved_google_weak_labels_and_rescore(queue_id=queue_id)
|
self._normalize_saved_google_weak_labels_and_rescore(queue_id=queue_id)
|
||||||
# Persist submission fingerprints so duplicate / prior-case grouping can
|
|
||||||
# run from stored data. Idempotent: only submissions missing the field
|
|
||||||
# read their image again.
|
|
||||||
self.backfill_submission_fingerprints(image_store, queue_id=queue_id)
|
|
||||||
with self._connect() as conn:
|
with self._connect() as conn:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
|
|
@ -459,7 +347,7 @@ class CopyrighterStore(
|
||||||
""",
|
""",
|
||||||
(int(datetime.now().timestamp()), now, queue_id),
|
(int(datetime.now().timestamp()), now, queue_id),
|
||||||
)
|
)
|
||||||
return len(records), queue_id
|
return len(records)
|
||||||
|
|
||||||
def _prune_missing_submission_files(
|
def _prune_missing_submission_files(
|
||||||
self,
|
self,
|
||||||
|
|
@ -505,14 +393,8 @@ class CopyrighterStore(
|
||||||
queue_id = str(queue["id"]) if queue else None
|
queue_id = str(queue["id"]) if queue else None
|
||||||
submissions = self._all("submissions", queue_id=queue_id)
|
submissions = self._all("submissions", queue_id=queue_id)
|
||||||
evidence = self._evidence_by_submission(queue_id=queue_id)
|
evidence = self._evidence_by_submission(queue_id=queue_id)
|
||||||
duplicates = self.duplicate_groups(queue_id=queue_id)
|
|
||||||
for submission in submissions:
|
for submission in submissions:
|
||||||
submission["evidence"] = evidence.get(submission["id"], [])
|
submission["evidence"] = evidence.get(submission["id"], [])
|
||||||
# Only present when the case actually has a duplicate peer, so the
|
|
||||||
# GUI can treat presence of the key as "show the badge".
|
|
||||||
group = duplicates.get(str(submission["id"]))
|
|
||||||
if group:
|
|
||||||
submission["duplicateGroup"] = group
|
|
||||||
return {
|
return {
|
||||||
"submissions": submissions,
|
"submissions": submissions,
|
||||||
"submissionQueue": queue,
|
"submissionQueue": queue,
|
||||||
|
|
@ -523,7 +405,6 @@ class CopyrighterStore(
|
||||||
"auditEvents": self.audit_events(),
|
"auditEvents": self.audit_events(),
|
||||||
"coverageThresholds": dict(self.coverage_thresholds),
|
"coverageThresholds": dict(self.coverage_thresholds),
|
||||||
"searchCoverage": self._search_coverage(submissions, evidence),
|
"searchCoverage": self._search_coverage(submissions, evidence),
|
||||||
"decisionMemoPresets": self.decision_memo_presets(),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
def _search_coverage(self, submissions: list[dict[str, Any]], evidence_by_submission: dict[str, list[dict[str, Any]]]) -> dict[str, Any]:
|
def _search_coverage(self, submissions: list[dict[str, Any]], evidence_by_submission: dict[str, list[dict[str, Any]]]) -> dict[str, Any]:
|
||||||
|
|
@ -578,16 +459,8 @@ class CopyrighterStore(
|
||||||
# Attribute via _evidence_matches_provider (domain-aware) so Google
|
# Attribute via _evidence_matches_provider (domain-aware) so Google
|
||||||
# Custom Search (source "google", domain "google_custom_search") is
|
# Custom Search (source "google", domain "google_custom_search") is
|
||||||
# counted under google_search, not the Vision "google" bucket.
|
# counted under google_search, not the Vision "google" bucket.
|
||||||
# Require an actual result, not merely a matching row: a submission
|
|
||||||
# whose only search evidence is a failure/skip has no coverage, and
|
|
||||||
# counting it inflated the coverage ratio into the "good" band while
|
|
||||||
# the operator still had nothing to review (mirrors the llm bucket).
|
|
||||||
for search_provider in ("naver", "google", "google_search"):
|
for search_provider in ("naver", "google", "google_search"):
|
||||||
if any(
|
if any(_evidence_matches_provider(item, search_provider) for item in evidence):
|
||||||
_evidence_matches_provider(item, search_provider)
|
|
||||||
and _provider_item_has_result(item)
|
|
||||||
for item in evidence
|
|
||||||
):
|
|
||||||
has_search_evidence = True
|
has_search_evidence = True
|
||||||
provider_stats[search_provider]["evidenceSubmissions"] += 1
|
provider_stats[search_provider]["evidenceSubmissions"] += 1
|
||||||
if has_search_evidence:
|
if has_search_evidence:
|
||||||
|
|
@ -720,17 +593,10 @@ 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"}:
|
||||||
|
|
|
||||||
|
|
@ -1,226 +0,0 @@
|
||||||
"""Online snapshot backup for CopyrighterStore, as a mixin.
|
|
||||||
|
|
||||||
The whole operational record — decisions, knowledge base, audit trail — lives in
|
|
||||||
one SQLite file plus a few media directories, and an air-gapped deployment has no
|
|
||||||
cloud backup to fall back on. This produces a consistent snapshot while the
|
|
||||||
server keeps serving.
|
|
||||||
|
|
||||||
WAL note: copying the .sqlite3 file while the server runs yields a torn snapshot
|
|
||||||
because recent commits still live in the -wal sidecar. sqlite3's online backup
|
|
||||||
API checkpoints into the destination instead, which is why the DB is copied
|
|
||||||
through `Connection.backup` rather than shutil.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import shutil
|
|
||||||
import sqlite3
|
|
||||||
import zipfile
|
|
||||||
from datetime import datetime
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
# Store-owned media roots included in a snapshot, as (archive folder, attribute).
|
|
||||||
# Submission source images are handled separately: they live in whatever folder
|
|
||||||
# the active queue points at, which may be outside the data root.
|
|
||||||
_MEDIA_SOURCES = (
|
|
||||||
("knowledge", "knowledge_image_dir"),
|
|
||||||
("collections", "collection_image_dir"),
|
|
||||||
("face-crops", "face_crop_image_dir"),
|
|
||||||
)
|
|
||||||
|
|
||||||
DEFAULT_BACKUP_RETENTION = 10
|
|
||||||
|
|
||||||
|
|
||||||
class StoreBackupMixin:
|
|
||||||
def create_backup(
|
|
||||||
self, *, retention: int = DEFAULT_BACKUP_RETENTION
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Write a timestamped snapshot zip and prune older ones."""
|
|
||||||
backup_dir = Path(self.db_path).resolve().parent / "backups"
|
|
||||||
backup_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
||||||
# Two snapshots inside the same second would otherwise overwrite each
|
|
||||||
# other, silently destroying the earlier restore point.
|
|
||||||
archive_path = backup_dir / f"copyrighter-snapshot-{stamp}.zip"
|
|
||||||
suffix = 1
|
|
||||||
while archive_path.exists():
|
|
||||||
suffix += 1
|
|
||||||
archive_path = backup_dir / f"copyrighter-snapshot-{stamp}-{suffix}.zip"
|
|
||||||
staged_db = backup_dir / f".snapshot-{archive_path.stem}.sqlite3"
|
|
||||||
|
|
||||||
try:
|
|
||||||
self._snapshot_database(staged_db)
|
|
||||||
with zipfile.ZipFile(
|
|
||||||
archive_path, "w", compression=zipfile.ZIP_DEFLATED
|
|
||||||
) as archive:
|
|
||||||
archive.write(staged_db, arcname="copyrighter.sqlite3")
|
|
||||||
media_counts = self._archive_media(archive)
|
|
||||||
finally:
|
|
||||||
staged_db.unlink(missing_ok=True)
|
|
||||||
|
|
||||||
pruned = _prune_old_backups(backup_dir, retention)
|
|
||||||
size_bytes = archive_path.stat().st_size
|
|
||||||
self.add_audit_event(
|
|
||||||
"rights.ops",
|
|
||||||
"Backup snapshot created",
|
|
||||||
archive_path.name,
|
|
||||||
f"{size_bytes} bytes · media {media_counts} · pruned {pruned}",
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"file": archive_path.name,
|
|
||||||
"path": str(archive_path),
|
|
||||||
"sizeBytes": size_bytes,
|
|
||||||
"createdAt": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
|
||||||
"mediaFiles": media_counts,
|
|
||||||
"prunedFiles": pruned,
|
|
||||||
}
|
|
||||||
|
|
||||||
def backups(self) -> list[dict[str, Any]]:
|
|
||||||
backup_dir = Path(self.db_path).resolve().parent / "backups"
|
|
||||||
if not backup_dir.exists():
|
|
||||||
return []
|
|
||||||
entries = []
|
|
||||||
for path in sorted(backup_dir.glob("copyrighter-snapshot-*.zip"), reverse=True):
|
|
||||||
stat = path.stat()
|
|
||||||
entries.append(
|
|
||||||
{
|
|
||||||
"file": path.name,
|
|
||||||
"sizeBytes": stat.st_size,
|
|
||||||
"createdAt": datetime.fromtimestamp(stat.st_mtime).strftime(
|
|
||||||
"%Y-%m-%d %H:%M:%S"
|
|
||||||
),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return entries
|
|
||||||
|
|
||||||
def _snapshot_database(self, destination: Path) -> None:
|
|
||||||
# Connection.backup copies a transactionally consistent image, including
|
|
||||||
# anything still sitting in the WAL, without blocking writers.
|
|
||||||
#
|
|
||||||
# sqlite3's context manager only commits/rolls back — it does NOT close.
|
|
||||||
# Both handles must be closed explicitly or the staged file stays locked
|
|
||||||
# and cannot be zipped/removed on Windows.
|
|
||||||
source = self._connect()
|
|
||||||
try:
|
|
||||||
target = sqlite3.connect(destination)
|
|
||||||
try:
|
|
||||||
source.backup(target)
|
|
||||||
finally:
|
|
||||||
target.close()
|
|
||||||
finally:
|
|
||||||
source.close()
|
|
||||||
|
|
||||||
def _archive_media(self, archive: zipfile.ZipFile) -> int:
|
|
||||||
roots: list[tuple[str, Path]] = []
|
|
||||||
for arc_folder, attribute in _MEDIA_SOURCES:
|
|
||||||
root = getattr(self, attribute, None)
|
|
||||||
if root is not None:
|
|
||||||
roots.append((arc_folder, Path(root)))
|
|
||||||
|
|
||||||
submission_root = self._active_submission_root()
|
|
||||||
if submission_root is not None:
|
|
||||||
roots.append(("submissions", submission_root))
|
|
||||||
|
|
||||||
written = 0
|
|
||||||
for arc_folder, root_path in roots:
|
|
||||||
if not root_path.exists():
|
|
||||||
continue
|
|
||||||
for path in sorted(root_path.rglob("*")):
|
|
||||||
if not path.is_file():
|
|
||||||
continue
|
|
||||||
archive.write(
|
|
||||||
path,
|
|
||||||
arcname=f"media/{arc_folder}/{path.relative_to(root_path).as_posix()}",
|
|
||||||
)
|
|
||||||
written += 1
|
|
||||||
return written
|
|
||||||
|
|
||||||
def _active_submission_root(self) -> Path | None:
|
|
||||||
# Best-effort: a store with no configured queue still produces a valid
|
|
||||||
# database snapshot, just without submission source images.
|
|
||||||
try:
|
|
||||||
queue = self.bootstrap_active_queue()
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
if not queue or not queue.get("folderPath"):
|
|
||||||
return None
|
|
||||||
return Path(str(queue["folderPath"]))
|
|
||||||
|
|
||||||
|
|
||||||
def restore_backup(archive_path: Path | str, target_db: Path | str) -> dict[str, Any]:
|
|
||||||
"""Restore a snapshot over a STOPPED server's data directory.
|
|
||||||
|
|
||||||
Offline on purpose: replacing the database under a live server would leave
|
|
||||||
open connections reading a file that no longer exists.
|
|
||||||
"""
|
|
||||||
archive = Path(archive_path)
|
|
||||||
db_path = Path(target_db)
|
|
||||||
if not archive.exists():
|
|
||||||
raise ValueError(f"backup archive not found: {archive}")
|
|
||||||
|
|
||||||
data_root = db_path.resolve().parent
|
|
||||||
with zipfile.ZipFile(archive) as bundle:
|
|
||||||
names = set(bundle.namelist())
|
|
||||||
if "copyrighter.sqlite3" not in names:
|
|
||||||
raise ValueError("archive does not contain copyrighter.sqlite3")
|
|
||||||
|
|
||||||
if db_path.exists():
|
|
||||||
# Keep the pre-restore database next to it; an operator who restores
|
|
||||||
# the wrong snapshot would otherwise have no way back.
|
|
||||||
rescue = db_path.with_suffix(
|
|
||||||
f".sqlite3.pre-restore-{datetime.now().strftime('%Y%m%d-%H%M%S')}"
|
|
||||||
)
|
|
||||||
shutil.copy2(db_path, rescue)
|
|
||||||
# Stale sidecars would be replayed on top of the restored file.
|
|
||||||
for sidecar in (f"{db_path}-wal", f"{db_path}-shm"):
|
|
||||||
Path(sidecar).unlink(missing_ok=True)
|
|
||||||
|
|
||||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
with bundle.open("copyrighter.sqlite3") as source, open(db_path, "wb") as target:
|
|
||||||
shutil.copyfileobj(source, target)
|
|
||||||
|
|
||||||
restored_media = 0
|
|
||||||
for name in sorted(names):
|
|
||||||
if not name.startswith("media/") or name.endswith("/"):
|
|
||||||
continue
|
|
||||||
relative = Path(name).relative_to("media")
|
|
||||||
destination = data_root / _MEDIA_RESTORE_DIRS.get(
|
|
||||||
relative.parts[0], relative.parts[0]
|
|
||||||
)
|
|
||||||
destination = destination.joinpath(*relative.parts[1:])
|
|
||||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
with bundle.open(name) as source, open(destination, "wb") as target:
|
|
||||||
shutil.copyfileobj(source, target)
|
|
||||||
restored_media += 1
|
|
||||||
|
|
||||||
return {"database": str(db_path), "mediaFiles": restored_media}
|
|
||||||
|
|
||||||
|
|
||||||
# Archive folder -> path relative to the data root, so a restore lands where the
|
|
||||||
# running server expects to find each media kind.
|
|
||||||
_MEDIA_RESTORE_DIRS = {
|
|
||||||
"knowledge": "knowledge/images",
|
|
||||||
"collections": "collections/images",
|
|
||||||
"face-crops": "face-crops",
|
|
||||||
# Submission images restore under the data root's default submissions folder.
|
|
||||||
# If the queue pointed elsewhere, re-import that folder after restoring.
|
|
||||||
"submissions": "submissions",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _prune_old_backups(backup_dir: Path, retention: int) -> int:
|
|
||||||
keep = max(1, int(retention))
|
|
||||||
snapshots = sorted(
|
|
||||||
backup_dir.glob("copyrighter-snapshot-*.zip"),
|
|
||||||
key=lambda path: path.stat().st_mtime,
|
|
||||||
reverse=True,
|
|
||||||
)
|
|
||||||
removed = 0
|
|
||||||
for path in snapshots[keep:]:
|
|
||||||
try:
|
|
||||||
path.unlink()
|
|
||||||
removed += 1
|
|
||||||
except OSError:
|
|
||||||
continue
|
|
||||||
return removed
|
|
||||||
|
|
@ -23,7 +23,6 @@ STORE_TABLES = {
|
||||||
"corrections",
|
"corrections",
|
||||||
"audit_events",
|
"audit_events",
|
||||||
"submission_queues",
|
"submission_queues",
|
||||||
"decision_memo_presets",
|
|
||||||
}
|
}
|
||||||
RISK_BANDS = ("low", "medium", "high", "failed", "pending")
|
RISK_BANDS = ("low", "medium", "high", "failed", "pending")
|
||||||
DECISION_STATUSES = ("unreviewed", "held", "rejected", "approved", "corrected")
|
DECISION_STATUSES = ("unreviewed", "held", "rejected", "approved", "corrected")
|
||||||
|
|
@ -48,11 +47,6 @@ DEFAULT_QUERY_COVERAGE_WARN_THRESHOLD = 40
|
||||||
MIN_COVERAGE_THRESHOLD = 0
|
MIN_COVERAGE_THRESHOLD = 0
|
||||||
MAX_COVERAGE_THRESHOLD = 100
|
MAX_COVERAGE_THRESHOLD = 100
|
||||||
DEFAULT_FACE_CROP_RETENTION_DAYS = 90
|
DEFAULT_FACE_CROP_RETENTION_DAYS = 90
|
||||||
# Upper bound on LLM narrations generated by one seed/reload. A folder import can
|
|
||||||
# bring in hundreds of submissions; narrating all of them in one request makes
|
|
||||||
# the reload take as long as the model does, times the batch. The rest are picked
|
|
||||||
# up by the next reload (narration is only-if-missing and therefore resumable).
|
|
||||||
MAX_LLM_SUMMARIES_PER_SEED = 20
|
|
||||||
PAYLOAD_REQUIRED_FIELDS = {
|
PAYLOAD_REQUIRED_FIELDS = {
|
||||||
"submissions": {
|
"submissions": {
|
||||||
"id",
|
"id",
|
||||||
|
|
|
||||||
|
|
@ -1,161 +0,0 @@
|
||||||
"""Submission-to-submission duplicate / prior-case index, as a mixin.
|
|
||||||
|
|
||||||
pHash was only ever compared against the knowledge base and against search-result
|
|
||||||
images — never between submissions — so the same picture arriving twice was
|
|
||||||
reviewed twice from scratch, and a resubmission of an already-rejected image was
|
|
||||||
invisible unless a watchlist entry happened to exist.
|
|
||||||
|
|
||||||
The fingerprints are already computed during analysis but the evidence payload
|
|
||||||
whitelist drops them, so this module backfills `fingerprints` onto the submission
|
|
||||||
record (idempotent, image read only when missing) and groups from there.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from rights_filter.analysis.fingerprints import FingerprintService
|
|
||||||
from rights_filter.server.image_store import LocalSubmissionImageStore
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# Hamming-distance similarity at/above which two submissions are treated as the
|
|
||||||
# same picture. Deliberately stricter than the knowledge-base match threshold
|
|
||||||
# (0.9): this drives "these are duplicates, handle them together", so a false
|
|
||||||
# grouping is more costly than a missed one.
|
|
||||||
DUPLICATE_SIMILARITY_THRESHOLD = 0.97
|
|
||||||
|
|
||||||
|
|
||||||
class StoreDuplicatesMixin:
|
|
||||||
def backfill_submission_fingerprints(
|
|
||||||
self, image_store: LocalSubmissionImageStore, queue_id: str | None = None
|
|
||||||
) -> int:
|
|
||||||
"""Persist exact/perceptual fingerprints on submissions missing them."""
|
|
||||||
service = FingerprintService()
|
|
||||||
updated = 0
|
|
||||||
for submission in self._all("submissions", queue_id=queue_id):
|
|
||||||
if submission.get("fingerprints"):
|
|
||||||
continue
|
|
||||||
submission_id = str(submission["id"])
|
|
||||||
fingerprints = _fingerprints_for_submission(
|
|
||||||
service, image_store, submission_id
|
|
||||||
)
|
|
||||||
if fingerprints is None:
|
|
||||||
continue
|
|
||||||
submission["fingerprints"] = fingerprints
|
|
||||||
self._put("submissions", submission_id, submission)
|
|
||||||
updated += 1
|
|
||||||
return updated
|
|
||||||
|
|
||||||
def duplicate_groups(self, queue_id: str | None = None) -> dict[str, dict[str, Any]]:
|
|
||||||
"""Map submission id -> duplicate-group summary for the given queue.
|
|
||||||
|
|
||||||
Only submissions that have at least one peer appear in the result.
|
|
||||||
"""
|
|
||||||
submissions = [
|
|
||||||
item for item in self._all("submissions", queue_id=queue_id)
|
|
||||||
if _perceptual_of(item) or _exact_of(item)
|
|
||||||
]
|
|
||||||
groups = _cluster(submissions)
|
|
||||||
|
|
||||||
summary: dict[str, dict[str, Any]] = {}
|
|
||||||
for group in groups:
|
|
||||||
if len(group) < 2:
|
|
||||||
continue
|
|
||||||
for submission in group:
|
|
||||||
peers = [other for other in group if other["id"] != submission["id"]]
|
|
||||||
decided_peers = [
|
|
||||||
{
|
|
||||||
"submissionId": str(peer["id"]),
|
|
||||||
"title": str(peer.get("title", "")),
|
|
||||||
"decision": str(peer.get("decisionStatus", "unreviewed")),
|
|
||||||
}
|
|
||||||
for peer in peers
|
|
||||||
if str(peer.get("decisionStatus", "unreviewed")) != "unreviewed"
|
|
||||||
]
|
|
||||||
summary[str(submission["id"])] = {
|
|
||||||
"size": len(group),
|
|
||||||
"peerIds": [str(peer["id"]) for peer in peers],
|
|
||||||
"priorDecisions": decided_peers,
|
|
||||||
"conflicting": _has_conflicting_decisions(group),
|
|
||||||
}
|
|
||||||
return summary
|
|
||||||
|
|
||||||
|
|
||||||
def _fingerprints_for_submission(
|
|
||||||
service: FingerprintService,
|
|
||||||
image_store: LocalSubmissionImageStore,
|
|
||||||
submission_id: str,
|
|
||||||
) -> dict[str, str] | None:
|
|
||||||
try:
|
|
||||||
payload = image_store.image_payload(submission_id)
|
|
||||||
except (KeyError, OSError, ValueError):
|
|
||||||
# A submission whose file is gone simply has no fingerprint; the queue
|
|
||||||
# prune handles its removal separately.
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
fingerprints = service.fingerprints_for(payload.content)
|
|
||||||
except Exception:
|
|
||||||
logger.warning("Could not fingerprint submission %s", submission_id)
|
|
||||||
return None
|
|
||||||
return {"exact": fingerprints.exact, "perceptual": fingerprints.perceptual}
|
|
||||||
|
|
||||||
|
|
||||||
def _exact_of(submission: dict[str, Any]) -> str:
|
|
||||||
return str((submission.get("fingerprints") or {}).get("exact", ""))
|
|
||||||
|
|
||||||
|
|
||||||
def _perceptual_of(submission: dict[str, Any]) -> str:
|
|
||||||
return str((submission.get("fingerprints") or {}).get("perceptual", ""))
|
|
||||||
|
|
||||||
|
|
||||||
def _cluster(submissions: list[dict[str, Any]]) -> list[list[dict[str, Any]]]:
|
|
||||||
"""Group submissions by exact hash, then merge near-identical pHash groups.
|
|
||||||
|
|
||||||
Queues hold hundreds of cases, so the O(n^2) perceptual pass is cheap enough
|
|
||||||
and avoids the accuracy loss of hash bucketing.
|
|
||||||
"""
|
|
||||||
service = FingerprintService()
|
|
||||||
groups: list[list[dict[str, Any]]] = []
|
|
||||||
by_exact: dict[str, list[dict[str, Any]]] = {}
|
|
||||||
|
|
||||||
for submission in submissions:
|
|
||||||
exact = _exact_of(submission)
|
|
||||||
if exact:
|
|
||||||
by_exact.setdefault(exact, []).append(submission)
|
|
||||||
else:
|
|
||||||
groups.append([submission])
|
|
||||||
groups.extend(by_exact.values())
|
|
||||||
|
|
||||||
merged: list[list[dict[str, Any]]] = []
|
|
||||||
for group in groups:
|
|
||||||
representative = _perceptual_of(group[0])
|
|
||||||
target = None
|
|
||||||
if representative:
|
|
||||||
for candidate in merged:
|
|
||||||
candidate_hash = _perceptual_of(candidate[0])
|
|
||||||
if not candidate_hash:
|
|
||||||
continue
|
|
||||||
if (
|
|
||||||
service.similarity(representative, candidate_hash)
|
|
||||||
>= DUPLICATE_SIMILARITY_THRESHOLD
|
|
||||||
):
|
|
||||||
target = candidate
|
|
||||||
break
|
|
||||||
if target is None:
|
|
||||||
merged.append(list(group))
|
|
||||||
else:
|
|
||||||
target.extend(group)
|
|
||||||
return merged
|
|
||||||
|
|
||||||
|
|
||||||
def _has_conflicting_decisions(group: list[dict[str, Any]]) -> bool:
|
|
||||||
decisions = {
|
|
||||||
str(item.get("decisionStatus", "unreviewed"))
|
|
||||||
for item in group
|
|
||||||
if str(item.get("decisionStatus", "unreviewed")) != "unreviewed"
|
|
||||||
}
|
|
||||||
# The same image approved in one case and rejected in another is a review
|
|
||||||
# consistency problem the operator should see before deciding again.
|
|
||||||
return "approved" in decisions and bool(decisions & {"rejected", "held"})
|
|
||||||
|
|
@ -17,11 +17,7 @@ from typing import Any
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
from rights_filter.analysis.face_person_detection import (
|
from rights_filter.analysis.face_person_detection import HeuristicFacePersonDetector
|
||||||
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
|
||||||
|
|
@ -156,9 +152,12 @@ class StoreEnrichmentMixin:
|
||||||
|
|
||||||
evidence = Evidence(
|
evidence = Evidence(
|
||||||
source=EvidenceSource.FACE_PERSON,
|
source=EvidenceSource.FACE_PERSON,
|
||||||
reason=face_person_reason(signal),
|
reason="Face/person detected",
|
||||||
confidence=signal.confidence,
|
confidence=0.8,
|
||||||
data=face_person_evidence_data(signal),
|
data={
|
||||||
|
"face_count": signal.face_count,
|
||||||
|
"person_count": signal.person_count,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
self._put(
|
self._put(
|
||||||
"evidence",
|
"evidence",
|
||||||
|
|
|
||||||
|
|
@ -203,66 +203,6 @@ 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,19 +725,9 @@ 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,7 +18,6 @@ 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,
|
||||||
|
|
@ -110,15 +109,6 @@ 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(),
|
||||||
|
|
@ -316,42 +306,11 @@ 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", []),
|
||||||
# Provenance signal (provenance rows only; empty elsewhere).
|
|
||||||
"provenanceKind": str(evidence.data.get("provenance_kind", "")),
|
|
||||||
"rightsFields": dict(evidence.data.get("rights_fields", {}) or {}),
|
|
||||||
"captureCategories": list(evidence.data.get("capture_categories", []) or []),
|
|
||||||
# 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", ""),
|
|
||||||
# Fabrication counters. Without these on the payload the metric exists
|
|
||||||
# only in memory and the operator/admin never sees a model that has
|
|
||||||
# started inventing citations.
|
|
||||||
"groundedClaims": evidence.data.get("grounded_claims", ""),
|
|
||||||
"droppedClaims": evidence.data.get("dropped_claims", ""),
|
|
||||||
"verified": evidence.data.get("verified", ""),
|
|
||||||
"status": "active",
|
"status": "active",
|
||||||
"submission_id": submission_id,
|
"submission_id": submission_id,
|
||||||
}
|
}
|
||||||
|
|
@ -399,27 +358,8 @@ 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", ""),
|
|
||||||
"grounded_claims": payload.get("groundedClaims", ""),
|
|
||||||
"dropped_claims": payload.get("droppedClaims", ""),
|
|
||||||
"verified": payload.get("verified", ""),
|
|
||||||
"provenance_kind": payload.get("provenanceKind", ""),
|
|
||||||
"rights_fields": payload.get("rightsFields", {}),
|
|
||||||
"capture_categories": payload.get("captureCategories", []),
|
|
||||||
"tier": payload.get("faceTier", ""),
|
|
||||||
"detector": payload.get("faceDetector", ""),
|
|
||||||
"face_area_ratio": payload.get("faceAreaRatio", ""),
|
|
||||||
"face_count": payload.get("faceCount", ""),
|
|
||||||
"person_count": payload.get("personCount", ""),
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -446,8 +386,6 @@ def _domain_source_from_ui_payload(payload: dict[str, Any]) -> EvidenceSource:
|
||||||
|
|
||||||
|
|
||||||
def _domain_source_from_ui(source: str) -> EvidenceSource:
|
def _domain_source_from_ui(source: str) -> EvidenceSource:
|
||||||
if source == "provenance":
|
|
||||||
return EvidenceSource.PROVENANCE
|
|
||||||
if source == "google":
|
if source == "google":
|
||||||
return EvidenceSource.WEB_DETECTION
|
return EvidenceSource.WEB_DETECTION
|
||||||
if source == "naver":
|
if source == "naver":
|
||||||
|
|
@ -476,20 +414,8 @@ 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:
|
||||||
stable_data = {
|
base = f"{submission_id}:{evidence.source}:{evidence.reason}:{json.dumps(evidence.data, sort_keys=True, default=str)}"
|
||||||
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]
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -504,11 +430,6 @@ def _ui_source(source: EvidenceSource) -> str:
|
||||||
return "failure"
|
return "failure"
|
||||||
if source == EvidenceSource.FACE_PERSON:
|
if source == EvidenceSource.FACE_PERSON:
|
||||||
return "face"
|
return "face"
|
||||||
if source == EvidenceSource.PROVENANCE:
|
|
||||||
return "provenance"
|
|
||||||
# NOTE: this fallback is silent. A new EvidenceSource that forgets a branch
|
|
||||||
# above lands here and is mislabelled as a fingerprint rather than failing
|
|
||||||
# loudly — add the branch when adding the source.
|
|
||||||
return "fingerprint"
|
return "fingerprint"
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,404 +0,0 @@
|
||||||
"""Operator throughput features for CopyrighterStore, as a mixin.
|
|
||||||
|
|
||||||
Batch decisions, reusable decision-memo presets, and decision export. Mixed into
|
|
||||||
CopyrighterStore; relies on persistence + operations methods and self.*
|
|
||||||
attributes provided by the host class.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import csv
|
|
||||||
import io
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from rights_filter.server.image_store import LocalSubmissionImageStore
|
|
||||||
from rights_filter.server.store_serialization import _now_label, _stable_id
|
|
||||||
|
|
||||||
# A batch is an operator convenience, not an import path: keep it small enough
|
|
||||||
# that one accidental "select all" cannot rewrite the whole queue in one click.
|
|
||||||
MAX_BATCH_DECISIONS = 200
|
|
||||||
|
|
||||||
# Fields the applicant-facing export may contain. Anything not listed here —
|
|
||||||
# risk score, evidence, provider state, internal memos beyond the decision
|
|
||||||
# reason — must never cross this boundary.
|
|
||||||
APPLICANT_EXPORT_FIELDS = ("submissionId", "title", "decision", "applicantStatus", "memo")
|
|
||||||
|
|
||||||
INTERNAL_EXPORT_FIELDS = (
|
|
||||||
"submissionId",
|
|
||||||
"title",
|
|
||||||
"decision",
|
|
||||||
"applicantStatus",
|
|
||||||
"riskScore",
|
|
||||||
"riskBand",
|
|
||||||
"memo",
|
|
||||||
"decidedAt",
|
|
||||||
"evidenceUsed",
|
|
||||||
"evidenceUrls",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class StoreWorkflowMixin:
|
|
||||||
# ---- decision-time feature snapshot ---------------------------------
|
|
||||||
|
|
||||||
def decision_feature_snapshot(self, submission_id: str) -> dict[str, Any]:
|
|
||||||
"""Record what the operator actually saw at the moment they decided.
|
|
||||||
|
|
||||||
The queue sorts by riskScore descending and the case screen shows the
|
|
||||||
score, band and reasons before the decision buttons. A label collected
|
|
||||||
under that layout does not mean "this evidence predicts rejection"; it
|
|
||||||
means "the operator agreed with the score I showed them". Learning from
|
|
||||||
such labels re-learns the existing weights.
|
|
||||||
|
|
||||||
Capturing the displayed state at decision time is what makes that
|
|
||||||
circularity correctable later: with the snapshot, a model can condition
|
|
||||||
on (or hold out) what was shown. Without it, the labels are
|
|
||||||
indistinguishable from copies of the score, permanently. It has to be
|
|
||||||
written from the first decision onward — it cannot be reconstructed.
|
|
||||||
"""
|
|
||||||
submission = self._get("submissions", submission_id)
|
|
||||||
evidence = self._evidence_for_submission(submission_id)
|
|
||||||
shown = [
|
|
||||||
{
|
|
||||||
"id": str(item.get("id", "")),
|
|
||||||
"source": str(item.get("source", "")),
|
|
||||||
"matchType": str(item.get("matchType", "")),
|
|
||||||
"contributed": bool(item.get("contributed", False)),
|
|
||||||
"operatorStatus": str(item.get("operatorStatus") or ""),
|
|
||||||
}
|
|
||||||
for item in evidence
|
|
||||||
]
|
|
||||||
return {
|
|
||||||
"capturedAt": _now_label(),
|
|
||||||
# The score the operator was looking at — the queue's sort key.
|
|
||||||
"riskScore": submission.get("riskScore"),
|
|
||||||
"riskBand": submission.get("riskBand"),
|
|
||||||
"reasons": list(submission.get("reasons") or []),
|
|
||||||
# The as-created stamp, so a later analysis can tell whether the
|
|
||||||
# displayed score had already drifted from the original.
|
|
||||||
"initialRiskScore": submission.get("initialRiskScore"),
|
|
||||||
"initialRiskBand": submission.get("initialRiskBand"),
|
|
||||||
"scorerVersion": submission.get("scorerVersion", "legacy"),
|
|
||||||
"providerState": dict(submission.get("providerState") or {}),
|
|
||||||
"evidenceCount": len(shown),
|
|
||||||
"contributedCount": sum(1 for item in shown if item["contributed"]),
|
|
||||||
"evidence": shown,
|
|
||||||
}
|
|
||||||
|
|
||||||
# ---- batch decisions ------------------------------------------------
|
|
||||||
|
|
||||||
def record_decision_batch(
|
|
||||||
self,
|
|
||||||
submission_ids: list[str],
|
|
||||||
decision: str,
|
|
||||||
memo: str = "",
|
|
||||||
image_store: LocalSubmissionImageStore | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Apply one decision to many submissions, reporting per-case outcomes.
|
|
||||||
|
|
||||||
Each case goes through record_decision so the per-case transaction, audit
|
|
||||||
event and watchlist side effects stay identical to a single decision.
|
|
||||||
One failure does not abort the rest — the caller gets an explicit list of
|
|
||||||
what succeeded and what did not.
|
|
||||||
"""
|
|
||||||
if decision not in {"approved", "held", "rejected"}:
|
|
||||||
raise ValueError("unsupported decision")
|
|
||||||
unique_ids = list(dict.fromkeys(str(value) for value in submission_ids if str(value)))
|
|
||||||
if not unique_ids:
|
|
||||||
raise ValueError("no submissions selected")
|
|
||||||
if len(unique_ids) > MAX_BATCH_DECISIONS:
|
|
||||||
raise ValueError(
|
|
||||||
f"batch too large: {len(unique_ids)} > {MAX_BATCH_DECISIONS}"
|
|
||||||
)
|
|
||||||
if decision == "rejected" and not memo.strip():
|
|
||||||
raise ValueError("reject decision requires memo")
|
|
||||||
|
|
||||||
succeeded: list[str] = []
|
|
||||||
failed: list[dict[str, str]] = []
|
|
||||||
for submission_id in unique_ids:
|
|
||||||
try:
|
|
||||||
self.record_decision(submission_id, decision, memo, image_store)
|
|
||||||
succeeded.append(submission_id)
|
|
||||||
except KeyError:
|
|
||||||
# str(KeyError) is just the repr'd key — useless in the UI.
|
|
||||||
failed.append(
|
|
||||||
{"submissionId": submission_id, "error": "제출물을 찾을 수 없습니다"}
|
|
||||||
)
|
|
||||||
except ValueError as exc:
|
|
||||||
failed.append({"submissionId": submission_id, "error": str(exc)})
|
|
||||||
|
|
||||||
self.add_audit_event(
|
|
||||||
"rights.ops",
|
|
||||||
"Operator batch decision",
|
|
||||||
f"{decision} x{len(succeeded)}",
|
|
||||||
f"{len(succeeded)} applied, {len(failed)} failed · {memo or 'memo optional'}",
|
|
||||||
)
|
|
||||||
payload = self.bootstrap()
|
|
||||||
payload["batchDecision"] = {
|
|
||||||
"decision": decision,
|
|
||||||
"applied": succeeded,
|
|
||||||
"failed": failed,
|
|
||||||
}
|
|
||||||
return payload
|
|
||||||
|
|
||||||
# ---- decision memo presets ------------------------------------------
|
|
||||||
|
|
||||||
def decision_memo_presets(self) -> list[dict[str, Any]]:
|
|
||||||
presets = self._all("decision_memo_presets")
|
|
||||||
return sorted(presets, key=lambda item: str(item.get("label", "")))
|
|
||||||
|
|
||||||
def save_decision_memo_preset(self, label: str, body: str) -> list[dict[str, Any]]:
|
|
||||||
clean_label = str(label or "").strip()
|
|
||||||
clean_body = str(body or "").strip()
|
|
||||||
if not clean_label:
|
|
||||||
raise ValueError("preset label is required")
|
|
||||||
if not clean_body:
|
|
||||||
raise ValueError("preset body is required")
|
|
||||||
if len(clean_label) > 40:
|
|
||||||
raise ValueError("preset label must be 40 characters or fewer")
|
|
||||||
if len(clean_body) > 500:
|
|
||||||
raise ValueError("preset body must be 500 characters or fewer")
|
|
||||||
|
|
||||||
# Key by label so re-saving the same label edits it instead of stacking
|
|
||||||
# near-duplicate chips in the operator's decision pane.
|
|
||||||
preset_id = _stable_id("preset", clean_label)
|
|
||||||
self._put(
|
|
||||||
"decision_memo_presets",
|
|
||||||
preset_id,
|
|
||||||
{
|
|
||||||
"id": preset_id,
|
|
||||||
"label": clean_label,
|
|
||||||
"body": clean_body,
|
|
||||||
"updatedAt": _now_label(),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
self.add_audit_event(
|
|
||||||
"rights.ops", "Decision memo preset saved", preset_id, clean_label
|
|
||||||
)
|
|
||||||
return self.decision_memo_presets()
|
|
||||||
|
|
||||||
def delete_decision_memo_preset(self, preset_id: str) -> list[dict[str, Any]]:
|
|
||||||
with self._connect() as conn:
|
|
||||||
cursor = conn.execute(
|
|
||||||
"delete from decision_memo_presets where id = ?", (str(preset_id),)
|
|
||||||
)
|
|
||||||
if not cursor.rowcount:
|
|
||||||
raise KeyError(preset_id)
|
|
||||||
self.add_audit_event(
|
|
||||||
"rights.ops", "Decision memo preset deleted", str(preset_id), "removed"
|
|
||||||
)
|
|
||||||
return self.decision_memo_presets()
|
|
||||||
|
|
||||||
# ---- calibration / decision stats -----------------------------------
|
|
||||||
|
|
||||||
def decision_stats(self) -> dict[str, Any]:
|
|
||||||
"""Cross-tabulate risk band against the operator's actual decision.
|
|
||||||
|
|
||||||
The rule-based risk score is hand-tuned and has never been checked
|
|
||||||
against real decisions. This exposes the mismatch so it can be: a
|
|
||||||
high-risk case the operator approved is over-detection; a low-risk case
|
|
||||||
they rejected is under-detection. Everything is read from data already
|
|
||||||
stored (submissions + the decision audit trail).
|
|
||||||
"""
|
|
||||||
bands = ["high", "medium", "low", "failed", "pending"]
|
|
||||||
decisions = ["approved", "held", "rejected", "corrected", "unreviewed"]
|
|
||||||
matrix = {band: {decision: 0 for decision in decisions} for band in bands}
|
|
||||||
matrix_other: dict[str, int] = {decision: 0 for decision in decisions}
|
|
||||||
|
|
||||||
submissions = self._all("submissions")
|
|
||||||
unreviewed_epochs: list[int] = []
|
|
||||||
for submission in submissions:
|
|
||||||
band = str(submission.get("riskBand", "pending"))
|
|
||||||
decision = str(submission.get("decisionStatus", "unreviewed"))
|
|
||||||
if decision not in decisions:
|
|
||||||
decision = "unreviewed"
|
|
||||||
bucket = matrix.get(band, matrix_other)
|
|
||||||
bucket[decision] = bucket.get(decision, 0) + 1
|
|
||||||
if decision == "unreviewed":
|
|
||||||
unreviewed_epochs.append(int(submission.get("submittedEpoch", 0) or 0))
|
|
||||||
|
|
||||||
def _decided(band: str) -> int:
|
|
||||||
row = matrix.get(band, {})
|
|
||||||
return sum(row[d] for d in ("approved", "held", "rejected", "corrected"))
|
|
||||||
|
|
||||||
high_decided = _decided("high")
|
|
||||||
low_decided = _decided("low")
|
|
||||||
calibration = {
|
|
||||||
# High band the operator ended up approving -> the score cried wolf.
|
|
||||||
"highBandApprovalRate": _ratio(matrix["high"]["approved"], high_decided),
|
|
||||||
"highBandDecided": high_decided,
|
|
||||||
# Low band the operator rejected -> the score missed real risk.
|
|
||||||
"lowBandRejectionRate": _ratio(matrix["low"]["rejected"], low_decided),
|
|
||||||
"lowBandDecided": low_decided,
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
"bands": bands,
|
|
||||||
"decisions": decisions,
|
|
||||||
"matrix": matrix,
|
|
||||||
"matrixOther": matrix_other,
|
|
||||||
"calibration": calibration,
|
|
||||||
"throughput": self._decision_throughput(),
|
|
||||||
"backlog": {
|
|
||||||
"unreviewed": len(unreviewed_epochs),
|
|
||||||
"total": len(submissions),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
def _decision_throughput(self) -> dict[str, Any]:
|
|
||||||
# Group the decision audit events by calendar day (the timestamp label is
|
|
||||||
# "YYYY-MM-DD HH:MM:SS", so the date is a stable prefix).
|
|
||||||
by_day: dict[str, int] = {}
|
|
||||||
for event in self._decision_events():
|
|
||||||
day = str(event["timestamp"])[:10]
|
|
||||||
if day:
|
|
||||||
by_day[day] = by_day.get(day, 0) + 1
|
|
||||||
recent = sorted(by_day.items(), reverse=True)[:14]
|
|
||||||
return {
|
|
||||||
"byDay": [{"day": day, "count": count} for day, count in reversed(recent)],
|
|
||||||
"totalDecisions": sum(by_day.values()),
|
|
||||||
}
|
|
||||||
|
|
||||||
# ---- decision export -------------------------------------------------
|
|
||||||
|
|
||||||
def export_decisions(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
audience: str = "internal",
|
|
||||||
status: str = "",
|
|
||||||
since: str = "",
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Collect decided cases for hand-off.
|
|
||||||
|
|
||||||
`audience="applicant"` restricts the payload to the fields that may leave
|
|
||||||
the review system: no risk score, no evidence, no provider state.
|
|
||||||
"""
|
|
||||||
if audience not in {"internal", "applicant"}:
|
|
||||||
raise ValueError("audience must be 'internal' or 'applicant'")
|
|
||||||
wanted_status = str(status or "").strip()
|
|
||||||
if wanted_status and wanted_status not in {"approved", "held", "rejected", "corrected"}:
|
|
||||||
raise ValueError("unsupported decision status filter")
|
|
||||||
|
|
||||||
decided_at = self._decision_timestamps()
|
|
||||||
since_text = str(since or "").strip()
|
|
||||||
rows: list[dict[str, Any]] = []
|
|
||||||
for submission in self._all("submissions"):
|
|
||||||
decision = str(submission.get("decisionStatus", "unreviewed"))
|
|
||||||
if decision == "unreviewed":
|
|
||||||
continue
|
|
||||||
if wanted_status and decision != wanted_status:
|
|
||||||
continue
|
|
||||||
submission_id = str(submission.get("id", ""))
|
|
||||||
timestamp = decided_at.get(submission_id, "")
|
|
||||||
if since_text and timestamp and timestamp < since_text:
|
|
||||||
continue
|
|
||||||
rows.append(
|
|
||||||
self._export_row(submission, decision, timestamp, audience=audience)
|
|
||||||
)
|
|
||||||
|
|
||||||
rows.sort(key=lambda row: str(row.get("decidedAt", "")), reverse=True)
|
|
||||||
fields = (
|
|
||||||
APPLICANT_EXPORT_FIELDS if audience == "applicant" else INTERNAL_EXPORT_FIELDS
|
|
||||||
)
|
|
||||||
self.add_audit_event(
|
|
||||||
"rights.ops",
|
|
||||||
"Decisions exported",
|
|
||||||
audience,
|
|
||||||
f"{len(rows)} case(s) · status={wanted_status or 'all'} · since={since_text or 'all'}",
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"audience": audience,
|
|
||||||
"fields": list(fields),
|
|
||||||
"rows": rows,
|
|
||||||
"csv": _rows_to_csv(rows, fields),
|
|
||||||
}
|
|
||||||
|
|
||||||
def _export_row(
|
|
||||||
self,
|
|
||||||
submission: dict[str, Any],
|
|
||||||
decision: str,
|
|
||||||
decided_at: str,
|
|
||||||
*,
|
|
||||||
audience: str,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
submission_id = str(submission.get("id", ""))
|
|
||||||
memo = self._latest_decision_memo(submission_id)
|
|
||||||
row = {
|
|
||||||
"submissionId": submission_id,
|
|
||||||
"title": str(submission.get("title", "")),
|
|
||||||
"decision": decision,
|
|
||||||
"applicantStatus": str(submission.get("applicantStatus", "")),
|
|
||||||
"memo": memo,
|
|
||||||
}
|
|
||||||
if audience == "applicant":
|
|
||||||
return row
|
|
||||||
|
|
||||||
used = [
|
|
||||||
item
|
|
||||||
for item in self._evidence_for_submission(submission_id)
|
|
||||||
if str(item.get("operatorStatus", "")) == "used_for_judgment"
|
|
||||||
]
|
|
||||||
row.update(
|
|
||||||
{
|
|
||||||
"riskScore": submission.get("riskScore", 0),
|
|
||||||
"riskBand": str(submission.get("riskBand", "")),
|
|
||||||
"decidedAt": decided_at,
|
|
||||||
"evidenceUsed": " | ".join(str(item.get("title", "")) for item in used),
|
|
||||||
"evidenceUrls": " | ".join(
|
|
||||||
str(item.get("url", "")) for item in used if item.get("url")
|
|
||||||
),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return row
|
|
||||||
|
|
||||||
def _decision_events(self) -> list[dict[str, Any]]:
|
|
||||||
with self._connect() as conn:
|
|
||||||
rows = conn.execute(
|
|
||||||
"""
|
|
||||||
select object_ref, timestamp, payload from audit_events
|
|
||||||
where event = 'Operator decision created'
|
|
||||||
order by id asc
|
|
||||||
"""
|
|
||||||
).fetchall()
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
"object_ref": str(row["object_ref"]),
|
|
||||||
"timestamp": str(row["timestamp"]),
|
|
||||||
"payload": row["payload"],
|
|
||||||
}
|
|
||||||
for row in rows
|
|
||||||
]
|
|
||||||
|
|
||||||
def _decision_timestamps(self) -> dict[str, str]:
|
|
||||||
# Submissions carry no decided-at field; the audit trail is the only
|
|
||||||
# record of when the operator decided, so read the latest event per case.
|
|
||||||
return {event["object_ref"]: event["timestamp"] for event in self._decision_events()}
|
|
||||||
|
|
||||||
def _latest_decision_memo(self, submission_id: str) -> str:
|
|
||||||
import json
|
|
||||||
|
|
||||||
memo = ""
|
|
||||||
for event in self._decision_events():
|
|
||||||
if event["object_ref"] != submission_id:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
change = str(json.loads(event["payload"]).get("change", ""))
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
continue
|
|
||||||
# Audit change text is "<decision> · <memo>".
|
|
||||||
_, separator, tail = change.partition(" · ")
|
|
||||||
if separator and tail and tail != "memo optional":
|
|
||||||
memo = tail
|
|
||||||
return memo
|
|
||||||
|
|
||||||
|
|
||||||
def _ratio(numerator: int, denominator: int) -> float:
|
|
||||||
return round(numerator / denominator, 3) if denominator else 0.0
|
|
||||||
|
|
||||||
|
|
||||||
def _rows_to_csv(rows: list[dict[str, Any]], fields: tuple[str, ...]) -> str:
|
|
||||||
buffer = io.StringIO()
|
|
||||||
writer = csv.DictWriter(buffer, fieldnames=list(fields), extrasaction="ignore")
|
|
||||||
writer.writeheader()
|
|
||||||
for row in rows:
|
|
||||||
writer.writerow(row)
|
|
||||||
return buffer.getvalue()
|
|
||||||
|
|
@ -128,14 +128,8 @@ def test_workbench_shell_exposes_all_internal_operator_views():
|
||||||
assert "추천 쿼리로 외부 검색 결과를 보강합니다." in html
|
assert "추천 쿼리로 외부 검색 결과를 보강합니다." in html
|
||||||
assert "기준 DB에 반영합니다." in html
|
assert "기준 DB에 반영합니다." in html
|
||||||
assert ".operator-workflow" in styles
|
assert ".operator-workflow" in styles
|
||||||
# 2026-07-24: the v1 "no bulk approve/reject" restriction was lifted (see
|
assert "bulk-approve" not in html.lower()
|
||||||
# docs/superpowers/specs/2026-05-25-image-rights-operator-gui-design.md).
|
assert "bulk-reject" not in html.lower()
|
||||||
# Batch decisions are allowed, but only with the guardrails that made lifting
|
|
||||||
# it acceptable — assert those are present rather than that the controls are.
|
|
||||||
assert 'id="bulk-approve"' in html
|
|
||||||
assert 'id="bulk-reject"' in html
|
|
||||||
assert 'id="bulk-decision-count"' in html # selection size is always visible
|
|
||||||
assert 'id="bulk-decision-status"' in html # per-case outcome is reported back
|
|
||||||
|
|
||||||
assert 'data-view="evidence"' not in html
|
assert 'data-view="evidence"' not in html
|
||||||
assert 'id="evidence-view"' not in html
|
assert 'id="evidence-view"' not in html
|
||||||
|
|
|
||||||
|
|
@ -1,214 +0,0 @@
|
||||||
"""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,12 +1,5 @@
|
||||||
from rights_filter.analysis.llm_assistance import (
|
from rights_filter.analysis.llm_assistance import FakeInternalLlmClient, InternalLlmAssistant
|
||||||
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():
|
||||||
|
|
@ -52,67 +45,3 @@ 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,265 +0,0 @@
|
||||||
"""Structured evidence reading: the model cites, and the citation is checked.
|
|
||||||
|
|
||||||
The assistant no longer returns free prose. It returns claims that each carry
|
|
||||||
the evidence labels they rest on, and a claim whose labels were invented is
|
|
||||||
dropped before it can reach the operator. These tests pin that boundary, the
|
|
||||||
prompt input cap, and the field whitelist.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from rights_filter.analysis.llm_assistance import (
|
|
||||||
MAX_PROMPT_EVIDENCE,
|
|
||||||
InternalLlmAssistant,
|
|
||||||
OllamaGenerateLlmClient,
|
|
||||||
_evidence_prompt,
|
|
||||||
_select_prompt_evidence,
|
|
||||||
)
|
|
||||||
from rights_filter.analysis.llm_prompts import (
|
|
||||||
CLAIM_TEXT_PLACEHOLDER,
|
|
||||||
NEEDS_CHECK_PLACEHOLDER,
|
|
||||||
)
|
|
||||||
from rights_filter.domain.records import Evidence, EvidenceSource
|
|
||||||
|
|
||||||
|
|
||||||
class _ScriptedTransport:
|
|
||||||
"""Returns a canned Ollama response and records the request payload."""
|
|
||||||
|
|
||||||
def __init__(self, document):
|
|
||||||
self.sent = {}
|
|
||||||
self._body = document if isinstance(document, str) else json.dumps(document)
|
|
||||||
|
|
||||||
def request_json(self, method, url, payload=None, timeout=None):
|
|
||||||
self.sent = dict(payload or {})
|
|
||||||
return {"response": self._body, "model": "qwen2.5:3b-instruct"}
|
|
||||||
|
|
||||||
|
|
||||||
def _client(document) -> tuple[OllamaGenerateLlmClient, _ScriptedTransport]:
|
|
||||||
transport = _ScriptedTransport(document)
|
|
||||||
return OllamaGenerateLlmClient(transport=transport), transport
|
|
||||||
|
|
||||||
|
|
||||||
def _web(match="full", url="https://example.test/a", **data):
|
|
||||||
return Evidence(
|
|
||||||
source=EvidenceSource.WEB_DETECTION,
|
|
||||||
reason="Web detection match",
|
|
||||||
confidence=0.9,
|
|
||||||
data={"match": match, "result_url": url, "evidence_id": "ev-1", **data},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# --- citation verification ---------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_claim_citing_real_evidence_survives():
|
|
||||||
client, _ = _client(
|
|
||||||
{"claims": [{"text": "동일 이미지가 외부 페이지에 있습니다.", "evidence": ["E1"]}],
|
|
||||||
"needs_check": ["출처 페이지의 라이선스 표기"]}
|
|
||||||
)
|
|
||||||
|
|
||||||
result = client.summarize_evidence_detailed([_web()])
|
|
||||||
|
|
||||||
assert result.dropped_claims == 0
|
|
||||||
assert len(result.claims) == 1
|
|
||||||
assert result.claims[0]["evidence"] == ["E1"]
|
|
||||||
assert "동일 이미지가 외부 페이지에 있습니다." in result.text
|
|
||||||
assert "출처 페이지의 라이선스 표기" in result.text
|
|
||||||
|
|
||||||
|
|
||||||
def test_claim_citing_an_invented_label_is_dropped():
|
|
||||||
# The failure mode that matters: a confident sentence attached to evidence
|
|
||||||
# that was never shown to the model.
|
|
||||||
client, _ = _client(
|
|
||||||
{"claims": [
|
|
||||||
{"text": "실제 근거.", "evidence": ["E1"]},
|
|
||||||
{"text": "지어낸 근거에 붙은 단정.", "evidence": ["E9"]},
|
|
||||||
]}
|
|
||||||
)
|
|
||||||
|
|
||||||
result = client.summarize_evidence_detailed([_web()])
|
|
||||||
|
|
||||||
assert result.dropped_claims == 1
|
|
||||||
assert [claim["text"] for claim in result.claims] == ["실제 근거."]
|
|
||||||
assert "지어낸" not in result.text
|
|
||||||
|
|
||||||
|
|
||||||
def test_claim_that_echoes_the_shape_example_is_dropped():
|
|
||||||
# Observed with qwen2.5:0.5b against real evidence: it returned the
|
|
||||||
# placeholder as the claim text while citing every real label, so citation
|
|
||||||
# checking alone passed it as perfectly grounded and completely empty.
|
|
||||||
client, _ = _client(
|
|
||||||
{"claims": [{"text": CLAIM_TEXT_PLACEHOLDER, "evidence": ["E1"]}],
|
|
||||||
"needs_check": [NEEDS_CHECK_PLACEHOLDER]}
|
|
||||||
)
|
|
||||||
|
|
||||||
result = client.summarize_evidence_detailed([_web()])
|
|
||||||
|
|
||||||
assert result.claims == ()
|
|
||||||
assert result.dropped_claims == 1
|
|
||||||
assert result.needs_check == ()
|
|
||||||
assert CLAIM_TEXT_PLACEHOLDER not in result.text
|
|
||||||
|
|
||||||
|
|
||||||
def test_any_angle_bracket_placeholder_is_dropped():
|
|
||||||
client, _ = _client({"claims": [{"text": "<summary here>", "evidence": ["E1"]}]})
|
|
||||||
|
|
||||||
result = client.summarize_evidence_detailed([_web()])
|
|
||||||
|
|
||||||
assert result.claims == ()
|
|
||||||
|
|
||||||
|
|
||||||
def test_claim_without_any_citation_is_dropped():
|
|
||||||
client, _ = _client({"claims": [{"text": "근거 없는 단정.", "evidence": []}]})
|
|
||||||
|
|
||||||
result = client.summarize_evidence_detailed([_web()])
|
|
||||||
|
|
||||||
assert result.dropped_claims == 1
|
|
||||||
assert result.claims == ()
|
|
||||||
assert "근거 없는 단정" not in result.text
|
|
||||||
|
|
||||||
|
|
||||||
def test_unparseable_output_yields_no_claims_and_no_prose():
|
|
||||||
# A model that ignores the format contract must not have its prose shown as
|
|
||||||
# if it had been checked.
|
|
||||||
client, _ = _client("I think this image is fine, it looks authentic.")
|
|
||||||
|
|
||||||
result = client.summarize_evidence_detailed([_web()])
|
|
||||||
|
|
||||||
assert result.claims == ()
|
|
||||||
assert "authentic" not in result.text
|
|
||||||
|
|
||||||
|
|
||||||
def test_json_wrapped_in_prose_is_still_read():
|
|
||||||
client, _ = _client(
|
|
||||||
'Sure! {"claims": [{"text": "근거 있음.", "evidence": ["E1"]}]} hope that helps'
|
|
||||||
)
|
|
||||||
|
|
||||||
result = client.summarize_evidence_detailed([_web()])
|
|
||||||
|
|
||||||
assert len(result.claims) == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_partially_valid_citation_list_keeps_only_real_labels():
|
|
||||||
client, _ = _client(
|
|
||||||
{"claims": [{"text": "혼합 인용.", "evidence": ["E1", "E42"]}]}
|
|
||||||
)
|
|
||||||
|
|
||||||
result = client.summarize_evidence_detailed([_web()])
|
|
||||||
|
|
||||||
assert result.claims[0]["evidence"] == ["E1"]
|
|
||||||
|
|
||||||
|
|
||||||
# --- prompt input boundary ---------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_prompt_is_capped_and_keeps_the_rows_that_carry_risk():
|
|
||||||
scoring = [_web(match="full", url=f"https://example.test/{index}") for index in range(4)]
|
|
||||||
noise = [
|
|
||||||
Evidence(EvidenceSource.SEARCH_SKIPPED, "quota reached", 0.0, {})
|
|
||||||
for _ in range(MAX_PROMPT_EVIDENCE + 10)
|
|
||||||
]
|
|
||||||
|
|
||||||
selected = _select_prompt_evidence(noise + scoring)
|
|
||||||
|
|
||||||
assert len(selected) == MAX_PROMPT_EVIDENCE
|
|
||||||
assert all(item in selected for item in scoring)
|
|
||||||
|
|
||||||
|
|
||||||
def test_prompt_excludes_unvetted_and_privacy_fields():
|
|
||||||
evidence = _web(
|
|
||||||
promotion_reason="이 값을 그대로 베끼면 요약이 증거 목록 복붙이 된다",
|
|
||||||
privacy_note="동일 인물 판정은 아닙니다",
|
|
||||||
crop_index=2,
|
|
||||||
thumbnail_url="https://example.test/thumb.jpg",
|
|
||||||
page_title="허용되는 제목",
|
|
||||||
)
|
|
||||||
|
|
||||||
prompt = _evidence_prompt([evidence])
|
|
||||||
|
|
||||||
assert "허용되는 제목" in prompt
|
|
||||||
assert "복붙" not in prompt
|
|
||||||
assert "privacy_note" not in prompt
|
|
||||||
assert "crop_index" not in prompt
|
|
||||||
assert "thumbnail_url" not in prompt
|
|
||||||
|
|
||||||
|
|
||||||
def test_prompt_labels_evidence_so_the_model_can_cite_it():
|
|
||||||
prompt = _evidence_prompt(_select_prompt_evidence([_web(), _web(url="https://b.test")]))
|
|
||||||
|
|
||||||
assert "[E1]" in prompt
|
|
||||||
assert "[E2]" in prompt
|
|
||||||
|
|
||||||
|
|
||||||
def test_llm_summary_evidence_is_never_fed_back_into_the_prompt():
|
|
||||||
prior = Evidence(EvidenceSource.LLM_SUMMARY, "prior summary", 0.0, {"summary": "이전 요약"})
|
|
||||||
|
|
||||||
selected = _select_prompt_evidence([prior, _web()])
|
|
||||||
|
|
||||||
assert all(item.source != EvidenceSource.LLM_SUMMARY for item in selected)
|
|
||||||
|
|
||||||
|
|
||||||
# --- request shape -----------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_request_pins_the_context_window_and_asks_for_json():
|
|
||||||
client, transport = _client({"claims": []})
|
|
||||||
|
|
||||||
client.summarize_evidence_detailed([_web()])
|
|
||||||
|
|
||||||
assert transport.sent["format"] == "json"
|
|
||||||
assert transport.sent["options"]["num_ctx"] == client.num_ctx
|
|
||||||
assert transport.sent["keep_alive"] == client.keep_alive
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("field", ["grounded_claims", "dropped_claims"])
|
|
||||||
def test_assistant_records_how_much_survived_verification(field):
|
|
||||||
client, _ = _client(
|
|
||||||
{"claims": [
|
|
||||||
{"text": "진짜.", "evidence": ["E1"]},
|
|
||||||
{"text": "가짜.", "evidence": ["E7"]},
|
|
||||||
]}
|
|
||||||
)
|
|
||||||
|
|
||||||
evidence = InternalLlmAssistant(client).summarize("SUB-1", [_web()])
|
|
||||||
|
|
||||||
assert evidence.data[field] == 1
|
|
||||||
|
|
||||||
|
|
||||||
# --- linked sources must describe what the model read -------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def _many(count):
|
|
||||||
return [_web(url=f"https://example.test/{index}") for index in range(count)]
|
|
||||||
|
|
||||||
|
|
||||||
def test_linked_sources_never_exceed_what_the_model_was_shown():
|
|
||||||
# The original defect: a 79-row case produced a summary read from 12 rows
|
|
||||||
# while the UI reported 78 "linked sources" and the policy check passed.
|
|
||||||
client, _ = _client({"claims": []})
|
|
||||||
evidence = _many(40)
|
|
||||||
|
|
||||||
result_evidence = InternalLlmAssistant(client).summarize("SUB-1", evidence)
|
|
||||||
|
|
||||||
assert len(result_evidence.data["source_evidence_ids"]) <= MAX_PROMPT_EVIDENCE
|
|
||||||
assert len(result_evidence.data["source_urls"]) <= MAX_PROMPT_EVIDENCE
|
|
||||||
|
|
||||||
|
|
||||||
def test_linked_sources_are_the_cited_rows_when_claims_verify():
|
|
||||||
client, _ = _client({"claims": [{"text": "근거 있음.", "evidence": ["E1"]}]})
|
|
||||||
|
|
||||||
result_evidence = InternalLlmAssistant(client).summarize("SUB-1", _many(20))
|
|
||||||
|
|
||||||
assert len(result_evidence.data["source_urls"]) == 1
|
|
||||||
assert result_evidence.data["verified"] is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_verified_is_false_when_nothing_survived_citation_checking():
|
|
||||||
# "verified" used to mean "the case has evidence", which is not a statement
|
|
||||||
# about the summary at all.
|
|
||||||
client, _ = _client({"claims": [{"text": "지어냄.", "evidence": ["E99"]}]})
|
|
||||||
|
|
||||||
result_evidence = InternalLlmAssistant(client).summarize("SUB-1", _many(20))
|
|
||||||
|
|
||||||
assert result_evidence.data["verified"] is False
|
|
||||||
|
|
@ -1,182 +0,0 @@
|
||||||
"""Provenance signals: rights metadata transcription + capture forensics.
|
|
||||||
|
|
||||||
Both ship as zero-point labels, so the tests that matter most are the ones about
|
|
||||||
what is NOT read (privacy whitelist) and what does NOT trigger (precision).
|
|
||||||
"""
|
|
||||||
|
|
||||||
from io import BytesIO
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
import pytest
|
|
||||||
from PIL import Image
|
|
||||||
|
|
||||||
from rights_filter.analysis.provenance import (
|
|
||||||
MIN_CAPTURE_CATEGORIES,
|
|
||||||
RIGHTS_EXIF_TAGS,
|
|
||||||
detect_screen_capture,
|
|
||||||
extract_rights_metadata,
|
|
||||||
provenance_evidence,
|
|
||||||
)
|
|
||||||
from rights_filter.analysis.risk_scoring import RiskScorer
|
|
||||||
from rights_filter.domain.records import EvidenceSource
|
|
||||||
from rights_filter.governance.policies import assert_no_biometric_template
|
|
||||||
from rights_filter.server.store_serialization import (
|
|
||||||
_domain_evidence_from_ui,
|
|
||||||
_evidence_payload,
|
|
||||||
)
|
|
||||||
|
|
||||||
EXIF_COPYRIGHT = 0x8298
|
|
||||||
EXIF_ARTIST = 0x013B
|
|
||||||
EXIF_GPS_IFD = 0x8825
|
|
||||||
EXIF_BODY_SERIAL = 0xA431
|
|
||||||
EXIF_MAKE = 0x010F
|
|
||||||
EXIF_MODEL = 0x0110
|
|
||||||
|
|
||||||
|
|
||||||
def _jpeg(width=640, height=480, exif=None, color=128) -> bytes:
|
|
||||||
image = Image.new("RGB", (width, height), (color, color, color))
|
|
||||||
buffer = BytesIO()
|
|
||||||
if exif is None:
|
|
||||||
image.save(buffer, format="JPEG")
|
|
||||||
else:
|
|
||||||
image.save(buffer, format="JPEG", exif=exif)
|
|
||||||
return buffer.getvalue()
|
|
||||||
|
|
||||||
|
|
||||||
def _exif(tags):
|
|
||||||
exif = Image.Exif()
|
|
||||||
for tag, value in tags.items():
|
|
||||||
exif[int(tag)] = value
|
|
||||||
return exif
|
|
||||||
|
|
||||||
|
|
||||||
def _capture_frame(width=1280, height=720, subtitles=True, letterbox=True) -> bytes:
|
|
||||||
frame = np.full((height, width), 130, dtype=np.uint8)
|
|
||||||
# Mid-frame texture so the bottom band stands out against something.
|
|
||||||
frame[int(height * 0.2) : int(height * 0.6), :] = 120
|
|
||||||
if letterbox:
|
|
||||||
bar = int(height * 0.08)
|
|
||||||
frame[:bar, :] = 0
|
|
||||||
frame[-bar:, :] = 0
|
|
||||||
if subtitles:
|
|
||||||
# Blocky high-contrast glyph-like marks in the lower band.
|
|
||||||
row = int(height * 0.82)
|
|
||||||
for start in range(120, width - 120, 26):
|
|
||||||
frame[row : row + 22, start : start + 14] = 255
|
|
||||||
buffer = BytesIO()
|
|
||||||
Image.fromarray(frame).save(buffer, format="PNG")
|
|
||||||
return buffer.getvalue()
|
|
||||||
|
|
||||||
|
|
||||||
# --- rights metadata ---------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_reads_the_copyright_the_author_wrote_into_the_file():
|
|
||||||
content = _jpeg(exif=_exif({EXIF_COPYRIGHT: "(C) 2026 Example News"}))
|
|
||||||
|
|
||||||
assert extract_rights_metadata(content)["copyright"] == "(C) 2026 Example News"
|
|
||||||
|
|
||||||
|
|
||||||
def test_reads_artist_field():
|
|
||||||
content = _jpeg(exif=_exif({EXIF_ARTIST: "Hong Gildong"}))
|
|
||||||
|
|
||||||
assert extract_rights_metadata(content)["artist"] == "Hong Gildong"
|
|
||||||
|
|
||||||
|
|
||||||
def test_image_without_rights_fields_yields_nothing():
|
|
||||||
assert extract_rights_metadata(_jpeg()) == {}
|
|
||||||
|
|
||||||
|
|
||||||
def test_unreadable_content_is_no_metadata_not_an_error():
|
|
||||||
# .svg is an accepted upload suffix that Pillow cannot open.
|
|
||||||
assert extract_rights_metadata(b"<svg xmlns='http://www.w3.org/2000/svg'/>") == {}
|
|
||||||
assert extract_rights_metadata(b"") == {}
|
|
||||||
|
|
||||||
|
|
||||||
def test_privacy_fields_are_never_read():
|
|
||||||
# The whitelist is the privacy boundary: location and camera identity must
|
|
||||||
# not be extracted even when present in the file.
|
|
||||||
content = _jpeg(
|
|
||||||
exif=_exif(
|
|
||||||
{
|
|
||||||
EXIF_COPYRIGHT: "(C) 2026 Example",
|
|
||||||
EXIF_BODY_SERIAL: "SN-12345",
|
|
||||||
EXIF_MAKE: "ExampleCorp",
|
|
||||||
EXIF_MODEL: "X-100",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
extracted = extract_rights_metadata(content)
|
|
||||||
|
|
||||||
# Only the rights field comes back — camera identity does not, even though
|
|
||||||
# it is sitting right next to it in the same EXIF block.
|
|
||||||
assert extracted == {"copyright": "(C) 2026 Example"}
|
|
||||||
# GPS is not written here (its tag is an IFD pointer, not a value), so pin it
|
|
||||||
# at the whitelist instead: the boundary is the constant, not the fixture.
|
|
||||||
for tag in (EXIF_GPS_IFD, EXIF_BODY_SERIAL, EXIF_MAKE, EXIF_MODEL):
|
|
||||||
assert tag not in RIGHTS_EXIF_TAGS
|
|
||||||
|
|
||||||
|
|
||||||
def test_rights_reason_marks_the_value_as_self_reported():
|
|
||||||
# Metadata is trivially forgeable; the reason must never read as clearance.
|
|
||||||
evidence = provenance_evidence(_jpeg(exif=_exif({EXIF_COPYRIGHT: "(C) Example"})))
|
|
||||||
|
|
||||||
assert "자기신고" in evidence[0].reason
|
|
||||||
|
|
||||||
|
|
||||||
# --- capture forensics -------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_letterboxed_broadcast_frame_with_subtitles_is_flagged():
|
|
||||||
result = detect_screen_capture(_capture_frame())
|
|
||||||
|
|
||||||
assert result
|
|
||||||
assert len(result["categories"]) >= MIN_CAPTURE_CATEGORIES
|
|
||||||
|
|
||||||
|
|
||||||
def test_plain_photograph_is_not_flagged():
|
|
||||||
assert detect_screen_capture(_jpeg(width=800, height=533)) == {}
|
|
||||||
|
|
||||||
|
|
||||||
def test_geometry_alone_does_not_trigger_the_label():
|
|
||||||
# A letterboxed 1280x720 frame is one observation stated twice. Counting it
|
|
||||||
# as two categories would let any cinematic crop trigger the label.
|
|
||||||
result = detect_screen_capture(_capture_frame(subtitles=False))
|
|
||||||
|
|
||||||
assert result == {}
|
|
||||||
|
|
||||||
|
|
||||||
def test_undecodable_content_is_not_flagged():
|
|
||||||
assert detect_screen_capture(b"not an image") == {}
|
|
||||||
|
|
||||||
|
|
||||||
# --- evidence, scoring, round trip -------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_provenance_evidence_scores_zero_but_still_surfaces_its_reason():
|
|
||||||
evidence = provenance_evidence(_jpeg(exif=_exif({EXIF_COPYRIGHT: "(C) Example"})))
|
|
||||||
|
|
||||||
result = RiskScorer().score(evidence)
|
|
||||||
|
|
||||||
assert result.score == 0
|
|
||||||
assert result.band == "low"
|
|
||||||
assert any("권리 표기" in reason for reason in result.reasons)
|
|
||||||
|
|
||||||
|
|
||||||
def test_provenance_evidence_survives_the_payload_round_trip():
|
|
||||||
evidence = provenance_evidence(_jpeg(exif=_exif({EXIF_COPYRIGHT: "(C) Example"})))[0]
|
|
||||||
|
|
||||||
restored = _domain_evidence_from_ui(_evidence_payload("SUB-1", evidence))
|
|
||||||
|
|
||||||
assert restored.source == EvidenceSource.PROVENANCE
|
|
||||||
assert restored.data["provenance_kind"] == "rights_metadata"
|
|
||||||
assert restored.data["rights_fields"]["copyright"] == "(C) Example"
|
|
||||||
assert RiskScorer().score([restored]).score == 0
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("kind", ["rights_metadata", "screen_capture"])
|
|
||||||
def test_provenance_payloads_carry_no_biometric_fields(kind):
|
|
||||||
content = _jpeg(exif=_exif({EXIF_COPYRIGHT: "(C) x"})) if kind == "rights_metadata" else _capture_frame()
|
|
||||||
for evidence in provenance_evidence(content):
|
|
||||||
assert_no_biometric_template(_evidence_payload("SUB-1", evidence))
|
|
||||||
|
|
@ -1,27 +1,11 @@
|
||||||
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()
|
||||||
|
|
||||||
|
|
@ -58,57 +42,6 @@ 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(
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import base64
|
||||||
from rights_filter.analysis.preprocessing import ImagePayload
|
from rights_filter.analysis.preprocessing import ImagePayload
|
||||||
from rights_filter.domain.records import Evidence, EvidenceSource
|
from rights_filter.domain.records import Evidence, EvidenceSource
|
||||||
from rights_filter.integrations.cloud_vision_web_detection import GoogleVisionRestClient
|
from rights_filter.integrations.cloud_vision_web_detection import GoogleVisionRestClient
|
||||||
from rights_filter.integrations.env_clients import DEFAULT_OLLAMA_MODEL, build_provider_runtime
|
from rights_filter.integrations.env_clients import build_provider_runtime
|
||||||
from rights_filter.integrations.google_custom_search import GoogleCustomSearchClient
|
from rights_filter.integrations.google_custom_search import GoogleCustomSearchClient
|
||||||
from rights_filter.integrations.naver_search import NaverOpenApiSearchClient
|
from rights_filter.integrations.naver_search import NaverOpenApiSearchClient
|
||||||
from rights_filter.analysis.llm_assistance import OllamaGenerateLlmClient
|
from rights_filter.analysis.llm_assistance import OllamaGenerateLlmClient
|
||||||
|
|
@ -130,12 +130,8 @@ def test_google_custom_search_client_uses_env_key_and_search_engine_id():
|
||||||
assert "searchType=image" in call["url"]
|
assert "searchType=image" in call["url"]
|
||||||
|
|
||||||
|
|
||||||
def test_ollama_generate_client_uses_local_endpoint_and_renders_verified_claims():
|
def test_ollama_generate_client_uses_local_endpoint_and_returns_response_text():
|
||||||
# The client no longer passes the model's prose through: it reads the
|
transport = FakeJsonTransport({"response": "Source-linked summary"})
|
||||||
# structured claims and renders only the ones whose citations check out.
|
|
||||||
transport = FakeJsonTransport(
|
|
||||||
{"response": '{"claims": [{"text": "출처 페이지가 확인됩니다.", "evidence": ["E1"]}]}'}
|
|
||||||
)
|
|
||||||
client = OllamaGenerateLlmClient(
|
client = OllamaGenerateLlmClient(
|
||||||
base_url="http://localhost:11434",
|
base_url="http://localhost:11434",
|
||||||
model="qwen2.5:0.5b-instruct",
|
model="qwen2.5:0.5b-instruct",
|
||||||
|
|
@ -153,8 +149,7 @@ def test_ollama_generate_client_uses_local_endpoint_and_renders_verified_claims(
|
||||||
summary = client.summarize_evidence(evidence)
|
summary = client.summarize_evidence(evidence)
|
||||||
|
|
||||||
call = transport.calls[0]
|
call = transport.calls[0]
|
||||||
assert "출처 페이지가 확인됩니다." in summary
|
assert summary == "Source-linked summary"
|
||||||
assert "[E1]" in summary
|
|
||||||
assert call["method"] == "POST"
|
assert call["method"] == "POST"
|
||||||
assert call["url"] == "http://localhost:11434/api/generate"
|
assert call["url"] == "http://localhost:11434/api/generate"
|
||||||
assert call["headers"] == {}
|
assert call["headers"] == {}
|
||||||
|
|
@ -279,6 +274,4 @@ def test_provider_runtime_leaves_missing_env_providers_disabled():
|
||||||
"GOOGLE_CUSTOM_SEARCH_CX": False,
|
"GOOGLE_CUSTOM_SEARCH_CX": False,
|
||||||
}
|
}
|
||||||
assert runtime.provider_payloads["llm"]["enabled"] is True
|
assert runtime.provider_payloads["llm"]["enabled"] is True
|
||||||
assert runtime.provider_payloads["llm"]["compliance"] == (
|
assert runtime.provider_payloads["llm"]["compliance"] == "Ollama local API configured (qwen2.5:0.5b-instruct)"
|
||||||
f"Ollama local API configured ({DEFAULT_OLLAMA_MODEL})"
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -1,125 +0,0 @@
|
||||||
"""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
|
|
||||||
|
|
@ -1,153 +0,0 @@
|
||||||
"""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,11 +7,7 @@ from io import BytesIO
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from rights_filter.analysis.face_person_detection import (
|
from rights_filter.analysis.face_person_detection import FacePersonSignal
|
||||||
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
|
||||||
|
|
@ -23,13 +19,6 @@ 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
|
||||||
|
|
@ -1071,7 +1060,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 IDENTIFIABLE_FACE_REASON in review["reasons"]
|
assert "Face/person detected" in review["reasons"]
|
||||||
assert "재분석 요청이 접수됨" not in review["reasons"]
|
assert "재분석 요청이 접수됨" not in review["reasons"]
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -7482,11 +7471,7 @@ def test_sqlite_store_rerun_enrichment_uses_ollama_llm_when_configured(tmp_path:
|
||||||
|
|
||||||
|
|
||||||
def test_sqlite_store_seed_auto_runs_ollama_llm_when_source_evidence_exists(tmp_path: Path):
|
def test_sqlite_store_seed_auto_runs_ollama_llm_when_source_evidence_exists(tmp_path: Path):
|
||||||
# The assistant reads structured claims now; prose without citations is
|
transport = FakeJsonTransport({"response": "Seed-time source summary"})
|
||||||
# dropped before it can be stored.
|
|
||||||
transport = FakeJsonTransport(
|
|
||||||
{"response": '{"claims": [{"text": "시드 시점 근거 요약.", "evidence": ["E1"]}]}'}
|
|
||||||
)
|
|
||||||
runtime = build_provider_runtime(
|
runtime = build_provider_runtime(
|
||||||
env={"OLLAMA_BASE_URL": "http://localhost:11434", "OLLAMA_MODEL": "qwen2.5:0.5b-instruct"},
|
env={"OLLAMA_BASE_URL": "http://localhost:11434", "OLLAMA_MODEL": "qwen2.5:0.5b-instruct"},
|
||||||
transport=transport,
|
transport=transport,
|
||||||
|
|
@ -8023,7 +8008,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"] == [IDENTIFIABLE_FACE_REASON]
|
assert review["reasons"] == ["Face/person detected"]
|
||||||
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
|
||||||
|
|
||||||
|
|
@ -8067,7 +8052,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 IDENTIFIABLE_FACE_REASON in review["reasons"]
|
assert "Face/person detected" 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
|
||||||
|
|
|
||||||
|
|
@ -1,255 +0,0 @@
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from rights_filter.integrations.env_clients import build_provider_runtime
|
|
||||||
from rights_filter.server.sqlite_store import CopyrighterStore
|
|
||||||
from rights_filter.server.store_backup import restore_backup
|
|
||||||
from rights_filter.server.store_workflow import MAX_BATCH_DECISIONS
|
|
||||||
|
|
||||||
from tests.rights_filter.server.test_sqlite_store import _image_store, FakeJsonTransport
|
|
||||||
|
|
||||||
|
|
||||||
def _photo_store(root: Path, *, duplicate: bool = False):
|
|
||||||
"""Two visually distinct PNGs (ids = file stems), optionally plus a byte copy.
|
|
||||||
|
|
||||||
Real raster images so the fingerprint/duplicate paths behave as in production;
|
|
||||||
the shared SVG fixture cannot be perceptually hashed.
|
|
||||||
"""
|
|
||||||
from io import BytesIO
|
|
||||||
|
|
||||||
from PIL import Image, ImageDraw
|
|
||||||
|
|
||||||
from rights_filter.server.image_store import LocalSubmissionImageStore
|
|
||||||
|
|
||||||
image_dir = root / "images"
|
|
||||||
image_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
for name, color in (("alpha", (210, 40, 40)), ("beta", (30, 90, 200))):
|
|
||||||
canvas = Image.new("RGB", (64, 48), color=color)
|
|
||||||
draw = ImageDraw.Draw(canvas)
|
|
||||||
# Vary the pixels so the average hash is informative, not degenerate.
|
|
||||||
draw.rectangle([4, 4, 30, 30], fill=(255, 255, 255))
|
|
||||||
draw.ellipse([32, 10, 60, 40], fill=(10, 10, 10))
|
|
||||||
if name == "beta":
|
|
||||||
draw.line([0, 0, 63, 47], fill=(250, 250, 0), width=5)
|
|
||||||
buffer = BytesIO()
|
|
||||||
canvas.save(buffer, format="PNG")
|
|
||||||
(image_dir / f"{name}.png").write_bytes(buffer.getvalue())
|
|
||||||
|
|
||||||
if duplicate:
|
|
||||||
(image_dir / "alpha-copy.png").write_bytes((image_dir / "alpha.png").read_bytes())
|
|
||||||
|
|
||||||
return LocalSubmissionImageStore(root)
|
|
||||||
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def _first_ids(store: CopyrighterStore, count: int) -> list[str]:
|
|
||||||
return [str(item["id"]) for item in store.bootstrap()["submissions"][:count]]
|
|
||||||
|
|
||||||
|
|
||||||
# --- batch decisions ---------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_batch_decision_applies_to_every_selected_submission(tmp_path: Path):
|
|
||||||
store = _store(tmp_path)
|
|
||||||
ids = _first_ids(store, 2)
|
|
||||||
|
|
||||||
payload = store.record_decision_batch(ids, "approved")
|
|
||||||
|
|
||||||
assert payload["batchDecision"]["applied"] == ids
|
|
||||||
assert payload["batchDecision"]["failed"] == []
|
|
||||||
decided = {
|
|
||||||
str(item["id"]): item["decisionStatus"] for item in payload["submissions"]
|
|
||||||
}
|
|
||||||
assert all(decided[submission_id] == "approved" for submission_id in ids)
|
|
||||||
|
|
||||||
|
|
||||||
def test_batch_decision_reports_per_case_failure_without_aborting_the_rest(tmp_path: Path):
|
|
||||||
store = _store(tmp_path)
|
|
||||||
ids = _first_ids(store, 1)
|
|
||||||
|
|
||||||
payload = store.record_decision_batch([*ids, "SUB-DOES-NOT-EXIST"], "approved")
|
|
||||||
|
|
||||||
assert payload["batchDecision"]["applied"] == ids
|
|
||||||
assert [item["submissionId"] for item in payload["batchDecision"]["failed"]] == [
|
|
||||||
"SUB-DOES-NOT-EXIST"
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_batch_reject_requires_memo(tmp_path: Path):
|
|
||||||
store = _store(tmp_path)
|
|
||||||
ids = _first_ids(store, 1)
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="memo"):
|
|
||||||
store.record_decision_batch(ids, "rejected", " ")
|
|
||||||
|
|
||||||
|
|
||||||
def test_batch_decision_rejects_oversized_selection(tmp_path: Path):
|
|
||||||
store = _store(tmp_path)
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="batch too large"):
|
|
||||||
store.record_decision_batch(
|
|
||||||
[f"SUB-{index}" for index in range(MAX_BATCH_DECISIONS + 1)], "approved"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# --- memo presets ------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_memo_preset_save_is_upsert_by_label(tmp_path: Path):
|
|
||||||
store = _store(tmp_path)
|
|
||||||
|
|
||||||
store.save_decision_memo_preset("연예인 초상", "연예인 초상이 포함되어 있습니다.")
|
|
||||||
presets = store.save_decision_memo_preset("연예인 초상", "수정된 문구")
|
|
||||||
|
|
||||||
assert len(presets) == 1
|
|
||||||
assert presets[0]["body"] == "수정된 문구"
|
|
||||||
assert store.bootstrap()["decisionMemoPresets"][0]["label"] == "연예인 초상"
|
|
||||||
|
|
||||||
|
|
||||||
def test_memo_preset_requires_label_and_body(tmp_path: Path):
|
|
||||||
store = _store(tmp_path)
|
|
||||||
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
store.save_decision_memo_preset("", "body")
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
store.save_decision_memo_preset("label", " ")
|
|
||||||
|
|
||||||
|
|
||||||
def test_memo_preset_delete_removes_entry(tmp_path: Path):
|
|
||||||
store = _store(tmp_path)
|
|
||||||
presets = store.save_decision_memo_preset("방송 캡처", "방송 화면 캡처입니다.")
|
|
||||||
|
|
||||||
remaining = store.delete_decision_memo_preset(presets[0]["id"])
|
|
||||||
|
|
||||||
assert remaining == []
|
|
||||||
|
|
||||||
|
|
||||||
# --- decision export ---------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_export_skips_undecided_cases_and_carries_the_memo(tmp_path: Path):
|
|
||||||
store = _store(tmp_path)
|
|
||||||
ids = _first_ids(store, 1)
|
|
||||||
store.record_decision(ids[0], "rejected", "연예인 초상 포함")
|
|
||||||
|
|
||||||
export = store.export_decisions(audience="internal")
|
|
||||||
|
|
||||||
assert [row["submissionId"] for row in export["rows"]] == ids
|
|
||||||
assert export["rows"][0]["decision"] == "rejected"
|
|
||||||
assert export["rows"][0]["memo"] == "연예인 초상 포함"
|
|
||||||
assert "riskScore" in export["rows"][0]
|
|
||||||
|
|
||||||
|
|
||||||
def test_applicant_export_never_exposes_internal_signals(tmp_path: Path):
|
|
||||||
store = _store(tmp_path)
|
|
||||||
ids = _first_ids(store, 1)
|
|
||||||
store.record_decision(ids[0], "approved")
|
|
||||||
|
|
||||||
export = store.export_decisions(audience="applicant")
|
|
||||||
|
|
||||||
row = export["rows"][0]
|
|
||||||
for forbidden in ("riskScore", "riskBand", "evidenceUsed", "evidenceUrls"):
|
|
||||||
assert forbidden not in row
|
|
||||||
assert "riskScore" not in export["csv"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_export_status_filter_and_bad_audience(tmp_path: Path):
|
|
||||||
store = _store(tmp_path)
|
|
||||||
ids = _first_ids(store, 2)
|
|
||||||
store.record_decision(ids[0], "approved")
|
|
||||||
store.record_decision(ids[1], "held")
|
|
||||||
|
|
||||||
approved = store.export_decisions(status="approved")
|
|
||||||
|
|
||||||
assert [row["submissionId"] for row in approved["rows"]] == [ids[0]]
|
|
||||||
with pytest.raises(ValueError, match="audience"):
|
|
||||||
store.export_decisions(audience="public")
|
|
||||||
|
|
||||||
|
|
||||||
# --- duplicate grouping ------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_identical_images_are_grouped_with_prior_decision(tmp_path: Path):
|
|
||||||
store = _store(tmp_path, duplicate=True)
|
|
||||||
store.record_decision("alpha", "rejected", "원본 반려")
|
|
||||||
|
|
||||||
groups = store.duplicate_groups()
|
|
||||||
|
|
||||||
assert "alpha-copy" in groups
|
|
||||||
assert "alpha" in groups["alpha-copy"]["peerIds"]
|
|
||||||
assert groups["alpha-copy"]["priorDecisions"][0]["decision"] == "rejected"
|
|
||||||
# A distinct image must not be pulled into the group.
|
|
||||||
assert "beta" not in groups
|
|
||||||
|
|
||||||
|
|
||||||
def test_duplicate_group_flags_conflicting_decisions(tmp_path: Path):
|
|
||||||
store = _store(tmp_path, duplicate=True)
|
|
||||||
store.record_decision("alpha", "approved")
|
|
||||||
store.record_decision("alpha-copy", "rejected", "같은 이미지인데 반려")
|
|
||||||
|
|
||||||
groups = store.duplicate_groups()
|
|
||||||
|
|
||||||
assert groups["alpha"]["conflicting"] is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_unique_images_get_no_duplicate_group(tmp_path: Path):
|
|
||||||
store = _store(tmp_path)
|
|
||||||
|
|
||||||
submissions = store.bootstrap()["submissions"]
|
|
||||||
|
|
||||||
assert all("duplicateGroup" not in item for item in submissions)
|
|
||||||
|
|
||||||
|
|
||||||
# --- backup / restore --------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_backup_snapshot_round_trips_decisions(tmp_path: Path):
|
|
||||||
store = _store(tmp_path)
|
|
||||||
ids = _first_ids(store, 1)
|
|
||||||
store.record_decision(ids[0], "approved")
|
|
||||||
|
|
||||||
snapshot = store.create_backup()
|
|
||||||
|
|
||||||
assert snapshot["file"].startswith("copyrighter-snapshot-")
|
|
||||||
assert snapshot["sizeBytes"] > 0
|
|
||||||
assert store.backups()[0]["file"] == snapshot["file"]
|
|
||||||
|
|
||||||
# Simulate loss, then restore into a fresh database path.
|
|
||||||
restored_db = tmp_path / "restored" / "copyrighter.sqlite3"
|
|
||||||
result = restore_backup(snapshot["path"], restored_db)
|
|
||||||
|
|
||||||
assert result["database"] == str(restored_db)
|
|
||||||
reopened = CopyrighterStore(
|
|
||||||
restored_db,
|
|
||||||
provider_runtime=build_provider_runtime(env={}, transport=FakeJsonTransport({})),
|
|
||||||
)
|
|
||||||
reopened.initialize()
|
|
||||||
decided = {
|
|
||||||
str(item["id"]): item["decisionStatus"] for item in reopened.bootstrap()["submissions"]
|
|
||||||
}
|
|
||||||
assert decided[ids[0]] == "approved"
|
|
||||||
|
|
||||||
|
|
||||||
def test_backup_retention_prunes_older_snapshots(tmp_path: Path):
|
|
||||||
store = _store(tmp_path)
|
|
||||||
|
|
||||||
created = [store.create_backup(retention=2)["file"] for _ in range(3)]
|
|
||||||
|
|
||||||
# Distinct files even within one second, and only the newest two survive.
|
|
||||||
assert len(set(created)) == 3
|
|
||||||
assert len(store.backups()) == 2
|
|
||||||
|
|
||||||
|
|
||||||
def test_restore_rejects_missing_archive(tmp_path: Path):
|
|
||||||
with pytest.raises(ValueError, match="not found"):
|
|
||||||
restore_backup(tmp_path / "nope.zip", tmp_path / "db.sqlite3")
|
|
||||||
|
|
@ -17,8 +17,6 @@ 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.provenance",
|
|
||||||
"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",
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -13,20 +13,6 @@
|
||||||
<script src="app.js" defer></script>
|
<script src="app.js" defer></script>
|
||||||
</head>
|
</head>
|
||||||
<body data-internal-only="true">
|
<body data-internal-only="true">
|
||||||
<div class="auth-gate" id="auth-gate" hidden role="dialog" aria-modal="true" aria-labelledby="auth-gate-title">
|
|
||||||
<form class="auth-gate-card" id="auth-gate-form" autocomplete="off">
|
|
||||||
<h1 id="auth-gate-title">접근 토큰 입력</h1>
|
|
||||||
<p class="auth-gate-hint">
|
|
||||||
이 서버는 인증이 설정되어 있습니다. 운영 담당자에게 받은
|
|
||||||
<code>COPYRIGHTER_AUTH_TOKEN</code> 값을 입력하세요.
|
|
||||||
</p>
|
|
||||||
<label class="auth-gate-label" for="auth-gate-input">접근 토큰</label>
|
|
||||||
<input class="auth-gate-input" id="auth-gate-input" type="password" required
|
|
||||||
autocomplete="off" spellcheck="false" aria-describedby="auth-gate-error">
|
|
||||||
<p class="auth-gate-error" id="auth-gate-error" role="alert" hidden></p>
|
|
||||||
<button class="primary-action" id="auth-gate-submit" type="submit">인증</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
<div class="app-shell">
|
<div class="app-shell">
|
||||||
<nav class="nav-rail" aria-label="내부 운영 콘솔">
|
<nav class="nav-rail" aria-label="내부 운영 콘솔">
|
||||||
<div class="brand-block">
|
<div class="brand-block">
|
||||||
|
|
@ -53,11 +39,6 @@
|
||||||
<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>
|
||||||
|
|
@ -102,18 +83,7 @@
|
||||||
<button class="primary-action" type="button" id="reload-submissions">새 제출 불러오기</button>
|
<button class="primary-action" type="button" id="reload-submissions">새 제출 불러오기</button>
|
||||||
<button class="secondary-action" type="button" id="bulk-rerun">선택 재분석</button>
|
<button class="secondary-action" type="button" id="bulk-rerun">선택 재분석</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="queue-actions queue-actions-secondary">
|
|
||||||
<span class="bulk-decision-label" id="bulk-decision-count">선택 0건</span>
|
|
||||||
<button class="approve-action" type="button" id="bulk-approve" data-bulk-decision="approved">선택 승인</button>
|
|
||||||
<button class="hold-action" type="button" id="bulk-hold" data-bulk-decision="held">선택 보류</button>
|
|
||||||
<button class="reject-action" type="button" id="bulk-reject" data-bulk-decision="rejected">선택 반려</button>
|
|
||||||
<button class="secondary-action" type="button" id="export-decisions">판정 내보내기</button>
|
|
||||||
<button class="secondary-action" type="button" id="create-backup">백업 만들기</button>
|
|
||||||
<span class="review-progress" id="review-progress" aria-live="polite"></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="import-status" id="bulk-decision-status" aria-live="polite"></div>
|
|
||||||
<pre class="keyboard-help" id="keyboard-help" hidden></pre>
|
|
||||||
<div class="import-status" id="submission-import-status" aria-live="polite"></div>
|
<div class="import-status" id="submission-import-status" aria-live="polite"></div>
|
||||||
|
|
||||||
<section class="operator-workflow" aria-label="운영 흐름">
|
<section class="operator-workflow" aria-label="운영 흐름">
|
||||||
|
|
@ -151,7 +121,6 @@
|
||||||
<option value="google">구글 근거</option>
|
<option value="google">구글 근거</option>
|
||||||
<option value="fingerprint">이미지 유사도</option>
|
<option value="fingerprint">이미지 유사도</option>
|
||||||
<option value="face">얼굴/인물 감지</option>
|
<option value="face">얼굴/인물 감지</option>
|
||||||
<option value="provenance">출처 표기</option>
|
|
||||||
<option value="llm">내부 요약</option>
|
<option value="llm">내부 요약</option>
|
||||||
<option value="failure">외부 검색 tool 실패</option>
|
<option value="failure">외부 검색 tool 실패</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
@ -231,12 +200,10 @@
|
||||||
<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>
|
||||||
</label>
|
</label>
|
||||||
<div class="memo-presets" id="memo-presets" aria-label="자주 쓰는 사유"></div>
|
|
||||||
<p class="form-error" id="memo-error" aria-live="assertive"></p>
|
<p class="form-error" id="memo-error" aria-live="assertive"></p>
|
||||||
<div class="decision-actions">
|
<div class="decision-actions">
|
||||||
<button class="approve-action" type="button" data-decision="approved">승인</button>
|
<button class="approve-action" type="button" data-decision="approved">승인</button>
|
||||||
|
|
@ -250,7 +217,6 @@
|
||||||
<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>
|
||||||
|
|
@ -505,17 +471,6 @@
|
||||||
<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>
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,6 @@
|
||||||
internal: "내부",
|
internal: "내부",
|
||||||
fingerprint: "이미지 유사도",
|
fingerprint: "이미지 유사도",
|
||||||
face: "얼굴/인물 감지",
|
face: "얼굴/인물 감지",
|
||||||
provenance: "출처 표기",
|
|
||||||
naver: "네이버",
|
naver: "네이버",
|
||||||
google: "구글",
|
google: "구글",
|
||||||
llm: "내부 요약",
|
llm: "내부 요약",
|
||||||
|
|
|
||||||
|
|
@ -2817,332 +2817,3 @@ tbody tr.selected-row,
|
||||||
.evidence-row-new {
|
.evidence-row-new {
|
||||||
box-shadow: inset 0 0 0 2px var(--teal);
|
box-shadow: inset 0 0 0 2px var(--teal);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- Auth gate ------------------------------------------------------- */
|
|
||||||
/* Shown only when the server runs with COPYRIGHTER_AUTH_TOKEN and a data
|
|
||||||
route answered 401. Covers the shell so no half-loaded queue is visible
|
|
||||||
behind it. */
|
|
||||||
.auth-gate {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
z-index: 100;
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
padding: 24px;
|
|
||||||
background: rgba(16, 23, 29, 0.55);
|
|
||||||
backdrop-filter: blur(2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-gate[hidden] {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-gate-card {
|
|
||||||
width: min(420px, 100%);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 26px 26px 22px;
|
|
||||||
background: var(--card-raised);
|
|
||||||
border: 1px solid var(--hair);
|
|
||||||
border-radius: var(--r-lg);
|
|
||||||
box-shadow: var(--shadow-lg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-gate-card h1 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 1.05rem;
|
|
||||||
letter-spacing: -0.01em;
|
|
||||||
color: var(--ink);
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-gate-hint {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 0.82rem;
|
|
||||||
line-height: 1.55;
|
|
||||||
color: var(--ink-soft);
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-gate-hint code {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 0.78rem;
|
|
||||||
padding: 1px 5px;
|
|
||||||
border-radius: var(--r-sm);
|
|
||||||
background: var(--card-sunk);
|
|
||||||
border: 1px solid var(--hair-soft);
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-gate-label {
|
|
||||||
margin-top: 4px;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: 0.02em;
|
|
||||||
color: var(--ink-soft);
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-gate-input {
|
|
||||||
width: 100%;
|
|
||||||
padding: 9px 11px;
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 0.86rem;
|
|
||||||
color: var(--ink);
|
|
||||||
background: var(--card);
|
|
||||||
border: 1px solid var(--hair-strong);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-gate-input:focus {
|
|
||||||
outline: 2px solid var(--teal-line);
|
|
||||||
outline-offset: 1px;
|
|
||||||
border-color: var(--teal);
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-gate-error {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 0.78rem;
|
|
||||||
color: var(--red);
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-gate-error[hidden] {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-gate-card .primary-action {
|
|
||||||
margin-top: 6px;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- Batch decisions / review throughput ------------------------------ */
|
|
||||||
.queue-actions-secondary {
|
|
||||||
margin-top: 8px;
|
|
||||||
padding-top: 8px;
|
|
||||||
border-top: 1px dashed var(--hair);
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bulk-decision-label {
|
|
||||||
font-size: 0.78rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--ink-soft);
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
|
|
||||||
.review-progress {
|
|
||||||
margin-left: auto;
|
|
||||||
font-size: 0.76rem;
|
|
||||||
color: var(--ink-faint);
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
|
|
||||||
.keyboard-help {
|
|
||||||
margin: 8px 0 0;
|
|
||||||
padding: 12px 14px;
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 0.78rem;
|
|
||||||
line-height: 1.7;
|
|
||||||
color: var(--ink-soft);
|
|
||||||
background: var(--card-sunk);
|
|
||||||
border: 1px solid var(--hair);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
white-space: pre;
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.keyboard-help[hidden] {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- Decision memo presets -------------------------------------------- */
|
|
||||||
.memo-presets {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 5px;
|
|
||||||
margin-top: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.memo-preset-chip {
|
|
||||||
padding: 3px 9px;
|
|
||||||
font-size: 0.74rem;
|
|
||||||
color: var(--ink-soft);
|
|
||||||
background: var(--card-sunk);
|
|
||||||
border: 1px solid var(--hair);
|
|
||||||
border-radius: var(--r-pill);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.memo-preset-chip:hover {
|
|
||||||
color: var(--teal-deep);
|
|
||||||
border-color: var(--teal-line);
|
|
||||||
background: var(--teal-tint-soft);
|
|
||||||
}
|
|
||||||
|
|
||||||
.memo-preset-add {
|
|
||||||
border-style: dashed;
|
|
||||||
color: var(--ink-faint);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- Duplicate group badge -------------------------------------------- */
|
|
||||||
.duplicate-badge {
|
|
||||||
display: inline-block;
|
|
||||||
margin-top: 3px;
|
|
||||||
padding: 2px 8px;
|
|
||||||
font-size: 0.7rem;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: 0.01em;
|
|
||||||
color: var(--blue);
|
|
||||||
background: var(--blue-tint);
|
|
||||||
border: 1px solid var(--blue-line);
|
|
||||||
border-radius: var(--r-pill);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.duplicate-badge:hover {
|
|
||||||
filter: brightness(0.97);
|
|
||||||
}
|
|
||||||
|
|
||||||
.duplicate-badge.conflicting {
|
|
||||||
color: var(--red);
|
|
||||||
background: var(--red-tint);
|
|
||||||
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