diff --git a/src/rights_filter/server/sqlite_store.py b/src/rights_filter/server/sqlite_store.py index 63bbbe8..e7a5ee4 100644 --- a/src/rights_filter/server/sqlite_store.py +++ b/src/rights_filter/server/sqlite_store.py @@ -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}", diff --git a/tests/operator_gui/test_static_workbench.py b/tests/operator_gui/test_static_workbench.py index 58556b7..a996c47 100644 --- a/tests/operator_gui/test_static_workbench.py +++ b/tests/operator_gui/test_static_workbench.py @@ -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(): diff --git a/tests/rights_filter/server/test_http_app.py b/tests/rights_filter/server/test_http_app.py index 29ebfa4..b2f80dd 100644 --- a/tests/rights_filter/server/test_http_app.py +++ b/tests/rights_filter/server/test_http_app.py @@ -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() diff --git a/web/operator-gui/app.js b/web/operator-gui/app.js index 175081c..c835418 100644 --- a/web/operator-gui/app.js +++ b/web/operator-gui/app.js @@ -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 ``; + const reason = unavailable ? String(runtime.lastFailure || runtime.compliance || "외부 검색 tool 미연결") : ""; + return ``; }) .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) => ` -
+ 얼굴 영역 ${escapeHtml(String(crop.index))} · 동일 인물 판정은 아닙니다 + `, ) .join(""); @@ -1471,7 +1486,7 @@ function renderRerunDiffSummary(diff) {