fix: resolve multi-agent review findings for workbench efficiency round
This commit is contained in:
parent
4d98582ed3
commit
37294dc140
6 changed files with 115 additions and 49 deletions
|
|
@ -1262,15 +1262,21 @@ class CopyrighterStore:
|
|||
for item in self._evidence_for_submission(submission_id)
|
||||
}
|
||||
rerun_marker_prefix = f"ev-{submission_id}-rerun-"
|
||||
# LLM 요약은 재분석마다 삭제 후 재생성되어 id가 항상 바뀌므로(요약의
|
||||
# source_evidence_ids에 타임스탬프 마커 id가 섞임) diff에 포함하면
|
||||
# 변경이 없어도 매번 신규+제거로 잡힌다 — diff 대상에서 제외한다.
|
||||
added_ids = [
|
||||
evidence_id
|
||||
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 = [
|
||||
evidence_before[evidence_id]
|
||||
for evidence_id in evidence_before
|
||||
if evidence_id not in evidence_after
|
||||
and str(evidence_before[evidence_id].get("source", "")) != "llm"
|
||||
]
|
||||
refreshed = self._get("submissions", submission_id)
|
||||
refreshed["lastRerunDiff"] = {
|
||||
|
|
@ -1280,7 +1286,7 @@ class CopyrighterStore:
|
|||
"addedEvidenceIds": added_ids,
|
||||
"removedEvidenceIds": [str(item.get("id", "")) for item in removed_items],
|
||||
"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
|
||||
],
|
||||
}
|
||||
|
|
@ -1960,16 +1966,22 @@ class CopyrighterStore:
|
|||
crop_dir = self.face_crop_image_dir / submission_id
|
||||
if crop_dir.exists():
|
||||
for stale in crop_dir.glob("crop-*.jpg"):
|
||||
stale.unlink()
|
||||
try:
|
||||
stale.unlink()
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
face_crops: list[dict[str, Any]] = []
|
||||
for index, box in enumerate(signal.face_boxes[:3], start=1):
|
||||
crops = build_face_crop_derivatives(original, [box])
|
||||
if not crops:
|
||||
continue
|
||||
crop_dir.mkdir(parents=True, exist_ok=True)
|
||||
crop_path = crop_dir / f"crop-{index}.jpg"
|
||||
crop_path.write_bytes(crops[0].content)
|
||||
try:
|
||||
crop_dir.mkdir(parents=True, exist_ok=True)
|
||||
crop_path = crop_dir / f"crop-{index}.jpg"
|
||||
crop_path.write_bytes(crops[0].content)
|
||||
except OSError:
|
||||
continue
|
||||
face_crops.append(
|
||||
{
|
||||
"index": index,
|
||||
|
|
@ -2094,20 +2106,29 @@ class CopyrighterStore:
|
|||
stored_crops = submission.get("faceCrops")
|
||||
if stored_crops is None:
|
||||
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:
|
||||
face_boxes = tuple(
|
||||
tuple(int(value) for value in item.get("box", []))
|
||||
# 저장된 faceCrops의 index를 그대로 사용해 증거의 crop_index가
|
||||
# 워크벤치 썸네일 번호와 항상 일치하게 한다(중간 박스 크롭 실패 시
|
||||
# 재열거하면 번호가 어긋난다).
|
||||
indexed_boxes = [
|
||||
(int(item.get("index", 0)), tuple(int(value) for value in item.get("box", [])))
|
||||
for item in stored_crops
|
||||
if len(item.get("box", [])) == 4
|
||||
)
|
||||
if not face_boxes:
|
||||
]
|
||||
if not indexed_boxes:
|
||||
return [], 0
|
||||
|
||||
crops = build_face_crop_derivatives(original_image, face_boxes)
|
||||
evidence: list[Evidence] = []
|
||||
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
|
||||
crop_evidence = self.provider_runtime.google_adapter.detect(
|
||||
f"{submission_id}:face-crop-{crop_index}",
|
||||
|
|
|
|||
|
|
@ -618,6 +618,8 @@ def test_google_custom_search_is_exposed_as_operator_text_query_choice():
|
|||
assert "reverse search" not in html.lower()
|
||||
assert "operatorSearchProviders" in script
|
||||
assert "visibleProviderControls" in script
|
||||
# 검색 선택지뿐 아니라 제공자 카드/상태 칩에서도 google_search가 보여야 한다.
|
||||
assert "retiredProviderIds = new Set()" in script
|
||||
|
||||
|
||||
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 "faceCrops" 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():
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
from urllib.error import HTTPError
|
||||
|
||||
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)
|
||||
_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_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(
|
||||
base + f"/api/knowledge/{entry_id}",
|
||||
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_entry = next(item for item in deactivated["knowledgeEntries"] if item["id"] == entry_id)
|
||||
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": ""})
|
||||
assert reactivate_error.value.code == 400
|
||||
|
||||
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)
|
||||
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={})
|
||||
assert empty_patch_error.value.code == 400
|
||||
|
||||
events = _json(base + "/api/audit-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:
|
||||
review = _json(base + "/api/submissions/SUB-API1/review")
|
||||
assert review.get("faceCrops", []) == []
|
||||
# 키 자체는 항상 존재해야 한다 — 키 부재는 "아직 동기화 안 됨"을 뜻한다.
|
||||
assert review["faceCrops"] == []
|
||||
finally:
|
||||
server.shutdown()
|
||||
|
||||
|
|
@ -1271,5 +1285,13 @@ def test_rerun_enrichment_records_evidence_diff(tmp_path: Path):
|
|||
assert diff["at"]
|
||||
marker_prefix = "ev-SUB-API1-rerun-"
|
||||
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:
|
||||
server.shutdown()
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
knowledgeTypeLabels,
|
||||
} = window.OperatorLabels;
|
||||
|
||||
const retiredProviderIds = new Set(["google_search"]);
|
||||
const retiredProviderIds = new Set();
|
||||
const operatorSearchProviders = [
|
||||
{ id: "naver", label: providerLabels.naver },
|
||||
{ id: "google_search", label: providerLabels.google_search },
|
||||
|
|
@ -629,7 +629,8 @@ function renderOperatorSearchProviderOptions() {
|
|||
const runtime = providers.find((item) => item.id === provider.id);
|
||||
const unavailable = Boolean(runtime) && !runtime.enabled;
|
||||
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("");
|
||||
|
||||
|
|
@ -964,36 +965,50 @@ function applySuggestedQuery(button) {
|
|||
}
|
||||
|
||||
|
||||
let isSuggestedQueryRunActive = false;
|
||||
|
||||
async function executeSuggestedQueries(queries) {
|
||||
if (isSuggestedQueryRunActive) return;
|
||||
const submission = getSelectedCase();
|
||||
if (!submission || !queries.length) return;
|
||||
|
||||
const statusTarget = document.getElementById("suggested-query-run-status");
|
||||
const naver = providers.find((provider) => provider.id === "naver");
|
||||
if (!naver || !naver.enabled) {
|
||||
if (statusTarget) statusTarget.textContent = "네이버 외부 검색 tool이 비활성입니다.";
|
||||
if (statusTarget) statusTarget.textContent = `${providerLabels.naver} 외부 검색 tool이 비활성입니다.`;
|
||||
return;
|
||||
}
|
||||
|
||||
const results = [];
|
||||
for (const [index, query] of queries.entries()) {
|
||||
if (statusTarget) statusTarget.textContent = `"${query}" 실행 중… (${index + 1}/${queries.length})`;
|
||||
try {
|
||||
await apiJson("/api/search/manual", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ submission_id: submission.id, provider: "naver", query }),
|
||||
});
|
||||
results.push(`"${query}" 완료`);
|
||||
} catch (errorValue) {
|
||||
results.push(`"${query}" 실패: ${errorValue.message}`);
|
||||
isSuggestedQueryRunActive = true;
|
||||
try {
|
||||
const results = [];
|
||||
for (const [index, query] of queries.entries()) {
|
||||
if (statusTarget) statusTarget.textContent = `"${query}" 실행 중… (${index + 1}/${queries.length})`;
|
||||
try {
|
||||
const review = await apiJson("/api/search/manual", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ submission_id: submission.id, provider: "naver", 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) {
|
||||
results.push(`"${query}" 실패: ${errorValue.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suggestedQueryRunSummary = {
|
||||
caseId: submission.id,
|
||||
message: `추천 쿼리 실행 — ${results.join(" · ")}`,
|
||||
};
|
||||
await refreshFromApi();
|
||||
suggestedQueryRunSummary = {
|
||||
caseId: submission.id,
|
||||
message: `추천 쿼리 실행 — ${results.join(" · ")}`,
|
||||
};
|
||||
await refreshFromApi();
|
||||
} finally {
|
||||
isSuggestedQueryRunActive = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -1297,10 +1312,10 @@ function renderCaseReview() {
|
|||
document.getElementById("face-crop-strip").innerHTML = (submission.faceCrops || [])
|
||||
.map(
|
||||
(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))} 크롭">
|
||||
<span>얼굴 영역 ${escapeHtml(String(crop.index))} · 동일 인물 판정 아님</span>
|
||||
</div>
|
||||
<span>얼굴 영역 ${escapeHtml(String(crop.index))} · 동일 인물 판정은 아닙니다</span>
|
||||
</a>
|
||||
`,
|
||||
)
|
||||
.join("");
|
||||
|
|
@ -1471,7 +1486,7 @@ function renderRerunDiffSummary(diff) {
|
|||
<summary>이번 재분석에서 제거됨 · ${removedSummaries.length}개</summary>
|
||||
<ul>
|
||||
${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("")}
|
||||
</ul>
|
||||
</details>
|
||||
|
|
@ -3285,10 +3300,12 @@ async function saveKnowledgeEntryEdit(form) {
|
|||
|
||||
|
||||
async function deactivateKnowledgeEntry(entryId) {
|
||||
const reason = window.prompt("비활성 사유 메모(선택)");
|
||||
if (reason === null) return;
|
||||
try {
|
||||
const payload = await apiJson(`/api/knowledge/${encodeURIComponent(entryId)}/deactivate`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ reason: "" }),
|
||||
body: JSON.stringify({ reason: reason.trim() }),
|
||||
});
|
||||
applyBootstrap(payload);
|
||||
renderAll();
|
||||
|
|
|
|||
|
|
@ -343,11 +343,12 @@
|
|||
<span>유형</span>
|
||||
<select id="knowledge-type-filter">
|
||||
<option value="all">전체</option>
|
||||
<option value="public_figure">연예인/유명인</option>
|
||||
<option value="celebrity">연예인/유명인</option>
|
||||
<option value="work">작품</option>
|
||||
<option value="character">캐릭터</option>
|
||||
<option value="game">게임</option>
|
||||
<option value="rejected_image">반려 이미지</option>
|
||||
<option value="other">기타</option>
|
||||
</select>
|
||||
</label>
|
||||
<label for="knowledge-status-filter">
|
||||
|
|
|
|||
|
|
@ -2781,7 +2781,7 @@ tbody tr.selected-row,
|
|||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
padding: 10px;
|
||||
border: 1px solid #d4d4d8;
|
||||
border: 1px solid var(--hair);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
|
|
@ -2791,7 +2791,7 @@ tbody tr.selected-row,
|
|||
}
|
||||
|
||||
.face-crop-strip .face-crop-item img {
|
||||
border: 1px solid #d4d4d8;
|
||||
border: 1px solid var(--hair);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
|
|
@ -2801,19 +2801,19 @@ tbody tr.selected-row,
|
|||
gap: 8px 16px;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #d4d4d8;
|
||||
border: 1px solid var(--hair);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.evidence-new-chip {
|
||||
border: 1px solid #1d4ed8;
|
||||
border: 1px solid var(--teal);
|
||||
border-radius: 999px;
|
||||
padding: 0 8px;
|
||||
font-size: 12px;
|
||||
color: #1d4ed8;
|
||||
color: var(--teal);
|
||||
}
|
||||
|
||||
.evidence-row-new {
|
||||
box-shadow: inset 0 0 0 2px #1d4ed8;
|
||||
box-shadow: inset 0 0 0 2px var(--teal);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue