feat: ship deterministic parser and lifecycle release
This commit is contained in:
1 parent
208c434b31
commit
7b5d855b09
176 files changed
+20248
-1362
No files matched your search
@@ -15,7 +15,8 @@ import {
|
||||
normalizeBaseUrl,
|
||||
parseJsonObject,
|
||||
parseSseBlock,
|
||||
validateAgentOperation
|
||||
validateAgentOperation,
|
||||
validateStandardOperation
|
||||
} from './external-agent-client.mjs';
|
||||
import { createBusinessServer } from './server.mjs';
|
||||
|
||||
@@ -302,6 +303,123 @@ describe('external parser contract', () => {
|
||||
assert.match(calls[2].body.message, /上一轮校验提示:安排其他\/备案的项目\/备案说明和数量均为选填/);
|
||||
});
|
||||
|
||||
it('repairs a stale modification year question in the same session', async () => {
|
||||
const calls = [];
|
||||
let streamCount = 0;
|
||||
const sseFor = (result) => new Response([
|
||||
`event: message.delta\ndata: ${JSON.stringify({ content: JSON.stringify(result) })}`,
|
||||
'event: run.completed\ndata: {"status":"completed"}',
|
||||
''
|
||||
].join('\n\n'), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'text/event-stream' }
|
||||
});
|
||||
const stale = {
|
||||
status: 'agent_parse_needs_input', blockers: [], operation: null,
|
||||
reply: '请提供出发日期的年份。',
|
||||
missing_fields: ['data.departure_dates'],
|
||||
questions: [{ field: 'data.departure_dates', prompt: '请提供出发日期的完整年份。' }]
|
||||
};
|
||||
const passed = {
|
||||
status: 'agent_parse_passed', blockers: [],
|
||||
operation: {
|
||||
action: 'order_update_shared_child', order_nature: 'formal', submit_mode: 'dry_run',
|
||||
data: {
|
||||
existing_refs: { kind: 'shared_child_order' },
|
||||
customer: { name: '辽宁康辉', keyword: '辽宁康辉' },
|
||||
departure_dates: ['2026-11-15'],
|
||||
updates: { actions: [{ target: 'lodging_note', operation: 'append', value: '领队单住' }] }
|
||||
}
|
||||
}
|
||||
};
|
||||
const fetchImpl = async (url, options) => {
|
||||
const body = options.body ? JSON.parse(options.body) : null;
|
||||
calls.push({ url, body });
|
||||
if (url.endsWith('/api/open/agent-sessions')) {
|
||||
return new Response(JSON.stringify({ session_id: 'open_sess_stale_update_date' }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' }
|
||||
});
|
||||
}
|
||||
streamCount += 1;
|
||||
return sseFor(streamCount === 1 ? stale : passed);
|
||||
};
|
||||
const parser = new ExternalAgentParser({
|
||||
baseUrl: 'https://superagent.nianxx.cn', apiKey: 'df_open_test', fetchImpl,
|
||||
now: () => new Date('2026-08-26T01:22:50.000Z')
|
||||
});
|
||||
const result = await parser.parse({
|
||||
taskId: 'TASK-STALE-UPDATE-DATE',
|
||||
rawText: '追加散拼子单订房说明\n预订客户:辽宁康辉\n出发日期:11-15\n追加内容:领队单住',
|
||||
receivedAt: '2026-08-26T01:22:50.000Z'
|
||||
});
|
||||
|
||||
assert.equal(result.status, 'agent_parse_passed');
|
||||
assert.equal(result.operation.data.departure_dates[0], '2026-11-15');
|
||||
assert.equal(result.external_request.contract_repair_count, 1);
|
||||
assert.equal(calls.length, 3);
|
||||
assert.match(calls[2].body.message, /上一轮校验提示:修改类输入中的无年份月日必须按任务当前日期取下一次发生日/);
|
||||
});
|
||||
|
||||
it('repairs the reported stale restore year question in the same session', async () => {
|
||||
const calls = [];
|
||||
let streamCount = 0;
|
||||
const sseFor = (result) => new Response([
|
||||
`event: message.delta\ndata: ${JSON.stringify({ content: JSON.stringify(result) })}`,
|
||||
'event: run.completed\ndata: {"status":"completed"}',
|
||||
''
|
||||
].join('\n\n'), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'text/event-stream' }
|
||||
});
|
||||
const stale = {
|
||||
status: 'agent_parse_needs_input', blockers: [], operation: null,
|
||||
reply: '恢复订单需要完整出发日期(含年份)。',
|
||||
missing_fields: ['data.departure_dates'],
|
||||
questions: [{ field: 'data.departure_dates', prompt: '当前仅提供 11-05,请补充年份。' }]
|
||||
};
|
||||
const passed = {
|
||||
status: 'agent_parse_passed', blockers: [],
|
||||
operation: {
|
||||
action: 'order_restore', order_nature: 'formal', submit_mode: 'dry_run',
|
||||
data: {
|
||||
customer: { name: '衡阳国旅广东', keyword: '衡阳国旅广东' },
|
||||
departure_dates: ['2025-11-05'],
|
||||
product: { name: '老挝好时光', keyword: '老挝好时光' },
|
||||
transition: { to_status: '预订' }
|
||||
}
|
||||
}
|
||||
};
|
||||
const fetchImpl = async (url, options) => {
|
||||
const body = options.body ? JSON.parse(options.body) : null;
|
||||
calls.push({ url, body });
|
||||
if (url.endsWith('/api/open/agent-sessions')) {
|
||||
return new Response(JSON.stringify({ session_id: 'open_sess_stale_restore_date' }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' }
|
||||
});
|
||||
}
|
||||
streamCount += 1;
|
||||
return sseFor(streamCount === 1 ? stale : passed);
|
||||
};
|
||||
const parser = new ExternalAgentParser({
|
||||
baseUrl: 'https://superagent.nianxx.cn', apiKey: 'df_open_test', fetchImpl,
|
||||
now: () => new Date('2026-08-26T05:09:53.000Z')
|
||||
});
|
||||
const result = await parser.parse({
|
||||
taskId: 'TASK-STALE-RESTORE-DATE',
|
||||
rawText: '恢复订单\n预订客户:衡阳国旅广东\n出发日期:11-05\n产品名称:老挝好时光\n恢复状态:预订',
|
||||
receivedAt: '2026-08-26T05:09:53.000Z'
|
||||
});
|
||||
|
||||
assert.equal(result.status, 'agent_parse_passed');
|
||||
assert.equal(result.operation.action, 'order_restore');
|
||||
assert.equal(result.operation.data.departure_dates[0], '2026-11-05');
|
||||
assert.equal(result.external_request.contract_repair_count, 1);
|
||||
assert.equal(calls.length, 3);
|
||||
assert.match(calls[2].body.message, /上一轮校验提示:取消或恢复订单的无年份出发日期必须按任务当前日期取下一次发生日/);
|
||||
});
|
||||
|
||||
it('reports the concrete unknown operation.data keys', () => {
|
||||
const result = normalizeParseResult({
|
||||
status: 'agent_parse_passed',
|
||||
@@ -1095,7 +1213,7 @@ describe('SSE and result helpers', () => {
|
||||
assert.equal(result.operation.data.recurrence.start_date, '2026-01-01');
|
||||
});
|
||||
|
||||
it('does not apply newbooking rollover to update operations', () => {
|
||||
it('rolls yearless modification lookup dates to the next occurrence', () => {
|
||||
const operation = {
|
||||
action: 'order_update_shared_plan',
|
||||
order_nature: 'formal',
|
||||
@@ -1107,13 +1225,139 @@ describe('SSE and result helpers', () => {
|
||||
}
|
||||
};
|
||||
const result = normalizeParseResult(canonicalPassedResult(operation), {
|
||||
taskId: 'TASK-NEXT-DATE-UPDATE-UNCHANGED',
|
||||
taskId: 'TASK-NEXT-DATE-UPDATE-ROLLOVER',
|
||||
receivedAt: '2026-08-18T04:30:00.000Z',
|
||||
rawText: '修改散拼母团计划\n发团日期:01-07\n计划收客数:10'
|
||||
});
|
||||
|
||||
assert.equal(result.status, 'agent_parse_passed');
|
||||
assert.equal(result.operation.data.departure_dates[0], '2026-01-07');
|
||||
assert.equal(result.operation.data.departure_dates[0], '2027-01-07');
|
||||
});
|
||||
|
||||
it('normalizes the reported shared-child 11-15 lookup without asking for a year', () => {
|
||||
const operation = {
|
||||
action: 'order_update_shared_child',
|
||||
order_nature: 'formal',
|
||||
submit_mode: 'dry_run',
|
||||
data: {
|
||||
existing_refs: { kind: 'shared_child_order' },
|
||||
customer: { name: '辽宁康辉', keyword: '辽宁康辉' },
|
||||
departure_dates: ['2025-11-15'],
|
||||
product: { name: '广东衡阳', keyword: '广东衡阳' },
|
||||
leader: { name: '张三', keyword: '张三' },
|
||||
updates: { actions: [{ target: 'lodging_note', operation: 'append', value: '领队单住' }] }
|
||||
}
|
||||
};
|
||||
const result = normalizeParseResult(canonicalPassedResult(operation), {
|
||||
taskId: 'TASK-NEXT-DATE-SHARED-CHILD',
|
||||
receivedAt: '2026-08-26T01:22:50.000Z',
|
||||
rawText: '追加散拼子单订房说明\n预订客户:辽宁康辉\n出发日期:11-15\n产品名称:广东衡阳\n领队:张三\n追加内容:领队单住'
|
||||
});
|
||||
|
||||
assert.equal(result.status, 'agent_parse_passed');
|
||||
assert.equal(result.operation.data.departure_dates[0], '2026-11-15');
|
||||
});
|
||||
|
||||
it('normalizes modification target dates and preserves explicit years', () => {
|
||||
const update = {
|
||||
action: 'order_update_independent',
|
||||
order_nature: 'formal',
|
||||
submit_mode: 'dry_run',
|
||||
data: {
|
||||
existing_refs: { kind: 'independent_order', identifier: 'LW-260903-A' },
|
||||
departure_dates: ['2025-11-15'],
|
||||
updates: { actions: [{ target: 'departure_date', operation: 'set', value: '2026-01-07' }] }
|
||||
}
|
||||
};
|
||||
const rolled = normalizeParseResult(canonicalPassedResult(update), {
|
||||
taskId: 'TASK-NEXT-DATE-UPDATE-TARGET', receivedAt: '2026-08-18T04:30:00.000Z',
|
||||
rawText: '独立团信息修改\n单号:LW-260903-A\n出发日期:2025-11-15\n目标修改:出发日期改为01-07'
|
||||
});
|
||||
assert.equal(rolled.operation.data.departure_dates[0], '2025-11-15');
|
||||
assert.equal(rolled.operation.data.updates.actions[0].value, '2027-01-07');
|
||||
|
||||
const explicit = normalizeParseResult(canonicalPassedResult({
|
||||
...update,
|
||||
data: { ...update.data, updates: { actions: [{ target: 'departure_date', operation: 'set', value: '2025-01-07' }] } }
|
||||
}), {
|
||||
taskId: 'TASK-EXPLICIT-DATE-UPDATE-TARGET', receivedAt: '2026-08-18T04:30:00.000Z',
|
||||
rawText: '独立团信息修改\n单号:LW-260903-A\n目标修改:出发日期改为2025-01-07'
|
||||
});
|
||||
assert.equal(explicit.operation.data.updates.actions[0].value, '2025-01-07');
|
||||
});
|
||||
|
||||
it('normalizes a yearless hotel-change end date', () => {
|
||||
const operation = {
|
||||
action: 'arrangement_hotel', order_nature: 'formal', submit_mode: 'dry_run',
|
||||
data: {
|
||||
existing_refs: { identifier: 'LW-260903-A' },
|
||||
arrangement: { mode: 'update', changes: { end_date: '2026-01-07', room_count: 9 } }
|
||||
}
|
||||
};
|
||||
const result = normalizeParseResult(canonicalPassedResult(operation), {
|
||||
taskId: 'TASK-NEXT-DATE-HOTEL-CHANGE', receivedAt: '2026-08-18T04:30:00.000Z',
|
||||
rawText: '安排变更\n团号:LW-260903-A\n目标变更:离店日期改为01-07;房间数改为9间\n安排类型:酒店'
|
||||
});
|
||||
assert.equal(result.operation.data.arrangement.changes.end_date, '2027-01-07');
|
||||
});
|
||||
|
||||
it('normalizes cancel and restore lookup dates and preserves explicit years', () => {
|
||||
const lifecycle = (action, date) => ({
|
||||
action, order_nature: 'formal', submit_mode: 'dry_run',
|
||||
data: {
|
||||
customer: { name: '衡阳国旅广东', keyword: '衡阳国旅广东' },
|
||||
departure_dates: [date],
|
||||
...(action === 'order_restore' ? { transition: { to_status: '预订' } } : {})
|
||||
}
|
||||
});
|
||||
const restore = normalizeParseResult(canonicalPassedResult(lifecycle('order_restore', '2025-11-05')), {
|
||||
taskId: 'TASK-NEXT-DATE-RESTORE', receivedAt: '2026-08-26T05:09:53.000Z',
|
||||
rawText: '恢复订单\n预订客户:衡阳国旅广东\n出发日期:11-05\n恢复状态:预订'
|
||||
});
|
||||
assert.equal(restore.operation.data.departure_dates[0], '2026-11-05');
|
||||
|
||||
const cancel = normalizeParseResult(canonicalPassedResult(lifecycle('order_cancel', '2026-01-05')), {
|
||||
taskId: 'TASK-NEXT-DATE-CANCEL', receivedAt: '2026-08-26T05:09:53.000Z',
|
||||
rawText: '取消订单\n预订客户:衡阳国旅广东\n出发日期:01-05'
|
||||
});
|
||||
assert.equal(cancel.operation.data.departure_dates[0], '2027-01-05');
|
||||
|
||||
const explicit = normalizeParseResult(canonicalPassedResult(lifecycle('order_restore', '2025-11-05')), {
|
||||
taskId: 'TASK-EXPLICIT-DATE-RESTORE', receivedAt: '2026-08-26T05:09:53.000Z',
|
||||
rawText: '恢复订单\n预订客户:衡阳国旅广东\n出发日期:2025-11-05'
|
||||
});
|
||||
assert.equal(explicit.operation.data.departure_dates[0], '2025-11-05');
|
||||
});
|
||||
|
||||
it('rejects a stale year-only restore question so the client performs one contract repair', () => {
|
||||
const result = normalizeParseResult({
|
||||
status: 'agent_parse_needs_input', blockers: [], operation: null,
|
||||
missing_fields: ['data.departure_dates'],
|
||||
questions: [{ field: 'data.departure_dates', prompt: '当前仅提供 11-05,请补充年份。' }],
|
||||
reply: '请补充年份。'
|
||||
}, {
|
||||
taskId: 'TASK-STALE-RESTORE-YEAR-QUESTION', receivedAt: '2026-08-26T05:09:53.000Z',
|
||||
rawText: '恢复订单\n预订客户:衡阳国旅广东\n出发日期:11-05\n恢复状态:预订'
|
||||
});
|
||||
assert.equal(result.status, 'agent_parse_blocked');
|
||||
assert.equal(result.error_code, 'external_operation_contract_invalid');
|
||||
assert.match(result.validation_errors.join('\n'), /不得仅因缺少年份追问/);
|
||||
});
|
||||
|
||||
it('rejects a stale year-only modification question so the client performs one contract repair', () => {
|
||||
const result = normalizeParseResult({
|
||||
status: 'agent_parse_needs_input', blockers: [], operation: null,
|
||||
missing_fields: ['data.departure_dates'],
|
||||
questions: [{ field: 'data.departure_dates', prompt: '请提供出发日期的完整年份。' }],
|
||||
reply: '请补充年份。'
|
||||
}, {
|
||||
taskId: 'TASK-STALE-YEAR-QUESTION', receivedAt: '2026-08-26T01:22:50.000Z',
|
||||
rawText: '11-15',
|
||||
dateContextText: '追加散拼子单订房说明\n预订客户:辽宁康辉\n追加内容:领队单住\n11-15'
|
||||
});
|
||||
assert.equal(result.status, 'agent_parse_blocked');
|
||||
assert.equal(result.error_code, 'external_operation_contract_invalid');
|
||||
assert.match(result.validation_errors.join('\n'), /不得仅因缺少年份追问/);
|
||||
});
|
||||
|
||||
it('uses original task history when a continuation only supplies a missing field', () => {
|
||||
@@ -1359,6 +1603,43 @@ describe('SSE and result helpers', () => {
|
||||
assert.equal(result.operation.data.existing_refs.identifier, 'LW-260903-A');
|
||||
});
|
||||
|
||||
it('treats ERP passenger rows as an initial baseline while retaining a 5000-row technical bound', () => {
|
||||
const headers = '序号\t姓名\tNAME\t性别\t出生日期\t出生地\t证件类型\t证件号码\t签发地\t签发日\t有效期\t电话\t备注';
|
||||
const row = (sequence) => `${sequence}\t合成游客\tSYNTHETIC USER\t男\t1990-01-01\t湖南\t护照\tE90000001\t中国\t2025-01-01\t2035-01-01\t13800000001\t合成示例`;
|
||||
const parseState = {
|
||||
action: 'passenger_list_import',
|
||||
order_nature: 'formal',
|
||||
submit_mode: 'dry_run',
|
||||
data: {
|
||||
existing_refs: { kind: 'independent_order', identifier: 'LW-260903-A' },
|
||||
passenger_list: { operation: 'import', row_count: 1, text: `${headers}\n${row(25)}` }
|
||||
}
|
||||
};
|
||||
assert.deepEqual(validateAgentOperation(parseState), []);
|
||||
|
||||
const executionState = structuredClone(parseState);
|
||||
executionState.data.existing_refs = {
|
||||
kind: 'independent_order',
|
||||
identifier: 'LW-260903-A',
|
||||
group_no: 'LW-260903-A',
|
||||
tid: '14420',
|
||||
ddid: '14482',
|
||||
resolved: true,
|
||||
resolution_source: 'erp_unique_match',
|
||||
expected_passenger_count: 16
|
||||
};
|
||||
executionState.data.passenger_list.operation = 'first_import';
|
||||
assert.deepEqual(validateStandardOperation(executionState), []);
|
||||
|
||||
const technicalOverflow = structuredClone(parseState);
|
||||
technicalOverflow.data.passenger_list.text = `${headers}\n${row(5001)}`;
|
||||
assert.match(validateAgentOperation(technicalOverflow).join('\n'), /5000 行技术安全上限/);
|
||||
|
||||
const executionTechnicalOverflow = structuredClone(executionState);
|
||||
executionTechnicalOverflow.data.passenger_list.text = `${headers}\n${row(5001)}`;
|
||||
assert.match(validateStandardOperation(executionTechnicalOverflow).join('\n'), /5000 行技术安全上限/);
|
||||
});
|
||||
|
||||
it('accepts hotel arrangement input without a room type and leaves that field for ERP resolution', () => {
|
||||
const operation = {
|
||||
action: 'arrangement_hotel',
|
||||
@@ -1430,6 +1711,33 @@ describe('SSE and result helpers', () => {
|
||||
assert.equal(operationPlans.validateDispatchOperation(arrangementWithoutIdentifier).ok, false);
|
||||
});
|
||||
|
||||
it('accepts only visitor-list for a shared mother-plan export scope', () => {
|
||||
const named = (name) => ({ name, keyword: name });
|
||||
const wholeGroup = {
|
||||
action: 'confirmation_export',
|
||||
order_nature: 'formal',
|
||||
submit_mode: 'dry_run',
|
||||
data: {
|
||||
existing_refs: { kind: 'shared_plan' },
|
||||
customer: named('合成客户'),
|
||||
product: named('合成产品'),
|
||||
departure_dates: ['2026-11-01'],
|
||||
confirmation: { type: 'visitor-list' }
|
||||
}
|
||||
};
|
||||
assert.deepEqual(validateAgentOperation(wholeGroup), []);
|
||||
assert.equal(operationPlans.validateDispatchOperation(wholeGroup).ok, true);
|
||||
|
||||
const wrongType = structuredClone(wholeGroup);
|
||||
wrongType.data.confirmation = { type: 'liantai-confirm' };
|
||||
assert.match(validateAgentOperation(wrongType).join('\n'), /散拼母团.*整团游客信息/);
|
||||
|
||||
const mixedTypes = structuredClone(wholeGroup);
|
||||
delete mixedTypes.data.confirmation;
|
||||
mixedTypes.data.export_types = ['visitor-list', 'guide-confirm'];
|
||||
assert.match(validateAgentOperation(mixedTypes).join('\n'), /散拼母团.*整团游客信息/);
|
||||
});
|
||||
|
||||
it('allows a no-customer shared mother-plan capacity update when the departure date is present', () => {
|
||||
const operation = {
|
||||
action: 'order_update_shared_plan',
|
||||
@@ -1478,8 +1786,8 @@ describe('SSE and result helpers', () => {
|
||||
assert.deepEqual(operationPlans.validateDispatchOperation(operation).blockers, []);
|
||||
const dispatchWarnings = operationPlans.validateDispatchOperation(operation).warnings.join('\n');
|
||||
assert.doesNotMatch(dispatchWarnings, /rooms\.SGL|rooms\.TWN/);
|
||||
assert.doesNotMatch(dispatchWarnings, /pax\.adult|pax\.child_bed|pax\.leader/);
|
||||
assert.match(dispatchWarnings, /pax\.child_no_bed|pax\.infant/);
|
||||
assert.doesNotMatch(dispatchWarnings, /pax\.adult|pax\.child_bed|pax\.child_no_bed|pax\.leader/);
|
||||
assert.match(dispatchWarnings, /pax\.infant/);
|
||||
});
|
||||
|
||||
it('passes Agent and plugin front-gate validation for every supported directive route', () => {
|
||||
@@ -1564,16 +1872,38 @@ describe('SSE and result helpers', () => {
|
||||
assert.equal(result.no_erp_write, true);
|
||||
});
|
||||
|
||||
it('blocks NEED_INPUT when the Agent did not return a user-facing reply', () => {
|
||||
it('derives a NEED_INPUT reply from validated question prompts when the Agent omitted the top-level reply', () => {
|
||||
const result = normalizeParseResult({
|
||||
status: 'agent_parse_needs_input',
|
||||
blockers: [],
|
||||
operation: null,
|
||||
missing_fields: ['data.product'],
|
||||
questions: [{ field: 'data.product', prompt: '请补充线路产品名称。' }],
|
||||
questions: [
|
||||
{ field: 'data.product', prompt: '请补充线路产品名称。' },
|
||||
{ field: 'data.departure_dates', prompt: '请补充出发日期。' }
|
||||
],
|
||||
captured_facts: {}
|
||||
}, { taskId: 'TASK-NEEDS-INPUT-NO-REPLY', receivedAt: '2026-07-12T08:00:00.000Z' });
|
||||
|
||||
assert.equal(result.status, 'agent_parse_needs_input');
|
||||
assert.equal(result.operation, null);
|
||||
assert.equal(result.reply, '请补充线路产品名称。;请补充出发日期。');
|
||||
assert.deepEqual(result.questions, [
|
||||
{ field: 'data.product', prompt: '请补充线路产品名称。' },
|
||||
{ field: 'data.departure_dates', prompt: '请补充出发日期。' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('still blocks NEED_INPUT when only internal missing fields exist without a displayable question', () => {
|
||||
const result = normalizeParseResult({
|
||||
status: 'agent_parse_needs_input',
|
||||
blockers: [],
|
||||
operation: null,
|
||||
missing_fields: ['data.product'],
|
||||
questions: [],
|
||||
captured_facts: {}
|
||||
}, { taskId: 'TASK-NEEDS-INPUT-NO-QUESTION', receivedAt: '2026-07-12T08:00:00.000Z' });
|
||||
|
||||
assert.equal(result.status, 'agent_parse_blocked');
|
||||
assert.equal(result.error_code, 'external_needs_input_missing_reply');
|
||||
assert.match(result.blockers.join('\n'), /没有可展示的 Agent 回复/);
|
||||
|
||||
Reference in new issue
Block a user