diff --git a/apps/api/src/routes/semantic-models.integration.test.ts b/apps/api/src/routes/semantic-models.integration.test.ts index 7fe1083..44692fe 100644 --- a/apps/api/src/routes/semantic-models.integration.test.ts +++ b/apps/api/src/routes/semantic-models.integration.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; const mocks = vi.hoisted(() => ({ updateModelExtensions: vi.fn(), + updateDatasetMetadata: vi.fn(), exists: vi.fn(), })); @@ -12,6 +13,7 @@ vi.mock("@archmax/core/config/env", () => ({ vi.mock("@archmax/core/services/semantic-model-files", () => ({ SemanticModelFileService: class { updateModelExtensions = mocks.updateModelExtensions; + updateDatasetMetadata = mocks.updateDatasetMetadata; exists = mocks.exists; }, })); @@ -68,3 +70,63 @@ describe("PATCH /semantic-models/:name/extensions", () => { expect(res.status).not.toBe(200); }); }); + +describe("PATCH /semantic-models/:name/datasets/:datasetName", () => { + it("updates dataset metadata and returns ok", async () => { + mocks.updateDatasetMetadata.mockResolvedValue(true); + + const payload = { + description: "Order line items", + ai_context: { instructions: "Use for revenue analysis" }, + fields: [{ name: "total_price", description: "Line total in USD" }], + }; + + const res = await app.request(`${BASE}/my-model/datasets/orders`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + + expect(res.status).toBe(200); + const body = await jsonBody<{ ok: boolean }>(res); + expect(body.ok).toBe(true); + expect(mocks.updateDatasetMetadata).toHaveBeenCalledWith("proj1", "my-model", "orders", payload); + }); + + it("returns 404 when the dataset does not exist", async () => { + mocks.updateDatasetMetadata.mockResolvedValue(false); + + const res = await app.request(`${BASE}/my-model/datasets/missing`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ description: "x" }), + }); + + expect(res.status).toBe(404); + }); + + it("returns 400 when the service rejects an unknown field", async () => { + mocks.updateDatasetMetadata.mockRejectedValue(new Error('Unknown field "ghost" in dataset "orders"')); + + const res = await app.request(`${BASE}/my-model/datasets/orders`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ fields: [{ name: "ghost", description: "x" }] }), + }); + + expect(res.status).toBe(400); + const body = await jsonBody<{ error: string }>(res); + expect(body.error).toMatch(/Unknown field/); + }); + + it("returns 400 for an invalid field entry payload", async () => { + const res = await app.request(`${BASE}/my-model/datasets/orders`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ fields: [{ description: "missing name" }] }), + }); + + expect(res.status).not.toBe(200); + expect(mocks.updateDatasetMetadata).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/routes/semantic-models.ts b/apps/api/src/routes/semantic-models.ts index bddb466..05ab7de 100644 --- a/apps/api/src/routes/semantic-models.ts +++ b/apps/api/src/routes/semantic-models.ts @@ -2,7 +2,7 @@ import { Hono } from "hono"; import { zValidator } from "@hono/zod-validator"; import { z } from "zod/v4"; import { getEnv } from "@archmax/core/config/env"; -import { semanticModelSchema, customExtensionSchema } from "@archmax/core/services/semantic-model-schema"; +import { semanticModelSchema, customExtensionSchema, aiContextSchema } from "@archmax/core/services/semantic-model-schema"; import { SemanticModelFileService } from "@archmax/core/services/semantic-model-files"; import { AppError } from "../utils/errors"; @@ -95,6 +95,42 @@ const app = new Hono() return c.json({ ok: true }); }, ) + .patch( + "/:name/datasets/:datasetName", + zValidator( + "json", + z.object({ + description: z.string().optional(), + ai_context: aiContextSchema, + fields: z + .array( + z.object({ + name: z.string().min(1), + description: z.string().optional(), + ai_context: aiContextSchema, + }), + ) + .optional(), + }), + ), + async (c) => { + const svc = getFileService(); + const body = c.req.valid("json"); + let updated: boolean; + try { + updated = await svc.updateDatasetMetadata( + param(c, "projectId"), + param(c, "name"), + param(c, "datasetName"), + body, + ); + } catch (err) { + throw AppError.badRequest(err instanceof Error ? err.message : "Invalid dataset update"); + } + if (!updated) throw AppError.notFound("Dataset not found"); + return c.json({ ok: true }); + }, + ) .delete("/:name", async (c) => { const projectId = param(c, "projectId"); const name = param(c, "name"); diff --git a/apps/docs/src/content/docs/guides/semantic-models.mdx b/apps/docs/src/content/docs/guides/semantic-models.mdx index c9891a4..d28e931 100644 --- a/apps/docs/src/content/docs/guides/semantic-models.mdx +++ b/apps/docs/src/content/docs/guides/semantic-models.mdx @@ -150,6 +150,21 @@ The AI builder automatically creates groups when building models with 4 or more Groups use a 4-color CI palette: `sage`, `rose`, `blue`, `purple`. Colors are assigned automatically when creating groups. +## Dataset Detail Panel + +Click any dataset node in the graph view to open the **dataset detail panel**, a vertical panel that slides in from the right edge of the graph. The panel shows the selected dataset's metadata in one place: + +- **Description** — the dataset's summary +- **AI description** — the `ai_context` instructions surfaced to AI agents +- **Synonyms** and **Examples** — the `ai_context` synonyms and examples, one entry per line +- **Fields** — every field with its data type and description + +The panel spans the full height of the editor (alongside the Graph / Tree / YAML toolbar). Click a different node to switch the panel to that dataset, and use the close button in the panel header to collapse it (the node stays selected in the graph). Clicking the empty canvas clears the selection. Drag the panel's left edge to resize it. + +### Editing and Saving + +The description, AI description, synonyms, examples, and each field's description are editable directly in the panel. Make your edits and click **Save** to persist them. Saving updates only the selected dataset's YAML file — `source`, field expressions, `view_query`, and other custom extensions are left untouched. The **Save** button stays disabled until you make a change. This is a quick way to correct or enrich metadata without opening the YAML or the chat agent. + ## AI Context Every entity (model, dataset, field, relationship, metric) supports `ai_context`, either a plain string or a structured object with `instructions`, `synonyms`, and `examples`. This metadata is surfaced to AI agents through MCP tools, helping them understand what the data means and how to use it. diff --git a/apps/frontend/src/components/layout/panel-resize-handle.tsx b/apps/frontend/src/components/layout/panel-resize-handle.tsx index ca04a0c..18330b9 100644 --- a/apps/frontend/src/components/layout/panel-resize-handle.tsx +++ b/apps/frontend/src/components/layout/panel-resize-handle.tsx @@ -5,6 +5,7 @@ export function useResizablePanel( defaultWidth: number, min = 180, max = 480, + invert = false, ) { const [width, setWidth] = useState(() => { try { @@ -36,9 +37,8 @@ export function useResizablePanel( document.body.style.userSelect = "none"; function onMouseMove(ev: MouseEvent) { - setWidth( - Math.max(min, Math.min(max, startW + ev.clientX - startX)), - ); + const delta = invert ? startX - ev.clientX : ev.clientX - startX; + setWidth(Math.max(min, Math.min(max, startW + delta))); } function onMouseUp() { @@ -51,7 +51,7 @@ export function useResizablePanel( document.addEventListener("mousemove", onMouseMove); document.addEventListener("mouseup", onMouseUp); }, - [min, max], + [min, max, invert], ); return { width, onMouseDown }; diff --git a/apps/frontend/src/components/model-visualization/dataset-detail-panel.tsx b/apps/frontend/src/components/model-visualization/dataset-detail-panel.tsx new file mode 100644 index 0000000..7f7c101 --- /dev/null +++ b/apps/frontend/src/components/model-visualization/dataset-detail-panel.tsx @@ -0,0 +1,292 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { X, Save, Loader2, Database } from "lucide-react"; +import { + Button, + Textarea, + Label, + Badge, + Separator, + ScrollArea, + cn, +} from "@archmax/ui"; +import type { AiContext, AiContextObject, DatasetFull } from "./types"; +import { getAiContextObject, getFieldDataType } from "./types"; +import { useUpdateDatasetMetadata, type DatasetMetadataPatch } from "./use-dataset-metadata"; + +interface DatasetDetailPanelProps { + projectId: string; + modelName: string; + dataset: DatasetFull; + onClose: () => void; + className?: string; +} + +interface EditState { + description: string; + aiInstructions: string; + /** Synonyms/examples are edited as one entry per line. */ + synonymsText: string; + examplesText: string; + fieldDescriptions: Record; +} + +/** Parse a one-per-line editor value into a trimmed, non-empty list. */ +function parseList(text: string): string[] { + return text + .split("\n") + .map((s) => s.trim()) + .filter(Boolean); +} + +function listEqual(a: string, b: string): boolean { + const pa = parseList(a); + const pb = parseList(b); + return pa.length === pb.length && pa.every((v, i) => v === pb[i]); +} + +function deriveState(ds: DatasetFull): EditState { + const fieldDescriptions: Record = {}; + for (const f of ds.fields) fieldDescriptions[f.name] = f.description ?? ""; + const ai = getAiContextObject(ds.ai_context); + return { + description: ds.description ?? "", + aiInstructions: ai.instructions ?? "", + synonymsText: (ai.synonyms ?? []).join("\n"), + examplesText: (ai.examples ?? []).join("\n"), + fieldDescriptions, + }; +} + +/** + * Merge a fresh on-disk snapshot into the working editor state, preserving + * fields the user has actively edited (where `current` diverges from the + * previous `baseline`) while refreshing untouched fields to their latest + * persisted value. This keeps the panel in sync with background refetches + * without clobbering in-progress edits. + */ +function resyncUntouched(current: EditState, baseline: EditState, next: EditState): EditState { + const fieldDescriptions: Record = {}; + for (const name of Object.keys(next.fieldDescriptions)) { + const cur = current.fieldDescriptions[name] ?? ""; + const base = baseline.fieldDescriptions[name] ?? ""; + fieldDescriptions[name] = cur !== base ? cur : next.fieldDescriptions[name]; + } + return { + description: current.description !== baseline.description ? current.description : next.description, + aiInstructions: + current.aiInstructions !== baseline.aiInstructions ? current.aiInstructions : next.aiInstructions, + synonymsText: !listEqual(current.synonymsText, baseline.synonymsText) + ? current.synonymsText + : next.synonymsText, + examplesText: !listEqual(current.examplesText, baseline.examplesText) + ? current.examplesText + : next.examplesText, + fieldDescriptions, + }; +} + +function isAiDirty(edit: EditState, baseline: EditState): boolean { + return ( + edit.aiInstructions !== baseline.aiInstructions || + !listEqual(edit.synonymsText, baseline.synonymsText) || + !listEqual(edit.examplesText, baseline.examplesText) + ); +} + +function isDirty(edit: EditState, baseline: EditState): boolean { + if (edit.description !== baseline.description) return true; + if (isAiDirty(edit, baseline)) return true; + return Object.keys(baseline.fieldDescriptions).some( + (name) => (edit.fieldDescriptions[name] ?? "") !== baseline.fieldDescriptions[name], + ); +} + +/** + * Build an `ai_context` payload from the editable instructions, synonyms, and + * examples. Returns "" to clear the context entirely when nothing remains. + */ +function buildAiContext(edit: EditState): AiContext { + const next: AiContextObject = {}; + const instructions = edit.aiInstructions.trim(); + const synonyms = parseList(edit.synonymsText); + const examples = parseList(edit.examplesText); + if (instructions) next.instructions = instructions; + if (synonyms.length > 0) next.synonyms = synonyms; + if (examples.length > 0) next.examples = examples; + if (!next.instructions && !next.synonyms && !next.examples) return ""; + return next; +} + +export function DatasetDetailPanel({ + projectId, + modelName, + dataset, + onClose, + className, +}: DatasetDetailPanelProps) { + const [baseline, setBaseline] = useState(() => deriveState(dataset)); + const [edit, setEdit] = useState(baseline); + const baselineRef = useRef(baseline); + baselineRef.current = baseline; + const lastNameRef = useRef(dataset.name); + + // Keep the editor in sync with the latest `dataset` prop. Switching datasets + // fully resets; a background refetch of the same dataset refreshes the + // baseline and any untouched fields while preserving in-progress edits. + useEffect(() => { + const next = deriveState(dataset); + if (lastNameRef.current !== dataset.name) { + lastNameRef.current = dataset.name; + setBaseline(next); + setEdit(next); + return; + } + setEdit((cur) => resyncUntouched(cur, baselineRef.current, next)); + setBaseline(next); + }, [dataset]); + + const mutation = useUpdateDatasetMetadata(projectId, modelName); + + const dirty = isDirty(edit, baseline); + + const handleSave = useCallback(() => { + const patch: DatasetMetadataPatch = {}; + if (edit.description !== baseline.description) patch.description = edit.description; + if (isAiDirty(edit, baseline)) { + patch.ai_context = buildAiContext(edit); + } + const changedFields = Object.keys(baseline.fieldDescriptions) + .filter((name) => (edit.fieldDescriptions[name] ?? "") !== baseline.fieldDescriptions[name]) + .map((name) => ({ name, description: edit.fieldDescriptions[name] ?? "" })); + if (changedFields.length > 0) patch.fields = changedFields; + if (Object.keys(patch).length === 0) return; + mutation.mutate({ datasetName: dataset.name, patch }); + }, [edit, baseline, dataset, mutation]); + + return ( +
+
+
+
+ +

{dataset.name}

+
+

+ {dataset.source} +

+
+
+ + +
+
+ + +
+
+ +