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:
318
apps/server/src/agent-context/business-context.registry.ts
Normal file
318
apps/server/src/agent-context/business-context.registry.ts
Normal 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,
|
||||
);
|
||||
@@ -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('账单')]),
|
||||
);
|
||||
});
|
||||
});
|
||||
180
apps/server/src/agent-context/business-context.service.ts
Normal file
180
apps/server/src/agent-context/business-context.service.ts
Normal 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 };
|
||||
73
apps/server/src/agent-context/business-context.tools.spec.ts
Normal file
73
apps/server/src/agent-context/business-context.tools.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
66
apps/server/src/agent-context/business-context.types.ts
Normal file
66
apps/server/src/agent-context/business-context.types.ts
Normal 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[];
|
||||
}
|
||||
90
apps/server/src/agent-context/get-business-context.tool.ts
Normal file
90
apps/server/src/agent-context/get-business-context.tool.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
52
apps/server/src/agent-context/get-pending-tasks.tool.ts
Normal file
52
apps/server/src/agent-context/get-pending-tasks.tool.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
12
apps/server/src/agent-context/index.ts
Normal file
12
apps/server/src/agent-context/index.ts
Normal 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';
|
||||
103
apps/server/src/agent-context/pending-tasks.service.spec.ts
Normal file
103
apps/server/src/agent-context/pending-tasks.service.spec.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
183
apps/server/src/agent-context/pending-tasks.service.ts
Normal file
183
apps/server/src/agent-context/pending-tasks.service.ts
Normal 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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
69
apps/server/src/ai-chat/ai-a2ui-submissions.service.spec.ts
Normal file
69
apps/server/src/ai-chat/ai-a2ui-submissions.service.spec.ts
Normal 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 },
|
||||
});
|
||||
});
|
||||
});
|
||||
48
apps/server/src/ai-chat/ai-a2ui-submissions.service.ts
Normal file
48
apps/server/src/ai-chat/ai-a2ui-submissions.service.ts
Normal 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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
36
apps/server/src/ai-chat/ai-a2ui.artifact.spec.ts
Normal file
36
apps/server/src/ai-chat/ai-a2ui.artifact.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
58
apps/server/src/ai-chat/ai-a2ui.artifact.ts
Normal file
58
apps/server/src/ai-chat/ai-a2ui.artifact.ts
Normal 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 } : {}),
|
||||
};
|
||||
}
|
||||
@@ -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。`;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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> }> = [];
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
66
apps/server/src/ai-chat/ai-chat.submit-content.spec.ts
Normal file
66
apps/server/src/ai-chat/ai-chat.submit-content.spec.ts
Normal 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('建议录入费用');
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user