From 177e365b557988d6e90cac22502367ecdc86f111 Mon Sep 17 00:00:00 2001 From: changukyu Date: Fri, 25 Sep 2026 18:24:26 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=ED=8E=98=EB=A5=B4=EC=86=8C=EB=82=98=20?= =?UTF-8?q?=EB=A6=AC=EB=B7=B0=20=EA=B6=8C=EA=B3=A0=201~3=20=E2=80=94=20?= =?UTF-8?q?=ED=8C=90=EC=A0=95=20=EC=8B=9C=EC=A0=90=20=EB=82=B4=EB=B3=B4?= =?UTF-8?q?=EB=82=B4=EA=B8=B0,=20'=EC=9C=84=ED=97=98'=EC=9D=80=20=EA=B2=80?= =?UTF-8?q?=ED=86=A0=20=EC=88=9C=EC=84=9C,=20=EC=9A=B4=EC=98=81=20?= =?UTF-8?q?=EB=AA=A8=EB=93=9C=20=EB=AC=B8=EC=84=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ① 내부 내보내기가 현재 점수·현재 사용 표시 대신 decisionSnapshot 을 읽는다. 드리프트는 currentRiskScore·scoredVersion 열로 드러낸다. 스냅샷 없는 옛 행은 no-snapshot. ② 큐 열 '위험' → '검토 우선'(침해 판정 아님), 점수 구성 패널에 범위 한 줄, 가이드 명시. ③ docs/operations/operating-modes.md: 전체 egress / API 호스트만 / 완전 폐쇄에서 점수가 뜻하는 것. 완전 폐쇄는 키를 빼야 한다(남겨 두면 큐 전체가 실패 하한 30). verify.ps1: ruff clean, 681 passed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BStHFrjQj2VypecSG9ex1V --- docs/INDEX.md | 2 + docs/operations/offline-install.md | 6 ++- docs/operations/operating-modes.md | 40 +++++++++++++++++++ docs/operations/review-guide.md | 2 + src/rights_filter/server/store_workflow.py | 36 +++++++++++++---- .../server/test_store_workflow.py | 25 +++++++++++- web/operator-gui/app-render-evidence.js | 1 + web/operator-gui/index.html | 2 +- web/operator-gui/styles.css | 6 +++ 9 files changed, 110 insertions(+), 10 deletions(-) create mode 100644 docs/operations/operating-modes.md diff --git a/docs/INDEX.md b/docs/INDEX.md index 98ba03b..c799ac0 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -1,5 +1,7 @@ # Project INDEX +- 2026-09-25 · **페르소나 리뷰 권고 1~3.** ① 내부 판정 내보내기가 현재 점수·현재 `사용` 표시 대신 **`decisionSnapshot`(판정 시점)** 을 읽는다 — 판정 뒤 표시를 바꾸거나 리스코어돼도 "무엇을 보고 판정했나"가 바뀌지 않는다. 드리프트를 숨기지 않도록 `currentRiskScore`·`scoredVersion` 열 추가, 스냅샷 없는 옛 행은 현재값 + `no-snapshot`. 지원자용에는 둘 다 안 나간다(테스트). ② '위험' 명칭: 큐 열 `위험` → `검토 우선`(툴팁 "침해 판정이 아닙니다"), 점수 구성 패널에 "웹 노출·인물 식별성으로 매긴 검토 순서, 권리 증빙은 반영 안 됨" 한 줄, 가이드에 라이선스 인물 80 vs 무단 그림 50 예시로 명시. 통계 화면의 고위험/저위험 용어는 밴드 이름이라 유지. ③ `docs/operations/operating-modes.md` 신설 — A 전체 egress / B API 호스트만(결과 페이지 fetch 실패는 예외를 삼켜 **사본 비교가 조용히 빠짐**) / C 완전 폐쇄(**키를 둔 채 망만 막으면 매 호출이 실패 → 큐 전체 30/medium**, 키 제거·비상 차단 시 0 + `외부 검색 꺼짐`). offline-install.md 에서 링크. + - 2026-09-25 · **페르소나 × 소크라테스 3단논법 리뷰** (`docs/reviews/2026-09-25-persona-socratic-review.md`). 5 페르소나 9 논증. 붕괴 3: 법무 2-A(actor 익명 + **내보내기가 decisionSnapshot 이 아니라 현재 점수·현재 '사용' 표시를 읽음** — 코드 확인), 법무 2-B(점수는 권리 위험이 아니라 노출×식별성 — '위험' 명칭 과장), 결정자 5-A(판정 0건, 가중치 4회 변경 모두 판정 대조 없음). 권고 1~3(스냅샷 내보내기 · 명칭 교정 · 운영 모드 문서)은 S. - 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` 와 기동 로그에 버전·스코어러 버전. 가이드 근거 표시 행 갱신. diff --git a/docs/operations/offline-install.md b/docs/operations/offline-install.md index 50a5237..4f5ea4f 100644 --- a/docs/operations/offline-install.md +++ b/docs/operations/offline-install.md @@ -149,6 +149,9 @@ python -m pytest tests/operator_gui -q ## Notes +- **Which network mode?** Read `operating-modes.md` before deploying: what + scores mean with full egress, API hosts only, or no egress at all (and why a + fully closed site must remove the API keys rather than just block the network). - The operator GUI (`web/operator-gui/`) is fully self-contained: all scripts/styles are local and the Pretendard font is bundled (`assets/fonts/PretendardVariable.woff2`). No CDN fetch at runtime. @@ -160,6 +163,7 @@ python -m pytest tests/operator_gui -q fetches arbitrary public result-page / image / stylesheet URLs returned by those searches (SSRF-guarded: private/loopback/CGNAT/link-local ranges are blocked). Restricting egress to the API hosts only disables image comparison - of external results; it does not break core review. + of external results; it does not break core review, but it weakens the web-copy + signal silently (no failure row) - see `operating-modes.md` mode B. - `COPYRIGHTER_AUTH_TOKEN` should be set in `.env` for any multi-operator or networked deployment so the data routes require a shared token. diff --git a/docs/operations/operating-modes.md b/docs/operations/operating-modes.md new file mode 100644 index 0000000..130b41d --- /dev/null +++ b/docs/operations/operating-modes.md @@ -0,0 +1,40 @@ +# 운영 모드 — 네트워크를 얼마나 여느냐에 따라 무엇이 남는가 + +"폐쇄망 납품"은 **설치** 쪽 이야기다. 런타임 의존성(numpy · opencv · Pillow)은 오프라인 +휠로 들어가고(`offline-install.md`), 콘솔·폰트도 전부 동봉이다. 그러나 **탐지의 핵심 신호는 +외부 검색 API 에서 온다.** 그래서 배포 전에 아래 셋 중 어느 모드인지 정하고, 운영자에게 +그 모드에서 점수가 무엇을 뜻하는지 알려야 한다. + +| | A. 전체 egress | B. API 호스트만 | C. 완전 폐쇄 | +|---|---|---|---| +| 허용 대상 | API 호스트 + 검색 결과 페이지·이미지 | `vision.googleapis.com` · `openapi.naver.com` (+ CSE 사용 시 `www.googleapis.com`) | 없음 | +| Vision 웹 탐지 (동일·부분·페이지·유사) | O | O | X | +| 네이버 이름 검색 | O | O | X | +| **검색결과 사본 비교** (결과 페이지를 받아 지문 대조) | O | **X — 조용히 빠진다** | X | +| 지문 · 얼굴 · 출처 메타데이터 · 기준 DB 일치 | O | O | O | +| 실이미지 20장 기준 한 줄 요약 | `웹 완전일치 사본 N곳 · …` | Vision full 매치가 있으면 같은 `웹 완전일치`, 없으면 `부분·페이지` 로 내려감 | `식별 가능 인물` 또는 `외부 검색 꺼짐` | +| 점수가 뜻하는 것 | 설계대로 | 웹 존재 축이 약해져 일부가 한 단계 낮게 나온다 | 인물·기준 DB 이력만 — **신규 이미지는 대부분 low** | + +## 모드별 주의 + +**B. API 호스트만.** 결과 페이지 받기가 막히면 오류 없이 빈 결과로 처리된다 +(`store_search_candidates.py` 의 페이지 fetch 는 예외를 삼킨다). 그래서 실패 하한(30)이 걸리지 +않고 사유에도 남지 않는다. Vision 이 같은 이미지를 `full` 로 잡으면 점수는 같다. 사본 비교에만 +기대던 케이스는 조용히 약해진다 — 이 모드를 택했다면 운영자에게 알린다. + +**C. 완전 폐쇄 — API 키를 빼거나 비상 차단을 켜고 운영한다.** 키를 둔 채 망만 막으면 매 +호출이 **실패**(`External API failed`)가 되고, v5 부터 실패는 검토 하한 30 이라 **큐 전체가 +medium 으로 깔린다.** 키가 없거나 비상 차단이 켜진 상태면 `disabled` 로 처리돼 0점이고, 한 줄 +요약에 `외부 검색 꺼짐` 이 붙는다(v7 이후). 이 모드에서 low 는 "확인했고 깨끗함"이 아니라 +"외부 확인을 하지 않음"이다. + +**공통.** 일일 한도 도달(`usage limit reached`)은 모드와 무관하게 검토 하한 30 과 +` 미수행` 사유로 드러난다. 네이버 기본 한도 100 = 약 20장/일이므로 하루 처리량에 +맞춰 `COPYRIGHTER_NAVER_DAILY_LIMIT` 을 정한다. + +## 확인 방법 + +- 기동 로그 첫 줄: `Provider readiness: Naver search: ready, Google Vision web detection: ready, …` +- `/health` → `version` · `scorerVersion` +- 콘솔 상단 `외부 검색 tool N/5 활성 · 실패 N` +- 모드 C 는 한 줄 요약에 `외부 검색 꺼짐` 이 떠야 정상이다. 대신 `외부 검색 실패` 가 뜨면 키가 남아 있는 것이다. diff --git a/docs/operations/review-guide.md b/docs/operations/review-guide.md index 09396f7..79688f3 100644 --- a/docs/operations/review-guide.md +++ b/docs/operations/review-guide.md @@ -34,6 +34,8 @@ | 외부 검색 실패 / 일일 한도로 일부 미수행 | 그 검색이 **안 돌았다** | 증거가 적은 게 안전하다는 뜻이 아니다. 점수는 중간 하한(30)까지만 오른다 — 보류 후 다음 날 `선택 재분석` | | 신뢰 출처 제외 N행 | 자사·계약처 도메인에서 발견된 행 | 점수에서 빠진다. 스크래퍼 게시가 같이 있으면 그것만 센다 | +**점수는 검토 순서이지 침해 판정이 아니다.** 축은 웹 노출·인물 식별성·이름 검색·등록 이력이고, 라이선스·초상 동의 같은 **권리 증빙은 점수에 없다.** 그래서 라이선스가 있는 인물 사진이 80(높음), 무단 사본인 그림이 50(중간)이 될 수 있다 — 높음은 "먼저 보라"이고, 권리 판단은 운영자가 한다. + 점수 밴드: **높음 70+ · 중간 30+ · 낮음.** 웹에 있다는 것만으로는 최대 65 라 높음이 되지 않는다 — 높음은 "웹 사본 + 인물" 또는 "등록 일치"다. 밴드는 우선순위이지 결론이 아니다. diff --git a/src/rights_filter/server/store_workflow.py b/src/rights_filter/server/store_workflow.py index 55d299b..9482c79 100644 --- a/src/rights_filter/server/store_workflow.py +++ b/src/rights_filter/server/store_workflow.py @@ -31,6 +31,8 @@ INTERNAL_EXPORT_FIELDS = ( "applicantStatus", "riskScore", "riskBand", + "scoredVersion", + "currentRiskScore", "memo", "decidedAt", "evidenceUsed", @@ -337,15 +339,35 @@ class StoreWorkflowMixin: if audience == "applicant": return row - used = [ - item - for item in self._evidence_for_submission(submission_id) - if str(item.get("operatorStatus", "")) == "used_for_judgment" - ] + # What the operator saw WHEN THEY DECIDED, not what the case says now. + # Reading the live score and live "사용" marks meant a rescore or a mark + # changed after the decision rewrote the record of why it was made + # (persona review 2026-09-25, 법무 2-A). The snapshot has been written + # since the first decision for exactly this; rows decided before it + # existed fall back to the live values and say so via scoredVersion. + snapshot = submission.get("decisionSnapshot") or {} + evidence = self._evidence_for_submission(submission_id) + if snapshot.get("evidence") is not None: + used_ids = { + str(item.get("id", "")) + for item in snapshot["evidence"] + if str(item.get("operatorStatus", "")) == "used_for_judgment" + } + used = [item for item in evidence if str(item.get("id", "")) in used_ids] + score, band = snapshot.get("riskScore", 0), str(snapshot.get("riskBand", "")) + scored_version = str(snapshot.get("scoredVersion") or snapshot.get("scorerVersion") or "") + else: + used = [item for item in evidence if str(item.get("operatorStatus", "")) == "used_for_judgment"] + score, band = submission.get("riskScore", 0), str(submission.get("riskBand", "")) + scored_version = "no-snapshot" row.update( { - "riskScore": submission.get("riskScore", 0), - "riskBand": str(submission.get("riskBand", "")), + "riskScore": score, + "riskBand": band, + "scoredVersion": scored_version, + # Kept beside the decision-time score so drift since the + # decision is visible rather than silently substituted. + "currentRiskScore": submission.get("riskScore", 0), "decidedAt": decided_at, "evidenceUsed": " | ".join(str(item.get("title", "")) for item in used), "evidenceUrls": " | ".join( diff --git a/tests/rights_filter/server/test_store_workflow.py b/tests/rights_filter/server/test_store_workflow.py index 64dd551..900986f 100644 --- a/tests/rights_filter/server/test_store_workflow.py +++ b/tests/rights_filter/server/test_store_workflow.py @@ -165,6 +165,29 @@ def test_export_memo_is_the_latest_decisions_memo_not_the_last_nonempty_one(tmp_ assert export["rows"][0]["memo"] == "" +def test_internal_export_records_what_was_shown_at_decision_time(tmp_path: Path): + # Persona review 2026-09-25 (법무 2-A): the export read the live score and the + # live "사용" marks, so a mark changed after the decision rewrote the record of + # why the case was decided. + store = _store(tmp_path) + submission_id = _first_ids(store, 1)[0] + evidence = store.review(submission_id)["evidence"] + assert len(evidence) >= 1 + cited = evidence[0] + store.mark_evidence_status(submission_id, cited["id"], "used_for_judgment") + decided_score = store.review(submission_id)["riskScore"] + store.record_decision(submission_id, "rejected", "근거 A 로 반려") + + # After the decision: the cited row is un-cited and dismissed. + store.mark_evidence_status(submission_id, cited["id"], "irrelevant") + + row = store.export_decisions(audience="internal")["rows"][0] + assert row["evidenceUsed"] == cited["title"] + assert row["riskScore"] == decided_score + assert row["currentRiskScore"] == store.review(submission_id)["riskScore"] + assert row["scoredVersion"] + + def test_applicant_export_never_exposes_internal_signals(tmp_path: Path): store = _store(tmp_path) ids = _first_ids(store, 1) @@ -173,7 +196,7 @@ def test_applicant_export_never_exposes_internal_signals(tmp_path: Path): export = store.export_decisions(audience="applicant") row = export["rows"][0] - for forbidden in ("riskScore", "riskBand", "evidenceUsed", "evidenceUrls"): + for forbidden in ("riskScore", "riskBand", "evidenceUsed", "evidenceUrls", "scoredVersion", "currentRiskScore"): assert forbidden not in row assert "riskScore" not in export["csv"] diff --git a/web/operator-gui/app-render-evidence.js b/web/operator-gui/app-render-evidence.js index f4820b0..e2bba2a 100644 --- a/web/operator-gui/app-render-evidence.js +++ b/web/operator-gui/app-render-evidence.js @@ -535,6 +535,7 @@ function setScoreBreakdown(submission) { box.innerHTML = `

점수 구성 ${breakdown.final}

+

웹 노출과 인물 식별성으로 매긴 검토 순서입니다. 권리 증빙(라이선스·동의)은 반영되지 않으며, 침해 여부는 판정에서 정합니다.

    ${rows}${excludedRows}
diff --git a/web/operator-gui/index.html b/web/operator-gui/index.html index b77a946..9055fef 100644 --- a/web/operator-gui/index.html +++ b/web/operator-gui/index.html @@ -211,7 +211,7 @@ 선택 이미지 제출 - 위험 + 검토 우선 상위 근거 외부 검색 tool 활용 지원자 상태 diff --git a/web/operator-gui/styles.css b/web/operator-gui/styles.css index 0abe12f..8122ed0 100644 --- a/web/operator-gui/styles.css +++ b/web/operator-gui/styles.css @@ -3164,6 +3164,12 @@ tbody tr.selected-row, color: var(--green); } +.score-breakdown-scope { + margin: 0 0 8px; + color: var(--ink-soft); + font-size: 12px; +} + .score-breakdown-clamped { margin: 8px 0 0; color: var(--amber);