From 3dfe900242f8f4a38d6b385f2b7db3cad2b4a2d1 Mon Sep 17 00:00:00 2001 From: Sebastien Henry Date: Wed, 24 Jun 2026 15:41:26 -0500 Subject: [PATCH 01/16] chore: green baseline + onboarding for prompt-driven authoring - ignore gitignored .cursor/ in eslint so local `make ci` matches green CI - add CODEBASE.md (onboarding map) and feature requirements - fold in pre-existing in-flight hardening (restClient, twb_builder, verify scripts) Co-Authored-By: Claude Opus 4.8 --- .env.example | 3 + .gitignore | 1 + ACCEPTANCE.md | 57 ++- ASSUMPTIONS.md | 96 +++++ CODEBASE.md | 346 +++++++++++++++++ PLAN.md | 53 ++- docs/feature-prompt-authoring/REQUIREMENTS.md | 351 ++++++++++++++++++ eslint.config.js | 8 +- package.json | 4 +- scripts/demo.ts | 11 +- scripts/mcp-smoke.ts | 155 ++++++++ scripts/verify-setup.ts | 123 ++++++ sidecar/server.py | 2 + sidecar/tests/test_twb_builder.py | 17 +- sidecar/twb_builder.py | 81 ++-- src/restClient.ts | 74 +++- src/sidecar.ts | 2 + src/tools/createStarterWorkbook.ts | 1 + tests/restClient.test.ts | 11 +- 19 files changed, 1340 insertions(+), 56 deletions(-) create mode 100644 CODEBASE.md create mode 100644 docs/feature-prompt-authoring/REQUIREMENTS.md create mode 100644 scripts/mcp-smoke.ts create mode 100644 scripts/verify-setup.ts diff --git a/.env.example b/.env.example index 22ac2b1..0bfde6f 100644 --- a/.env.example +++ b/.env.example @@ -16,3 +16,6 @@ PAT_VALUE=replace-me-never-commit-the-real-value # Optional. Localhost port for the Python authoring sidecar. Defaults to 8899. # SIDECAR_PORT=8899 + +# Optional. Target project for `npm run demo` (must exist; not the Default project). +# DEMO_PROJECT=Sales diff --git a/.gitignore b/.gitignore index b68201c..6b3b93d 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,4 @@ tmp/ .DS_Store .idea/ .vscode/ +.cursor/ diff --git a/ACCEPTANCE.md b/ACCEPTANCE.md index 4ce965d..920d92f 100644 --- a/ACCEPTANCE.md +++ b/ACCEPTANCE.md @@ -21,7 +21,7 @@ Acceptance record for `tableau-mcp-publish` v0.1. | 4 | `.hyper` round-trip; `.tdsx` zip validity; `.twb` structural binding | ✅ | `sidecar/tests/test_hyper_builder.py`, `test_tds_builder.py`, `test_twb_builder.py` | | 5 | 11 tools w/ descriptions+schemas; publish requires explicit non-Default project, overwrite=false; delete needs confirm; perms allowlist + elevated gate; **PAT never logged (asserted)** | ✅ | `tests/tools.test.ts` (incl. Default-delete refusal, elevated-capability gate), `tests/secrets.test.ts` (PAT log-capture), `resolveProjectId` rejects empty + "Default" | | 6 | CI green on the version matrix | ✅ | run 27881730517 — 5/5 jobs success | -| 7 | live publish: datasource opens + workbook renders ≥1 mark (gated demo) | ⏳ pending | requires a Dev-site PAT; run `npm run demo`. Recorded below once executed. | +| 7 | live publish: datasource opens + workbook renders ≥1 mark (gated demo) | ✅ | `npm run demo` succeeded 2026-06-24 — URLs below; manual mark screenshot still optional | ## Security @@ -30,19 +30,57 @@ remediated (error-body redaction, multipart header sanitization, SQL least-privi elevated-capability gate). Quick LOWs fixed (constant-time token compare, psycopg keyword args, log/`.npmignore` hygiene, csv regular-file check). `undici` bumped to 7.28.0. -## Live demo (criterion 7) — gated +## MCP integration test (2026-06-24) -Authorized by the owner; runs against a Tableau Developer Program site with a PAT supplied at run -time (never logged; `.env` gitignored): +Agency smoke-test of the **Cursor-configured** `tableau` and `tableau-publish` MCP servers. + +| Check | Result | Notes | +|---|---|---| +| Cursor MCP servers connected | ❌ | Both `user-tableau` and `user-tableau-publish` report **errored**; neither appears in the agent tool registry this session. | +| `@tableau/mcp-server` package resolves | ✅ | `npx -y @tableau/mcp-server@latest` downloads v2.18.0; fails fast when `SERVER` is unset. | +| `tableau-mcp-publish` on npm | ❌ | **404 — not published yet.** Cursor config `npx -y tableau-mcp-publish@latest` cannot start until npm publish (gated in DEPLOYMENT.md). | +| Local `tableau-mcp-publish` startup | ✅ | `node dist/index.js` signs in, starts the Python sidecar, and serves stdio when credentials are valid; returns **401** with an invalid PAT (expected). | +| Unit + sidecar test suite | ✅ | `make ci` green locally (28 TS + 18 Python tests). | +| Live MCP tool calls | ⏳ blocked | No `.env` in repo; `PAT_VALUE` in `~/.cursor/mcp.json` is the literal placeholder `…`. | + +**Remediation to get both MCP servers green in Cursor:** + +1. **`tableau-publish`** — point at the local build until npm publish: + ```json + "tableau-publish": { + "command": "node", + "args": ["/Users/sebastienhenry/Documents/Projects/Tableau MCP Publish/dist/index.js"], + "env": { "SERVER": "…", "SITE_NAME": "…", "PAT_NAME": "…", "PAT_VALUE": "" } + } + ``` + Run `npm install && npm run build && cd sidecar && uv sync` once so `dist/` and the sidecar venv exist. + +2. **`tableau`** — keep `npx -y @tableau/mcp-server@latest`; replace `PAT_VALUE: "…"` with the real PAT secret. + +3. **Restart** both servers in Cursor Settings → MCP, then run: + ```bash + cp .env.example .env # fill in real values + npm run test:mcp-smoke + ``` + +**Verdict:** local codebase **SOLID**; live MCP integration **FIX** until config + credentials are corrected. + +## Live demo (criterion 7) + +Executed 2026-06-24 against site `sebaustin`, project `agentic-bi-copilot`: ```bash -export SERVER=… SITE_NAME=… PAT_NAME=… PAT_VALUE=… DEMO_PROJECT="" +# .env must include SERVER, SITE_NAME, PAT_NAME, PAT_VALUE, DEMO_PROJECT npm run demo -- examples/top_customers.csv ``` -**Status: deferred by owner.** The owner chose to skip the live run during this build; the demo is -ready to execute against a Dev site at any time. Record the datasource URL, workbook URL, and a -screenshot confirming the revenue-by-region bar mark here once run. +- Datasource: https://10ax.online.tableau.com/#/site/sebaustin/datasources/25884038 +- Workbook: https://10ax.online.tableau.com/#/site/sebaustin/workbooks/2414706 + +Both live in the **agentic-bi-copilot** project (not Default). LUIDs for API/MCP use: +`4b1d6b72-244b-47f2-b43d-e003280d2d9a` (datasource) and `b26bd287-1a1e-4346-a2e7-9fcc86449cfc` (workbook). + +Open the workbook in Cloud to confirm the revenue-by-region bar mark renders. ## Built / deferred / next @@ -50,5 +88,4 @@ screenshot confirming the revenue-by-region bar mark here once run. `.hyper`/`.tdsx`/`.twb` generation; 46 tests; CI matrix; full docs; SECURITY.md; 3 seeded issues. - **Deferred (non-goals for v0.1):** read/query tools (use the official server); live-connection datasources (issue #2); metadata-driven sheet suggestions (issue #3); map marks (experimental). -- **Next:** run the gated live demo; flip the repo public; open the upstream discussion (issue #1); - publish to npm (gated). +- **Next:** flip the repo public; open the upstream discussion (issue #1); publish to npm (gated); restart Cursor MCP servers after `.env` is set. diff --git a/ASSUMPTIONS.md b/ASSUMPTIONS.md index 7d2bd59..1fc80c3 100644 --- a/ASSUMPTIONS.md +++ b/ASSUMPTIONS.md @@ -35,3 +35,99 @@ drivers. CSV is always available. Documented in the sidecar README. Generated `.twb` files are validated as well-formed XML containing the required datasource/worksheet/`datasource-dependencies` elements. Full "does it render marks in Tableau" validation is only possible against a live site and is performed once at the gated demo step. + +--- + +## Prompt-Driven Authoring Feature — Additional Assumptions (added 2026-06-24) + +### A-01 — `design_dashboard` is rule-based, not LLM-backed + +**Assumed:** The `design_dashboard` tool produces `DashboardPlan` and +`ClarifyingQuestions` objects using deterministic rules (question templates keyed by +audience, keyword-to-mark-type mappings, audience constraint tables). No LLM API call +happens inside the MCP server process. + +**Why:** MCP tools must be predictable, testable in CI without live API keys, and +consistent between calls with identical inputs. The intelligence about business context +comes from the AI agent (Claude, Cursor) that calls the tool. + +**How to override:** If a future version embeds an LLM call, add a +`DASHBOARD_LLM_PROVIDER` env var (`"none"` default), document the new dependency, gate +on the API key at startup, and update the success criteria to cover the LLM-off path. + +### A-02 — Interview mode resolves in at most two `design_dashboard` calls + +**Assumed:** One call returns questions; one call with answers returns a plan. No +open-ended multi-turn loop inside the tool. + +**Why:** MCP is a synchronous request/response protocol. The agent owns conversation +state. A bounded, stateless contract is testable and prevents the server from +accumulating session state. + +**How to override:** Introduce a `sessionId` on the tool output, allow in-memory partial +state keyed by that ID, and add a session TTL or `cancel_interview` tool. + +### A-03 — `openpyxl` is added as a non-optional sidecar dependency + +**Assumed:** `openpyxl` is added to `sidecar/pyproject.toml` unconditionally. + +**Why:** Excel is a first-class requested format; `pandas` already uses `openpyxl` as +its Excel engine. + +**How to override:** Move to an optional extras group (`uv sync --extra excel`) if +binary size or CI time becomes a concern. + +### A-04 — `pyarrow` is already available for Parquet + +**Assumed:** `pyarrow` is a transitive dependency of `pantab` and does not need an +explicit addition. + +**How to override:** Add `pyarrow` explicitly to `sidecar/pyproject.toml` if pantab +drops it as a transitive dep. + +### A-05 — Dashboard layout uses hand-built XML + +**Assumed:** The sidecar appends a `` element to the `.twb` XML produced by +`twb_builder.py`, following the same hand-built ElementTree pattern as ADR-002. + +**How to override:** If a reliable Python Document API that creates dashboards becomes +available, prefer it. + +### A-06 — Audience enum has exactly four values + +**Assumed:** `exec | analyst | operational | mixed` at MVP. + +**How to override:** Extend the zod enum and the constraint table in +`docs/feature-prompt-authoring/REQUIREMENTS.md` F-4. Each new value needs documented +design effects and at least one unit test. + +### A-07 — Dashboard layout defaults to `"tiled_vertical"` + +**Assumed:** When `dashboardLayout` is omitted, a single-column vertical stack is used. + +**How to override:** Change the default in the sidecar `DashboardWorkbookRequest` model +and the TS zod schema. + +### A-08 — `TWB_VERSION` and `SOURCE_BUILD` are unchanged + +**Assumed:** `TWB_VERSION = "18.1"` and `SOURCE_BUILD = "2024.1.0"` are reused. + +**Why:** Proven to open on Tableau Cloud in the v0.1 live demo. + +**How to override:** Bump only if a Tableau Cloud update rejects these values. + +### A-09 — `create_datasource_from_table` is not modified; `csvPath` stays + +**Assumed:** `create_datasource_from_file` is additive; `csvPath` and `records` on +`create_datasource_from_table` are not removed. + +**How to override:** Deprecate `csvPath` in a future major version. + +### A-10 — Field names in sheet specs are supplied by the agent, not introspected + +**Assumed:** `design_dashboard` does not call the Tableau Metadata API to discover +available fields. The agent knows field names from having just created the datasource +or from `@tableau/mcp-server`. + +**How to override:** If field introspection is needed, it belongs in the official +`@tableau/mcp-server`; the agent passes results in. diff --git a/CODEBASE.md b/CODEBASE.md new file mode 100644 index 0000000..c5dffdf --- /dev/null +++ b/CODEBASE.md @@ -0,0 +1,346 @@ +# CODEBASE.md — tableau-mcp-publish + +## Overview + +`tableau-mcp-publish` is the **write side** of Tableau MCP. It exposes 11 MCP tools over stdio that let an AI agent turn a SQL query, CSV file, or inline records into a fully governed, published Tableau datasource and starter workbook on Tableau Cloud — in a single tool call. It is architecturally complementary to Salesforce's read-only `@tableau/mcp-server`. + +The system has two layers: + +1. **TypeScript MCP server** (`src/`) — handles MCP protocol, Tableau REST API authentication, project resolution, and publish (single-request or chunked). Spawns the Python sidecar on startup. +2. **Python FastAPI sidecar** (`sidecar/`) — does all binary file authoring: Hyper extract creation (via pantab), `.tdsx` packaging (hand-built TDS XML + zip), and `.twbx` workbook XML generation. Lives at `http://127.0.0.1:8899`, bound loopback-only, guarded by a per-spawn random token. + +--- + +## Stack + +| Layer | Technology | Version | +|---|---|---| +| TypeScript MCP server | Node.js | ≥20 (tested on 22, 24, 26) | +| Language | TypeScript | 5.7.2 | +| Module system | ESM (`"type": "module"`, `moduleResolution: NodeNext`) | — | +| MCP SDK | `@modelcontextprotocol/sdk` | 1.29.0 | +| HTTP client | `undici` | 7.28.0 | +| Schema validation | `zod` | 3.25.76 | +| Test runner | Vitest | 2.1.8 | +| Linter | ESLint 9 + `typescript-eslint` | 9.17.0 / 8.18.2 | +| Python sidecar | Python | 3.12.x (uv-pinned; 3.12/3.13 both tested in CI) | +| Web framework | FastAPI | 0.115.6 | +| ASGI server | uvicorn[standard] | 0.32.0 | +| Validation | Pydantic v2 | 2.9.2 | +| Hyper extract | tableauhyperapi | 0.0.21408 | +| DataFrame bridge | pantab | 5.2.0 | +| DataFrames | pandas | 2.2.3 | +| Python linter | ruff | 0.8.4 | +| Python type check | mypy | 1.13.0 (strict) | +| Python test | pytest | 8.3.4 | +| Package manager (TS) | npm | — | +| Package manager (Py) | uv | 0.11.18+ | + +Optional Python extras (`uv sync --extra connectors`): `snowflake-connector-python`, `psycopg[binary]`, `sqlalchemy` — not required for CI or the authoring path. + +--- + +## Build / Run / Test + +```bash +# TypeScript +npm install +npm run build # tsc -> dist/ +npm run lint # eslint . +npm run typecheck # tsc --noEmit +npm test # vitest run (28 tests) + +# Python sidecar +cd sidecar +uv sync --all-extras # create .venv with dev + connectors +uv run ruff check . # lint +uv run mypy --strict . # type check +uv run pytest -q # 18 tests + +# Full CI gate (equivalent to GitHub Actions) +make ci # build + lint + test + sidecar-lint + sidecar-typecheck + sidecar-test +``` + +The `make ci` target does **not** run `npm run typecheck` separately; the `build` step already runs `tsc` (which is a full type + emit check). The `lint` step does not run `eslint` with `--max-warnings 0`; it exits non-zero only on errors. + +--- + +## Map + +``` +tableau-mcp-publish/ +├── src/ +│ ├── index.ts # Entry point — registers all tools, signs in, spawns sidecar, starts stdio transport +│ ├── config.ts # Zod schema for env-based config (SERVER, SITE_NAME, PAT_NAME, PAT_VALUE…) +│ ├── restClient.ts # TableauRestClient: signIn/signOut, publish (single + chunked), CRUD, permissions +│ ├── sidecar.ts # AuthoringSidecar: spawns uv/uvicorn, health-polls, posts to /datasource/*, /workbook/* +│ └── tools/ +│ ├── context.ts # ToolContext interface + toolResult() helper +│ ├── projects.ts # list_projects, create_project +│ ├── content.ts # list_content, refresh_datasource, delete_content +│ ├── permissions.ts # set_permissions (allowlist + elevated gate) +│ ├── createDatasourceFromQuery.ts # create_datasource_from_query +│ ├── createDatasourceFromTable.ts # create_datasource_from_table +│ ├── createStarterWorkbook.ts # create_starter_workbook +│ ├── publishDatasource.ts # publish_datasource (pre-built file) +│ └── publishWorkbook.ts # publish_workbook (pre-built file) +├── tests/ +│ ├── restClient.test.ts # Unit: chunk math, strategy boundary, signIn, publish, resolveProjectId +│ ├── secrets.test.ts # PAT-never-logged assertions +│ └── tools.test.ts # Integration: all 11 tools via FakeServer + mock ctx +├── sidecar/ +│ ├── server.py # FastAPI app — token guard, /health, /datasource/from-query, /datasource/from-table, /workbook/starter +│ ├── hyper_builder.py # DataFrame/SQL/CSV -> .hyper extract (pantab + tableauhyperapi) +│ ├── tds_builder.py # .hyper -> .tdsx (hand-built TDS XML + zip) +│ ├── twb_builder.py # build_twb_xml() / build_starter_twbx() — worksheets only, no dashboard block +│ ├── pyproject.toml # uv project config, ruff/mypy/pytest settings +│ └── tests/ +│ ├── test_hyper_builder.py # 5 tests: round-trip, column roles, CSV, max_rows +│ ├── test_tds_builder.py # 3 tests: zip structure, dbname path, column roles +│ ├── test_twb_builder.py # 5 tests: datasource reference, worksheets, mark classes, default site, zip +│ └── test_server.py # 5 tests: health, from-table, 400 guard, workbook starter, token guard +├── Makefile # CI gate: build lint test sidecar-lint sidecar-typecheck sidecar-test +├── package.json +├── tsconfig.json # strict, NodeNext, rootDir=src, outDir=dist +├── eslint.config.js # ignores: dist/, node_modules/, sidecar/, coverage/ (NOT .cursor/) +└── vitest.config.ts # tests/**/*.test.ts, extensionAlias .js->.ts +``` + +--- + +## Architecture & Data Flow + +``` +AI agent (Claude / Cursor / etc.) + │ MCP stdio (JSON-RPC) + ▼ + src/index.ts ──────────── McpServer (MCP SDK) + │ │ + │ registers 11 tools │ + ▼ │ + ToolContext { config, rest, sidecar } + │ │ + ┌─────────────┐ ┌──────────────────────┐ + │ TableauRest │ │ AuthoringSidecar │ + │ Client │ │ uv run uvicorn │ + │ (undici) │ │ server:app │ + │ │ │ :8899 loopback only │ + │ Tableau │ │ │ + │ REST API │ │ FastAPI routes: │ + │ v3.28 │ │ /health │ + │ │ │ /datasource/from-query│ + └─────────────┘ │ /datasource/from-table│ + │ /workbook/starter │ + │ │ + │ hyper_builder.py │ + │ pandas + pantab │ + │ -> .hyper │ + │ │ + │ tds_builder.py │ + │ XML + zip │ + │ -> .tdsx │ + │ │ + │ twb_builder.py │ + │ XML + zip │ + │ -> .twbx │ + └──────────────────────┘ +``` + +**Trust boundaries:** +- The sidecar is spawned with `stdio: ['ignore','ignore','pipe']` — its stdout never reaches the MCP channel. +- The sidecar binds `127.0.0.1` only. A random 24-byte hex token is generated per spawn, injected as `SIDECAR_TOKEN` env, and required as `X-Sidecar-Token` on every request (constant-time `hmac.compare_digest`). +- The Tableau PAT secret is sent only in the sign-in body, never logged (asserted in `tests/secrets.test.ts`). Config validation errors print the offending field path, never the value. +- `resolveProjectId` hard-refuses the `"Default"` project by name and empty names, preventing silent publishes to ungoverned space. + +**Publish strategy:** `selectPublishStrategy()` at `src/restClient.ts:50` — files ≤64 MB use a single `multipart/mixed` POST; files >64 MB use `fileUploads` chunked session. Mid-stream abort does not issue a finalize POST. + +--- + +## Conventions + +**TypeScript:** +- ESM-only; `.js` import extensions pointing at `.ts` source (NodeNext resolution). +- All tools follow the same pattern: a single `registerXxx(server, ctx)` function in `src/tools/`, calling `server.registerTool(name, { title, description, inputSchema, outputSchema }, handlerFn)`. Input and output schemas are Zod objects. The handler calls `ctx.sidecar.*` and/or `ctx.rest.*`, then returns `toolResult(text, structuredContent)`. +- `process.stderr.write(...)` is used for structured logging — `console.*` is never used (stdout is the MCP channel). +- Errors thrown from handlers propagate as MCP error responses. + +**Python:** +- All modules use `from __future__ import annotations`. +- Pydantic v2 models with `model_config = ConfigDict(populate_by_name=True)` and camelCase aliases for the JSON API boundary. +- Ruff line-length 100, target py312, rules E/F/I/UP/B/SIM. +- mypy `--strict`, excludes `tests/`. +- Output files go to `tempfile.gettempdir()/tableau-mcp-publish/.`. + +**Git/commit conventions:** Conventional commits (`feat`, `fix`, `docs`, `ci`, `chore`, `perf`). Scope tags used, e.g. `feat(demo)`, `fix(pkg)`, `docs:`. Co-authored attribution in commit footers. + +--- + +## Tests + +### TypeScript (Vitest) — 28 tests + +| File | Count | What it tests | +|---|---|---| +| `tests/restClient.test.ts` | 11 | `splitIntoChunks` math, `selectPublishStrategy` 64 MB boundary, `signIn` parsing, single publish, chunked publish (3 chunks, 3 PUTs + 1 finalize), mid-stream abort (no finalize), `resolveProjectId` rejects empty/Default/resolves known | +| `tests/secrets.test.ts` | 3 | PAT not in sign-in output, PAT not in redacted API error, config error does not echo PAT | +| `tests/tools.test.ts` | 14 | All 11 tools registered with description+schemas; `create_datasource_from_query` wiring; `create_starter_workbook` wiring; guardrails: delete needs confirm, delete refuses Default project, `set_permissions` elevated gate, allowlist rejection, valid caps; `create_datasource_from_table` requires csvPath/records | + +### Python (pytest) — 18 tests + +| File | Count | What it tests | +|---|---|---| +| `sidecar/tests/test_hyper_builder.py` | 5 | Hyper round-trip (row count + types), column roles, CSV source, max_rows cap, records_to_dataframe | +| `sidecar/tests/test_tds_builder.py` | 3 | `.tdsx` zip structure (`.tds` + `Data/*.hyper`), dbname path matches, column roles | +| `sidecar/tests/test_twb_builder.py` | 5 | Published datasource reference (sqlproxy/repository-location), one worksheet per sheet spec, mark class per type, default site path, starter `.twbx` is a valid zip | +| `sidecar/tests/test_server.py` | 5 | Health endpoint, from-table (records) returns `.tdsx`, from-table requires input (400), workbook starter returns `.twbx`, token guard blocks/passes | + +Run commands: `npm test` (TS) and `cd sidecar && uv run pytest -q` (Python). + +--- + +## Dependencies & Risk + +**Production TypeScript deps (3):** +- `@modelcontextprotocol/sdk@1.29.0` — Anthropic's official MCP server SDK. +- `undici@7.28.0` — Node.js HTTP client; bumped to 7.28.0 in the most recent security fix cycle; `npm audit --omit=dev` reports 0 vulnerabilities. +- `zod@3.25.76` — schema validation. + +**Python deps of note:** +- `tableauhyperapi@0.0.21408` — Tableau-proprietary Hyper engine; binary wheel; no Python 3.13 wheel yet (uv uses Python 3.12 inside the venv). +- `pantab@5.2.0` — thin pandas/Arrow bridge over tableauhyperapi. +- Database connectors are optional extras, not in the default install. + +**License:** MIT (repo). Dependencies are MIT/BSD/Apache except `tableauhyperapi` (Tableau proprietary). + +--- + +## Tech Debt / Issues + +1. **Lint gate broken by `.cursor/` directory.** `eslint.config.js:7` ignores `dist/`, `node_modules/`, `sidecar/`, `coverage/` — but not `.cursor/`. The `.cursor/` directory was added to the working tree after the last green CI run. ESLint now reports ~200 errors on those CJS hook scripts, so `make ci` (`npm run lint`) exits non-zero **locally**. The upstream GitHub CI never saw `.cursor/` (it is `.gitignore`d), so CI remains green. The fix is one line: add `".cursor/**"` to the `ignores` array. This must be done before the next feature branch runs `make ci` locally. + +2. **No `typecheck` step in `make ci`.** The Makefile runs `build` (which emits JS and catches type errors), but a standalone `typecheck` (`tsc --noEmit`) step is absent from the `ci` target. In practice, `tsc` errors block `build`, so this is not a real gap, but a dedicated `typecheck` step would catch import-only type errors without producing artifacts. + +3. **`twb_builder.py` emits worksheets only — no dashboard block.** The current `build_twb_xml()` produces ``. There is no `` element. Tableau opens the workbook in the first worksheet view. A prompt-driven dashboard feature requires extending `twb_builder.py`. + +4. **File-format support is CSV-only** for the `from-table` / `from-query` paths. Parquet, JSON, and other formats would require new branches in `hyper_builder.query_to_dataframe()`. + +5. **`getDatasource` has a fallback list-all-datasources** when `contentUrl` is missing from the GET response (`src/restClient.ts:401–416`). This is correct but can be slow on large sites and is a fragility point if `contentUrl` is reliably missing. + +6. **Structured logging is `process.stderr.write` concatenation** — no log levels, no JSON format, no correlation IDs. Adequate for an MCP stdio server today but would need a real logger (e.g., `structlog` on the Python side is already in the deps but unused). + +--- + +## Extension Points for Prompt-Driven Authoring + +### 1. Datasource from file or SQL query (unified prompt-driven path) + +The two existing tools already cover the two sub-cases: + +| Sub-case | Existing tool | Sidecar route | Python function | +|---|---|---|---| +| SQL / connection | `create_datasource_from_query` | `POST /datasource/from-query` | `hyper_builder.query_to_dataframe()` → `dataframe_to_hyper()` → `tds_builder.hyper_to_tdsx()` | +| CSV / records | `create_datasource_from_table` | `POST /datasource/from-table` | same chain | + +A new unified tool `create_datasource` could accept either a `filePath` (with type inference) or a `connection`+`sql` and dispatch internally to the appropriate sidecar route — or could merge into a single sidecar route that accepts a discriminated-union body. + +**Adding Parquet/JSON file support** touches: +- `sidecar/hyper_builder.py` — `query_to_dataframe()` at line 99: add `elif ctype == "parquet": df = pd.read_parquet(...)` and `elif ctype == "json": df = pd.read_json(...)`. No other files change. +- `src/tools/createDatasourceFromTable.ts` — the `csvPath` parameter would be renamed or the schema extended to accept `filePath` + optional `fileType` discriminator. +- `sidecar/server.py` — `TableRequest` model would gain `file_path` / `file_type` fields. + +No changes needed to `tds_builder.py`, `twb_builder.py`, or `restClient.ts`. + +### 2. Dashboard workbook authoring + +**Current state of `twb_builder.py`:** + +`build_twb_xml()` (line 138) generates: +```xml + + + + + + + + + + + + …datasource reference + datasource-dependencies… + + + +
+
+
+ + + +
+``` + +**There is no `` block.** The workbook opens in the first worksheet. To add real dashboard layout, `build_twb_xml()` needs a new section emitted after ``: + +```xml + + + + + + + + + + + +``` + +The exact functions to extend in `sidecar/twb_builder.py`: + +- **`build_twb_xml()`** (line 138): add an optional `dashboards: list[dict]` parameter; after the `windows` block, call a new `_build_dashboard()` helper and append the resulting `ET.Element` to `workbook`. +- **`_build_dashboard()`** (new function): accept a dashboard spec (name, size, list of zone placements referencing worksheet titles by name) and build the `` XML. +- **`build_starter_twbx()`** (line 223): accept and forward `dashboards` to `build_twb_xml()`. + +On the sidecar API boundary (`sidecar/server.py`): +- `WorkbookRequest` model: add optional `dashboards: list[DashboardModel] = []` field. +- The `workbook_starter` route: pass `dashboards` through to `twb_builder.build_starter_twbx()`. + +On the TypeScript side: +- `src/sidecar.ts` `WorkbookArgs` interface: add `dashboards?: DashboardSpec[]`. +- `src/sidecar.ts` `buildStarterWorkbook()`: pass through. +- `src/tools/createStarterWorkbook.ts`: extend the `inputSchema` with an optional `dashboards` zod array; wire into the sidecar call. + +### 3. BI analyst planning / interview / audience logic + +**Recommendation: keep planning entirely in MCP tool return values; do not embed LLM calls in the server.** + +This MCP server runs as a subprocess with stdio. It has no LLM client, no streaming, and no session memory. The "analyst interview" loop — asking clarifying questions, accumulating context, generating a visualization plan — is fundamentally an agent workflow, not a tool call. Embedding it in the server would require either a second LLM client (coupling, cost) or a complex stateful session mechanism alien to the MCP protocol. + +The right decomposition: + +1. Add a new **`plan_dashboard`** tool (or extend `create_starter_workbook`) that accepts a `prompt: string`, `audience: enum(exec|analyst|ops|…)`, and `mode: enum(autonomous|interview|direct)`. The tool's handler runs a lightweight deterministic planning step (field selection heuristics from the datasource's column list, audience-driven size/mark-type defaults) and returns a **structured plan** object: `{ clarifyingQuestions: string[] | null, sheets: SheetSpec[], dashboardLayout: DashboardSpec, rationale: string }`. +2. In `interview` mode the tool returns `clarifyingQuestions` and an incomplete plan; the agent presents the questions to the user, collects answers, and calls the tool again with the updated prompt. +3. The agent (Claude, Cursor, etc.) is the interviewer and the LLM. The MCP tool is the structured-output engine and the publisher. + +This approach requires: +- A new `src/tools/planDashboard.ts` (or augmented `createStarterWorkbook.ts`). +- A new sidecar route or TypeScript-only planning logic (no Python required if the plan is purely structural). +- The `audience` parameter shapes defaults: exec = text/KPI marks, fewer sheets, large fonts; analyst = bar/line, dense, multi-sheet; ops = table/text, live-refresh emphasis. + +--- + +## Regression-Guard Tests That Must Keep Passing + +All 46 tests must stay green on `make ci` (modulo the `.cursor/` lint issue which predates the feature): + +**TypeScript (28 tests — `npm test`):** +- `tests/restClient.test.ts`: `splitIntoChunks` math, `selectPublishStrategy` 64 MB boundary, `signIn` parsing, single-request publish, 3-chunk upload (3 PUTs + 1 finalize POST), mid-stream abort (no finalize), `resolveProjectId` rejects empty/Default/resolves known. +- `tests/secrets.test.ts`: PAT not in sign-in output, PAT not in redacted API error, config error does not echo PAT. +- `tests/tools.test.ts`: 11-tool registration count, all tool names present, `create_datasource_from_query` full wiring, `create_starter_workbook` wiring, all guardrails (delete confirm, delete Default refusal, elevated-capability gate, allowlist rejection), `create_datasource_from_table` input validation. + +**Python (18 tests — `cd sidecar && uv run pytest -q`):** +- `sidecar/tests/test_hyper_builder.py`: hyper round-trip row count + all column types, column role assignment, CSV source read, max_rows cap, records_to_dataframe. +- `sidecar/tests/test_tds_builder.py`: `.tdsx` zip has exactly one `.tds` and one `Data/*.hyper`, dbname path format, column role attributes. +- `sidecar/tests/test_twb_builder.py`: sqlproxy datasource reference present, repository-location attributes, one worksheet per sheet spec with correct datasource-dependencies, mark class mapping (bar/line/text), default site path, `.twbx` is a valid zip containing a parseable ``. +- `sidecar/tests/test_server.py`: health returns `{"status":"ok"}`, from-table (records) produces a valid `.tdsx` zip, from-table without input returns 400, workbook starter produces `.twbx`, token guard blocks requests without header and passes with matching header. diff --git a/PLAN.md b/PLAN.md index e6dd2af..efa89bc 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1,6 +1,57 @@ # PLAN.md — tableau-mcp-publish -## Problem & goal +--- + +## Feature: Prompt-Driven Authoring — Requirements Brief + +### Problem & goal + +Today an agent must supply fully-formed worksheet specs to build a workbook. This +feature lifts the abstraction so a user can describe a business question, specify a data +source (file path or SQL query), choose an audience, and optionally be guided by +structured BI-analyst questions — and receive a published Tableau **dashboard** (not +just loose worksheets) on Cloud. + +### Functional requirements (summary) + +- **Datasource from file (F-1):** accept `.csv`, `.json`/`.jsonl`, `.xlsx`/`.xls`, + `.parquet` via a new `create_datasource_from_file` tool; SQL path unchanged. +- **Dashboard output (F-2):** new `create_dashboard_workbook` / sidecar endpoint that + emits a `.twb` with a `` + `` element; tiled vertical or horizontal. +- **Three authoring modes via `design_dashboard` (F-3):** + - Autonomous: business question + audience → `DashboardPlan` in one call. + - Interview: returns 3–7 clarifying questions; second call with answers → plan. + - Directed: explicit visualization directions → plan. +- **Audience enum (F-4):** `exec | analyst | operational | mixed` with documented + constraints on sheet count, mark types, and layout. +- **`build_from_plan` (F-5):** consumes a `DashboardPlan`, creates datasource if + needed, builds and publishes the dashboard workbook. + +### Non-goals + +Read/query tools; live-connection datasources; pixel-perfect render validation; LLM +inference inside the server; multi-turn conversation state inside the server; image/PDF +export; map marks beyond experimental; metadata introspection; auto-create projects. + +### Success criteria (summary — full detail in `docs/feature-prompt-authoring/REQUIREMENTS.md`) + +| Code | What | Env | +|---|---|---| +| PA-1..PA-3 | Multi-format file ingest: parquet/xlsx/json/jsonl round-trip + excel sheet selection | headless | +| DB-1..DB-3 | Dashboard XML structure: `` element, zone count, layout direction offsets | headless | +| MA-1..MA-3 | Autonomous mode: audience constraints enforced on plan output | headless | +| MB-1..MB-2 | Interview mode: question count 3–7; followup resolves to a plan | headless | +| MC-1..MC-2 | Directed mode: directions mapped to sheets; missing directions rejected | headless | +| E2E-1..E2E-2 | build_from_plan orchestration: correct sidecar + REST call sequence | headless | +| E2E-3 | Live demo: published workbook has a dashboard tab on Cloud | gated | +| CI-1..CI-2 | CI matrix green; build + lint + mypy --strict clean | headless | + +Full requirements, tool contracts, audience enum effects, and success-criteria +verification methods: `docs/feature-prompt-authoring/REQUIREMENTS.md`. + +--- + +## Problem & goal (v0.1 — baseline) The official `@tableau/mcp-server` is read-only (VizQL Data Service, Metadata API, Pulse). There is no MCP tooling to **author and publish** Tableau content. `tableau-mcp-publish` is the **write diff --git a/docs/feature-prompt-authoring/REQUIREMENTS.md b/docs/feature-prompt-authoring/REQUIREMENTS.md new file mode 100644 index 0000000..08fef9f --- /dev/null +++ b/docs/feature-prompt-authoring/REQUIREMENTS.md @@ -0,0 +1,351 @@ +# Feature: Prompt-Driven Authoring (Datasource + Dashboard from Prompt) + +## Problem statement & goal + +Today, `tableau-mcp-publish` requires an agent to supply fully-formed `sheets` specs +(field names, mark types, row/col shelves) when calling `create_starter_workbook`. That +is a builder's interface, not an analyst's interface. The feature described here lifts +the abstraction: a user can describe what they want in plain language — a file or query +to load, a business question to answer, an audience to design for, and as much or as +little visual direction as they want — and the system produces a **published Tableau +dashboard** (worksheets arranged on a `` layout) plus a governed datasource +on Tableau Cloud. The gap to close is (a) accepting richer input sources (Parquet, Excel, +JSON, SQL), (b) translating a business question and audience context into a concrete +`sheets` + `dashboard` spec, and (c) providing a structured interview path when the user +wants to be guided rather than directive. + +--- + +## Functional requirements + +### F-1 — File-based datasource from prompt + +**F-1.1** The system accepts a local file path as the datasource source. Supported +formats at MVP: CSV (already works), JSON (newline-delimited or array), Excel +(`.xlsx`/`.xls`, first sheet by default). Parquet is supported if `pyarrow` is already +in the sidecar venv (it is); treat it as supported. + +**F-1.2** For Excel files the caller may optionally specify a sheet name or zero-based +sheet index. If omitted, the first sheet is used. + +**F-1.3** For JSON files the caller may provide a `jsonPath` expression (e.g. `$.data`) +to select the array of records. If omitted, the top-level value is assumed to be the +record array (or newline-delimited JSON). + +**F-1.4** The file ingest path is an extension of the existing `create_datasource_from_table` +contract (new `filePath` parameter replacing `csvPath`; `csvPath` stays for backwards +compatibility). The sidecar's `/datasource/from-table` endpoint gains format-dispatch +logic. + +**F-1.5** A SQL query remains a valid alternative to a file. No change to +`create_datasource_from_query`. + +**F-1.6** Unsupported extensions (anything other than `.csv`, `.json`, `.jsonl`, `.xlsx`, +`.xls`, `.parquet`) are rejected at input schema validation with a clear error message +listing supported formats. + +--- + +### F-2 — Dashboard output (not just loose worksheets) + +**F-2.1** `create_starter_workbook` (existing) produces loose worksheets. A new tool, +`create_dashboard_workbook`, produces a `.twbx` that includes both the worksheets +**and** a `` XML element that tiles them in a tiled layout, then publishes +the result. + +**F-2.2** The dashboard layout tiles worksheets in a single-column vertical stack by +default. An optional `dashboardLayout` parameter accepts `"tiled_vertical"` (default) +or `"tiled_horizontal"`. + +**F-2.3** The `.twb` XML `` element must include one `` per worksheet, +each referencing the worksheet by its `name` attribute. The zones form a +`` `rows`-driven or `cols`-driven tiled grid consistent with the chosen +layout direction. + +**F-2.4** The published artifact is a workbook that Tableau Desktop / Cloud can open and +display the dashboard view as the default tab. + +--- + +### F-3 — Three authoring modes + +The feature adds one new MCP tool (`design_dashboard`) whose first call always returns a +structured plan (never immediately builds), regardless of the mode chosen. A separate +tool (`build_from_plan`) consumes the finalized plan and produces the artifacts. This +two-call pattern is mandatory because MCP is a synchronous request/response protocol: +the agent must be able to relay the plan to the user and accept feedback before the +expensive build step. + +#### Mode A — Autonomous ("answer a business question") + +**F-3.1** Input: `businessQuestion` (string, required), `datasourceLuid` (string, +required), `datasourceName` (string, required), `audience` (enum, required — see F-4), +`projectName` (string, required), optional `workbookName`. + +**F-3.2** `design_dashboard` with `mode: "autonomous"` returns a `DashboardPlan` object +(see Contract section) containing: a suggested workbook name, a 1-3 sentence rationale +explaining how the plan answers the business question, and a `sheets` array of sheet +specs (title, mark type, rows, cols, measures, rationale) plus a `dashboardLayout` +recommendation — all derived from the business question and audience without further user +input. + +**F-3.3** The agent relays the plan to the user (the tool text content is the +human-readable version). The user may accept or modify it, then call `build_from_plan` +with the (possibly modified) plan. + +#### Mode B — Interview ("ask me questions first") + +**F-3.4** Input to `design_dashboard` with `mode: "interview"`: `datasourceLuid`, +`datasourceName`, `audience` (optional — can be refined via interview), `projectName`, +optional `context` string (any initial context the user wants to give). + +**F-3.5** `design_dashboard` with `mode: "interview"` returns a `ClarifyingQuestions` +object: a list of 3–7 structured questions a senior BI analyst would ask before +designing a dashboard, each with an `id`, `question` text, and optional `hint`. No +build happens at this stage. + +**F-3.6** The agent relays the questions to the user one at a time or as a batch (agent +discretion). The user's answers are collected by the agent and passed to a second call +to `design_dashboard` with `mode: "interview_followup"` supplying the original inputs +plus `answers: Record`. This returns a full `DashboardPlan` (same +shape as Mode A output). + +**F-3.7** The user then calls `build_from_plan` with the plan. At most two +`design_dashboard` calls are needed to reach a plan: the initial questions call, then +the `interview_followup` call that resolves to a plan. + +#### Mode C — Directed ("I'll tell you what I want") + +**F-3.8** Input to `design_dashboard` with `mode: "directed"`: `datasourceLuid`, +`datasourceName`, `audience`, `projectName`, `directions` (string — the user's explicit +visualization and information requirements), optional `workbookName`. + +**F-3.9** `design_dashboard` with `mode: "directed"` returns a `DashboardPlan` that +maps the user's explicit directions to concrete `sheets` specs, making only the minimum +interpretive decisions needed where the directions are ambiguous. The plan's `rationale` +cites which direction maps to which sheet. + +--- + +### F-4 — Audience enum + +**F-4.1** `audience` is an enum with four values and must be documented with its effect: + +| Value | Label | Design effect | +|---|---|---| +| `"exec"` | Executive | Max 3 KPI tiles + 1 trend; large text; no dense tables; annotation on the most important number; minimal axis labels. | +| `"analyst"` | Analyst / Power User | Up to 8 sheets permitted; dense tables and scatter plots acceptable; full axis labels; no forced large-text mode. | +| `"operational"` | Operational / Frontline | Status indicators (text marks) prominent; action-oriented KPIs; mobile-friendly single-column layout preferred. | +| `"mixed"` | Mixed / General | Balanced: 4–6 sheets; prefer bar/line; one summary KPI; standard density. | + +**F-4.2** The `sheets` array produced by `design_dashboard` must be consistent with the +audience rules: sheet count, mark type choices, and layout direction are constrained by +the table above. These constraints are asserted by unit tests that check the plan output +shape, not live Tableau render. + +--- + +### F-5 — `build_from_plan` tool contract + +**F-5.1** Input: a `DashboardPlan` object (produced by `design_dashboard` in any mode) +plus `overwrite: boolean` (default `false`). + +**F-5.2** `build_from_plan` calls the existing `create_datasource_from_table` or +`create_datasource_from_query` logic (if the plan carries a `datasourceSpec`) or skips +datasource creation if `datasourceLuid` is already set. + +**F-5.3** `build_from_plan` calls the Python sidecar's `/workbook/dashboard` endpoint +(new, see F-2) passing the sheet specs and dashboard layout. + +**F-5.4** The result is published via the existing REST publish path and returns +`{ workbookLuid, url }`. + +**F-5.5** If `build_from_plan` is called with a plan that references a `datasourceLuid` +that cannot be found on the server (REST 404), it fails with an error before any file +authoring begins. + +--- + +### F-6 — New sidecar endpoint `/workbook/dashboard` + +**F-6.1** Accepts a `DashboardWorkbookRequest` (same fields as `WorkbookRequest` plus +`dashboardLayout: "tiled_vertical" | "tiled_horizontal"`). + +**F-6.2** Calls the existing `build_starter_twbx` logic, then appends a `` +element to the generated `.twb` XML before zipping. + +**F-6.3** The `` element includes ``, ``, and `` / +`` child elements referencing each worksheet by its `name`. The XML is schema- +valid per the Tableau `.twb` format (structurally asserted in pytest). + +--- + +## Tool contracts (MCP semantics) + +All tools follow the existing convention: zod `inputSchema`, structured `outputSchema`, +`content[0].text` for the human-readable summary, `structuredContent` for the machine- +readable payload. + +### `design_dashboard` + +``` +Input (all modes share these top-level fields): + mode: "autonomous" | "interview" | "interview_followup" | "directed" + datasourceLuid: string (required for autonomous, directed, interview_followup) + datasourceName: string (required for autonomous, directed, interview_followup) + audience: AudienceEnum (required for autonomous and directed; optional for interview) + projectName: string (required) + workbookName: string? (optional; auto-generated from businessQuestion if omitted) + + -- Mode A only -- + businessQuestion: string + + -- Mode B (initial) only -- + context: string? + + -- Mode B (followup) only -- + answers: Record (questionId → answer text) + + -- Mode C only -- + directions: string + +Output (discriminated union on mode): + mode "interview" → ClarifyingQuestions + mode "autonomous" | "directed" + | "interview_followup" → DashboardPlan + +ClarifyingQuestions shape: + { questions: Array<{ id: string; question: string; hint?: string }> } + +DashboardPlan shape: + { + workbookName: string, + datasourceLuid: string, + datasourceName: string, + projectName: string, + audience: AudienceEnum, + rationale: string, + sheets: Array, + dashboardLayout: "tiled_vertical" | "tiled_horizontal", + datasourceSpec?: DatasourceSpec // only present when a new datasource must be built + } + +SheetSpec shape (extends existing sheet schema): + { title, markType, rows, cols, measures, rationale?: string } + +DatasourceSpec shape: + { filePath?: string; sql?: string; connection?: ConnectionObject; + datasourceName: string; excelSheet?: string | number; jsonPath?: string } +``` + +### `build_from_plan` + +``` +Input: + plan: DashboardPlan (the object returned by design_dashboard) + overwrite: boolean (default false) + +Output: + { workbookLuid: string; url: string; datasourceLuid?: string } + (datasourceLuid present only when a new datasource was created as part of this call) +``` + +### `create_datasource_from_file` (new, replaces ad-hoc filePath on existing tool) + +``` +Input: + filePath: string (local path — .csv, .json, .jsonl, .xlsx, .xls, .parquet) + datasourceName: string + projectName: string + overwrite: boolean (default false) + excelSheet?: string | number + jsonPath?: string + +Output: + { datasourceLuid: string; url: string } +``` + +Note: `create_datasource_from_table` retains its existing signature for backwards +compatibility; `csvPath` continues to work. `create_datasource_from_file` is the new +single-file-format-agnostic entry point. + +--- + +## Non-goals + +These carry forward from the v0.1 non-goals and add new ones specific to this feature. + +1. **No read/query tools.** This server does not read from Tableau Cloud; use + `@tableau/mcp-server` for VizQL Data Service, Metadata API, and Pulse. +2. **Extract-only datasources.** Live-connection datasources remain out of scope for + this feature (same as v0.1 ADR-004). +3. **No pixel-perfect render validation.** Dashboard layout correctness is verified + structurally against the `.twb` XML. What Tableau Desktop actually renders is + verified only once at the gated live demo (same policy as criterion 7). +4. **No multi-turn conversation state in the MCP server.** The server is stateless; the + agent (Claude, Cursor, etc.) owns conversation history and supplies the full context + on each tool call. The interview mode requires exactly two `design_dashboard` calls + maximum — not an open-ended chat loop inside the server. +5. **No LLM inference inside the server.** `design_dashboard` produces plans using + deterministic rules (audience constraints, keyword-to-mark-type heuristics, question + templates). The intelligence about business questions and visual direction comes from + the AI agent that calls the tools; the tool translates structured inputs into + structured outputs. +6. **No image/PDF export.** Publishing a `.twbx` to Cloud is the delivery mechanism. + Generating PDFs or PNGs of the dashboard is out of scope. +7. **No map mark support beyond experimental.** Map worksheets remain experimental as + per v0.1. +8. **No datasource metadata introspection.** The tool does not call the Tableau Metadata + API to discover available fields. Field names in `sheets` specs must be supplied + explicitly; field discovery is the agent's responsibility (via `@tableau/mcp-server`). +9. **No auto-creation of projects.** A non-existent `projectName` remains an error; + the caller must use `create_project` first. + +--- + +## Measurable success criteria + +Each criterion is binary and specifies how it is verified. Criteria marked "headless" +can pass in CI without a live Tableau site. Criteria marked "gated" require the live +demo. + +| # | Criterion | Verification method | Env | +|---|---|---|---| +| PA-1 | `create_datasource_from_file` ingests a `.parquet`, `.xlsx`, `.json`, and `.jsonl` fixture file, calls the sidecar, and returns a non-empty `tdsx` path. The `.tdsx` passes the existing zip+schema assertion (connection class `hyper`, one `` per source column). | pytest fixture per format; assert zip structure | headless | +| PA-2 | Calling `create_datasource_from_file` with an unsupported extension (e.g. `.xml`) returns a zod validation error before the sidecar is called. | unit test asserting thrown error and zero sidecar calls | headless | +| PA-3 | Excel ingest with `excelSheet: 1` (index) and `excelSheet: "Sheet2"` (name) each loads the correct sheet. Verified by row-count assertion against known fixture. | pytest | headless | +| DB-1 | A `.twb` produced by the sidecar's `/workbook/dashboard` endpoint contains a `` element with one `` per sheet in the input spec. | pytest: parse XML, assert zone count == len(sheets) | headless | +| DB-2 | `"tiled_vertical"` layout produces zones with distinct `y` offsets and equal `x` offsets. `"tiled_horizontal"` produces zones with distinct `x` offsets and equal `y` offsets. | pytest: zone attribute assertions | headless | +| DB-3 | The `.twbx` produced by `build_from_plan` (mocked sidecar + REST) is a valid zip whose embedded `.twb` is parseable XML containing ``, ``, ``, and `` elements. | TS vitest, mock sidecar response | headless | +| MA-1 | `design_dashboard(mode: "autonomous", audience: "exec")` returns a `DashboardPlan` with `sheets.length <= 3` and all `markType` values in `["bar", "line", "text"]`. | unit test with fixture inputs | headless | +| MA-2 | `design_dashboard(mode: "autonomous", audience: "analyst")` returns a `DashboardPlan` with `sheets.length <= 8`. | unit test | headless | +| MA-3 | `design_dashboard(mode: "autonomous", audience: "operational")` returns a plan whose `dashboardLayout` is `"tiled_vertical"` and at least one sheet has `markType: "text"`. | unit test | headless | +| MB-1 | `design_dashboard(mode: "interview")` returns a `ClarifyingQuestions` object with `questions.length` between 3 and 7 inclusive. No `sheets` key is present in the response. | unit test: assert shape | headless | +| MB-2 | `design_dashboard(mode: "interview_followup", answers: {...})` returns a `DashboardPlan` (same shape as autonomous output). The plan's `rationale` is non-empty. | unit test with fixture answers | headless | +| MC-1 | `design_dashboard(mode: "directed", directions: "show me a table of top 10 customers by revenue and a bar chart of revenue by region")` returns a plan with exactly 2 sheets: one `markType: "text"` and one `markType: "bar"`. | unit test with this exact direction string | headless | +| MC-2 | `design_dashboard(mode: "directed")` called without `directions` returns a zod validation error. | unit test | headless | +| E2E-1 | `build_from_plan` called with a mocked `DashboardPlan` (all modes) calls the sidecar `/workbook/dashboard` endpoint exactly once and calls `rest.publishWorkbook` exactly once. No `rest.publishDatasource` call is made when `plan.datasourceSpec` is absent. | TS vitest with mocked rest + sidecar | headless | +| E2E-2 | `build_from_plan` with a plan that includes a `datasourceSpec.filePath` calls `create_datasource_from_file` before the workbook build and includes the returned `datasourceLuid` in the response. | TS vitest | headless | +| E2E-3 | Full end-to-end: `design_dashboard(mode: "autonomous") → build_from_plan` against a real Dev site publishes a workbook whose Cloud URL resolves to a workbook with at least one dashboard tab. Captured in `ACCEPTANCE.md`. | gated live demo (`npm run demo:dashboard`) | gated | +| CI-1 | All new TS tests pass on Node 22.x, 24.x, 26.x; all new Python tests pass on 3.12 and 3.13. CI matrix green. | GitHub Actions | headless | +| CI-2 | `npm run build` compiles clean; `npm run lint` 0 errors; `uv run ruff check .` and `uv run mypy --strict .` clean after adding new modules. | CI | headless | + +Total new tests: target minimum 20 new TS tests + 12 new Python tests (in addition to +the existing 28 + 18 = 46). + +--- + +## Constraints & assumptions + +| # | Item | Assumed value | Why | How to override | +|---|---|---|---|---| +| C-1 | Auth | Reuse existing `SERVER`, `SITE_NAME`, `PAT_NAME`, `PAT_VALUE` env vars. No new auth surface. | Feature builds on the same Tableau Cloud connection. | N/A — these are hard constraints. | +| C-2 | Sidecar communication | Extend existing loopback + `X-Sidecar-Token` pattern for new endpoints. | Consistent with ADR-003; no new security surface. | N/A. | +| C-3 | `design_dashboard` is deterministic | Plans are produced by rule-based logic, not LLM inference inside the server. | MCP tools must be predictable and testable without an LLM call from within the tool. The calling agent (Claude) supplies the intelligence. | If future versions embed an LLM call, the tool must document this, add a configurable model env var, and gate on `OPENAI_API_KEY` / `ANTHROPIC_API_KEY`. | +| C-4 | `openpyxl` for Excel | Assumed available; add to `sidecar/pyproject.toml` as a non-optional dependency. | Excel is a first-class requested format. `pandas` already uses openpyxl as its Excel engine. | Remove from deps if Excel support is dropped. | +| C-5 | `pyarrow` for Parquet | Already in the sidecar venv (used by pantab); treat as available. | No new dependency needed. | If pyarrow is ever removed from pantab's deps, add it explicitly. | +| C-6 | Dashboard XML format | Tableau `.twb` `` element with `` / `` layout (same hand-built XML approach as ADR-002). | No maintained Python Document API exists; hand-built XML is the v0.1 pattern. | If a Document API emerges, prefer it. | +| C-7 | Interview mode question count | 3–7 questions. | Fewer than 3 is not useful; more than 7 is not senior-analyst behavior and degrades UX. | Override via a `maxQuestions` parameter if needed in a future version. | +| C-8 | Dashboard tile limit | `exec` ≤ 3 sheets, `analyst` ≤ 8 sheets, `operational` ≤ 6 sheets, `mixed` ≤ 6 sheets. | Derived from Tableau best-practice density guidelines; avoids unrenderable dashboards. | Caller may override by providing `sheets` directly in a directed plan. | +| C-9 | `.twbx` version string | `TWB_VERSION = "18.1"`, `SOURCE_BUILD = "2024.1.0"` (unchanged from existing `twb_builder.py`). | Proven to open in Cloud (criterion 7 live demo). | Bump only if a new Tableau Cloud version rejects these values. | +| C-10 | Backwards compatibility | `create_datasource_from_table` retains `csvPath` / `records` parameters unchanged. | Existing agents and the demo script use this tool. | Do not remove `csvPath` / `records` in this feature. | diff --git a/eslint.config.js b/eslint.config.js index c9d94a2..b8487cf 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -4,7 +4,13 @@ import tseslint from "typescript-eslint"; export default tseslint.config( { - ignores: ["dist/**", "node_modules/**", "sidecar/**", "coverage/**"], + ignores: [ + "dist/**", + "node_modules/**", + "sidecar/**", + "coverage/**", + ".cursor/**", + ], }, eslint.configs.recommended, ...tseslint.configs.recommended, diff --git a/package.json b/package.json index 6163c0f..7b30d09 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,9 @@ "test:watch": "vitest", "lint": "eslint .", "typecheck": "tsc --noEmit", - "demo": "tsx scripts/demo.ts" + "demo": "tsx scripts/demo.ts", + "test:mcp-smoke": "tsx scripts/mcp-smoke.ts", + "verify-setup": "tsx scripts/verify-setup.ts" }, "dependencies": { "@modelcontextprotocol/sdk": "1.29.0", diff --git a/scripts/demo.ts b/scripts/demo.ts index 5ee40b2..f81299c 100644 --- a/scripts/demo.ts +++ b/scripts/demo.ts @@ -7,6 +7,7 @@ * * npm run demo -- examples/top_customers.csv */ +import { resolve } from "node:path"; import { loadConfig } from "../src/config.js"; import { TableauRestClient } from "../src/restClient.js"; import { AuthoringSidecar } from "../src/sidecar.js"; @@ -37,7 +38,7 @@ async function main(): Promise { return; } - const csvPath = process.argv[2] ?? "examples/top_customers.csv"; + const csvPath = resolve(process.argv[2] ?? "examples/top_customers.csv"); const projectName = process.env.DEMO_PROJECT as string; const config = loadConfig(); const rest = new TableauRestClient(config); @@ -61,11 +62,17 @@ async function main(): Promise { const ds = await rest.publishDatasource(tdsxPath, datasourceName, projectId, true); console.log(`✅ Datasource published → ${ds.url}`); - const { contentUrl } = await rest.getDatasource(ds.id); + const contentUrl = + ds.contentUrl ?? (await rest.getDatasource(ds.id)).contentUrl; + if (!contentUrl) { + throw new Error("Published datasource is missing contentUrl; cannot bind the workbook."); + } + console.log(`Binding workbook to published datasource slug: ${contentUrl}`); const { twbxPath } = await sidecar.buildStarterWorkbook({ datasourceName, datasourceContentUrl: contentUrl, site: config.siteName, + serverUrl: config.server, sheets: [ { title: "Revenue by Region", markType: "bar", cols: ["region"], rows: [], measures: ["revenue"] }, ], diff --git a/scripts/mcp-smoke.ts b/scripts/mcp-smoke.ts new file mode 100644 index 0000000..65db273 --- /dev/null +++ b/scripts/mcp-smoke.ts @@ -0,0 +1,155 @@ +/** + * Smoke-test both Tableau MCP servers over stdio: handshake, list tools, call one + * safe read-only tool per server. + * + * Requires SERVER, SITE_NAME, PAT_NAME, PAT_VALUE in the environment (or a local + * .env file). Never logs PAT_VALUE. + * + * Usage: + * npm run test:mcp-smoke + * npm run test:mcp-smoke -- --publish-only + * npm run test:mcp-smoke -- --official-only + */ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; + +type Target = "official" | "publish"; + +interface ServerSpec { + id: Target; + label: string; + command: string; + args: string[]; + probeTool: string; + probeArgs: Record; +} + +const SERVERS: ServerSpec[] = [ + { + id: "official", + label: "@tableau/mcp-server", + command: "npx", + args: ["-y", "@tableau/mcp-server@latest"], + probeTool: "list-datasources", + probeArgs: {}, + }, + { + id: "publish", + label: "tableau-mcp-publish", + command: "node", + args: ["dist/index.js"], + probeTool: "list_projects", + probeArgs: {}, + }, +]; + +function hasAuthEnv(): boolean { + return Boolean( + process.env.SERVER && + process.env.SITE_NAME !== undefined && + process.env.PAT_NAME && + process.env.PAT_VALUE && + process.env.PAT_VALUE !== "…" && + process.env.PAT_VALUE !== "replace-me-never-commit-the-real-value", + ); +} + +function envForServer(): Record { + const base = { ...process.env } as Record; + for (const key of ["SERVER", "SITE_NAME", "PAT_NAME", "PAT_VALUE"] as const) { + const value = process.env[key]; + if (value !== undefined) base[key] = value; + } + return base; +} + +async function smokeOne(spec: ServerSpec): Promise<{ ok: boolean; detail: string }> { + const transport = new StdioClientTransport({ + command: spec.command, + args: spec.args, + env: envForServer(), + cwd: process.cwd(), + stderr: "pipe", + }); + + const client = new Client({ name: "tableau-mcp-smoke", version: "0.1.0" }); + const stderrChunks: string[] = []; + transport.stderr?.on("data", (chunk: Buffer) => { + const line = chunk.toString("utf8"); + if (!line.includes(process.env.PAT_VALUE ?? "\0")) { + stderrChunks.push(line); + } + }); + + try { + await client.connect(transport, { timeout: 60_000 }); + const tools = await client.listTools(); + const names = tools.tools.map((t) => t.name); + if (!names.includes(spec.probeTool)) { + return { + ok: false, + detail: `connected but probe tool "${spec.probeTool}" missing (${names.length} tools listed)`, + }; + } + + const result = await client.callTool({ + name: spec.probeTool, + arguments: spec.probeArgs, + }); + if (result.isError) { + return { ok: false, detail: `tool ${spec.probeTool} returned isError=true` }; + } + + const preview = JSON.stringify(result.structuredContent ?? result.content).slice(0, 240); + return { + ok: true, + detail: `${names.length} tools; ${spec.probeTool} ok → ${preview}${preview.length >= 240 ? "…" : ""}`, + }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + const stderr = stderrChunks.join("").trim(); + return { + ok: false, + detail: stderr ? `${msg}\nstderr: ${stderr.slice(0, 400)}` : msg, + }; + } finally { + await client.close().catch(() => undefined); + await transport.close().catch(() => undefined); + } +} + +async function main(): Promise { + try { + process.loadEnvFile(".env"); + } catch { + // optional .env + } + + const args = new Set(process.argv.slice(2)); + let targets = SERVERS; + if (args.has("--official-only")) targets = targets.filter((s) => s.id === "official"); + if (args.has("--publish-only")) targets = targets.filter((s) => s.id === "publish"); + + if (!hasAuthEnv()) { + console.log("MCP smoke test skipped — set SERVER, SITE_NAME, PAT_NAME, PAT_VALUE (real PAT, not placeholder)."); + process.exit(0); + } + + console.log(`Tableau MCP smoke test → ${process.env.SERVER} (site "${process.env.SITE_NAME}")`); + let failed = 0; + + for (const spec of targets) { + process.stdout.write(` ${spec.label} … `); + const res = await smokeOne(spec); + console.log(res.ok ? "PASS" : "FAIL"); + console.log(` ${res.detail}`); + if (!res.ok) failed += 1; + } + + process.exit(failed > 0 ? 1 : 0); +} + +main().catch((err) => { + console.error(err instanceof Error ? err.message : String(err)); + process.exit(1); +}); diff --git a/scripts/verify-setup.ts b/scripts/verify-setup.ts new file mode 100644 index 0000000..172063c --- /dev/null +++ b/scripts/verify-setup.ts @@ -0,0 +1,123 @@ +/** + * Pre-flight checks before starting Tableau MCP servers in Cursor. + * Never prints PAT_VALUE. + */ +import { existsSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { loadConfig } from "../src/config.js"; +import { TableauRestClient } from "../src/restClient.js"; +import { AuthoringSidecar } from "../src/sidecar.js"; + +const PLACEHOLDER_PATS = new Set([ + "…", + "...", + "replace-me-never-commit-the-real-value", + "replace-me", + "your_pat_secret_here", +]); + +function fail(msg: string): never { + console.error(`❌ ${msg}`); + process.exit(1); +} + +function ok(msg: string): void { + console.log(`✅ ${msg}`); +} + +function warn(msg: string): void { + console.log(`⚠️ ${msg}`); +} + +async function main(): Promise { + const root = process.cwd(); + console.log("Tableau MCP setup verification\n"); + + const dist = resolve(root, "dist/index.js"); + if (!existsSync(dist)) { + fail("dist/index.js missing — run: npm install && npm run build"); + } + ok("dist/index.js exists"); + + const envPath = resolve(root, ".env"); + if (!existsSync(envPath)) { + fail( + ".env missing — run: cp .env.example .env\n" + + "Then edit .env and set PAT_VALUE to your real Personal Access Token secret.", + ); + } + ok(".env exists"); + + try { + process.loadEnvFile(".env"); + } catch { + fail(".env could not be loaded"); + } + + const pat = process.env.PAT_VALUE ?? ""; + if (!pat || PLACEHOLDER_PATS.has(pat) || pat.length < 8) { + fail( + "PAT_VALUE in .env is missing or still a placeholder.\n" + + " 1. Open Tableau Cloud → Account Settings → Personal Access Tokens\n" + + " 2. Create a token (copy the secret immediately — shown once)\n" + + " 3. Set PAT_NAME and PAT_VALUE in .env\n" + + " 4. Restart both MCP servers in Cursor Settings → MCP", + ); + } + ok("PAT_VALUE is set (not a placeholder)"); + + let config; + try { + config = loadConfig(); + } catch (err) { + fail(err instanceof Error ? err.message : String(err)); + } + ok(`Config valid — ${config.server} site "${config.siteName}"`); + + console.log("\nTesting Tableau sign-in…"); + const rest = new TableauRestClient(config); + try { + await rest.signIn(); + ok("Tableau REST sign-in succeeded"); + await rest.signOut(); + } catch (err) { + fail( + `Tableau sign-in failed: ${err instanceof Error ? err.message : String(err)}\n` + + " Check SERVER, SITE_NAME, PAT_NAME, PAT_VALUE in .env match your Cloud site.", + ); + } + + console.log("\nTesting Python sidecar…"); + const sidecar = new AuthoringSidecar(config.sidecarHost, config.sidecarPort); + try { + await sidecar.start(); + ok("Python sidecar started and healthy"); + } catch (err) { + fail( + `Sidecar failed: ${err instanceof Error ? err.message : String(err)}\n` + + " Run: cd sidecar && uv sync", + ); + } finally { + await sidecar.stop(); + } + + const globalMcp = resolve(process.env.HOME ?? "", ".cursor/mcp.json"); + if (existsSync(globalMcp)) { + const raw = readFileSync(globalMcp, "utf8"); + if (raw.includes('"PAT_VALUE": "…"') || raw.includes('"PAT_VALUE":"…"')) { + warn( + "~/.cursor/mcp.json still has PAT_VALUE: \"…\" — remove the tableau entries there\n" + + " (this project's .cursor/mcp.json + .env is the source of truth when this folder is open).", + ); + } + } + + console.log("\nAll checks passed. Next:"); + console.log(" 1. Cursor Settings → MCP → restart tableau + tableau-publish"); + console.log(" 2. npm run test:mcp-smoke"); +} + +main().catch((err) => { + console.error(err instanceof Error ? err.message : String(err)); + process.exit(1); +}); diff --git a/sidecar/server.py b/sidecar/server.py index c764232..a6fbfa6 100644 --- a/sidecar/server.py +++ b/sidecar/server.py @@ -77,6 +77,7 @@ class WorkbookRequest(BaseModel): datasource_name: str = Field(alias="datasourceName") datasource_content_url: str = Field(alias="datasourceContentUrl") site: str = "" + server_url: str = Field(default="", alias="serverUrl") sheets: list[SheetModel] @@ -115,5 +116,6 @@ def workbook_starter(req: WorkbookRequest) -> dict[str, str]: site=req.site, sheets=sheets, out_path=_out("twbx"), + server_url=req.server_url, ) return {"path": str(twbx_path)} diff --git a/sidecar/tests/test_twb_builder.py b/sidecar/tests/test_twb_builder.py index 1fbfcc7..359009e 100644 --- a/sidecar/tests/test_twb_builder.py +++ b/sidecar/tests/test_twb_builder.py @@ -18,19 +18,33 @@ def test_twb_references_published_datasource() -> None: - xml = twb_builder.build_twb_xml("Top Customers", "TopCustomers", "mysite", SHEETS) + xml = twb_builder.build_twb_xml( + "Top Customers", + "TopCustomers", + "mysite", + SHEETS, + server_url="https://x.online.tableau.com", + ) root = ET.fromstring(xml) assert root.tag == "workbook" datasource = root.find(".//datasources/datasource") assert datasource is not None assert datasource.get("caption") == "Top Customers" + assert datasource.get("inline") == "true" + assert datasource.get("name") == "sqlproxy.TopCustomers" assert root.find(".//connection[@class='sqlproxy']") is not None repo = root.find(".//repository-location") assert repo is not None assert repo.get("id") == "TopCustomers" assert repo.get("path") == "/t/mysite/datasources" + assert repo.get("site") == "mysite" + + inner = root.find("./datasources/datasource/connection[@class='sqlproxy']") + assert inner is not None + assert inner.get("dbname") == "TopCustomers" + assert inner.get("server") == "x.online.tableau.com" def test_twb_one_worksheet_per_sheet_with_dependencies() -> None: @@ -41,6 +55,7 @@ def test_twb_one_worksheet_per_sheet_with_dependencies() -> None: deps = root.find(".//datasource-dependencies") assert deps is not None + assert deps.get("datasource") == "sqlproxy.ds" dep_cols = {c.get("name") for c in deps.findall("column")} assert "[Region]" in dep_cols assert "[Revenue]" in dep_cols diff --git a/sidecar/twb_builder.py b/sidecar/twb_builder.py index 39e72e5..f139160 100644 --- a/sidecar/twb_builder.py +++ b/sidecar/twb_builder.py @@ -79,7 +79,13 @@ def _add_dependency_columns( ) -def _build_worksheet(sheet: dict[str, Any], ds_name: str) -> ET.Element: +def _ds_internal_name(content_key: str) -> str: + """Internal workbook datasource id for a published (sqlproxy) connection.""" + safe = re.sub(r"[^A-Za-z0-9_]+", "", content_key) or "datasource" + return f"sqlproxy.{safe}" + + +def _build_worksheet(sheet: dict[str, Any], ds_caption: str, ds_internal: str) -> ET.Element: title = str(sheet["title"]) mark_type = str(sheet.get("mark_type", "bar")).lower() cols_dims = [str(c) for c in sheet.get("cols", [])] @@ -93,15 +99,13 @@ def _build_worksheet(sheet: dict[str, Any], ds_name: str) -> ET.Element: ET.SubElement( datasources, "datasource", - {"caption": ds_name, "name": f"federated.{_slug(ds_name)}"}, + {"caption": ds_caption, "name": ds_internal}, ) - deps = ET.SubElement( - view, "datasource-dependencies", {"datasource": f"federated.{_slug(ds_name)}"} - ) + deps = ET.SubElement(view, "datasource-dependencies", {"datasource": ds_internal}) _add_dependency_columns(deps, cols_dims + rows_dims, measures) - ds_ref = f"[federated.{_slug(ds_name)}]" + ds_ref = f"[{ds_internal}]" cols_exprs = [f"{ds_ref}.{_dim_instance(f)}" for f in cols_dims] rows_exprs = [f"{ds_ref}.{_dim_instance(f)}" for f in rows_dims] rows_exprs += [f"{ds_ref}.{_measure_instance(f)}" for f in measures] @@ -123,13 +127,25 @@ def _build_worksheet(sheet: dict[str, Any], ds_name: str) -> ET.Element: return worksheet +def _server_host(server_url: str) -> str: + if not server_url: + return "" + if "://" in server_url: + return server_url.split("://", 1)[1].split("/", 1)[0] + return server_url.split("/", 1)[0] + + def build_twb_xml( datasource_name: str, datasource_content_url: str, site: str, sheets: list[dict[str, Any]], + server_url: str = "", ) -> str: slug = _slug(datasource_name) + content_key = datasource_content_url or slug + ds_internal = _ds_internal_name(content_key) + server_host = _server_host(server_url) workbook = ET.Element( "workbook", {"source-build": SOURCE_BUILD, "version": TWB_VERSION}, @@ -140,29 +156,35 @@ def build_twb_xml( datasource = ET.SubElement( datasources, "datasource", - {"caption": datasource_name, "name": f"federated.{slug}", "version": TWB_VERSION}, - ) - ET.SubElement( - datasource, - "repository-location", { - "id": datasource_content_url or datasource_name, - "path": _repository_path(site), - "revision": "1.0", + "caption": datasource_name, + "name": ds_internal, + "version": TWB_VERSION, + "inline": "true", }, ) - connection = ET.SubElement(datasource, "connection", {"class": "sqlproxy"}) - named_conns = ET.SubElement(connection, "named-connections") - named_conn = ET.SubElement( - named_conns, - "named-connection", - {"caption": datasource_name, "name": f"sqlproxy.{slug}"}, - ) - ET.SubElement( - named_conn, - "connection", - {"class": "sqlproxy", "dbname": datasource_content_url or datasource_name}, - ) + repo_attrs: dict[str, str] = { + "id": content_key, + "path": _repository_path(site), + "revision": "1.0", + } + if site: + repo_attrs["site"] = site + ET.SubElement(datasource, "repository-location", repo_attrs) + conn_attrs: dict[str, str] = { + "class": "sqlproxy", + "dbname": content_key, + } + if server_host: + conn_attrs.update( + { + "channel": "https", + "directory": "/dataserver", + "port": "443", + "server": server_host, + } + ) + ET.SubElement(datasource, "connection", conn_attrs) # Declare every referenced field once at the datasource level. seen_dims: list[str] = [] @@ -191,7 +213,7 @@ def build_twb_xml( worksheets = ET.SubElement(workbook, "worksheets") windows = ET.SubElement(workbook, "windows") for sheet in sheets: - worksheets.append(_build_worksheet(sheet, datasource_name)) + worksheets.append(_build_worksheet(sheet, datasource_name, ds_internal)) ET.SubElement(windows, "window", {"class": "worksheet", "name": str(sheet["title"])}) xml_body = ET.tostring(workbook, encoding="unicode") @@ -204,9 +226,12 @@ def build_starter_twbx( site: str, sheets: list[dict[str, Any]], out_path: Path, + server_url: str = "", ) -> Path: """Build a .twbx (zip containing the generated .twb) for a published datasource.""" - twb_xml = build_twb_xml(datasource_name, datasource_content_url, site, sheets) + twb_xml = build_twb_xml( + datasource_name, datasource_content_url, site, sheets, server_url=server_url + ) out_path.parent.mkdir(parents=True, exist_ok=True) with zipfile.ZipFile(out_path, "w", zipfile.ZIP_DEFLATED) as archive: archive.writestr(f"{_slug(datasource_name)}.twb", twb_xml) diff --git a/src/restClient.ts b/src/restClient.ts index 9b261e6..a7e3c32 100644 --- a/src/restClient.ts +++ b/src/restClient.ts @@ -13,6 +13,8 @@ export interface Session { export interface PublishResult { id: string; url: string; + /** Server-assigned slug; present when publishing a datasource. */ + contentUrl?: string; } export interface ProjectRef { @@ -63,6 +65,11 @@ export function splitIntoChunks(buf: Buffer, chunkSize: number): Buffer[] { return chunks; } +function asArray(value: T | T[] | undefined | null): T[] { + if (value == null) return []; + return Array.isArray(value) ? value : [value]; +} + function xmlEscape(value: string): string { return value .replace(/&/g, "&") @@ -247,7 +254,8 @@ export class TableauRestClient { return { id: json.project.id, name: json.project.name }; } - private contentUrl(type: ContentType, id: string): string { + /** Browser URL fallback when the REST response omits webpageUrl (uses LUID — may not open in Cloud UI). */ + private fallbackCloudUrl(type: ContentType, id: string): string { const sitePart = this.cfg.siteName ? `/site/${this.cfg.siteName}` : ""; const seg = type === "datasource" ? "datasources" : "workbooks"; return `${this.cfg.server}/#${sitePart}/${seg}/${id}`; @@ -268,7 +276,9 @@ export class TableauRestClient { projectId: string, overwrite: boolean, ): Promise { - return this.publish("workbook", filePath, name, projectId, overwrite); + return this.publish("workbook", filePath, name, projectId, overwrite, { + skipConnectionCheck: true, + }); } private async publish( @@ -277,6 +287,7 @@ export class TableauRestClient { name: string, projectId: string, overwrite: boolean, + extraQuery?: Record, ): Promise { const { siteId } = this.requireSession(); const { size } = await stat(filePath); @@ -301,7 +312,7 @@ export class TableauRestClient { }, ]); json = await this.api("POST", `/sites/${siteId}/${collection}`, { - query: { overwrite }, + query: { overwrite, ...extraQuery }, body, contentType, }); @@ -312,18 +323,34 @@ export class TableauRestClient { ]); const typeParam = type === "datasource" ? { datasourceType: fileExt } : { workbookType: fileExt }; json = await this.api("POST", `/sites/${siteId}/${collection}`, { - query: { uploadSessionId, overwrite, ...typeParam }, + query: { uploadSessionId, overwrite, ...typeParam, ...extraQuery }, body, contentType, }); } - const id = + const dsPublished = type === "datasource" - ? (json as { datasource?: { id?: string } }).datasource?.id - : (json as { workbook?: { id?: string } }).workbook?.id; + ? (json as { + datasource?: { id?: string; contentUrl?: string | null; webpageUrl?: string }; + }).datasource + : undefined; + const wbPublished = + type === "workbook" + ? (json as { workbook?: { id?: string; webpageUrl?: string } }).workbook + : undefined; + + const id = dsPublished?.id ?? wbPublished?.id; if (!id) throw new Error(`publish ${type}: missing id in response.`); - return { id, url: this.contentUrl(type, id) }; + + const contentUrl = dsPublished?.contentUrl ?? undefined; + const webpageUrl = dsPublished?.webpageUrl ?? wbPublished?.webpageUrl; + + return { + id, + url: webpageUrl ?? this.fallbackCloudUrl(type, id), + ...(contentUrl ? { contentUrl } : {}), + }; } /** @@ -364,11 +391,38 @@ export class TableauRestClient { async getDatasource(id: string): Promise<{ id: string; name: string; contentUrl: string }> { const { siteId } = this.requireSession(); const json = (await this.api("GET", `/sites/${siteId}/datasources/${id}`)) as { - datasource?: { id?: string; name?: string; contentUrl?: string }; + datasource?: { id?: string; name?: string; contentUrl?: string | null }; }; const ds = json.datasource; if (!ds?.id) throw new Error(`getDatasource: datasource ${id} not found.`); - return { id: ds.id, name: ds.name ?? "", contentUrl: ds.contentUrl ?? ds.name ?? "" }; + + let contentUrl = ds.contentUrl ?? ""; + if (!contentUrl) { + const listed = await this.getAllPages<{ id: string; contentUrl?: string; name?: string }>( + "/datasources", + (page) => { + const list = asArray( + (page as { datasources?: { datasource?: Array<{ id?: string; contentUrl?: string; name?: string }> | { id?: string; contentUrl?: string; name?: string } } }) + .datasources?.datasource, + ); + return list.map((d) => ({ + id: d.id ?? "", + contentUrl: d.contentUrl, + name: d.name, + })); + }, + ); + const match = listed.find((d) => d.id === id); + contentUrl = match?.contentUrl ?? match?.name ?? ds.name ?? ""; + } + + if (!contentUrl) { + throw new Error( + `getDatasource: contentUrl missing for datasource ${id}. Republish the datasource or pass contentUrl from the publish response.`, + ); + } + + return { id: ds.id, name: ds.name ?? "", contentUrl }; } async refreshDatasource(datasourceId: string): Promise { diff --git a/src/sidecar.ts b/src/sidecar.ts index 0cd22e3..62ace23 100644 --- a/src/sidecar.ts +++ b/src/sidecar.ts @@ -37,6 +37,8 @@ export interface WorkbookArgs { datasourceContentUrl: string; /** Site contentUrl (may be empty for the Default site). */ site: string; + /** Tableau Cloud/Server host URL (e.g. https://10ax.online.tableau.com). */ + serverUrl?: string; sheets: SheetSpec[]; } diff --git a/src/tools/createStarterWorkbook.ts b/src/tools/createStarterWorkbook.ts index 812ad47..103ec9f 100644 --- a/src/tools/createStarterWorkbook.ts +++ b/src/tools/createStarterWorkbook.ts @@ -40,6 +40,7 @@ export function registerCreateStarterWorkbook(server: McpServer, ctx: ToolContex datasourceName, datasourceContentUrl: contentUrl, site: ctx.config.siteName, + serverUrl: ctx.config.server, sheets, }); const projectId = await ctx.rest.resolveProjectId(projectName); diff --git a/tests/restClient.test.ts b/tests/restClient.test.ts index e4918c8..21b8ac5 100644 --- a/tests/restClient.test.ts +++ b/tests/restClient.test.ts @@ -93,11 +93,18 @@ describe("publishDatasource — single request (<=64MB)", () => { const client = await signedInClient(); mockedStat.mockResolvedValue({ size: 1000 } as never); mockedReadFile.mockResolvedValue(Buffer.from("filedata") as never); - mockedRequest.mockResolvedValueOnce(jsonResponse(201, { datasource: { id: "DS1" } }) as never); + mockedRequest.mockResolvedValueOnce( + jsonResponse(201, { + datasource: { + id: "DS1", + webpageUrl: "https://x.online.tableau.com/#/site/s/datasources/12345", + }, + }) as never, + ); const res = await client.publishDatasource("/tmp/x.tdsx", "Name", "PID", false); expect(res.id).toBe("DS1"); - expect(res.url).toContain("/site/s/datasources/DS1"); + expect(res.url).toBe("https://x.online.tableau.com/#/site/s/datasources/12345"); const lastCall = mockedRequest.mock.calls.at(-1)!; expect(String(lastCall[0])).toContain("/api/3.28/sites/S/datasources"); From 83a15b18da7a2f343595d80bdb13b976fe8bc7c3 Mon Sep 17 00:00:00 2001 From: Sebastien Henry Date: Wed, 24 Jun 2026 16:03:13 -0500 Subject: [PATCH 02/16] docs(plan): approved feature plan (100/100) + normative BI design rules Plan loop converged in 2 iterations. Final surface: 3 new tools (create_datasource_from_file, design_dashboard, build_from_plan) + 2 sidecar routes. BI_DESIGN.md is the normative chart/audience/layout spec. Co-Authored-By: Claude Opus 4.8 --- docs/feature-prompt-authoring/BI_DESIGN.md | 740 ++++++++++++++++++ docs/feature-prompt-authoring/PLAN.md | 829 +++++++++++++++++++++ 2 files changed, 1569 insertions(+) create mode 100644 docs/feature-prompt-authoring/BI_DESIGN.md create mode 100644 docs/feature-prompt-authoring/PLAN.md diff --git a/docs/feature-prompt-authoring/BI_DESIGN.md b/docs/feature-prompt-authoring/BI_DESIGN.md new file mode 100644 index 0000000..f4e5c99 --- /dev/null +++ b/docs/feature-prompt-authoring/BI_DESIGN.md @@ -0,0 +1,740 @@ +# BI_DESIGN.md — Deterministic Chart-Selection and Audience-Layout Rules + +> **Status:** normative. Per `PLAN.md` §intro, this file wins over PLAN.md inline tables +> whenever the two diverge. The TypeScript planner (`src/planner/`) reads these tables as +> its rule source. Every rule is computable from static inputs; no LLM inference occurs +> inside the server. +> +> **Versioning:** increment `schemaVersion` (currently `1`) only when a rule change would +> break an existing unit test. Consumers MUST reject a `schemaVersion` they do not +> recognize. + +--- + +## 0. Terminology and invariants + +| Term | Definition | +|---|---| +| **Dimension** | A field that categorizes or slices data. Always placed on Cols or Rows as a grouping key. In `twb_builder.py` terms: `role="dimension"`, `type="nominal"`, encoded with `_dim_instance()`. | +| **Measure** | A numeric field aggregated with SUM. In `twb_builder.py` terms: `role="measure"`, `type="quantitative"`, encoded with `_measure_instance()`. | +| **Temporal** | A dimension whose values represent dates or times. | +| **Geographic** | A dimension whose values represent spatial entities (country, state, city, postal code, latitude/longitude). | +| **High-cardinality dimension** | A dimension with an expected distinct-value count > 20. Trigger top-N truncation. | +| **Low-cardinality dimension** | A dimension with an expected distinct-value count <= 20. Full enumeration is legible. | +| **KPI / BAN** | A "big-ass number": a single aggregate value with no grouping dimension, displayed as a large text mark. | +| **Shelf placement** | The assignment of field expressions to `cols` (x-axis), `rows` (y-axis / measure), and `measures` arrays in `SheetSpec`. Measures are always in the `measures` array regardless of visual orientation. | + +**Invariants that every generated `SheetSpec` must satisfy:** +1. `markType` must be one of `"bar" | "line" | "text" | "map"` (the four values in `_MARK_CLASS`). +2. A `text` mark with no `measures` entry is a label only — the `text` encoding in `twb_builder.py` requires at least one measure to render a value. If a KPI has no measure, the sheet is dropped. +3. A `bar` mark requires at least one dimension in `cols` and at least one entry in `measures`. +4. A `line` mark requires a temporal dimension in `cols` and at least one entry in `measures`. +5. A `map` mark is experimental (REQUIREMENTS Non-goal #7). It may appear in a plan only when geographic fields are present, and `build_from_plan` must emit a warning in `rationale`. +6. A `SheetSpec` with placeholder field tokens (`""`, `""`) is valid for `design_dashboard` output but is rejected by `build_from_plan` (fail-loud). + +--- + +## 1. Field-role inference + +The planner classifies every `FieldHint` into exactly one primary role plus zero or more +secondary tags. Classification is a pure function of `(name, dataType)`. Rules are +evaluated top-to-bottom; **first match wins**. + +### 1.1 Data-type primary classification + +| `dataType` in `FieldHint` | Primary role | Notes | +|---|---|---| +| `"number"` | **measure** | Default aggregate: SUM. | +| `"date"` | **temporal** dimension | Also tagged `temporal`. Never treated as a measure. | +| `"boolean"` | **dimension** | Nominal. Cardinality = 2 (always low). | +| `"string"` | **dimension** | Nominal by default; name-pattern rules below may override. | +| absent / unknown | **dimension** | Conservative fallback; the builder accepts `datatype="string"`. | + +### 1.2 Name-pattern overrides (applied after dtype classification, order-sensitive) + +Patterns are matched against the lowercased field name using the regex listed. First +pattern that matches overrides the dtype result. + +| Priority | Regex (case-insensitive, applied to field name) | Assigned role | Secondary tags | +|---|---|---|---| +| 1 | `\b(id|_id|uuid|guid|key|pk|fk|code|sku|serial|token|hash)\b` | **identifier** dimension | `high_cardinality`, `suppress` — never place on Rows/Cols/Color; suppress from shelf selection. | +| 2 | `\b(date|day|month|quarter|year|week|timestamp|time|period|fiscal|fy|cy)\b` | **temporal** dimension | `temporal` — place on Cols for line charts. | +| 3 | `\b(lat|latitude|lon|longitude|lng)\b` | **geographic** dimension | `geo_coordinate` — pair lat+lon for map marks. | +| 4 | `\b(country|nation|state|province|region|city|city_name|metro|zip|postal|postcode|geoid|territory)\b` | **geographic** dimension | `geo_named` — use for map marks; low-cardinality countries/states, high-cardinality cities/zip. | +| 5 | `\b(name|label|title|description|desc|category|cat|type|status|stage|segment|group|tier|bucket|channel|source|medium|campaign)\b` | **dimension** | `low_cardinality` hint — assume <= 20 distinct values unless `high_cardinality` evidence contradicts. | +| 6 | `\b(flag|is_|has_|can_|should_|active|enabled|deleted|archived|approved|published)\b` | **dimension** | `boolean_flag`, cardinality = 2. | +| 7 | `\b(revenue|sales|amount|total|sum|gross|net|profit|margin|cost|price|spend|budget|arr|mrr|gmv|ltv|aov|cac|volume|qty|quantity|count|units|orders|transactions|conversions|sessions|pageviews|clicks|impressions|rate|ratio|pct|percent|score|index|weight|value)\b` | **measure** | `numeric` — aggregate SUM by default. | +| 8 | `\b(rank|ranking|position|priority|order|index|sequence)\b` | **dimension** | `ordinal` — treat as an ordered categorical, not a summed numeric. | +| — (no match) | Fallback: dtype rule from §1.1 | — | — | + +### 1.3 Cardinality heuristic + +When actual distinct-value counts are not available (the server does no introspection — +Non-goal #8), classify cardinality from the field name alone: + +| Cardinality tag | Evidence | Distinct-value threshold | +|---|---|---| +| `high_cardinality` | Secondary tag `identifier`, or name matches `\b(name|email|url|path|description)\b` | > 20 | +| `low_cardinality` | Secondary tag `boolean_flag`, or name matches `\b(status|stage|type|tier|channel|segment|region|country|state)\b` | <= 20 | +| unknown | No pattern match | Assume `low_cardinality` for initial chart selection; note in `rationale`. | + +### 1.4 Tie-breakers + +When a field matches more than one pattern at the same priority level (impossible by +design — patterns are mutually exclusive per priority), the earlier row wins. + +When a field's dtype says `"number"` but its name matches an `identifier` pattern +(priority 1): **identifier wins** — a numeric ID is not a measure. + +When a field's dtype says `"string"` but its name matches a `measure` pattern +(priority 7): treat as a **potential string-typed measure**; place it on the `measures` +array but annotate `rationale` with "string-typed measure — verify aggregation in +Tableau." + +### 1.5 Field classification summary (output per field) + +``` +FieldClassification = { + name: string, // original field name + role: "measure" | "dimension" | "identifier" | "temporal" | "geographic", + tags: Set, // zero or more secondary tags from §1.2 + cardinalityHint: "low" | "high" | "unknown", + suppress: boolean // true for identifiers; exclude from shelf selection +} +``` + +The planner builds this set before chart selection. Suppressed fields are never placed +on shelves. + +--- + +## 2. Chart and mark selection + +### 2.1 Decision table + +Evaluate the classified field set against the data-shape rules in order. **First matching +row wins.** After chart type is selected, §2.2 maps field → shelf. + +| Rule ID | Data shape (from classified fields) | Chart type | `markType` | `twb_builder.py` mark class | Builder status | +|---|---|---|---|---|---| +| C-01 | 1 temporal dimension + 1 or more measures + 0 non-temporal dimensions | **Line** (single or multi-series line) | `"line"` | `Line` | **Supported** | +| C-02 | 1 temporal dimension + 1 or more measures + 1 low-card non-temporal dimension | **Multi-series line** (color by dimension) | `"line"` | `Line` | **Supported** — color encoding requires new builder support; see §2.3. | +| C-03 | 1 low-card dimension (non-temporal, non-geo) + 1 or more measures | **Bar** | `"bar"` | `Bar` | **Supported** | +| C-04 | 1 high-card dimension (non-temporal, non-geo) + 1 or more measures | **Top-N bar** (truncate to N=10 by default; note in `rationale`) | `"bar"` | `Bar` | **Supported** — top-N truncation is a planner annotation, not a builder feature; the field is placed on Cols normally. The agent must apply a top-N filter in Tableau after publish, or the planner annotates for the user. | +| C-05 | 2 measures + 0 or 1 low-card dimension | **Scatter** | — | — | **NOT supported** — `twb_builder.py` has no scatter mark class. Fall back to `"bar"` for `exec`/`operational`/`mixed`; allow for `analyst` with fallback applied and noted in `rationale`. See §2.3. | +| C-06 | 0 grouping dimensions + 1 measure (standalone KPI) | **BAN / KPI text** | `"text"` | `Text` | **Supported** — measure placed on `measures` array; `twb_builder.py` adds `` automatically for `text` marks. | +| C-07 | 1 geographic dimension (`geo_named` or `geo_coordinate`) + 1 measure | **Filled map or symbol map** | `"map"` | `Map` | **Experimental** — emit a warning in `rationale`; do not use for `exec` audience. | +| C-08 | 1 low-card dimension + multiple measures (part-to-whole context detected) | **Stacked bar** | `"bar"` | `Bar` | **Supported** — stacking is a planner annotation; multiple measures go on `measures` array; note in `rationale` that stacking must be configured in Tableau. | +| C-09 | Multiple low-card dimensions + 1 measure | **Bar** (first dimension on Cols; additional on Color — see §2.3) | `"bar"` | `Bar` | **Supported** for single-dimension placement; Color encoding requires new builder support. | +| C-10 | 0 dimensions + 0 measures (degenerate) | **Drop sheet**; add note to `rationale` | — | — | N/A | + +**Pie chart policy:** Pies are never generated. When a part-to-whole question is detected, +use stacked bar (C-08) or treemap (not currently supported — see §2.3). This is a firm +rule, not an audience preference. Rationale: pies fail perceptual accuracy tests at > 4 +slices; stacked bars and treemaps are superior alternatives available in Tableau. + +**Treemap policy:** Treemap (`Square` mark class) is not in `_MARK_CLASS`. It requires new +builder support. Until added, fall back to `"bar"` for part-to-whole shapes with more than +two measures. See §2.3. + +### 2.2 Field-to-shelf mapping + +For each chart type, the exact field placement. All field names are placed as-is; the +builder wraps them in `_dim_instance()` / `_measure_instance()`. + +**C-01 / C-02 — Line** +``` +cols: [temporal_field] +rows: [] +measures: [measure_field_1, measure_field_2, ...] +color: [low_card_dimension] // C-02 only; see §2.3 for builder gap +``` +The temporal field is the sole dimension; the builder places it on the x-axis. + +**C-03 / C-04 — Bar** +``` +cols: [dimension_field] +rows: [] +measures: [measure_field_1, ...] +``` +Horizontal bar: swap cols and rows (dimension on `rows`, empty `cols`). Use horizontal +orientation for high-card dimensions (C-04) so long labels are legible. The planner +chooses orientation based on cardinality: high-card → horizontal (dimension in `rows`); +low-card → vertical (dimension in `cols`). + +**C-06 — KPI text (BAN)** +``` +cols: [] +rows: [] +measures: [kpi_measure_field] +``` +No dimension. `twb_builder.py` emits ``. + +**C-07 — Map** +``` +cols: [] +rows: [] +measures: [measure_field] +color: [measure_field] // heatmap intensity; see §2.3 +rows_geo: [geo_dimension] // placed via Rows in Tableau; builder handles "map" mark class +``` +Note: the map mark's actual field placement in the generated `.twb` relies on the builder's +`Map` class. Geographic field must be placed on `rows` for the `Map` mark to resolve. + +**C-08 — Stacked bar** +Same as C-03/C-04. Multiple measures in `measures` array. Add to `rationale`: +"Configure stacking in Tableau: Analysis > Stack Marks > On." + +**C-09 — Bar with secondary dimension** +``` +cols: [primary_dimension] +rows: [] +measures: [measure_field] +color: [secondary_dimension] // requires new builder support; see §2.3 +``` +Fall back to dropping the secondary dimension if Color encoding is not yet supported. + +### 2.3 Builder gap register — chart types requiring NEW `twb_builder.py` support + +The following chart types or encodings are referenced in the decision table but are not +emitted by the current builder. Each entry: the gap, its impact, and the interim fallback +until the builder is extended. + +| Gap ID | Feature | Affected rules | Impact | Interim fallback | +|---|---|---|---|---| +| G-01 | **Color encoding** on `` element. The builder has no `` emission path for multi-series line (C-02) or segmented bar (C-09). | C-02, C-09 | Multi-series lines render as overlapping single-color lines. Segmented bars are unsegmented. | Drop color dimension from shelf. Planner annotates `rationale`: "Color encoding not yet supported by the builder; apply color manually in Tableau Desktop." | +| G-02 | **Scatter mark class** (`"scatter"` / `Circle` in Tableau XML). `_MARK_CLASS` has no `"scatter"` key. | C-05 | Scatter plots cannot be built. | Fall back to `"bar"` for all audiences. For `analyst`, annotate `rationale`: "Scatter plot requested; rendered as bar until builder adds Circle mark class." | +| G-03 | **Treemap mark class** (`Square`). Not in `_MARK_CLASS`. | Part-to-whole with > 2 measures | Treemaps cannot be built. | Fall back to `"bar"` (stacked annotation). | +| G-04 | **Reference lines / band annotations**. No `` emission. | Analyst annotation rule (§3) | Reference lines for averages or targets cannot be embedded. | Annotate `rationale`: "Add reference lines manually in Tableau." | +| G-05 | **Top-N set / filter**. Builder emits no `` element. | C-04 (high-card dim) | Top-N restriction must be applied after publish in Tableau Desktop. | Planner annotates `rationale`: "High-cardinality dimension — apply Top 10 filter in Tableau Desktop: right-click field > Filter > Top > By field." | + +Any plan that relies on a gap feature must include the fallback and the `rationale` +annotation. `build_from_plan` does not block on gap features; it builds the fallback +silently and preserves the annotation. + +--- + +## 3. Audience design heuristics + +All values in this section are hard constraints enforced by the audience-clamp functions +in `src/planner/audience.ts`. Each rule maps to a unit-testable predicate. + +### 3.1 Master audience table + +| Property | `exec` | `analyst` | `operational` | `mixed` | +|---|---|---|---|---| +| **`maxSheets`** | **3** | **8** | **6** | **6** | +| **Minimum KPI sheet count** | **1** (text mark required) | 0 | **1** (text mark required) | 0 | +| **Allowed `markType` values** | `bar`, `line`, `text` | `bar`, `line`, `text` (scatter → bar fallback per G-02) | `text`, `bar`, `line` | `bar`, `line`, `text` | +| **Map mark allowed** | No (too experimental for exec) | Yes (with G-05 warning) | No | No | +| **Default `dashboardLayout`** | `tiled_vertical` | `tiled_horizontal` | `tiled_vertical` | `tiled_vertical` | +| **Layout override allowed** | Yes, by caller | Yes, by caller | No — always `tiled_vertical` (mobile-first) | Yes, by caller | +| **KPI zone position** | First sheet (index 0) | Any position | First sheet (index 0) | First sheet (index 0) if present | +| **Max measures per sheet** | 1 | 4 | 2 | 2 | +| **Max dimensions per sheet** | 1 | 3 | 1 | 2 | +| **Density** | Sparse | Dense | Medium | Standard | +| **Annotation / reference lines** | 1 key callout on the KPI (manual — G-04) | Full (average line, target line — G-04) | Status-driven callouts only | Optional | +| **Axis labels** | Suppressed (minimized) | Full | Abbreviated | Standard | +| **Default canvas width (px)** | 1000 | 1200 | 800 | 1000 | +| **Default canvas height (px)** | 800 | 900 | 1200 | 900 | + +### 3.2 Audience clamp algorithm (pure function, order-sensitive) + +Applied after the raw sheet list is generated by chart selection. Steps are applied in +the listed order; each step is independently unit-testable. + +``` +function applyAudienceClamps(sheets, audience): + + STEP 1 — TRUNCATE + If sheets.length > maxSheets[audience]: + Remove sheets from the tail until sheets.length == maxSheets[audience]. + Note removed sheets in rationale: "Truncated N sheets to fit audience limit." + + STEP 2 — DROP DISALLOWED MARK TYPES + For each sheet where markType not in allowedMarkTypes[audience]: + Replace markType with the audience default: + exec → "text" for 0-dimension sheets, "bar" otherwise + analyst → "bar" + operational → "text" for 0-dimension sheets, "bar" otherwise + mixed → "bar" + Append to sheet.rationale: "Mark type replaced to comply with audience constraints." + + STEP 3 — ENSURE KPI LEAD + If minimumKpiCount[audience] > 0 AND no sheet with markType=="text" exists at index 0: + Insert a KPI text sheet at index 0 (using the first measure from the field list). + If insertion would exceed maxSheets[audience], drop the last sheet to make room. + + STEP 4 — ENFORCE LAYOUT + If audience == "operational": + Force dashboardLayout = "tiled_vertical" regardless of any other input. + Else: + Use defaultLayout[audience] unless the caller's directions explicitly set a layout. + + STEP 5 — CAP MEASURES AND DIMENSIONS PER SHEET + For each sheet: + If measures.length > maxMeasures[audience]: + Truncate measures to maxMeasures[audience]; note in sheet.rationale. + If (cols.length + rows.length) > maxDimensions[audience]: + Truncate dimensions to maxDimensions[audience] (keep cols first); note in sheet.rationale. + + STEP 6 — MAP MARK GUARD + If audience in {"exec", "operational", "mixed"}: + For each sheet where markType == "map": + Replace with "bar"; note in sheet.rationale: "Map mark not allowed for this audience." + + RETURN clamped sheets + dashboardLayout +``` + +### 3.3 Audience-specific design notes (non-algorithmic, for rationale generation) + +These notes are appended to the `DashboardPlan.rationale` string verbatim so the agent +can relay them to the user. They are not enforced structurally. + +**exec:** "Executive view: maximum 3 sheets, leading KPI, large text. Axis labels are +minimal. Annotations on the primary KPI should be added manually in Tableau." + +**analyst:** "Analyst view: up to 8 sheets, dense layout, full axis labels. Reference +lines (average, target) should be added manually post-publish." + +**operational:** "Operational view: single-column mobile-friendly layout, status marks +prominent, action-oriented KPIs. Dashboard is optimized for 800px width." + +**mixed:** "General audience: balanced 4-6 sheet layout with one summary KPI and +standard density." + +--- + +## 4. Dashboard layout templates + +### 4.1 Canvas sizes (pixels) + +| Audience | Width | Height | Rationale | +|---|---|---|---| +| `exec` | 1000 | 800 | Standard widescreen; fits a projector slide. | +| `analyst` | 1200 | 900 | Wide canvas for side-by-side sheets. | +| `operational` | 800 | 1200 | Tall single-column; mobile-friendly portrait. | +| `mixed` | 1000 | 900 | Balanced. | + +These values map directly to the `` element in the `` XML: +``. + +### 4.2 Zone tiling geometry + +Zone coordinates use the 0–100000 Tableau grid (as documented in `PLAN.md` §6.3). +The `_tile_zones(titles, layout)` helper in `twb_builder.py` implements this math. + +**`tiled_vertical`** (default for exec, operational, mixed): +``` +n = len(titles) +unit_h = floor(100000 / n) +For i in 0..n-1: + x = 0 + w = 100000 + y = i * unit_h + h = unit_h (last zone: h = 100000 - (n-1)*unit_h to absorb rounding remainder) +``` + +**`tiled_horizontal`** (default for analyst): +``` +n = len(titles) +unit_w = floor(100000 / n) +For i in 0..n-1: + y = 0 + h = 100000 + x = i * unit_w + w = unit_w (last zone: w = 100000 - (n-1)*unit_w to absorb rounding remainder) +``` + +Invariants (tested by DB-2): +- `tiled_vertical`: all zones have equal `x=0` and `w=100000`; `y` values are strictly + increasing; `Σh == 100000`. +- `tiled_horizontal`: all zones have equal `y=0` and `h=100000`; `x` values are strictly + increasing; `Σw == 100000`. +- No two zones may have the same `(x, y)` origin. +- Zone IDs: container `id=1`; worksheet zones `id=2..n+1`. + +### 4.3 Layout templates per audience + +The templates describe zone arrangement abstractly. "KPI" = `text` mark sheet; +"Chart" = `bar` or `line` sheet. + +**exec — vertical stack, KPI first** +``` +Zone 1 (top, ~30% h): KPI / BAN sheet +Zone 2 (middle, ~40% h): Primary trend or bar chart +Zone 3 (bottom, ~30% h): Secondary bar chart (if sheet count == 3) +``` +Expressed as 3-sheet `tiled_vertical` tiling. If only 2 sheets: top ~30%, bottom ~70%. + +**analyst — horizontal side-by-side** +``` +Row 1 (top 50% h): + Col 1 (left 50% w): Primary chart + Col 2 (right 50% w): Secondary chart +Row 2 (bottom 50% h): + Full width: Detail table or scatter → bar +``` +The builder's current tiling is strictly 1D (all horizontal or all vertical). A true +2×2 grid requires nested zones not yet emitted by `_tile_zones`. **Interim:** for +`analyst` with up to 4 sheets, use `tiled_horizontal` (all sheets side-by-side); for +5-8 sheets, switch to `tiled_vertical`. Nested-zone support is a tracked gap (see §4.4). + +**operational — single-column, KPI first** +``` +Zone 1 (top, ~20% h): KPI / status text mark +Zone 2 (middle, ~40% h): Bar chart (pipeline, volume) +Zone 3–6 (remaining h, equal): Detail text marks or small bar charts +``` +Always `tiled_vertical`. + +**mixed — vertical, KPI optional lead** +``` +Zone 1 (top, ~25% h): Summary KPI (if present) +Zone 2 (middle, ~50% h): Primary bar or line chart +Zone 3–6 (remaining h, equal): Supporting charts +``` +`tiled_vertical` by default. + +### 4.4 Layout gap register + +| Gap ID | Feature | Impact | Interim | +|---|---|---|---| +| L-01 | **Nested / 2D zone layout.** `_tile_zones` only supports 1D tiling. A true 2×2 analyst grid (top row: 2 charts; bottom row: 1 wide chart) requires a container zone with two children, plus a second container zone. | Analyst dashboards with > 4 sheets are rendered as a tall single column when `tiled_horizontal` is chosen. | Use `tiled_horizontal` for <= 4 analyst sheets; `tiled_vertical` for 5-8. Note in `rationale`. | +| L-02 | **Floating zones.** Tableau supports floating layout in addition to tiled. No floating zone emission. | Annotations and KPI callouts cannot be layered over charts. | Not addressed; note in `rationale`. | + +--- + +## 5. Worked examples + +Each example shows: input fields + audience → field-role inference → chart selection → +audience clamps → final `DashboardPlan` sketch. + +### 5.1 Example A — Exec: "How is revenue trending by region?" + +**Input:** +``` +businessQuestion: "How is revenue trending by region?" +audience: "exec" +fieldHints: + - { name: "order_date", dataType: "date" } + - { name: "region", dataType: "string" } + - { name: "revenue", dataType: "number" } + - { name: "order_id", dataType: "string" } + - { name: "customer_id", dataType: "string" } +``` + +**Step 1 — Field-role inference:** + +| Field | dtype | Name-pattern match | Role | Tags | Suppress | +|---|---|---|---|---|---| +| `order_date` | date | temporal pattern (`date`) | temporal dimension | `temporal` | No | +| `region` | string | low-card pattern (`region`) | dimension | `low_cardinality` | No | +| `revenue` | number | measure pattern (`revenue`) | measure | `numeric` | No | +| `order_id` | string | identifier pattern (`_id`) | identifier | `high_cardinality`, `suppress` | Yes | +| `customer_id` | string | identifier pattern (`_id`) | identifier | `high_cardinality`, `suppress` | Yes | + +Usable fields after suppression: `order_date`, `region`, `revenue`. + +**Step 2 — Keyword heuristic (from PLAN.md §3.2, cross-checked with §2.1):** + +Question clauses: +- "trending" → matches `trend` keyword → line (Rule C-01 / C-02) +- "by region" → dimension modifier, low-card → consider multi-series (C-02) or separate bar (C-03) + +Primary shape: 1 temporal + 1 non-temporal low-card dim + 1 measure → Rule **C-02** (multi-series line). + +**Step 3 — Chart selection:** + +Sheet 1: Multi-series line → markType `"line"`, cols: `["order_date"]`, rows: `[]`, measures: `["revenue"]`, color: `["region"]` (G-01 gap noted). + +Supporting chart: "by region" also suggests a bar chart for regional totals. +Sheet 2: Rule C-03 → markType `"bar"`, cols: `["region"]`, rows: `[]`, measures: `["revenue"]`. + +KPI: "revenue trending" implies a total KPI. +Sheet 3: Rule C-06 → markType `"text"`, cols: `[]`, rows: `[]`, measures: `["revenue"]`. + +Raw sheet list (before clamps): +``` +[Sheet3: KPI text, Sheet1: Line, Sheet2: Bar] +``` + +**Step 4 — Audience clamps (`exec`):** + +- maxSheets = 3: 3 sheets — within limit, no truncation. +- KPI lead: Sheet3 (text) moved to index 0. +- Allowed marks: bar, line, text — all compliant. +- Layout: `tiled_vertical`. +- Max measures/sheet = 1, max dims/sheet = 1 — compliant. +- Map guard: no map marks. + +**Final `DashboardPlan`:** +``` +workbookName: "Revenue Trend by Region" +audience: "exec" +dashboardLayout: "tiled_vertical" +sheets: + [0] { title: "Total Revenue", markType: "text", cols: [], rows: [], measures: ["revenue"] } + [1] { title: "Revenue Trend Over Time", markType: "line", cols: ["order_date"], rows: [], measures: ["revenue"], + rationale: "Color by region not embedded (G-01); apply manually in Tableau." } + [2] { title: "Revenue by Region", markType: "bar", cols: ["region"], rows: [], measures: ["revenue"] } +rationale: "Executive view: KPI leads, trend line answers 'trending', bar answers 'by region'. + Color encoding for multi-series not yet supported by builder (G-01). + Executive view: maximum 3 sheets, leading KPI, large text." +``` + +--- + +### 5.2 Example B — Analyst: "What drives churn?" + +**Input:** +``` +businessQuestion: "What drives churn?" +audience: "analyst" +fieldHints: + - { name: "customer_id", dataType: "string" } + - { name: "churned", dataType: "boolean" } + - { name: "tenure_months", dataType: "number" } + - { name: "plan_type", dataType: "string" } + - { name: "monthly_spend", dataType: "number" } + - { name: "support_tickets", dataType: "number" } + - { name: "region", dataType: "string" } + - { name: "signup_date", dataType: "date" } +``` + +**Step 1 — Field-role inference:** + +| Field | Role | Tags | Suppress | +|---|---|---|---| +| `customer_id` | identifier | `high_cardinality`, `suppress` | Yes | +| `churned` | dimension | `boolean_flag`, `low_cardinality` | No | +| `tenure_months` | measure | `numeric` | No | +| `plan_type` | dimension | `low_cardinality` | No | +| `monthly_spend` | measure | `numeric` | No | +| `support_tickets` | measure | `numeric` | No | +| `region` | dimension | `low_cardinality` | No | +| `signup_date` | temporal dimension | `temporal` | No | + +Usable fields: `churned`, `tenure_months`, `plan_type`, `monthly_spend`, +`support_tickets`, `region`, `signup_date`. + +**Step 2 — Keyword heuristic:** + +"What drives churn?" — "drives" → no exact keyword match; falls to default `"bar"`. +"churn" → `churned` is a boolean dimension (cardinality 2). +Interpretation: compare measures across churned vs. not-churned segments. + +**Step 3 — Chart selection:** + +Sheet 1 — Churn rate KPI: `churned` is boolean, 0-dim → Rule C-06 (text). Actually, +`churned` is a dimension not a measure. Build a KPI by counting churned customers: +markType `"text"`, measures: `["support_tickets"]` (proxy for churn volume — note in +rationale that agent should supply a churn-count computed field or use count of `churned`). + +Sheet 2 — Tenure vs. spend (2 measures, 1 dim): 2 measures + 1 low-card dim → Rule C-05 +(scatter). Scatter not supported (G-02) → fall back to bar. markType `"bar"`, +cols: `["plan_type"]`, measures: `["tenure_months"]`. + +Sheet 3 — Spend by plan type and churn: C-03 → markType `"bar"`, cols: `["plan_type"]`, +measures: `["monthly_spend"]`. + +Sheet 4 — Support tickets by churn status: C-03 → markType `"bar"`, cols: `["churned"]`, +measures: `["support_tickets"]`. + +Sheet 5 — Churn over time: C-01 → markType `"line"`, cols: `["signup_date"]`, +measures: `["monthly_spend"]`. + +Sheet 6 — Revenue by region and churn: C-03 → markType `"bar"`, cols: `["region"]`, +measures: `["monthly_spend"]`. + +Raw sheet count: 6. Within analyst limit of 8. + +**Step 4 — Audience clamps (`analyst`):** + +- maxSheets = 8: 6 sheets — no truncation. +- No required KPI lead for analyst. +- All mark types compliant (bar, line, text). +- Layout: `tiled_horizontal` (default for analyst). 6 sheets → switch to `tiled_vertical` + per L-01 interim rule (> 4 sheets with tiled_horizontal is too narrow). +- Scatter fallback noted (G-02). +- Max measures = 4, max dims = 3 — all compliant. + +**Final `DashboardPlan`:** +``` +workbookName: "Churn Driver Analysis" +audience: "analyst" +dashboardLayout: "tiled_vertical" +sheets: + [0] { title: "Support Tickets (Churn Proxy)", markType: "text", measures: ["support_tickets"], + rationale: "KPI proxy — agent should supply a dedicated churn-count field." } + [1] { title: "Tenure by Plan Type", markType: "bar", cols: ["plan_type"], measures: ["tenure_months"], + rationale: "Scatter plot (2-measure) not supported (G-02); rendered as bar." } + [2] { title: "Monthly Spend by Plan Type", markType: "bar", cols: ["plan_type"], measures: ["monthly_spend"] } + [3] { title: "Support Tickets by Churn Status", markType: "bar", cols: ["churned"], measures: ["support_tickets"] } + [4] { title: "Monthly Spend Over Time", markType: "line", cols: ["signup_date"], measures: ["monthly_spend"] } + [5] { title: "Spend by Region", markType: "bar", cols: ["region"], measures: ["monthly_spend"] } +rationale: "Analyst view: 6 sheets comparing measures across churn-related dimensions. + Scatter plot for 2-measure shape replaced with bar (G-02). + Switched to tiled_vertical because 6 sheets exceed the 4-sheet tiled_horizontal + threshold (L-01). Add reference lines for averages manually post-publish." +``` + +--- + +### 5.3 Example C — Operational: "Current order pipeline status" + +**Input:** +``` +businessQuestion: "Current order pipeline status" +audience: "operational" +fieldHints: + - { name: "order_id", dataType: "string" } + - { name: "status", dataType: "string" } + - { name: "stage", dataType: "string" } + - { name: "amount", dataType: "number" } + - { name: "order_date", dataType: "date" } + - { name: "region", dataType: "string" } + - { name: "days_open", dataType: "number" } +``` + +**Step 1 — Field-role inference:** + +| Field | Role | Tags | Suppress | +|---|---|---|---| +| `order_id` | identifier | `high_cardinality`, `suppress` | Yes | +| `status` | dimension | `low_cardinality` | No | +| `stage` | dimension | `low_cardinality` | No | +| `amount` | measure | `numeric` | No | +| `order_date` | temporal dimension | `temporal` | No | +| `region` | dimension | `low_cardinality` | No | +| `days_open` | measure | `numeric` | No | + +Usable fields: `status`, `stage`, `amount`, `order_date`, `region`, `days_open`. + +**Step 2 — Keyword heuristic:** + +"Current order pipeline status" — "status" → no time keyword, "pipeline" → no exact match. +Default: `"bar"`. "current" hints at a snapshot KPI rather than a trend. + +**Step 3 — Chart selection:** + +Sheet 1 — Pipeline value KPI: 0 grouping dims + 1 measure → Rule C-06 (text KPI). +markType `"text"`, measures: `["amount"]`. + +Sheet 2 — Orders by status: C-03 (low-card dim `status`) → markType `"bar"`, +cols: `["status"]`, measures: `["amount"]`. + +Sheet 3 — Days open by stage: C-03 (`stage` is low-card) → markType `"bar"`, +cols: `["stage"]`, measures: `["days_open"]`. + +Raw count: 3. Well within operational limit of 6. + +**Step 4 — Audience clamps (`operational`):** + +- maxSheets = 6: 3 sheets — no truncation. +- Minimum KPI count = 1: Sheet 1 is text at index 0 — satisfied (MA-3 equivalent). +- Allowed marks: text, bar, line — all compliant. +- Layout: force `tiled_vertical` (operational always vertical — layout override not allowed). +- Max measures = 2, max dims = 1 — compliant. +- Map guard: no map marks. + +**Final `DashboardPlan`:** +``` +workbookName: "Order Pipeline Status" +audience: "operational" +dashboardLayout: "tiled_vertical" +sheets: + [0] { title: "Total Pipeline Value", markType: "text", cols: [], rows: [], measures: ["amount"] } + [1] { title: "Orders by Status", markType: "bar", cols: ["status"], rows: [], measures: ["amount"] } + [2] { title: "Days Open by Stage", markType: "bar", cols: ["stage"], rows: [], measures: ["days_open"] } +rationale: "Operational view: status KPI leads, two bar charts show pipeline + distribution by status and stage. Single-column layout for mobile-friendly + display at 800×1200px." +``` + +--- + +## 6. Keyword → mark-type heuristic (full table) + +This is the normative version of the inline table in `PLAN.md` §3.2. The planner in +`src/planner/marks.ts` must implement this table exactly. Rules are applied per clause +after splitting `businessQuestion` or `directions` on `\s+and\s+`, `,`, or `;`. +**First match wins per clause.** Multiple clauses may produce multiple sheets. + +| Priority | Regex (case-insensitive, applied to clause text) | Implied chart type | `markType` | Typical shelf placement | +|---|---|---|---|---| +| 1 | `\b(top\s+\d+\|list\|table\|detail\|breakdown\|show me all\|all records)\b` | Detail table | `"text"` | First usable dimension on `rows`; first usable measure on `measures`. | +| 2 | `\b(trend\|over time\|by (month\|quarter\|year\|date\|day\|week\|period)\|growth\|historical\|time series\|trajectory)\b` | Line | `"line"` | First temporal dim on `cols`; first measure on `measures`. | +| 3 | `\b(kpi\|headline\|total\|grand total\|overall\|how many\|count of\|sum of\|single number\|big number)\b` | KPI text | `"text"` | No dim; first measure on `measures`. | +| 4 | `\b(compare\|comparison\|versus\|vs\.?\|by (region\|category\|segment\|channel\|product\|team\|country\|state\|city\|type\|status\|stage\|tier\|group)\|across\|per\|distribution\|breakdown by\|rank)\b` | Bar | `"bar"` | First usable non-temporal dim on `cols`; first measure on `measures`. | +| 5 | `\b(map\|geography\|geographic\|location\|where\|by country\|by state\|by city\|by region (on map)\|spatial)\b` | Map | `"map"` | First geo dim on `rows`; first measure on `measures`. | +| 6 | `\b(correlation\|scatter\|relationship between\|drives\|impact of\|x vs y\|plotted against)\b` | Scatter (→ bar fallback G-02) | `"bar"` | First usable dim on `cols`; first measure on `measures`. | +| 7 | `\b(share\|proportion\|composition\|part.?to.?whole\|breakdown of .+\%\|percentage of)\b` | Stacked bar (→ bar with annotation) | `"bar"` | First usable dim on `cols`; all usable measures on `measures`. | +| — (no match) | Default | Bar | `"bar"` | First usable non-temporal dim on `cols`; first usable measure on `measures`. | + +**Conflict resolution when two priorities both match the same clause:** lower priority +number wins (highest priority = smallest integer). + +**No-dimension case:** if the heuristic selects `"bar"` or `"line"` but no usable +dimension exists (all are suppressed or absent), downgrade to `"text"` (KPI) and note +in `rationale`. + +--- + +## 7. Interview question bank (normative) + +`design_dashboard(mode: "interview")` selects 3-7 questions from this ordered bank. A +question is included if and only if the corresponding input is unknown. Selection is +deterministic given the input state. + +| ID | Included when | Question text | Hint | +|---|---|---|---| +| `q_audience` | `audience` is absent from input | "Who is the primary audience for this dashboard?" | "For example: executive leadership, data analyst team, operations/frontline staff, or a mixed group." | +| `q_goal` | `businessQuestion` and `directions` both absent | "What is the primary business question this dashboard should answer?" | "For example: 'How is revenue trending?' or 'Which regions are underperforming?'" | +| `q_key_metric` | `fieldHints` is absent or empty | "What is the one metric that matters most to the viewer?" | "For example: revenue, churn rate, order volume, customer count." | +| `q_time_frame` | `fieldHints` is absent or contains no temporal field | "Does this dashboard need to show data over time, or is a point-in-time snapshot sufficient?" | "Time-based views require a date or period field." | +| `q_dimensions` | `fieldHints` is absent or contains only measures (no usable dimensions) | "What categories or segments should the data be broken down by?" | "For example: by region, by product, by customer segment, by status." | +| `q_filters` | `context` does not mention filter or scope | "Are there any filters or scope limitations that should be applied by default?" | "For example: current fiscal year only, specific region, active customers only." | +| `q_action` | `audience` is `"operational"` or `context` mentions 'action' or 'decision' | "What action or decision does the viewer take after looking at this dashboard?" | "For example: escalate an order, contact a customer, reallocate budget." | + +Questions are emitted in the order listed. If all 7 are applicable, all 7 are included +(maximum = 7, satisfying MB-1). If the minimum of 3 is not reached (all inputs known), +the planner always includes `q_goal`, `q_key_metric`, and `q_filters` as unconditional +minimums. + +--- + +## 8. Rule versioning and test contract + +### 8.1 Version + +This file is `schemaVersion: 1`. The TS planner reads this value and throws if it does +not equal 1. + +### 8.2 Unit-testable predicates (mapping to REQUIREMENTS criteria) + +Each row below is a concrete assertion a test can make without an LLM call: + +| Predicate | Maps to | +|---|---| +| `plan.sheets.length <= 3` when `audience == "exec"` | MA-1, C-8 | +| All `plan.sheets[*].markType` in `{"bar","line","text"}` when `audience == "exec"` | MA-1 | +| `plan.sheets.length <= 8` when `audience == "analyst"` | MA-2 | +| `plan.dashboardLayout == "tiled_vertical"` when `audience == "operational"` | MA-3 | +| At least one `plan.sheets[*].markType == "text"` when `audience in {"exec","operational"}` | MA-3, §3.2 step 3 | +| `plan.sheets[0].markType == "text"` when `audience == "exec"` (KPI lead) | §3.2 step 3 | +| `directed("show me a table of top 10 customers by revenue and a bar chart of revenue by region")` → `sheets.length == 2`, `sheets[0].markType == "text"`, `sheets[1].markType == "bar"` | MC-1 | +| `questions.length` in `[3,7]` for `mode == "interview"` | MB-1 | +| `"sheets"` key absent from `ClarifyingQuestions` response | MB-1 | +| `DashboardPlan.rationale` non-empty string | MB-2 | +| `plan.sheets[*].markType == "map"` only when geographic field present AND audience == "analyst"` | §3.1, §3.2 step 6 | +| `plan.sheets[*].measures.length <= maxMeasures[audience]` | §3.2 step 5 | +| `(cols.length + rows.length) <= maxDimensions[audience]` per sheet | §3.2 step 5 | +| Zone `Σh == 100000` for `tiled_vertical` with any `n` in `[1,8]` | DB-2 | +| Zone `Σw == 100000` for `tiled_horizontal` with any `n` in `[1,8]` | DB-2 | +| All `y` values distinct and `x == 0` for `tiled_vertical` | DB-2 | +| All `x` values distinct and `y == 0` for `tiled_horizontal` | DB-2 | + +--- + +*End of BI_DESIGN.md — schemaVersion 1* diff --git a/docs/feature-prompt-authoring/PLAN.md b/docs/feature-prompt-authoring/PLAN.md new file mode 100644 index 0000000..3de7f33 --- /dev/null +++ b/docs/feature-prompt-authoring/PLAN.md @@ -0,0 +1,829 @@ +# PLAN.md — Prompt-Driven Authoring + +> Implementation plan for the prompt-driven authoring feature in the **existing** +> `tableau-mcp-publish` project. Inputs: `CODEBASE.md`, `docs/feature-prompt-authoring/REQUIREMENTS.md`, +> and the live extension-point code. The root `/PLAN.md` is the read-only project baseline; this +> document is scoped to this feature only and reuses the baseline's architecture, conventions, +> guardrails, and the 46-test regression suite as a hard constraint. +> +> `docs/feature-prompt-authoring/BI_DESIGN.md` (the deterministic field-inference / chart-selection / +> audience-layout rule tables, authored after this plan by the data-engineer) is the **normative spec** +> for every planning rule referenced in §3, §5, and §6. This plan treats it as the single source of +> truth: where BI_DESIGN.md and the restated tables here ever diverge, **BI_DESIGN.md wins on every +> field/chart/audience/layout rule** and `src/planner/*` is implemented to its tables; any divergence is +> recorded in the Revision log. If BI_DESIGN.md is absent at build time, the restated tables in §3 are +> the fallback contract. + +--- + +## 1. Problem, goal, and scope + +### 1.1 Problem + +The current surface (`create_starter_workbook`, `create_datasource_from_table`) is a *builder's* +interface: the caller must already know field names, mark types, row/col shelves, and must supply a +CSV. Two gaps block an *analyst's* interface: + +1. **Ingest is CSV-only.** `hyper_builder.query_to_dataframe()` (line 99) handles `csv`, `snowflake`, + `postgres` only. JSON / Parquet / Excel are rejected. +2. **Output is loose worksheets, never a dashboard.** `twb_builder.build_twb_xml()` (line 138) emits + ``, ``, `` — there is **no `` element** (CODEBASE.md + Tech-Debt #3). Tableau opens the first worksheet, not an arranged dashboard. +3. **No planning layer.** Nothing translates a business question + audience into a concrete + `sheets` + `dashboard` spec, and there is no guided "interview" path. + +### 1.2 Goal + +A user describes a data source (file or SQL) and a business question with an audience, optionally +guided by a senior-BI-analyst interview, and receives a **published Tableau dashboard** (worksheets +arranged on a `` with ``) plus a governed datasource on Cloud — in a bounded, +stateless, testable tool sequence. + +### 1.3 Scope boundary (carried from REQUIREMENTS Non-goals) + +In scope: multi-format file ingest; `` XML emission; deterministic plan generation for +3 modes × 4 audiences; bounded interview (≤2 calls); orchestration tool that builds + publishes. + +Out of scope (unchanged): read/query tools; live-connection datasources; pixel-perfect render +validation (one gated live demo only); LLM inference inside the server; multi-turn server-side +conversation state; image/PDF export; map marks beyond experimental; metadata introspection; +project auto-creation. + +### 1.4 The central design decision — server is deterministic + stateless; intelligence lives in the agent + +The MCP server runs as a stdio subprocess. It has **no LLM client, no streaming, no session memory** +(CODEBASE.md §"BI analyst planning"). Embedding an interview loop or business-question reasoning in +the server would require either a second LLM client (cost, coupling, a new `ANTHROPIC_API_KEY` +dependency the baseline does not have) or a stateful session mechanism alien to MCP's +request/response model. + +Therefore the boundary is: + +| Concern | Owner | Why | +|---|---|---| +| Understanding the business question, choosing references, judging answers | **Calling agent (Claude/Cursor)** | This is LLM work; the agent already has the model. | +| Conversation history, relaying questions/answers to the user | **Calling agent** | MCP is stateless; the agent supplies full context per call. | +| Deterministic plan generation (field inference → marks → audience clamps) | **Server (TS planner)** | Predictable, unit-testable without an LLM call. | +| File authoring (`.hyper`/`.tdsx`/`.twb`) and REST publish | **Server (Python sidecar + TS REST)** | Reuses the proven baseline chain. | + +Consequence (enforced by tests): every planner output is a pure function of its inputs. Given the +same `(mode, audience, businessQuestion|directions|answers, fieldHints)` it returns byte-identical +plans. This is what makes MA-1..MC-2 assertable in CI with no live model. + +--- + +## 2. Final tool surface (overlap resolution) + +REQUIREMENTS proposed five names across its body and contracts: `create_datasource_from_file`, +`design_dashboard`, `build_from_plan`, plus a `create_dashboard_workbook` / sidecar +`/workbook/dashboard`. The root brief additionally listed `create_dashboard_workbook`. These overlap. +**Resolution: three new MCP tools + one new sidecar endpoint.** `create_dashboard_workbook` is +*rejected as a public tool* — its job (build worksheets + a dashboard, then publish) is fully covered +by `build_from_plan`, and exposing both would create two ways to build a dashboard with divergent +contracts. The dashboard-building capability instead lives behind the `/workbook/dashboard` sidecar +endpoint that `build_from_plan` calls. This keeps the surface minimal and gives exactly one path to a +published dashboard. + +Final state: **11 existing tools (unchanged) + 3 new tools = 14 MCP tools; 4 existing sidecar routes + +1 new route = 5 routes.** + +### 2.1 `create_datasource_from_file` (new) — F-1 + +Single file-format-agnostic ingest entry point. `create_datasource_from_table` keeps its exact +existing signature (`csvPath` / `records`) for backward-compat (C-10); this tool is additive. + +``` +inputSchema (zod): + filePath: z.string().min(1) // local path + fileType: z.enum(["csv","json","jsonl","xlsx","xls","parquet"]).optional() + // inferred from extension when omitted; rejects others + datasourceName: z.string().min(1) + projectName: z.string().min(1) + overwrite: z.boolean().default(false) + excelSheet: z.union([z.string(), z.number().int().nonnegative()]).optional() + jsonPath: z.string().optional() // e.g. "$.data"; default = top-level array / NDJSON +outputSchema: { datasourceLuid: z.string(), url: z.string() } +``` + +Validation: a `.refine` derives `fileType` from the extension when omitted and **rejects any +extension not in the enum before the sidecar is called** (PA-2). The handler calls a new sidecar +route `POST /datasource/from-file`, then `resolveProjectId` + `publishDatasource` exactly as the +table tool does. + +### 2.2 `design_dashboard` (new) — F-3 + +The planner. Always returns structured output; **never builds**. Discriminated on `mode`. + +``` +inputSchema (zod, discriminated union on `mode`): + shared: + mode: z.enum(["autonomous","interview","interview_followup","directed"]) + datasourceLuid: z.string().min(1) // required for autonomous/directed/interview_followup + datasourceName: z.string().min(1) // " + projectName: z.string().min(1) + audience: AudienceEnum // required autonomous/directed; optional interview + workbookName: z.string().min(1).optional() // auto-derived from question when omitted + fieldHints: z.array(FieldHint).optional() // see §2.5 — the agent's known field list + mode "autonomous": businessQuestion: z.string().min(1) + mode "interview": context: z.string().optional(); audience optional + mode "interview_followup": + answers: z.record(z.string(), z.string()) // questionId → answer + context: z.string().optional() + mode "directed": directions: z.string().min(1) + +outputSchema (discriminated on `mode` in the response payload): + mode "interview" → ClarifyingQuestions (§4.1) + mode "autonomous"|"directed"|"interview_followup" → DashboardPlan (§4.2) +``` + +The `content[0].text` is the human-readable rendering the agent relays to the user (the questions, or +the plan + rationale). `structuredContent` is the machine payload the agent passes verbatim into the +next call. + +### 2.3 `build_from_plan` (new) — F-5 + +The orchestrator/publisher. Consumes a finalized `DashboardPlan` (possibly user-edited) and produces +artifacts. This is the **only** tool that writes to Cloud in this feature. + +``` +inputSchema: + plan: DashboardPlan // §4.2, validated by the same zod schema design_dashboard emits + overwrite: z.boolean().default(false) +outputSchema: + { workbookLuid: z.string(), url: z.string(), datasourceLuid: z.string().optional() } +``` + +Handler sequence (all-or-nothing ordering, fail-loud): +1. Re-validate `plan` against the shared zod schema (defense in depth), including the + `schemaVersion == 1` guard (§3) and the placeholder-token rejection (§2.5). +2. If `plan.datasourceSpec` present → build + publish datasource first (reusing + `create_datasource_from_file` / `from-query` logic), capture `datasourceLuid` (E2E-2). +3. Else `getDatasource(plan.datasourceLuid)` → if REST 404, throw **before** any file authoring (F-5.5). +4. `sidecar.buildDashboardWorkbook({...sheets, dashboardLayout, canvasWidth, canvasHeight, datasourceContentUrl, ...})` + → `.twbx` (canvas dimensions are derived from `plan.audience`; see §3.1 and §6). +5. `resolveProjectId(plan.projectName)` → `publishWorkbook(...)` (E2E-1). +6. Return `{ workbookLuid, url, datasourceLuid? }`. + +### 2.4 Why not collapse `design_dashboard` + `build_from_plan` into one tool + +The two-call split is mandatory, not stylistic. MCP is synchronous request/response; the agent must +relay the plan (and, in interview mode, the questions) to the human and accept edits **before** the +expensive, side-effectful build/publish. A single tool would either publish without review or block +the protocol waiting on a human. Splitting also makes the planner a pure function (testable) and the +builder the only side-effecting tool (auditable). REQUIREMENTS F-3 makes this two-call pattern a +requirement. + +### 2.5 `FieldHint` — how the planner knows what fields exist + +The server does no metadata introspection (Non-goal #8); the **agent supplies the field list** it +already obtained (via the read-side `@tableau/mcp-server`). `FieldHint` is the optional carrier and is +the sole input to the §3.0 field-role inference engine: + +``` +FieldHint = { name: string; role?: "dimension"|"measure"; dataType?: "string"|"number"|"date"|"boolean" } +``` + +`role` is now optional: when omitted, the planner derives the role from §3.0 (BI_DESIGN §1) rather +than trusting a caller-supplied label. When `fieldHints` is omitted entirely, classification is +skipped and the planner emits a plan whose sheets reference placeholder field tokens (`""`, +`""`) and the rationale states the agent must fill them before `build_from_plan`. This keeps +`design_dashboard` callable even with zero field knowledge and keeps the "no introspection" boundary +intact. `build_from_plan` rejects a plan that still contains placeholder tokens with a clear error +(fail-loud). + +--- + +## 3. The planner rule pipeline (field inference → marks → clamps) + +**BI_DESIGN.md is the single source of truth for every rule in this section.** The tables below +restate its rules so the milestones/tests are self-contained; where any cell here ever diverges from +BI_DESIGN.md, BI_DESIGN.md wins and `src/planner/*` is implemented to BI_DESIGN.md (the divergence is +recorded in the Revision log). The pipeline runs in a fixed order, and each stage is an isolated pure +function so it is unit-testable on its own: + +``` +fieldHints ─▶ §3.0 classifyFields ─▶ §3.2 markHeuristic ─▶ §3.3 chartSelect ─▶ §3.4 applyAudienceClamps ─▶ DashboardPlan + (src/planner/fields.ts) (src/planner/marks.ts) (src/planner/plan.ts) (src/planner/audience.ts) +``` + +`schemaVersion` guard (BI_DESIGN §0/§8.1): the planner reads BI_DESIGN's `schemaVersion` and the +incoming plan's `schemaVersion`. `src/planner/schema.ts` and `src/planner/plan.ts` **throw a clear +error if either `!= 1`** (forward-incompatible rules must fail loud, never silently mis-plan). A unit +test asserts that a non-1 version throws (§9.2). + +### 3.0 Field-role inference engine — `src/planner/fields.ts` (BI_DESIGN §1) — runs BEFORE chart selection + +This stage was previously missing and is the **first** step of the pipeline: chart selection in §3.3 +consumes its `FieldClassification[]` output, not the raw `fieldHints`. It is a pure function of +`(name, dataType)` per `FieldHint`, mirroring BI_DESIGN §1 exactly. + +**Step A — dtype primary classification (BI_DESIGN §1.1):** `number`→measure; `date`→temporal +dimension (never a measure); `boolean`→dimension (cardinality 2); `string`→dimension; absent/unknown→ +dimension (conservative fallback). + +**Step B — name-pattern overrides (BI_DESIGN §1.2): 8 priority-ordered regexes, applied to the +lowercased field name, first match wins, overriding Step A:** + +| Priority | Regex (case-insensitive) | Role | Secondary tags | +|---|---|---|---| +| 1 | `\b(id\|_id\|uuid\|guid\|key\|pk\|fk\|code\|sku\|serial\|token\|hash)\b` | **identifier** | `high_cardinality`, **`suppress`** | +| 2 | `\b(date\|day\|month\|quarter\|year\|week\|timestamp\|time\|period\|fiscal\|fy\|cy)\b` | **temporal** | `temporal` | +| 3 | `\b(lat\|latitude\|lon\|longitude\|lng)\b` | **geographic** | `geo_coordinate` | +| 4 | `\b(country\|nation\|state\|province\|region\|city\|city_name\|metro\|zip\|postal\|postcode\|geoid\|territory)\b` | **geographic** | `geo_named` | +| 5 | `\b(name\|label\|title\|description\|desc\|category\|cat\|type\|status\|stage\|segment\|group\|tier\|bucket\|channel\|source\|medium\|campaign)\b` | **dimension** | `low_cardinality` | +| 6 | `\b(flag\|is_\|has_\|can_\|should_\|active\|enabled\|deleted\|archived\|approved\|published)\b` | **dimension** | `boolean_flag` (cardinality 2) | +| 7 | `\b(revenue\|sales\|amount\|total\|sum\|gross\|net\|profit\|margin\|cost\|price\|spend\|budget\|...\|value)\b` (full list = BI_DESIGN §1.2 row 7) | **measure** | `numeric` (SUM) | +| 8 | `\b(rank\|ranking\|position\|priority\|order\|index\|sequence)\b` | **dimension** | `ordinal` | +| — | no match | fall back to Step A dtype role | — | + +**Step C — cardinality heuristic (BI_DESIGN §1.3):** `high_cardinality` when tagged `identifier` or +the name matches `\b(name|email|url|path|description)\b`; `low_cardinality` when tagged `boolean_flag` +or the name matches `\b(status|stage|type|tier|channel|segment|region|country|state)\b`; otherwise +`unknown` (assume low for selection, note in rationale). + +**Step D — tie-breakers (BI_DESIGN §1.4):** numeric dtype **and** identifier-name → **identifier wins** +(a numeric ID is not a measure); string dtype **and** measure-name → place on `measures` and annotate +"string-typed measure — verify aggregation in Tableau." + +**Output (BI_DESIGN §1.5):** +``` +FieldClassification = { + name: string, + role: "measure" | "dimension" | "identifier" | "temporal" | "geographic", + tags: string[], // secondary tags from Steps B/C + cardinalityHint: "low" | "high" | "unknown", + suppress: boolean // true for identifiers (§1.5) +} +``` + +**Suppression is load-bearing (BI_DESIGN §1.5):** fields with `suppress=true` are **never** placed on +Rows/Cols/Color/measures — they are removed from the usable set before §3.3 runs. This is what keeps +`order_id` / `customer_id` off the shelves in the §5 worked examples. When `fieldHints` is omitted, +classification is skipped and the planner emits placeholder tokens (§2.5) instead. + +### 3.1 Audience → layout/density constraints (BI_DESIGN §3.1 master table + §4.1 canvas) + +All values are deterministic functions of `audience`. The `maxMeasures`/`maxDimensions` columns are +enforced by clamp STEP 5 (§3.4); `mapAllowed` drives STEP 6; the canvas columns are emitted into the +dashboard `` (§3 → §6). This restates BI_DESIGN §3.1 + §4.1. + +| audience | maxSheets | defaultLayout | allowedMarkTypes | mapAllowed | minimumKpiCount | maxMeasures/sheet | maxDimensions/sheet | canvas W×H | +|---|---|---|---|---|---|---|---|---| +| `exec` | 3 | `tiled_vertical` | bar, line, text | No | **1** (text, index 0) | **1** | **1** | **1000×800** | +| `analyst` | 8 | `tiled_horizontal` | bar, line, text (scatter→bar, G-02) | Yes (G-05 warn) | **0** | **4** | **3** | **1200×900** | +| `operational` | 6 | `tiled_vertical` (forced) | text, bar, line | No | **1** (text, index 0) | **2** | **1** | **800×1200** | +| `mixed` | 6 | `tiled_vertical` | bar, line, text | No | **0** | **2** | **2** | **1000×900** | + +### 3.2 Keyword → mark-type heuristic — `src/planner/marks.ts` (BI_DESIGN §6, normative) + +Applied per clause after splitting `businessQuestion` / `directions` on `\s+and\s+`, `,`, or `;`. +**Lower priority number wins** when two rows match the same clause; first match wins per clause; each +clause may yield a sheet. This restates BI_DESIGN §6 exactly (the previous 5-row inline table was the +divergent fallback and is replaced). + +| Priority | Regex (case-insensitive, per clause) | `markType` | shelf placement | +|---|---|---|---| +| 1 | `\b(top\s+\d+\|list\|table\|detail\|breakdown\|show me all\|all records)\b` | `text` | first usable dim on `rows`; first usable measure on `measures` | +| 2 | `\b(trend\|over time\|by (month\|quarter\|year\|date\|day\|week\|period)\|growth\|historical\|time series\|trajectory)\b` | `line` | first temporal dim on `cols`; first measure on `measures` | +| 3 | `\b(kpi\|headline\|total\|grand total\|overall\|how many\|count of\|sum of\|single number\|big number)\b` | `text` (KPI) | no dim; first measure on `measures` | +| 4 | `\b(compare\|comparison\|versus\|vs\.?\|by (region\|category\|segment\|...\|group)\|across\|per\|distribution\|breakdown by\|rank)\b` | `bar` | first usable non-temporal dim on `cols`; first measure on `measures` | +| 5 | `\b(map\|geography\|geographic\|location\|where\|by country\|by state\|by city\|spatial)\b` | `map` | first geo dim on `rows`; first measure on `measures` | +| 6 | `\b(correlation\|scatter\|relationship between\|drives\|impact of\|x vs y\|plotted against)\b` | `bar` (scatter→bar, G-02) | first usable dim on `cols`; first measure on `measures` | +| 7 | `\b(share\|proportion\|composition\|part.?to.?whole\|percentage of)\b` | `bar` (stacked annotation) | first usable dim on `cols`; all usable measures on `measures` | +| — | no match | `bar` | first usable non-temporal dim on `cols`; first usable measure on `measures` | + +**No-dimension downgrade (BI_DESIGN §6 final clause):** if the heuristic selected `bar` or `line` but +**no usable dimension exists** (all suppressed or absent), downgrade the sheet to `text` (KPI) and note +it in `rationale`. **KPI-without-measure drop (invariant §0.2):** a resulting `text` mark that has **no +`measures` entry** is a label only and is **dropped** from the plan (the builder's `text` encoding +needs a measure to render). These two rules are unit-tested in §9.2 (no-dimension downgrade test). + +### 3.3 Chart selection from the classified field set (BI_DESIGN §2) + +Chart selection consumes the §3.0 `FieldClassification[]` (suppressed fields already removed) plus the +per-clause `markType` from §3.2, then resolves shelves per BI_DESIGN §2.2 and applies the §2.3 builder +gaps. The `markType` enum is `{bar,line,text,map}`, matching `_MARK_CLASS` exactly — scatter, treemap, +color, reference-line, and top-N are **not** builder features, so the shapes that imply them fall back +per the gap register and **carry their specific `rationale` annotation** (asserted — see §3.5, §9.2): + +| Gap | Trigger shape | Fallback | Required `rationale` annotation | +|---|---|---|---| +| G-01 | multi-series / segmented (C-02, C-09) | drop color dim | "Color encoding not yet supported by the builder; apply color manually in Tableau." | +| G-02 | 2 measures (C-05 scatter) / "drives"/"correlation" clause | `markType:"bar"` | "Scatter plot requested; rendered as bar until builder adds Circle mark class." | +| G-03 | part-to-whole, >2 measures (treemap) | `markType:"bar"` (stacked) | "Treemap not supported; rendered as bar." | +| G-04 | analyst reference/target line | — | "Add reference lines manually in Tableau." | +| G-05 | high-cardinality dimension (C-04) | place dim normally | "High-cardinality dimension — apply Top 10 filter in Tableau Desktop: right-click field > Filter > Top > By field." | + +### 3.4 Audience clamp algorithm — `src/planner/audience.ts` (BI_DESIGN §3.2, **verbatim 6 steps**) + +Applied after §3.3 generates the raw sheet list. Steps run in the listed order; **each step is an +independently unit-tested pure function** restating BI_DESIGN §3.2 step-for-step (the previous 5-clamp +list was under-specified — it omitted STEP 5 per-sheet caps and STEP 6 map guard, both now restored): + +``` +function applyAudienceClamps(sheets, audience): + + STEP 1 — TRUNCATE + If sheets.length > maxSheets[audience]: + Remove sheets from the TAIL until length == maxSheets[audience]. + Note in rationale: "Truncated N sheets to fit audience limit." + + STEP 2 — DROP DISALLOWED MARK TYPES + For each sheet where markType not in allowedMarkTypes[audience]: + Replace with the audience default: + exec/operational → "text" for 0-dimension sheets, "bar" otherwise + analyst/mixed → "bar" + Append to sheet.rationale: "Mark type replaced to comply with audience constraints." + + STEP 3 — ENSURE KPI LEAD + If minimumKpiCount[audience] > 0 AND no sheet with markType=="text" at index 0: + Insert a KPI text sheet at index 0 (first measure from the field list). + If insertion exceeds maxSheets[audience], drop the last sheet to make room. + + STEP 4 — ENFORCE LAYOUT + If audience == "operational": force dashboardLayout = "tiled_vertical" (override not allowed). + Else: use defaultLayout[audience] unless the caller's directions explicitly set a layout. + + STEP 5 — CAP MEASURES AND DIMENSIONS PER SHEET + For each sheet: + If measures.length > maxMeasures[audience]: + Truncate to maxMeasures[audience]; note in sheet.rationale. + If (cols.length + rows.length) > maxDimensions[audience]: + Truncate dimensions to maxDimensions[audience] (keep cols first); note in sheet.rationale. + + STEP 6 — MAP MARK GUARD + If audience in {"exec", "operational", "mixed"}: + For each sheet where markType == "map": + Replace with "bar"; note in sheet.rationale: "Map mark not allowed for this audience." + + RETURN clamped sheets + dashboardLayout +``` + +These six steps each map to a unit-testable predicate (BI_DESIGN §8.2) and satisfy MA-1, MA-2, MA-3. +STEP 5 (per-sheet measure/dimension caps) and STEP 6 (map guard) each get their own unit tests (§9.2). + +### 3.5 Gap-fallback annotations are produced and asserted (BI_DESIGN §2.3 / §4.4) + +Per BI_DESIGN's rule "any plan that relies on a gap feature must include the fallback **and** the +`rationale` annotation," the planner attaches the exact §3.3 annotation string to the affected +`SheetSpec.rationale` whenever it applies a fallback, and `build_from_plan` preserves it (it does not +block on gap features; it builds the fallback silently and keeps the annotation). This generation is +verified by directed unit tests in §9.2: a 2-measure analyst shape carries the G-02 scatter-fallback +note in `sheet.rationale`; a high-cardinality dimension carries the G-05 top-N note. Layout gaps L-01 +(analyst > 4 sheets → `tiled_vertical`) and L-02 (floating) are annotated the same way. + +### 3.6 Interview question bank (deterministic templates) — F-3.4..F-3.7 (BI_DESIGN §7) + +`mode: "interview"` returns 3–7 questions selected from the fixed, ordered bank in BI_DESIGN §7 by +which inputs are still unknown (audience missing → `q_audience`; no `fieldHints` → `q_key_metric`; +etc.). Selection is deterministic given the inputs, so MB-1 (count 3–7, no `sheets` key) is assertable. +If fewer than 3 questions would otherwise be selected, BI_DESIGN §7 mandates the unconditional minimums +`q_goal`, `q_key_metric`, `q_filters`. Each question: `{ id, question, hint? }`. `interview_followup` +maps `answers[id]` back to the same fields the autonomous path consumes, then runs the identical +field-inference → mark → clamp pipeline → MB-2 yields a `DashboardPlan` with non-empty `rationale`. + +--- + +## 4. The `DashboardPlan` contract (the lynchpin) + +This is the explicit, versioned contract between the planner (`design_dashboard`) and the builder +(`build_from_plan` → sidecar). It is one zod schema defined once in `src/planner/schema.ts`, imported +by both tools, and structurally mirrored by a Pydantic model on the sidecar boundary. Defining it in a +single module is what keeps producer and consumer from drifting. + +### 4.1 `ClarifyingQuestions` + +``` +ClarifyingQuestions = { + schemaVersion: 1, + kind: "questions", + questions: Array<{ id: string; question: string; hint?: string }> // length 3..7 +} +``` + +### 4.2 `DashboardPlan` + +``` +DashboardPlan = { + schemaVersion: 1, // §3 guard: schema/planner throw if != 1 + kind: "plan", + workbookName: string, + datasourceLuid: string, + datasourceName: string, + projectName: string, + audience: "exec" | "analyst" | "operational" | "mixed", + rationale: string, // non-empty; cites how the plan answers the question + dashboardLayout: "tiled_vertical" | "tiled_horizontal", + sheets: SheetSpec[], // length 1..maxSheets[audience] + datasourceSpec?: DatasourceSpec // present only when a NEW datasource must be built +} + +SheetSpec = { // superset of the existing src/sidecar.ts SheetSpec + title: string, + markType: "bar" | "line" | "text" | "map", + rows: string[], // field names (or placeholder tokens until filled) + cols: string[], + measures: string[], // SUM-aggregated, matching existing twb_builder semantics + rationale?: string // which clause this sheet answers + any gap annotation (§3.5) +} + +DatasourceSpec = { + datasourceName: string, + filePath?: string, // file path → /datasource/from-file + fileType?: "csv"|"json"|"jsonl"|"xlsx"|"xls"|"parquet", + excelSheet?: string | number, + jsonPath?: string, + sql?: string, // query path → /datasource/from-query + connection?: Record // matches existing QueryArgs.connection +} +``` + +Invariants (enforced by zod `.refine`, fail-loud): +- `schemaVersion == 1`; the schema and planner throw on any other value (BI_DESIGN §0/§8.1). +- Exactly one of `datasourceSpec` xor a resolvable `datasourceLuid` is required for `build_from_plan`. +- Within `datasourceSpec`, exactly one of (`filePath`) xor (`sql` + `connection`) is set. +- `sheets.length` ≥ 1 and ≤ `maxSheets[audience]`; `rationale` non-empty; no placeholder tokens at + build time. +- `markType` values respect the audience allow-list; `measures.length ≤ maxMeasures[audience]` and + `cols.length + rows.length ≤ maxDimensions[audience]` per sheet (validated again at build time as + defense in depth, mirroring clamp STEP 5). +- A `text` mark has ≥1 `measures` entry (invariant §0.2 — a measureless KPI is dropped, never built). + +`SheetSpec` is a strict superset of the existing `src/sidecar.ts` `SheetSpec` and the sidecar's +`SheetModel`, so the existing `/workbook/starter` path and its 5 twb tests are untouched. + +--- + +## 5. The MCP interaction contract for interview mode + +Stateless and bounded to **at most two `design_dashboard` calls** before a plan exists (F-3.7). + +```mermaid +sequenceDiagram + participant U as User + participant A as Agent (Claude) + participant T as design_dashboard (tool) + participant B as build_from_plan (tool) + + U->>A: "Build me a dashboard from datasource X" (wants guidance) + A->>T: mode="interview", datasourceLuid, audience?, context? + T-->>A: ClarifyingQuestions { questions[3..7] } (no build, no state) + A->>U: relays questions (batch or one-by-one) + U->>A: answers + A->>T: mode="interview_followup", answers{id->text}, + original inputs + T-->>A: DashboardPlan { sheets, layout, rationale } (no build) + A->>U: relays plan, asks accept/edit + U->>A: accept (or edits) + A->>B: build_from_plan(plan, overwrite?) + B-->>A: { workbookLuid, url } + A->>U: "Published → url" +``` + +Contract rules: +- The server stores **nothing** between calls. Question IDs are stable, content-derived strings (e.g. + `q_audience`, `q_key_metric`) so the agent can map answers without server-held state. +- The follow-up call carries the *full* original input set plus `answers`; the server reconstructs the + same plan it would have produced autonomously, with answers overriding defaults. +- Autonomous and directed modes skip the questions hop entirely (one call → plan). +- Hard ceiling: two planner calls. There is no third "refine" call in this feature; further edits are + the agent mutating the plan object locally and calling `build_from_plan`. + +--- + +## 6. Sidecar dashboard XML + +### 6.1 Functions to extend in `sidecar/twb_builder.py` + +Exactly the three named in CODEBASE.md §"Dashboard workbook authoring": + +1. **`build_twb_xml()`** (line 138): add optional params `dashboards: list[dict] | None = None`, + `dashboard_layout: str = "tiled_vertical"`, `canvas_width: int = 1000`, and + `canvas_height: int = 800`. After the existing `` block is built, if `dashboards` is + provided, call `_build_dashboard(...)` (passing the canvas dimensions into the emitted ``) and + append its `` element to the `workbook` root. **Defaults (`None` + 1000×800) ⇒ + byte-identical output to today** when no dashboard is requested, so the 5 existing twb tests pass + unchanged (regression guard). +2. **`_build_dashboard()`** (new helper): accepts the dashboard name, the layout direction, the + ordered list of worksheet titles, and **`canvas_width` / `canvas_height`**, and returns the + `` `ET.Element` whose `` carries the supplied + dimensions (no longer hardcoded — see §6.2). +3. **`build_starter_twbx()`** (line 223): add the same four optional params and forward them to + `build_twb_xml()`. (`build_dashboard_twbx()` is *not* a new public function — the dashboard path + reuses `build_starter_twbx` with `dashboards` populated, keeping one zip/writer code path.) + +### 6.2 The `` structure to emit + +Zones reference worksheets by the worksheet's `name` attribute (the sheet `title`). Tableau zone +coordinates use a 0–100000 grid. The `` element carries the **audience-derived** canvas +dimensions (`canvas_width` × `canvas_height` from §3.1 / BI_DESIGN §4.1) — it is no longer hardcoded to +1000×800. The dashboard is the default open tab via a `` entry appended to +``. The example below shows the `analyst` canvas (1200×900): + +```xml + + + + + + + + + + + + +``` + +The four audience sizes (BI_DESIGN §4.1): exec 1000×800, analyst 1200×900, operational 800×1200, +mixed 1000×900. A pytest asserts the emitted `` `maxwidth`/`maxheight` match the audience passed +through the request (§9.1). + +### 6.3 Zone tiling math (the geometry helper) + +A single pure helper `_tile_zones(titles, layout)` returns the `(x, y, w, h)` quad per worksheet. The +math is the main correctness risk (zone overlap), so it is isolated and unit-tested independently +(DB-2). It is independent of the canvas pixel size — zones always use the 0–100000 grid: + +- Grid = 100000 × 100000. `n = len(titles)`. +- `tiled_vertical`: each zone `w = 100000`, `h = floor(100000/n)`, `x = 0`, + `y = i * floor(100000/n)`. The **last** zone absorbs the rounding remainder so `Σh == 100000` + exactly (no gap/overlap). → distinct `y`, equal `x` (satisfies DB-2). +- `tiled_horizontal`: symmetric on the x-axis: `h = 100000`, `w = floor(100000/n)`, `y = 0`, + `x = i * floor(100000/n)`, last zone absorbs remainder. → distinct `x`, equal `y`. +- IDs: container zone `id=1`; worksheet zones `id=2..n+1`. Names are the worksheet titles verbatim. + +### 6.4 Sidecar API boundary (`sidecar/server.py`) + +New route `POST /workbook/dashboard` with a `DashboardWorkbookRequest` model = `WorkbookRequest` + +`dashboard_layout: str = Field(default="tiled_vertical", alias="dashboardLayout")` + +`canvas_width: int = Field(default=1000, alias="canvasWidth")` + +`canvas_height: int = Field(default=800, alias="canvasHeight")`. `dashboard_layout` is validated +against `{"tiled_vertical","tiled_horizontal"}` (400 otherwise); `canvas_width`/`canvas_height` are +validated as positive ints (400 otherwise). The route builds the dashboard list from the sheet titles +and forwards to +`build_starter_twbx(..., dashboards=[...], dashboard_layout=..., canvas_width=..., canvas_height=...)`. +The TS caller (`build_from_plan` → `sidecar.buildDashboardWorkbook`) derives `canvasWidth`/ +`canvasHeight` from `plan.audience` via the §3.1 table before the call. The existing `/workbook/starter` +route is untouched. + +New route `POST /datasource/from-file` with a `FileRequest` model: `name`, `file_path`, `file_type?`, +`excel_sheet?`, `json_path?`. It dispatches into `query_to_dataframe` after `hyper_builder` gains +format branches (§6.5). + +### 6.5 Multi-format ingest in `hyper_builder.py` + +Extend `query_to_dataframe()` connection dispatch (line 99) with new branches, plus a thin +`file_to_dataframe(file_type, path, excel_sheet, json_path)` dispatcher so format logic is one place: + +| fileType | pandas call | notes | +|---|---|---| +| `csv` | `pd.read_csv` | unchanged path | +| `json` | `pd.read_json(path)` then optional `jsonPath` selection | array or object-with-array | +| `jsonl` | `pd.read_json(path, lines=True)` | NDJSON | +| `xlsx` / `xls` | `pd.read_excel(path, sheet_name=excel_sheet or 0)` | `openpyxl` engine (C-4) | +| `parquet` | `pd.read_parquet(path)` | `pyarrow` already present (C-5) | + +`jsonPath` MVP supports a leading `$.` single-level selector (documented limit); anything deeper +is rejected with a clear error rather than silently mis-parsing. Unknown `file_type` → `ValueError` +(surfaced as 400). `openpyxl` is added to `sidecar/pyproject.toml` as a non-optional dep (C-4). + +--- + +## 7. Component & data flow + +```mermaid +flowchart TD + Agent[AI agent - LLM, owns conversation] -- MCP stdio --> Index[src/index.ts] + Index --> DD[design_dashboard tool] + Index --> BFP[build_from_plan tool] + Index --> CDF[create_datasource_from_file tool] + + DD --> Planner[src/planner/* - pure TS rules] + Planner --> Fields[fields.ts - field-role inference §3.0] + Fields --> Marks[marks.ts - keyword heuristic §3.2] + Marks --> Clamps[audience.ts - 6-step clamp §3.4] + Planner -. reads .-> BIspec[(BI_DESIGN.md rule tables)] + DD -- returns plan/questions, no side effects --> Agent + + BFP --> Rest1[restClient.getDatasource / publishWorkbook] + BFP --> SC[AuthoringSidecar] + CDF --> SC + CDF --> Rest2[restClient.publishDatasource] + + SC -- loopback + X-Sidecar-Token --> Server[sidecar/server.py] + Server --> Hyper[hyper_builder.py - file_to_dataframe + from-file] + Server --> Tds[tds_builder.py - unchanged] + Server --> Twb[twb_builder.py - +_build_dashboard / _tile_zones / canvas size] + + Rest1 -- HTTPS + PAT --> Cloud[(Tableau Cloud REST v3.28)] + Rest2 -- HTTPS + PAT --> Cloud +``` + +### Trust boundaries (unchanged from baseline, extended) +- **Agent ↔ server:** stdio JSON-RPC; sidecar stdout never reaches the channel. +- **TS ↔ sidecar:** `127.0.0.1` only + per-spawn `X-Sidecar-Token` (`hmac.compare_digest`). New + routes inherit the existing `token_guard` middleware automatically (C-2). +- **server ↔ Cloud:** PAT in sign-in body only, never logged; explicit-project guardrail + (`resolveProjectId` rejects empty/Default) applies to every new publish. +- **Boundary preserved:** all REST + auth stays in TS; all file authoring stays in Python. The TS + planner (`fields.ts`/`marks.ts`/`audience.ts`/`plan.ts`) produces only structural plans (no network), + so it does not cross the boundary. + +--- + +## 8. Build sequence (dependency-ordered, each slice independently buildable + testable) + +The thinnest runnable slice first; each milestone leaves `make ci` green and adds its own tests. + +**M0 — Pre-flight (regression baseline).** +Fix the `.cursor/**` lint-ignore gap (CODEBASE.md Tech-Debt #1, one line in `eslint.config.js`) so +`make ci` is green locally before the feature branch. Confirm the 46 existing tests pass. No feature +code. *Done when:* `make ci` green on the clean tree. + +**M1 — Multi-format ingest (Python-only, no new tool yet).** ← thinnest vertical slice +`hyper_builder.file_to_dataframe()` + branches; add `openpyxl` dep; new `/datasource/from-file` +sidecar route + `FileRequest` model. *Tests:* PA-1 (parquet/xlsx/json/jsonl fixtures round-trip), +PA-3 (excel sheet by index + name). *Done when:* new pytest green, `mypy --strict` clean, all 18 +prior Python tests pass. + +**M2 — `create_datasource_from_file` MCP tool.** +`src/tools/createDatasourceFromFile.ts` + `sidecar.ts` `buildDatasourceFromFile()` + register in +`index.ts`. *Tests:* PA-2 (unsupported extension → zod error, zero sidecar calls), wiring test +(correct sidecar route + publish call), 12-tool registration count. *Done when:* new vitest green, +existing 28 pass (registration count updated 11→12). + +**M3 — Dashboard XML in the sidecar (Python-only).** +`twb_builder._tile_zones()`, `_build_dashboard()` (with audience canvas size), extend `build_twb_xml` / +`build_starter_twbx` with optional `dashboards`/`dashboard_layout`/`canvas_width`/`canvas_height`; new +`/workbook/dashboard` route + `DashboardWorkbookRequest` (incl. `canvasWidth`/`canvasHeight`). *Tests:* +DB-1 (one `` per sheet), DB-2 (vertical distinct-y/equal-x; horizontal +distinct-x/equal-y; Σ covers grid, no overlap), **canvas-size test** (emitted `` matches the +audience: exec 1000×800, analyst 1200×900, operational 800×1200, mixed 1000×900), default-`None` +regression (5 twb tests unchanged). *Done when:* new pytest green, mypy clean. + +**M4 — Field inference + `DashboardPlan` schema + planner rules (TS-only, pure).** +`src/planner/fields.ts` (the §3.0 field-role inference engine: dtype classification, the 8 priority +regexes, cardinality heuristic, tie-breakers, `FieldClassification` with `suppress`) — built **first** +because every later stage consumes it; `src/planner/schema.ts` (zod for `DashboardPlan` / +`ClarifyingQuestions` / `SheetSpec` / `DatasourceSpec`, incl. the `schemaVersion == 1` guard); +`src/planner/marks.ts` (BI_DESIGN §6 keyword heuristic + no-dimension downgrade); +`src/planner/audience.ts` (the 6-step clamp); `src/planner/questions.ts` (interview bank); +`src/planner/plan.ts` (generator wiring fields→marks→chart-select→clamps, applies gap annotations, +reads BI_DESIGN tables, enforces the `schemaVersion` guard). No tool yet. *Tests:* **field-inference +units (identifier-suppression: numeric `customer_id` → role `identifier`, `suppress=true`, never on a +shelf; measure/dimension classification per BI_DESIGN §8 predicates)**, audience-clamp unit tests +(incl. STEP 5 per-sheet caps and STEP 6 map guard), no-dimension downgrade, mark-heuristic units, +gap-annotation units (G-02 scatter→bar note; G-05 top-N note), `schemaVersion != 1` throws, schema +round-trip. *Done when:* planner functions unit-tested, build clean. + +**M5 — `design_dashboard` tool.** +`src/tools/designDashboard.ts` wires modes to the planner; register in `index.ts`. *Tests:* MA-1..3, +MB-1..2, MC-1..2 (the exact fixtures in REQUIREMENTS; MC-1 pins `audience:"analyst"` — see §9.2), +13-tool registration count. *Done when:* all mode tests green. + +**M6 — `build_from_plan` tool (orchestration + publish).** +`src/tools/buildFromPlan.ts` + `sidecar.ts` `buildDashboardWorkbook()` (derives `canvasWidth`/ +`canvasHeight` from `plan.audience`); placeholder-token rejection; `schemaVersion` re-validation; +datasource-first ordering. *Tests:* E2E-1 (one sidecar `/workbook/dashboard`, one `publishWorkbook`, +no `publishDatasource` when no spec), E2E-2 (`datasourceSpec.filePath` → file build first + +`datasourceLuid` in result), DB-3 (mocked sidecar response → valid plan flows through), 14-tool +registration count. *Done when:* orchestration tests green. + +**M7 — CI matrix + docs + gated live demo.** +Update CI to run the new tests on Node 22/24/26 + Python 3.12/3.13 (CI-1, CI-2). Add +`scripts/demo-dashboard.ts` and `npm run demo:dashboard`. Run the one authorized live publish; capture +the dashboard-tab screenshot in `ACCEPTANCE.md` (E2E-3). *Done when:* CI green across matrix; demo +artifact captured. + +Dependency edges: M1→M2, M3→M6, M4→M5→M6, all→M7. Within M4, `fields.ts` precedes `marks.ts` and +`plan.ts`. M1/M3 (Python) and M4 (TS planner) can proceed in parallel after M0. + +--- + +## 9. Test strategy (mapped to REQUIREMENTS success-criteria IDs) + +Target: ≥24 new TS tests + ≥13 new Python tests, on top of the 46 that must stay green. + +### 9.1 Headless Python — structural `.twb` / `.tdsx` assertions +| Test | Asserts | Criterion | +|---|---|---| +| `test_hyper_builder` parquet/xlsx/json/jsonl fixtures | round-trip row count + types; one `` per source col after tdsx | PA-1 | +| excel sheet by index `1` and name `"Sheet2"` | correct sheet row count | PA-3 | +| `test_twb_builder` dashboard | parse `.twb`; `` present; `` count == len(sheets) | DB-1 | +| zone geometry | vertical: distinct `y`, equal `x`; horizontal: distinct `x`, equal `y`; no overlap, full coverage | DB-2 | +| **canvas size per audience** | emitted `` `maxwidth`/`maxheight` == exec 1000×800, analyst 1200×900, operational 800×1200, mixed 1000×900 | §3.1/§4.1, DB-1 | +| default-None regression | `build_twb_xml` with no `dashboards` (defaults 1000×800) == today's output | regression | +| `test_server` new routes | `/workbook/dashboard` (+ `canvasWidth`/`canvasHeight`) → valid `.twbx`; `/datasource/from-file` → valid `.tdsx`; token guard still applies | DB-1, PA-1 | + +### 9.2 Headless TS — planner unit tests + mocked orchestration +| Test | Asserts | Criterion | +|---|---|---| +| **field inference — identifier suppression** | numeric/string `customer_id`, `order_id` → role `"identifier"`, `suppress==true`; never appears in any sheet's `cols`/`rows`/`measures` | §3.0, BI_DESIGN §8 | +| **field inference — measure vs dimension** | `revenue`(number)→measure; `region`(string)→dimension(low_card); `order_date`(date)→temporal; `is_active`→boolean_flag dimension | §3.0, BI_DESIGN §8 | +| **schemaVersion guard** | `DashboardPlan`/BI_DESIGN with `schemaVersion != 1` → planner throws | §3, BI_DESIGN §0/§8.1 | +| autonomous exec | `sheets.length ≤ 3`, all `markType ∈ {bar,line,text}`, `sheets[0].markType=="text"` (KPI lead) | MA-1 | +| autonomous analyst | `sheets.length ≤ 8` | MA-2 | +| autonomous operational | `dashboardLayout == "tiled_vertical"` and ≥1 `markType=="text"` | MA-3 | +| **clamp STEP 5 — per-sheet caps** | exec sheet with 3 measures → truncated to 1 (`maxMeasures`); analyst sheet with 5 dims → `cols+rows ≤ 3` | §3.4 STEP 5, BI_DESIGN §8 | +| **clamp STEP 6 — map guard** | a `map` sheet under exec/operational/mixed → `markType=="bar"` + "Map mark not allowed" note; survives under analyst | §3.4 STEP 6 | +| **no-dimension downgrade** | `bar`-selected shape with all dimensions suppressed → `markType=="text"` (KPI) with downgrade note; measureless `text` is dropped | §3.2, BI_DESIGN §0.2/§6 | +| **gap annotation — scatter→bar** | 2-measure analyst shape → `markType=="bar"` with the G-02 scatter-fallback note in `sheet.rationale` | §3.5, G-02 | +| **gap annotation — top-N** | high-cardinality dimension → `markType=="bar"` with the G-05 top-N note in `sheet.rationale` | §3.5, G-05 | +| interview | `questions.length ∈ [3,7]`, no `sheets` key | MB-1 | +| interview_followup | returns `DashboardPlan`, `rationale` non-empty | MB-2 | +| directed (exact string, `audience:"analyst"`) | exactly 2 sheets: `sheets[0].markType=="text"`, `sheets[1].markType=="bar"` (analyst has `minimumKpiCount==0`, so the clamp inserts no extra KPI and the `[text, bar]` pair survives) | MC-1 | +| directed missing `directions` | zod validation error | MC-2 | +| from-file unsupported ext | thrown error, **zero** sidecar calls (spy count 0) | PA-2 | +| build_from_plan no spec | 1× sidecar `/workbook/dashboard`, 1× `publishWorkbook`, 0× `publishDatasource`; request carries audience-derived `canvasWidth`/`canvasHeight` | E2E-1 | +| build_from_plan with file spec | file build first, `datasourceLuid` in result | E2E-2 | +| build_from_plan twbx shape | mocked sidecar response → `.twb` has `///` | DB-3 | +| registration counts | 12 → 13 → 14 as tools land | regression | + +Orchestration tests reuse the existing `FakeServer` + `vi.fn()` ctx pattern from `tests/tools.test.ts` +(mock `rest` + `sidecar`); no live REST, no PAT in CI. + +**MC-1 audience pinning (defect fix):** the MC-1 directed fixture +(`"show me a table of top 10 customers by revenue and a bar chart of revenue by region"`) is run with +`audience:"analyst"`. Analyst's `minimumKpiCount == 0`, so clamp STEP 3 inserts **no** leading KPI +sheet, and the heuristic's `[text (top-10 table), bar (by region)]` two-sheet result survives the +pipeline unchanged — matching BI_DESIGN §8.2's MC-1 predicate (`sheets.length == 2`, +`sheets[0].markType == "text"`, `sheets[1].markType == "bar"`). Running it under `exec` (where +`minimumKpiCount == 1`) would inject a third KPI sheet and break the expected result, which is why the +audience is pinned. + +### 9.3 Gated live demo +`npm run demo:dashboard`: `design_dashboard(autonomous) → build_from_plan` against the Dev site; +verify the Cloud URL resolves to a workbook with ≥1 dashboard tab; screenshot → `ACCEPTANCE.md` +(E2E-3). This is the only non-headless check, matching the baseline's criterion-7 policy. + +### 9.4 CI +Extend the matrix legs and add the new test files to both jobs (CI-1); `npm run build` + `npm run lint` ++ `ruff` + `mypy --strict` clean after the new modules (CI-2). + +--- + +## 10. Risks & mitigations + +| # | Risk | Severity | Mitigation | +|---|---|---|---| +| R-1 | **Tableau dashboard XML fidelity** — a hand-built `` that parses but won't render in Cloud. | High | Reuse the proven `TWB_VERSION="18.1"` / `SOURCE_BUILD="2024.1.0"` (C-9). Match the exact zone structure in CODEBASE.md §"Dashboard workbook authoring". Structural pytest (DB-1/DB-2/canvas-size) for shape; one gated live render (E2E-3) for ground truth, identical to how the baseline validated worksheets. | +| R-2 | **Zone overlap / sizing math** — rounding gaps or overlapping tiles. | High | Isolate `_tile_zones()` as a pure helper; last-zone-absorbs-remainder so `Σ == 100000` exactly; DB-2 asserts distinct offsets, equal cross-axis, full coverage, zero overlap. Canvas pixel size is decoupled from the 0–100000 zone grid, so audience sizing cannot perturb tiling. | +| R-3 | **Multi-format type mapping** — wrong dtype inference (e.g. JSON ints as float, Excel dates as strings). | Medium | pantab infers Hyper types from pandas dtypes (existing, proven). PA-1 round-trips each format and asserts column types; PA-3 covers Excel sheet selection. `jsonPath` limited to one documented level, else explicit error. | +| R-4 | **Backward-compat regression** — breaking any of the 11 tools / 46 tests. | High | Every change is additive: optional sidecar params default to today's behavior (`dashboards=None`, canvas 1000×800 ⇒ byte-identical); `create_datasource_from_table` signature frozen (C-10); `/workbook/starter` untouched; `SheetSpec` is a strict superset. Registration-count tests track 11→14 explicitly. M0 restores green CI before any feature code. | +| R-5 | **Planner ↔ builder contract drift** — `DashboardPlan` shape diverging between producer and consumer. | Medium | One zod schema in `src/planner/schema.ts` imported by both tools; one mirrored Pydantic model; `build_from_plan` re-validates the plan it receives incl. the `schemaVersion` and per-sheet-cap invariants (defense in depth). | +| R-6 | **Non-determinism leaks into the planner** — making MA-/MC- tests flaky. | Medium | No `Date.now()`, no RNG, no model calls in `src/planner/*`; field inference, mark heuristic, and clamps are pure functions of inputs; question IDs are content-derived constants; tests use the exact fixture strings from REQUIREMENTS. | +| R-7 | **Placeholder fields reaching Cloud** — a plan with `` tokens published as broken viz. | Medium | `build_from_plan` rejects any plan still containing placeholder tokens with a clear, actionable error before authoring (fail-loud). | +| R-8 | **`openpyxl` / `pyarrow` availability** in CI venv. | Low | `openpyxl` added as non-optional dep (C-4); `pyarrow` already present via pantab (C-5); CI-2 fails loudly if missing. | +| R-9 | **Secret/PAT leakage via new file paths or error text.** | High | No new auth surface (C-1). New tools route through existing `restClient` (PAT only in sign-in body). Error messages echo field paths, never values; extend the `secrets.test.ts` log-capture assertion to the new tools. | +| R-10 | **PLAN ↔ BI_DESIGN rule drift** — restated tables in §3 falling out of sync with the normative spec. | Medium | BI_DESIGN.md is declared normative in the header and §3; the planner is implemented to BI_DESIGN, not to the restated cells; BI_DESIGN §8.2 predicates are the test oracle, so any drift fails a unit test rather than shipping. | + +--- + +## 11. Security, observability, testing — designed in + +- **Security:** no new auth or secret surface (C-1); loopback + token reused for new routes (C-2); + input validated at every boundary (zod in TS, Pydantic + `ValueError`→400 in Python); `filePath` + must be a regular file (existing check reused); explicit-project guardrail on every publish; no + secrets in code or logs (R-9). Security-review trigger applies because new code does file-system + reads and external publish calls — route through the existing reviewed paths. +- **Observability:** keep `process.stderr.write` structured logging (stdout is the MCP channel). Each + new tool logs the mode/format and the resulting LUID/URL (never the PAT, never file contents). The + sidecar logs route + output path at `warning` level. Correlation-ID/JSON logging stays a tracked + debt item (CODEBASE.md #6), not introduced here (YAGNI). +- **Testing:** TDD per slice — write the headless assertion first (RED), implement to green, refactor; + 80%+ coverage on new modules; small single-purpose modules (planner split into + fields/schema/marks/audience/questions/plan, each <200 lines). The 46-test suite is the regression + gate on every milestone. + +--- + +## 12. File-change map (new vs. modified) + +**New (TypeScript):** `src/tools/createDatasourceFromFile.ts`, `src/tools/designDashboard.ts`, +`src/tools/buildFromPlan.ts`, `src/planner/fields.ts` (§3.0 field-role inference engine), +`src/planner/schema.ts`, `src/planner/marks.ts`, `src/planner/audience.ts`, `src/planner/questions.ts`, +`src/planner/plan.ts`. +**Modified (TypeScript):** `src/index.ts` (register 3 tools), `src/sidecar.ts` +(`buildDatasourceFromFile`, `buildDashboardWorkbook` — derives + sends `canvasWidth`/`canvasHeight`, +new arg interfaces), `eslint.config.js` (M0 ignore fix). +**New (Python):** none required as new files — extensions live in existing modules; optional new +`sidecar/file_loaders.py` if `file_to_dataframe` grows past ~80 lines (extract-when-real per coding +rules). +**Modified (Python):** `sidecar/hyper_builder.py` (`file_to_dataframe` + branches), +`sidecar/twb_builder.py` (`_tile_zones`, `_build_dashboard` with `canvas_width`/`canvas_height`, extend +`build_twb_xml` / `build_starter_twbx` with `dashboards`/`dashboard_layout`/`canvas_width`/ +`canvas_height`), `sidecar/server.py` (2 routes + 2 models incl. `canvasWidth`/`canvasHeight` on +`DashboardWorkbookRequest`), `sidecar/pyproject.toml` (`openpyxl`). +**New (tests):** TS field-inference/planner/tool tests, Python from-file + dashboard + canvas-size +tests, `scripts/demo-dashboard.ts`, `ACCEPTANCE.md` dashboard section. + +--- + +## 13. Revision log + +| Round | Date | Change | +|---|---|---| +| 0 | 2026-06-24 | Initial plan. Resolved the 5 proposed names into 3 MCP tools (`create_datasource_from_file`, `design_dashboard`, `build_from_plan`) + 2 sidecar routes (`/datasource/from-file`, `/workbook/dashboard`); rejected `create_dashboard_workbook` as redundant with `build_from_plan`. Specified the full `DashboardPlan` / `ClarifyingQuestions` zod contract in one shared module, the bounded ≤2-call interview sequence, the `_tile_zones` geometry + `` XML against the named `twb_builder.py` functions, the audience rule tables (BI_DESIGN.md normative), an 8-milestone dependency-ordered build (M0 regression baseline first, thinnest slice M1), the test matrix mapped to every REQUIREMENTS criterion ID (PA/DB/MA/MB/MC/E2E/CI), and 9 risks with mitigations including the 46-test regression guard. | +| 1 | 2026-06-24 | **Critic pass — made the plan consistent with the now-normative BI_DESIGN.md (7 defects).** (1, CRITICAL) Added the missing **field-role inference engine** as new §3.0 / `src/planner/fields.ts` (BI_DESIGN §1: dtype classification, 8 priority name-pattern regexes, cardinality heuristic, tie-breakers, `FieldClassification` with `suppress`); wired as the first pipeline stage before chart selection, added to the file-change map (§12), the build sequence (M4 builds it first), the data-flow diagram (§7), and the test matrix (identifier-suppression + measure/dimension units, §9.2). (2, HIGH) Replaced the under-specified 5-clamp list with BI_DESIGN §3.2's **verbatim 6-step** clamp algorithm (§3.4), restoring STEP 5 per-sheet `maxMeasures`/`maxDimensions` caps and STEP 6 map-mark guard; added unit tests for both (§9.2). (3, HIGH) Made **audience canvas sizes emittable**: added `canvas_width`/`canvas_height` (derived from audience: exec 1000×800, analyst 1200×900, operational 800×1200, mixed 1000×900) through `DashboardWorkbookRequest` → `build_starter_twbx` → `_build_dashboard` → `` (no longer hardcoded 1000×800), with a pytest asserting the emitted `` per audience (§6, §9.1). (4, HIGH) Added **gap-fallback annotation** requirement + asserting tests (§3.5, §9.2): 2-measure analyst → `bar` with G-02 note; high-card dim → G-05 top-N note. (5, MED) **Pinned MC-1** to `audience:"analyst"` (`minimumKpiCount==0`) so the expected `[text, bar]` pair survives the clamp; documented in §9.2. (6, MED) Specified **no-dimension downgrade** (`bar`/`line`→`text`) and **KPI-without-measure drop** (invariant §0.2) in §3.2 + a unit test (§9.2). (7, LOW/MED) Wired the **`schemaVersion != 1` guard** into `schema.ts`/`plan.ts` (§3, §4.2) with a throwing unit test (§9.2). Restated §3.2 keyword table to BI_DESIGN §6 normative form; added R-10 (PLAN↔BI_DESIGN drift). **Preserved unchanged:** the 3-tool surface with `create_dashboard_workbook` rejected, the deterministic/stateless boundary, the additive-only backward-compat + regression guards (default-None byte-identical, 11→14 registration count, M0 baseline), the isolated `_tile_zones()` geometry, the `{bar,line,text,map}` mark enum, the dependency-ordered build, and the concrete risk register. | + + From d79c4eb9cca1231ed270e2b1e2762bf8428f2e10 Mon Sep 17 00:00:00 2001 From: Sebastien Henry Date: Wed, 24 Jun 2026 17:13:15 -0500 Subject: [PATCH 03/16] feat: prompt-driven datasource + dashboard authoring (3 tools) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the prompt-driven authoring layer the user asked for: - create_datasource_from_file: build+publish a governed datasource from a file (csv/json/jsonl/xlsx/parquet) or the existing SQL-query path - design_dashboard: deterministic, stateless BI planner — autonomous, interview (senior-BI-analyst clarifying questions), interview_followup, and directed modes; audience-aware (exec/analyst/operational/mixed) - build_from_plan: the only side-effecting tool — consumes a DashboardPlan, optionally creates the datasource, builds a real dashboard (.twb with /) and publishes to Cloud Sidecar gains /datasource/from-file and /workbook/dashboard routes; twb_builder emits dashboard zones via a pure _tile_zones geometry (Σ==100000, no overlap), with per-audience canvas sizes. Planner (src/planner/*) implements the normative BI_DESIGN rules: field-role inference + identifier suppression, chart->mark selection with documented gap fallbacks, and the 6-step audience clamp. Guardrails: explicit project required, overwrite=false default, placeholder-token and audience-invariant rejection before any publish, file-size cap + row clamp, PAT/token never logged. Tools 11 -> 14. Gate green: 75 TS + 71 Python tests. Co-Authored-By: Claude Opus 4.8 --- SECURITY.md | 66 ++- package.json | 1 + scripts/demo-dashboard.ts | 137 ++++++ sidecar/hyper_builder.py | 118 ++++- sidecar/pyproject.toml | 4 +- sidecar/server.py | 87 ++++ sidecar/tests/test_hyper_builder_formats.py | 167 ++++++++ sidecar/tests/test_server_new_routes.py | 306 +++++++++++++ sidecar/tests/test_twb_dashboard.py | 225 ++++++++++ sidecar/twb_builder.py | 127 +++++- sidecar/uv.lock | 50 ++- src/index.ts | 11 +- src/planner/audience.ts | 268 ++++++++++++ src/planner/fields.ts | 253 +++++++++++ src/planner/marks.ts | 284 ++++++++++++ src/planner/plan.ts | 254 +++++++++++ src/planner/questions.ts | 126 ++++++ src/planner/schema.ts | 147 +++++++ src/sidecar.ts | 66 +++ src/tools/buildFromPlan.ts | 187 ++++++++ src/tools/createDatasourceFromFile.ts | 106 +++++ src/tools/designDashboard.ts | 161 +++++++ tests/planner.test.ts | 452 ++++++++++++++++++++ tests/tools.test.ts | 278 +++++++++++- 24 files changed, 3866 insertions(+), 15 deletions(-) create mode 100644 scripts/demo-dashboard.ts create mode 100644 sidecar/tests/test_hyper_builder_formats.py create mode 100644 sidecar/tests/test_server_new_routes.py create mode 100644 sidecar/tests/test_twb_dashboard.py create mode 100644 src/planner/audience.ts create mode 100644 src/planner/fields.ts create mode 100644 src/planner/marks.ts create mode 100644 src/planner/plan.ts create mode 100644 src/planner/questions.ts create mode 100644 src/planner/schema.ts create mode 100644 src/tools/buildFromPlan.ts create mode 100644 src/tools/createDatasourceFromFile.ts create mode 100644 src/tools/designDashboard.ts create mode 100644 tests/planner.test.ts diff --git a/SECURITY.md b/SECURITY.md index e4e1523..d6d2401 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,10 +1,12 @@ # SECURITY.md -**Audit date:** 2026-06-20 · **Method:** STRIDE decomposition + manual code/dependency review. +**Audit date:** 2026-06-20 (baseline) · 2026-06-24 (prompt-driven authoring add-on) · **Method:** STRIDE decomposition + manual code/dependency review. **Result:** 0 CRITICAL · 0 HIGH · 4 MEDIUM · 7 LOW · 1 PASS. The core safety controls (PAT never logged, stderr-only logging, loopback+token sidecar, confirm gate on delete, capability allowlist, no silent overwrite / no Default-project publish) are present and effective. +**Prompt-driven authoring add-on (2026-06-24):** 0 CRITICAL · 0 HIGH · 2 MEDIUM · 2 LOW · 4 PASS — see the "Prompt-driven authoring — added surface" section below. No CRITICAL/HIGH issues; the feature is shippable. + ## Trust boundaries | Boundary | From → To | Control | @@ -55,5 +57,67 @@ needs `confirm=true`; elevated permission grants need `confirmElevated=true`; `o false; publishing always requires an explicit project (never Default). Operators wanting a publish-only posture can avoid wiring `delete_content` / `set_permissions` into their client. +## Prompt-driven authoring — added surface + +**Audited:** 2026-06-24 · branch `feat/prompt-driven-authoring` · STRIDE + dependency/secret/input +review of the new feature only (baseline findings F-01..F-12 above are unchanged and still hold). + +### New surface +- Tools (TS): `create_datasource_from_file`, `design_dashboard`, `build_from_plan` + (`src/tools/*.ts`), pure planner (`src/planner/{fields,marks,audience,questions,schema,plan}.ts`). +- Sidecar client methods `buildDatasourceFromFile` / `buildDashboardWorkbook` (`src/sidecar.ts`). +- Sidecar routes `POST /datasource/from-file`, `POST /workbook/dashboard` (`sidecar/server.py`), + multi-format ingest `file_to_dataframe` (`sidecar/hyper_builder.py`), dashboard XML + (`sidecar/twb_builder.py`). +- New Python deps: `openpyxl` (xlsx), `pyarrow` (parquet, via pantab); no new npm deps. + +### Trust-boundary note (local file read) +`create_datasource_from_file` / `/datasource/from-file` read **any** file the MCP-server process +can read — there is no allowed-roots / traversal / symlink restriction; the only check is +`Path.is_file()` (`hyper_builder.py:115`). This is **by design and matches the existing `csvPath` +posture (F-10)**: the agent already runs with the PAT's full authority and is trusted to name +paths. The realistic threat is therefore a *prompt-injected* agent, not the path mechanism itself. +The path is **not** written to any log (the ingest path has no logger) and only appears in a `400` +`detail` returned to the same agent that supplied it — no cross-tenant disclosure. Reading a hostile +*content* file (parsing) is covered by PA-2 below. + +### STRIDE summary (new surface) + +| Threat | Result | +|---|---| +| Spoofing | Mitigated — both new routes sit behind the same global `token_guard` middleware (127.0.0.1 bind + per-spawn `X-Sidecar-Token`, `hmac.compare_digest`). No route opts out. | +| Tampering | Mitigated — all user strings (field names, sheet/dashboard titles, datasource name) are written via ElementTree text/attribute nodes, which escape `< > & "`; verified well-formed under injection input. No string-concatenated XML in the new builder. | +| Repudiation | Unchanged — Tableau Cloud records the publishing user; `build_from_plan` is the only side-effecting new tool. | +| Information disclosure | Mitigated — no secrets in the new code; file paths not logged; sidecar error bodies are agent-supplied paths only. | +| Denial of service | Partially mitigated — `file_to_dataframe` reads the whole file into memory with **no row/size cap and no zip-bomb guard** (PA-1). Loopback+token-gated, so the attacker is a trusted/injected agent. | +| Elevation of privilege | Mitigated — `build_from_plan` still routes publishing through `resolveProjectId`, which rejects empty and `Default` projects; `overwrite` defaults `false`; PAT/sidecar token never logged. `design_dashboard` is pure (no sidecar/REST calls). | + +### Findings & remediation status (new surface) + +| ID | Sev | Finding | Status | +|---|---|---|---| +| PA-1 | MED | `file_to_dataframe` (xlsx/parquet/json/csv) loads the entire file into memory with no pre-read size cap, no `max_rows` cap (unlike `query_to_dataframe`), and no decompression-bomb guard for xlsx (zip) — a hostile/huge file an injected agent points at can exhaust sidecar memory. | ⚠️ **Needs-fix (recommended)** — loopback + token gate the blast radius to the local agent, so not shippable-blocking, but cheap to harden. See fix below. | +| PA-2 | LOW | Untrusted-file parsing via `openpyxl` / `pyarrow` / `pandas.read_json`. No known RCE/unsafe-deser path at the pinned versions; openpyxl reads xlsx as data (no macro execution), pyarrow parquet read is data-only. Residual risk is the DoS already tracked as PA-1. | ⚠️ Accepted — versions current; revisit if a parser CVE lands. | +| PA-3 | MED | Transitive `starlette==0.41.3` (via `fastapi==0.115.6`) carries 8 advisories (PYSEC-2026-161, CVE-2025-54121, CVE-2025-62727, CVE-2026-48818, CVE-2026-48817, CVE-2026-54283, CVE-2026-54282). **None is reachable here:** the sidecar uses no `StaticFiles`/`FileResponse`/`HTTPEndpoint`/`request.url`/form-urlencoded parsing, and the token guard keys off the `X-Sidecar-Token` header (not `request.url.path`), so the host/path-confusion auth-bypass does not bypass it. Bind is loopback-only, defeating the "unauthenticated remote attacker" precondition. | ⚠️ **Needs-fix (hygiene)** — bump `fastapi` to pull patched starlette (≥ 0.49.1 covers the reachable-by-class items; latest covers all). Not exploitable in this deployment; not shippable-blocking. | +| PA-4 | LOW | `openpyxl>=3.1.5` is the only non-`==` pin in `pyproject.toml`; the lock resolves to 3.1.5, so reproducibility holds today, but the floor allows drift on the next `uv lock`. | ⚠️ Accepted — pin to `==3.1.5` for parity with the rest of the manifest when convenient. | +| PA-5 | PASS | XML injection into `.twb`/`.tds`: field names, sheet/dashboard titles, and the ``/`` text nodes are all ElementTree-escaped (verified: injected `