迭代批量安排酒店
This commit is contained in:
1 parent
4cfbefffe8
commit
c2c71d35af
48 files changed
+5670
-57
No files matched your search
@@ -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' },
|
||||
|
||||
@@ -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') : [];
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user