diff --git a/tests/operator_gui/test_static_workbench.py b/tests/operator_gui/test_static_workbench.py index e297988..d271744 100644 --- a/tests/operator_gui/test_static_workbench.py +++ b/tests/operator_gui/test_static_workbench.py @@ -811,3 +811,23 @@ def test_suggested_queries_support_one_click_and_batch_execution(): # 기존 "입력칸 채우기" 버튼은 수정용으로 유지된다. assert "data-suggested-query" in script assert ".suggested-query-item" in styles + + +def test_registered_knowledge_panel_supports_search_filter_edit_and_lifecycle(): + html = _read(INDEX) + script = _read(APP_JS) + + registered_panel = html.split('data-knowledge-panel="registered"', 1)[1].split('data-knowledge-panel="manual"', 1)[0] + assert 'id="knowledge-search"' in registered_panel + assert 'id="knowledge-type-filter"' in registered_panel + assert 'id="knowledge-status-filter"' in registered_panel + assert 'id="knowledge-active-filter"' in registered_panel + + assert "filteredKnowledgeEntries" in script + assert "data-edit-kb" in script + assert "data-knowledge-edit-form" in script + assert "saveKnowledgeEntryEdit" in script + assert "data-deactivate-kb" in script + assert "data-reactivate-kb" in script + assert "재활성 사유 메모" in script + assert "data-toggle-kb" not in script diff --git a/web/operator-gui/app.js b/web/operator-gui/app.js index be5c0fc..1992eb8 100644 --- a/web/operator-gui/app.js +++ b/web/operator-gui/app.js @@ -32,6 +32,9 @@ const searchCoverage = {}; let suggestedQueryRunSummary = null; // { caseId, message } — 현재 케이스의 마지막 추천 쿼리 실행 결과 +const knowledgeFilters = { query: "", type: "all", status: "all", active: "all" }; +let editingKnowledgeEntryId = ""; + const DEFAULT_COVERAGE_THRESHOLDS = Object.freeze({ coverageGoodRate: 70, @@ -1727,57 +1730,104 @@ function renderEvidenceSearch() { } +function filteredKnowledgeEntries() { + const query = knowledgeFilters.query.trim().toLowerCase(); + return knowledgeEntries.filter((entry) => { + const typeOk = knowledgeFilters.type === "all" || entry.type === knowledgeFilters.type; + const statusOk = knowledgeFilters.status === "all" || (entry.entryStatus || "confirmed") === knowledgeFilters.status; + const activeOk = knowledgeFilters.active === "all" || (knowledgeFilters.active === "active") === Boolean(entry.active); + const haystack = [entry.name, ...(entry.aliases || []), ...(entry.keywords || [])].join(" ").toLowerCase(); + const queryOk = !query || haystack.includes(query); + return typeOk && statusOk && activeOk && queryOk; + }); +} + + function renderKnowledgeBase() { - document.getElementById("knowledge-list").innerHTML = knowledgeEntries - .map((entry) => { - const aliases = entry.aliases || []; - const keywords = entry.keywords || []; - const entryStatus = entry.entryStatus || "confirmed"; - const statusMeta = - entryStatus === "watchlist" - ? `원 케이스: ${escapeHtml(entry.sourceSubmissionId || "-")}미래 검출 ${escapeHtml(entry.contributionCount || 0)}건` - : ""; - const memoInline = entry.memo && entry.memo.startsWith("외부 후보 수집에서 반영:") ? `${escapeHtml(entry.memo)}` : ""; - const memoBlock = memoInline ? "" : `

${escapeHtml(entry.memo)}

`; - const watchlistActions = - entryStatus === "watchlist" - ? ` - - - ` - : ``; - return ` -
- ${ - entry.imageAsset - ? `${escapeHtml(entry.name)} 참조 이미지` - : `` - } -
-
- ${escapeHtml(entry.name)} -
-
-
- ${escapeHtml(formatProvenance(entry.provenance))} - ${escapeHtml(formatKnowledgeStatus(entryStatus))} + const entries = filteredKnowledgeEntries(); + document.getElementById("knowledge-list").innerHTML = entries.length + ? entries + .map((entry) => { + const aliases = entry.aliases || []; + const keywords = entry.keywords || []; + const entryStatus = entry.entryStatus || "confirmed"; + const statusMeta = + entryStatus === "watchlist" + ? `원 케이스: ${escapeHtml(entry.sourceSubmissionId || "-")}미래 검출 ${escapeHtml(entry.contributionCount || 0)}건` + : ""; + const memoInline = entry.memo && entry.memo.startsWith("외부 후보 수집에서 반영:") ? `${escapeHtml(entry.memo)}` : ""; + const memoBlock = memoInline ? "" : `

${escapeHtml(entry.memo)}

`; + const lifecycleActions = + entryStatus === "watchlist" + ? ` + + + ` + : entryStatus === "excluded" + ? "" + : entry.active + ? `` + : ``; + const editForm = + editingKnowledgeEntryId === entry.id + ? ` +
+ + + +
+ + +
+
+ ` + : ""; + return ` +
+ ${ + entry.imageAsset + ? `${escapeHtml(entry.name)} 참조 이미지` + : `` + } +
+
+ ${escapeHtml(entry.name)} +
+
+
+ ${escapeHtml(formatProvenance(entry.provenance))} + ${escapeHtml(formatKnowledgeStatus(entryStatus))} +
+
+ ${escapeHtml(formatKnowledgeType(entry.type))} + 별칭: ${escapeHtml(aliases.join(", ") || "-")} + 키워드: ${escapeHtml(keywords.join(", ") || "-")} + 샘플 지문 ${(entry.sampleFingerprints || []).length} + ${statusMeta} + ${memoInline} +
+
+ ${memoBlock} + ${editForm}
-
- ${escapeHtml(formatKnowledgeType(entry.type))} - 별칭: ${escapeHtml(aliases.join(", ") || "-")} - 키워드: ${escapeHtml(keywords.join(", ") || "-")} - 샘플 지문 ${(entry.sampleFingerprints || []).length} - ${statusMeta} - ${memoInline} +
+ + ${lifecycleActions}
-
- ${memoBlock} -
-
${watchlistActions}
-
- `; - }) - .join(""); + + `; + }) + .join("") + : `
조건에 맞는 기준 항목이 없습니다.
`; renderCollectionCandidates(); } @@ -3161,30 +3211,57 @@ function markEvidenceIrrelevant(evidenceId) { -function toggleKnowledgeEntry(entryId) { +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); + } +} - const entry = knowledgeEntries.find((item) => item.id === entryId); - if (!entry) return; +async function deactivateKnowledgeEntry(entryId) { + try { + const payload = await apiJson(`/api/knowledge/${encodeURIComponent(entryId)}/deactivate`, { + method: "POST", + body: JSON.stringify({ reason: "" }), + }); + applyBootstrap(payload); + renderAll(); + } catch (errorValue) { + showApiError(errorValue.message); + } +} - entry.active = !entry.active; - - auditEvents.unshift({ - - timestamp: "방금", - - actor: "rights.ops", - - event: entry.active ? "Knowledge entry reactivated" : "Knowledge entry deactivated", - - object: entry.name, - - change: entry.active ? "active with memo" : "inactive", - - }); - - renderAll(); +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); + } } @@ -3405,6 +3482,27 @@ function bindEvents() { document.getElementById("knowledge-form").addEventListener("submit", addKnowledgeEntry); + document.getElementById("knowledge-search").addEventListener("input", (event) => { + knowledgeFilters.query = event.target.value; + renderKnowledgeBase(); + }); + [ + ["knowledge-type-filter", "type"], + ["knowledge-status-filter", "status"], + ["knowledge-active-filter", "active"], + ].forEach(([elementId, key]) => { + document.getElementById(elementId).addEventListener("change", (event) => { + knowledgeFilters[key] = event.target.value; + renderKnowledgeBase(); + }); + }); + document.addEventListener("submit", (event) => { + const editFormElement = event.target.closest("[data-knowledge-edit-form]"); + if (!editFormElement) return; + event.preventDefault(); + void saveKnowledgeEntryEdit(editFormElement); + }); + document.getElementById("knowledge-image").addEventListener("change", updateKnowledgeImageName); document.getElementById("reload-submissions").addEventListener("click", reloadSubmissions); @@ -3417,11 +3515,10 @@ function bindEvents() { document.getElementById("mark-irrelevant").addEventListener("click", () => markEvidenceIrrelevant()); document.getElementById("disable-derived").addEventListener("click", () => { - - const automaticEntry = knowledgeEntries.find((entry) => entry.provenance === "automatic" && entry.active); - - if (automaticEntry) toggleKnowledgeEntry(automaticEntry.id); - + const automaticEntry = knowledgeEntries.find( + (entry) => entry.provenance === "automatic" && entry.active && (entry.entryStatus || "confirmed") === "confirmed", + ); + if (automaticEntry) void deactivateKnowledgeEntry(automaticEntry.id); }); document.getElementById("open-correction").addEventListener("click", () => { @@ -3490,14 +3587,30 @@ function bindEvents() { - const knowledgeButton = event.target.closest("[data-toggle-kb]"); - - if (knowledgeButton) { - - toggleKnowledgeEntry(knowledgeButton.dataset.toggleKb); - + const editKnowledgeButton = event.target.closest("[data-edit-kb]"); + if (editKnowledgeButton) { + editingKnowledgeEntryId = editingKnowledgeEntryId === editKnowledgeButton.dataset.editKb ? "" : editKnowledgeButton.dataset.editKb; + renderKnowledgeBase(); return; + } + const cancelEditKnowledgeButton = event.target.closest("[data-cancel-edit-kb]"); + if (cancelEditKnowledgeButton) { + editingKnowledgeEntryId = ""; + renderKnowledgeBase(); + return; + } + + const deactivateKnowledgeButton = event.target.closest("[data-deactivate-kb]"); + if (deactivateKnowledgeButton) { + void deactivateKnowledgeEntry(deactivateKnowledgeButton.dataset.deactivateKb); + return; + } + + const reactivateKnowledgeButton = event.target.closest("[data-reactivate-kb]"); + if (reactivateKnowledgeButton) { + void reactivateKnowledgeEntry(reactivateKnowledgeButton.dataset.reactivateKb); + return; } diff --git a/web/operator-gui/index.html b/web/operator-gui/index.html index 17eb6fa..01d8290 100644 --- a/web/operator-gui/index.html +++ b/web/operator-gui/index.html @@ -332,6 +332,40 @@

등록된 기준

+
+ + + + +
diff --git a/web/operator-gui/styles.css b/web/operator-gui/styles.css index 11f43a6..61381c3 100644 --- a/web/operator-gui/styles.css +++ b/web/operator-gui/styles.css @@ -2768,3 +2768,24 @@ tbody tr.selected-row, .suggested-query-actions { margin-top: 8px; } + +.knowledge-filter-bar { + display: flex; + flex-wrap: wrap; + gap: 8px 12px; + margin-bottom: 12px; +} + +.knowledge-edit-form { + display: grid; + gap: 8px; + margin-top: 8px; + padding: 10px; + border: 1px solid #d4d4d8; + border-radius: 8px; +} + +.knowledge-edit-actions { + display: flex; + gap: 8px; +}