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)
|
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"):
|
||||||
stale.unlink()
|
try:
|
||||||
|
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
|
||||||
crop_dir.mkdir(parents=True, exist_ok=True)
|
try:
|
||||||
crop_path = crop_dir / f"crop-{index}.jpg"
|
crop_dir.mkdir(parents=True, exist_ok=True)
|
||||||
crop_path.write_bytes(crops[0].content)
|
crop_path = crop_dir / f"crop-{index}.jpg"
|
||||||
|
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}",
|
||||||
|
|
|
||||||
|
|
@ -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():
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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,36 +965,50 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
const results = [];
|
isSuggestedQueryRunActive = true;
|
||||||
for (const [index, query] of queries.entries()) {
|
try {
|
||||||
if (statusTarget) statusTarget.textContent = `"${query}" 실행 중… (${index + 1}/${queries.length})`;
|
const results = [];
|
||||||
try {
|
for (const [index, query] of queries.entries()) {
|
||||||
await apiJson("/api/search/manual", {
|
if (statusTarget) statusTarget.textContent = `"${query}" 실행 중… (${index + 1}/${queries.length})`;
|
||||||
method: "POST",
|
try {
|
||||||
body: JSON.stringify({ submission_id: submission.id, provider: "naver", query }),
|
const review = await apiJson("/api/search/manual", {
|
||||||
});
|
method: "POST",
|
||||||
results.push(`"${query}" 완료`);
|
body: JSON.stringify({ submission_id: submission.id, provider: "naver", query }),
|
||||||
} catch (errorValue) {
|
});
|
||||||
results.push(`"${query}" 실패: ${errorValue.message}`);
|
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 = {
|
suggestedQueryRunSummary = {
|
||||||
caseId: submission.id,
|
caseId: submission.id,
|
||||||
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();
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue