Files
LWLT-AIBOT/control-plane/src/parser-contract.ts
2026-09-09 17:24:16 +08:00

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'] };
}