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