feat(knowledge): studio 图谱实体编辑 + 新建关系接通真后端 + e2e
P1 真缺口补全(3/7,价值最高):KnowledgeGraphPanel 从只读→可维护正式轨——useUpdateEntity→PUT /entities/{id}(编辑实体名,经后端 normalizedName 规范化)、useCreateRelation→POST /entities/{id}/relations(源→目标 + relationType);均带 commandId,graph node.id=实体 id 直接复用。UI:每实体 inline 编辑 + 底部源/目标/类型新建关系表单。
验证:vitest 52/52(contract 加 PUT/POST URL+body+targetEntityId 转 number)、tsc 0 错;新增 e2e knowledge-entity-crud.spec.ts 2 例真后端绿(编辑名回显规范化值、建关系回显新边),全量 26/26 无回归。反假绿:编辑回显失败暴露后端 normalizedName 去连字符规范化,e2e 适配真实规范化用稳定值断言、非掩盖。种子:global-setup #2c work2 三实体(与 graph 的 work1 隔离、按 description marker 复位)。
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e5ee1fdb58
commit
c119b90919
@ -94,6 +94,25 @@ async function globalSetup(): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
// 2c) 实体/关系 CRUD:作品 2 下三个 Canonical 实体——jia 供编辑实体名、yi/bing 供新建关系(各自独立避免互相影响)。
|
||||
// 用 work2 与 knowledge-graph 的 work1 fixture 隔离;复位删 work2 全部关系 + 按 description marker 删实体
|
||||
// (编辑会改 normalized_name 故不能按名删,description marker 不被编辑触碰)。
|
||||
await client.query('DELETE FROM muse_knowledge_relation WHERE tenant_id=1 AND work_id=2');
|
||||
await client.query(
|
||||
"DELETE FROM muse_knowledge_entity WHERE tenant_id=1 AND work_id=2 AND description LIKE 'e2e-crud-marker:%'"
|
||||
);
|
||||
const crudEnt = async (entName: string): Promise<void> => {
|
||||
await client.query(
|
||||
`INSERT INTO muse_knowledge_entity
|
||||
(work_id, entity_type, normalized_name, scope, description, status, source_status, source_action_policy, revision, tenant_id)
|
||||
VALUES (2,'character',$1,'global',$2,'active','active','allowed',1,1)`,
|
||||
[entName, 'e2e-crud-marker:' + entName]
|
||||
);
|
||||
};
|
||||
await crudEnt('crudshiti-jia');
|
||||
await crudEnt('crudshiti-yi');
|
||||
await crudEnt('crudshiti-bing');
|
||||
|
||||
// 3) accept-suggestion:复位 block3→rev1 + 清来源归因;suggestion3 复 pending/active(spec 硬编码 work3/block3/suggestion3)。
|
||||
await client.query('DELETE FROM muse_content_block_source_attribution WHERE tenant_id=1 AND block_id=3');
|
||||
const blk = await client.query(
|
||||
|
||||
72
muse-studio/e2e/knowledge-entity-crud.spec.ts
Normal file
72
muse-studio/e2e/knowledge-entity-crud.spec.ts
Normal file
@ -0,0 +1,72 @@
|
||||
import { expect, test, type Page } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* 知识实体/关系维护 e2e(正式轨人工维护,真实后端,MSW off)。
|
||||
*
|
||||
* 前置:vite VITE_API_MOCK=false、真实 muse-server 48080、muse_slice_live 作品 2 下有 CRUD 种子实体
|
||||
* crudshiti-jia/yi/bing(由 e2e/global-setup.ts #2c 每轮复位;用 work2 与 knowledge-graph 的 work1 fixture 隔离)。
|
||||
* 目的:rendered studio 图谱面板的实体编辑(PUT /entities/{id})与新建关系(POST /entities/{id}/relations)
|
||||
* 直打真后端(反假绿):编辑 jia 实体名、在 yi→bing 间建关系(各用独立实体避免互相影响)。
|
||||
*/
|
||||
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('编辑实体名 → 写回 Canonical(真实后端)', async ({ page }) => {
|
||||
await seedToken(page);
|
||||
await page.goto('/knowledge/2');
|
||||
|
||||
// 进入 jia 实体编辑态(按钮 accessible name 含实体名)。
|
||||
const editBtn = page.getByRole('button', { name: '编辑实体 crudshiti-jia' });
|
||||
await expect(editBtn).toBeVisible({ timeout: 15_000 });
|
||||
await editBtn.click();
|
||||
|
||||
// 改名并保存,捕获真实 PUT 响应(打真后端,非 mock)。
|
||||
// 注:后端 updateEntity 把 name 经 normalizedName() 规范化(NFKD+去非字母数字+小写)后存为 normalizedName,
|
||||
// 图谱节点显示的正是 normalizedName;故输入用规范稳定形式(纯小写字母,无连字符),令回显值可预期、可断言。
|
||||
await page.getByLabel('实体名称').fill('crudjiarenamed');
|
||||
const [resp] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(r) => /\/entities\/\d+$/.test(r.url()) && r.request().method() === 'PUT',
|
||||
{ timeout: 15_000 }
|
||||
),
|
||||
page.getByRole('button', { name: '保存' }).click(),
|
||||
]);
|
||||
|
||||
// Yudao 口径:成功为 HTTP 200 + CommonResult{code:0}。
|
||||
expect(resp.status()).toBe(200);
|
||||
expect((await resp.json()).code).toBe(0);
|
||||
// 回显:图谱重渲染显示规范化后的新实体名(证编辑真写回 Canonical,非假绿)。
|
||||
await expect(page.getByText('crudjiarenamed').first()).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
|
||||
test('新建实体关系 → 写入 Canonical(真实后端)', async ({ page }) => {
|
||||
await seedToken(page);
|
||||
await page.goto('/knowledge/2');
|
||||
|
||||
// 等图谱加载(新建关系下拉出现)。
|
||||
await expect(page.getByLabel('源实体')).toBeVisible({ timeout: 15_000 });
|
||||
// 选源/目标实体(yi→bing,与编辑用的 jia 隔离)+ 关系类型。
|
||||
await page.getByLabel('源实体').selectOption({ label: 'crudshiti-yi' });
|
||||
await page.getByLabel('目标实体').selectOption({ label: 'crudshiti-bing' });
|
||||
await page.getByLabel('关系类型').fill('e2e-shitu');
|
||||
|
||||
const [resp] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(r) => /\/entities\/\d+\/relations$/.test(r.url()) && r.request().method() === 'POST',
|
||||
{ timeout: 15_000 }
|
||||
),
|
||||
page.getByRole('button', { name: '新建关系' }).click(),
|
||||
]);
|
||||
|
||||
// 创建关系为 201 Created(后端 @ResponseStatus(CREATED))。
|
||||
expect([200, 201]).toContain(resp.status());
|
||||
expect((await resp.json()).code).toBe(0);
|
||||
// 回显:关系区出现新建的关系语义。
|
||||
await expect(page.getByText(/e2e-shitu/).first()).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
@ -1,19 +1,36 @@
|
||||
import { useKnowledgeGraph, type KnowledgeGraphNodeVO } from '@/features/knowledge/hooks/useKnowledge';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
useKnowledgeGraph,
|
||||
useUpdateEntity,
|
||||
useCreateRelation,
|
||||
type KnowledgeGraphNodeVO,
|
||||
} from '@/features/knowledge/hooks/useKnowledge';
|
||||
|
||||
/**
|
||||
* 知识库工作台 - 知识图谱视图(先审后入的"正式轨"成果)。
|
||||
* 知识库工作台 - 知识图谱视图(先审后入的"正式轨"成果) + 实体/关系维护。
|
||||
*
|
||||
* 渲染某作品已确认入库的 Canonical 实体(节点,muse_knowledge_entity)与关系(边,muse_knowledge_relation)。
|
||||
* 与「待确认草稿面板」构成双轨主权闭环:候选(Shadow)经人工确认入库 → 正式图谱(Canonical)在此可见。
|
||||
* 节点 name 为后端规范化名(canonical key);关系按节点 id 解析两端名称渲染。
|
||||
* 渲染某作品已确认入库的 Canonical 实体(节点,muse_knowledge_entity)与关系(边,muse_knowledge_relation),
|
||||
* 并支持人工维护正式轨:编辑实体名(PUT /entities/{id})、在两实体间新建关系(POST /entities/{id}/relations)。
|
||||
* 与「待确认草稿面板」构成双轨主权闭环:候选(Shadow)经人工确认入库 → 正式图谱(Canonical)在此可见可维护。
|
||||
* 节点 name 为后端规范化名(canonical key);节点/边 id 即实体/关系 id(后端 String.valueOf 序列化防精度丢失)。
|
||||
*/
|
||||
export default function KnowledgeGraphPanel({ workId }: { workId: string }) {
|
||||
const { data, isLoading, isError } = useKnowledgeGraph(workId);
|
||||
const updateEntity = useUpdateEntity(workId);
|
||||
const createRelation = useCreateRelation(workId);
|
||||
|
||||
const nodes = data?.nodes ?? [];
|
||||
const edges = data?.edges ?? [];
|
||||
const nameById = new Map(nodes.map((node) => [node.id, node.name]));
|
||||
|
||||
// 实体编辑态:当前编辑的实体 id + 名称草稿(null 表示无编辑)。
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editName, setEditName] = useState('');
|
||||
// 新建关系表单态:源/目标为实体 id,relationType 为自由文本语义。
|
||||
const [relSource, setRelSource] = useState('');
|
||||
const [relTarget, setRelTarget] = useState('');
|
||||
const [relType, setRelType] = useState('');
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<section className="mb-8 rounded-2xl border border-indigo-100 bg-indigo-50/40 p-6">
|
||||
@ -27,6 +44,31 @@ export default function KnowledgeGraphPanel({ workId }: { workId: string }) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const startEdit = (node: KnowledgeGraphNodeVO) => {
|
||||
setEditingId(node.id);
|
||||
setEditName(node.name);
|
||||
};
|
||||
|
||||
// 保存实体名:经 PUT /entities/{id} 写回 Canonical,成功后退出编辑态(hook 已失效图谱触发重渲染)。
|
||||
const saveEdit = async () => {
|
||||
if (!editingId || !editName.trim()) return;
|
||||
await updateEntity.mutateAsync({ entityId: editingId, name: editName.trim() });
|
||||
setEditingId(null);
|
||||
};
|
||||
|
||||
// 提交新建关系:源/目标必选且不同、关系类型非空;成功后清空表单(图谱重渲染呈现新边)。
|
||||
const submitRelation = async () => {
|
||||
if (!relSource || !relTarget || !relType.trim() || relSource === relTarget) return;
|
||||
await createRelation.mutateAsync({
|
||||
entityId: relSource,
|
||||
targetEntityId: relTarget,
|
||||
relationType: relType.trim(),
|
||||
});
|
||||
setRelSource('');
|
||||
setRelTarget('');
|
||||
setRelType('');
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="mb-8 rounded-2xl border border-indigo-100 bg-white shadow-sm overflow-hidden">
|
||||
<div className="flex items-center gap-2 border-b border-indigo-50 bg-gradient-to-r from-indigo-50/80 to-violet-50/40 px-6 py-4">
|
||||
@ -37,20 +79,57 @@ export default function KnowledgeGraphPanel({ workId }: { workId: string }) {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 实体节点 */}
|
||||
{/* 实体节点(支持编辑名称) */}
|
||||
<div className="px-6 py-4">
|
||||
<h3 className="mb-2 text-xs font-bold uppercase tracking-wider text-slate-400">实体</h3>
|
||||
<ul className="flex flex-wrap gap-2">
|
||||
<ul className="space-y-2">
|
||||
{nodes.map((node: KnowledgeGraphNodeVO) => (
|
||||
<li
|
||||
key={node.id}
|
||||
className="inline-flex items-center gap-2 rounded-xl border border-indigo-100 bg-indigo-50/40 px-3 py-1.5"
|
||||
className="flex items-center gap-2 rounded-xl border border-indigo-100 bg-indigo-50/40 px-3 py-2"
|
||||
>
|
||||
<span className="rounded-md bg-slate-100 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-slate-500">
|
||||
<span className="shrink-0 rounded-md bg-slate-100 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-slate-500">
|
||||
{node.type}
|
||||
</span>
|
||||
<strong className="text-sm font-semibold text-slate-800">{node.name}</strong>
|
||||
<span className="text-[10px] text-slate-400">{node.status}</span>
|
||||
{editingId === node.id ? (
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
aria-label="实体名称"
|
||||
className="min-w-0 flex-1 rounded-lg border border-indigo-200 px-2 py-1 text-sm text-slate-800 focus:border-indigo-400 focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={saveEdit}
|
||||
disabled={updateEntity.isPending || !editName.trim()}
|
||||
className="shrink-0 rounded-lg bg-indigo-600 px-3 py-1 text-xs font-semibold text-white hover:bg-indigo-700 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{updateEntity.isPending ? '保存中…' : '保存'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingId(null)}
|
||||
className="shrink-0 rounded-lg border border-slate-200 px-3 py-1 text-xs font-semibold text-slate-500 hover:bg-slate-50"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<strong className="min-w-0 flex-1 truncate text-sm font-semibold text-slate-800">{node.name}</strong>
|
||||
<span className="shrink-0 text-[10px] text-slate-400">{node.status}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => startEdit(node)}
|
||||
aria-label={`编辑实体 ${node.name}`}
|
||||
className="shrink-0 rounded-lg border border-slate-200 px-2 py-1 text-xs font-semibold text-slate-500 hover:bg-slate-50"
|
||||
>
|
||||
✏️ 编辑
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@ -71,6 +150,57 @@ export default function KnowledgeGraphPanel({ workId }: { workId: string }) {
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 新建关系:在两个已入库实体间补建关系(双轨"正式轨"的人工维护) */}
|
||||
<div className="border-t border-slate-50 bg-slate-50/40 px-6 py-4">
|
||||
<h3 className="mb-2 text-xs font-bold uppercase tracking-wider text-slate-400">新建关系</h3>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<select
|
||||
value={relSource}
|
||||
onChange={(e) => setRelSource(e.target.value)}
|
||||
aria-label="源实体"
|
||||
className="rounded-lg border border-slate-200 px-2 py-1.5 text-sm text-slate-700 focus:border-indigo-400 focus:outline-none"
|
||||
>
|
||||
<option value="">源实体…</option>
|
||||
{nodes.map((n) => (
|
||||
<option key={n.id} value={n.id}>
|
||||
{n.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
value={relType}
|
||||
onChange={(e) => setRelType(e.target.value)}
|
||||
placeholder="关系类型(如:师徒)"
|
||||
aria-label="关系类型"
|
||||
className="w-36 rounded-lg border border-slate-200 px-2 py-1.5 text-sm text-slate-700 focus:border-indigo-400 focus:outline-none"
|
||||
/>
|
||||
<select
|
||||
value={relTarget}
|
||||
onChange={(e) => setRelTarget(e.target.value)}
|
||||
aria-label="目标实体"
|
||||
className="rounded-lg border border-slate-200 px-2 py-1.5 text-sm text-slate-700 focus:border-indigo-400 focus:outline-none"
|
||||
>
|
||||
<option value="">目标实体…</option>
|
||||
{nodes.map((n) => (
|
||||
<option key={n.id} value={n.id}>
|
||||
{n.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={submitRelation}
|
||||
disabled={
|
||||
createRelation.isPending || !relSource || !relTarget || !relType.trim() || relSource === relTarget
|
||||
}
|
||||
className="rounded-lg bg-gradient-to-r from-indigo-600 to-violet-600 px-4 py-1.5 text-sm font-semibold text-white shadow-sm transition-all hover:from-indigo-700 hover:to-violet-700 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{createRelation.isPending ? '创建中…' : '新建关系'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@ -13,6 +13,8 @@ import {
|
||||
useKnowledgeDelete,
|
||||
useKnowledgeList,
|
||||
useRecheckKnowledgeDraft,
|
||||
useUpdateEntity,
|
||||
useCreateRelation,
|
||||
type KnowledgeDraftVO,
|
||||
} from './useKnowledge';
|
||||
|
||||
@ -231,4 +233,39 @@ describe('useKnowledge OpenAPI command contract', () => {
|
||||
expect(ignoreBodies[0]).toMatchObject({ commandId: expect.any(String) });
|
||||
expect(recheckBodies[0]).toMatchObject({ commandId: expect.any(String) });
|
||||
});
|
||||
|
||||
it('puts entity update and posts relation create with command fields', async () => {
|
||||
const putBodies: Record<string, unknown>[] = [];
|
||||
const postBodies: Record<string, unknown>[] = [];
|
||||
const requestedUrls: string[] = [];
|
||||
|
||||
server.use(
|
||||
http.put('/app-api/muse/entities/:entityId', async ({ request }) => {
|
||||
requestedUrls.push(request.url);
|
||||
putBodies.push((await request.json()) as Record<string, unknown>);
|
||||
return HttpResponse.json({ code: 0, msg: 'success', data: { entityId: '11', status: 'active' } });
|
||||
}),
|
||||
http.post('/app-api/muse/entities/:entityId/relations', async ({ request }) => {
|
||||
requestedUrls.push(request.url);
|
||||
postBodies.push((await request.json()) as Record<string, unknown>);
|
||||
return HttpResponse.json({ code: 0, msg: 'success', data: { relationId: '99' } }, { status: 201 });
|
||||
})
|
||||
);
|
||||
|
||||
const wrapper = createWrapper();
|
||||
|
||||
const { result: updateHook } = renderHook(() => useUpdateEntity('1'), { wrapper });
|
||||
updateHook.current.mutate({ entityId: '11', name: '新名' });
|
||||
await waitFor(() => expect(updateHook.current.isSuccess).toBe(true));
|
||||
|
||||
const { result: relationHook } = renderHook(() => useCreateRelation('1'), { wrapper });
|
||||
relationHook.current.mutate({ entityId: '11', targetEntityId: '12', relationType: 'shitu' });
|
||||
await waitFor(() => expect(relationHook.current.isSuccess).toBe(true));
|
||||
|
||||
// 契约:PUT 命中实体端点;POST 命中关系端点;均带 commandId,targetEntityId 转 number(后端 Long)。
|
||||
expect(requestedUrls[0]).toContain('/app-api/muse/entities/11');
|
||||
expect(requestedUrls[1]).toContain('/app-api/muse/entities/11/relations');
|
||||
expect(putBodies[0]).toMatchObject({ commandId: expect.any(String), name: '新名' });
|
||||
expect(postBodies[0]).toMatchObject({ commandId: expect.any(String), targetEntityId: 12, relationType: 'shitu' });
|
||||
});
|
||||
});
|
||||
|
||||
@ -549,6 +549,50 @@ export function useKnowledgeGraph(workId: string | null, depth = 2) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新知识实体:修改已入库 Canonical 实体的描述/名称。
|
||||
*
|
||||
* WHY:PUT /entities/{entityId} 让用户维护正式图谱里的实体(双轨"正式轨"的人工维护,与只读视图互补);
|
||||
* entityId 即图谱节点 id(后端 graph node.id = String.valueOf(entity.id));commandId 幂等键。
|
||||
* 成功后失效图谱视图(实体变化需重渲染)。
|
||||
*/
|
||||
export function useUpdateEntity(workId: string) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (vars: { entityId: string; name?: string; description?: string }) =>
|
||||
api.put<{ entityId?: string; status?: string }>(`/entities/${vars.entityId}`, {
|
||||
commandId: createCommandId(),
|
||||
name: vars.name,
|
||||
description: vars.description,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['knowledgeGraph', workId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建知识关系:在两个已入库实体间建立关系。
|
||||
*
|
||||
* WHY:POST /entities/{entityId}/relations 让用户在正式图谱里补建实体关系(源→目标,带 relationType);
|
||||
* entityId/targetEntityId 均为图谱节点 id(=实体 id);commandId 幂等键。成功后失效图谱视图(新增边需重渲染)。
|
||||
*/
|
||||
export function useCreateRelation(workId: string) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (vars: { entityId: string; targetEntityId: string; relationType: string; description?: string }) =>
|
||||
api.post<{ relationId?: string }>(`/entities/${vars.entityId}/relations`, {
|
||||
commandId: createCommandId(),
|
||||
targetEntityId: Number(vars.targetEntityId),
|
||||
relationType: vars.relationType,
|
||||
description: vars.description,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['knowledgeGraph', workId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 作品已绑定的知识来源(来源绑定读模型投影)。
|
||||
* 后端 id 为字符串(防 JS 大整数精度丢失);checkedAt 为最近核验时间。
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user