feat: add knowledge content search

This commit is contained in:
2026-07-30 11:02:04 +08:00
parent c2655361b5
commit d1f573108e
7 changed files with 629 additions and 8 deletions

View File

@@ -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; }

View File

@@ -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>

View File

@@ -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) =>

View File

@@ -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;

View File

@@ -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}"

View File

@@ -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)

View File

@@ -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