diff --git a/README.md b/README.md index 686ece22..e235bab1 100644 --- a/README.md +++ b/README.md @@ -34,9 +34,14 @@ business knowledge it builds and maintains for you. > No extra usage billing from **ktx**.

- ktx ingestion flow from source systems through validation to wiki and semantic-layer outputs + Ingestion: ktx ingests databases, BI tools, modeling code, and docs through its context engine (source connectors, context builder, reconciliation, validation) into wiki Markdown and semantic-layer YAML

+

+ Serving: an agent queries ktx through MCP, which searches the wiki and semantic layer, returns approved metrics, and compiles them into read-only SQL run against the warehouse +

+ + ## Why ktx General-purpose agents struggle on data tasks. They re-explore your warehouse diff --git a/docs-site/app/diagram-studio/page.tsx b/docs-site/app/diagram-studio/page.tsx new file mode 100644 index 00000000..205ebd7a --- /dev/null +++ b/docs-site/app/diagram-studio/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from "next"; + +import { DiagramStudio } from "@/components/diagram-studio/studio"; + +export const metadata: Metadata = { + title: "Diagram studio", + robots: { index: false, follow: false }, +}; + +export default function DiagramStudioPage() { + return ; +} diff --git a/docs-site/components/diagram-studio/flows.ts b/docs-site/components/diagram-studio/flows.ts new file mode 100644 index 00000000..cddf75cb --- /dev/null +++ b/docs-site/components/diagram-studio/flows.ts @@ -0,0 +1,328 @@ +import { type Edge, MarkerType, type Node } from "@xyflow/react"; + +import { C } from "./nodes"; + +const EDGE_COLOR = "#b3bcc4"; +const MARKER_COLOR = "#9aa6ad"; + +const labelStyle = { + fontFamily: "var(--font-inter), system-ui, sans-serif", + fontSize: 15, + fontWeight: 600, + fill: C.inkMuted, +}; +const labelBgStyle = { fill: "#ffffff", stroke: C.chipBorder, strokeWidth: 1 }; +const labelBg = { + labelBgPadding: [8, 4] as [number, number], + labelBgBorderRadius: 6, + labelStyle, + labelBgStyle, +}; + +const marker = { type: MarkerType.ArrowClosed, color: MARKER_COLOR, width: 16, height: 16 }; +const edgeStyle = { stroke: EDGE_COLOR, strokeWidth: 2 }; + +/* ============================== INGESTION =============================== */ + +const SRC_W = 300; +const SRC_H = 138; +const SRC_GAP = 24; +const srcY = (i: number) => i * (SRC_H + SRC_GAP); + +export const ingestionNodes: Node[] = [ + { + id: "title", + type: "title", + position: { x: 0, y: -96 }, + data: { + width: 560, + eyebrow: "1 · Ingestion", + title: "ktx builds your context layer", + }, + }, + { + id: "db", + type: "card", + position: { x: 0, y: srcY(0) }, + data: { + width: SRC_W, + height: SRC_H, + accent: C.teal, + rows: [ + { kind: "title", text: "Databases" }, + { kind: "desc", text: "Schemas, keys, query history" }, + { kind: "muted", text: "Postgres · Snowflake · BigQuery · …" }, + ], + handles: [{ side: "right", type: "source", id: "out" }], + }, + }, + { + id: "bi", + type: "card", + position: { x: 0, y: srcY(1) }, + data: { + width: SRC_W, + height: SRC_H, + accent: C.orange, + rows: [ + { kind: "title", text: "BI tools" }, + { kind: "desc", text: "Dashboards, explores, usage" }, + { kind: "muted", text: "Metabase · Looker · …" }, + ], + handles: [{ side: "right", type: "source", id: "out" }], + }, + }, + { + id: "model", + type: "card", + position: { x: 0, y: srcY(2) }, + data: { + width: SRC_W, + height: SRC_H, + accent: C.amber, + rows: [ + { kind: "title", text: "Modeling code" }, + { kind: "desc", text: "Metrics, models, joins, entities" }, + { kind: "muted", text: "dbt · LookML · MetricFlow · …" }, + ], + handles: [{ side: "right", type: "source", id: "out" }], + }, + }, + { + id: "docs", + type: "card", + position: { x: 0, y: srcY(3) }, + data: { + width: SRC_W, + height: SRC_H, + accent: C.emerald, + rows: [ + { kind: "title", text: "Docs & notes" }, + { kind: "desc", text: "Policies, definitions, notes" }, + { kind: "muted", text: "Notion · any text · …" }, + ], + handles: [{ side: "right", type: "source", id: "out" }], + }, + }, + { + id: "engine", + type: "engine", + position: { x: 420, y: 52 }, + data: { + width: 380, + height: 520, + steps: [ + { n: 1, title: "Source connectors", desc: "Read each source in its shape" }, + { n: 2, title: "Context builder", desc: "Evidence into proposed updates" }, + { n: 3, title: "Reconciliation", desc: "Merge with existing context" }, + { n: 4, title: "Validation", desc: "Check references & semantics" }, + ], + handles: [ + { side: "left", type: "target", id: "in" }, + { side: "right", type: "source", id: "out" }, + ], + }, + }, + { + id: "wiki", + type: "card", + position: { x: 900, y: 66 }, + data: { + width: 320, + height: 220, + accent: C.emerald, + rows: [ + { kind: "mono", text: "wiki/*.md", color: C.emerald }, + { kind: "title", text: "Wiki" }, + { kind: "chips", items: ["free-form", "auto-maintained"] }, + { kind: "desc", text: "Definitions, caveats, policies," }, + { kind: "desc", text: "and notes agents can search." }, + ], + handles: [{ side: "left", type: "target", id: "in" }], + }, + }, + { + id: "sl", + type: "card", + position: { x: 900, y: 338 }, + data: { + width: 320, + height: 220, + accent: C.teal, + rows: [ + { kind: "mono", text: "semantic-layer/*.yaml", color: C.teal }, + { kind: "title", text: "Semantic layer" }, + { kind: "chips", items: ["executable", "auto-maintained"] }, + { kind: "desc", text: "Metrics, joins, dimensions, and" }, + { kind: "desc", text: "filters ktx compiles into SQL." }, + ], + handles: [{ side: "left", type: "target", id: "in" }], + }, + }, +]; + +const ingestEdge = (source: string, target: string): Edge => ({ + id: `${source}-${target}`, + source, + target, + sourceHandle: "out", + targetHandle: "in", + type: "default", + style: edgeStyle, + markerEnd: marker, +}); + +export const ingestionEdges: Edge[] = [ + ingestEdge("db", "engine"), + ingestEdge("bi", "engine"), + ingestEdge("model", "engine"), + ingestEdge("docs", "engine"), + ingestEdge("engine", "wiki"), + ingestEdge("engine", "sl"), +]; + +/* =============================== RUNTIME ================================ */ + +export const runtimeNodes: Node[] = [ + { + id: "title", + type: "title", + position: { x: 0, y: -84 }, + data: { + width: 560, + eyebrow: "2 · Serving", + title: "agents query it through MCP", + }, + }, + { + id: "agent", + type: "card", + position: { x: 0, y: 115 }, + data: { + width: 280, + height: 190, + accent: C.neutral, + align: "center", + rows: [ + { kind: "title", text: "Your agent" }, + { kind: "muted", text: "Claude Code · Cursor" }, + { kind: "muted", text: "Codex · OpenCode" }, + ], + handles: [ + { side: "right", type: "source", id: "ask", top: "42%" }, + { side: "right", type: "target", id: "answer", top: "62%" }, + ], + }, + }, + { + id: "hub", + type: "hub", + position: { x: 420, y: 85 }, + data: { + width: 360, + height: 250, + rows: [ + "Search wiki + semantic layer", + "Return approved metrics", + "Compile metrics → SQL", + ], + handles: [ + { side: "left", type: "target", id: "ask", top: "42%" }, + { side: "left", type: "source", id: "answer", top: "62%" }, + { side: "right", type: "source", id: "to-context", top: "30%" }, + { side: "right", type: "source", id: "to-warehouse", top: "72%" }, + ], + }, + }, + { + id: "context", + type: "card", + position: { x: 920, y: 15 }, + data: { + width: 300, + height: 150, + accent: C.teal, + rows: [ + { kind: "title", text: "Context layer" }, + { kind: "mono", text: "wiki/*.md", color: C.emerald }, + { kind: "mono", text: "semantic-layer/*.yaml", color: C.teal }, + ], + handles: [{ side: "left", type: "target", id: "in" }], + }, + }, + { + id: "warehouse", + type: "card", + position: { x: 920, y: 255 }, + data: { + width: 300, + height: 150, + accent: C.slate, + rows: [ + { kind: "title", text: "Warehouse" }, + { + kind: "badge", + text: "read-only", + bg: "#ecf6f8", + border: "#bfe3ea", + color: C.teal, + }, + { kind: "desc", text: "Runs the compiled SQL" }, + ], + handles: [{ side: "left", type: "target", id: "in" }], + }, + }, +]; + +export const runtimeEdges: Edge[] = [ + { + id: "ask", + source: "agent", + sourceHandle: "ask", + target: "hub", + targetHandle: "ask", + type: "default", + label: "ask", + ...labelBg, + style: edgeStyle, + markerEnd: marker, + }, + { + id: "answer", + source: "hub", + sourceHandle: "answer", + target: "agent", + targetHandle: "answer", + type: "default", + label: "answer", + ...labelBg, + style: edgeStyle, + markerEnd: marker, + }, + { + id: "search", + source: "hub", + sourceHandle: "to-context", + target: "context", + targetHandle: "in", + type: "default", + label: "search", + ...labelBg, + style: edgeStyle, + markerStart: marker, + markerEnd: marker, + }, + { + id: "readonly", + source: "hub", + sourceHandle: "to-warehouse", + target: "warehouse", + targetHandle: "in", + type: "default", + label: "read-only", + ...labelBg, + style: edgeStyle, + markerStart: marker, + markerEnd: marker, + }, +]; diff --git a/docs-site/components/diagram-studio/mascot.tsx b/docs-site/components/diagram-studio/mascot.tsx new file mode 100644 index 00000000..467f6ee5 --- /dev/null +++ b/docs-site/components/diagram-studio/mascot.tsx @@ -0,0 +1,57 @@ +/** + * Inlined ktx mascot, ported from assets/ktx-mascot.svg. + * + * - `light` renders the dark-bodied mascot for light surfaces. + * - `dark` renders the cream-bodied mascot for dark surfaces (e.g. the ktx + * hub panel), mirroring brand/ktx-mascot-dark.svg. + */ +export function KtxMascot({ + variant = "light", + size = 56, +}: { + variant?: "light" | "dark"; + size?: number; +}) { + const body = variant === "dark" ? "#F5F1EA" : "#1B3139"; + const eye = variant === "dark" ? "#1B3139" : "#F5F1EA"; + return ( + + + + + + + + + + + + ); +} diff --git a/docs-site/components/diagram-studio/nodes.tsx b/docs-site/components/diagram-studio/nodes.tsx new file mode 100644 index 00000000..f648a905 --- /dev/null +++ b/docs-site/components/diagram-studio/nodes.tsx @@ -0,0 +1,493 @@ +"use client"; + +import { Handle, Position, type Node, type NodeProps } from "@xyflow/react"; + +import { KtxMascot } from "./mascot"; + +/** Fixed palette mirrored from the approved SVG diagrams so the exported PNG + * is theme-independent (one image that reads on light and dark GitHub). */ +export const C = { + ink: "#1b1b18", + inkSoft: "#57534e", + inkMuted: "#8c857f", + cardBorder: "#e2dfd9", + engineBg: "#15323a", + engineBorder: "#23474f", + cyan: "#55dced", + stepNum: "#06262c", + stepTitle: "#f3f1ec", + stepDesc: "#9fb6bc", + hubRow: "#eef4f5", + chipBg: "#faf9f6", + chipBorder: "#e7e5e4", + teal: "#0e7490", + emerald: "#059669", + orange: "#f97316", + amber: "#d97706", + slate: "#334155", + neutral: "#94a3b8", +} as const; + +const DISPLAY = "var(--font-display), system-ui, sans-serif"; +const BODY = "var(--font-inter), system-ui, sans-serif"; +const MONO = "var(--font-mono), ui-monospace, monospace"; + +const CARD_SHADOW = "0 3px 12px rgba(27, 49, 57, 0.10)"; +const ENGINE_SHADOW = "0 6px 22px rgba(2, 12, 15, 0.30)"; + +/** ktx logo mascot size, shared by the engine and hub headers. */ +const LOGO_SIZE = 56; + +type HandleSpec = { + side: "left" | "right"; + type: "source" | "target"; + id: string; + top?: string; +}; + +function Handles({ specs }: { specs?: HandleSpec[] }) { + if (!specs) return null; + return ( + <> + {specs.map((h) => ( + + ))} + + ); +} + +/* ------------------------------- Card node ------------------------------- */ + +type CardRow = + | { kind: "title"; text: string } + | { kind: "mono"; text: string; color: string } + | { kind: "desc"; text: string } + | { kind: "muted"; text: string } + | { kind: "chips"; items: string[] } + | { kind: "badge"; text: string; bg: string; border: string; color: string }; + +type CardData = { + width: number; + height: number; + accent: string; + align?: "center"; + rows: CardRow[]; + handles?: HandleSpec[]; +}; + +function gapFor(kind: CardRow["kind"], prev?: CardRow["kind"]): number { + if (!prev) return 0; + if (kind === "desc" && prev === "desc") return 3; + if (kind === "mono" && prev === "mono") return 2; + if (kind === "title") return 6; + return 10; +} + +function CardRowView({ row }: { row: CardRow }) { + switch (row.kind) { + case "title": + return ( + + {row.text} + + ); + case "mono": + return ( + + {row.text} + + ); + case "desc": + return ( + + {row.text} + + ); + case "muted": + return ( + + {row.text} + + ); + case "chips": + return ( +
+ {row.items.map((c) => ( + + {c} + + ))} +
+ ); + case "badge": + return ( + + {row.text} + + ); + } +} + +function CardNode({ data }: NodeProps>) { + const center = data.align === "center"; + return ( +
+ + + {data.rows.map((row, i) => ( +
+ +
+ ))} +
+ ); +} + +/* ------------------------------ Engine node ------------------------------ */ + +type EngineStep = { n: number; title: string; desc: string }; + +type EngineData = { + width: number; + height: number; + steps: EngineStep[]; + handles?: HandleSpec[]; +}; + +function EngineNode({ data }: NodeProps>) { + return ( +
+ + +
+ + + ktx + +
+
+ {data.steps.map((s) => ( +
+ + {s.n} + +
+ + {s.title} + + + {s.desc} + +
+
+ ))} +
+
+ ); +} + +/* -------------------------------- Hub node ------------------------------- */ + +type HubData = { + width: number; + height: number; + rows: string[]; + handles?: HandleSpec[]; +}; + +function HubNode({ data }: NodeProps>) { + return ( +
+ + +
+ + + ktx + +
+
+ {data.rows.map((r) => ( +
+ + + {r} + +
+ ))} +
+
+ ); +} + +/* ------------------------------- Title node ------------------------------ */ + +type TitleData = { width: number; eyebrow: string; title: string }; + +function TitleNode({ data }: NodeProps>) { + return ( +
+ + {data.eyebrow} + + + {data.title} + +
+ ); +} + +export const nodeTypes = { + card: CardNode, + engine: EngineNode, + hub: HubNode, + title: TitleNode, +}; diff --git a/docs-site/components/diagram-studio/studio.tsx b/docs-site/components/diagram-studio/studio.tsx new file mode 100644 index 00000000..7b96ae7b --- /dev/null +++ b/docs-site/components/diagram-studio/studio.tsx @@ -0,0 +1,242 @@ +"use client"; + +import "@xyflow/react/dist/style.css"; + +import { useCallback, useRef, useState } from "react"; +import { + Background, + BackgroundVariant, + type Edge, + getNodesBounds, + type Node, + ReactFlow, + ReactFlowProvider, + useEdgesState, + useNodesState, + useReactFlow, +} from "@xyflow/react"; +import { toPng } from "html-to-image"; + +import { + ingestionEdges, + ingestionNodes, + runtimeEdges, + runtimeNodes, +} from "./flows"; +import { nodeTypes } from "./nodes"; + +const EXPORT_PADDING = 48; +const EXPORT_PIXEL_RATIO = 2; + +function DiagramCanvasInner({ + initialNodes, + initialEdges, + fileName, + height, + dark, +}: { + initialNodes: Node[]; + initialEdges: Edge[]; + fileName: string; + height: number; + dark: boolean; +}) { + const wrapperRef = useRef(null); + const [nodes, , onNodesChange] = useNodesState(initialNodes); + const [edges, , onEdgesChange] = useEdgesState(initialEdges); + const { getNodes } = useReactFlow(); + const [busy, setBusy] = useState(false); + + const download = useCallback(async () => { + const viewport = wrapperRef.current?.querySelector( + ".react-flow__viewport", + ); + if (!viewport) return; + setBusy(true); + try { + await document.fonts.ready; + const bounds = getNodesBounds(getNodes()); + const outW = Math.ceil(bounds.width + EXPORT_PADDING * 2); + const outH = Math.ceil(bounds.height + EXPORT_PADDING * 2); + const tx = EXPORT_PADDING - bounds.x; + const ty = EXPORT_PADDING - bounds.y; + const dataUrl = await toPng(viewport, { + width: outW, + height: outH, + pixelRatio: EXPORT_PIXEL_RATIO, + // transparent background so one PNG works on light and dark GitHub + style: { + width: `${outW}px`, + height: `${outH}px`, + transform: `translate(${tx}px, ${ty}px) scale(1)`, + }, + }); + const link = document.createElement("a"); + link.download = fileName; + link.href = dataUrl; + link.click(); + } finally { + setBusy(false); + } + }, [fileName, getNodes]); + + return ( +
+
+ +
+
+ + + +
+
+ ); +} + +function btnStyle(disabled: boolean): React.CSSProperties { + return { + fontFamily: "var(--font-inter), system-ui, sans-serif", + fontSize: 13, + fontWeight: 600, + padding: "7px 14px", + borderRadius: 8, + border: "1px solid #0e7490", + background: disabled ? "#9bbdc6" : "#0e7490", + color: "#ffffff", + cursor: disabled ? "default" : "pointer", + }; +} + +function DiagramCanvas(props: { + initialNodes: Node[]; + initialEdges: Edge[]; + fileName: string; + height: number; + dark: boolean; +}) { + return ( + + + + ); +} + +export function DiagramStudio() { + const [dark, setDark] = useState(false); + return ( +
+
+

+ ktx diagram studio +

+

+ Static diagrams. Export is a transparent 2× PNG framed to the node + bounds — the dark-background toggle is only for previewing. +

+ +
+ +
+

1 · Ingestion — building the context layer

+ +
+ +
+

2 · Serving — answering agents at runtime

+ +
+
+ ); +} + +const sectionTitle: React.CSSProperties = { + fontFamily: "var(--font-display), system-ui, sans-serif", + fontSize: 18, + fontWeight: 600, + color: "#1b1b18", + marginBottom: 12, +}; diff --git a/docs-site/package.json b/docs-site/package.json index 2af1c19d..f418c0ee 100644 --- a/docs-site/package.json +++ b/docs-site/package.json @@ -14,6 +14,7 @@ "fumadocs-core": "16.8.10", "fumadocs-mdx": "15.0.7", "fumadocs-ui": "16.8.10", + "html-to-image": "1.11.11", "next": "^16", "react": "19.2.6", "react-dom": "19.2.6" diff --git a/docs-site/public/images/ingestion-flow-transparent.svg b/docs-site/public/images/ingestion-flow-transparent.svg deleted file mode 100644 index 86356d6b..00000000 --- a/docs-site/public/images/ingestion-flow-transparent.svg +++ /dev/null @@ -1,210 +0,0 @@ - - ktx ingestion flow - Source systems flow through source connectors, context builder, reconciliation, and validation to create wiki Markdown and semantic-layer YAML outputs. - - - - - - - - - - - - - - - - - - - - - - - - - Databases - Schemas, columns, keys, - row counts, and query - history. - - - PostgreSQL - - Snowflake - - BigQuery - - SQLite - - - - - - - BI tools - Dashboards, questions, - explores, usage, and trusted - examples. - - - Metabase - - Looker - - - - - - - Modeling code - Existing metrics, dimensions, - models, joins, and entities. - - - dbt - - LookML - - MetricFlow - - - - - - - Docs and notes - Policies, caveats, team - definitions, and analyst - context. - - - Notion - - Any text - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 1 - Source connectors - Read each configured system in - its native shape. - - - - - - 2 - Context builder - Turn source evidence into - proposed context updates. - - - - - - 3 - Reconciliation - Merge new evidence with the - context that already exists. - - - - - - 4 - Validation - Check references and semantics - before agents rely on them. - - - - - - - - wiki/*.md - Wiki - - - free-form - - auto-maintained - - Definitions, caveats, policies, analyst notes, and - business language that agents can search. - - - - - - semantic-layer/*.yaml - Semantic layer - - - structured - - executable - - auto-maintained - - Metrics, joins, tables, dimensions, filters, and - segments that ktx can validate and compile into - SQL. - - - - - references - - - diff --git a/docs-site/public/images/ingestion-flow.png b/docs-site/public/images/ingestion-flow.png index 49bc544f..59f6ad17 100644 Binary files a/docs-site/public/images/ingestion-flow.png and b/docs-site/public/images/ingestion-flow.png differ diff --git a/docs-site/public/images/mcp-runtime-flow.png b/docs-site/public/images/mcp-runtime-flow.png new file mode 100644 index 00000000..ec56dff1 Binary files /dev/null and b/docs-site/public/images/mcp-runtime-flow.png differ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bf496066..15bc75f3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -85,6 +85,9 @@ importers: fumadocs-ui: specifier: 16.8.10 version: 16.8.10(@tailwindcss/oxide@4.3.0)(@types/mdx@2.0.13)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(fumadocs-core@16.8.10(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.15)(lucide-react@1.16.0(react@19.2.6))(next@16.2.6(@opentelemetry/api@1.9.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(zod@4.4.3))(next@16.2.6(@opentelemetry/api@1.9.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(tailwindcss@4.3.0) + html-to-image: + specifier: 1.11.11 + version: 1.11.11 next: specifier: ^16 version: 16.2.6(@opentelemetry/api@1.9.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -3722,6 +3725,9 @@ packages: html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + html-to-image@1.11.11: + resolution: {integrity: sha512-9gux8QhvjRO/erSnDPv28noDZcPZmYE7e1vFsBLKLlRlKDSqNJYebj6Qz1TGd5lsRV+X+xYyjCKjuZdABinWjA==} + html-void-elements@3.0.0: resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} @@ -9623,6 +9629,8 @@ snapshots: html-escaper@2.0.2: {} + html-to-image@1.11.11: {} + html-void-elements@3.0.0: {} http-errors@2.0.1: