③ 파일명 stem 충돌: 다른 폴더의 같은 이름 파일이 한 건으로 합쳐져 둘째가 조용히 미심사였다. id 는 재유도하지 않고(기존 판정 보존) id_collisions() 로 탐지해 bootstrap.intakeConflicts 와 파일당 1회 감사로 남기고, 콘솔 투입 상태줄에 경고 목록을 띄운다. ④ 행 버튼 '미사용' 이 false_positive 로 가 대표 근거 하나를 고르면 나머지 점수가 지워졌다 → '사용'/'무관', 사용 재클릭은 pending(표시 해제, 점수 유지). ⑤ 분모 0 비율은 None → '측정 불가'. 외부 검색이 꺼진 케이스는 점수 0 유지, 한 줄 요약에 '외부 검색 꺼짐'. 부채: operator-review-workflow 13px 넘침 해소(겹치지 않는 레인 병합) → 다이어그램 5종 첫 REBUILD OK, v6 이전 카드 문구 교정. tests/conftest.py 로 로컬 Ollama 격리(가동 중에도 통과). 루트 VERSION 0.1.0 단일 출처, /health 와 기동 로그에 버전·스코어러 버전. verify.ps1 (Ollama 가동 중, override 없음): ruff clean, 680 passed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BStHFrjQj2VypecSG9ex1V
331 lines
7.7 KiB
JavaScript
331 lines
7.7 KiB
JavaScript
// Part 14 of 15 of the operator console app script.
|
|
//
|
|
// Knowledge/correction/provider/evidence-status admin actions.
|
|
//
|
|
// Load order is significant: these are classic (non-module) scripts sharing one
|
|
// global scope, and index.html must keep them in the order listed above/below.
|
|
// Splitting is a pure line-range extraction of the former single app.js.
|
|
|
|
function addKnowledgeEntryFromCase() {
|
|
|
|
// The control is labelled "주의 후보 보기" (view watchlist candidates), so show
|
|
// the registered list filtered to watchlist — it previously opened the blank
|
|
// manual-registration form, which is a different action entirely.
|
|
|
|
switchView("knowledge");
|
|
|
|
switchKnowledgeTab("registered");
|
|
|
|
knowledgeFilters.status = "watchlist";
|
|
|
|
const statusFilter = document.getElementById("knowledge-status-filter");
|
|
|
|
if (statusFilter) statusFilter.value = "watchlist";
|
|
|
|
renderAll();
|
|
|
|
}
|
|
|
|
|
|
|
|
async function deactivateCorrectionEntry(correctionId) {
|
|
const correction = corrections.find((item) => item.id === correctionId);
|
|
if (!correction) return;
|
|
|
|
// Deactivate the derived knowledge entries through the real backend endpoint
|
|
// so the change is persisted and audited server-side. The previous version
|
|
// only mutated local state and appended a fake audit event, so the action was
|
|
// silently discarded on the next refreshFromApi().
|
|
// Only entries that are still active need deactivating. Skipping already-
|
|
// inactive ones avoids the server's "이미 비활성 상태입니다" 400, which used to
|
|
// abort the whole loop on the first inactive entry.
|
|
const entryIds = (correction.derivedEntries || [])
|
|
.filter((entry) => entry && entry.id && entry.active)
|
|
.map((entry) => entry.id);
|
|
if (!entryIds.length) {
|
|
showApiError("이 보정 항목에는 비활성화할 활성 파생 지식 항목이 없습니다.");
|
|
return;
|
|
}
|
|
|
|
const failures = [];
|
|
try {
|
|
for (const entryId of entryIds) {
|
|
try {
|
|
await apiJson(`/api/knowledge/${encodeURIComponent(entryId)}/deactivate`, {
|
|
method: "POST",
|
|
body: JSON.stringify({ reason: "보정 파생 항목 일괄 비활성화" }),
|
|
});
|
|
} catch (_perEntryError) {
|
|
// One entry failing must not strand the rest.
|
|
failures.push(entryId);
|
|
}
|
|
}
|
|
} finally {
|
|
// Always refresh so the UI reflects whatever was actually persisted, even on
|
|
// a partial failure. Guard it so a refresh error isn't an unhandled rejection.
|
|
try {
|
|
await refreshFromApi();
|
|
} catch (_refreshError) {
|
|
/* keep the per-entry failure summary below */
|
|
}
|
|
}
|
|
if (failures.length) {
|
|
showApiError(`일부 파생 항목 비활성화에 실패했습니다 (${failures.length}건).`);
|
|
}
|
|
}
|
|
|
|
|
|
|
|
async function toggleProvider(providerId) {
|
|
|
|
const provider = providers.find((item) => item.id === providerId);
|
|
|
|
if (!provider) return;
|
|
|
|
try {
|
|
|
|
await apiJson(`/api/providers/${encodeURIComponent(providerId)}`, {
|
|
|
|
method: "PATCH",
|
|
|
|
body: JSON.stringify({ enabled: !provider.enabled }),
|
|
|
|
});
|
|
|
|
await refreshFromApi();
|
|
|
|
} catch (errorValue) {
|
|
|
|
showApiError(errorValue.message);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
async function setEvidenceStatus(evidenceId, status, note = "") {
|
|
|
|
const submission = getSelectedCase();
|
|
|
|
if (!submission || !evidenceId) return;
|
|
|
|
try {
|
|
|
|
await apiJson(`/api/evidence/${encodeURIComponent(evidenceId)}/status`, {
|
|
|
|
method: "POST",
|
|
|
|
body: JSON.stringify({ submission_id: submission.id, status: evidenceStatusPayload(status), note }),
|
|
|
|
});
|
|
|
|
await refreshFromApi();
|
|
|
|
} catch (errorValue) {
|
|
|
|
showApiError(errorValue.message);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
function markEvidenceIrrelevant(evidenceId) {
|
|
|
|
const submission = getSelectedCase();
|
|
|
|
if (!submission) return;
|
|
|
|
const fallback = submission.evidence.find((item) => item.contributed);
|
|
|
|
const targetId = evidenceId || fallback?.id;
|
|
|
|
void setEvidenceStatus(targetId, "irrelevant", "운영자 무관 처리");
|
|
|
|
}
|
|
|
|
|
|
|
|
async function saveKnowledgeEntryEdit(form) {
|
|
const entryId = form.dataset.knowledgeEditForm;
|
|
const body = {
|
|
aliases: form.elements.aliases.value.split(",").map((item) => item.trim()).filter(Boolean),
|
|
keywords: form.elements.keywords.value.split(",").map((item) => item.trim()).filter(Boolean),
|
|
memo: form.elements.memo.value.trim(),
|
|
};
|
|
try {
|
|
const payload = await apiJson(`/api/knowledge/${encodeURIComponent(entryId)}`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify(body),
|
|
});
|
|
editingKnowledgeEntryId = "";
|
|
applyBootstrap(payload);
|
|
renderAll();
|
|
} catch (errorValue) {
|
|
showApiError(errorValue.message);
|
|
}
|
|
}
|
|
|
|
|
|
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: reason.trim() }),
|
|
});
|
|
applyBootstrap(payload);
|
|
renderAll();
|
|
} catch (errorValue) {
|
|
showApiError(errorValue.message);
|
|
}
|
|
}
|
|
|
|
|
|
async function reactivateKnowledgeEntry(entryId) {
|
|
const reason = window.prompt("재활성 사유 메모(필수)") || "";
|
|
if (!reason.trim()) {
|
|
showApiError("재활성에는 사유 메모가 필요합니다.");
|
|
return;
|
|
}
|
|
try {
|
|
const payload = await apiJson(`/api/knowledge/${encodeURIComponent(entryId)}/reactivate`, {
|
|
method: "POST",
|
|
body: JSON.stringify({ reason: reason.trim() }),
|
|
});
|
|
applyBootstrap(payload);
|
|
renderAll();
|
|
} catch (errorValue) {
|
|
showApiError(errorValue.message);
|
|
}
|
|
}
|
|
|
|
|
|
|
|
async function promoteWatchlistEntry(entryId) {
|
|
|
|
try {
|
|
|
|
const payload = await apiJson(`/api/knowledge/${encodeURIComponent(entryId)}/promote-watchlist`, {
|
|
|
|
method: "POST",
|
|
|
|
body: JSON.stringify({}),
|
|
|
|
});
|
|
|
|
applyBootstrap(payload);
|
|
|
|
renderAll();
|
|
|
|
} catch (errorValue) {
|
|
|
|
showApiError(errorValue.message);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
async function excludeWatchlistEntry(entryId) {
|
|
|
|
try {
|
|
|
|
const payload = await apiJson(`/api/knowledge/${encodeURIComponent(entryId)}/exclude-watchlist`, {
|
|
|
|
method: "POST",
|
|
|
|
body: JSON.stringify({ reason: "오탐 확인" }),
|
|
|
|
});
|
|
|
|
applyBootstrap(payload);
|
|
|
|
renderAll();
|
|
|
|
} catch (errorValue) {
|
|
|
|
showApiError(errorValue.message);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
async function emergencyDisableExternalProviders() {
|
|
|
|
try {
|
|
|
|
await apiJson("/api/providers/emergency-disable", {
|
|
|
|
method: "POST",
|
|
|
|
body: JSON.stringify({}),
|
|
|
|
});
|
|
|
|
await refreshFromApi();
|
|
|
|
} catch (errorValue) {
|
|
|
|
showApiError(errorValue.message);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// 증거 그룹 전체를 한 번에 표시한다.
|
|
//
|
|
// 단건 API 만 있던 동안 운영자 되먹임은 순증 노동이었다 — 실 DB 제출물 하나를
|
|
// 100 에서 35 로 내리려면 google 증거 51행을 개별로 눌러야 했다. 확인 대화를
|
|
// 두지 않는 이유는 이 동작이 되돌릴 수 있고(행마다 다시 표시 가능),
|
|
// 여기서 줄이려는 것이 바로 그 클릭이기 때문이다. 되돌릴 수 없는 느낌이 드는
|
|
// 것은 판정이고, 확인 대화는 거기에만 있다.
|
|
async function setEvidenceGroupStatus(groupId, status, note = "") {
|
|
|
|
const submission = getSelectedCase();
|
|
|
|
if (!submission || !groupId) return;
|
|
|
|
const items = groupedEvidence(submission.evidence)[groupId] || [];
|
|
|
|
const evidenceIds = items.map((item) => item.id).filter(Boolean);
|
|
|
|
if (!evidenceIds.length) return;
|
|
|
|
try {
|
|
|
|
await apiJson("/api/evidence/status-batch", {
|
|
|
|
method: "POST",
|
|
|
|
body: JSON.stringify({
|
|
|
|
submission_id: submission.id,
|
|
|
|
evidence_ids: evidenceIds,
|
|
|
|
status: evidenceStatusPayload(status),
|
|
|
|
note,
|
|
|
|
}),
|
|
|
|
});
|
|
|
|
await refreshFromApi();
|
|
|
|
} catch (errorValue) {
|
|
|
|
showApiError(errorValue.message);
|
|
|
|
}
|
|
|
|
}
|