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

1080 lines
52 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 {
BUSINESS_ROUTES,
businessRouteById,
firstNonEmptyLine,
normalizeDirectiveText,
resolveBusinessRoute,
resolveRouteFieldLabel,
type BusinessRouteDefinition,
type BusinessRouteId,
type ProgramFieldDefinition
} from './business-routes.js';
import { validateParsedOperation } from './parser-contract.js';
export const PROGRAM_PARSER_FALLBACK_CODES = Object.freeze(new Set([
'program_unsupported_syntax',
'program_unknown_field',
'program_internal_error',
'program_timeout',
'program_contract_invalid'
]));
const PASSENGER_HEADERS = Object.freeze([
'序号', '姓名', 'NAME', '性别', '出生日期', '出生地', '证件类型', '证件号码',
'签发地', '签发日', '有效期', '电话', '备注'
]);
export const ALL_EXPORT_TYPES = Object.freeze([
'xingyou-confirm',
'liantai-confirm',
'job-order',
'visitor-list',
'guide-confirm',
'hotel-preorder',
'transport-preorder',
'filing-current',
'filing-history',
'pickup-sign'
]);
const EXPORT_TYPE_ALIASES = new Map<string, string>([
['星游客户确认单', 'xingyou-confirm'],
['星游确认单', 'xingyou-confirm'],
['联泰客户确认单', 'liantai-confirm'],
['联泰确认单', 'liantai-confirm'],
['团队申请书', 'job-order'],
['游客名单', 'visitor-list'],
['游客信息', 'visitor-list'],
['导游通知书', 'guide-confirm'],
['导游确认单', 'guide-confirm'],
['酒店预订单', 'hotel-preorder'],
['团体票申请书', 'transport-preorder'],
['大交通预订单', 'transport-preorder'],
['备案确认书', 'filing-current'],
['历史备案确认书', 'filing-history'],
['接机牌', 'pickup-sign']
]);
const SHARED_PLAN_VISITOR_EXPORT_ALIASES = new Set([
'整团游客信息'
]);
const NEXT_OCCURRENCE_DATE_ROUTES = new Set<BusinessRouteId>([
'team_order_create',
'team_order_batch_create',
'shared_plan_create',
'shared_child_order_create',
'order_update_shared_plan',
'order_update_shared_child',
'order_update_independent',
'arrangement_hotel_update',
'order_cancel',
'order_restore'
]);
type JsonRecord = Record<string, unknown>;
export interface ProgramHistoryTurn {
text: string;
receivedAt?: string | Date;
}
export interface ProgramParseInput {
rawText: string;
receivedAt?: string | Date;
routeId?: BusinessRouteId | null;
history?: readonly ProgramHistoryTurn[];
timeoutMs?: number;
}
export type ProgramParseResult = JsonRecord & {
status: 'agent_parse_passed' | 'agent_parse_needs_input' | 'agent_parse_blocked';
blockers: string[];
operation: JsonRecord | null;
business_route_id: BusinessRouteId | null;
program_parser_version: string;
};
interface DateContext {
year: number;
month: number;
day: number;
}
interface FieldParseSuccess {
ok: true;
value: unknown;
}
interface FieldParseFailure {
ok: false;
message: string;
}
type FieldParseResult = FieldParseSuccess | FieldParseFailure;
interface ExtractedFields {
values: Map<string, string>;
conflicts: Map<string, string[]>;
unknownFields: string[];
ambiguousFields: string[];
unsupportedLines: string[];
multipleRoute: BusinessRouteId | null;
}
interface BuildSuccess {
ok: true;
operation: JsonRecord;
}
interface BuildFailure {
ok: false;
code: string;
message: string;
field?: string;
}
type BuildResult = BuildSuccess | BuildFailure;
function isRecord(value: unknown): value is JsonRecord {
return Boolean(value && typeof value === 'object' && !Array.isArray(value));
}
function compactText(value: unknown): string {
return String(value || '').normalize('NFKC').trim();
}
function isPlaceholder(value: string): boolean {
const normalized = compactText(value);
return !normalized || (/^<[^>]+>$/u.test(normalized)) || (/^请(?:填写|输入|补充)/u.test(normalized));
}
function dateContext(value: string | Date | undefined): DateContext {
const parsed = value instanceof Date ? value : value ? new Date(value) : new Date();
const safe = Number.isNaN(parsed.getTime()) ? new Date() : parsed;
const parts = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit'
}).formatToParts(safe);
const part = (type: string) => Number(parts.find((item) => item.type === type)?.value || 0);
return { year: part('year'), month: part('month'), day: part('day') };
}
function pad2(value: number): string {
return String(value).padStart(2, '0');
}
function validDate(year: number, month: number, day: number): boolean {
if (year < 2000 || year > 2100 || month < 1 || month > 12 || day < 1 || day > 31) return false;
const date = new Date(Date.UTC(year, month - 1, day));
return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day;
}
function formatDate(year: number, month: number, day: number): string {
return `${year}-${pad2(month)}-${pad2(day)}`;
}
function parseDateParts(rawValue: string): { year: number | null; month: number; day: number } | null {
const value = compactText(rawValue)
.replace(/[年月]/gu, '-')
.replace(/日/gu, '')
.replace(/[/.]/gu, '-')
.replace(/\s+/gu, '');
const full = value.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/u);
if (full) return { year: Number(full[1]), month: Number(full[2]), day: Number(full[3]) };
const short = value.match(/^(\d{1,2})-(\d{1,2})$/u);
if (short) return { year: null, month: Number(short[1]), day: Number(short[2]) };
return null;
}
function parseDate(rawValue: string, context: DateContext, {
nextOccurrence = false,
referenceYear
}: { nextOccurrence?: boolean; referenceYear?: number } = {}): FieldParseResult {
const parts = parseDateParts(rawValue);
if (!parts) return { ok: false, message: '请使用 YYYY-MM-DD 或 MM-DD 日期格式。' };
let year = parts.year ?? referenceYear ?? context.year;
if (parts.year === null && referenceYear === undefined && nextOccurrence) {
if (parts.month < context.month || (parts.month === context.month && parts.day < context.day)) year += 1;
}
if (!validDate(year, parts.month, parts.day)) return { ok: false, message: '日期不存在或超出支持范围。' };
return { ok: true, value: formatDate(year, parts.month, parts.day) };
}
function parseDateRange(rawValue: string, context: DateContext, nextOccurrence: boolean): FieldParseResult {
const pieces = compactText(rawValue).split(/\s*(?:到|至|~|~|—|–)\s*/u).filter(Boolean);
if (pieces.length !== 2) return { ok: false, message: '日期范围必须包含明确的开始日期和结束日期。' };
const startResult = parseDate(pieces[0], context, { nextOccurrence });
if (!startResult.ok) return startResult;
const start = String(startResult.value);
const startYear = Number(start.slice(0, 4));
const endParts = parseDateParts(pieces[1]);
if (!endParts) return { ok: false, message: '结束日期格式无效。' };
let endYear = endParts.year ?? startYear;
let endResult = parseDate(pieces[1], context, { referenceYear: endYear });
if (!endResult.ok) return endResult;
if (String(endResult.value) < start && endParts.year === null) {
endYear += 1;
endResult = parseDate(pieces[1], context, { referenceYear: endYear });
if (!endResult.ok) return endResult;
}
if (String(endResult.value) < start) return { ok: false, message: '结束日期不能早于开始日期。' };
return { ok: true, value: { start_date: start, end_date: String(endResult.value) } };
}
function parseDateOrRange(rawValue: string, context: DateContext, nextOccurrence: boolean): FieldParseResult {
if (/(?:到|至|~|~|—|–)/u.test(rawValue)) return parseDateRange(rawValue, context, nextOccurrence);
const single = parseDate(rawValue, context, { nextOccurrence });
return single.ok ? { ok: true, value: [single.value] } : single;
}
function parseDateList(rawValue: string, context: DateContext, nextOccurrence: boolean, range?: JsonRecord): FieldParseResult {
const pieces = compactText(rawValue).split(/[\/、,,;;\s]+/u).filter(Boolean);
if (!pieces.length) return { ok: false, message: '请至少填写一个日期。' };
const values: string[] = [];
const rangeStart = typeof range?.start_date === 'string' ? range.start_date : '';
const rangeEnd = typeof range?.end_date === 'string' ? range.end_date : '';
for (const piece of pieces) {
const parts = parseDateParts(piece);
let result = parseDate(piece, context, {
nextOccurrence: !rangeStart && nextOccurrence,
referenceYear: rangeStart && parts?.year === null ? Number(rangeStart.slice(0, 4)) : undefined
});
if (result.ok && rangeStart && rangeEnd && String(result.value) < rangeStart && parts?.year === null) {
result = parseDate(piece, context, { referenceYear: Number(rangeStart.slice(0, 4)) + 1 });
}
if (!result.ok) return result;
const date = String(result.value);
if (rangeStart && (date < rangeStart || date > rangeEnd)) {
return { ok: false, message: `日期 ${date} 不在填写的发团范围内。` };
}
if (values.includes(date)) return { ok: false, message: `日期 ${date} 重复。` };
values.push(date);
}
values.sort();
return { ok: true, value: values };
}
function namedRef(rawValue: string): JsonRecord {
const keyword = compactText(rawValue);
return { name: keyword.replace(/人名币/gu, '人民币'), keyword };
}
function positiveInteger(rawValue: string): FieldParseResult {
const normalized = compactText(rawValue).replace(/(?:人|位|个|间|辆|份|张)$/u, '');
if (!/^\d+$/u.test(normalized) || Number(normalized) < 1) return { ok: false, message: '必须填写正整数。' };
return { ok: true, value: Number(normalized) };
}
function booleanValue(rawValue: string): FieldParseResult {
const value = compactText(rawValue).toLowerCase();
if (['是', '确认', '已确认', 'true', 'yes', 'y', '1'].includes(value)) return { ok: true, value: true };
if (['否', '不', '不覆盖', 'false', 'no', 'n', '0'].includes(value)) return { ok: true, value: false };
return { ok: false, message: '请明确填写“是”或“否”。' };
}
function parsePassengerCounts(rawValue: string): FieldParseResult {
const value = compactText(rawValue)
.replace(/[((]\s*占床?\s*[))]/gu, '占')
.replace(/[((]\s*不占床?\s*[))]/gu, '不占')
.replace(/\s+/gu, '');
const explicit: JsonRecord = {};
const explicitPattern = /(不占床儿童|儿童不占床|小孩不占床|占床儿童|儿童占床|小孩占床|成人|大人|婴儿|领队)[::]?(\d+)(?:人|位)?/gu;
const explicitKeys = new Map<string, string>([
['成人', 'adult'], ['大人', 'adult'],
['占床儿童', 'child_bed'], ['儿童占床', 'child_bed'], ['小孩占床', 'child_bed'],
['不占床儿童', 'child_no_bed'], ['儿童不占床', 'child_no_bed'], ['小孩不占床', 'child_no_bed'],
['婴儿', 'infant'], ['领队', 'leader']
]);
const explicitMatches = [...value.matchAll(explicitPattern)];
for (const match of explicitMatches) {
const key = explicitKeys.get(match[1])!;
if (explicit[key] !== undefined) return { ok: false, message: `人数分类 ${match[1]} 重复。` };
const count = Number(match[2]);
if (!Number.isSafeInteger(count)) return { ok: false, message: `人数分类 ${match[1]} 必须是安全范围内的非负整数。` };
explicit[key] = count;
}
if (Object.keys(explicit).length) {
const residue = value.replace(explicitPattern, '').replace(/[++、,,;;]/gu, '');
if (residue) return { ok: false, message: `包含无法识别的人数内容:${residue}。` };
const total = Object.values(explicit).reduce<number>((sum, item) => sum + Number(item), 0);
return total > 0 && Number.isSafeInteger(total)
? { ok: true, value: explicit }
: { ok: false, message: '人数合计必须大于 0 且在安全整数范围内。' };
}
const pieces = value.split(/[++]/u);
if (!pieces.length || pieces.length > 5 || pieces.some((piece) => !piece)) {
return { ok: false, message: '无法识别人数分类格式。' };
}
if (pieces.some((piece) => !/^\d+(?:占|不占)?$/u.test(piece))) {
return { ok: false, message: '人数只能使用数字及“占/不占”分类。' };
}
const childKind = (piece: string): 'child_bed' | 'child_no_bed' | null => {
if (/不占$/u.test(piece)) return 'child_no_bed';
if (/占$/u.test(piece)) return 'child_bed';
return null;
};
const taggedChildPair = pieces.length >= 4
&& childKind(pieces[1]) !== null
&& childKind(pieces[2]) !== null
&& childKind(pieces[1]) !== childKind(pieces[2]);
const validLongDistribution = pieces.length >= 4
&& (/^\d+$/u.test(pieces[0]))
&& (pieces.every((piece) => /^\d+$/u.test(piece))
|| (taggedChildPair && pieces.slice(3).every((piece) => /^\d+$/u.test(piece))));
if (!/^\d+$/u.test(pieces[0])
|| (pieces.length === 3 && (!/^\d+(?:占|不占)$/u.test(pieces[1]) || !/^\d+$/u.test(pieces[2])))
|| (pieces.length >= 4 && !validLongDistribution)) {
return { ok: false, message: '人数分类位置无效,请按成人+占/不占儿童+领队,或成人+占床儿童+不占床儿童+领队填写。' };
}
const numbers = pieces.map((piece) => Number(piece.match(/^\d+/u)?.[0]));
if (numbers.some((number) => !Number.isSafeInteger(number) || number < 0)) return { ok: false, message: '人数必须是安全范围内的非负整数。' };
const result: JsonRecord = { adult: numbers[0] };
if (pieces.length === 2) {
if (/不占/u.test(pieces[1])) result.child_no_bed = numbers[1];
else if (/占/u.test(pieces[1])) result.child_bed = numbers[1];
else result.leader = numbers[1];
} else if (pieces.length === 3) {
if (/不占/u.test(pieces[1])) result.child_no_bed = numbers[1];
else if (/占/u.test(pieces[1])) result.child_bed = numbers[1];
else return { ok: false, message: '三段人数的第二段必须注明“占”或“不占”。' };
result.leader = numbers[2];
} else if (pieces.length === 4) {
if (taggedChildPair) {
result[childKind(pieces[1])!] = numbers[1];
result[childKind(pieces[2])!] = numbers[2];
} else {
result.child_bed = numbers[1];
result.child_no_bed = numbers[2];
}
result.leader = numbers[3];
} else if (pieces.length === 5) {
if (taggedChildPair) {
result[childKind(pieces[1])!] = numbers[1];
result[childKind(pieces[2])!] = numbers[2];
} else {
result.child_bed = numbers[1];
result.child_no_bed = numbers[2];
}
result.infant = numbers[3];
result.leader = numbers[4];
}
const total = Object.values(result).reduce<number>((sum, item) => sum + Number(item), 0);
return total > 0 && Number.isSafeInteger(total)
? { ok: true, value: result }
: { ok: false, message: '人数合计必须大于 0 且在安全整数范围内。' };
}
const ROOM_CODES = new Map<string, string>([
['SGL', 'SGL'], ['单', 'SGL'], ['单间', 'SGL'], ['单人间', 'SGL'],
['TWN', 'TWN'], ['标', 'TWN'], ['标间', 'TWN'], ['双标间', 'TWN'],
['TRP', 'TRP'], ['三', 'TRP'], ['三人间', 'TRP'],
['DBL', 'DBL'], ['双', 'DBL'], ['双人间', 'DBL'], ['大床', 'DBL'], ['大床房', 'DBL']
]);
function parseRoomCounts(rawValue: string): FieldParseResult {
const value = compactText(rawValue).replace(/\s+/gu, '');
const result: JsonRecord = {};
const consumed: string[] = [];
const pattern = /(\d+)\s*(SGL|TWN|TRP|DBL|单人间|单间|单|双标间|标间|标|三人间|三|双人间|大床房|大床|双)/giu;
for (const match of value.matchAll(pattern)) {
const code = ROOM_CODES.get(match[2].toUpperCase()) || ROOM_CODES.get(match[2]);
if (!code) continue;
const count = Number(match[1]);
if (count < 1 || result[code] !== undefined) return { ok: false, message: '房型数量必须是正整数且同一房型不能重复。' };
result[code] = count;
consumed.push(match[0]);
}
if (!Object.keys(result).length) return { ok: false, message: '无法识别房型,请使用“8标1单”或“3SGL+2TWN”。' };
let residue = value;
for (const item of consumed) residue = residue.replace(item, '');
residue = residue.replace(/[++、,,;;间房]/gu, '');
if (residue) return { ok: false, message: `包含无法识别的房型内容:${residue}。` };
return { ok: true, value: result };
}
function parsePassengerTsv(rawValue: string): FieldParseResult {
const text = String(rawValue || '').replace(/\r\n?/gu, '\n').replace(/^\n+|\n+$/gu, '');
const lines = text.split('\n').filter((line) => line.length > 0);
if (lines.length < 2) return { ok: false, message: '名单必须包含规定表头和至少一行游客。' };
const headers = lines[0].split('\t').map((cell) => cell.trim());
if (headers.length !== PASSENGER_HEADERS.length || headers.some((header, index) => header !== PASSENGER_HEADERS[index])) {
return { ok: false, message: '名单必须使用规定的 13 列 TSV 表头和顺序。' };
}
const sequence = new Set<number>();
let leaderContact: JsonRecord | undefined;
for (let index = 1; index < lines.length; index += 1) {
const cells = lines[index].split('\t');
if (cells.length !== PASSENGER_HEADERS.length) return { ok: false, message: `名单第 ${index} 行不是完整 13 列。` };
const rowNo = Number(cells[0].trim());
if (!Number.isInteger(rowNo) || rowNo < 1) return { ok: false, message: `名单第 ${index} 行序号必须是正整数。` };
if (sequence.has(rowNo)) return { ok: false, message: `名单序号 ${rowNo} 重复。` };
sequence.add(rowNo);
if (compactText(cells[12]) === '领队') {
if (leaderContact) return { ok: false, message: '名单备注列只能标记一位领队。' };
const name = String(cells[1] || '').trim();
const phone = String(cells[11] || '').trim();
if (!name || !phone) return { ok: false, message: `名单第 ${index} 行标记为领队时,姓名和电话都必须填写。` };
if (name.length > 200 || phone.length > 200) return { ok: false, message: `名单第 ${index} 行领队姓名或电话超过长度限制。` };
leaderContact = { sequence: rowNo, name, phone };
}
}
return {
ok: true,
value: {
text,
row_count: lines.length - 1,
...(leaderContact ? { leader_contact: leaderContact } : {})
}
};
}
function parseExportType(rawValue: string): FieldParseResult {
const pieces = compactText(rawValue).split(/[\/、,,;;++]/u).map((item) => item.trim()).filter(Boolean);
if (pieces.some((piece) => SHARED_PLAN_VISITOR_EXPORT_ALIASES.has(piece))) {
if (pieces.length !== 1) return { ok: false, message: '整团游客信息必须单独导出,不能与游客名单或其他文件类型混用。' };
return { ok: true, value: { types: ['visitor-list'], target_kind: 'shared_plan' } };
}
if (pieces.length === 1 && pieces[0] === '全部') return { ok: true, value: { types: [...ALL_EXPORT_TYPES] } };
const values: string[] = [];
for (const piece of pieces) {
const mapped = EXPORT_TYPE_ALIASES.get(piece) || (ALL_EXPORT_TYPES.includes(piece) ? piece : '');
if (!mapped) return { ok: false, message: `无法识别文件类型“${piece}”。` };
if (!values.includes(mapped)) values.push(mapped);
}
return values.length ? { ok: true, value: { types: values } } : { ok: false, message: '请填写文件类型。' };
}
function splitControlledClauses(rawValue: string): string[] {
const primary = compactText(rawValue).split(/[;;\n]+/u).map((item) => item.trim()).filter(Boolean);
const output: string[] = [];
for (const item of primary) {
const commaPieces = item.split(/[,,]/u).map((piece) => piece.trim()).filter(Boolean);
if (commaPieces.length > 1 && commaPieces.every((piece) => /(?:改为|设为|调整为|=|:|:)/u.test(piece))) output.push(...commaPieces);
else output.push(item);
}
return output;
}
function controlledAssignment(clause: string): { target: string; value: string } | null {
const match = clause.match(/^(.+?)(?:调整为|变更为|修改为|改为|设为|=|:|:)\s*(.+)$/u);
const target = compactText(match?.[1] || '').replace(/\s+/gu, '');
const value = compactText(match?.[2] || '');
return target && value ? { target, value } : null;
}
function parseIndependentUpdates(rawValue: string, context: DateContext, nextOccurrence = false): BuildResult {
const actions: JsonRecord[] = [];
const push = (target: string, operation: string, value: unknown): BuildFailure | null => {
if (actions.some((item) => item.target === target)) return { ok: false, code: 'program_field_conflict', message: `修改目标 ${target} 重复。`, field: 'update_targets' };
actions.push({ target, operation, value });
return null;
};
for (const clauseValue of splitControlledClauses(rawValue)) {
const clause = clauseValue.replace(/^将/u, '').trim();
const assignment = controlledAssignment(clause);
if (!assignment) return { ok: false, code: 'program_unsupported_syntax', message: `无法识别修改表达“${clause}”。`, field: 'update_targets' };
const { target, value } = assignment;
if (/^(?:人数|预估人数|游客人数)$/u.test(target)) {
const counts = parsePassengerCounts(value);
if (!counts.ok) return { ok: false, code: 'program_invalid_value', message: counts.message, field: 'update_targets' };
for (const [key, count] of Object.entries(counts.value as JsonRecord)) {
const conflict = push(`pax.${key}`, 'set', count);
if (conflict) return conflict;
}
continue;
}
if (/^(?:用房数量|房型数量|用房|房间)$/u.test(target)) {
const rooms = parseRoomCounts(value);
if (!rooms.ok) return { ok: false, code: 'program_invalid_value', message: rooms.message, field: 'update_targets' };
for (const [key, count] of Object.entries(rooms.value as JsonRecord)) {
const conflict = push(`rooms.${key}`, 'set', count);
if (conflict) return conflict;
}
continue;
}
const numberTargets: Array<[RegExp, string, number]> = [
[/^(?:标间数|双标间数|twin_room_count)$/iu, 'twin_room_count', 1],
[/^(?:行程天数|天数|trip_days)$/iu, 'trip_days', 1],
[/^(?:成人数|成人|pax\.adult)$/iu, 'pax.adult', 0],
[/^(?:占床儿童数|占床儿童|pax\.child_bed)$/iu, 'pax.child_bed', 0],
[/^(?:不占床儿童数|不占床儿童|pax\.child_no_bed)$/iu, 'pax.child_no_bed', 0],
[/^(?:婴儿数|婴儿|pax\.infant)$/iu, 'pax.infant', 0],
[/^(?:领队人数|pax\.leader)$/iu, 'pax.leader', 0],
[/^(?:预计总人数|pax\.expected_total)$/iu, 'pax.expected_total', 0],
[/^(?:单人间数|SGL|rooms\.SGL)$/iu, 'rooms.SGL', 0],
[/^(?:双标间数|TWN|rooms\.TWN)$/iu, 'rooms.TWN', 0],
[/^(?:三人间数|TRP|rooms\.TRP)$/iu, 'rooms.TRP', 0],
[/^(?:大床房数|DBL|rooms\.DBL)$/iu, 'rooms.DBL', 0],
[/^(?:不占床房数|HNM|rooms\.HNM)$/iu, 'rooms.HNM', 0],
[/^(?:领队房数|TL|rooms\.TL)$/iu, 'rooms.TL', 0]
];
const numberTarget = numberTargets.find(([pattern]) => pattern.test(target));
if (numberTarget) {
const normalized = compactText(value).replace(/(?:人|位|个|间|天)$/u, '');
if (!/^\d+$/u.test(normalized) || Number(normalized) < numberTarget[2]) {
return { ok: false, code: 'program_invalid_value', message: `修改目标必须是大于等于 ${numberTarget[2]} 的整数。`, field: 'update_targets' };
}
const conflict = push(numberTarget[1], 'set', Number(normalized));
if (conflict) return conflict;
continue;
}
if (/^(?:出发日期|发团日期|departure_date)$/iu.test(target)) {
const parsed = parseDate(value, context, { nextOccurrence });
if (!parsed.ok) return { ok: false, code: 'program_invalid_value', message: parsed.message, field: 'update_targets' };
const conflict = push('departure_date', 'set', parsed.value);
if (conflict) return conflict;
continue;
}
const namedTargets: Array<[RegExp, string]> = [
[/^(?:预订客户|预定客户|客户|customer)$/iu, 'customer'],
[/^(?:产品名称|产品|product)$/iu, 'product'],
[/^(?:领队|leader)$/iu, 'leader'],
[/^(?:线路|行程线路|route)$/iu, 'route']
];
const namedTarget = namedTargets.find(([pattern]) => pattern.test(target));
if (namedTarget) {
const conflict = push(namedTarget[1], 'set', namedRef(value));
if (conflict) return conflict;
continue;
}
const textTargets: Array<[RegExp, string]> = [
[/^(?:状态|status)(?:追加|补充)?$/iu, 'status'],
[/^(?:备注|remark)(?:追加|补充)?$/iu, 'remark'],
[/^(?:下单备注|xiadanbeizhu)(?:追加|补充)?$/iu, 'xiadanbeizhu'],
[/^(?:订房说明|booking_note)(?:追加|补充)?$/iu, 'booking_note']
];
const textTarget = textTargets.find(([pattern]) => pattern.test(target));
if (textTarget) {
const operation = /(?:追加|补充)/u.test(target) ? 'append' : 'set';
const conflict = push(textTarget[1], operation, value);
if (conflict) return conflict;
continue;
}
return { ok: false, code: 'program_unsupported_syntax', message: `未登记的独立团修改目标:“${clause}”。`, field: 'update_targets' };
}
return actions.length
? actions.length <= 32
? { ok: true, operation: { actions } }
: { ok: false, code: 'program_invalid_value', message: '一次最多允许 32 个修改目标。', field: 'update_targets' }
: { ok: false, code: 'program_unsupported_syntax', message: '目标修改中没有可执行字段。', field: 'update_targets' };
}
function parseHotelChanges(rawValue: string, context: DateContext, nextOccurrence = false): BuildResult {
const changes: JsonRecord = {};
for (const clause of splitControlledClauses(rawValue)) {
const assignment = controlledAssignment(clause);
if (!assignment) return { ok: false, code: 'program_unsupported_syntax', message: `无法识别变更表达“${clause}”。`, field: 'hotel_changes' };
const { target, value } = assignment;
if (/^(?:离店日期|退房日期)$/u.test(target)) {
if (changes.end_date !== undefined) return { ok: false, code: 'program_field_conflict', message: '离店日期重复。', field: 'hotel_changes' };
const parsed = parseDate(value, context, { nextOccurrence });
if (!parsed.ok) return { ok: false, code: 'program_invalid_value', message: parsed.message, field: 'hotel_changes' };
changes.end_date = parsed.value;
} else if (/^(?:房间数|房间总数|间数)$/u.test(target)) {
if (changes.room_count !== undefined) return { ok: false, code: 'program_field_conflict', message: '房间数重复。', field: 'hotel_changes' };
const parsed = positiveInteger(value);
if (!parsed.ok) return { ok: false, code: 'program_invalid_value', message: parsed.message, field: 'hotel_changes' };
changes.room_count = parsed.value;
} else {
return { ok: false, code: 'program_business_rule_blocked', message: '安排变更当前只支持酒店离店日期和房间数。', field: 'hotel_changes' };
}
}
return Object.keys(changes).length
? { ok: true, operation: changes }
: { ok: false, code: 'program_invalid_value', message: '请填写离店日期和/或目标房间数。', field: 'hotel_changes' };
}
function normalizedComparableValue(value: string): string {
return compactText(value).replace(/\r\n?/gu, '\n');
}
function simpleRequiredKeys(route: BusinessRouteDefinition, values: Map<string, string>): string[] {
const missing = route.fields.filter((field) => field.required && isPlaceholder(values.get(field.key) || '')).map((field) => field.key);
if (['passenger_list_import_independent', 'passenger_list_import_shared_child', 'order_update_shared_child',
'order_update_independent', 'order_cancel', 'order_restore', 'confirmation_export'].includes(route.routeId)
&& !isPlaceholder(values.get('order_no') || '')) {
return missing.filter((key) => !['customer', 'departure_date'].includes(key));
}
if (route.routeId === 'order_update_shared_plan' && !isPlaceholder(values.get('order_no') || '')) {
return missing.filter((key) => key !== 'departure_date');
}
return missing;
}
function extractFields(route: BusinessRouteDefinition, turns: readonly ProgramHistoryTurn[]): ExtractedFields {
const values = new Map<string, string>();
const conflicts = new Map<string, string[]>();
const unknownFields: string[] = [];
const ambiguousFields: string[] = [];
const unsupportedLines: string[] = [];
let multipleRoute: BusinessRouteId | null = null;
for (const turn of turns) {
const lines = String(turn.text || '').replace(/^\uFEFF/u, '').replace(/\r\n?/gu, '\n').split('\n');
const nonEmptyIndex = lines.findIndex((line) => line.trim());
if (nonEmptyIndex >= 0) {
const resolution = resolveBusinessRoute(lines[nonEmptyIndex]);
if (resolution.routeId) {
if (resolution.routeId !== route.routeId) multipleRoute = resolution.routeId;
lines.splice(nonEmptyIndex, 1);
} else if (normalizeDirectiveText(lines[nonEmptyIndex]) === normalizeDirectiveText(route.directive)) {
lines.splice(nonEmptyIndex, 1);
}
}
const turnValues = new Map<string, string>();
const turnConflicts = new Map<string, string[]>();
const unlabeled: string[] = [];
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index];
if (!line.trim()) continue;
const delimiter = line.search(/[::]/u);
if (delimiter < 0) {
const embeddedRoute = resolveBusinessRoute(line);
if (embeddedRoute.routeId) {
if (embeddedRoute.routeId !== route.routeId) multipleRoute = embeddedRoute.routeId;
continue;
}
unlabeled.push(line);
continue;
}
const label = line.slice(0, delimiter);
const resolution = resolveRouteFieldLabel(route, label);
if (!resolution.field) {
const embeddedRoute = resolveBusinessRoute(label);
if (embeddedRoute.routeId && embeddedRoute.routeId !== route.routeId) {
multipleRoute = embeddedRoute.routeId;
continue;
}
if (resolution.match === 'ambiguous') ambiguousFields.push(label.trim());
else unknownFields.push(label.trim());
continue;
}
let rawValue = line.slice(delimiter + 1).trim();
if (resolution.field.kind === 'passenger_tsv') {
const rows: string[] = rawValue ? [rawValue] : [];
let nextIndex = index + 1;
for (; nextIndex < lines.length; nextIndex += 1) {
const nextLine = lines[nextIndex];
const nextDelimiter = nextLine.search(/[::]/u);
if (nextDelimiter >= 0) {
const nextResolution = resolveRouteFieldLabel(route, nextLine.slice(0, nextDelimiter));
if (nextResolution.field) break;
}
if (nextLine.length) rows.push(nextLine);
}
rawValue = rows.join('\n').replace(/^\n+|\n+$/gu, '');
index = nextIndex - 1;
}
const previous = turnValues.get(resolution.field.key);
if (previous !== undefined && normalizedComparableValue(previous) !== normalizedComparableValue(rawValue)) {
turnConflicts.set(resolution.field.key, [previous, rawValue]);
} else {
turnValues.set(resolution.field.key, rawValue);
}
}
const meaningfulUnlabeled = unlabeled.map((line) => line.trim()).filter(Boolean);
if (meaningfulUnlabeled.length) {
const merged = new Map(values);
for (const [key, value] of turnValues) merged.set(key, value);
const missing = simpleRequiredKeys(route, merged);
if (meaningfulUnlabeled.length === 1 && missing.length === 1 && !turnValues.has(missing[0])) {
turnValues.set(missing[0], meaningfulUnlabeled[0]);
} else {
unsupportedLines.push(...meaningfulUnlabeled);
}
}
for (const [key, value] of turnValues) {
values.set(key, value);
// A later explicit value resolves a conflict from an earlier turn. A
// conflict created inside this same turn is restored immediately below.
conflicts.delete(key);
}
for (const [key, conflictValues] of turnConflicts) conflicts.set(key, conflictValues);
}
return { values, conflicts, unknownFields, ambiguousFields, unsupportedLines, multipleRoute };
}
function commonLookupData(values: Map<string, unknown>, kind?: string): JsonRecord {
const data: JsonRecord = {};
const identifier = compactText(values.get('order_no'));
if (identifier || kind) data.existing_refs = { ...(kind ? { kind } : {}), ...(identifier ? { identifier } : {}) };
if (values.get('customer')) data.customer = values.get('customer');
if (values.get('product')) data.product = values.get('product');
if (values.get('leader')) data.leader = values.get('leader');
if (values.get('departure_date')) data.departure_dates = [values.get('departure_date')];
return data;
}
function specialRequests(values: Map<string, unknown>): string | undefined {
const pieces: string[] = [];
if (values.get('special_requests')) pieces.push(String(values.get('special_requests')));
if (values.get('business_reason')) pieces.push(`业务原因:${String(values.get('business_reason'))}`);
if (values.get('expected_time')) pieces.push(`期望完成时间:${String(values.get('expected_time'))}`);
return pieces.length ? pieces.join(';') : undefined;
}
function operation(action: string, data: JsonRecord): JsonRecord {
return { action, order_nature: 'formal', submit_mode: 'dry_run', data };
}
function buildOperation(route: BusinessRouteDefinition, values: Map<string, unknown>, context: DateContext): BuildResult {
const special = specialRequests(values);
switch (route.routeId) {
case 'team_order_create':
return { ok: true, operation: operation(route.action, {
customer: values.get('customer'), product: values.get('product'),
departure_dates: [values.get('departure_date')], passenger_counts: values.get('passenger_counts'),
room_counts: values.get('room_counts'), ...(special ? { special_requests: special } : {})
}) };
case 'team_order_batch_create':
if (!Array.isArray(values.get('departure_dates')) || (values.get('departure_dates') as unknown[]).length < 2) {
return { ok: false, code: 'program_invalid_value', message: '独立团批量下单至少需要两个发团周期日期。', field: 'departure_dates' };
}
return { ok: true, operation: operation(route.action, {
customer: values.get('customer'), product: values.get('product'), departure_dates: values.get('departure_dates'),
recurrence: { ...(values.get('departure_range') as JsonRecord), pattern: 'specified_dates' },
passenger_counts: values.get('passenger_counts'), room_counts: values.get('room_counts'),
...(special ? { special_requests: special } : {})
}) };
case 'shared_plan_create': {
const source = values.get('departure_source');
const sourceIsRange = isRecord(source);
const dates = values.get('departure_dates') || (Array.isArray(source) ? source : undefined);
if (sourceIsRange && !dates) return { ok: false, code: 'program_missing_required', message: '日期范围下必须填写发团周期。', field: 'departure_dates' };
const splitOrder = values.get('split_customer') || values.get('split_passenger_counts') ? {
...(values.get('split_customer') ? { customer: values.get('split_customer') } : {}),
...(values.get('split_passenger_counts') ? { passenger_counts: values.get('split_passenger_counts') } : {})
} : undefined;
return { ok: true, operation: operation(route.action, {
product: values.get('product'), ...(dates ? { departure_dates: dates } : {}),
...(sourceIsRange ? { recurrence: { ...(source as JsonRecord), pattern: 'specified_dates' } } : {}),
planned_capacity: values.get('planned_capacity'), room_counts: values.get('room_counts'),
...(splitOrder ? { split_order: splitOrder } : {})
}) };
}
case 'shared_child_order_create': {
const data: JsonRecord = {
...commonLookupData(new Map([
['customer', values.get('customer')], ['product', values.get('product')], ['leader', values.get('leader')],
['departure_date', values.get('departure_date')]
].filter((entry) => entry[1] !== undefined) as Array<[string, unknown]>)),
passenger_counts: values.get('passenger_counts'), ...(special ? { special_requests: special } : {})
};
const parent = compactText(values.get('parent_group_no'));
if (parent) data.existing_refs = { kind: 'shared_plan', identifier: parent, parent_group_no: parent };
return { ok: true, operation: operation(route.action, data) };
}
case 'passenger_list_import_independent':
case 'passenger_list_import_shared_child': {
const parsedList = values.get('passenger_tsv') as JsonRecord;
const overwrite = values.get('overwrite_confirmed') === true;
return { ok: true, operation: operation(route.action, {
...commonLookupData(values, route.routeId === 'passenger_list_import_independent' ? 'independent_order' : 'shared_child_order'),
passenger_list: {
operation: overwrite ? 'full_replace' : 'import', ...(overwrite ? { confirmed: true } : {}),
row_count: parsedList.row_count, text: parsedList.text,
...(parsedList.leader_contact ? { leader_contact: parsedList.leader_contact } : {})
}
}) };
}
case 'arrangement_guide_create':
return { ok: true, operation: operation(route.action, {
existing_refs: { identifier: values.get('group_no') },
arrangement: { mode: 'create', resource: values.get('resource'), ...(values.get('remark') ? { remark: values.get('remark') } : {}) }
}) };
case 'arrangement_vehicle_create': {
const range = values.get('vehicle_range') as JsonRecord;
return { ok: true, operation: operation(route.action, {
existing_refs: { identifier: values.get('group_no') },
arrangement: {
mode: 'create', supplier: values.get('supplier'), start_date: range.start_date, end_date: range.end_date,
quantity: values.get('quantity'), ...(values.get('remark') ? { remark: values.get('remark') } : {})
}
}) };
}
case 'arrangement_hotel_create':
if (String(values.get('end_date')) <= String(values.get('start_date'))) {
return { ok: false, code: 'program_invalid_value', message: '酒店离店日期必须晚于入住日期。', field: 'end_date' };
}
return { ok: true, operation: operation(route.action, {
existing_refs: { identifier: values.get('group_no') },
arrangement: {
mode: 'create', resource: values.get('resource'), start_date: values.get('start_date'), end_date: values.get('end_date'),
room_count: values.get('room_count'), ...(values.get('remark') ? { remark: values.get('remark') } : {})
}
}) };
case 'arrangement_transport_create':
return { ok: true, operation: operation(route.action, {
existing_refs: { identifier: values.get('group_no') }, arrangement: {
mode: 'create', supplier: values.get('supplier'), quantity: values.get('quantity'), date: values.get('date'),
...(values.get('item') ? { item: values.get('item') } : {}), ...(values.get('remark') ? { remark: values.get('remark') } : {})
}
}) };
case 'arrangement_other_create': {
const filing = {
...(values.get('filing_number') ? { number: values.get('filing_number') } : {}),
...(values.get('filing_date') ? { date: values.get('filing_date') } : {}),
...(values.get('entry_port') ? { entry_port: values.get('entry_port') } : {}),
...(values.get('exit_port') ? { exit_port: values.get('exit_port') } : {})
};
return { ok: true, operation: operation(route.action, {
existing_refs: { identifier: values.get('group_no') }, arrangement: {
mode: 'create', supplier: values.get('supplier'), date: values.get('date'),
...(values.get('item') ? { item: values.get('item') } : {}),
...(values.get('quantity') ? { quantity: values.get('quantity') } : {}),
...(values.get('remark') ? { remark: values.get('remark') } : {}),
...(Object.keys(filing).length ? { filing } : {})
}
}) };
}
case 'order_update_shared_plan':
return { ok: true, operation: operation(route.action, {
...commonLookupData(values, 'shared_plan'),
updates: { actions: [{ target: 'planned_capacity', operation: 'set', value: values.get('planned_capacity') }] }
}) };
case 'order_update_shared_child':
if (String(values.get('lodging_note') || '').length > 50) {
return { ok: false, code: 'program_invalid_value', message: '单次追加订房说明不得超过 50 个字符。', field: 'lodging_note' };
}
return { ok: true, operation: operation(route.action, {
...commonLookupData(values, 'shared_child_order'),
updates: { actions: [{ target: 'lodging_note', operation: 'append', value: values.get('lodging_note') }] }
}) };
case 'order_update_independent': {
const updates = parseIndependentUpdates(String(values.get('update_targets') || ''), context, true);
if (!updates.ok) return updates;
return { ok: true, operation: operation(route.action, {
...commonLookupData(values, 'independent_order'), updates: updates.operation,
...(special ? { special_requests: special } : {})
}) };
}
case 'arrangement_hotel_update': {
const type = compactText(values.get('arrangement_type'));
if (type && !['酒店', '安排酒店'].includes(type)) {
return { ok: false, code: 'program_business_rule_blocked', message: '安排变更程序模式当前只支持酒店。', field: 'arrangement_type' };
}
const changes = parseHotelChanges(String(values.get('hotel_changes') || ''), context, true);
if (!changes.ok) return changes;
return { ok: true, operation: operation(route.action, {
existing_refs: { identifier: values.get('group_no') }, arrangement: { mode: 'update', changes: changes.operation },
...(special ? { special_requests: special } : {})
}) };
}
case 'order_cancel':
return { ok: true, operation: operation(route.action, commonLookupData(values)) };
case 'order_restore': {
const status = compactText(values.get('restore_status'));
if (status && !['预订', '已确认', '收客中'].includes(status)) {
return { ok: false, code: 'program_invalid_value', message: '恢复状态只允许预订、已确认或收客中。', field: 'restore_status' };
}
return { ok: true, operation: operation(route.action, {
...commonLookupData(values), ...(status ? { transition: { to_status: status } } : {})
}) };
}
case 'confirmation_export': {
const selection = values.get('export_type') as JsonRecord;
const types = selection.types as string[];
const targetKind = selection.target_kind === 'shared_plan' ? 'shared_plan' : undefined;
const data: JsonRecord = { ...commonLookupData(values, targetKind) };
if (types.length === 1) data.confirmation = { type: types[0] };
else data.export_types = types;
if (types.includes('pickup-sign')) {
const visitor = compactText(values.get('visitor_name'));
if (!visitor) return { ok: false, code: 'program_missing_required', message: '接机牌需要填写游客姓名。', field: 'visitor_name' };
data.visitor_name = visitor;
} else if (values.get('visitor_name')) data.visitor_name = values.get('visitor_name');
return { ok: true, operation: operation(route.action, data) };
}
}
}
function parseField(field: ProgramFieldDefinition, rawValue: string, route: BusinessRouteDefinition, context: DateContext, parsed: Map<string, unknown>): FieldParseResult {
if (isPlaceholder(rawValue)) return { ok: false, message: '该字段尚未填写。' };
const nextOccurrence = NEXT_OCCURRENCE_DATE_ROUTES.has(route.routeId);
switch (field.kind) {
case 'text': return { ok: true, value: compactText(rawValue) };
case 'named_ref': return { ok: true, value: namedRef(rawValue) };
case 'date': return parseDate(rawValue, context, { nextOccurrence });
case 'date_range': return parseDateRange(rawValue, context, nextOccurrence);
case 'date_or_range': return parseDateOrRange(rawValue, context, nextOccurrence);
case 'date_list': return parseDateList(rawValue, context, nextOccurrence,
isRecord(parsed.get('departure_range')) ? parsed.get('departure_range') as JsonRecord
: isRecord(parsed.get('departure_source')) ? parsed.get('departure_source') as JsonRecord : undefined);
case 'passenger_counts': return parsePassengerCounts(rawValue);
case 'room_counts': return parseRoomCounts(rawValue);
case 'positive_integer': return positiveInteger(rawValue);
case 'boolean': return booleanValue(rawValue);
case 'passenger_tsv': return parsePassengerTsv(rawValue);
case 'export_type': return parseExportType(rawValue);
case 'update_targets':
case 'hotel_changes': return { ok: true, value: rawValue };
}
}
function baseEnvelope(route: BusinessRouteDefinition | null): {
parse_mode: 'program';
business_route_id: BusinessRouteId | null;
program_parser_version: string;
input_contract_version: string | null;
external_request: JsonRecord;
} {
return {
parse_mode: 'program',
business_route_id: route?.routeId || null,
program_parser_version: route?.programVersion || BUSINESS_ROUTES[0].programVersion,
input_contract_version: route?.inputContractVersion || null,
external_request: { provider: 'program', external_call_made: false, attempt_count: 1 }
};
}
function blocked(route: BusinessRouteDefinition | null, code: string, message: string, extra: JsonRecord = {}): ProgramParseResult {
return {
...baseEnvelope(route),
status: 'agent_parse_blocked', blockers: [message], operation: null, reply: message, error_code: code,
failure_stage: 'program_parse', failure_source: 'program_parser', failure_message: message,
agent_returned: true, plugin_dispatch_started: false, erp_write_started: false,
no_plugin_dispatch: true, no_erp_write: true, ...extra
} as ProgramParseResult;
}
function needsInput(route: BusinessRouteDefinition, code: string, fields: string[], messages: Map<string, string>, captured: JsonRecord): ProgramParseResult {
const unique = [...new Set(fields)];
const questions = unique.map((key) => {
const field = route.fields.find((candidate) => candidate.key === key);
return { field: key, prompt: messages.get(key) || `请补充${field?.label || key}。` };
});
const reply = questions.map((question) => question.prompt).join(';');
return {
...baseEnvelope(route),
status: 'agent_parse_needs_input', blockers: [], operation: null,
missing_fields: unique, questions, captured_facts: captured, reply, error_code: code,
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 || '';
const routeResolution = suppliedRoute ? null : resolveBusinessRoute(routeMessage);
// If the complete message contains two explicit actions, retain the first
// directive as the primary route so extractFields() can return the stronger
// program_multiple_actions blocker. Signature ambiguity remains unresolved.
const firstLineRoute = routeResolution?.match === 'ambiguous'
? resolveBusinessRoute(firstNonEmptyLine(routeMessage)).route
: null;
const route = suppliedRoute || routeResolution?.route || firstLineRoute || null;
if (!route) {
const code = routeResolution?.match === 'ambiguous' ? 'program_ambiguous_input' : 'program_unsupported_syntax';
return blocked(null, code, '无法唯一识别业务路由,已阻止程序解析。');
}
const turns: ProgramHistoryTurn[] = [
...(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));
const extracted = extractFields(route, turns);
if (extracted.multipleRoute) return blocked(route, 'program_multiple_actions', '同一任务补充中出现了另一个业务动作,不能合并解析。');
if (extracted.unknownFields.length) {
return blocked(route, 'program_unknown_field', `当前业务不认识字段:${[...new Set(extracted.unknownFields)].join('、')}。`, {
unknown_field_count: new Set(extracted.unknownFields).size
});
}
if (extracted.ambiguousFields.length) {
return blocked(route, 'program_ambiguous_input', `字段标签无法唯一纠错:${[...new Set(extracted.ambiguousFields)].join('、')}。`);
}
if (extracted.unsupportedLines.length) {
return blocked(route, 'program_unsupported_syntax', '存在无法安全归入唯一缺失字段的无标签内容。', {
unsupported_line_count: extracted.unsupportedLines.length
});
}
if (extracted.conflicts.size) {
const messages = new Map<string, string>();
for (const key of extracted.conflicts.keys()) messages.set(key, `同一轮中${route.fields.find((field) => field.key === key)?.label || key}出现冲突值,请明确一个值。`);
return needsInput(route, 'program_field_conflict', [...extracted.conflicts.keys()], messages, {});
}
const missing = simpleRequiredKeys(route, extracted.values);
if (missing.length) {
const messages = new Map(missing.map((key) => [key, `请补充${route.fields.find((field) => field.key === key)?.label || key}。`]));
return needsInput(route, 'program_missing_required', missing, messages, Object.fromEntries(extracted.values));
}
const context = dateContext(input.receivedAt || turns.at(-1)?.receivedAt);
const parsed = new Map<string, unknown>();
const invalid: string[] = [];
const messages = new Map<string, string>();
for (const field of route.fields) {
const rawValue = extracted.values.get(field.key);
if (isPlaceholder(rawValue || '')) continue;
const result = parseField(field, rawValue!, route, context, parsed);
if (!result.ok) {
invalid.push(field.key);
messages.set(field.key, `${field.label}无效:${result.message}`);
} else parsed.set(field.key, result.value);
}
if (invalid.length) return needsInput(route, 'program_invalid_value', invalid, messages, Object.fromEntries(parsed));
const built = buildOperation(route, parsed, context);
if (!built.ok) {
if (built.code === 'program_invalid_value' || built.code === 'program_missing_required' || built.code === 'program_field_conflict') {
const field = built.field || 'business_input';
return needsInput(route, built.code, [field], new Map([[field, built.message]]), Object.fromEntries(parsed));
}
return blocked(route, built.code, built.message);
}
const validationErrors = await validateParsedOperation(built.operation);
if (validationErrors.length) {
return blocked(route, 'program_contract_invalid', '程序解析结果未通过统一 operation 契约。', {
validation_errors: validationErrors,
contract_error_count: validationErrors.length
});
}
return {
...baseEnvelope(route), status: 'agent_parse_passed', blockers: [], operation: built.operation,
reply: '系统已完成确定性解析。', no_plugin_dispatch: true, no_erp_write: true
} as ProgramParseResult;
}
export async function parseProgramInput(input: ProgramParseInput): Promise<ProgramParseResult> {
const timeoutMs = Math.max(10, Math.min(Number(input.timeoutMs || 2_000), 30_000));
let timer: NodeJS.Timeout | undefined;
try {
return await Promise.race([
parseProgramInputInner(input),
new Promise<ProgramParseResult>((resolve) => {
timer = setTimeout(() => {
const route = input.routeId ? businessRouteById(input.routeId) : null;
resolve(blocked(route, 'program_timeout', '程序解析超过时间限制。'));
}, timeoutMs);
})
]);
} catch (error) {
const route = input.routeId ? businessRouteById(input.routeId) : null;
return blocked(route, 'program_internal_error', '程序解析发生内部错误。', {
internal_error_name: error instanceof Error ? error.name : 'UnknownError'
});
} finally {
if (timer) clearTimeout(timer);
}
}
export function programFallbackAllowed(result: unknown): boolean {
return isRecord(result) && PROGRAM_PARSER_FALLBACK_CODES.has(String(result.error_code || ''));
}
export function programParserVersion(routeId: unknown): string | null {
return businessRouteById(routeId)?.programVersion || null;
}
export function programRouteForMessage(message: unknown): BusinessRouteId | null {
return resolveBusinessRoute(message).routeId;
}