feat(knowledge): add batch synchronization
This commit is contained in:
@@ -8,6 +8,7 @@ import TableRowActions from "./TableRowActions.vue";
|
|||||||
import AdminPagination from "./AdminPagination.vue";
|
import AdminPagination from "./AdminPagination.vue";
|
||||||
import type {
|
import type {
|
||||||
KnowledgeDetail,
|
KnowledgeDetail,
|
||||||
|
KnowledgeBatchSyncResult,
|
||||||
KnowledgeItem,
|
KnowledgeItem,
|
||||||
KnowledgeSyncJob,
|
KnowledgeSyncJob,
|
||||||
MigrationSummary,
|
MigrationSummary,
|
||||||
@@ -29,6 +30,9 @@ const editOpen = ref(false);
|
|||||||
const editSaving = ref(false);
|
const editSaving = ref(false);
|
||||||
const batchOpen = ref(false);
|
const batchOpen = ref(false);
|
||||||
const batchSaving = ref(false);
|
const batchSaving = ref(false);
|
||||||
|
const batchSyncing = ref(false);
|
||||||
|
const batchSyncResultOpen = ref(false);
|
||||||
|
const batchSyncResult = ref<KnowledgeBatchSyncResult | null>(null);
|
||||||
const resolvingNode = ref(false);
|
const resolvingNode = ref(false);
|
||||||
const nodeResolved = ref(false);
|
const nodeResolved = ref(false);
|
||||||
const sourceTitle = ref("");
|
const sourceTitle = ref("");
|
||||||
@@ -130,6 +134,32 @@ async function saveBatchMetadata() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function syncSelected() {
|
||||||
|
if (!selectedRows.value.length) {
|
||||||
|
ElMessage.warning("请先勾选需要同步的知识库");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!await confirmAction(
|
||||||
|
`将依次同步已选择的 ${selectedRows.value.length} 个知识库。同步成功后最新内容立即生效,单个失败不会中断其他知识库。`,
|
||||||
|
"确认批量同步",
|
||||||
|
{ confirmButtonText: "开始同步", cancelButtonText: "取消", type: "warning" },
|
||||||
|
)) return;
|
||||||
|
batchSyncing.value = true;
|
||||||
|
try {
|
||||||
|
const result = await api.batchSyncKnowledge(selectedRows.value.map((item) => item.id));
|
||||||
|
batchSyncResult.value = result;
|
||||||
|
batchSyncResultOpen.value = true;
|
||||||
|
ElMessage[result.failed ? "warning" : "success"](
|
||||||
|
`批量同步完成:成功 ${result.success} 个,失败 ${result.failed} 个`,
|
||||||
|
);
|
||||||
|
await load();
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(errorMessage(error, "批量同步失败"));
|
||||||
|
} finally {
|
||||||
|
batchSyncing.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleMore(command: string, item: KnowledgeItem) {
|
async function handleMore(command: string, item: KnowledgeItem) {
|
||||||
try {
|
try {
|
||||||
if (command === "detail") return await openDetail(item);
|
if (command === "detail") return await openDetail(item);
|
||||||
@@ -353,7 +383,10 @@ async function confirmAction(
|
|||||||
|
|
||||||
<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>
|
<div class="knowledge-batch-actions">
|
||||||
|
<el-button type="primary" plain :disabled="selectedRows.length === 0 || batchSaving" :loading="batchSyncing" @click="syncSelected">批量同步</el-button>
|
||||||
|
<el-button :disabled="selectedRows.length === 0 || batchSyncing" @click="openBatchEdit">批量修改类型</el-button>
|
||||||
|
</div>
|
||||||
</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">
|
||||||
@@ -404,6 +437,28 @@ async function confirmAction(
|
|||||||
</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>
|
||||||
|
|
||||||
|
<el-dialog v-model="batchSyncResultOpen" title="批量同步结果" width="min(760px, 94vw)" append-to-body destroy-on-close>
|
||||||
|
<el-alert
|
||||||
|
v-if="batchSyncResult"
|
||||||
|
:title="`共处理 ${batchSyncResult.total} 个:成功 ${batchSyncResult.success} 个,失败 ${batchSyncResult.failed} 个`"
|
||||||
|
:type="batchSyncResult.failed ? 'warning' : 'success'"
|
||||||
|
:closable="false"
|
||||||
|
show-icon
|
||||||
|
/>
|
||||||
|
<el-table v-if="batchSyncResult" :data="batchSyncResult.results" max-height="440" class="batch-sync-result-table">
|
||||||
|
<el-table-column label="知识库" min-width="220">
|
||||||
|
<template #default="{ row }">{{ row.name || `知识库 #${row.knowledgeId}` }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="结果" width="100">
|
||||||
|
<template #default="{ row }"><el-tag :type="row.ok ? 'success' : 'danger'">{{ row.ok ? '成功' : '失败' }}</el-tag></template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="状态 / 原因" min-width="300">
|
||||||
|
<template #default="{ row }">{{ row.ok ? (row.status === 'unchanged' ? '内容无变化' : '最新内容已生效') : row.error }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<template #footer><el-button type="primary" @click="batchSyncResultOpen = false">完成</el-button></template>
|
||||||
|
</el-dialog>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -421,6 +476,12 @@ async function confirmAction(
|
|||||||
color: #66756f;
|
color: #66756f;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.knowledge-batch-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.knowledge-section-head h4 {
|
.knowledge-section-head h4 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
@@ -440,4 +501,8 @@ async function confirmAction(
|
|||||||
.batch-edit-form {
|
.batch-edit-form {
|
||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.batch-sync-result-table {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import type {
|
|||||||
KnowledgeSyncJob,
|
KnowledgeSyncJob,
|
||||||
KnowledgeVersionContent,
|
KnowledgeVersionContent,
|
||||||
MigrationSummary,
|
MigrationSummary,
|
||||||
|
KnowledgeBatchSyncResult,
|
||||||
KnowledgeBatchUpdateResult,
|
KnowledgeBatchUpdateResult,
|
||||||
RetrievalLogItem,
|
RetrievalLogItem,
|
||||||
AttentionItem,
|
AttentionItem,
|
||||||
@@ -142,6 +143,11 @@ export const api = {
|
|||||||
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 }) =>
|
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) }),
|
||||||
|
batchSyncKnowledge: (knowledgeIds: number[]) =>
|
||||||
|
request<KnowledgeBatchSyncResult>("/admin/knowledge/batch/sync", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ knowledgeIds }),
|
||||||
|
}),
|
||||||
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 }) =>
|
||||||
request<{ imported: number; skipped: number; id: number; name: string }>(
|
request<{ imported: number; skipped: number; id: number; name: string }>(
|
||||||
|
|||||||
@@ -222,6 +222,20 @@ export interface KnowledgeBatchUpdateResult {
|
|||||||
results: Array<{ knowledgeId: number; ok: boolean; error?: string }>;
|
results: Array<{ knowledgeId: number; ok: boolean; error?: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface KnowledgeBatchSyncResult {
|
||||||
|
total: number;
|
||||||
|
success: number;
|
||||||
|
failed: number;
|
||||||
|
results: Array<{
|
||||||
|
knowledgeId: number;
|
||||||
|
name?: string;
|
||||||
|
ok: boolean;
|
||||||
|
status?: string;
|
||||||
|
job?: KnowledgeSyncJob;
|
||||||
|
error?: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface RetrievalLogItem {
|
export interface RetrievalLogItem {
|
||||||
id: number;
|
id: number;
|
||||||
sessionId?: number | null;
|
sessionId?: number | null;
|
||||||
|
|||||||
@@ -20,8 +20,9 @@ from app.models.knowledge import (
|
|||||||
KnowledgeVersion,
|
KnowledgeVersion,
|
||||||
)
|
)
|
||||||
from app.schemas.knowledge import (
|
from app.schemas.knowledge import (
|
||||||
KnowledgeLifecycleRequest,
|
|
||||||
KnowledgeBatchMetadataUpdateRequest,
|
KnowledgeBatchMetadataUpdateRequest,
|
||||||
|
KnowledgeBatchSyncRequest,
|
||||||
|
KnowledgeLifecycleRequest,
|
||||||
KnowledgeMetadataUpdateRequest,
|
KnowledgeMetadataUpdateRequest,
|
||||||
KnowledgeStatusRequest,
|
KnowledgeStatusRequest,
|
||||||
)
|
)
|
||||||
@@ -180,6 +181,79 @@ def sync_jobs(
|
|||||||
return api_success([_job_dict(item) for item in rows])
|
return api_success([_job_dict(item) for item in rows])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/knowledge/batch/sync")
|
||||||
|
async def batch_sync(
|
||||||
|
payload: KnowledgeBatchSyncRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_admin: Admin = Depends(get_current_admin),
|
||||||
|
) -> dict:
|
||||||
|
_require_super_admin(current_admin)
|
||||||
|
knowledge_ids = list(dict.fromkeys(payload.knowledgeIds))
|
||||||
|
rows = {
|
||||||
|
item.id: item
|
||||||
|
for item in db.scalars(select(Knowledge).where(Knowledge.id.in_(knowledge_ids))).all()
|
||||||
|
}
|
||||||
|
results: list[dict] = []
|
||||||
|
for knowledge_id in knowledge_ids:
|
||||||
|
knowledge = rows.get(knowledge_id)
|
||||||
|
if knowledge is None:
|
||||||
|
results.append({"knowledgeId": knowledge_id, "ok": False, "error": "知识库不存在"})
|
||||||
|
continue
|
||||||
|
if knowledge.lifecycle_status == "archived":
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
"knowledgeId": knowledge_id,
|
||||||
|
"name": knowledge.name,
|
||||||
|
"ok": False,
|
||||||
|
"error": "归档知识库不能同步",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
job = await KnowledgePipelineService.synchronize(
|
||||||
|
db, knowledge, admin_id=current_admin.id
|
||||||
|
)
|
||||||
|
OperationLogService.write(
|
||||||
|
db,
|
||||||
|
admin_id=current_admin.id,
|
||||||
|
module="knowledge",
|
||||||
|
action="batch_sync",
|
||||||
|
target_id=knowledge.id,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
"knowledgeId": knowledge.id,
|
||||||
|
"name": knowledge.name,
|
||||||
|
"ok": True,
|
||||||
|
"status": job.status,
|
||||||
|
"job": _job_dict(job),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
OperationLogService.write(
|
||||||
|
db,
|
||||||
|
admin_id=current_admin.id,
|
||||||
|
module="knowledge",
|
||||||
|
action="batch_sync",
|
||||||
|
target_id=knowledge.id,
|
||||||
|
result="FAILED",
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
"knowledgeId": knowledge.id,
|
||||||
|
"name": knowledge.name,
|
||||||
|
"ok": False,
|
||||||
|
"error": str(exc),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
success = sum(1 for item in results if item["ok"])
|
||||||
|
return api_success(
|
||||||
|
{"total": len(results), "success": success, "failed": len(results) - success, "results": results}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/knowledge/{knowledge_id}/sync")
|
@router.post("/knowledge/{knowledge_id}/sync")
|
||||||
async def synchronize(
|
async def synchronize(
|
||||||
knowledge_id: int,
|
knowledge_id: int,
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ class KnowledgeBatchMetadataUpdateRequest(BaseModel):
|
|||||||
knowledgeType: str | None = Field(default=None, pattern="^(course|qa|general)$")
|
knowledgeType: str | None = Field(default=None, pattern="^(course|qa|general)$")
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgeBatchSyncRequest(BaseModel):
|
||||||
|
knowledgeIds: list[int] = Field(min_length=1, max_length=500)
|
||||||
|
|
||||||
|
|
||||||
class AttentionUpdateRequest(BaseModel):
|
class AttentionUpdateRequest(BaseModel):
|
||||||
status: str = Field(pattern="^(pending|processing|resolved|ignored)$")
|
status: str = Field(pattern="^(pending|processing|resolved|ignored)$")
|
||||||
note: str | None = Field(default=None, max_length=2000)
|
note: str | None = Field(default=None, max_length=2000)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
from datetime import datetime
|
||||||
from unittest.mock import AsyncMock, patch
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -9,7 +10,7 @@ from sqlalchemy import create_engine, select
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy.pool import StaticPool
|
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_sync, 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 (
|
from app.models.knowledge import (
|
||||||
@@ -18,9 +19,14 @@ from app.models.knowledge import (
|
|||||||
KnowledgeManifest,
|
KnowledgeManifest,
|
||||||
KnowledgePublishLog,
|
KnowledgePublishLog,
|
||||||
KnowledgeSourceSnapshot,
|
KnowledgeSourceSnapshot,
|
||||||
|
KnowledgeSyncJob,
|
||||||
KnowledgeVersion,
|
KnowledgeVersion,
|
||||||
)
|
)
|
||||||
from app.schemas.knowledge import KnowledgeBatchMetadataUpdateRequest, KnowledgeMetadataUpdateRequest
|
from app.schemas.knowledge import (
|
||||||
|
KnowledgeBatchMetadataUpdateRequest,
|
||||||
|
KnowledgeBatchSyncRequest,
|
||||||
|
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
|
||||||
|
|
||||||
@@ -40,8 +46,8 @@ def _knowledge(db: Session, knowledge_id: int = 1) -> Knowledge:
|
|||||||
item = Knowledge(
|
item = Knowledge(
|
||||||
id=knowledge_id,
|
id=knowledge_id,
|
||||||
name="原名称",
|
name="原名称",
|
||||||
feishu_space_id="space-1",
|
feishu_space_id=f"space-{knowledge_id}",
|
||||||
feishu_node_id="node-1",
|
feishu_node_id=f"node-{knowledge_id}",
|
||||||
status=1,
|
status=1,
|
||||||
knowledge_type="general",
|
knowledge_type="general",
|
||||||
review_mode="manual",
|
review_mode="manual",
|
||||||
@@ -55,7 +61,7 @@ def _knowledge(db: Session, knowledge_id: int = 1) -> Knowledge:
|
|||||||
source_title="源标题",
|
source_title="源标题",
|
||||||
source_content="源内容",
|
source_content="源内容",
|
||||||
content_hash=f"hash-{knowledge_id}",
|
content_hash=f"hash-{knowledge_id}",
|
||||||
source_identifier="space-1/node-1",
|
source_identifier=f"space-{knowledge_id}/node-{knowledge_id}",
|
||||||
)
|
)
|
||||||
db.add(snapshot)
|
db.add(snapshot)
|
||||||
db.flush()
|
db.flush()
|
||||||
@@ -128,6 +134,47 @@ def test_batch_metadata_update_reports_missing_items():
|
|||||||
assert db.scalar(select(Knowledge).where(Knowledge.id == item.id)).knowledge_type == "qa"
|
assert db.scalar(select(Knowledge).where(Knowledge.id == item.id)).knowledge_type == "qa"
|
||||||
|
|
||||||
|
|
||||||
|
def test_batch_sync_continues_after_an_item_fails_and_reports_each_result():
|
||||||
|
with _database() as db:
|
||||||
|
first = _knowledge(db, 1)
|
||||||
|
second = _knowledge(db, 2)
|
||||||
|
successful_job = KnowledgeSyncJob(
|
||||||
|
id=101,
|
||||||
|
knowledge_id=first.id,
|
||||||
|
mode="replace",
|
||||||
|
status="published",
|
||||||
|
stage="completed",
|
||||||
|
progress=100,
|
||||||
|
requested_by=1,
|
||||||
|
created_at=datetime.now(),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def synchronize(_db, knowledge, *, admin_id):
|
||||||
|
assert admin_id == 1
|
||||||
|
if knowledge.id == second.id:
|
||||||
|
raise RuntimeError("飞书接口暂时不可用")
|
||||||
|
return successful_job
|
||||||
|
|
||||||
|
with patch.object(KnowledgePipelineService, "synchronize", side_effect=synchronize) as mocked:
|
||||||
|
response = asyncio.run(
|
||||||
|
batch_sync(
|
||||||
|
KnowledgeBatchSyncRequest(knowledgeIds=[first.id, first.id, second.id, 999]),
|
||||||
|
db,
|
||||||
|
_admin(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = response["data"]
|
||||||
|
assert result["total"] == 3
|
||||||
|
assert result["success"] == 1
|
||||||
|
assert result["failed"] == 2
|
||||||
|
assert [item["knowledgeId"] for item in result["results"]] == [first.id, second.id, 999]
|
||||||
|
assert result["results"][0]["status"] == "published"
|
||||||
|
assert result["results"][1]["error"] == "飞书接口暂时不可用"
|
||||||
|
assert result["results"][2]["error"] == "知识库不存在"
|
||||||
|
assert mocked.call_count == 2
|
||||||
|
|
||||||
|
|
||||||
def test_sync_automatically_replaces_current_version_and_removes_history():
|
def test_sync_automatically_replaces_current_version_and_removes_history():
|
||||||
source_content = (
|
source_content = (
|
||||||
"# 一、课程说明\n这是更新后的课程内容,同步成功后应当立即生效。\n\n"
|
"# 一、课程说明\n这是更新后的课程内容,同步成功后应当立即生效。\n\n"
|
||||||
|
|||||||
Reference in New Issue
Block a user