feat: AI 对话支持 A2UI 表单/审查/图表与 Agent 工具
This commit is contained in:
328
apps/server/src/ai-chat/ai-review.shared.ts
Normal file
328
apps/server/src/ai-chat/ai-review.shared.ts
Normal file
@@ -0,0 +1,328 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import type {
|
||||
AiReview,
|
||||
AiReviewSection,
|
||||
AiReviewSectionType,
|
||||
} from './entities/ai-review.entity';
|
||||
|
||||
export const MAX_TITLE = 50;
|
||||
export const MAX_SUMMARY = 500;
|
||||
export const MAX_SECTIONS = 20;
|
||||
export const MAX_SECTION_TITLE = 50;
|
||||
export const MAX_COLUMNS = 30;
|
||||
export const MAX_COLUMN_KEY = 50;
|
||||
export const MAX_COLUMN_TITLE = 50;
|
||||
export const MAX_ROWS = 500;
|
||||
export const MAX_CELL_LENGTH = 200;
|
||||
export const MAX_ISSUES = 50;
|
||||
export const MAX_ISSUE_LENGTH = 200;
|
||||
export const MAX_SECTIONS_JSON_BYTES = 12 * 1024 * 1024;
|
||||
export const MAX_SECTION_JSON_BYTES = Math.floor(MAX_SECTIONS_JSON_BYTES / MAX_SECTIONS);
|
||||
export const MAX_CAPACITY = 200;
|
||||
|
||||
export const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
export const COLUMN_KEY_RE = /^[a-zA-Z0-9_]{1,50}$/;
|
||||
export const SECTION_KEY_RE = /^[a-zA-Z0-9_]{1,50}$/;
|
||||
export const SECTION_TYPES = new Set<AiReviewSectionType>([
|
||||
'students',
|
||||
'rooms',
|
||||
'transfers',
|
||||
'checkins',
|
||||
]);
|
||||
export const SECTION_ORDER: AiReviewSectionType[] = ['students', 'rooms', 'transfers', 'checkins'];
|
||||
export const SECTION_DEPENDENCIES: Record<AiReviewSectionType, AiReviewSectionType[]> = {
|
||||
students: [],
|
||||
rooms: [],
|
||||
transfers: ['students', 'rooms'],
|
||||
checkins: [],
|
||||
};
|
||||
export const SCHEMA_KEYS = new Set(['title', 'summary', 'sections']);
|
||||
export const SECTION_KEYS_ALLOWED = new Set([
|
||||
'key',
|
||||
'type',
|
||||
'title',
|
||||
'kind',
|
||||
'sheet',
|
||||
'columns',
|
||||
'rows',
|
||||
'issues',
|
||||
]);
|
||||
export const COLUMN_KEYS_ALLOWED = new Set(['key', 'title']);
|
||||
|
||||
/**
|
||||
* Column-key aliases the model may produce when parsing workbooks.
|
||||
* Keys are normalized to canonical names per section so the import
|
||||
* logic only deals with one vocabulary.
|
||||
*/
|
||||
export const SECTION_ALIASES: Record<AiReviewSectionType, Record<string, string>> = {
|
||||
students: {
|
||||
org: 'organization',
|
||||
organizationName: 'organization',
|
||||
orgName: 'organization',
|
||||
},
|
||||
rooms: {
|
||||
roomNo: 'roomNumber',
|
||||
number: 'roomNumber',
|
||||
},
|
||||
transfers: {
|
||||
fromRoom: 'oldRoom',
|
||||
currentRoom: 'oldRoom',
|
||||
sourceRoom: 'oldRoom',
|
||||
toRoom: 'newRoom',
|
||||
targetRoom: 'newRoom',
|
||||
destRoom: 'newRoom',
|
||||
date: 'transferDate',
|
||||
changeDate: 'transferDate',
|
||||
moveDate: 'transferDate',
|
||||
mobile: 'studentPhone',
|
||||
phone: 'studentPhone',
|
||||
},
|
||||
checkins: {
|
||||
studentName: 'name',
|
||||
mobile: 'phone',
|
||||
roomNo: 'roomNumber',
|
||||
room: 'roomNumber',
|
||||
date: 'checkInDate',
|
||||
inDate: 'checkInDate',
|
||||
checkinDate: 'checkInDate',
|
||||
outDate: 'checkOutDate',
|
||||
checkoutDate: 'checkOutDate',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Excel 表头 → 规范列名。与 SECTION_ALIASES 合并使用;
|
||||
* 键会被归一化(去空格/下划线/大小写),因此同时覆盖中文与英文写法。
|
||||
*/
|
||||
export const SECTION_HEADER_ALIASES: Record<AiReviewSectionType, Record<string, string>> = {
|
||||
students: {
|
||||
姓名: 'name',
|
||||
学生姓名: 'name',
|
||||
name: 'name',
|
||||
手机号: 'phone',
|
||||
电话: 'phone',
|
||||
联系电话: 'phone',
|
||||
phone: 'phone',
|
||||
mobile: 'phone',
|
||||
学号: 'studentNo',
|
||||
学生编号: 'studentNo',
|
||||
studentNo: 'studentNo',
|
||||
studentno: 'studentNo',
|
||||
性别: 'gender',
|
||||
gender: 'gender',
|
||||
机构: 'organization',
|
||||
所属机构: 'organization',
|
||||
校区: 'organization',
|
||||
组织: 'organization',
|
||||
organization: 'organization',
|
||||
},
|
||||
rooms: {
|
||||
房间号: 'roomNumber',
|
||||
宿舍号: 'roomNumber',
|
||||
房号: 'roomNumber',
|
||||
roomNumber: 'roomNumber',
|
||||
roomnumber: 'roomNumber',
|
||||
容量: 'capacity',
|
||||
床位数: 'capacity',
|
||||
床位: 'capacity',
|
||||
capacity: 'capacity',
|
||||
楼栋: 'building',
|
||||
楼号: 'building',
|
||||
building: 'building',
|
||||
楼层: 'floor',
|
||||
floor: 'floor',
|
||||
房型: 'roomType',
|
||||
房间类型: 'roomType',
|
||||
roomType: 'roomType',
|
||||
},
|
||||
transfers: {
|
||||
学号: 'studentNo',
|
||||
studentNo: 'studentNo',
|
||||
studentno: 'studentNo',
|
||||
手机号: 'studentPhone',
|
||||
学生手机号: 'studentPhone',
|
||||
电话: 'studentPhone',
|
||||
phone: 'studentPhone',
|
||||
studentPhone: 'studentPhone',
|
||||
原宿舍: 'oldRoom',
|
||||
原房间: 'oldRoom',
|
||||
oldRoom: 'oldRoom',
|
||||
目标宿舍: 'newRoom',
|
||||
新宿舍: 'newRoom',
|
||||
newRoom: 'newRoom',
|
||||
换宿日期: 'transferDate',
|
||||
日期: 'transferDate',
|
||||
transferDate: 'transferDate',
|
||||
},
|
||||
checkins: {
|
||||
姓名: 'name',
|
||||
学生姓名: 'name',
|
||||
name: 'name',
|
||||
手机号: 'phone',
|
||||
电话: 'phone',
|
||||
phone: 'phone',
|
||||
mobile: 'phone',
|
||||
学号: 'studentNo',
|
||||
studentNo: 'studentNo',
|
||||
宿舍号: 'roomNumber',
|
||||
房间号: 'roomNumber',
|
||||
roomNumber: 'roomNumber',
|
||||
楼栋: 'building',
|
||||
building: 'building',
|
||||
性别: 'gender',
|
||||
gender: 'gender',
|
||||
入住时间: 'checkInDate',
|
||||
入住日期: 'checkInDate',
|
||||
checkInDate: 'checkInDate',
|
||||
计费起始日: 'billingStartDate',
|
||||
计费开始日: 'billingStartDate',
|
||||
退宿日期: 'checkOutDate',
|
||||
退宿时间: 'checkOutDate',
|
||||
离宿时间: 'checkOutDate',
|
||||
入住类型: 'stayType',
|
||||
住宿类型: 'stayType',
|
||||
},
|
||||
};
|
||||
|
||||
export const SECTION_CANONICAL_KEYS: Record<AiReviewSectionType, Set<string>> = {
|
||||
students: new Set(['name', 'phone', 'studentNo', 'gender', 'organization']),
|
||||
rooms: new Set(['roomNumber', 'capacity', 'building', 'floor', 'roomType']),
|
||||
transfers: new Set(['studentNo', 'studentPhone', 'oldRoom', 'newRoom', 'transferDate']),
|
||||
checkins: new Set([
|
||||
'name',
|
||||
'phone',
|
||||
'studentNo',
|
||||
'roomNumber',
|
||||
'checkInDate',
|
||||
'billingStartDate',
|
||||
'checkOutDate',
|
||||
'gender',
|
||||
'building',
|
||||
'stayType',
|
||||
]),
|
||||
};
|
||||
|
||||
export interface AiReviewSubmitResult {
|
||||
students: { created: number; skipped: number; issues: string[] };
|
||||
rooms: { created: number; skipped: number; issues: string[] };
|
||||
transfers: { completed: number; skipped: number; issues: string[] };
|
||||
checkins: { completed: number; skipped: number; issues: string[] };
|
||||
message: string;
|
||||
}
|
||||
|
||||
export type AiReviewSectionResult =
|
||||
| { created: number; skipped: number; issues: string[] }
|
||||
| { completed: number; skipped: number; issues: string[] };
|
||||
|
||||
export interface ValidatedReviewSchema {
|
||||
title: string;
|
||||
summary: string | null;
|
||||
sections: AiReviewSection[];
|
||||
}
|
||||
|
||||
export interface AiReviewStepSubmitResult {
|
||||
review: AiReview;
|
||||
result: AiReviewSectionResult;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function requireString(
|
||||
value: unknown,
|
||||
label: string,
|
||||
max: number,
|
||||
optional = false,
|
||||
): string {
|
||||
if (value === undefined || value === null) {
|
||||
if (optional) return '';
|
||||
throw new BadRequestException(`${label}不能为空`);
|
||||
}
|
||||
if (typeof value !== 'string' || !value.trim()) {
|
||||
throw new BadRequestException(`${label}必须是字符串`);
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.length > max) {
|
||||
throw new BadRequestException(`${label}长度不能超过 ${max}`);
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export function assertKeys(raw: Record<string, unknown>, allowed: Set<string>, label: string): void {
|
||||
for (const key of Object.keys(raw)) {
|
||||
if (!allowed.has(key)) throw new BadRequestException(`${label}包含未知属性: ${key}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function toDateString(value: unknown): string | null {
|
||||
if (typeof value === 'string' && DATE_RE.test(value.trim())) return value.trim();
|
||||
return null;
|
||||
}
|
||||
|
||||
export function normalizePhone(value: unknown): string | null {
|
||||
if (typeof value !== 'string') return null;
|
||||
const phone = value.replace(/[\s-]/g, '');
|
||||
return /^1[3-9]\d{9}$/.test(phone) ? phone : null;
|
||||
}
|
||||
|
||||
export function isSectionType(value: unknown): value is AiReviewSectionType {
|
||||
return typeof value === 'string' && SECTION_TYPES.has(value as AiReviewSectionType);
|
||||
}
|
||||
|
||||
export function normalizeSectionType(
|
||||
key: string,
|
||||
rawType: unknown,
|
||||
): AiReviewSectionType {
|
||||
if (rawType !== undefined && rawType !== null && !isSectionType(rawType)) {
|
||||
throw new BadRequestException(`分表业务类型不支持: ${JSON.stringify(rawType)}`);
|
||||
}
|
||||
if (isSectionType(rawType)) return rawType;
|
||||
if (isSectionType(key)) return key;
|
||||
const prefix = SECTION_ORDER.find((type) => key.startsWith(`${type}_`));
|
||||
if (prefix) return prefix;
|
||||
throw new BadRequestException(`分表标识无法解析业务类型: ${key}`);
|
||||
}
|
||||
|
||||
export function sectionStatus(section: AiReviewSection): AiReviewSection['status'] {
|
||||
if (
|
||||
section.status === 'submitted' ||
|
||||
section.status === 'failed' ||
|
||||
section.status === 'skipped'
|
||||
) {
|
||||
return section.status;
|
||||
}
|
||||
return 'pending';
|
||||
}
|
||||
|
||||
export function emptySectionResult(key: AiReviewSectionType): AiReviewSectionResult {
|
||||
return key === 'transfers' || key === 'checkins'
|
||||
? { completed: 0, skipped: 0, issues: [] }
|
||||
: { created: 0, skipped: 0, issues: [] };
|
||||
}
|
||||
|
||||
export function sectionResultMessage(
|
||||
key: AiReviewSectionType,
|
||||
result: AiReviewSectionResult,
|
||||
): string {
|
||||
if (key === 'students') {
|
||||
return `成功导入学生 ${(result as { created: number }).created} 人,跳过 ${result.skipped} 条`;
|
||||
}
|
||||
if (key === 'rooms') {
|
||||
return `成功导入宿舍 ${(result as { created: number }).created} 间,跳过 ${result.skipped} 条`;
|
||||
}
|
||||
if (key === 'transfers') {
|
||||
return `成功换宿 ${(result as { completed: number }).completed} 条,跳过 ${result.skipped} 条`;
|
||||
}
|
||||
return `成功入住 ${(result as { completed: number }).completed} 条,跳过 ${result.skipped} 条`;
|
||||
}
|
||||
|
||||
export function withInitialSectionState(section: AiReviewSection): AiReviewSection {
|
||||
return {
|
||||
...section,
|
||||
status: 'pending',
|
||||
resultSummary: null,
|
||||
submittedAt: null,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user