feat(knowledge): auto-publish successful syncs
This commit is contained in:
@@ -57,9 +57,11 @@ function selectVersion(value: number) {
|
|||||||
function versionStatus(version: KnowledgeVersion) {
|
function versionStatus(version: KnowledgeVersion) {
|
||||||
const labels: Record<string, string> = {
|
const labels: Record<string, string> = {
|
||||||
published: "正式版本",
|
published: "正式版本",
|
||||||
pending_review: "待审核",
|
processing: "同步处理中",
|
||||||
approved: "待发布",
|
pending_review: "未生效版本",
|
||||||
rejected: "已拒绝",
|
approved: "未生效版本",
|
||||||
|
rejected: "未生效版本",
|
||||||
|
discarded: "已废弃版本",
|
||||||
superseded: "历史版本",
|
superseded: "历史版本",
|
||||||
};
|
};
|
||||||
return labels[version.status] ?? version.status;
|
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="title" label="标题" min-width="180" />
|
||||||
<el-table-column prop="summary" label="摘要" min-width="320" show-overflow-tooltip />
|
<el-table-column prop="summary" label="摘要" min-width="320" show-overflow-tooltip />
|
||||||
<el-table-column prop="riskLevel" label="风险" width="100" />
|
<el-table-column prop="riskLevel" label="风险" width="100" />
|
||||||
<el-table-column prop="reviewStatus" label="审核" width="110" />
|
|
||||||
</el-table>
|
</el-table>
|
||||||
</el-collapse-item>
|
</el-collapse-item>
|
||||||
</el-collapse>
|
</el-collapse>
|
||||||
|
|||||||
@@ -34,19 +34,18 @@ const batchSaving = ref(false);
|
|||||||
const resolvingNode = ref(false);
|
const resolvingNode = ref(false);
|
||||||
const nodeResolved = ref(false);
|
const nodeResolved = ref(false);
|
||||||
const sourceTitle = ref("");
|
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 pager = reactive({ page: 1, pageSize: 20, total: 0 });
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
name: "",
|
name: "",
|
||||||
feishuSpaceId: "",
|
feishuSpaceId: "",
|
||||||
feishuNodeId: "",
|
feishuNodeId: "",
|
||||||
knowledgeType: "general",
|
knowledgeType: "general",
|
||||||
reviewMode: "manual",
|
|
||||||
status: 0,
|
status: 0,
|
||||||
remark: "",
|
remark: "",
|
||||||
});
|
});
|
||||||
const editForm = reactive({ id: 0, name: "", knowledgeType: "general", reviewMode: "manual" });
|
const editForm = reactive({ id: 0, name: "", knowledgeType: "general" });
|
||||||
const batchForm = reactive({ knowledgeType: "", reviewMode: "" });
|
const batchForm = reactive({ knowledgeType: "" });
|
||||||
|
|
||||||
onMounted(load);
|
onMounted(load);
|
||||||
|
|
||||||
@@ -55,7 +54,7 @@ async function load(page = pager.page, pageSize = pager.pageSize) {
|
|||||||
try {
|
try {
|
||||||
const result = await api.knowledge({
|
const result = await api.knowledge({
|
||||||
keyword: filter.keyword, knowledgeType: filter.type, openStatus: filter.open,
|
keyword: filter.keyword, knowledgeType: filter.type, openStatus: filter.open,
|
||||||
sourceStatus: filter.source, pending: filter.pending === "" ? undefined : filter.pending === "yes",
|
sourceStatus: filter.source,
|
||||||
page, pageSize,
|
page, pageSize,
|
||||||
});
|
});
|
||||||
rows.value = result.items;
|
rows.value = result.items;
|
||||||
@@ -74,7 +73,6 @@ function openEdit() {
|
|||||||
id: detail.value.knowledge.id,
|
id: detail.value.knowledge.id,
|
||||||
name: detail.value.knowledge.name,
|
name: detail.value.knowledge.name,
|
||||||
knowledgeType: detail.value.knowledge.knowledgeType,
|
knowledgeType: detail.value.knowledge.knowledgeType,
|
||||||
reviewMode: detail.value.knowledge.reviewMode,
|
|
||||||
});
|
});
|
||||||
editOpen.value = true;
|
editOpen.value = true;
|
||||||
}
|
}
|
||||||
@@ -87,7 +85,7 @@ async function saveMetadata() {
|
|||||||
editSaving.value = true;
|
editSaving.value = true;
|
||||||
try {
|
try {
|
||||||
await api.updateKnowledgeMetadata(editForm.id, {
|
await api.updateKnowledgeMetadata(editForm.id, {
|
||||||
name: editForm.name.trim(), knowledgeType: editForm.knowledgeType, reviewMode: editForm.reviewMode,
|
name: editForm.name.trim(), knowledgeType: editForm.knowledgeType,
|
||||||
});
|
});
|
||||||
ElMessage.success("知识库基本信息已更新");
|
ElMessage.success("知识库基本信息已更新");
|
||||||
editOpen.value = false;
|
editOpen.value = false;
|
||||||
@@ -104,13 +102,13 @@ function openBatchEdit() {
|
|||||||
ElMessage.warning("请先勾选需要修改的知识库");
|
ElMessage.warning("请先勾选需要修改的知识库");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Object.assign(batchForm, { knowledgeType: "", reviewMode: "" });
|
Object.assign(batchForm, { knowledgeType: "" });
|
||||||
batchOpen.value = true;
|
batchOpen.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveBatchMetadata() {
|
async function saveBatchMetadata() {
|
||||||
if (!batchForm.knowledgeType && !batchForm.reviewMode) {
|
if (!batchForm.knowledgeType) {
|
||||||
ElMessage.warning("请至少选择一个修改项");
|
ElMessage.warning("请选择知识库类型");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!await confirmAction(
|
if (!await confirmAction(
|
||||||
@@ -123,7 +121,6 @@ async function saveBatchMetadata() {
|
|||||||
const result = await api.batchUpdateKnowledgeMetadata({
|
const result = await api.batchUpdateKnowledgeMetadata({
|
||||||
knowledgeIds: selectedRows.value.map((item) => item.id),
|
knowledgeIds: selectedRows.value.map((item) => item.id),
|
||||||
knowledgeType: batchForm.knowledgeType || undefined,
|
knowledgeType: batchForm.knowledgeType || undefined,
|
||||||
reviewMode: batchForm.reviewMode || undefined,
|
|
||||||
});
|
});
|
||||||
ElMessage[result.failed ? "warning" : "success"](`批量修改完成:成功 ${result.success} 个,失败 ${result.failed} 个`);
|
ElMessage[result.failed ? "warning" : "success"](`批量修改完成:成功 ${result.success} 个,失败 ${result.failed} 个`);
|
||||||
batchOpen.value = false;
|
batchOpen.value = false;
|
||||||
@@ -168,9 +165,9 @@ async function createKnowledge() {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await api.createKnowledge({ ...form });
|
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 = "";
|
nodeResolved.value = false; sourceTitle.value = "";
|
||||||
ElMessage.success("知识库已新增,请进行首次同步");
|
ElMessage.success("知识库已新增,首次同步成功后将自动生效");
|
||||||
await load();
|
await load();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error(errorMessage(error, "知识库新增失败"));
|
ElMessage.error(errorMessage(error, "知识库新增失败"));
|
||||||
@@ -216,15 +213,15 @@ async function refreshDetail(id: number) {
|
|||||||
return nextDetail;
|
return nextDetail;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sync(item: KnowledgeItem, mode: "review" | "replace") {
|
async function sync(item: KnowledgeItem) {
|
||||||
operatingId.value = item.id;
|
operatingId.value = item.id;
|
||||||
try {
|
try {
|
||||||
const result = await api.syncKnowledge(item.id, mode);
|
const result = await api.syncKnowledge(item.id);
|
||||||
const message = result.status === "unchanged"
|
const message = result.status === "unchanged"
|
||||||
? "飞书内容未变化,未生成新版本"
|
? "飞书内容未变化,未生成新版本"
|
||||||
: result.status === "published"
|
: result.status === "published"
|
||||||
? "同步、质检和自动发布已完成"
|
? "同步完成,最新版本已自动生效"
|
||||||
: "同步处理完成,已生成待审核版本";
|
: "同步完成";
|
||||||
ElMessage.success(message);
|
ElMessage.success(message);
|
||||||
await load();
|
await load();
|
||||||
if (detailOpen.value) await refreshDetail(item.id);
|
if (detailOpen.value) await refreshDetail(item.id);
|
||||||
@@ -237,7 +234,7 @@ async function sync(item: KnowledgeItem, mode: "review" | "replace") {
|
|||||||
|
|
||||||
async function runMigration() {
|
async function runMigration() {
|
||||||
if (!await confirmAction(
|
if (!await confirmAction(
|
||||||
"将依次重新读取全部现有飞书文章并生成待审核版本。迁移审核完成前,新检索链路不会使用这些知识库。",
|
"将依次重新读取全部飞书文章。每个知识库同步成功后会立即替换为最新版本,原版本仍可回滚。",
|
||||||
"开始知识迁移",
|
"开始知识迁移",
|
||||||
{ confirmButtonText: "开始迁移", cancelButtonText: "取消", type: "warning" },
|
{ confirmButtonText: "开始迁移", cancelButtonText: "取消", type: "warning" },
|
||||||
)) return;
|
)) 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) {
|
async function rollback(version: KnowledgeVersion) {
|
||||||
if (!detail.value) return;
|
if (!detail.value) return;
|
||||||
if (!await confirmAction(
|
if (!await confirmAction(
|
||||||
@@ -399,14 +344,13 @@ async function confirmAction(
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="page-head inline">
|
<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>
|
<el-button type="primary" :loading="loading" @click="runMigration">迁移现有知识库</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section class="migration-overview">
|
<section class="migration-overview">
|
||||||
<div><span>知识库总数</span><strong>{{ migration?.total ?? 0 }}</strong></div>
|
<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 ?? 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.completed ?? 0 }}</strong></div>
|
||||||
<div><span>失败</span><strong>{{ migration?.counts.failed ?? 0 }}</strong></div>
|
<div><span>失败</span><strong>{{ migration?.counts.failed ?? 0 }}</strong></div>
|
||||||
</section>
|
</section>
|
||||||
@@ -425,11 +369,8 @@ async function confirmAction(
|
|||||||
<el-select v-model="form.knowledgeType">
|
<el-select v-model="form.knowledgeType">
|
||||||
<el-option label="课程知识库" value="course" /><el-option label="答疑知识库" value="qa" /><el-option label="通用知识库" value="general" />
|
<el-option label="课程知识库" value="course" /><el-option label="答疑知识库" value="qa" /><el-option label="通用知识库" value="general" />
|
||||||
</el-select>
|
</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-input v-model="form.remark" placeholder="备注(可修改)" />
|
||||||
<el-button type="primary" @click="createKnowledge">保存并准备同步</el-button>
|
<el-button type="primary" @click="createKnowledge">保存知识库</el-button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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.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.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.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>
|
||||||
|
|
||||||
<div class="knowledge-batch-bar">
|
<div class="knowledge-batch-bar">
|
||||||
<span>已选择 {{ selectedRows.length }} 个知识库</span>
|
<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>
|
</div>
|
||||||
|
|
||||||
<el-table v-loading="loading" :data="rows" stripe @selection-change="selectedRows = $event">
|
<el-table v-loading="loading" :data="rows" stripe @selection-change="selectedRows = $event">
|
||||||
<el-table-column type="selection" width="48" />
|
<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="知识库" 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="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><small class="table-subline">待发布 {{ row.pendingVersionId ? `#${row.pendingVersionId}` : '无' }}</small></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 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 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>
|
</el-table>
|
||||||
<AdminPagination :page="pager.page" :page-size="pager.pageSize" :total="pager.total" @change="load" />
|
<AdminPagination :page="pager.page" :page-size="pager.pageSize" :total="pager.total" @change="load" />
|
||||||
|
|
||||||
<el-drawer v-model="detailOpen" size="76%" title="知识库详情" destroy-on-close>
|
<el-drawer v-model="detailOpen" size="76%" title="知识库详情" destroy-on-close>
|
||||||
<template v-if="detail">
|
<template v-if="detail">
|
||||||
<div class="knowledge-detail-head">
|
<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 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>
|
</div>
|
||||||
<el-alert v-if="detail.knowledge.sourceError" :title="detail.knowledge.sourceError" type="error" :closable="false" />
|
<el-alert v-if="detail.knowledge.sourceError" :title="detail.knowledge.sourceError" type="error" :closable="false" />
|
||||||
<el-tabs v-model="activeTab">
|
<el-tabs v-model="activeTab">
|
||||||
<el-tab-pane label="基本信息" name="overview">
|
<el-tab-pane label="基本信息" name="overview">
|
||||||
<div class="knowledge-section-head"><h4>基本信息</h4><el-button type="primary" plain @click="openEdit">编辑基本信息</el-button></div>
|
<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>
|
<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><el-button v-if="!detail.manifest.confirmed && detail.pendingVersion" type="primary" @click="confirmManifest">确认说明卡</el-button></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>
|
||||||
<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" />
|
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
<el-tab-pane label="内容预览" name="content" lazy>
|
<el-tab-pane label="内容预览" name="content" lazy>
|
||||||
<KnowledgeContentPreview v-model:version-id="previewVersionId" :versions="versions" />
|
<KnowledgeContentPreview v-model:version-id="previewVersionId" :versions="versions" />
|
||||||
</el-tab-pane>
|
</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="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>
|
</el-tabs>
|
||||||
</template>
|
</template>
|
||||||
</el-drawer>
|
</el-drawer>
|
||||||
@@ -486,7 +423,6 @@ async function confirmAction(
|
|||||||
<el-form label-position="top">
|
<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-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.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-alert title="本次修改不会重新同步飞书,也不会改变当前正式版本和开放状态。" type="info" :closable="false" />
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer><el-button @click="editOpen = false">取消</el-button><el-button type="primary" :loading="editSaving" @click="saveMetadata">保存</el-button></template>
|
<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-alert :title="`已选择 ${selectedRows.length} 个知识库,未选择的字段将保持原值。`" type="info" :closable="false" />
|
||||||
<el-form label-position="top" class="batch-edit-form">
|
<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.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>
|
</el-form>
|
||||||
<template #footer><el-button @click="batchOpen = false">取消</el-button><el-button type="primary" :loading="batchSaving" @click="saveBatchMetadata">下一步</el-button></template>
|
<template #footer><el-button @click="batchOpen = false">取消</el-button><el-button type="primary" :loading="batchSaving" @click="saveBatchMetadata">下一步</el-button></template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|||||||
@@ -139,9 +139,9 @@ export const api = {
|
|||||||
request<KnowledgeItem>("/admin/knowledge", { method: "POST", body: JSON.stringify(payload) }),
|
request<KnowledgeItem>("/admin/knowledge", { method: "POST", body: JSON.stringify(payload) }),
|
||||||
updateKnowledge: (id: number, payload: Record<string, unknown>) =>
|
updateKnowledge: (id: number, payload: Record<string, unknown>) =>
|
||||||
request<KnowledgeItem>(`/admin/knowledge/${id}`, { method: "PUT", body: JSON.stringify(payload) }),
|
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) }),
|
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) }),
|
request<KnowledgeBatchUpdateResult>("/admin/knowledge/batch/metadata", { method: "PATCH", body: JSON.stringify(payload) }),
|
||||||
deleteKnowledge: (id: number) => request<null>(`/admin/knowledge/${id}`, { method: "DELETE" }),
|
deleteKnowledge: (id: number) => request<null>(`/admin/knowledge/${id}`, { method: "DELETE" }),
|
||||||
importKnowledgeFromSpace: (body: { node_token: string }) =>
|
importKnowledgeFromSpace: (body: { node_token: string }) =>
|
||||||
@@ -153,14 +153,8 @@ export const api = {
|
|||||||
knowledgeVersions: (id: number) => request<KnowledgeVersion[]>(`/admin/knowledge/${id}/versions`),
|
knowledgeVersions: (id: number) => request<KnowledgeVersion[]>(`/admin/knowledge/${id}/versions`),
|
||||||
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, mode: "review" | "replace") =>
|
syncKnowledge: (id: number) =>
|
||||||
request<KnowledgeSyncJob>(`/admin/knowledge/${id}/sync`, { method: "POST", body: JSON.stringify({ mode }) }),
|
request<KnowledgeSyncJob>(`/admin/knowledge/${id}/sync`, { method: "POST" }),
|
||||||
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 }) }),
|
|
||||||
rollbackKnowledgeVersion: (id: number, versionId: number, keepOpen: boolean) =>
|
rollbackKnowledgeVersion: (id: number, versionId: number, keepOpen: boolean) =>
|
||||||
request<KnowledgeItem>(`/admin/knowledge/${id}/version/${versionId}/rollback`, { method: "POST", body: JSON.stringify({ keepOpen }) }),
|
request<KnowledgeItem>(`/admin/knowledge/${id}/version/${versionId}/rollback`, { method: "POST", body: JSON.stringify({ keepOpen }) }),
|
||||||
setKnowledgeOpen: (id: number, open: boolean) =>
|
setKnowledgeOpen: (id: number, open: boolean) =>
|
||||||
|
|||||||
@@ -93,13 +93,10 @@ export interface KnowledgeItem {
|
|||||||
remark?: string | null;
|
remark?: string | null;
|
||||||
sourceTitle?: string | null;
|
sourceTitle?: string | null;
|
||||||
knowledgeType: "course" | "qa" | "general";
|
knowledgeType: "course" | "qa" | "general";
|
||||||
reviewMode: "manual" | "automatic";
|
|
||||||
lifecycleStatus: "active" | "archived";
|
lifecycleStatus: "active" | "archived";
|
||||||
sourceStatus: "normal" | "error";
|
sourceStatus: "normal" | "error";
|
||||||
sourceError?: string | null;
|
sourceError?: string | null;
|
||||||
manifestConfirmed: boolean;
|
|
||||||
currentVersionId?: number | null;
|
currentVersionId?: number | null;
|
||||||
pendingVersionId?: number | null;
|
|
||||||
lastSyncedAt?: string | null;
|
lastSyncedAt?: string | null;
|
||||||
lastPublishedAt?: string | null;
|
lastPublishedAt?: string | null;
|
||||||
migrationStatus: string;
|
migrationStatus: string;
|
||||||
@@ -190,7 +187,6 @@ export interface KnowledgeManifest {
|
|||||||
export interface KnowledgeDetail {
|
export interface KnowledgeDetail {
|
||||||
knowledge: KnowledgeItem;
|
knowledge: KnowledgeItem;
|
||||||
currentVersion?: KnowledgeVersion | null;
|
currentVersion?: KnowledgeVersion | null;
|
||||||
pendingVersion?: KnowledgeVersion | null;
|
|
||||||
manifest?: KnowledgeManifest | 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=""),
|
knowledgeType: str = Query(default=""),
|
||||||
openStatus: int | None = Query(default=None),
|
openStatus: int | None = Query(default=None),
|
||||||
sourceStatus: str = Query(default=""),
|
sourceStatus: str = Query(default=""),
|
||||||
pending: bool | None = Query(default=None),
|
|
||||||
page: int = Query(default=1, ge=1),
|
page: int = Query(default=1, ge=1),
|
||||||
pageSize: int = Query(default=20, ge=10, le=100),
|
pageSize: int = Query(default=20, ge=10, le=100),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
@@ -51,8 +50,6 @@ def list_knowledge(
|
|||||||
query = query.where(Knowledge.status == openStatus)
|
query = query.where(Knowledge.status == openStatus)
|
||||||
if sourceStatus:
|
if sourceStatus:
|
||||||
query = query.where(Knowledge.source_status == 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
|
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()
|
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))
|
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,
|
feishu_node_id=node_token,
|
||||||
status=0,
|
status=0,
|
||||||
knowledge_type="general",
|
knowledge_type="general",
|
||||||
review_mode="manual",
|
review_mode="automatic",
|
||||||
remark=f"obj_type={info.get('obj_type', '')}",
|
remark=f"obj_type={info.get('obj_type', '')}",
|
||||||
)
|
)
|
||||||
db.add(knowledge)
|
db.add(knowledge)
|
||||||
@@ -153,7 +150,7 @@ def create_knowledge(
|
|||||||
status=0,
|
status=0,
|
||||||
remark=payload.remark,
|
remark=payload.remark,
|
||||||
knowledge_type=payload.knowledgeType,
|
knowledge_type=payload.knowledgeType,
|
||||||
review_mode=payload.reviewMode,
|
review_mode="automatic",
|
||||||
)
|
)
|
||||||
db.add(knowledge)
|
db.add(knowledge)
|
||||||
db.flush()
|
db.flush()
|
||||||
@@ -179,7 +176,7 @@ def update_knowledge(
|
|||||||
knowledge.name = payload.name
|
knowledge.name = payload.name
|
||||||
knowledge.remark = payload.remark
|
knowledge.remark = payload.remark
|
||||||
knowledge.knowledge_type = payload.knowledgeType
|
knowledge.knowledge_type = payload.knowledgeType
|
||||||
knowledge.review_mode = payload.reviewMode
|
knowledge.review_mode = "automatic"
|
||||||
db.add(knowledge)
|
db.add(knowledge)
|
||||||
OperationLogService.write(db, admin_id=current_admin.id, module="knowledge", action="update", target_id=knowledge.id)
|
OperationLogService.write(db, admin_id=current_admin.id, module="knowledge", action="update", target_id=knowledge.id)
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -243,13 +240,10 @@ def _knowledge_dict(knowledge: Knowledge) -> dict:
|
|||||||
"remark": knowledge.remark,
|
"remark": knowledge.remark,
|
||||||
"sourceTitle": knowledge.source_title,
|
"sourceTitle": knowledge.source_title,
|
||||||
"knowledgeType": knowledge.knowledge_type,
|
"knowledgeType": knowledge.knowledge_type,
|
||||||
"reviewMode": knowledge.review_mode,
|
|
||||||
"lifecycleStatus": knowledge.lifecycle_status,
|
"lifecycleStatus": knowledge.lifecycle_status,
|
||||||
"sourceStatus": knowledge.source_status,
|
"sourceStatus": knowledge.source_status,
|
||||||
"sourceError": knowledge.source_error,
|
"sourceError": knowledge.source_error,
|
||||||
"manifestConfirmed": knowledge.manifest_confirmed == 1,
|
|
||||||
"currentVersionId": knowledge.current_version_id,
|
"currentVersionId": knowledge.current_version_id,
|
||||||
"pendingVersionId": knowledge.pending_version_id,
|
|
||||||
"lastSyncedAt": knowledge.last_synced_at,
|
"lastSyncedAt": knowledge.last_synced_at,
|
||||||
"lastPublishedAt": knowledge.last_published_at,
|
"lastPublishedAt": knowledge.last_published_at,
|
||||||
"migrationStatus": knowledge.migration_status,
|
"migrationStatus": knowledge.migration_status,
|
||||||
|
|||||||
@@ -23,10 +23,8 @@ from app.schemas.knowledge import (
|
|||||||
KnowledgeLifecycleRequest,
|
KnowledgeLifecycleRequest,
|
||||||
KnowledgeBatchMetadataUpdateRequest,
|
KnowledgeBatchMetadataUpdateRequest,
|
||||||
KnowledgeMetadataUpdateRequest,
|
KnowledgeMetadataUpdateRequest,
|
||||||
KnowledgePublishRequest,
|
KnowledgeRollbackRequest,
|
||||||
KnowledgeReviewRequest,
|
|
||||||
KnowledgeStatusRequest,
|
KnowledgeStatusRequest,
|
||||||
KnowledgeSyncRequest,
|
|
||||||
)
|
)
|
||||||
from app.services.admin_service import OperationLogService
|
from app.services.admin_service import OperationLogService
|
||||||
from app.services.knowledge_pipeline_service import KnowledgePipelineService
|
from app.services.knowledge_pipeline_service import KnowledgePipelineService
|
||||||
@@ -41,8 +39,8 @@ def batch_update_metadata(
|
|||||||
current_admin: Admin = Depends(get_current_admin),
|
current_admin: Admin = Depends(get_current_admin),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
_require_super_admin(current_admin)
|
_require_super_admin(current_admin)
|
||||||
if payload.knowledgeType is None and payload.reviewMode is None:
|
if payload.knowledgeType is None:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请至少选择一个待修改字段")
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请选择待修改的知识库类型")
|
||||||
knowledge_ids = list(dict.fromkeys(payload.knowledgeIds))
|
knowledge_ids = list(dict.fromkeys(payload.knowledgeIds))
|
||||||
rows = {
|
rows = {
|
||||||
item.id: item
|
item.id: item
|
||||||
@@ -56,8 +54,6 @@ def batch_update_metadata(
|
|||||||
continue
|
continue
|
||||||
if payload.knowledgeType is not None:
|
if payload.knowledgeType is not None:
|
||||||
item.knowledge_type = payload.knowledgeType
|
item.knowledge_type = payload.knowledgeType
|
||||||
if payload.reviewMode is not None:
|
|
||||||
item.review_mode = payload.reviewMode
|
|
||||||
db.add(item)
|
db.add(item)
|
||||||
OperationLogService.write(
|
OperationLogService.write(
|
||||||
db,
|
db,
|
||||||
@@ -85,7 +81,7 @@ def update_metadata(
|
|||||||
knowledge = _knowledge(db, knowledge_id)
|
knowledge = _knowledge(db, knowledge_id)
|
||||||
knowledge.name = payload.name.strip()
|
knowledge.name = payload.name.strip()
|
||||||
knowledge.knowledge_type = payload.knowledgeType
|
knowledge.knowledge_type = payload.knowledgeType
|
||||||
knowledge.review_mode = payload.reviewMode
|
knowledge.review_mode = "automatic"
|
||||||
db.add(knowledge)
|
db.add(knowledge)
|
||||||
OperationLogService.write(
|
OperationLogService.write(
|
||||||
db, admin_id=current_admin.id, module="knowledge", action="update_metadata", target_id=knowledge.id
|
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)
|
_require_super_admin(current_admin)
|
||||||
knowledge = _knowledge(db, knowledge_id)
|
knowledge = _knowledge(db, knowledge_id)
|
||||||
current = db.get(KnowledgeVersion, knowledge.current_version_id) if knowledge.current_version_id else None
|
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 = None
|
||||||
manifest_version_id = knowledge.pending_version_id or knowledge.current_version_id
|
manifest_version_id = knowledge.current_version_id
|
||||||
if manifest_version_id:
|
if manifest_version_id:
|
||||||
manifest = db.scalar(select(KnowledgeManifest).where(KnowledgeManifest.version_id == manifest_version_id))
|
manifest = db.scalar(select(KnowledgeManifest).where(KnowledgeManifest.version_id == manifest_version_id))
|
||||||
return api_success(
|
return api_success(
|
||||||
{
|
{
|
||||||
"knowledge": _knowledge_detail(knowledge),
|
"knowledge": _knowledge_detail(knowledge),
|
||||||
"currentVersion": _version_dict(current),
|
"currentVersion": _version_dict(current),
|
||||||
"pendingVersion": _version_dict(pending),
|
|
||||||
"manifest": _manifest_dict(manifest),
|
"manifest": _manifest_dict(manifest),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -206,7 +200,6 @@ def sync_jobs(
|
|||||||
@router.post("/knowledge/{knowledge_id}/sync")
|
@router.post("/knowledge/{knowledge_id}/sync")
|
||||||
async def synchronize(
|
async def synchronize(
|
||||||
knowledge_id: int,
|
knowledge_id: int,
|
||||||
payload: KnowledgeSyncRequest,
|
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_admin: Admin = Depends(get_current_admin),
|
current_admin: Admin = Depends(get_current_admin),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
@@ -216,7 +209,7 @@ async def synchronize(
|
|||||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="归档知识库不能同步")
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="归档知识库不能同步")
|
||||||
try:
|
try:
|
||||||
job = await KnowledgePipelineService.synchronize(
|
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:
|
except Exception as exc:
|
||||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=f"知识同步失败:{exc}") from 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))
|
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")
|
@router.post("/knowledge/{knowledge_id}/version/{version_id}/rollback")
|
||||||
def rollback(
|
def rollback(
|
||||||
knowledge_id: int,
|
knowledge_id: int,
|
||||||
version_id: int,
|
version_id: int,
|
||||||
payload: KnowledgePublishRequest,
|
payload: KnowledgeRollbackRequest,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_admin: Admin = Depends(get_current_admin),
|
current_admin: Admin = Depends(get_current_admin),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
@@ -321,8 +253,6 @@ def open_status(
|
|||||||
if payload.open:
|
if payload.open:
|
||||||
if not knowledge.current_version_id:
|
if not knowledge.current_version_id:
|
||||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="知识库没有正式发布版本")
|
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":
|
if knowledge.source_status != "normal":
|
||||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="飞书源文章异常,不能开放")
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="飞书源文章异常,不能开放")
|
||||||
if knowledge.lifecycle_status != "active":
|
if knowledge.lifecycle_status != "active":
|
||||||
@@ -381,7 +311,7 @@ async def run_migration(
|
|||||||
for knowledge in rows:
|
for knowledge in rows:
|
||||||
try:
|
try:
|
||||||
job = await KnowledgePipelineService.synchronize(
|
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)})
|
results.append({"knowledgeId": knowledge.id, "ok": True, "job": _job_dict(job)})
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -428,14 +358,11 @@ def _knowledge_detail(item: Knowledge) -> dict:
|
|||||||
"feishuSpaceId": item.feishu_space_id,
|
"feishuSpaceId": item.feishu_space_id,
|
||||||
"feishuNodeId": item.feishu_node_id,
|
"feishuNodeId": item.feishu_node_id,
|
||||||
"knowledgeType": item.knowledge_type,
|
"knowledgeType": item.knowledge_type,
|
||||||
"reviewMode": item.review_mode,
|
|
||||||
"open": item.status == 1,
|
"open": item.status == 1,
|
||||||
"lifecycleStatus": item.lifecycle_status,
|
"lifecycleStatus": item.lifecycle_status,
|
||||||
"sourceStatus": item.source_status,
|
"sourceStatus": item.source_status,
|
||||||
"sourceError": item.source_error,
|
"sourceError": item.source_error,
|
||||||
"manifestConfirmed": item.manifest_confirmed == 1,
|
|
||||||
"currentVersionId": item.current_version_id,
|
"currentVersionId": item.current_version_id,
|
||||||
"pendingVersionId": item.pending_version_id,
|
|
||||||
"lastSyncedAt": item.last_synced_at,
|
"lastSyncedAt": item.last_synced_at,
|
||||||
"lastPublishedAt": item.last_published_at,
|
"lastPublishedAt": item.last_published_at,
|
||||||
"migrationStatus": item.migration_status,
|
"migrationStatus": item.migration_status,
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ class Knowledge(Base, TimestampMixin):
|
|||||||
remark: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
remark: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
source_title: 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)
|
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)
|
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_status: Mapped[str] = mapped_column(String(30), default="normal", nullable=False)
|
||||||
source_error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
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)
|
status: int = Field(default=1, ge=0, le=1)
|
||||||
remark: str | None = Field(default=None, max_length=255)
|
remark: str | None = Field(default=None, max_length=255)
|
||||||
knowledgeType: str = Field(default="general", pattern="^(course|qa|general)$")
|
knowledgeType: str = Field(default="general", pattern="^(course|qa|general)$")
|
||||||
reviewMode: str = Field(default="manual", pattern="^(manual|automatic)$")
|
|
||||||
|
|
||||||
|
|
||||||
class PromptSaveRequest(BaseModel):
|
class PromptSaveRequest(BaseModel):
|
||||||
|
|||||||
@@ -3,16 +3,7 @@ from __future__ import annotations
|
|||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
class KnowledgeSyncRequest(BaseModel):
|
class KnowledgeRollbackRequest(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):
|
|
||||||
keepOpen: bool = False
|
keepOpen: bool = False
|
||||||
|
|
||||||
|
|
||||||
@@ -27,13 +18,11 @@ class KnowledgeLifecycleRequest(BaseModel):
|
|||||||
class KnowledgeMetadataUpdateRequest(BaseModel):
|
class KnowledgeMetadataUpdateRequest(BaseModel):
|
||||||
name: str = Field(min_length=1, max_length=100)
|
name: str = Field(min_length=1, max_length=100)
|
||||||
knowledgeType: str = Field(pattern="^(course|qa|general)$")
|
knowledgeType: str = Field(pattern="^(course|qa|general)$")
|
||||||
reviewMode: str = Field(pattern="^(manual|automatic)$")
|
|
||||||
|
|
||||||
|
|
||||||
class KnowledgeBatchMetadataUpdateRequest(BaseModel):
|
class KnowledgeBatchMetadataUpdateRequest(BaseModel):
|
||||||
knowledgeIds: list[int] = Field(min_length=1, max_length=500)
|
knowledgeIds: list[int] = Field(min_length=1, max_length=500)
|
||||||
knowledgeType: str | None = Field(default=None, pattern="^(course|qa|general)$")
|
knowledgeType: str | None = Field(default=None, pattern="^(course|qa|general)$")
|
||||||
reviewMode: str | None = Field(default=None, pattern="^(manual|automatic)$")
|
|
||||||
|
|
||||||
|
|
||||||
class AttentionUpdateRequest(BaseModel):
|
class AttentionUpdateRequest(BaseModel):
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ from app.models.knowledge import (
|
|||||||
KnowledgeChunk,
|
KnowledgeChunk,
|
||||||
KnowledgeManifest,
|
KnowledgeManifest,
|
||||||
KnowledgePublishLog,
|
KnowledgePublishLog,
|
||||||
KnowledgeReviewRecord,
|
|
||||||
KnowledgeSection,
|
KnowledgeSection,
|
||||||
KnowledgeSourceSnapshot,
|
KnowledgeSourceSnapshot,
|
||||||
KnowledgeSyncJob,
|
KnowledgeSyncJob,
|
||||||
@@ -25,7 +24,7 @@ from app.services.external_errors import ExternalServiceError
|
|||||||
from app.services.feishu_async_service import AsyncFeishuKnowledgeService
|
from app.services.feishu_async_service import AsyncFeishuKnowledgeService
|
||||||
from app.services.feishu_service import _feishu_retrieval_config
|
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_SECTION_CHARS = 2800
|
||||||
MAX_CHUNK_CHARS = 650
|
MAX_CHUNK_CHARS = 650
|
||||||
|
|
||||||
@@ -46,13 +45,10 @@ class KnowledgePipelineService:
|
|||||||
knowledge: Knowledge,
|
knowledge: Knowledge,
|
||||||
*,
|
*,
|
||||||
admin_id: int,
|
admin_id: int,
|
||||||
mode: str,
|
|
||||||
) -> KnowledgeSyncJob:
|
) -> KnowledgeSyncJob:
|
||||||
if mode not in {"review", "replace"}:
|
|
||||||
raise ValueError("同步模式必须为 review 或 replace")
|
|
||||||
job = KnowledgeSyncJob(
|
job = KnowledgeSyncJob(
|
||||||
knowledge_id=knowledge.id,
|
knowledge_id=knowledge.id,
|
||||||
mode=mode,
|
mode="replace",
|
||||||
status="running",
|
status="running",
|
||||||
stage="fetching",
|
stage="fetching",
|
||||||
progress=5,
|
progress=5,
|
||||||
@@ -88,10 +84,38 @@ class KnowledgePipelineService:
|
|||||||
knowledge.source_error = None
|
knowledge.source_error = None
|
||||||
knowledge.source_title = source_title
|
knowledge.source_title = source_title
|
||||||
knowledge.last_synced_at = _now()
|
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.status = "unchanged"
|
||||||
|
job.result_summary = json.dumps({"changed": False}, ensure_ascii=False)
|
||||||
job.stage = "completed"
|
job.stage = "completed"
|
||||||
job.progress = 100
|
job.progress = 100
|
||||||
job.result_summary = json.dumps({"changed": False}, ensure_ascii=False)
|
|
||||||
job.finished_at = _now()
|
job.finished_at = _now()
|
||||||
db.add_all([knowledge, job])
|
db.add_all([knowledge, job])
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -188,7 +212,7 @@ class KnowledgePipelineService:
|
|||||||
content_hash=section.content_hash,
|
content_hash=section.content_hash,
|
||||||
generation_model="deterministic-v1",
|
generation_model="deterministic-v1",
|
||||||
generation_rule_version=PROCESSING_RULE_VERSION,
|
generation_rule_version=PROCESSING_RULE_VERSION,
|
||||||
review_status="pending",
|
review_status="approved",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -210,7 +234,9 @@ class KnowledgePipelineService:
|
|||||||
boundaries=manifest_data["boundaries"],
|
boundaries=manifest_data["boundaries"],
|
||||||
content_hash=_hash(json.dumps(manifest_data, ensure_ascii=False, sort_keys=True)),
|
content_hash=_hash(json.dumps(manifest_data, ensure_ascii=False, sort_keys=True)),
|
||||||
change_level=change_level,
|
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)
|
db.add(manifest)
|
||||||
|
|
||||||
@@ -222,40 +248,32 @@ class KnowledgePipelineService:
|
|||||||
{"sections": len(sections), "chunks": chunk_count, "manifestChange": change_level},
|
{"sections": len(sections), "chunks": chunk_count, "manifestChange": change_level},
|
||||||
ensure_ascii=False,
|
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_title = source_title
|
||||||
knowledge.source_status = "normal"
|
knowledge.source_status = "normal"
|
||||||
knowledge.source_error = None
|
knowledge.source_error = None
|
||||||
knowledge.pending_version_id = version.id
|
|
||||||
knowledge.last_synced_at = _now()
|
knowledge.last_synced_at = _now()
|
||||||
knowledge.migration_status = "pending_review"
|
cls._activate_version(
|
||||||
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(
|
|
||||||
db,
|
db,
|
||||||
knowledge,
|
knowledge,
|
||||||
version,
|
version,
|
||||||
admin_id,
|
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.status = "published"
|
||||||
|
job.stage = "completed"
|
||||||
|
job.progress = 100
|
||||||
job.result_summary = json.dumps(
|
job.result_summary = json.dumps(
|
||||||
{"versionId": version.id, "versionNo": version.version_no, "autoPublished": True, **quality},
|
{"versionId": version.id, "versionNo": version.version_no, "autoPublished": True, **quality},
|
||||||
ensure_ascii=False,
|
ensure_ascii=False,
|
||||||
)
|
)
|
||||||
|
job.finished_at = _now()
|
||||||
db.add_all([knowledge, version, job])
|
db.add_all([knowledge, version, job])
|
||||||
db.commit()
|
db.commit()
|
||||||
return job
|
return job
|
||||||
@@ -278,57 +296,34 @@ class KnowledgePipelineService:
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def confirm_manifest(db: Session, knowledge: Knowledge, version: KnowledgeVersion, admin_id: int) -> None:
|
def _activate_version(
|
||||||
manifest = db.scalar(select(KnowledgeManifest).where(KnowledgeManifest.version_id == version.id))
|
db: Session,
|
||||||
if manifest is None:
|
knowledge: Knowledge,
|
||||||
raise ValueError("该版本没有知识库说明卡")
|
version: KnowledgeVersion,
|
||||||
manifest.confirmed = 1
|
admin_id: int,
|
||||||
manifest.confirmed_by = admin_id
|
*,
|
||||||
manifest.confirmed_at = _now()
|
keep_open: bool,
|
||||||
if knowledge.pending_version_id == version.id:
|
action: str,
|
||||||
knowledge.manifest_confirmed = 1
|
) -> None:
|
||||||
db.add_all([manifest, knowledge])
|
"""Atomically activate a validated version and preserve rollback history."""
|
||||||
|
if version.quality_status == "blocked":
|
||||||
@staticmethod
|
raise ValueError("质检未通过的版本不能生效")
|
||||||
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("知识库说明卡尚未确认")
|
|
||||||
if keep_open and knowledge.lifecycle_status != "active":
|
if keep_open and knowledge.lifecycle_status != "active":
|
||||||
raise ValueError("归档知识库不能在发布后开放")
|
raise ValueError("归档知识库不能在版本替换后开放")
|
||||||
if keep_open and knowledge.source_status != "normal":
|
if keep_open and knowledge.source_status != "normal":
|
||||||
raise ValueError("飞书源文章异常,不能在发布后开放")
|
raise ValueError("飞书源文章异常,不能替换正式版本")
|
||||||
previous_version_id = knowledge.current_version_id
|
previous_version_id = knowledge.current_version_id
|
||||||
if previous_version_id:
|
if previous_version_id:
|
||||||
previous = db.get(KnowledgeVersion, previous_version_id)
|
previous = db.get(KnowledgeVersion, previous_version_id)
|
||||||
if previous:
|
if previous and previous.id != version.id:
|
||||||
previous.status = "superseded"
|
previous.status = "superseded"
|
||||||
db.add(previous)
|
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.status = "published"
|
||||||
version.published_by = admin_id
|
version.published_by = admin_id
|
||||||
version.published_at = _now()
|
version.published_at = _now()
|
||||||
@@ -346,7 +341,7 @@ class KnowledgePipelineService:
|
|||||||
knowledge_id=knowledge.id,
|
knowledge_id=knowledge.id,
|
||||||
from_version_id=previous_version_id,
|
from_version_id=previous_version_id,
|
||||||
to_version_id=version.id,
|
to_version_id=version.id,
|
||||||
action="publish",
|
action=action,
|
||||||
keep_open=knowledge.status,
|
keep_open=knowledge.status,
|
||||||
operated_by=admin_id,
|
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": "存在重复章节"})
|
issues.append({"level": "warning", "code": "duplicate_sections", "message": "存在重复章节"})
|
||||||
high_risk = detect_risk_level(content) == "high"
|
high_risk = detect_risk_level(content) == "high"
|
||||||
if high_risk:
|
if high_risk:
|
||||||
issues.append({"level": "warning", "code": "high_risk", "message": "包含高风险主题,需要重点审核"})
|
issues.append({"level": "warning", "code": "high_risk", "message": "包含高风险主题,请重点关注内容准确性"})
|
||||||
return {
|
return {
|
||||||
"blocking": any(item["level"] == "blocking" for item in issues),
|
"blocking": any(item["level"] == "blocking" for item in issues),
|
||||||
"issues": issues,
|
"issues": issues,
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from sqlalchemy import create_engine, select
|
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.api.admin_knowledge_lifecycle import batch_update_metadata, 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 Knowledge, KnowledgeManifest, KnowledgeSourceSnapshot, KnowledgeVersion
|
from app.models.knowledge import (
|
||||||
|
Knowledge,
|
||||||
|
KnowledgeCard,
|
||||||
|
KnowledgeManifest,
|
||||||
|
KnowledgePublishLog,
|
||||||
|
KnowledgeSourceSnapshot,
|
||||||
|
KnowledgeVersion,
|
||||||
|
)
|
||||||
from app.schemas.knowledge import KnowledgeBatchMetadataUpdateRequest, KnowledgeMetadataUpdateRequest
|
from app.schemas.knowledge import KnowledgeBatchMetadataUpdateRequest, KnowledgeMetadataUpdateRequest
|
||||||
from app.services.knowledge_agent_service import KnowledgeAgentService
|
from app.services.knowledge_agent_service import KnowledgeAgentService
|
||||||
from app.services.knowledge_pipeline_service import KnowledgePipelineService
|
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)
|
original = (item.feishu_space_id, item.feishu_node_id, item.current_version_id, item.status)
|
||||||
update_metadata(
|
update_metadata(
|
||||||
item.id,
|
item.id,
|
||||||
KnowledgeMetadataUpdateRequest(name="新后台名称", knowledgeType="course", reviewMode="automatic"),
|
KnowledgeMetadataUpdateRequest(name="新后台名称", knowledgeType="course"),
|
||||||
db,
|
db,
|
||||||
_admin(),
|
_admin(),
|
||||||
)
|
)
|
||||||
@@ -98,7 +108,7 @@ def test_metadata_update_requires_super_admin():
|
|||||||
with pytest.raises(HTTPException) as exc:
|
with pytest.raises(HTTPException) as exc:
|
||||||
update_metadata(
|
update_metadata(
|
||||||
item.id,
|
item.id,
|
||||||
KnowledgeMetadataUpdateRequest(name="新名称", knowledgeType="qa", reviewMode="manual"),
|
KnowledgeMetadataUpdateRequest(name="新名称", knowledgeType="qa"),
|
||||||
db,
|
db,
|
||||||
_admin("operator"),
|
_admin("operator"),
|
||||||
)
|
)
|
||||||
@@ -138,3 +148,76 @@ def test_rollback_switches_published_version_statuses():
|
|||||||
assert item.current_version_id == target.id
|
assert item.current_version_id == target.id
|
||||||
assert target.status == "published"
|
assert target.status == "published"
|
||||||
assert old_version.status == "superseded"
|
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