From c6c77be406c1ca01744a81fb26a6beb12c9c074e Mon Sep 17 00:00:00 2001 From: Tobias Grosse-Puppendahl Date: Sun, 7 Jun 2026 12:56:45 +0200 Subject: [PATCH 1/5] docs(openspec): propose LLM prompt caching for the agent Add a change proposal to enable Anthropic-style prompt caching for the semantic model agent. The ~47KB static system prefix (system prompt, connection context, tool definitions) is currently re-billed on every tool-loop iteration and follow-up turn. The proposal injects provider-aware cache_control breakpoints over the existing OpenAI-compatible client, adds OpenRouter sticky routing, and logs cache token usage. Co-authored-by: Cursor --- .../changes/add-llm-prompt-caching/design.md | 58 +++++++++++++++++++ .../add-llm-prompt-caching/proposal.md | 26 +++++++++ .../specs/semantic-model-agent/spec.md | 56 ++++++++++++++++++ .../changes/add-llm-prompt-caching/tasks.md | 37 ++++++++++++ 4 files changed, 177 insertions(+) create mode 100644 openspec/changes/add-llm-prompt-caching/design.md create mode 100644 openspec/changes/add-llm-prompt-caching/proposal.md create mode 100644 openspec/changes/add-llm-prompt-caching/specs/semantic-model-agent/spec.md create mode 100644 openspec/changes/add-llm-prompt-caching/tasks.md diff --git a/openspec/changes/add-llm-prompt-caching/design.md b/openspec/changes/add-llm-prompt-caching/design.md new file mode 100644 index 0000000..53957e8 --- /dev/null +++ b/openspec/changes/add-llm-prompt-caching/design.md @@ -0,0 +1,58 @@ +## Context + +The agent stack is **deepagents** (LangGraph) with **`ChatOpenAI`** (`@langchain/openai`) pointed at OpenRouter by default (`AGENT_API_BASE_URL=https://openrouter.ai/api/v1`, `AGENT_MODEL=anthropic/claude-sonnet-4.6`). The agent is assembled in `packages/core/src/services/agent.ts:45-88` via `createDeepAgent({ model, backend, tools, memory, systemPrompt, middleware })`. The system prompt is passed as a plain string (`buildSystemPrompt(connections)`), and deepagents' memory middleware appends an optional project `AGENTS.md`. + +There is no prompt caching today and no token-usage tracking. The constraint is that the **system prompt is sent as an OpenAI-style `{ role: "system" }` message** through the chat-completions wire format. + +### Key external constraints (verified) + +- OpenRouter supports Anthropic prompt caching via **explicit per-block `cache_control: { type: "ephemeral" }` breakpoints** placed on message **content blocks**. Per-block breakpoints work across all Anthropic-compatible OpenRouter providers (Anthropic, Bedrock, Vertex). ([OpenRouter prompt caching docs](https://openrouter.ai/docs/guides/best-practices/prompt-caching)) +- Anthropic allows a **maximum of 4 explicit breakpoints**; the cache covers `tools` → `system` → `messages` in order, up to and including the marked block. +- Caching only triggers for prefixes **> ~1024 tokens** — our ~47 KB system prompt easily clears this. +- Top-level (automatic) `cache_control` is **not** reliably honored over the OpenAI-compat path; explicit per-block breakpoints on the system message are the robust route. +- `session_id` in the request body enables **provider sticky routing**, which maximizes cache hits across the many requests in a tool loop and across turns. +- LangChain reports cache usage on the response as `usage_metadata.input_token_details.cache_read` / `cache_creation`. + +## Goals / Non-Goals + +- Goals: + - Cache the stable prompt prefix (tool definitions + system prompt) for Anthropic/Claude models so tool-loop iterations and follow-up turns are billed at the cached rate. + - Keep the existing OpenAI-compatible provider configuration; no required provider switch. + - Make caching safe-by-default: on for supported models, transparent no-op otherwise, and disableable via env. + - Observe cache effectiveness (cache_read / cache_creation tokens). +- Non-Goals: + - Switching the default client to `@langchain/anthropic` (kept as a documented alternative only). + - Caching frequently changing per-turn content beyond the stable prefix. + - Building a full cost dashboard / persisted token-accounting model (logging only for now). + - Caching the short single-shot title-generation call (negligible benefit). + +## Decisions + +- **Decision: Inject breakpoints via a LangChain model-wrapping middleware, not by hand-building messages.** deepagents owns message assembly (system string + memory + history + tool results), so the only stable interception point is a middleware that rewrites the outgoing request just before the model call. The middleware converts the system message's string content into a single content block `[{ type: "text", text, cache_control: { type: "ephemeral", ttl } }]`. This places one breakpoint at the end of the static prefix (tools + system), which is the dominant repeated content. + - Alternatives considered: (a) Passing a structured `systemPrompt` to `createDeepAgent` — rejected, the API takes a string and memory mutates it. (b) Top-level automatic `cache_control` — rejected, unreliable over chat-completions. (c) Switching to `ChatAnthropic` direct — rejected as default (breaks provider-agnostic config) but noted as an option since `@langchain/anthropic@^1.4.0` is already a dependency. + +- **Decision: Provider-aware activation.** The middleware only injects breakpoints when caching is enabled AND the model id indicates an Anthropic/Claude model (e.g. contains `claude`/`anthropic`). For other models (OpenAI, Gemini, Ollama) it is a no-op, relying on those providers' implicit caching. This prevents sending unsupported syntax to providers that reject extra breakpoints. + +- **Decision: Per-conversation sticky routing.** Pass the conversation id as `session_id` (via OpenRouter request extra body) so the tool loop and subsequent turns route to the same provider and reuse the cache. The conversation id is already available at the worker/API call site. + +- **Decision: Configuration.** + - `AGENT_PROMPT_CACHE_ENABLED` (default `true`) — master switch. + - `AGENT_PROMPT_CACHE_TTL` (default `5m`; accepts `5m` or `1h`) — maps to ephemeral default vs `{ ttl: "1h" }`. 1h costs more on writes but survives long pauses. + +- **Decision: Lightweight observability.** Handle `on_chat_model_end` in `processAgentStream` to read `usage_metadata` and accumulate `cache_read` / `cache_creation` / `input` / `output` tokens for the run. Log a single structured summary per agent run (worker + in-process API path). No new DB model in this change. + +## Risks / Trade-offs + +- **Risk: OpenRouter passthrough quirks for cache_control over chat-completions.** → Validate against the live default model during implementation; ship behind `AGENT_PROMPT_CACHE_ENABLED` so it can be disabled instantly if a provider rejects it. +- **Risk: Cache invalidation from non-deterministic prefix content.** Connection context ordering or tool-arg serialization changes break the prefix hash. → Ensure `buildConnectionContext` output is stable/ordered; mark only content that is byte-identical across requests. +- **Risk: 1h TTL increases write cost** if conversations are short. → Default to 5m; document the trade-off. +- **Trade-off: Single breakpoint** (tools+system) rather than multiple. Keeps us well under the 4-breakpoint limit and covers the largest repeated payload; a rolling message-history breakpoint can be added later if usage data justifies it. + +## Migration Plan + +- Purely additive and backward-compatible. New env vars default to caching-on; existing deployments gain caching automatically for Claude models with no config change. Set `AGENT_PROMPT_CACHE_ENABLED=false` to restore prior behavior. No data migration. + +## Open Questions + +- Should cache usage be surfaced in the UI (per-message token/cost badge) or kept to server logs for now? (Proposed: logs only in this change.) +- Should the playground/test agent also use sticky routing keyed by test-run id? (Proposed: yes, reuse the same helper.) diff --git a/openspec/changes/add-llm-prompt-caching/proposal.md b/openspec/changes/add-llm-prompt-caching/proposal.md new file mode 100644 index 0000000..6cfcd1d --- /dev/null +++ b/openspec/changes/add-llm-prompt-caching/proposal.md @@ -0,0 +1,26 @@ +# Change: Enable LLM Prompt Caching for the Semantic Model Agent + +## Why + +The semantic model agent resends a large, static prefix on every LLM call: a ~47 KB system prompt (`packages/core/prompts/semantic-model-agent.md`), the connection context, the optional project `AGENTS.md`, and the full tool-definition list. A single user turn runs many model→tool→model iterations, so this prefix is re-billed as fresh input tokens on each iteration and again on every follow-up turn. With Claude (the default model via OpenRouter), prompt caching can serve this stable prefix at ~10% of the input price after the first write, cutting input-token cost dramatically for these tool-heavy conversations. + +## What Changes + +- Inject Anthropic-style `cache_control: { type: "ephemeral" }` breakpoints on the stable prompt prefix (tool definitions + system prompt) so the provider caches it across tool-loop iterations and across turns. +- Add OpenRouter sticky routing via a per-conversation `session_id` so repeated requests hit the same provider endpoint and reuse the cache. +- Make caching **provider-aware and configurable**: enabled by default, automatically a no-op for providers/models that don't support explicit breakpoints, controllable via new env vars (`AGENT_PROMPT_CACHE_ENABLED`, `AGENT_PROMPT_CACHE_TTL`). +- Capture and log per-call cache token usage (`cache_read` / `cache_creation`) so cost savings are observable. +- Apply the same caching helper to the playground/test agent, which is also tool-loop heavy. +- Update `.env.example` and the documentation site with the new configuration. + +## Impact + +- Affected specs: `semantic-model-agent` +- Affected code: + - `packages/core/src/services/agent.ts` (LLM construction, agent assembly) + - `packages/core/src/services/agent-middleware.ts` (new cache-control middleware) + - `packages/core/src/services/agent-stream.ts` (capture `usage_metadata` on model end) + - `packages/core/src/services/playground-agent.ts` (reuse caching helper) + - `packages/core/src/config/env.ts` (new env vars) + - `apps/worker/src/processor.ts` / `apps/api/src/routes/agent.ts` (pass conversation id as session key, log usage) + - `.env.example`, `apps/docs` (configuration docs) diff --git a/openspec/changes/add-llm-prompt-caching/specs/semantic-model-agent/spec.md b/openspec/changes/add-llm-prompt-caching/specs/semantic-model-agent/spec.md new file mode 100644 index 0000000..782526b --- /dev/null +++ b/openspec/changes/add-llm-prompt-caching/specs/semantic-model-agent/spec.md @@ -0,0 +1,56 @@ +## ADDED Requirements + +### Requirement: LLM Prompt Caching + +The agent SHALL cache the stable prompt prefix (tool definitions and system prompt) so that repeated LLM calls within a single tool-loop turn and across follow-up turns reuse cached input tokens instead of re-billing the full prefix. For Anthropic/Claude models served via the configured OpenAI-compatible endpoint, the agent MUST inject an explicit `cache_control: { type: "ephemeral" }` breakpoint on the system message content block so the provider caches everything up to and including that block. + +The breakpoint MUST be placed on content that is byte-identical across consecutive requests of the same conversation; per-turn varying content (the incoming user message, tool results) MUST NOT be marked for caching. + +#### Scenario: System prefix cached across tool-loop iterations +- **WHEN** an agent turn performs multiple model→tool→model iterations using a Claude model +- **THEN** the first model call writes the tool+system prefix to the provider cache +- **AND** subsequent iterations in the same turn read that prefix from cache rather than re-billing it as fresh input tokens + +#### Scenario: Cache reused on a follow-up turn +- **WHEN** the user sends a follow-up message in the same conversation while the cache is still live +- **THEN** the agent's first LLM call for that turn reads the previously cached prefix + +#### Scenario: Sticky provider routing +- **WHEN** the agent issues multiple LLM requests for one conversation through OpenRouter +- **THEN** the requests include the conversation identifier as the session/sticky-routing key +- **AND** the requests are routed to the same provider endpoint to maximize cache hits + +### Requirement: Provider-Aware Cache Activation + +Prompt caching SHALL be provider-aware and configurable. Caching MUST be controlled by an `AGENT_PROMPT_CACHE_ENABLED` environment variable that defaults to enabled, and a cache lifetime controlled by `AGENT_PROMPT_CACHE_TTL` (default `5m`, also accepting `1h`). When the configured model does not support explicit cache breakpoints (e.g. non-Anthropic models), the agent MUST NOT inject `cache_control` markers and MUST continue operating normally. + +#### Scenario: Caching disabled via configuration +- **WHEN** `AGENT_PROMPT_CACHE_ENABLED` is set to `false` +- **THEN** the agent issues LLM requests without any `cache_control` breakpoints +- **AND** behaves identically to the pre-caching implementation + +#### Scenario: Non-Anthropic model is a no-op +- **WHEN** `AGENT_MODEL` points to a non-Anthropic model (e.g. an OpenAI or Ollama model) +- **THEN** the agent does not inject explicit `cache_control` markers +- **AND** the LLM request succeeds without provider errors + +#### Scenario: Configurable cache TTL +- **WHEN** `AGENT_PROMPT_CACHE_TTL` is set to `1h` +- **THEN** injected `cache_control` breakpoints request the 1-hour ephemeral lifetime +- **AND** when unset, the default 5-minute ephemeral lifetime is used + +### Requirement: Cache Token Usage Logging + +The agent run pipeline SHALL capture per-call LLM token usage, including cache read and cache creation token counts, and log a structured summary for each completed agent run so that caching effectiveness and cost savings are observable. + +#### Scenario: Cache usage captured on model completion +- **WHEN** an LLM call completes and the provider returns usage metadata with `cache_read` and/or `cache_creation` token counts +- **THEN** those counts are accumulated for the agent run + +#### Scenario: Run usage summary logged +- **WHEN** an agent run finishes (in the worker or the in-process API path) +- **THEN** a structured log entry records total input, output, cache-read, and cache-creation tokens for the run + +#### Scenario: Missing usage metadata tolerated +- **WHEN** a provider returns no cache usage fields +- **THEN** the run completes normally and the logged cache counts are zero diff --git a/openspec/changes/add-llm-prompt-caching/tasks.md b/openspec/changes/add-llm-prompt-caching/tasks.md new file mode 100644 index 0000000..cf20949 --- /dev/null +++ b/openspec/changes/add-llm-prompt-caching/tasks.md @@ -0,0 +1,37 @@ +## 1. Configuration + +- [ ] 1.1 Add `AGENT_PROMPT_CACHE_ENABLED` (default `true`) and `AGENT_PROMPT_CACHE_TTL` (default `5m`, accepts `5m`/`1h`) to the Zod env schema in `packages/core/src/config/env.ts` +- [ ] 1.2 Add both vars (with comments) to `.env.example` + +## 2. Caching middleware & helper + +- [ ] 2.1 Add a provider-aware helper that decides whether the configured `AGENT_MODEL` supports explicit cache breakpoints (Anthropic/Claude check) +- [ ] 2.2 Implement a LangChain model-wrapping middleware in `packages/core/src/services/agent-middleware.ts` that, when enabled, rewrites the outgoing system message content into a single `{ type: "text", text, cache_control: { type: "ephemeral", ttl } }` block +- [ ] 2.3 Register the middleware in `createSemlayerAgent` (`packages/core/src/services/agent.ts`) alongside the existing tool-error-recovery middleware +- [ ] 2.4 Reuse the helper/middleware in `createPlaygroundAgent` (`packages/core/src/services/playground-agent.ts`) + +## 3. Sticky routing + +- [ ] 3.1 Thread the conversation id (and test-run id for playground) to the LLM client as the OpenRouter `session_id` / sticky-routing key +- [ ] 3.2 Verify `session_id` is sent on every request in the tool loop (worker path `apps/worker/src/processor.ts` and in-process path `apps/api/src/routes/agent.ts`) + +## 4. Token usage observability + +- [ ] 4.1 Handle `on_chat_model_end` in `processAgentStream` (`packages/core/src/services/agent-stream.ts`) to read `usage_metadata` (input, output, `cache_read`, `cache_creation`) and accumulate per-run totals +- [ ] 4.2 Log a structured per-run usage summary in the worker and in-process API completion paths + +## 5. Validation + +- [ ] 5.1 Manually verify against the default model (`anthropic/claude-sonnet-4.6` via OpenRouter) that a second call within a turn reports `cache_read` tokens +- [ ] 5.2 Verify a non-Anthropic model (e.g. an OpenAI model) runs without injected breakpoints and without provider errors +- [ ] 5.3 Verify `AGENT_PROMPT_CACHE_ENABLED=false` produces requests with no `cache_control` markers + +## 6. Tests + +- [ ] 6.1 Unit test the provider-aware activation helper (Anthropic vs non-Anthropic model ids; enabled/disabled flag) +- [ ] 6.2 Unit test the middleware rewrites the system message into a cache-controlled content block only when active, and leaves messages untouched otherwise +- [ ] 6.3 Unit test usage accumulation in `processAgentStream` (cache fields present and absent) + +## 7. Documentation + +- [ ] 7.1 Document `AGENT_PROMPT_CACHE_ENABLED` and `AGENT_PROMPT_CACHE_TTL` (defaults, TTL trade-off, provider support) in the agent configuration page of `apps/docs` From 4ed03992f0b7269910b255e7068b2db5a5baf732 Mon Sep 17 00:00:00 2001 From: Tobias Grosse-Puppendahl Date: Sun, 7 Jun 2026 16:28:40 +0200 Subject: [PATCH 2/5] feat(models): add dataset detail panel to graph view Clicking a dataset node opens a collapsible, resizable panel on the right of the graph showing the dataset's description, AI context, and per-field descriptions. Edits are saved via a new granular `PATCH /semantic-models/:name/datasets/:datasetName` route backed by `SemanticModelFileService.updateDatasetMetadata`, which merges metadata into the single dataset YAML file without touching source, expressions, view_query, or custom extensions. Co-authored-by: Cursor --- .../semantic-models.integration.test.ts | 62 +++++ apps/api/src/routes/semantic-models.ts | 38 ++- .../content/docs/guides/semantic-models.mdx | 14 ++ .../components/layout/panel-resize-handle.tsx | 8 +- .../dataset-detail-panel.tsx | 216 ++++++++++++++++++ .../model-visualization/model-graph-view.tsx | 35 ++- .../model-visualization.tsx | 60 ++++- .../components/model-visualization/types.ts | 21 ++ .../use-dataset-metadata.ts | 34 +++ .../add-dataset-detail-panel/design.md | 48 ++++ .../add-dataset-detail-panel/proposal.md | 26 +++ .../specs/semantic-models/spec.md | 103 +++++++++ .../changes/add-dataset-detail-panel/tasks.md | 35 +++ .../src/services/semantic-model-files.test.ts | 101 ++++++++ .../core/src/services/semantic-model-files.ts | 81 +++++++ .../src/services/semantic-model-schema.ts | 1 + 16 files changed, 861 insertions(+), 22 deletions(-) create mode 100644 apps/frontend/src/components/model-visualization/dataset-detail-panel.tsx create mode 100644 apps/frontend/src/components/model-visualization/use-dataset-metadata.ts create mode 100644 openspec/changes/add-dataset-detail-panel/design.md create mode 100644 openspec/changes/add-dataset-detail-panel/proposal.md create mode 100644 openspec/changes/add-dataset-detail-panel/specs/semantic-models/spec.md create mode 100644 openspec/changes/add-dataset-detail-panel/tasks.md 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..df0b3ce 100644 --- a/apps/docs/src/content/docs/guides/semantic-models.mdx +++ b/apps/docs/src/content/docs/guides/semantic-models.mdx @@ -150,6 +150,20 @@ 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 (existing synonyms and examples are shown read-only below the editor) +- **Fields** — every field with its data type and description + +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, 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..968fd1f --- /dev/null +++ b/apps/frontend/src/components/model-visualization/dataset-detail-panel.tsx @@ -0,0 +1,216 @@ +import { useCallback, useEffect, useMemo, 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, DatasetFull } from "./types"; +import { getAiContextObject, getAiInstructions, getFieldDataType } from "./types"; +import { useUpdateDatasetMetadata, type DatasetMetadataPatch } from "./use-dataset-metadata"; + +interface DatasetDetailPanelProps { + projectId: string; + modelName: string; + dataset: DatasetFull; + onClose: () => void; + className?: string; +} + +function initFieldDescriptions(ds: DatasetFull): Record { + const out: Record = {}; + for (const f of ds.fields) out[f.name] = f.description ?? ""; + return out; +} + +/** + * Build an `ai_context` payload that preserves any existing synonyms/examples + * while updating the editable `instructions`. Returns "" to clear the context + * entirely when nothing meaningful remains. + */ +function buildAiContext(original: AiContext | undefined, instructions: string): AiContext { + const obj = getAiContextObject(original); + const next = { ...obj, instructions: instructions.trim() || undefined }; + if (!next.instructions) delete next.instructions; + const hasSynonyms = !!next.synonyms && next.synonyms.length > 0; + const hasExamples = !!next.examples && next.examples.length > 0; + if (!next.instructions && !hasSynonyms && !hasExamples) return ""; + return next; +} + +export function DatasetDetailPanel({ + projectId, + modelName, + dataset, + onClose, + className, +}: DatasetDetailPanelProps) { + const baselineDescription = dataset.description ?? ""; + const baselineInstructions = getAiInstructions(dataset.ai_context); + const baselineFields = useMemo(() => initFieldDescriptions(dataset), [dataset]); + + const [description, setDescription] = useState(baselineDescription); + const [aiInstructions, setAiInstructions] = useState(baselineInstructions); + const [fieldDescriptions, setFieldDescriptions] = useState>(baselineFields); + + // Reset editor state when switching to a different dataset. + const lastName = useRef(dataset.name); + useEffect(() => { + if (lastName.current !== dataset.name) { + lastName.current = dataset.name; + setDescription(dataset.description ?? ""); + setAiInstructions(getAiInstructions(dataset.ai_context)); + setFieldDescriptions(initFieldDescriptions(dataset)); + } + }, [dataset]); + + const mutation = useUpdateDatasetMetadata(projectId, modelName); + + const dirty = + description !== baselineDescription || + aiInstructions !== baselineInstructions || + Object.keys(baselineFields).some((name) => (fieldDescriptions[name] ?? "") !== baselineFields[name]); + + const handleSave = useCallback(() => { + const patch: DatasetMetadataPatch = {}; + if (description !== baselineDescription) patch.description = description; + if (aiInstructions !== baselineInstructions) { + patch.ai_context = buildAiContext(dataset.ai_context, aiInstructions); + } + const changedFields = dataset.fields + .filter((f) => (fieldDescriptions[f.name] ?? "") !== (f.description ?? "")) + .map((f) => ({ name: f.name, description: fieldDescriptions[f.name] ?? "" })); + if (changedFields.length > 0) patch.fields = changedFields; + if (Object.keys(patch).length === 0) return; + mutation.mutate({ datasetName: dataset.name, patch }); + }, [ + description, + baselineDescription, + aiInstructions, + baselineInstructions, + fieldDescriptions, + dataset, + mutation, + ]); + + const aiObj = getAiContextObject(dataset.ai_context); + + return ( +
+
+
+
+ +

{dataset.name}

+
+

+ {dataset.source} +

+
+ +
+ + +
+
+ +