feat: add knowledge content search
This commit is contained in:
@@ -6,6 +6,8 @@ import type { KnowledgeVersionContent } from "../types/api";
|
|||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
versionId: number | null;
|
versionId: number | null;
|
||||||
|
targetSectionId?: number | null;
|
||||||
|
highlightKeyword?: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
@@ -25,6 +27,7 @@ const characterCount = computed(() => (
|
|||||||
));
|
));
|
||||||
|
|
||||||
watch(() => props.versionId, loadVersion, { immediate: true });
|
watch(() => props.versionId, loadVersion, { immediate: true });
|
||||||
|
watch(() => props.targetSectionId, selectTargetSection);
|
||||||
|
|
||||||
async function loadVersion(versionId: number | null) {
|
async function loadVersion(versionId: number | null) {
|
||||||
const sequence = ++requestSequence;
|
const sequence = ++requestSequence;
|
||||||
@@ -38,7 +41,9 @@ async function loadVersion(versionId: number | null) {
|
|||||||
const result = await api.knowledgeVersionContent(versionId);
|
const result = await api.knowledgeVersionContent(versionId);
|
||||||
if (sequence !== requestSequence) return;
|
if (sequence !== requestSequence) return;
|
||||||
content.value = result;
|
content.value = result;
|
||||||
activeSectionId.value = result.sections[0]?.id ?? null;
|
activeSectionId.value = props.targetSectionId && result.sections.some((section) => section.id === props.targetSectionId)
|
||||||
|
? props.targetSectionId
|
||||||
|
: result.sections[0]?.id ?? null;
|
||||||
} catch (loadError) {
|
} catch (loadError) {
|
||||||
if (sequence !== requestSequence) return;
|
if (sequence !== requestSequence) return;
|
||||||
error.value = loadError instanceof Error ? loadError.message : "知识库内容加载失败";
|
error.value = loadError instanceof Error ? loadError.message : "知识库内容加载失败";
|
||||||
@@ -47,6 +52,32 @@ async function loadVersion(versionId: number | null) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function selectTargetSection(sectionId?: number | null) {
|
||||||
|
if (!sectionId || !content.value?.sections.some((section) => section.id === sectionId)) return;
|
||||||
|
activeSectionId.value = sectionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function highlightedParts(text: string) {
|
||||||
|
const keyword = (props.highlightKeyword || "").trim();
|
||||||
|
if (!keyword) return [{ text, match: false }];
|
||||||
|
const parts: Array<{ text: string; match: boolean }> = [];
|
||||||
|
const source = text || "";
|
||||||
|
const lowerSource = source.toLowerCase();
|
||||||
|
const lowerKeyword = keyword.toLowerCase();
|
||||||
|
let cursor = 0;
|
||||||
|
while (cursor < source.length) {
|
||||||
|
const index = lowerSource.indexOf(lowerKeyword, cursor);
|
||||||
|
if (index < 0) {
|
||||||
|
parts.push({ text: source.slice(cursor), match: false });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (index > cursor) parts.push({ text: source.slice(cursor, index), match: false });
|
||||||
|
parts.push({ text: source.slice(index, index + keyword.length), match: true });
|
||||||
|
cursor = index + keyword.length;
|
||||||
|
}
|
||||||
|
return parts.length ? parts : [{ text: source, match: false }];
|
||||||
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -94,7 +125,7 @@ async function loadVersion(versionId: number | null) {
|
|||||||
<div><small>{{ activeSection.sectionKey }}</small><h3>{{ activeSection.title }}</h3></div>
|
<div><small>{{ activeSection.sectionKey }}</small><h3>{{ activeSection.title }}</h3></div>
|
||||||
<span>源内容位置 {{ activeSection.sourceStart }}–{{ activeSection.sourceEnd }}</span>
|
<span>源内容位置 {{ activeSection.sourceStart }}–{{ activeSection.sourceEnd }}</span>
|
||||||
</div>
|
</div>
|
||||||
<pre>{{ activeSection.content }}</pre>
|
<pre><template v-for="(part, index) in highlightedParts(activeSection.content)" :key="index"><mark v-if="part.match">{{ part.text }}</mark><span v-else>{{ part.text }}</span></template></pre>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -133,6 +164,7 @@ async function loadVersion(versionId: number | null) {
|
|||||||
.content-article-head h3 { margin: 5px 0 0; color: #203b32; font-size: 20px; }
|
.content-article-head h3 { margin: 5px 0 0; color: #203b32; font-size: 20px; }
|
||||||
.content-article-head > span { color: #88958f; font-size: 12px; white-space: nowrap; }
|
.content-article-head > span { color: #88958f; font-size: 12px; white-space: nowrap; }
|
||||||
.content-article pre { margin: 20px 0 0; white-space: pre-wrap; overflow-wrap: anywhere; color: #354840; font: inherit; font-size: 14px; line-height: 1.85; }
|
.content-article pre { margin: 20px 0 0; white-space: pre-wrap; overflow-wrap: anywhere; color: #354840; font: inherit; font-size: 14px; line-height: 1.85; }
|
||||||
|
.content-article mark { padding: 0 2px; border-radius: 3px; background: #fff1a8; color: inherit; }
|
||||||
.content-card-panel { margin-top: 16px; }
|
.content-card-panel { margin-top: 16px; }
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.content-preview-toolbar { align-items: stretch; flex-direction: column; }
|
.content-preview-toolbar { align-items: stretch; flex-direction: column; }
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import TableRowActions from "./TableRowActions.vue";
|
|||||||
import AdminPagination from "./AdminPagination.vue";
|
import AdminPagination from "./AdminPagination.vue";
|
||||||
import type {
|
import type {
|
||||||
KnowledgeDetail,
|
KnowledgeDetail,
|
||||||
|
KnowledgeContentSearchItem,
|
||||||
KnowledgeBatchSyncResult,
|
KnowledgeBatchSyncResult,
|
||||||
KnowledgeItem,
|
KnowledgeItem,
|
||||||
KnowledgeSyncJob,
|
KnowledgeSyncJob,
|
||||||
@@ -24,6 +25,8 @@ const detailOpen = ref(false);
|
|||||||
const detail = ref<KnowledgeDetail | null>(null);
|
const detail = ref<KnowledgeDetail | null>(null);
|
||||||
const jobs = ref<KnowledgeSyncJob[]>([]);
|
const jobs = ref<KnowledgeSyncJob[]>([]);
|
||||||
const previewVersionId = ref<number | null>(null);
|
const previewVersionId = ref<number | null>(null);
|
||||||
|
const previewTargetSectionId = ref<number | null>(null);
|
||||||
|
const previewHighlightKeyword = ref("");
|
||||||
const activeTab = ref("overview");
|
const activeTab = ref("overview");
|
||||||
const selectedRows = ref<KnowledgeItem[]>([]);
|
const selectedRows = ref<KnowledgeItem[]>([]);
|
||||||
const editOpen = ref(false);
|
const editOpen = ref(false);
|
||||||
@@ -38,6 +41,10 @@ const nodeResolved = ref(false);
|
|||||||
const sourceTitle = ref("");
|
const sourceTitle = ref("");
|
||||||
const filter = reactive({ keyword: "", type: "", open: "", source: "" });
|
const filter = reactive({ keyword: "", type: "", open: "", source: "" });
|
||||||
const pager = reactive({ page: 1, pageSize: 20, total: 0 });
|
const pager = reactive({ page: 1, pageSize: 20, total: 0 });
|
||||||
|
const contentSearch = reactive({ keyword: "", includeClosed: true });
|
||||||
|
const contentSearchRows = ref<KnowledgeContentSearchItem[]>([]);
|
||||||
|
const contentSearchLoading = ref(false);
|
||||||
|
const contentSearchPager = reactive({ page: 1, pageSize: 10, total: 0 });
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
name: "",
|
name: "",
|
||||||
feishuSpaceId: "",
|
feishuSpaceId: "",
|
||||||
@@ -178,6 +185,36 @@ async function handleMore(command: string, item: KnowledgeItem) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function searchContent(page = contentSearchPager.page, pageSize = contentSearchPager.pageSize) {
|
||||||
|
const keyword = contentSearch.keyword.trim();
|
||||||
|
if (!keyword) {
|
||||||
|
contentSearchRows.value = [];
|
||||||
|
Object.assign(contentSearchPager, { page: 1, pageSize, total: 0 });
|
||||||
|
return ElMessage.warning("请输入要搜索的知识库内容关键字");
|
||||||
|
}
|
||||||
|
contentSearchLoading.value = true;
|
||||||
|
try {
|
||||||
|
const result = await api.knowledgeContentSearch({
|
||||||
|
keyword,
|
||||||
|
includeClosed: contentSearch.includeClosed,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
});
|
||||||
|
contentSearchRows.value = result.items;
|
||||||
|
Object.assign(contentSearchPager, { page: result.page, pageSize: result.pageSize, total: result.total });
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(errorMessage(error, "知识库内容搜索失败"));
|
||||||
|
} finally {
|
||||||
|
contentSearchLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearContentSearch() {
|
||||||
|
contentSearch.keyword = "";
|
||||||
|
contentSearchRows.value = [];
|
||||||
|
Object.assign(contentSearchPager, { page: 1, pageSize: contentSearchPager.pageSize, total: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
async function restore(item: KnowledgeItem) {
|
async function restore(item: KnowledgeItem) {
|
||||||
if (!await confirmAction("恢复后知识库仍保持关闭,需要单独确认开放。", "恢复知识库", {
|
if (!await confirmAction("恢复后知识库仍保持关闭,需要单独确认开放。", "恢复知识库", {
|
||||||
confirmButtonText: "确认恢复", cancelButtonText: "取消", type: "warning",
|
confirmButtonText: "确认恢复", cancelButtonText: "取消", type: "warning",
|
||||||
@@ -218,11 +255,16 @@ async function resolveNode() {
|
|||||||
} finally { resolvingNode.value = false; }
|
} finally { resolvingNode.value = false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openDetail(item: KnowledgeItem) {
|
async function openDetail(
|
||||||
|
item: KnowledgeItem | { id: number },
|
||||||
|
options: { sectionId?: number | null; keyword?: string; tab?: string } = {},
|
||||||
|
) {
|
||||||
detailOpen.value = true;
|
detailOpen.value = true;
|
||||||
detail.value = null;
|
detail.value = null;
|
||||||
activeTab.value = "overview";
|
activeTab.value = options.tab || "overview";
|
||||||
previewVersionId.value = null;
|
previewVersionId.value = null;
|
||||||
|
previewTargetSectionId.value = options.sectionId ?? null;
|
||||||
|
previewHighlightKeyword.value = options.keyword || "";
|
||||||
try {
|
try {
|
||||||
const refreshedDetail = await refreshDetail(item.id);
|
const refreshedDetail = await refreshDetail(item.id);
|
||||||
previewVersionId.value = refreshedDetail.currentVersion?.id ?? null;
|
previewVersionId.value = refreshedDetail.currentVersion?.id ?? null;
|
||||||
@@ -232,6 +274,13 @@ async function openDetail(item: KnowledgeItem) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function openSearchHit(item: KnowledgeContentSearchItem) {
|
||||||
|
await openDetail(
|
||||||
|
{ id: item.knowledgeId },
|
||||||
|
{ sectionId: item.sectionId, keyword: contentSearch.keyword.trim(), tab: "content" },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function refreshDetail(id: number) {
|
async function refreshDetail(id: number) {
|
||||||
const [nextDetail, nextJobs] = await Promise.all([
|
const [nextDetail, nextJobs] = await Promise.all([
|
||||||
api.knowledgeDetail(id), api.knowledgeSyncJobs(id),
|
api.knowledgeDetail(id), api.knowledgeSyncJobs(id),
|
||||||
@@ -330,6 +379,36 @@ function typeLabel(value: string) {
|
|||||||
return knowledgeTypeOptions.find((item) => item.value === value)?.label || value;
|
return knowledgeTypeOptions.find((item) => item.value === value)?.label || value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function contentItemTypeLabel(value: string) {
|
||||||
|
return ({ section: "章节", card: "知识卡", chunk: "切片" } as Record<string, string>)[value] || value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchedLabel(values: string[]) {
|
||||||
|
const labels = values.map((value) => ({ title: "标题", content: "正文" } as Record<string, string>)[value] || value);
|
||||||
|
return labels.join("、");
|
||||||
|
}
|
||||||
|
|
||||||
|
function highlightSnippet(text: string) {
|
||||||
|
const keyword = contentSearch.keyword.trim();
|
||||||
|
if (!keyword) return [{ text, match: false }];
|
||||||
|
const parts: Array<{ text: string; match: boolean }> = [];
|
||||||
|
const source = text || "";
|
||||||
|
const lowerSource = source.toLowerCase();
|
||||||
|
const lowerKeyword = keyword.toLowerCase();
|
||||||
|
let cursor = 0;
|
||||||
|
while (cursor < source.length) {
|
||||||
|
const index = lowerSource.indexOf(lowerKeyword, cursor);
|
||||||
|
if (index < 0) {
|
||||||
|
parts.push({ text: source.slice(cursor), match: false });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (index > cursor) parts.push({ text: source.slice(cursor, index), match: false });
|
||||||
|
parts.push({ text: source.slice(index, index + keyword.length), match: true });
|
||||||
|
cursor = index + keyword.length;
|
||||||
|
}
|
||||||
|
return parts.length ? parts : [{ text: source, match: false }];
|
||||||
|
}
|
||||||
|
|
||||||
function errorMessage(error: unknown, fallback: string) {
|
function errorMessage(error: unknown, fallback: string) {
|
||||||
return error instanceof Error ? error.message : fallback;
|
return error instanceof Error ? error.message : fallback;
|
||||||
}
|
}
|
||||||
@@ -380,6 +459,58 @@ async function confirmAction(
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="knowledge-content-search-panel">
|
||||||
|
<div class="content-search-head">
|
||||||
|
<div>
|
||||||
|
<h3>搜索知识库内容</h3>
|
||||||
|
<p>跨全部当前最新版搜索章节、知识卡和切片,用于确认内容是否已同步进检索链路。</p>
|
||||||
|
</div>
|
||||||
|
<el-switch v-model="contentSearch.includeClosed" active-text="包含已关闭" inactive-text="仅开放" />
|
||||||
|
</div>
|
||||||
|
<div class="content-search-row">
|
||||||
|
<el-input
|
||||||
|
v-model="contentSearch.keyword"
|
||||||
|
placeholder="输入内容关键字,例如:静水流深静心、合一作业、会议链接"
|
||||||
|
clearable
|
||||||
|
@clear="clearContentSearch"
|
||||||
|
@keydown.enter.exact.prevent="searchContent(1)"
|
||||||
|
/>
|
||||||
|
<el-button type="primary" :loading="contentSearchLoading" @click="searchContent(1)">搜索内容</el-button>
|
||||||
|
</div>
|
||||||
|
<div v-if="contentSearchPager.total || contentSearchRows.length" v-loading="contentSearchLoading" class="content-search-results">
|
||||||
|
<div class="content-search-result-summary">
|
||||||
|
找到 {{ contentSearchPager.total }} 条命中
|
||||||
|
<span>结果只来自当前最新版;已关闭知识库会标记为不可被用户端默认召回。</span>
|
||||||
|
</div>
|
||||||
|
<article v-for="item in contentSearchRows" :key="`${item.itemType}-${item.itemId}`" class="content-search-hit">
|
||||||
|
<div class="content-search-hit-main">
|
||||||
|
<div class="content-search-hit-title">
|
||||||
|
<strong>{{ item.title }}</strong>
|
||||||
|
<el-tag size="small" effect="plain">{{ contentItemTypeLabel(item.itemType) }}</el-tag>
|
||||||
|
<el-tag size="small" :type="item.canAgentUse ? 'success' : 'warning'">{{ item.canAgentUse ? '可参与召回' : '不可默认召回' }}</el-tag>
|
||||||
|
</div>
|
||||||
|
<p class="content-search-hit-meta">
|
||||||
|
{{ item.knowledgeName }} · V{{ item.versionNo }} · {{ item.sectionKey }} · 命中{{ matchedLabel(item.matchedIn) }}
|
||||||
|
</p>
|
||||||
|
<p class="content-search-hit-snippet">
|
||||||
|
<template v-for="(part, index) in highlightSnippet(item.snippet)" :key="index">
|
||||||
|
<mark v-if="part.match">{{ part.text }}</mark><span v-else>{{ part.text }}</span>
|
||||||
|
</template>
|
||||||
|
</p>
|
||||||
|
<div class="content-search-diagnostics">
|
||||||
|
<span>{{ item.knowledgeStatus === 1 ? '已开放' : '已关闭' }}</span>
|
||||||
|
<span>{{ item.sourceStatus === 'normal' ? '源正常' : '源异常' }}</span>
|
||||||
|
<span>{{ item.hasChunks ? '已生成切片' : '未生成切片' }}</span>
|
||||||
|
<span>{{ item.isCurrentVersion ? '当前版本' : '非当前版本' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<el-button type="primary" plain @click="openSearchHit(item)">查看内容</el-button>
|
||||||
|
</article>
|
||||||
|
<AdminPagination :page="contentSearchPager.page" :page-size="contentSearchPager.pageSize" :total="contentSearchPager.total" @change="searchContent" />
|
||||||
|
</div>
|
||||||
|
<el-empty v-else-if="contentSearch.keyword && !contentSearchLoading" description="暂无内容搜索结果" :image-size="64" />
|
||||||
|
</section>
|
||||||
|
|
||||||
<div class="knowledge-filters">
|
<div class="knowledge-filters">
|
||||||
<el-input v-model="filter.keyword" placeholder="搜索后台名称或飞书标题" clearable @change="load(1)" />
|
<el-input v-model="filter.keyword" placeholder="搜索后台名称或飞书标题" clearable @change="load(1)" />
|
||||||
<el-select v-model="filter.type" placeholder="知识库类型" clearable @change="load(1)"><el-option v-for="item in knowledgeTypeOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select>
|
<el-select v-model="filter.type" placeholder="知识库类型" clearable @change="load(1)"><el-option v-for="item in knowledgeTypeOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select>
|
||||||
@@ -420,7 +551,11 @@ async function confirmAction(
|
|||||||
<section v-if="detail.manifest" class="manifest-panel"><h4>Knowledge Manifest</h4><p><strong>用途:</strong>{{ detail.manifest.purpose }}</p><p><strong>适用:</strong>{{ detail.manifest.applicableQuestions }}</p><p><strong>不适用:</strong>{{ detail.manifest.inapplicableQuestions }}</p><p><strong>核心主题:</strong>{{ detail.manifest.coreTopics }}</p><p><strong>边界:</strong>{{ detail.manifest.boundaries }}</p></section>
|
<section v-if="detail.manifest" class="manifest-panel"><h4>Knowledge Manifest</h4><p><strong>用途:</strong>{{ detail.manifest.purpose }}</p><p><strong>适用:</strong>{{ detail.manifest.applicableQuestions }}</p><p><strong>不适用:</strong>{{ detail.manifest.inapplicableQuestions }}</p><p><strong>核心主题:</strong>{{ detail.manifest.coreTopics }}</p><p><strong>边界:</strong>{{ detail.manifest.boundaries }}</p></section>
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
<el-tab-pane label="内容预览" name="content" lazy>
|
<el-tab-pane label="内容预览" name="content" lazy>
|
||||||
<KnowledgeContentPreview :version-id="previewVersionId" />
|
<KnowledgeContentPreview
|
||||||
|
:version-id="previewVersionId"
|
||||||
|
:target-section-id="previewTargetSectionId"
|
||||||
|
:highlight-keyword="previewHighlightKeyword"
|
||||||
|
/>
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
<el-tab-pane label="同步记录" name="jobs"><el-table :data="jobs"><el-table-column prop="id" label="任务" width="80" /><el-table-column prop="status" label="状态" width="130" /><el-table-column prop="stage" label="阶段" width="130" /><el-table-column label="进度" width="180"><template #default="{ row }"><el-progress :percentage="row.progress" /></template></el-table-column><el-table-column prop="sourceError" label="失败原因" min-width="260" /></el-table></el-tab-pane>
|
<el-tab-pane label="同步记录" name="jobs"><el-table :data="jobs"><el-table-column prop="id" label="任务" width="80" /><el-table-column prop="status" label="状态" width="130" /><el-table-column prop="stage" label="阶段" width="130" /><el-table-column label="进度" width="180"><template #default="{ row }"><el-progress :percentage="row.progress" /></template></el-table-column><el-table-column prop="sourceError" label="失败原因" min-width="260" /></el-table></el-tab-pane>
|
||||||
</el-tabs>
|
</el-tabs>
|
||||||
@@ -511,4 +646,134 @@ async function confirmAction(
|
|||||||
.batch-sync-result-table {
|
.batch-sync-result-table {
|
||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.knowledge-content-search-panel {
|
||||||
|
margin: 18px 0;
|
||||||
|
padding: 18px;
|
||||||
|
border: 1px solid #dfe8e5;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fbfdfc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-search-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 20px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-search-head h3 {
|
||||||
|
margin: 0;
|
||||||
|
color: #203b32;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-search-head p {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
color: #71817b;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-search-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-search-results {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-search-result-summary {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
color: #53645e;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-search-result-summary span {
|
||||||
|
color: #81918b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-search-hit {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 14px;
|
||||||
|
border: 1px solid #e1ebe7;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-search-hit-main {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-search-hit-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-search-hit-title strong {
|
||||||
|
overflow: hidden;
|
||||||
|
color: #203b32;
|
||||||
|
font-size: 14px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-search-hit-meta {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
color: #708179;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-search-hit-snippet {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
color: #354840;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-search-hit-snippet mark {
|
||||||
|
padding: 0 2px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: #fff1a8;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-search-diagnostics {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-search-diagnostics span {
|
||||||
|
padding: 3px 7px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #eef5f2;
|
||||||
|
color: #536b61;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.content-search-head,
|
||||||
|
.content-search-result-summary,
|
||||||
|
.content-search-hit {
|
||||||
|
align-items: stretch;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-search-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import type {
|
|||||||
ChatRecordQuery,
|
ChatRecordQuery,
|
||||||
DashboardStats,
|
DashboardStats,
|
||||||
KnowledgeItem,
|
KnowledgeItem,
|
||||||
|
KnowledgeContentSearchItem,
|
||||||
KnowledgeDetail,
|
KnowledgeDetail,
|
||||||
KnowledgeSyncJob,
|
KnowledgeSyncJob,
|
||||||
KnowledgeVersionContent,
|
KnowledgeVersionContent,
|
||||||
@@ -156,6 +157,8 @@ export const api = {
|
|||||||
{ method: "POST", body: JSON.stringify(body) },
|
{ method: "POST", body: JSON.stringify(body) },
|
||||||
),
|
),
|
||||||
knowledgeDetail: (id: number) => request<KnowledgeDetail>(`/admin/knowledge/${id}/detail`),
|
knowledgeDetail: (id: number) => request<KnowledgeDetail>(`/admin/knowledge/${id}/detail`),
|
||||||
|
knowledgeContentSearch: (query: { keyword: string; includeClosed?: boolean; page?: number; pageSize?: number }) =>
|
||||||
|
request<PageResult<KnowledgeContentSearchItem>>(`/admin/knowledge/content-search${queryString(query)}`),
|
||||||
knowledgeVersionContent: (versionId: number) => request<KnowledgeVersionContent>(`/admin/knowledge/version/${versionId}/content`),
|
knowledgeVersionContent: (versionId: number) => request<KnowledgeVersionContent>(`/admin/knowledge/version/${versionId}/content`),
|
||||||
knowledgeSyncJobs: (id: number) => request<KnowledgeSyncJob[]>(`/admin/knowledge/${id}/sync-jobs`),
|
knowledgeSyncJobs: (id: number) => request<KnowledgeSyncJob[]>(`/admin/knowledge/${id}/sync-jobs`),
|
||||||
syncKnowledge: (id: number) =>
|
syncKnowledge: (id: number) =>
|
||||||
|
|||||||
@@ -209,6 +209,29 @@ export interface KnowledgeVersionContent {
|
|||||||
cards: Array<Record<string, unknown>>;
|
cards: Array<Record<string, unknown>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface KnowledgeContentSearchItem {
|
||||||
|
knowledgeId: number;
|
||||||
|
knowledgeName: string;
|
||||||
|
sourceTitle?: string | null;
|
||||||
|
knowledgeType: "course" | "qa" | "general" | "fixed";
|
||||||
|
knowledgeStatus: number;
|
||||||
|
lifecycleStatus: "active" | "archived";
|
||||||
|
sourceStatus: "normal" | "error";
|
||||||
|
versionId: number;
|
||||||
|
versionNo: number;
|
||||||
|
publishedAt?: string | null;
|
||||||
|
itemType: "section" | "card" | "chunk";
|
||||||
|
itemId: number;
|
||||||
|
sectionId: number;
|
||||||
|
sectionKey: string;
|
||||||
|
title: string;
|
||||||
|
snippet: string;
|
||||||
|
matchedIn: Array<"title" | "content">;
|
||||||
|
hasChunks: boolean;
|
||||||
|
isCurrentVersion: boolean;
|
||||||
|
canAgentUse: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface KnowledgeSyncJob {
|
export interface KnowledgeSyncJob {
|
||||||
id: number;
|
id: number;
|
||||||
knowledgeId: number;
|
knowledgeId: number;
|
||||||
|
|||||||
@@ -3,12 +3,13 @@ from __future__ import annotations
|
|||||||
import json
|
import json
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, literal, or_, select, union_all
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.core.dependencies import get_current_admin
|
from app.core.dependencies import get_current_admin
|
||||||
from app.core.responses import api_success
|
from app.core.responses import api_success
|
||||||
|
from app.api.pagination import page_result
|
||||||
from app.models.admin import Admin
|
from app.models.admin import Admin
|
||||||
from app.models.knowledge import (
|
from app.models.knowledge import (
|
||||||
Knowledge,
|
Knowledge,
|
||||||
@@ -165,6 +166,153 @@ def version_content(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/knowledge/content-search")
|
||||||
|
def content_search(
|
||||||
|
keyword: str = Query(default="", min_length=1, max_length=100),
|
||||||
|
includeClosed: bool = Query(default=True),
|
||||||
|
page: int = Query(default=1, ge=1),
|
||||||
|
pageSize: int = Query(default=10, ge=5, le=50),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_admin: Admin = Depends(get_current_admin),
|
||||||
|
) -> dict:
|
||||||
|
_require_super_admin(current_admin)
|
||||||
|
keyword = keyword.strip()
|
||||||
|
if not keyword:
|
||||||
|
return api_success({"items": [], "total": 0, "page": page, "pageSize": pageSize})
|
||||||
|
|
||||||
|
pattern = f"%{_escape_like(keyword)}%"
|
||||||
|
base_filters = [
|
||||||
|
Knowledge.current_version_id.is_not(None),
|
||||||
|
Knowledge.current_version_id == KnowledgeVersion.id,
|
||||||
|
Knowledge.lifecycle_status == "active",
|
||||||
|
]
|
||||||
|
if not includeClosed:
|
||||||
|
base_filters.append(Knowledge.status == 1)
|
||||||
|
|
||||||
|
section_query = (
|
||||||
|
select(
|
||||||
|
Knowledge.id.label("knowledge_id"),
|
||||||
|
Knowledge.name.label("knowledge_name"),
|
||||||
|
Knowledge.source_title.label("source_title"),
|
||||||
|
Knowledge.knowledge_type.label("knowledge_type"),
|
||||||
|
Knowledge.status.label("knowledge_status"),
|
||||||
|
Knowledge.lifecycle_status.label("lifecycle_status"),
|
||||||
|
Knowledge.source_status.label("source_status"),
|
||||||
|
KnowledgeVersion.id.label("version_id"),
|
||||||
|
KnowledgeVersion.version_no.label("version_no"),
|
||||||
|
KnowledgeVersion.published_at.label("published_at"),
|
||||||
|
literal("section").label("item_type"),
|
||||||
|
literal(1).label("item_rank"),
|
||||||
|
KnowledgeSection.id.label("item_id"),
|
||||||
|
KnowledgeSection.id.label("section_id"),
|
||||||
|
KnowledgeSection.section_key.label("section_key"),
|
||||||
|
KnowledgeSection.title.label("title"),
|
||||||
|
KnowledgeSection.content.label("content"),
|
||||||
|
KnowledgeSection.sort_order.label("sort_order"),
|
||||||
|
)
|
||||||
|
.select_from(KnowledgeSection)
|
||||||
|
.join(Knowledge, Knowledge.id == KnowledgeSection.knowledge_id)
|
||||||
|
.join(KnowledgeVersion, KnowledgeVersion.id == KnowledgeSection.version_id)
|
||||||
|
.where(*base_filters)
|
||||||
|
.where(or_(KnowledgeSection.title.like(pattern, escape="\\"), KnowledgeSection.content.like(pattern, escape="\\")))
|
||||||
|
)
|
||||||
|
card_query = (
|
||||||
|
select(
|
||||||
|
Knowledge.id.label("knowledge_id"),
|
||||||
|
Knowledge.name.label("knowledge_name"),
|
||||||
|
Knowledge.source_title.label("source_title"),
|
||||||
|
Knowledge.knowledge_type.label("knowledge_type"),
|
||||||
|
Knowledge.status.label("knowledge_status"),
|
||||||
|
Knowledge.lifecycle_status.label("lifecycle_status"),
|
||||||
|
Knowledge.source_status.label("source_status"),
|
||||||
|
KnowledgeVersion.id.label("version_id"),
|
||||||
|
KnowledgeVersion.version_no.label("version_no"),
|
||||||
|
KnowledgeVersion.published_at.label("published_at"),
|
||||||
|
literal("card").label("item_type"),
|
||||||
|
literal(2).label("item_rank"),
|
||||||
|
KnowledgeCard.id.label("item_id"),
|
||||||
|
KnowledgeCard.section_id.label("section_id"),
|
||||||
|
KnowledgeSection.section_key.label("section_key"),
|
||||||
|
KnowledgeCard.title.label("title"),
|
||||||
|
KnowledgeCard.summary.label("content"),
|
||||||
|
KnowledgeSection.sort_order.label("sort_order"),
|
||||||
|
)
|
||||||
|
.select_from(KnowledgeCard)
|
||||||
|
.join(Knowledge, Knowledge.id == KnowledgeCard.knowledge_id)
|
||||||
|
.join(KnowledgeVersion, KnowledgeVersion.id == KnowledgeCard.version_id)
|
||||||
|
.join(KnowledgeSection, KnowledgeSection.id == KnowledgeCard.section_id)
|
||||||
|
.where(*base_filters)
|
||||||
|
.where(
|
||||||
|
or_(
|
||||||
|
KnowledgeCard.title.like(pattern, escape="\\"),
|
||||||
|
KnowledgeCard.summary.like(pattern, escape="\\"),
|
||||||
|
KnowledgeCard.core_conclusion.like(pattern, escape="\\"),
|
||||||
|
KnowledgeCard.applicable_questions.like(pattern, escape="\\"),
|
||||||
|
KnowledgeCard.keywords.like(pattern, escape="\\"),
|
||||||
|
KnowledgeCard.synonyms.like(pattern, escape="\\"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
chunk_query = (
|
||||||
|
select(
|
||||||
|
Knowledge.id.label("knowledge_id"),
|
||||||
|
Knowledge.name.label("knowledge_name"),
|
||||||
|
Knowledge.source_title.label("source_title"),
|
||||||
|
Knowledge.knowledge_type.label("knowledge_type"),
|
||||||
|
Knowledge.status.label("knowledge_status"),
|
||||||
|
Knowledge.lifecycle_status.label("lifecycle_status"),
|
||||||
|
Knowledge.source_status.label("source_status"),
|
||||||
|
KnowledgeVersion.id.label("version_id"),
|
||||||
|
KnowledgeVersion.version_no.label("version_no"),
|
||||||
|
KnowledgeVersion.published_at.label("published_at"),
|
||||||
|
literal("chunk").label("item_type"),
|
||||||
|
literal(3).label("item_rank"),
|
||||||
|
KnowledgeChunk.id.label("item_id"),
|
||||||
|
KnowledgeChunk.section_id.label("section_id"),
|
||||||
|
KnowledgeSection.section_key.label("section_key"),
|
||||||
|
KnowledgeChunk.title.label("title"),
|
||||||
|
KnowledgeChunk.content.label("content"),
|
||||||
|
KnowledgeSection.sort_order.label("sort_order"),
|
||||||
|
)
|
||||||
|
.select_from(KnowledgeChunk)
|
||||||
|
.join(Knowledge, Knowledge.id == KnowledgeChunk.knowledge_id)
|
||||||
|
.join(KnowledgeVersion, KnowledgeVersion.id == KnowledgeChunk.version_id)
|
||||||
|
.join(KnowledgeSection, KnowledgeSection.id == KnowledgeChunk.section_id)
|
||||||
|
.where(*base_filters)
|
||||||
|
.where(
|
||||||
|
or_(
|
||||||
|
KnowledgeChunk.title.like(pattern, escape="\\"),
|
||||||
|
KnowledgeChunk.content.like(pattern, escape="\\"),
|
||||||
|
KnowledgeChunk.normalized_text.like(pattern, escape="\\"),
|
||||||
|
KnowledgeChunk.keywords.like(pattern, escape="\\"),
|
||||||
|
KnowledgeChunk.synonyms.like(pattern, escape="\\"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
search = union_all(section_query, card_query, chunk_query).subquery()
|
||||||
|
total = db.scalar(select(func.count()).select_from(search)) or 0
|
||||||
|
rows = db.execute(
|
||||||
|
select(search)
|
||||||
|
.order_by(
|
||||||
|
search.c.knowledge_status.desc(),
|
||||||
|
search.c.knowledge_id.desc(),
|
||||||
|
search.c.sort_order.asc(),
|
||||||
|
search.c.item_rank.asc(),
|
||||||
|
search.c.item_id.asc(),
|
||||||
|
)
|
||||||
|
.offset((page - 1) * pageSize)
|
||||||
|
.limit(pageSize)
|
||||||
|
).mappings().all()
|
||||||
|
section_ids = [int(row["section_id"]) for row in rows if row["section_id"]]
|
||||||
|
chunk_section_ids = set()
|
||||||
|
if section_ids:
|
||||||
|
chunk_section_ids = set(
|
||||||
|
db.scalars(select(KnowledgeChunk.section_id).where(KnowledgeChunk.section_id.in_(section_ids))).all()
|
||||||
|
)
|
||||||
|
items = [_content_search_item(row, keyword, int(row["section_id"]) in chunk_section_ids) for row in rows]
|
||||||
|
return api_success(page_result(items, total=total, page=page, page_size=pageSize))
|
||||||
|
|
||||||
|
|
||||||
@router.get("/knowledge/{knowledge_id}/sync-jobs")
|
@router.get("/knowledge/{knowledge_id}/sync-jobs")
|
||||||
def sync_jobs(
|
def sync_jobs(
|
||||||
knowledge_id: int,
|
knowledge_id: int,
|
||||||
@@ -456,3 +604,64 @@ def _json_value(raw: str | None):
|
|||||||
return json.loads(raw)
|
return json.loads(raw)
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
return raw
|
return raw
|
||||||
|
|
||||||
|
|
||||||
|
def _escape_like(value: str) -> str:
|
||||||
|
return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||||
|
|
||||||
|
|
||||||
|
def _content_search_item(row, keyword: str, has_chunks: bool) -> dict:
|
||||||
|
title = str(row["title"] or "")
|
||||||
|
content = str(row["content"] or "")
|
||||||
|
is_active_current = (
|
||||||
|
int(row["knowledge_status"] or 0) == 1
|
||||||
|
and row["lifecycle_status"] == "active"
|
||||||
|
and row["source_status"] == "normal"
|
||||||
|
and bool(row["version_id"])
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"knowledgeId": row["knowledge_id"],
|
||||||
|
"knowledgeName": row["knowledge_name"],
|
||||||
|
"sourceTitle": row["source_title"],
|
||||||
|
"knowledgeType": row["knowledge_type"],
|
||||||
|
"knowledgeStatus": row["knowledge_status"],
|
||||||
|
"lifecycleStatus": row["lifecycle_status"],
|
||||||
|
"sourceStatus": row["source_status"],
|
||||||
|
"versionId": row["version_id"],
|
||||||
|
"versionNo": row["version_no"],
|
||||||
|
"publishedAt": row["published_at"],
|
||||||
|
"itemType": row["item_type"],
|
||||||
|
"itemId": row["item_id"],
|
||||||
|
"sectionId": row["section_id"],
|
||||||
|
"sectionKey": row["section_key"],
|
||||||
|
"title": title,
|
||||||
|
"snippet": _snippet(f"{title}\n{content}", keyword),
|
||||||
|
"matchedIn": _matched_in(title, content, keyword),
|
||||||
|
"hasChunks": has_chunks,
|
||||||
|
"isCurrentVersion": True,
|
||||||
|
"canAgentUse": is_active_current and has_chunks,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _matched_in(title: str, content: str, keyword: str) -> list[str]:
|
||||||
|
result: list[str] = []
|
||||||
|
lowered = keyword.lower()
|
||||||
|
if lowered in title.lower():
|
||||||
|
result.append("title")
|
||||||
|
if lowered in content.lower():
|
||||||
|
result.append("content")
|
||||||
|
return result or ["content"]
|
||||||
|
|
||||||
|
|
||||||
|
def _snippet(text: str, keyword: str, radius: int = 90) -> str:
|
||||||
|
compact = " ".join(text.split())
|
||||||
|
if not compact:
|
||||||
|
return ""
|
||||||
|
index = compact.lower().find(keyword.lower())
|
||||||
|
if index < 0:
|
||||||
|
return compact[: radius * 2] + ("..." if len(compact) > radius * 2 else "")
|
||||||
|
start = max(0, index - radius)
|
||||||
|
end = min(len(compact), index + len(keyword) + radius)
|
||||||
|
prefix = "..." if start > 0 else ""
|
||||||
|
suffix = "..." if end < len(compact) else ""
|
||||||
|
return f"{prefix}{compact[start:end]}{suffix}"
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ class ModelConfig(Base):
|
|||||||
class SystemConfig(Base):
|
class SystemConfig(Base):
|
||||||
__tablename__ = "sys_system_config"
|
__tablename__ = "sys_system_config"
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
id: Mapped[int] = mapped_column(BigInteger().with_variant(Integer, "sqlite"), primary_key=True, autoincrement=True)
|
||||||
config_key: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
|
config_key: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
|
||||||
config_value: Mapped[str] = mapped_column(Text, nullable=False)
|
config_value: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
description: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
description: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
|||||||
@@ -11,14 +11,16 @@ from sqlalchemy.orm import Session
|
|||||||
from sqlalchemy.pool import StaticPool
|
from sqlalchemy.pool import StaticPool
|
||||||
|
|
||||||
from app.api.admin_knowledge import delete_knowledge
|
from app.api.admin_knowledge import delete_knowledge
|
||||||
from app.api.admin_knowledge_lifecycle import batch_sync, batch_update_metadata, update_metadata
|
from app.api.admin_knowledge_lifecycle import batch_sync, batch_update_metadata, content_search, update_metadata
|
||||||
from app.models import Base
|
from app.models import Base
|
||||||
from app.models.admin import Admin, Role
|
from app.models.admin import Admin, Role
|
||||||
from app.models.knowledge import (
|
from app.models.knowledge import (
|
||||||
Knowledge,
|
Knowledge,
|
||||||
KnowledgeCard,
|
KnowledgeCard,
|
||||||
|
KnowledgeChunk,
|
||||||
KnowledgeManifest,
|
KnowledgeManifest,
|
||||||
KnowledgePublishLog,
|
KnowledgePublishLog,
|
||||||
|
KnowledgeSection,
|
||||||
KnowledgeSourceSnapshot,
|
KnowledgeSourceSnapshot,
|
||||||
KnowledgeSyncJob,
|
KnowledgeSyncJob,
|
||||||
KnowledgeVersion,
|
KnowledgeVersion,
|
||||||
@@ -268,3 +270,90 @@ def test_sync_validation_failure_keeps_current_version_active():
|
|||||||
assert item.current_version_id == previous_version_id
|
assert item.current_version_id == previous_version_id
|
||||||
assert current.status == "published"
|
assert current.status == "published"
|
||||||
assert len(versions) == 1
|
assert len(versions) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_content_search_finds_current_sections_cards_and_chunks():
|
||||||
|
with _database() as db:
|
||||||
|
item = _knowledge(db)
|
||||||
|
version_id = item.current_version_id
|
||||||
|
section = KnowledgeSection(
|
||||||
|
knowledge_id=item.id,
|
||||||
|
version_id=version_id,
|
||||||
|
section_key="S0098",
|
||||||
|
title="三十、练习一:静水流深静心",
|
||||||
|
content="选择舒适安全的姿势,觉察呼吸和身体,不适时立即停止。",
|
||||||
|
source_start=0,
|
||||||
|
source_end=40,
|
||||||
|
sort_order=1,
|
||||||
|
content_hash="section-hash",
|
||||||
|
)
|
||||||
|
db.add(section)
|
||||||
|
db.flush()
|
||||||
|
db.add_all([
|
||||||
|
KnowledgeChunk(
|
||||||
|
knowledge_id=item.id,
|
||||||
|
version_id=version_id,
|
||||||
|
section_id=section.id,
|
||||||
|
title=section.title,
|
||||||
|
content="静水流深静心需要选择舒适安全的姿势。",
|
||||||
|
normalized_text="静水流深静心 选择舒适安全的姿势",
|
||||||
|
keywords='["静水流深静心"]',
|
||||||
|
source_start=0,
|
||||||
|
source_end=20,
|
||||||
|
sort_order=1,
|
||||||
|
content_hash="chunk-hash",
|
||||||
|
),
|
||||||
|
KnowledgeCard(
|
||||||
|
knowledge_id=item.id,
|
||||||
|
version_id=version_id,
|
||||||
|
section_id=section.id,
|
||||||
|
title=section.title,
|
||||||
|
summary="静水流深静心的摘要",
|
||||||
|
core_conclusion="回到身体觉察",
|
||||||
|
applicable_questions="静水流深静心怎么做",
|
||||||
|
inapplicable_questions="天气",
|
||||||
|
keywords='["静水流深静心"]',
|
||||||
|
risk_level="normal",
|
||||||
|
source_range="0-40",
|
||||||
|
content_hash="card-hash",
|
||||||
|
generation_rule_version="test-v1",
|
||||||
|
review_status="approved",
|
||||||
|
),
|
||||||
|
])
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
response = content_search("静水流深", includeClosed=True, page=1, pageSize=10, db=db, current_admin=_admin())
|
||||||
|
|
||||||
|
data = response["data"]
|
||||||
|
assert data["total"] == 3
|
||||||
|
assert {item["itemType"] for item in data["items"]} == {"section", "card", "chunk"}
|
||||||
|
assert data["items"][0]["sectionKey"] == "S0098"
|
||||||
|
assert data["items"][0]["hasChunks"] is True
|
||||||
|
assert data["items"][0]["canAgentUse"] is True
|
||||||
|
assert "静水流深" in data["items"][0]["snippet"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_content_search_can_exclude_closed_knowledge():
|
||||||
|
with _database() as db:
|
||||||
|
item = _knowledge(db)
|
||||||
|
item.status = 0
|
||||||
|
db.add(item)
|
||||||
|
db.add(KnowledgeSection(
|
||||||
|
knowledge_id=item.id,
|
||||||
|
version_id=item.current_version_id,
|
||||||
|
section_key="S0001",
|
||||||
|
title="合一作业",
|
||||||
|
content="合一作业内容",
|
||||||
|
source_start=0,
|
||||||
|
source_end=10,
|
||||||
|
sort_order=1,
|
||||||
|
content_hash="closed-section",
|
||||||
|
))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
included = content_search("合一", includeClosed=True, page=1, pageSize=10, db=db, current_admin=_admin())
|
||||||
|
excluded = content_search("合一", includeClosed=False, page=1, pageSize=10, db=db, current_admin=_admin())
|
||||||
|
|
||||||
|
assert included["data"]["total"] == 1
|
||||||
|
assert included["data"]["items"][0]["canAgentUse"] is False
|
||||||
|
assert excluded["data"]["total"] == 0
|
||||||
|
|||||||
Reference in New Issue
Block a user