feat: knowledge base search/filter, inline edit, and server-backed lifecycle actions
This commit is contained in:
parent
cd9d69dddb
commit
646b871b76
4 changed files with 267 additions and 79 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
? `<span>원 케이스: ${escapeHtml(entry.sourceSubmissionId || "-")}</span><span>미래 검출 ${escapeHtml(entry.contributionCount || 0)}건</span>`
|
||||
: "";
|
||||
const memoInline = entry.memo && entry.memo.startsWith("외부 후보 수집에서 반영:") ? `<span>${escapeHtml(entry.memo)}</span>` : "";
|
||||
const memoBlock = memoInline ? "" : `<p class="muted small">${escapeHtml(entry.memo)}</p>`;
|
||||
const watchlistActions =
|
||||
entryStatus === "watchlist"
|
||||
? `
|
||||
<button class="row-action" type="button" data-promote-watchlist="${escapeHtml(entry.id)}">확정 DB 반영</button>
|
||||
<button class="row-action danger" type="button" data-exclude-watchlist="${escapeHtml(entry.id)}">오탐 제외</button>
|
||||
`
|
||||
: `<button class="row-action" type="button" data-toggle-kb="${escapeHtml(entry.id)}">${entry.active ? "비활성" : "활성"}</button>`;
|
||||
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>
|
||||
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"
|
||||
? `<span>원 케이스: ${escapeHtml(entry.sourceSubmissionId || "-")}</span><span>미래 검출 ${escapeHtml(entry.contributionCount || 0)}건</span>`
|
||||
: "";
|
||||
const memoInline = entry.memo && entry.memo.startsWith("외부 후보 수집에서 반영:") ? `<span>${escapeHtml(entry.memo)}</span>` : "";
|
||||
const memoBlock = memoInline ? "" : `<p class="muted small">${escapeHtml(entry.memo)}</p>`;
|
||||
const lifecycleActions =
|
||||
entryStatus === "watchlist"
|
||||
? `
|
||||
<button class="row-action" type="button" data-promote-watchlist="${escapeHtml(entry.id)}">확정 DB 반영</button>
|
||||
<button class="row-action danger" type="button" data-exclude-watchlist="${escapeHtml(entry.id)}">오탐 제외</button>
|
||||
`
|
||||
: entryStatus === "excluded"
|
||||
? ""
|
||||
: entry.active
|
||||
? `<button class="row-action danger" type="button" data-deactivate-kb="${escapeHtml(entry.id)}">비활성</button>`
|
||||
: `<button class="row-action" type="button" data-reactivate-kb="${escapeHtml(entry.id)}">재활성</button>`;
|
||||
const editForm =
|
||||
editingKnowledgeEntryId === entry.id
|
||||
? `
|
||||
<form class="knowledge-edit-form" data-knowledge-edit-form="${escapeHtml(entry.id)}">
|
||||
<label>
|
||||
<span>별칭</span>
|
||||
<input name="aliases" type="text" value="${escapeHtml(aliases.join(", "))}">
|
||||
</label>
|
||||
<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 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 class="knowledge-actions">
|
||||
<button class="row-action" type="button" data-edit-kb="${escapeHtml(entry.id)}">편집</button>
|
||||
${lifecycleActions}
|
||||
</div>
|
||||
</div>
|
||||
${memoBlock}
|
||||
</div>
|
||||
<div class="knowledge-actions">${watchlistActions}</div>
|
||||
</article>
|
||||
`;
|
||||
})
|
||||
.join("");
|
||||
</article>
|
||||
`;
|
||||
})
|
||||
.join("")
|
||||
: `<div class="empty-state">조건에 맞는 기준 항목이 없습니다.</div>`;
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -332,6 +332,40 @@
|
|||
<div class="pane-heading">
|
||||
<h2>등록된 기준</h2>
|
||||
</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>
|
||||
</section>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue