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 "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
|
||||||
|
|
|
||||||
|
|
@ -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,8 +1730,23 @@ 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();
|
||||||
|
document.getElementById("knowledge-list").innerHTML = entries.length
|
||||||
|
? entries
|
||||||
.map((entry) => {
|
.map((entry) => {
|
||||||
const aliases = entry.aliases || [];
|
const aliases = entry.aliases || [];
|
||||||
const keywords = entry.keywords || [];
|
const keywords = entry.keywords || [];
|
||||||
|
|
@ -1739,13 +1757,40 @@ function renderKnowledgeBase() {
|
||||||
: "";
|
: "";
|
||||||
const memoInline = entry.memo && entry.memo.startsWith("외부 후보 수집에서 반영:") ? `<span>${escapeHtml(entry.memo)}</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 memoBlock = memoInline ? "" : `<p class="muted small">${escapeHtml(entry.memo)}</p>`;
|
||||||
const watchlistActions =
|
const lifecycleActions =
|
||||||
entryStatus === "watchlist"
|
entryStatus === "watchlist"
|
||||||
? `
|
? `
|
||||||
<button class="row-action" type="button" data-promote-watchlist="${escapeHtml(entry.id)}">확정 DB 반영</button>
|
<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 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>`;
|
: 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 `
|
return `
|
||||||
<article class="knowledge-row ${entry.active ? "" : "inactive"} ${entryStatus === "watchlist" ? "watchlist" : ""}">
|
<article class="knowledge-row ${entry.active ? "" : "inactive"} ${entryStatus === "watchlist" ? "watchlist" : ""}">
|
||||||
${
|
${
|
||||||
|
|
@ -1772,12 +1817,17 @@ function renderKnowledgeBase() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
${memoBlock}
|
${memoBlock}
|
||||||
|
${editForm}
|
||||||
|
</div>
|
||||||
|
<div class="knowledge-actions">
|
||||||
|
<button class="row-action" type="button" data-edit-kb="${escapeHtml(entry.id)}">편집</button>
|
||||||
|
${lifecycleActions}
|
||||||
</div>
|
</div>
|
||||||
<div class="knowledge-actions">${watchlistActions}</div>
|
|
||||||
</article>
|
</article>
|
||||||
`;
|
`;
|
||||||
})
|
})
|
||||||
.join("");
|
.join("")
|
||||||
|
: `<div class="empty-state">조건에 맞는 기준 항목이 없습니다.</div>`;
|
||||||
renderCollectionCandidates();
|
renderCollectionCandidates();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3161,30 +3211,57 @@ function markEvidenceIrrelevant(evidenceId) {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
function toggleKnowledgeEntry(entryId) {
|
async function saveKnowledgeEntryEdit(form) {
|
||||||
|
const entryId = form.dataset.knowledgeEditForm;
|
||||||
const entry = knowledgeEntries.find((item) => item.id === entryId);
|
const body = {
|
||||||
|
aliases: form.elements.aliases.value.split(",").map((item) => item.trim()).filter(Boolean),
|
||||||
if (!entry) return;
|
keywords: form.elements.keywords.value.split(",").map((item) => item.trim()).filter(Boolean),
|
||||||
|
memo: form.elements.memo.value.trim(),
|
||||||
entry.active = !entry.active;
|
};
|
||||||
|
try {
|
||||||
auditEvents.unshift({
|
const payload = await apiJson(`/api/knowledge/${encodeURIComponent(entryId)}`, {
|
||||||
|
method: "PATCH",
|
||||||
timestamp: "방금",
|
body: JSON.stringify(body),
|
||||||
|
|
||||||
actor: "rights.ops",
|
|
||||||
|
|
||||||
event: entry.active ? "Knowledge entry reactivated" : "Knowledge entry deactivated",
|
|
||||||
|
|
||||||
object: entry.name,
|
|
||||||
|
|
||||||
change: entry.active ? "active with memo" : "inactive",
|
|
||||||
|
|
||||||
});
|
});
|
||||||
|
editingKnowledgeEntryId = "";
|
||||||
|
applyBootstrap(payload);
|
||||||
renderAll();
|
renderAll();
|
||||||
|
} catch (errorValue) {
|
||||||
|
showApiError(errorValue.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue