fix: resolve multi-agent review findings for workbench efficiency round

This commit is contained in:
유창욱 2026-06-12 18:44:35 +09:00
parent 4d98582ed3
commit 37294dc140
6 changed files with 115 additions and 49 deletions

View file

@ -1262,15 +1262,21 @@ class CopyrighterStore:
for item in self._evidence_for_submission(submission_id) for item in self._evidence_for_submission(submission_id)
} }
rerun_marker_prefix = f"ev-{submission_id}-rerun-" rerun_marker_prefix = f"ev-{submission_id}-rerun-"
# LLM 요약은 재분석마다 삭제 후 재생성되어 id가 항상 바뀌므로(요약의
# source_evidence_ids에 타임스탬프 마커 id가 섞임) diff에 포함하면
# 변경이 없어도 매번 신규+제거로 잡힌다 — diff 대상에서 제외한다.
added_ids = [ added_ids = [
evidence_id evidence_id
for evidence_id in evidence_after for evidence_id in evidence_after
if evidence_id not in evidence_before and not evidence_id.startswith(rerun_marker_prefix) if evidence_id not in evidence_before
and not evidence_id.startswith(rerun_marker_prefix)
and str(evidence_after[evidence_id].get("source", "")) != "llm"
] ]
removed_items = [ removed_items = [
evidence_before[evidence_id] evidence_before[evidence_id]
for evidence_id in evidence_before for evidence_id in evidence_before
if evidence_id not in evidence_after if evidence_id not in evidence_after
and str(evidence_before[evidence_id].get("source", "")) != "llm"
] ]
refreshed = self._get("submissions", submission_id) refreshed = self._get("submissions", submission_id)
refreshed["lastRerunDiff"] = { refreshed["lastRerunDiff"] = {
@ -1280,7 +1286,7 @@ class CopyrighterStore:
"addedEvidenceIds": added_ids, "addedEvidenceIds": added_ids,
"removedEvidenceIds": [str(item.get("id", "")) for item in removed_items], "removedEvidenceIds": [str(item.get("id", "")) for item in removed_items],
"removedSummaries": [ "removedSummaries": [
{"source": str(item.get("source", "")), "title": str(item.get("title", ""))} {"source": str(item.get("source", "")), "reason": str(item.get("title", ""))}
for item in removed_items for item in removed_items
], ],
} }
@ -1960,16 +1966,22 @@ class CopyrighterStore:
crop_dir = self.face_crop_image_dir / submission_id crop_dir = self.face_crop_image_dir / submission_id
if crop_dir.exists(): if crop_dir.exists():
for stale in crop_dir.glob("crop-*.jpg"): for stale in crop_dir.glob("crop-*.jpg"):
try:
stale.unlink() stale.unlink()
except OSError:
continue
face_crops: list[dict[str, Any]] = [] face_crops: list[dict[str, Any]] = []
for index, box in enumerate(signal.face_boxes[:3], start=1): for index, box in enumerate(signal.face_boxes[:3], start=1):
crops = build_face_crop_derivatives(original, [box]) crops = build_face_crop_derivatives(original, [box])
if not crops: if not crops:
continue continue
try:
crop_dir.mkdir(parents=True, exist_ok=True) crop_dir.mkdir(parents=True, exist_ok=True)
crop_path = crop_dir / f"crop-{index}.jpg" crop_path = crop_dir / f"crop-{index}.jpg"
crop_path.write_bytes(crops[0].content) crop_path.write_bytes(crops[0].content)
except OSError:
continue
face_crops.append( face_crops.append(
{ {
"index": index, "index": index,
@ -2094,20 +2106,29 @@ class CopyrighterStore:
stored_crops = submission.get("faceCrops") stored_crops = submission.get("faceCrops")
if stored_crops is None: if stored_crops is None:
signal = HeuristicFacePersonDetector().detect(original_image) signal = HeuristicFacePersonDetector().detect(original_image)
face_boxes: tuple = signal.face_boxes indexed_boxes = [
(index, box)
for index, box in enumerate(signal.face_boxes[:3], start=1)
]
else: else:
face_boxes = tuple( # 저장된 faceCrops의 index를 그대로 사용해 증거의 crop_index가
tuple(int(value) for value in item.get("box", [])) # 워크벤치 썸네일 번호와 항상 일치하게 한다(중간 박스 크롭 실패 시
# 재열거하면 번호가 어긋난다).
indexed_boxes = [
(int(item.get("index", 0)), tuple(int(value) for value in item.get("box", [])))
for item in stored_crops for item in stored_crops
if len(item.get("box", [])) == 4 if len(item.get("box", [])) == 4
) ]
if not face_boxes: if not indexed_boxes:
return [], 0 return [], 0
crops = build_face_crop_derivatives(original_image, face_boxes)
evidence: list[Evidence] = [] evidence: list[Evidence] = []
call_count = 0 call_count = 0
for crop_index, crop in enumerate(crops, start=1): for crop_index, box in indexed_boxes:
crops = build_face_crop_derivatives(original_image, [box])
if not crops:
continue
crop = crops[0]
call_count += 1 call_count += 1
crop_evidence = self.provider_runtime.google_adapter.detect( crop_evidence = self.provider_runtime.google_adapter.detect(
f"{submission_id}:face-crop-{crop_index}", f"{submission_id}:face-crop-{crop_index}",

View file

@ -618,6 +618,8 @@ def test_google_custom_search_is_exposed_as_operator_text_query_choice():
assert "reverse search" not in html.lower() assert "reverse search" not in html.lower()
assert "operatorSearchProviders" in script assert "operatorSearchProviders" in script
assert "visibleProviderControls" in script assert "visibleProviderControls" in script
# 검색 선택지뿐 아니라 제공자 카드/상태 칩에서도 google_search가 보여야 한다.
assert "retiredProviderIds = new Set()" in script
def test_audit_target_and_change_columns_have_equal_widths(): def test_audit_target_and_change_columns_have_equal_widths():
@ -840,7 +842,10 @@ def test_workbench_shows_detected_face_crop_strip():
assert 'id="face-crop-strip"' in html assert 'id="face-crop-strip"' in html
assert "faceCrops" in script assert "faceCrops" in script
assert "얼굴 영역" in script assert "얼굴 영역" in script
assert "동일 인물 판정 아님" in script # 면책 문구는 기존 증거 행과 동일한 표현을 재사용한다.
assert "동일 인물 판정은 아닙니다" in script
# 크롭 썸네일은 클릭해 원본 크롭을 크게 볼 수 있어야 한다.
assert "face-crop-link" in script
def test_rerun_diff_summary_and_new_evidence_badges_are_rendered(): def test_rerun_diff_summary_and_new_evidence_badges_are_rendered():

View file

@ -1163,6 +1163,8 @@ def test_http_server_serves_bundled_font_with_mime_and_immutable_cache(tmp_path:
def test_knowledge_entry_update_deactivate_reactivate_lifecycle(tmp_path: Path): def test_knowledge_entry_update_deactivate_reactivate_lifecycle(tmp_path: Path):
from urllib.error import HTTPError
static_dir, image_store, store = _fixtures(tmp_path) static_dir, image_store, store = _fixtures(tmp_path)
server = build_server(host="127.0.0.1", port=0, store=store, image_store=image_store, static_dir=static_dir) server = build_server(host="127.0.0.1", port=0, store=store, image_store=image_store, static_dir=static_dir)
_start(server) _start(server)
@ -1177,6 +1179,12 @@ def test_knowledge_entry_update_deactivate_reactivate_lifecycle(tmp_path: Path):
entry = next(item for item in store._all("knowledge_entries") if item["name"] == "윈터") entry = next(item for item in store._all("knowledge_entries") if item["name"] == "윈터")
entry_id = entry["id"] entry_id = entry["id"]
# 비활성 항목이 점수 계산용 지식 저장소에서 빠지는지 검증하려면
# 샘플 지문이 있어야 한다(_knowledge_repository는 지문 없는 항목을 건너뛴다).
entry["sampleFingerprints"] = ["feedfacefeedface"]
store._put("knowledge_entries", entry_id, entry)
assert any(item.id == entry_id for item in store._knowledge_repository().active_knowledge_entries())
updated = _json( updated = _json(
base + f"/api/knowledge/{entry_id}", base + f"/api/knowledge/{entry_id}",
method="PATCH", method="PATCH",
@ -1190,16 +1198,21 @@ def test_knowledge_entry_update_deactivate_reactivate_lifecycle(tmp_path: Path):
deactivated = _json(base + f"/api/knowledge/{entry_id}/deactivate", method="POST", body={"reason": "중복 항목"}) deactivated = _json(base + f"/api/knowledge/{entry_id}/deactivate", method="POST", body={"reason": "중복 항목"})
deactivated_entry = next(item for item in deactivated["knowledgeEntries"] if item["id"] == entry_id) deactivated_entry = next(item for item in deactivated["knowledgeEntries"] if item["id"] == entry_id)
assert deactivated_entry["active"] is False assert deactivated_entry["active"] is False
# 비활성화된 항목은 이후 분석의 위험도 산정에 반영되지 않아야 한다.
assert not any(item.id == entry_id for item in store._knowledge_repository().active_knowledge_entries())
with pytest.raises(Exception): with pytest.raises(HTTPError) as reactivate_error:
_json(base + f"/api/knowledge/{entry_id}/reactivate", method="POST", body={"reason": ""}) _json(base + f"/api/knowledge/{entry_id}/reactivate", method="POST", body={"reason": ""})
assert reactivate_error.value.code == 400
reactivated = _json(base + f"/api/knowledge/{entry_id}/reactivate", method="POST", body={"reason": "검토 후 재사용"}) reactivated = _json(base + f"/api/knowledge/{entry_id}/reactivate", method="POST", body={"reason": "검토 후 재사용"})
reactivated_entry = next(item for item in reactivated["knowledgeEntries"] if item["id"] == entry_id) reactivated_entry = next(item for item in reactivated["knowledgeEntries"] if item["id"] == entry_id)
assert reactivated_entry["active"] is True assert reactivated_entry["active"] is True
assert any(item.id == entry_id for item in store._knowledge_repository().active_knowledge_entries())
with pytest.raises(Exception): with pytest.raises(HTTPError) as empty_patch_error:
_json(base + f"/api/knowledge/{entry_id}", method="PATCH", body={}) _json(base + f"/api/knowledge/{entry_id}", method="PATCH", body={})
assert empty_patch_error.value.code == 400
events = _json(base + "/api/audit-events") events = _json(base + "/api/audit-events")
event_names = [event["event"] for event in events] event_names = [event["event"] for event in events]
@ -1246,7 +1259,8 @@ def test_face_crops_are_empty_for_undetectable_images(tmp_path: Path):
try: try:
review = _json(base + "/api/submissions/SUB-API1/review") review = _json(base + "/api/submissions/SUB-API1/review")
assert review.get("faceCrops", []) == [] # 키 자체는 항상 존재해야 한다 — 키 부재는 "아직 동기화 안 됨"을 뜻한다.
assert review["faceCrops"] == []
finally: finally:
server.shutdown() server.shutdown()
@ -1271,5 +1285,13 @@ def test_rerun_enrichment_records_evidence_diff(tmp_path: Path):
assert diff["at"] assert diff["at"]
marker_prefix = "ev-SUB-API1-rerun-" marker_prefix = "ev-SUB-API1-rerun-"
assert all(not evidence_id.startswith(marker_prefix) for evidence_id in diff["addedEvidenceIds"]) assert all(not evidence_id.startswith(marker_prefix) for evidence_id in diff["addedEvidenceIds"])
# 변경이 없는 연속 재분석은 빈 diff를 만들어야 한다 — 재분석 마커와
# 재생성되는 LLM 요약이 가짜 신규/제거로 잡히면 회귀다.
rerun_again = _json(base + "/api/submissions/SUB-API1/rerun-enrichment", method="POST", body={})
second_diff = rerun_again["lastRerunDiff"]
assert second_diff["addedEvidenceIds"] == []
assert second_diff["removedSummaries"] == []
assert second_diff["scoreBefore"] == second_diff["scoreAfter"]
finally: finally:
server.shutdown() server.shutdown()

View file

@ -11,7 +11,7 @@
knowledgeTypeLabels, knowledgeTypeLabels,
} = window.OperatorLabels; } = window.OperatorLabels;
const retiredProviderIds = new Set(["google_search"]); const retiredProviderIds = new Set();
const operatorSearchProviders = [ const operatorSearchProviders = [
{ id: "naver", label: providerLabels.naver }, { id: "naver", label: providerLabels.naver },
{ id: "google_search", label: providerLabels.google_search }, { id: "google_search", label: providerLabels.google_search },
@ -629,7 +629,8 @@ function renderOperatorSearchProviderOptions() {
const runtime = providers.find((item) => item.id === provider.id); const runtime = providers.find((item) => item.id === provider.id);
const unavailable = Boolean(runtime) && !runtime.enabled; const unavailable = Boolean(runtime) && !runtime.enabled;
const label = unavailable ? `${provider.label} (비활성)` : provider.label; const label = unavailable ? `${provider.label} (비활성)` : provider.label;
return `<option value="${escapeHtml(provider.id)}" ${unavailable ? "disabled" : ""}>${escapeHtml(label)}</option>`; const reason = unavailable ? String(runtime.lastFailure || runtime.compliance || "외부 검색 tool 미연결") : "";
return `<option value="${escapeHtml(provider.id)}" ${unavailable ? "disabled" : ""} title="${escapeHtml(reason)}">${escapeHtml(label)}</option>`;
}) })
.join(""); .join("");
@ -964,26 +965,37 @@ function applySuggestedQuery(button) {
} }
let isSuggestedQueryRunActive = false;
async function executeSuggestedQueries(queries) { async function executeSuggestedQueries(queries) {
if (isSuggestedQueryRunActive) return;
const submission = getSelectedCase(); const submission = getSelectedCase();
if (!submission || !queries.length) return; if (!submission || !queries.length) return;
const statusTarget = document.getElementById("suggested-query-run-status"); const statusTarget = document.getElementById("suggested-query-run-status");
const naver = providers.find((provider) => provider.id === "naver"); const naver = providers.find((provider) => provider.id === "naver");
if (!naver || !naver.enabled) { if (!naver || !naver.enabled) {
if (statusTarget) statusTarget.textContent = "네이버 외부 검색 tool이 비활성입니다."; if (statusTarget) statusTarget.textContent = `${providerLabels.naver} 외부 검색 tool이 비활성입니다.`;
return; return;
} }
isSuggestedQueryRunActive = true;
try {
const results = []; const results = [];
for (const [index, query] of queries.entries()) { for (const [index, query] of queries.entries()) {
if (statusTarget) statusTarget.textContent = `"${query}" 실행 중… (${index + 1}/${queries.length})`; if (statusTarget) statusTarget.textContent = `"${query}" 실행 중… (${index + 1}/${queries.length})`;
try { try {
await apiJson("/api/search/manual", { const review = await apiJson("/api/search/manual", {
method: "POST", method: "POST",
body: JSON.stringify({ submission_id: submission.id, provider: "naver", query }), body: JSON.stringify({ submission_id: submission.id, provider: "naver", query }),
}); });
results.push(`"${query}" 완료`); const historyEntry = (review.queryHistory || [])[0];
const resultCount = historyEntry && historyEntry.query === query ? Number(historyEntry.count || 0) : 0;
const skipped =
resultCount === 0
? (review.evidence || []).find((item) => item.source === "failure" && item.query === query)
: null;
results.push(skipped ? `"${query}" ${formatReason(skipped.title || "건너뜀")}` : `"${query}" 완료(근거 ${resultCount}건)`);
} catch (errorValue) { } catch (errorValue) {
results.push(`"${query}" 실패: ${errorValue.message}`); results.push(`"${query}" 실패: ${errorValue.message}`);
} }
@ -994,6 +1006,9 @@ async function executeSuggestedQueries(queries) {
message: `추천 쿼리 실행 — ${results.join(" · ")}`, message: `추천 쿼리 실행 — ${results.join(" · ")}`,
}; };
await refreshFromApi(); await refreshFromApi();
} finally {
isSuggestedQueryRunActive = false;
}
} }
@ -1297,10 +1312,10 @@ function renderCaseReview() {
document.getElementById("face-crop-strip").innerHTML = (submission.faceCrops || []) document.getElementById("face-crop-strip").innerHTML = (submission.faceCrops || [])
.map( .map(
(crop) => ` (crop) => `
<div class="similar-item face-crop-item"> <a class="similar-item face-crop-item face-crop-link" href="${escapeHtml(crop.url)}" target="_blank" rel="noreferrer" aria-label="얼굴 영역 ${escapeHtml(String(crop.index))} 크게 보기">
<img src="${escapeHtml(crop.url)}" alt="얼굴 영역 ${escapeHtml(String(crop.index))} 크롭"> <img src="${escapeHtml(crop.url)}" alt="얼굴 영역 ${escapeHtml(String(crop.index))} 크롭">
<span>얼굴 영역 ${escapeHtml(String(crop.index))} · 동일 인물 판정 아님</span> <span>얼굴 영역 ${escapeHtml(String(crop.index))} · 동일 인물 판정아닙니다</span>
</div> </a>
`, `,
) )
.join(""); .join("");
@ -1471,7 +1486,7 @@ function renderRerunDiffSummary(diff) {
<summary>이번 재분석에서 제거됨 · ${removedSummaries.length}</summary> <summary>이번 재분석에서 제거됨 · ${removedSummaries.length}</summary>
<ul> <ul>
${removedSummaries ${removedSummaries
.map((item) => `<li>${escapeHtml(sourceLabels[item.source] || item.source || "내부")} · ${escapeHtml(formatReason(item.title || ""))}</li>`) .map((item) => `<li>${escapeHtml(sourceLabels[item.source] || item.source || "내부")} · ${escapeHtml(formatReason(item.reason || ""))}</li>`)
.join("")} .join("")}
</ul> </ul>
</details> </details>
@ -3285,10 +3300,12 @@ async function saveKnowledgeEntryEdit(form) {
async function deactivateKnowledgeEntry(entryId) { async function deactivateKnowledgeEntry(entryId) {
const reason = window.prompt("비활성 사유 메모(선택)");
if (reason === null) return;
try { try {
const payload = await apiJson(`/api/knowledge/${encodeURIComponent(entryId)}/deactivate`, { const payload = await apiJson(`/api/knowledge/${encodeURIComponent(entryId)}/deactivate`, {
method: "POST", method: "POST",
body: JSON.stringify({ reason: "" }), body: JSON.stringify({ reason: reason.trim() }),
}); });
applyBootstrap(payload); applyBootstrap(payload);
renderAll(); renderAll();

View file

@ -343,11 +343,12 @@
<span>유형</span> <span>유형</span>
<select id="knowledge-type-filter"> <select id="knowledge-type-filter">
<option value="all">전체</option> <option value="all">전체</option>
<option value="public_figure">연예인/유명인</option> <option value="celebrity">연예인/유명인</option>
<option value="work">작품</option> <option value="work">작품</option>
<option value="character">캐릭터</option> <option value="character">캐릭터</option>
<option value="game">게임</option> <option value="game">게임</option>
<option value="rejected_image">반려 이미지</option> <option value="rejected_image">반려 이미지</option>
<option value="other">기타</option>
</select> </select>
</label> </label>
<label for="knowledge-status-filter"> <label for="knowledge-status-filter">

View file

@ -2781,7 +2781,7 @@ tbody tr.selected-row,
gap: 8px; gap: 8px;
margin-top: 8px; margin-top: 8px;
padding: 10px; padding: 10px;
border: 1px solid #d4d4d8; border: 1px solid var(--hair);
border-radius: 8px; border-radius: 8px;
} }
@ -2791,7 +2791,7 @@ tbody tr.selected-row,
} }
.face-crop-strip .face-crop-item img { .face-crop-strip .face-crop-item img {
border: 1px solid #d4d4d8; border: 1px solid var(--hair);
border-radius: 6px; border-radius: 6px;
} }
@ -2801,19 +2801,19 @@ tbody tr.selected-row,
gap: 8px 16px; gap: 8px 16px;
align-items: center; align-items: center;
padding: 10px 12px; padding: 10px 12px;
border: 1px solid #d4d4d8; border: 1px solid var(--hair);
border-radius: 8px; border-radius: 8px;
margin-bottom: 12px; margin-bottom: 12px;
} }
.evidence-new-chip { .evidence-new-chip {
border: 1px solid #1d4ed8; border: 1px solid var(--teal);
border-radius: 999px; border-radius: 999px;
padding: 0 8px; padding: 0 8px;
font-size: 12px; font-size: 12px;
color: #1d4ed8; color: var(--teal);
} }
.evidence-row-new { .evidence-row-new {
box-shadow: inset 0 0 0 2px #1d4ed8; box-shadow: inset 0 0 0 2px var(--teal);
} }