feat: add AI catalog description generation
This commit is contained in:
@@ -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`,
|
||||
);
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
@@ -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.",
|
||||
|
||||
Reference in New Issue
Block a user