import type { Carte, CarteEntry, StaticHints } from "./carte.js"; import { getParamsControls, getParamsSchema, getTopLevelObjectShape } from "./params.js"; import type { UIAdapter } from "./ui-adapter.js"; import { printSchema } from "./zod-print.js"; export type PromptFormat = "markdown" | "xml" | "plain"; export interface GeneratePromptOptions { /** * Output serialisation. Defaults to `"markdown"`. XML tends to work slightly * better with Claude for structured content; markdown is the most portable * choice for GPT-family models. */ format?: PromptFormat; /** * Append a guide that tells the LLM how to structure its response (the * `{ layout, panels: [...] }` shape, the `{ $bind: "..." }` ref syntax, * the "respond with a single JSON code block" instruction). Defaults to * `true` because without it the LLM has to guess the wrapper shape and * usually guesses wrong. Set `false` if you supply your own format * instructions or use a structured-output mode. */ includePlanFormat?: boolean; /** * Restrict the prompt to a subset of carte entries by id. Ids absent * from the carte are silently skipped. RBAC still applies — the * effective set is `only ∩ rbac_allowed`. * * Useful for two-stage flows: a first LLM call picks relevant query ids * from a compact carte summary (see `generateCompactPrompt`), and the * second call gets a full prompt restricted to those ids. */ only?: ReadonlyArray; } export interface GenerateCompactPromptOptions { /** * Output serialisation. Defaults to `"markdown"`. Markdown is the natural * fit; xml and plain are provided for consistency with `generatePrompt`. */ format?: PromptFormat; } interface ResolvedQuery { entry: CarteEntry; stats: StaticHints | undefined; } /** * Resolve the entry's `staticHints` for prompt rendering. Validates the * shape via `assertStaticHints`. When `CARTE_VERIFY_STATIC_HINTS` is set * to a truthy value, calls the function twice and asserts equality — the * opt-in determinism check that catches "I cached a DB query inside * staticHints" foot-guns in CI without paying the runtime cost in * production. */ async function getStaticHints( entry: CarteEntry, ): Promise { if (!entry.staticHints) return undefined; const value = await entry.staticHints(); const validated = assertStaticHints(value, entry.id); if (isStrictHintsCheckEnabled()) { const second = await entry.staticHints(); const validatedSecond = assertStaticHints(second, entry.id); if (!staticHintsEqual(validated, validatedSecond)) { throw new Error( `Carte entry "${entry.id}".staticHints() returned different values across two calls. ` + `staticHints MUST be deterministic — the same value on every call within a process. ` + `If you computed this from a database query or any runtime state, that is a structural ` + `security leak: per-request variation in staticHints means runtime data is reaching ` + `the LLM's prompt. Move the value into a static authoring-time constant, or split into ` + `two entries with different access predicates if the variation is per-role.`, ); } } return validated; } function isStrictHintsCheckEnabled(): boolean { // Read once per call. Truthy strings ("1", "true", anything non-empty // other than "false"/"0") enable the check. Off by default — production // pays no overhead. if (typeof process === "undefined" || !process.env) return false; const v = process.env.CARTE_VERIFY_STATIC_HINTS; if (!v) return false; return v !== "0" && v.toLowerCase() !== "false"; } function staticHintsEqual(a: StaticHints, b: StaticHints): boolean { const aKeys = Object.keys(a); const bKeys = Object.keys(b); if (aKeys.length !== bKeys.length) return false; for (const key of aKeys) { if (!(key in b)) return false; const av = a[key]; const bv = b[key]; if (Array.isArray(av) && Array.isArray(bv)) { if (av.length !== bv.length) return false; for (let i = 0; i < av.length; i++) { if (av[i] !== bv[i]) return false; } continue; } if (av !== bv) return false; } return true; } /** * Runtime guard that enforces the `StaticHints` shape. The return value of * `staticHints` is rendered verbatim into the LLM's prompt — anything that * reaches it lands in model context. Restricting to a flat record of * primitives and primitive arrays structurally rules out the foot-gun of * an author returning row-shaped data. * * Failures throw `Error` with a security-rationale message; they are * intentionally loud — a misshapen `staticHints` is a contract violation, * not a user-experience hiccup. */ function assertStaticHints(value: unknown, entryId: string): StaticHints { if (value === null || typeof value !== "object" || Array.isArray(value)) { throw new Error( `Carte entry "${entryId}".staticHints() must return a plain object (Record). Got ${describeValue(value)}. ` + `staticHints output is rendered verbatim into the LLM's prompt and must contain only authoring-time, deterministic metadata — see the security-model docs.`, ); } for (const [key, v] of Object.entries(value as Record)) { if (!isStaticHintValue(v)) { throw new Error( `Carte entry "${entryId}".staticHints() returned a value at key "${key}" that violates the StaticHints contract. ` + `Allowed: string, number, boolean, null, or an array of those. Got ${describeValue(v)}. ` + `staticHints output reaches the LLM's context — never include row data, sample values, or anything derived from runtime state.`, ); } } return value as StaticHints; } function isStaticHintValue(v: unknown): boolean { // `undefined` is allowed in the type so that try/catch patterns producing // a discriminated-union return (`{ a: number, b: T } | { b: T }`) don't // need to be normalized at the call site. JSON.stringify drops undefined // properties, so they're benign in the prompt output. if (v === null || v === undefined) return true; const t = typeof v; if (t === "string" || t === "number" || t === "boolean") return true; if (Array.isArray(v)) { return v.every((item) => { if (item === null) return true; const ti = typeof item; return ti === "string" || ti === "number" || ti === "boolean"; }); } return false; } function describeValue(v: unknown): string { if (v === null) return "null"; if (Array.isArray(v)) return "an array of non-primitive values"; if (typeof v === "object") return "a nested object"; return typeof v; } interface ResolvedComponent { id: string; description: string | undefined; propsBody: string; } /** * Serialises the carte and UI carte into a system-prompt fragment the LLM * can reason about. Carte entries are filtered by `access(ctx)` *before* * serialisation, so the LLM only sees entries the current context may use. * * The prompt text is the framework's interface to the model — keep it stable. */ export async function generatePrompt( carte: Carte, uiAdapter: UIAdapter, ctx: TCtx, options: GeneratePromptOptions = {}, ): Promise { const format = options.format ?? "markdown"; const allowed = filterCarte(carte, ctx, options.only); const queries: ResolvedQuery[] = await Promise.all( allowed.map(async (entry) => ({ entry, stats: await getStaticHints(entry), })), ); const components: ResolvedComponent[] = uiAdapter.getComponentIds().map((id) => { const props = uiAdapter.getProps(id); return { id, description: uiAdapter.getDescription(id), propsBody: props ? printSchema(props) : "{}", }; }); const includePlanFormat = options.includePlanFormat ?? true; // Only mention filter / sort syntax in the format guidance if at least one // visible entry actually declares them; otherwise the paragraphs are noise // the LLM may pattern-match on. const anyFilters = queries.some((q) => queryHasFilters(q.entry)); const anySorts = queries.some((q) => queryHasSorts(q.entry)); switch (format) { case "markdown": return joinSections( renderMarkdown(queries, components), includePlanFormat ? planFormatMarkdown(anyFilters, anySorts) : undefined, ); case "xml": return joinSections( renderXml(queries, components), includePlanFormat ? planFormatXml(anyFilters, anySorts) : undefined, ); case "plain": return joinSections( renderPlain(queries, components), includePlanFormat ? planFormatPlain(anyFilters, anySorts) : undefined, ); } } function joinSections(...sections: ReadonlyArray): string { return sections.filter((s): s is string => Boolean(s)).join("\n\n"); } function planFormatMarkdown(includeFilters: boolean, includeSorts: boolean): string { const filtersPara = includeFilters ? ` If a query lists a **Filters** block, you may pass \`params.filters: [{ "field": "...", "op": "...", "value": ... }, ...]\` using only the field/operator combinations listed for that query. Multiple filters are ANDed together. The \`isNull\` and \`isNotNull\` operators take no \`value\`.` : ""; const sortsPara = includeSorts ? ` If a query lists a **Sorts** block, you may pass \`params.sorts: [{ "field": "...", "direction": "asc" | "desc" }, ...]\` using only the fields and directions listed for that query. Sorts are applied in the order given.` : ""; return `## Output format Respond with a single JSON code block — no prose before or after — matching this shape exactly: \`\`\`json { "layout": "grid" | "stack", "panels": [ { "query": { "id": "", "params": { ... } }, "component": { "id": "", "props": { ... } } } ] } \`\`\` Use \`id\` (not \`name\` or \`type\`) for both \`query\` and \`component\`. The id must come from the lists above; do not invent ids. For component props that should bind to a column from the query result, use \`{ "$bind": "fieldName" }\` where \`fieldName\` is one of the keys returned by the query. For props that should receive the entire query result as-is (e.g. table rows), use \`{ "$bind": "*" }\`. Pass literal values (strings, numbers, etc.) directly for non-bindable props.${filtersPara}${sortsPara} ## When you can't answer Some questions can't be answered with the queries available. Decline cleanly when: - The user is asking about a topic with no related query in the carte above. - The closest visible query addresses a meaningfully different concept (different entity, different metric, different time horizon) and picking it would silently answer a different question than the one asked. - The user is asking a meta-question ("what can I ask about?", "what's available?"). In any of these cases, respond with a single JSON code block in this shape instead of a plan: \`\`\`json { "message": "Brief explanation of what you can't answer, followed by 2-3 specific example questions you CAN answer based on the queries above." } \`\`\` Prefer declining over silently substituting a tangentially-related query — the user can see what they asked vs what they got, and a wrong answer is more confusing than a clear "I can't answer that with what's available." Do NOT speculate about why a query might be missing (don't say "you may not have access" or "this might exist in another carte"); just describe what you can answer. Do NOT decline because you're unsure how to fill in params — emit your best-guess plan and let validation feedback come back.`; } function planFormatXml(includeFilters: boolean, includeSorts: boolean): string { const filtersPara = includeFilters ? ` If a query lists a block, you may pass params.filters as an array of {field, op, value} objects using only the listed field/operator combinations. Multiple filters are ANDed. The isNull and isNotNull operators take no value.` : ""; const sortsPara = includeSorts ? ` If a query lists a block, you may pass params.sorts as an array of {field, direction} objects (direction is "asc" or "desc") using only the listed fields and directions. Sorts apply in order.` : ""; return ` Respond with a single JSON code block (\`\`\`json ... \`\`\`) — no prose before or after — matching this shape: { "layout": "grid" | "stack", "panels": [ { "query": { "id": "", "params": { ... } }, "component": { "id": "", "props": { ... } } } ] } Use \`id\` (not \`name\` or \`type\`) for both \`query\` and \`component\`. The id must come from the lists above; do not invent ids. For props that bind to a column from the query result, use \`{ "$bind": "fieldName" }\` where \`fieldName\` is a key returned by the query. For props that should receive the entire result (e.g. table rows), use \`{ "$bind": "*" }\`.${filtersPara}${sortsPara} If picking any of the queries above would silently answer a meaningfully different question than the one asked, respond with this shape instead of a plan: { "message": "what you can't answer + 2-3 specific example questions you CAN answer based on the queries above" }. Prefer declining over substituting a tangentially-related query. Use it for genuine misses and meta-questions; do NOT use it when you're merely unsure how to fill in params (emit a plan and let validation feedback). Do NOT speculate about why a query might be missing. `; } function planFormatPlain(includeFilters: boolean, includeSorts: boolean): string { const filtersPara = includeFilters ? ` If a query lists a Filters block, you may pass params.filters as an array of {field, op, value} objects using only the listed field/operator combinations. Multiple filters are ANDed. The isNull and isNotNull operators take no value.` : ""; const sortsPara = includeSorts ? ` If a query lists a Sorts block, you may pass params.sorts as an array of {field, direction} objects (direction is "asc" or "desc") using only the listed fields and directions. Sorts apply in order.` : ""; return `OUTPUT FORMAT Respond with a single JSON code block (\`\`\`json ... \`\`\`) — no prose before or after — matching this shape: { "layout": "grid" | "stack", "panels": [ { "query": { "id": "", "params": { ... } }, "component": { "id": "", "props": { ... } } } ] } Use 'id' (not 'name' or 'type') for both query and component. Ids must come from the lists above. For props that bind to a query column, use { "$bind": "fieldName" }. For props that should receive the entire result (e.g. table rows), use { "$bind": "*" }.${filtersPara}${sortsPara} If picking any of the queries above would silently answer a meaningfully different question than the one asked, respond with this shape instead of a plan: { "message": "what you can't answer + 2-3 specific example questions you CAN answer based on the queries above" }. Prefer declining over substituting a tangentially-related query. Use it for genuine misses and meta-questions; do NOT use it when merely unsure how to fill in params. Do NOT speculate about why a query might be missing.`; } function renderMarkdown(queries: ResolvedQuery[], components: ResolvedComponent[]): string { const querySections = queries.map(({ entry, stats }) => { const controls = getParamsControls(entry.params); const hasDirectFilters = queryHasDirectFilters(entry); const hasDirectSorts = queryHasDirectSorts(entry); const lines: string[] = [ `### ${entry.id}`, entry.description, ``, `Params: ${printSchema(getParamsSchema(entry.params))}`, `Returns: ${printSchema(entry.returns)}`, ]; if (controls?.filters && Object.keys(controls.filters).length > 0) { lines.push(``, `**Filters** (optional, combine freely — multiple filters are ANDed):`); for (const [field, def] of Object.entries(controls.filters)) { const ops = def.operators.join(", "); const desc = def.description ? ` — ${def.description}` : ""; lines.push(` - \`${field}\`: ${def.type} — ${ops}${desc}`); } } else if (hasDirectFilters) { lines.push( ``, `**Filters**: This query accepts \`params.filters: [{ "field": "...", "op": "...", "value": ... }, ...]\`. See the params schema above for the exact filter payload this query expects.`, ); } if (controls?.sorts && Object.keys(controls.sorts).length > 0) { lines.push(``, `**Sorts** (optional, applied in order):`); for (const [field, def] of Object.entries(controls.sorts)) { const directions = (def.directions ?? ["asc", "desc"]).join(", "); const desc = def.description ? ` — ${def.description}` : ""; lines.push(` - \`${field}\`: ${directions}${desc}`); } } else if (hasDirectSorts) { lines.push( ``, `**Sorts**: This query accepts \`params.sorts: [{ "field": "...", "direction": "asc" | "desc" }, ...]\`. See the params schema above for the exact sort payload this query expects.`, ); } if (controls?.limit?.max !== undefined) { lines.push(``, `Max limit: ${controls.limit.max}`); } if (entry.exampleQuestions?.length) { lines.push(``, `Example questions:`); for (const q of entry.exampleQuestions) lines.push(` - ${q}`); } if (stats !== undefined) { lines.push(``, `Hints: ${JSON.stringify(stats)}`); } return lines.join("\n"); }); const componentSections = components.map((c) => { const lines: string[] = [`### ${c.id}`]; if (c.description) lines.push(c.description); lines.push(``, `Props: ${c.propsBody}`); return lines.join("\n"); }); return [ `## Available queries`, querySections.length > 0 ? querySections.join("\n\n") : "_(none available for this context)_", ``, `## Available components`, componentSections.length > 0 ? componentSections.join("\n\n") : "_(none registered)_", ].join("\n"); } function renderXml(queries: ResolvedQuery[], components: ResolvedComponent[]): string { const queryEls = queries.map(({ entry, stats }) => { const controls = getParamsControls(entry.params); const hasDirectFilters = queryHasDirectFilters(entry); const hasDirectSorts = queryHasDirectSorts(entry); const parts: string[] = [ ` `, ` ${escapeXml(entry.description)}`, ` ${escapeXml(printSchema(getParamsSchema(entry.params)))}`, ` ${escapeXml(printSchema(entry.returns))}`, ]; if (controls?.filters && Object.keys(controls.filters).length > 0) { parts.push(` `); for (const [field, def] of Object.entries(controls.filters)) { const desc = def.description ? ` description="${escapeXml(def.description)}"` : ""; parts.push( ` `, ); } parts.push(` `); } else if (hasDirectFilters) { parts.push( ` `, ); } if (controls?.sorts && Object.keys(controls.sorts).length > 0) { parts.push(` `); for (const [field, def] of Object.entries(controls.sorts)) { const directions = (def.directions ?? ["asc", "desc"]).join(", "); const desc = def.description ? ` description="${escapeXml(def.description)}"` : ""; parts.push(` `); } parts.push(` `); } else if (hasDirectSorts) { parts.push( ` `, ); } if (controls?.limit?.max !== undefined) { parts.push(` ${controls.limit.max}`); } if (entry.exampleQuestions?.length) { parts.push(` `); for (const q of entry.exampleQuestions) { parts.push(` ${escapeXml(q)}`); } parts.push(` `); } if (stats !== undefined) { parts.push(` ${escapeXml(JSON.stringify(stats))}`); } parts.push(` `); return parts.join("\n"); }); const componentEls = components.map((c) => { const parts: string[] = [` `]; if (c.description) parts.push(` ${escapeXml(c.description)}`); parts.push(` ${escapeXml(c.propsBody)}`); parts.push(` `); return parts.join("\n"); }); return [ ``, ...queryEls, ``, ``, ...componentEls, ``, ].join("\n"); } function renderPlain(queries: ResolvedQuery[], components: ResolvedComponent[]): string { const querySections = queries.map(({ entry, stats }) => { const controls = getParamsControls(entry.params); const hasDirectFilters = queryHasDirectFilters(entry); const hasDirectSorts = queryHasDirectSorts(entry); const lines: string[] = [ `${entry.id}`, ` ${entry.description}`, ` Params: ${printSchema(getParamsSchema(entry.params))}`, ` Returns: ${printSchema(entry.returns)}`, ]; if (controls?.filters && Object.keys(controls.filters).length > 0) { lines.push(` Filters (optional, combine freely — multiple filters are ANDed):`); for (const [field, def] of Object.entries(controls.filters)) { const ops = def.operators.join(", "); const desc = def.description ? ` — ${def.description}` : ""; lines.push(` - ${field}: ${def.type} — ${ops}${desc}`); } } else if (hasDirectFilters) { lines.push( ` Filters: this query accepts params.filters as an array of {field, op, value} objects. See the params schema above for the exact payload constraints.`, ); } if (controls?.sorts && Object.keys(controls.sorts).length > 0) { lines.push(` Sorts (optional, applied in order):`); for (const [field, def] of Object.entries(controls.sorts)) { const directions = (def.directions ?? ["asc", "desc"]).join(", "); const desc = def.description ? ` — ${def.description}` : ""; lines.push(` - ${field}: ${directions}${desc}`); } } else if (hasDirectSorts) { lines.push( ` Sorts: this query accepts params.sorts as an array of {field, direction} objects. See the params schema above for the exact payload constraints.`, ); } if (controls?.limit?.max !== undefined) { lines.push(` Max limit: ${controls.limit.max}`); } if (entry.exampleQuestions?.length) { lines.push(` Example questions:`); for (const q of entry.exampleQuestions) lines.push(` - ${q}`); } if (stats !== undefined) { lines.push(` Hints: ${JSON.stringify(stats)}`); } return lines.join("\n"); }); const componentSections = components.map((c) => { const lines: string[] = [`${c.id}`]; if (c.description) lines.push(` ${c.description}`); lines.push(` Props: ${c.propsBody}`); return lines.join("\n"); }); return [ `QUERIES`, querySections.length > 0 ? querySections.join("\n\n") : " (none available for this context)", ``, `COMPONENTS`, componentSections.length > 0 ? componentSections.join("\n\n") : " (none registered)", ].join("\n"); } function escapeXml(value: string): string { return value .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); } function queryHasFilters(entry: CarteEntry): boolean { const controls = getParamsControls(entry.params); return Boolean( (controls?.filters && Object.keys(controls.filters).length > 0) || queryHasDirectFilters(entry), ); } function queryHasSorts(entry: CarteEntry): boolean { const controls = getParamsControls(entry.params); return Boolean((controls?.sorts && Object.keys(controls.sorts).length > 0) || queryHasDirectSorts(entry)); } function queryHasDirectFilters(entry: CarteEntry): boolean { const controls = getParamsControls(entry.params); if (controls?.filters) return false; return hasTopLevelParamField(entry.params, "filters"); } function queryHasDirectSorts(entry: CarteEntry): boolean { const controls = getParamsControls(entry.params); if (controls?.sorts) return false; return hasTopLevelParamField(entry.params, "sorts"); } function hasTopLevelParamField(params: CarteEntry["params"], key: string): boolean { const shape = getTopLevelObjectShape(getParamsSchema(params)); return Boolean(shape?.[key]); } interface CompactEntry { id: string; summary: string; restricted: boolean; } const COMPACT_DESCRIPTION_LIMIT = 120; const RESTRICTED_TAG = " [restricted]"; /** * Generates a compact one-line-per-entry carte summary for use as a * first-stage LLM call that selects relevant entries before full prompt * generation. The output framing instructs the model to return ONLY a JSON * array of query ids. * * Carte entries are filtered by `access(ctx)` before serialisation, same * as `generatePrompt`. Entries that have an `access` predicate (and pass it * for the current context) are tagged `[restricted]` so the caller knows the * list shape may differ across roles. * * Components aren't part of the compact prompt — the first stage selects * queries; the second-stage `generatePrompt({ only })` handles components. * * @example * // Stage 1: identify relevant entries * const compact = await generateCompactPrompt(carte, ctx); * const ids = JSON.parse(await callLLM(compact + "\n\nUser: " + userMessage)); * * // Stage 2: full prompt for just those entries * const full = await generatePrompt(carte, uiAdapter, ctx, { only: ids }); * const plan = JSON.parse(await callLLM(full + "\n\nUser: " + userMessage)); * * // Validate and execute as normal * const parsed = parsePlan(plan, carte, uiAdapter, ctx); * const results = parsed.ok ? await executePlan(parsed.plan, carte) : []; */ export async function generateCompactPrompt( carte: Carte, ctx: unknown, options: GenerateCompactPromptOptions = {}, ): Promise { const format = options.format ?? "markdown"; const entries: CompactEntry[] = filterCarte(carte, ctx).map((e) => ({ id: e.id, summary: compactSummary(e.description), restricted: e.access !== undefined, })); switch (format) { case "markdown": return renderCompactMarkdown(entries); case "xml": return renderCompactXml(entries); case "plain": return renderCompactPlain(entries); } } function filterCarte( carte: Carte, ctx: unknown, only?: ReadonlyArray, ): Array> { const onlySet = only ? new Set(only) : null; return Object.values(carte).filter( (e) => (!onlySet || onlySet.has(e.id)) && (!e.access || e.access(ctx)), ); } function compactSummary(description: string): string { const sentence = firstSentence(description.trim()); if (sentence.length <= COMPACT_DESCRIPTION_LIMIT) return sentence; const cut = sentence.slice(0, COMPACT_DESCRIPTION_LIMIT); const lastSpace = cut.lastIndexOf(" "); const trimmed = lastSpace > 0 ? cut.slice(0, lastSpace) : cut; return `${trimmed.replace(/[.,;:]+$/, "")}…`; } function firstSentence(text: string): string { const match = text.match(/^[\s\S]*?[.!?](?=\s|$)/); return match ? match[0].trim() : text; } const COMPACT_INSTRUCTIONS = 'You are a data assistant. The user will ask a question about their data. Your job is to identify which of the following queries are relevant to that question. Return ONLY a JSON array of query ids (e.g. ["a", "b"]) — no prose, no code fences. If none are relevant, return [].'; function renderCompactMarkdown(entries: ReadonlyArray): string { const lines = entries.map((e) => { const tag = e.restricted ? RESTRICTED_TAG : ""; return `- \`${e.id}\`: ${e.summary}${tag}`; }); return [ COMPACT_INSTRUCTIONS, "", "## Available queries", "", lines.length > 0 ? lines.join("\n") : "_(none available for this context)_", ].join("\n"); } function renderCompactXml(entries: ReadonlyArray): string { const queryEls = entries.map((e) => { const restrictedAttr = e.restricted ? ` restricted="true"` : ""; return ` ${escapeXml(e.summary)}`; }); return [ ``, COMPACT_INSTRUCTIONS, ``, ``, ...(queryEls.length > 0 ? queryEls : [" "]), ``, ].join("\n"); } function renderCompactPlain(entries: ReadonlyArray): string { const lines = entries.map((e) => { const tag = e.restricted ? RESTRICTED_TAG : ""; return `- ${e.id}: ${e.summary}${tag}`; }); return [ COMPACT_INSTRUCTIONS, "", "AVAILABLE QUERIES", "", lines.length > 0 ? lines.join("\n") : " (none available for this context)", ].join("\n"); }