feat: 实现阶段二实体体系——AI预处理真实化+实体CRUD+审核归一化

- 新增 entities 数据表(含迁移)、Entity 类型、DAO 层完整 CRUD
- AI 预处理管道接入真实模型(generateText),输出结构化 JSON(摘要+规范化内容+候选实体)
- 模板接口重构为 {systemPrompt, buildUserPrompt, parseOutput},general/meeting 模板真实化
- 新增 5 个实体路由端点 + 实体管理前端页面(列表/详情/编辑弹窗)
- 审核面板增强:展示 AI 预处理结构化结果+候选实体归一化面板(合并/新建/选择/放弃)
- 素材通过时根据用户确认的候选实体写入 entities 表
- 工作台菜单新增"实体"入口
- 新增 entities DAO 测试(16)、processor 测试(11)、路由测试(8),服务端 367 测试全部通过
- TypeScript 0 错误
This commit is contained in:
2026-06-08 18:49:30 +08:00
parent 034496e946
commit 12edf0b545
36 changed files with 2109 additions and 62 deletions

View File

@@ -0,0 +1,264 @@
import type Database from "bun:sqlite";
import { describe, expect, test } from "bun:test";
import {
createEntity,
deleteEntity,
getEntity,
listEntities,
listEntityNames,
updateEntity,
} from "../../../src/server/db/entities";
import { createProject } from "../../../src/server/db/projects";
import { createNoopLogger } from "../../../src/server/logger";
import { createMigratedTestDatabase } from "../../helpers";
const LOG = createNoopLogger();
function withEntitiesDb(callback: (db: Database) => void): void {
const handle = createMigratedTestDatabase("entities-dao-test");
try {
callback(handle.db);
handle.close();
} finally {
handle.cleanup();
}
}
function setupProject(db: Database, name = "测试项目"): string {
const result = createProject(db, { name }, LOG);
if ("error" in result) throw new Error(result.error);
return result.project.id;
}
function setupEntity(
db: Database,
projectId: string,
overrides: Partial<{ aliases: string[]; description: string; name: string; type: string }> = {},
): string {
const result = createEntity(
db,
projectId,
{
aliases: overrides.aliases,
description: overrides.description ?? "测试实体描述",
name: overrides.name ?? "测试实体",
type: (overrides.type ?? "person") as "person",
},
LOG,
);
if ("error" in result) throw new Error(result.error);
return result.entity.id;
}
describe("实体数据访问层", () => {
describe("createEntity", () => {
test("创建实体成功aliases 为数组", () => {
withEntitiesDb((db) => {
const projectId = setupProject(db);
const result = createEntity(
db,
projectId,
{
description: "描述",
name: "张三",
type: "person",
},
LOG,
);
expect("error" in result).toBe(false);
const entity = (result as { entity: { aliases: string[]; description: string; name: string; type: string } })
.entity;
expect(entity.name).toBe("张三");
expect(entity.description).toBe("描述");
expect(entity.type).toBe("person");
expect(entity.aliases).toEqual([]);
});
});
test("创建实体时指定别名", () => {
withEntitiesDb((db) => {
const projectId = setupProject(db);
const result = createEntity(
db,
projectId,
{
aliases: ["小张", "张工"],
name: "张三",
type: "person",
},
LOG,
);
expect("error" in result).toBe(false);
const entity = (result as { entity: { aliases: string[] } }).entity;
expect(entity.aliases).toEqual(["小张", "张工"]);
});
});
test("空名称返回 400", () => {
withEntitiesDb((db) => {
const projectId = setupProject(db);
const result = createEntity(db, projectId, { name: " ", type: "other" }, LOG);
expect("error" in result).toBe(true);
expect((result as { status: number }).status).toBe(400);
});
});
test("同项目下重名返回 409", () => {
withEntitiesDb((db) => {
const projectId = setupProject(db);
createEntity(db, projectId, { name: "张三", type: "person" }, LOG);
const result = createEntity(db, projectId, { name: "张三", type: "person" }, LOG);
expect("error" in result).toBe(true);
expect((result as { status: number }).status).toBe(409);
});
});
test("不同项目可重名", () => {
withEntitiesDb((db) => {
const p1 = setupProject(db, "项目一");
const p2 = setupProject(db, "项目二");
expect("error" in createEntity(db, p1, { name: "张三", type: "person" }, LOG)).toBe(false);
expect("error" in createEntity(db, p2, { name: "张三", type: "person" }, LOG)).toBe(false);
});
});
});
describe("getEntity", () => {
test("获取实体成功", () => {
withEntitiesDb((db) => {
const projectId = setupProject(db);
const entityId = setupEntity(db, projectId);
const result = getEntity(db, projectId, entityId);
expect("error" in result).toBe(false);
expect((result as { entity: { id: string } }).entity.id).toBe(entityId);
});
});
test("不存在返回 404", () => {
withEntitiesDb((db) => {
const projectId = setupProject(db);
const result = getEntity(db, projectId, "nonexistent");
expect("error" in result).toBe(true);
expect((result as { status: number }).status).toBe(404);
});
});
});
describe("listEntities", () => {
test("分页列出实体", () => {
withEntitiesDb((db) => {
const projectId = setupProject(db);
setupEntity(db, projectId, { name: "实体1", type: "person" });
setupEntity(db, projectId, { name: "实体2", type: "organization" });
const result = listEntities(db, projectId, { page: 1, pageSize: 10 });
expect(result.total).toBe(2);
const types = result.items.map((i: { type: string }) => i.type).sort();
expect(types).toContain("person");
expect(types).toContain("organization");
});
});
test("按类型筛选", () => {
withEntitiesDb((db) => {
const projectId = setupProject(db);
setupEntity(db, projectId, { name: "人", type: "person" });
setupEntity(db, projectId, { name: "公司", type: "organization" });
const result = listEntities(db, projectId, { page: 1, pageSize: 10, type: "person" });
expect(result.total).toBe(1);
const firstItem = result.items[0]!;
expect(firstItem.name).toBe("人");
});
});
test("软删除实体不出现在列表中", () => {
withEntitiesDb((db) => {
const projectId = setupProject(db);
const entityId = setupEntity(db, projectId);
deleteEntity(db, projectId, entityId, LOG);
const result = listEntities(db, projectId, { page: 1, pageSize: 10 });
expect(result.total).toBe(0);
});
});
});
describe("listEntityNames", () => {
test("返回所有实体名称和别名", () => {
withEntitiesDb((db) => {
const projectId = setupProject(db);
setupEntity(db, projectId, { aliases: ["小张"], name: "张三", type: "person" });
setupEntity(db, projectId, { name: "李四", type: "person" });
const result = listEntityNames(db, projectId);
expect(result).toHaveLength(2);
const names = result.map((r) => r.name).sort();
expect(names).toEqual(["张三", "李四"]);
const p3 = result.find((r) => r.name === "张三")!;
expect(p3.aliases).toEqual(["小张"]);
});
});
});
describe("updateEntity", () => {
test("更新实体名称和别名", () => {
withEntitiesDb((db) => {
const projectId = setupProject(db);
const entityId = setupEntity(db, projectId, { name: "张三" });
const result = updateEntity(db, projectId, entityId, { aliases: ["张总", "老张"], name: "张三丰" }, LOG);
expect("error" in result).toBe(false);
const entity = (result as { entity: { aliases: string[]; name: string } }).entity;
expect(entity.name).toBe("张三丰");
expect(entity.aliases).toEqual(["张总", "老张"]);
});
});
test("更新时名称去重", () => {
withEntitiesDb((db) => {
const projectId = setupProject(db);
setupEntity(db, projectId, { name: "已有实体" });
const entityId = setupEntity(db, projectId, { name: "张三" });
const result = updateEntity(db, projectId, entityId, { name: "已有实体" }, LOG);
expect("error" in result).toBe(true);
expect((result as { status: number }).status).toBe(409);
});
});
test("空参数返回原实体", () => {
withEntitiesDb((db) => {
const projectId = setupProject(db);
const entityId = setupEntity(db, projectId);
const result = updateEntity(db, projectId, entityId, {}, LOG);
expect("error" in result).toBe(false);
const entity = (result as { entity: { id: string } }).entity;
expect(entity.id).toBe(entityId);
});
});
});
describe("deleteEntity", () => {
test("软删除实体成功", () => {
withEntitiesDb((db) => {
const projectId = setupProject(db);
const entityId = setupEntity(db, projectId);
const result = deleteEntity(db, projectId, entityId, LOG);
expect("error" in result).toBe(false);
const getResult = getEntity(db, projectId, entityId);
expect("error" in getResult).toBe(true);
expect((getResult as { status: number }).status).toBe(404);
});
});
test("不存在返回 404", () => {
withEntitiesDb((db) => {
const projectId = setupProject(db);
const result = deleteEntity(db, projectId, "nonexistent", LOG);
expect("error" in result).toBe(true);
expect((result as { status: number }).status).toBe(404);
});
});
});
});

View File

@@ -136,7 +136,7 @@ describe("素材数据访问层", () => {
const materialId = setupMaterial(db, projectId);
setMaterialStatus(db, materialId, "review");
const result = approveMaterial(db, projectId, materialId, LOG);
const result = approveMaterial(db, projectId, materialId, [], LOG);
expect("error" in result).toBe(false);
const material = (result as { material: { status: string } }).material;
expect(material.status).toBe("approved");
@@ -148,7 +148,7 @@ describe("素材数据访问层", () => {
const projectId = setupProject(db);
const materialId = setupMaterial(db, projectId);
const result = approveMaterial(db, projectId, materialId, LOG);
const result = approveMaterial(db, projectId, materialId, [], LOG);
expect("error" in result).toBe(true);
expect((result as { status: number }).status).toBe(409);
});
@@ -157,7 +157,7 @@ describe("素材数据访问层", () => {
test("素材不存在返回 404", () => {
withMaterialsDb((db) => {
const projectId = setupProject(db);
const result = approveMaterial(db, projectId, "nonexistent", LOG);
const result = approveMaterial(db, projectId, "nonexistent", [], LOG);
expect("error" in result).toBe(true);
expect((result as { status: number }).status).toBe(404);
});

View File

@@ -75,7 +75,15 @@ function setMaterialStatus(
);
}
class FailingProcessor extends MaterialProcessor {
class FakeProcessor extends MaterialProcessor {
public processOneResult = '{"summary":"test","normalizedContent":"test","candidateEntities":[]}';
protected override async processOne(_material: ProcessableMaterial): Promise<string> {
return Promise.resolve(this.processOneResult);
}
}
class FailingProcessor extends FakeProcessor {
public attempts = 0;
public failUntilAttempt = Number.POSITIVE_INFINITY;
@@ -98,7 +106,7 @@ describe("素材处理器", () => {
setMaterialStatus(db, id1, "processing");
setMaterialStatus(db, id2, "processing");
const processor = new MaterialProcessor(db, LOG);
const processor = new FakeProcessor(db, LOG);
const recovered = processor.recoverStuckMaterials();
expect(recovered).toBe(2);
@@ -112,7 +120,7 @@ describe("素材处理器", () => {
const projectId = setupProject(db);
setupMaterial(db, projectId);
const processor = new MaterialProcessor(db, LOG);
const processor = new FakeProcessor(db, LOG);
const recovered = processor.recoverStuckMaterials();
expect(recovered).toBe(0);
@@ -124,16 +132,17 @@ describe("素材处理器", () => {
const projectId = setupProject(db);
const id = setupMaterial(db, projectId, { description: "测试内容" });
const processor = new MaterialProcessor(db, LOG);
const processor = new FakeProcessor(db, LOG);
processor.processOneResult = '{"summary":"概要","normalizedContent":"规范内容","candidateEntities":[]}';
await processor.processNext();
const row = getMaterialRow(db, id);
expect(row?.status).toBe("review");
expect(row?.processedContent).toBe("测试内容");
expect(row?.processedContent).toBe('{"summary":"概要","normalizedContent":"规范内容","candidateEntities":[]}');
});
});
test("processNext 根据 materialType 选择模板", async () => {
test("processNext 根据 materialType 调用对应模板", async () => {
await withProcessorDbAsync(async (db) => {
const projectId = setupProject(db);
const id = setupMaterial(db, projectId, {
@@ -141,12 +150,12 @@ describe("素材处理器", () => {
materialType: "meeting",
});
const processor = new MaterialProcessor(db, LOG);
const processor = new FakeProcessor(db, LOG);
await processor.processNext();
const row = getMaterialRow(db, id);
expect(row?.status).toBe("review");
expect(row?.processedContent).toBe("会议内容");
expect(row?.processedContent).not.toBeNull();
});
});
@@ -188,7 +197,7 @@ describe("素材处理器", () => {
await withProcessorDbAsync(async (db) => {
setupProject(db);
const processor = new MaterialProcessor(db, LOG);
const processor = new FakeProcessor(db, LOG);
await processor.processNext();
});
});
@@ -201,7 +210,7 @@ describe("素材处理器", () => {
await new Promise((r) => setTimeout(r, 20));
const id2 = setupMaterial(db, projectId, { description: "第二个" });
const processor = new MaterialProcessor(db, LOG);
const processor = new FakeProcessor(db, LOG);
await processor.processNext();
expect(getMaterialRow(db, id1)?.status).toBe("review");
@@ -211,7 +220,7 @@ describe("素材处理器", () => {
test("start 启动后能正常 stop", () => {
withProcessorDb((db) => {
const processor = new MaterialProcessor(db, LOG);
const processor = new FakeProcessor(db, LOG);
processor.start(100);
processor.stop();
});
@@ -222,7 +231,7 @@ describe("素材处理器", () => {
const projectId = setupProject(db);
const id = setupMaterial(db, projectId, { description: "定时扫描" });
const processor = new MaterialProcessor(db, LOG);
const processor = new FakeProcessor(db, LOG);
processor.start(50);
await new Promise((r) => setTimeout(r, 300));
@@ -231,7 +240,6 @@ describe("素材处理器", () => {
const row = getMaterialRow(db, id);
expect(row?.status).toBe("review");
expect(row?.processedContent).toBe("定时扫描");
});
});
});

View File

@@ -0,0 +1,218 @@
import type Database from "bun:sqlite";
import { describe, expect, test } from "bun:test";
import type { Entity, RuntimeMode } from "../../../src/shared/api";
import { createProject } from "../../../src/server/db/projects";
import { createNoopLogger } from "../../../src/server/logger";
import { createMigratedMemoryTestDatabase } from "../../helpers";
const MODE: RuntimeMode = "test";
const LOG = createNoopLogger();
async function createEntityViaHandler(req: Request, db: Database): Promise<Response> {
const { handleCreateEntity: h } = await import("../../../src/server/routes/entities/create");
return h(req, db, MODE, LOG);
}
async function listEntitiesViaHandler(req: Request, db: Database): Promise<Response> {
const { handleListEntities: h } = await import("../../../src/server/routes/entities/list");
return h(req, db, MODE, LOG);
}
async function getEntityViaHandler(req: Request, db: Database): Promise<Response> {
const { handleGetEntity: h } = await import("../../../src/server/routes/entities/get");
return h(req, db, MODE, LOG);
}
async function updateEntityViaHandler(req: Request, db: Database): Promise<Response> {
const { handleUpdateEntity: h } = await import("../../../src/server/routes/entities/update");
return h(req, db, MODE, LOG);
}
async function deleteEntityViaHandler(req: Request, db: Database): Promise<Response> {
const { handleDeleteEntity: h } = await import("../../../src/server/routes/entities/delete");
return h(req, db, MODE, LOG);
}
async function withRouteDb(callback: (db: Database) => Promise<void>): Promise<void> {
const handle = createMigratedMemoryTestDatabase("entity-route-test");
try {
await callback(handle.db);
handle.close();
} finally {
handle.cleanup();
}
}
function createTestProject(db: Database) {
const result = createProject(db, { name: "测试项目" }, LOG);
if ("error" in result) throw new Error(result.error);
return result.project;
}
describe("实体 API 路由", () => {
describe("POST /api/projects/:id/entities", () => {
test("正常创建实体", async () => {
await withRouteDb(async (db) => {
const project = createTestProject(db);
const req = new Request(`http://localhost/api/projects/${project.id}/entities`, {
body: JSON.stringify({ name: "张三", type: "person", description: "描述", aliases: ["小张"] }),
headers: { "Content-Type": "application/json" },
method: "POST",
});
const res = await createEntityViaHandler(req, db);
expect(res.status).toBe(201);
const body = (await res.json()) as { entity: Entity };
expect(body.entity.name).toBe("张三");
expect(body.entity.type).toBe("person");
expect(body.entity.aliases).toEqual(["小张"]);
});
});
test("无 name 返回 400", async () => {
await withRouteDb(async (db) => {
const project = createTestProject(db);
const req = new Request(`http://localhost/api/projects/${project.id}/entities`, {
body: JSON.stringify({ type: "other" }),
headers: { "Content-Type": "application/json" },
method: "POST",
});
const res = await createEntityViaHandler(req, db);
expect(res.status).toBe(400);
});
});
});
describe("GET /api/projects/:id/entities", () => {
test("分页列出实体", async () => {
await withRouteDb(async (db) => {
const project = createTestProject(db);
const req1 = new Request(`http://localhost/api/projects/${project.id}/entities`, {
body: JSON.stringify({ name: "实体1", type: "person" }),
headers: { "Content-Type": "application/json" },
method: "POST",
});
await createEntityViaHandler(req1, db);
const req = new Request(`http://localhost/api/projects/${project.id}/entities?page=1&pageSize=10`, {
method: "GET",
});
const res = await listEntitiesViaHandler(req, db);
expect(res.status).toBe(200);
const body = (await res.json()) as { items: Entity[]; total: number };
expect(body.total).toBe(1);
});
});
test("按类型筛选", async () => {
await withRouteDb(async (db) => {
const project = createTestProject(db);
const req1 = new Request(`http://localhost/api/projects/${project.id}/entities`, {
body: JSON.stringify({ name: "人", type: "person" }),
headers: { "Content-Type": "application/json" },
method: "POST",
});
await createEntityViaHandler(req1, db);
const req2 = new Request(`http://localhost/api/projects/${project.id}/entities`, {
body: JSON.stringify({ name: "公司", type: "organization" }),
headers: { "Content-Type": "application/json" },
method: "POST",
});
await createEntityViaHandler(req2, db);
const req = new Request(`http://localhost/api/projects/${project.id}/entities?type=person`, { method: "GET" });
const res = await listEntitiesViaHandler(req, db);
const body = (await res.json()) as { items: Entity[]; total: number };
expect(body.total).toBe(1);
const firstItem = body.items[0]!;
expect(firstItem.name).toBe("人");
});
});
});
describe("GET /api/projects/:id/entities/:eid", () => {
test("获取实体详情", async () => {
await withRouteDb(async (db) => {
const project = createTestProject(db);
const createReq = new Request(`http://localhost/api/projects/${project.id}/entities`, {
body: JSON.stringify({ name: "张三", type: "person" }),
headers: { "Content-Type": "application/json" },
method: "POST",
});
const createRes = await createEntityViaHandler(createReq, db);
const created = (await createRes.json()) as { entity: Entity };
const req = new Request(`http://localhost/api/projects/${project.id}/entities/${created.entity.id}`, {
method: "GET",
});
const res = await getEntityViaHandler(req, db);
expect(res.status).toBe(200);
const body = (await res.json()) as { entity: Entity };
expect(body.entity.name).toBe("张三");
});
});
test("不存在的实体返回 404", async () => {
await withRouteDb(async (db) => {
const project = createTestProject(db);
const req = new Request(`http://localhost/api/projects/${project.id}/entities/nonexistent`, { method: "GET" });
const res = await getEntityViaHandler(req, db);
expect(res.status).toBe(404);
});
});
});
describe("PATCH /api/projects/:id/entities/:eid", () => {
test("更新实体名称", async () => {
await withRouteDb(async (db) => {
const project = createTestProject(db);
const createReq = new Request(`http://localhost/api/projects/${project.id}/entities`, {
body: JSON.stringify({ name: "张三", type: "person" }),
headers: { "Content-Type": "application/json" },
method: "POST",
});
const createRes = await createEntityViaHandler(createReq, db);
const created = (await createRes.json()) as { entity: Entity };
const req = new Request(`http://localhost/api/projects/${project.id}/entities/${created.entity.id}`, {
body: JSON.stringify({ name: "张三丰" }),
headers: { "Content-Type": "application/json" },
method: "PATCH",
});
const res = await updateEntityViaHandler(req, db);
expect(res.status).toBe(200);
const body = (await res.json()) as { entity: Entity };
expect(body.entity.name).toBe("张三丰");
});
});
});
describe("DELETE /api/projects/:id/entities/:eid", () => {
test("软删除实体", async () => {
await withRouteDb(async (db) => {
const project = createTestProject(db);
const createReq = new Request(`http://localhost/api/projects/${project.id}/entities`, {
body: JSON.stringify({ name: "张三", type: "person" }),
headers: { "Content-Type": "application/json" },
method: "POST",
});
const createRes = await createEntityViaHandler(createReq, db);
const created = (await createRes.json()) as { entity: Entity };
const deleteReq = new Request(`http://localhost/api/projects/${project.id}/entities/${created.entity.id}`, {
method: "DELETE",
});
const res = await deleteEntityViaHandler(deleteReq, db);
expect(res.status).toBe(200);
const getReq = new Request(`http://localhost/api/projects/${project.id}/entities/${created.entity.id}`, {
method: "GET",
});
const getRes = await getEntityViaHandler(getReq, db);
expect(getRes.status).toBe(404);
});
});
});
});