854 lines
39 KiB
TypeScript
854 lines
39 KiB
TypeScript
import assert from 'node:assert/strict';
|
||
import { readFile } from 'node:fs/promises';
|
||
import { performance } from 'node:perf_hooks';
|
||
import test from 'node:test';
|
||
import { BUSINESS_ROUTES, resolveBusinessRoute, resolveRouteFieldLabel } from '../src/business-routes.js';
|
||
import { compareParserResults, parserFieldDifferences } from '../src/parser-contract.js';
|
||
import { parseProgramInput, programFallbackAllowed } from '../src/program-parser.js';
|
||
import { ParserOrchestrator } from '../src/parser-orchestrator.js';
|
||
|
||
const receivedAt = '2026-08-25T09:00:00+08:00';
|
||
const headers = '序号\t姓名\tNAME\t性别\t出生日期\t出生地\t证件类型\t证件号码\t签发地\t签发日\t有效期\t电话\t备注';
|
||
const row = '1\t测试游客\tTEST USER\t男\t1990-01-01\t湖南\t护照\tE90000001\t中国\t2025-01-01\t2035-01-01\t13800000001\t合成示例';
|
||
|
||
const routeCases: Array<{ routeId: string; action: string; input: string }> = [
|
||
{
|
||
routeId: 'team_order_create', action: 'team_order_create', input: `独立团单个下单
|
||
发团日期:09-03
|
||
预订客户:老挝联泰人名币
|
||
产品搜索:遇见老挝
|
||
预估人数:15+1
|
||
用房数量:8标1单`
|
||
},
|
||
{
|
||
routeId: 'team_order_batch_create', action: 'team_order_batch_create', input: `独立团批量下单
|
||
发团日期:09-01到09-30
|
||
预订客户:衡阳广东
|
||
产品搜索:老挝好时光
|
||
预估人数:15+1
|
||
用房数量:8标1单
|
||
发团周期:09.03/09.05/09.30`
|
||
},
|
||
{
|
||
routeId: 'shared_plan_create', action: 'shared_plan_create', input: `散拼团新增计划
|
||
发团日期:09-01到09-30
|
||
产品搜索:广东衡阳6D
|
||
计划收客数:40
|
||
用房数量:8标1单
|
||
发团周期:09.07/09.15/09.26`
|
||
},
|
||
{
|
||
routeId: 'shared_child_order_create', action: 'shared_child_order_create', input: `散拼团单个新增子单
|
||
预订客户:辽宁康辉
|
||
出发日期:2026-09-15
|
||
产品名称:老挝广东8D
|
||
领队:张三
|
||
人数:15+1`
|
||
},
|
||
{
|
||
routeId: 'shared_child_order_batch_create', action: 'shared_child_order_batch_create', input: `散拼团多个新增子单
|
||
预订客户:LW衡阳国旅云南分社(广东市场)
|
||
出发日期:10-06到11-03
|
||
产品名称:老挝行程--广东
|
||
领队:张三
|
||
人数:20+1`
|
||
},
|
||
{
|
||
routeId: 'passenger_list_import_independent', action: 'passenger_list_import', input: `导入独立团名单
|
||
预订客户:辽宁康辉
|
||
出发日期:2026-09-03
|
||
名单内容:
|
||
${headers}
|
||
${row}`
|
||
},
|
||
{
|
||
routeId: 'passenger_list_import_shared_child', action: 'passenger_list_import', input: `导入散拼子单名单
|
||
单号:D26091501
|
||
名单内容:
|
||
${headers}
|
||
${row}
|
||
覆盖确认:是`
|
||
},
|
||
{
|
||
routeId: 'arrangement_guide_create', action: 'arrangement_guide', input: `安排导游
|
||
团号:LW-260903-A
|
||
导游搜索:李明
|
||
备注:首日机场接团`
|
||
},
|
||
{
|
||
routeId: 'arrangement_vehicle_create', action: 'arrangement_vehicle', input: `安排用车
|
||
团号:LW-260903-A
|
||
用车日期:09-03到09-15
|
||
结算单位搜索:380
|
||
数量:1`
|
||
},
|
||
{
|
||
routeId: 'arrangement_hotel_create', action: 'arrangement_hotel', input: `安排酒店
|
||
团号:LW-260903-A
|
||
入住日期:09-03
|
||
离店日期:09-09
|
||
酒店搜索:万荣 龙吟阁
|
||
间数:8`
|
||
},
|
||
{
|
||
routeId: 'arrangement_transport_create', action: 'arrangement_transport', input: `安排大交通
|
||
团号:LW-260903-A
|
||
结算单位搜索:老挝航空
|
||
数量:16
|
||
交通日期:09-03`
|
||
},
|
||
{
|
||
routeId: 'arrangement_other_create', action: 'arrangement_other', input: `安排其他/备案
|
||
团号:LW-260903-A
|
||
业务日期:09-03
|
||
结算单位搜索:美女
|
||
数量:15`
|
||
},
|
||
{
|
||
routeId: 'order_update_shared_plan', action: 'order_update_shared_plan', input: `修改散拼母团计划收客数
|
||
出发日期:2026-09-15
|
||
产品名称:老挝广东8D
|
||
目标计划收客数:45`
|
||
},
|
||
{
|
||
routeId: 'order_update_shared_child', action: 'order_update_shared_child', input: `追加散拼子单订房说明
|
||
预订客户:辽宁康辉
|
||
出发日期:2026-09-15
|
||
追加内容:领队单住`
|
||
},
|
||
{
|
||
routeId: 'order_update_independent', action: 'order_update_independent', input: `独立团信息修改
|
||
预订客户:辽宁康辉
|
||
出发日期:2026-09-03
|
||
目标修改:人数改为8+1(占)+1;用房数量改为3SGL+2TWN`
|
||
},
|
||
{
|
||
routeId: 'arrangement_hotel_update', action: 'arrangement_hotel', input: `安排变更
|
||
团号:LW-260903-A
|
||
目标变更:离店日期改为09-13;房间数改为9间
|
||
安排类型:酒店`
|
||
},
|
||
{
|
||
routeId: 'order_cancel', action: 'order_cancel', input: `取消订单
|
||
预订客户:辽宁康辉
|
||
出发日期:2026-09-03`
|
||
},
|
||
{
|
||
routeId: 'order_restore', action: 'order_restore', input: `恢复订单
|
||
单号:LW-260903-A
|
||
恢复状态:预订`
|
||
},
|
||
{
|
||
routeId: 'confirmation_export', action: 'confirmation_export', input: `导出团队文件
|
||
预订客户:辽宁康辉
|
||
出发日期:2026-09-03
|
||
文件类型:联泰客户确认单`
|
||
},
|
||
{ routeId: 'arrangement_guide_update', action: 'arrangement_guide', input: '安排导游变更\n团号:LW-TEST\n导游搜索:测试导游' },
|
||
{ routeId: 'arrangement_vehicle_update', action: 'arrangement_vehicle', input: '安排用车变更\n团号:LW-TEST\n用车日期:2026-09-15到2026-09-16\n结算单位搜索:测试车队\n数量:2' },
|
||
{ routeId: 'arrangement_transport_update', action: 'arrangement_transport', input: '安排大交通变更\n团号:LW-TEST\n交通日期:2026-09-15\n结算单位搜索:测试航空\n数量:2' },
|
||
{ routeId: 'arrangement_other_update', action: 'arrangement_other', input: '安排其他/备案变更\n团号:LW-TEST\n业务日期:2026-09-15\n结算单位搜索:测试备案' }
|
||
];
|
||
|
||
test('the machine registry contains the 23 independent routes exactly once', () => {
|
||
assert.equal(BUSINESS_ROUTES.length, 23);
|
||
assert.equal(new Set(BUSINESS_ROUTES.map((route) => route.routeId)).size, 23);
|
||
assert.equal(new Set(BUSINESS_ROUTES.map((route) => route.directive)).size, 23);
|
||
assert.deepEqual(BUSINESS_ROUTES.map((route) => route.routeId), routeCases.map((route) => route.routeId));
|
||
assert.deepEqual(
|
||
[...BUSINESS_ROUTES].sort((left, right) => left.releaseOrder - right.releaseOrder).map((route) => route.releaseOrder),
|
||
Array.from({ length: 23 }, (_, index) => index + 1)
|
||
);
|
||
assert.equal(BUSINESS_ROUTES.find((route) => route.releaseOrder === 1)?.routeId, 'confirmation_export');
|
||
assert.equal(BUSINESS_ROUTES.find((route) => route.releaseOrder === 18)?.routeId, 'order_update_independent');
|
||
assert.equal(BUSINESS_ROUTES.find((route) => route.releaseOrder === 19)?.routeId, 'shared_child_order_batch_create');
|
||
});
|
||
|
||
test('machine routes stay synchronized with their declared Program or Agent boundary', async () => {
|
||
const [template, registry, initialMigration, batchMigration, updateMigration] = await Promise.all([
|
||
readFile(new URL('../../agent设计规范/templates/business-input-templates.md', import.meta.url), 'utf8'),
|
||
readFile(new URL('../../agent设计规范/business-adaptation-registry.md', import.meta.url), 'utf8'),
|
||
readFile(new URL('../migrations/013_business_parser_modes.sql', import.meta.url), 'utf8'),
|
||
readFile(new URL('../migrations/022_shared_child_order_batch_create.sql', import.meta.url), 'utf8'),
|
||
readFile(new URL('../migrations/024_arrangement_update_routes.sql', import.meta.url), 'utf8')
|
||
]);
|
||
for (const route of BUSINESS_ROUTES) {
|
||
const directivePattern = new RegExp(route.directive.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
|
||
const actionPattern = new RegExp(route.action.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
|
||
assert.match(template, directivePattern, route.routeId);
|
||
if (route.routeId === 'shared_child_order_batch_create') {
|
||
assert.doesNotMatch(registry, actionPattern, 'Program-only batch route must not be added to the Agent adaptation registry');
|
||
} else {
|
||
assert.match(registry, actionPattern, route.routeId);
|
||
}
|
||
assert.match(`${initialMigration}\n${batchMigration}\n${updateMigration}`, new RegExp(`'${route.routeId}'`), route.routeId);
|
||
}
|
||
});
|
||
|
||
test('versioned sanitized gold corpus replays deterministically', async () => {
|
||
const corpus = JSON.parse(await readFile(
|
||
new URL('../../samples/business-parser-gold-corpus.json', import.meta.url), 'utf8'
|
||
));
|
||
assert.equal(corpus.version, 'ltjt-program-parser-v1.0.13');
|
||
assert.ok(corpus.cases.length >= 28);
|
||
for (const candidate of corpus.cases) {
|
||
const parse = () => parseProgramInput({
|
||
rawText: candidate.input,
|
||
receivedAt: corpus.received_at,
|
||
routeId: candidate.route_id,
|
||
history: (candidate.history || []).map((text: string) => ({ text, receivedAt: corpus.received_at }))
|
||
});
|
||
const [first, second] = await Promise.all([parse(), parse()]);
|
||
assert.deepEqual(first, second, `${candidate.id} must replay identically`);
|
||
assert.equal(first.program_parser_version, corpus.version, `${candidate.id} parser version`);
|
||
assert.equal(first.status, candidate.expected_status, `${candidate.id}: ${JSON.stringify(first)}`);
|
||
if (candidate.expected_action) assert.equal((first.operation as any)?.action, candidate.expected_action, candidate.id);
|
||
if (candidate.expected_error_code) assert.equal(first.error_code, candidate.expected_error_code, candidate.id);
|
||
}
|
||
});
|
||
|
||
test('all 23 official-format examples produce contract-valid operations', async () => {
|
||
for (const candidate of routeCases) {
|
||
const result = await parseProgramInput({ rawText: candidate.input, receivedAt });
|
||
assert.equal(result.business_route_id, candidate.routeId, candidate.routeId);
|
||
assert.equal(result.status, 'agent_parse_passed', `${candidate.routeId}: ${JSON.stringify(result)}`);
|
||
assert.equal((result.operation as Record<string, unknown>).action, candidate.action, candidate.routeId);
|
||
}
|
||
});
|
||
|
||
test('shared-child batch input compiles the inclusive cross-month range without exposing an Agent action', async () => {
|
||
const result = await parseProgramInput({ rawText: routeCases[4].input, receivedAt });
|
||
assert.equal(result.status, 'agent_parse_passed', JSON.stringify(result));
|
||
assert.deepEqual(result.operation, {
|
||
action: 'shared_child_order_batch_create',
|
||
order_nature: 'formal',
|
||
submit_mode: 'dry_run',
|
||
data: {
|
||
customer: { name: 'LW衡阳国旅云南分社(广东市场)', keyword: 'LW衡阳国旅云南分社(广东市场)' },
|
||
product: { name: '老挝行程--广东', keyword: '老挝行程--广东' },
|
||
leader: { name: '张三', keyword: '张三' },
|
||
recurrence: { start_date: '2026-10-06', end_date: '2026-11-03' },
|
||
passenger_counts: { adult: 20, leader: 1 }
|
||
}
|
||
});
|
||
});
|
||
|
||
test('passenger TSV preserves 13 columns when the final passenger has empty optional fields', async () => {
|
||
for (const route of [routeCases[5], routeCases[6]]) {
|
||
for (const emptyPhone of [false, true]) {
|
||
const rows = Array.from({ length: 19 }, (_, i) => {
|
||
const cells = row.split('\t');
|
||
cells[0] = String(i + 1);
|
||
cells[12] = i === 0 ? '领队' : '';
|
||
if (emptyPhone && i === 18) cells[11] = '';
|
||
return cells.join('\t');
|
||
});
|
||
const text = `${headers}\n${rows.join('\n')}`;
|
||
for (const crlf of [false, true]) {
|
||
const input = `${route.input.replace(row, rows.join('\n'))}\n\n`;
|
||
const result = await parseProgramInput({ rawText: crlf ? input.replace(/\n/g, '\r\n') : input, receivedAt });
|
||
assert.equal(result.status, 'agent_parse_passed', JSON.stringify(result));
|
||
const roster = (result.operation as any).data.passenger_list;
|
||
assert.equal(roster.row_count, 19);
|
||
assert.equal(roster.text, text);
|
||
assert.deepEqual(roster.leader_contact, { sequence: 1, name: '测试游客', phone: '13800000001' });
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
test('passenger TSV rejects actual 12-column and 14-column data rows', async () => {
|
||
const cells = row.split('\t');
|
||
for (const malformed of [cells.slice(0, 12).join('\t'), `${cells.join('\t')}\t`]) {
|
||
const result = await parseProgramInput({ rawText: routeCases[5].input.replace(row, malformed), receivedAt });
|
||
assert.equal(result.status, 'agent_parse_needs_input');
|
||
assert.equal(result.error_code, 'program_invalid_value');
|
||
assert.match(String(result.reply), /13 列/);
|
||
}
|
||
});
|
||
|
||
test('passenger TSV promotes only an exact leader remark into structured ERP contact data', async () => {
|
||
const leaderRow = '1\t测试领队\tLEADER TEST\t男\t1980-01-01\t湖南\t护照\tE90000001\t中国\t2025-01-01\t2035-01-01\t13800000001\t 领队 ';
|
||
const exact = await parseProgramInput({
|
||
rawText: routeCases[5].input.replace(row, leaderRow),
|
||
receivedAt
|
||
});
|
||
assert.equal(exact.status, 'agent_parse_passed', JSON.stringify(exact));
|
||
assert.deepEqual((exact.operation as any).data.passenger_list.leader_contact, {
|
||
sequence: 1,
|
||
name: '测试领队',
|
||
phone: '13800000001'
|
||
});
|
||
|
||
const ordinaryRemark = await parseProgramInput({
|
||
rawText: routeCases[5].input.replace(row, leaderRow.replace('\t 领队 ', '\t领队备注')),
|
||
receivedAt
|
||
});
|
||
assert.equal(ordinaryRemark.status, 'agent_parse_passed', JSON.stringify(ordinaryRemark));
|
||
assert.equal((ordinaryRemark.operation as any).data.passenger_list.leader_contact, undefined);
|
||
});
|
||
|
||
test('passenger TSV rejects duplicate leaders or a leader without both name and phone', async () => {
|
||
const leader = '1\t测试领队甲\tLEADER ONE\t男\t1980-01-01\t湖南\t护照\tE90000001\t中国\t2025-01-01\t2035-01-01\t13800000001\t领队';
|
||
const duplicate = '2\t测试领队乙\tLEADER TWO\t女\t1981-01-01\t湖南\t护照\tE90000002\t中国\t2025-01-01\t2035-01-01\t13800000002\t领队';
|
||
const duplicated = await parseProgramInput({
|
||
rawText: routeCases[5].input.replace(row, `${leader}\n${duplicate}`),
|
||
receivedAt
|
||
});
|
||
assert.equal(duplicated.status, 'agent_parse_needs_input');
|
||
assert.equal(duplicated.error_code, 'program_invalid_value');
|
||
assert.match(String(duplicated.reply), /只能标记一位领队/);
|
||
|
||
for (const invalidRow of [
|
||
leader.replace('\t测试领队甲\t', '\t\t'),
|
||
leader.replace('\t13800000001\t领队', '\t\t领队')
|
||
]) {
|
||
const invalid = await parseProgramInput({
|
||
rawText: routeCases[5].input.replace(row, invalidRow),
|
||
receivedAt
|
||
});
|
||
assert.equal(invalid.status, 'agent_parse_needs_input');
|
||
assert.equal(invalid.error_code, 'program_invalid_value');
|
||
assert.match(String(invalid.reply), /姓名和电话/);
|
||
}
|
||
});
|
||
|
||
test('independent room updates accept DBL shorthand without dropping other intentions', async () => {
|
||
for (const target of [
|
||
'房间数更改为 12 标+2 大', '用房数量改为12标+2大床',
|
||
'房型数量修改为12TWN+2DBL', '房间调整为12标间+2大床房'
|
||
]) {
|
||
const result = await parseProgramInput({ rawText: `独立团信息修改\n预订客户:测试客户\n出发日期:2026-09-16\n目标修改:${target}`, receivedAt });
|
||
assert.equal(result.status, 'agent_parse_passed', JSON.stringify(result));
|
||
assert.deepEqual((result.operation as any).data.updates.actions, [
|
||
{ target: 'rooms.TWN', operation: 'set', value: 12 },
|
||
{ target: 'rooms.DBL', operation: 'set', value: 2 }
|
||
]);
|
||
}
|
||
const combined = await parseProgramInput({ rawText: '独立团信息修改\n预订客户:测试客户\n出发日期:2026-09-16\n目标修改:房间数更改为12标+2大,成人更改为28', receivedAt });
|
||
assert.equal(combined.status, 'agent_parse_passed');
|
||
assert.equal((combined.operation as any).data.updates.actions[2].value, 28);
|
||
for (const target of ['房间数更改为12', '房间数更改为12标+2大楼', '房间数更改为12标+2大+1DBL', '大床房数改为-1', '房间数更改为12标+2大;DBL改为3']) {
|
||
const result = await parseProgramInput({ rawText: `独立团信息修改\n预订客户:测试客户\n出发日期:2026-09-16\n目标修改:${target}`, receivedAt });
|
||
assert.notEqual(result.status, 'agent_parse_passed', target);
|
||
assert.equal(result.operation, null);
|
||
}
|
||
const clear = await parseProgramInput({ rawText: '独立团信息修改\n预订客户:测试客户\n出发日期:2026-09-16\n目标修改:大床房数更改为0', receivedAt });
|
||
assert.equal(clear.status, 'agent_parse_passed');
|
||
assert.deepEqual((clear.operation as any).data.updates.actions, [{ target: 'rooms.DBL', operation: 'set', value: 0 }]);
|
||
});
|
||
|
||
test('independent headcount distributions keep occupied and non-occupied children distinct', async () => {
|
||
const parseHeadcount = (value: string) => parseProgramInput({
|
||
rawText: routeCases[14].input.replace(
|
||
'人数改为8+1(占)+1;用房数量改为3SGL+2TWN',
|
||
`人数改为${value}`
|
||
),
|
||
receivedAt
|
||
});
|
||
|
||
const reported = await parseHeadcount('8+1(占)+1(不占)+1');
|
||
assert.equal(reported.status, 'agent_parse_passed', JSON.stringify(reported));
|
||
assert.deepEqual((reported.operation as any).data.updates.actions, [
|
||
{ target: 'pax.adult', operation: 'set', value: 8 },
|
||
{ target: 'pax.child_bed', operation: 'set', value: 1 },
|
||
{ target: 'pax.child_no_bed', operation: 'set', value: 1 },
|
||
{ target: 'pax.leader', operation: 'set', value: 1 }
|
||
]);
|
||
|
||
const reversedLabels = await parseHeadcount('8+2(不占)+3(占)+1');
|
||
assert.equal(reversedLabels.status, 'agent_parse_passed', JSON.stringify(reversedLabels));
|
||
assert.deepEqual(
|
||
Object.fromEntries((reversedLabels.operation as any).data.updates.actions.map((item: any) => [item.target, item.value])),
|
||
{ 'pax.adult': 8, 'pax.child_no_bed': 2, 'pax.child_bed': 3, 'pax.leader': 1 }
|
||
);
|
||
|
||
const legacyPositional = await parseHeadcount('8+1+2+1');
|
||
assert.equal(legacyPositional.status, 'agent_parse_passed', JSON.stringify(legacyPositional));
|
||
assert.deepEqual((legacyPositional.operation as any).data.updates.actions, [
|
||
{ target: 'pax.adult', operation: 'set', value: 8 },
|
||
{ target: 'pax.child_bed', operation: 'set', value: 1 },
|
||
{ target: 'pax.child_no_bed', operation: 'set', value: 2 },
|
||
{ target: 'pax.leader', operation: 'set', value: 1 }
|
||
]);
|
||
|
||
const taggedWithInfant = await parseHeadcount('8+1(占)+2(不占)+0+1');
|
||
assert.equal(taggedWithInfant.status, 'agent_parse_passed', JSON.stringify(taggedWithInfant));
|
||
assert.deepEqual((taggedWithInfant.operation as any).data.updates.actions, [
|
||
{ target: 'pax.adult', operation: 'set', value: 8 },
|
||
{ target: 'pax.child_bed', operation: 'set', value: 1 },
|
||
{ target: 'pax.child_no_bed', operation: 'set', value: 2 },
|
||
{ target: 'pax.infant', operation: 'set', value: 0 },
|
||
{ target: 'pax.leader', operation: 'set', value: 1 }
|
||
]);
|
||
|
||
const legacyNonBed = await parseHeadcount('8+2(不占)+1');
|
||
assert.equal(legacyNonBed.status, 'agent_parse_passed', JSON.stringify(legacyNonBed));
|
||
assert.deepEqual((legacyNonBed.operation as any).data.updates.actions, [
|
||
{ target: 'pax.adult', operation: 'set', value: 8 },
|
||
{ target: 'pax.child_no_bed', operation: 'set', value: 2 },
|
||
{ target: 'pax.leader', operation: 'set', value: 1 }
|
||
]);
|
||
|
||
for (const invalid of [
|
||
'8+1(占)+2(占)+1',
|
||
'8+1(占)+2+1',
|
||
'+8+1(占)+2(不占)+1',
|
||
'8++1(占)+2(不占)+1',
|
||
'8+1(占)+2(不占)+1+',
|
||
'9007199254740993+1(占)+2(不占)+1',
|
||
'成人9007199254740993+领队1',
|
||
'0+0+0+0'
|
||
]) {
|
||
const result = await parseHeadcount(invalid);
|
||
assert.equal(result.status, 'agent_parse_needs_input', `${invalid}: ${JSON.stringify(result)}`);
|
||
assert.equal(result.error_code, 'program_invalid_value', invalid);
|
||
assert.equal(programFallbackAllowed(result), false, invalid);
|
||
assert.equal(result.operation, null, invalid);
|
||
assert.equal(result.no_erp_write, true, invalid);
|
||
assert.equal(result.no_plugin_dispatch, true, invalid);
|
||
}
|
||
});
|
||
|
||
test('registered customer typo is normalized only in name while keyword preserves source', async () => {
|
||
const result = await parseProgramInput({ rawText: routeCases[0].input, receivedAt });
|
||
const customer = ((result.operation as any).data.customer);
|
||
assert.deepEqual(customer, { name: '老挝联泰人民币', keyword: '老挝联泰人名币' });
|
||
});
|
||
|
||
test('route and field labels allow only a unique one-character correction', async () => {
|
||
assert.equal(resolveBusinessRoute('导出团队文伴').routeId, 'confirmation_export');
|
||
const route = BUSINESS_ROUTES.find((candidate) => candidate.routeId === 'confirmation_export')!;
|
||
assert.equal(resolveRouteFieldLabel(route, '文件类形').field?.key, 'export_type');
|
||
const result = await parseProgramInput({
|
||
rawText: `导出团队文伴\n预订客户:辽宁康辉\n出发日期:2026-09-03\n文件类形:联泰客户确认单`,
|
||
receivedAt
|
||
});
|
||
assert.equal(result.status, 'agent_parse_passed');
|
||
});
|
||
|
||
test('route resolution is source-agnostic for embedded directives and complete unique field signatures', async () => {
|
||
const wrapped = `AgentBus 转发的结构化业务块\n${routeCases[0].input}`;
|
||
const wrappedResolution = resolveBusinessRoute(wrapped);
|
||
assert.equal(wrappedResolution.routeId, 'team_order_create');
|
||
assert.equal(wrappedResolution.match, 'exact');
|
||
|
||
const wrappedProgram = await parseProgramInput({ rawText: wrapped, receivedAt });
|
||
assert.equal(wrappedProgram.business_route_id, 'team_order_create');
|
||
assert.equal(wrappedProgram.status, 'agent_parse_blocked');
|
||
assert.equal(wrappedProgram.error_code, 'program_unsupported_syntax');
|
||
|
||
const fieldOnly = routeCases[0].input.split('\n').slice(1).join('\n');
|
||
const signatureResolution = resolveBusinessRoute(fieldOnly);
|
||
assert.equal(signatureResolution.routeId, 'team_order_create');
|
||
assert.equal(signatureResolution.match, 'signature');
|
||
const fieldOnlyProgram = await parseProgramInput({ rawText: fieldOnly, receivedAt });
|
||
assert.equal(fieldOnlyProgram.status, 'agent_parse_passed');
|
||
assert.equal(fieldOnlyProgram.business_route_id, 'team_order_create');
|
||
|
||
const ambiguousLookup = `预订客户:辽宁康辉\n出发日期:2026-09-03`;
|
||
assert.deepEqual(
|
||
{ routeId: resolveBusinessRoute(ambiguousLookup).routeId, match: resolveBusinessRoute(ambiguousLookup).match },
|
||
{ routeId: null, match: 'ambiguous' }
|
||
);
|
||
assert.equal(resolveBusinessRoute('文件类型:联泰客户确认单').routeId, null);
|
||
});
|
||
|
||
test('unknown labels are eligible for auto fallback, but invalid values and conflicts are not', async () => {
|
||
const unknown = await parseProgramInput({ rawText: `${routeCases[0].input}\n内部编号:123`, receivedAt });
|
||
assert.equal(unknown.error_code, 'program_unknown_field');
|
||
assert.equal(programFallbackAllowed(unknown), true);
|
||
|
||
const invalid = await parseProgramInput({ rawText: routeCases[0].input.replace('发团日期:09-03', '发团日期:2026-02-30'), receivedAt });
|
||
assert.equal(invalid.status, 'agent_parse_needs_input');
|
||
assert.equal(invalid.error_code, 'program_invalid_value');
|
||
assert.equal(programFallbackAllowed(invalid), false);
|
||
|
||
const conflict = await parseProgramInput({ rawText: `${routeCases[0].input}\n用房数量:9标`, receivedAt });
|
||
assert.equal(conflict.status, 'agent_parse_needs_input');
|
||
assert.equal(conflict.error_code, 'program_field_conflict');
|
||
assert.equal(programFallbackAllowed(conflict), false);
|
||
});
|
||
|
||
test('business-value errors stay on the deterministic path instead of becoming contract fallbacks', async () => {
|
||
const badPassengerSyntax = await parseProgramInput({
|
||
rawText: routeCases[0].input.replace('预估人数:15+1', '预估人数:成人15+外星人9'),
|
||
receivedAt
|
||
});
|
||
assert.equal(badPassengerSyntax.error_code, 'program_invalid_value');
|
||
assert.equal(programFallbackAllowed(badPassengerSyntax), false);
|
||
|
||
const oneBatchDate = await parseProgramInput({
|
||
rawText: routeCases[1].input.replace('09.03/09.05/09.30', '09.03'),
|
||
receivedAt
|
||
});
|
||
assert.equal(oneBatchDate.error_code, 'program_invalid_value');
|
||
assert.equal(programFallbackAllowed(oneBatchDate), false);
|
||
|
||
const invalidHotelRange = await parseProgramInput({
|
||
rawText: routeCases[9].input.replace('离店日期:09-09', '离店日期:09-03'),
|
||
receivedAt
|
||
});
|
||
assert.equal(invalidHotelRange.error_code, 'program_invalid_value');
|
||
assert.equal(programFallbackAllowed(invalidHotelRange), false);
|
||
|
||
const longLodgingNote = await parseProgramInput({
|
||
rawText: routeCases[13].input.replace('领队单住', '订房说明'.repeat(20)),
|
||
receivedAt
|
||
});
|
||
assert.equal(longLodgingNote.error_code, 'program_invalid_value');
|
||
assert.equal(programFallbackAllowed(longLodgingNote), false);
|
||
});
|
||
|
||
test('independent updates require an exact registered target name', async () => {
|
||
const unknownTarget = await parseProgramInput({
|
||
rawText: routeCases[14].input.replace(
|
||
'人数改为8+1(占)+1;用房数量改为3SGL+2TWN',
|
||
'人数备注改为8+1'
|
||
),
|
||
receivedAt
|
||
});
|
||
assert.equal(unknownTarget.error_code, 'program_unsupported_syntax');
|
||
assert.equal(programFallbackAllowed(unknownTarget), true);
|
||
|
||
const invalidKnownTarget = await parseProgramInput({
|
||
rawText: routeCases[14].input.replace(
|
||
'人数改为8+1(占)+1;用房数量改为3SGL+2TWN',
|
||
'行程天数改为0'
|
||
),
|
||
receivedAt
|
||
});
|
||
assert.equal(invalidKnownTarget.error_code, 'program_invalid_value');
|
||
assert.equal(programFallbackAllowed(invalidKnownTarget), false);
|
||
});
|
||
|
||
test('later turns overwrite prior fields while an unlabeled supplement fills exactly one missing field', async () => {
|
||
const initial = `安排导游\n团号:LW-260903-A`;
|
||
const completed = await parseProgramInput({ rawText: '李明', receivedAt, routeId: 'arrangement_guide_create', history: [{ text: initial, receivedAt }] });
|
||
assert.equal(completed.status, 'agent_parse_passed');
|
||
assert.equal((completed.operation as any).data.arrangement.resource.name, '李明');
|
||
|
||
const overwritten = await parseProgramInput({
|
||
rawText: '导游搜索:王芳', receivedAt, routeId: 'arrangement_guide_create',
|
||
history: [{ text: `${initial}\n导游搜索:李明`, receivedAt }]
|
||
});
|
||
assert.equal(overwritten.status, 'agent_parse_passed');
|
||
assert.equal((overwritten.operation as any).data.arrangement.resource.name, '王芳');
|
||
|
||
const resolvedConflict = await parseProgramInput({
|
||
rawText: '导游搜索:赵敏', receivedAt, routeId: 'arrangement_guide_create',
|
||
history: [{ text: `${initial}\n导游搜索:李明\n导游搜索:王芳`, receivedAt }]
|
||
});
|
||
assert.equal(resolvedConflict.status, 'agent_parse_passed');
|
||
assert.equal((resolvedConflict.operation as any).data.arrangement.resource.name, '赵敏');
|
||
});
|
||
|
||
test('a second business directive cannot be merged into the same task', async () => {
|
||
const result = await parseProgramInput({
|
||
rawText: routeCases[16].input,
|
||
receivedAt,
|
||
routeId: 'confirmation_export',
|
||
history: [{ text: routeCases[18].input, receivedAt }]
|
||
});
|
||
assert.equal(result.error_code, 'program_multiple_actions');
|
||
assert.equal(programFallbackAllowed(result), false);
|
||
|
||
const sameTurn = await parseProgramInput({
|
||
rawText: `${routeCases[18].input}\n取消订单\n单号:SYNTH-GROUP-001`,
|
||
receivedAt
|
||
});
|
||
assert.equal(sameTurn.error_code, 'program_multiple_actions');
|
||
assert.equal(programFallbackAllowed(sameTurn), false);
|
||
});
|
||
|
||
test('passenger TSV rejects duplicate sequence numbers without changing any value', async () => {
|
||
const duplicate = routeCases[5].input.replace(row, `${row}\n${row}`);
|
||
const result = await parseProgramInput({ rawText: duplicate, receivedAt });
|
||
assert.equal(result.status, 'agent_parse_needs_input');
|
||
assert.equal(result.error_code, 'program_invalid_value');
|
||
assert.match(String((result.questions as any[])[0].prompt), /序号.*重复/);
|
||
});
|
||
|
||
test('all export expands deterministically and pickup sign requires a visitor name', async () => {
|
||
const all = await parseProgramInput({
|
||
rawText: `导出团队文件\n单号:LW-260903-A\n文件类型:全部\n游客姓名:测试游客`, receivedAt
|
||
});
|
||
assert.equal(all.status, 'agent_parse_passed');
|
||
assert.equal((all.operation as any).data.export_types.length, 10);
|
||
|
||
const pickup = await parseProgramInput({ rawText: `导出团队文件\n单号:LW-260903-A\n文件类型:接机牌`, receivedAt });
|
||
assert.equal(pickup.status, 'agent_parse_needs_input');
|
||
assert.deepEqual(pickup.missing_fields, ['visitor_name']);
|
||
});
|
||
|
||
test('whole shared-plan visitor information stays distinct from order and child visitor lists', async () => {
|
||
const wholeGroup = await parseProgramInput({
|
||
rawText: `导出团队文件
|
||
预订客户:LW衡阳国旅云南分公司
|
||
出发日期:2026-11-01
|
||
产品名称:广东衡阳
|
||
文件类型:整团游客信息`,
|
||
receivedAt
|
||
});
|
||
assert.equal(wholeGroup.status, 'agent_parse_passed');
|
||
assert.deepEqual((wholeGroup.operation as any).data.existing_refs, { kind: 'shared_plan' });
|
||
assert.deepEqual((wholeGroup.operation as any).data.confirmation, { type: 'visitor-list' });
|
||
|
||
const explicitGroup = await parseProgramInput({
|
||
rawText: `导出团队文件
|
||
单号:LW-261101A-A
|
||
文件类型:整团游客信息`,
|
||
receivedAt
|
||
});
|
||
assert.equal(explicitGroup.status, 'agent_parse_passed');
|
||
assert.deepEqual((explicitGroup.operation as any).data.existing_refs, {
|
||
kind: 'shared_plan',
|
||
identifier: 'LW-261101A-A'
|
||
});
|
||
|
||
const concreteOrder = await parseProgramInput({
|
||
rawText: `导出团队文件
|
||
单号:D26110101
|
||
文件类型:游客名单`,
|
||
receivedAt
|
||
});
|
||
assert.equal(concreteOrder.status, 'agent_parse_passed');
|
||
assert.deepEqual((concreteOrder.operation as any).data.existing_refs, { identifier: 'D26110101' });
|
||
assert.deepEqual((concreteOrder.operation as any).data.confirmation, { type: 'visitor-list' });
|
||
|
||
const mixedScope = await parseProgramInput({
|
||
rawText: `导出团队文件
|
||
单号:LW-261101A-A
|
||
文件类型:整团游客信息/游客名单`,
|
||
receivedAt
|
||
});
|
||
assert.equal(mixedScope.status, 'agent_parse_needs_input');
|
||
assert.equal(mixedScope.error_code, 'program_invalid_value');
|
||
assert.deepEqual(mixedScope.missing_fields, ['export_type']);
|
||
assert.match(String((mixedScope.questions as any[])[0].prompt), /整团游客信息必须单独导出/);
|
||
});
|
||
|
||
test('date normalization handles next occurrence, cross-year ranges, and leap days', async () => {
|
||
const crossYear = await parseProgramInput({
|
||
rawText: `独立团批量下单
|
||
发团日期:12-28到01-05
|
||
预订客户:合成客户
|
||
产品搜索:合成产品
|
||
预估人数:15+1
|
||
用房数量:8标1单
|
||
发团周期:12.29/01.03`,
|
||
receivedAt: '2026-12-20T09:00:00+08:00'
|
||
});
|
||
assert.equal(crossYear.status, 'agent_parse_passed');
|
||
assert.deepEqual((crossYear.operation as any).data.recurrence, {
|
||
start_date: '2026-12-28', end_date: '2027-01-05', pattern: 'specified_dates'
|
||
});
|
||
assert.deepEqual((crossYear.operation as any).data.departure_dates, ['2026-12-29', '2027-01-03']);
|
||
|
||
const nextYear = await parseProgramInput({
|
||
rawText: routeCases[0].input.replace('09-03', '01-03'),
|
||
receivedAt: '2026-12-20T09:00:00+08:00'
|
||
});
|
||
assert.equal((nextYear.operation as any).data.departure_dates[0], '2027-01-03');
|
||
|
||
const leap = await parseProgramInput({
|
||
rawText: routeCases[0].input.replace('09-03', '2028-02-29'), receivedAt
|
||
});
|
||
assert.equal(leap.status, 'agent_parse_passed');
|
||
});
|
||
|
||
test('modification dates use the next occurrence and preserve explicit years', async () => {
|
||
const reported = await parseProgramInput({
|
||
rawText: `追加散拼子单订房说明
|
||
预订客户:辽宁康辉
|
||
出发日期:11-15
|
||
产品名称:广东衡阳
|
||
领队:张三
|
||
追加内容:领队单住`,
|
||
receivedAt: '2026-08-26T01:22:50.000Z'
|
||
});
|
||
assert.equal(reported.status, 'agent_parse_passed');
|
||
assert.equal((reported.operation as any).data.departure_dates[0], '2026-11-15');
|
||
|
||
const crossed = await parseProgramInput({
|
||
rawText: routeCases[13].input.replace('2026-09-15', '01-15'),
|
||
receivedAt: '2026-08-26T01:22:50.000Z'
|
||
});
|
||
assert.equal((crossed.operation as any).data.departure_dates[0], '2027-01-15');
|
||
|
||
const explicit = await parseProgramInput({
|
||
rawText: routeCases[13].input.replace('2026-09-15', '2025-11-15'),
|
||
receivedAt: '2026-08-26T01:22:50.000Z'
|
||
});
|
||
assert.equal((explicit.operation as any).data.departure_dates[0], '2025-11-15');
|
||
|
||
const targetDate = await parseProgramInput({
|
||
rawText: `独立团信息修改
|
||
单号:LW-260903-A
|
||
目标修改:出发日期改为01-07`,
|
||
receivedAt: '2026-08-26T01:22:50.000Z'
|
||
});
|
||
assert.equal((targetDate.operation as any).data.updates.actions[0].value, '2027-01-07');
|
||
|
||
const hotelChange = await parseProgramInput({
|
||
rawText: `安排变更
|
||
团号:LW-260903-A
|
||
目标变更:离店日期改为01-07;房间数改为9间
|
||
安排类型:酒店`,
|
||
receivedAt: '2026-08-26T01:22:50.000Z'
|
||
});
|
||
assert.equal((hotelChange.operation as any).data.arrangement.changes.end_date, '2027-01-07');
|
||
});
|
||
|
||
test('a yearless modification date supplied on a later turn uses that turn date context', async () => {
|
||
const result = await parseProgramInput({
|
||
rawText: '11-15',
|
||
receivedAt: '2026-08-26T01:23:59.000Z',
|
||
routeId: 'order_update_shared_child',
|
||
history: [{
|
||
text: '追加散拼子单订房说明\n预订客户:辽宁康辉\n追加内容:领队单住',
|
||
receivedAt: '2026-08-26T01:22:50.000Z'
|
||
}]
|
||
});
|
||
assert.equal(result.status, 'agent_parse_passed');
|
||
assert.equal((result.operation as any).data.departure_dates[0], '2026-11-15');
|
||
});
|
||
|
||
test('cancel and restore dates use the next occurrence and preserve explicit years', async () => {
|
||
const restore = await parseProgramInput({
|
||
rawText: `恢复订单
|
||
预订客户:衡阳国旅广东
|
||
出发日期:11-05
|
||
产品名称:老挝好时光
|
||
恢复状态:预订`,
|
||
receivedAt: '2026-08-26T05:09:53.000Z'
|
||
});
|
||
assert.equal(restore.status, 'agent_parse_passed');
|
||
assert.equal((restore.operation as any).action, 'order_restore');
|
||
assert.equal((restore.operation as any).data.departure_dates[0], '2026-11-05');
|
||
|
||
const cancel = await parseProgramInput({
|
||
rawText: `取消订单
|
||
预订客户:衡阳国旅广东
|
||
出发日期:01-05`,
|
||
receivedAt: '2026-08-26T05:09:53.000Z'
|
||
});
|
||
assert.equal(cancel.status, 'agent_parse_passed');
|
||
assert.equal((cancel.operation as any).data.departure_dates[0], '2027-01-05');
|
||
|
||
const explicit = await parseProgramInput({
|
||
rawText: `恢复订单
|
||
预订客户:衡阳国旅广东
|
||
出发日期:2025-11-05`,
|
||
receivedAt: '2026-08-26T05:09:53.000Z'
|
||
});
|
||
assert.equal(explicit.status, 'agent_parse_passed');
|
||
assert.equal((explicit.operation as any).data.departure_dates[0], '2025-11-05');
|
||
});
|
||
|
||
test('deterministic parser meets the ordinary and large-list local P95 budgets', async () => {
|
||
await parseProgramInput({ rawText: routeCases[0].input, receivedAt });
|
||
const ordinary: number[] = [];
|
||
for (let index = 0; index < 40; index += 1) {
|
||
const started = performance.now();
|
||
const result = await parseProgramInput({ rawText: routeCases[0].input, receivedAt });
|
||
assert.equal(result.status, 'agent_parse_passed');
|
||
ordinary.push(performance.now() - started);
|
||
}
|
||
const largeRows = Array.from({ length: 500 }, (_, index) => {
|
||
const sequence = index + 1;
|
||
return `${sequence}\t测试游客${sequence}\tTEST ${sequence}\t男\t1990-01-01\t湖南\t护照\tE${String(sequence).padStart(8, '0')}\t中国\t2025-01-01\t2035-01-01\t138${String(sequence).padStart(8, '0')}\t合成示例`;
|
||
}).join('\n');
|
||
const largeInput = `导入独立团名单\n单号:SYNTH-GROUP-001\n名单内容:\n${headers}\n${largeRows}`;
|
||
const large: number[] = [];
|
||
for (let index = 0; index < 12; index += 1) {
|
||
const started = performance.now();
|
||
const result = await parseProgramInput({ rawText: largeInput, receivedAt });
|
||
assert.equal(result.status, 'agent_parse_passed');
|
||
large.push(performance.now() - started);
|
||
}
|
||
const p95 = (values: number[]) => [...values].sort((left, right) => left - right)[Math.ceil(values.length * 0.95) - 1];
|
||
assert.ok(p95(ordinary) <= 100, `ordinary P95 ${p95(ordinary).toFixed(2)}ms`);
|
||
assert.ok(p95(large) <= 250, `large-list P95 ${p95(large).toFixed(2)}ms`);
|
||
});
|
||
|
||
test('semantic comparator ignores safe ordering but reports changed operation fields', () => {
|
||
const left = {
|
||
status: 'agent_parse_passed', operation: {
|
||
action: 'order_update_independent', data: { updates: { actions: [
|
||
{ target: 'rooms.TWN', operation: 'set', value: 2 },
|
||
{ target: 'rooms.SGL', operation: 'set', value: 3 }
|
||
] } }
|
||
}
|
||
};
|
||
const equivalent = structuredClone(left);
|
||
(equivalent.operation.data.updates.actions as any[]).reverse();
|
||
assert.equal(compareParserResults(left, equivalent).status, 'equivalent');
|
||
(equivalent.operation.data.updates.actions as any[])[0].value = 99;
|
||
assert.equal(compareParserResults(left, equivalent).status, 'different');
|
||
const details = parserFieldDifferences(left, equivalent);
|
||
assert.equal(details.length, 1);
|
||
assert.match(details[0].path, /^\$\.operation/);
|
||
assert.notEqual(details[0].ai.value, details[0].program.value);
|
||
});
|
||
|
||
function orchestrationClaim(mode: 'ai' | 'shadow' | 'auto' | 'program', rawText: string, affinity: 'ai' | 'program' | null = null): any {
|
||
return {
|
||
task: {
|
||
task_id: 'TASK-PARSER-1', created_at: receivedAt,
|
||
parser: {
|
||
route_id: 'team_order_create', input_contract_version: 'test-contract-v1', configured_mode: mode, config_revision: 1,
|
||
authoritative_engine: null, engine_affinity: affinity, version: null,
|
||
fallback_reason: null, comparison_status: null, decision_id: null,
|
||
review_status: null, diff_paths: [], field_differences: [],
|
||
unreviewed_difference_count: 0, shadow_differences: [], reparse_count: 0
|
||
}
|
||
},
|
||
attemptNo: 1, workerId: 'worker', messageId: 'message', messageText: rawText,
|
||
turnNo: 1, sessionId: '', recoveryCount: 0, history: []
|
||
};
|
||
}
|
||
|
||
test('mode orchestration keeps AI authority in Shadow and enforces the Auto fallback allowlist', async () => {
|
||
let aiCalls = 0;
|
||
const aiResult = await parseProgramInput({ rawText: routeCases[0].input, receivedAt });
|
||
const ai = {
|
||
async parse() {
|
||
aiCalls += 1;
|
||
return { ...aiResult, parse_mode: 'ai', parser_prompt_version: 'test-ai-v1' };
|
||
}
|
||
};
|
||
const orchestrator = new ParserOrchestrator(ai);
|
||
|
||
const shadow = await orchestrator.parse(orchestrationClaim('shadow', routeCases[0].input));
|
||
assert.equal(shadow.decision.authoritativeEngine, 'ai');
|
||
assert.equal(shadow.decision.comparisonStatus, 'equivalent');
|
||
assert.equal(aiCalls, 1);
|
||
|
||
const autoPassed = await orchestrator.parse(orchestrationClaim('auto', routeCases[0].input));
|
||
assert.equal(autoPassed.decision.authoritativeEngine, 'program');
|
||
assert.equal(aiCalls, 1);
|
||
|
||
const unknownField = `${routeCases[0].input}\n内部编号:123`;
|
||
const autoFallback = await orchestrator.parse(orchestrationClaim('auto', unknownField));
|
||
assert.equal(autoFallback.decision.authoritativeEngine, 'ai');
|
||
assert.equal(autoFallback.decision.fallbackReason, 'program_unknown_field');
|
||
assert.equal(aiCalls, 2);
|
||
|
||
const programBlocked = await orchestrator.parse(orchestrationClaim('program', unknownField));
|
||
assert.equal(programBlocked.decision.authoritativeEngine, 'program');
|
||
assert.equal((programBlocked.result as any).error_code, 'program_unknown_field');
|
||
assert.equal(aiCalls, 2);
|
||
|
||
const invalidAuto = await orchestrator.parse(orchestrationClaim(
|
||
'auto', routeCases[0].input.replace('发团日期:09-03', '发团日期:2026-02-30')
|
||
));
|
||
assert.equal(invalidAuto.decision.authoritativeEngine, 'program');
|
||
assert.equal((invalidAuto.result as any).error_code, 'program_invalid_value');
|
||
assert.equal(aiCalls, 2);
|
||
|
||
const affinity = await orchestrator.parse(orchestrationClaim('auto', unknownField, 'ai'));
|
||
assert.equal(affinity.decision.authoritativeEngine, 'ai');
|
||
assert.equal(aiCalls, 3);
|
||
});
|