feat(ai-chat): AI 导入确认流程完善并清理代码质量
- 新增导入确认/映射解析辅助,支持预检后生成导入向导 - 抽取 parseAttachmentArgs/beginImportToolRun 等重复逻辑 - 双重类型断言改为运行时守卫,消除 aislop 告警
This commit is contained in:
@@ -20,6 +20,14 @@ describe('AiAttachmentService', () => {
|
|||||||
expect(detectMimeType(buffer, declared)).toBe(expected);
|
expect(detectMimeType(buffer, declared)).toBe(expected);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('detects CSV from the declared MIME type without a binary signature', () => {
|
||||||
|
const detectMimeType = (
|
||||||
|
service as unknown as { detectMimeType(buffer: Buffer, declared: string): string }
|
||||||
|
).detectMimeType.bind(service);
|
||||||
|
expect(detectMimeType(Buffer.from('姓名,学号\n张三,1\n'), 'text/csv')).toBe('text/csv');
|
||||||
|
expect(detectMimeType(Buffer.from('a,b\n1,2\n'), 'application/csv')).toBe('text/csv');
|
||||||
|
});
|
||||||
|
|
||||||
it('rejects more than five attachments before repository access', async () => {
|
it('rejects more than five attachments before repository access', async () => {
|
||||||
await expect(service.requireReadyOwned(7, [1, 2, 3, 4, 5, 6])).rejects.toBeInstanceOf(
|
await expect(service.requireReadyOwned(7, [1, 2, 3, 4, 5, 6])).rejects.toBeInstanceOf(
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
@@ -48,6 +56,8 @@ describe('AiAttachmentService', () => {
|
|||||||
).assertFileExtension.bind(service);
|
).assertFileExtension.bind(service);
|
||||||
expect(() => assertFileExtension('report.exe', 'application/pdf')).toThrow(BadRequestException);
|
expect(() => assertFileExtension('report.exe', 'application/pdf')).toThrow(BadRequestException);
|
||||||
expect(() => assertFileExtension('report.pdf', 'application/pdf')).not.toThrow();
|
expect(() => assertFileExtension('report.pdf', 'application/pdf')).not.toThrow();
|
||||||
|
expect(() => assertFileExtension('students.csv', 'text/csv')).not.toThrow();
|
||||||
|
expect(() => assertFileExtension('students.xlsx', 'text/csv')).toThrow(BadRequestException);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('decodes UTF-8 filenames mangled by Latin-1 multipart parsing', () => {
|
it('decodes UTF-8 filenames mangled by Latin-1 multipart parsing', () => {
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ const ACCEPTED_MIME_TYPES = new Set([
|
|||||||
'image/png',
|
'image/png',
|
||||||
'image/webp',
|
'image/webp',
|
||||||
'application/pdf',
|
'application/pdf',
|
||||||
|
'text/csv',
|
||||||
|
'application/csv',
|
||||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||||
@@ -210,6 +212,9 @@ export class AiAttachmentService {
|
|||||||
const result = await mammoth.extractRawText({ buffer });
|
const result = await mammoth.extractRawText({ buffer });
|
||||||
return this.normalizeExtractedText(result.value);
|
return this.normalizeExtractedText(result.value);
|
||||||
}
|
}
|
||||||
|
if (mimeType.includes('csv')) {
|
||||||
|
return this.normalizeExtractedText(buffer.toString('utf8').replace(/^\uFEFF/, ''));
|
||||||
|
}
|
||||||
if (mimeType.includes('spreadsheetml')) {
|
if (mimeType.includes('spreadsheetml')) {
|
||||||
return this.normalizeExtractedText(await this.excelReader.extractText(buffer));
|
return this.normalizeExtractedText(await this.excelReader.extractText(buffer));
|
||||||
}
|
}
|
||||||
@@ -235,6 +240,7 @@ export class AiAttachmentService {
|
|||||||
|
|
||||||
private assertDeclaredType(declared: string, detected: string): void {
|
private assertDeclaredType(declared: string, detected: string): void {
|
||||||
if (!declared || declared === 'application/octet-stream') return;
|
if (!declared || declared === 'application/octet-stream') return;
|
||||||
|
if (detected.includes('csv') || declared.includes('csv')) return;
|
||||||
if (declared !== detected) throw new BadRequestException('附件类型与文件内容不一致');
|
if (declared !== detected) throw new BadRequestException('附件类型与文件内容不一致');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,6 +251,8 @@ export class AiAttachmentService {
|
|||||||
'image/png': ['png'],
|
'image/png': ['png'],
|
||||||
'image/webp': ['webp'],
|
'image/webp': ['webp'],
|
||||||
'application/pdf': ['pdf'],
|
'application/pdf': ['pdf'],
|
||||||
|
'text/csv': ['csv'],
|
||||||
|
'application/csv': ['csv'],
|
||||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['docx'],
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['docx'],
|
||||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['xlsx'],
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['xlsx'],
|
||||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation': ['pptx'],
|
'application/vnd.openxmlformats-officedocument.presentationml.presentation': ['pptx'],
|
||||||
@@ -278,6 +286,7 @@ export class AiAttachmentService {
|
|||||||
) {
|
) {
|
||||||
return declaredMimeType;
|
return declaredMimeType;
|
||||||
}
|
}
|
||||||
|
if (/csv/i.test(declaredMimeType)) return 'text/csv';
|
||||||
return 'application/octet-stream';
|
return 'application/octet-stream';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,6 +314,8 @@ export class AiAttachmentService {
|
|||||||
'image/png': 'png',
|
'image/png': 'png',
|
||||||
'image/webp': 'webp',
|
'image/webp': 'webp',
|
||||||
'application/pdf': 'pdf',
|
'application/pdf': 'pdf',
|
||||||
|
'text/csv': 'csv',
|
||||||
|
'application/csv': 'csv',
|
||||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'docx',
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'docx',
|
||||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'xlsx',
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'xlsx',
|
||||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'pptx',
|
'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'pptx',
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export const A2UI_TOOL_SCHEMAS = [
|
|||||||
function: {
|
function: {
|
||||||
name: 'preflight_import',
|
name: 'preflight_import',
|
||||||
description:
|
description:
|
||||||
'对上传的 Excel 进行导入预检并生成“可插入性报告”:分阶段行数(新建/更新/错误/跳过)、阻断原因(缺列、未找到学生/宿舍、文件内重复、已有在住、格式错误、未知校区)、需要确认的问题与导入后的下一步建议。当用户上传 Excel 并疑似需要批量导入学生、宿舍、换宿或入住数据时先调用;根据报告向用户确认后,再调用 start_import_wizard。',
|
'对上传的 Excel 进行导入预检并生成“可插入性报告”:分阶段行数(新建/更新/错误/跳过)、阻断原因(缺列、未找到学生/宿舍、文件内重复、已有在住、格式错误、未知校区)、需要确认的问题与导入后的下一步建议。当用户上传 Excel 并疑似需要批量导入学生、宿舍、换宿或入住数据时先调用;预检结果会以可交互卡片展示列映射与策略确认,引导用户在卡内点击「生成导入向导」,无需在聊天里重复确认卡内已覆盖的问题。',
|
||||||
parameters: {
|
parameters: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
@@ -26,6 +26,12 @@ export const A2UI_TOOL_SCHEMAS = [
|
|||||||
type: 'integer',
|
type: 'integer',
|
||||||
description: '上传的 Excel 附件 ID。系统直接从文件读取行数据,无需(也不要)在参数里抄录数据。',
|
description: '上传的 Excel 附件 ID。系统直接从文件读取行数据,无需(也不要)在参数里抄录数据。',
|
||||||
},
|
},
|
||||||
|
headerRow: {
|
||||||
|
type: 'integer',
|
||||||
|
description: '表头所在行(从 1 开始,默认 1)。预检时对整个文件使用该行作为表头。',
|
||||||
|
minimum: 1,
|
||||||
|
maximum: 1000,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
required: ['attachmentId'],
|
required: ['attachmentId'],
|
||||||
additionalProperties: false,
|
additionalProperties: false,
|
||||||
@@ -144,70 +150,6 @@ export const A2UI_TOOL_SCHEMAS = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
type: 'function' as const,
|
|
||||||
function: {
|
|
||||||
name: 'render_review',
|
|
||||||
description:
|
|
||||||
'生成一张“批量导入工作流预览卡”显示给用户。当用户上传 Excel 并需要批量导入学生、宿舍、换宿或入住数据时调用;传入 attachmentId 后,系统直接解析文件生成行数据(推荐,避免抄录错误),sections 只需给出分表、表名和列映射;无附件时才手工提供 rows。用户确认后系统才会入库。每个回答回合只能调用一次,且只生成一张预览卡:需要导入的多个分表(最多 20 个)必须合并到同一次调用的 sections 里,一次全部给出;同一业务类型可有多张 sheet,每张 sheet 分配唯一 key 并填写正确的 type;生成成功后直接提示用户审阅,可逐表确认、整组确认或一次全部确认,不要重复调用本工具。',
|
|
||||||
parameters: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
title: { type: 'string', description: '预览标题(≤50字)', maxLength: 50 },
|
|
||||||
summary: { type: 'string', description: '预览说明(≤500字)', maxLength: 500 },
|
|
||||||
attachmentId: {
|
|
||||||
type: 'integer',
|
|
||||||
description: '上传的 Excel 附件 ID。传入后系统直接从文件读取全部行数据,无需(也不要)在 rows 里抄录数据。',
|
|
||||||
},
|
|
||||||
sections: {
|
|
||||||
type: 'array',
|
|
||||||
description: '分表预览(1-20个)。每张 sheet 的 key 必须是唯一实例 ID(仅字母数字下划线,≤50),type 为业务类型。',
|
|
||||||
minItems: 1,
|
|
||||||
maxItems: 20,
|
|
||||||
items: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
key: { type: 'string', description: '唯一实例 ID(如 checkins_girls_4、students_building_2),仅字母数字下划线且 ≤50 字符', pattern: '^[a-zA-Z0-9_]{1,50}$' },
|
|
||||||
type: { type: 'string', description: '业务类型:students 学生 / rooms 宿舍 / transfers 换宿 / checkins 入住记录', enum: ['students', 'rooms', 'transfers', 'checkins'] },
|
|
||||||
title: { type: 'string', description: '分表标题(≤50字)', maxLength: 50 },
|
|
||||||
kind: { type: 'string', enum: ['table'], description: '固定为 table' },
|
|
||||||
sheet: { type: 'string', description: '工作表名称(与 Excel 中的 sheet 名一致);省略时使用第一个工作表' },
|
|
||||||
headerRow: { type: 'integer', description: '表头所在行(从 1 开始),默认 1' },
|
|
||||||
columns: {
|
|
||||||
type: 'array',
|
|
||||||
description: '表格列定义(1-30个)。省略 sourceHeader 时系统按表头文字自动识别;给出 sourceHeader 可指定该列在工作表中的原始表头。',
|
|
||||||
items: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
key: { type: 'string', description: '列标识,仅字母数字下划线', pattern: '^[a-zA-Z0-9_]{1,50}$' },
|
|
||||||
title: { type: 'string', description: '列中文标题(≤50字)', maxLength: 50 },
|
|
||||||
sourceHeader: { type: 'string', description: '工作表中对应的原始表头文字(如 姓名/手机号)', maxLength: 50 },
|
|
||||||
},
|
|
||||||
required: ['key', 'title'],
|
|
||||||
additionalProperties: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
rows: {
|
|
||||||
type: 'array',
|
|
||||||
description: '行数据(≤500行)。建议键名:学生 name/phone/studentNo/gender/organization;宿舍 roomNumber/capacity/building/floor/roomType;换宿 studentNo 或 studentPhone、oldRoom、newRoom、transferDate(YYYY-MM-DD);入住记录 name/phone 或 studentNo、roomNumber、checkInDate(YYYY-MM-DD)。服务端兼容常见别名。',
|
|
||||||
items: {
|
|
||||||
type: 'object',
|
|
||||||
description: '单元格值仅允许字符串、数字、布尔或 null',
|
|
||||||
additionalProperties: { anyOf: CELL_VALUE_ANY_OF },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
issues: { type: 'array', description: '解析中发现的问题(≤50条)', items: { type: 'string' } },
|
|
||||||
},
|
|
||||||
required: ['key', 'type', 'title', 'kind', 'columns', 'rows'],
|
|
||||||
additionalProperties: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
required: ['title', 'sections'],
|
|
||||||
additionalProperties: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
type: 'function' as const,
|
type: 'function' as const,
|
||||||
function: {
|
function: {
|
||||||
@@ -260,10 +202,11 @@ export const SYSTEM_PROMPT = `你是恭学系统的业务助理。回答必须
|
|||||||
修改学生示例:批量修改姓名/档案时,render_form 的字段可用 name_<学生ID> 等命名展示待修改内容,用户提交后再调用 update_students,每条更新必须带学生 id。
|
修改学生示例:批量修改姓名/档案时,render_form 的字段可用 name_<学生ID> 等命名展示待修改内容,用户提交后再调用 update_students,每条更新必须带学生 id。
|
||||||
当用户上传 Excel 并需要批量导入(学生、宿舍、换宿、入住记录)时,按以下固定流程执行:
|
当用户上传 Excel 并需要批量导入(学生、宿舍、换宿、入住记录)时,按以下固定流程执行:
|
||||||
1. 先调用 preflight_import(传入 attachmentId)生成“可插入性预检报告”:报告给出分阶段行数(新建/更新/错误/跳过)、阻断原因(缺列、未找到学生/宿舍、文件内重复、已有在住、格式错误、未知校区)、需要确认的问题和导入后的下一步建议。
|
1. 先调用 preflight_import(传入 attachmentId)生成“可插入性预检报告”:报告给出分阶段行数(新建/更新/错误/跳过)、阻断原因(缺列、未找到学生/宿舍、文件内重复、已有在住、格式错误、未知校区)、需要确认的问题和导入后的下一步建议。
|
||||||
2. 报告为 blocked 时,向用户说明阻断原因并建议修正文件后重传,不要生成向导;报告为 needs_input 时,按报告中的 questions 向用户确认:选项型问题用 render_form 生成表单(如更新策略、重复策略、校区、未匹配行处理),列映射类问题用聊天文本确认;报告为 ready 时可直接进入下一步,如需列映射确认也可先问。不要替用户默认做出影响数据的决定。
|
2. 预检报告会以可交互卡片显示给用户:卡内已提供列映射控件和策略控件(更新已有记录、重复行策略、校区、未匹配行处理),并有「生成导入向导」按钮。引导用户在卡内完成确认并点击按钮即可生成向导,不要在聊天里反复确认卡内已覆盖的问题。你只需说明报告结论:blocked 时解释阻断原因并建议修正文件后重传(因缺少列映射而 blocked 时提示在卡内补全映射);needs_input 时说明需要确认的问题并提示在卡内选择;ready 时提示可直接在卡内生成向导。卡内未覆盖的自由输入(如自定义校区)才在聊天中向用户提问。不要替用户默认做出影响数据的决定。
|
||||||
报告只给汇总统计时,基于报告中的 errorSamples(工作表与行号、示例值)向用户解释具体错误原因(如某行缺少手机号、姓名带日期后缀、宿舍未建档等)。
|
报告只给汇总统计时,基于报告中的 errorSamples(工作表与行号、示例值)向用户解释具体错误原因(如某行缺少手机号、姓名带日期后缀、宿舍未建档等)。
|
||||||
3. 用户确认后调用 start_import_wizard:必须传入 attachmentId 和 stages(业务类型 stepKey:students 学生 / rooms 宿舍 / checkins 入住 / transfers 换宿,以及对应工作表 sheet 名),并把确认结果一并传入(mapping 列映射、organization 校区、updateExisting 是否更新已有记录、duplicatePolicy 重复行策略 error/skip、skipUnmatched 是否跳过未匹配行)。系统直接从文件解析行数据,禁止把整表数据抄进工具参数或凭空补全。
|
3. 仅当用户明确在聊天文本中给出确认(而非使用预检卡)时,才调用 start_import_wizard:必须传入 attachmentId 和 stages(业务类型 stepKey:students 学生 / rooms 宿舍 / checkins 入住 / transfers 换宿,以及对应工作表 sheet 名),并把确认结果一并传入(mapping 列映射、organization 校区、updateExisting 是否更新已有记录、duplicatePolicy 重复行策略 error/skip、skipUnmatched 是否跳过未匹配行)。系统直接从文件解析行数据,禁止把整表数据抄进工具参数或凭空补全。若当前消息已通过预检卡生成向导,不要重复调用。
|
||||||
4. 生成向导后提示用户打开,按“基础档案(学生/宿舍)→ 关系(入住/换宿)”的顺序逐阶段预览,人工确认后系统才会入库;不要代替用户调用任何写工具直接插入。
|
4. 生成向导后提示用户打开,按“基础档案(学生/宿舍)→ 关系(入住/换宿)”的顺序逐阶段预览,人工确认后系统才会入库;不要代替用户调用任何写工具直接插入。
|
||||||
|
工具结果中的 permittedSteps 表示当前用户可提交的阶段,只引导这些阶段,未列出的阶段不要建议提交或执行。
|
||||||
每个回答回合最多调用一次 preflight_import 和一次 start_import_wizard;报告与导入完成后由你给出下一步建议,不要自动执行后续写操作。
|
每个回答回合最多调用一次 preflight_import 和一次 start_import_wizard;报告与导入完成后由你给出下一步建议,不要自动执行后续写操作。
|
||||||
当用户需要可视化数据(趋势、占比、对比、多维、完成率等)时,调用 render_chart 生成图表卡片(chartType 支持 line 折线/bar 柱状/pie 饼图/area 面积/scatter 散点/radar 雷达/gauge 仪表盘/funnel 漏斗,columns+rows 表格数据)。
|
当用户需要可视化数据(趋势、占比、对比、多维、完成率等)时,调用 render_chart 生成图表卡片(chartType 支持 line 折线/bar 柱状/pie 饼图/area 面积/scatter 散点/radar 雷达/gauge 仪表盘/funnel 漏斗,columns+rows 表格数据)。
|
||||||
上传的 Office 附件:上传时系统已自动提取附件文本并随消息提供(Excel 为“工作表名 + tab 分隔行”的文本,Word/PPT 为提取的文本),直接基于这些文本核对表头与数据、回答用户问题即可;没有单独的附件解析工具,不需要(也无法)主动读取附件原始文件。批量导入前如不确定列名,先调用 preflight_import(内部会解析文件并给出列映射、分阶段统计与错误样本),再向用户确认并生成导入向导。
|
上传的 Office 附件:上传时系统已自动提取附件文本并随消息提供(Excel 为“工作表名 + tab 分隔行”的文本,Word/PPT 为提取的文本),直接基于这些文本核对表头与数据、回答用户问题即可;没有单独的附件解析工具,不需要(也无法)主动读取附件原始文件。批量导入前如不确定列名,先调用 preflight_import(内部会解析文件并给出列映射、分阶段统计与错误样本),再向用户确认并生成导入向导。
|
||||||
@@ -271,6 +214,6 @@ export const SYSTEM_PROMPT = `你是恭学系统的业务助理。回答必须
|
|||||||
- 系统业务按“基础档案 → 业务关系 → 运行数据 → 结算”组织。常见闭环:学生、宿舍、教室、组织等基础档案先行;再建立分班、入住、租赁等关系;之后才有考勤、费用等运行数据;最后生成账单、押金等结算。
|
- 系统业务按“基础档案 → 业务关系 → 运行数据 → 结算”组织。常见闭环:学生、宿舍、教室、组织等基础档案先行;再建立分班、入住、租赁等关系;之后才有考勤、费用等运行数据;最后生成账单、押金等结算。
|
||||||
- 执行任何写入或导入前,先判断该操作依赖的前置数据是否已存在(可用查询工具核实):入住依赖学生和宿舍,换宿依赖学生和宿舍,账单依赖入住记录和费用,考勤依赖班级和排课。前置缺失时,先向用户说明缺什么、建议先完成哪一步,再继续,不要机械地跳过依赖直接入库。
|
- 执行任何写入或导入前,先判断该操作依赖的前置数据是否已存在(可用查询工具核实):入住依赖学生和宿舍,换宿依赖学生和宿舍,账单依赖入住记录和费用,考勤依赖班级和排课。前置缺失时,先向用户说明缺什么、建议先完成哪一步,再继续,不要机械地跳过依赖直接入库。
|
||||||
- 导入或录入完成后,主动给出下一步建议(例如:入住导入完成 → 建议录入本月公共费用 → 生成并确认账单;学生导入完成 → 建议分班或排课)。
|
- 导入或录入完成后,主动给出下一步建议(例如:入住导入完成 → 建议录入本月公共费用 → 生成并确认账单;学生导入完成 → 建议分班或排课)。
|
||||||
- 用户上传 Excel 但未说明用途时,先根据表头判断包含哪些业务,向用户说明将导入什么、依赖什么;疑似导入时先调用 preflight_import 生成预检报告,再按报告提问并生成导入向导,按依赖顺序执行。
|
- 用户上传 Excel 但未说明用途时,先根据表头判断包含哪些业务,向用户说明将导入什么、依赖什么;疑似导入时先调用 preflight_import 生成预检报告,再引导用户在预检卡内确认并生成导入向导,按依赖顺序执行。
|
||||||
- 只引导当前角色权限范围内可执行的下一步,不得建议或执行用户无权操作。
|
- 只引导当前角色权限范围内可执行的下一步,不得建议或执行用户无权操作。
|
||||||
不得扩大用户权限或猜测不可见数据。回答使用简洁中文 Markdown。`;
|
不得扩大用户权限或猜测不可见数据。回答使用简洁中文 Markdown。`;
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
EditMessageDto,
|
EditMessageDto,
|
||||||
MessagePageQueryDto,
|
MessagePageQueryDto,
|
||||||
RegenerateMessageDto,
|
RegenerateMessageDto,
|
||||||
|
ResolveImportPreflightDto,
|
||||||
SendMessageDto,
|
SendMessageDto,
|
||||||
SubmitFormDto,
|
SubmitFormDto,
|
||||||
SubmitReviewDto,
|
SubmitReviewDto,
|
||||||
@@ -237,6 +238,23 @@ export class AiChatController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('import/preflight/:messageId/resolve/stream')
|
||||||
|
@Throttle({ default: { ttl: 60000, limit: 10 } })
|
||||||
|
async resolveImportPreflight(
|
||||||
|
@Req() req: AuthenticatedRequest,
|
||||||
|
@Res() res: Response,
|
||||||
|
@Param('messageId', ParseIntPipe) messageId: number,
|
||||||
|
@Body() dto: ResolveImportPreflightDto,
|
||||||
|
): Promise<void> {
|
||||||
|
const conversationId = await this.service.resolvePreflightConversationId(
|
||||||
|
req.user.id,
|
||||||
|
messageId,
|
||||||
|
);
|
||||||
|
return this.handleStream(res, dto.clientRequestId, conversationId, (signal, emit, onReady) =>
|
||||||
|
this.service.resolveImportPreflight(req.user, messageId, dto, signal, emit, onReady),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@Post('reviews/:reviewId/steps/:sectionKey/confirm')
|
@Post('reviews/:reviewId/steps/:sectionKey/confirm')
|
||||||
async confirmReviewStep(
|
async confirmReviewStep(
|
||||||
@Req() req: AuthenticatedRequest,
|
@Req() req: AuthenticatedRequest,
|
||||||
|
|||||||
@@ -78,7 +78,6 @@ export async function executeGeneration(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
tools.push(...A2UI_TOOL_SCHEMAS);
|
tools.push(...A2UI_TOOL_SCHEMAS);
|
||||||
tools = tools.filter((tool) => tool.function.name !== 'render_review');
|
|
||||||
const runtimeConfig = await context.configService.getRuntimeConfig();
|
const runtimeConfig = await context.configService.getRuntimeConfig();
|
||||||
const config = {
|
const config = {
|
||||||
...runtimeConfig,
|
...runtimeConfig,
|
||||||
|
|||||||
109
apps/server/src/ai-chat/ai-chat.import-confirm.ts
Normal file
109
apps/server/src/ai-chat/ai-chat.import-confirm.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
IMPORT_FIELD_ALIASES,
|
||||||
|
IMPORT_STEP_KEYS,
|
||||||
|
type ColumnMapping,
|
||||||
|
type ImportRunSettings,
|
||||||
|
type ImportStepKey,
|
||||||
|
} from '../imports/imports.types';
|
||||||
|
|
||||||
|
export interface ConfirmedMappingOptions {
|
||||||
|
/** 每个业务阶段允许的表头集合;为空时不校验表头是否存在。 */
|
||||||
|
allowedHeadersByStep?: Partial<Record<ImportStepKey, string[]>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析并校验用户确认的列映射。
|
||||||
|
* 与 start_import_wizard 工具、预检卡 resolve 端点共用,保证两条入口口径一致。
|
||||||
|
*/
|
||||||
|
export function parseConfirmedMapping(
|
||||||
|
raw: unknown,
|
||||||
|
options: ConfirmedMappingOptions = {},
|
||||||
|
): Partial<Record<ImportStepKey, ColumnMapping>> | undefined {
|
||||||
|
if (raw === undefined || raw === null) return undefined;
|
||||||
|
if (typeof raw !== 'object' || Array.isArray(raw)) {
|
||||||
|
throw new BadRequestException('mapping 参数格式错误');
|
||||||
|
}
|
||||||
|
const mapping: Partial<Record<ImportStepKey, ColumnMapping>> = {};
|
||||||
|
for (const [stepKey, fields] of Object.entries(raw as Record<string, unknown>)) {
|
||||||
|
if (!(IMPORT_STEP_KEYS as readonly string[]).includes(stepKey)) {
|
||||||
|
throw new BadRequestException(`mapping 包含未知业务类型:${stepKey}`);
|
||||||
|
}
|
||||||
|
if (fields === undefined || fields === null) continue;
|
||||||
|
if (typeof fields !== 'object' || Array.isArray(fields)) {
|
||||||
|
throw new BadRequestException(`mapping 中「${stepKey}」的列映射格式错误`);
|
||||||
|
}
|
||||||
|
const typedStepKey = stepKey as ImportStepKey;
|
||||||
|
const allowedFields = new Set(Object.keys(IMPORT_FIELD_ALIASES[typedStepKey]));
|
||||||
|
const allowedHeaders = new Set(options.allowedHeadersByStep?.[typedStepKey] ?? []);
|
||||||
|
const columnMapping: ColumnMapping = {};
|
||||||
|
for (const [field, header] of Object.entries(fields as Record<string, unknown>)) {
|
||||||
|
if (typeof field !== 'string' || !field.trim() || field.length > 50) continue;
|
||||||
|
if (!allowedFields.has(field)) {
|
||||||
|
throw new BadRequestException(`mapping 中「${stepKey}」包含未知字段:${field}`);
|
||||||
|
}
|
||||||
|
if (typeof header !== 'string' || !header.trim()) continue;
|
||||||
|
const headerName = header.slice(0, 200);
|
||||||
|
if (allowedHeaders.size > 0 && !allowedHeaders.has(headerName)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`「${stepKey}」列映射「${headerName}」不在工作表表头中`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
columnMapping[field] = headerName;
|
||||||
|
}
|
||||||
|
mapping[typedStepKey] = columnMapping;
|
||||||
|
}
|
||||||
|
return mapping;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从包含顶层 organization/updateExisting/duplicatePolicy/skipUnmatched 的对象解析策略设置。 */
|
||||||
|
export function parseConfirmedSettings(parsedRecord: Record<string, unknown>): ImportRunSettings {
|
||||||
|
const settings: ImportRunSettings = {};
|
||||||
|
if (parsedRecord.organization !== undefined && parsedRecord.organization !== null) {
|
||||||
|
if (typeof parsedRecord.organization !== 'string') {
|
||||||
|
throw new BadRequestException('organization 必须是字符串');
|
||||||
|
}
|
||||||
|
const organization = parsedRecord.organization.trim().slice(0, 100);
|
||||||
|
if (organization) settings.organization = organization;
|
||||||
|
}
|
||||||
|
if (parsedRecord.updateExisting !== undefined) {
|
||||||
|
if (typeof parsedRecord.updateExisting !== 'boolean') {
|
||||||
|
throw new BadRequestException('updateExisting 必须是布尔值');
|
||||||
|
}
|
||||||
|
settings.updateExisting = parsedRecord.updateExisting;
|
||||||
|
}
|
||||||
|
if (parsedRecord.duplicatePolicy !== undefined) {
|
||||||
|
if (parsedRecord.duplicatePolicy !== 'error' && parsedRecord.duplicatePolicy !== 'skip') {
|
||||||
|
throw new BadRequestException('duplicatePolicy 只能是 error 或 skip');
|
||||||
|
}
|
||||||
|
settings.duplicatePolicy = parsedRecord.duplicatePolicy;
|
||||||
|
}
|
||||||
|
if (parsedRecord.skipUnmatched !== undefined) {
|
||||||
|
if (typeof parsedRecord.skipUnmatched !== 'boolean') {
|
||||||
|
throw new BadRequestException('skipUnmatched 必须是布尔值');
|
||||||
|
}
|
||||||
|
settings.skipUnmatched = parsedRecord.skipUnmatched;
|
||||||
|
}
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解析 resolve 端点嵌套的 settings 参数(缺省为空对象)。 */
|
||||||
|
export function parseNestedSettings(raw: unknown): ImportRunSettings {
|
||||||
|
if (raw === undefined || raw === null) return {};
|
||||||
|
if (typeof raw !== 'object' || Array.isArray(raw)) {
|
||||||
|
throw new BadRequestException('settings 参数格式错误');
|
||||||
|
}
|
||||||
|
return parseConfirmedSettings(raw as Record<string, unknown>);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isExcelAttachment(attachment: {
|
||||||
|
mimeType: string;
|
||||||
|
originalName: string;
|
||||||
|
}): boolean {
|
||||||
|
return (
|
||||||
|
attachment.mimeType.includes('spreadsheetml') ||
|
||||||
|
attachment.mimeType.includes('excel') ||
|
||||||
|
attachment.mimeType.includes('csv') ||
|
||||||
|
/\.(xlsx|csv)$/i.test(attachment.originalName)
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -58,7 +58,9 @@ import {
|
|||||||
} from './ai-chat.streaming';
|
} from './ai-chat.streaming';
|
||||||
import {
|
import {
|
||||||
resolveFormConversationId,
|
resolveFormConversationId,
|
||||||
|
resolvePreflightConversationId,
|
||||||
resolveReviewConversationId,
|
resolveReviewConversationId,
|
||||||
|
resolveImportPreflight,
|
||||||
submitForm,
|
submitForm,
|
||||||
submitReview,
|
submitReview,
|
||||||
confirmReviewStep,
|
confirmReviewStep,
|
||||||
@@ -297,10 +299,10 @@ export class AiChatService implements AiChatServiceContext {
|
|||||||
executePreflightImport(
|
executePreflightImport(
|
||||||
messageId: number,
|
messageId: number,
|
||||||
call: ModelToolCall,
|
call: ModelToolCall,
|
||||||
userId: number,
|
context: ReturnType<typeof AgentToolContextFactory.fromAuthenticatedUser>,
|
||||||
emit: AiSseEmitter,
|
emit: AiSseEmitter,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
return executePreflightImport(this, messageId, call, userId, emit);
|
return executePreflightImport(this, messageId, call, context, emit);
|
||||||
}
|
}
|
||||||
|
|
||||||
executeGeneration(input: GenerationInput): Promise<void> {
|
executeGeneration(input: GenerationInput): Promise<void> {
|
||||||
@@ -407,6 +409,10 @@ export class AiChatService implements AiChatServiceContext {
|
|||||||
return resolveReviewConversationId(this, userId, reviewId);
|
return resolveReviewConversationId(this, userId, reviewId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
resolvePreflightConversationId(userId: number, messageId: number): Promise<number> {
|
||||||
|
return resolvePreflightConversationId(this, userId, messageId);
|
||||||
|
}
|
||||||
|
|
||||||
submitForm(
|
submitForm(
|
||||||
user: AuthenticatedUser,
|
user: AuthenticatedUser,
|
||||||
formId: string,
|
formId: string,
|
||||||
@@ -433,6 +439,21 @@ export class AiChatService implements AiChatServiceContext {
|
|||||||
return submitReview(this, user, reviewId, dto, signal, emit, onReady);
|
return submitReview(this, user, reviewId, dto, signal, emit, onReady);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
resolveImportPreflight(
|
||||||
|
user: AuthenticatedUser,
|
||||||
|
messageId: number,
|
||||||
|
dto: {
|
||||||
|
clientRequestId: string;
|
||||||
|
mapping?: Record<string, unknown>;
|
||||||
|
settings?: Record<string, unknown>;
|
||||||
|
},
|
||||||
|
signal: AbortSignal,
|
||||||
|
emit: AiSseEmitter,
|
||||||
|
onReady: () => void,
|
||||||
|
): Promise<void> {
|
||||||
|
return resolveImportPreflight(this, user, messageId, dto, signal, emit, onReady);
|
||||||
|
}
|
||||||
|
|
||||||
confirmReviewStep(
|
confirmReviewStep(
|
||||||
user: AuthenticatedUser,
|
user: AuthenticatedUser,
|
||||||
reviewId: string,
|
reviewId: string,
|
||||||
|
|||||||
@@ -13,6 +13,17 @@ import type {
|
|||||||
} from './ai-chat.types';
|
} from './ai-chat.types';
|
||||||
import { DEFAULT_TITLE, reviewSectionType } from './ai-chat.types';
|
import { DEFAULT_TITLE, reviewSectionType } from './ai-chat.types';
|
||||||
import type { AuthenticatedUser } from '../authorization';
|
import type { AuthenticatedUser } from '../authorization';
|
||||||
|
import type {
|
||||||
|
ImportStageRequest,
|
||||||
|
ImportStepKey,
|
||||||
|
PreflightReport,
|
||||||
|
} from '../imports/imports.types';
|
||||||
|
import {
|
||||||
|
isExcelAttachment,
|
||||||
|
parseConfirmedMapping,
|
||||||
|
parseNestedSettings,
|
||||||
|
} from './ai-chat.import-confirm';
|
||||||
|
import { compactImportWizard } from './ai-chat.tool-actions';
|
||||||
|
|
||||||
export async function resolveFormConversationId(
|
export async function resolveFormConversationId(
|
||||||
context: AiChatServiceContext,
|
context: AiChatServiceContext,
|
||||||
@@ -32,6 +43,193 @@ export async function resolveReviewConversationId(
|
|||||||
return review.conversationId;
|
return review.conversationId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function resolvePreflightConversationId(
|
||||||
|
context: AiChatServiceContext,
|
||||||
|
userId: number,
|
||||||
|
messageId: number,
|
||||||
|
): Promise<number> {
|
||||||
|
const message = await context.messages.findOne({
|
||||||
|
where: { id: messageId },
|
||||||
|
relations: { conversation: true },
|
||||||
|
});
|
||||||
|
if (!message) throw new NotFoundException('消息不存在');
|
||||||
|
if (message.role !== 'assistant') {
|
||||||
|
throw new BadRequestException('该消息不是助手消息,无法确认导入预检');
|
||||||
|
}
|
||||||
|
const conversation = await context.requireOwnedConversation(userId, message.conversation.id);
|
||||||
|
return conversation.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readPreflightCard(
|
||||||
|
metadata: Record<string, unknown> | null | undefined,
|
||||||
|
): PreflightReport {
|
||||||
|
const card = metadata?.a2uiImportPreflight;
|
||||||
|
if (!card || typeof card !== 'object' || Array.isArray(card)) {
|
||||||
|
throw new BadRequestException('预检报告不存在或已失效');
|
||||||
|
}
|
||||||
|
if (!isPreflightReport(card)) {
|
||||||
|
throw new BadRequestException('预检报告格式异常');
|
||||||
|
}
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPreflightReport(value: object): value is PreflightReport {
|
||||||
|
const record = value as Record<string, unknown>;
|
||||||
|
return (
|
||||||
|
typeof record.verdict === 'string' &&
|
||||||
|
Array.isArray(record.stages) &&
|
||||||
|
Array.isArray(record.blocks) &&
|
||||||
|
Array.isArray(record.questions) &&
|
||||||
|
Array.isArray(record.nextSteps) &&
|
||||||
|
Array.isArray(record.errorSamples)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadReviewForConfirm(
|
||||||
|
context: AiChatServiceContext,
|
||||||
|
user: AuthenticatedUser,
|
||||||
|
reviewId: string,
|
||||||
|
): Promise<AiReview> {
|
||||||
|
const review = await context.reviewService.findOwned(reviewId, user.id);
|
||||||
|
if (review.status === 'submitted') {
|
||||||
|
throw new ConflictException('导入已全部确认,无需重复确认');
|
||||||
|
}
|
||||||
|
if (review.status === 'expired') {
|
||||||
|
throw new ConflictException('导入预览已失效,请重新生成预览');
|
||||||
|
}
|
||||||
|
return review;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function finalizeReview(
|
||||||
|
context: AiChatServiceContext,
|
||||||
|
updated: AiReview,
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
await context.markReviewSubmittedOnMessage(
|
||||||
|
updated.assistantMessageId,
|
||||||
|
updated.conversationId,
|
||||||
|
updated,
|
||||||
|
);
|
||||||
|
return context.reviewService.serialize(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logImportOp(
|
||||||
|
context: AiChatServiceContext,
|
||||||
|
user: AuthenticatedUser,
|
||||||
|
action: string,
|
||||||
|
detail: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await context.opLog?.log({
|
||||||
|
userId: user.id,
|
||||||
|
username: user.username,
|
||||||
|
module: '批量导入',
|
||||||
|
action,
|
||||||
|
detail,
|
||||||
|
targetType: 'ai_review',
|
||||||
|
status: 'success',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveImportPreflight(
|
||||||
|
context: AiChatServiceContext,
|
||||||
|
user: AuthenticatedUser,
|
||||||
|
messageId: number,
|
||||||
|
dto: {
|
||||||
|
clientRequestId: string;
|
||||||
|
mapping?: Record<string, unknown>;
|
||||||
|
settings?: Record<string, unknown>;
|
||||||
|
},
|
||||||
|
signal: AbortSignal,
|
||||||
|
emit: AiSseEmitter,
|
||||||
|
onReady: () => void,
|
||||||
|
): Promise<void> {
|
||||||
|
context.throwIfAborted(signal);
|
||||||
|
const message = await context.messages.findOne({ where: { id: messageId } });
|
||||||
|
if (!message) throw new NotFoundException('消息不存在');
|
||||||
|
if (message.role !== 'assistant') {
|
||||||
|
throw new BadRequestException('该消息不是助手消息,无法确认导入预检');
|
||||||
|
}
|
||||||
|
const conversation = await context.requireOwnedConversation(user.id, message.conversationId);
|
||||||
|
const preflight = readPreflightCard(message.metadata);
|
||||||
|
|
||||||
|
await context.acquireConversation(conversation.id);
|
||||||
|
try {
|
||||||
|
context.throwIfAborted(signal);
|
||||||
|
const existingWizard = message.metadata?.a2uiImportWizard;
|
||||||
|
if (existingWizard && typeof existingWizard === 'object' && !Array.isArray(existingWizard)) {
|
||||||
|
onReady();
|
||||||
|
emit('ui.import_preflight', {
|
||||||
|
messageId,
|
||||||
|
preflight: { ...preflight, resolved: true },
|
||||||
|
});
|
||||||
|
emit('ui.import_wizard', { messageId, wizard: existingWizard });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const attachmentId = preflight.attachmentId;
|
||||||
|
const headerRow = preflight.headerRow ?? 1;
|
||||||
|
if (!Number.isInteger(attachmentId) || (attachmentId as number) <= 0) {
|
||||||
|
throw new BadRequestException('预检报告缺少附件信息,请重新预检');
|
||||||
|
}
|
||||||
|
const [attachment] = await context.attachmentService.requireReadyOwned(user.id, [
|
||||||
|
attachmentId as number,
|
||||||
|
]);
|
||||||
|
if (!isExcelAttachment(attachment)) {
|
||||||
|
throw new BadRequestException('附件不是 Excel 文件,无法生成导入向导');
|
||||||
|
}
|
||||||
|
const stages: ImportStageRequest[] = preflight.stages.map((stage) => ({
|
||||||
|
stepKey: stage.stepKey,
|
||||||
|
sheet: stage.sheetNames[0],
|
||||||
|
headerRow,
|
||||||
|
}));
|
||||||
|
if (stages.some((stage) => !stage.sheet || !String(stage.sheet).trim())) {
|
||||||
|
throw new BadRequestException('预检报告缺少工作表信息,请重新预检');
|
||||||
|
}
|
||||||
|
const allowedHeadersByStep: Partial<Record<ImportStepKey, string[]>> = {};
|
||||||
|
for (const stage of preflight.stages) {
|
||||||
|
allowedHeadersByStep[stage.stepKey] = stage.headers ?? [];
|
||||||
|
}
|
||||||
|
const mapping = parseConfirmedMapping(dto.mapping, { allowedHeadersByStep });
|
||||||
|
const settings = parseNestedSettings(dto.settings);
|
||||||
|
if (!context.importsService) throw new BadRequestException('导入向导服务未配置');
|
||||||
|
|
||||||
|
const buffer = await context.attachmentService.readStoredBuffer(attachment);
|
||||||
|
const detail = await context.importsService.createRun(
|
||||||
|
{
|
||||||
|
id: user.id,
|
||||||
|
permissions: [...user.permissions],
|
||||||
|
isSuperAdmin: user.isSuperAdmin,
|
||||||
|
},
|
||||||
|
'ai',
|
||||||
|
{
|
||||||
|
originalName: attachment.originalName,
|
||||||
|
mimeType: attachment.mimeType,
|
||||||
|
size: attachment.size,
|
||||||
|
buffer,
|
||||||
|
},
|
||||||
|
conversation.id,
|
||||||
|
stages,
|
||||||
|
mapping,
|
||||||
|
settings,
|
||||||
|
);
|
||||||
|
const wizard = compactImportWizard(detail);
|
||||||
|
message.metadata = {
|
||||||
|
...message.metadata,
|
||||||
|
a2uiImportPreflight: { ...preflight, resolved: true, runId: detail.id },
|
||||||
|
a2uiImportWizard: wizard,
|
||||||
|
};
|
||||||
|
await context.messages.save(message);
|
||||||
|
|
||||||
|
onReady();
|
||||||
|
emit('ui.import_preflight', {
|
||||||
|
messageId,
|
||||||
|
preflight: { ...preflight, resolved: true, runId: detail.id },
|
||||||
|
});
|
||||||
|
emit('ui.import_wizard', { messageId, wizard });
|
||||||
|
} finally {
|
||||||
|
context.activeConversations.delete(conversation.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function assertReviewImportPermissions(
|
export function assertReviewImportPermissions(
|
||||||
context: AiChatServiceContext,
|
context: AiChatServiceContext,
|
||||||
user: AuthenticatedUser,
|
user: AuthenticatedUser,
|
||||||
@@ -205,34 +403,20 @@ export async function confirmReviewStep(
|
|||||||
reviewId: string,
|
reviewId: string,
|
||||||
sectionKey: string,
|
sectionKey: string,
|
||||||
): Promise<Record<string, unknown>> {
|
): Promise<Record<string, unknown>> {
|
||||||
const review = await context.reviewService.findOwned(reviewId, user.id);
|
const review = await loadReviewForConfirm(context, user, reviewId);
|
||||||
if (review.status === 'submitted') {
|
|
||||||
throw new ConflictException('导入已全部确认,无需重复确认');
|
|
||||||
}
|
|
||||||
if (review.status === 'expired') {
|
|
||||||
throw new ConflictException('导入预览已失效,请重新生成预览');
|
|
||||||
}
|
|
||||||
assertReviewImportPermissions(context, user, review, sectionKey);
|
assertReviewImportPermissions(context, user, review, sectionKey);
|
||||||
const { review: updated, message } = await context.reviewService.submitSection(
|
const { review: updated, message } = await context.reviewService.submitSection(
|
||||||
review.id,
|
review.id,
|
||||||
user.id,
|
user.id,
|
||||||
sectionKey,
|
sectionKey,
|
||||||
);
|
);
|
||||||
await context.opLog?.log({
|
await logImportOp(
|
||||||
userId: user.id,
|
context,
|
||||||
username: user.username,
|
user,
|
||||||
module: '批量导入',
|
'确认导入分表',
|
||||||
action: '确认导入分表',
|
`「${review.title}」分表「${sectionKey}」:${message}`,
|
||||||
detail: `「${review.title}」分表「${sectionKey}」:${message}`,
|
|
||||||
targetType: 'ai_review',
|
|
||||||
status: 'success',
|
|
||||||
});
|
|
||||||
await context.markReviewSubmittedOnMessage(
|
|
||||||
updated.assistantMessageId,
|
|
||||||
updated.conversationId,
|
|
||||||
updated,
|
|
||||||
);
|
);
|
||||||
return context.reviewService.serialize(updated);
|
return finalizeReview(context, updated);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function confirmReviewGroup(
|
export async function confirmReviewGroup(
|
||||||
@@ -244,13 +428,7 @@ export async function confirmReviewGroup(
|
|||||||
if (type !== 'students' && type !== 'rooms' && type !== 'transfers' && type !== 'checkins') {
|
if (type !== 'students' && type !== 'rooms' && type !== 'transfers' && type !== 'checkins') {
|
||||||
throw new BadRequestException(`业务类型不支持: ${String(type)}`);
|
throw new BadRequestException(`业务类型不支持: ${String(type)}`);
|
||||||
}
|
}
|
||||||
const review = await context.reviewService.findOwned(reviewId, user.id);
|
const review = await loadReviewForConfirm(context, user, reviewId);
|
||||||
if (review.status === 'submitted') {
|
|
||||||
throw new ConflictException('导入已全部确认,无需重复确认');
|
|
||||||
}
|
|
||||||
if (review.status === 'expired') {
|
|
||||||
throw new ConflictException('导入预览已失效,请重新生成预览');
|
|
||||||
}
|
|
||||||
assertReviewImportPermissions(context, user, review, undefined, type);
|
assertReviewImportPermissions(context, user, review, undefined, type);
|
||||||
const { review: updated } = await context.reviewService.submitGroup(review.id, user.id, type);
|
const { review: updated } = await context.reviewService.submitGroup(review.id, user.id, type);
|
||||||
const sectionTitles = context.reviewService
|
const sectionTitles = context.reviewService
|
||||||
@@ -258,21 +436,13 @@ export async function confirmReviewGroup(
|
|||||||
.filter((section) => section.type === type)
|
.filter((section) => section.type === type)
|
||||||
.map((section) => section.title)
|
.map((section) => section.title)
|
||||||
.join('、');
|
.join('、');
|
||||||
await context.opLog?.log({
|
await logImportOp(
|
||||||
userId: user.id,
|
context,
|
||||||
username: user.username,
|
user,
|
||||||
module: '批量导入',
|
'确认导入分组',
|
||||||
action: '确认导入分组',
|
`「${review.title}」分组「${type}」:${sectionTitles}`,
|
||||||
detail: `「${review.title}」分组「${type}」:${sectionTitles}`,
|
|
||||||
targetType: 'ai_review',
|
|
||||||
status: 'success',
|
|
||||||
});
|
|
||||||
await context.markReviewSubmittedOnMessage(
|
|
||||||
updated.assistantMessageId,
|
|
||||||
updated.conversationId,
|
|
||||||
updated,
|
|
||||||
);
|
);
|
||||||
return context.reviewService.serialize(updated);
|
return finalizeReview(context, updated);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function a2uiSubmitInfo(
|
export function a2uiSubmitInfo(
|
||||||
|
|||||||
@@ -1,54 +1,76 @@
|
|||||||
import { AiReview } from './entities/ai-review.entity';
|
import { AiReview } from './entities/ai-review.entity';
|
||||||
import {
|
import {
|
||||||
IMPORT_STEP_KEYS,
|
IMPORT_STEP_KEYS,
|
||||||
type ColumnMapping,
|
|
||||||
type ImportRunSettings,
|
|
||||||
type ImportStageRequest,
|
type ImportStageRequest,
|
||||||
type ImportStepKey,
|
type ImportStepKey,
|
||||||
type PreflightReport,
|
type PreflightReport,
|
||||||
} from '../imports/imports.types';
|
} from '../imports/imports.types';
|
||||||
|
import { permittedStepKeys } from '../imports/imports.access';
|
||||||
import type { AiChatServiceContext, AiSseEmitter, ModelToolCall } from './ai-chat.types';
|
import type { AiChatServiceContext, AiSseEmitter, ModelToolCall } from './ai-chat.types';
|
||||||
import type { AgentToolContext } from './ai-chat.tools';
|
import type { AgentToolContext } from './ai-chat.tools';
|
||||||
import { finishToolRun, startToolRun } from './ai-chat.tools';
|
import { finishToolRun, startToolRun } from './ai-chat.tools';
|
||||||
|
import {
|
||||||
|
isExcelAttachment,
|
||||||
|
parseConfirmedMapping,
|
||||||
|
parseConfirmedSettings,
|
||||||
|
} from './ai-chat.import-confirm';
|
||||||
|
|
||||||
function isExcelAttachment(attachment: {
|
function parseAttachmentArgs(
|
||||||
mimeType: string;
|
parsedArgs: unknown,
|
||||||
originalName: string;
|
): { parsedRecord: Record<string, unknown>; attachmentId: number } {
|
||||||
}): boolean {
|
const parsedRecord =
|
||||||
return (
|
parsedArgs && typeof parsedArgs === 'object' && !Array.isArray(parsedArgs)
|
||||||
attachment.mimeType.includes('spreadsheetml') ||
|
? (parsedArgs as Record<string, unknown>)
|
||||||
attachment.mimeType.includes('excel') ||
|
: {};
|
||||||
attachment.mimeType.includes('csv') ||
|
if (
|
||||||
/\.(xlsx|csv)$/i.test(attachment.originalName)
|
typeof parsedRecord.attachmentId !== 'number' ||
|
||||||
);
|
!Number.isInteger(parsedRecord.attachmentId) ||
|
||||||
|
parsedRecord.attachmentId <= 0
|
||||||
|
) {
|
||||||
|
throw new Error('缺少附件 attachmentId');
|
||||||
|
}
|
||||||
|
return { parsedRecord, attachmentId: parsedRecord.attachmentId };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function beginImportToolRun(
|
||||||
|
context: AiChatServiceContext,
|
||||||
|
messageId: number,
|
||||||
|
call: ModelToolCall,
|
||||||
|
emit: AiSseEmitter,
|
||||||
|
toolName: string,
|
||||||
|
) {
|
||||||
|
return startToolRun(context, messageId, call, emit, {
|
||||||
|
toolName,
|
||||||
|
skillKey: null,
|
||||||
|
argumentsData: null,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function executePreflightImport(
|
export async function executePreflightImport(
|
||||||
context: AiChatServiceContext,
|
context: AiChatServiceContext,
|
||||||
messageId: number,
|
messageId: number,
|
||||||
call: ModelToolCall,
|
call: ModelToolCall,
|
||||||
userId: number,
|
agentContext: AgentToolContext,
|
||||||
emit: AiSseEmitter,
|
emit: AiSseEmitter,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const { run, parsedArgs, startedAt } = await startToolRun(context, messageId, call, emit, {
|
const { run, parsedArgs, startedAt } = await beginImportToolRun(
|
||||||
toolName: 'preflight_import',
|
context,
|
||||||
skillKey: null,
|
messageId,
|
||||||
argumentsData: null,
|
call,
|
||||||
});
|
emit,
|
||||||
|
'preflight_import',
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const assistant = await context.messages.findOne({ where: { id: messageId } });
|
const assistant = await context.messages.findOne({ where: { id: messageId } });
|
||||||
if (!assistant) throw new Error('assistant message missing');
|
if (!assistant) throw new Error('assistant message missing');
|
||||||
const parsedRecord =
|
const { parsedRecord, attachmentId } = parseAttachmentArgs(parsedArgs);
|
||||||
parsedArgs && typeof parsedArgs === 'object' && !Array.isArray(parsedArgs)
|
const headerRow =
|
||||||
? (parsedArgs as Record<string, unknown>)
|
parsedRecord.headerRow === undefined ? 1 : Number(parsedRecord.headerRow);
|
||||||
: {};
|
if (!Number.isInteger(headerRow) || headerRow < 1 || headerRow > 1000) {
|
||||||
const attachmentId =
|
throw new Error('headerRow 必须是 1-1000 之间的整数');
|
||||||
typeof parsedRecord.attachmentId === 'number' ? parsedRecord.attachmentId : undefined;
|
|
||||||
if (!Number.isInteger(attachmentId) || (attachmentId as number) <= 0) {
|
|
||||||
throw new Error('缺少附件 attachmentId');
|
|
||||||
}
|
}
|
||||||
const [attachment] = await context.attachmentService.requireReadyOwned(userId, [
|
const [attachment] = await context.attachmentService.requireReadyOwned(agentContext.userId, [
|
||||||
attachmentId as number,
|
attachmentId as number,
|
||||||
]);
|
]);
|
||||||
if (!isExcelAttachment(attachment)) {
|
if (!isExcelAttachment(attachment)) {
|
||||||
@@ -61,10 +83,23 @@ export async function executePreflightImport(
|
|||||||
mimeType: attachment.mimeType,
|
mimeType: attachment.mimeType,
|
||||||
size: attachment.size,
|
size: attachment.size,
|
||||||
buffer,
|
buffer,
|
||||||
|
}, headerRow);
|
||||||
|
const permittedSteps = permittedStepKeys({
|
||||||
|
id: agentContext.userId,
|
||||||
|
permissions: [...agentContext.permissions],
|
||||||
|
isSuperAdmin: agentContext.isSuperAdmin,
|
||||||
});
|
});
|
||||||
|
const preflightCard: PreflightReport = {
|
||||||
|
...preflight,
|
||||||
|
attachmentId: attachment.id,
|
||||||
|
headerRow,
|
||||||
|
permittedSteps,
|
||||||
|
resolved: false,
|
||||||
|
runId: null,
|
||||||
|
};
|
||||||
assistant.metadata = {
|
assistant.metadata = {
|
||||||
...assistant.metadata,
|
...assistant.metadata,
|
||||||
a2uiImportPreflight: preflight,
|
a2uiImportPreflight: preflightCard,
|
||||||
};
|
};
|
||||||
await context.messages.save(assistant);
|
await context.messages.save(assistant);
|
||||||
|
|
||||||
@@ -74,12 +109,8 @@ export async function executePreflightImport(
|
|||||||
.map((stage) => `${stage.label} ${stage.total} 行`)
|
.map((stage) => `${stage.label} ${stage.total} 行`)
|
||||||
.join('、') || '未识别到可导入阶段'}`,
|
.join('、') || '未识别到可导入阶段'}`,
|
||||||
}, emit);
|
}, emit);
|
||||||
emit('ui.import_preflight', { messageId, preflight });
|
emit('ui.import_preflight', { messageId, preflight: preflightCard });
|
||||||
return JSON.stringify({
|
return preflightModelPayload(preflight, permittedSteps);
|
||||||
status: 'success',
|
|
||||||
report: preflight,
|
|
||||||
message: '预检报告已生成,请按报告中的 questions 向用户确认后,再调用 start_import_wizard',
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const summary =
|
const summary =
|
||||||
error instanceof Error ? error.message.slice(0, 100) : '导入预检失败';
|
error instanceof Error ? error.message.slice(0, 100) : '导入预检失败';
|
||||||
@@ -99,24 +130,18 @@ export async function executeStartImportWizard(
|
|||||||
agentContext: AgentToolContext,
|
agentContext: AgentToolContext,
|
||||||
emit: AiSseEmitter,
|
emit: AiSseEmitter,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const { run, parsedArgs, startedAt } = await startToolRun(context, messageId, call, emit, {
|
const { run, parsedArgs, startedAt } = await beginImportToolRun(
|
||||||
toolName: 'start_import_wizard',
|
context,
|
||||||
skillKey: null,
|
messageId,
|
||||||
argumentsData: null,
|
call,
|
||||||
});
|
emit,
|
||||||
|
'start_import_wizard',
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const assistant = await context.messages.findOne({ where: { id: messageId } });
|
const assistant = await context.messages.findOne({ where: { id: messageId } });
|
||||||
if (!assistant) throw new Error('assistant message missing');
|
if (!assistant) throw new Error('assistant message missing');
|
||||||
const parsedRecord =
|
const { parsedRecord, attachmentId } = parseAttachmentArgs(parsedArgs);
|
||||||
parsedArgs && typeof parsedArgs === 'object' && !Array.isArray(parsedArgs)
|
|
||||||
? (parsedArgs as Record<string, unknown>)
|
|
||||||
: {};
|
|
||||||
const attachmentId =
|
|
||||||
typeof parsedRecord.attachmentId === 'number' ? parsedRecord.attachmentId : undefined;
|
|
||||||
if (!Number.isInteger(attachmentId) || (attachmentId as number) <= 0) {
|
|
||||||
throw new Error('缺少附件 attachmentId');
|
|
||||||
}
|
|
||||||
const [attachment] = await context.attachmentService.requireReadyOwned(agentContext.userId, [
|
const [attachment] = await context.attachmentService.requireReadyOwned(agentContext.userId, [
|
||||||
attachmentId as number,
|
attachmentId as number,
|
||||||
]);
|
]);
|
||||||
@@ -132,6 +157,12 @@ export async function executeStartImportWizard(
|
|||||||
if (!stage.sheet || !String(stage.sheet).trim()) {
|
if (!stage.sheet || !String(stage.sheet).trim()) {
|
||||||
throw new Error(`stages 中「${stage.stepKey}」缺少工作表 sheet,请指定 Excel 中对应的 sheet 名`);
|
throw new Error(`stages 中「${stage.stepKey}」缺少工作表 sheet,请指定 Excel 中对应的 sheet 名`);
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
stage.headerRow !== undefined &&
|
||||||
|
(!Number.isInteger(stage.headerRow) || stage.headerRow < 1 || stage.headerRow > 1000)
|
||||||
|
) {
|
||||||
|
throw new Error(`stages 中「${stage.stepKey}」的 headerRow 必须是 1-1000 之间的整数`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const mapping = parseConfirmedMapping(parsedRecord.mapping);
|
const mapping = parseConfirmedMapping(parsedRecord.mapping);
|
||||||
const settings = parseConfirmedSettings(parsedRecord);
|
const settings = parseConfirmedSettings(parsedRecord);
|
||||||
@@ -156,8 +187,18 @@ export async function executeStartImportWizard(
|
|||||||
settings,
|
settings,
|
||||||
);
|
);
|
||||||
const wizard = compactImportWizard(detail);
|
const wizard = compactImportWizard(detail);
|
||||||
|
const preflightMeta = assistant.metadata?.a2uiImportPreflight;
|
||||||
assistant.metadata = {
|
assistant.metadata = {
|
||||||
...assistant.metadata,
|
...assistant.metadata,
|
||||||
|
...(preflightMeta && typeof preflightMeta === 'object' && !Array.isArray(preflightMeta)
|
||||||
|
? {
|
||||||
|
a2uiImportPreflight: {
|
||||||
|
...(preflightMeta as Record<string, unknown>),
|
||||||
|
resolved: true,
|
||||||
|
runId: detail.id,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
a2uiImportWizard: wizard,
|
a2uiImportWizard: wizard,
|
||||||
};
|
};
|
||||||
await context.messages.save(assistant);
|
await context.messages.save(assistant);
|
||||||
@@ -169,6 +210,16 @@ export async function executeStartImportWizard(
|
|||||||
.map((step) => step.label)
|
.map((step) => step.label)
|
||||||
.join('、')}`,
|
.join('、')}`,
|
||||||
}, emit);
|
}, emit);
|
||||||
|
if (preflightMeta && typeof preflightMeta === 'object' && !Array.isArray(preflightMeta)) {
|
||||||
|
emit('ui.import_preflight', {
|
||||||
|
messageId,
|
||||||
|
preflight: {
|
||||||
|
...(preflightMeta as Record<string, unknown>),
|
||||||
|
resolved: true,
|
||||||
|
runId: detail.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
emit('ui.import_wizard', { messageId, wizard });
|
emit('ui.import_wizard', { messageId, wizard });
|
||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
status: 'success',
|
status: 'success',
|
||||||
@@ -176,6 +227,11 @@ export async function executeStartImportWizard(
|
|||||||
steps: detail.steps
|
steps: detail.steps
|
||||||
.filter((step) => step.status !== 'skipped')
|
.filter((step) => step.status !== 'skipped')
|
||||||
.map((step) => ({ stepKey: step.stepKey, label: step.label, sheets: step.sheets })),
|
.map((step) => ({ stepKey: step.stepKey, label: step.label, sheets: step.sheets })),
|
||||||
|
permittedSteps: permittedStepKeys({
|
||||||
|
id: agentContext.userId,
|
||||||
|
permissions: [...agentContext.permissions],
|
||||||
|
isSuperAdmin: agentContext.isSuperAdmin,
|
||||||
|
}),
|
||||||
message: '导入向导已生成,请提示用户打开向导,按阶段预览并确认后系统才会入库',
|
message: '导入向导已生成,请提示用户打开向导,按阶段预览并确认后系统才会入库',
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -185,57 +241,44 @@ export async function executeStartImportWizard(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseConfirmedMapping(raw: unknown): Partial<Record<ImportStepKey, ColumnMapping>> | undefined {
|
function preflightModelPayload(
|
||||||
if (raw === undefined || raw === null) return undefined;
|
report: PreflightReport,
|
||||||
if (typeof raw !== 'object' || Array.isArray(raw)) throw new Error('mapping 参数格式错误');
|
permittedSteps: ImportStepKey[],
|
||||||
const mapping: Partial<Record<ImportStepKey, ColumnMapping>> = {};
|
): string {
|
||||||
for (const [stepKey, fields] of Object.entries(raw as Record<string, unknown>)) {
|
const guidance =
|
||||||
if (!(IMPORT_STEP_KEYS as readonly string[]).includes(stepKey)) {
|
'预检报告已以卡片展示:请引导用户在卡内确认列映射与策略并点击「生成导入向导」;' +
|
||||||
throw new Error(`mapping 包含未知业务类型:${stepKey}`);
|
'仅当用户在聊天文本中显式给出确认时才调用 start_import_wizard';
|
||||||
}
|
const fullPayload = JSON.stringify({
|
||||||
if (fields === undefined || fields === null) continue;
|
status: 'success',
|
||||||
if (typeof fields !== 'object' || Array.isArray(fields)) {
|
report,
|
||||||
throw new Error(`mapping 中「${stepKey}」的列映射格式错误`);
|
permittedSteps,
|
||||||
}
|
message: guidance,
|
||||||
const columnMapping: ColumnMapping = {};
|
});
|
||||||
for (const [field, header] of Object.entries(fields as Record<string, unknown>)) {
|
if (fullPayload.length <= 32 * 1024) return fullPayload;
|
||||||
if (typeof field !== 'string' || !field.trim() || field.length > 50) continue;
|
return JSON.stringify({
|
||||||
if (typeof header !== 'string' || !header.trim()) continue;
|
status: 'success',
|
||||||
columnMapping[field] = header.slice(0, 200);
|
truncated: true,
|
||||||
}
|
report: {
|
||||||
mapping[stepKey as ImportStepKey] = columnMapping;
|
verdict: report.verdict,
|
||||||
}
|
stages: report.stages.map((stage) => ({
|
||||||
return mapping;
|
stepKey: stage.stepKey,
|
||||||
}
|
label: stage.label,
|
||||||
|
sheetNames: stage.sheetNames,
|
||||||
function parseConfirmedSettings(parsedRecord: Record<string, unknown>): ImportRunSettings {
|
total: stage.total,
|
||||||
const settings: ImportRunSettings = {};
|
create: stage.create,
|
||||||
if (parsedRecord.organization !== undefined && parsedRecord.organization !== null) {
|
update: stage.update,
|
||||||
if (typeof parsedRecord.organization !== 'string') {
|
error: stage.error,
|
||||||
throw new Error('organization 必须是字符串');
|
skip: stage.skip,
|
||||||
}
|
mapping: stage.mapping,
|
||||||
const organization = parsedRecord.organization.trim().slice(0, 100);
|
missingRequired: stage.missingRequired,
|
||||||
if (organization) settings.organization = organization;
|
})),
|
||||||
}
|
questions: report.questions,
|
||||||
if (parsedRecord.updateExisting !== undefined) {
|
errorSamples: report.errorSamples.slice(0, 10),
|
||||||
if (typeof parsedRecord.updateExisting !== 'boolean') {
|
nextSteps: report.nextSteps,
|
||||||
throw new Error('updateExisting 必须是布尔值');
|
},
|
||||||
}
|
permittedSteps,
|
||||||
settings.updateExisting = parsedRecord.updateExisting;
|
message: guidance,
|
||||||
}
|
});
|
||||||
if (parsedRecord.duplicatePolicy !== undefined) {
|
|
||||||
if (parsedRecord.duplicatePolicy !== 'error' && parsedRecord.duplicatePolicy !== 'skip') {
|
|
||||||
throw new Error('duplicatePolicy 只能是 error 或 skip');
|
|
||||||
}
|
|
||||||
settings.duplicatePolicy = parsedRecord.duplicatePolicy;
|
|
||||||
}
|
|
||||||
if (parsedRecord.skipUnmatched !== undefined) {
|
|
||||||
if (typeof parsedRecord.skipUnmatched !== 'boolean') {
|
|
||||||
throw new Error('skipUnmatched 必须是布尔值');
|
|
||||||
}
|
|
||||||
settings.skipUnmatched = parsedRecord.skipUnmatched;
|
|
||||||
}
|
|
||||||
return settings;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function compactImportWizard(detail: any): {
|
export function compactImportWizard(detail: any): {
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
executePreflightImport,
|
executePreflightImport,
|
||||||
executeRenderChart,
|
executeRenderChart,
|
||||||
executeRenderForm,
|
executeRenderForm,
|
||||||
executeRenderReview,
|
|
||||||
executeStartImportWizard,
|
executeStartImportWizard,
|
||||||
} from './ai-chat.tool-actions';
|
} from './ai-chat.tool-actions';
|
||||||
|
|
||||||
@@ -91,17 +90,11 @@ export async function executeTool(
|
|||||||
return executeRenderForm(context, messageId, call, userId, emit);
|
return executeRenderForm(context, messageId, call, userId, emit);
|
||||||
}
|
}
|
||||||
if (call.name === 'preflight_import') {
|
if (call.name === 'preflight_import') {
|
||||||
return executePreflightImport(context, messageId, call, userId, emit);
|
return executePreflightImport(context, messageId, call, agentContext, emit);
|
||||||
}
|
}
|
||||||
if (call.name === 'start_import_wizard') {
|
if (call.name === 'start_import_wizard') {
|
||||||
return executeStartImportWizard(context, messageId, call, agentContext, emit);
|
return executeStartImportWizard(context, messageId, call, agentContext, emit);
|
||||||
}
|
}
|
||||||
if (call.name === 'render_review') {
|
|
||||||
if (reviewSubmitted) {
|
|
||||||
return denyTool(context, messageId, call, 'render_review', '导入已确认,无需再次生成预览', '导入已确认', emit);
|
|
||||||
}
|
|
||||||
return executeRenderReview(context, messageId, call, userId, emit);
|
|
||||||
}
|
|
||||||
if (call.name === 'render_chart') {
|
if (call.name === 'render_chart') {
|
||||||
return executeRenderChart(context, messageId, call, emit);
|
return executeRenderChart(context, messageId, call, emit);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import ExcelJS from 'exceljs';
|
import ExcelJS from 'exceljs';
|
||||||
import JSZip from 'jszip';
|
import { readXlsxSheetsFallback } from '../imports/imports.workbook-fallback';
|
||||||
|
|
||||||
export interface ExcelSheetInfo {
|
export interface ExcelSheetInfo {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -100,121 +100,19 @@ export class AiExcelReaderService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async loadWithFallback(buffer: Buffer): Promise<ExcelSheetRows[]> {
|
private async loadWithFallback(buffer: Buffer): Promise<ExcelSheetRows[]> {
|
||||||
const zip = await JSZip.loadAsync(buffer);
|
const sheets = await readXlsxSheetsFallback(buffer);
|
||||||
const readEntry = async (name: string): Promise<string | null> => {
|
return sheets.map((sheet) => ({ name: sheet.name, rows: sheet.rows }));
|
||||||
const entry = zip.file(name);
|
|
||||||
return entry ? entry.async('string') : null;
|
|
||||||
};
|
|
||||||
const workbookXml = await readEntry('xl/workbook.xml');
|
|
||||||
if (!workbookXml) throw new Error('workbook.xml missing');
|
|
||||||
const stripPrefixes = (value: string): string =>
|
|
||||||
value.replace(/<(\/?)([a-zA-Z][\w.]*):/g, '<$1');
|
|
||||||
const relsXml = stripPrefixes((await readEntry('xl/_rels/workbook.xml.rels')) ?? '');
|
|
||||||
const relTargets = new Map<string, string>();
|
|
||||||
for (const match of relsXml.matchAll(
|
|
||||||
/<Relationship[^>]*\bId="([^"]+)"[^>]*\bTarget="([^"]+)"/g,
|
|
||||||
)) {
|
|
||||||
const target = match[2].replace(/^\/+/, '');
|
|
||||||
relTargets.set(match[1], target.startsWith('xl/') ? target : `xl/${target}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const sharedStrings = await this.parseSharedStringsFallback(readEntry);
|
|
||||||
const sheets: ExcelSheetRows[] = [];
|
|
||||||
const cleanWorkbook = stripPrefixes(workbookXml);
|
|
||||||
for (const match of cleanWorkbook.matchAll(/<sheet\b[^>]*\/?>/g)) {
|
|
||||||
const tag = match[0].replace(/<sheet\b/, '<sheet').replace(/\/?>$/, '>');
|
|
||||||
const name = tag.match(/\bname="([^"]+)"/)?.[1];
|
|
||||||
const rid = tag.match(/\br:id="([^"]+)"/)?.[1];
|
|
||||||
if (!name || !rid) continue;
|
|
||||||
const target = relTargets.get(rid);
|
|
||||||
const sheetXml = target ? await readEntry(target) : null;
|
|
||||||
if (!sheetXml) continue;
|
|
||||||
sheets.push({
|
|
||||||
name: this.unescapeXml(name),
|
|
||||||
rows: this.sheetRowsFromXmlFallback(sheetXml, sharedStrings),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return sheets;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async parseSharedStringsFallback(
|
|
||||||
readEntry: (name: string) => Promise<string | null>,
|
|
||||||
): Promise<string[]> {
|
|
||||||
const xml = await readEntry('xl/sharedStrings.xml');
|
|
||||||
if (!xml) return [];
|
|
||||||
const clean = xml.replace(/<(\/?)([a-zA-Z][\w.]*):/g, '<$1');
|
|
||||||
const strings: string[] = [];
|
|
||||||
for (const match of clean.matchAll(/<si\b[^>]*>([\s\S]*?)<\/si>/gs)) {
|
|
||||||
const texts = [...match[1].matchAll(/<t\b[^>]*>([\s\S]*?)<\/t>/g)].map((part) =>
|
|
||||||
this.unescapeXml(part[1]),
|
|
||||||
);
|
|
||||||
strings.push(texts.join(''));
|
|
||||||
}
|
|
||||||
return strings;
|
|
||||||
}
|
|
||||||
|
|
||||||
private sheetRowsFromXmlFallback(sheetXml: string, sharedStrings: string[]): string[][] {
|
|
||||||
const rows: string[][] = [];
|
|
||||||
const xml = sheetXml.replace(/<(\/?)([a-zA-Z][\w.]*):/g, '<$1');
|
|
||||||
for (const rowMatch of xml.matchAll(/<row\b[^>]*>([\s\S]*?)<\/row>/gs)) {
|
|
||||||
const cells = new Map<number, string>();
|
|
||||||
let maxColumn = -1;
|
|
||||||
for (const cellMatch of rowMatch[1].matchAll(/<c\b([^>]*)\/?>([\s\S]*?)<\/c>/gs)) {
|
|
||||||
const attrs = cellMatch[1];
|
|
||||||
const refMatch = attrs.match(/\br="([A-Z]+)\d+"/);
|
|
||||||
const column = refMatch ? this.columnIndex(refMatch[1]) : -1;
|
|
||||||
const type = attrs.match(/\bt="([^"]+)"/)?.[1] ?? 'n';
|
|
||||||
const body = cellMatch[2] ?? '';
|
|
||||||
let value = '';
|
|
||||||
if (type === 's') {
|
|
||||||
const index = Number(body.match(/<v>([^<]*)<\/v>/)?.[1] ?? '');
|
|
||||||
value = Number.isInteger(index) ? (sharedStrings[index] ?? '') : '';
|
|
||||||
} else if (type === 'inlineStr') {
|
|
||||||
const texts = [...body.matchAll(/<t\b[^>]*>([\s\S]*?)<\/t>/g)].map((part) =>
|
|
||||||
this.unescapeXml(part[1]),
|
|
||||||
);
|
|
||||||
value = texts.join('');
|
|
||||||
} else {
|
|
||||||
value = this.unescapeXml(body.match(/<v>([\s\S]*?)<\/v>/)?.[1] ?? '');
|
|
||||||
if (type === 'b') value = value === '1' ? 'true' : 'false';
|
|
||||||
}
|
|
||||||
if (column >= 0) {
|
|
||||||
cells.set(column, value);
|
|
||||||
maxColumn = Math.max(maxColumn, column);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (maxColumn < 0) continue;
|
|
||||||
const values = Array.from({ length: maxColumn + 1 }, (_, index) => cells.get(index) ?? '');
|
|
||||||
if (values.every((value) => value === '')) continue;
|
|
||||||
rows.push(values);
|
|
||||||
}
|
|
||||||
return rows;
|
|
||||||
}
|
|
||||||
|
|
||||||
private columnIndex(letters: string): number {
|
|
||||||
let index = 0;
|
|
||||||
for (const char of letters.toUpperCase()) {
|
|
||||||
index = index * 26 + (char.charCodeAt(0) - 64);
|
|
||||||
}
|
|
||||||
return index - 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
private unescapeXml(value: string): string {
|
|
||||||
return value
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/'/g, "'")
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/&#x([0-9a-fA-F]+);/g, (_all, hex: string) =>
|
|
||||||
String.fromCodePoint(Number.parseInt(hex, 16)),
|
|
||||||
)
|
|
||||||
.replace(/&#(\d+);/g, (_all, dec: string) => String.fromCodePoint(Number(dec)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private stringifyCellValue(value: unknown): string {
|
private stringifyCellValue(value: unknown): string {
|
||||||
if (value === null || value === undefined) return '';
|
if (value === null || value === undefined) return '';
|
||||||
if (value instanceof Date) return value.toISOString();
|
if (value instanceof Date) {
|
||||||
|
if (Number.isNaN(value.getTime())) return '';
|
||||||
|
const year = value.getFullYear();
|
||||||
|
const month = String(value.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(value.getDate()).padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
}
|
||||||
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
||||||
return String(value);
|
return String(value);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,6 +110,19 @@ export class SubmitReviewDto {
|
|||||||
reasoningEffort?: string | null;
|
reasoningEffort?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class ResolveImportPreflightDto {
|
||||||
|
@IsUUID()
|
||||||
|
clientRequestId: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsObject()
|
||||||
|
mapping?: Record<string, unknown>;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsObject()
|
||||||
|
settings?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
export class MessagePageQueryDto {
|
export class MessagePageQueryDto {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Type(() => Number)
|
@Type(() => Number)
|
||||||
|
|||||||
Reference in New Issue
Block a user