ktx/packages/context/src/scan/relationship-feedback-export.ts
2026-05-10 23:51:24 +02:00

179 lines
5.9 KiB
TypeScript

import type { KtxLocalProject } from '../project/index.js';
import type {
KtxRelationshipReviewDecisionArtifact,
KtxRelationshipReviewDecisionEntry,
KtxRelationshipReviewDecisionValue,
} from './relationship-review-decisions.js';
const DECISION_ARTIFACT_SUFFIX = '/enrichment/relationship-review-decisions.json';
const FEEDBACK_SCHEMA_VERSION = 1;
export type KtxRelationshipFeedbackDecisionFilter = KtxRelationshipReviewDecisionValue | 'all';
export interface ExportLocalRelationshipFeedbackLabelsInput {
connectionId?: string | null;
decision?: KtxRelationshipFeedbackDecisionFilter;
now?: () => Date;
}
export interface KtxRelationshipFeedbackLabel {
schemaVersion: 1;
candidateId: string;
decision: KtxRelationshipReviewDecisionValue;
previousStatus: KtxRelationshipReviewDecisionEntry['previousStatus'];
connectionId: string;
runId: string;
syncId: string;
decidedAt: string;
reviewer: string;
note: string | null;
relationshipType: KtxRelationshipReviewDecisionEntry['relationshipType'];
source: string;
score: number | null;
confidence: number;
pkScore: number | null;
fkScore: number | null;
fromTable: string;
fromColumns: string[];
toTable: string;
toColumns: string[];
reasons: string[];
artifactPath: string;
}
export interface KtxRelationshipFeedbackExportWarning {
path: string;
message: string;
}
export interface ExportLocalRelationshipFeedbackLabelsResult {
generatedAt: string;
filters: {
connectionId: string | null;
decision: KtxRelationshipFeedbackDecisionFilter;
};
summary: {
total: number;
accepted: number;
rejected: number;
connections: number;
runs: number;
};
labels: KtxRelationshipFeedbackLabel[];
warnings: KtxRelationshipFeedbackExportWarning[];
}
function qualifiedTableName(entry: KtxRelationshipReviewDecisionEntry, side: 'from' | 'to'): string {
const table = entry[side].table;
return [table.catalog, table.db, table.name].filter((part): part is string => Boolean(part)).join('.');
}
function labelFromDecision(entry: KtxRelationshipReviewDecisionEntry, artifactPath: string): KtxRelationshipFeedbackLabel {
return {
schemaVersion: FEEDBACK_SCHEMA_VERSION,
candidateId: entry.candidateId,
decision: entry.decision,
previousStatus: entry.previousStatus,
connectionId: entry.connectionId,
runId: entry.runId,
syncId: entry.syncId,
decidedAt: entry.decidedAt,
reviewer: entry.reviewer,
note: entry.note,
relationshipType: entry.relationshipType,
source: entry.source,
score: entry.score,
confidence: entry.confidence,
pkScore: entry.pkScore,
fkScore: entry.fkScore,
fromTable: qualifiedTableName(entry, 'from'),
fromColumns: [...entry.from.columns],
toTable: qualifiedTableName(entry, 'to'),
toColumns: [...entry.to.columns],
reasons: [...entry.reasons],
artifactPath,
};
}
function sortLabels(labels: KtxRelationshipFeedbackLabel[]): KtxRelationshipFeedbackLabel[] {
return [...labels].sort((left, right) => {
return (
left.connectionId.localeCompare(right.connectionId) ||
left.runId.localeCompare(right.runId) ||
left.candidateId.localeCompare(right.candidateId) ||
left.decidedAt.localeCompare(right.decidedAt)
);
});
}
function passesFilters(
label: KtxRelationshipFeedbackLabel,
filters: { connectionId: string | null; decision: KtxRelationshipFeedbackDecisionFilter },
): boolean {
if (filters.connectionId && label.connectionId !== filters.connectionId) {
return false;
}
return filters.decision === 'all' || label.decision === filters.decision;
}
function messageFromUnknownError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
async function readDecisionLabels(
project: KtxLocalProject,
artifactPath: string,
): Promise<KtxRelationshipFeedbackLabel[]> {
const raw = await project.fileStore.readFile(artifactPath);
const parsed = JSON.parse(raw.content) as KtxRelationshipReviewDecisionArtifact;
const decisions = Array.isArray(parsed.decisions) ? parsed.decisions : [];
return decisions.map((entry) => labelFromDecision(entry, artifactPath));
}
function summarize(labels: KtxRelationshipFeedbackLabel[]): ExportLocalRelationshipFeedbackLabelsResult['summary'] {
return {
total: labels.length,
accepted: labels.filter((label) => label.decision === 'accepted').length,
rejected: labels.filter((label) => label.decision === 'rejected').length,
connections: new Set(labels.map((label) => label.connectionId)).size,
runs: new Set(labels.map((label) => `${label.connectionId}:${label.runId}`)).size,
};
}
export async function exportLocalRelationshipFeedbackLabels(
project: KtxLocalProject,
input: ExportLocalRelationshipFeedbackLabelsInput = {},
): Promise<ExportLocalRelationshipFeedbackLabelsResult> {
const filters = {
connectionId: input.connectionId ?? null,
decision: input.decision ?? 'all',
};
const listed = await project.fileStore.listFiles('raw-sources');
const artifactPaths = listed.files.filter((path) => path.endsWith(DECISION_ARTIFACT_SUFFIX)).sort();
const labels: KtxRelationshipFeedbackLabel[] = [];
const warnings: KtxRelationshipFeedbackExportWarning[] = [];
for (const artifactPath of artifactPaths) {
try {
labels.push(...(await readDecisionLabels(project, artifactPath)));
} catch (error) {
warnings.push({ path: artifactPath, message: messageFromUnknownError(error) });
}
}
const filtered = sortLabels(labels.filter((label) => passesFilters(label, filters)));
return {
generatedAt: (input.now?.() ?? new Date()).toISOString(),
filters,
summary: summarize(filtered),
labels: filtered,
warnings,
};
}
export function formatKtxRelationshipFeedbackLabelsJsonl(result: ExportLocalRelationshipFeedbackLabelsResult): string {
if (result.labels.length === 0) {
return '';
}
return `${result.labels.map((label) => JSON.stringify(label)).join('\n')}\n`;
}