fix: 페르소나 리뷰 권고 1~3 — 판정 시점 내보내기, '위험'은 검토 순서, 운영 모드 문서

① 내부 내보내기가 현재 점수·현재 사용 표시 대신 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BStHFrjQj2VypecSG9ex1V
This commit is contained in:
changukyu 2026-09-25 18:24:26 +09:00
parent 7405ab3e0e
commit 177e365b55
9 changed files with 110 additions and 10 deletions

View file

@ -1,5 +1,7 @@
# Project INDEX # 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 · **페르소나 × 소크라테스 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` 와 기동 로그에 버전·스코어러 버전. 가이드 근거 표시 행 갱신. - 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` 와 기동 로그에 버전·스코어러 버전. 가이드 근거 표시 행 갱신.

View file

@ -149,6 +149,9 @@ python -m pytest tests/operator_gui -q
## Notes ## 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 - The operator GUI (`web/operator-gui/`) is fully self-contained: all
scripts/styles are local and the Pretendard font is bundled scripts/styles are local and the Pretendard font is bundled
(`assets/fonts/PretendardVariable.woff2`). No CDN fetch at runtime. (`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 fetches arbitrary public result-page / image / stylesheet URLs returned by
those searches (SSRF-guarded: private/loopback/CGNAT/link-local ranges are those searches (SSRF-guarded: private/loopback/CGNAT/link-local ranges are
blocked). Restricting egress to the API hosts only disables image comparison 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 - `COPYRIGHTER_AUTH_TOKEN` should be set in `.env` for any multi-operator or
networked deployment so the data routes require a shared token. networked deployment so the data routes require a shared token.

View file

@ -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 과
`<provider> 미수행` 사유로 드러난다. 네이버 기본 한도 100 = 약 20장/일이므로 하루 처리량에
맞춰 `COPYRIGHTER_NAVER_DAILY_LIMIT` 을 정한다.
## 확인 방법
- 기동 로그 첫 줄: `Provider readiness: Naver search: ready, Google Vision web detection: ready, …`
- `/health` → `version` · `scorerVersion`
- 콘솔 상단 `외부 검색 tool N/5 활성 · 실패 N`
- 모드 C 는 한 줄 요약에 `외부 검색 꺼짐` 이 떠야 정상이다. 대신 `외부 검색 실패` 가 뜨면 키가 남아 있는 것이다.

View file

@ -34,6 +34,8 @@
| 외부 검색 실패 / 일일 한도로 일부 미수행 | 그 검색이 **안 돌았다** | 증거가 적은 게 안전하다는 뜻이 아니다. 점수는 중간 하한(30)까지만 오른다 — 보류 후 다음 날 `선택 재분석` | | 외부 검색 실패 / 일일 한도로 일부 미수행 | 그 검색이 **안 돌았다** | 증거가 적은 게 안전하다는 뜻이 아니다. 점수는 중간 하한(30)까지만 오른다 — 보류 후 다음 날 `선택 재분석` |
| 신뢰 출처 제외 N행 | 자사·계약처 도메인에서 발견된 행 | 점수에서 빠진다. 스크래퍼 게시가 같이 있으면 그것만 센다 | | 신뢰 출처 제외 N행 | 자사·계약처 도메인에서 발견된 행 | 점수에서 빠진다. 스크래퍼 게시가 같이 있으면 그것만 센다 |
**점수는 검토 순서이지 침해 판정이 아니다.** 축은 웹 노출·인물 식별성·이름 검색·등록 이력이고, 라이선스·초상 동의 같은 **권리 증빙은 점수에 없다.** 그래서 라이선스가 있는 인물 사진이 80(높음), 무단 사본인 그림이 50(중간)이 될 수 있다 — 높음은 "먼저 보라"이고, 권리 판단은 운영자가 한다.
점수 밴드: **높음 70+ · 중간 30+ · 낮음.** 웹에 있다는 것만으로는 최대 65 라 높음이 되지 점수 밴드: **높음 70+ · 중간 30+ · 낮음.** 웹에 있다는 것만으로는 최대 65 라 높음이 되지
않는다 — 높음은 "웹 사본 + 인물" 또는 "등록 일치"다. 밴드는 우선순위이지 결론이 아니다. 않는다 — 높음은 "웹 사본 + 인물" 또는 "등록 일치"다. 밴드는 우선순위이지 결론이 아니다.

View file

@ -31,6 +31,8 @@ INTERNAL_EXPORT_FIELDS = (
"applicantStatus", "applicantStatus",
"riskScore", "riskScore",
"riskBand", "riskBand",
"scoredVersion",
"currentRiskScore",
"memo", "memo",
"decidedAt", "decidedAt",
"evidenceUsed", "evidenceUsed",
@ -337,15 +339,35 @@ class StoreWorkflowMixin:
if audience == "applicant": if audience == "applicant":
return row return row
used = [ # What the operator saw WHEN THEY DECIDED, not what the case says now.
item # Reading the live score and live "사용" marks meant a rescore or a mark
for item in self._evidence_for_submission(submission_id) # changed after the decision rewrote the record of why it was made
if str(item.get("operatorStatus", "")) == "used_for_judgment" # (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( row.update(
{ {
"riskScore": submission.get("riskScore", 0), "riskScore": score,
"riskBand": str(submission.get("riskBand", "")), "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, "decidedAt": decided_at,
"evidenceUsed": " | ".join(str(item.get("title", "")) for item in used), "evidenceUsed": " | ".join(str(item.get("title", "")) for item in used),
"evidenceUrls": " | ".join( "evidenceUrls": " | ".join(

View file

@ -165,6 +165,29 @@ def test_export_memo_is_the_latest_decisions_memo_not_the_last_nonempty_one(tmp_
assert export["rows"][0]["memo"] == "" 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): def test_applicant_export_never_exposes_internal_signals(tmp_path: Path):
store = _store(tmp_path) store = _store(tmp_path)
ids = _first_ids(store, 1) 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") export = store.export_decisions(audience="applicant")
row = export["rows"][0] 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 forbidden not in row
assert "riskScore" not in export["csv"] assert "riskScore" not in export["csv"]

View file

@ -535,6 +535,7 @@ function setScoreBreakdown(submission) {
box.innerHTML = ` box.innerHTML = `
<h4>점수 구성 <strong>${breakdown.final}</strong></h4> <h4>점수 구성 <strong>${breakdown.final}</strong></h4>
<p class="score-breakdown-scope">웹 노출과 인물 식별성으로 매긴 <b>검토 순서</b>입니다. 권리 증빙(라이선스·동의)은 반영되지 않으며, 침해 여부는 판정에서 정합니다.</p>
<ul>${rows}${excludedRows}</ul> <ul>${rows}${excludedRows}</ul>

View file

@ -211,7 +211,7 @@
<th scope="col"><span class="sr-only">선택</span></th> <th scope="col"><span class="sr-only">선택</span></th>
<th scope="col">이미지</th> <th scope="col">이미지</th>
<th scope="col">제출</th> <th scope="col">제출</th>
<th scope="col">위험</th> <th scope="col" title="웹 노출 · 인물 식별성 기반 검토 순서 — 침해 판정이 아닙니다">검토 우선</th>
<th scope="col">상위 근거</th> <th scope="col">상위 근거</th>
<th scope="col">외부 검색 tool 활용</th> <th scope="col">외부 검색 tool 활용</th>
<th scope="col">지원자 상태</th> <th scope="col">지원자 상태</th>

View file

@ -3164,6 +3164,12 @@ tbody tr.selected-row,
color: var(--green); color: var(--green);
} }
.score-breakdown-scope {
margin: 0 0 8px;
color: var(--ink-soft);
font-size: 12px;
}
.score-breakdown-clamped { .score-breakdown-clamped {
margin: 8px 0 0; margin: 8px 0 0;
color: var(--amber); color: var(--amber);