迭代批量安排酒店

This commit is contained in:
andy committed 2026-09-24 14:43:08 +08:00
1 parent 4cfbefffe8
commit c2c71d35af
48 files changed
+5670 -57

No files matched your search

+93
View File
@@ -0,0 +1,93 @@
import { readFileSync } from 'node:fs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { parseProgramInput } from '../src/program-parser.js';
import { classifyExecutionResult, successReceiptFromResult } from '../src/task-service.js';
import { hotelBatch, hotelBatchMatchesOperation, verifiedHotelBatchItems } from '../src/hotel-batch.js';
import { partialBatchProgressEvents } from '../src/team-progress.js';
import { arrangementEventFromSuccess } from '../src/arrangement-ledger.js';
import { buildCustomerSuccessReceipt, replyTextFromReceipt } from '../src/reply-contract.js';
const block = (day: number, group = 'LW-SAMPLE') => `团号:${group}\n入住日期:2026-09-${day}\n离店日期:2026-09-${day + 1}\n酒店搜索:示例酒店${day}TWN\n间数:1`;
const parse = (rawText: string) => parseProgramInput({ rawText, receivedAt: '2026-09-24T00:00:00Z' });
type Json = Record<string, any>;
async function fixture() {
const parsed = await parse(`安排酒店\n${block(26)}\n${block(27)}\n${block(28)}`);
assert.equal(parsed.status, 'agent_parse_passed', parsed.reply as string);
const operation = parsed.operation as Json;
const items = operation.data.arrangements.map((arrangement: Json, index: number) => ({ index, arrangement, status: 'completed',
result: { status: 'completed', write_attempted: true, no_erp_write: false, report: { status: 'lifecycle_completed',
server_response: { completed: true }, resolved_refs: { kind: 'independent_order', identifier: 'LW-SAMPLE', tid: '1' },
requery: { matched: true, subpage: { arrangement_target: { row_id: String(index + 10), quantity_value: '1' },
checks: [{ field: 'riqi0', actual: arrangement.start_date, matched: true }, { field: 'riqis0', actual: arrangement.end_date, matched: true }] } }
} } }));
return { operation, result: { status: 'completed', write_attempted: true, no_erp_write: false,
hotel_batch: { version: 1, group_number: 'LW-SAMPLE', items } } as Json };
}
test('hotel input splits five repeated group blocks and preserves multi-night stays and ordinary input', async () => {
const result = await parse(`安排酒店\n${[25,26,27,28,29].map(day => block(day)).join('\n')}`);
assert.equal(result.status, 'agent_parse_passed');
assert.equal((result.operation?.data as Json).arrangements.length, 5);
const single = await parse(`安排酒店\n${block(26).replace('2026-09-27', '2026-09-29')}`);
assert.equal((single.operation?.data as Json).arrangement.end_date, '2026-09-29');
assert.equal((single.operation?.data as Json).arrangements, undefined);
});
test('the entire batch blocks on cross-team, missing field, duplicate, mixed action, invalid date and oversized input', async () => {
for (const raw of [
`${block(26)}\n${block(27, 'OTHER')}`, `${block(26)}\n${block(27).replace('间数:1','')}`,
`${block(26)}\n${block(26)}`, `${block(26)}\n安排用车\n${block(27)}`,
`${block(26)}\n${block(27).replace('2026-09-28','2026-09-26')}`,
Array.from({ length:11 },(_, i)=>block(i + 10)).join('\n')
]) {
const result = await parse(`安排酒店\n${raw}`);
assert.notEqual(result.status, 'agent_parse_passed', raw);
assert.equal(result.operation, null);
assert.equal(result.no_erp_write, true);
}
});
test('all rows require bound, distinct write/requery evidence before whole-batch success', async () => {
const { operation, result } = await fixture();
assert.equal(classifyExecutionResult(result).status, 'completed');
assert.ok(hotelBatchMatchesOperation(operation, result));
const reply = replyTextFromReceipt(buildCustomerSuccessReceipt(successReceiptFromResult(result)!, result, operation));
assert.match(reply, /酒店批量安排成功/); assert.match(reply, /成功 3 条/); assert.match(reply, /2026\/09\/28/);
for (const mutate of [
(r: Json) => { r.hotel_batch.items[1].result.report.server_response.completed = false; },
(r: Json) => { r.hotel_batch.items[1].result.report.requery.subpage.arrangement_target.row_id = '10'; },
(r: Json) => { r.hotel_batch.items[1].result.report.requery.subpage.checks[0].actual = '2026-01-01'; }
]) { const bad = structuredClone(result); mutate(bad); assert.equal(classifyExecutionResult(bad).status, 'reconciliation_pending'); }
const bad = structuredClone(result); bad.hotel_batch.items[1].arrangement.resource.keyword = 'different';
assert.equal(hotelBatchMatchesOperation(operation, bad), false);
});
test('partial stop retains distinct successful ledger events and uncertainty never counts as success', async () => {
const { operation, result } = await fixture();
result.status = 'blocked';
const last = result.hotel_batch.items[2];
last.status = 'failed'; last.result = { status: 'blocked', no_erp_write: true, write_attempted: false, message: '未匹配酒店' };
assert.equal(classifyExecutionResult(result).status, 'blocked');
assert.equal(successReceiptFromResult(result), null);
const input = { taskId:'TASK', executionId:'EXEC', operation, result, receipt:null, effectiveAt:'2026-09-24T00:00:00Z', recordedAt:'2026-09-24T00:00:00Z' };
const events = partialBatchProgressEvents(input);
assert.equal(events.length, 2); assert.notEqual(events[0].execution_id, events[1].execution_id);
const arrangements = verifiedHotelBatchItems(operation, result).map(item => arrangementEventFromSuccess({ ...input,
executionId:`EXEC:hotel:${item.index + 1}`, operation:{ action:'arrangement_hotel', data:{ existing_refs:operation.data.existing_refs, arrangement:item.arrangement } },
result:item.result, receipt:successReceiptFromResult(item.result) }));
assert.ok(arrangements.every(Boolean)); assert.notEqual(arrangements[0]?.record_key, arrangements[1]?.record_key);
last.status = 'uncertain'; last.result = { status:'execution_uncertain', write_attempted:true, no_erp_write:false };
result.status = 'execution_uncertain';
assert.equal(classifyExecutionResult(result).status, 'reconciliation_pending');
assert.equal(partialBatchProgressEvents(input).length, 2);
assert.match(hotelBatch.resultText(result.hotel_batch), /待核验 1 条/);
});
test('published operator batch example parses to three complete ordered stays', async () => {
const catalog = readFileSync('agent设计规范/templates/business-input-templates.md','utf8');
const section = catalog.split('#### 同团批量安排示例')[1].split('### 11.')[0];
const rawText = section.match(/```text\n([\s\S]*?)```/)![1];
const parsed = await parse(rawText);
assert.equal(parsed.status, 'agent_parse_passed');
const arrangements = (parsed.operation!.data as Json).arrangements;
assert.equal(arrangements.length,3);
assert.equal(arrangements[2].start_date,'2026-09-28');
assert.equal(arrangements[2].end_date,'2026-09-30');
});
+20 -1
View File
@@ -189,7 +189,7 @@ 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.14');
assert.equal(corpus.version, 'ltjt-program-parser-v1.0.15');
assert.ok(corpus.cases.length >= 28);
for (const candidate of corpus.cases) {
const parse = () => parseProgramInput({
@@ -863,3 +863,22 @@ test('mode orchestration keeps AI authority in Shadow and enforces the Auto fall
assert.equal(affinity.decision.authoritativeEngine, 'ai');
assert.equal(aiCalls, 3);
});
test('repeated hotel blocks always use their deterministic batch contract even with AI affinity', async () => {
let aiCalls = 0;
const orchestrator = new ParserOrchestrator({ async parse() { aiCalls++; return {}; } });
const rawText = '安排酒店\n' + [26,27].map(day => `团号:LW-SAMPLE\n入住日期:2026-09-${day}\n离店日期:2026-09-${day+1}\n酒店搜索:示例酒店TWN\n间数:1`).join('\n');
for (const mode of ['ai','shadow','auto','program'] as const) {
const claim = orchestrationClaim(mode, rawText, 'ai');
claim.task.parser.route_id = 'arrangement_hotel_create';
const parsed = await orchestrator.parse(claim);
assert.equal(parsed.decision.authoritativeEngine, 'program');
assert.equal((parsed.result as any).operation.data.arrangements.length, 2);
claim.messageText = '间数:2';
claim.history = [{ turnNo: 1, rawText }];
const incomplete = await orchestrator.parse(claim);
assert.equal((incomplete.result as any).error_code, 'program_hotel_batch_invalid');
}
assert.equal(aiCalls, 0);
});