Files
LWLT-AIBOT/control-plane/test/program-parser.test.ts

757 lines
33 KiB
TypeScript
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 { 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: '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
文件类型:联泰客户确认单`
}
];
test('the machine registry contains the 18 independent routes exactly once', () => {
assert.equal(BUSINESS_ROUTES.length, 18);
assert.equal(new Set(BUSINESS_ROUTES.map((route) => route.routeId)).size, 18);
assert.equal(new Set(BUSINESS_ROUTES.map((route) => route.directive)).size, 18);
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: 18 }, (_, 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');
});
test('machine routes stay synchronized with the operator template and business registry', async () => {
const [template, registry, migration] = 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')
]);
for (const route of BUSINESS_ROUTES) {
assert.match(template, new RegExp(route.directive.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')), route.routeId);
assert.match(registry, new RegExp(route.action.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')), route.routeId);
assert.match(migration, 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.6');
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 18 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('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[4].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[4].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[4].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[4].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 headcount distributions keep occupied and non-occupied children distinct', async () => {
const parseHeadcount = (value: string) => parseProgramInput({
rawText: routeCases[13].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[8].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[12].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[13].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[13].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[15].input,
receivedAt,
routeId: 'confirmation_export',
history: [{ text: routeCases[17].input, receivedAt }]
});
assert.equal(result.error_code, 'program_multiple_actions');
assert.equal(programFallbackAllowed(result), false);
const sameTurn = await parseProgramInput({
rawText: `${routeCases[17].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[4].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[12].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[12].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);
});