import { pathToFileURL } from 'node:url'; import { resolve } from 'node:path'; import { createHash } from 'node:crypto'; interface ExternalContractModule { validateProgramOperation(operation: unknown): string[]; } let contractModulePromise: Promise | null = null; async function loadContractModule(): Promise { if (!contractModulePromise) { const modulePath = resolve(process.cwd(), 'LianSyn-platform/external-agent-client.mjs'); contractModulePromise = import(pathToFileURL(modulePath).href) as Promise; } return contractModulePromise; } export async function validateParsedOperation(operation: unknown): Promise { const contract = await loadContractModule(); return contract.validateProgramOperation(operation); } const EXPORT_TYPE_ORDER = [ 'xingyou-confirm', 'liantai-confirm', 'job-order', 'visitor-list', 'guide-confirm', 'hotel-preorder', 'transport-preorder', 'filing-current', 'filing-history', 'pickup-sign' ]; function isRecord(value: unknown): value is Record { return Boolean(value && typeof value === 'object' && !Array.isArray(value)); } function canonicalize(value: unknown, path = ''): unknown { if (Array.isArray(value)) { const items = value.map((item, index) => canonicalize(item, `${path}[${index}]`)); if (path.endsWith('.updates.actions')) { return items.sort((left, right) => String((left as Record)?.target || '') .localeCompare(String((right as Record)?.target || ''))); } if (path.endsWith('.export_types')) { return items.sort((left, right) => EXPORT_TYPE_ORDER.indexOf(String(left)) - EXPORT_TYPE_ORDER.indexOf(String(right))); } if (path.endsWith('.departure_dates')) return items.sort((left, right) => String(left).localeCompare(String(right))); return items; } if (!isRecord(value)) return value; return Object.fromEntries( Object.keys(value).sort().map((key) => [key, canonicalize(value[key], path ? `${path}.${key}` : key)]) ); } function parserStatus(value: unknown): string { return isRecord(value) ? String(value.status || '') : ''; } function stringSet(value: unknown): string[] { return Array.isArray(value) ? [...new Set(value.map(String).filter(Boolean))].sort() : []; } function questionFields(value: unknown): string[] { if (!Array.isArray(value)) return []; return [...new Set(value .filter(isRecord) .map((item) => String(item.field || '').trim()) .filter(Boolean))].sort(); } function diffPaths(left: unknown, right: unknown, path = '$', output: string[] = []): string[] { if (output.length >= 100) return output; if (Object.is(left, right)) return output; if (Array.isArray(left) && Array.isArray(right)) { if (left.length !== right.length) output.push(`${path}.length`); const length = Math.min(left.length, right.length); for (let index = 0; index < length; index += 1) diffPaths(left[index], right[index], `${path}[${index}]`, output); return output; } if (isRecord(left) && isRecord(right)) { const keys = [...new Set([...Object.keys(left), ...Object.keys(right)])].sort(); for (const key of keys) { if (!Object.hasOwn(left, key) || !Object.hasOwn(right, key)) output.push(`${path}.${key}`); else diffPaths(left[key], right[key], `${path}.${key}`, output); if (output.length >= 100) break; } return output; } output.push(path); return output; } export type ParserComparisonStatus = 'equivalent' | 'different' | 'not_comparable'; export interface ParserComparison { status: ParserComparisonStatus; ai_status: string; program_status: string; diff_paths: string[]; } export interface ParserComparisonValue { present: boolean; value: unknown; } export interface ParserFieldDifference { path: string; ai: ParserComparisonValue; program: ParserComparisonValue; } function comparisonView(value: unknown, status: string): unknown { const result = isRecord(value) ? value : {}; if (status === 'agent_parse_passed') { return { operation: canonicalize(result.operation, 'operation') }; } if (status === 'agent_parse_needs_input') { return { missing_fields: stringSet(result.missing_fields), question_fields: questionFields(result.questions) }; } return { status }; } function valueAtPath(value: unknown, path: string): { present: boolean; value: unknown } { if (path === '$') return { present: true, value }; if (!path.startsWith('$')) return { present: false, value: null }; const tokens: Array = []; const suffix = path.slice(1); const pattern = /\.([A-Za-z0-9_-]+)|\[(\d+)\]/gu; let consumed = 0; for (const match of suffix.matchAll(pattern)) { if (match.index !== consumed) return { present: false, value: null }; tokens.push(match[1] !== undefined ? match[1] : Number(match[2])); consumed = (match.index || 0) + match[0].length; } if (consumed !== suffix.length) return { present: false, value: null }; let current = value; for (const token of tokens) { if (typeof token === 'number') { if (!Array.isArray(current) || token >= current.length) return { present: false, value: null }; current = current[token]; } else { if (Array.isArray(current) && token === 'length') { current = current.length; continue; } if (!isRecord(current) || !Object.hasOwn(current, token)) return { present: false, value: null }; current = current[token]; } } return { present: true, value: current }; } function comparisonPreview(value: unknown): unknown { if (value === undefined) return null; const serialized = JSON.stringify(value); if (serialized === undefined) return String(value); if (serialized.length <= 2_000 && !(typeof value === 'string' && value.length > 500)) return value; return { redacted_large_value: true, type: Array.isArray(value) ? 'array' : value === null ? 'null' : typeof value, character_count: serialized.length, ...(Array.isArray(value) ? { item_count: value.length } : {}), ...(isRecord(value) ? { key_count: Object.keys(value).length } : {}), sha256: createHash('sha256').update(serialized).digest('hex') }; } /** * Builds the narrow, authenticated task-detail projection used for Shadow * review. It never returns complete candidates or transport metadata; only * semantic paths already identified by compareParserResults are included. */ export function parserFieldDifferences(aiResult: unknown, programResult: unknown): ParserFieldDifference[] { const comparison = compareParserResults(aiResult, programResult); if (comparison.status !== 'different') return []; const aiView = comparisonView(aiResult, comparison.ai_status); const programView = comparisonView(programResult, comparison.program_status); return comparison.diff_paths.slice(0, 100).map((path) => { const ai = valueAtPath(aiView, path); const program = valueAtPath(programView, path); return { path, ai: { present: ai.present, value: ai.present ? comparisonPreview(ai.value) : null }, program: { present: program.present, value: program.present ? comparisonPreview(program.value) : null } }; }); } export function compareParserResults(aiResult: unknown, programResult: unknown): ParserComparison { const aiStatus = parserStatus(aiResult); const programStatus = parserStatus(programResult); if (!aiStatus || !programStatus) { return { status: 'not_comparable', ai_status: aiStatus, program_status: programStatus, diff_paths: ['$'] }; } if (aiStatus !== programStatus) { return { status: 'different', ai_status: aiStatus, program_status: programStatus, diff_paths: ['$.status'] }; } const ai = isRecord(aiResult) ? aiResult : {}; const program = isRecord(programResult) ? programResult : {}; if (aiStatus === 'agent_parse_passed') { const aiOperation = canonicalize(ai.operation, 'operation'); const programOperation = canonicalize(program.operation, 'operation'); const paths = diffPaths(aiOperation, programOperation, '$.operation'); return { status: paths.length ? 'different' : 'equivalent', ai_status: aiStatus, program_status: programStatus, diff_paths: paths }; } if (aiStatus === 'agent_parse_needs_input') { const left = { missing_fields: stringSet(ai.missing_fields), question_fields: questionFields(ai.questions) }; const right = { missing_fields: stringSet(program.missing_fields), question_fields: questionFields(program.questions) }; const paths = diffPaths(left, right, '$'); return { status: paths.length ? 'different' : 'equivalent', ai_status: aiStatus, program_status: programStatus, diff_paths: paths }; } if (aiStatus === 'agent_parse_blocked') { // Blocker prose and provider error codes are not a stable semantic // contract. Treat two blocked candidates as non-comparable instead of // inflating Shadow equivalence statistics. return { status: 'not_comparable', ai_status: aiStatus, program_status: programStatus, diff_paths: [] }; } return { status: 'not_comparable', ai_status: aiStatus, program_status: programStatus, diff_paths: ['$.status'] }; }