feat: add AI catalog description generation

This commit is contained in:
Codex
2026-08-29 16:42:56 +02:00
parent b0afba81ca
commit 376dd5a09d
76 changed files with 14860 additions and 102 deletions
+121
View File
@@ -132,6 +132,13 @@ export interface CatalogMetadataDeleteCounts {
relationships: number;
}
export type CatalogDescriptionTarget = "tables" | "columns";
export interface CatalogDescriptionConsolidationCounts {
copied: number;
skipped: number;
}
export type CatalogSyncScope = "tables" | "columns" | "relationships" | "all";
export type CatalogSyncState = "queued" | "running" | "awaiting_confirmation" | "applying"
| "succeeded" | "failed" | "cancelled" | "interrupted";
@@ -176,8 +183,113 @@ export interface CatalogSyncEvent {
createdAt: string;
}
export interface MetadataGenerationModel {
id: string;
label: string;
}
export interface MetadataGenerationModels {
models: MetadataGenerationModel[];
default: string | null;
}
export type DescriptionGenerationScope = "selected_columns" | "selected_tables" | "all" | "missing";
export type DescriptionGenerationStatus = "queued" | "running" | "completed"
| "completed_with_errors" | "cancelled" | "failed" | "interrupted";
export interface DescriptionGenerationRun {
id: string;
databaseId: string;
scope: DescriptionGenerationScope;
modelId: string;
language: "en" | "it";
status: DescriptionGenerationStatus;
total: number;
processed: number;
generated: number;
nonGeneratable: number;
failed: number;
createdAt: string;
startedAt: string | null;
updatedAt: string;
finishedAt: string | null;
errorSummary: string | null;
}
export interface DescriptionGenerationEvent {
sequence: number;
level: "info" | "warning" | "error";
message: string;
createdAt: string;
}
export const listCatalogDatabases = () => apiFetch<CatalogDatabase[]>("/catalog/databases");
export const listMetadataGenerationModels = () =>
apiFetch<MetadataGenerationModels>("/catalog/metadata-generation/models");
export function startDescriptionGenerationRun(
databaseId: string,
modelId: string,
scope: Extract<DescriptionGenerationScope, "selected_columns" | "selected_tables">,
targetIds: string[],
): Promise<DescriptionGenerationRun>;
export function startDescriptionGenerationRun(
databaseId: string,
modelId: string,
scope: Extract<DescriptionGenerationScope, "all" | "missing">,
): Promise<DescriptionGenerationRun>;
export function startDescriptionGenerationRun(
databaseId: string,
modelId: string,
scope: DescriptionGenerationScope,
targetIds?: string[],
): Promise<DescriptionGenerationRun> {
const body = scope === "all" || scope === "missing"
? { modelId, scope }
: { modelId, scope, targetIds: targetIds ?? [] };
return apiFetch<DescriptionGenerationRun>(
`/catalog/databases/${encodeURIComponent(databaseId)}/description-generation-runs`,
{ method: "POST", body: JSON.stringify(body) },
);
}
export const getDescriptionGenerationRun = (runId: string) =>
apiFetch<DescriptionGenerationRun>(
`/catalog/description-generation-runs/${encodeURIComponent(runId)}`,
);
export const listDescriptionGenerationRuns = (limit = 50) =>
apiFetch<DescriptionGenerationRun[]>(
`/catalog/description-generation-runs?limit=${encodeURIComponent(String(limit))}`,
);
export const listDescriptionGenerationEvents = (runId: string, after = 0) =>
apiFetch<DescriptionGenerationEvent[]>(
`/catalog/description-generation-runs/${encodeURIComponent(runId)}/events-list?after=${after}`,
);
export const cancelDescriptionGenerationRun = (runId: string) =>
apiFetch<DescriptionGenerationRun>(
`/catalog/description-generation-runs/${encodeURIComponent(runId)}/cancel`,
{ method: "POST" },
);
export const unlockDescriptionGenerationRun = () =>
apiFetch<DescriptionGenerationRun>(
"/catalog/description-generation-runs/unlock",
{ method: "POST" },
);
export function descriptionGenerationEventsUrl(runId: string, after = 0): string {
const url = joinBackendPath(
BASE,
`/catalog/description-generation-runs/${encodeURIComponent(runId)}/events?after=${after}`,
);
assertSameOriginRequestUrl(url);
return url;
}
export const createCatalogDatabase = (input: DatabaseConfiguration) =>
apiFetch<CatalogDatabase>("/catalog/databases", { method: "POST", body: JSON.stringify(input) });
@@ -254,6 +366,15 @@ export const deleteCatalogTableMetadata = (
{ method: "POST", body: JSON.stringify({ tableIds, target }) },
);
export const consolidateCatalogDescriptions = (
databaseId: string,
target: CatalogDescriptionTarget,
targetIds: string[],
) => apiFetch<CatalogDescriptionConsolidationCounts>(
`/catalog/databases/${encodeURIComponent(databaseId)}/descriptions/consolidate`,
{ method: "POST", body: JSON.stringify({ target, targetIds }) },
);
export const startCatalogSync = (
databaseId: string,
version: number,
@@ -0,0 +1,76 @@
import { http, HttpResponse } from "msw";
import { server } from "../test/msw";
import {
cancelDescriptionGenerationRun,
descriptionGenerationEventsUrl,
listDescriptionGenerationRuns,
unlockDescriptionGenerationRun,
type DescriptionGenerationRun,
} from "./catalog-databases";
const historicalRun: DescriptionGenerationRun = {
id: "77777777-7777-4777-8777-777777777777",
databaseId: "11111111-1111-4111-8111-111111111111",
scope: "missing",
modelId: "local-qwen",
language: "it",
status: "interrupted",
total: 4,
processed: 2,
generated: 2,
nonGeneratable: 0,
failed: 0,
createdAt: "2026-08-28T08:00:00Z",
startedAt: "2026-08-28T08:00:01Z",
updatedAt: "2026-08-28T08:02:00Z",
finishedAt: "2026-08-28T08:02:00Z",
errorSummary: null,
};
test("lists newest-first persisted description-generation history with a bounded limit", async () => {
let requestedLimit: string | null = null;
server.use(http.get("/api/catalog/description-generation-runs", ({ request }) => {
requestedLimit = new URL(request.url).searchParams.get("limit");
return HttpResponse.json([historicalRun]);
}));
await expect(listDescriptionGenerationRuns(50)).resolves.toEqual([historicalRun]);
expect(requestedLimit).toBe("50");
});
test("stops a description-generation run with the run-scoped body-less endpoint", async () => {
let contentType: string | null = "unset";
server.use(http.post(
"/api/catalog/description-generation-runs/:runId/cancel",
({ request }) => {
contentType = request.headers.get("content-type");
return HttpResponse.json({ ...historicalRun, status: "cancelled" });
},
));
await expect(cancelDescriptionGenerationRun(historicalRun.id)).resolves.toMatchObject({
id: historicalRun.id,
status: "cancelled",
});
expect(contentType).toBeNull();
});
test("unlocks the installation-wide stale description-generation run without a request body", async () => {
let contentType: string | null = "unset";
server.use(http.post(
"/api/catalog/description-generation-runs/unlock",
({ request }) => {
contentType = request.headers.get("content-type");
return HttpResponse.json(historicalRun);
},
));
await expect(unlockDescriptionGenerationRun()).resolves.toEqual(historicalRun);
expect(contentType).toBeNull();
});
test("builds the same-origin description-generation SSE replay URL from an event sequence", () => {
expect(descriptionGenerationEventsUrl(historicalRun.id, 17)).toBe(
`/api/catalog/description-generation-runs/${historicalRun.id}/events?after=17`,
);
});
+21
View File
@@ -143,6 +143,27 @@ test("keeps only a known safe bounded JSON error payload", async () => {
}
});
test.each([
["description_generation_target_ids_duplicate", "Description generation target IDs must be unique."],
["description_generation_no_eligible_targets", "No eligible catalog tables or columns need description generation."],
["catalog_table_not_found", "One or more selected catalog tables were not found."],
])("maps the description-generation error code %s to safe local copy", async (code, message) => {
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify({ code, message: "provider detail must not be trusted" }), {
status: 400,
headers: { "content-type": "application/json" },
}),
);
try {
const failure = await apiFetch<unknown>("/description-generation-error")
.catch((error: unknown) => error) as ApiError;
expect(failure).toMatchObject({ code, message, payload: { code } });
} finally {
fetchSpy.mockRestore();
}
});
test("derives local messages without retaining a malicious known-code message", async () => {
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify({
+22 -1
View File
@@ -15,7 +15,14 @@ const safeErrorCodes = new Set([
"semantic_index_incompatible", "pi_management_forbidden", "pi_management_unavailable",
"pi_management_invalid_config", "pi_management_write_failed",
"catalog_unavailable", "database_conflict", "database_invalid", "database_not_found",
"database_stale", "database_operation_failed",
"database_stale", "database_operation_failed", "database_operation_in_progress",
"catalog_target_not_found", "description_consolidation_invalid", "description_consolidation_failed",
"description_generation_request_invalid", "description_generation_run_active",
"description_generation_failed", "description_generation_run_not_found",
"description_generation_no_eligible_targets",
"description_generation_target_ids_duplicate", "metadata_generation_model_unavailable",
"catalog_column_not_found", "catalog_table_not_found",
"workspace_configuration_unavailable",
"schema_sync_conflict", "schema_introspection_failed", "schema_request_invalid",
"schema_operation_failed", "sync_run_not_found", "table_stale", "column_stale",
]);
@@ -56,6 +63,20 @@ const localCodeMessages: Record<string, string> = {
database_not_found: "The database configuration was not found.",
database_stale: "The database configuration changed. Reload and try again.",
database_operation_failed: "The database operation failed.",
database_operation_in_progress: "A database operation is already in progress.",
catalog_target_not_found: "One or more selected catalog targets were not found.",
description_consolidation_invalid: "The description consolidation request is invalid.",
description_consolidation_failed: "Description consolidation failed.",
description_generation_request_invalid: "The description generation request is invalid.",
description_generation_run_active: "A description generation run is already active.",
description_generation_failed: "Description generation failed.",
description_generation_run_not_found: "The description generation run was not found.",
description_generation_no_eligible_targets: "No eligible catalog tables or columns need description generation.",
description_generation_target_ids_duplicate: "Description generation target IDs must be unique.",
metadata_generation_model_unavailable: "The selected description model is unavailable.",
catalog_column_not_found: "The selected catalog column was not found.",
catalog_table_not_found: "One or more selected catalog tables were not found.",
workspace_configuration_unavailable: "The database workspace configuration is unavailable.",
schema_sync_conflict: "A schema synchronization is already active or no longer current.",
schema_introspection_failed: "The database schema could not be read safely.",
schema_request_invalid: "The schema request is invalid.",