Files
LWLT-AIBOT/tools/business-input-templates.test.mjs
2026-09-09 18:42:20 +08:00

521 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
import test from 'node:test';
import Ajv2020 from 'ajv/dist/2020.js';
import { buildParserMessage } from '../LianSyn-platform/external-agent-client.mjs';
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const read = (relativePath) => readFileSync(path.join(repoRoot, relativePath), 'utf8').replace(/\r\n/gu, '\n');
const catalog = read('agent设计规范/templates/business-input-templates.md');
const registry = read('agent设计规范/business-behavior-registry.md');
const mainPrompt = read('agent设计规范/agent-prompt.md');
const skillRuntimeFiles = [
'agent设计规范/skills/lwlt-newbooking/SKILL.md',
'agent设计规范/skills/lwlt-newbooking/agents/openai.yaml',
'agent设计规范/skills/lwlt-newbooking/references/actions-and-fields.md',
'agent设计规范/skills/lwlt-newbooking/references/input-contract.md',
'agent设计规范/skills/lwlt-newbooking/references/normalization-rules.md',
'agent设计规范/skills/lwlt-newbooking/references/output-contract.md',
'agent设计规范/skills/lwlt-updating/SKILL.md',
'agent设计规范/skills/lwlt-updating/agents/openai.yaml',
'agent设计规范/skills/lwlt-updating/references/input-contract.md',
'agent设计规范/skills/lwlt-updating/references/operation-contract.md',
'agent设计规范/skills/lwlt-arrangement/SKILL.md',
'agent设计规范/skills/lwlt-arrangement/agents/openai.yaml',
'agent设计规范/skills/lwlt-arrangement/references/input-contract.md',
'agent设计规范/skills/lwlt-arrangement/references/operation-contract.md',
'agent设计规范/skills/lwlt-confirmation/SKILL.md',
'agent设计规范/skills/lwlt-confirmation/agents/openai.yaml',
'agent设计规范/skills/lwlt-confirmation/references/input-contract.md',
'agent设计规范/skills/lwlt-confirmation/references/output-contract.md',
'agent设计规范/skills/lwlt-lifecycle/SKILL.md',
'agent设计规范/skills/lwlt-lifecycle/agents/openai.yaml',
'agent设计规范/skills/lwlt-lifecycle/references/input-contract.md',
'agent设计规范/skills/lwlt-lifecycle/references/operation-contract.md'
];
const skillRuntimeText = skillRuntimeFiles.map(read).join('\n');
const executableDirectives = [
'独立团单个下单',
'独立团批量下单',
'散拼团新增计划',
'散拼团单个新增子单',
'导入独立团名单',
'导入散拼子单名单',
'安排导游',
'安排用车',
'安排酒店',
'安排大交通',
'安排其他/备案',
'修改散拼母团计划收客数',
'追加散拼子单订房说明',
'取消订单',
'恢复订单',
'导出团队文件'
];
const narrowModificationDirectives = [
'独立团信息修改',
'安排变更'
];
const deferredDirectives = [
'真实应收团款处理',
'团队文件交付自动化',
'团队文件上传'
];
const agentDirectives = [
...executableDirectives.slice(0, 13),
...narrowModificationDirectives,
...executableDirectives.slice(13)
];
const allDirectives = [
...agentDirectives.slice(0, 4),
'散拼团多个新增子单',
...agentDirectives.slice(4)
];
const skillContracts = {
'agent设计规范/skills/lwlt-newbooking/references/input-contract.md': [
'独立团单个下单',
'独立团批量下单',
'散拼团新增计划',
'散拼团单个新增子单'
],
'agent设计规范/skills/lwlt-arrangement/references/input-contract.md': [
'安排导游',
'安排用车',
'安排酒店',
'安排大交通',
'安排其他/备案',
'安排变更'
],
'agent设计规范/skills/lwlt-updating/references/input-contract.md': [
'修改散拼母团计划收客数',
'追加散拼子单订房说明',
'独立团信息修改'
],
'agent设计规范/skills/lwlt-confirmation/references/input-contract.md': ['导出团队文件'],
'agent设计规范/skills/lwlt-lifecycle/references/input-contract.md': [
'导入独立团名单',
'导入散拼子单名单',
'取消订单',
'恢复订单'
]
};
const businessPages = {
'agent设计规范/businesses/team_order_create.md': '独立团单个下单',
'agent设计规范/businesses/team_order_batch_create.md': '独立团批量下单',
'agent设计规范/businesses/shared_plan_create.md': '散拼团新增计划',
'agent设计规范/businesses/shared_child_order_create.md': '散拼团单个新增子单'
};
const minimumLabels = {
独立团单个下单: ['发团日期', '预订客户', '产品搜索', '预估人数', '用房数量'],
独立团批量下单: ['发团日期', '预订客户', '产品搜索', '预估人数', '用房数量', '发团周期'],
散拼团新增计划: ['发团日期', '产品搜索', '计划收客数', '用房数量'],
散拼团单个新增子单: ['预订客户', '出发日期', '人数'],
散拼团多个新增子单: ['预订客户', '出发日期', '产品名称', '人数'],
导入独立团名单: ['预订客户', '出发日期'],
导入散拼子单名单: ['预订客户', '出发日期'],
安排导游: ['团号', '导游搜索'],
安排用车: ['团号', '结算单位搜索', '用车日期', '数量'],
安排酒店: ['团号', '入住日期', '离店日期', '酒店搜索', '间数'],
安排大交通: ['团号', '结算单位搜索', '数量', '交通日期'],
'安排其他/备案': ['团号', '结算单位搜索', '业务日期'],
修改散拼母团计划收客数: ['出发日期', '目标计划收客数'],
追加散拼子单订房说明: ['预订客户', '出发日期', '追加内容'],
独立团信息修改: ['预订客户', '出发日期', '目标修改'],
安排变更: ['团号', '目标变更'],
取消订单: ['预订客户', '出发日期'],
恢复订单: ['预订客户', '出发日期'],
导出团队文件: ['预订客户', '出发日期', '文件类型']
};
const textBlocks = (document) =>
[...document.matchAll(/```text\r?\n([\s\S]*?)```/g)].map((match) => match[1].trim());
const headings = [...catalog.matchAll(/^### (\d+)\. (.+)$/gm)];
const operationSections = headings.map((match, index) => {
const start = match.index;
const end = headings[index + 1]?.index ?? catalog.length;
return {
number: Number(match[1]),
directive: match[2].trim(),
body: catalog.slice(start, end)
};
});
const sectionByDirective = new Map(operationSections.map((section) => [section.directive, section]));
const labeledBlock = (section, label) => {
const pattern = new RegExp(
`^#### ${label}\\r?\\n\\r?\\n\`\`\`text\\r?\\n([\\s\\S]*?)\`\`\``,
'm'
);
return section.body.match(pattern)?.[1].trim() ?? null;
};
const fieldLines = (block) =>
block
.split(/\r?\n/)
.slice(1)
.filter((line) => line.includes(':'));
const assertTwoBlockContract = (document, directives, source) => {
const blocks = textBlocks(document);
for (const directive of directives) {
const matches = blocks.filter((block) => block.startsWith(`${directive}\n`));
assert.equal(matches.length, 2, `${source}: ${directive} must have exactly template + example`);
const [template, example] = matches;
for (const line of fieldLines(template)) {
assert.match(line, /(必填|选填):/, `${source}: unmarked template field: ${line}`);
}
assert.doesNotMatch(example, /(必填|选填)/, `${source}: example contains field rules`);
assert.doesNotMatch(example, /<[^>]+>/, `${source}: example contains a placeholder`);
}
};
test('catalog has exactly 19 ordered operations with adjacent template and example blocks', () => {
assert.deepEqual(
operationSections.map((section) => section.number),
Array.from({ length: 19 }, (_, index) => index + 1)
);
assert.deepEqual(operationSections.map((section) => section.directive), allDirectives);
for (const section of operationSections) {
const template = labeledBlock(section, '模板');
const example = labeledBlock(section, '示例');
assert.ok(template, `${section.directive}: missing template block`);
assert.ok(example, `${section.directive}: missing example block`);
assert.equal(textBlocks(section.body).length, 2, `${section.directive}: must contain only two text blocks`);
assert.equal(template.split(/\r?\n/, 1)[0], section.directive);
assert.equal(example.split(/\r?\n/, 1)[0], section.directive);
assert.ok(section.body.indexOf('#### 模板') < section.body.indexOf('#### 示例'));
}
assert.doesNotMatch(catalog, /最小必填速查|## 二、能力状态/);
});
test('main prompt remains a compact Skill router', () => {
assert.match(mainPrompt, /prompt_version: ltjt-agent-prompt-v1\.8-independent-headcount-categories/);
for (const action of [
'team_order_create',
'team_order_batch_create',
'shared_plan_create',
'shared_child_order_create',
'passenger_list_import',
'arrangement_guide',
'arrangement_vehicle',
'arrangement_hotel',
'arrangement_transport',
'arrangement_other',
'order_update_shared_plan',
'order_update_shared_child',
'order_update_independent',
'order_cancel',
'order_restore',
'confirmation_export'
]) assert.equal(mainPrompt.includes(action), false, `main prompt duplicates Skill action: ${action}`);
assert.match(mainPrompt, /`agent_parse_needs_input`/);
assert.match(mainPrompt, /`agent_parse_blocked`/);
assert.match(mainPrompt, /你是老挝联泰系统操作员。根据用户实际表达识别业务类型,并调用适合的业务能力处理,最后原样返回标准 JSON 结果。/);
assert.match(mainPrompt, /不得脱离业务能力自行编造 `operation` 字段或用户事实/);
assert.doesNotMatch(mainPrompt, /## 18 类标准路由|## 输出状态|## 提取边界|```json/);
assert.ok(mainPrompt.length < 1_200, `main prompt is too large: ${mainPrompt.length}`);
assert.doesNotMatch(mainPrompt, /插件|ERP|门禁|TEST-202609|test_context|server_response|native_request|requery|fresh|write_attempted|DoInfo|allowlist|\b(?:tid|ddid|row_id)\b/);
const runtimeMessage = buildParserMessage({
rawText: '独立团信息修改\n单号:LW-260903-A\n目标修改:标间数改为9间',
taskId: 'TASK-PARSE-ONLY-PROMPT',
receivedAt: '2026-08-12T08:00:00.000Z'
});
assert.match(runtimeMessage, /只解析用户输入,不查询对象、不执行任务、不判断结果/);
assert.match(runtimeMessage, /新建业务、订单\/安排修改以及取消\/恢复订单中,用户只提供月日且原文没有年份时/);
assert.match(runtimeMessage, /取消\/恢复的定位出发日期均执行上述规则/);
assert.doesNotMatch(runtimeMessage, /标准指令路由/);
assert.doesNotMatch(runtimeMessage, /插件|ERP|门禁|TEST-202609|test_context|server_response|native_request|requery|fresh|write_attempted|DoInfo|allowlist|\b(?:tid|ddid|row_id)\b/);
});
test('all packaged Skill sources contain only parsing knowledge', () => {
// Business-field names such as the ERP leader lookup column are parsing
// knowledge. Skills must still stay clear of execution plumbing and
// internal/runtime references.
assert.doesNotMatch(skillRuntimeText, /插件|浏览器|门禁|TEST-202609|test_context|server_response|native_request|requery|fresh|write_attempted|DoInfo|allowlist|\b(?:tid|ddid|row_id)\b|E2E\d*/);
for (const action of [
'team_order_create',
'team_order_batch_create',
'shared_plan_create',
'shared_child_order_create',
'passenger_list_import',
'arrangement_guide',
'arrangement_vehicle',
'arrangement_hotel',
'arrangement_transport',
'arrangement_other',
'order_update_shared_plan',
'order_update_shared_child',
'order_update_independent',
'order_cancel',
'order_restore',
'confirmation_export'
]) assert.ok(skillRuntimeText.includes(action), `Skill sources missing parser action: ${action}`);
});
test('Agent parse Schema accepts business facts and rejects execution metadata', () => {
const operationSchema = JSON.parse(read('schemas/agent_operation.schema.json'));
const resultSchema = JSON.parse(read('schemas/agent_parse_result.schema.json'));
const ajv = new Ajv2020({ allErrors: true, strict: false });
ajv.addFormat('date', /^\d{4}-\d{2}-\d{2}$/);
ajv.addSchema(operationSchema);
const validate = ajv.compile(resultSchema);
const parseOnly = {
status: 'agent_parse_passed',
blockers: [],
operation: {
action: 'order_cancel',
order_nature: 'formal',
submit_mode: 'dry_run',
data: { existing_refs: { identifier: 'LW-260903-A' } }
}
};
assert.equal(validate(parseOnly), true, JSON.stringify(validate.errors));
const multiTargetUpdate = {
status: 'agent_parse_passed',
blockers: [],
operation: {
action: 'order_update_independent',
order_nature: 'formal',
submit_mode: 'dry_run',
data: {
existing_refs: { kind: 'independent_order' },
customer: { name: '衡阳国旅广东', keyword: '衡阳国旅广东' },
departure_dates: ['2026-09-03'],
product: { name: '老挝好时光', keyword: '老挝好时光' },
updates: {
actions: [
{ target: 'pax.adult', operation: 'set', value: 8 },
{ target: 'pax.child_bed', operation: 'set', value: 1 },
{ target: 'pax.leader', operation: 'set', value: 1 }
]
}
}
}
};
assert.equal(validate(multiTargetUpdate), true, JSON.stringify(validate.errors));
const businessLookup = structuredClone(parseOnly);
businessLookup.operation.data = {
customer: { name: '辽宁康辉', keyword: '辽宁康辉' },
departure_dates: ['2026-09-03'],
product: { name: '遇见老挝', keyword: '遇见老挝' },
leader: { name: '张三', keyword: '张三' }
};
assert.equal(validate(businessLookup), true, JSON.stringify(validate.errors));
const executionMetadata = structuredClone(parseOnly);
executionMetadata.operation.source = { test_context: { run_id: 'not-agent-data' } };
assert.equal(validate(executionMetadata), false);
assert.match(JSON.stringify(validate.errors), /additionalProperties/);
});
test('template fields are marked and examples contain only concrete values', () => {
for (const section of operationSections) {
const template = labeledBlock(section, '模板');
const example = labeledBlock(section, '示例');
const fields = fieldLines(template);
assert.ok(fields.length > 0, `${section.directive}: expected at least one field`);
assert.ok(fields.some((line) => line.includes('(必填):')), `${section.directive}: no required field`);
for (const line of fields) {
assert.match(line, /(必填|选填):/, `${section.directive}: unmarked template field: ${line}`);
}
assert.doesNotMatch(example, /(必填|选填)/, `${section.directive}: example contains field rules`);
assert.doesNotMatch(example, /<[^>]+>/, `${section.directive}: example contains a placeholder`);
}
});
test('user templates never request ERP internal identifiers and use canonical customer label', () => {
const forbiddenLabels = /^(?:母团\s*)?(?:tid|ddid|row_id|行\s*ID|账号|运行编号|测试标记|allowlist)((?:必填|选填)):/im;
for (const section of operationSections) {
const template = labeledBlock(section, '模板');
assert.doesNotMatch(template, forbiddenLabels);
assert.doesNotMatch(template, /^预定客户((?:必填|选填)):/m, 'template must use 预订客户');
}
});
test('all Agent directives are routed to their Skill contract in the same two-block format', () => {
for (const directive of agentDirectives) {
assert.ok(registry.includes(`\`${directive}\``), `registry missing ${directive}`);
}
for (const [relativePath, directives] of Object.entries(skillContracts)) {
assertTwoBlockContract(read(relativePath), directives, relativePath);
}
});
test('shared-child batch stays Program-only while remaining copyable from the operator catalog', () => {
const directive = '散拼团多个新增子单';
const section = sectionByDirective.get(directive);
assert.ok(section, `${directive}: missing operator template`);
assert.match(catalog, /`散拼团多个新增子单`固定走程序解析,不走 AI/);
assert.doesNotMatch(registry, /^\|[^\n]*`散拼团多个新增子单`[^\n]*\|$/m);
assert.equal(skillRuntimeText.includes('shared_child_order_batch_create'), false);
assert.equal(mainPrompt.includes('shared_child_order_batch_create'), false);
});
test('the four detailed business pages use adjacent template and example blocks', () => {
for (const [relativePath, directive] of Object.entries(businessPages)) {
const document = read(relativePath);
const blocks = textBlocks(document).filter((block) => block.startsWith(`${directive}\n`));
assert.equal(blocks.length, 2, `${relativePath}: expected one template and one example`);
assert.match(document, /### 1\.1 模板\r?\n/);
assert.match(document, /### 1\.2 示例\r?\n/);
for (const line of fieldLines(blocks[0])) {
assert.match(line, /(必填|选填):/, `${relativePath}: unmarked template field: ${line}`);
}
assert.doesNotMatch(blocks[1], /(必填|选填)|<[^>]+>/);
}
});
test('the two desktop-approved examples remain exactly copyable', () => {
const independentSingle = [
'独立团单个下单',
'发团日期:11-01',
'预订客户:老挝联泰人民币',
'产品搜索:遇见老挝',
'预估人数:15+1',
'用房数量:8标1单'
].join('\n');
const sharedPlan = [
'散拼团新增计划',
'发团日期:11-01到11-30',
'产品搜索:广东衡阳6D',
'计划收客数:40',
'用房数量:8标1单',
'发团周期:11.01/11.15/11.26'
].join('\n');
assert.equal(labeledBlock(sectionByDirective.get('独立团单个下单'), '示例'), independentSingle);
assert.equal(labeledBlock(sectionByDirective.get('散拼团新增计划'), '示例'), sharedPlan);
});
test('every operation preserves its explicit minimum-required field set', () => {
for (const [directive, expectedLabels] of Object.entries(minimumLabels)) {
const template = labeledBlock(sectionByDirective.get(directive), '模板');
const actualLabels = fieldLines(template)
.filter((line) => line.includes('(必填):'))
.map((line) => line.slice(0, line.indexOf('(必填):')));
assert.deepEqual(actualLabels, expectedLabels, `${directive} minimum-required fields changed`);
}
for (const directive of ['导入独立团名单', '导入散拼子单名单']) {
const section = sectionByDirective.get(directive);
assert.match(section.body, /名单附件(必填):[\s\S]*`\.xls` 或 `\.xlsx`/);
assert.doesNotMatch(labeledBlock(section, '模板'), /名单内容/);
}
});
test('modification input asks only for targets and export stays minimal', () => {
const controlPlaneReadme = read('control-plane/README.md');
const releaseGate = read('agent设计规范/test-fixtures/lwlt-lifecycle/release-gate.md');
assert.doesNotMatch(catalog, /清理测试订单/);
assert.doesNotMatch(registry, /\| `清理测试订单` \|/);
const forbiddenPreviousValueField = /^(?:修改前[^(]*|原值|当前值|当前安排|当前收结状态)((?:必填|选填)):/m;
for (const directive of [
'修改散拼母团计划收客数',
'追加散拼子单订房说明',
'独立团信息修改',
'安排变更'
]) {
assert.doesNotMatch(labeledBlock(sectionByDirective.get(directive), '模板'), forbiddenPreviousValueField);
}
assert.deepEqual(minimumLabels['导出团队文件'], ['预订客户', '出发日期', '文件类型']);
assert.doesNotMatch(catalog, /^### \d+\. 读取团队文件源响应$/m);
assert.doesNotMatch(catalog, /^读取团队文件源响应$/m);
assert.match(catalog, /游客名单返回 `\.xlsx`;其他文件通常返回 PDF/);
assert.doesNotMatch(catalog, /before_snapshot|AgentBus|OSS|object key|updates\.actions/);
assert.match(controlPlaneReadme, /游客信息保留原始 ERP `\.xls` 作为源归档,同时由平台通过 LibreOffice 生成真实 `\.xlsx`/);
assert.match(releaseGate, /系统自行读取当前值并形成 before\/after/);
});
test('team-file export uses the business name while keeping the internal action stable', () => {
const confirmationContract = read('agent设计规范/skills/lwlt-confirmation/references/input-contract.md');
const platformUi = read('LianSyn-platform/app.js');
const pluginPlanner = read('chrome-extension/ltjt-order-assistant/operation-plans.js');
assert.ok(sectionByDirective.has('导出团队文件'));
assert.match(confirmationContract, /# 导出团队文件输入契约/);
assert.match(read('agent设计规范/skills/lwlt-confirmation/SKILL.md'), /confirmation_export operation JSON/);
assert.ok(registry.includes('| `导出团队文件` | `lwlt-confirmation` | `confirmation_export` |'));
assert.match(platformUi, /confirmation_export: '导出团队文件'/);
assert.match(pluginPlanner, /confirmation_export:\s*{[\s\S]*?label: '导出团队文件'/);
assert.match(catalog, /文件类型(必填):[\s\S]*整团游客信息/);
assert.match(catalog, /整团游客信息.*散拼母团/);
assert.match(catalog, /游客名单.*独立团.*散拼具体子单/);
assert.match(confirmationContract, /`整团游客信息`.*散拼母团/);
assert.match(confirmationContract, /`游客名单`.*独立团.*散拼具体子单/);
assert.match(pluginPlanner, /validateSharedPlanVisitorExport/);
assert.doesNotMatch(platformUi, /确认件源读取\/恢复/);
assert.doesNotMatch(pluginPlanner, /label: '确认件源读取\/恢复'/);
});
test('independent-order updates preserve broad parser targets while arrangement changes stay narrow', () => {
const parser = read('LianSyn-platform/external-agent-client.mjs');
const updatingSkill = read('agent设计规范/skills/lwlt-updating/SKILL.md');
const arrangementSkill = read('agent设计规范/skills/lwlt-arrangement/SKILL.md');
const modificationSection = catalog.slice(catalog.indexOf('## 五、修改'), catalog.indexOf('## 六、取消与恢复'));
for (const directive of narrowModificationDirectives) {
assert.ok(modificationSection.includes(`### ${sectionByDirective.get(directive).number}. ${directive}`));
assert.ok(registry.includes(`\`${directive}\``));
}
assert.match(updatingSkill, /只提取目标值,不询问、不生成当前值、原值或 before\/after/);
assert.match(updatingSkill, /保留业务可见目标/);
assert.match(arrangementSkill, /安排变更只接受酒店离店日期和\/或目标房间总数/);
assert.match(parser, /修改类只提取目标值/);
assert.match(parser, /updates\.actions 包含多个动作/);
assert.match(parser, /new Set\(\['end_date', 'room_count'\]\)/);
assert.match(labeledBlock(sectionByDirective.get('独立团信息修改'), '示例'), /目标修改:人数改为8\+1(占)\+1/);
assert.match(updatingSkill, /兼容旧三段 `人数改为8\+1(占)\+1`/);
assert.match(updatingSkill, /pax\.child_no_bed=1/);
assert.match(sectionByDirective.get('独立团信息修改')?.body ?? '', /未出现的类别保持原值/);
assert.match(labeledBlock(sectionByDirective.get('安排变更'), '示例'), /离店日期改为11-05;房间数改为15间/);
assert.doesNotMatch(catalog, /需求登记:/);
});
test('deferred items 20 to 22 are absent from current user and Skill input contracts', () => {
const currentInputSurface = [
catalog,
...Object.keys(skillContracts).map((relativePath) => read(relativePath))
].join('\n');
for (const directive of deferredDirectives) {
assert.doesNotMatch(currentInputSurface, new RegExp(directive));
}
assert.doesNotMatch(catalog, /^### (?:20|21|22)\./m);
});
test('new business adaptation template enforces the two-block writing convention', () => {
const template = read('agent设计规范/templates/business-adaptation-template.md');
assert.match(template, /#### 模板\r?\n/);
assert.match(template, /#### 示例\r?\n/);
assert.match(template, /模板和示例必须紧邻出现/);
assert.match(template, /逐字段标明`(必填)\/(选填)`/);
});