feat: knowledge base search/filter, inline edit, and server-backed lifecycle actions

This commit is contained in:
유창욱 2026-06-12 17:51:36 +09:00
parent cd9d69dddb
commit 646b871b76
4 changed files with 267 additions and 79 deletions

View file

@ -811,3 +811,23 @@ def test_suggested_queries_support_one_click_and_batch_execution():
# 기존 "입력칸 채우기" 버튼은 수정용으로 유지된다. # 기존 "입력칸 채우기" 버튼은 수정용으로 유지된다.
assert "data-suggested-query" in script assert "data-suggested-query" in script
assert ".suggested-query-item" in styles 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

View file

@ -32,6 +32,9 @@ const searchCoverage = {};
let suggestedQueryRunSummary = null; // { caseId, message } — 현재 케이스의 마지막 추천 쿼리 실행 결과 let suggestedQueryRunSummary = null; // { caseId, message } — 현재 케이스의 마지막 추천 쿼리 실행 결과
const knowledgeFilters = { query: "", type: "all", status: "all", active: "all" };
let editingKnowledgeEntryId = "";
const DEFAULT_COVERAGE_THRESHOLDS = Object.freeze({ const DEFAULT_COVERAGE_THRESHOLDS = Object.freeze({
coverageGoodRate: 70, 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() { function renderKnowledgeBase() {
document.getElementById("knowledge-list").innerHTML = knowledgeEntries const entries = filteredKnowledgeEntries();
.map((entry) => { document.getElementById("knowledge-list").innerHTML = entries.length
const aliases = entry.aliases || []; ? entries
const keywords = entry.keywords || []; .map((entry) => {
const entryStatus = entry.entryStatus || "confirmed"; const aliases = entry.aliases || [];
const statusMeta = const keywords = entry.keywords || [];
entryStatus === "watchlist" const entryStatus = entry.entryStatus || "confirmed";
? `<span>원 케이스: ${escapeHtml(entry.sourceSubmissionId || "-")}</span><span>미래 검출 ${escapeHtml(entry.contributionCount || 0)}건</span>` const statusMeta =
: ""; entryStatus === "watchlist"
const memoInline = entry.memo && entry.memo.startsWith("외부 후보 수집에서 반영:") ? `<span>${escapeHtml(entry.memo)}</span>` : ""; ? `<span>원 케이스: ${escapeHtml(entry.sourceSubmissionId || "-")}</span><span>미래 검출 ${escapeHtml(entry.contributionCount || 0)}건</span>`
const memoBlock = memoInline ? "" : `<p class="muted small">${escapeHtml(entry.memo)}</p>`; : "";
const watchlistActions = const memoInline = entry.memo && entry.memo.startsWith("외부 후보 수집에서 반영:") ? `<span>${escapeHtml(entry.memo)}</span>` : "";
entryStatus === "watchlist" const memoBlock = memoInline ? "" : `<p class="muted small">${escapeHtml(entry.memo)}</p>`;
? ` const lifecycleActions =
<button class="row-action" type="button" data-promote-watchlist="${escapeHtml(entry.id)}">확정 DB 반영</button> entryStatus === "watchlist"
<button class="row-action danger" type="button" data-exclude-watchlist="${escapeHtml(entry.id)}">오탐 제외</button> ? `
` <button class="row-action" type="button" data-promote-watchlist="${escapeHtml(entry.id)}">확정 DB 반영</button>
: `<button class="row-action" type="button" data-toggle-kb="${escapeHtml(entry.id)}">${entry.active ? "비활성" : "활성"}</button>`; <button class="row-action danger" type="button" data-exclude-watchlist="${escapeHtml(entry.id)}">오탐 제외</button>
return ` `
<article class="knowledge-row ${entry.active ? "" : "inactive"} ${entryStatus === "watchlist" ? "watchlist" : ""}"> : entryStatus === "excluded"
${ ? ""
entry.imageAsset : entry.active
? `<img class="knowledge-thumb" src="${escapeHtml(entry.imageAsset)}" alt="${escapeHtml(entry.name)} 참조 이미지">` ? `<button class="row-action danger" type="button" data-deactivate-kb="${escapeHtml(entry.id)}">비활성</button>`
: `<div class="knowledge-thumb empty" aria-hidden="true"></div>` : `<button class="row-action" type="button" data-reactivate-kb="${escapeHtml(entry.id)}">재활성</button>`;
} const editForm =
<div class="knowledge-main"> editingKnowledgeEntryId === entry.id
<div class="row-title knowledge-title"> ? `
<span>${escapeHtml(entry.name)}</span> <form class="knowledge-edit-form" data-knowledge-edit-form="${escapeHtml(entry.id)}">
</div> <label>
<div class="knowledge-detail-line"> <span>별칭</span>
<div class="knowledge-chip-row"> <input name="aliases" type="text" value="${escapeHtml(aliases.join(", "))}">
<span class="provenance-chip ${escapeHtml(entry.provenance)}">${escapeHtml(formatProvenance(entry.provenance))}</span> </label>
<span class="watchlist-chip ${escapeHtml(entryStatus)}">${escapeHtml(formatKnowledgeStatus(entryStatus))}</span> <label>
<span>검색 키워드</span>
<input name="keywords" type="text" value="${escapeHtml(keywords.join(", "))}">
</label>
<label class="wide-field">
<span>정책 메모</span>
<textarea name="memo" rows="3">${escapeHtml(entry.memo || "")}</textarea>
</label>
<div class="knowledge-edit-actions">
<button class="primary-action" type="submit">저장</button>
<button class="secondary-action" type="button" data-cancel-edit-kb="${escapeHtml(entry.id)}">취소</button>
</div>
</form>
`
: "";
return `
<article class="knowledge-row ${entry.active ? "" : "inactive"} ${entryStatus === "watchlist" ? "watchlist" : ""}">
${
entry.imageAsset
? `<img class="knowledge-thumb" src="${escapeHtml(entry.imageAsset)}" alt="${escapeHtml(entry.name)} 참조 이미지">`
: `<div class="knowledge-thumb empty" aria-hidden="true"></div>`
}
<div class="knowledge-main">
<div class="row-title knowledge-title">
<span>${escapeHtml(entry.name)}</span>
</div>
<div class="knowledge-detail-line">
<div class="knowledge-chip-row">
<span class="provenance-chip ${escapeHtml(entry.provenance)}">${escapeHtml(formatProvenance(entry.provenance))}</span>
<span class="watchlist-chip ${escapeHtml(entryStatus)}">${escapeHtml(formatKnowledgeStatus(entryStatus))}</span>
</div>
<div class="row-meta knowledge-meta">
<span>${escapeHtml(formatKnowledgeType(entry.type))}</span>
<span>별칭: ${escapeHtml(aliases.join(", ") || "-")}</span>
<span>키워드: ${escapeHtml(keywords.join(", ") || "-")}</span>
<span>샘플 지문 ${(entry.sampleFingerprints || []).length}</span>
${statusMeta}
${memoInline}
</div>
</div>
${memoBlock}
${editForm}
</div> </div>
<div class="row-meta knowledge-meta"> <div class="knowledge-actions">
<span>${escapeHtml(formatKnowledgeType(entry.type))}</span> <button class="row-action" type="button" data-edit-kb="${escapeHtml(entry.id)}">편집</button>
<span>별칭: ${escapeHtml(aliases.join(", ") || "-")}</span> ${lifecycleActions}
<span>키워드: ${escapeHtml(keywords.join(", ") || "-")}</span>
<span>샘플 지문 ${(entry.sampleFingerprints || []).length}</span>
${statusMeta}
${memoInline}
</div> </div>
</div> </article>
${memoBlock} `;
</div> })
<div class="knowledge-actions">${watchlistActions}</div> .join("")
</article> : `<div class="empty-state">조건에 맞는 기준 항목이 없습니다.</div>`;
`;
})
.join("");
renderCollectionCandidates(); 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-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("knowledge-image").addEventListener("change", updateKnowledgeImageName);
document.getElementById("reload-submissions").addEventListener("click", reloadSubmissions); document.getElementById("reload-submissions").addEventListener("click", reloadSubmissions);
@ -3417,11 +3515,10 @@ function bindEvents() {
document.getElementById("mark-irrelevant").addEventListener("click", () => markEvidenceIrrelevant()); document.getElementById("mark-irrelevant").addEventListener("click", () => markEvidenceIrrelevant());
document.getElementById("disable-derived").addEventListener("click", () => { document.getElementById("disable-derived").addEventListener("click", () => {
const automaticEntry = knowledgeEntries.find(
const automaticEntry = knowledgeEntries.find((entry) => entry.provenance === "automatic" && entry.active); (entry) => entry.provenance === "automatic" && entry.active && (entry.entryStatus || "confirmed") === "confirmed",
);
if (automaticEntry) toggleKnowledgeEntry(automaticEntry.id); if (automaticEntry) void deactivateKnowledgeEntry(automaticEntry.id);
}); });
document.getElementById("open-correction").addEventListener("click", () => { document.getElementById("open-correction").addEventListener("click", () => {
@ -3490,14 +3587,30 @@ function bindEvents() {
const knowledgeButton = event.target.closest("[data-toggle-kb]"); const editKnowledgeButton = event.target.closest("[data-edit-kb]");
if (editKnowledgeButton) {
if (knowledgeButton) { editingKnowledgeEntryId = editingKnowledgeEntryId === editKnowledgeButton.dataset.editKb ? "" : editKnowledgeButton.dataset.editKb;
renderKnowledgeBase();
toggleKnowledgeEntry(knowledgeButton.dataset.toggleKb);
return; 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;
} }

View file

@ -332,6 +332,40 @@
<div class="pane-heading"> <div class="pane-heading">
<h2>등록된 기준</h2> <h2>등록된 기준</h2>
</div> </div>
<div class="knowledge-filter-bar">
<label for="knowledge-search">
<span>검색</span>
<input id="knowledge-search" type="search" autocomplete="off" placeholder="이름, 별칭, 키워드">
</label>
<label for="knowledge-type-filter">
<span>유형</span>
<select id="knowledge-type-filter">
<option value="all">전체</option>
<option value="public_figure">연예인/유명인</option>
<option value="work">작품</option>
<option value="character">캐릭터</option>
<option value="game">게임</option>
<option value="rejected_image">반려 이미지</option>
</select>
</label>
<label for="knowledge-status-filter">
<span>상태</span>
<select id="knowledge-status-filter">
<option value="all">전체</option>
<option value="confirmed">확정 DB</option>
<option value="watchlist">주의 후보</option>
<option value="excluded">제외됨</option>
</select>
</label>
<label for="knowledge-active-filter">
<span>활성</span>
<select id="knowledge-active-filter">
<option value="all">전체</option>
<option value="active">활성</option>
<option value="inactive">비활성</option>
</select>
</label>
</div>
<div id="knowledge-list" class="stack-list"></div> <div id="knowledge-list" class="stack-list"></div>
</section> </section>
</section> </section>

View file

@ -2768,3 +2768,24 @@ tbody tr.selected-row,
.suggested-query-actions { .suggested-query-actions {
margin-top: 8px; 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;
}