feat(knowledge): 知识库工作台前端先审后入(确认草稿→Canonical)rendered-UI 活体证
后端:SummaryRespVO 暴露 confirm 所需并发/源令牌(sourceSnapshotId/authorizationSnapshotId/revision), 否则前端无法构造合法 confirm 请求(非破坏性新增,不改 confirm 语义);转换器填充。 前端:useKnowledgeDrafts/useConfirmKnowledgeDraft hooks(回显令牌)+ KnowledgeDraftPanel(待确认草稿+确认入库) + KnowledgePage(/knowledge/:workId)接入。e2e knowledge-confirm:chromium MSW-off 点确认入库→真 confirm → draft confirmed+entityId(DB 证 draft5 confirmed/entity_id=3)。tsc 0。 遗留:confirm 对已存在同名 Canonical 实体抛未处理 DuplicateKeyException(500),应改干净 conflict(另 flag)。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f63247fb8f
commit
749c5b2471
@ -427,6 +427,10 @@ public class MuseKnowledgeDraftService {
|
||||
vo.setConfidence(draft.getConfidence() == null ? null : draft.getConfidence().floatValue());
|
||||
vo.setConflictSummary(conflictSummary(draft, payload));
|
||||
vo.setCreatedAt(toOpenApiDateTime(draft.getCreateTime()));
|
||||
// 暴露 confirm 所需并发/源令牌,供前端"先审后入"原样回显(见 SummaryRespVO 注释)。
|
||||
vo.setSourceSnapshotId(draft.getSourceSnapshotId());
|
||||
vo.setAuthorizationSnapshotId(draft.getAuthorizationSnapshotId());
|
||||
vo.setRevision(draft.getRevision());
|
||||
return vo;
|
||||
}
|
||||
|
||||
|
||||
@ -72,6 +72,12 @@ public final class AppKnowledgeDraftVO {
|
||||
private Float confidence;
|
||||
private String conflictSummary;
|
||||
private String createdAt;
|
||||
// 确认草稿(先审后入)所需的并发/源新鲜度令牌:客户端从列表读取后在 confirm 原样回显,
|
||||
// 后端据此做乐观并发(revision)与源快照一致性(sourceSnapshotId/authorizationSnapshotId)校验。
|
||||
// 不暴露则前端无法构造合法 confirm 请求(见 ConfirmReqVO 必填项)。
|
||||
private String sourceSnapshotId;
|
||||
private String authorizationSnapshotId;
|
||||
private Integer revision;
|
||||
}
|
||||
|
||||
@Data
|
||||
|
||||
44
muse-studio/e2e/knowledge-confirm.spec.ts
Normal file
44
muse-studio/e2e/knowledge-confirm.spec.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import { expect, test, type Page } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* 知识草稿确认入库 e2e(先审后入,真实后端,MSW off)。
|
||||
*
|
||||
* 前置:vite 以 VITE_API_MOCK=false 运行、真实 muse-server 在 48080(含暴露 confirm 并发/源令牌的 SummaryRespVO)、
|
||||
* muse_slice_live 中作品 1 下有 pending 知识草稿。
|
||||
* 目的:rendered studio 的「待确认草稿」工作台 → 点「确认入库」→ 经唯一合法入口(POST /knowledge-drafts/{id}/confirm)
|
||||
* 把 Shadow 草稿物化为 Canonical 实体,采纳 POST 直打真后端(反假绿)。
|
||||
*/
|
||||
const TOKEN = 'test1';
|
||||
|
||||
async function seedToken(page: Page): Promise<void> {
|
||||
await page.addInitScript((t) => {
|
||||
window.localStorage.setItem('accessToken', t);
|
||||
window.localStorage.setItem('tenantId', '1');
|
||||
}, TOKEN);
|
||||
}
|
||||
|
||||
test('知识草稿确认入库(Shadow→Canonical,真实后端)', async ({ page }) => {
|
||||
await seedToken(page);
|
||||
await page.goto('/knowledge/1');
|
||||
|
||||
// 待确认草稿面板渲染 + 「确认入库」按钮可见(证后端 SummaryRespVO 暴露的草稿被加载渲染)。
|
||||
const confirmButton = page.getByRole('button', { name: /确认入库/ }).first();
|
||||
await expect(confirmButton).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// 点击确认并捕获真实 confirm 写入响应(打真后端,非 mock)。
|
||||
const [resp] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(r) => /\/knowledge-drafts\/\d+\/confirm$/.test(r.url()) && r.request().method() === 'POST',
|
||||
{ timeout: 15_000 }
|
||||
),
|
||||
confirmButton.click(),
|
||||
]);
|
||||
|
||||
// Yudao 口径:成功为 HTTP 200 + CommonResult{code:0}。
|
||||
expect(resp.status()).toBe(200);
|
||||
const body = await resp.json();
|
||||
expect(body.code).toBe(0);
|
||||
// 物化结果:草稿转 confirmed,并返回新建 Canonical 实体 id。
|
||||
expect(body.data?.status).toBe('confirmed');
|
||||
expect(String(body.data?.entityId ?? '')).not.toBe('');
|
||||
});
|
||||
@ -0,0 +1,70 @@
|
||||
import {
|
||||
useKnowledgeDrafts,
|
||||
useConfirmKnowledgeDraft,
|
||||
type KnowledgeDraftVO,
|
||||
} from '@/features/knowledge/hooks/useKnowledge';
|
||||
|
||||
/**
|
||||
* 知识库工作台 - 待确认草稿面板(先审后入)。
|
||||
*
|
||||
* 列出某作品下 AI/抽取产生的 pending 知识草稿;用户审阅后「确认入库」→ 经唯一合法入口
|
||||
* (POST /knowledge-drafts/{id}/confirm)把 Shadow 草稿物化为 Canonical 实体/关系。
|
||||
* 体现双轨主权:候选不直接进正式图谱,必须人工审阅确认(冲突/来源过期/风险由后端 fail-closed 守住)。
|
||||
*/
|
||||
export default function KnowledgeDraftPanel({ workId }: { workId: string }) {
|
||||
const { data, isLoading, isError } = useKnowledgeDrafts(workId);
|
||||
const confirm = useConfirmKnowledgeDraft(workId);
|
||||
|
||||
const pendingDrafts = (data ?? []).filter((draft) => draft.status === 'pending');
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<section className="mb-8 rounded-2xl border border-teal-100 bg-teal-50/40 p-6">
|
||||
<p className="text-sm text-teal-700">正在加载待确认知识草稿…</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// 拉取失败或无待确认草稿时不占位(工作台主体仍展示知识库列表)。
|
||||
if (isError || pendingDrafts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="mb-8 rounded-2xl border border-teal-100 bg-white shadow-sm overflow-hidden">
|
||||
<div className="flex items-center gap-2 border-b border-teal-50 bg-gradient-to-r from-teal-50/80 to-cyan-50/40 px-6 py-4">
|
||||
<span className="text-lg">🔎</span>
|
||||
<h2 className="text-base font-bold text-slate-800">待确认知识草稿(先审后入)</h2>
|
||||
<span className="ml-1 inline-flex items-center justify-center rounded-full bg-teal-600 px-2 py-0.5 text-xs font-semibold text-white">
|
||||
{pendingDrafts.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ul className="divide-y divide-slate-50">
|
||||
{pendingDrafts.map((draft: KnowledgeDraftVO) => (
|
||||
<li key={draft.draftId} className="flex items-start justify-between gap-4 px-6 py-4">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-block rounded-md bg-slate-100 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider text-slate-500">
|
||||
{draft.entityType}
|
||||
</span>
|
||||
<strong className="truncate text-sm font-semibold text-slate-800">{draft.name}</strong>
|
||||
</div>
|
||||
{draft.description && (
|
||||
<p className="mt-1 line-clamp-2 text-xs leading-relaxed text-slate-400">{draft.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => confirm.mutate(draft)}
|
||||
disabled={confirm.isPending}
|
||||
className="shrink-0 inline-flex items-center gap-1 rounded-xl bg-gradient-to-r from-teal-600 to-cyan-600 px-4 py-2 text-sm font-semibold text-white shadow-sm transition-all hover:from-teal-700 hover:to-cyan-700 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{confirm.isPending ? '⏳ 确认中…' : '✅ 确认入库'}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@ -403,3 +403,67 @@ export function useDocumentDelete(kbId: string) {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 待确认知识草稿(先审后入)摘要。
|
||||
* sourceSnapshotId/authorizationSnapshotId/revision 是 confirm 必需的并发/源令牌:
|
||||
* 后端列表暴露、客户端在 confirm 时原样回显,后端据此做乐观并发与源新鲜度校验。
|
||||
*/
|
||||
export interface KnowledgeDraftVO {
|
||||
draftId: number;
|
||||
entityType: string;
|
||||
name: string;
|
||||
description: string;
|
||||
status: string;
|
||||
sourceType?: string;
|
||||
confidence?: number;
|
||||
conflictSummary?: string;
|
||||
createdAt?: string;
|
||||
sourceSnapshotId?: string;
|
||||
authorizationSnapshotId?: string;
|
||||
revision?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询作品待确认知识草稿(GET /works/{workId}/knowledge-drafts)。
|
||||
* queryKey: ['knowledgeDrafts', workId];workId 为空时不发请求。
|
||||
*/
|
||||
export function useKnowledgeDrafts(workId: string | null) {
|
||||
return useQuery<KnowledgeDraftVO[], Error>({
|
||||
queryKey: ['knowledgeDrafts', workId],
|
||||
enabled: !!workId,
|
||||
queryFn: () => api.get<KnowledgeDraftVO[]>(`/works/${workId}/knowledge-drafts`),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 确认知识草稿:把 Shadow 草稿物化为 Canonical 实体/关系。
|
||||
*
|
||||
* WHY:POST /knowledge-drafts/{draftId}/confirm 是 AI/抽取候选写入正式知识图谱的唯一合法入口(双轨主权·先审后入)。
|
||||
* 客户端原样回显从列表读到的并发/源令牌(revision→expectedDraftRevision、sourceSnapshotId、authorizationSnapshotId),
|
||||
* 后端据此做乐观并发与源快照一致性校验;不可在前端伪造/绕过(否则会被判 revision 冲突或来源过期)。
|
||||
* 注:本切片按"原样接受"(accept/single);风险确认这里按无风险草稿置 acknowledged=true,
|
||||
* 带 riskFlags 的草稿需进一步携带 acknowledgedRiskCodes(后续随风险展示一并补)。
|
||||
*/
|
||||
export function useConfirmKnowledgeDraft(workId: string) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (draft: KnowledgeDraftVO) =>
|
||||
api.post<{ draftId?: string; entityId?: string; status?: string }>(
|
||||
`/knowledge-drafts/${draft.draftId}/confirm`,
|
||||
{
|
||||
commandId: createCommandId(),
|
||||
expectedDraftRevision: draft.revision ?? 1,
|
||||
sourceSnapshotId: draft.sourceSnapshotId,
|
||||
authorizationSnapshotId: draft.authorizationSnapshotId,
|
||||
confirmMode: 'single',
|
||||
decisionType: 'accept',
|
||||
riskAcknowledgement: { acknowledged: true },
|
||||
}
|
||||
),
|
||||
onSuccess: () => {
|
||||
// 确认成功后刷新待确认列表(该草稿转 confirmed、移出 pending)。
|
||||
queryClient.invalidateQueries({ queryKey: ['knowledgeDrafts', workId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import KnowledgeDraftPanel from '@/features/knowledge/components/KnowledgeDraftPanel';
|
||||
import { useKnowledgeList, useKnowledgeDelete, useInstalledKnowledgeUninstall } from '@/features/knowledge/hooks/useKnowledge';
|
||||
import type { KnowledgeBaseVO } from '@/features/knowledge/hooks/useKnowledge';
|
||||
import CreateKBModal from '@/features/knowledge/components/CreateKBModal';
|
||||
@ -10,6 +12,8 @@ import { isKnowledgeBaseSummary } from '@/features/knowledge/utils/typeGuards';
|
||||
* 包含多页签过滤(自建、已安装、全局公开)、操作流控隔离、资料管理面板跳转以及 Zod 创建表单弹层
|
||||
*/
|
||||
export default function KnowledgePage() {
|
||||
// 作品维度路由(/knowledge/:workId)携带 workId 时,展示该作品的"待确认知识草稿"工作台(先审后入)。
|
||||
const { workId } = useParams<{ workId: string }>();
|
||||
const [activeTab, setActiveTab] = useState<'user' | 'installed' | 'global'>('user');
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [selectedKbId, setSelectedKbId] = useState<string | null>(null);
|
||||
@ -86,6 +90,9 @@ export default function KnowledgePage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 作品维度:待确认知识草稿(先审后入工作台);无 workId 或无待确认草稿时自动隐藏 */}
|
||||
{workId && <KnowledgeDraftPanel workId={workId} />}
|
||||
|
||||
{/* 选项页签切换区域 */}
|
||||
<div className="flex border-b border-slate-200 gap-6 mb-8 select-none">
|
||||
<button
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user