迭代批量安排酒店

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

+2 -2
View File
@@ -79,8 +79,8 @@ export function effectiveParserModeForRoute(route: BusinessRouteDefinition, conf
return route.parserPolicy === 'program_only' ? 'program' : 'ai';
}
const PROGRAM_VERSION = 'ltjt-program-parser-v1.0.14';
export const BUSINESS_INPUT_CONTRACT_VERSION = 'business-input-templates-0.5.136';
const PROGRAM_VERSION = 'ltjt-program-parser-v1.0.15';
export const BUSINESS_INPUT_CONTRACT_VERSION = 'business-input-templates-0.5.137';
const lookupFields = ({ customerRequired = true } = {}): ProgramFieldDefinition[] => [
{ key: 'order_no', label: '单号', aliases: ['团号', '订单号'], kind: 'text' },
+23
View File
@@ -0,0 +1,23 @@
import { createRequire } from 'node:module';
import { resolve } from 'node:path';
const require = createRequire(import.meta.url);
type Json = Record<string, any>;
export const hotelBatch = require(resolve(process.cwd(), 'chrome-extension/ltjt-order-assistant/hotel-batch.js')) as {
inspect(value: unknown): null | { completed: number; failed: number; uncertain: number; total: number; not_started: number; allCompleted: boolean; knownStopped: boolean };
resultText(batch: unknown): string;
verified(result: unknown, identifier: unknown): boolean;
};
// Bind every row back to the server's immutable parsed operation, not a plugin-supplied plan.
export function hotelBatchMatchesOperation(operation: unknown, result: unknown): boolean {
const op = operation as Json, batch = (result as Json)?.hotel_batch;
if (op?.action !== 'arrangement_hotel' || !Array.isArray(op.data?.arrangements) || !hotelBatch.inspect(batch)
|| batch.group_number !== op.data.existing_refs?.identifier || batch.items.length !== op.data.arrangements.length) return false;
const canonical = (value: any): any => Array.isArray(value) ? value.map(canonical) : value && typeof value === 'object'
? Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical(value[key])])) : value;
if (batch.items.some((item: Json, index: number) => JSON.stringify(canonical(item.arrangement)) !== JSON.stringify(canonical(op.data.arrangements[index])))) return false;
return true;
}
export function verifiedHotelBatchItems(operation: unknown, result: unknown): Json[] {
return hotelBatchMatchesOperation(operation, result) ? (result as Json).hotel_batch.items.filter((item: Json) => item.status === 'completed') : [];
}
+10 -1
View File
@@ -1,7 +1,7 @@
import { performance } from 'node:perf_hooks';
import type { BusinessRouteId, ParserMode } from './business-routes.js';
import { compareParserResults } from './parser-contract.js';
import { parseProgramInput, programFallbackAllowed, programParserVersion } from './program-parser.js';
import { parseProgramInput, isHotelBatchInput, programFallbackAllowed, programParserVersion } from './program-parser.js';
import type { ParseDecisionInput, ParseTaskClaim } from './task-service.js';
export interface AiParser {
@@ -71,6 +71,15 @@ export class ParserOrchestrator {
const configuredMode: ParserMode = claim.task.parser.configured_mode;
const affinity = claim.task.parser.engine_affinity;
// Repeated hotel blocks have a deterministic relationship. Do not send
// them to the single-arrangement Agent contract, including on AI reparse.
if (routeId === 'arrangement_hotel_create' && [claim.messageText, ...claim.history.map(item => item.rawText)].some(isHotelBatchInput)) {
const started = performance.now();
const result = await parseProgramInput(this.programInput(claim));
return { result, decision: { routeId, configuredMode, authoritativeEngine: 'program',
programVersion: programVersion(result, routeId), programResult: result, programDurationMs: elapsed(started) } };
}
// Unresolved/legacy traffic and tasks explicitly pinned to AI preserve the
// original parser path. This is also the permanent affinity after an Auto
// fallback or the legacy one-time task AI reparse.
+40
View File
@@ -1044,6 +1044,40 @@ function needsInput(route: BusinessRouteDefinition, code: string, fields: string
} as ProgramParseResult;
}
export function isHotelBatchInput(text: string): boolean {
return Boolean(hotelBlocks(text));
}
function hotelBlocks(text: string): string[] | null {
const lines = text.replace(/\r\n?/gu, '\n').split('\n');
const starts = lines.flatMap((line, index) => /^\s*(?:团号|单号)\s*[::]/u.test(line) ? [index] : []);
if (starts.length < 2) return null;
if (lines.slice(0, starts[0]).some(line => line.trim() && line.trim() !== '安排酒店')) return [];
return starts.map((start, index) => ['安排酒店', ...lines.slice(start, starts[index + 1])].join('\n'));
}
async function parseHotelBatch(input: ProgramParseInput, route: BusinessRouteDefinition, blocks: string[]): Promise<ProgramParseResult> {
if (blocks.length < 2 || blocks.length > 10) return blocked(route, 'program_hotel_batch_invalid', '同团批量酒店必须为 2—10 条,每条从团号开始且字段完整,请重新发送整批。');
const arrangements: unknown[] = [];
let identifier = '';
for (let index = 0; index < blocks.length; index++) {
const parsed = await parseProgramInputInner({ ...input, history: [], rawText: blocks[index], routeId: route.routeId });
if (parsed.status !== 'agent_parse_passed' || !parsed.operation) return blocked(route, 'program_hotel_batch_invalid',
`第 ${index + 1} 条:${String(parsed.reply || '字段不完整')} 请修正后重新发送整批,尚未执行任何安排。`);
const data = parsed.operation.data as JsonRecord;
const group = String((data.existing_refs as JsonRecord).identifier).normalize('NFKC').replace(/\s+/gu, '').toUpperCase();
if (identifier && identifier !== group) return blocked(route, 'program_hotel_batch_invalid', '批量安排酒店只支持同一个团号,请按团分别发送。');
identifier = group;
arrangements.push(data.arrangement);
}
const operation = { action: 'arrangement_hotel', order_nature: 'formal', submit_mode: 'dry_run',
data: { existing_refs: { identifier }, arrangements } };
const errors = await validateParsedOperation(operation);
if (errors.length) return blocked(route, 'program_hotel_batch_invalid', errors.join(';'));
return { ...baseEnvelope(route), status: 'agent_parse_passed', blockers: [], operation,
reply: `已识别同团 ${arrangements.length} 条酒店新增安排,将按输入顺序逐条执行。`, no_plugin_dispatch: true, no_erp_write: true } as ProgramParseResult;
}
async function parseProgramInputInner(input: ProgramParseInput): Promise<ProgramParseResult> {
const suppliedRoute = input.routeId ? businessRouteById(input.routeId) : null;
const routeMessage = input.rawText || input.history?.[0]?.text || '';
@@ -1063,6 +1097,12 @@ async function parseProgramInputInner(input: ProgramParseInput): Promise<Program
...(input.history || []).map((turn) => ({ text: String(turn.text || ''), receivedAt: turn.receivedAt })),
{ text: String(input.rawText || ''), receivedAt: input.receivedAt }
].filter((turn, index, all) => turn.text.trim() && !(index === all.length - 1 && index > 0 && turn.text === all[index - 1].text));
if (route.routeId === 'arrangement_hotel_create') {
const batch = hotelBlocks(turns.at(-1)?.text || '');
if (batch) return parseHotelBatch(input, route, batch);
if (turns.slice(0, -1).some(turn => hotelBlocks(turn.text))) return blocked(route, 'program_hotel_batch_invalid',
'批量酒店需要保留每条对应关系,请修正后重新发送完整整批内容。');
}
const extracted = extractFields(route, turns);
if (extracted.multipleRoute) return blocked(route, 'program_multiple_actions', '同一任务补充中出现了另一个业务动作,不能合并解析。');
if (extracted.unknownFields.length) {
+9
View File
@@ -1,3 +1,4 @@
import { hotelBatch } from './hotel-batch.js';
import { normalizeTaskArtifactFileName } from './artifact-name.js';
import { arrangementSummaryText, isArrangementAction } from './arrangement-ledger.js';
import { receiptDate, teamProgressText } from './team-progress.js';
@@ -573,6 +574,14 @@ export function buildCustomerSuccessReceipt(
resultValue: unknown,
operationValue: unknown
): Record<string, unknown> {
const batch = object(resultValue).hotel_batch;
if (hotelBatch.inspect(batch)?.allCompleted) {
const resultText = hotelBatch.resultText(batch);
const title = '酒店批量安排成功';
const progress = teamProgressText(baseReceipt.team_progress, { title, resultText });
return { ...baseReceipt, reply: { contract: AGENTBUS_REPLY_CONTRACT_VERSION, profile: 'hotel-batch-v1',
text: progress || `📢【${title}】\n\n团号:${object(batch).group_number}\n\n${resultText}`, fields: { action: 'arrangement_hotel' } } };
}
const reply = buildCustomerSuccessReply(baseReceipt, resultValue, operationValue);
if (!reply) return baseReceipt;
const result = object(resultValue);
+34 -7
View File
@@ -1,3 +1,4 @@
import { hotelBatch, hotelBatchMatchesOperation, verifiedHotelBatchItems } from './hotel-batch.js';
import { EventEmitter } from 'node:events';
import { randomToken, encryptText, decryptText, sha256Bytes, sha256Text } from './crypto.js';
import type { AppConfig } from './config.js';
@@ -1226,6 +1227,11 @@ function splitOrderProbeReceiptVerified(result: Record<string, unknown>, report:
export function successReceiptFromResult(value: unknown): Record<string, unknown> | null {
const result = jsonObject(value);
if (result.hotel_batch) {
const stats = hotelBatch.inspect(result.hotel_batch);
return stats?.allCompleted ? { success: true, group_number: jsonObject(result.hotel_batch).group_number,
hotel_batch: result.hotel_batch, status: 'verified' } : null;
}
const report = jsonObject(result.report);
const verification = jsonObject(result.verification);
const blockers = [result.blockers, report.blockers]
@@ -2204,6 +2210,11 @@ const IMMUTABLE_EXECUTION_STATUSES = new Set([
export function classifyExecutionResult(value: unknown): ExecutionClassification {
const result = jsonObject(value);
const rawStatus = text(result.status || result.result_status || 'running').toLowerCase();
if (result.hotel_batch) {
const stats = hotelBatch.inspect(result.hotel_batch);
if (!stats) return { rawStatus, status: 'reconciliation_pending', terminal: true, uncertain: true };
if (stats.knownStopped && /blocked|failed/.test(rawStatus)) return { rawStatus, status: 'blocked', terminal: true, uncertain: false };
}
const noErpWrite = result.no_erp_write === true;
const writeAttempted = result.write_attempted === true
|| /submit_started|write_started|submitted/i.test(text(result.execution_phase));
@@ -6590,6 +6601,13 @@ export class TaskService {
resultObject = attachStoredArtifactMetadata(this.config, resultObject, taskId, stored);
}
const operation = decryptedJson(this.config, row.operation_ciphertext) || row.operation || null;
if (resultObject.hotel_batch) {
const stats = hotelBatch.inspect(resultObject.hotel_batch);
if (!stats || !hotelBatchMatchesOperation(operation, resultObject)) {
throw new TaskError('hotel_batch_evidence_mismatch', '酒店批量结果与原始任务或逐条回查证据不一致。');
}
}
const classification = classifyExecutionResult(resultObject);
const { rawStatus, status, uncertain, terminal } = classification;
const stage = text(resultObject.stage || 'erp').slice(0, 160);
@@ -6616,7 +6634,6 @@ export class TaskService {
Object.assign(resultObject, lifecycle);
const message = text(resultObject.message || (uncertain ? 'ERP 结果不确定,请勿重复提交同一任务,等待任务所属账号只读核验。' : '插件已返回状态。')).slice(0, 2_000);
const executionFailure = failureSummary(resultObject, '', status, stage);
const operation = decryptedJson(this.config, row.operation_ciphertext) || row.operation || null;
const baseSuccessReceipt = status === 'completed' ? successReceiptFromResult(resultObject) : null;
// This snapshot is exclusively derived by the server, never by a plugin.
if (baseSuccessReceipt) {
@@ -6649,7 +6666,10 @@ export class TaskService {
&& hasPrewriteNoErpEvidence(previousExecutionResult)
&& hasPrewriteNoErpEvidence(resultObject)
&& sameConnection;
const canFinalizeTerminalReconciliation = canFinalizeReconciliation || canFinalizeNoWriteReconciliation || canFinalizeDispatchNoWrite;
const canFinalizeBatchReconciliation = (text(row.status) === 'reconciliation_pending' || failedReconciliation)
&& text(attemptRow.status) === 'reconciliation_pending' && sameConnection && status === 'blocked'
&& lifecycle.reconciliation_resolved && hotelBatch.inspect(resultObject.hotel_batch)?.knownStopped === true;
const canFinalizeTerminalReconciliation = canFinalizeReconciliation || canFinalizeNoWriteReconciliation || canFinalizeDispatchNoWrite || canFinalizeBatchReconciliation;
if (executionStatusImmutable(text(row.status)) && !canFinalizeTerminalReconciliation) {
throw new TaskError('result_after_terminal', `任务已处于不可变状态 ${row.status},迟到结果已忽略。`);
}
@@ -6663,15 +6683,22 @@ export class TaskService {
taskId, executionId, operation, result: resultObject, receipt: baseSuccessReceipt,
effectiveAt: attemptRow.started_at, recordedAt: new Date().toISOString()
}) : null;
if (arrangementEvent) {
const batchEvents = verifiedHotelBatchItems(operation, resultObject).map(item => arrangementEventFromSuccess({
taskId, executionId: `${executionId}:hotel:${item.index + 1}`, effectiveAt: attemptRow.started_at,
recordedAt: new Date().toISOString(),
operation: { ...jsonObject(operation), data: { existing_refs: jsonObject(jsonObject(operation).data).existing_refs, arrangement: item.arrangement } },
result: item.result, receipt: successReceiptFromResult(item.result)
})).filter((event): event is NonNullable<typeof event> => Boolean(event));
if (arrangementEvent || batchEvents.length) {
const ledger = new ArrangementLedger(this.config);
await ledger.prepare(client, context, successReceiptFromResult);
await ledger.insert(client, context, arrangementEvent);
const history = await ledger.history(client, context, arrangementEvent.group_number);
for (const event of arrangementEvent ? [arrangementEvent, ...batchEvents] : batchEvents) await ledger.insert(client, context, event);
const summaryEvent = arrangementEvent || batchEvents[0];
const history = await ledger.history(client, context, summaryEvent.group_number);
// Freeze the account/group summary with this receipt in the SAME transaction.
successReceipt = buildCustomerSuccessReceipt({
if (successReceipt) successReceipt = buildCustomerSuccessReceipt({
...successReceipt,
arrangement_summary: arrangementSummary(history, arrangementEvent.group_number, arrangementEvent.recorded_at)
arrangement_summary: arrangementSummary(history, summaryEvent.group_number, summaryEvent.recorded_at)
}, resultObject, operation);
}
const progressInput = { taskId, executionId, operation, result: resultObject,
+6
View File
@@ -1,3 +1,4 @@
import { verifiedHotelBatchItems } from './hotel-batch.js';
import type pg from 'pg';
import type { AppConfig } from './config.js';
import { decryptText, encryptText, sha256Text } from './crypto.js';
@@ -49,6 +50,7 @@ export interface ProgressInput {
export function progressEventsFromSuccess(input: ProgressInput): ProgressEvent[] {
const op = obj(input.operation), data = obj(op.data), result = obj(input.result), report = obj(result.report), receipt = obj(input.receipt);
const action = txt(op.action), refs = obj(report.resolved_refs || result.resolved_refs);
if (Array.isArray(data.arrangements)) return [];
if (!ACTIONS.has(action) || result.status !== 'completed' || !Object.keys(receipt).length || receipt.success === false
|| result.uncertain === true || result.manual_review_required === true || report.manual_review_required === true
|| [result.blockers, report.blockers].some(v => Array.isArray(v) && v.length)) return [];
@@ -116,6 +118,10 @@ export function progressEventsFromSuccess(input: ProgressInput): ProgressEvent[]
// An interrupted batch is not a successful task. Preserve only independently
// confirmed rows from the existing adapter's per-item completion contract.
export function partialBatchProgressEvents(input: ProgressInput): ProgressEvent[] {
if (obj(input.result).hotel_batch) return verifiedHotelBatchItems(input.operation, input.result).flatMap(item =>
progressEventsFromSuccess({ ...input, executionId: `${input.executionId}:hotel:${item.index + 1}`,
operation: { ...obj(input.operation), data: { existing_refs: obj(obj(input.operation).data).existing_refs, arrangement: item.arrangement } },
result: item.result, receipt: { success: true, group_number: obj(obj(obj(input.operation).data).existing_refs).identifier } }));
const op = obj(input.operation), result = obj(input.result), report = obj(result.report);
if (result.status === 'completed' || !['blocked', 'failed', 'reconciliation_pending'].includes(txt(result.status))) return [];
const batches = list(result.batch_results).length ? list(result.batch_results) : list(report.batch_results);