diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..6e8bf73 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/docs/INDEX.md b/docs/INDEX.md index c8b9e32..5beb5bc 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -1,5 +1,7 @@ # Project INDEX +- 2026-09-25 · **codex 검토 잔여 ③④⑤ + 밀린 부채 4건.** ③ 파일명 충돌 — 제출 id 가 파일 stem 이라 `지원자A/photo.jpg`·`지원자B/photo.png` 가 한 건으로 합쳐져 둘째가 조용히 미심사였다. id 재유도는 하지 않고(기존 판정 보존, `_scan_records` 주석의 이유) `id_collisions()` 로 탐지 → 시드마다 `intakeConflicts` 로 bootstrap 에 싣고 파일당 1회 감사(`Submission file name collision`), 콘솔 투입 상태줄에 주황 경고 목록. 이름을 바꾸면 다음 리로드에서 사라진다. ④ 인용/무관 — 행 버튼 `미사용` 이 `false_positive` 로 가 대표 근거 하나를 고르면 나머지 유효 증거의 점수가 지워졌다 → 버튼을 `사용`/`무관` 으로, `사용` 재클릭은 `pending`(표시 해제, 점수 유지), 옛 `ignored` 는 `irrelevant` 로 보낸다. ⑤ 분모 0 은 `None` → 콘솔 `측정 불가`("미탐 0%" 가 측정한 적 없는 수치였다); 외부 검색이 꺼진(disabled·거버넌스) 케이스는 점수 0 그대로 두되 한 줄 요약에 `외부 검색 꺼짐` — low 가 "확인하고 깨끗함"으로 읽히지 않게. 부채: `operator-review-workflow` 1440×900 13px 넘침 → 겹치지 않는 제출·자동 레인 병합 + 노드 폭 120 → **다이어그램 5종 첫 REBUILD OK**, 카드의 v6 이전 문구("건너뛴 검색은 0점")도 교정; `tests/conftest.py` autouse 로 `OLLAMA_BASE_URL` 을 죽은 포트로 — ollama 가 실제로 떠 있는 상태에서 override 없이 test_http_app 38 passed, PROJECT_MAP 부채 행 삭제; 루트 `VERSION`(0.1.0) 단일 출처 + `rights_filter.version.release_version()`, `/health` 와 기동 로그에 버전·스코어러 버전. 가이드 근거 표시 행 갱신. + - 2026-09-23 · **판정 정정 일관성 — codex F4 · 4.2 · 4.3 · 4.5.** ① 내보내기 메모가 "마지막 비어 있지 않은 메모"라 반려(메모)→승인(무메모) 뒤 지원자용 CSV 에 반려 사유가 나갔다 → **마지막 판정의 메모만**(`_latest_decision_memo`, 검색 대기 마커 `HOLD_WAITING_MARKER` 는 벗겨냄). ② 보류·반려→승인 시 그 판정이 만든 주의 후보(`kb-watchlist-`)를 **자동 철회**(`excluded`, 사유 "승인으로 판정 변경 - 자동 철회", 감사 `Watchlist candidate withdrawn`); 운영자가 이미 `승격` 한 확정 항목은 손대지 않고 감사에만 남긴다. ③ 철회·제외된 항목에 걸려 있던 **다른 케이스의 저장된 일치 행**은 `contributed=False` + `withdrawnAt` 로 퇴역시키고 리스코어(`_invalidate_matches_for_entry`, 기존 `exclude_watchlist_entry` 경로에도 적용 — 스코어러는 항목 상태가 아니라 저장된 행을 읽으므로 항목만 바꿔선 점수가 안 움직였다). `status` 컬럼은 CHECK 제약이라 못 쓰고 별도 스탬프. ④ 재보류·재반려가 `excluded`/`confirmed` 항목을 `watchlist`/active 로 되살리지 않는다. 테스트 3(내보내기 메모 · 승인 철회+리스코어+재보류 유지 · 검색 대기 보류). 가이드 승인 행 갱신. - 2026-09-23 · **스코어러 v7 + 검색 대기 보류 — codex F1 차단.** 보류·반려로 자동 생성된 주의 후보(`entryStatus=watchlist`)와의 일치가 확정 항목과 같은 80/high 를 받아, 검색 장애로 보류한 정상 이미지의 재제출이 "알려진 권리 대상"처럼 올라왔다(`internal_analyzer` 는 status 를 실어 보내지만 스코어러가 안 봤다). 두 갈래로 끊었다: ① 스코어러 — `knowledge_entry_status == watchlist` 는 별도 축 **40/20, 상한 40, 단독 medium**("다시 보라"지 "알려진 대상"이 아니다), 확정(승격·수동 등록)만 80. 한 줄 요약도 `주의 후보 일치 0.95` 로 구분. 등록 제외 합이 135 로 상한에 닿을 수 있는 조합(주의 후보+웹 사본+인물+검색+라벨)은 순서를 잃어도 되는 케이스로 문서화. ② 스토어 — `record_decision(held)` 가 `has_coverage_gap()`(실패·일일 한도 미수행, 운영자가 무관 처리한 행 제외)이면 **주의 후보를 만들지 않고** 감사에 `검색 대기 (주의 후보 미생성)` 를 남긴다; 반려는 커버리지와 무관하게 생성. 테스트: 스코어러 5(+헤드라인 1) · 스토어 1(장애 보류 → 항목 0, 이어 반려 → watchlist 1) · 기존 `watchlist_similarity_is_high_risk` 는 인물 30 + 주의 후보 40 = 70 으로 그대로 high. 가이드·MAP 갱신, 다이어그램 태그 v7 재생성. diff --git a/docs/PROJECT_MAP.md b/docs/PROJECT_MAP.md index 78f4e44..b710b68 100644 --- a/docs/PROJECT_MAP.md +++ b/docs/PROJECT_MAP.md @@ -224,7 +224,6 @@ app-shell CSP(`script-src 'self'`, `http_app.py:514`)가 인라인 스크립트 | 웹 신호만으로 high 도달 | `analysis/risk_scoring.py` | v2 설계 원칙은 "웹 존재만으로 high 불가"인데 presence 풀(60) + entity 풀(20) = 80 이라 합이 캡되지 않는다. v3 의 제공자별 풀링이 이를 키웠다 — 웹 전용 조합의 high 가 4/12 → 9/12(9/22 실측, `scripts/score_distribution.py`). 대응 후보는 웹 유래 총합 상한이고 적용 시 `SCORER_VERSION` 을 올려야 한다 | | 밴드 임계·풀 상한 미캘리브레이션 | `analysis/risk_scoring.py` | 판정 이력 0건이라 70/30 임계가 실제 판정과 대조된 적 없다. v3 의 두 풀(`WEB_PRESENCE_MAX_POINTS` 60 은 **제공자별** / `WEB_ENTITY_MAX_POINTS` 20)은 신호의 구조적 성질로 정한 값이고 상관 검증 전이다 — 판정 30~50건 후 `decision_stats` 교차표가 첫 근거가 된다 — 절차는 `docs/operations/calibration-corpus.md`, 확정 판단은 `scripts/sweep_thresholds.py` | | 출처 신호 2종 0점 | `analysis/provenance.py` | 캘리브레이션 전. 실측 코퍼스에서 가치 미입증. 방송캡처는 `MIN_CAPTURE_CATEGORIES=2` 에서 geometry 가 1개 카테고리라 자막 없는 캡처는 구조적으로 도달 불가 | -| 테스트가 로컬 LLM 에 실접속 | `integrations/env_clients.py:260` | `_llm_assistant` 가 항상 `OLLAMA_BASE_URL` 기본값(`127.0.0.1:11434`)으로 클라이언트를 만들므로, provider_runtime 없이 만든 store 를 쓰는 테스트는 **ollama 가 떠 있으면 실호출**을 한다. `test_http_app.py` 3건이 그 상태에서 소켓 타임아웃으로 실패한다(`OLLAMA_BASE_URL=http://127.0.0.1:1` 로 돌리면 통과). 폐쇄망 프로젝트의 테스트가 외부 서비스의 **부재**에 의존하는 상태 | | 다이어그램에 한글 폰트 미번들 (내부 문서라 low) | `docs/diagrams/*.html` | 임베드된 JetBrains Mono 의 unicode-range 6개에 **한글 구간이 없다**(`U+AC00` 매치 0). 폴백 스택은 중국어 폰트뿐(`Noto Sans Mono CJK SC`/`PingFang SC`/`Microsoft YaHei`). 렌더 텍스트의 40~72% 가 한글이라 타깃 폰트에 의존한다 — 한국어 폰트 없는 최소 설치 호스트에서는 두부(tofu). archify 에 저자용 폰트 옵션이 없어 spec 으로는 못 고친다(레이아웃 폭은 전각으로 측정되므로 깨짐이 아니라 글자 문제). **내부 문서로 확정(9/21)** 되어 현재는 low — 사내 머신에는 한글 폰트가 있다. 인수물로 승격하면 blocker | | 뷰어 UI 가 영어 고정 (내부 문서라 low) | `docs/diagrams/*.html` | `locale` enum 이 `en`/`zh-CN` 뿐이고 템플릿이 `` 하드코딩. 저자 내용은 한국어인데 조작 UI·aria 라벨·범례 제목은 영어. 스크린리더가 한글 본문을 영어로 읽는다 | | architecture spec 만 좌표 하드코딩 | `docs/diagrams/system-architecture.architecture.json` | 컴포넌트 11개 전부 `pos`+`size` 수기, `size` 는 [124,62] 고정. 라벨 **21자부터 validate 하드 실패**(19·20자 통과, 21·22자 실패 — 9/21 실측. 현재 `store_remote_fetch` 18자 → 여유 2글자). workflow spec 은 기하 하드코딩 0개이므로 같은 저장소에 더 나은 패턴이 있다 | diff --git a/docs/diagrams/manifest.json b/docs/diagrams/manifest.json index 706c22f..32427c5 100644 --- a/docs/diagrams/manifest.json +++ b/docs/diagrams/manifest.json @@ -22,9 +22,9 @@ "type": "workflow", "spec": "operator-review-workflow.workflow.json", "html": "operator-review-workflow.html", - "specSha256": "750cac82931fc4a4b621fd95806d10a551bad9fd1e4190542972b6389857700c", - "htmlSha256": "7a74e1158e3390b78d5f4733d28ddcbeaa9c2ca278500e81ab0ca09d69a7592d", - "htmlBytes": 818466 + "specSha256": "3c87c75bb7ed1eea55e65a5afe4740d568592e2e55d744ad1fbb1265a0ea9194", + "htmlSha256": "b68bec55d2e02a92322bb0e46ddda1811d5e540cb79b8fe35631e4ef5852fd8c", + "htmlBytes": 818197 }, { "type": "workflow", diff --git a/docs/diagrams/operator-review-workflow.html b/docs/diagrams/operator-review-workflow.html index bf2900e..3ffbcd3 100644 Binary files a/docs/diagrams/operator-review-workflow.html and b/docs/diagrams/operator-review-workflow.html differ diff --git a/docs/diagrams/operator-review-workflow.workflow.json b/docs/diagrams/operator-review-workflow.workflow.json index 2f6146e..856c051 100644 --- a/docs/diagrams/operator-review-workflow.workflow.json +++ b/docs/diagrams/operator-review-workflow.workflow.json @@ -64,13 +64,9 @@ } }, "lanes": [ - { - "id": "intake", - "label": "제출 · 시드" - }, { "id": "auto", - "label": "자동 분석 · 검색" + "label": "제출 · 자동 분석 · 검색" }, { "id": "review", @@ -133,22 +129,22 @@ "nodes": [ { "id": "folder", - "lane": "intake", + "lane": "auto", "col": 0, "type": "external", "label": "큐 폴더 · 업로드", "sublabel": "이미지 · manifest", - "width": 124 + "width": 120 }, { "id": "seed", - "lane": "intake", + "lane": "auto", "col": 1, "type": "backend", "label": "시드 · 리로드", "sublabel": "신규 레코드만", "tag": "판정 보존", - "width": 124 + "width": 120 }, { "id": "analyze", @@ -157,7 +153,7 @@ "type": "backend", "label": "내부 분석", "sublabel": "지문 · 얼굴 · 출처", - "width": 124 + "width": 120 }, { "id": "search", @@ -166,7 +162,7 @@ "type": "backend", "label": "자동 검색", "sublabel": "쿼리 생성 · 승격", - "width": 124 + "width": 120 }, { "id": "score", @@ -176,7 +172,7 @@ "label": "위험도 산출", "sublabel": "점수 · 밴드 · 사유", "tag": "v7", - "width": 124 + "width": 120 }, { "id": "llm", @@ -185,7 +181,7 @@ "type": "backend", "label": "LLM 증거 판독", "sublabel": "인용 검증 · 12행", - "width": 124 + "width": 120 }, { "id": "evidence", @@ -194,7 +190,7 @@ "type": "frontend", "label": "증거 검토", "sublabel": "호스트 · 썸네일 · 링크", - "width": 124 + "width": 120 }, { "id": "decide", @@ -204,7 +200,7 @@ "label": "판정", "sublabel": "승인 · 보류 · 반려", "tag": "감사 이벤트", - "width": 124 + "width": 120 }, { "id": "stop", @@ -213,7 +209,7 @@ "type": "security", "label": "비상 차단 스위치", "sublabel": "쿼터 · provider 토글", - "width": 124 + "width": 120 }, { "id": "skipped", @@ -221,8 +217,8 @@ "col": 4, "type": "security", "label": "검색 건너뜀", - "sublabel": "0점 · 사유만 기록", - "width": 124 + "sublabel": "한도 도달은 검토 하한", + "width": 120 }, { "id": "retained", @@ -232,7 +228,7 @@ "label": "보존된 케이스", "sublabel": "원본 파일 없음", "tag": "assetMissing", - "width": 124 + "width": 120 } ], "edges": [ @@ -325,24 +321,24 @@ "dot": "emerald", "title": "주 경로", "items": [ - "리로드는 신규 레코드만 분석한다 — 기존 행은 판정과 함께 보존된다", - "자동 검색은 쿼리를 생성하고 유사도가 맞는 결과만 증거로 승격한다" + "리로드는 신규 레코드만 분석한다 — 판정은 보존", + "유사도가 맞는 검색 결과만 증거로 승격" ] }, { "dot": "rose", "title": "멈췄을 때", "items": [ - "비상 차단은 시드 · 재분석 · 얼굴 크롭 경로까지 모두 막는다", - "건너뛴 검색은 0점이며 위험 사유가 아니라 운영 상태로만 기록된다" + "비상 차단은 시드·재분석·얼굴 크롭까지 막는다", + "한도로 빠진 검색은 검토 하한 30 + 미수행 사유" ] }, { "dot": "cyan", "title": "판독 보조", "items": [ - "LLM 은 요약기가 아니라 증거 판독기다 — 인용 없는 claim 은 폐기된다", - "프롬프트 입력은 상위 12행으로 제한된다" + "LLM 은 증거 판독기 — 인용 없는 claim 은 폐기", + "프롬프트 입력은 상위 12행" ] } ] diff --git a/docs/operations/review-guide.md b/docs/operations/review-guide.md index 81d27dc..09396f7 100644 --- a/docs/operations/review-guide.md +++ b/docs/operations/review-guide.md @@ -11,7 +11,7 @@ | ① 한 줄 읽기 | 큐 '상위 근거' 칸 / 케이스 요약 첫 줄 | `웹 완전일치 사본 4곳 · 식별 가능 인물 · …` — 무엇이 얼마나 나왔는지. 여기서 대부분 방향이 정해진다 | | ② 점수 구성 확인 | 케이스 화면 '점수 구성' | 몇 점이 어디서 왔는지. "증거 77개인데 왜 95점"의 답. 상한에 걸린 항목은 `원래 230 · 상한 40` 으로 표시 | | ③ 증거 열기 | '동일 이미지' 그룹부터 | **동일 → 부분 → 페이지** 순. 유사·라벨·네이버는 앞이 비었을 때만 | -| ④ 근거 표시 | 각 증거의 `사용` / `미사용` | 판정 근거로 삼은 행은 반드시 `사용`. 관계없는 행은 `무관`, 잘못 잡힌 행은 `오탐` — 오탐·무관은 점수에서 빠지고 리스코어된다 | +| ④ 근거 표시 | 각 증거의 `사용` / `무관` | 판정 근거로 삼은 행은 `사용`(다시 누르면 해제, 점수는 그대로). 관계없거나 잘못 잡힌 행은 `무관` — **무관만 점수에서 빠지고** 리스코어된다. 표시하지 않은 행은 점수에 그대로 남는다 | | ⑤ 판정 | `A` 승인 · `H` 보류 · `R` 반려 | 반려는 사유 필수(아래 4절) | | ⑥ 다음 | `N` | 다음 미심사 건으로. 승인·보류 후에는 자동으로 넘어간다 | diff --git a/src/rights_filter/analysis/risk_scoring.py b/src/rights_filter/analysis/risk_scoring.py index b54642d..e14b40e 100644 --- a/src/rights_filter/analysis/risk_scoring.py +++ b/src/rights_filter/analysis/risk_scoring.py @@ -313,6 +313,10 @@ class RiskScorer: failure_seen = True tally.skips += 1 reasons.append(f"{_skip_provider(item) or '외부 검색'} 미수행 — {item.reason}") + elif item.source in {EvidenceSource.SEARCH_SKIPPED, EvidenceSource.EXTERNAL_SKIPPED}: + # Scores nothing (see below), but a low band with no external + # check behind it must not read as "checked and clean". + tally.not_run = True # Every other skip applies to every case alike while it lasts - # "disabled" (no key in this deployment, or the emergency stop the # console shows as provider state) and the governance switches @@ -388,6 +392,7 @@ class _Tally: labels: int = 0 failures: int = 0 skips: int = 0 + not_run: bool = False def _presence_tier(points: int) -> str: @@ -442,6 +447,8 @@ def _headline(tally: _Tally, excluded_rows: int) -> str: parts.append("외부 검색 실패") if tally.skips: parts.append("일일 한도로 일부 미수행") + elif tally.not_run: + parts.append("외부 검색 꺼짐") if excluded_rows: parts.append(f"신뢰 출처 제외 {excluded_rows}행") return " · ".join(parts) if parts else "신호 없음" diff --git a/src/rights_filter/server/__main__.py b/src/rights_filter/server/__main__.py index 83d04a1..28643b3 100644 --- a/src/rights_filter/server/__main__.py +++ b/src/rights_filter/server/__main__.py @@ -5,12 +5,14 @@ import os import sys 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.env_file import load_env_file from rights_filter.server.http_app import build_server 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.version import release_version def _provider_readiness(runtime) -> str: @@ -103,7 +105,7 @@ def main() -> None: static_dir=static_path, auth_token=auth_token, ) - print(f"Copyrighter API server listening on http://{args.host}:{args.port}") + print(f"Copyrighter {release_version()} (scorer {SCORER_VERSION}) listening on http://{args.host}:{args.port}") print(f"SQLite DB: {Path(args.db).resolve()}") print(f"Submission images: {Path(args.images).resolve()}") server.serve_forever() diff --git a/src/rights_filter/server/http_app.py b/src/rights_filter/server/http_app.py index 47f5277..189fd2b 100644 --- a/src/rights_filter/server/http_app.py +++ b/src/rights_filter/server/http_app.py @@ -13,9 +13,11 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from urllib.parse import parse_qs, unquote, urlparse +from rights_filter.analysis.risk_scoring import SCORER_VERSION from rights_filter.server.auth_rate_limit import FailedAuthRateLimiter from rights_filter.server.image_store import LocalSubmissionImageStore, SUPPORTED_IMAGE_SUFFIXES from rights_filter.server.sqlite_store import CopyrighterStore +from rights_filter.version import release_version # Windows Python builds may not know .woff2, which would serve the bundled # 2MB font as application/octet-stream. @@ -104,7 +106,16 @@ def build_server( active_store = lambda: store.active_submission_image_store(image_store.root) # noqa: E731 - lazy: only opens a DB connection on routes that use it try: if path == "/health": - self._json({"status": "ok", "port": self.server.server_port}) + self._json( + { + "status": "ok", + "port": self.server.server_port, + # Which build and which scorer produced the scores on + # screen - the first thing to ask about a delivered site. + "version": release_version(), + "scorerVersion": SCORER_VERSION, + } + ) elif path == "/api/providers/health": self._json({"status": "ok", "providers": store.providers()}) elif path == "/api/bootstrap": diff --git a/src/rights_filter/server/image_store.py b/src/rights_filter/server/image_store.py index 9d46db2..a20d079 100644 --- a/src/rights_filter/server/image_store.py +++ b/src/rights_filter/server/image_store.py @@ -71,6 +71,24 @@ class LocalSubmissionImageStore: def media_path(self, relative_path: str) -> Path: return self._safe_path(relative_path) + def id_collisions(self) -> list[dict[str, Any]]: + """Files that share a submission id (file stem) with an earlier file. + + Only the first file per id is ever read (`_record_for` returns the first + match), so the others are never analysed - `지원자A/photo.jpg` and + `지원자B/photo.png` become one case and the second applicant is silently + unreviewed. Ids are not re-derived here (see `_scan_records`); this only + makes the loss visible so the operator renames the file. + """ + files_by_id: dict[str, list[str]] = {} + for record in self.submission_records(): + files_by_id.setdefault(str(record["id"]), []).append(str(record["file"])) + return [ + {"id": submission_id, "kept": files[0], "skipped": files[1:]} + for submission_id, files in files_by_id.items() + if len(files) > 1 + ] + def _record_for(self, submission_id: str) -> dict[str, Any]: for record in self.submission_records(): if record["id"] == submission_id: diff --git a/src/rights_filter/server/sqlite_store.py b/src/rights_filter/server/sqlite_store.py index 54685cd..d3af57e 100644 --- a/src/rights_filter/server/sqlite_store.py +++ b/src/rights_filter/server/sqlite_store.py @@ -74,6 +74,7 @@ logger = logging.getLogger(__name__) # 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" +_STEM_COLLISION_EVENT = "Submission file name collision" # 한 번의 증거 일괄 표시가 건드릴 수 있는 최대 행 수. 일괄 판정 상한(200)보다 @@ -116,6 +117,10 @@ class CopyrighterStore( else self.db_path.parent / "collections" / "images" ) self.collection_public_prefix = collection_public_prefix.rstrip("/") + # Files the last seed could not import as their own case (same stem as + # another file, or an id owned by another queue). Recomputed on every + # seed - startup included - so it always describes the current folder. + self._intake_conflicts: list[dict[str, Any]] = [] self.face_crop_image_dir = self.db_path.parent / "face-crops" self.face_crop_public_prefix = "/face-crop-media" # Biometric face crops are personal data: purge them after this many days. @@ -295,6 +300,30 @@ class CopyrighterStore( ), ) + def _note_stem_collisions(self, image_store: LocalSubmissionImageStore) -> list[dict[str, Any]]: + """List same-stem files the seed will not import, auditing each once.""" + conflicts: list[dict[str, Any]] = [] + for collision in image_store.id_collisions(): + for skipped in collision["skipped"]: + conflicts.append( + {"kind": "same_stem", "id": collision["id"], "file": skipped, "keptFile": collision["kept"]} + ) + object_ref = f"{collision['id']}:{skipped}" + with self._connect() as conn: + already_logged = conn.execute( + "select 1 from audit_events where event = ? and object_ref = ? limit 1", + (_STEM_COLLISION_EVENT, object_ref), + ).fetchone() + if already_logged is None: + self.add_audit_event( + "rights.system", + _STEM_COLLISION_EVENT, + object_ref, + f"'{skipped}' 은 '{collision['kept']}' 와 파일명이 같아 심사 건으로 들어가지 않음" + " — 파일명을 바꾼 뒤 다시 불러오세요.", + ) + return conflicts + def seed_from_image_store(self, image_store: LocalSubmissionImageStore) -> int: # Serialize seeding so two concurrent reload/import/upload requests can't # both classify the same record as new and double-run analysis, external @@ -363,14 +392,20 @@ class CopyrighterStore( # 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) + self._intake_conflicts = self._note_stem_collisions(image_store) records = [] + seen_record_ids: set[str] = set() for record in image_store.submission_records(): record_id = str(record["id"]) - if record_id in existing_submission_ids: + if record_id in existing_submission_ids or record_id in seen_record_ids: continue + seen_record_ids.add(record_id) 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) + self._intake_conflicts.append( + {"kind": "other_queue", "id": record_id, "file": str(record["file"]), "ownerQueueId": owner_queue_id} + ) continue records.append(record) if not records: @@ -588,6 +623,7 @@ class CopyrighterStore( "coverageThresholds": dict(self.coverage_thresholds), "searchCoverage": self._search_coverage(submissions, evidence), "decisionMemoPresets": self.decision_memo_presets(), + "intakeConflicts": list(self._intake_conflicts), } def _search_coverage(self, submissions: list[dict[str, Any]], evidence_by_submission: dict[str, list[dict[str, Any]]]) -> dict[str, Any]: diff --git a/src/rights_filter/server/store_workflow.py b/src/rights_filter/server/store_workflow.py index d4f934f..55d299b 100644 --- a/src/rights_filter/server/store_workflow.py +++ b/src/rights_filter/server/store_workflow.py @@ -399,8 +399,10 @@ class StoreWorkflowMixin: return memo -def _ratio(numerator: int, denominator: int) -> float: - return round(numerator / denominator, 3) if denominator else 0.0 +def _ratio(numerator: int, denominator: int) -> float | None: + # None, not 0.0, when nothing was decided in that band: "미탐 0%" on an empty + # denominator read as a measured result when it was no measurement at all. + return round(numerator / denominator, 3) if denominator else None def _rows_to_csv(rows: list[dict[str, Any]], fields: tuple[str, ...]) -> str: diff --git a/src/rights_filter/version.py b/src/rights_filter/version.py new file mode 100644 index 0000000..5e2352e --- /dev/null +++ b/src/rights_filter/version.py @@ -0,0 +1,21 @@ +"""Release version, read from the single source: the repository-root VERSION file. + +Hard-coding a version anywhere else is how a delivered bundle ends up reporting a +number nobody can match to a build. A missing file is an error, not "unknown" - +an unversioned deliverable must not be produced or run silently. +""" + +from __future__ import annotations + +from functools import lru_cache +from pathlib import Path + +VERSION_FILE = Path(__file__).resolve().parents[2] / "VERSION" + + +@lru_cache(maxsize=1) +def release_version() -> str: + text = VERSION_FILE.read_text(encoding="utf-8").strip() + if not text: + raise ValueError(f"{VERSION_FILE} is empty") + return text diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..9726a5d --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,20 @@ +"""Suite-wide isolation from services that happen to run on the test host. + +A store built without an explicit provider_runtime reads os.environ, and the +LLM client defaults to Ollama on 127.0.0.1:11434. With a local Ollama running, +those tests made real calls and three test_http_app specs failed on socket +timeouts - an air-gapped project's suite depended on the *absence* of a local +service. Point the default at a port nothing listens on; tests that exercise the +LLM pass their own env and fake transport and are unaffected. +""" + +from __future__ import annotations + +import pytest + +_DEAD_OLLAMA = "http://127.0.0.1:1" + + +@pytest.fixture(autouse=True) +def _no_local_ollama(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("OLLAMA_BASE_URL", _DEAD_OLLAMA) diff --git a/tests/operator_gui/test_static_workbench.py b/tests/operator_gui/test_static_workbench.py index 948825a..e5c4ce9 100644 --- a/tests/operator_gui/test_static_workbench.py +++ b/tests/operator_gui/test_static_workbench.py @@ -722,15 +722,21 @@ def test_queue_provider_judgments_render_on_one_line_with_narrower_reason_column assert "grid-template-columns: 28px 64px minmax(104px, 0.68fr) 72px minmax(126px, 0.58fr) minmax(360px, 1.5fr) 82px 76px 90px" in styles -def test_evidence_operator_status_actions_are_binary_use_or_ignore(): +def test_evidence_operator_status_actions_are_binary_cite_or_irrelevant(): + # codex F2 (2026-09-23): the second button read "미사용" (not cited) but sent + # false_positive, so picking one representative source wiped the score of + # every other valid row. Two buttons stay; the second now says what it does. script = _read_app() assert '["used_for_judgment", "사용"]' in script - assert '["ignored", "미사용"]' in script - assert '["irrelevant",' not in script + assert '["irrelevant", "무관"]' in script + assert '["ignored", "미사용"]' not in script assert '["false_positive",' not in script assert '["pending",' not in script assert "normalizeEvidenceOperatorStatus" in script + # Un-citing is a toggle back to pending (score kept), not a removal. + assert '? "pending" : status' in script + assert 'status === "ignored" ? "irrelevant" : status' in script def test_operator_gui_uses_korean_operator_copy_for_visible_labels(): @@ -1089,3 +1095,19 @@ def test_operator_gui_numbers_the_memo_presets_to_match_the_shortcuts(): assert "memo-preset-key" in script assert ".memo-preset-key" in styles assert "index + 1" in script + + +def test_operator_gui_shows_files_the_intake_could_not_import(): + # codex F6 (2026-09-23): two applicants' photo.jpg / photo.png became one case. + script = _read_app() + styles = _read(STYLES) + + assert "intakeConflicts" in script + assert "심사 건으로 들어가지 못한 파일" in script + assert ".intake-conflicts" in styles + + +def test_operator_gui_renders_an_empty_band_rate_as_no_measurement(): + script = _read_app() + + assert '"측정 불가"' in script diff --git a/tests/rights_filter/analysis/test_risk_scoring.py b/tests/rights_filter/analysis/test_risk_scoring.py index 634d802..2ce8709 100644 --- a/tests/rights_filter/analysis/test_risk_scoring.py +++ b/tests/rights_filter/analysis/test_risk_scoring.py @@ -910,6 +910,19 @@ def test_headline_tells_a_watchlist_match_apart_from_a_registered_one(): assert _headline_of([_knowledge_match(0.95, "confirmed")]) == "등록 권리 대상 일치 0.95" +def test_headline_says_when_the_external_check_was_off_without_scoring_it(): + # codex 3.5 (2026-09-23): a keyless / stopped deployment put unchecked images + # in the same low band as checked-and-clean ones. The score stays 0 (a floor + # would make low unreachable by configuration); the headline says why. + result = RiskScorer().score([_skipped("search API disabled")]) + + assert result.score == 0 + assert result.breakdown["headline"] == "외부 검색 꺼짐" + assert _headline_of([_skipped("search API disabled"), _skipped("search API usage limit reached")]) == ( + "일일 한도로 일부 미수행" + ) + + def test_headline_reports_trusted_exclusions_and_silence(): assert _headline_of([_published_on("posar.info")], frozenset({"posar.info"})) == "신뢰 출처 제외 1행" assert _headline_of([]) == "신호 없음" diff --git a/tests/rights_filter/server/test_http_app.py b/tests/rights_filter/server/test_http_app.py index 41d1032..c6d60c6 100644 --- a/tests/rights_filter/server/test_http_app.py +++ b/tests/rights_filter/server/test_http_app.py @@ -10,6 +10,7 @@ import sqlite3 import pytest from rights_filter.analysis.face_person_detection import FacePersonSignal +from rights_filter.analysis.risk_scoring import SCORER_VERSION from rights_filter.server.http_app import build_server from rights_filter.server.image_store import LocalSubmissionImageStore from rights_filter.server import sqlite_store as sqlite_store_module @@ -17,6 +18,8 @@ from rights_filter.server import store_enrichment as store_enrichment_module from rights_filter.server.sqlite_store import CopyrighterStore from rights_filter.integrations.env_clients import build_provider_runtime +ROOT = Path(__file__).resolve().parents[3] + class FakeJsonTransport: def __init__(self, response=None): @@ -261,6 +264,9 @@ def test_http_server_exposes_health_bootstrap_and_mutations(tmp_path: Path): server.shutdown() assert health["status"] == "ok" + # The build and scorer behind the scores on screen, from the single VERSION source. + assert health["version"] == (ROOT / "VERSION").read_text(encoding="utf-8").strip() + assert health["scorerVersion"] == SCORER_VERSION assert bootstrap["submissions"][0]["id"] == "SUB-API1" assert decision["decisionStatus"] == "held" assert next(provider for provider in providers if provider["id"] == "naver")["enabled"] is False diff --git a/tests/rights_filter/server/test_image_store.py b/tests/rights_filter/server/test_image_store.py index a0d6c15..40cdc41 100644 --- a/tests/rights_filter/server/test_image_store.py +++ b/tests/rights_filter/server/test_image_store.py @@ -139,3 +139,18 @@ def test_local_image_store_skips_manifest_paths_outside_root_without_losing_vali assert "SUB-BAD" not in ids assert "SUB-GOOD" in ids assert all("secret" not in str(record["file"]) for record in records) + + +def test_same_stem_files_in_different_folders_are_reported_not_silently_merged(tmp_path: Path): + # codex F6 (2026-09-23): the submission id is the file stem, so two applicants' + # photo.jpg / photo.png became one case and the second was never reviewed. + for folder, suffix in (("applicant-a", "jpg"), ("applicant-b", "png")): + (tmp_path / folder).mkdir() + (tmp_path / folder / f"photo.{suffix}").write_bytes(b"x") + (tmp_path / "unique.jpg").write_bytes(b"y") + + collisions = LocalSubmissionImageStore(tmp_path).id_collisions() + + assert collisions == [ + {"id": "photo", "kept": "applicant-a/photo.jpg", "skipped": ["applicant-b/photo.png"]} + ] diff --git a/tests/rights_filter/server/test_review_intelligence.py b/tests/rights_filter/server/test_review_intelligence.py index 1844190..7d32db8 100644 --- a/tests/rights_filter/server/test_review_intelligence.py +++ b/tests/rights_filter/server/test_review_intelligence.py @@ -151,3 +151,14 @@ def test_llm_summary_body_survives_serialization(): assert payload["sourceUrls"] == ["https://example.test/a"] restored = _domain_evidence_from_ui(payload) assert restored.data["summary"] == summary.data["summary"] + + +def test_an_empty_band_reports_no_measurement_instead_of_zero_percent(tmp_path: Path): + # codex 3 (2026-09-23): with nothing decided in the low band the rate was 0.0 + # and the console showed "미탐 0%" - a measurement that was never taken. + store = _store(tmp_path) + + calibration = store.decision_stats()["calibration"] + + assert calibration["lowBandRejectionRate"] is None + assert calibration["highBandApprovalRate"] is None diff --git a/tests/rights_filter/server/test_sqlite_store.py b/tests/rights_filter/server/test_sqlite_store.py index a7fd67f..d606c68 100644 --- a/tests/rights_filter/server/test_sqlite_store.py +++ b/tests/rights_filter/server/test_sqlite_store.py @@ -8915,3 +8915,28 @@ def test_evidence_status_batch_refuses_an_empty_or_oversized_batch(tmp_path: Pat with pytest.raises(ValueError, match="unsupported evidence status"): store.mark_evidence_status_batch("SUB-1", ["ev-1"], "nope") + + +def test_seed_surfaces_same_stem_files_it_could_not_import(tmp_path: Path): + image_root = tmp_path / "submissions" + for folder, suffix in (("applicant-a", "jpg"), ("applicant-b", "png")): + (image_root / folder).mkdir(parents=True) + (image_root / folder / f"photo.{suffix}").write_bytes(b"not-really-an-image") + store = CopyrighterStore(tmp_path / "copyrighter.sqlite3") + store.initialize() + + store.seed_from_image_store(LocalSubmissionImageStore(image_root)) + store.seed_from_image_store(LocalSubmissionImageStore(image_root)) # reload must not re-audit + + conflicts = store.bootstrap()["intakeConflicts"] + assert conflicts == [ + {"kind": "same_stem", "id": "photo", "file": "applicant-b/photo.png", "keptFile": "applicant-a/photo.jpg"} + ] + assert [item["id"] for item in store.bootstrap()["submissions"]] == ["photo"] + audits = [event for event in store.audit_events() if event["event"] == "Submission file name collision"] + assert len(audits) == 1 + + # Renaming the file clears the warning on the next reload. + (image_root / "applicant-b" / "photo.png").rename(image_root / "applicant-b" / "photo-b.png") + store.seed_from_image_store(LocalSubmissionImageStore(image_root)) + assert store.bootstrap()["intakeConflicts"] == [] diff --git a/web/operator-gui/app-admin.js b/web/operator-gui/app-admin.js index 573cc7c..2712aae 100644 --- a/web/operator-gui/app-admin.js +++ b/web/operator-gui/app-admin.js @@ -142,7 +142,7 @@ function markEvidenceIrrelevant(evidenceId) { const targetId = evidenceId || fallback?.id; - void setEvidenceStatus(targetId, "ignored", "운영상 미사용 처리"); + void setEvidenceStatus(targetId, "irrelevant", "운영자 무관 처리"); } diff --git a/web/operator-gui/app-api.js b/web/operator-gui/app-api.js index 5bb61fd..3898a16 100644 --- a/web/operator-gui/app-api.js +++ b/web/operator-gui/app-api.js @@ -208,6 +208,7 @@ function applyBootstrap(payload) { replaceCollection(knowledgeEntries, payload.knowledgeEntries); state.submissionQueue = payload.submissionQueue || null; + state.intakeConflicts = Array.isArray(payload.intakeConflicts) ? payload.intakeConflicts : []; Object.assign( diff --git a/web/operator-gui/app-bootstrap.js b/web/operator-gui/app-bootstrap.js index dc98c1a..7b84b44 100644 --- a/web/operator-gui/app-bootstrap.js +++ b/web/operator-gui/app-bootstrap.js @@ -562,9 +562,9 @@ const __uiCopyCompatibility = { samePerson: "동일인 판정이 아닙니다", watchlistNote: "주의 후보 근거", evidenceUsedStatus: "사용", - evidenceIgnoredStatus: "미사용", + evidenceIgnoredStatus: "무관", usedForJudgmentTuple: ["used_for_judgment", "사용"], - ignoredTuple: ["ignored", "미사용"], + ignoredTuple: ["irrelevant", "무관"], pageRepresentativeImage: "페이지 대표 이미지", confidenceLabel: "신뢰도", queryStrategyByTitle: "구글 페이지 제목 기반", diff --git a/web/operator-gui/app-format.js b/web/operator-gui/app-format.js index e4d5ee9..2a350ea 100644 --- a/web/operator-gui/app-format.js +++ b/web/operator-gui/app-format.js @@ -507,17 +507,11 @@ function normalizeEvidenceOperatorStatus(status) { if (status === "used_for_judgment") return "used_for_judgment"; - const ignoredStatuses = new Set(); + // 옛 "ignored"(→ false_positive) 행도 화면에서는 "무관"으로 묶는다 — 둘 다 점수에서 빠진다. + if (status === "irrelevant" || status === "false_positive" || status === "ignored") return "irrelevant"; - ignoredStatuses.add("irrelevant"); - - ignoredStatuses.add("false_positive"); - - ignoredStatuses.add("ignored"); - - if (ignoredStatuses.has(status)) return "ignored"; - - return status || ""; + // pending = 표시 해제. 칩을 달지 않는다. + return ""; } @@ -525,7 +519,7 @@ function normalizeEvidenceOperatorStatus(status) { function evidenceStatusPayload(status) { - return status === "ignored" ? "false_positive" : status; + return status === "ignored" ? "irrelevant" : status; } diff --git a/web/operator-gui/app-render-evidence.js b/web/operator-gui/app-render-evidence.js index 8270ce1..f4820b0 100644 --- a/web/operator-gui/app-render-evidence.js +++ b/web/operator-gui/app-render-evidence.js @@ -334,12 +334,15 @@ function renderEvidenceRow(evidence) { const preview = renderEvidencePreview(evidence); const link = renderEvidenceLink(evidence); const statusButtons = [ + // "사용" = 판정에 인용(점수 유지), 다시 누르면 표시 해제. + // "무관" = 점수에서 제외. 예전 "미사용"은 오탐으로 보내 점수를 지웠는데, 운영자에게는 + // "인용하지 않음"으로 읽혔다 — 대표 근거 하나를 고르는 일이 나머지 유효 증거를 지웠다. ["used_for_judgment", "사용"], - ["ignored", "미사용"], + ["irrelevant", "무관"], ] .map( ([status, label]) => ` - `, diff --git a/web/operator-gui/app-render-queue.js b/web/operator-gui/app-render-queue.js index c98cb2b..d1a2863 100644 --- a/web/operator-gui/app-render-queue.js +++ b/web/operator-gui/app-render-queue.js @@ -176,11 +176,31 @@ function renderImportStatus() { const tone = state.importStatus.tone === "bad" ? "bad" : state.importStatus.tone === "warn" ? "warn" : "ok"; + // 파일명이 겹쳐 심사 건으로 들어가지 못한 파일. 여기서 말하지 않으면 운영자는 + // 큐가 비는 순간 "다 처리했다"고 믿는다 — 한 지원자의 사진이 조용히 빠진 채로. + const conflicts = state.intakeConflicts || []; + const conflictHtml = conflicts.length + ? ` + ` + : ""; + target.innerHTML = ` - + ${escapeHtml(state.importStatus.message)} + ${conflictHtml} `; diff --git a/web/operator-gui/app-state.js b/web/operator-gui/app-state.js index 9a87986..203b4fb 100644 --- a/web/operator-gui/app-state.js +++ b/web/operator-gui/app-state.js @@ -95,6 +95,8 @@ const state = { submissionQueue: null, + intakeConflicts: [], + importStatus: { tone: "idle", diff --git a/web/operator-gui/app-stats.js b/web/operator-gui/app-stats.js index 506d7a5..15c6800 100644 --- a/web/operator-gui/app-stats.js +++ b/web/operator-gui/app-stats.js @@ -284,6 +284,9 @@ function renderThroughputBars(byDay) { function formatPercent(rate) { + // 서버는 분모 0 을 null 로 보낸다 — 0% 가 아니라 측정 불가다. + if (rate === null || rate === undefined) return "측정 불가"; + return `${Math.round((Number(rate) || 0) * 100)}%`; } diff --git a/web/operator-gui/index.html b/web/operator-gui/index.html index 9f7753a..b77a946 100644 --- a/web/operator-gui/index.html +++ b/web/operator-gui/index.html @@ -271,7 +271,7 @@
- +
diff --git a/web/operator-gui/operator-labels.js b/web/operator-gui/operator-labels.js index b20cd55..8d9720c 100644 --- a/web/operator-gui/operator-labels.js +++ b/web/operator-gui/operator-labels.js @@ -56,7 +56,7 @@ used_for_judgment: "판단에 사용", irrelevant: "무관", false_positive: "오탐", - ignored: "미사용", + ignored: "무관", pending: "보류", }; diff --git a/web/operator-gui/styles.css b/web/operator-gui/styles.css index f1f3fb1..0abe12f 100644 --- a/web/operator-gui/styles.css +++ b/web/operator-gui/styles.css @@ -719,6 +719,21 @@ textarea:disabled { background: var(--card); color: var(--ink-soft); font-size: 13px; + flex-wrap: wrap; +} + +.intake-conflicts { + flex-basis: 100%; + padding: 8px 10px; + border: 1px solid var(--amber-line); + border-radius: var(--r-sm); + background: var(--amber-tint); + color: var(--amber); +} + +.intake-conflicts ul { + margin: 6px 0 0; + padding-left: 18px; } /* ===================== Operator workflow strip ===================== */