fix: 판정 정정 일관성 — 승인은 파생 주의 후보를 철회하고, 내보내기 메모는 마지막 판정의 것만

codex F4·4.2·4.3·4.5:
- 내보내기 메모가 마지막 비어 있지 않은 메모라 반려(메모)→승인(무메모) 뒤 지원자용 CSV 에
  반려 사유가 나갔다 → 마지막 판정의 메모만.
- 보류·반려→승인 시 그 판정이 만든 주의 후보를 자동 철회(excluded, 감사 기록). 운영자가
  승격한 확정 항목은 손대지 않고 감사에만 남긴다.
- 철회·제외된 항목에 걸려 있던 다른 케이스의 저장된 일치 행을 contributed=False 로 퇴역시키고
  리스코어 — 스코어러는 항목 상태가 아니라 저장된 행을 읽는다. 기존 exclude 경로에도 적용.
- 재보류·재반려가 excluded/confirmed 항목을 되살리지 않는다.

verify.ps1: ruff clean, 674 passed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BStHFrjQj2VypecSG9ex1V
This commit is contained in:
changukyu 2026-09-23 21:57:17 +09:00
parent 92fc8094d4
commit 70e1bcb900
8 changed files with 158 additions and 7 deletions

View file

@ -1,5 +1,7 @@
# Project INDEX # Project INDEX
- 2026-09-23 · **판정 정정 일관성 — codex F4 · 4.2 · 4.3 · 4.5.** ① 내보내기 메모가 "마지막 비어 있지 않은 메모"라 반려(메모)→승인(무메모) 뒤 지원자용 CSV 에 반려 사유가 나갔다 → **마지막 판정의 메모만**(`_latest_decision_memo`, 검색 대기 마커 `HOLD_WAITING_MARKER` 는 벗겨냄). ② 보류·반려→승인 시 그 판정이 만든 주의 후보(`kb-watchlist-<id>`)를 **자동 철회**(`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 재생성. - 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 재생성.
- 2026-09-23 · **codex 기능 검토** (`docs/reviews/2026-09-23-codex-feature-review.md`, 읽기 전용 샌드박스). 검증: F1 보류→주의 후보→재제출 시 80/high **CONFIRMED**(`internal_analyzer.py` 가 watchlist 도 knowledge_entry_id 로 내고 스코어러는 entry_status 를 안 본다) · F4 반려(메모)→승인(무메모) 시 내보내기 메모가 이전 반려 메모 **CONFIRMED**(`_latest_decision_memo` 가 마지막 비어있지 않은 메모를 고른다) · F2 '미사용' 버튼이 오탐과 구분 없이 점수 제거 **PLAUSIBLE**(UI 는 사용/미사용 2버튼, 서버 상태는 4종) · F6 파일명 stem 충돌 CONFIRMED(기존 문서화 한계) · 통계 분모 0 → 0% 표시 CONFIRMED(기존 부채). 채택 순위: ① 보류 사유 분리 + 주의 후보의 80점 자동 승격 차단 ② 판정 정정 일관성(메모·파생 기준) ③ 투입 충돌 감지 ④ 인용/무관/오탐 분리 ⑤ 검색 완결성 표시. 미채택: 출처별 신뢰도 자동 학습(codex 도 보류 권고), 지원자 통지 연동(L, 외부 시스템). - 2026-09-23 · **codex 기능 검토** (`docs/reviews/2026-09-23-codex-feature-review.md`, 읽기 전용 샌드박스). 검증: F1 보류→주의 후보→재제출 시 80/high **CONFIRMED**(`internal_analyzer.py` 가 watchlist 도 knowledge_entry_id 로 내고 스코어러는 entry_status 를 안 본다) · F4 반려(메모)→승인(무메모) 시 내보내기 메모가 이전 반려 메모 **CONFIRMED**(`_latest_decision_memo` 가 마지막 비어있지 않은 메모를 고른다) · F2 '미사용' 버튼이 오탐과 구분 없이 점수 제거 **PLAUSIBLE**(UI 는 사용/미사용 2버튼, 서버 상태는 4종) · F6 파일명 stem 충돌 CONFIRMED(기존 문서화 한계) · 통계 분모 0 → 0% 표시 CONFIRMED(기존 부채). 채택 순위: ① 보류 사유 분리 + 주의 후보의 80점 자동 승격 차단 ② 판정 정정 일관성(메모·파생 기준) ③ 투입 충돌 감지 ④ 인용/무관/오탐 분리 ⑤ 검색 완결성 표시. 미채택: 출처별 신뢰도 자동 학습(codex 도 보류 권고), 지원자 통지 연동(L, 외부 시스템).

View file

@ -41,7 +41,7 @@
| 판정 | 언제 | 뒤에 일어나는 일 | | 판정 | 언제 | 뒤에 일어나는 일 |
|---|---|---| |---|---|---|
| **승인** `A` | 자사 제작·라이선스 확인·자유 이용 확인. 웹 사본이 있어도 출처가 우리이거나 계약처면 승인 | 지원자 상태 '승인 대기'. 기준 DB 에는 **아무것도 안 들어간다** — 재사용될 이미지면 `클리어 기준 등록` 을 따로 누른다 | | **승인** `A` | 자사 제작·라이선스 확인·자유 이용 확인. 웹 사본이 있어도 출처가 우리이거나 계약처면 승인 | 지원자 상태 '승인 대기'. 기준 DB 에는 **아무것도 안 들어간다** — 재사용될 이미지면 `클리어 기준 등록` 을 따로 누른다. 보류·반려를 **승인으로 바꾸면** 그 판정이 만든 주의 후보는 자동 철회되고(이미 `승격` 한 항목은 남고 감사에 기록), 그 항목에 걸려 있던 다른 케이스는 리스코어된다. 내보내기 메모는 항상 **마지막 판정**의 메모다 |
| **보류** `H` | 출처 불명·권리자 확인 필요·검색이 안 돈 경우 | 기준 DB 에 **주의 후보(watchlist)** 로 들어간다. 같은 이미지가 다시 오면 `주의 후보 이미지 유사도` 로 잡힌다. 단, **검색이 안 돌아서(미수행·실패) 보류한 경우는 주의 후보를 만들지 않는다** — "확인 못 함"은 "의심됨"이 아니다. 감사 로그에 `검색 대기` 로 남는다 | | **보류** `H` | 출처 불명·권리자 확인 필요·검색이 안 돈 경우 | 기준 DB 에 **주의 후보(watchlist)** 로 들어간다. 같은 이미지가 다시 오면 `주의 후보 이미지 유사도` 로 잡힌다. 단, **검색이 안 돌아서(미수행·실패) 보류한 경우는 주의 후보를 만들지 않는다** — "확인 못 함"은 "의심됨"이 아니다. 감사 로그에 `검색 대기` 로 남는다 |
| **반려** `R` | 타인 저작물 사본·초상권 침해·이전 반려와 동일 | 사유 필수. 기준 DB 주의 후보 갱신. 지원자 상태 '반려 예정' | | **반려** `R` | 타인 저작물 사본·초상권 침해·이전 반려와 동일 | 사유 필수. 기준 DB 주의 후보 갱신. 지원자 상태 '반려 예정' |

View file

@ -29,6 +29,7 @@ from rights_filter.server.store_constants import (
DEFAULT_QUERY_COVERAGE_GOOD_THRESHOLD, DEFAULT_QUERY_COVERAGE_GOOD_THRESHOLD,
DEFAULT_QUERY_COVERAGE_WARN_THRESHOLD, DEFAULT_QUERY_COVERAGE_WARN_THRESHOLD,
EVIDENCE_OPERATOR_STATUSES, EVIDENCE_OPERATOR_STATUSES,
HOLD_WAITING_MARKER,
MAX_COVERAGE_THRESHOLD, MAX_COVERAGE_THRESHOLD,
MAX_LLM_SUMMARIES_PER_SEED, MAX_LLM_SUMMARIES_PER_SEED,
MIN_COVERAGE_THRESHOLD, MIN_COVERAGE_THRESHOLD,
@ -806,6 +807,13 @@ class CopyrighterStore(
) )
if decision == "rejected" or (decision == "held" and not waiting_for_search): if decision == "rejected" or (decision == "held" and not waiting_for_search):
self._create_or_update_watchlist_entry(submission_id, decision, memo, image_store, conn=conn) self._create_or_update_watchlist_entry(submission_id, decision, memo, image_store, conn=conn)
# Reversing a hold/rejection into an approval withdraws the entry
# that decision derived - otherwise the current approval and the
# old "suspect" criterion coexist, and every later match of this
# image still scores it (codex F4 / 4.2).
rescore_ids = (
self._withdraw_auto_watchlist_entry(submission_id, conn=conn) if decision == "approved" else []
)
self.add_audit_event( self.add_audit_event(
actor="rights.ops", actor="rights.ops",
@ -813,10 +821,13 @@ class CopyrighterStore(
object_id=submission_id, object_id=submission_id,
change=( change=(
f"{decision} · {memo or 'memo optional'}" f"{decision} · {memo or 'memo optional'}"
+ (" · 검색 대기 (주의 후보 미생성)" if waiting_for_search else "") + (f" · {HOLD_WAITING_MARKER}" if waiting_for_search else "")
), ),
conn=conn, conn=conn,
) )
# Derived state, after the commit but still under the write lock.
for other_id in rescore_ids:
self._rescore_submission(other_id)
return self.review(submission_id) return self.review(submission_id)
def mark_evidence_status( def mark_evidence_status(

View file

@ -5,6 +5,10 @@ depend on it without import cycles. Extracted from sqlite_store.py unchanged.
from __future__ import annotations from __future__ import annotations
# Appended to a hold's audit change text when the hold was taken while an
# external check had not run. The export's memo lookup strips it back off.
HOLD_WAITING_MARKER = "검색 대기 (주의 후보 미생성)"
EVIDENCE_OPERATOR_STATUSES = { EVIDENCE_OPERATOR_STATUSES = {
"used_for_judgment": "판단에 사용", "used_for_judgment": "판단에 사용",
"irrelevant": "무관", "irrelevant": "무관",

View file

@ -72,6 +72,10 @@ class StoreOperationsMixin:
str(entry.get("name", entry_id)), str(entry.get("name", entry_id)),
entry["excludedReason"], entry["excludedReason"],
) )
# Cases that matched this candidate keep scoring it until their stored
# match rows are retired too (codex 4.5).
for submission_id in self._invalidate_matches_for_entry(entry_id):
self._rescore_submission(submission_id)
return self.bootstrap() return self.bootstrap()
def update_knowledge_entry(self, entry_id: str, payload: dict[str, Any]) -> dict[str, Any]: def update_knowledge_entry(self, entry_id: str, payload: dict[str, Any]) -> dict[str, Any]:
@ -137,6 +141,71 @@ class StoreOperationsMixin:
) )
return self.bootstrap() return self.bootstrap()
def _withdraw_auto_watchlist_entry(self, submission_id: str, conn: Any = None) -> list[str]:
"""Retire the watchlist entry this submission's hold/rejection created.
Returns the ids of OTHER submissions whose stored match rows pointed at
the entry; the caller rescores them once the write has committed. A
candidate the operator already promoted to confirmed is left alone - that
is their judgement, not the reversed decision's - and only noted.
"""
entry_id = _stable_id("kb-watchlist", submission_id)
try:
entry = self._get("knowledge_entries", entry_id, conn=conn)
except KeyError:
return []
if entry.get("entryStatus") == "confirmed":
self.add_audit_event(
"rights.ops",
"Watchlist candidate kept despite approval",
str(entry.get("name", entry_id)),
"확정 항목이라 자동 철회하지 않음 - 수동 검토 필요",
conn=conn,
)
return []
if entry.get("entryStatus") != "watchlist" or not entry.get("active", False):
return []
entry["entryStatus"] = "excluded"
entry["active"] = False
entry["excludedReason"] = "승인으로 판정 변경 - 자동 철회"
entry["excludedAt"] = _now_label()
entry["excludedBy"] = "rights.ops"
self._put("knowledge_entries", entry_id, entry, conn=conn)
self.add_audit_event(
"rights.ops",
"Watchlist candidate withdrawn",
str(entry.get("name", entry_id)),
entry["excludedReason"],
conn=conn,
)
return self._invalidate_matches_for_entry(entry_id, conn=conn)
def _invalidate_matches_for_entry(self, entry_id: str, conn: Any = None) -> list[str]:
"""Stop stored match rows against a retired entry from scoring.
The scorer reads persisted evidence, not the entry's current state, so
retiring an entry alone leaves every case it matched at its old score.
Rows are kept (the operator can still see what was matched) but marked
non-contributing; the caller rescores the returned submissions.
"""
touched: list[str] = []
for payload in self._all("evidence"):
if str(payload.get("knowledgeEntryId", "")) != entry_id:
continue
if payload.get("withdrawnAt"):
continue
# `status` is CHECK-constrained to the analysis/operator vocabulary,
# so the retirement rides on `contributed` (what the scorer reads)
# plus its own stamp.
payload["contributed"] = False
payload["withdrawnAt"] = _now_label()
payload["withdrawnReason"] = "기준 항목 철회"
self._put("evidence", str(payload["id"]), payload, conn=conn)
submission_id = str(payload.get("submission_id", ""))
if submission_id and submission_id not in touched:
touched.append(submission_id)
return touched
def _create_or_update_watchlist_entry( def _create_or_update_watchlist_entry(
self, self,
submission_id: str, submission_id: str,
@ -173,8 +242,11 @@ class StoreOperationsMixin:
"keywords": keywords, "keywords": keywords,
"memo": memo.strip() or ("보류 판정으로 자동 생성" if decision == "held" else "반려 판정으로 자동 생성"), "memo": memo.strip() or ("보류 판정으로 자동 생성" if decision == "held" else "반려 판정으로 자동 생성"),
"provenance": "automatic", "provenance": "automatic",
"active": True, # A candidate the operator already confirmed or excluded keeps that
"entryStatus": "watchlist", # state: a repeated hold must not resurrect an excluded candidate or
# demote a confirmed one back to "unconfirmed" (codex 4.3).
"active": existing.get("active", True) if existing.get("entryStatus") in {"confirmed", "excluded"} else True,
"entryStatus": existing.get("entryStatus") if existing.get("entryStatus") in {"confirmed", "excluded"} else "watchlist",
"originDecisionStatus": decision, "originDecisionStatus": decision,
"sourceDecision": f"DEC-{submission_id}", "sourceDecision": f"DEC-{submission_id}",
"sourceSubmissionId": submission_id, "sourceSubmissionId": submission_id,

View file

@ -12,6 +12,7 @@ import io
from typing import Any from typing import Any
from rights_filter.server.image_store import LocalSubmissionImageStore from rights_filter.server.image_store import LocalSubmissionImageStore
from rights_filter.server.store_constants import HOLD_WAITING_MARKER
from rights_filter.server.store_serialization import _now_label, _stable_id 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 # A batch is an operator convenience, not an import path: keep it small enough
@ -380,6 +381,9 @@ class StoreWorkflowMixin:
def _latest_decision_memo(self, submission_id: str) -> str: def _latest_decision_memo(self, submission_id: str) -> str:
import json import json
# The memo of the LATEST decision only. Taking the last non-empty memo
# across all events meant a rejection's memo outlived the approval that
# reversed it and went out to the applicant (codex F4).
memo = "" memo = ""
for event in self._decision_events(): for event in self._decision_events():
if event["object_ref"] != submission_id: if event["object_ref"] != submission_id:
@ -388,10 +392,10 @@ class StoreWorkflowMixin:
change = str(json.loads(event["payload"]).get("change", "")) change = str(json.loads(event["payload"]).get("change", ""))
except (ValueError, TypeError): except (ValueError, TypeError):
continue continue
# Audit change text is "<decision> · <memo>". # Audit change text is "<decision> · <memo>[ · <hold marker>]".
_, separator, tail = change.partition(" · ") _, separator, tail = change.partition(" · ")
if separator and tail and tail != "memo optional": tail = tail.removesuffix(f" · {HOLD_WAITING_MARKER}")
memo = tail memo = tail if separator and tail and tail != "memo optional" else ""
return memo return memo

View file

@ -834,6 +834,49 @@ def test_sqlite_store_hold_while_search_was_down_does_not_create_a_watchlist_ent
assert [entry["entryStatus"] for entry in store.bootstrap()["knowledgeEntries"]] == ["watchlist"] assert [entry["entryStatus"] for entry in store.bootstrap()["knowledgeEntries"]] == ["watchlist"]
def test_sqlite_store_approval_withdraws_the_watchlist_entry_and_its_matches(tmp_path: Path):
# codex F4 / 4.2 / 4.5 (2026-09-23): reversing a hold into an approval left
# the derived "suspect" entry active, and every case that had matched it
# kept its score because the scorer reads stored match rows, not the entry.
image_root = tmp_path / "submissions"
image_store = _image_store(image_root)
db_path = tmp_path / "copyrighter.sqlite3"
store = CopyrighterStore(db_path)
store.initialize()
store.seed_from_image_store(image_store)
store.record_decision("SUB-DB1", "held", "권리 확인 필요", image_store=image_store)
(image_root / "images" / "copy.svg").write_text("<svg><!-- FACE PERSON --></svg>", encoding="utf-8")
(image_root / "submissions.json").write_text(
"""
[
{"id": "SUB-DB1", "title": "DB sample", "file": "images/sample.svg", "width": 800, "height": 600, "submitted_at": "2026-05-26 10:30"},
{"id": "SUB-DB2", "title": "Copied DB sample", "file": "images/copy.svg", "width": 800, "height": 600, "submitted_at": "2026-05-26 10:40"}
]
""",
encoding="utf-8",
)
store.seed_from_image_store(LocalSubmissionImageStore(image_root))
before = store.review("SUB-DB2")
assert any(item["key"] == "watchlist" for item in before["riskBreakdown"]["components"])
store.record_decision("SUB-DB1", "approved", "", image_store=image_store)
entry = store.bootstrap()["knowledgeEntries"][0]
assert entry["entryStatus"] == "excluded"
assert entry["active"] is False
assert "자동 철회" in entry["excludedReason"]
after = store.review("SUB-DB2")
assert all(item["key"] != "watchlist" for item in after["riskBreakdown"]["components"])
assert after["riskScore"] < before["riskScore"]
match_row = next(item for item in after["evidence"] if item.get("knowledgeEntryStatus") == "watchlist")
assert match_row["contributed"] is False
assert any(event["event"] == "Watchlist candidate withdrawn" for event in store.audit_events())
# A later hold must not resurrect the withdrawn candidate.
store.record_decision("SUB-DB1", "held", "다시 보류", image_store=image_store)
assert store.bootstrap()["knowledgeEntries"][0]["entryStatus"] == "excluded"
def test_sqlite_store_promotes_and_excludes_watchlist_candidates(tmp_path: Path): def test_sqlite_store_promotes_and_excludes_watchlist_candidates(tmp_path: Path):
image_store = _image_store(tmp_path / "submissions") image_store = _image_store(tmp_path / "submissions")
db_path = tmp_path / "copyrighter.sqlite3" db_path = tmp_path / "copyrighter.sqlite3"

View file

@ -150,6 +150,21 @@ def test_export_skips_undecided_cases_and_carries_the_memo(tmp_path: Path):
assert "riskScore" in export["rows"][0] assert "riskScore" in export["rows"][0]
def test_export_memo_is_the_latest_decisions_memo_not_the_last_nonempty_one(tmp_path: Path):
# codex F4 (2026-09-23): a rejection's memo outlived the approval that
# reversed it and went out to the applicant as the reason for a case that
# was, by then, approved.
store = _store(tmp_path)
ids = _first_ids(store, 1)
store.record_decision(ids[0], "rejected", "연예인 초상 포함")
store.record_decision(ids[0], "approved")
export = store.export_decisions(audience="applicant")
assert export["rows"][0]["decision"] == "approved"
assert export["rows"][0]["memo"] == ""
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)