feat(knowledge): auto-publish successful syncs
This commit is contained in:
@@ -57,9 +57,11 @@ function selectVersion(value: number) {
|
||||
function versionStatus(version: KnowledgeVersion) {
|
||||
const labels: Record<string, string> = {
|
||||
published: "正式版本",
|
||||
pending_review: "待审核",
|
||||
approved: "待发布",
|
||||
rejected: "已拒绝",
|
||||
processing: "同步处理中",
|
||||
pending_review: "未生效版本",
|
||||
approved: "未生效版本",
|
||||
rejected: "未生效版本",
|
||||
discarded: "已废弃版本",
|
||||
superseded: "历史版本",
|
||||
};
|
||||
return labels[version.status] ?? version.status;
|
||||
@@ -136,7 +138,6 @@ function versionStatus(version: KnowledgeVersion) {
|
||||
<el-table-column prop="title" label="标题" min-width="180" />
|
||||
<el-table-column prop="summary" label="摘要" min-width="320" show-overflow-tooltip />
|
||||
<el-table-column prop="riskLevel" label="风险" width="100" />
|
||||
<el-table-column prop="reviewStatus" label="审核" width="110" />
|
||||
</el-table>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
|
||||
@@ -34,19 +34,18 @@ const batchSaving = ref(false);
|
||||
const resolvingNode = ref(false);
|
||||
const nodeResolved = ref(false);
|
||||
const sourceTitle = ref("");
|
||||
const filter = reactive({ keyword: "", type: "", open: "", source: "", pending: "" });
|
||||
const filter = reactive({ keyword: "", type: "", open: "", source: "" });
|
||||
const pager = reactive({ page: 1, pageSize: 20, total: 0 });
|
||||
const form = reactive({
|
||||
name: "",
|
||||
feishuSpaceId: "",
|
||||
feishuNodeId: "",
|
||||
knowledgeType: "general",
|
||||
reviewMode: "manual",
|
||||
status: 0,
|
||||
remark: "",
|
||||
});
|
||||
const editForm = reactive({ id: 0, name: "", knowledgeType: "general", reviewMode: "manual" });
|
||||
const batchForm = reactive({ knowledgeType: "", reviewMode: "" });
|
||||
const editForm = reactive({ id: 0, name: "", knowledgeType: "general" });
|
||||
const batchForm = reactive({ knowledgeType: "" });
|
||||
|
||||
onMounted(load);
|
||||
|
||||
@@ -55,7 +54,7 @@ async function load(page = pager.page, pageSize = pager.pageSize) {
|
||||
try {
|
||||
const result = await api.knowledge({
|
||||
keyword: filter.keyword, knowledgeType: filter.type, openStatus: filter.open,
|
||||
sourceStatus: filter.source, pending: filter.pending === "" ? undefined : filter.pending === "yes",
|
||||
sourceStatus: filter.source,
|
||||
page, pageSize,
|
||||
});
|
||||
rows.value = result.items;
|
||||
@@ -74,7 +73,6 @@ function openEdit() {
|
||||
id: detail.value.knowledge.id,
|
||||
name: detail.value.knowledge.name,
|
||||
knowledgeType: detail.value.knowledge.knowledgeType,
|
||||
reviewMode: detail.value.knowledge.reviewMode,
|
||||
});
|
||||
editOpen.value = true;
|
||||
}
|
||||
@@ -87,7 +85,7 @@ async function saveMetadata() {
|
||||
editSaving.value = true;
|
||||
try {
|
||||
await api.updateKnowledgeMetadata(editForm.id, {
|
||||
name: editForm.name.trim(), knowledgeType: editForm.knowledgeType, reviewMode: editForm.reviewMode,
|
||||
name: editForm.name.trim(), knowledgeType: editForm.knowledgeType,
|
||||
});
|
||||
ElMessage.success("知识库基本信息已更新");
|
||||
editOpen.value = false;
|
||||
@@ -104,13 +102,13 @@ function openBatchEdit() {
|
||||
ElMessage.warning("请先勾选需要修改的知识库");
|
||||
return;
|
||||
}
|
||||
Object.assign(batchForm, { knowledgeType: "", reviewMode: "" });
|
||||
Object.assign(batchForm, { knowledgeType: "" });
|
||||
batchOpen.value = true;
|
||||
}
|
||||
|
||||
async function saveBatchMetadata() {
|
||||
if (!batchForm.knowledgeType && !batchForm.reviewMode) {
|
||||
ElMessage.warning("请至少选择一个修改项");
|
||||
if (!batchForm.knowledgeType) {
|
||||
ElMessage.warning("请选择知识库类型");
|
||||
return;
|
||||
}
|
||||
if (!await confirmAction(
|
||||
@@ -123,7 +121,6 @@ async function saveBatchMetadata() {
|
||||
const result = await api.batchUpdateKnowledgeMetadata({
|
||||
knowledgeIds: selectedRows.value.map((item) => item.id),
|
||||
knowledgeType: batchForm.knowledgeType || undefined,
|
||||
reviewMode: batchForm.reviewMode || undefined,
|
||||
});
|
||||
ElMessage[result.failed ? "warning" : "success"](`批量修改完成:成功 ${result.success} 个,失败 ${result.failed} 个`);
|
||||
batchOpen.value = false;
|
||||
@@ -168,9 +165,9 @@ async function createKnowledge() {
|
||||
}
|
||||
try {
|
||||
await api.createKnowledge({ ...form });
|
||||
Object.assign(form, { name: "", feishuSpaceId: "", feishuNodeId: "", knowledgeType: "general", reviewMode: "manual", status: 0, remark: "" });
|
||||
Object.assign(form, { name: "", feishuSpaceId: "", feishuNodeId: "", knowledgeType: "general", status: 0, remark: "" });
|
||||
nodeResolved.value = false; sourceTitle.value = "";
|
||||
ElMessage.success("知识库已新增,请进行首次同步");
|
||||
ElMessage.success("知识库已新增,首次同步成功后将自动生效");
|
||||
await load();
|
||||
} catch (error) {
|
||||
ElMessage.error(errorMessage(error, "知识库新增失败"));
|
||||
@@ -216,15 +213,15 @@ async function refreshDetail(id: number) {
|
||||
return nextDetail;
|
||||
}
|
||||
|
||||
async function sync(item: KnowledgeItem, mode: "review" | "replace") {
|
||||
async function sync(item: KnowledgeItem) {
|
||||
operatingId.value = item.id;
|
||||
try {
|
||||
const result = await api.syncKnowledge(item.id, mode);
|
||||
const result = await api.syncKnowledge(item.id);
|
||||
const message = result.status === "unchanged"
|
||||
? "飞书内容未变化,未生成新版本"
|
||||
: result.status === "published"
|
||||
? "同步、质检和自动发布已完成"
|
||||
: "同步处理完成,已生成待审核版本";
|
||||
? "同步完成,最新版本已自动生效"
|
||||
: "同步完成";
|
||||
ElMessage.success(message);
|
||||
await load();
|
||||
if (detailOpen.value) await refreshDetail(item.id);
|
||||
@@ -237,7 +234,7 @@ async function sync(item: KnowledgeItem, mode: "review" | "replace") {
|
||||
|
||||
async function runMigration() {
|
||||
if (!await confirmAction(
|
||||
"将依次重新读取全部现有飞书文章并生成待审核版本。迁移审核完成前,新检索链路不会使用这些知识库。",
|
||||
"将依次重新读取全部飞书文章。每个知识库同步成功后会立即替换为最新版本,原版本仍可回滚。",
|
||||
"开始知识迁移",
|
||||
{ confirmButtonText: "开始迁移", cancelButtonText: "取消", type: "warning" },
|
||||
)) return;
|
||||
@@ -253,58 +250,6 @@ async function runMigration() {
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmManifest() {
|
||||
if (!detail.value?.pendingVersion) return;
|
||||
try {
|
||||
await api.confirmKnowledgeManifest(detail.value.knowledge.id, detail.value.pendingVersion.id);
|
||||
ElMessage.success("知识库说明卡已确认");
|
||||
await refreshDetail(detail.value.knowledge.id);
|
||||
} catch (error) {
|
||||
ElMessage.error(errorMessage(error, "说明卡确认失败"));
|
||||
}
|
||||
}
|
||||
|
||||
async function review(action: "approve" | "reject") {
|
||||
if (!detail.value?.pendingVersion) return;
|
||||
let reason = "";
|
||||
if (action === "reject") {
|
||||
try {
|
||||
const result = await ElMessageBox.prompt(
|
||||
"请记录拒绝原因。知识内容不能在后台修改,请前往飞书源文章修改后重新同步。",
|
||||
"拒绝版本",
|
||||
{ confirmButtonText: "确认拒绝", cancelButtonText: "取消", inputValidator: (value) => Boolean(value?.trim()) || "请填写拒绝原因" },
|
||||
);
|
||||
reason = result.value;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
try {
|
||||
await api.reviewKnowledgeVersion(detail.value.knowledge.id, detail.value.pendingVersion.id, action, reason);
|
||||
ElMessage.success(action === "approve" ? "版本已审核通过" : "版本已拒绝");
|
||||
await refreshDetail(detail.value.knowledge.id);
|
||||
} catch (error) {
|
||||
ElMessage.error(errorMessage(error, "版本审核失败"));
|
||||
}
|
||||
}
|
||||
|
||||
async function publish(keepOpen: boolean) {
|
||||
if (!detail.value?.pendingVersion) return;
|
||||
if (!await confirmAction(
|
||||
`将发布 V${detail.value.pendingVersion.versionNo},从用户下一条消息开始生效;发布后知识库将${keepOpen ? "保持开放" : "处于关闭状态"}。`,
|
||||
"发布知识版本",
|
||||
{ confirmButtonText: "确认发布", cancelButtonText: "取消", type: "warning" },
|
||||
)) return;
|
||||
try {
|
||||
await api.publishKnowledgeVersion(detail.value.knowledge.id, detail.value.pendingVersion.id, keepOpen);
|
||||
ElMessage.success("正式版本已发布");
|
||||
await load();
|
||||
await refreshDetail(detail.value.knowledge.id);
|
||||
} catch (error) {
|
||||
ElMessage.error(errorMessage(error, "版本发布失败"));
|
||||
}
|
||||
}
|
||||
|
||||
async function rollback(version: KnowledgeVersion) {
|
||||
if (!detail.value) return;
|
||||
if (!await confirmAction(
|
||||
@@ -399,14 +344,13 @@ async function confirmAction(
|
||||
|
||||
<template>
|
||||
<div class="page-head inline">
|
||||
<div><h2>知识库管理</h2><p>一篇飞书文章对应一个知识库;同步、审核和发布相互独立。</p></div>
|
||||
<div><h2>知识库管理</h2><p>一篇飞书文章对应一个知识库;同步成功后最新版本自动生效,历史版本可随时回滚。</p></div>
|
||||
<el-button type="primary" :loading="loading" @click="runMigration">迁移现有知识库</el-button>
|
||||
</div>
|
||||
|
||||
<section class="migration-overview">
|
||||
<div><span>知识库总数</span><strong>{{ migration?.total ?? 0 }}</strong></div>
|
||||
<div><span>待迁移</span><strong>{{ migration?.counts.pending ?? 0 }}</strong></div>
|
||||
<div><span>待审核</span><strong>{{ migration?.counts.pending_review ?? 0 }}</strong></div>
|
||||
<div><span>迁移完成</span><strong>{{ migration?.counts.completed ?? 0 }}</strong></div>
|
||||
<div><span>失败</span><strong>{{ migration?.counts.failed ?? 0 }}</strong></div>
|
||||
</section>
|
||||
@@ -425,11 +369,8 @@ async function confirmAction(
|
||||
<el-select v-model="form.knowledgeType">
|
||||
<el-option label="课程知识库" value="course" /><el-option label="答疑知识库" value="qa" /><el-option label="通用知识库" value="general" />
|
||||
</el-select>
|
||||
<el-select v-model="form.reviewMode">
|
||||
<el-option label="必须人工审核" value="manual" /><el-option label="允许自动发布" value="automatic" />
|
||||
</el-select>
|
||||
<el-input v-model="form.remark" placeholder="备注(可修改)" />
|
||||
<el-button type="primary" @click="createKnowledge">保存并准备同步</el-button>
|
||||
<el-button type="primary" @click="createKnowledge">保存知识库</el-button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -438,46 +379,42 @@ async function confirmAction(
|
||||
<el-select v-model="filter.type" placeholder="知识库类型" clearable @change="load(1)"><el-option label="课程" value="course" /><el-option label="答疑" value="qa" /><el-option label="通用" value="general" /></el-select>
|
||||
<el-select v-model="filter.open" placeholder="开放状态" clearable @change="load(1)"><el-option label="已开放" value="1" /><el-option label="已关闭" value="0" /></el-select>
|
||||
<el-select v-model="filter.source" placeholder="源状态" clearable @change="load(1)"><el-option label="正常" value="normal" /><el-option label="源异常" value="error" /></el-select>
|
||||
<el-select v-model="filter.pending" placeholder="待发布版本" clearable @change="load(1)"><el-option label="有待发布版本" value="yes" /><el-option label="无待发布版本" value="no" /></el-select>
|
||||
</div>
|
||||
|
||||
<div class="knowledge-batch-bar">
|
||||
<span>已选择 {{ selectedRows.length }} 个知识库</span>
|
||||
<el-button :disabled="selectedRows.length === 0" @click="openBatchEdit">批量修改类型/审核方式</el-button>
|
||||
<el-button :disabled="selectedRows.length === 0" @click="openBatchEdit">批量修改类型</el-button>
|
||||
</div>
|
||||
|
||||
<el-table v-loading="loading" :data="rows" stripe @selection-change="selectedRows = $event">
|
||||
<el-table-column type="selection" width="48" />
|
||||
<el-table-column label="知识库" min-width="240"><template #default="{ row }"><strong>{{ row.name }}</strong><small class="table-subline">{{ row.sourceTitle || "尚未同步源标题" }}</small></template></el-table-column>
|
||||
<el-table-column label="类型/审核" width="150"><template #default="{ row }">{{ typeLabel(row.knowledgeType) }}<small class="table-subline">{{ row.reviewMode === 'manual' ? '人工审核' : '允许自动发布' }}</small></template></el-table-column>
|
||||
<el-table-column label="版本" width="130"><template #default="{ row }"><span>正式 {{ row.currentVersionId ? `#${row.currentVersionId}` : '无' }}</span><small class="table-subline">待发布 {{ row.pendingVersionId ? `#${row.pendingVersionId}` : '无' }}</small></template></el-table-column>
|
||||
<el-table-column label="类型" width="140"><template #default="{ row }">{{ typeLabel(row.knowledgeType) }}</template></el-table-column>
|
||||
<el-table-column label="当前版本" width="130"><template #default="{ row }"><span>{{ row.currentVersionId ? `#${row.currentVersionId}` : '尚未同步' }}</span></template></el-table-column>
|
||||
<el-table-column label="状态" width="210"><template #default="{ row }"><div class="knowledge-status-cell"><el-switch v-model="row.status" :active-value="1" :inactive-value="0" active-text="开放" inactive-text="关闭" inline-prompt :loading="operatingId === row.id" :disabled="row.lifecycleStatus === 'archived'" :before-change="() => toggleOpen(row)" /><el-tag :type="statusType(row.sourceStatus)">{{ row.sourceStatus === 'normal' ? '源正常' : '源异常' }}</el-tag></div></template></el-table-column>
|
||||
<el-table-column prop="lastSyncedAt" label="最近同步" width="180" />
|
||||
<el-table-column label="操作" width="220" fixed="right"><template #default="{ row }"><TableRowActions><el-button link type="primary" @click="openDetail(row)">详情</el-button><el-button link type="primary" :disabled="row.lifecycleStatus === 'archived'" :loading="operatingId === row.id" @click="sync(row, 'review')">同步</el-button><el-dropdown trigger="click" :teleported="true" @command="handleMore($event, row)"><el-button link type="primary">更多</el-button><template #dropdown><el-dropdown-menu><el-dropdown-item command="detail">查看详情</el-dropdown-item><el-dropdown-item command="preview">进入 Agent 预览</el-dropdown-item><el-dropdown-item command="versions">查看版本</el-dropdown-item><el-dropdown-item v-if="row.lifecycleStatus === 'active'" command="archive" divided>归档</el-dropdown-item><el-dropdown-item v-else command="restore" divided>恢复</el-dropdown-item><el-dropdown-item command="delete" :disabled="row.lifecycleStatus !== 'archived'" divided>删除</el-dropdown-item></el-dropdown-menu></template></el-dropdown></TableRowActions></template></el-table-column>
|
||||
<el-table-column label="操作" width="220" fixed="right"><template #default="{ row }"><TableRowActions><el-button link type="primary" @click="openDetail(row)">详情</el-button><el-button link type="primary" :disabled="row.lifecycleStatus === 'archived'" :loading="operatingId === row.id" @click="sync(row)">同步最新版本</el-button><el-dropdown trigger="click" :teleported="true" @command="handleMore($event, row)"><el-button link type="primary">更多</el-button><template #dropdown><el-dropdown-menu><el-dropdown-item command="detail">查看详情</el-dropdown-item><el-dropdown-item command="preview">进入 Agent 预览</el-dropdown-item><el-dropdown-item command="versions">查看版本</el-dropdown-item><el-dropdown-item v-if="row.lifecycleStatus === 'active'" command="archive" divided>归档</el-dropdown-item><el-dropdown-item v-else command="restore" divided>恢复</el-dropdown-item><el-dropdown-item command="delete" :disabled="row.lifecycleStatus !== 'archived'" divided>删除</el-dropdown-item></el-dropdown-menu></template></el-dropdown></TableRowActions></template></el-table-column>
|
||||
</el-table>
|
||||
<AdminPagination :page="pager.page" :page-size="pager.pageSize" :total="pager.total" @change="load" />
|
||||
|
||||
<el-drawer v-model="detailOpen" size="76%" title="知识库详情" destroy-on-close>
|
||||
<template v-if="detail">
|
||||
<div class="knowledge-detail-head">
|
||||
<div><h3>{{ detail.knowledge.name }}</h3><p>{{ typeLabel(detail.knowledge.knowledgeType) }} · {{ detail.knowledge.reviewMode === 'manual' ? '必须人工审核' : '允许自动发布' }}</p></div>
|
||||
<div><h3>{{ detail.knowledge.name }}</h3><p>{{ typeLabel(detail.knowledge.knowledgeType) }} · 同步后自动生效</p></div>
|
||||
<div class="knowledge-detail-head-actions"><el-button type="primary" plain @click="activeTab = 'content'">预览内容</el-button><el-tag :type="detail.knowledge.status === 1 ? 'success' : 'info'">{{ detail.knowledge.status === 1 ? '已开放' : '已关闭' }}</el-tag><el-tag :type="statusType(detail.knowledge.sourceStatus)">{{ detail.knowledge.sourceStatus === 'normal' ? '源文章正常' : '源文章异常' }}</el-tag></div>
|
||||
</div>
|
||||
<el-alert v-if="detail.knowledge.sourceError" :title="detail.knowledge.sourceError" type="error" :closable="false" />
|
||||
<el-tabs v-model="activeTab">
|
||||
<el-tab-pane label="基本信息" name="overview">
|
||||
<div class="knowledge-section-head"><h4>基本信息</h4><el-button type="primary" plain @click="openEdit">编辑基本信息</el-button></div>
|
||||
<dl class="knowledge-meta"><dt>后台名称</dt><dd>{{ detail.knowledge.name }}</dd><dt>知识库类型</dt><dd>{{ typeLabel(detail.knowledge.knowledgeType) }}</dd><dt>审核方式</dt><dd>{{ detail.knowledge.reviewMode === 'manual' ? '必须人工审核发布' : '允许自动处理后发布' }}</dd><dt>SpaceID</dt><dd>{{ detail.knowledge.feishuSpaceId }}</dd><dt>NodeID</dt><dd>{{ detail.knowledge.feishuNodeId }}</dd><dt>正式版本</dt><dd>{{ detail.currentVersion ? `V${detail.currentVersion.versionNo}` : '暂无' }}</dd><dt>待发布版本</dt><dd>{{ detail.pendingVersion ? `V${detail.pendingVersion.versionNo}` : '暂无' }}</dd></dl>
|
||||
<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><el-button v-if="!detail.manifest.confirmed && detail.pendingVersion" type="primary" @click="confirmManifest">确认说明卡</el-button></section>
|
||||
<div v-if="detail.pendingVersion?.status === 'pending_review'" class="version-actions"><el-button type="success" @click="review('approve')">审核通过</el-button><el-button type="danger" @click="review('reject')">审核拒绝</el-button></div>
|
||||
<div v-else-if="detail.pendingVersion?.status === 'approved'" class="version-actions"><el-button type="primary" @click="publish(true)">发布并保持开放</el-button><el-button @click="publish(false)">发布后关闭</el-button></div>
|
||||
<el-alert v-else-if="detail.pendingVersion?.status === 'rejected'" title="该版本已被拒绝,请在飞书修改源文章后重新同步。" type="warning" :closable="false" />
|
||||
<dl class="knowledge-meta"><dt>后台名称</dt><dd>{{ detail.knowledge.name }}</dd><dt>知识库类型</dt><dd>{{ typeLabel(detail.knowledge.knowledgeType) }}</dd><dt>版本更新</dt><dd>同步成功后自动替换</dd><dt>SpaceID</dt><dd>{{ detail.knowledge.feishuSpaceId }}</dd><dt>NodeID</dt><dd>{{ detail.knowledge.feishuNodeId }}</dd><dt>当前版本</dt><dd>{{ detail.currentVersion ? `V${detail.currentVersion.versionNo}` : '暂无' }}</dd></dl>
|
||||
<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 label="内容预览" name="content" lazy>
|
||||
<KnowledgeContentPreview v-model:version-id="previewVersionId" :versions="versions" />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="版本记录" name="versions"><el-table :data="versions"><el-table-column label="版本" width="90"><template #default="{ row }">V{{ row.versionNo }}</template></el-table-column><el-table-column prop="status" label="状态" width="130" /><el-table-column prop="qualityStatus" label="质检" width="120" /><el-table-column prop="createdAt" label="生成时间" /><el-table-column prop="publishedAt" label="发布时间" /><el-table-column label="操作" width="180"><template #default="{ row }"><el-button link type="primary" @click="showVersion(row)">查看内容</el-button><el-button v-if="['published','superseded'].includes(row.status) && row.id !== detail?.knowledge.currentVersionId" link @click="rollback(row)">回滚</el-button></template></el-table-column></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="mode" label="模式" width="110" /><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>
|
||||
</template>
|
||||
</el-drawer>
|
||||
@@ -486,7 +423,6 @@ async function confirmAction(
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="后台知识库名称" required><el-input v-model="editForm.name" maxlength="100" show-word-limit /></el-form-item>
|
||||
<el-form-item label="知识库类型" required><el-select v-model="editForm.knowledgeType"><el-option label="课程知识库" value="course" /><el-option label="答疑知识库" value="qa" /><el-option label="通用知识库" value="general" /></el-select></el-form-item>
|
||||
<el-form-item label="审核方式" required><el-select v-model="editForm.reviewMode"><el-option label="必须人工审核发布" value="manual" /><el-option label="允许自动处理后发布" value="automatic" /></el-select></el-form-item>
|
||||
<el-alert title="本次修改不会重新同步飞书,也不会改变当前正式版本和开放状态。" type="info" :closable="false" />
|
||||
</el-form>
|
||||
<template #footer><el-button @click="editOpen = false">取消</el-button><el-button type="primary" :loading="editSaving" @click="saveMetadata">保存</el-button></template>
|
||||
@@ -496,7 +432,6 @@ async function confirmAction(
|
||||
<el-alert :title="`已选择 ${selectedRows.length} 个知识库,未选择的字段将保持原值。`" type="info" :closable="false" />
|
||||
<el-form label-position="top" class="batch-edit-form">
|
||||
<el-form-item label="知识库类型"><el-select v-model="batchForm.knowledgeType" clearable placeholder="不修改"><el-option label="课程知识库" value="course" /><el-option label="答疑知识库" value="qa" /><el-option label="通用知识库" value="general" /></el-select></el-form-item>
|
||||
<el-form-item label="审核方式"><el-select v-model="batchForm.reviewMode" clearable placeholder="不修改"><el-option label="必须人工审核发布" value="manual" /><el-option label="允许自动处理后发布" value="automatic" /></el-select></el-form-item>
|
||||
</el-form>
|
||||
<template #footer><el-button @click="batchOpen = false">取消</el-button><el-button type="primary" :loading="batchSaving" @click="saveBatchMetadata">下一步</el-button></template>
|
||||
</el-dialog>
|
||||
|
||||
@@ -139,9 +139,9 @@ export const api = {
|
||||
request<KnowledgeItem>("/admin/knowledge", { method: "POST", body: JSON.stringify(payload) }),
|
||||
updateKnowledge: (id: number, payload: Record<string, unknown>) =>
|
||||
request<KnowledgeItem>(`/admin/knowledge/${id}`, { method: "PUT", body: JSON.stringify(payload) }),
|
||||
updateKnowledgeMetadata: (id: number, payload: { name: string; knowledgeType: string; reviewMode: string }) =>
|
||||
updateKnowledgeMetadata: (id: number, payload: { name: string; knowledgeType: string }) =>
|
||||
request<KnowledgeItem>(`/admin/knowledge/${id}/metadata`, { method: "PATCH", body: JSON.stringify(payload) }),
|
||||
batchUpdateKnowledgeMetadata: (payload: { knowledgeIds: number[]; knowledgeType?: string; reviewMode?: string }) =>
|
||||
batchUpdateKnowledgeMetadata: (payload: { knowledgeIds: number[]; knowledgeType?: string }) =>
|
||||
request<KnowledgeBatchUpdateResult>("/admin/knowledge/batch/metadata", { method: "PATCH", body: JSON.stringify(payload) }),
|
||||
deleteKnowledge: (id: number) => request<null>(`/admin/knowledge/${id}`, { method: "DELETE" }),
|
||||
importKnowledgeFromSpace: (body: { node_token: string }) =>
|
||||
@@ -153,14 +153,8 @@ export const api = {
|
||||
knowledgeVersions: (id: number) => request<KnowledgeVersion[]>(`/admin/knowledge/${id}/versions`),
|
||||
knowledgeVersionContent: (versionId: number) => request<KnowledgeVersionContent>(`/admin/knowledge/version/${versionId}/content`),
|
||||
knowledgeSyncJobs: (id: number) => request<KnowledgeSyncJob[]>(`/admin/knowledge/${id}/sync-jobs`),
|
||||
syncKnowledge: (id: number, mode: "review" | "replace") =>
|
||||
request<KnowledgeSyncJob>(`/admin/knowledge/${id}/sync`, { method: "POST", body: JSON.stringify({ mode }) }),
|
||||
confirmKnowledgeManifest: (id: number, versionId: number) =>
|
||||
request<null>(`/admin/knowledge/${id}/manifest/${versionId}/confirm`, { method: "POST", body: "{}" }),
|
||||
reviewKnowledgeVersion: (id: number, versionId: number, action: "approve" | "reject", reason?: string) =>
|
||||
request<null>(`/admin/knowledge/${id}/version/${versionId}/review`, { method: "POST", body: JSON.stringify({ action, reason }) }),
|
||||
publishKnowledgeVersion: (id: number, versionId: number, keepOpen: boolean) =>
|
||||
request<KnowledgeItem>(`/admin/knowledge/${id}/version/${versionId}/publish`, { method: "POST", body: JSON.stringify({ keepOpen }) }),
|
||||
syncKnowledge: (id: number) =>
|
||||
request<KnowledgeSyncJob>(`/admin/knowledge/${id}/sync`, { method: "POST" }),
|
||||
rollbackKnowledgeVersion: (id: number, versionId: number, keepOpen: boolean) =>
|
||||
request<KnowledgeItem>(`/admin/knowledge/${id}/version/${versionId}/rollback`, { method: "POST", body: JSON.stringify({ keepOpen }) }),
|
||||
setKnowledgeOpen: (id: number, open: boolean) =>
|
||||
|
||||
@@ -93,13 +93,10 @@ export interface KnowledgeItem {
|
||||
remark?: string | null;
|
||||
sourceTitle?: string | null;
|
||||
knowledgeType: "course" | "qa" | "general";
|
||||
reviewMode: "manual" | "automatic";
|
||||
lifecycleStatus: "active" | "archived";
|
||||
sourceStatus: "normal" | "error";
|
||||
sourceError?: string | null;
|
||||
manifestConfirmed: boolean;
|
||||
currentVersionId?: number | null;
|
||||
pendingVersionId?: number | null;
|
||||
lastSyncedAt?: string | null;
|
||||
lastPublishedAt?: string | null;
|
||||
migrationStatus: string;
|
||||
@@ -190,7 +187,6 @@ export interface KnowledgeManifest {
|
||||
export interface KnowledgeDetail {
|
||||
knowledge: KnowledgeItem;
|
||||
currentVersion?: KnowledgeVersion | null;
|
||||
pendingVersion?: KnowledgeVersion | null;
|
||||
manifest?: KnowledgeManifest | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
"""make knowledge synchronization automatically publish
|
||||
|
||||
Revision ID: 0011_knowledge_auto_publish
|
||||
Revises: 0010_prompt_history_management
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "0011_knowledge_auto_publish"
|
||||
down_revision = "0010_prompt_history_management"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Keep the legacy column for backward compatibility while removing manual
|
||||
# review from the active workflow and API.
|
||||
op.execute("UPDATE sys_knowledge SET review_mode = 'automatic'")
|
||||
op.alter_column(
|
||||
"sys_knowledge",
|
||||
"review_mode",
|
||||
existing_type=sa.String(length=30),
|
||||
existing_nullable=False,
|
||||
server_default="automatic",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.alter_column(
|
||||
"sys_knowledge",
|
||||
"review_mode",
|
||||
existing_type=sa.String(length=30),
|
||||
existing_nullable=False,
|
||||
server_default="manual",
|
||||
)
|
||||
@@ -36,7 +36,6 @@ def list_knowledge(
|
||||
knowledgeType: str = Query(default=""),
|
||||
openStatus: int | None = Query(default=None),
|
||||
sourceStatus: str = Query(default=""),
|
||||
pending: bool | None = Query(default=None),
|
||||
page: int = Query(default=1, ge=1),
|
||||
pageSize: int = Query(default=20, ge=10, le=100),
|
||||
db: Session = Depends(get_db),
|
||||
@@ -51,8 +50,6 @@ def list_knowledge(
|
||||
query = query.where(Knowledge.status == openStatus)
|
||||
if sourceStatus:
|
||||
query = query.where(Knowledge.source_status == sourceStatus)
|
||||
if pending is not None:
|
||||
query = query.where(Knowledge.pending_version_id.is_not(None) if pending else Knowledge.pending_version_id.is_(None))
|
||||
total = db.scalar(select(func.count()).select_from(query.subquery())) or 0
|
||||
items = db.scalars(query.order_by(Knowledge.id.desc()).offset((page - 1) * pageSize).limit(pageSize)).all()
|
||||
return api_success(page_result([_knowledge_dict(item) for item in items], total=total, page=page, page_size=pageSize))
|
||||
@@ -119,7 +116,7 @@ def import_knowledge_from_space(
|
||||
feishu_node_id=node_token,
|
||||
status=0,
|
||||
knowledge_type="general",
|
||||
review_mode="manual",
|
||||
review_mode="automatic",
|
||||
remark=f"obj_type={info.get('obj_type', '')}",
|
||||
)
|
||||
db.add(knowledge)
|
||||
@@ -153,7 +150,7 @@ def create_knowledge(
|
||||
status=0,
|
||||
remark=payload.remark,
|
||||
knowledge_type=payload.knowledgeType,
|
||||
review_mode=payload.reviewMode,
|
||||
review_mode="automatic",
|
||||
)
|
||||
db.add(knowledge)
|
||||
db.flush()
|
||||
@@ -179,7 +176,7 @@ def update_knowledge(
|
||||
knowledge.name = payload.name
|
||||
knowledge.remark = payload.remark
|
||||
knowledge.knowledge_type = payload.knowledgeType
|
||||
knowledge.review_mode = payload.reviewMode
|
||||
knowledge.review_mode = "automatic"
|
||||
db.add(knowledge)
|
||||
OperationLogService.write(db, admin_id=current_admin.id, module="knowledge", action="update", target_id=knowledge.id)
|
||||
db.commit()
|
||||
@@ -243,13 +240,10 @@ def _knowledge_dict(knowledge: Knowledge) -> dict:
|
||||
"remark": knowledge.remark,
|
||||
"sourceTitle": knowledge.source_title,
|
||||
"knowledgeType": knowledge.knowledge_type,
|
||||
"reviewMode": knowledge.review_mode,
|
||||
"lifecycleStatus": knowledge.lifecycle_status,
|
||||
"sourceStatus": knowledge.source_status,
|
||||
"sourceError": knowledge.source_error,
|
||||
"manifestConfirmed": knowledge.manifest_confirmed == 1,
|
||||
"currentVersionId": knowledge.current_version_id,
|
||||
"pendingVersionId": knowledge.pending_version_id,
|
||||
"lastSyncedAt": knowledge.last_synced_at,
|
||||
"lastPublishedAt": knowledge.last_published_at,
|
||||
"migrationStatus": knowledge.migration_status,
|
||||
|
||||
@@ -23,10 +23,8 @@ from app.schemas.knowledge import (
|
||||
KnowledgeLifecycleRequest,
|
||||
KnowledgeBatchMetadataUpdateRequest,
|
||||
KnowledgeMetadataUpdateRequest,
|
||||
KnowledgePublishRequest,
|
||||
KnowledgeReviewRequest,
|
||||
KnowledgeRollbackRequest,
|
||||
KnowledgeStatusRequest,
|
||||
KnowledgeSyncRequest,
|
||||
)
|
||||
from app.services.admin_service import OperationLogService
|
||||
from app.services.knowledge_pipeline_service import KnowledgePipelineService
|
||||
@@ -41,8 +39,8 @@ def batch_update_metadata(
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
_require_super_admin(current_admin)
|
||||
if payload.knowledgeType is None and payload.reviewMode is None:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请至少选择一个待修改字段")
|
||||
if payload.knowledgeType is None:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请选择待修改的知识库类型")
|
||||
knowledge_ids = list(dict.fromkeys(payload.knowledgeIds))
|
||||
rows = {
|
||||
item.id: item
|
||||
@@ -56,8 +54,6 @@ def batch_update_metadata(
|
||||
continue
|
||||
if payload.knowledgeType is not None:
|
||||
item.knowledge_type = payload.knowledgeType
|
||||
if payload.reviewMode is not None:
|
||||
item.review_mode = payload.reviewMode
|
||||
db.add(item)
|
||||
OperationLogService.write(
|
||||
db,
|
||||
@@ -85,7 +81,7 @@ def update_metadata(
|
||||
knowledge = _knowledge(db, knowledge_id)
|
||||
knowledge.name = payload.name.strip()
|
||||
knowledge.knowledge_type = payload.knowledgeType
|
||||
knowledge.review_mode = payload.reviewMode
|
||||
knowledge.review_mode = "automatic"
|
||||
db.add(knowledge)
|
||||
OperationLogService.write(
|
||||
db, admin_id=current_admin.id, module="knowledge", action="update_metadata", target_id=knowledge.id
|
||||
@@ -104,16 +100,14 @@ def detail(
|
||||
_require_super_admin(current_admin)
|
||||
knowledge = _knowledge(db, knowledge_id)
|
||||
current = db.get(KnowledgeVersion, knowledge.current_version_id) if knowledge.current_version_id else None
|
||||
pending = db.get(KnowledgeVersion, knowledge.pending_version_id) if knowledge.pending_version_id else None
|
||||
manifest = None
|
||||
manifest_version_id = knowledge.pending_version_id or knowledge.current_version_id
|
||||
manifest_version_id = knowledge.current_version_id
|
||||
if manifest_version_id:
|
||||
manifest = db.scalar(select(KnowledgeManifest).where(KnowledgeManifest.version_id == manifest_version_id))
|
||||
return api_success(
|
||||
{
|
||||
"knowledge": _knowledge_detail(knowledge),
|
||||
"currentVersion": _version_dict(current),
|
||||
"pendingVersion": _version_dict(pending),
|
||||
"manifest": _manifest_dict(manifest),
|
||||
}
|
||||
)
|
||||
@@ -206,7 +200,6 @@ def sync_jobs(
|
||||
@router.post("/knowledge/{knowledge_id}/sync")
|
||||
async def synchronize(
|
||||
knowledge_id: int,
|
||||
payload: KnowledgeSyncRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
@@ -216,7 +209,7 @@ async def synchronize(
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="归档知识库不能同步")
|
||||
try:
|
||||
job = await KnowledgePipelineService.synchronize(
|
||||
db, knowledge, admin_id=current_admin.id, mode=payload.mode
|
||||
db, knowledge, admin_id=current_admin.id
|
||||
)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=f"知识同步失败:{exc}") from exc
|
||||
@@ -225,72 +218,11 @@ async def synchronize(
|
||||
return api_success(_job_dict(job))
|
||||
|
||||
|
||||
@router.post("/knowledge/{knowledge_id}/manifest/{version_id}/confirm")
|
||||
def confirm_manifest(
|
||||
knowledge_id: int,
|
||||
version_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
_require_super_admin(current_admin)
|
||||
knowledge, version = _knowledge_version(db, knowledge_id, version_id)
|
||||
if knowledge.pending_version_id != version.id:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="只能确认当前待发布版本的说明卡")
|
||||
try:
|
||||
KnowledgePipelineService.confirm_manifest(db, knowledge, version, current_admin.id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
|
||||
OperationLogService.write(db, admin_id=current_admin.id, module="knowledge", action="confirm_manifest", target_id=knowledge.id)
|
||||
db.commit()
|
||||
return api_success()
|
||||
|
||||
|
||||
@router.post("/knowledge/{knowledge_id}/version/{version_id}/review")
|
||||
def review(
|
||||
knowledge_id: int,
|
||||
version_id: int,
|
||||
payload: KnowledgeReviewRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
_require_super_admin(current_admin)
|
||||
knowledge, version = _knowledge_version(db, knowledge_id, version_id)
|
||||
if knowledge.pending_version_id != version.id or version.status != "pending_review":
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="只能审核当前待审核版本")
|
||||
if version.quality_status == "blocked" and payload.action == "approve":
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="自动质检存在阻断项,不能审核通过")
|
||||
KnowledgePipelineService.review_version(db, knowledge, version, current_admin.id, payload.action, payload.reason)
|
||||
OperationLogService.write(db, admin_id=current_admin.id, module="knowledge", action=f"review_{payload.action}", target_id=knowledge.id)
|
||||
db.commit()
|
||||
return api_success()
|
||||
|
||||
|
||||
@router.post("/knowledge/{knowledge_id}/version/{version_id}/publish")
|
||||
def publish(
|
||||
knowledge_id: int,
|
||||
version_id: int,
|
||||
payload: KnowledgePublishRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
_require_super_admin(current_admin)
|
||||
knowledge, version = _knowledge_version(db, knowledge_id, version_id)
|
||||
if knowledge.pending_version_id != version.id:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="只能发布当前待发布版本")
|
||||
try:
|
||||
KnowledgePipelineService.publish_version(db, knowledge, version, current_admin.id, payload.keepOpen)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
|
||||
OperationLogService.write(db, admin_id=current_admin.id, module="knowledge", action="publish", target_id=knowledge.id)
|
||||
db.commit()
|
||||
return api_success(_knowledge_detail(knowledge))
|
||||
|
||||
|
||||
@router.post("/knowledge/{knowledge_id}/version/{version_id}/rollback")
|
||||
def rollback(
|
||||
knowledge_id: int,
|
||||
version_id: int,
|
||||
payload: KnowledgePublishRequest,
|
||||
payload: KnowledgeRollbackRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
@@ -321,8 +253,6 @@ def open_status(
|
||||
if payload.open:
|
||||
if not knowledge.current_version_id:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="知识库没有正式发布版本")
|
||||
if knowledge.manifest_confirmed != 1:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="首次知识库说明卡尚未确认")
|
||||
if knowledge.source_status != "normal":
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="飞书源文章异常,不能开放")
|
||||
if knowledge.lifecycle_status != "active":
|
||||
@@ -381,7 +311,7 @@ async def run_migration(
|
||||
for knowledge in rows:
|
||||
try:
|
||||
job = await KnowledgePipelineService.synchronize(
|
||||
db, knowledge, admin_id=current_admin.id, mode="review"
|
||||
db, knowledge, admin_id=current_admin.id
|
||||
)
|
||||
results.append({"knowledgeId": knowledge.id, "ok": True, "job": _job_dict(job)})
|
||||
except Exception as exc:
|
||||
@@ -428,14 +358,11 @@ def _knowledge_detail(item: Knowledge) -> dict:
|
||||
"feishuSpaceId": item.feishu_space_id,
|
||||
"feishuNodeId": item.feishu_node_id,
|
||||
"knowledgeType": item.knowledge_type,
|
||||
"reviewMode": item.review_mode,
|
||||
"open": item.status == 1,
|
||||
"lifecycleStatus": item.lifecycle_status,
|
||||
"sourceStatus": item.source_status,
|
||||
"sourceError": item.source_error,
|
||||
"manifestConfirmed": item.manifest_confirmed == 1,
|
||||
"currentVersionId": item.current_version_id,
|
||||
"pendingVersionId": item.pending_version_id,
|
||||
"lastSyncedAt": item.last_synced_at,
|
||||
"lastPublishedAt": item.last_published_at,
|
||||
"migrationStatus": item.migration_status,
|
||||
|
||||
@@ -22,7 +22,7 @@ class Knowledge(Base, TimestampMixin):
|
||||
remark: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
source_title: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
knowledge_type: Mapped[str] = mapped_column(String(30), default="general", nullable=False)
|
||||
review_mode: Mapped[str] = mapped_column(String(30), default="manual", nullable=False)
|
||||
review_mode: Mapped[str] = mapped_column(String(30), default="automatic", nullable=False)
|
||||
lifecycle_status: Mapped[str] = mapped_column(String(30), default="active", nullable=False)
|
||||
source_status: Mapped[str] = mapped_column(String(30), default="normal", nullable=False)
|
||||
source_error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
@@ -71,7 +71,6 @@ class KnowledgeSaveRequest(BaseModel):
|
||||
status: int = Field(default=1, ge=0, le=1)
|
||||
remark: str | None = Field(default=None, max_length=255)
|
||||
knowledgeType: str = Field(default="general", pattern="^(course|qa|general)$")
|
||||
reviewMode: str = Field(default="manual", pattern="^(manual|automatic)$")
|
||||
|
||||
|
||||
class PromptSaveRequest(BaseModel):
|
||||
|
||||
@@ -3,16 +3,7 @@ from __future__ import annotations
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class KnowledgeSyncRequest(BaseModel):
|
||||
mode: str = Field(default="review", pattern="^(review|replace)$")
|
||||
|
||||
|
||||
class KnowledgeReviewRequest(BaseModel):
|
||||
action: str = Field(pattern="^(approve|reject)$")
|
||||
reason: str | None = Field(default=None, max_length=1000)
|
||||
|
||||
|
||||
class KnowledgePublishRequest(BaseModel):
|
||||
class KnowledgeRollbackRequest(BaseModel):
|
||||
keepOpen: bool = False
|
||||
|
||||
|
||||
@@ -27,13 +18,11 @@ class KnowledgeLifecycleRequest(BaseModel):
|
||||
class KnowledgeMetadataUpdateRequest(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=100)
|
||||
knowledgeType: str = Field(pattern="^(course|qa|general)$")
|
||||
reviewMode: str = Field(pattern="^(manual|automatic)$")
|
||||
|
||||
|
||||
class KnowledgeBatchMetadataUpdateRequest(BaseModel):
|
||||
knowledgeIds: list[int] = Field(min_length=1, max_length=500)
|
||||
knowledgeType: str | None = Field(default=None, pattern="^(course|qa|general)$")
|
||||
reviewMode: str | None = Field(default=None, pattern="^(manual|automatic)$")
|
||||
|
||||
|
||||
class AttentionUpdateRequest(BaseModel):
|
||||
|
||||
@@ -15,7 +15,6 @@ from app.models.knowledge import (
|
||||
KnowledgeChunk,
|
||||
KnowledgeManifest,
|
||||
KnowledgePublishLog,
|
||||
KnowledgeReviewRecord,
|
||||
KnowledgeSection,
|
||||
KnowledgeSourceSnapshot,
|
||||
KnowledgeSyncJob,
|
||||
@@ -25,7 +24,7 @@ from app.services.external_errors import ExternalServiceError
|
||||
from app.services.feishu_async_service import AsyncFeishuKnowledgeService
|
||||
from app.services.feishu_service import _feishu_retrieval_config
|
||||
|
||||
PROCESSING_RULE_VERSION = "knowledge-pipeline-v1"
|
||||
PROCESSING_RULE_VERSION = "knowledge-pipeline-v2-auto-publish"
|
||||
MAX_SECTION_CHARS = 2800
|
||||
MAX_CHUNK_CHARS = 650
|
||||
|
||||
@@ -46,13 +45,10 @@ class KnowledgePipelineService:
|
||||
knowledge: Knowledge,
|
||||
*,
|
||||
admin_id: int,
|
||||
mode: str,
|
||||
) -> KnowledgeSyncJob:
|
||||
if mode not in {"review", "replace"}:
|
||||
raise ValueError("同步模式必须为 review 或 replace")
|
||||
job = KnowledgeSyncJob(
|
||||
knowledge_id=knowledge.id,
|
||||
mode=mode,
|
||||
mode="replace",
|
||||
status="running",
|
||||
stage="fetching",
|
||||
progress=5,
|
||||
@@ -88,10 +84,38 @@ class KnowledgePipelineService:
|
||||
knowledge.source_error = None
|
||||
knowledge.source_title = source_title
|
||||
knowledge.last_synced_at = _now()
|
||||
latest_version = db.scalar(
|
||||
select(KnowledgeVersion)
|
||||
.where(KnowledgeVersion.snapshot_id == latest_snapshot.id)
|
||||
.order_by(KnowledgeVersion.id.desc())
|
||||
.limit(1)
|
||||
)
|
||||
if latest_version is not None and latest_version.id != knowledge.current_version_id:
|
||||
if latest_version.quality_status == "blocked":
|
||||
raise ValueError("最新同步版本质检未通过,已保留当前正式版本")
|
||||
cls._activate_version(
|
||||
db,
|
||||
knowledge,
|
||||
latest_version,
|
||||
admin_id,
|
||||
keep_open=knowledge.status == 1,
|
||||
action="sync_publish",
|
||||
)
|
||||
job.status = "published"
|
||||
job.result_summary = json.dumps(
|
||||
{
|
||||
"changed": False,
|
||||
"activatedExistingVersion": True,
|
||||
"versionId": latest_version.id,
|
||||
"versionNo": latest_version.version_no,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
else:
|
||||
job.status = "unchanged"
|
||||
job.result_summary = json.dumps({"changed": False}, ensure_ascii=False)
|
||||
job.stage = "completed"
|
||||
job.progress = 100
|
||||
job.result_summary = json.dumps({"changed": False}, ensure_ascii=False)
|
||||
job.finished_at = _now()
|
||||
db.add_all([knowledge, job])
|
||||
db.commit()
|
||||
@@ -188,7 +212,7 @@ class KnowledgePipelineService:
|
||||
content_hash=section.content_hash,
|
||||
generation_model="deterministic-v1",
|
||||
generation_rule_version=PROCESSING_RULE_VERSION,
|
||||
review_status="pending",
|
||||
review_status="approved",
|
||||
)
|
||||
)
|
||||
|
||||
@@ -210,7 +234,9 @@ class KnowledgePipelineService:
|
||||
boundaries=manifest_data["boundaries"],
|
||||
content_hash=_hash(json.dumps(manifest_data, ensure_ascii=False, sort_keys=True)),
|
||||
change_level=change_level,
|
||||
confirmed=1 if previous_manifest and previous_manifest.confirmed == 1 and change_level == "minor" else 0,
|
||||
confirmed=1,
|
||||
confirmed_by=admin_id,
|
||||
confirmed_at=_now(),
|
||||
)
|
||||
db.add(manifest)
|
||||
|
||||
@@ -222,40 +248,32 @@ class KnowledgePipelineService:
|
||||
{"sections": len(sections), "chunks": chunk_count, "manifestChange": change_level},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
version.status = "pending_review"
|
||||
if quality["blocking"]:
|
||||
issue_text = ";".join(
|
||||
item["message"] for item in quality["issues"] if item["level"] == "blocking"
|
||||
)
|
||||
raise ValueError(f"同步内容质检未通过:{issue_text or '存在阻断项'}")
|
||||
|
||||
knowledge.source_title = source_title
|
||||
knowledge.source_status = "normal"
|
||||
knowledge.source_error = None
|
||||
knowledge.pending_version_id = version.id
|
||||
knowledge.last_synced_at = _now()
|
||||
knowledge.migration_status = "pending_review"
|
||||
job.status = "pending_review"
|
||||
job.stage = "completed"
|
||||
job.progress = 100
|
||||
job.result_summary = json.dumps(
|
||||
{"versionId": version.id, "versionNo": version.version_no, **quality}, ensure_ascii=False
|
||||
)
|
||||
job.finished_at = _now()
|
||||
if (
|
||||
knowledge.review_mode == "automatic"
|
||||
and version.quality_status == "passed"
|
||||
and not quality["highRisk"]
|
||||
and manifest.confirmed == 1
|
||||
):
|
||||
cls.review_version(
|
||||
cls._activate_version(
|
||||
db,
|
||||
knowledge,
|
||||
version,
|
||||
admin_id,
|
||||
"approve",
|
||||
"自动审核方式:质检通过且说明卡可继承,自动发布。",
|
||||
keep_open=knowledge.status == 1,
|
||||
action="sync_publish",
|
||||
)
|
||||
cls.publish_version(db, knowledge, version, admin_id, keep_open=knowledge.status == 1)
|
||||
job.status = "published"
|
||||
job.stage = "completed"
|
||||
job.progress = 100
|
||||
job.result_summary = json.dumps(
|
||||
{"versionId": version.id, "versionNo": version.version_no, "autoPublished": True, **quality},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
job.finished_at = _now()
|
||||
db.add_all([knowledge, version, job])
|
||||
db.commit()
|
||||
return job
|
||||
@@ -278,57 +296,34 @@ class KnowledgePipelineService:
|
||||
raise
|
||||
|
||||
@staticmethod
|
||||
def confirm_manifest(db: Session, knowledge: Knowledge, version: KnowledgeVersion, admin_id: int) -> None:
|
||||
manifest = db.scalar(select(KnowledgeManifest).where(KnowledgeManifest.version_id == version.id))
|
||||
if manifest is None:
|
||||
raise ValueError("该版本没有知识库说明卡")
|
||||
manifest.confirmed = 1
|
||||
manifest.confirmed_by = admin_id
|
||||
manifest.confirmed_at = _now()
|
||||
if knowledge.pending_version_id == version.id:
|
||||
knowledge.manifest_confirmed = 1
|
||||
db.add_all([manifest, knowledge])
|
||||
|
||||
@staticmethod
|
||||
def review_version(db: Session, knowledge: Knowledge, version: KnowledgeVersion, admin_id: int, action: str, reason: str | None) -> None:
|
||||
if action not in {"approve", "reject"}:
|
||||
raise ValueError("审核动作不正确")
|
||||
cards = db.scalars(select(KnowledgeCard).where(KnowledgeCard.version_id == version.id)).all()
|
||||
review_status = "approved" if action == "approve" else "rejected"
|
||||
for card in cards:
|
||||
card.review_status = review_status
|
||||
db.add(card)
|
||||
version.status = "approved" if action == "approve" else "rejected"
|
||||
db.add_all(
|
||||
[
|
||||
version,
|
||||
KnowledgeReviewRecord(
|
||||
knowledge_id=knowledge.id,
|
||||
version_id=version.id,
|
||||
action=action,
|
||||
reason=reason,
|
||||
reviewed_by=admin_id,
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def publish_version(db: Session, knowledge: Knowledge, version: KnowledgeVersion, admin_id: int, keep_open: bool) -> None:
|
||||
if version.status != "approved":
|
||||
raise ValueError("只有审核通过的版本可以发布")
|
||||
manifest = db.scalar(select(KnowledgeManifest).where(KnowledgeManifest.version_id == version.id))
|
||||
if manifest is None or manifest.confirmed != 1:
|
||||
raise ValueError("知识库说明卡尚未确认")
|
||||
def _activate_version(
|
||||
db: Session,
|
||||
knowledge: Knowledge,
|
||||
version: KnowledgeVersion,
|
||||
admin_id: int,
|
||||
*,
|
||||
keep_open: bool,
|
||||
action: str,
|
||||
) -> None:
|
||||
"""Atomically activate a validated version and preserve rollback history."""
|
||||
if version.quality_status == "blocked":
|
||||
raise ValueError("质检未通过的版本不能生效")
|
||||
if keep_open and knowledge.lifecycle_status != "active":
|
||||
raise ValueError("归档知识库不能在发布后开放")
|
||||
raise ValueError("归档知识库不能在版本替换后开放")
|
||||
if keep_open and knowledge.source_status != "normal":
|
||||
raise ValueError("飞书源文章异常,不能在发布后开放")
|
||||
raise ValueError("飞书源文章异常,不能替换正式版本")
|
||||
previous_version_id = knowledge.current_version_id
|
||||
if previous_version_id:
|
||||
previous = db.get(KnowledgeVersion, previous_version_id)
|
||||
if previous:
|
||||
if previous and previous.id != version.id:
|
||||
previous.status = "superseded"
|
||||
db.add(previous)
|
||||
previous_pending_id = knowledge.pending_version_id
|
||||
if previous_pending_id and previous_pending_id not in {previous_version_id, version.id}:
|
||||
previous_pending = db.get(KnowledgeVersion, previous_pending_id)
|
||||
if previous_pending and previous_pending.status not in {"published", "superseded"}:
|
||||
previous_pending.status = "discarded"
|
||||
db.add(previous_pending)
|
||||
version.status = "published"
|
||||
version.published_by = admin_id
|
||||
version.published_at = _now()
|
||||
@@ -346,7 +341,7 @@ class KnowledgePipelineService:
|
||||
knowledge_id=knowledge.id,
|
||||
from_version_id=previous_version_id,
|
||||
to_version_id=version.id,
|
||||
action="publish",
|
||||
action=action,
|
||||
keep_open=knowledge.status,
|
||||
operated_by=admin_id,
|
||||
),
|
||||
@@ -510,7 +505,7 @@ def quality_check(content: str, sections: list[ParsedSection], chunk_count: int)
|
||||
issues.append({"level": "warning", "code": "duplicate_sections", "message": "存在重复章节"})
|
||||
high_risk = detect_risk_level(content) == "high"
|
||||
if high_risk:
|
||||
issues.append({"level": "warning", "code": "high_risk", "message": "包含高风险主题,需要重点审核"})
|
||||
issues.append({"level": "warning", "code": "high_risk", "message": "包含高风险主题,请重点关注内容准确性"})
|
||||
return {
|
||||
"blocking": any(item["level"] == "blocking" for item in issues),
|
||||
"issues": issues,
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import create_engine, select
|
||||
@@ -9,7 +12,14 @@ from sqlalchemy.pool import StaticPool
|
||||
from app.api.admin_knowledge_lifecycle import batch_update_metadata, update_metadata
|
||||
from app.models import Base
|
||||
from app.models.admin import Admin, Role
|
||||
from app.models.knowledge import Knowledge, KnowledgeManifest, KnowledgeSourceSnapshot, KnowledgeVersion
|
||||
from app.models.knowledge import (
|
||||
Knowledge,
|
||||
KnowledgeCard,
|
||||
KnowledgeManifest,
|
||||
KnowledgePublishLog,
|
||||
KnowledgeSourceSnapshot,
|
||||
KnowledgeVersion,
|
||||
)
|
||||
from app.schemas.knowledge import KnowledgeBatchMetadataUpdateRequest, KnowledgeMetadataUpdateRequest
|
||||
from app.services.knowledge_agent_service import KnowledgeAgentService
|
||||
from app.services.knowledge_pipeline_service import KnowledgePipelineService
|
||||
@@ -82,7 +92,7 @@ def test_metadata_update_preserves_source_version_and_open_state():
|
||||
original = (item.feishu_space_id, item.feishu_node_id, item.current_version_id, item.status)
|
||||
update_metadata(
|
||||
item.id,
|
||||
KnowledgeMetadataUpdateRequest(name="新后台名称", knowledgeType="course", reviewMode="automatic"),
|
||||
KnowledgeMetadataUpdateRequest(name="新后台名称", knowledgeType="course"),
|
||||
db,
|
||||
_admin(),
|
||||
)
|
||||
@@ -98,7 +108,7 @@ def test_metadata_update_requires_super_admin():
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
update_metadata(
|
||||
item.id,
|
||||
KnowledgeMetadataUpdateRequest(name="新名称", knowledgeType="qa", reviewMode="manual"),
|
||||
KnowledgeMetadataUpdateRequest(name="新名称", knowledgeType="qa"),
|
||||
db,
|
||||
_admin("operator"),
|
||||
)
|
||||
@@ -138,3 +148,76 @@ def test_rollback_switches_published_version_statuses():
|
||||
assert item.current_version_id == target.id
|
||||
assert target.status == "published"
|
||||
assert old_version.status == "superseded"
|
||||
|
||||
|
||||
def test_sync_automatically_replaces_current_version_and_keeps_rollback_history():
|
||||
source_content = (
|
||||
"# 一、课程说明\n这是更新后的课程内容,同步成功后应当立即生效。\n\n"
|
||||
"# 二、练习一:静水流深静心\n选择舒适安全的姿势,觉察呼吸和身体,不适时立即停止。"
|
||||
)
|
||||
with _database() as db:
|
||||
item = _knowledge(db)
|
||||
previous_version_id = item.current_version_id
|
||||
loader = AsyncMock(return_value=[("新源标题", source_content, None)])
|
||||
|
||||
with patch(
|
||||
"app.services.knowledge_pipeline_service.AsyncFeishuKnowledgeService._load_documents",
|
||||
loader,
|
||||
):
|
||||
job = asyncio.run(
|
||||
KnowledgePipelineService.synchronize(
|
||||
db,
|
||||
item,
|
||||
admin_id=1,
|
||||
)
|
||||
)
|
||||
|
||||
db.refresh(item)
|
||||
current = db.get(KnowledgeVersion, item.current_version_id)
|
||||
previous = db.get(KnowledgeVersion, previous_version_id)
|
||||
manifest = db.scalar(select(KnowledgeManifest).where(KnowledgeManifest.version_id == current.id))
|
||||
cards = db.scalars(select(KnowledgeCard).where(KnowledgeCard.version_id == current.id)).all()
|
||||
publish_log = db.scalar(
|
||||
select(KnowledgePublishLog).where(KnowledgePublishLog.to_version_id == current.id)
|
||||
)
|
||||
|
||||
assert job.status == "published"
|
||||
assert job.mode == "replace"
|
||||
assert current.status == "published"
|
||||
assert current.quality_status == "passed"
|
||||
assert previous.status == "superseded"
|
||||
assert item.pending_version_id is None
|
||||
assert item.status == 1
|
||||
assert item.migration_status == "completed"
|
||||
assert manifest.confirmed == 1
|
||||
assert cards and all(card.review_status == "approved" for card in cards)
|
||||
assert publish_log.action == "sync_publish"
|
||||
|
||||
|
||||
def test_sync_validation_failure_keeps_current_version_active():
|
||||
with _database() as db:
|
||||
item = _knowledge(db)
|
||||
previous_version_id = item.current_version_id
|
||||
loader = AsyncMock(return_value=[("无效源内容", "内容过短", None)])
|
||||
|
||||
with patch(
|
||||
"app.services.knowledge_pipeline_service.AsyncFeishuKnowledgeService._load_documents",
|
||||
loader,
|
||||
):
|
||||
with pytest.raises(ValueError, match="质检未通过"):
|
||||
asyncio.run(
|
||||
KnowledgePipelineService.synchronize(
|
||||
db,
|
||||
item,
|
||||
admin_id=1,
|
||||
)
|
||||
)
|
||||
|
||||
db.refresh(item)
|
||||
current = db.get(KnowledgeVersion, previous_version_id)
|
||||
versions = db.scalars(
|
||||
select(KnowledgeVersion).where(KnowledgeVersion.knowledge_id == item.id)
|
||||
).all()
|
||||
assert item.current_version_id == previous_version_id
|
||||
assert current.status == "published"
|
||||
assert len(versions) == 1
|
||||
|
||||
Reference in New Issue
Block a user