feat: ship deterministic parser and lifecycle release
This commit is contained in:
1 parent
208c434b31
commit
7b5d855b09
176 files changed
+20248
-1362
No files matched your search
@@ -0,0 +1,244 @@
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { resolve } from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
interface ExternalContractModule {
|
||||
validateAgentOperation(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.validateAgentOperation(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'] };
|
||||
}
|
||||
Reference in new issue
Block a user