feat(ai): 业务上下文感知与 A2UI 链路统一

- 新增代码内业务上下文元数据层(实体字典 + 三大闭环工作流)
- 新增 get_business_context / get_entity_schema / get_pending_tasks 运行时工具
- SYSTEM_PROMPT 与技能目录改为先查业务流程/待办再执行
- A2UI 增加 ai_a2ui_submissions 幂等表、表单过期、ui.artifact 事件
- 提交回灌携带 submissionId / fieldErrors / 下一步建议
- 前端 uiArtifacts 归一化与过期表单禁用
This commit is contained in:
2026-08-06 15:23:23 +08:00
parent d9c541dacc
commit 24e0ecbdaf
44 changed files with 2125 additions and 41 deletions

View File

@@ -0,0 +1,318 @@
import type {
BusinessEntity,
BusinessWorkflow,
} from './business-context.types';
/**
* 恭学系统业务上下文(代码内维护)。
*
* v1 覆盖三大闭环:学生教学、住宿计费、教室租赁。实体字段字典用于
* render_form 生成正确表单,工作流阶段/前置依赖/下一步建议用于引导
* Agent 感知业务流程。
*/
export const BUSINESS_ENTITIES: readonly BusinessEntity[] = [
{
key: 'student',
name: '学生档案',
description: '学生基础档案(姓名、学号、手机号、性别等),是分班、入住、账单的前置数据。',
searchTool: 'search_students',
requiredPermissions: ['student:view'],
fields: [
{ key: 'name', label: '姓名', type: 'string', required: true },
{ key: 'studentNo', label: '学号', type: 'string' },
{ key: 'phone', label: '手机号', type: 'string' },
{ key: 'gender', label: '性别', type: 'enum', enumFrom: 'student.gender' },
{ key: 'idNumber', label: '身份证号', type: 'string' },
],
relations: [
{ entityKey: 'class', via: 'class_student', requiredFor: ['class'] },
{ entityKey: 'occupancy', via: 'occupancy', requiredFor: ['checkin'] },
{ entityKey: 'bill', via: 'bill', requiredFor: ['bill'] },
{ entityKey: 'exam', via: 'exam_score', requiredFor: ['exam'] },
],
},
{
key: 'class',
name: '班级',
description: '班级档案与在读分班关系,排课、考勤、考试依赖班级。',
searchTool: 'search_classes',
requiredPermissions: ['class:view'],
fields: [
{ key: 'name', label: '班级名称', type: 'string', required: true },
{ key: 'grade', label: '年级', type: 'string' },
{ key: 'headTeacher', label: '班主任', type: 'string' },
],
relations: [
{ entityKey: 'student', via: 'class_student', requiredFor: ['class'] },
{ entityKey: 'schedule', via: 'class_schedule', requiredFor: ['schedule'] },
],
},
{
key: 'schedule',
name: '排课/日程',
description: '班级与教室的课程安排,考勤和教室日程依赖排课。',
searchTool: 'search_schedules',
requiredPermissions: ['schedule:view'],
fields: [
{ key: 'classId', label: '班级', type: 'number' },
{ key: 'classroomId', label: '教室', type: 'number' },
{ key: 'weekDay', label: '星期', type: 'number' },
{ key: 'startTime', label: '开始时间', type: 'string' },
{ key: 'endTime', label: '结束时间', type: 'string' },
],
relations: [
{ entityKey: 'class', via: 'class_schedule', requiredFor: ['schedule'] },
{ entityKey: 'classroom', via: 'class_schedule', requiredFor: ['schedule'] },
],
},
{
key: 'attendance',
name: '考勤',
description: '按班级与日期的考勤记录(出勤/迟到/缺勤/请假)。',
searchTool: 'get_attendance_summary',
requiredPermissions: ['attendance:view'],
fields: [
{ key: 'studentId', label: '学生', type: 'number', required: true },
{ key: 'date', label: '日期', type: 'date', required: true },
{ key: 'status', label: '状态', type: 'enum', enumFrom: 'attendance.status' },
],
relations: [
{ entityKey: 'class', via: 'class_schedule', requiredFor: ['attendance'] },
{ entityKey: 'schedule', via: 'class_schedule', requiredFor: ['attendance'] },
],
},
{
key: 'exam',
name: '考试/成绩',
description: '考试安排与成绩记录,依赖班级与学生档案。',
searchTool: 'search_exams',
requiredPermissions: ['exam:view'],
fields: [
{ key: 'name', label: '考试名称', type: 'string', required: true },
{ key: 'date', label: '考试日期', type: 'date' },
{ key: 'subject', label: '科目', type: 'string' },
],
relations: [
{ entityKey: 'class', via: 'exam_score', requiredFor: ['exam'] },
{ entityKey: 'student', via: 'exam_score', requiredFor: ['exam'] },
],
},
{
key: 'room',
name: '宿舍档案',
description: '宿舍/床位基础档案,入住登记的前置数据。',
searchTool: 'search_rooms',
requiredPermissions: ['room:view'],
fields: [
{ key: 'roomNumber', label: '宿舍号', type: 'string', required: true },
{ key: 'building', label: '楼栋', type: 'string' },
{ key: 'floor', label: '楼层', type: 'number' },
{ key: 'capacity', label: '容量', type: 'number', required: true },
{ key: 'roomType', label: '房型', type: 'string' },
{ key: 'monthlyRate', label: '月租金', type: 'number' },
],
relations: [
{ entityKey: 'occupancy', via: 'occupancy', requiredFor: ['checkin'] },
],
},
{
key: 'occupancy',
name: '入住记录',
description: '学生入住/换宿/退宿记录,费用与账单依赖入住状态。',
searchTool: 'get_room_occupancy_summary',
requiredPermissions: ['occupancy:view'],
fields: [
{ key: 'studentId', label: '学生', type: 'number', required: true },
{ key: 'roomId', label: '宿舍', type: 'number', required: true },
{ key: 'checkInDate', label: '入住日期', type: 'date', required: true },
{ key: 'billingStartDate', label: '计费开始日期', type: 'date', required: true },
{ key: 'stayType', label: '住宿类型', type: 'enum', enumFrom: 'occupancy.stayType' },
],
relations: [
{ entityKey: 'student', via: 'occupancy', requiredFor: ['checkin'] },
{ entityKey: 'room', via: 'occupancy', requiredFor: ['checkin'] },
{ entityKey: 'bill', via: 'bill', requiredFor: ['bill'] },
],
},
{
key: 'expense',
name: '费用',
description: '公共费用与个人费用,是生成账单的基础。',
searchTool: 'search_expenses',
requiredPermissions: ['expense:view'],
fields: [
{ key: 'type', label: '费用类型', type: 'string', required: true },
{ key: 'amount', label: '金额', type: 'number', required: true },
{ key: 'periodStart', label: '费用开始日期', type: 'date' },
{ key: 'periodEnd', label: '费用结束日期', type: 'date' },
],
relations: [
{ entityKey: 'occupancy', via: 'expense', requiredFor: ['expense'] },
{ entityKey: 'bill', via: 'bill_item', requiredFor: ['bill'] },
],
},
{
key: 'bill',
name: '账单',
description: '按学生与账期生成的账单(公共+个人费用分摊),支持确认与付款。',
searchTool: 'search_bills',
requiredPermissions: ['bill:view'],
fields: [
{ key: 'studentId', label: '学生', type: 'number', required: true },
{ key: 'periodStart', label: '账期开始', type: 'date', required: true },
{ key: 'periodEnd', label: '账期结束', type: 'date', required: true },
{ key: 'totalAmount', label: '总金额', type: 'number', required: true },
{ key: 'status', label: '状态', type: 'enum', enumFrom: 'bill.status' },
],
relations: [
{ entityKey: 'student', via: 'bill', requiredFor: ['bill'] },
{ entityKey: 'occupancy', via: 'bill', requiredFor: ['bill'] },
{ entityKey: 'deposit', via: 'deposit', requiredFor: ['deposit'] },
],
},
{
key: 'deposit',
name: '押金',
description: '押金收取与退还记录,通常在账单确认后处理。',
searchTool: 'search_deposits',
requiredPermissions: ['deposit:view'],
fields: [
{ key: 'studentId', label: '学生', type: 'number', required: true },
{ key: 'amount', label: '金额', type: 'number', required: true },
{ key: 'status', label: '状态', type: 'enum', enumFrom: 'deposit.status' },
],
relations: [
{ entityKey: 'student', via: 'deposit', requiredFor: ['deposit'] },
{ entityKey: 'bill', via: 'deposit', requiredFor: ['deposit'] },
],
},
{
key: 'classroom',
name: '教室档案',
description: '教室基础档案,租赁与教室日程的前置数据。',
searchTool: 'search_classrooms',
requiredPermissions: ['classroom:view'],
fields: [
{ key: 'name', label: '教室名称', type: 'string', required: true },
{ key: 'building', label: '楼栋', type: 'string' },
{ key: 'capacity', label: '容量', type: 'number' },
],
relations: [
{ entityKey: 'rental', via: 'classroom_rental', requiredFor: ['rental'] },
{ entityKey: 'schedule', via: 'class_schedule', requiredFor: ['schedule'] },
],
},
{
key: 'organization',
name: '组织/校区',
description: '校区与组织归属,租赁双方与档案归属依赖组织。',
requiredPermissions: ['organization:view'],
fields: [
{ key: 'name', label: '名称', type: 'string', required: true },
{ key: 'code', label: '编码', type: 'string' },
{ key: 'isHost', label: '是否本部', type: 'boolean' },
],
relations: [
{ entityKey: 'student', via: 'organization', requiredFor: ['profile'] },
{ entityKey: 'rental', via: 'classroom_rental', requiredFor: ['rental'] },
],
},
{
key: 'rental',
name: '教室租赁',
description: '教室租赁订单与合同(合同字段在租赁记录上),依赖教室与组织。',
searchTool: 'search_classroom_rentals',
requiredPermissions: ['rental:view'],
fields: [
{ key: 'classroomId', label: '教室', type: 'number', required: true },
{ key: 'lesseeOrganizationId', label: '承租方', type: 'number' },
{ key: 'startDate', label: '开始日期', type: 'date', required: true },
{ key: 'endDate', label: '结束日期', type: 'date', required: true },
{ key: 'dailyRate', label: '日租金', type: 'number' },
{ key: 'contractPath', label: '合同文件', type: 'string' },
],
relations: [
{ entityKey: 'classroom', via: 'classroom_rental', requiredFor: ['rental'] },
{ entityKey: 'organization', via: 'classroom_rental', requiredFor: ['rental'] },
{ entityKey: 'schedule', via: 'class_schedule', requiredFor: ['schedule'] },
],
},
];
export const BUSINESS_WORKFLOWS: readonly BusinessWorkflow[] = [
{
key: 'student_teaching',
name: '学生教学闭环',
description: '学生档案 → 分班 → 排课 → 考勤 → 考试/成绩。',
requiredPermissions: ['student:view'],
stages: [
{ key: 'profile', label: '学生档案', entities: ['student'] },
{ key: 'class', label: '分班', entities: ['class', 'student'] },
{ key: 'schedule', label: '排课', entities: ['schedule', 'class'] },
{ key: 'attendance', label: '考勤', entities: ['attendance', 'schedule', 'class'] },
{ key: 'exam', label: '考试/成绩', entities: ['exam', 'class', 'student'] },
],
prerequisites: {
class: ['profile'],
schedule: ['class'],
attendance: ['schedule'],
exam: ['class'],
},
nextSteps: [
{ key: 'after_profile', label: '学生档案完成 → 建议分班', after: ['profile'] },
{ key: 'after_class', label: '分班完成 → 建议排课', after: ['class'] },
{ key: 'after_attendance', label: '考勤稳定 → 建议记录考试成绩', after: ['attendance'] },
],
},
{
key: 'dormitory_billing',
name: '住宿计费闭环',
description: '学生/宿舍档案 → 入住 → 费用录入 → 生成账单 → 押金。',
requiredPermissions: ['occupancy:view'],
stages: [
{ key: 'profile', label: '学生档案', entities: ['student'] },
{ key: 'room', label: '宿舍档案', entities: ['room'] },
{ key: 'checkin', label: '入住/换宿/退宿', entities: ['occupancy', 'student', 'room'] },
{ key: 'expense', label: '费用录入', entities: ['expense', 'occupancy'] },
{ key: 'bill', label: '生成账单', entities: ['bill', 'occupancy', 'expense'] },
{ key: 'deposit', label: '押金', entities: ['deposit', 'bill', 'student'] },
],
prerequisites: {
checkin: ['profile', 'room'],
expense: ['checkin'],
bill: ['checkin', 'expense'],
deposit: ['bill'],
},
nextSteps: [
{ key: 'after_checkin', label: '入住完成 → 建议录入本月公共/个人费用', after: ['checkin'] },
{ key: 'after_expense', label: '费用录入完成 → 建议生成并确认账单', after: ['expense'] },
{ key: 'after_bill', label: '账单确认 → 建议标记已付并处理押金', after: ['bill'] },
],
},
{
key: 'classroom_rental',
name: '教室租赁闭环',
description: '教室/组织档案 → 租赁订单 → 合同 → 教室日程。',
requiredPermissions: ['rental:view'],
stages: [
{ key: 'classroom', label: '教室档案', entities: ['classroom'] },
{ key: 'organization', label: '组织/校区', entities: ['organization'] },
{ key: 'rental', label: '租赁订单', entities: ['rental', 'classroom', 'organization'] },
{ key: 'contract', label: '合同', entities: ['rental'] },
{ key: 'schedule', label: '教室日程', entities: ['schedule', 'classroom', 'rental'] },
],
prerequisites: {
rental: ['classroom', 'organization'],
contract: ['rental'],
schedule: ['rental'],
},
nextSteps: [
{ key: 'after_rental', label: '租赁订单生成 → 建议补充合同', after: ['rental'] },
{ key: 'after_contract', label: '合同归档 → 建议排定教室日程', after: ['contract'] },
],
},
];
export const BUSINESS_WORKFLOW_KEYS: readonly string[] = BUSINESS_WORKFLOWS.map(
(workflow) => workflow.key,
);

View File

@@ -0,0 +1,85 @@
import { describe, expect, it } from '@jest/globals';
import { BusinessContextService } from './business-context.service';
function principal(permissions: string[] = [], isSuperAdmin = false) {
return { permissions, isSuperAdmin };
}
describe('BusinessContextService', () => {
it('registry 中所有工作流/实体/权限引用都有效', () => {
const service = new BusinessContextService();
expect(service.validateRegistry()).toEqual([]);
expect(() => service.assertValidRegistry()).not.toThrow();
});
it('超级管理员可查看全部工作流与实体', () => {
const service = new BusinessContextService();
const result = service.getBusinessContext(principal([], true));
expect(result.workflows.length).toBeGreaterThanOrEqual(3);
expect(result.entities.length).toBeGreaterThanOrEqual(10);
expect(result.workflows.map((item) => item.key)).toEqual(
expect.arrayContaining(['student_teaching', 'dormitory_billing', 'classroom_rental']),
);
});
it('按权限过滤实体,且只暴露有权限的工作流', () => {
const service = new BusinessContextService();
const result = service.getBusinessContext(principal(['student:view', 'class:view']));
const entityKeys = result.entities.map((item) => item.key);
expect(entityKeys).toContain('student');
expect(entityKeys).toContain('class');
expect(entityKeys).not.toContain('bill');
expect(result.workflows.map((item) => item.key)).toEqual(['student_teaching']);
});
it('workflowKey 聚焦时只返回对应工作流与实体', () => {
const service = new BusinessContextService();
const result = service.getBusinessContext(
principal(['room:view', 'occupancy:view', 'expense:view', 'bill:view', 'student:view'], false),
'dormitory_billing',
);
expect(result.workflows.map((item) => item.key)).toEqual(['dormitory_billing']);
expect(result.entities.map((item) => item.key)).toEqual(
expect.arrayContaining(['room', 'occupancy', 'expense', 'bill', 'student']),
);
});
it('未知或不可见 workflowKey 返回空结果', () => {
const service = new BusinessContextService();
expect(service.getBusinessContext(principal([], false), 'unknown_loop').workflows).toEqual([]);
expect(
service.getBusinessContext(principal(['student:view'], false), 'dormitory_billing').workflows,
).toEqual([]);
});
it('getEntitySchema 返回字段字典与关系,未知/无权限实体返回 null', () => {
const service = new BusinessContextService();
const student = service.getEntitySchema(principal(['student:view']), 'student');
expect(student?.fields.map((field) => field.key)).toEqual(
expect.arrayContaining(['name', 'phone', 'gender', 'studentNo']),
);
expect(student?.relations.some((relation) => relation.entityKey === 'class')).toBe(true);
expect(service.getEntitySchema(principal(['student:view']), 'bill')).toBeNull();
expect(service.getEntitySchema(principal(['student:view']), 'unknown')).toBeNull();
});
it('suggestNextSteps 只返回已完成阶段对应的下一步建议', () => {
const service = new BusinessContextService();
const steps = service.suggestNextSteps(principal([], true), ['profile', 'checkin']);
const labels = steps.map((item) => item.label);
expect(labels).toEqual(
expect.arrayContaining([expect.stringContaining('费用')]),
);
expect(labels.some((item) => item.includes('账单'))).toBe(false);
expect(service.suggestNextSteps(principal([], true), ['profile']).some((item) => item.after.includes('checkin'))).toBe(false);
const afterExpense = service.suggestNextSteps(principal([], true), [
'profile',
'room',
'checkin',
'expense',
]);
expect(afterExpense.map((item) => item.label)).toEqual(
expect.arrayContaining([expect.stringContaining('账单')]),
);
});
});

View File

@@ -0,0 +1,180 @@
import { Injectable } from '@nestjs/common';
import {
BUSINESS_ENTITIES,
BUSINESS_WORKFLOWS,
} from './business-context.registry';
import type {
BusinessContextPrincipal,
BusinessContextResult,
BusinessEntity,
BusinessWorkflow,
BusinessWorkflowNextStep,
} from './business-context.types';
function hasPermission(
principal: BusinessContextPrincipal,
requiredPermissions: readonly string[],
): boolean {
if (principal.isSuperAdmin) return true;
return requiredPermissions.some((permission) => principal.permissions.includes(permission));
}
/**
* 根据已完成阶段返回后续建议(按权限过滤),供 A2UI 提交回灌等场景直接使用。
*/
export function suggestNextStepsForPrincipal(
principal: BusinessContextPrincipal,
completedStageKeys: readonly string[],
): BusinessWorkflowNextStep[] {
const completed = new Set(completedStageKeys);
const steps: BusinessWorkflowNextStep[] = [];
for (const workflow of BUSINESS_WORKFLOWS) {
if (!hasPermission(principal, workflow.requiredPermissions)) continue;
for (const step of workflow.nextSteps) {
if (!step.after.every((stageKey) => completed.has(stageKey))) continue;
if (step.permission && !hasPermission(principal, [step.permission])) continue;
steps.push(step);
}
}
return steps;
}
/**
* 业务上下文服务:把代码内维护的实体字典与工作流元数据按权限暴露给
* Agent纯逻辑不访问数据库
*/
@Injectable()
export class BusinessContextService {
/**
* 校验注册表引用完整性,返回所有问题;空数组表示合法。
*/
validateRegistry(): string[] {
const problems: string[] = [];
const entityKeys = new Set(BUSINESS_ENTITIES.map((entity) => entity.key));
const stageKeys = new Set(
BUSINESS_WORKFLOWS.flatMap((workflow) => workflow.stages.map((stage) => stage.key)),
);
const duplicateEntities = BUSINESS_ENTITIES
.map((entity) => entity.key)
.filter((key, index, all) => all.indexOf(key) !== index);
if (duplicateEntities.length) {
problems.push(`实体 key 重复: ${duplicateEntities.join(', ')}`);
}
for (const entity of BUSINESS_ENTITIES) {
if (!entity.name || !entity.description) {
problems.push(`实体 ${entity.key} 缺少 name/description`);
}
if (entity.requiredPermissions.length === 0) {
problems.push(`实体 ${entity.key} 缺少权限点`);
}
const fieldKeys = new Set<string>();
for (const field of entity.fields) {
if (fieldKeys.has(field.key)) {
problems.push(`实体 ${entity.key} 字段重复: ${field.key}`);
}
fieldKeys.add(field.key);
if (field.type === 'enum' && !field.enumFrom && !field.options) {
problems.push(`实体 ${entity.key} 枚举字段 ${field.key} 缺少 enumFrom/options`);
}
}
for (const relation of entity.relations) {
if (!entityKeys.has(relation.entityKey)) {
problems.push(`实体 ${entity.key} 关系引用未知实体: ${relation.entityKey}`);
}
for (const stageKey of relation.requiredFor) {
if (!stageKeys.has(stageKey)) {
problems.push(`实体 ${entity.key} 关系 ${relation.via} 引用未知阶段: ${stageKey}`);
}
}
}
}
for (const workflow of BUSINESS_WORKFLOWS) {
if (workflow.requiredPermissions.length === 0) {
problems.push(`工作流 ${workflow.key} 缺少权限点`);
}
const workflowStageKeys = new Set(workflow.stages.map((stage) => stage.key));
if (workflowStageKeys.size !== workflow.stages.length) {
problems.push(`工作流 ${workflow.key} 存在重复阶段`);
}
for (const stage of workflow.stages) {
for (const entityKey of stage.entities) {
if (!entityKeys.has(entityKey)) {
problems.push(`工作流 ${workflow.key} 阶段 ${stage.key} 引用未知实体: ${entityKey}`);
}
}
}
for (const [stageKey, prereqs] of Object.entries(workflow.prerequisites)) {
if (!workflowStageKeys.has(stageKey)) {
problems.push(`工作流 ${workflow.key} prerequisites 引用未知阶段: ${stageKey}`);
}
for (const prereq of prereqs) {
if (!workflowStageKeys.has(prereq)) {
problems.push(`工作流 ${workflow.key} 阶段 ${stageKey} 前置引用未知阶段: ${prereq}`);
}
}
}
for (const step of workflow.nextSteps) {
for (const afterKey of step.after) {
if (!workflowStageKeys.has(afterKey)) {
problems.push(`工作流 ${workflow.key} 下一步 ${step.key} 引用未知阶段: ${afterKey}`);
}
}
}
}
return [...new Set(problems)];
}
assertValidRegistry(): void {
const problems = this.validateRegistry();
if (problems.length > 0) {
throw new Error(`业务上下文注册表无效: ${problems.join('; ')}`);
}
}
/**
* 返回当前主体可见的工作流,以及这些工作流阶段引用的实体集合。
* 传 workflowKey 时只返回该工作流(不可见则返回空)。
*/
getBusinessContext(
principal: BusinessContextPrincipal,
workflowKey?: string,
): BusinessContextResult {
let workflows = BUSINESS_WORKFLOWS.filter((workflow) =>
hasPermission(principal, workflow.requiredPermissions),
);
if (workflowKey) {
workflows = workflows.filter((workflow) => workflow.key === workflowKey);
}
const entityKeys = new Set(
workflows.flatMap((workflow) => workflow.stages.flatMap((stage) => stage.entities)),
);
const entities = BUSINESS_ENTITIES.filter(
(entity) => entityKeys.has(entity.key) && hasPermission(principal, entity.requiredPermissions),
);
return { workflows, entities };
}
/** 返回指定实体的字段字典与关系;无权限或未知返回 null。 */
getEntitySchema(principal: BusinessContextPrincipal, entityKey: string): BusinessEntity | null {
const entity = BUSINESS_ENTITIES.find((item) => item.key === entityKey);
if (!entity || !hasPermission(principal, entity.requiredPermissions)) return null;
return entity;
}
/**
* 根据已完成阶段返回后续建议(按权限过滤)。
*/
suggestNextSteps(
principal: BusinessContextPrincipal,
completedStageKeys: readonly string[],
): BusinessWorkflowNextStep[] {
return suggestNextStepsForPrincipal(principal, completedStageKeys);
}
}
export { BUSINESS_ENTITIES, BUSINESS_WORKFLOWS };
export type { BusinessContextPrincipal, BusinessWorkflow };

View File

@@ -0,0 +1,73 @@
import { describe, expect, it, jest } from '@jest/globals';
import type { AgentToolContext } from '../agent-tools/agent-tool.types';
import { AgentToolContextFactory } from '../agent-tools/agent-tool.types';
import type { AuthenticatedUser } from '../authorization';
import type { BusinessContextService } from './business-context.service';
import { GetBusinessContextTool, GetEntitySchemaTool } from './get-business-context.tool';
import { GetPendingTasksTool } from './get-pending-tasks.tool';
function context(permissions: string[] = []): AgentToolContext {
const user: AuthenticatedUser = {
id: 7,
username: 'ops',
permissions,
isSuperAdmin: false,
roles: [],
};
return AgentToolContextFactory.fromAuthenticatedUser(user);
}
describe('agent business context tools', () => {
it('get_business_context 校验 workflowKey 并透传主体与过滤条件', async () => {
const service = {
getBusinessContext: jest.fn().mockReturnValue({ workflows: [], entities: [] }),
} as unknown as BusinessContextService;
const tool = new GetBusinessContextTool(service);
expect(tool.requiredPermission).toBe('ai:chat:use');
expect(tool.validate({ workflowKey: 123 }).ok).toBe(false);
expect(tool.validate({ workflowKey: 'x'.repeat(51) }).ok).toBe(false);
expect(tool.validate({ debug: true }).ok).toBe(false);
const parsed = tool.validate({ workflowKey: 'dormitory_billing' });
expect(parsed.ok).toBe(true);
if (!parsed.ok) return;
const ctx = context(['occupancy:view']);
await tool.execute(parsed.value, ctx);
expect(service.getBusinessContext).toHaveBeenCalledWith(
{ permissions: ['occupancy:view'], isSuperAdmin: false },
'dormitory_billing',
);
});
it('get_entity_schema 必须提供存在的 entityKey', async () => {
const service = {
getEntitySchema: jest.fn().mockReturnValue({ key: 'student', name: '学生档案' }),
} as unknown as BusinessContextService;
const tool = new GetEntitySchemaTool(service);
expect(tool.validate({}).ok).toBe(false);
expect(tool.validate({ entityKey: '' }).ok).toBe(false);
expect(tool.validate({ entityKey: 'student', extra: 1 }).ok).toBe(false);
const parsed = tool.validate({ entityKey: 'student' });
expect(parsed.ok).toBe(true);
if (!parsed.ok) return;
await tool.execute(parsed.value, context());
expect(service.getEntitySchema).toHaveBeenCalledWith(
{ permissions: [], isSuperAdmin: false },
'student',
);
});
it('get_pending_tasks 只接受已知工作流 key', async () => {
const service = {
getPendingTasks: jest.fn().mockResolvedValue([]),
};
const tool = new GetPendingTasksTool(service as never);
expect(tool.requiredPermission).toBe('ai:chat:use');
expect(tool.validate({ workflowKey: 'unknown_loop' }).ok).toBe(false);
expect(tool.validate({ workflowKey: 'dormitory_billing', limit: 1 }).ok).toBe(false);
const parsed = tool.validate({ workflowKey: 'dormitory_billing' });
expect(parsed.ok).toBe(true);
if (!parsed.ok) return;
await tool.execute(parsed.value, context(['bill:view']));
expect(service.getPendingTasks).toHaveBeenCalledWith(expect.anything(), 'dormitory_billing');
});
});

View File

@@ -0,0 +1,66 @@
/** 调用方主体验证(来自可信的 AgentToolContext / AuthenticatedUser。 */
export interface BusinessContextPrincipal {
readonly permissions: readonly string[];
readonly isSuperAdmin: boolean;
}
export type BusinessEntityFieldType = 'string' | 'number' | 'boolean' | 'date' | 'enum';
export interface BusinessEntityField {
key: string;
label: string;
type: BusinessEntityFieldType;
required?: boolean;
/** 枚举值来源(如 gender、room.status供 render_form 生成 options。 */
enumFrom?: string;
options?: Array<{ label: string; value: string }>;
description?: string;
}
export interface BusinessEntityRelation {
entityKey: string;
via: string;
/** 需要该关系已建立的工作流阶段 key。 */
requiredFor: string[];
}
export interface BusinessEntity {
key: string;
name: string;
description: string;
searchTool?: string;
requiredPermissions: readonly string[];
fields: readonly BusinessEntityField[];
relations: readonly BusinessEntityRelation[];
}
export interface BusinessWorkflowStage {
key: string;
label: string;
entities: readonly string[];
}
export interface BusinessWorkflowNextStep {
key: string;
label: string;
/** 满足这些阶段完成后才建议该步骤。 */
after: readonly string[];
/** 可选权限点,未授权用户不返回该建议。 */
permission?: string;
}
export interface BusinessWorkflow {
key: string;
name: string;
description: string;
requiredPermissions: readonly string[];
stages: readonly BusinessWorkflowStage[];
/** stageKey -> 前置阶段 key 列表。 */
prerequisites: Readonly<Record<string, readonly string[]>>;
nextSteps: readonly BusinessWorkflowNextStep[];
}
export interface BusinessContextResult {
workflows: readonly BusinessWorkflow[];
entities: readonly BusinessEntity[];
}

View File

@@ -0,0 +1,90 @@
import { Injectable } from '@nestjs/common';
import type { AgentToolContext, ToolDef, ToolInputResult } from '../agent-tools/agent-tool.types';
import { rejectUnknownKeys, optionalString } from '../agent-tools/tools/tool-input';
import type { BusinessContextService } from './business-context.service';
interface GetBusinessContextInput {
workflowKey?: string;
}
/**
* 让 Agent 获取当前角色可见的业务流程、实体字典与依赖规则。
*/
@Injectable()
export class GetBusinessContextTool implements ToolDef<GetBusinessContextInput> {
readonly name = 'get_business_context';
readonly skillKey = 'assistant';
readonly requiredPermission = 'ai:chat:use';
readonly description =
'获取当前账号可见的业务流程(学生教学/住宿计费/教室租赁)、实体字典、阶段依赖与下一步建议。写入或导入前先调用本工具确认前置数据要求。';
readonly inputSchema = {
type: 'object',
properties: {
workflowKey: {
type: 'string',
description: '可选聚焦某个闭环student_teaching / dormitory_billing / classroom_rental',
maxLength: 50,
},
},
additionalProperties: false,
};
constructor(private readonly service: BusinessContextService) {}
validate(input: Record<string, unknown>): ToolInputResult<GetBusinessContextInput> {
const invalid = rejectUnknownKeys(input, ['workflowKey']);
if (invalid) return invalid;
const workflowKey = optionalString(input.workflowKey, 'workflowKey', 50);
if (!workflowKey.ok) return workflowKey;
return { ok: true, value: { workflowKey: workflowKey.value } };
}
async execute(input: GetBusinessContextInput, context: AgentToolContext): Promise<unknown> {
return this.service.getBusinessContext(
{ permissions: context.permissions, isSuperAdmin: context.isSuperAdmin },
input.workflowKey,
);
}
}
/**
* 让 Agent 获取指定业务实体的字段字典与关系,用于生成准确表单。
*/
@Injectable()
export class GetEntitySchemaTool implements ToolDef<{ entityKey: string }> {
readonly name = 'get_entity_schema';
readonly skillKey = 'assistant';
readonly requiredPermission = 'ai:chat:use';
readonly description =
'获取指定业务实体student/class/room/occupancy/expense/bill/deposit/classroom/organization/rental 等的字段字典、枚举来源与关系render_form 前可按需调用以生成正确字段。';
readonly inputSchema = {
type: 'object',
properties: {
entityKey: { type: 'string', minLength: 1, maxLength: 50 },
},
required: ['entityKey'],
additionalProperties: false,
};
constructor(private readonly service: BusinessContextService) {}
validate(input: Record<string, unknown>): ToolInputResult<{ entityKey: string }> {
const invalid = rejectUnknownKeys(input, ['entityKey']);
if (invalid) return invalid;
if (typeof input.entityKey !== 'string' || !input.entityKey.trim()) {
return { ok: false, error: 'entityKey 必须是字符串' };
}
const entityKey = input.entityKey.trim();
if (entityKey.length > 50) {
return { ok: false, error: 'entityKey 长度不能超过 50' };
}
return { ok: true, value: { entityKey } };
}
async execute(input: { entityKey: string }, context: AgentToolContext): Promise<unknown> {
return this.service.getEntitySchema(
{ permissions: context.permissions, isSuperAdmin: context.isSuperAdmin },
input.entityKey,
);
}
}

View File

@@ -0,0 +1,52 @@
import { Injectable } from '@nestjs/common';
import type { AgentToolContext, ToolDef, ToolInputResult } from '../agent-tools/agent-tool.types';
import { rejectUnknownKeys } from '../agent-tools/tools/tool-input';
import { BUSINESS_WORKFLOW_KEYS } from './business-context.registry';
import type { PendingTasksService } from './pending-tasks.service';
interface GetPendingTasksInput {
workflowKey?: string;
}
/**
* 让 Agent 查询业务完成度(未分班、未入住、未出账单、临期租赁等),
* 用于核实前置数据与给出有数据支撑的下一步建议。
*/
@Injectable()
export class GetPendingTasksTool implements ToolDef<GetPendingTasksInput> {
readonly name = 'get_pending_tasks';
readonly skillKey = 'overview';
readonly requiredPermission = 'ai:chat:use';
readonly description =
'获取当前账号可见范围内的业务待办计数(未分班学生、已建档未入住学生、在住未生成账单、租赁缺合同/临期到期等)。写入或导入前用它核实前置数据是否齐备,完成后用它判断后续待办。';
readonly inputSchema = {
type: 'object',
properties: {
workflowKey: {
type: 'string',
description: '可选聚焦某个闭环student_teaching / dormitory_billing / classroom_rental',
enum: [...BUSINESS_WORKFLOW_KEYS],
},
},
additionalProperties: false,
};
constructor(private readonly service: PendingTasksService) {}
validate(input: Record<string, unknown>): ToolInputResult<GetPendingTasksInput> {
const invalid = rejectUnknownKeys(input, ['workflowKey']);
if (invalid) return invalid;
if (input.workflowKey === undefined) return { ok: true, value: {} };
if (
typeof input.workflowKey !== 'string' ||
!(BUSINESS_WORKFLOW_KEYS as readonly string[]).includes(input.workflowKey)
) {
return { ok: false, error: `workflowKey 必须是 ${BUSINESS_WORKFLOW_KEYS.join(' / ')} 之一` };
}
return { ok: true, value: { workflowKey: input.workflowKey } };
}
execute(input: GetPendingTasksInput, context: AgentToolContext): Promise<unknown> {
return this.service.getPendingTasks(context, input.workflowKey);
}
}

View File

@@ -0,0 +1,12 @@
export { BusinessContextService } from './business-context.service';
export { BUSINESS_ENTITIES, BUSINESS_WORKFLOWS } from './business-context.registry';
export type {
BusinessContextPrincipal,
BusinessContextResult,
BusinessEntity,
BusinessEntityField,
BusinessEntityRelation,
BusinessWorkflow,
BusinessWorkflowNextStep,
BusinessWorkflowStage,
} from './business-context.types';

View File

@@ -0,0 +1,103 @@
import { describe, expect, it, jest } from '@jest/globals';
import type { DataSource } from 'typeorm';
import { AgentToolContextFactory, type AgentToolContext } from '../agent-tools/agent-tool.types';
import type { AuthenticatedUser } from '../authorization';
import type { StudentAccessScopeFactory } from '../students/student-access-scope.factory';
import { PendingTasksService } from './pending-tasks.service';
function context(permissions: string[], isSuperAdmin = false): AgentToolContext {
const user: AuthenticatedUser = {
id: 7,
username: 'ops',
permissions,
isSuperAdmin,
roles: [],
};
return AgentToolContextFactory.fromAuthenticatedUser(user);
}
function createService(
scopeType: 'manageAll' | 'teacher',
queryMock: jest.Mock<Promise<Array<Record<string, unknown>>>>,
) {
const dataSource = { query: queryMock } as unknown as DataSource;
const scopeFactory = {
buildScope: jest.fn().mockReturnValue(
scopeType === 'manageAll'
? { type: 'manageAll' }
: { type: 'teacher', userId: 7 },
),
} as unknown as StudentAccessScopeFactory;
return new PendingTasksService(dataSource, scopeFactory);
}
const ROW = (cnt: unknown) => [{ cnt }] as Record<string, unknown>[];
describe('PendingTasksService', () => {
it('manageAll 权限下返回全部待办计数', async () => {
const query = jest.fn().mockImplementation(async (sql: string) => {
if (sql.includes('NOT EXISTS') && sql.includes('class_student')) return ROW('3');
if (sql.includes('LEFT JOIN occupancies o')) return ROW('5');
if (sql.includes('FROM occupancies o')) return ROW('8');
if (sql.includes('classroom_rentals r') && sql.includes('contract_path IS NULL')) return ROW('2');
if (sql.includes('classroom_rentals r') && sql.includes('end_date BETWEEN')) return ROW('4');
return ROW('0');
});
const service = createService('manageAll', query);
const tasks = await service.getPendingTasks(
context([
'student:view',
'class:view',
'occupancy:view',
'bill:view',
'rental:view',
]),
);
const byKey = Object.fromEntries(tasks.map((task) => [task.key, task]));
expect(byKey.students_without_class?.count).toBe(3);
expect(byKey.students_without_checkin?.count).toBe(5);
expect(byKey.occupancies_without_bill?.count).toBe(8);
expect(byKey.rentals_without_contract?.count).toBe(2);
expect(byKey.rentals_ending_soon?.count).toBe(4);
expect(byKey.students_without_class?.restricted).toBeUndefined();
});
it('teacher 范围只统计本人班级学生,且未分班任务标记 restricted', async () => {
const query = jest.fn().mockImplementation(async (sql: string, params: unknown[]) => {
expect(sql).toContain('cs.class_id IN');
expect(params).toEqual([7]);
if (sql.includes('LEFT JOIN occupancies o')) return ROW('2');
if (sql.includes('FROM occupancies o')) return ROW('1');
return ROW('0');
});
const service = createService('teacher', query);
const tasks = await service.getPendingTasks(
context(['student:view', 'occupancy:view', 'bill:view']),
);
const byKey = Object.fromEntries(tasks.map((task) => [task.key, task]));
expect(byKey.students_without_class).toMatchObject({ count: null, restricted: true });
expect(byKey.students_without_checkin?.count).toBe(2);
expect(byKey.occupancies_without_bill?.count).toBe(1);
expect(byKey.rentals_without_contract).toBeUndefined();
});
it('workflowKey 只返回该闭环的待办', async () => {
const query = jest.fn().mockResolvedValue(ROW('0'));
const service = createService('manageAll', query);
const tasks = await service.getPendingTasks(
context([], true),
'classroom_rental',
);
expect(tasks.map((task) => task.key)).toEqual([
'rentals_without_contract',
'rentals_ending_soon',
]);
});
it('缺少权限的任务不返回', async () => {
const query = jest.fn().mockResolvedValue(ROW('0'));
const service = createService('manageAll', query);
const tasks = await service.getPendingTasks(context(['student:view']));
expect(tasks.map((task) => task.key)).toEqual(['students_without_class']);
});
});

View File

@@ -0,0 +1,183 @@
import { Injectable } from '@nestjs/common';
import type { DataSource } from 'typeorm';
import type { AgentToolContext } from '../agent-tools/agent-tool.types';
import type { StudentAccessScope } from '../students/student-access-scope';
import { StudentAccessScopeFactory } from '../students/student-access-scope.factory';
export interface PendingTask {
key: string;
label: string;
entity: string;
permission: string;
workflowKeys: string[];
count: number | null;
restricted?: boolean;
detail?: string;
}
interface TaskDefinition {
key: string;
label: string;
entity: string;
permission: string;
workflowKeys: string[];
teacherRestricted?: boolean;
sql: (scope: StudentAccessScope) => { sql: string; params: unknown[] };
}
function can(context: AgentToolContext, permission: string): boolean {
return context.isSuperAdmin || context.permissions.includes(permission);
}
function today(): string {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
function inDays(days: number): string {
const date = new Date();
date.setDate(date.getDate() + days);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
const TASKS: readonly TaskDefinition[] = [
{
key: 'students_without_class',
label: '未分班学生',
entity: 'student',
permission: 'student:view',
workflowKeys: ['student_teaching'],
teacherRestricted: true,
sql: () => ({
sql: `
SELECT COUNT(*) AS cnt FROM students s
WHERE s.status = 'active'
AND NOT EXISTS (
SELECT 1 FROM class_student cs
WHERE cs.student_id = s.id AND cs.status = 'active'
)
`,
params: [],
}),
},
{
key: 'students_without_checkin',
label: '已建档未入住学生',
entity: 'occupancy',
permission: 'occupancy:view',
workflowKeys: ['dormitory_billing'],
sql: (scope) => ({
sql: `
SELECT COUNT(DISTINCT s.id) AS cnt FROM students s
INNER JOIN class_student cs ON cs.student_id = s.id AND cs.status = 'active'
LEFT JOIN occupancies o ON o.student_id = s.id AND o.status = 'active'
WHERE s.status = 'active'
${scope.type === 'teacher' ? 'AND cs.class_id IN (SELECT ct.class_id FROM class_teacher ct WHERE ct.user_id = ?)' : ''}
AND o.id IS NULL
`,
params: scope.type === 'teacher' ? [scope.userId] : [],
}),
},
{
key: 'occupancies_without_bill',
label: '在住但未生成账单',
entity: 'bill',
permission: 'bill:view',
workflowKeys: ['dormitory_billing'],
sql: (scope) => ({
sql: `
SELECT COUNT(DISTINCT o.id) AS cnt FROM occupancies o
${scope.type === 'teacher' ? 'INNER JOIN class_student cs ON cs.student_id = o.student_id AND cs.status = \'active\'' : ''}
LEFT JOIN bills b ON b.student_id = o.student_id
WHERE o.status = 'active'
${scope.type === 'teacher' ? 'AND cs.class_id IN (SELECT ct.class_id FROM class_teacher ct WHERE ct.user_id = ?)' : ''}
AND b.id IS NULL
`,
params: scope.type === 'teacher' ? [scope.userId] : [],
}),
},
{
key: 'rentals_without_contract',
label: '租赁未归档合同',
entity: 'rental',
permission: 'rental:view',
workflowKeys: ['classroom_rental'],
sql: () => ({
sql: `
SELECT COUNT(*) AS cnt FROM classroom_rentals r
WHERE r.status = 'active' AND r.contract_path IS NULL
`,
params: [],
}),
},
{
key: 'rentals_ending_soon',
label: '七天内到期租赁',
entity: 'rental',
permission: 'rental:view',
workflowKeys: ['classroom_rental'],
sql: () => ({
sql: `
SELECT COUNT(*) AS cnt FROM classroom_rentals r
WHERE r.status = 'active' AND r.end_date BETWEEN ? AND ?
`,
params: [today(), inDays(7)],
}),
},
];
/**
* 业务完成度查询:按权限与数据范围返回“待办”计数,供 Agent 在
* 写入/导入前核实前置数据、完成后给出有数据支撑的下一步建议。
*/
@Injectable()
export class PendingTasksService {
constructor(
private readonly dataSource: DataSource,
private readonly scopeFactory: StudentAccessScopeFactory,
) {}
async getPendingTasks(
context: AgentToolContext,
workflowKey?: string,
): Promise<PendingTask[]> {
const scope = this.scopeFactory.buildScope(context);
const tasks: PendingTask[] = [];
for (const definition of TASKS) {
if (!can(context, definition.permission)) continue;
if (workflowKey && !definition.workflowKeys.includes(workflowKey)) continue;
if (definition.teacherRestricted && scope.type === 'teacher') {
tasks.push({
key: definition.key,
label: definition.label,
entity: definition.entity,
permission: definition.permission,
workflowKeys: definition.workflowKeys,
count: null,
restricted: true,
detail: '当前角色数据范围不适合统计该待办,建议由教务/运营角色处理',
});
continue;
}
const { sql, params } = definition.sql(scope);
const rows = await this.dataSource.query(sql, params);
const count = Number((rows as Array<Record<string, unknown>>)[0]?.cnt ?? 0);
tasks.push({
key: definition.key,
label: definition.label,
entity: definition.entity,
permission: definition.permission,
workflowKeys: definition.workflowKeys,
count,
});
}
return tasks;
}
}

View File

@@ -4,8 +4,8 @@ export const AGENT_SKILLS: readonly Omit<AgentSkillDescriptor, 'tools'>[] = [
{
key: 'overview',
name: '经营总览',
description: '查看当前权限范围内的学生、班级和今日考勤概览。',
examples: ['今天整体运营情况怎么样?', '帮我汇总当前学生和班级数量'],
description: '查看当前权限范围内的学生、班级和今日考勤概览,以及业务待办(未分班、未入住、未出账单、临期租赁等)。',
examples: ['今天整体运营情况怎么样?', '有哪些业务待办需要处理?', '帮我汇总当前学生和班级数量'],
},
{
key: 'student',
@@ -22,20 +22,20 @@ export const AGENT_SKILLS: readonly Omit<AgentSkillDescriptor, 'tools'>[] = [
{
key: 'dormitory',
name: '宿舍管理',
description: '查询宿舍、入住数量和空余床位。',
examples: ['哪些房间还有空床?', '汇总当前宿舍入住情况'],
description: '查询宿舍、入住数量和空余床位,并按“学生/宿舍档案 → 入住 → 费用 → 账单 → 押金”闭环引导。',
examples: ['哪些房间还有空床?', '汇总当前宿舍入住情况', '我可以按 先学生、再入住、后账单 帮你完成'],
},
{
key: 'billing',
name: '账单查询',
description: '查询账单编号、账期、金额和状态。',
examples: ['查找本月未支付账单', '查询张同学最近的账单'],
description: '查询账单编号、账期、金额和状态,并在入住/费用完成后建议生成与确认账单。',
examples: ['查找本月未支付账单', '查询张同学最近的账单', '哪些在住学生还没生成账单?'],
},
{
key: 'classroom',
name: '教室与租用',
description: '查询教室信息、占用状态和租赁订单。',
examples: ['哪些教室空闲?', '本月教室租赁订单有哪些?'],
description: '查询教室信息、占用状态和租赁订单,并按“教室/组织档案 → 租赁 → 合同 → 日程”闭环引导。',
examples: ['哪些教室空闲?', '本月教室租赁订单有哪些?', '哪些租赁还没归档合同?'],
},
{
key: 'sync',

View File

@@ -32,6 +32,10 @@ import { ExpensesModule } from '../expenses/expenses.module';
import { ClassroomsModule } from '../classrooms/classrooms.module';
import { ClassroomRentalsModule } from '../classroom-rentals/classroom-rentals.module';
import { SyncModule } from '../sync/sync.module';
import { BusinessContextService } from '../agent-context/business-context.service';
import { PendingTasksService } from '../agent-context/pending-tasks.service';
import { GetBusinessContextTool, GetEntitySchemaTool } from '../agent-context/get-business-context.tool';
import { GetPendingTasksTool } from '../agent-context/get-pending-tasks.tool';
/**
* Agent Tools feature module.
@@ -66,6 +70,11 @@ import { SyncModule } from '../sync/sync.module';
providers: [
AgentToolRegistry,
AgentToolExecutor,
BusinessContextService,
PendingTasksService,
GetBusinessContextTool,
GetEntitySchemaTool,
GetPendingTasksTool,
SearchStudentsTool,
GetStudentBasicTool,
AgentBusinessScopeFactory,
@@ -107,9 +116,15 @@ export class AgentToolsModule implements OnModuleInit {
private readonly searchClassroomsTool: SearchClassroomsTool,
private readonly searchClassroomRentalsTool: SearchClassroomRentalsTool,
private readonly getSyncStatusTool: GetSyncStatusTool,
private readonly businessContextTool: GetBusinessContextTool,
private readonly entitySchemaTool: GetEntitySchemaTool,
private readonly pendingTasksTool: GetPendingTasksTool,
) {}
onModuleInit(): void {
this.registry.register(this.businessContextTool);
this.registry.register(this.entitySchemaTool);
this.registry.register(this.pendingTasksTool);
this.registry.register(this.searchTool);
this.registry.register(this.getTool);
this.registry.register(this.searchClassesTool);

View File

@@ -0,0 +1,69 @@
import { describe, expect, it, jest } from '@jest/globals';
import { A2uiSubmissionsService } from './ai-a2ui-submissions.service';
function createService(overrides: Record<string, unknown> = {}) {
const repo = {
findOne: jest.fn(),
save: jest.fn(async (value) => value),
create: jest.fn((value) => value),
...overrides,
};
return { service: new A2uiSubmissionsService(repo as never), repo };
}
const submission = {
id: 1,
artifactId: 'form-1',
clientRequestId: '4d5c1c2a-1111-4222-8333-444455556666',
status: 'created',
resultJson: '{"ok":true}',
createdAt: new Date(),
};
describe('A2uiSubmissionsService', () => {
it('recordSubmission 首次提交创建记录并标记 created', async () => {
const { service, repo } = createService({
findOne: jest.fn().mockResolvedValue(null),
});
const result = await service.recordSubmission({
artifactId: submission.artifactId,
clientRequestId: submission.clientRequestId,
status: 'created',
resultJson: submission.resultJson,
});
expect(result.created).toBe(true);
expect(repo.save).toHaveBeenCalledWith(
expect.objectContaining({
artifactId: submission.artifactId,
clientRequestId: submission.clientRequestId,
}),
);
});
it('recordSubmission 同一 clientRequestId 重复提交返回既有记录且不重复创建', async () => {
const { service, repo } = createService({
findOne: jest.fn().mockResolvedValue(submission),
});
const result = await service.recordSubmission({
artifactId: submission.artifactId,
clientRequestId: submission.clientRequestId,
status: 'created',
resultJson: '{"ok":false}',
});
expect(result.created).toBe(false);
expect(result.submission).toEqual(submission);
expect(repo.save).not.toHaveBeenCalled();
});
it('findSubmission 透传查询条件', async () => {
const { service, repo } = createService({
findOne: jest.fn().mockResolvedValue(submission),
});
await expect(
service.findSubmission(submission.artifactId, submission.clientRequestId),
).resolves.toEqual(submission);
expect(repo.findOne).toHaveBeenCalledWith({
where: { artifactId: submission.artifactId, clientRequestId: submission.clientRequestId },
});
});
});

View File

@@ -0,0 +1,48 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { AiA2uiSubmission } from './entities/ai-a2ui-submission.entity';
export interface RecordSubmissionInput {
artifactId: string;
clientRequestId: string;
status: string;
resultJson?: string | null;
}
/**
* A2UI 提交幂等服务。
*/
@Injectable()
export class A2uiSubmissionsService {
constructor(
@InjectRepository(AiA2uiSubmission)
private readonly submissions: Repository<AiA2uiSubmission>,
) {}
async recordSubmission(input: RecordSubmissionInput): Promise<{
created: boolean;
submission: AiA2uiSubmission;
}> {
const existing = await this.findSubmission(input.artifactId, input.clientRequestId);
if (existing) return { created: false, submission: existing };
const submission = await this.submissions.save(
this.submissions.create({
artifactId: input.artifactId,
clientRequestId: input.clientRequestId,
status: input.status,
resultJson: input.resultJson ?? null,
}),
);
return { created: true, submission };
}
async findSubmission(
artifactId: string,
clientRequestId: string,
): Promise<AiA2uiSubmission | null> {
return this.submissions.findOne({
where: { artifactId, clientRequestId },
});
}
}

View File

@@ -0,0 +1,36 @@
import { describe, expect, it } from '@jest/globals';
import { buildA2uiArtifact } from './ai-a2ui.artifact';
describe('buildA2uiArtifact', () => {
it('构造统一 artifact 载荷id/type/status/messageId/conversationId/payload', () => {
const artifact = buildA2uiArtifact({
type: 'form',
id: 'form-1',
status: 'pending',
messageId: 12,
conversationId: 3,
payload: { title: '新增学生' },
});
expect(artifact).toEqual({
id: 'form-1',
type: 'form',
status: 'pending',
messageId: 12,
conversationId: 3,
payload: { title: '新增学生' },
});
});
it('未知类型被拒绝', () => {
expect(() =>
buildA2uiArtifact({
type: 'hacker' as never,
id: 'x',
status: 'pending',
messageId: 1,
conversationId: 1,
payload: {},
}),
).toThrow();
});
});

View File

@@ -0,0 +1,58 @@
export type A2uiArtifactType =
| 'form'
| 'review'
| 'chart'
| 'import_preflight'
| 'import_wizard';
export type A2uiArtifactStatus = 'rendering' | 'pending' | 'submitted' | 'expired' | 'cancelled';
export interface A2uiArtifact<T = unknown> {
id: string;
type: A2uiArtifactType;
status: A2uiArtifactStatus;
messageId: number;
conversationId: number;
payload: T;
createdAt?: string | null;
submittedAt?: string | null;
supersededBy?: string | null;
}
const A2UI_ARTIFACT_TYPES = new Set<A2uiArtifactType>([
'form',
'review',
'chart',
'import_preflight',
'import_wizard',
]);
/**
* 构造统一的 A2UI artifact 载荷,供 `ui.artifact` SSE 事件与前端归一化。
*/
export function buildA2uiArtifact<T>(input: {
type: A2uiArtifactType;
id: string;
status: A2uiArtifactStatus;
messageId: number;
conversationId: number;
payload: T;
createdAt?: string | null;
submittedAt?: string | null;
supersededBy?: string | null;
}): A2uiArtifact<T> {
if (!A2UI_ARTIFACT_TYPES.has(input.type)) {
throw new Error(`未知 A2UI artifact 类型: ${String(input.type)}`);
}
return {
id: input.id,
type: input.type,
status: input.status,
messageId: input.messageId,
conversationId: input.conversationId,
payload: input.payload,
...(input.createdAt !== undefined ? { createdAt: input.createdAt } : {}),
...(input.submittedAt !== undefined ? { submittedAt: input.submittedAt } : {}),
...(input.supersededBy !== undefined ? { supersededBy: input.supersededBy } : {}),
};
}

View File

@@ -107,7 +107,7 @@ export const A2UI_TOOL_SCHEMAS = [
function: {
name: 'render_form',
description:
'生成一个确认表单显示给用户填写。当用户需要新增或修改业务数据、或需要用户输入/确认信息时调用;用户提交表单后才能执行写操作。',
'生成一个确认表单显示给用户填写。当用户需要新增或修改业务数据、或需要用户输入/确认信息时调用;用户提交表单后才能执行写操作。需要准确字段/枚举时,可先调用 get_entity_schema 获取实体字段字典。',
parameters: {
type: 'object',
properties: {
@@ -211,9 +211,10 @@ export const SYSTEM_PROMPT = `你是恭学系统的业务助理。回答必须
当用户需要可视化数据(趋势、占比、对比、多维、完成率等)时,调用 render_chart 生成图表卡片chartType 支持 line 折线/bar 柱状/pie 饼图/area 面积/scatter 散点/radar 雷达/gauge 仪表盘/funnel 漏斗columns+rows 表格数据)。
上传的 Office 附件上传时系统已自动提取附件文本并随消息提供Excel 为“工作表名 + tab 分隔行”的文本Word/PPT 为提取的文本),直接基于这些文本核对表头与数据、回答用户问题即可;没有单独的附件解析工具,不需要(也无法)主动读取附件原始文件。批量导入前如不确定列名,先调用 preflight_import内部会解析文件并给出列映射、分阶段统计与错误样本再向用户确认并生成导入向导。
业务工作流引导(重要):
- 系统业务按“基础档案 → 业务关系 → 运行数据 → 结算”组织。常见闭环:学生、宿舍、教室、组织等基础档案先行;再建立分班、入住、租赁等关系;之后才有考勤、费用等运行数据;最后生成账单押金等结算
- 执行任何写入或导入前,先判断该操作依赖的前置数据是否已存在(可用查询工具核实):入住依赖学生和宿舍,换宿依赖学生和宿舍,账单依赖入住记录和费用,考勤依赖班级和排课。前置缺失时,先向用户说明缺什么、建议先完成哪一步,再继续,不要机械地跳过依赖直接入库
- 导入或录入完成后,主动给出下一步建议(例如:入住导入完成 → 建议录入本月公共费用 → 生成并确认账单;学生导入完成 → 建议分班或排课)
- 系统业务按“基础档案 → 业务关系 → 运行数据 → 结算”组织,包含三大闭环:学生教学(学生→分班→排课→考勤→考试)、住宿计费(学生/宿舍→入住→费用→账单押金)、教室租赁(教室/组织→租赁→合同→日程)
- 不确定当前角色可用哪些业务流程与实体时,先调用 get_business_context 获取权限范围内的闭环、阶段依赖与实体字典;编写 render_form 字段前可按需调用 get_entity_schema
- 执行任何写入或导入前,先调用 get_pending_tasks 或现有查询工具核实前置数据是否已存在:入住依赖学生和宿舍,换宿依赖学生和宿舍,账单依赖入住记录和费用,考勤依赖班级和排课。前置缺失时,先向用户说明缺什么、建议先完成哪一步,再继续,不要机械地跳过依赖直接入库
- 导入或录入完成后,根据完成阶段主动给出下一步建议(例如:入住完成 → 建议录入本月公共费用 → 生成并确认账单;学生档案完成 → 建议分班;租赁订单生成 → 建议补充合同),可用 get_pending_tasks 获取有数据支撑的待办。
- 用户上传 Excel 但未说明用途时,先根据表头判断包含哪些业务,向用户说明将导入什么、依赖什么;疑似导入时先调用 preflight_import 生成预检报告,再引导用户在预检卡内确认并生成导入向导,按依赖顺序执行。
- 只引导当前角色权限范围内可执行的下一步,不得建议或执行用户无权操作。
不得扩大用户权限或猜测不可见数据。回答使用简洁中文 Markdown。`;

View File

@@ -8,11 +8,13 @@ import { AiAttachmentService } from './ai-attachment.service';
import { AiChartService } from './ai-chart.service';
import { AiExcelReaderService } from './ai-excel-reader.service';
import { AiFormService } from './ai-form.service';
import { A2uiSubmissionsService } from './ai-a2ui-submissions.service';
import { AiReviewService } from './ai-review.service';
import { AiChatService } from './ai-chat.service';
import { AiModelStreamService } from './ai-model-stream.service';
import {
AiAttachment,
AiA2uiSubmission,
AiConversation,
AiForm,
AiMessage,
@@ -24,6 +26,7 @@ import {
imports: [
TypeOrmModule.forFeature([
AiAttachment,
AiA2uiSubmission,
AiConversation,
AiForm,
AiMessage,
@@ -40,6 +43,7 @@ import {
AiChartService,
AiExcelReaderService,
AiFormService,
A2uiSubmissionsService,
AiReviewService,
AiChatService,
AiModelStreamService,

View File

@@ -12,6 +12,7 @@ import { AiChartService } from './ai-chart.service';
import { AiExcelReaderService } from './ai-excel-reader.service';
import { AiFormService } from './ai-form.service';
import { AiReviewService } from './ai-review.service';
import type { A2uiSubmissionsService } from './ai-a2ui-submissions.service';
import { AiModelStreamService } from './ai-model-stream.service';
import { OperationLogsService } from '../operation-logs/operation-logs.service';
import {
@@ -114,6 +115,7 @@ export abstract class AiChatServiceBase implements AiChatServiceContext {
readonly chartService: AiChartService,
readonly abilityFactory: CaslAbilityFactory,
readonly authorization: AuthorizationService,
readonly a2uiSubmissions?: A2uiSubmissionsService,
readonly excelReader?: AiExcelReaderService,
readonly importsService?: ImportsService,
readonly opLog?: OperationLogsService,
@@ -127,7 +129,12 @@ export abstract class AiChatServiceBase implements AiChatServiceContext {
return a2uiReviewSubmitInfo(metadata);
}
buildFormSubmitModelContent(submit: { title: string; values: Record<string, unknown> }): string {
buildFormSubmitModelContent(submit: {
title: string;
values: Record<string, unknown>;
submissionId?: string;
fieldErrors?: Array<{ field: string; message: string }>;
}): string {
return buildFormSubmitModelContent(submit);
}
@@ -135,6 +142,8 @@ export abstract class AiChatServiceBase implements AiChatServiceContext {
reviewId: string;
reviewTitle: string;
resultMessage: string;
submissionId?: string;
nextSteps?: Array<{ key: string; label: string }>;
}): string {
return buildReviewSubmitModelContent(submit);
}

View File

@@ -53,6 +53,7 @@ function createService(
{} as never,
{
createForm: jest.fn(),
expirePreviousForms: jest.fn().mockResolvedValue([]),
findOwnedPending: jest.fn(),
validateValues: jest.fn(),
markSubmitted: jest.fn(),
@@ -65,6 +66,7 @@ function createService(
} as never,
{ createForUser: jest.fn().mockReturnValue({}) } as never,
{ assertPermission: jest.fn(), canPermission: jest.fn() } as never,
{ recordSubmission: jest.fn().mockResolvedValue({ created: true, submission: { id: 1 } }), findSubmission: jest.fn() } as never,
);
return { service, conversations, reviewService };
}
@@ -295,6 +297,7 @@ describe('AiChatService', () => {
} as never,
{
createForm: jest.fn(),
expirePreviousForms: jest.fn().mockResolvedValue([]),
findOwnedPending: jest.fn(),
validateValues: jest.fn(),
markSubmitted: jest.fn(),
@@ -314,6 +317,7 @@ describe('AiChatService', () => {
} as never,
{ createForUser: jest.fn().mockReturnValue({}) } as never,
{ assertPermission: jest.fn(), canPermission: jest.fn() } as never,
{ recordSubmission: jest.fn().mockResolvedValue({ created: true, submission: { id: 1 } }), findSubmission: jest.fn() } as never,
);
const emitted: Array<{ event: string; data: Record<string, unknown> }> = [];
const run = service.streamMessage(
@@ -456,6 +460,7 @@ describe('AiChatService', () => {
} as never,
{
createForm: jest.fn().mockResolvedValue(formShape),
expirePreviousForms: jest.fn().mockResolvedValue([]),
findOwnedPending: jest.fn(),
validateValues: jest.fn(),
markSubmitted: jest.fn(),
@@ -475,6 +480,7 @@ describe('AiChatService', () => {
} as never,
{ createForUser: jest.fn().mockReturnValue({}) } as never,
{ assertPermission: jest.fn(), canPermission: jest.fn() } as never,
{ recordSubmission: jest.fn().mockResolvedValue({ created: true, submission: { id: 1 } }), findSubmission: jest.fn() } as never,
);
const emitted: Array<{ event: string; data: Record<string, unknown> }> = [];
@@ -600,6 +606,7 @@ describe('AiChatService', () => {
} as never,
{
createForm: jest.fn(),
expirePreviousForms: jest.fn().mockResolvedValue([]),
findOwnedPending: jest.fn(),
validateValues: jest.fn(),
markSubmitted: jest.fn(),
@@ -619,6 +626,7 @@ describe('AiChatService', () => {
} as never,
{ createForUser: jest.fn().mockReturnValue({}) } as never,
{ assertPermission: jest.fn(), canPermission: jest.fn() } as never,
{ recordSubmission: jest.fn().mockResolvedValue({ created: true, submission: { id: 1 } }), findSubmission: jest.fn() } as never,
);
const emitted: Array<{ event: string; data: Record<string, unknown> }> = [];
@@ -705,6 +713,7 @@ describe('AiChatService', () => {
} as never,
{
createForm: jest.fn(),
expirePreviousForms: jest.fn().mockResolvedValue([]),
findOwnedPending: jest.fn(),
validateValues: jest.fn(),
markSubmitted: jest.fn(),
@@ -727,6 +736,7 @@ describe('AiChatService', () => {
} as never,
{ createForUser: jest.fn().mockReturnValue({}) } as never,
{ assertPermission, canPermission: jest.fn() } as never,
{ recordSubmission: jest.fn().mockResolvedValue({ created: true, submission: { id: 1 } }), findSubmission: jest.fn() } as never,
);
const emitted: Array<{ event: string }> = [];
@@ -842,6 +852,7 @@ describe('AiChatService', () => {
} as never,
{
createForm: jest.fn(),
expirePreviousForms: jest.fn().mockResolvedValue([]),
findOwnedPending: jest.fn(),
validateValues: jest.fn(),
markSubmitted: jest.fn(),
@@ -864,6 +875,7 @@ describe('AiChatService', () => {
} as never,
{ createForUser: jest.fn().mockReturnValue({}) } as never,
{ assertPermission, canPermission: jest.fn() } as never,
{ recordSubmission: jest.fn().mockResolvedValue({ created: true, submission: { id: 1 } }), findSubmission: jest.fn() } as never,
);
const order: string[] = [];
const emitted: Array<{ event: string; data: Record<string, unknown> }> = [];
@@ -1114,6 +1126,7 @@ describe('AiChatService', () => {
{ removeOrphans } as never,
{
createForm: jest.fn(),
expirePreviousForms: jest.fn().mockResolvedValue([]),
findOwnedPending: jest.fn(),
validateValues: jest.fn(),
markSubmitted: jest.fn(),
@@ -1133,6 +1146,7 @@ describe('AiChatService', () => {
} as never,
{ createForUser: jest.fn().mockReturnValue({}) } as never,
{ assertPermission: jest.fn(), canPermission: jest.fn() } as never,
{ recordSubmission: jest.fn().mockResolvedValue({ created: true, submission: { id: 1 } }), findSubmission: jest.fn() } as never,
);
await expect(service.deleteMessage(7, 3, 10)).resolves.toEqual({ deletedIds: [10, 11] });
@@ -1154,6 +1168,7 @@ describe('AiChatService', () => {
{} as never,
{
createForm: jest.fn(),
expirePreviousForms: jest.fn().mockResolvedValue([]),
findOwnedPending: jest.fn(),
validateValues: jest.fn(),
markSubmitted: jest.fn(),
@@ -1173,6 +1188,7 @@ describe('AiChatService', () => {
} as never,
{ createForUser: jest.fn().mockReturnValue({}) } as never,
{ assertPermission: jest.fn(), canPermission: jest.fn() } as never,
{ recordSubmission: jest.fn().mockResolvedValue({ created: true, submission: { id: 1 } }), findSubmission: jest.fn() } as never,
);
(service as unknown as { activeConversations: Set<number> }).activeConversations.add(3);
await expect(service.deleteMessage(7, 3, 10)).rejects.toBeInstanceOf(ConflictException);
@@ -1238,6 +1254,7 @@ describe('AiChatService', () => {
{ removeOrphans } as never,
{
createForm: jest.fn(),
expirePreviousForms: jest.fn().mockResolvedValue([]),
findOwnedPending: jest.fn(),
validateValues: jest.fn(),
markSubmitted: jest.fn(),
@@ -1257,6 +1274,7 @@ describe('AiChatService', () => {
} as never,
{ createForUser: jest.fn().mockReturnValue({}) } as never,
{ assertPermission: jest.fn(), canPermission: jest.fn() } as never,
{ recordSubmission: jest.fn().mockResolvedValue({ created: true, submission: { id: 1 } }), findSubmission: jest.fn() } as never,
);
const emitted: Array<{ event: string; data: Record<string, unknown> }> = [];

View File

@@ -14,6 +14,7 @@ import { AiChartService } from './ai-chart.service';
import { AiExcelReaderService } from './ai-excel-reader.service';
import { AiFormService } from './ai-form.service';
import { AiReviewService } from './ai-review.service';
import type { A2uiSubmissionsService } from './ai-a2ui-submissions.service';
import { AiModelStreamService } from './ai-model-stream.service';
import { OperationLogsService } from '../operation-logs/operation-logs.service';
import { AiConversation, AiMessage, AiToolRun } from './entities';
@@ -66,6 +67,7 @@ export class AiChatService extends AiChatServiceBase {
chartService: AiChartService,
abilityFactory: CaslAbilityFactory,
authorization: AuthorizationService,
a2uiSubmissions?: A2uiSubmissionsService,
excelReader?: AiExcelReaderService,
importsService?: ImportsService,
opLog?: OperationLogsService,
@@ -84,6 +86,7 @@ export class AiChatService extends AiChatServiceBase {
chartService,
abilityFactory,
authorization,
a2uiSubmissions,
excelReader,
importsService,
opLog,

View File

@@ -1,9 +1,22 @@
import type { AiChatServiceContext, AiSseEmitter } from './ai-chat.types';
import { DEFAULT_TITLE } from './ai-chat.types';
import type { AuthenticatedUser } from '../authorization';
import { suggestNextStepsForPrincipal } from '../agent-context/business-context.service';
import { buildA2uiArtifact } from './ai-a2ui.artifact';
import { A2uiSubmissionsService } from './ai-a2ui-submissions.service';
import { AiA2uiSubmission } from './entities';
import { assertReviewImportPermissions } from './ai-chat.review-confirm';
import { persistExchange, runGenerationAndRelease } from './ai-chat.submissions.runtime';
function submissionsService(
context: AiChatServiceContext,
): A2uiSubmissionsService {
return (
context.a2uiSubmissions ??
new A2uiSubmissionsService(context.dataSource.getRepository(AiA2uiSubmission))
);
}
export async function submitForm(
context: AiChatServiceContext,
user: AuthenticatedUser,
@@ -19,6 +32,18 @@ export async function submitForm(
const effectiveSkillKey = conversation.lockedSkillKey ?? null;
context.assertSkillAvailable(user, effectiveSkillKey);
const submissions = submissionsService(context);
const recorded = await submissions.recordSubmission({
artifactId: formId,
clientRequestId: dto.clientRequestId,
status: 'created',
});
if (!recorded.created) {
emit('error', { message: '该表单已提交过,请勿重复提交' });
emit('done', {});
return;
}
await context.acquireConversation(conversation.id);
try {
const summary = `已提交表单「${form.title}`;
@@ -31,7 +56,15 @@ export async function submitForm(
summary,
dto.clientRequestId,
effectiveSkillKey,
{ a2uiSubmit: { formId: form.id, formTitle: form.title, values } },
{
a2uiSubmit: {
formId: form.id,
formTitle: form.title,
values,
submissionId: String(recorded.submission.id),
fieldErrors: [],
},
},
undefined,
conversation.title === DEFAULT_TITLE ? form.title.slice(0, 30) : undefined,
),
@@ -39,6 +72,17 @@ export async function submitForm(
await context.formService.markSubmitted(form, values);
await context.markFormSubmittedOnMessage(form.assistantMessageId, conversation.id);
emit('ui.artifact', {
messageId: form.assistantMessageId,
artifact: buildA2uiArtifact({
type: 'form',
id: form.id,
status: 'submitted',
messageId: form.assistantMessageId,
conversationId: conversation.id,
payload: { ...context.formService.serialize(form), status: 'submitted' },
}),
});
await runGenerationAndRelease(context, {
user,
@@ -73,12 +117,43 @@ export async function submitReview(
context.assertSkillAvailable(user, effectiveSkillKey);
assertReviewImportPermissions(context, user, review);
const submissions = submissionsService(context);
const recorded = await submissions.recordSubmission({
artifactId: reviewId,
clientRequestId: dto.clientRequestId,
status: 'created',
});
if (!recorded.created) {
emit('error', { message: '该导入预览已确认过,请勿重复提交' });
emit('done', {});
return;
}
await context.acquireConversation(conversation.id);
try {
const { review: updatedReview, result } = await context.reviewService.submitAll(
review.id,
user.id,
);
const sectionTypes = context.reviewService
.parseSections(review.sectionsJson)
.map((section) => section.type)
.filter(Boolean);
const completedStageKeys = [
...new Set(
sectionTypes.map((type) =>
type === 'students'
? 'profile'
: type === 'rooms'
? 'room'
: 'checkin',
),
),
];
const nextSteps = suggestNextStepsForPrincipal(
{ permissions: user.permissions, isSuperAdmin: user.isSuperAdmin },
completedStageKeys,
).map((step) => ({ key: step.key, label: step.label }));
const summary = `已确认导入「${review.title}」:${result.message}`;
await context.opLog?.log({
userId: user.id,
@@ -103,6 +178,8 @@ export async function submitReview(
reviewId: review.id,
reviewTitle: review.title,
resultMessage: result.message,
submissionId: String(recorded.submission.id),
nextSteps,
},
},
undefined,
@@ -117,6 +194,17 @@ export async function submitReview(
messageId: updatedReview.assistantMessageId,
review: serialized,
});
emit('ui.artifact', {
messageId: updatedReview.assistantMessageId,
artifact: buildA2uiArtifact({
type: 'review',
id: review.id,
status: 'submitted',
messageId: updatedReview.assistantMessageId,
conversationId: conversation.id,
payload: serialized,
}),
});
await context.markReviewSubmittedOnMessage(
updatedReview.assistantMessageId,
conversation.id,

View File

@@ -0,0 +1,66 @@
import { describe, expect, it } from '@jest/globals';
import {
a2uiReviewSubmitInfo,
a2uiSubmitInfo,
buildFormSubmitModelContent,
buildReviewSubmitModelContent,
} from './ai-chat.submit-content';
describe('a2ui submit model content', () => {
it('表单提交信息解析 submissionId 与 fieldErrors', () => {
const info = a2uiSubmitInfo({
a2uiSubmit: {
formId: 'form-1',
formTitle: '新增学生',
values: { name: '张三' },
submissionId: 'sub-1',
fieldErrors: [{ field: 'phone', message: '手机号格式错误' }],
},
});
expect(info).toEqual({
formId: 'form-1',
title: '新增学生',
values: { name: '张三' },
submissionId: 'sub-1',
fieldErrors: [{ field: 'phone', message: '手机号格式错误' }],
});
});
it('表单回灌内容包含提交 ID 与结构化错误', () => {
const content = buildFormSubmitModelContent({
title: '新增学生',
values: { name: '张三' },
submissionId: 'sub-1',
fieldErrors: [],
});
expect(content).toContain('sub-1');
expect(content).toContain('"name":"张三"');
expect(content).toContain('fieldErrors');
});
it('审阅提交信息解析 submissionId 与下一步建议', () => {
const info = a2uiReviewSubmitInfo({
a2uiReviewSubmit: {
reviewId: 'review-1',
reviewTitle: '批量入住导入',
resultMessage: '已导入 12 条',
submissionId: 'sub-2',
nextSteps: [{ key: 'after_checkin', label: '入住完成 → 建议录入费用' }],
},
});
expect(info?.submissionId).toBe('sub-2');
expect(info?.nextSteps).toHaveLength(1);
});
it('审阅回灌内容包含提交 ID 与下一步建议', () => {
const content = buildReviewSubmitModelContent({
reviewId: 'review-1',
reviewTitle: '批量入住导入',
resultMessage: '已导入 12 条',
submissionId: 'sub-2',
nextSteps: [{ key: 'after_checkin', label: '入住完成 → 建议录入费用' }],
});
expect(content).toContain('sub-2');
expect(content).toContain('建议录入费用');
});
});

View File

@@ -3,21 +3,35 @@ import type { AiChatServiceContext } from './ai-chat.types';
export function a2uiSubmitInfo(
metadata: Record<string, unknown> | null,
): { title: string; values: Record<string, unknown> } | null {
): {
formId: string;
title: string;
values: Record<string, unknown>;
submissionId?: string;
fieldErrors?: Array<{ field: string; message: string }>;
} | null {
const submit = metadata?.a2uiSubmit;
if (!submit || typeof submit !== 'object' || Array.isArray(submit)) return null;
const record = submit as Record<string, unknown>;
const formId = typeof record.formId === 'string' ? record.formId : '';
const title = typeof record.formTitle === 'string' ? record.formTitle : '表单';
const values =
record.values && typeof record.values === 'object' && !Array.isArray(record.values)
? (record.values as Record<string, unknown>)
: {};
return { title, values };
const submissionId =
typeof record.submissionId === 'string' ? record.submissionId : undefined;
const fieldErrors = Array.isArray(record.fieldErrors)
? (record.fieldErrors as Array<{ field: string; message: string }>)
: [];
return { formId, title, values, submissionId, fieldErrors };
}
export function buildFormSubmitModelContent(submit: {
title: string;
values: Record<string, unknown>;
submissionId?: string;
fieldErrors?: Array<{ field: string; message: string }>;
}): string {
let json: string;
try {
@@ -25,7 +39,11 @@ export function buildFormSubmitModelContent(submit: {
} catch {
json = '[无法序列化]';
}
return `【表单提交:${submit.title}\n提交值JSON${json.slice(0, 32 * 1024)}\n用户已在表单中确认你可以执行允许的写操作工具。`;
const submissionId = submit.submissionId ? `\n提交ID${submit.submissionId}` : '';
const fieldErrors = submit.fieldErrors?.length
? `\nfieldErrors: ${JSON.stringify(submit.fieldErrors)}`
: '\nfieldErrors: []';
return `【表单提交:${submit.title}${submissionId}\n提交值JSON${json.slice(0, 32 * 1024)}${fieldErrors}\n用户已在表单中确认你可以执行允许的写操作工具。`;
}
export async function markFormSubmittedOnMessage(
@@ -48,15 +66,28 @@ export async function markFormSubmittedOnMessage(
export function a2uiReviewSubmitInfo(
metadata: Record<string, unknown> | null,
): { reviewId: string; reviewTitle: string; resultMessage: string } | null {
): {
reviewId: string;
reviewTitle: string;
resultMessage: string;
submissionId?: string;
nextSteps?: Array<{ key: string; label: string }>;
} | null {
const submit = metadata?.a2uiReviewSubmit;
if (!submit || typeof submit !== 'object' || Array.isArray(submit)) return null;
const record = submit as Record<string, unknown>;
if (typeof record.reviewId !== 'string') return null;
const submissionId =
typeof record.submissionId === 'string' ? record.submissionId : undefined;
const nextSteps = Array.isArray(record.nextSteps)
? (record.nextSteps as Array<{ key: string; label: string }>)
: [];
return {
reviewId: record.reviewId,
reviewTitle: typeof record.reviewTitle === 'string' ? record.reviewTitle : '批量导入',
resultMessage: typeof record.resultMessage === 'string' ? record.resultMessage : '导入已完成',
submissionId,
nextSteps,
};
}
@@ -64,8 +95,14 @@ export function buildReviewSubmitModelContent(submit: {
reviewId: string;
reviewTitle: string;
resultMessage: string;
submissionId?: string;
nextSteps?: Array<{ key: string; label: string }>;
}): string {
return `【批量导入已确认:${submit.reviewTitle}\n${submit.resultMessage}\n数据已由系统入库不要再次调用写入工具直接向用户汇报导入结果即可。`;
const submissionId = submit.submissionId ? `\n提交ID${submit.submissionId}` : '';
const nextSteps = submit.nextSteps?.length
? `\n下一步建议${submit.nextSteps.map((step) => step.label).join('')}`
: '';
return `【批量导入已确认:${submit.reviewTitle}${submissionId}\n${submit.resultMessage}${nextSteps}\n数据已由系统入库不要再次调用写入工具直接向用户汇报导入结果即可。`;
}
export async function markReviewSubmittedOnMessage(

View File

@@ -8,6 +8,7 @@ import { permittedStepKeys } from '../imports/imports.access';
import { expandStageSheets } from '../imports/imports.mapping';
import type { AiChatServiceContext, AiSseEmitter, ModelToolCall } from './ai-chat.types';
import type { AgentToolContext } from './ai-chat.tools';
import { buildA2uiArtifact } from './ai-a2ui.artifact';
import { AiMessage } from './entities';
import { finishToolRun, startToolRun } from './ai-chat.tools';
import {
@@ -155,6 +156,17 @@ export const executePreflightImport = makeImportToolExecutor(
.join('、') || '未识别到可导入阶段'}`,
}, emit);
emit('ui.import_preflight', { messageId, preflight: preflightCard });
emit('ui.artifact', {
messageId,
artifact: buildA2uiArtifact({
type: 'import_preflight',
id: `preflight-${attachment.id}`,
status: 'pending',
messageId,
conversationId: assistant.conversationId,
payload: preflightCard,
}),
});
return preflightModelPayload(preflight, permittedSteps);
});
@@ -241,6 +253,17 @@ export const executeStartImportWizard = makeImportToolExecutor(
});
}
emit('ui.import_wizard', { messageId, wizard });
emit('ui.artifact', {
messageId,
artifact: buildA2uiArtifact({
type: 'import_wizard',
id: `wizard-${detail.id}`,
status: 'pending',
messageId,
conversationId: assistant.conversationId,
payload: wizard,
}),
});
return JSON.stringify({
status: 'success',
runId: detail.id,

View File

@@ -1,5 +1,6 @@
import { AiReview } from './entities/ai-review.entity';
import type { AiChatServiceContext, AiSseEmitter, ModelToolCall } from './ai-chat.types';
import { buildA2uiArtifact } from './ai-a2ui.artifact';
import { finishToolRun, startToolRun } from './ai-chat.tools';
export async function executeRenderForm(
context: AiChatServiceContext,
@@ -20,6 +21,39 @@ export async function executeRenderForm(
{ userId, conversationId: assistant.conversationId, assistantMessageId: messageId },
parsedArgs,
);
const expiredForms = await context.formService.expirePreviousForms(
userId,
assistant.conversationId,
form.id,
);
await Promise.all(
expiredForms.map(async (expired) => {
const oldAssistant = await context.messages.findOne({
where: { id: expired.assistantMessageId, conversationId: assistant.conversationId },
});
const oldA2ui = oldAssistant?.metadata?.a2uiForm;
if (oldAssistant && oldA2ui && typeof oldA2ui === 'object' && !Array.isArray(oldA2ui)) {
oldAssistant.metadata = {
...oldAssistant.metadata,
a2uiForm: context.formService.serialize(expired),
};
await context.messages.save(oldAssistant);
}
const expiredPayload = context.formService.serialize(expired);
emit('ui.form', { messageId: expired.assistantMessageId, form: expiredPayload });
emit('ui.artifact', {
messageId: expired.assistantMessageId,
artifact: buildA2uiArtifact({
type: 'form',
id: expired.id,
status: 'expired',
messageId: expired.assistantMessageId,
conversationId: assistant.conversationId,
payload: expiredPayload,
}),
});
}),
);
assistant.metadata = {
...assistant.metadata,
a2uiForm: context.formService.serialize(form),
@@ -31,6 +65,17 @@ export async function executeRenderForm(
messageId,
form: context.formService.serialize(form),
});
emit('ui.artifact', {
messageId,
artifact: buildA2uiArtifact({
type: 'form',
id: form.id,
status: form.status === 'submitted' ? 'submitted' : 'pending',
messageId,
conversationId: assistant.conversationId,
payload: context.formService.serialize(form),
}),
});
return JSON.stringify({
status: 'success',
formId: form.id,
@@ -119,6 +164,17 @@ export async function executeRenderReview(
messageId: expired.assistantMessageId,
review: context.reviewService.serialize(expired),
});
emit('ui.artifact', {
messageId: expired.assistantMessageId,
artifact: buildA2uiArtifact({
type: 'review',
id: expired.id,
status: expired.status === 'submitted' ? 'submitted' : 'expired',
messageId: expired.assistantMessageId,
conversationId: assistant.conversationId,
payload: context.reviewService.serialize(expired),
}),
});
}),
);
assistant.metadata = {
@@ -132,6 +188,17 @@ export async function executeRenderReview(
messageId,
review: context.reviewService.serialize(review),
});
emit('ui.artifact', {
messageId,
artifact: buildA2uiArtifact({
type: 'review',
id: review.id,
status: review.status === 'submitted' ? 'submitted' : 'pending',
messageId,
conversationId: assistant.conversationId,
payload: context.reviewService.serialize(review),
}),
});
return JSON.stringify({
status: 'success',
reviewId: review.id,
@@ -179,6 +246,17 @@ export async function executeRenderChart(
messageId,
chart: context.chartService.serialize(chart),
});
emit('ui.artifact', {
messageId,
artifact: buildA2uiArtifact({
type: 'chart',
id: chart.id,
status: 'pending',
messageId,
conversationId: assistant.conversationId,
payload: context.chartService.serialize(chart),
}),
});
return JSON.stringify({
status: 'success',
chartId: chart.id,

View File

@@ -10,6 +10,7 @@ import { AiChartService } from './ai-chart.service';
import { AiExcelReaderService } from './ai-excel-reader.service';
import { AiFormService } from './ai-form.service';
import { AiReviewService } from './ai-review.service';
import type { A2uiSubmissionsService } from './ai-a2ui-submissions.service';
import { AiModelStreamService } from './ai-model-stream.service';
import { OperationLogsService } from '../operation-logs/operation-logs.service';
import {
@@ -95,6 +96,7 @@ export interface AiChatServiceContext {
readonly excelReader?: AiExcelReaderService;
readonly importsService?: ImportsService;
readonly opLog?: OperationLogsService;
readonly a2uiSubmissions?: A2uiSubmissionsService;
listSkills(user: AuthenticatedUser): ReturnType<AgentToolExecutor['listSkills']>;
serializeMessage(message: AiMessage): Record<string, unknown>;
redactText(value: string): string;
@@ -114,11 +116,18 @@ export interface AiChatServiceContext {
reviewTitle: string;
resultMessage: string;
} | null;
buildFormSubmitModelContent(submit: { title: string; values: Record<string, unknown> }): string;
buildFormSubmitModelContent(submit: {
title: string;
values: Record<string, unknown>;
submissionId?: string;
fieldErrors?: Array<{ field: string; message: string }>;
}): string;
buildReviewSubmitModelContent(submit: {
reviewId: string;
reviewTitle: string;
resultMessage: string;
submissionId?: string;
nextSteps?: Array<{ key: string; label: string }>;
}): string;
markFormSubmittedOnMessage(assistantMessageId: number, conversationId: number): Promise<void>;
markReviewSubmittedOnMessage(
@@ -175,6 +184,7 @@ export type AiSseEventName =
| 'ui.form'
| 'ui.review'
| 'ui.chart'
| 'ui.artifact'
| 'ui.import_preflight'
| 'ui.import_wizard'
| 'attachment.processed'

View File

@@ -1,4 +1,5 @@
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { In } from 'typeorm';
import { AiFormService } from './ai-form.service';
function createService(overrides: Record<string, unknown> = {}) {
@@ -140,6 +141,35 @@ describe('AiFormService', () => {
});
});
describe('expirePreviousForms', () => {
it('过期同会话其他 pending 表单并返回过期记录', async () => {
const pending = [
{ id: 'form-a', userId: 7, conversationId: 3, status: 'pending' },
{ id: 'form-b', userId: 7, conversationId: 3, status: 'pending' },
];
const { service, forms } = createService({
find: jest.fn().mockResolvedValue(pending),
update: jest.fn().mockResolvedValue({}),
});
const expired = await service.expirePreviousForms(7, 3, 'form-b');
expect(expired.map((item) => item.id)).toEqual(['form-a']);
expect(expired[0].status).toBe('expired');
expect(forms.update).toHaveBeenCalledWith(
{ id: In(['form-a']) },
{ status: 'expired' },
);
});
it('没有其他 pending 表单时返回空数组且不更新', async () => {
const { service, forms } = createService({
find: jest.fn().mockResolvedValue([{ id: 'form-b' }]),
update: jest.fn(),
});
await expect(service.expirePreviousForms(7, 3, 'form-b')).resolves.toEqual([]);
expect(forms.update).not.toHaveBeenCalled();
});
});
describe('validateValues', () => {
const form = {
fieldsJson: JSON.stringify(validSchema.fields),

View File

@@ -1,6 +1,6 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { In, Repository } from 'typeorm';
import { uuidV7 } from '../common/uuid-v7';
import { AiForm, type AiFormField } from './entities/ai-form.entity';
@@ -108,10 +108,28 @@ export class AiFormService {
async findOwnedPending(formId: string, userId: number): Promise<AiForm> {
const form = await this.forms.findOne({ where: { id: formId, userId, status: 'pending' } });
if (!form) throw new NotFoundException('表单不存在已提交');
if (!form) throw new NotFoundException('表单不存在已提交或已失效');
return form;
}
/**
* 过期同会话其他 pending 表单(新表单渲染后旧表单不可再提交)。
*/
async expirePreviousForms(
userId: number,
conversationId: number,
exceptFormId: string,
): Promise<AiForm[]> {
const pending = await this.forms.find({
where: { userId, conversationId, status: 'pending' },
});
const expired = pending.filter((form) => form.id !== exceptFormId);
if (expired.length === 0) return [];
const ids = expired.map((form) => form.id);
await this.forms.update({ id: In(ids) }, { status: 'expired' });
return expired.map((form) => ({ ...form, status: 'expired' as const }));
}
async markSubmitted(form: AiForm, values: Record<string, unknown>): Promise<AiForm> {
form.status = 'submitted';
form.submittedValuesJson = JSON.stringify(values);

View File

@@ -0,0 +1,35 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
PrimaryGeneratedColumn,
} from 'typeorm';
/**
* A2UI 提交幂等记录:同一 artifact + clientRequestId 只允许执行一次,
* 重复提交返回既有结果,避免双击/重试导致二次写入或二次生成。
*/
@Entity('ai_a2ui_submissions')
@Index('uk_ai_a2ui_submissions_artifact_client', ['artifactId', 'clientRequestId'], {
unique: true,
})
export class AiA2uiSubmission {
@PrimaryGeneratedColumn()
id: number;
@Column({ name: 'artifact_id', type: 'varchar', length: 100 })
artifactId: string;
@Column({ name: 'client_request_id', type: 'varchar', length: 36 })
clientRequestId: string;
@Column({ type: 'varchar', length: 20 })
status: string;
@Column({ name: 'result_json', type: 'text', nullable: true })
resultJson: string | null;
@CreateDateColumn({ name: 'created_at', type: 'datetime' })
createdAt: Date;
}

View File

@@ -10,7 +10,7 @@ import {
} from 'typeorm';
import { AiMessage } from './ai-message.entity';
export type AiFormStatus = 'pending' | 'submitted';
export type AiFormStatus = 'pending' | 'submitted' | 'expired';
export interface AiFormField {
name: string;

View File

@@ -4,3 +4,4 @@ export * from './ai-tool-run.entity';
export * from './ai-attachment.entity';
export * from './ai-form.entity';
export * from './ai-review.entity';
export * from './ai-a2ui-submission.entity';

View File

@@ -19,6 +19,7 @@ import { AddA2UiReviews1784880000000 } from './migrations/1784880000000-AddA2UiR
import { AddImportRuns1784910000000 } from './migrations/1784910000000-AddImportRuns';
import { DropAiMessageFeedback1784920000000 } from './migrations/1784920000000-DropAiMessageFeedback';
import { AddImportRunSettings1784930000000 } from './migrations/1784930000000-AddImportRunSettings';
import { AddA2UiSubmissions1786001000000 } from './migrations/1786001000000-AddA2UiSubmissions';
const allMigrations = [
InitialSchema1784520727860,
AddExamManagement1784600000000,
@@ -31,6 +32,7 @@ const allMigrations = [
AddImportRuns1784910000000,
DropAiMessageFeedback1784920000000,
AddImportRunSettings1784930000000,
AddA2UiSubmissions1786001000000,
];
import { AuthorizationModule } from './authorization';
import { RbacModule } from './rbac/rbac.module';
@@ -149,6 +151,7 @@ import { IntegrationConfigModule } from './integration/config/config.module';
Entities.AiAttachment,
Entities.AiForm,
Entities.AiReview,
Entities.AiA2uiSubmission,
Entities.ImportRun,
Entities.ImportStep,
Entities.ImportRow,

View File

@@ -53,6 +53,7 @@ export {
AiToolRun,
AiForm,
AiReview,
AiA2uiSubmission,
} from '../ai-chat/entities';
export { ImportRun } from '../imports/entities/import-run.entity';
export { ImportStep } from '../imports/entities/import-step.entity';

View File

@@ -0,0 +1,37 @@
import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm';
/**
* A2UI 提交幂等记录:同一 artifact + clientRequestId 只允许执行一次。
*/
export class AddA2UiSubmissions1786001000000 implements MigrationInterface {
async up(queryRunner: QueryRunner): Promise<void> {
if (await queryRunner.hasTable('ai_a2ui_submissions')) return;
await queryRunner.createTable(
new Table({
name: 'ai_a2ui_submissions',
columns: [
{ name: 'id', type: 'int', isPrimary: true, isGenerated: true, generationStrategy: 'increment' },
{ name: 'artifact_id', type: 'varchar', length: '100' },
{ name: 'client_request_id', type: 'varchar', length: '36' },
{ name: 'status', type: 'varchar', length: '20' },
{ name: 'result_json', type: 'text', isNullable: true },
{ name: 'created_at', type: 'datetime', default: 'CURRENT_TIMESTAMP' },
],
}),
);
await queryRunner.createIndex(
'ai_a2ui_submissions',
new TableIndex({
name: 'uk_ai_a2ui_submissions_artifact_client',
columnNames: ['artifact_id', 'client_request_id'],
isUnique: true,
}),
);
}
async down(queryRunner: QueryRunner): Promise<void> {
if (await queryRunner.hasTable('ai_a2ui_submissions')) {
await queryRunner.dropTable('ai_a2ui_submissions');
}
}
}