Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions apps/api/src/routes/semantic-models.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
}));

Expand All @@ -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;
},
}));
Expand Down Expand Up @@ -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();
});
});
38 changes: 37 additions & 1 deletion apps/api/src/routes/semantic-models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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");
Expand Down
15 changes: 15 additions & 0 deletions apps/docs/src/content/docs/guides/semantic-models.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 4 additions & 4 deletions apps/frontend/src/components/layout/panel-resize-handle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export function useResizablePanel(
defaultWidth: number,
min = 180,
max = 480,
invert = false,
) {
const [width, setWidth] = useState(() => {
try {
Expand Down Expand Up @@ -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() {
Expand All @@ -51,7 +51,7 @@ export function useResizablePanel(
document.addEventListener("mousemove", onMouseMove);
document.addEventListener("mouseup", onMouseUp);
},
[min, max],
[min, max, invert],
);

return { width, onMouseDown };
Expand Down
Loading
Loading