245 lines
9.1 KiB
TypeScript
245 lines
9.1 KiB
TypeScript
import { pathToFileURL } from 'node:url';
|
|
import { resolve } from 'node:path';
|
|
import { createHash } from 'node:crypto';
|
|
|
|
interface ExternalContractModule {
|
|
validateProgramOperation(operation: unknown): string[];
|
|
}
|
|
|
|
let contractModulePromise: Promise<ExternalContractModule> | null = null;
|
|
|
|
async function loadContractModule(): Promise<ExternalContractModule> {
|
|
if (!contractModulePromise) {
|
|
const modulePath = resolve(process.cwd(), 'LianSyn-platform/external-agent-client.mjs');
|
|
contractModulePromise = import(pathToFileURL(modulePath).href) as Promise<ExternalContractModule>;
|
|
}
|
|
return contractModulePromise;
|
|
}
|
|
|
|
export async function validateParsedOperation(operation: unknown): Promise<string[]> {
|
|
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<string, unknown> {
|
|
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<string, unknown>)?.target || '')
|
|
.localeCompare(String((right as Record<string, unknown>)?.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<string | number> = [];
|
|
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'] };
|
|
}
|