From 70e1bcb90031159e9b93abcab1a96a29b546a845 Mon Sep 17 00:00:00 2001 From: changukyu Date: Wed, 23 Sep 2026 21:57:17 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=ED=8C=90=EC=A0=95=20=EC=A0=95=EC=A0=95?= =?UTF-8?q?=20=EC=9D=BC=EA=B4=80=EC=84=B1=20=E2=80=94=20=EC=8A=B9=EC=9D=B8?= =?UTF-8?q?=EC=9D=80=20=ED=8C=8C=EC=83=9D=20=EC=A3=BC=EC=9D=98=20=ED=9B=84?= =?UTF-8?q?=EB=B3=B4=EB=A5=BC=20=EC=B2=A0=ED=9A=8C=ED=95=98=EA=B3=A0,=20?= =?UTF-8?q?=EB=82=B4=EB=B3=B4=EB=82=B4=EA=B8=B0=20=EB=A9=94=EB=AA=A8?= =?UTF-8?q?=EB=8A=94=20=EB=A7=88=EC=A7=80=EB=A7=89=20=ED=8C=90=EC=A0=95?= =?UTF-8?q?=EC=9D=98=20=EA=B2=83=EB=A7=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01BStHFrjQj2VypecSG9ex1V --- docs/INDEX.md | 2 + docs/operations/review-guide.md | 2 +- src/rights_filter/server/sqlite_store.py | 13 +++- src/rights_filter/server/store_constants.py | 4 + src/rights_filter/server/store_operations.py | 76 ++++++++++++++++++- src/rights_filter/server/store_workflow.py | 10 ++- .../rights_filter/server/test_sqlite_store.py | 43 +++++++++++ .../server/test_store_workflow.py | 15 ++++ 8 files changed, 158 insertions(+), 7 deletions(-) diff --git a/docs/INDEX.md b/docs/INDEX.md index 049584a..c8b9e32 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -1,5 +1,7 @@ # Project INDEX +- 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 재생성. - 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, 외부 시스템). diff --git a/docs/operations/review-guide.md b/docs/operations/review-guide.md index 29e80e6..81d27dc 100644 --- a/docs/operations/review-guide.md +++ b/docs/operations/review-guide.md @@ -41,7 +41,7 @@ | 판정 | 언제 | 뒤에 일어나는 일 | |---|---|---| -| **승인** `A` | 자사 제작·라이선스 확인·자유 이용 확인. 웹 사본이 있어도 출처가 우리이거나 계약처면 승인 | 지원자 상태 '승인 대기'. 기준 DB 에는 **아무것도 안 들어간다** — 재사용될 이미지면 `클리어 기준 등록` 을 따로 누른다 | +| **승인** `A` | 자사 제작·라이선스 확인·자유 이용 확인. 웹 사본이 있어도 출처가 우리이거나 계약처면 승인 | 지원자 상태 '승인 대기'. 기준 DB 에는 **아무것도 안 들어간다** — 재사용될 이미지면 `클리어 기준 등록` 을 따로 누른다. 보류·반려를 **승인으로 바꾸면** 그 판정이 만든 주의 후보는 자동 철회되고(이미 `승격` 한 항목은 남고 감사에 기록), 그 항목에 걸려 있던 다른 케이스는 리스코어된다. 내보내기 메모는 항상 **마지막 판정**의 메모다 | | **보류** `H` | 출처 불명·권리자 확인 필요·검색이 안 돈 경우 | 기준 DB 에 **주의 후보(watchlist)** 로 들어간다. 같은 이미지가 다시 오면 `주의 후보 이미지 유사도` 로 잡힌다. 단, **검색이 안 돌아서(미수행·실패) 보류한 경우는 주의 후보를 만들지 않는다** — "확인 못 함"은 "의심됨"이 아니다. 감사 로그에 `검색 대기` 로 남는다 | | **반려** `R` | 타인 저작물 사본·초상권 침해·이전 반려와 동일 | 사유 필수. 기준 DB 주의 후보 갱신. 지원자 상태 '반려 예정' | diff --git a/src/rights_filter/server/sqlite_store.py b/src/rights_filter/server/sqlite_store.py index 881dff5..54685cd 100644 --- a/src/rights_filter/server/sqlite_store.py +++ b/src/rights_filter/server/sqlite_store.py @@ -29,6 +29,7 @@ from rights_filter.server.store_constants import ( DEFAULT_QUERY_COVERAGE_GOOD_THRESHOLD, DEFAULT_QUERY_COVERAGE_WARN_THRESHOLD, EVIDENCE_OPERATOR_STATUSES, + HOLD_WAITING_MARKER, MAX_COVERAGE_THRESHOLD, MAX_LLM_SUMMARIES_PER_SEED, MIN_COVERAGE_THRESHOLD, @@ -806,6 +807,13 @@ class CopyrighterStore( ) 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) + # 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( actor="rights.ops", @@ -813,10 +821,13 @@ class CopyrighterStore( object_id=submission_id, change=( f"{decision} · {memo or 'memo optional'}" - + (" · 검색 대기 (주의 후보 미생성)" if waiting_for_search else "") + + (f" · {HOLD_WAITING_MARKER}" if waiting_for_search else "") ), 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) def mark_evidence_status( diff --git a/src/rights_filter/server/store_constants.py b/src/rights_filter/server/store_constants.py index 90af74b..4b6dbca 100644 --- a/src/rights_filter/server/store_constants.py +++ b/src/rights_filter/server/store_constants.py @@ -5,6 +5,10 @@ depend on it without import cycles. Extracted from sqlite_store.py unchanged. 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 = { "used_for_judgment": "판단에 사용", "irrelevant": "무관", diff --git a/src/rights_filter/server/store_operations.py b/src/rights_filter/server/store_operations.py index fdc8c6c..427dae1 100644 --- a/src/rights_filter/server/store_operations.py +++ b/src/rights_filter/server/store_operations.py @@ -72,6 +72,10 @@ class StoreOperationsMixin: str(entry.get("name", entry_id)), 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() def update_knowledge_entry(self, entry_id: str, payload: dict[str, Any]) -> dict[str, Any]: @@ -137,6 +141,71 @@ class StoreOperationsMixin: ) 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( self, submission_id: str, @@ -173,8 +242,11 @@ class StoreOperationsMixin: "keywords": keywords, "memo": memo.strip() or ("보류 판정으로 자동 생성" if decision == "held" else "반려 판정으로 자동 생성"), "provenance": "automatic", - "active": True, - "entryStatus": "watchlist", + # A candidate the operator already confirmed or excluded keeps that + # 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, "sourceDecision": f"DEC-{submission_id}", "sourceSubmissionId": submission_id, diff --git a/src/rights_filter/server/store_workflow.py b/src/rights_filter/server/store_workflow.py index 2cfca05..d4f934f 100644 --- a/src/rights_filter/server/store_workflow.py +++ b/src/rights_filter/server/store_workflow.py @@ -12,6 +12,7 @@ import io from typing import Any 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 # 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: 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 = "" for event in self._decision_events(): if event["object_ref"] != submission_id: @@ -388,10 +392,10 @@ class StoreWorkflowMixin: change = str(json.loads(event["payload"]).get("change", "")) except (ValueError, TypeError): continue - # Audit change text is " · ". + # Audit change text is " · [ · ]". _, separator, tail = change.partition(" · ") - if separator and tail and tail != "memo optional": - memo = tail + tail = tail.removesuffix(f" · {HOLD_WAITING_MARKER}") + memo = tail if separator and tail and tail != "memo optional" else "" return memo diff --git a/tests/rights_filter/server/test_sqlite_store.py b/tests/rights_filter/server/test_sqlite_store.py index 0097c14..a7fd67f 100644 --- a/tests/rights_filter/server/test_sqlite_store.py +++ b/tests/rights_filter/server/test_sqlite_store.py @@ -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"] +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("", 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): image_store = _image_store(tmp_path / "submissions") db_path = tmp_path / "copyrighter.sqlite3" diff --git a/tests/rights_filter/server/test_store_workflow.py b/tests/rights_filter/server/test_store_workflow.py index afe01a9..64dd551 100644 --- a/tests/rights_filter/server/test_store_workflow.py +++ b/tests/rights_filter/server/test_store_workflow.py @@ -150,6 +150,21 @@ def test_export_skips_undecided_cases_and_carries_the_memo(tmp_path: Path): 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): store = _store(tmp_path) ids = _first_ids(store, 1)