diff --git a/.env.example b/.env.example index 593c7577..79f84ae0 100644 --- a/.env.example +++ b/.env.example @@ -44,6 +44,24 @@ EMBEDDING_OUTPUT_TYPE=dense EMBEDDING_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1 EMBEDDING_API_KEY= +# First-party DataLink semantic service. Disabled by default so existing +# DataFoundry deployments do not require Python or uv. Set true to let +# `npm run dev` / `npm run start` launch REST + MCP with the main stack. +DATALINK_ENABLED=false +DATALINK_CONFIG_PATH=services/datalink/datalink_config.json +DATALINK_GRAPH_DB_PATH=storage/datalink/datalink.db +DATALINK_API_HOST=127.0.0.1 +DATALINK_API_PORT=8081 +DATALINK_MCP_HOST=127.0.0.1 +DATALINK_MCP_PORT=8080 +# Optional overrides. Empty values fall back to LLM_* / EMBEDDING_* above. +DATALINK_LLM_MODEL= +DATALINK_LLM_BASE_URL= +DATALINK_LLM_API_KEY= +DATALINK_EMBEDDING_MODEL= +DATALINK_EMBEDDING_BASE_URL= +DATALINK_EMBEDDING_API_KEY= + # Agent execute_command sandbox: Python venv with numpy/pandas/matplotlib/scikit-learn. # Default auto-detects repo `.venv` (also checks npm INIT_CWD). Override with absolute path: # WORKSPACE_PYTHON_VENV=/path/to/DataFoundry/.venv diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9da75ed2..df52c81e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -79,6 +79,12 @@ jobs: - name: Run configuration API regression run: node scripts/smoke-config-api.mjs + - name: Run built-in DataLink lifecycle regression + run: node scripts/smoke-builtin-datalink.mjs + + - name: Run DataLink stack configuration tests + run: npm run test:datalink-stack + - name: Run file and workspace regression run: node scripts/smoke-files.mjs @@ -97,6 +103,59 @@ jobs: - name: Run built-in DTC Growth regression run: node --test scripts/test-builtin-dtc-growth-datasource.mjs + datalink: + name: DataLink Service + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Setup Python + uses: actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Setup uv + uses: astral-sh/setup-uv@v6 + with: + enable-cache: true + + - name: Install DataLink dependencies + run: uv sync --project services/datalink --locked + + - name: Lint DataLink + run: npm run lint:datalink + + - name: Test DataLink + run: npm run test:datalink + + - name: Build DataLink package + run: npm run build:datalink + + - name: Smoke DataLink REST and MCP + shell: bash + run: | + set -euo pipefail + export DATALINK_CONFIG_PATH="$RUNNER_TEMP/datalink_config.json" + export DATALINK_GRAPH_DB_PATH="$RUNNER_TEMP/datalink.db" + uv run --project services/datalink datalink api --host 127.0.0.1 --port 8081 & + rest_pid=$! + uv run --project services/datalink datalink serve --host 127.0.0.1 --port 8080 --transport streamable-http --db "$DATALINK_GRAPH_DB_PATH" & + mcp_pid=$! + trap 'kill "$rest_pid" "$mcp_pid" 2>/dev/null || true' EXIT + for _ in $(seq 1 30); do + curl --fail --silent http://127.0.0.1:8081/healthz && break + sleep 1 + done + curl --fail --silent http://127.0.0.1:8081/healthz + for _ in $(seq 1 30); do + status=$(curl --silent --output /dev/null --write-out '%{http_code}' http://127.0.0.1:8080/mcp || true) + if [ "$status" != "000" ]; then break; fi + sleep 1 + done + test "$status" != "000" + docs: name: Docs runs-on: ubuntu-latest diff --git a/README.md b/README.md index 3cd32dd4..5cafb72e 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ DataFoundry 0.2 turns the first usable workbench into a more complete, stateful - **Branchable, concurrent analysis** — Keep multiple sessions running, queue follow-up prompts, restore completed work, and branch from an earlier question or checkpoint without overwriting the original path. - **Evidence-first follow-ups** — Reference a complete output or a selected table/text region in the next question; resolved evidence is carried into the governed run context with diagnostics. -- **Semantic trace and first-party Data Link** — Inspect checkpoint-backed run structure in a semantic Trace DAG. With our newly open-sourced [Data Link](https://github.com/datagallery-lab/datalink), connect tables and columns to business concepts, entities, joinable paths, and confidence-scored relationships for stronger agent grounding. +- **Semantic trace and built-in DataLink** — Inspect checkpoint-backed run structure in a semantic Trace DAG. The first-party DataLink service now ships in `services/datalink`, connecting tables and columns to business concepts, entities, joinable paths, and confidence-scored relationships for stronger agent grounding. - **Reusable outputs and workspace assets** — Preview and export tables, charts, reports, SQL, and files; upload files into an active session, then promote supported files for reuse across sessions. - **Production-facing Web foundation** — Built-in password authentication, same-origin API proxying, bilingual UI, model connection tests, onboarding, and an auto-provisioned DTC growth analysis case. @@ -96,6 +96,10 @@ LLM_MODEL=qwen-plus # or deepseek-chat, gpt-4o, ... LLM_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1 LLM_API_KEY=replace-with-your-key +# Optional: launch the built-in semantic service with the main stack. +# Requires Python 3.10+ and uv; then run `npm run install:datalink` once. +DATALINK_ENABLED=true + DATAFOUNDRY_AUTH_MODE=password AUTH_SESSION_SECRET=replace-with-at-least-32-random-characters AUTH_PUBLIC_BASE_URL=http://127.0.0.1:3000 # real production: https://your.domain @@ -116,10 +120,11 @@ Build and start: ```bash npm run build npm run build:web -npm run start:api # :8787 — /healthz liveness, /ready readiness -npm run start:web # :3000 — password mode via same-origin BFF +npm run start # Web :3000 + API :8787 (+ DataLink :8080/:8081 when enabled) ``` +For split-process deployment, `start:api` and `start:web` remain available; start DataLink separately with `start:datalink:mcp` and `start:datalink:api` when enabled. + Open `http://127.0.0.1:3000/login`, register or sign in, go to `/data-tasks`, and ask: ```text diff --git a/README_zh.md b/README_zh.md index d56d7e96..fdae8c83 100644 --- a/README_zh.md +++ b/README_zh.md @@ -63,7 +63,7 @@ DataFoundry 0.2 在首个可用版本上,进一步补齐了有状态、可追 - **可分支的并发分析** — 多个会话可同时运行,运行中可排队后续问题;恢复历史后,可从早期问题或 checkpoint 创建新分支,不覆盖原分析路径。 - **证据驱动的追问** — 可将完整产出,或选中的表格区域、文本片段引用到下一个问题;证据解析结果和诊断信息会进入受控 run context。 -- **语义 Trace 与自研 Data Link** — 通过基于 checkpoint 的语义 Trace DAG 复盘执行结构;结合我们新近开源的 [Data Link](https://github.com/datagallery-lab/datalink),把表和字段连接到业务概念、实体、可 JOIN 路径和带置信度的关系,为 Agent 提供更强语义支撑。 +- **语义 Trace 与内置 DataLink** — 通过基于 checkpoint 的语义 Trace DAG 复盘执行结构;自研 DataLink 服务现已随仓库内置于 `services/datalink`,把表和字段连接到业务概念、实体、可 JOIN 路径和带置信度的关系,为 Agent 提供更强语义支撑。 - **可复用的产出与工作区资产** — 统一预览和导出表格、图表、报告、SQL 和文件;文件可先上传到当前会话,再提升为跨会话复用的工作区资产。 - **面向正式部署的 Web 基础** — 内置密码认证、同源 API 代理、中英双语界面、模型连接测试、首次引导,并自动准备 DTC 增长分析案例。 @@ -96,6 +96,10 @@ LLM_MODEL=qwen-plus # 或 deepseek-chat、gpt-4o…… LLM_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1 LLM_API_KEY=replace-with-your-key +# 可选:随主应用启动内置语义服务。 +# 需要 Python 3.10+ 与 uv,并先执行一次 `npm run install:datalink`。 +DATALINK_ENABLED=true + DATAFOUNDRY_AUTH_MODE=password AUTH_SESSION_SECRET=replace-with-at-least-32-random-characters AUTH_PUBLIC_BASE_URL=http://127.0.0.1:3000 # 真实生产改为 https://你的域名 @@ -116,10 +120,11 @@ API_PROXY_TARGET=http://127.0.0.1:8787 ```bash npm run build npm run build:web -npm run start:api # :8787 — /healthz 存活,/ready 就绪 -npm run start:web # :3000 — password 模式走同源 BFF +npm run start # Web :3000 + API :8787(启用后含 DataLink :8080/:8081) ``` +需要拆分进程部署时,仍可使用 `start:api` 与 `start:web`;开启 DataLink 后再分别运行 `start:datalink:mcp` 和 `start:datalink:api`。 + 打开 `http://127.0.0.1:3000/login` 注册登录后进入 `/data-tasks`,提问: ```text diff --git a/apps/api/src/builtin-datalink-server.ts b/apps/api/src/builtin-datalink-server.ts new file mode 100644 index 00000000..f016f79f --- /dev/null +++ b/apps/api/src/builtin-datalink-server.ts @@ -0,0 +1,80 @@ +import type { MetadataStore } from "@datafoundry/metadata"; + +export const BUILTIN_DATALINK_SERVER_ID = "builtin-datalink"; + +const DATALINK_TOOL_MANIFEST = [{ name: "datalink_explore" }] as const; + +export const isBuiltinDatalinkEnabled = (env: NodeJS.ProcessEnv = process.env): boolean => + ["1", "true", "yes", "on"].includes((env.DATALINK_ENABLED ?? "").trim().toLowerCase()); + +export const ensureBuiltinDatalinkServer = (input: { + env?: NodeJS.ProcessEnv; + metadataStore: MetadataStore; + userId: string; + workspaceId: string; +}): "created" | "removed" | "skipped" | "updated" => { + const env = input.env ?? process.env; + const key = { + id: BUILTIN_DATALINK_SERVER_ID, + workspace_id: input.workspaceId, + user_id: input.userId, + kind: "mcp-server" as const, + }; + const current = input.metadataStore.configResources.find(key); + if (!isBuiltinDatalinkEnabled(env)) { + if (current?.builtin) { + input.metadataStore.configResources.delete(key); + return "removed"; + } + return "skipped"; + } + + // Never overwrite a user-owned resource that happens to use the reserved id. + if (current && !current.builtin) return "skipped"; + + const payload = builtinDatalinkPayload(env); + if ( + current + && current.name === "DataLink" + && current.description === "First-party DataFoundry semantic graph service." + && JSON.stringify(current.payload) === JSON.stringify(payload) + ) { + return "skipped"; + } + + input.metadataStore.configResources.upsert({ + ...key, + name: "DataLink", + description: "First-party DataFoundry semantic graph service.", + payload, + default_enabled: current?.default_enabled ?? true, + builtin: true, + status: "untested", + ...(current ? { expected_revision: current.revision } : {}), + }); + return current ? "updated" : "created"; +}; + +const builtinDatalinkPayload = (env: NodeJS.ProcessEnv): Record => { + const apiHost = connectHost(env.DATALINK_API_HOST); + const mcpHost = connectHost(env.DATALINK_MCP_HOST); + const apiPort = port(env.DATALINK_API_PORT, 8081); + const mcpPort = port(env.DATALINK_MCP_PORT, 8080); + return { + apiUrl: `http://${apiHost}:${apiPort}`, + managed: true, + serverUrl: `http://${mcpHost}:${mcpPort}/mcp`, + toolManifest: DATALINK_TOOL_MANIFEST, + transport: "streamable-http", + }; +}; + +const connectHost = (value: string | undefined): string => { + const host = value?.trim() || "127.0.0.1"; + return host === "0.0.0.0" || host === "::" ? "127.0.0.1" : host; +}; + +const port = (value: string | undefined, fallback: number): number => { + const parsed = Number(value); + return Number.isInteger(parsed) && parsed >= 1 && parsed <= 65535 ? parsed : fallback; +}; diff --git a/apps/api/src/config-api.ts b/apps/api/src/config-api.ts index dc3beeeb..191b5bee 100644 --- a/apps/api/src/config-api.ts +++ b/apps/api/src/config-api.ts @@ -55,6 +55,7 @@ import { basename, join, resolve, sep } from "node:path"; import writeXlsxFile, { type SheetData } from "write-excel-file/node"; import { resolveEffectiveRunConfig } from "./run-input.js"; +import { BUILTIN_DATALINK_SERVER_ID } from "./builtin-datalink-server.js"; import { llmEnvFingerprint, modelProfileConnectivityPayloadChanged, @@ -796,7 +797,7 @@ const handleDatalinkRequest = async ( const target = segments[0]; const action = segments[1]; if (target === "servers" && request.method === "GET") { - return ok({ servers: listDatalinkServers(context) }); + return ok({ servers: await listDatalinkServers(context) }); } if (!target) { return methodNotAllowed(); @@ -867,12 +868,20 @@ type DatalinkApiCallResult = { text: string; }; -const listDatalinkServers = (context: Required): Record[] => - context.metadataStore.configResources.list({ +const listDatalinkServers = async ( + context: Required +): Promise[]> => { + const resources = context.metadataStore.configResources.list({ workspace_id: context.workspaceId, user_id: context.userId, kind: "mcp-server" - }).filter(isDatalinkServer).map(datalinkServerDto); + }).filter(isDatalinkServer).sort((left, right) => + Number(right.id === BUILTIN_DATALINK_SERVER_ID) - Number(left.id === BUILTIN_DATALINK_SERVER_ID) + ); + return Promise.all(resources.map(async (resource) => + datalinkServerDto(resource, await builtinDatalinkHealthStatus(resource)) + )); +}; const getDatalinkServer = ( context: Required, @@ -903,13 +912,17 @@ const isDatalinkServer = (resource: ConfigResourceRecord): boolean => { const isDatalinkManifestToolName = (toolName: string): boolean => toolName.startsWith("datalink_") || toolName.startsWith("datagraph_"); -const datalinkServerDto = (resource: ConfigResourceRecord): Record => { +const datalinkServerDto = ( + resource: ConfigResourceRecord, + healthStatus = resource.status +): Record => { const tools = datalinkToolManifest(resource); return { id: resource.id, name: datalinkServerDisplayName(resource), description: resource.description ?? "", - healthStatus: resource.status, + healthStatus, + managed: resource.builtin && resource.payload.managed === true, serverUrl: stringValue(resource.payload.serverUrl) ?? stringValue(resource.payload.url) ?? "", apiUrl: stringValue(resource.payload.apiUrl) ?? "", transport: stringValue(resource.payload.transport) ?? "streamable-http", @@ -919,6 +932,21 @@ const datalinkServerDto = (resource: ConfigResourceRecord): Record => { + if (resource.id !== BUILTIN_DATALINK_SERVER_ID || resource.payload.managed !== true) { + return resource.status; + } + try { + const response = await fetch(datalinkApiEndpoint(resource, "/healthz"), { + headers: { Accept: "application/json" }, + signal: AbortSignal.timeout(1_000) + }); + return response.ok ? "connected" : "unavailable"; + } catch { + return "unavailable"; + } +}; + const datalinkServerDisplayName = (resource: ConfigResourceRecord): string => { if (resource.name.toLowerCase() === "datagraph") { return "datalink"; @@ -1636,6 +1664,9 @@ const handleGenericResourceRequest = async ( user_id: context.userId, kind }); + if (current.builtin && current.id === BUILTIN_DATALINK_SERVER_ID) { + throw new Error(`BUILTIN_RESOURCE_READONLY:${id}`); + } if (kind === "knowledge-base") { context.metadataStore.db.prepare( "DELETE FROM knowledge_embeddings WHERE user_id = ? AND collection_id = ?" @@ -1687,6 +1718,13 @@ const saveConfigResourceInTransaction = ( user_id: context.userId, kind }); + if (current?.builtin && current.id === BUILTIN_DATALINK_SERVER_ID) { + const mutableKeys = new Set(["defaultEnabled", "revision"]); + const readonlyKeys = Object.keys(body).filter((key) => !mutableKeys.has(key)); + if (readonlyKeys.length > 0) { + throw new Error(`BUILTIN_RESOURCE_READONLY:${id}`); + } + } if (current?.builtin && kind === "skill") { const mutableKeys = new Set(["defaultEnabled", "revision"]); const readonlyKeys = Object.keys(body).filter((key) => !mutableKeys.has(key)); diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index 395c74b1..e10ef171 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -40,6 +40,7 @@ import { Observable } from "rxjs"; import { handleConfigApiRequest } from "./config-api.js"; import { createAsyncMemoByKey, createStartupTimer } from "./async-memo.js"; import { ensureBuiltinDtcGrowthDatasource } from "./builtin-dtc-growth-datasource.js"; +import { ensureBuiltinDatalinkServer } from "./builtin-datalink-server.js"; import { reclaimOrphanedQueuedAndRunningRuns } from "./stale-active-runs.js"; import { loadPasswordAuthConfig, type PasswordAuthConfig } from "./auth/config.js"; import { AuthService, type AuthIdentity } from "./auth/service.js"; @@ -1261,6 +1262,11 @@ const ensureBuiltinConfigResources = async ( userId, workspaceId }); + ensureBuiltinDatalinkServer({ + metadataStore, + userId, + workspaceId + }); for (const source of BUILTIN_SKILL_SOURCES) { const content = readFileSync(source.path); diff --git a/docs/en/architecture/overview.md b/docs/en/architecture/overview.md index 07e16913..44969513 100644 --- a/docs/en/architecture/overview.md +++ b/docs/en/architecture/overview.md @@ -109,10 +109,11 @@ The default path is formal mode (`password` + `build` / `start`); do not run `np ```bash npm run build && npm run build:web -npm run start:api -npm run start:web +npm run start ``` +`npm run start` manages Web and API with the same local-process deployment used previously. With `DATALINK_ENABLED=true`, it additionally starts the bundled Python DataLink REST and MCP processes; with the default `false`, the original two-process topology is unchanged. Separate `start:*` commands remain available for external process supervisors. + | Environment | Email | Public URL | | --- | --- | --- | | Formal test | `AUTH_EMAIL_DELIVERY=test` | Local / private | diff --git a/docs/en/guides/datalink.md b/docs/en/guides/datalink.md new file mode 100644 index 00000000..9f0e8ce0 --- /dev/null +++ b/docs/en/guides/datalink.md @@ -0,0 +1,84 @@ +# DataLink semantic service + +DataLink is DataFoundry's first-party semantic graph service. It connects physical schemas and profiles to business concepts, entities, join paths, and confidence-scored relationships. The source lives in `services/datalink` and remains Python-based. + +## Runtime topology + +When enabled, the existing DataFoundry deployment starts four local processes: + +| Process | Default endpoint | Purpose | +| --- | --- | --- | +| Web | `http://127.0.0.1:3000` | Workbench UI | +| DataFoundry API | `http://127.0.0.1:8787` | Agent runtime and management API | +| DataLink MCP | `http://127.0.0.1:8080/mcp` | `datalink_explore` for agent grounding | +| DataLink REST | `http://127.0.0.1:8081` | Graph management and visualization API | + +The API registers `builtin-datalink` once per user and workspace. The managed resource is preferred in the DataLink panel, cannot be deleted through the configuration API, and reports `unavailable` when its REST health probe fails. User-configured external DataLink servers remain supported. + +## Enable the bundled service + +Install Python 3.10+ and [uv](https://docs.astral.sh/uv/), then run: + +```bash +npm run install:datalink +``` + +Set the root `.env`: + +```bash +DATALINK_ENABLED=true +``` + +Use `npm run dev` for contributor hot reload or the formal deployment commands: + +```bash +npm run build +npm run build:web +npm run start +``` + +`Ctrl+C` terminates all child processes. If `DATALINK_ENABLED=false` or is omitted, DataFoundry starts only Web and API and does not check for Python or uv. + +## Configuration + +Default managed paths and endpoints are: + +```bash +DATALINK_CONFIG_PATH=services/datalink/datalink_config.json +DATALINK_GRAPH_DB_PATH=storage/datalink/datalink.db +DATALINK_API_HOST=127.0.0.1 +DATALINK_API_PORT=8081 +DATALINK_MCP_HOST=127.0.0.1 +DATALINK_MCP_PORT=8080 +``` + +DataLink reuses DataFoundry's `LLM_*` and `EMBEDDING_*` settings. Define `DATALINK_LLM_MODEL`, `DATALINK_LLM_BASE_URL`, `DATALINK_LLM_API_KEY`, or their `DATALINK_EMBEDDING_*` equivalents only when the semantic service needs a separate provider. + +API keys do not need to be written to `datalink_config.json`. Keep them in environment variables or your deployment secret manager. The graph database is stored under the ignored `storage/` directory by default; include it in the deployment backup policy. + +## Split processes + +For an existing process supervisor, use the same deployment model with separate commands: + +```bash +npm run start:api +npm run start:web +npm run start:datalink:mcp +npm run start:datalink:api +``` + +All four commands read the root `.env`. Disabling automatic startup does not remove support for external DataLink servers configured through MCP settings. + +## Verification + +```bash +curl http://127.0.0.1:8081/healthz +``` + +Expected response: + +```json +{"status":"ok","service":"datalink"} +``` + +If startup reports that uv is missing, install uv and rerun `npm run install:datalink`. If the workbench shows the managed server as unavailable, verify ports `8080` and `8081`, inspect both DataLink process logs, and confirm the configured graph path is writable. diff --git a/docs/en/guides/web-workbench.md b/docs/en/guides/web-workbench.md index 2703cec3..14bb0778 100644 --- a/docs/en/guides/web-workbench.md +++ b/docs/en/guides/web-workbench.md @@ -8,8 +8,7 @@ Default path is the **formal** stack (`password` + `build` / `start`). Formal te ```bash npm run build && npm run build:web -npm run start:api -npm run start:web +npm run start ``` Open: diff --git a/docs/en/quick-start.md b/docs/en/quick-start.md index f838f361..f67f692d 100644 --- a/docs/en/quick-start.md +++ b/docs/en/quick-start.md @@ -11,7 +11,7 @@ Formal mode has two environments. **Startup commands are the same**; the main di Do **not** run `npm run dev` / `dev:api` / `dev:web` in either formal environment. Contributor hot-reload is in the appendix. -You do not need a business database for the first run. You only need Node.js, npm, and a model API key compatible with the OpenAI `/chat/completions` interface. +You do not need a business database for the first run. You only need Node.js, npm, and a model API key compatible with the OpenAI `/chat/completions` interface. The built-in DataLink semantic service additionally requires Python 3.10+ and [uv](https://docs.astral.sh/uv/), but remains opt-in. ## Requirements @@ -19,6 +19,7 @@ You do not need a business database for the first run. You only need Node.js, np - npm - Linux, macOS, or Windows - A model API key—for example Qwen, DeepSeek, or another OpenAI-compatible service +- Optional for DataLink: Python >= 3.10 and uv On Windows, install and run the project in the same environment. Do not share `node_modules` between Windows and WSL. @@ -62,7 +63,25 @@ LLM_BASE_URL=https://api.deepseek.com LLM_API_KEY=your-api-key ``` -### 2.2 Formal test (recommended for first acceptance) +### 2.2 DataLink semantic service (optional) + +DataLink is included under `services/datalink` but does not start by default. To launch its REST and MCP processes with DataFoundry: + +```bash +npm run install:datalink +``` + +Then set in the root `.env`: + +```bash +DATALINK_ENABLED=true +``` + +It reuses `LLM_*` and `EMBEDDING_*` by default. `DATALINK_LLM_*`, `DATALINK_EMBEDDING_*`, host, port, config-path, and graph-path variables in `.env.example` provide explicit overrides. Keep `false` to run the existing Web/API stack without Python or uv. + +See [DataLink](guides/datalink.md) for process topology and split-process commands. + +### 2.3 Formal test (recommended for first acceptance) Root `.env`: @@ -87,7 +106,7 @@ API_PROXY_TARGET=http://127.0.0.1:8787 On register / password reset, copy the verification link from the **API process console**. -### 2.3 Real production +### 2.4 Real production Start from the formal-test settings, then change to: @@ -111,8 +130,7 @@ Keep the frontend on `password`, empty public API URLs, and `API_PROXY_TARGET`. ```bash npm run build npm run build:web -npm run start:api # :8787 -npm run start:web # :3000 +npm run start # Web :3000 + API :8787; DataLink :8080/:8081 when enabled ``` Checks: @@ -120,8 +138,12 @@ Checks: ```bash curl http://127.0.0.1:8787/healthz # process up curl http://127.0.0.1:8787/ready # Mastra / builtins ready (includes startup_ms) +# When DATALINK_ENABLED=true: +curl http://127.0.0.1:8081/healthz ``` +For a process supervisor or separate hosts, keep using `start:api` and `start:web`, plus `start:datalink:mcp` and `start:datalink:api`. The same `.env` controls all four commands. + Open [http://127.0.0.1:3000/login](http://127.0.0.1:3000/login) (or your public origin in real production), register or sign in, then go to `/data-tasks`. After changing any `NEXT_PUBLIC_*` value in `apps/web/.env.local`, run `npm run build:web` again. @@ -191,7 +213,7 @@ Symptom: Browser cannot open the workbench URL. Fix: -- Confirm `npm run start:web` is still running (do not use `dev` in formal mode). +- Confirm `npm run start` is still running (do not use `dev` in formal mode). - Check whether port 3000 is in use. - If 3000 is taken, use the frontend port shown in terminal output. @@ -209,12 +231,12 @@ curl http://127.0.0.1:8787/ready If the health check fails: ```bash -npm run start:api +npm run start ``` ### No verification email -- **Formal test** (`AUTH_EMAIL_DELIVERY=test`): copy the link from the `start:api` terminal. +- **Formal test** (`AUTH_EMAIL_DELIVERY=test`): copy the link from the `npm run start` terminal. - **Real production** (`smtp`): check `AUTH_SMTP_*` and that `AUTH_PUBLIC_BASE_URL` matches the public origin. ### Model unavailable @@ -262,9 +284,12 @@ npm run dev # or: npm run dev:api && npm run dev:web ``` +With `DATALINK_ENABLED=true`, prefer the combined `npm run dev`; the split form also requires `dev:datalink:mcp` and `dev:datalink:api`. + ## Next steps - Use the Web UI: [Web workbench guide](guides/web-workbench.md) - Use the terminal UI: [TUI guide](guides/tui.md) - Connect your own data: [Data sources guide](guides/data-sources.md) +- Enable semantic grounding: [DataLink guide](guides/datalink.md) - Review capability boundaries: [Capabilities](capabilities.md) diff --git a/docs/en/security.md b/docs/en/security.md index c6fa624d..820af4f7 100644 --- a/docs/en/security.md +++ b/docs/en/security.md @@ -101,7 +101,7 @@ Two formal environments share the same start commands: Password mode adds `/api/v1/auth/*` endpoints for registration, login, email verification, password reset, logout, session listing, and password change. Unsafe requests require `X-CSRF-Token` from the `df_csrf` cookie. The session cookie is `df_session`. -Also set `NEXT_PUBLIC_DATAFOUNDRY_AUTH_MODE=password` and leave `NEXT_PUBLIC_AGENT_RUNTIME_URL` / `NEXT_PUBLIC_CONFIG_API_URL` empty so the browser uses the same-origin Next BFF; point the upstream API with `API_PROXY_TARGET` in `apps/web/.env.local`. Start with `npm run build && npm run build:web && npm run start:api && npm run start:web`. Real-production reverse-proxy sample: `deploy/nginx.datafoundry.conf.example`. +Also set `NEXT_PUBLIC_DATAFOUNDRY_AUTH_MODE=password` and leave `NEXT_PUBLIC_AGENT_RUNTIME_URL` / `NEXT_PUBLIC_CONFIG_API_URL` empty so the browser uses the same-origin Next BFF; point the upstream API with `API_PROXY_TARGET` in `apps/web/.env.local`. Build with `npm run build && npm run build:web`, then run `npm run start`. Real-production reverse-proxy sample: `deploy/nginx.datafoundry.conf.example`. Real production also needs secret management, audit export, access control, and operations monitoring. diff --git a/docs/javascripts/language-switcher.js b/docs/javascripts/language-switcher.js index 80e05c48..a81c844d 100644 --- a/docs/javascripts/language-switcher.js +++ b/docs/javascripts/language-switcher.js @@ -105,6 +105,7 @@ ["Web workbench", "Web 工作台"], ["TUI", "TUI"], ["Data sources", "数据源"], + ["DataLink", "DataLink"], ["Examples", "案例"], ["DTC growth operating review", "DTC 增长经营复盘"], ["Developer Guide", "开发者指南"], diff --git a/docs/zh/architecture/overview.md b/docs/zh/architecture/overview.md index 4c633a70..356310be 100644 --- a/docs/zh/architecture/overview.md +++ b/docs/zh/architecture/overview.md @@ -113,10 +113,11 @@ password 模式在 `/api/v1/auth/*` 下提供基于 Cookie 的会话、非安全 ```bash npm run build && npm run build:web -npm run start:api -npm run start:web +npm run start ``` +`npm run start` 仍以原有本地进程方式管理 Web 与 API。设置 `DATALINK_ENABLED=true` 后,会额外启动仓库内置的 Python DataLink REST 与 MCP 进程;默认 `false` 时保持原来的双进程拓扑。已有进程守护系统仍可使用拆分的 `start:*` 命令。 + | 环境 | 邮箱 | 公网地址 | | --- | --- | --- | | 正式测试 | `AUTH_EMAIL_DELIVERY=test` | 本机 / 内网 | diff --git a/docs/zh/guides/datalink.md b/docs/zh/guides/datalink.md new file mode 100644 index 00000000..e31e0930 --- /dev/null +++ b/docs/zh/guides/datalink.md @@ -0,0 +1,84 @@ +# DataLink 语义服务 + +DataLink 是 DataFoundry 的一方语义图服务,用于将物理 Schema 与数据画像连接到业务概念、实体、可 JOIN 路径和带置信度的关系。源码位于 `services/datalink`,继续使用 Python 实现。 + +## 运行拓扑 + +启用后,现有 DataFoundry 部署会启动四个本地进程: + +| 进程 | 默认地址 | 用途 | +| --- | --- | --- | +| Web | `http://127.0.0.1:3000` | 工作台界面 | +| DataFoundry API | `http://127.0.0.1:8787` | Agent Runtime 与管理 API | +| DataLink MCP | `http://127.0.0.1:8080/mcp` | 向 Agent 提供 `datalink_explore` 语义上下文 | +| DataLink REST | `http://127.0.0.1:8081` | 图谱管理与可视化 API | + +API 会按用户和 workspace 幂等注册 `builtin-datalink`。该托管资源会优先显示在 DataLink 面板中,不能通过配置 API 删除;REST 健康检查失败时显示为 `unavailable`。用户自行配置的外部 DataLink 服务仍可继续使用。 + +## 启用内置服务 + +安装 Python 3.10+ 与 [uv](https://docs.astral.sh/uv/) 后执行: + +```bash +npm run install:datalink +``` + +在根目录 `.env` 中设置: + +```bash +DATALINK_ENABLED=true +``` + +贡献者热更新使用 `npm run dev`;正式部署使用: + +```bash +npm run build +npm run build:web +npm run start +``` + +按 `Ctrl+C` 会统一结束所有子进程。`DATALINK_ENABLED=false` 或未配置时,只启动 Web 与 API,也不会检查 Python 或 uv。 + +## 配置 + +托管服务默认使用以下路径与端口: + +```bash +DATALINK_CONFIG_PATH=services/datalink/datalink_config.json +DATALINK_GRAPH_DB_PATH=storage/datalink/datalink.db +DATALINK_API_HOST=127.0.0.1 +DATALINK_API_PORT=8081 +DATALINK_MCP_HOST=127.0.0.1 +DATALINK_MCP_PORT=8080 +``` + +DataLink 默认复用 DataFoundry 的 `LLM_*` 与 `EMBEDDING_*`。只有需要为语义服务指定独立供应商时,才配置 `DATALINK_LLM_MODEL`、`DATALINK_LLM_BASE_URL`、`DATALINK_LLM_API_KEY` 或对应的 `DATALINK_EMBEDDING_*`。 + +API Key 不需要写入 `datalink_config.json`,应保存在环境变量或部署 Secret 管理系统中。图数据库默认存放在已忽略的 `storage/` 目录,正式部署时需要纳入备份策略。 + +## 拆分进程 + +已有进程守护系统时,仍沿用当前部署方式分别启动: + +```bash +npm run start:api +npm run start:web +npm run start:datalink:mcp +npm run start:datalink:api +``` + +四个命令读取同一份根目录 `.env`。关闭自动启动不会影响通过 MCP 设置连接外部 DataLink 服务。 + +## 验证 + +```bash +curl http://127.0.0.1:8081/healthz +``` + +预期响应: + +```json +{"status":"ok","service":"datalink"} +``` + +如果启动提示缺少 uv,请安装后重新执行 `npm run install:datalink`。如果工作台显示托管服务不可用,请检查 `8080`、`8081` 端口与两个 DataLink 进程日志,并确认图数据库路径可写。 diff --git a/docs/zh/guides/web-workbench.md b/docs/zh/guides/web-workbench.md index a45a3e4b..c9637303 100644 --- a/docs/zh/guides/web-workbench.md +++ b/docs/zh/guides/web-workbench.md @@ -8,8 +8,7 @@ ```bash npm run build && npm run build:web -npm run start:api -npm run start:web +npm run start ``` 打开: diff --git a/docs/zh/quick-start.md b/docs/zh/quick-start.md index 13d6f893..e764f50f 100644 --- a/docs/zh/quick-start.md +++ b/docs/zh/quick-start.md @@ -11,7 +11,7 @@ 两种正式态都**不要跑** `npm run dev` / `dev:api` / `dev:web`。贡献者本地热更新见文末附录。 -首次体验不需要准备业务数据库。你只需要 Node.js、npm 和一个兼容 OpenAI `/chat/completions` 接口的模型 API Key。 +首次体验不需要准备业务数据库。你只需要 Node.js、npm 和一个兼容 OpenAI `/chat/completions` 接口的模型 API Key。内置 DataLink 语义服务还需要 Python 3.10+ 与 [uv](https://docs.astral.sh/uv/),但默认不开启。 ## 环境要求 @@ -19,6 +19,7 @@ - npm - Linux、macOS 或 Windows - 一个模型 API Key,例如通义千问、DeepSeek 或其他 OpenAI-compatible 服务 +- DataLink 可选依赖:Python >= 3.10 与 uv Windows 用户请在同一个系统内安装和运行项目。不要在 Windows 和 WSL 之间共用 `node_modules`。 @@ -62,7 +63,25 @@ LLM_BASE_URL=https://api.deepseek.com LLM_API_KEY=你的_API_Key ``` -### 2.2 正式测试(推荐首次验收) +### 2.2 DataLink 语义服务(可选) + +DataLink 源码已内置在 `services/datalink`,默认不会随主应用启动。需要启用 REST 与 MCP 服务时先执行: + +```bash +npm run install:datalink +``` + +然后在根目录 `.env` 中设置: + +```bash +DATALINK_ENABLED=true +``` + +默认复用 `LLM_*` 与 `EMBEDDING_*`;如需独立配置,可使用 `.env.example` 中的 `DATALINK_LLM_*`、`DATALINK_EMBEDDING_*`、主机、端口、配置路径和图数据库路径。保持 `false` 时,原有 Web/API 部署不依赖 Python 或 uv。 + +进程关系与拆分启动方式见 [DataLink 指南](guides/datalink.md)。 + +### 2.3 正式测试(推荐首次验收) 根目录 `.env`: @@ -87,7 +106,7 @@ API_PROXY_TARGET=http://127.0.0.1:8787 注册/重置密码时,验证链接会打印在 **API 进程控制台**,复制到浏览器即可。 -### 2.3 真实生产 +### 2.4 真实生产 在正式测试配置基础上改为: @@ -111,8 +130,7 @@ AUTH_SMTP_PASSWORD= ```bash npm run build npm run build:web -npm run start:api # :8787 -npm run start:web # :3000 +npm run start # Web :3000 + API :8787;启用时含 DataLink :8080/:8081 ``` 检查: @@ -120,8 +138,12 @@ npm run start:web # :3000 ```bash curl http://127.0.0.1:8787/healthz # 进程存活 curl http://127.0.0.1:8787/ready # Mastra / builtin 就绪(含 startup_ms) +# DATALINK_ENABLED=true 时: +curl http://127.0.0.1:8081/healthz ``` +使用进程守护或拆分主机时,可继续分别运行 `start:api`、`start:web`、`start:datalink:mcp` 与 `start:datalink:api`;四个命令读取同一份 `.env`。 + 打开 [http://127.0.0.1:3000/login](http://127.0.0.1:3000/login)(真实生产则打开你的公网域名)注册或登录后进入 `/data-tasks`。 改过 `apps/web/.env.local` 中的 `NEXT_PUBLIC_*` 后,需要重新执行 `npm run build:web`。 @@ -191,7 +213,7 @@ node -v 处理: -- 确认 `npm run start:web` 还在运行(正式态不要开 `dev`)。 +- 确认 `npm run start` 还在运行(正式态不要开 `dev`)。 - 检查 3000 端口是否被占用。 - 如果 3000 被占用,查看终端输出中的实际前端端口。 @@ -209,12 +231,12 @@ curl http://127.0.0.1:8787/ready 如果健康检查失败: ```bash -npm run start:api +npm run start ``` ### 注册收不到邮件 -- **正式测试**(`AUTH_EMAIL_DELIVERY=test`):到运行 `start:api` 的终端里找验证链接。 +- **正式测试**(`AUTH_EMAIL_DELIVERY=test`):到运行 `npm run start` 的终端里找验证链接。 - **真实生产**(`smtp`):检查 `AUTH_SMTP_*` 与发信账号;确认 `AUTH_PUBLIC_BASE_URL` 与对外域名一致。 ### 模型不可用 @@ -262,9 +284,12 @@ npm run dev # 或:npm run dev:api && npm run dev:web ``` +设置 `DATALINK_ENABLED=true` 后建议使用统一的 `npm run dev`;拆分启动时还需要运行 `dev:datalink:mcp` 与 `dev:datalink:api`。 + ## 下一步 - 使用 Web 界面:[Web 工作台指南](guides/web-workbench.md) - 使用终端界面:[TUI 指南](guides/tui.md) - 连接自己的数据:[数据源指南](guides/data-sources.md) +- 启用语义增强:[DataLink 指南](guides/datalink.md) - 查看能力边界:[能力全览](capabilities.md) diff --git a/docs/zh/security.md b/docs/zh/security.md index d5481c71..1ac7b46a 100644 --- a/docs/zh/security.md +++ b/docs/zh/security.md @@ -101,7 +101,7 @@ AUTH_EMAIL_FROM=DataFoundry 密码模式提供 `/api/v1/auth/*` 接口,用于注册、登录、邮箱验证、密码重置、退出登录、会话列表和修改密码。非安全方法请求需要携带来自 `df_csrf` Cookie 的 `X-CSRF-Token`。会话 Cookie 名为 `df_session`。 -前端请同步设置 `NEXT_PUBLIC_DATAFOUNDRY_AUTH_MODE=password`,并留空 `NEXT_PUBLIC_AGENT_RUNTIME_URL` / `NEXT_PUBLIC_CONFIG_API_URL`,让浏览器走同源 Next BFF;上游 API 用 `API_PROXY_TARGET`(写在 `apps/web/.env.local`)。启动命令:`npm run build && npm run build:web && npm run start:api && npm run start:web`。真实生产反代样例见 `deploy/nginx.datafoundry.conf.example`。 +前端请同步设置 `NEXT_PUBLIC_DATAFOUNDRY_AUTH_MODE=password`,并留空 `NEXT_PUBLIC_AGENT_RUNTIME_URL` / `NEXT_PUBLIC_CONFIG_API_URL`,让浏览器走同源 Next BFF;上游 API 用 `API_PROXY_TARGET`(写在 `apps/web/.env.local`)。先执行 `npm run build && npm run build:web`,再运行 `npm run start`。真实生产反代样例见 `deploy/nginx.datafoundry.conf.example`。 真实生产部署还需要 Secret 管理、审计导出、访问控制和运维监控。 diff --git a/mkdocs.yml b/mkdocs.yml index e4aebf9d..b468d139 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -74,6 +74,7 @@ nav: - Web workbench: en/guides/web-workbench.md - TUI: en/guides/tui.md - Data sources: en/guides/data-sources.md + - DataLink: en/guides/datalink.md - Examples: - DTC growth operating review: en/examples/dtc-growth-demo.md - Developer Guide: diff --git a/package.json b/package.json index feaad081..f5089618 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "docs", "mkdocs.yml", "packages", + "services", "requirements-docs.txt", "requirements.txt", "scripts", @@ -56,11 +57,21 @@ "postinstall": "node scripts/postinstall.mjs", "predev:api": "node scripts/ensure-dev-environment.mjs", "dev": "node scripts/dev.mjs", + "start": "node scripts/start.mjs", "dev:api": "npm --workspace @datafoundry/api run dev", "start:api": "npm --prefix apps/api run start", "dev:web": "npm --workspace @datafoundry/web run dev", "build:web": "npm --workspace @datafoundry/web run build", "start:web": "npm --prefix apps/web run start", + "install:datalink": "uv sync --project services/datalink --locked", + "lint:datalink": "uv run --project services/datalink ruff check services/datalink/src services/datalink/tests", + "test:datalink": "uv run --project services/datalink pytest services/datalink/tests", + "build:datalink": "uv build --project services/datalink", + "dev:datalink:api": "node scripts/start-datalink.mjs api", + "dev:datalink:mcp": "node scripts/start-datalink.mjs mcp", + "start:datalink:api": "node scripts/start-datalink.mjs api", + "start:datalink:mcp": "node scripts/start-datalink.mjs mcp", + "test:datalink-stack": "node --test scripts/datalink-stack-config.test.mjs", "test:web": "npm --workspace @datafoundry/web run test", "dev:tui": "npm --workspace @datafoundry/tui run dev", "start:tui": "npm --workspace @datafoundry/tui run start --", @@ -87,6 +98,7 @@ "smoke:run-config-disabled": "npm run build && node scripts/smoke-run-config-disabled.mjs", "smoke:data-gateway": "npm run build && node scripts/smoke-data-gateway.mjs", "smoke:datalink-semantic": "node scripts/smoke-datalink-semantic.mjs", + "smoke:builtin-datalink": "npm run build && node scripts/smoke-builtin-datalink.mjs", "smoke:server-datasources": "npm run build && node scripts/smoke-server-datasources-e2e.mjs", "smoke:sql": "npm run build && node scripts/smoke-sql-readonly.mjs", "smoke:agent": "npm run build && node scripts/smoke-agent-runtime.mjs", diff --git a/scripts/datalink-stack-config.mjs b/scripts/datalink-stack-config.mjs new file mode 100644 index 00000000..c3c12374 --- /dev/null +++ b/scripts/datalink-stack-config.mjs @@ -0,0 +1,28 @@ +import { resolve } from "node:path"; + +export function datalinkEnabled(env = process.env) { + return ["1", "true", "yes", "on"].includes((env.DATALINK_ENABLED ?? "").trim().toLowerCase()); +} + +export function resolveDatalinkEnv(root, env = process.env) { + return { + ...env, + DATALINK_CONFIG_PATH: + env.DATALINK_CONFIG_PATH?.trim() || resolve(root, "services/datalink/datalink_config.json"), + DATALINK_GRAPH_DB_PATH: + env.DATALINK_GRAPH_DB_PATH?.trim() || resolve(root, "storage/datalink/datalink.db"), + DATALINK_API_HOST: env.DATALINK_API_HOST?.trim() || "127.0.0.1", + DATALINK_API_PORT: String(readPort(env.DATALINK_API_PORT, 8081, "DATALINK_API_PORT")), + DATALINK_MCP_HOST: env.DATALINK_MCP_HOST?.trim() || "127.0.0.1", + DATALINK_MCP_PORT: String(readPort(env.DATALINK_MCP_PORT, 8080, "DATALINK_MCP_PORT")), + }; +} + +function readPort(value, fallback, name) { + if (!value?.trim()) return fallback; + const port = Number(value); + if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new Error(`${name} must be an integer between 1 and 65535.`); + } + return port; +} diff --git a/scripts/datalink-stack-config.test.mjs b/scripts/datalink-stack-config.test.mjs new file mode 100644 index 00000000..bae5026e --- /dev/null +++ b/scripts/datalink-stack-config.test.mjs @@ -0,0 +1,26 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { datalinkEnabled, resolveDatalinkEnv } from "./datalink-stack-config.mjs"; + +test("DataLink is opt-in", () => { + assert.equal(datalinkEnabled({}), false); + assert.equal(datalinkEnabled({ DATALINK_ENABLED: "true" }), true); + assert.equal(datalinkEnabled({ DATALINK_ENABLED: "1" }), true); + assert.equal(datalinkEnabled({ DATALINK_ENABLED: "false" }), false); +}); + +test("DataLink paths and ports have repository-owned defaults", () => { + const env = resolveDatalinkEnv("/repo", {}); + assert.equal(env.DATALINK_CONFIG_PATH, "/repo/services/datalink/datalink_config.json"); + assert.equal(env.DATALINK_GRAPH_DB_PATH, "/repo/storage/datalink/datalink.db"); + assert.equal(env.DATALINK_API_PORT, "8081"); + assert.equal(env.DATALINK_MCP_PORT, "8080"); +}); + +test("invalid DataLink ports fail before processes start", () => { + assert.throws( + () => resolveDatalinkEnv("/repo", { DATALINK_API_PORT: "70000" }), + /DATALINK_API_PORT/, + ); +}); diff --git a/scripts/dev.mjs b/scripts/dev.mjs index 995bccfb..4bfe109f 100755 --- a/scripts/dev.mjs +++ b/scripts/dev.mjs @@ -1,119 +1,4 @@ #!/usr/bin/env node -/** - * Build workspace packages and start API + web dev servers (Linux, Windows, macOS). - * - * Usage: - * npm run dev # start both - * npm run dev -- --api # API only - * npm run dev -- --web # web only - */ -import { spawn, execSync } from "node:child_process"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; +import { runStack } from "./stack-runner.mjs"; -const root = join(dirname(fileURLToPath(import.meta.url)), ".."); -const args = process.argv.slice(2); -const apiOnly = args.includes("--api"); -const webOnly = args.includes("--web"); -const startApi = !webOnly || apiOnly; -const startWeb = !apiOnly || webOnly; - -execSync("node scripts/ensure-dev-environment.mjs", { - cwd: root, - stdio: "inherit", - env: process.env, - shell: true, -}); - -for (const port of [8787, 3000]) { - try { - freePort(port); - } catch { - // Port may already be free; dev servers will fail loudly if it stays busy. - } -} - -/** @type {import('node:child_process').ChildProcess[]} */ -const children = []; - -if (startApi) { - children.push(spawnNpm(["--workspace", "@datafoundry/api", "run", "dev"])); -} -if (startWeb) { - children.push(spawnNpm(["--workspace", "@datafoundry/web", "run", "dev"])); -} - -if (children.length === 0) { - console.error("Nothing to start. Use --api and/or --web."); - process.exit(1); -} - -console.log( - "\n[dev] " + - (startApi ? "API → http://127.0.0.1:8787 " : "") + - (startWeb ? "Web → http://localhost:3000/data-tasks" : "") + - "\n", -); - -function shutdown(signal) { - for (const child of children) { - if (!child.killed) child.kill(signal); - } -} - -process.on("SIGINT", () => shutdown("SIGINT")); -process.on("SIGTERM", () => shutdown("SIGTERM")); - -for (const child of children) { - child.on("exit", (code, signal) => { - if (signal) return; - if (code && code !== 0) { - shutdown("SIGTERM"); - process.exit(code); - } - }); -} - -function spawnNpm(args) { - return spawn("npm", args, { - cwd: root, - stdio: "inherit", - env: process.env, - shell: true, - }); -} - -function freePort(port) { - if (process.platform === "win32") { - let output = ""; - try { - output = execSync(`netstat -ano | findstr :${port}`, { - encoding: "utf8", - shell: true, - stdio: ["ignore", "pipe", "ignore"], - }); - } catch { - return; - } - - const pids = new Set(); - for (const line of output.split(/\r?\n/u)) { - if (!/\bLISTENING\b/u.test(line)) continue; - const pid = line.trim().split(/\s+/u).at(-1); - if (pid && /^\d+$/u.test(pid) && pid !== "0") { - pids.add(pid); - } - } - - for (const pid of pids) { - execSync(`taskkill /F /PID ${pid}`, { stdio: "ignore", shell: true }); - } - return; - } - - execSync(`fuser -k ${port}/tcp 2>/dev/null || true`, { - cwd: root, - stdio: "ignore", - shell: true, - }); -} +await runStack({ mode: "development", args: process.argv.slice(2) }); diff --git a/scripts/smoke-builtin-datalink.mjs b/scripts/smoke-builtin-datalink.mjs new file mode 100644 index 00000000..db36794b --- /dev/null +++ b/scripts/smoke-builtin-datalink.mjs @@ -0,0 +1,45 @@ +import assert from "node:assert/strict"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + BUILTIN_DATALINK_SERVER_ID, + ensureBuiltinDatalinkServer, +} from "../apps/api/dist/builtin-datalink-server.js"; +import { createMetadataStore } from "../packages/metadata/dist/index.js"; + +const root = mkdtempSync(join(tmpdir(), "datafoundry-builtin-datalink-")); +const metadataStore = createMetadataStore({ database_path: join(root, "metadata.sqlite") }); +const common = { metadataStore, userId: "dev-user", workspaceId: "default" }; +const enabledEnv = { + DATALINK_ENABLED: "true", + DATALINK_API_PORT: "18081", + DATALINK_MCP_PORT: "18080", +}; + +assert.equal(ensureBuiltinDatalinkServer({ ...common, env: enabledEnv }), "created"); +assert.equal(ensureBuiltinDatalinkServer({ ...common, env: enabledEnv }), "skipped"); + +const resource = metadataStore.configResources.get({ + id: BUILTIN_DATALINK_SERVER_ID, + workspace_id: "default", + user_id: "dev-user", + kind: "mcp-server", +}); +assert.equal(resource.builtin, true); +assert.equal(resource.default_enabled, true); +assert.equal(resource.payload.apiUrl, "http://127.0.0.1:18081"); +assert.equal(resource.payload.serverUrl, "http://127.0.0.1:18080/mcp"); +assert.deepEqual(resource.payload.toolManifest, [{ name: "datalink_explore" }]); + +assert.equal(ensureBuiltinDatalinkServer({ ...common, env: {} }), "removed"); +assert.equal(metadataStore.configResources.find({ + id: BUILTIN_DATALINK_SERVER_ID, + workspace_id: "default", + user_id: "dev-user", + kind: "mcp-server", +}), undefined); + +metadataStore.close(); +console.log("Built-in DataLink lifecycle smoke OK"); diff --git a/scripts/stack-runner.mjs b/scripts/stack-runner.mjs new file mode 100644 index 00000000..40650a01 --- /dev/null +++ b/scripts/stack-runner.mjs @@ -0,0 +1,151 @@ +import { execFileSync, execSync, spawn } from "node:child_process"; +import { existsSync } from "node:fs"; +import { loadEnvFile } from "node:process"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { datalinkEnabled, resolveDatalinkEnv } from "./datalink-stack-config.mjs"; + +const root = join(dirname(fileURLToPath(import.meta.url)), ".."); + +export async function runStack({ mode, args = [] }) { + loadRootEnv(); + const apiOnly = args.includes("--api"); + const webOnly = args.includes("--web"); + const startApi = !webOnly || apiOnly; + const startWeb = !apiOnly || webOnly; + const startDatalink = startApi && datalinkEnabled(); + const datalinkEnv = resolveDatalinkEnv(root); + + if (mode === "development") { + execSync("node scripts/ensure-dev-environment.mjs", { + cwd: root, + stdio: "inherit", + env: process.env, + shell: true, + }); + const ports = [ + ...(startApi ? [8787] : []), + ...(startWeb ? [3000] : []), + ...(startDatalink + ? [Number(datalinkEnv.DATALINK_MCP_PORT), Number(datalinkEnv.DATALINK_API_PORT)] + : []), + ]; + for (const port of ports) freePort(port); + } + + if (startDatalink) ensureUvAvailable(); + + const children = []; + if (startDatalink) { + children.push(spawnProcess("DataLink MCP", "uv", [ + "run", "--project", "services/datalink", "datalink", "serve", + "--host", datalinkEnv.DATALINK_MCP_HOST, + "--port", datalinkEnv.DATALINK_MCP_PORT, + "--transport", "streamable-http", + "--db", datalinkEnv.DATALINK_GRAPH_DB_PATH, + ], datalinkEnv)); + children.push(spawnProcess("DataLink REST", "uv", [ + "run", "--project", "services/datalink", "datalink", "api", + "--host", datalinkEnv.DATALINK_API_HOST, + "--port", datalinkEnv.DATALINK_API_PORT, + ], datalinkEnv)); + } + if (startApi) { + const command = mode === "development" + ? ["--workspace", "@datafoundry/api", "run", "dev"] + : ["--prefix", "apps/api", "run", "start"]; + children.push(spawnProcess("DataFoundry API", "npm", command, datalinkEnv)); + } + if (startWeb) { + const command = mode === "development" + ? ["--workspace", "@datafoundry/web", "run", "dev"] + : ["--prefix", "apps/web", "run", "start"]; + children.push(spawnProcess("DataFoundry Web", "npm", command, datalinkEnv)); + } + + if (children.length === 0) { + throw new Error("Nothing to start. Use --api and/or --web."); + } + + console.log(formatEndpoints({ datalinkEnv, startApi, startDatalink, startWeb })); + let shuttingDown = false; + const shutdown = (signal) => { + if (shuttingDown) return; + shuttingDown = true; + for (const { child } of children) { + if (!child.killed) child.kill(signal); + } + }; + + process.on("SIGINT", () => shutdown("SIGINT")); + process.on("SIGTERM", () => shutdown("SIGTERM")); + for (const { child, label } of children) { + child.on("exit", (code, signal) => { + if (shuttingDown || signal) return; + console.error(`[stack] ${label} exited with code ${code ?? "unknown"}.`); + shutdown("SIGTERM"); + process.exitCode = code && code !== 0 ? code : 1; + }); + } +} + +function loadRootEnv() { + const envPath = join(root, ".env"); + if (existsSync(envPath)) loadEnvFile(envPath); +} + +function ensureUvAvailable() { + try { + execFileSync("uv", ["--version"], { stdio: "ignore" }); + } catch { + throw new Error( + "DATALINK_ENABLED=true requires uv and Python 3.10+. Install uv, then run `npm run install:datalink`.", + ); + } +} + +function spawnProcess(label, command, args, env) { + const child = spawn(command, args, { + cwd: root, + stdio: "inherit", + env, + shell: process.platform === "win32", + }); + child.on("error", (error) => console.error(`[stack] Unable to start ${label}: ${error.message}`)); + return { child, label }; +} + +function formatEndpoints({ datalinkEnv, startApi, startDatalink, startWeb }) { + const endpoints = []; + if (startApi) endpoints.push("API http://127.0.0.1:8787"); + if (startWeb) endpoints.push("Web http://localhost:3000/data-tasks"); + if (startDatalink) { + endpoints.push(`DataLink MCP http://127.0.0.1:${datalinkEnv.DATALINK_MCP_PORT}/mcp`); + endpoints.push(`DataLink REST http://127.0.0.1:${datalinkEnv.DATALINK_API_PORT}`); + } + return `\n[stack] ${endpoints.join(" | ")}\n`; +} + +function freePort(port) { + try { + if (process.platform === "win32") { + const output = execSync(`netstat -ano | findstr :${port}`, { + encoding: "utf8", + shell: true, + stdio: ["ignore", "pipe", "ignore"], + }); + const pids = new Set(); + for (const line of output.split(/\r?\n/u)) { + if (!/\bLISTENING\b/u.test(line)) continue; + const pid = line.trim().split(/\s+/u).at(-1); + if (pid && /^\d+$/u.test(pid) && pid !== "0") pids.add(pid); + } + for (const pid of pids) execSync(`taskkill /F /PID ${pid}`, { stdio: "ignore", shell: true }); + return; + } + execSync(`fuser -k ${port}/tcp 2>/dev/null || true`, { cwd: root, stdio: "ignore", shell: true }); + } catch { + // The port was already free. + } +} diff --git a/scripts/start-datalink.mjs b/scripts/start-datalink.mjs new file mode 100755 index 00000000..e49e5153 --- /dev/null +++ b/scripts/start-datalink.mjs @@ -0,0 +1,47 @@ +#!/usr/bin/env node +import { spawn } from "node:child_process"; +import { existsSync } from "node:fs"; +import { loadEnvFile } from "node:process"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { resolveDatalinkEnv } from "./datalink-stack-config.mjs"; + +const root = join(dirname(fileURLToPath(import.meta.url)), ".."); +const envPath = join(root, ".env"); +if (existsSync(envPath)) loadEnvFile(envPath); +const env = resolveDatalinkEnv(root); +const service = process.argv[2]; +const args = service === "api" + ? [ + "run", "--project", "services/datalink", "datalink", "api", + "--host", env.DATALINK_API_HOST, + "--port", env.DATALINK_API_PORT, + ] + : service === "mcp" + ? [ + "run", "--project", "services/datalink", "datalink", "serve", + "--host", env.DATALINK_MCP_HOST, + "--port", env.DATALINK_MCP_PORT, + "--transport", "streamable-http", + "--db", env.DATALINK_GRAPH_DB_PATH, + ] + : undefined; + +if (!args) throw new Error("Usage: node scripts/start-datalink.mjs "); + +const child = spawn("uv", args, { + cwd: root, + env, + stdio: "inherit", + shell: process.platform === "win32", +}); +child.on("error", (error) => { + console.error(`[datalink] Unable to start: ${error.message}`); + process.exitCode = 1; +}); +child.on("exit", (code) => { + process.exitCode = code ?? 1; +}); +process.on("SIGINT", () => child.kill("SIGINT")); +process.on("SIGTERM", () => child.kill("SIGTERM")); diff --git a/scripts/start.mjs b/scripts/start.mjs new file mode 100755 index 00000000..f05efd57 --- /dev/null +++ b/scripts/start.mjs @@ -0,0 +1,4 @@ +#!/usr/bin/env node +import { runStack } from "./stack-runner.mjs"; + +await runStack({ mode: "production", args: process.argv.slice(2) }); diff --git a/services/datalink/.gitignore b/services/datalink/.gitignore new file mode 100644 index 00000000..8aeff1e4 --- /dev/null +++ b/services/datalink/.gitignore @@ -0,0 +1,26 @@ +.venv + +__pycache__ + +.cursorignore + +.claude + +.pytest_cache + +*.so + +# Config files may contain API keys — never commit +datalink_config.json + +*.db + +scripts + +.cursor + +.DS_Store + +eval.config.json + +design/ diff --git a/services/datalink/LICENSE.txt b/services/datalink/LICENSE.txt new file mode 100644 index 00000000..e93aca40 --- /dev/null +++ b/services/datalink/LICENSE.txt @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 DataAgent contributors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/services/datalink/README.md b/services/datalink/README.md new file mode 100644 index 00000000..a9638241 --- /dev/null +++ b/services/datalink/README.md @@ -0,0 +1,339 @@ +

DataLink 🧬

+ +> DataLink is maintained as DataFoundry's first-party semantic service under `services/datalink`. Run it from the repository root through DataFoundry's stack commands; the standalone CLI remains available for service development. + +

+ A unified data map for data agents — connecting data across sources and formats
through queryable relationships, profiles, concepts, and confidence signals. +

+ +

+ English · 简体中文 +

+ +

+ Quick Start + · + Docs + · + Design + · + Interface Reference + · + Contributing + · + License +

+ +## ✨ Why DataLink + +Modern data agents can see schemas, but schemas alone rarely explain how data should be used. Which fields can be joined, which fields refer to the same business concept, which relationships are explicit constraints, and which are only candidates all require additional context. + +DataLink organizes structures, profiles, business concepts, and relationship signals into a **unified data map**. The design is not tied to one data format: + +- 🗺️ **Unified organization** — each data type keeps its own structure while sharing a common relationship model and query surface. +- 🏗️ **Layered representation** — the structural layer records where data lives and what it looks like; the concept layer records what it represents and what it relates to. +- 🔎 **Relationship-aware** — explicit edges such as foreign keys and lineage, plus inferred edges such as joinability, concept equivalence, correlation, and distribution similarity, carry confidence scores. +- 🧩 **Extensible by data type** — new formats can define their own connectors, extractors, profilers, nodes, and profiling strategies while reusing graph storage, retrieval, and agent integration capabilities. +- 📦 **Agent-ready** — retrieval APIs exposed as MCP tools and REST API. The MCP server exposes `datalink_explore` for instant agent queries; the REST API mirrors all CLI commands for management and integration. + +> **DataLink is a data map that continuously calibrates itself.** It first uses static analysis — schema extraction, content profiling, algorithmic inference, and concept mapping — to establish a baseline map, helping the system understand data structures, features, and possible relationships. When data agents actually use the data, their execution trajectories — for example, which tables are joined, which columns are queried together, and which analysis paths are repeatedly traversed — provide usage evidence that static analysis alone cannot capture. This evidence can further enrich and calibrate the data map while providing confidence estimates grounded in real usage. The ultimate form of relationship construction in DataLink is therefore two-directional: static governance provides the baseline map, while agent trajectories enrich and calibrate it with evidence from real usage. + +> For now, DataLink focuses on relational databases, CSV, and Parquet, with Table/Column as its core objects. PPT, PDF, Markdown, and image connectors will be added progressively as future extensions for unstructured data types within the unified data map. + +

+ DataLink unified data map connecting related data points across tables, records, documents, slides, and images +

+ +The illustration shows the design goal of the unified data map: DataLink organizes, labels, and exposes relationships across sources and formats. Tables and records represent the current implementation focus, while documents, slides, and images illustrate future extension directions. + +## 🗺️ How It Works + +

+ DataLink Architecture +

+ +The build pipeline organizes supported data sources into a queryable data map: + +``` +Connector → Extractor → Profiler → Inferrer → Mapper → Graph +``` + +The current pipeline is implemented primarily for tabular data, with responsibilities separated so additional data types can be added over time: + +1. **Connector** currently connects to relational databases and CSV/Parquet files; future formats can provide dedicated connectors. +2. **Extractor** currently creates Table/Column nodes and explicit relationships such as `contains` and `foreign_key`; other formats can define their own structural nodes. +3. **Profiler** currently computes column types, distributions, cardinality, and samples; other formats can add suitable structure and content profiles. +4. **Inferrer** discovers implicit relationships with confidence scores — `joinable`, `semantic_synonym`, `correlated`, `distribution_similar`, `co_occurs`. +5. **Mapper** associates structural nodes with Concept/Entity nodes using metadata or LLM inference, providing a common entry point for concept normalization across sources and formats. +6. **Graph** stores nodes, relationships, and profiles in SQLite and exposes a unified query interface for agents. + +**The design idea:** a revenue column in a database, a revenue chart in a slide deck, and a revenue paragraph in a PDF can eventually connect to the same Concept node. Each format keeps its own structure while sharing business concepts, relationships, and query behavior. The current release validates this approach through its table and column implementation. + +## ⚡ Quick Start + +Install with [uv](https://docs.astral.sh/uv/): + +```bash +uv pip install -e . +``` + +Or with pip: + +```bash +pip install -e . +``` + +Configure your LLM provider: + +```bash +cp datalink_config.example.json datalink_config.json +``` + +Edit `datalink_config.json` — set your LLM API key: + +```json +{ + "llm": { + "model": "gpt-4o", + "api_key": "sk-...", + "base_url": "https://api.openai.com/v1" + }, + "embedding": { + "model": "text-embedding-3-small", + "similarity_threshold": 0.75 + }, + "merge_llm_temperature": 0.0, + "graph_db_path": "datalink.db" +} +``` + +OpenAI-compatible providers (DeepSeek, Qwen, etc.) work by changing `base_url`: + +```json +{ + "llm": { + "model": "deepseek-chat", + "api_key": "...", + "base_url": "https://api.deepseek.com" + } +} +``` + +Embedding pre-filtering improves merge accuracy and reduces token cost. Leave `embedding.model` empty to skip pre-filtering and use pure LLM judgment: + +```json +{ + "embedding": { + "model": "", + "similarity_threshold": 0.75 + } +} +``` + +Build a graph from your data: + +```bash +# Add tables from a database (also works as initial build on empty graph) +datalink add-table --source "postgresql://user:pass@localhost/mydb" + +# MySQL — use bare mysql:// or mysql+pymysql:// (driver auto-detected) +datalink add-table --source "mysql://user:pass@localhost/mydb" + +# Add tables from CSV files +datalink add-table --source "./data/*.csv" + +# Add a specific table +datalink add-table --source "postgresql://..." --table orders + +# Rebuild the graph when underlying data has changed (old data preserved if pipeline fails) +datalink rebuild + +# Remove a table and its nodes/edges +datalink remove-table --table orders +``` + +> **Deduplication**: Re-adding an existing table (same source path + same table name) is automatically skipped. Use `remove-table` first to re-add. + +Explore the graph: + +```bash +# Search for nodes +datalink search "customer" --type column + +# Find paths between two nodes +datalink path --from column:postgres:users:email --to column:postgres:orders:customer_email + +# One-call exploration — answers the whole question +datalink explore "how do users connect to orders" + +# Graph overview stats +datalink info + +# Show the full graph as JSON +datalink show +``` + +## 🧩 What You Can Build With It + +| Use case | How DataLink helps | +| --- | --- | +| **NL2SQL accuracy** | `joinable` and `semantic_synonym` edges let agents find the right JOINs without trial-and-error. | +| **Cross-format insight** | A concept like "revenue" can link a database column, a PPT chart, and a PDF paragraph — agents see the full picture. | +| **Data lineage & discovery** | `derived_from`, `references`, and `find_paths` make lineage and hidden relationships queryable. | +| **Schema exploration** | `search_nodes` and `extract_subgraph` let agents understand unfamiliar datasets fast. | +| **MCP-powered agents** | Run `datalink serve` and any MCP client (Claude, Cursor, Copilot) can call retrieval tools directly. | +| **Statistical QA** | Column profiles (distribution, cardinality, patterns) answer "what does this data look like?" without querying the source. | + +## 🛠️ Architecture + +DataLink uses decoupled modules with explicit extension points. The current implementation is tabular-first, while the module boundaries leave room for additional data types: + +| Module | Responsibility | Extensible? | +| --- | --- | --- | +| `connector` | Connect to data sources, extract schema + sample | ✅ add new source types | +| `extractor` | Create structural nodes per data type | ✅ add new structural vocabularies | +| `profiler` | Compute type-appropriate fingerprints | ✅ add new profiling strategies | +| `inferrer` | Discover implicit relationships with confidence | — shared across types | +| `mapper` | Associate structural nodes with Concept/Entity | — shared foundation | +| `graph` | SQLite storage, CRUD, four retrieval interfaces | — shared engine | +| `builder` | Orchestrate the build pipeline | — shared orchestration | +| `cli` | Typer CLI — add-table, rebuild, show, search, explore, path, info, serve, api, config | — shared interface | +| `mcp` | MCP Server exposing retrieval as agent tools | — shared interface | +| `api` | FastAPI REST API — mirrors all CLI commands as HTTP endpoints | — shared interface | + +**Adding a new data type** such as PPT requires a connector, extractor, profiler, suitable node models, and applicable relationship rules. Graph storage, relationship representation, retrieval APIs, and agent integration can be reused or extended within the existing framework. + +## 🔌 MCP Tools + +Start the MCP server: + +```bash +# Default SSE transport +datalink serve --port 8080 + +# Recommended streamable-http transport (more stable) +datalink serve --port 8080 --transport streamable-http +``` + +The MCP server exposes **one core tool** for agent retrieval: + +| Tool | Description | +| --- | --- | +| `datalink_explore` | Universal retrieval — one call answers the whole data question | + +Write operations (add-table, rebuild, remove-table) and show are available via the **REST API** instead — they are offline/management operations that don't belong in an agent's retrieval context. + +```bash +# Start the REST API server (default port 8081) +datalink api + +# Example: add a table via REST API +curl -X POST http://localhost:8081/add-table -H "Content-Type: application/json" \ + -d '{"source": "./data/", "source_type": "csv"}' + +# Example: explore via REST API +curl -X POST http://localhost:8081/explore -H "Content-Type: application/json" \ + -d '{"query": "customer orders"}' +``` + +**`datalink_explore` output example:** + +``` +matched: 3 columns across 2 tables + +## orders — 3 columns, 10,000 rows, source: csv +### customer_id (integer, identifier) +- meaning: FK referencing customers.id +- data: null 0%, unique 8,921, range [1–999] +- also related: customers.id (FK, conf 1.00) + +## customers — 5 columns, 999 rows, source: csv +### id (integer, identifier) +- meaning: primary key, referenced by orders.customer_id +- data: null 0%, unique 999, range [1–999] +### email (varchar, email_address) +- meaning: contact email, unique per customer +- data: null 2%, unique 979, samples ["a@b.com", "c@d.com"] + +## Cross-table query patterns +orders.customer_id → customers.id (FK, conf 1.00) + SQL: SELECT * FROM orders JOIN customers ON orders.customer_id = customers.id +``` + +**Optional** (enable via `datalink_config.json` `mcp_tools` field or `DATALINK_MCP_TOOLS` env var): + +| Tool | Description | +| --- | --- | +| `datalink_search_nodes` | Search nodes by name or field role | +| `datalink_get_node` | Get node details with connected edges and profile | +| `datalink_find_paths` | Find relationship paths between two nodes | +| `datalink_extract_subgraph` | Extract a neighborhood subgraph around given nodes | +| `datalink_list_datasets` | List all data sources with basic stats | +| `datalink_list_pending_edges` | List dangling edges referencing missing nodes | + +## 🛣️ Roadmap + + + + + + + + + + + + + + + + + + +
PPT connector
Slide, TextBox, Image, and Chart nodes; layout and text profiling; cross-slide concept links.
PDF / Markdown connector
Section, Paragraph, Table, Figure as structural nodes; content & structure profiling.
Image connector
Image nodes with vision-model profiles for captions, detected objects, and scenes; visual concept mapping.
Incremental update
Detect source changes, re-profile affected nodes, update edges without full rebuild.
Embedding-backed search ✅
Vector similarity on node names, descriptions, and concept content for hybrid retrieval (text + vector) in `search_nodes` and `explore`. Configurable and rebuildable.
REST API ✅
FastAPI adapter mirrors all CLI commands as HTTP endpoints. Manage the graph (add-table, rebuild, remove-table) and query it (explore, search, etc.) via REST.
Agent trajectory–driven edges
Extract real-world relationships from agent execution traces — which tables get joined, which columns are queried together, which paths agents walk. Trajectory evidence enriches the static governance baseline with usage-grounded edges such as `frequently_joined` and `co_occurs_in_query`, completing the two-directional relationship construction.
+ +## 📚 Documentation + + + + + + + + + + +
Docs Index
Navigate all documentation.
Design Document
Layered data map, relationship types, build pipeline, and retrieval APIs.
Interface Reference
CLI commands, REST API endpoints, MCP tools, and retrieval API specifications.
Pipeline Details
Step-by-step build pipeline documentation.
+ +## 🛠️ Developer Loop + +```bash +uv sync +uv run pytest +uv run ruff check src/ tests/ +``` + +Run targeted tests for the module you're working on: + +```bash +uv run pytest tests/test_profiler.py +uv run pytest tests/test_inferrer.py +``` + +## 🤝 Contributing + +DataLink is under active development. Small, well-scoped contributions are easiest to review. + +1. Open an issue or discussion for architectural changes, new data type connectors, edge type proposals, or retrieval API extensions. +2. Keep pull requests focused on one module boundary. +3. Run `uv run pytest` and `uv run ruff check` for the modules you touched. +4. Update docs when a change affects CLI commands, MCP tools, relationship definitions, or retrieval behavior. +5. Do not commit credentials, database files, or generated graph storage. + +## 🧪 Status + +DataLink is in early development (v0.1.0). Its working capabilities currently focus on **tabular data**: relational databases, CSV, and Parquet support structural extraction, column profiling, relationship inference, concept mapping, graph storage, and agent retrieval. The unified data map is the long-term direction; PPT, PDF, Markdown, and image support still requires dedicated node models and processing components. + +## 📄 License + +Apache License 2.0. See [LICENSE.txt](LICENSE.txt). diff --git a/services/datalink/README_zh.md b/services/datalink/README_zh.md new file mode 100644 index 00000000..21d5cad3 --- /dev/null +++ b/services/datalink/README_zh.md @@ -0,0 +1,339 @@ +

DataLink 🧬

+ +> DataLink 现作为 DataFoundry 的一方语义服务维护在 `services/datalink`。正式使用请从仓库根目录通过 DataFoundry 的统一命令启动;独立 CLI 继续用于服务开发与调试。 + +

+ 面向数据 Agent 的统一数据地图——连接不同来源、不同形态的数据,
提供可查询、可扩展、带有关系与置信度的数据上下文。 +

+ +

+ English · 简体中文 +

+ +

+ 快速开始 + · + 文档 + · + 设计文档 + · + 接口参考 + · + 贡献指南 + · + 许可证 +

+ +## ✨ 为什么需要 DataLink + +数据 Agent 能看到 schema,却很难仅凭 schema 判断数据之间的真实联系。表名和列头一目了然,但哪些字段可以 JOIN、哪些字段表达同一个业务概念、哪些关系来自明确约束、哪些只是候选线索,仍然需要额外的上下文。 + +DataLink 将数据结构、内容特征、业务概念和关联线索组织成一张**统一数据地图**。它的设计不绑定某一种数据形态: + +- 🗺️ **统一组织** — 每种数据类型保留自己的结构特征,同时进入同一套关系模型和查询接口。 +- 🏗️ **分层表达** — 数据结构层记录数据在哪里、长什么样;业务概念层记录数据表示什么、与哪些对象有关。 +- 🔎 **关系感知** — 显式边(外键、血缘)和推断边(可 JOIN、概念同义、相关性、分布相似)都带置信度。Agent 按确定性筛选,不必靠猜。 +- 🧩 **按类型扩展** — 新数据类型可以定义自己的 Connector、Extractor、Profiler、节点和画像策略,并复用关系表达、图存储、检索与 Agent 接入能力。 +- 📦 **Agent 开箱即用** — 检索 API 通过 MCP 工具和 REST API 暴露。MCP server 提供 `datalink_explore` 供 Agent 即时查询;REST API 与 CLI 命令一一对应,方便管理和集成。 + +> **DataLink 是一张会持续校准的数据地图。** 首先通过静态分析完成 schema 提取、内容画像、算法推断和概念映射,建立数据地图的基线,帮助系统理解数据的结构、特征,以及数据之间可能存在的关系。当数据 Agent 实际使用数据时,它的执行轨迹——例如哪些表被 JOIN、哪些列被共同查询、哪些分析路径被反复走过——会提供静态分析难以获得的使用证据。这些证据可以进一步丰富和校准数据地图,并为关系提供更贴近实际使用的置信度评估。因此 DataLink 中数据关系构建的最终形态是双向的:静态治理提供基线地图,Agent 轨迹则用真实使用证据来丰富和校准地图。 + +> 现阶段,DataLink 主要支持关系型数据库、CSV 和 Parquet,核心处理对象仍是 Table/Column。PPT、PDF、Markdown、图片等非结构化数据类型将作为后续扩展方向,逐步接入统一的数据地图。 + +

+ DataLink 统一数据地图:连接表格、记录、文档、幻灯片和图片中的相关数据点 +

+ +上图展示了统一数据地图的设计目标:DataLink 组织、标注并对外提供跨数据源、跨格式的数据关系;其中表格和记录代表当前主要实现,文档、幻灯片和图片展示后续扩展方向。 + +## 🗺️ 工作原理 + +

+ DataLink 架构图 +

+ +构建管线把已接入的数据源组织成可查询的数据地图: + +``` +Connector → Extractor → Profiler → Inferrer → Semantic Mapper → Graph +``` + +当前管线以表格数据为主要实现,同时按照可扩展的数据类型处理流程划分职责: + +1. **Connector** — 当前连接关系型数据库和 CSV/Parquet 文件,提取 schema 与样本数据;未来可为其他类型提供专用连接器。 +2. **Extractor** — 当前生成 Table/Column 等结构节点并建立 `contains`、`foreign_key` 等显式关系;其他类型可定义自己的结构节点。 +3. **Profiler** — 当前计算字段类型、分布、基数、样本等列画像;其他类型可扩展对应的内容与结构画像。 +4. **Inferrer** — 以置信度分数发现隐式关系——`joinable`、`semantic_synonym`、`correlated`、`distribution_similar`、`co_occurs`。 +5. **Mapper** — 利用元数据或 LLM 推断,将结构节点关联到 Concept/Entity 节点,产出 `represents` 边,为跨来源、跨类型的概念归一提供统一入口。 +6. **Graph** — 将节点、关系和画像存入 SQLite(邻接表),通过统一接口供 Agent 查询。 + +**核心设计理念**:数据库里的“营收”列、PPT 里的“营收”图表、PDF 里关于“营收”的段落,未来都可以关联到同一个 Concept 节点。不同类型保留自己的结构表达,但共享业务概念、关系模型和查询方式。当前版本已经在表和列上实现了这套流程,并以此验证统一数据地图的核心能力。 + +## ⚡ 快速开始 + +使用 [uv](https://docs.astral.sh/uv/) 安装: + +```bash +uv pip install -e . +``` + +或使用 pip: + +```bash +pip install -e . +``` + +配置 LLM 提供商: + +```bash +cp datalink_config.example.json datalink_config.json +``` + +编辑 `datalink_config.json`,填入 LLM API Key: + +```json +{ + "llm": { + "model": "gpt-4o", + "api_key": "sk-...", + "base_url": "https://api.openai.com/v1" + }, + "embedding": { + "model": "text-embedding-3-small", + "similarity_threshold": 0.75 + }, + "merge_llm_temperature": 0.0, + "graph_db_path": "datalink.db" +} +``` + +OpenAI 兼容提供商(DeepSeek、Qwen 等)只需改 `base_url`: + +```json +{ + "llm": { + "model": "deepseek-chat", + "api_key": "...", + "base_url": "https://api.deepseek.com" + } +} +``` + +Embedding 粗筛能提升合并准确性、降低 token 开销。`embedding.model` 留空则跳过粗筛,全靠 LLM 判断: + +```json +{ + "embedding": { + "model": "", + "similarity_threshold": 0.75 + } +} +``` + +从数据构建图谱: + +```bash +# 从数据库添加表(空图谱上等于首次构建) +datalink add-table --source "postgresql://user:pass@localhost/mydb" + +# MySQL — 直接用 mysql:// 或 mysql+pymysql://(驱动自动检测) +datalink add-table --source "mysql://user:pass@localhost/mydb" + +# 从 CSV 文件添加表 +datalink add-table --source "./data/*.csv" + +# 添加指定表 +datalink add-table --source "postgresql://..." --table orders + +# 底层数据变更后重建图谱(pipeline 失败时旧数据保留) +datalink rebuild + +# 移除一张表及其关联节点/边 +datalink remove-table --table orders +``` + +> **去重**:重复添加已存在的表(同数据源路径 + 同表名)会自动跳过。如需重新添加,先 `remove-table` 再 `add-table`。 + +探索图谱: + +```bash +# 搜索节点 +datalink search "customer" --type column + +# 查找两节点之间的路径 +datalink path --from column:postgres:users:email --to column:postgres:orders:customer_email + +# 一键探索——一次调用回答完整问题 +datalink explore "用户和订单是如何关联的" + +# 图谱概览统计 +datalink info + +# 以 JSON 输出完整图谱 +datalink show +``` + +## 🧩 用 DataLink 可以做什么 + +| 应用场景 | DataLink 的作用 | +| --- | --- | +| **NL2SQL 准确性** | `joinable` 和 `semantic_synonym` 边让 Agent 直接找到正确的 JOIN,不用反复试错。 | +| **跨格式洞察** | "营收"这一概念可以把数据库列、PPT 图表、PDF 段落连在一起——Agent 看到的是完整拼图。 | +| **数据血缘与发现** | `derived_from`、`references` 和 `find_paths` 让血缘和隐含关系变得可查询。 | +| **Schema 探索** | `search_nodes` 和 `extract_subgraph` 让 Agent 快速摸清陌生数据集的结构。 | +| **MCP 驱动的 Agent** | 运行 `datalink serve`,任何 MCP 客户端(Claude、Cursor、Copilot)都能直接调用检索工具。 | +| **统计问答** | 列级画像(分布、基数、模式)无需查询源数据就能回答"这数据长什么样?"。 | + +## 🛠️ 架构 + +DataLink 采用分层、解耦的模块设计。当前实现以表格数据为主,但模块边界为更多数据类型预留了扩展入口: + +| 模块 | 职责 | 可扩展? | +| --- | --- | --- | +| `connector` | 连接数据源,提取 schema 与样本 | ✅ 新增数据源类型 | +| `extractor` | 按数据类型创建结构节点 | ✅ 新增结构词汇 | +| `profiler` | 计算适配类型的指纹 | ✅ 新增画像策略 | +| `inferrer` | 以置信度发现隐式关系 | — 跨类型共享 | +| `mapper` | 将结构节点关联到 Concept/Entity | — 共享基础能力 | +| `graph` | SQLite 存储、CRUD、四种检索接口 | — 共享引擎 | +| `builder` | 编排构建管线 | — 共享编排 | +| `cli` | Typer CLI——add-table、rebuild、show、search、explore、path、info、serve、api、config | — 共享界面 | +| `mcp` | MCP Server,将检索暴露为 Agent 工具(默认只暴露 datalink_explore) | — 共享界面 | +| `api` | FastAPI REST API——与 CLI 命令一一对应的 HTTP 端点 | — 共享界面 | + +**扩展新数据类型**(例如 PPT)时,需要实现相应的 Connector、Extractor、Profiler,并按该类型补充节点模型和适用的关系推断策略。图存储、关系表达、查询接口及 Agent 接入方式可以在现有框架上复用或扩展。 + +## 🔌 MCP 工具 + +启动 MCP 服务器: + +```bash +# 默认 SSE transport +datalink serve --port 8080 + +# 推荐 streamable-http transport(更稳定) +datalink serve --port 8080 --transport streamable-http +``` + +MCP 服务器只暴露**一个核心检索工具**: + +| 工具 | 描述 | +| --- | --- | +| `datalink_explore` | 万能检索——一次调用回答完整数据问题 | + +写操作(add-table、rebuild、remove-table)和 show 通过 **REST API** 提供——它们是离线/管理操作,不属于 Agent 的检索上下文。 + +```bash +# 启动 REST API 服务器(默认端口 8081) +datalink api + +# 示例:通过 REST API 添加表 +curl -X POST http://localhost:8081/add-table -H "Content-Type: application/json" \ + -d '{"source": "./data/", "source_type": "csv"}' + +# 示例:通过 REST API 检索 +curl -X POST http://localhost:8081/explore -H "Content-Type: application/json" \ + -d '{"query": "客户 订单"}' +``` + +**`datalink_explore` 输出示例:** + +``` +matched: 3 columns across 2 tables + +## orders — 3 columns, 10,000 rows, source: csv +### customer_id (integer, identifier) +- meaning: FK 引用 customers.id +- data: null 0%, unique 8,921, range [1–999] +- also related: customers.id (FK, conf 1.00) + +## customers — 5 columns, 999 rows, source: csv +### id (integer, identifier) +- meaning: 主键,被 orders.customer_id 引用 +- data: null 0%, unique 999, range [1–999] +### email (varchar, email_address) +- meaning: 联系邮箱,每个客户唯一 +- data: null 2%, unique 979, samples ["a@b.com", "c@d.com"] + +## Cross-table query patterns +orders.customer_id → customers.id (FK, conf 1.00) + SQL: SELECT * FROM orders JOIN customers ON orders.customer_id = customers.id +``` + +**辅助工具**(通过 `datalink_config.json` 的 `mcp_tools` 字段或 `DATALINK_MCP_TOOLS` 环境变量启用): + +| 工具 | 描述 | +| --- | --- | +| `datalink_search_nodes` | 按名称或字段角色搜索节点 | +| `datalink_get_node` | 获取节点详情、邻接边和画像 | +| `datalink_find_paths` | 查找两节点之间的关系路径 | +| `datalink_extract_subgraph` | 提取指定节点周围的邻域子图 | +| `datalink_list_datasets` | 列出所有数据源及基本统计 | +| `datalink_list_pending_edges` | 列出引用缺失节点的悬空边 | + +## 🛣️ 路线图 + + + + + + + + + + + + + + + + + + +
PPT 连接器
Slide、TextBox、Image、Chart 作为结构节点;布局与文本画像;跨幻灯片含义链接。
PDF / Markdown 连接器
Section、Paragraph、Table、Figure 作为结构节点;内容与结构画像。
图片连接器
Image 节点 + 视觉模型画像(描述、检测物体、场景);视觉概念映射。
增量更新
检测数据源变更,只对受影响节点重新画像、更新边,无需全量重建。
向量相似检索 ✅
对节点名称、描述和概念内容做向量相似度检索,让 `search_nodes` 和 `explore` 成为混合检索(全文 + 向量),可配置、可重建。
REST API ✅
FastAPI 适配器,与 CLI 命令一一对应的 HTTP 端点。通过 REST 管理图谱(add-table、rebuild、remove-table)和查询数据(explore、search 等)。
Agent 轨迹驱动的边关系
从 Agent 的执行轨迹中提取真实的数据关系——哪些表被 JOIN、哪些列被一起查询、Agent 走了哪些路径。轨迹证据用真实使用行为丰富静态治理基线,产出 `frequently_joined`、`co_occurs_in_query` 等使用接地边,完成双向关系构建的闭环。
+ +## 📚 文档 + + + + + + + + + + +
文档索引
浏览全部文档。
设计文档
双层架构、边类型、构建管线与检索 API。
接口参考
CLI 命令、REST API 端点、MCP 工具与检索 API 规格。
管线详情
逐步构建管线文档。
+ +## 🛠️ 开发 + +```bash +uv sync +uv run pytest +uv run ruff check src/ tests/ +``` + +针对正在开发的模块跑定向测试: + +```bash +uv run pytest tests/test_profiler.py +uv run pytest tests/test_inferrer.py +``` + +## 🤝 贡献指南 + +DataLink 正在积极开发中。小而聚焦的 PR 最容易通过审查。 + +1. 涉及架构变更、新数据类型连接器、边类型提案或检索 API 扩展,请先开 Issue 或 Discussion。 +2. PR 聚焦于一个模块边界。 +3. 对改动模块跑 `uv run pytest` 和 `uv run ruff check`。 +4. 变更影响 CLI 命令、MCP 工具、边含义或检索行为时,请同步更新文档。 +5. 不要提交凭证、数据库文件或生成的图谱存储。 + +## 🧪 项目状态 + +DataLink 处于早期开发阶段(v0.1.0)。当前可用能力主要面向**表格数据**:关系型数据库、CSV 和 Parquet 已支持结构提取、列画像、关系推断、概念映射、图存储与 Agent 检索。统一数据地图是项目的长期设计方向;PPT、PDF、Markdown、图片等非表格连接器仍在路线图中,需要继续补齐对应的节点模型和处理组件。 + +## 📄 许可证 + +Apache License 2.0。详见 [LICENSE.txt](LICENSE.txt)。 diff --git a/services/datalink/datalink_config.example.json b/services/datalink/datalink_config.example.json new file mode 100644 index 00000000..b7b3f76a --- /dev/null +++ b/services/datalink/datalink_config.example.json @@ -0,0 +1,26 @@ +{ + "llm": { + "model": "gpt-4o", + "api_key": "", + "base_url": "https://api.openai.com/v1", + "temperature": 0.7, + "max_tokens": 16384, + "timeout": 120.0 + }, + "embedding": { + "model": "", + "api_key": "", + "base_url": "", + "similarity_threshold": 0.75, + "timeout": 60.0 + }, + "merge_llm_temperature": 0.0, + "merge_batch_interval": 10, + "mapping_batch_size": 15, + "graph_db_path": "../../storage/datalink/datalink.db", + "sample_size": 1000, + "confidence_threshold": 0.3, + "joinable_overlap_threshold": 0.1, + "correlation_threshold": 0.5, + "mcp_tools": "" +} diff --git a/services/datalink/docs/DESIGN.md b/services/datalink/docs/DESIGN.md new file mode 100644 index 00000000..8215381c --- /dev/null +++ b/services/datalink/docs/DESIGN.md @@ -0,0 +1,553 @@ +# DataLink 设计文档 + +## 1. 项目概述 + +**DataLink** 是一个面向数据 Agent 的**统一数据地图系统**。它将数据结构、画像、业务概念和关联线索组织为可查询的关系网络,为数据发现、分析规划和 Agent 工具调用提供上下文。 + +项目的长期设计目标是接入表格、文档、幻灯片、图片等多种数据类型,使不同类型保留各自结构,同时共享关系表达、存储和查询能力。**当前版本的实际实现主要面向表格数据**,支持关系型数据库、CSV 和 Parquet,核心节点仍是 Table/Column;非表格类型需要继续补充连接器、节点模型和处理组件。 + +核心能力: + +- 从 CSV/Parquet 文件或关系型数据库自动构建数据地图 +- 维护分层表达:数据结构层(Table/Column)+ 业务概念层(Concept/Entity) +- 推断列之间的隐式关系(可 JOIN、同义、分布相似、统计相关) +- 通过 CLI 和 MCP Server 供人类与 AI Agent 查询 + +技术栈:Python 3.10+、Pydantic、Pandas、SQLAlchemy、SQLite、Typer、Rich、MCP(FastMCP)、OpenAI/Anthropic LLM。 + +--- + +## 2. 核心设计理念 + +### 2.1 分层数据地图 + +| 层级 | 节点类型 | 含义 | 示例 | +|------|----------|------|------| +| **数据结构层** | `TableNode` | 表/数据集 | `orders`, `customers` | +| **数据结构层** | `ColumnNode` | 列/字段 | `customer_id`, `amount` | +| **业务概念层** | `ConceptNode` | 可度量的概念/属性 | `revenue`, `person_identifier` | +| **业务概念层** | `EntityNode` | 可识别的实体/集合 | `customer`, `order` | + +数据结构层来自 schema 与采样数据;业务概念层来自元数据注释或 LLM 推断,并通过 `represents` / `has_concept` 边与结构节点连接。这种分层方式用于统一组织数据,并不限制未来只能采用 Table/Column 节点。 + +### 2.2 边的分类体系 + +边按来源与用途分为四类(见 `models/edge.py`): + +| 类别 | 说明 | 典型边类型 | 置信度 | +|------|------|------------|--------| +| **A. 结构显式** | Schema/元数据直接给出 | `contains`, `foreign_key` | 1.0 | +| **B. 推断关系** | 算法推断 | `joinable`, `semantic_synonym`, `correlated`, `distribution_similar` | 0.0–1.0 | +| **C. 上下文** | 使用行为——从 Agent 执行轨迹中提取的关系(规划中) | `co_occurs_in_query`, `frequently_joined` | — | +| **D. 跨层关联** | 数据结构 ↔ 业务概念 | `represents`, `has_concept` | 0.5–1.0 | + +推断边会按 `confidence_threshold`(默认 0.3)过滤后入库。 + +**边关系的双向构建:** DataLink 的边关系最终形态来自两个方向。一是*静态治理*——通过 schema 提取、内容画像、算法推断和概念映射,理解数据长什么样、*可能*存在什么关系(类别 A/B/D)。二是*轨迹驱动*——当 Agent 实际使用数据时,其执行轨迹(哪些表被 JOIN、哪些列被一起查询、走了哪些路径)揭示静态分析无法发现的真实关系(类别 C)。两者相辅相成:静态治理提供基线地图,轨迹证据用真实使用行为来丰富和校准地图。类别 C 的边类型尚未实现,将在后续版本中从 Agent 的 `datalink_explore` 调用和 SQL 生成记录中提取。 + +**Pending edges(悬空边):** 当边的 `source_id` 或 `target_id` 对应的节点尚未入库(典型:跨数据源 FK 的目标表还没加入),边不会丢弃而是存入 `pending_edges` 表,标记 `missing_endpoints`(如 `["target"]`)。检索时这些边作为 `suggested_edges` 展示,不参与路径发现和子图扩展。当新数据源通过 `add_table` 加入后,Pipeline 自动调用 `resolve_pending_edges()` 将两端节点都存在的 pending 边移入 `edges` 主表。 + +### 2.3 设计原则 + +1. **统一地图,不抹平类型差异**:不同数据类型可以使用不同的节点结构和画像方法,但通过统一的关系模型、存储和查询接口协作。 +2. **扩展点前置**:Connector、Extractor、Profiler 和关系推断按数据类型逐步扩展,公共能力尽量沉淀在 Graph、Retrieval、CLI、REST 和 MCP 层。 +3. **采样而非全量扫描**:当前表格实现默认采样 1000 行做 Profiling,平衡速度与准确性。 +4. **概念归一**:当前所有列通过 LLMMapper 推断 Concept/Entity;有 comment 的列将 comment 作为额外信号,以提高映射质量。 +5. **增量维护**:支持 `add_table` / `rebuild` / `remove_table`,不必每次全量重建。 +6. **Agent 友好**:通过 MCP、REST 和 CLI 提供搜索、路径发现和一键探索等能力。 +7. **双向关系构建**:边关系既来自静态治理(schema、画像、推断),也来自 Agent 使用轨迹(哪些表被 JOIN、哪些列被一起查询)。静态治理提供基线地图,轨迹证据丰富和校准地图——两者相辅相成。当前只实现了静态治理方向,轨迹驱动方向将在路线图中推进。 + +--- + +## 3. 系统架构 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 用户 / AI Agent │ +└──────────────┬──────────────────────────────┬───────────────────┘ + │ CLI (Typer) │ MCP Server (FastMCP) + ▼ ▼ +┌──────────────────────────────────────────────────────────────────┐ +│ builder/pipeline.py │ +│ BuildPipeline (编排层) │ +└──┬────────┬────────┬────────┬────────┬────────┬────────┬─────────┘ + │ │ │ │ │ │ │ + ▼ ▼ ▼ ▼ ▼ ▼ ▼ +connector extractor profiler inferrer mapper graph + │ │ │ │ │ │ + │ │ │ │ │ ├── storage.py (SQLite CRUD) + │ │ │ │ │ └── retrieval.py (查询 API) + ▼ ▼ ▼ ▼ ▼ + CSV/ Table/ Column Join/ Concept/ + DB Column Profile Synonym Entity + 节点 指纹 等推断 映射 +``` + +### 目录结构 + +``` +DataLink/ +├── src/datalink/ +│ ├── cli/ # 命令行入口 +│ ├── mcp/ # MCP Server(Agent 集成) +│ ├── builder/ # 构建流水线编排 +│ ├── connector/ # 数据源连接(文件/数据库) +│ ├── extractor/ # 结构层节点提取 +│ ├── profiler/ # 列统计指纹 +│ ├── inferrer/ # 隐式关系推断 +│ ├── mapper/ # 概念层映射 +│ ├── graph/ # 存储 + 检索 +│ ├── models/ # Pydantic 数据模型 +│ └── config.py # 全局配置 +├── tests/ # 单元测试(每模块对应) +├── datalink_config.json +└── pyproject.toml +``` + +--- + +## 4. 构建流水线(Build Pipeline) + +`BuildPipeline`(`builder/pipeline.py`)是核心编排器,完整构建分 6 步: + +以下步骤描述的是**当前表格数据实现**。未来接入其他类型时,可沿用相同职责划分,但需要为该类型实现适配组件,不能假设现有 `TabularExtractor`、`TabularProfiler` 和列级 Inferrer 可以直接复用。 + +```mermaid +flowchart LR + A[1. Connect
连接数据源] --> B[2. Extract
提取结构节点] + B --> C[3. Profile
列统计指纹] + C --> D[4. Infer
推断隐式关系] + D --> E[5. Map
概念层映射] + E --> F[6. Store
写入 SQLite] +``` + +### Step 1: Connect(连接数据源) + +- `FileConnector`:CSV/Parquet,每个文件视为一张表 +- `DatabaseConnector`:SQLAlchemy 内省 schema、FK、comment,并采样数据 +- 输出:`DatasourceInfo`(tables + sample_data) + +### Step 2: Extract(提取结构层) + +`TabularExtractor` 从 `DatasourceInfo` 生成: + +- `TableNode`:ID 格式 `table:{source}:{table_name}` +- `ColumnNode`:ID 格式 `column:{source}:{table}:{column}` +- 显式边:`contains`(表→列)、`foreign_key`(列→列) + +### Step 3: Profile(列指纹) + +`TabularProfiler` 对每列计算 `ColumnProfile`: + +- 基础统计:null_rate、cardinality、unique_rate +- 类型检测:integer / float / string / datetime 等 +- 字段角色:email_address、identifier、monetary_value 等(规则 + 正则) +- 数值/字符串统计、top_values、直方图、sample_values(供 LLM 使用) + +### Step 4: Infer(推断隐式关系) + +| Inferrer | 边类型 | 逻辑 | +|----------|--------|------| +| `JoinableInferrer` | `joinable` | 跨表列值域重叠率 ≥ 阈值 | +| `SynonymInferrer` | `semantic_synonym` | 同 semantic_type / 列名同义词组 | +| `DistributionInferrer` | `distribution_similar` | 数值/分类分布相似度 | +| `CorrelationInferrer` | `correlated` | 仅对已 joinable 的数值列算 Pearson 相关 | + +### Step 5: Map(概念层映射) + +- `LLMMapper`:**所有列**统一走 LLM 推断 Concept/Entity(有 comment 的列将 comment 作为额外信号带入 prompt) +- `add_table` 时新增 Concept/Entity 通过 `merge_with_existing()` 与已有节点做消歧合并:**Embedding 粗筛 + LLM 精判**两阶段模型推理 + 边重定向 + 去重 + - Embedding 粗筛(可选):计算新旧节点 cosine 相似度,≥阈值的配对进入候选列表 + - LLM 精判(必选):候选列表 + 仅相关已有节点发给 LLM 确认合并决策(embedding 粗筛过滤掉了不相似的已有节点,不再全量注入) + - 未配置 embedding 模型时全量已有节点注入 LLM 判断 + +### Step 6: Store(持久化) + +`GraphStorage` 写入 SQLite(`datalink.db`),并记录 build 元数据。 + +### 构建模式 + +Pipeline 提供三种构建模式 + 三种重建子模式: + +- **`add_datasource` / `add_table`**(添加表/数据源):向图谱添加来自一个数据源的表。不指定表名时处理该数据源的所有表;在空图谱上调用等同于首次构建。**去重校验**:添加前会检查图谱中已存在的表(通过 table ID 匹配,ID = `table:{source}:{table_name}`),已存在的表自动跳过而非重复注册。返回结果中标注 `added_tables`(新添加的)和 `skipped_tables`(跳过的)。如需重新添加已有表,先 `remove_table` 再 `add_datasource`。添加后自动为新节点构建 embedding 向量(如果配置了 embedding 模型)。CLI `datalink add-table`、MCP `add_table`。文件路径在入口处自动 resolve 为绝对路径,确保节点 ID 全局唯一。 +- **`rebuild`**(重建):从 SQLite 中读取所有已有 TableNode 的元数据,自动重新连接数据源。支持三种模式(`--mode/-m`): + - **`full`**(默认):重走完整 pipeline(extract → profile → infer → map → embed)。安全性保证:compute-first → clear-last 模式,旧数据只在新 pipeline 成功后才清除,失败则旧图谱完好保留。CLI `datalink rebuild`、MCP `rebuild(mode="full")`。 + - **`vec`**:只重建 embedding 向量索引。不改变图谱数据、不调用 LLM。适用场景:更换了 embedding 模型配置。CLI `datalink rebuild --mode vec`、MCP `rebuild(mode="vec")`。 + - **`profile`**:重新统计所有 table/column 的 profile 值,并重建依赖 profile 的推断边(joinable、distribution_similar、semantic_synonym、correlated)。不涉及概念层、不调用 LLM。适用场景:数据源数据量变化但概念结构不变。CLI `datalink rebuild --mode profile`、MCP `rebuild(mode="profile")`。 +- **`remove_table`**(删除):级联删除表/列/边/Profile/embedding,可选清理孤立 Concept/Entity;同时清理引用已移除节点的 pending edges。孤立概念节点清理分两阶段:Stage 1 删除无结构层 `represents` 锚定的 Concept;Stage 2 删除无存活 Concept 连接的 Entity。`has_concept` 边(概念层内部)不算锚定。CLI `datalink remove-table`、MCP `remove_table`。 + +Pipeline 内部还有一个 `init_build` 方法(先 clear_all 再调 add_datasource),供需要显式清空的场景使用,但 CLI/MCP 入口不需要它——空图谱上 `add-table` 自然就是初始构建。 + +--- + +## 5. 模块详解 + +### 5.1 connector — 数据源连接 + +| 文件 | 职责 | +|------|------| +| `base.py` | 抽象接口:`connect`, `get_datasource_info`, `get_sample_data`, `disconnect` | +| `file.py` | CSV/Parquet,Pandas 读入,无 FK | +| `database.py` | SQLAlchemy 内省,支持 PostgreSQL/MySQL/SQLite 等。**Driver fallback**:裸 scheme(`mysql://`)自动尝试 `pymysql` 等替代驱动;MySQL/MariaDB/SQLite 不做 URL path dot 拆分,且默认 `schema_name` 清为 `None`(避免 SQLite 查询 `public.sqlite_master`) | + +### 5.2 extractor — 结构提取 + +`tabular.py`:将 `TableInfo` / `ColumnInfo` / `ForeignKeyInfo` 转为图谱节点与显式边,ID 规则稳定可复现。 + +### 5.3 profiler — 统计分析 + +`tabular.py`:基于采样数据生成 `ColumnProfile`,是 inferrer 与 mapper 的共同输入。 + +### 5.4 inferrer — 关系推断 + +四个独立 Inferrer,可单独测试与调参(阈值在 `DataLinkConfig` 中配置)。 + +### 5.5 mapper — 概念映射 + +- **LLMMapper**:所有列统一走 LLM 推理,有 comment 的列将 comment 作为额外信号带入 prompt;批量分析列名、类型、统计、样本值,输出 JSON 结构化 Concept/Entity;`add_table` 时通过 `merge_with_existing` 与已有 Concept/Entity 做消歧合并(Embedding 粗筛 + LLM 精判两阶段模型推理) + +### 5.6 graph — 存储与检索 + +**storage.py**(SQLite): + +- 表:`nodes`, `edges`, `column_profiles`, `metadata`, `pending_edges`, `node_embeddings` +- CRUD、批量写入、`remove_table`、孤立节点清理、`remove_edges_by_types`、embedding CRUD、`get_graph_stats` + +**retrieval.py**(检索 API): + +1. `explore` — **万能检索入口**(query → 格式化文本,内含节点详情 + 关系链 + 数据指纹) + - 内部组合调用 search / get_node / find_paths / profile 等 + - 多维度匹配 + 沿边扩展召回 + - 自适应输出预算 + - focus 参数调节深度 +2. `search_nodes` — 名称/semantic_type 搜索 + 向量相似检索(混合检索,embedding 未配置时退化为纯全文)(辅助方法,不默认暴露给 agent) +3. `get_node` — 节点详情 + 邻接边 + Profile(辅助方法) +4. `find_paths` — BFS 路径发现(辅助方法) +5. `extract_subgraph` — 从指定节点按 hop 扩展子图(辅助方法) +6. `list_datasets` — 列出所有表及统计(辅助方法) + +### 5.7 cli — 命令行 + +CLI 命令与 MCP 工具一一对应: + +| 命令 | 功能 | 对应 MCP | +|------|------|----------| +| `datalink add-table` | 添加表/数据源 | `add_table` | +| `datalink rebuild` | 重建图谱 | `rebuild` | +| `datalink remove-table` | 删除表 | `remove_table` | +| `datalink explore` | 万能检索 | `datalink_explore` | +| `datalink search` | 搜索节点 | `datalink_search_nodes` | +| `datalink get-node` | 节点详情 | `datalink_get_node` | +| `datalink path` | 两节点间路径 | `datalink_find_paths` | +| `datalink extract-subgraph` | 子图扩展 | `datalink_extract_subgraph` | +| `datalink info` | 图谱概览(全局统计) | — | +| `datalink list-datasets` | 表列表 | `datalink_list_datasets` | +| `datalink pending-edges` | 悬空边列表 | `datalink_list_pending_edges` | +| `datalink serve` | 启动 MCP Server | — | +| `datalink config` | 写配置 | — | + +### 5.8 mcp — Agent 集成 + +基于 FastMCP,支持两种 transport 协议: +- **SSE**(传统,默认) — 长连接流式推送 +- **streamable-http**(推荐) — 更稳定,适合长耗时操作 + +启动方式:`datalink serve --transport streamable-http` + +默认暴露(命名与 CLI 一致): + +- `datalink_explore` — 万能检索,一个调用回答整个数据问题 +- `add_table` — 添加表/数据源(table 可选,null 则加全部;空图谱上等于首次构建;已有表自动跳过) +- `rebuild` — 重建图谱(三种模式:full/vec/profile;pipeline 失败时旧数据保留) +- `remove_table` — 删除表 + +可选暴露(通过 `DATALINK_MCP_TOOLS` 环境变量,逗号分隔全名): + +- `datalink_search_nodes` — 精确名称搜索 +- `datalink_get_node` — 单节点详情 +- `datalink_find_paths` — 路径发现 +- `datalink_extract_subgraph` — 子图扩展 +- `datalink_list_datasets` — 表概览 +- `datalink_list_pending_edges` — 悬空边列表 + +示例:`DATALINK_MCP_TOOLS=datalink_search_nodes,datalink_get_node` + +Agent 可通过 MCP 的 `datalink_explore` 自主探索数据关系、规划 JOIN 路径,一次调用即可获得完整上下文。 + +--- + +## 6. 数据模型 + +### 6.1 核心模型(`models/`) + +``` +Node (基类) +├── ColumnNode — table_id, dtype, semantic_type, profile_id, comment +├── TableNode — source, source_type, row_count, column_ids +├── ConceptNode — description, unit, dimension +└── EntityNode — description + +Edge — source_id, target_id, type, confidence, properties + +ColumnProfile — 列统计指纹(dtype, semantic_type, 分布, top_values 等) + +DatasourceConfig / DatasourceInfo — 连接配置与提取结果 +``` + +### 6.2 存储 Schema(`graph/schema.sql`) + +- **nodes**:统一存四类节点,`properties` 为 JSON +- **edges**:带 `confidence`,**无 FK 级联删除**(`INSERT OR REPLACE` on nodes 会触发内部 DELETE+INSERT,级联会破坏已有边关系;边清理由 `remove_table` / `cleanup_orphaned_semantic_nodes` 显式处理) +- **column_profiles**:与 column 节点 1:1,同样无 FK 级联(Profile 清理由 `remove_table` 显式处理) +- **node_embeddings**:node_id → embedding_vector (float32 BLOB) + embedding_model + searchable_text,独立于主表,`rebuild --mode vec` 时重建 +- **metadata**:构建元信息 + +索引覆盖 type、name、source/target、confidence 等常见查询。 + +--- + +## 7. 配置说明 + +配置文件:`datalink_config.json`(也可通过 `DataLinkConfig.load()` 加载) + +| 配置项 | 默认值 | 说明 | +|--------|--------|------| +| `graph_db_path` | `datalink.db` | SQLite 数据库文件名(裸文件名或相对路径自动解析到 `~/.datalink/storage/`,绝对路径原样使用) | +| `sample_size` | 1000 | 采样行数 | +| `confidence_threshold` | 0.3 | 推断边最低置信度 | +| `joinable_overlap_threshold` | 0.1 | JOIN 推断重叠率阈值 | +| `correlation_threshold` | 0.5 | 相关系数绝对值阈值 | +| `llm.model` | `gpt-4o` | LLM 模型名 | +| `llm.api_key` | — | 也可从 `OPENAI_API_KEY` 环境变量读取 | +| `llm.base_url` | `https://api.openai.com/v1` | OpenAI-compatible API 地址 | +| `llm.timeout` | `120.0` | LLM API HTTP 超时秒数(自托管网关 504 时增大此值) | +| `embedding.model` | `""` | Embedding 模型名(空=跳过向量检索和粗筛) | +| `embedding.api_key` | `""` | 空=回退到 `llm.api_key` | +| `embedding.base_url` | `""` | 空=回退到 `llm.base_url` | +| `embedding.similarity_threshold` | `0.75` | Embedding 粗筛 cosine 相似度阈值(同时用于向量检索最低相似度过滤) | +| `embedding.timeout` | `60.0` | Embedding API HTTP 超时秒数 | + +**Embedding 用途**:配置后生效于两个场景: +1. **图谱消歧**(merge_with_existing):embedding 预筛候选合并对,降低 LLM 调用开销(原有功能) +2. **混合检索**(search_nodes / explore):向量相似检索 + 全文检索合并,提升召回率(新功能) + +配置 `embedding.model` 后需运行 `datalink rebuild --mode vec`(或在 `add-table`/`rebuild` 时自动构建)来生成向量索引。更换模型后需再次 `rebuild --mode vec` 以重建向量。 +| `merge_llm_temperature` | `0.0` | merge LLM 调用温度(低=更确定性) | +| `merge_batch_interval` | `10` | 分批推理时每 N 个 batch 才做一次 merge(1=每个 batch 后都 merge) | +| `mapping_batch_size` | `15` | LLM mapping 每批列数(减小如 5 可降低单次推理时间,适合网关 timeout 短的情况) | + +--- + +## 8. 凭证遮蔽 + +DataLink 的节点 ID 和 `source` 属性中会嵌入数据库连接串(如 `postgresql://admin:s3cret@host/db`)。当 DataLink 作为概念层供 Agent 使用时,这些凭证不应该出现在 Agent 的输出中。 + +### 8.1 遵循方案 + +采用 **方案 A**:输出层遮蔽 + 遮蔽 ID → 真实 ID 反查映射。 + +- 不修改 ID 生成和存储方式——数据库中始终使用真实 ID +- 所有检索接口加 `mask_credential=True` 参数,在输出时自动遮蔽连接串中的 `user:pass@` 部分 +- 支持 `dialect+driver` 格式(如 `mysql+pymysql://`、`postgresql+psycopg2://`)的 URL 遮蔽 +- 遮蔽后的 ID 可以作为后续接口的输入——系统维护 `{masked_id → real_id}` 映射并自动反查 +- CLI 命令不做输出遮蔽(人用终端),但支持接收遮蔽 ID 作为输入 + +### 8.2 遮蔽范围 + +`mask_result()` 递归遍历整个输出结构,遮蔽以下字段中的凭证: + +| 字段类型 | 具体字段 | 遮蔽方法 | +|---|---|---| +| ID 字段 | `id`, `source_id`, `target_id`, `other_id`, `table_id`, `profile_id` | `mask_id()` — 识别 ID 格式并精确遮蔽 source 段 | +| 凭证字段 | `source`, `connection_string` | `mask_credentials()` — 遮蔽 userinfo 部分 | +| ID 列表字段 | `column_ids` | 元素逐个 `mask_id()` | +| 嵌套 dict | `properties`, `other_node` | 递归 `mask_result()` | +| 其他字符串值 | 任意 key 的字符串值 | 兜底检测:如果包含 DB URL 模式,用 `_mask_embedded_urls()` 遮蔽 | + +### 8.3 遵循规则 + +``` +postgresql://admin:s3cret@db.example.com:5432/mydb → postgresql://***:***@db.example.com:5432/mydb +postgresql+psycopg2://admin:s3cret@host/db → postgresql+psycopg2://***:***@host/db +mysql+pymysql://root:pass@localhost/testdb → mysql+pymysql://***:***@localhost/testdb +postgresql://user@host/db → postgresql://***@host/db +sqlite:///path/to/db.sqlite → 不变(无 userinfo) +/home/user/data/orders.csv → 不变(不是 DB URL) +``` + +节点 ID 中对应的遮蔽效果: + +``` +table:postgresql://admin:s3cret@host/db:orders → table:postgresql://***:***@host/db:orders +column:postgresql://admin:s3cret@host/db:orders:col → column:postgresql://***:***@host/db:orders:col +table:/home/user/data:orders → 不变 +``` + +### 8.4 实现位置 + +| 文件 | 作用 | +|---|---| +| `datalink/utils/credential.py` | `mask_credentials()`, `mask_id()`, `mask_result()`, `is_masked_id()`, `build_id_mapping()`, `resolve_masked_id()` | +| `datalink/graph/retrieval.py` | 所有公共检索方法加 `mask_credential` 参数;构造时构建 ID 映射;输入参数自动反查 | +| `datalink/mcp/server.py` | 所有检索 MCP 工具加 `mask_credential=True` 参数,传递给 retrieval | + +### 8.5 默认值 + +| 调用方 | `mask_credential` 默认值 | +|---|---| +| MCP 工具 | `True`(Agent 场景安全优先) | +| CLI 命令 | 输出不做遮蔽(传 `mask_credential=False` 给 retrieval),但输入参数支持接收遮蔽 ID | +| `GraphRetrieval` 公共方法 | `True` | + +--- + +## 9. 快速上手指南 + +### 9.1 环境准备 + +```bash +uv sync +``` + +### 9.2 构建图谱 + +```bash +# 从 CSV 目录构建(首次建图谱 = add-table 不传 --table) +datalink add-table --source ./data/ + +# 从数据库构建 +datalink add-table --source "postgresql://user:pass@localhost/mydb" + +datalink info +datalink search "customer" +``` + +### 9.2 重建图谱(数据源内容有变化时) + +```bash +# 完整重建(默认) +datalink rebuild + +# 只重建向量索引(更换了 embedding 模型时) +datalink rebuild --mode vec + +# 只重算统计值 + 依赖统计的推断边(数据量变化但业务含义不变时) +datalink rebuild --mode profile +``` + +### 9.3 增量添加数据源 + +```bash +# 只添加指定表 +datalink add-table --source ./new_data/ --table new_orders + +# 添加整个数据源的所有表 +datalink add-table --source "postgresql://user:pass@localhost/another_db" +``` + +### 9.4 探索关系 + +```bash +# 查找两列之间的连接路径 +datalink path --from column:source:orders:customer_id --to column:source:users:id +``` + +### 9.5 Agent 集成 + +```bash +datalink serve --port 8080 +``` + +在 Cursor 等支持 MCP 的 IDE 中配置 DataLink MCP Server,Agent 即可调用 `add_table`、`rebuild`、`explore` 等工具。 + +### 9.6 开发调试 + +```bash +# 运行测试 +uv run pytest + +# 代码检查 +uv run ruff check src tests +``` + +测试覆盖:`test_connector`, `test_extractor`, `test_profiler`, `test_inferrer`, `test_mapper`, `test_builder`, `test_storage`, `test_retrieval`, `test_cli`, `test_models`。 + +--- + +## 10. 典型使用场景 + +### 场景 1:Agent 找「收入」相关字段 + +1. `search_nodes("revenue")` → 命中 Concept 或 Column +2. `get_node(concept_id)` → 查看 `represents` 边指向哪些 Column +3. `list_datasets()` → 确认字段所在表 + +### 场景 2:跨表 JOIN 规划 + +1. 定位两表的关键列 ID +2. `find_paths(col_a, col_b, edge_types="joinable,foreign_key,semantic_synonym")` +3. 按 confidence 选择 JOIN 路径 + +### 场景 3:增量接入新数据源 + +```bash +# 添加整个数据源(不指定表名) +datalink add-table --source "postgresql://user:pass@localhost/another_db" + +# 或者只添加一张表 +datalink add-table --source ./new_data/ --table new_orders +``` + +新表会与已有图谱做 joinable/synonym 等联合推断。 + +### 场景 4:数据更新后刷新图谱 + +```bash +# 完整重建(数据大幅变化) +datalink rebuild + +# 只重算统计 + 推断边(数据量变化但概念结构不变) +datalink rebuild --mode profile + +# 只重建向量(更换了 embedding 模型) +datalink rebuild --mode vec +``` + +系统自动从已有元数据中读取数据源信息,重新连接并重建。不需要重新告诉系统数据在哪。 + +--- + +## 11. 扩展点 + +| 扩展方向 | 建议入口 | +|----------|----------| +| 新数据源(JSON、API) | 实现 `BaseConnector` 子类 | +| 新节点类型(如 Dashboard) | 扩展 `NodeType` + extractor | +| 新推断规则 | 在 `inferrer/` 新增 Inferrer,在 pipeline 中注册 | +| 上下文边(查询共现) | 实现 `EdgeType.CO_OCCURS_IN_QUERY` 等 | +| 向量相似检索 | 在 `GraphRetrieval.search_nodes` 中增加 embedding | +| 非 SQLite 存储 | 实现与 `GraphStorage` 等价的存储层 | + +--- + +## 11. 关键代码入口 + +| 用途 | 文件 | +|------|------| +| 构建编排 | `src/datalink/builder/pipeline.py` | +| CLI 入口 | `src/datalink/cli/main.py` | +| MCP 入口 | `src/datalink/mcp/server.py` | +| 图谱查询 | `src/datalink/graph/retrieval.py` | +| 数据模型 | `src/datalink/models/` | +| 配置 | `src/datalink/config.py` | + +--- + +## 12. 已知限制 + +1. 当前只有表格数据连接器、Table/Column 结构节点、列画像和列级关系推断;PPT、PDF、Markdown、图片等类型尚未实现。 +2. 文件源无 FK,跨文件关系依赖 inferrer;数据库源的跨数据源 FK 会存入 pending_edges 表,在新数据源加入后自动 resolve。 +3. LLM 映射需要 API Key;无 Key 时整个业务概念层为空(Concept/Entity 都不生成)。 +4. CorrelationInferrer 在 `add_table` 流程中不调用——无法获取已有表的 sample_data(未持久化)来做跨表 merge。 +5. 上下文边(C 类)尚未实现。 +6. 永远无法 resolve 的 pending edges(目标数据源永不加入数据地图)仅作为 suggested_edges 展示,不参与路径发现和子图扩展。 diff --git a/services/datalink/docs/DL-en.png b/services/datalink/docs/DL-en.png new file mode 100644 index 00000000..cbe3157e Binary files /dev/null and b/services/datalink/docs/DL-en.png differ diff --git a/services/datalink/docs/DL-zh.png b/services/datalink/docs/DL-zh.png new file mode 100644 index 00000000..cbe3157e Binary files /dev/null and b/services/datalink/docs/DL-zh.png differ diff --git a/services/datalink/docs/DataMap.png b/services/datalink/docs/DataMap.png new file mode 100644 index 00000000..090879d4 Binary files /dev/null and b/services/datalink/docs/DataMap.png differ diff --git a/services/datalink/docs/INTERFACE.md b/services/datalink/docs/INTERFACE.md new file mode 100644 index 00000000..5137a938 --- /dev/null +++ b/services/datalink/docs/INTERFACE.md @@ -0,0 +1,751 @@ +# DataLink 对外接口使用说明 + +本文档列出所有外界能感知的接口——CLI 命令、REST API 和 MCP 工具,包括参数、行为、配置方式和典型用法。 + +--- + +## 接口分层 + +DataLink 的接口分为两层: + +- **在线检索**:agent 在对话中即时查询数据图谱。通过 MCP 工具(`datalink_explore`)或 REST API 读操作完成。 +- **离线管理**:建图、改图、调试等操作。通过 CLI 命令或 REST API 写操作完成。 + +| 层 | 接口类型 | 典型用途 | +|---|---|---| +| **在线检索** | MCP `datalink_explore`、REST API `/explore` 等 | Agent 查询数据:什么表可以 JOIN、某列是什么意思 | +| **离线管理** | CLI 命令、REST API `/add-table` 等 | 管理员建图、重建索引、调试图谱状态 | + +MCP 默认只暴露 `datalink_explore`(检索),不再包含写操作。写操作通过 REST API 或 CLI 完成。 + +--- + +## CLI 命令 + +通过 `datalink` 命令调用。所有命令均支持 `--db` 指定数据库路径(默认 `datalink.db`)。 + +### 写操作 + +#### `datalink add-table` — 添加表/数据源 + +向图谱添加来自一个数据源的表。这是构建图谱的核心入口。 + +- 传 `--table` → 只添加指定的那张表 +- **不传 `--table` → 添加该数据源的所有表** +- 在空图谱上调用时,效果等同于首次构建 +- **已有表自动跳过**:如果要添加的表已存在于图谱中(同数据源路径 + 同表名),该表会被跳过而非重复注册。返回结果中会标注 `skipped_tables`。如需重新添加,请先 `remove-table` 删除再重新添加。 + +```bash +# 首次建图谱:添加整个 CSV 目录的所有表 +datalink add-table --source ./data/ + +# 首次建图谱:从数据库添加所有表 +datalink add-table --source "postgresql://user:pass@localhost/mydb" + +# 增量添加:只加一张表 +datalink add-table --source ./new_data/ --table new_orders + +# 增量添加:添加整个新数据源的所有表 +datalink add-table --source "postgresql://user:pass@localhost/another_db" +``` + +| 参数 | 必填 | 默认值 | 说明 | +|---|---|---|---| +| `--source` / `-s` | 是 | — | 数据源路径:CSV/Parquet 文件/目录,或数据库连接串 | +| `--table` / `-t` | 否 | `""` | 表名(空 = 添加所有表) | +| `--db` / `-d` | 否 | `datalink.db` | 图谱数据库路径 | + +此命令通过 `DataLinkConfig.load()` 读取配置,`datalink_config.json` 中的阈值和 LLM 设置都会生效。 + +数据源类型自动检测:以 `postgresql://` / `postgresql+psycopg2://` / `mysql://` / `mysql+pymysql://` 等(支持 `dialect+driver://` 格式)开头 → 数据库;以 `.csv` 或目录结尾 → CSV;以 `.parquet` / `.pq` 结尾 → Parquet。 + +数据库驱动自动回退:如果连接串使用裸 scheme(如 `mysql://`)未指定 driver,SQLAlchemy 默认使用 `mysqlclient`(C 扩展)。若该驱动未安装,系统自动尝试已安装的替代驱动(`mysql://` → `mysql+pymysql://`,`postgresql://` → `postgresql+psycopg2://`)。建议直接使用 `dialect+driver://` 格式(如 `mysql+pymysql://`)以确保确定性。 + +数据库 schema 处理: +- PostgreSQL:默认使用 `public` schema;如需指定其他 schema,可在连接串的数据库名后加 `.schema_name`(如 `postgresql://user:pass@host/mydb.myschema`)。 +- MySQL/MariaDB:不使用 PostgreSQL 式 schema,URL path 就是数据库名(如 `mysql://.../appdb`)。系统不会对 MySQL URL path 中的 dot 做 schema 拆分——连接串 `/appdb.codex_install_test` 不是合法的 MySQL 用法。要添加特定表,使用 `--table` 参数。 +- SQLite:使用 SQLAlchemy 连接串,例如 `sqlite:////absolute/path/to/file.sqlite`(Linux 绝对路径 4 个斜杠)或 `sqlite:///C:/path/to/file.db`(Windows)。文件名中的 `.db` / `.sqlite` 不会被当成 schema;连接后 `schema_name` 自动为 `None`。表名含连字符等特殊字符时,COUNT/采样 SQL 会自动加引号。 + +#### `datalink rebuild` — 重建图谱 + +从已有数据源重建数据地图。**不需要传任何数据源信息**,系统会从 SQLite 中读取所有已有表的元数据。 + +三种重建模式: + +- **`full`**(默认):清空图谱,重走完整 pipeline(extract → profile → infer → map → embed)。这是原来的 rebuild 行为,现在也包括 embedding 向量构建。适用场景:数据源内容大幅变化、需要全面刷新。 + +- **`vec`**:只重建 embedding 向量索引。不改变图谱数据、不调用 LLM——仅用当前配置的 embedding 模型重新计算所有节点的可检索文本向量。适用场景:用户更换了 embedding 模型,需要更新向量索引。 + +- **`profile`**:重新统计所有 table/column 的 profile 值,并重建依赖 profile 的推断边(joinable、distribution_similar、semantic_synonym、correlated)。不涉及概念层(REPRESENTS/HAS_CONCEPT)、不调用 LLM。适用场景:数据源的数据量有变化但概念结构不需要更新——profile 变化可能影响推断边(如枚举值变了 → joinable overlap 变了 → 边可能增减),所以 profile 模式会一并重建这些边。 + +**安全性保证(mode=full)**:旧数据只在新 pipeline 成功完成后才会被清除。如果 pipeline 失败(如 LLM 超时),旧图谱数据完好保留,再次执行 rebuild 即可恢复。 + +```bash +# 完整重建(默认) +datalink rebuild + +# 只重建向量索引 +datalink rebuild --mode vec + +# 只重算统计值 +datalink rebuild --mode profile + +# 指定数据库路径 +datalink rebuild --db /path/to/graph.db +``` + +| 参数 | 必填 | 默认值 | 说明 | +|---|---|---|---| +| `--mode` / `-m` | 否 | `full` | 重建模式:`full`、`vec`、`profile` | +| `--db` / `-d` | 否 | `datalink.db` | 图谱数据库路径 | + +此命令通过 `DataLinkConfig.load()` 读取配置。如果图谱中没有任何表,会报错提示先用 `add-table` 初始化。 + +`mode=vec` 需要配置了 embedding 模型才能使用,否则会报错提示先配置 `embedding.model`。 + +#### `datalink remove-table` — 删除表 + +从图谱中删除一张表及其所有列、边、Profile,可选清理孤立的 Concept/Entity。孤立清理分两阶段:先删无结构层 `represents` 锚定的 Concept,再删无存活 Concept 连接的 Entity(`has_concept` 边不算锚定)。 + +```bash +datalink remove-table --table orders +datalink remove-table --table "table:csv:orders" +``` + +| 参数 | 必填 | 默认值 | 说明 | +|---|---|---|---| +| `--table` / `-t` | 是 | — | 表名或完整表 ID(以 `table:` 开头) | +| `--db` / `-d` | 否 | `datalink.db` | 图谱数据库路径 | + +传表名时会自动查找对应 ID。删除后自动清理引用已移除节点的 pending edges。 + +### 读操作 + +#### `datalink show` — 展示完整图谱 + +读取当前图谱数据库中的所有节点和边,以 JSON 格式输出。适合导出图谱数据、调试、或快速查看整个图谱状态。 + +```bash +datalink show +datalink show --db /path/to/graph.db +``` + +| 参数 | 必填 | 默认值 | 说明 | +|---|---|---|---| +| `--db` / `-d` | 否 | `datalink.db` | 图谱数据库路径 | + +CLI 不做凭证遮蔽(终端输出只给操作者本人看)。 + +**注意**:`show` 是离线调试工具,不适合 agent 在对话中使用(输出量可能很大)。REST API `/show` 返回遮蔽后的 JSON。 + +以下读操作 CLI 命令与 REST API 端点一一对应。 + +#### `datalink explore` — 万能检索 + +**推荐优先使用。** 给一个 query,一次返回相关节点详情 + 关系链 + 数据指纹。对应 REST API `/explore`。 + +```bash +datalink explore "revenue customer_id orders" +datalink explore "email" --focus data_profile +datalink explore "how is orders connected to customers" --focus join_paths +``` + +| 参数 | 必填 | 默认值 | 说明 | +|---|---|---|---| +| `query` | 是 | — | 关键词、名称、自然语言描述 | +| `--focus` / `-f` | 否 | 均衡 | 聚焦方向:`join_paths`、`schema`、`data_profile` | +| `--max-nodes` / `-n` | 否 | 自动 | 详情节点上限(0 = 自动按项目大小) | +| `--db` / `-d` | 否 | `datalink.db` | 图谱数据库路径 | + +#### `datalink search` — 搜索节点 + +按名称子串搜索节点,返回位置列表(无详情)。`explore` 通常更好。对应 REST API `/search`。 + +```bash +datalink search "customer" --type column --limit 20 +``` + +| 参数 | 必填 | 默认值 | 说明 | +|---|---|---|---| +| `query` | 是 | — | 搜索关键词 | +| `--type` / `-t` | 否 | 全类型 | 过滤节点类型:`column`、`table`、`concept`、`entity` | +| `--limit` / `-l` | 否 | 10 | 最大结果数 | +| `--db` / `-d` | 否 | `datalink.db` | 图谱数据库路径 | + +#### `datalink get-node` — 节点详情 + +获取单个节点的详细信息,包括邻接边和完整 Profile。对应 REST API `/get-node`。 + +节点 ID 支持短别名格式:`frpm.County Name`、`frpm`、`County Name` 等(自动解析为完整 ID)。 + +节点不存在时命令以退出码 1 终止(而非退出码 0),便于脚本和 Agent 判断。 + +边信息包含方向标识:出边显示为 `→`(当前节点指向对方),入边显示为 `←`(对方指向当前节点)。 + +对于表类型节点,`contains` 边(表→列)不再展开输出——列名列表已包含在 `column_ids` 属性中,展示了完整的列名。 + +```bash +datalink get-node column:csv:orders:customer_id +datalink get-node frpm.County Name +datalink get-node column:csv:orders:customer_id --no-edges +``` + +| 参数 | 必填 | 默认值 | 说明 | +|---|---|---|---| +| `node_id` | 是 | — | 节点 ID(支持完整 ID、遮蔽 ID、短别名) | +| `--edges` / `--no-edges` | 否 | 显示 | 是否包含邻接边信息 | +| `--db` / `-d` | 否 | `datalink.db` | 图谱数据库路径 | + +#### `datalink path` — 两节点间路径 + +查找两个节点之间的连接路径(BFS,按置信度排序)。对应 REST API `/path`。 + +节点 ID 支持短别名格式:`frpm.County Name`、`frpm`、`County Name` 等(自动解析为完整 ID)。 + +```bash +datalink path --from column:csv:orders:customer_id --to column:csv:customers:id +datalink path --from col1 --to col2 --edge-types joinable,foreign_key +datalink path --from frpm.CDSCode --to schools.CDSCode --limit 5 +``` + +| 参数 | 必填 | 默认值 | 说明 | +|---|---|---|---| +| `--from` | 是 | — | 起始节点 ID(支持完整 ID、遮蔽 ID、短别名) | +| `--to` | 是 | — | 目标节点 ID(同上) | +| `--depth` / `-d` | 否 | 3 | 最大路径深度 | +| `--limit` / `-l` | 否 | 3 | 最大返回路径数 | +| `--edge-types` | 否 | 全类型 | 逗号分隔的边类型(如 `joinable,foreign_key`) | +| `--db` | 否 | `datalink.db` | 图谱数据库路径 | + +#### `datalink extract-subgraph` — 子图扩展 + +从指定节点按 hop 扩展子图,返回周围节点和边。对应 REST API `/extract-subgraph`。 + +```bash +datalink extract-subgraph column:csv:orders:customer_id --hops 2 +datalink extract-subgraph "node1,node2,node3" --hops 1 +``` + +| 参数 | 必填 | 默认值 | 说明 | +|---|---|---|---| +| `node_ids` | 是 | — | 逗号分隔的起始节点 ID | +| `--hops` / `-h` | 否 | 2 | 扩展层数 | +| `--db` / `-d` | 否 | `datalink.db` | 图谱数据库路径 | + +#### `datalink info` — 图谱概览 + +显示图谱统计:节点/边/Profile 数量,按类型分布。对应 REST API `/info`。 + +```bash +datalink info +datalink info --db /path/to/graph.db +``` + +| 参数 | 必填 | 默认值 | 说明 | +|---|---|---|---| +| `--db` / `-d` | 否 | `datalink.db` | 图谱数据库路径 | + +#### `datalink list-datasets` — 表列表 + +列出所有表/数据集及其基本统计(列数、边数)。对应 REST API `/list-datasets`。 + +```bash +datalink list-datasets +datalink list-datasets --db /path/to/graph.db +``` + +| 参数 | 必填 | 默认值 | 说明 | +|---|---|---|---| +| `--db` / `-d` | 否 | `datalink.db` | 图谱数据库路径 | + +#### `datalink pending-edges` — 悬空边列表 + +列出引用尚未入库节点的边(典型:跨数据源 FK)。对应 REST API `/pending-edges`。 + +```bash +datalink pending-edges --type foreign_key +datalink pending-edges --node column:csv:orders:region +``` + +| 参数 | 必填 | 默认值 | 说明 | +|---|---|---|---| +| `--node` / `-n` | 否 | 全部 | 过滤涉及某节点的 pending edges | +| `--type` / `-t` | 否 | 全类型 | 过滤边类型(如 `foreign_key`、`joinable`) | +| `--limit` / `-l` | 否 | 50 | 最大结果数 | +| `--db` / `-d` | 否 | `datalink.db` | 图谱数据库路径 | + +### 服务与配置 + +#### `datalink serve` — 启动 MCP Server + +启动 MCP Server 供 AI Agent 连接。默认只暴露 `datalink_explore`(纯检索)。 + +```bash +# 默认 SSE transport(传统模式) +datalink serve --port 8080 + +# 推荐 streamable-http transport(更稳定) +datalink serve --port 8080 --transport streamable-http +``` + +| 参数 | 必填 | 默认值 | 说明 | +|---|---|---|---| +| `--port` / `-p` | 否 | 8080 | 监听端口 | +| `--db` / `-d` | 否 | `datalink.db` | 图谱数据库路径 | +| `--transport` / `-t` | 否 | `sse` | Transport 协议:`sse`(传统)或 `streamable-http`(推荐) | + +#### `datalink api` — 启动 REST API Server + +启动 REST API Server,提供所有 CLI 命令的 HTTP 等价接口。 + +```bash +# 默认端口 8081(避免和 MCP 的 8080 冲突) +datalink api + +# 指定端口和绑定地址 +datalink api --port 9000 --host 127.0.0.1 +``` + +| 参数 | 必填 | 默认值 | 说明 | +|---|---|---|---| +| `--port` / `-p` | 否 | 8081 | 监听端口 | +| `--host` | 否 | `0.0.0.0` | 绑定地址(`127.0.0.1` = 仅本地) | + +#### `datalink config` — 写配置 + +修改 `datalink_config.json`,仅传入有值的字段会被更新。 + +```bash +datalink config --llm-model deepseek-chat --llm-base-url https://api.deepseek.com/v1 +datalink config --mcp-tools "datalink_search_nodes,datalink_get_node" +datalink config --db /data/my_graph.db +``` + +| 参数 | 必填 | 默认值 | 说明 | +|---|---|---|---| +| `--llm-model` | 否 | 不改 | LLM 模型名 | +| `--llm-api-key` | 否 | 不改 | LLM API Key(也可用 `OPENAI_API_KEY` 环境变量) | +| `--llm-base-url` | 否 | 不改 | LLM API 地址(OpenAI-compatible) | +| `--db` / `-d` | 否 | 不改 | 图谱数据库路径 | +| `--mcp-tools` / `-t` | 否 | 不改 | MCP 辅助工具白名单(逗号分隔全名,空字符串 = 仅核心工具) | + +--- + +## REST API + +通过 `datalink api` 启动 REST API server。所有端点名称与 CLI 命令保持一致。 + +### 凭证遮蔽 + +REST API 的所有检索端点默认启用凭证遮蔽(和 MCP 一致)。数据库连接串中的用户名和密码会被替换为 `***`。 + +`/show` 端点也默认遮蔽。`/config` 端点的 `GET` 响应中 API key 被遮蔽为 `***`。 + +### 写操作(离线管理) + +#### `POST /add-table` — 添加表/数据源 + +与 CLI `datalink add-table` 等价。 + +```json +{ + "source": "./data/", + "table": null, + "source_type": "csv" +} +``` + +```json +{ + "source": "postgresql://user:pass@localhost/mydb", + "table": "products", + "source_type": "database", + "schema_name": null +} +``` + +| 参数 | 类型 | 必填 | 默认值 | 说明 | +|---|---|---|---|---| +| `source` | string | 是 | — | 数据源路径或数据库连接串 | +| `table` | string | 否 | null | 指定单表名,null → 添加所有表 | +| `source_type` | string | 否 | `"csv"` | `"csv"`、`"parquet"`、`"database"` | +| `schema_name` | string | 否 | null | 数据库 schema 名。PostgreSQL 默认 `public` | + +返回:JSON,含 `status`(`"success"` 或 `"skipped"`)、`added_tables`、`skipped_tables`、`stats`。 + +#### `POST /rebuild` — 重建图谱 + +与 CLI `datalink rebuild` 等价。 + +```json +{"mode": "full"} +``` + +```json +{"mode": "vec"} +``` + +```json +{"mode": "profile"} +``` + +| 参数 | 类型 | 必填 | 默认值 | 说明 | +|---|---|---|---|---| +| `mode` | string | 否 | `"full"` | `"full"`、`"vec"`、`"profile"` | + +返回:JSON,含 `status` 和对应模式的统计信息。 + +#### `POST /remove-table` — 删除表 + +与 CLI `datalink remove-table` 等价。支持传表名(自动查找 ID)或完整表 ID。 + +```json +{ + "table_id": "orders", + "cleanup_orphans": true +} +``` + +| 参数 | 类型 | 必填 | 默认值 | 说明 | +|---|---|---|---|---| +| `table_id` | string | 是 | — | 表名或完整表 ID | +| `cleanup_orphans` | bool | 否 | true | 是否清理孤立 Concept/Entity | + +返回:JSON,含 `status`、`removed_columns`、`removed_orphans`、`stats`。 + +#### `GET /show` — 展示完整图谱 + +与 CLI `datalink show` 等价,但返回遮蔽后的 JSON。 + +无参数。 + +返回遮蔽后的 JSON:`{"nodes": [...], "edges": [...]}`。 + +### 读操作(在线检索) + +#### `POST /explore` — 万能检索 + +与 CLI `datalink explore` 等价。**Agent 应优先调用此端点。** + +```json +{ + "query": "revenue customer_id orders", + "focus": "join_paths", + "max_nodes": null +} +``` + +| 参数 | 类型 | 必填 | 默认值 | 说明 | +|---|---|---|---|---| +| `query` | string | 是 | — | 关键词、名称、自然语言描述 | +| `max_nodes` | int | 否 | null | 详情节点上限(null → 自动) | +| `focus` | string | 否 | null | `"join_paths"`、`"schema"`、`"data_profile"` 或 null | + +返回:格式化文本(不是 JSON),和 MCP `datalink_explore` 返回格式一致。 + +#### `POST /search` — 搜索节点 + +```json +{ + "query": "customer", + "node_type": "column", + "limit": 10 +} +``` + +| 参数 | 类型 | 必填 | 默认值 | 说明 | +|---|---|---|---|---| +| `query` | string | 是 | — | 搜索关键词 | +| `node_type` | string | 否 | null | `"column"`、`"table"`、`"concept"`、`"entity"` | +| `limit` | int | 否 | 10 | 最大结果数 | + +返回:JSON 列表。 + +#### `POST /get-node` — 节点详情 + +```json +{ + "node_id": "column:csv:orders:customer_id", + "include_edges": true +} +``` + +| 参数 | 类型 | 必填 | 默认值 | 说明 | +|---|---|---|---|---| +| `node_id` | string | 是 | — | 节点 ID(完整 ID、遮蔽 ID、短别名均可) | +| `include_edges` | bool | 否 | true | 是否包含邻接边 | + +返回:JSON,含节点详情 + 边列表 + profile + pending edges。对于表类型节点,`contains` 边不再返回(列名列表已包含在 `column_ids` 属性中)。响应大小超过 25000 chars 时自动裁剪低置信度边。 + +#### `POST /path` — 两节点间路径 + +```json +{ + "source_id": "column:csv:orders:customer_id", + "target_id": "column:csv:customers:id", + "max_depth": 3, + "limit": 3, + "edge_types": null +} +``` + +| 参数 | 类型 | 必填 | 默认值 | 说明 | +|---|---|---|---|---| +| `source_id` | string | 是 | — | 起始节点 ID(支持短别名) | +| `target_id` | string | 是 | — | 目标节点 ID(支持短别名) | +| `max_depth` | int | 否 | 3 | 最大路径深度 | +| `limit` | int | 否 | 3 | 最大返回路径数 | +| `edge_types` | string | 否 | null | 逗号分隔的边类型 | + +#### `POST /extract-subgraph` — 子图扩展 + +```json +{ + "node_ids": "column:csv:orders:customer_id,column:csv:orders:order_id", + "max_hops": 2 +} +``` + +| 参数 | 类型 | 必填 | 默认值 | 说明 | +|---|---|---|---|---| +| `node_ids` | string | 是 | — | 逗号分隔的起始节点 ID | +| `max_hops` | int | 否 | 2 | 扩展层数 | + +#### `GET /list-datasets` — 表列表 + +无参数。返回 JSON 列表。 + +#### `POST /pending-edges` — 悬空边列表 + +```json +{ + "node_id": null, + "edge_type": null, + "limit": 50 +} +``` + +| 参数 | 类型 | 必填 | 默认值 | 说明 | +|---|---|---|---|---| +| `node_id` | string | 否 | null | 过滤涉及某节点 | +| `edge_type` | string | 否 | null | 过滤边类型 | +| `limit` | int | 否 | 50 | 最大结果数 | + +#### `GET /info` — 图谱概览 + +无参数。返回 JSON,含节点/边/Profile 统计。 + +### 配置 + +#### `GET /config` — 获取当前配置 + +返回 JSON(API key 被遮蔽为 `***`)。 + +#### `PATCH /config` — 更新配置 + +```json +{ + "llm_model": "deepseek-chat", + "llm_base_url": "https://api.deepseek.com/v1", + "embedding_model": "text-embedding-3-small" +} +``` + +| 参数 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `llm_model` | string | 否 | LLM 模型名 | +| `llm_api_key` | string | 否 | LLM API Key | +| `llm_base_url` | string | 否 | LLM API 地址 | +| `graph_db_path` | string | 否 | 图谱数据库路径 | +| `mcp_tools` | string | 否 | MCP 辅助工具白名单 | +| `embedding_model` | string | 否 | Embedding 模型名 | +| `embedding_api_key` | string | 否 | Embedding API Key | +| `embedding_base_url` | string | 否 | Embedding API 地址 | + +返回更新后的配置 JSON(API key 遮蔽)。仅传入有值的字段会被更新。 + +--- + +## MCP 工具 + +MCP Server 通过 `datalink serve` 启动,供 AI Agent 通过 MCP 协议调用。 + +**默认只暴露 `datalink_explore`**(万能检索)。写操作(add_table、rebuild、remove_table)和 show 已移至 REST API。 + +### 凭证遮蔽 + +所有 MCP 检索工具默认启用凭证遮蔽(`mask_credential=True`)。数据库连接串中的用户名和密码会被替换为 `***`,防止在 Agent 输出中泄露敏感信息。 + +支持裸 scheme 和 `dialect+driver` 格式(如 `postgresql+psycopg2://`、`mysql+pymysql://`)。 + +``` +原始:postgresql://admin:s3cret@db.example.com:5432/mydb +遮蔽:postgresql://***:***@db.example.com:5432/mydb +``` + +遮蔽后的 ID 可以直接作为后续接口的输入参数使用——系统会自动将遮蔽 ID 反查为真实 ID。 + +### 返回格式 + +所有 MCP 工具返回**自然语言文本**(不是 JSON),以节省 token 消耗。文本中的节点引用使用短格式(如 `frpm.County Name`)而非完整 URI ID。每个返回值末尾附一个 ID 映射表,将短名映射回完整 ID,方便后续工具调用时回传 node_id 参数。 + +### 短别名格式 + +所有需要 node_id 参数的 MCP 工具支持短别名输入,无需使用完整 URI ID: + +| 格式 | 示例 | 说明 | +|---|---|---| +| 完整 ID | `column:sqlite:///.../db.sqlite:frpm:County Name` | 原始格式 | +| 遮蔽 ID | `column:sqlite:///***:***@.../db.sqlite:frpm:County Name` | 凭证遮蔽后,自动反查 | +| 短别名 | `frpm.County Name` | table.column 格式,自动解析 | +| 裸名 | `frpm`、`County Name` | 自动搜索匹配 | + +无效格式:`profile:column:...`(Profile 不是独立节点)、带错拼的缩写(如 `schools.FundingType` vs `schools.Funding Type`)。 + +解析失败时返回格式化提示,包含可用的 ID 格式和推荐的替代工具。 + +### 默认暴露 + +#### `datalink_explore` — 万能检索 + +**Agent 应优先调用此工具回答所有数据问题。** + +```json +{ + "query": "revenue customer_id orders", + "focus": "join_paths", + "max_nodes": null +} +``` + +| 参数 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `query` | string | 是 | 关键词、名称或自然语言 | +| `max_nodes` | int | 否 | 详情节点上限(null → 自动) | +| `focus` | string | 否 | `"join_paths"`、`"schema"`、`"data_profile"` 或 null | +| `mask_credential` | bool | 否 | 是否遮蔽凭证(默认 `true`) | + +返回:格式化文本字符串(不是 JSON),按表分组,每列自包含。 + +### 可选暴露(辅助工具) + +以下 6 个工具默认不注册,Agent 不可见。需要通过配置启用。 + +#### 启用方式 + +两种途径,**环境变量优先**: + +1. 环境变量(临时覆盖,适合 CI): + ```bash + DATALINK_MCP_TOOLS=datalink_search_nodes,datalink_get_node datalink serve + ``` + +2. 配置文件(持久化): + ```json` + { "mcp_tools": "datalink_search_nodes,datalink_get_node,datalink_find_paths" } + ``` + 或 CLI: + ```bash + datalink config --mcp-tools "datalink_search_nodes,datalink_get_node" + ``` + +#### 辅助工具列表 + +| 工具名 | 能力 | 说明 | +|---|---|---| +| `datalink_search_nodes` | 按名称/semantic_type 搜索节点 | 返回位置列表(无详情),`explore` 通常更好 | +| `datalink_get_node` | 单节点详情 + 全部邻接边 + 完整 Profile | 需要看某一列完整 profile 时使用 | +| `datalink_find_paths` | 两节点间路径(可指定 edge_type,默认 limit=3) | 需要精确 JOIN 规划时使用 | +| `datalink_extract_subgraph` | 从指定节点按 hop 扩展子图 | 需要精确子图导出时使用 | +| `datalink_list_datasets` | 所有表及统计概览 | 需要全局"项目里有什么数据"时使用 | +| `datalink_list_pending_edges` | 悬空边列表 | 调试跨源 FK 状态时使用 | + +各辅助工具参数详见原文档。 + +--- + +## 接口对照表 + +| 功能 | CLI 命令 | REST API | MCP 工具 | 默认可用 | +|---|---|---|---|---| +| 万能检索 | `datalink explore` | `POST /explore` | `datalink_explore` | ✅ MCP / REST | +| 展示完整图谱 | `datalink show` | `GET /show` | — | ✅ CLI / REST | +| 添加表/数据源 | `datalink add-table` | `POST /add-table` | — | ✅ CLI / REST | +| 重建图谱 | `datalink rebuild` | `POST /rebuild` | — | ✅ CLI / REST | +| 删除表 | `datalink remove-table` | `POST /remove-table` | — | ✅ CLI / REST | +| 搜索节点 | `datalink search` | `POST /search` | `datalink_search_nodes` | ❌ MCP需启用 | +| 节点详情 | `datalink get-node` | `POST /get-node` | `datalink_get_node` | ❌ MCP需启用 | +| 路径发现 | `datalink path` | `POST /path` | `datalink_find_paths` | ❌ MCP需启用 | +| 子图扩展 | `datalink extract-subgraph` | `POST /extract-subgraph` | `datalink_extract_subgraph` | ❌ MCP需启用 | +| 图谱概览 | `datalink info` | `GET /info` | — | ✅ CLI / REST | +| 表列表 | `datalink list-datasets` | `GET /list-datasets` | `datalink_list_datasets` | ❌ MCP需启用 | +| 悬空边 | `datalink pending-edges` | `POST /pending-edges` | `datalink_list_pending_edges` | ❌ MCP需启用 | +| 启动 MCP | `datalink serve` | — | — | ✅ CLI | +| 启动 REST API | `datalink api` | — | — | ✅ CLI | +| 配置 | `datalink config` | `GET/PATCH /config` | — | ✅ CLI / REST | + +--- + +## 配置文件 + +`datalink_config.json`(当前目录),可通过 `datalink config` 命令或手动编辑。 + +```json +{ + "llm": { + "model": "gpt-4o", + "api_key": "", + "base_url": "https://api.openai.com/v1", + "temperature": 0.1, + "max_tokens": 16384, + "timeout": 120.0 + }, + "embedding": { + "model": "", + "api_key": "", + "base_url": "", + "timeout": 60.0, + "similarity_threshold": 0.75 + }, + "merge_llm_temperature": 0.0, + "merge_batch_interval": 10, + "mapping_batch_size": 15, + "graph_db_path": "datalink.db", + "sample_size": 1000, + "confidence_threshold": 0.3, + "joinable_overlap_threshold": 0.1, + "correlation_threshold": 0.5, + "mcp_tools": "" +} +``` + +| 字段 | 默认值 | 说明 | +|---|---|---| +| `llm.model` | `"gpt-4o"` | LLM 模型名 | +| `llm.api_key` | `""` | API Key(也可用 `OPENAI_API_KEY` 环境变量) | +| `llm.base_url` | `"https://api.openai.com/v1"` | OpenAI-compatible API 地址 | +| `llm.temperature` | `0.1` | LLM 温度 | +| `llm.max_tokens` | `16384` | LLM 最大 token 数 | +| `llm.timeout` | `120.0` | LLM API HTTP 超时秒数(自托管网关 504 时增大此值) | +| `embedding.model` | `""` | Embedding 模型名(空=跳过向量检索和粗筛) | +| `embedding.api_key` | `""` | 空=回退到 `llm.api_key` | +| `embedding.base_url` | `""` | 空=回退到 `llm.base_url` | +| `embedding.similarity_threshold` | `0.75` | Embedding 粗筛 cosine 相似度阈值(同时用于向量检索的最低相似度过滤) | +| `embedding.timeout` | `60.0` | Embedding API HTTP 超时秒数 | + +**Embedding 用途**:配置后生效于两个场景: +1. **图谱消歧**(merge_with_existing):embedding 预筛候选合并对,降低 LLM 调用开销(原有功能) +2. **混合检索**(search_nodes / explore):向量相似检索 + 全文检索合并,提升召回率(新功能) + +配置 `embedding.model` 后需运行 `datalink rebuild --mode vec`(或在 `add-table`/`rebuild` 时自动构建)来生成向量索引。更换模型后需再次 `rebuild --mode vec` 以重建向量。 +| `merge_llm_temperature` | `0.0` | merge LLM 调用温度(低=更确定性) | +| `merge_batch_interval` | `10` | 分批推理时每 N 个 batch 才做一次 merge | +| `mapping_batch_size` | `15` | LLM mapping 每批列数(减小如 5 可降低单次推理时间和 prompt 大小,适合网关 timeout 较短的情况) | +| `graph_db_path` | `"datalink.db"` | SQLite 数据库路径 | +| `sample_size` | `1000` | 采样行数 | +| `confidence_threshold` | `0.3` | 推断边最低置信度 | +| `joinable_overlap_threshold` | `0.1` | JOIN 推断重叠率阈值 | +| `correlation_threshold` | `0.5` | 相关系数阈值 | +| `mcp_tools` | `""` | MCP 辅助工具白名单(逗号分隔全名,空 = 仅核心工具) | + +`mcp_tools` 环境变量 `DATALINK_MCP_TOOLS` 优先级高于配置文件,适合临时覆盖。 diff --git a/services/datalink/docs/README.md b/services/datalink/docs/README.md new file mode 100644 index 00000000..c5ab9f80 --- /dev/null +++ b/services/datalink/docs/README.md @@ -0,0 +1,15 @@ +# DataLink 文档索引 + +| 文档 | 说明 | +|------|------| +| [DESIGN.md](./DESIGN.md) | 项目整体架构与设计概览 | +| [INTERFACE.md](INTERFACE.md) | 用户可使用的 CLI 及 MCP 接口 | +| [pipeline/01-connector.md](./pipeline/01-connector.md) | 数据源连接:文件/数据库读取与采样 | +| [pipeline/02-extractor.md](./pipeline/02-extractor.md) | 结构层提取:节点 ID 生成与显式边 | +| [pipeline/03-profiler.md](./pipeline/03-profiler.md) | 列指纹:类型检测、字段角色分类、统计量 | +| [pipeline/04-inferrer.md](./pipeline/04-inferrer.md) | 隐式关系推断:JOIN/同义/分布/相关 | +| [pipeline/05-mapper.md](./pipeline/05-mapper.md) | 概念层映射:元数据与 LLM | +| [pipeline/06-storage-and-retrieval.md](./pipeline/06-storage-and-retrieval.md) | 持久化与图检索算法 | +| [pipeline/07-pipeline-orchestration.md](./pipeline/07-pipeline-orchestration.md) | 构建流水线编排与增量更新 | + +文档按流水线执行顺序编号,建议按序阅读。 diff --git a/services/datalink/docs/pipeline/01-connector.md b/services/datalink/docs/pipeline/01-connector.md new file mode 100644 index 00000000..5c4442c3 --- /dev/null +++ b/services/datalink/docs/pipeline/01-connector.md @@ -0,0 +1,226 @@ +# Connector 模块实现文档 + +**源码路径:** `src/datalink/connector/`
+**输入:** `DatasourceConfig`
+**输出:** `DatasourceInfo`(tables + sample_data) + +Connector 是流水线的第一步,负责从外部数据源读取 schema 元数据并采样行数据,供后续 Profiling 与推断使用。 + +--- + +## 1. 接口契约 + +`BaseConnector` 定义四个生命周期方法: + +| 方法 | 职责 | +|------|------| +| `connect()` | 建立连接 / 加载文件到内存 | +| `get_datasource_info()` | 返回完整 `DatasourceInfo` | +| `get_sample_data(table, n)` | 按表名取 n 行样本 | +| `disconnect()` | 释放连接 / 清空缓存 | + +Pipeline 通过 `_connect_datasource()` 调用:`connect()` → `get_datasource_info()` → `disconnect()`。 + +--- + +## 2. FileConnector(CSV / Parquet) + +**文件:** `connector/file.py` + +### 2.1 路径解析与加载 + +``` +config.path + ├── 单文件 → _read_single_file(path) + │ table_name = path.stem(不含扩展名) + └── 目录 → _read_directory(path) + glob("*.csv") + glob("*.parquet") + glob("*.pq") + 每个文件 → 独立 table,name = stem +``` + +加载逻辑: + +- `.csv` → `pd.read_csv(path)`,全量读入内存 +- `.parquet` / `.pq` → `pd.read_parquet(path)` +- 结果存入 `self._dataframes: dict[str, pd.DataFrame]` + +**注意:** 文件源是**全量加载**,不是流式;大文件会占用较多内存。采样发生在 `get_datasource_info()` 阶段。 + +### 2.2 Schema 推断(`_infer_columns`) + +对每个 DataFrame 的每一列: + +| 字段 | 算法 | +|------|------| +| `name` | `df.columns` 原样 | +| `dtype` | `_classify_dtype(series)` | +| `nullable` | `series.isna().any()` | +| `is_primary_key` | `nunique == len(df) and not nullable`(启发式:全唯一非空列视为 PK) | +| `comment` | 固定 `""`(文件无注释) | +| `foreign_keys` | 固定 `[]` | + +### 2.3 类型分类(`_classify_dtype`) + +按优先级检测: + +1. Pandas 原生 dtype:`integer` / `float` / `boolean` / `datetime` +2. `object` / `string` 列进一步试探: + - `pd.to_numeric(non_null)` 成功 → `numeric_string` + - `pd.to_datetime(non_null)` 成功 → `datetime` + - 否则 → `string` +3. 其他 → `str(series.dtype)` + +### 2.4 采样策略 + +```python +sample_df = df.head(self.config.sample_size) # 默认 1000 +sample_data[name] = sample_df.to_dict(orient="records") +``` + +**特点:** 取**前 N 行**,非随机采样,结果可能受文件排序影响。 + +### 2.5 输出结构 + +每个文件对应一个 `TableInfo`: + +```python +TableInfo( + name=stem, + schema_name="file", + columns=[ColumnInfo, ...], + foreign_keys=[], + row_count=len(df), + source=str(config.path), +) +``` + +`DatasourceInfo.sample_data` 为 `{table_name: [{col: val, ...}, ...]}`。 + +--- + +## 3. DatabaseConnector(关系型数据库) + +**文件:** `connector/database.py`
+**依赖:** SQLAlchemy `create_engine` + `inspect()` + +### 3.1 连接与 schema 选择 + +```python +self.engine = create_engine(config.connection_string) +``` + +支持所有有 SQLAlchemy 驱动的数据库(PostgreSQL、MySQL、SQLite 等),包括 `dialect+driver` 格式(如 `mysql+pymysql://`、`postgresql+psycopg2://`)。 + +**Schema 选择规则:** + +| 数据库类型 | 默认 schema | URL `.schema` 后缀 | 行为 | +|---|---|---|---| +| PostgreSQL | `public` | 支持:`postgresql://user:pass@host/mydb.myschema` → schema=`myschema`,连接到 `mydb` | 指定 schema 后按该 schema 内省;不指定则用 `public` | +| MySQL/MariaDB | `None`(自动) | 不适用 | URL 中的 database 名即为 MySQL 库名,`Inspector.get_table_names(schema=None)` 列出当前库的所有表 | +| SQLite | `None` | 不适用 | SQLite 单文件无 schema 层级 | + +**MySQL / SQLite 注意事项:** `DatasourceConfig.schema_name` 默认值为 `"public"`(PostgreSQL 约定),但 `DatabaseConnector.connect()` 会自动检测 dialect:对 MySQL/MariaDB/SQLite 将 `schema_name` 设为 `None`,避免查询不存在的 `public` schema 导致失败。SQLite 文件路径中的点(如 `.db`、`.sqlite`)也不会被 `_extract_schema_from_url()` 拆成 `db.schema`。 + +URL 中的 `db.schema` 格式通过 `_extract_schema_from_url()` 解析:如果连接串 path 包含 `.`(如 `/mydb.myschema`),则拆分为 database name 和 schema name,重建连接串只含 database name。MySQL/MariaDB/SQLite 跳过此拆分。 + +### 3.2 Schema 内省流程 + +对 `config.schema_name` 下每张表(PostgreSQL 默认 `public`,MySQL/SQLite 为 `None`): + +``` +inspector.get_table_names(schema=config.schema_name) + → 对每张 table_name: + _extract_columns() + _extract_foreign_keys() + _get_row_count() # SELECT COUNT(*) FROM {qualified_table_name} + _get_table_comment() # inspector.get_table_comment() + get_sample_data(table, sample_size) +``` + +`_qualified_table_name()` 对非 `public` schema 的表名加上 schema 前缀(如 `myschema.orders`),`public` schema 和 `None` schema 则直接使用裸表名。**所有拼进原始 SQL 的标识符(表名/schema)都会按方言加引号**,因此带连字符的表名(如 `dacomp-zh-006`)可以正确执行 `COUNT(*)` 与采样查询。 + +### 3.3 列元数据(`_extract_columns`) + +1. `inspector.get_pk_constraint()` → 得到 PK 列集合 +2. `inspector.get_columns(table, schema)` → 每列: + - `name`, `dtype`(SQLAlchemy 类型字符串), `nullable`, `comment` + - `is_primary_key = name in pk_columns` + +### 3.4 外键提取(`_extract_foreign_keys`) + +`inspector.get_foreign_keys()` 返回约束列表,每条转为: + +```python +ForeignKeyInfo( + constraint_name=fk["name"], + source_table=table_name, + source_column=fk["constrained_columns"][0], # 仅取第一列(复合 FK 简化) + target_table=fk["referred_table"], + target_column=fk["referred_columns"][0], +) +``` + +这些 FK 会在 Extractor 阶段转为 `foreign_key` 边(confidence=1.0)。 + +### 3.5 随机采样(`get_sample_data`) + +按方言选择排序子句: + +| 方言 | SQL | +|------|-----| +| postgresql | `ORDER BY RANDOM()` | +| mysql | `ORDER BY RAND()` | +| sqlite | `ORDER BY RANDOM()` | +| 其他 | 无 ORDER(取前 N 行) | + +```sql +SELECT * FROM {table_name} {order_clause} LIMIT {n} +``` + +通过 `pd.read_sql(text(query), engine)` 执行,转为 `list[dict]`。 + +失败时记录 warning 并返回 `[]`,该表后续 Profiling 会被跳过。 + +### 3.6 行数统计 + +```sql +SELECT COUNT(*) FROM {table_name} +``` + +注意:表名未加 schema 前缀,依赖数据库默认 search_path。 + +--- + +## 4. 数据流小结 + +``` +DatasourceConfig + │ + ▼ + connect() ──► 内存 DataFrame / SQLAlchemy Engine + │ + ▼ +get_datasource_info() + │ + ├── tables[]: TableInfo(schema + FK + comments) + └── sample_data{}: 每表最多 sample_size 行 + │ + ▼ + disconnect() + │ + ▼ + DatasourceInfo ──► Extractor / Profiler +``` + +--- + +## 5. 已知限制与影响 + +| 限制 | 对下游的影响 | +|------|-------------| +| 文件源无 FK | 跨表关系完全依赖 JoinableInferrer | +| CSV 取 head 非随机 | Profile 统计可能有偏差 | +| 数据库采样失败返回空 | 该表无 Profile,不参与推断 | +| 复合 FK 只取第一列 | 多列 FK 可能不完整 | +| 文件全量加载 | 大 CSV 内存压力大 | +| `dialect+driver://` URL 需安装对应 Python driver | 如 `mysql+pymysql://` 需 `pip install pymysql`,否则 SQLAlchemy 连接失败 | diff --git a/services/datalink/docs/pipeline/02-extractor.md b/services/datalink/docs/pipeline/02-extractor.md new file mode 100644 index 00000000..3e971477 --- /dev/null +++ b/services/datalink/docs/pipeline/02-extractor.md @@ -0,0 +1,176 @@ +# Extractor 模块实现文档 + +**源码路径:** `src/datalink/extractor/tabular.py`
+**输入:** `DatasourceInfo`
+**输出:** `(list[TableNode], list[ColumnNode], list[Edge])` + +Extractor 将 Connector 输出的原始元数据转换为图谱**结构层**节点和**显式边**(A 类边),不涉及数据值分析。 + +--- + +## 1. 核心类:TabularExtractor + +唯一公开方法: + +```python +def extract(self, datasource_info: DatasourceInfo) -> tuple[list[TableNode], list[ColumnNode], list[Edge]] +``` + +--- + +## 2. ID 生成规则 + +`generate_id(*parts)` 用冒号拼接,保证**确定性**(同一数据源重复构建 ID 不变): + +| 节点/边 | ID 格式 | 示例 | +|---------|---------|------| +| TableNode | `table:{source}:{table_name}` | `table:./data:orders` | +| ColumnNode | `column:{source}:{table}:{column}` | `column:./data:orders:customer_id` | +| contains 边 | `edge:contains:{table_id}:{col_id}` | — | +| foreign_key 边 | `edge:fk:{src_table}:{src_col}:{tgt_table}:{tgt_col}` | — | + +其中 `source = table_info.source or config.path or config.connection_string or config.name or "unknown"`。`table_info.source` 由 Connector 写入(文件源为路径字符串,数据库源为连接字符串),优先使用以确保 ID 确定性。 + +--- + +## 3. 逐表处理流程 + +对每个 `TableInfo`: + +### 3.1 创建 TableNode + +```python +TableNode( + id=table_id, + name=table_info.name, + source=source_name, + row_count=table_info.row_count or 0, + properties={ + "schema_name": table_info.schema_name, + "comment": table_info.comment, + }, +) +``` + +`column_ids` 在创建所有 Column 后回填。 + +### 3.2 创建 ColumnNode + +对每个 `ColumnInfo`: + +```python +ColumnNode( + id=col_id, + name=col_info.name, + table_id=table_id, + dtype=col_info.dtype, + comment=col_info.comment, + properties={ + "nullable": col_info.nullable, + "is_primary_key": col_info.is_primary_key, + "default_value": str(col_info.default_value) or "", + }, +) +``` + +此阶段 `semantic_type`、`profile_id` 为空,由 Pipeline Step 3 在 Profiling 后回填。 + +### 3.3 创建 contains 边 + +每列一条,方向 **Table → Column**: + +```python +Edge( + source_id=table_id, + target_id=col_id, + type=EdgeType.CONTAINS, + confidence=1.0, # 默认值 +) +``` + +### 3.4 创建 foreign_key 边 + +对 `table_info.foreign_keys` 中每条 FK: + +```python +fk_source_id = generate_id("column", source, fk.source_table, fk.source_column) +fk_target_id = generate_id("column", source, fk.target_table, fk.target_column) + +Edge( + source_id=fk_source_id, + target_id=fk_target_id, + type=EdgeType.FOREIGN_KEY, + confidence=1.0, + properties={"constraint_name": fk.constraint_name}, +) +``` + +**方向:** FK 列(source)→ 被引用列(target)。 + +**悬空边处理:** FK 涉及的列若不在本次 extract 范围内(跨 schema 或跨数据源),边不会丢弃而是存入 `pending_edges` 表,标记 `missing_endpoints`(如 `["target"]`)。当目标数据源后续通过 `add_table` 加入图谱时,Pipeline 自动调用 `resolve_pending_edges()`,将两端节点都存在的 pending 边"接合"到 `edges` 主表,参与正常检索和遍历。 + +--- + +## 4. Pipeline 中的属性回填 + +Extractor 本身不写 DB;Pipeline 在 Profiling 后会更新 ColumnNode / TableNode 的 `properties`,供 Storage 序列化: + +**ColumnNode 追加:** + +```python +col.properties["dtype"] = col.dtype +col.properties["semantic_type"] = col.semantic_type # 来自 Profile +col.properties["table_id"] = col.table_id +col.properties["profile_id"] = col.profile_id +col.properties["comment"] = col.comment +``` + +**TableNode 追加:** + +```python +table.properties["source"] = table.source +table.properties["row_count"] = table.row_count +table.properties["column_ids"] = table.column_ids +``` + +Storage 的 `_row_to_node()` 从 `properties` JSON 反序列化回强类型字段。 + +--- + +## 5. 数据流 + +``` +DatasourceInfo + tables[].columns[] ──► ColumnNode[] + tables[] ──► TableNode[] + tables[].foreign_keys[] ──► Edge[FOREIGN_KEY] + (implicit) ──► Edge[CONTAINS] (每列一条) + │ + ▼ +Pipeline Step 3: 用 profile_map 更新 semantic_type / profile_id + │ + ▼ +GraphStorage.add_nodes_batch() + add_edges_batch() +``` + +--- + +## 6. 复杂度 + +设 T 表、C 总列数、F FK 数: + +- 时间:O(T × avg_cols + F) +- 空间:O(T + C + F + C)(contains 边数 = C) + +无跨表比较,纯元数据映射。 + +--- + +## 7. 设计决策 + +| 决策 | 理由 | +|------|------| +| ID 含 source 前缀 | 多数据源合并时避免列名冲突 | +| FK 仅来自 schema | 文件源无 FK,留给 Inferrer | +| 不在 Extractor 做 Profiling | 职责分离,Profile 需 sample_data | +| properties 冗余存储 typed 字段 | SQLite 单表存所有节点,JSON 存变长属性 | diff --git a/services/datalink/docs/pipeline/03-profiler.md b/services/datalink/docs/pipeline/03-profiler.md new file mode 100644 index 00000000..4d3d16a0 --- /dev/null +++ b/services/datalink/docs/pipeline/03-profiler.md @@ -0,0 +1,209 @@ +# Profiler 模块实现文档 + +**源码路径:** `src/datalink/profiler/tabular.py`
+**输入:** `DatasourceInfo`(必须含 `sample_data`)
+**输出:** `list[ColumnProfile]` + +Profiler 对每列采样数据计算**统计指纹(ColumnProfile)**,是 Inferrer 与 Mapper 的核心数据基础。 + +--- + +## 1. 入口:profile_datasource + +``` +对每个 table in datasource_info.tables: + sample_rows = sample_data[table_name] + if empty → warning + skip + df = pd.DataFrame(sample_rows) + 对每个 column in table.columns: + if col_name not in df.columns → skip + profile_column(col_id, col_name, df[col_name], comment) +``` + +`col_id` 与 Extractor 一致:`column:{source}:{table}:{column}`
+`profile.id` = `profile:{col_id}` + +--- + +## 2. profile_column 计算流程 + +对单列 `series`(Pandas Series): + +### 2.1 基础统计 + +```python +non_null = series.dropna() +total_count = len(series) +null_count = series.isna().sum() +null_rate = null_count / total_count +cardinality = non_null.nunique() +unique_rate = cardinality / total_count +dtype = _classify_dtype(series) +value_patterns = _detect_patterns(non_null) +semantic_type = _classify_semantic_type(name, dtype, patterns, non_null) +``` + +### 2.2 数值列扩展统计 + +条件:`pd.api.types.is_numeric_dtype(non_null)` + +```python +numeric_values = pd.to_numeric(non_null, errors="coerce").dropna() +min, max, mean, std, median = ... +distribution_histogram = _numeric_histogram(numeric_values, bins=10) +``` + +直方图:NumPy `histogram(values, bins=10)` → `[{bin_start, bin_end, count}, ...]` + +### 2.3 字符串列扩展统计 + +条件:`dtype == "string"` + +```python +min_length, max_length, avg_length = str_values.str.len().agg(...) +``` + +### 2.4 高频值与样本 + +```python +top_values = _compute_top_values(non_null, top_n=10) +# Counter → [{value, count, fraction}, ...] + +sample_values = non_null.sample(min(5, len), random_state=42).tolist() +``` + +`random_state=42` 保证可复现。 + +--- + +## 3. 类型检测算法(`_classify_dtype`) + +优先级链: + +| 步骤 | 条件 | 结果 | +|------|------|------| +| 1 | `is_bool_dtype` | `boolean` | +| 2 | `is_integer_dtype` + distinct ≤ 2 + values ⊆ {0,1} | `integer_boolean` | +| 3 | `is_integer_dtype`(其他) | `integer` | +| 4 | `is_float_dtype` + distinct ≤ 2 + values ⊆ {0.0,1.0} | `float_boolean` | +| 5 | `is_float_dtype`(其他) | `float` | +| 6 | `is_datetime64_any_dtype` | `datetime` | +| 7 | object/string:90%+ 可转 numeric,全整数 + distinct ≤ 2 + values ⊆ {0,1} | `integer_boolean` | +| 8 | object/string:90%+ 可转 numeric,全整数 | `integer` | +| 9 | object/string:90%+ 可转 numeric | `float` | +| 10 | object/string:90%+ 可转 datetime | `datetime` | +| 11 | object/string | `string` | +| 12 | 其他 | `unknown` | + +**`integer_boolean` 和 `float_boolean` 的设计意图:** + +数据库中布尔列通常存为 BIGINT(0/1),pandas 读出后 dtype 为 `integer`。如果直接标记为 `integer`,下游 JoinableInferrer 会误判这些列的值域高度重叠,产生无意义的 joinable 边(如 `Charter ↔ Charter School (Y/N)`)。 + +通过检测整数列仅含 {0, 1},标记为 `integer_boolean`,下游可以正确地: +- semantic_type → `boolean_flag`(字段角色分类更准确) +- JoinableInferrer → 跳过 boolean 类型列(避免虚假 JOIN 推断) + +--- + +## 4. 值模式检测(`_detect_patterns`) + +仅对 string/object 列,从最多 20 个随机样本(`random_state=42`)匹配正则: + +| pattern_name | 正则(摘要) | 触发条件 | +|--------------|-------------|----------| +| email_pattern | `^[\w.+-]+@[\w-]+\.[\w.-]+$` | 匹配率 > 80% | +| url_pattern | `^https?://` | > 80% | +| date_pattern | `^\d{4}-\d{2}-\d{2}$` | > 80% | +| datetime_pattern | ISO datetime 前缀 | > 80% | +| uuid_pattern | UUID 格式 | > 80% | +| phone_pattern | 电话格式 | > 80% | +| zip_pattern | 美国邮编 | > 80% | + +返回匹配的 pattern_name 列表,供字段角色分类使用。 + +--- + +## 5. 字段角色分类(`_classify_semantic_type`) + +三级决策树: + +### 5.1 值模式优先(最可靠) + +```python +pattern_to_semantic = { + "email_pattern": "email_address", + "url_pattern": "url", + "date_pattern": "date", + "datetime_pattern": "datetime", + "uuid_pattern": "uuid", + "phone_pattern": "phone_number", + "zip_pattern": "postal_code", +} +``` + +### 5.2 列名规则(`SEMANTIC_TYPE_RULES`) + +`SEMANTIC_TYPE_RULES` 包含两类规则(约 30+ 条 `(regex, semantic_type)` 对): + +**值模式规则**(前 8 条,对列值匹配): + +- `^[\w.+-]+@[\w-]+\.[\w.-]+$` → `email_address` +- `^\+?\d{1,3}[-.\s]?\(?\d{1,4}\)?[-.\s]?\d{1,4}[-.\s]?\d{1,9}$` → `phone_number` +- `^https?://[\w./-]+$` → `url` +- 等等 + +**列名规则**(其余条目,对**小写列名**匹配): + +- `.*_id$`, `^id$` → `identifier` +- `.*_amount$`, `^amount$` → `monetary_value` +- `.*_email$` → `email_address` +- `.*_date$`, `.*_time$` → `timestamp` + +完整列表见源码 `SEMANTIC_TYPE_RULES`。 + +### 5.3 Dtype 兜底 + +- `boolean` / `integer_boolean` / `float_boolean` → `boolean_flag` +- `datetime` → `timestamp` +- `unique_rate > 0.9` 且 dtype 为 integer/string → `identifier` +- 否则 → `unknown` + +--- + +## 6. Pipeline 中的 Profile 回写 + +```python +profile_map = {p.column_id: p for p in all_profiles} +for col in all_columns: + profile = profile_map.get(col.id) + if profile: + col.semantic_type = profile.semantic_type + col.profile_id = profile.id + col.properties[...] = ... # 供 Storage 序列化 +``` + +Profile 本身通过 `GraphStorage.add_profiles_batch()` 存入 `column_profiles` 表(properties 为除 id/column_id 外全部字段的 JSON)。 + +--- + +## 7. ColumnProfile 字段用途 + +| 字段 | 使用者 | +|------|--------| +| `semantic_type` | SynonymInferrer, LLMMapper prompt | +| `top_values`, `sample_values` | JoinableInferrer 值域重叠 | +| `dtype` | Joinable/Distribution 类型过滤 | +| `cardinality` | Joinable 跳过高基数列(>10000) | +| `min/max/mean`, `distribution_histogram` | DistributionInferrer, CorrelationInferrer | +| `sample_values` | LLMMapper prompt | + +--- + +## 8. 复杂度与限制 + +- 每列:O(n) 扫描采样行,n ≤ sample_size(默认 1000) +- 全库:O(总列数 × n) +- **限制:** + - 无 sample_data 的表/列被跳过 + - 字段角色规则基于英文列名启发式,中文列名易归为 `unknown` + - top_values 仅反映采样分布,非全量精确统计 diff --git a/services/datalink/docs/pipeline/04-inferrer.md b/services/datalink/docs/pipeline/04-inferrer.md new file mode 100644 index 00000000..a1755cce --- /dev/null +++ b/services/datalink/docs/pipeline/04-inferrer.md @@ -0,0 +1,345 @@ +# Inferrer 模块实现文档 + +**源码路径:** `src/datalink/inferrer/`
+**输入:** `list[ColumnNode]` + `list[ColumnProfile]`(+ 部分模块需 `joinable_edges` / `DatasourceInfo`)
+**输出:** `list[Edge]`(B 类隐式边,confidence < 1.0) + +四个 Inferrer 在 Pipeline Step 4 **顺序独立运行**,结果合并后统一按 `confidence_threshold` 过滤入库。 + +--- + +## 1. 总体比较策略 + +| Inferrer | 比较范围 | 跳过条件 | +|----------|----------|----------| +| JoinableInferrer | 跨表列对 | 同表、高基数、dtype 不兼容 | +| SynonymInferrer | 跨表列对 | 同表 | +| DistributionInferrer | 跨表列对 | 同表、dtype 不同 | +| CorrelationInferrer | join key 所在表的其他数值列对 | 非数值、无 joinable 边、无 sample_data | + +默认两两比较复杂度约为 O(C²),C 为列总数。 + +--- + +## 2. JoinableInferrer + +**文件:** `joinable.py`
+**边类型:** `EdgeType.JOINABLE`
+**配置:** `joinable_overlap_threshold`(默认 0.1),`max_cardinality`(默认 900) + +### 2.1 算法流程 + +``` +1. profile_map = {column_id → ColumnProfile} +2. 按 table_id 分组 columns → table_groups +3. 对每对 (table_a, table_b), table_a ≠ table_b: + 对 col_a ∈ table_a, col_b ∈ table_b: + if cardinality > max_cardinality → skip + if _is_boolean_skip(profile) → skip + if not _compatible_dtypes → skip + overlap = _compute_overlap(profile_a, profile_b) + if overlap >= threshold → 创建边,confidence = overlap +``` + +### 2.2 Boolean 列跳过(`_is_boolean_skip`) + +布尔列(boolean、integer_boolean、float_boolean)的值域极小(如 {0, 1}),与任何其他布尔列 trivially overlap,产生的 joinable 边毫无查询意义。 + +| dtype | 是否跳过 | 原因 | +|------|---------|------| +| `boolean` | ✅ 跳过 | pandas 真布尔类型 | +| `integer_boolean` | ✅ 跳过 | BIGINT(0/1) 存储,实质布尔 | +| `float_boolean` | ✅ 跳过 | FLOAT(0.0/1.0) 存储,实质布尔 | +| `integer` | ❌ 不跳过 | 真整数列(标识符、数值) | +| `string` | ❌ 不跳过 | 字符串列(可能有意义的状态码) | + +示例:`Charter (integer_boolean, values {0,1})` ↔ `Magnet (integer_boolean, values {0,1})` → overlap=100% → 但这是虚假的,因为布尔标记不会是 JOIN key。 + +### 2.3 Dtype 兼容矩阵(`_compatible_dtypes`) + +| dtype_a | dtype_b | 兼容 | +|---------|---------|------| +| integer/float/integer_boolean/float_boolean | integer/float/integer_boolean/float_boolean | ✓ | +| string | string | ✓ | +| datetime/date | datetime/date | ✓ | +| integer/float/integer_boolean/float_boolean | string | ✓(标识符跨类型) | +| 其他组合 | | ✗ | + +注意:boolean 类型的列虽然在兼容矩阵中可匹配,但会在 2.2 的 `_is_boolean_skip` 中被跳过,实际不会产生边。 + +### 2.3 重叠率公式(`_compute_overlap`) + +```python +values_a = {top_values 的 value} ∪ {str(v) for v in sample_values} +values_b = {同上} +intersection = values_a & values_b +overlap_rate = |intersection| / min(|values_a|, |values_b|) +``` + +**含义:** 较小值集合中有多少比例出现在另一列的采样域中。
+**局限:** 基于 top-10 + 最多 5 个样本,非全量 Jaccard;高基数列被跳过。 + +### 2.4 边属性 + +```python +properties = { + "overlap_rate": overlap_rate, + "dtype_a": profile_a.dtype, + "dtype_b": profile_b.dtype, +} +``` + +--- + +## 3. SynonymInferrer + +**文件:** `synonym.py`
+**边类型:** `EdgeType.SEMANTIC_SYNONYM` + +### 3.1 置信度融合(`_compute_confidence`) + +输入信号: + +- `type_match`:两列 `semantic_type` 均非 `unknown` 且相等 +- `name_sim`:`_name_similarity(col_a.name, col_b.name)` +- `group_match`:两列名同属 `SYNONYM_GROUPS` 中某一组 + +决策表: + +| 条件 | confidence | +|------|------------| +| type_match AND (name_sim > 0.5 OR group_match) | **0.95** | +| type_match only | **0.85** | +| group_match only | **0.80** | +| name_sim > 0.7 | **0.60** | +| name_sim > 0.5 | **0.40** | +| 其他 | **0.0**(不建边) | + +### 3.2 列名相似度(`_name_similarity`) + +```python +norm_a = name_a.lower().replace("_", "") +norm_b = name_b.lower().replace("_", "") + +if norm_a == norm_b: return 1.0 + +if 子串包含: + ratio = min(len) / max(len) + return 0.5 + 0.5 * ratio # [0.5, 1.0] + +jaccard = |set(norm_a) ∩ set(norm_b)| / |set(norm_a) ∪ set(norm_b)| # 字符集合 +prefix_score = 公共前缀长度 / max(len) +return max(jaccard, prefix_score) +``` + +**注意:** 非标准 Levenshtein,是字符 Jaccard + 前缀 + 子串启发式。 + +### 3.3 同义词组(`SYNONYM_GROUPS`) + +预定义 17 组常见列名同义词,例如: + +```python +{"customer_id", "user_id", "client_id", "account_id", "person_id"} +{"amount", "value", "total", "sum", "price", "cost", "fee", "charge"} +``` + +匹配时列名转小写、`-` 换 `_` 后直接 membership 检查。 + +--- + +## 4. DistributionInferrer + +**文件:** `distribution.py`
+**边类型:** `EdgeType.DISTRIBUTION_SIMILAR`
+**配置:** `similarity_threshold`(默认 0.5,类内硬编码) + +### 4.1 分 dtype 路由(`_compute_similarity`) + +``` +if both numeric (integer/float) → _numeric_similarity +if both string → _categorical_similarity +if both temporal (datetime/date) → _temporal_similarity +else → 0.0 +``` + +### 4.2 数值相似度(`_numeric_similarity`) + +**范围重叠:** + +```python +range_a = (min_value, max_value) +range_b = (min_value, max_value) +overlap_start = max(range_a[0], range_b[0]) +overlap_end = min(range_a[1], range_b[1]) +if overlap_start > overlap_end: overlap_fraction = 0 +else: + overlap_fraction = (overlap_end - overlap_start) / (max(range) - min(range)) +``` + +**均值相似度:** + +```python +mean_similarity = 1.0 - |mean_a - mean_b| / max(|mean_a|, |mean_b|) +``` + +**最终:** + +```python +similarity = overlap_fraction * 0.6 + mean_similarity * 0.4 +``` + +### 4.3 分类相似度(`_categorical_similarity`) + +```python +values_a = {value: fraction} from top_values +values_b = {value: fraction} from top_values +common = keys_a ∩ keys_b +weighted_overlap = Σ min(values_a[v], values_b[v]) for v in common +similarity = weighted_overlap / (sum(values_a) + sum(values_b)) * 2 + # 等价于 weighted_overlap / (total_weight / 2) +``` + +类似**加权分类分布重叠**(与 histogram 交集相关)。 + +### 4.4 时间相似度(`_temporal_similarity`) + +MVP 简化实现: + +```python +bin_similarity = min(len(hist_a), len(hist_b)) / max(len(hist_a), len(hist_b)) +return bin_similarity * 0.5 +``` + +仅比较直方图 bin 数量比例,**未**比较实际时间范围。 + +--- + +## 5. CorrelationInferrer + +**文件:** `correlated.py`
+**边类型:** `EdgeType.CORRELATED`
+**配置:** `correlation_threshold`(默认 0.5) + +### 5.1 核心设计 + +相关性分析的关键洞察:**correlated 边不应连接 join key 自身,而应连接 JOIN 后对齐的其他数值列**。 + +例如,若 `orders.customer_id ↔ users.id` 是 joinable 边(join key),则有意义的是 JOIN 后 `orders.amount` 和 `users.age` 是否线性相关——而不是 customer_id 和 id 之间的 trivial 相关(它们本就是同一组 ID)。 + +因此算法逻辑为: + +``` +对每条 joinable_edge (key_a ↔ key_b): + 1. 找到 key_a 所属表中其他数值列 (col_a2, col_a3, …) + 2. 找到 key_b 所属表中其他数值列 (col_b2, col_b3, …) + 3. 在 sample_data 上按 key_a = key_b 做 inner merge + 4. 对每对 (col_a2, col_b2) 计算真正的 Pearson r + 5. 若 |r| >= threshold → 创建 correlated 边 (col_a2 ↔ col_b2) +``` + +### 5.2 前置条件 + +| 条件 | 说明 | +|------|------| +| joinable 边 | 必须已有 joinable 边才能确定 join key | +| 数值列 dtype | 两列 dtype 均 ∈ `{integer, float}`,且非 join key 本身 | +| sample_data | 两表的 sample_data 都在 `DatasourceInfo` 中可用 | +| 对齐行数 ≥ 5 | merge 后至少 5 行有效数据才计算 Pearson | + +### 5.3 Pearson 相关算法(`_compute_pearson`) + +真正的样本级 Pearson 相关系数,不是范围比例代理: + +```python +series_a = pd.to_numeric(merged[col_name_a], errors="coerce").dropna() +series_b = pd.to_numeric(merged[col_name_b], errors="coerce").dropna() + +# Align indices after dropna +common_idx = series_a.index ∩ series_b.index +if len(common_idx) < MIN_ALIGNED_ROWS: # 默认 5 + return None + +corr = series_a.loc[common_idx].corr(series_b.loc[common_idx], method="pearson") +``` + +### 5.4 列名冲突处理(`_find_numeric_columns`) + +`pd.merge` 使用 `suffixes=("_a", "_b")`。列名在两表冲突时自动加 suffix(如 `score_a` / `score_b`),不冲突时保持原名。`_find_numeric_columns` 同时检查带 suffix 和不带 suffix 的名称: + +```python +suffixed_name = f"{col_name}{suffix}" # e.g., "score_a" +if suffixed_name in merged.columns: + merged_name = suffixed_name +elif col_name in merged.columns: # 不冲突,保持原名 + merged_name = col_name +else: + continue # 该列不在 merged 结果中 +``` + +### 5.5 边属性 + +```python +properties = { + "coefficient": corr, # Pearson r 值 + "method": "pearson", # 与实现一致 + "aligned_rows": len(merged), # merge 后对齐行数 + "joinable_edge": edge.id, # 哪条 joinable 边提供了 join key + "join_key_a": key_a_id, # 左侧 join key 列 ID + "join_key_b": key_b_id, # 右侧 join key 列 ID +} +``` + +### 5.6 Pipeline 调用方式 + +`CorrelationInferrer.infer()` 接收 `list[DatasourceInfo]`(而非单个 `DatasourceInfo`),因为跨表 merge 需要所有相关数据源的 sample_data 同时可用: + +```python +# build_full 中: +correlated_edges = correlation_inferrer.infer( + all_columns, all_profiles, joinable_edges, all_ds_infos +) +``` + +**`add_table` 中不调用 CorrelationInferrer**:增量添加新表时无法获取已有表的 sample_data(sample_data 未持久化到图数据库),因此无法做跨表 merge。这是一个已知限制——可通过将 sample_data 存入 metadata 表或重连数据源来解决。 + +--- + +## 6. Pipeline 中的合并与过滤 + +```python +all_inferred_edges = joinable + synonym + distribution + correlated + +filtered_edges = [e for e in all_edges + if e.confidence >= config.confidence_threshold] # 默认 0.3 +``` + +显式边(FK、contains)confidence=1.0,始终保留。 + +--- + +## 7. add_table 时的增量推断 + +新表列与**已有列**合并后重新跑四个 Inferrer,但只保留**至少一端为新列**的边: + +```python +new_column_ids = {c.id for c in new_columns} +def involves_new_column(edge): + return edge.source_id in new_column_ids or edge.target_id in new_column_ids +``` + +避免重复写入已有列对之间的边(Storage 用 INSERT OR REPLACE,重复 ID 会覆盖)。 + +CorrelationInferrer 在 `add_table` 流程中**未**调用——因为无法获取已有表的 `sample_data`(未持久化到图数据库),无法做跨表 merge 来计算真正的 Pearson 相关性。 + +--- + +## 8. 边 ID 命名 + +| 类型 | ID 格式 | +|------|---------| +| joinable | `edge:joinable:{col_a}:{col_b}` | +| synonym | `edge:synonym:{col_a}:{col_b}` | +| distribution | `edge:dist_similar:{col_a}:{col_b}` | +| correlated | `edge:correlated:{col_a}:{col_b}` | + +无向关系:source/target 顺序取决于双重循环的枚举顺序。 diff --git a/services/datalink/docs/pipeline/05-mapper.md b/services/datalink/docs/pipeline/05-mapper.md new file mode 100644 index 00000000..189b68fa --- /dev/null +++ b/services/datalink/docs/pipeline/05-mapper.md @@ -0,0 +1,416 @@ +# Mapper 模块实现文档 + +**源码路径:** `src/datalink/mapper/llm_mapper.py`
+**输入:** `list[ColumnNode]` + `list[ColumnProfile]`(所有列,包括有 comment 的)
+**输出:** `(list[ConceptNode], list[EntityNode], list[Edge])`(D 类跨层边)
+**副输出:** `dict[str, str]`(table_id → inferred_comment,仅对缺少 comment 的表) + +Mapper 有两个职责: +1. 将结构层 Column 映射到概念层 Concept / Entity。**所有列统一走 LLM 推理**——有 comment 的列会将 comment 作为额外信号带入 prompt。 +2. 为缺少 SQL metadata comment 的表生成 LLM 推理描述(`generate_table_comments`)。 + +`add_table` 时新增的 Concept/Entity 需要与图中已有节点做消歧合并(`merge_with_existing`)。 + +--- + +## 1. LLMMapper + +**文件:** `llm_mapper.py` + +### 1.1 整体流程 + +``` +map_columns(columns, profiles) + │ + ├─ ≤15 列 → _map_columns_single() + │ ├─ _build_columns_data() → JSON Lines 文本(含 comment 字段) + │ ├─ MAPPING_PROMPT_TEMPLATE.format(columns_data=...) + │ ├─ _call_llm(prompt) + │ ├─ _parse_response() → _try_parse_json() 渐进修复 + │ └─ _build_nodes_and_edges() + │ + ├─ >15 列 → 分批映射(每批 ≤15 列) + │ ├─ Batch 1 → _map_columns_single() + │ ├─ Batch 2 → _map_columns_single() → merge_with_existing(batch2, accumulated1) + │ ├─ Batch N → ... → merge_with_existing(batchN, accumulated_all) + │ └─ 各批独立成功/失败,渐进合并去重 + │ + └─ 返回合并后的 (concepts, entities, edges) + +add_table 时额外做一次 merge_with_existing 与图中已有节点消歧 +``` + +### 1.2 Prompt 构造(`_build_columns_data`) + +每列一行 JSON,字段: + +```json +{ + "column_id": "column:...", + "column_name": "...", + "table_id": "table:...", + "dtype": "integer", + "semantic_type": "identifier", + "null_rate": 0.0, + "cardinality": 1000, + "unique_rate": 1.0, + "min_value": 1, // 可选 + "max_value": 1000, // 可选 + "mean_value": 500.5, // 可选 + "sample_values": ["..."], // 最多 5 个 + "comment": "..." // ← 关键:有 comment 的列也走 LLM,comment 作为额外信号 +} +``` + +**所有列**都出现在 prompt 中。有 comment 的列在 JSON 中包含 `"comment"` 字段,供 LLM 作为额外信号使用。 + +### 1.3 LLM 调用参数 + +使用 OpenAI SDK 的 Chat Completions API(支持任何 OpenAI 协议的服务): + +```python +from openai import OpenAI + +client = OpenAI(api_key=api_key, base_url=config.base_url) +client.chat.completions.create( + model=config.model, + messages=[ + {"role": "system", "content": "You are a data semantic analyzer. Always respond with valid JSON."}, + {"role": "user", "content": prompt}, + ], + temperature=config.temperature, # 默认 0.1 + max_tokens=config.max_tokens, # 默认 16384 +) +``` + +`_call_llm` 也支持 `temperature` 参数覆盖,用于 merge 判断时使用更低温度(`merge_llm_temperature`,默认 0.0)。 + +### 1.4 期望的 LLM 输出结构 + +```json +{ + "concepts": [{ + "name": "person_identifier", + "description": "...", + "unit": "", + "dimension": "identity", + "columns": ["column:id1", "column:id2"], + "confidence": 0.9 + }], + "entities": [{ + "name": "customer", + "description": "...", + "concept_names": ["person_identifier", "email_address"], + "confidence": 0.85 + }] +} +``` + +Prompt 规则要求:多列可映射同一 Concept;Concept 名应泛化;Entity 聚合多个 Concept。 + +### 1.5 响应解析(`_parse_response` → `_try_parse_json`) + +解析使用渐进式修复策略,对弱模型产生的格式错误 JSON 逐步尝试修复: + +1. **直接解析**:提取 `{`...`}` 子串,`json.loads()` +2. **Markdown 代码块提取**:检测 `` ```json ... ``` `` 包裹 +3. **移除注释**:删除 JS-style 注释(`// ...` 和 `/* ... */`),保留后续结构字符(`}` `]`) +4. **移除尾逗号**:删除 `},` 和 `],` 中的多余逗号 +5. **括号闭合修复**:统计未闭合的 `{` `[`,补上缺失的 `}` `]` + +每步修复后重新尝试 `json.loads`。所有步骤失败才返回 None。 + +校验必须含 `concepts` 和 `entities` 键。 + +### 1.6 图结构物化(`_build_nodes_and_edges`) + +**Concept:** + +```python +ConceptNode( + id=f"concept:{concept_data['name']}", # 按 name 全局唯一 + name=concept_data["name"], + description, unit, dimension from JSON, + properties={"source": "llm_inference", "confidence": ...}, +) +``` + +对每个 `columns[]` 中的 col_id: + +```python +Edge(REPRESENTS, col_id → concept.id, confidence=concept.confidence) +``` + +**Entity:** + +```python +EntityNode( + id=f"entity:{entity_data['name']}", + ... +) +``` + +对每个 `concept_names[]`: + +```python +Edge(HAS_CONCEPT, entity.id → f"concept:{concept_name}", confidence=...) +``` + +**注意:** `has_concept` 边的 target Concept **必须**已在同次 LLM 响应的 concepts 列表中定义,否则边指向不存在的节点。 + +--- + +## 2. Concept/Entity 消歧合并(`merge_with_existing`) + +`add_table` 时,LLM 新产出的 Concept/Entity 可能与图中已有节点含义相同但名称不同。合并步骤确保含义一致的节点不会重复入库。 + +### 2.1 两阶段模型推理 + +合并算法采用 **Embedding 粗筛 + LLM 精判** 的两阶段模型推理: + +| 阶段 | 有 Embedding 配置 | 无 Embedding 配置 | +|------|-------------------|-------------------| +| **阶段 A:Embedding 粗筛** | 计算新旧节点的 cosine 相似度,≥ `similarity_threshold` 的配对进入候选列表 | 跳过,候选列表为空 | +| **阶段 B:LLM 精判** | 候选配对 + 仅相关已有节点(出现在候选对中的)发给 LLM 确认合并 | 全量已有节点发给 LLM 判断所有合并 | +| **失败回退** | LLM 失败 → 不合并,新节点直接保留 | 同上 | + +#### 阶段 A:Embedding 粗筛(`_embedding_prefilter`) + +当 `embedding.model` 配置了模型名时(如 `text-embedding-3-small`): + +1. 对每个 Concept/Entity,用 `name | description | unit | dimension` 组合文本计算 embedding 向量 +2. 计算每个新节点 vs 每个已有节点的 cosine 相似度 +3. 相似度 ≥ `embedding.similarity_threshold`(默认 0.75)的配对进入候选列表 +4. 候选列表传给 LLM 精判阶段作为参考 + +当 `embedding.model` 为空或 API 调用失败时,直接跳过粗筛,候选列表为空,LLM 精判阶段会直接对全量节点做合并判断。 + +#### 阶段 B:LLM 精判(`_llm_merge_judge`) + +无论有没有 Embedding 粗筛,都走 LLM 精判: + +1. 构建 merge prompt,包含: + - 新 Concept/Entity 列表(id + name + description + unit + dimension) + - 已有 Concept/Entity 列表: + - **有 Embedding 候选对时**:仅注入出现在候选对中的已有节点(详细信息),其余不相似的节点完全省略,prompt 中注明只展示了部分已有节点 + - **无 Embedding 候选对时**:全量注入所有已有节点(兜底保证正确性) + - 候选合并配对(来自 Embedding 粗筛,或"无候选——直接判断所有配对") +2. LLM 返回合并方案:`{"merges": [...], "new_kept": [...]}` +3. 每个 merge 条目包含:`new_id`、`existing_id`、`reason`、`confidence` +4. confidence < `confidence_threshold` 的合并被自动过滤掉 +5. LLM 判断考虑含义等价性,而非仅依赖名称相似度 + +Prompt 模板位于 `mapper/prompts/merge_prompt.txt`。 + +### 2.2 合并操作(`_execute_merge_plan`) + +根据 LLM 返回的合并方案执行实际合并: + +**概念合并:** 新概念 A 合并入已有概念 B + +- 保留 B(ID 不变),丢弃 A(不入库) +- B 的 description 取两者中**更长者**(信息量更大) +- B 的 properties 合并(追加 A 的 source 信息等) + +**实体合并:** 同理。 + +### 2.3 边重定向 + +合并节点后,所有引用被合并节点的边需要重定向: + +| 边类型 | 重定向规则 | 示例 | +|--------|------------|------| +| `REPRESENTS` (Column → Concept) | `target_id` 从 A 改为 B | `column:... → concept:customer_id` 改为 `→ concept:person_identifier` | +| `HAS_CONCEPT` (Entity → Concept) | `source_id` 和 `target_id` 都可能需重定向 | `entity:user → concept:customer_id` 改为 `entity:customer → concept:person_identifier` | + +### 2.4 边去重 + +重定向后可能产生 **(source_id, target_id, type)** 三元组完全相同的重复边。去重规则:**每组重复边保留 confidence 最高的一条,丢弃其余**。 + +### 2.5 Concept 属于多个 Entity — 不合并 + +一个 Concept 可以被多个 Entity 的 `has_concept` 边指向,这是多对多关系。只要 (source_id, target_id, type) 三元组不完全相同,边就共存,不做合并。 + +例如: + +``` +entity:customer → concept:person_identifier (边E1) +entity:order → concept:person_identifier (边E2) ← 不同 source_id,不合并 +``` + +但如果合并导致同一个三元组出现两次: + +``` +entity:customer → concept:person_identifier (边E1, confidence=0.9) +entity:customer → concept:person_identifier (边E3, confidence=0.85) ← 合并后重复 +``` + +去重后保留 E1(confidence 更高)。 + +### 2.6 调用时机 + +| 场景 | 是否调用 merge_with_existing | +|------|------| +| `build_full`(图空)单批(≤15 列) | **不调用**——没有已有节点,直接返回 | +| `build_full`(图空)多批(>15 列) | **调用**——每隔 N 个 batch 合并一次(N = `merge_batch_interval`),最后一次必定合并 | +| `add_table`(图已有数据) | **调用**——与已有图节点消歧合并 | + +分批映射的合并流程:不再每个 batch 都 merge,而是**累积 N 个 batch 后做一次 merge**(N 由 `merge_batch_interval` 控制,默认 10)。这样可以大幅减少 merge LLM 调用次数。最后一个 batch 后必定触发 merge,确保所有累积节点都被处理。 + +例如(`merge_batch_interval=2`,6 个 batch): + +``` +Batch 1 → 累积: [person_id] → pending +Batch 2 → 累积: [person_identifier] → merge(interval=2) → person_identifier ≡ person_id → accumulated=[person_id] +Batch 3 → 累积: [email] → pending +Batch 4 → 累积: [email_address] → merge(interval=2) → email_address ≡ email → accumulated=[person_id, email] +Batch 5 → 累积: [order_id] → pending +Batch 6 → 累积: [order_identifier] → merge(最后一个batch) → order_identifier ≡ order_id → accumulated=[person_id, email, order_id] +``` + +当 `merge_batch_interval=1`(默认旧行为)时,每个 batch 都触发 merge。 + +### 2.7 Embedding 配置 + +在 `datalink_config.json` 中配置: + +```json +{ + "embedding": { + "model": "text-embedding-3-small", + "api_key": "", + "base_url": "", + "similarity_threshold": 0.75 + }, + "merge_llm_temperature": 0.0 +} +``` + +| 配置项 | 默认值 | 说明 | +|--------|--------|------| +| `embedding.model` | `""` | 模型名,空=跳过 Embedding 粗筛,纯 LLM 判断 | +| `embedding.api_key` | `""` | 空=回退到 `llm.api_key` | +| `embedding.base_url` | `""` | 空=回退到 `llm.base_url` | +| `embedding.similarity_threshold` | `0.75` | cosine 相似度阈值,低于此的配对不会成为候选 | +| `merge_llm_temperature` | `0.0` | merge LLM 调用温度,低温度确保判断确定性 | +| `merge_batch_interval` | `10` | 分批推理时每 N 个 batch 才做一次 merge(1=每个 batch 都 merge) | + +--- + +## 3. Pipeline 中的调用 + +```python +# Step 5: Concept mapping — all columns via LLM +llm_mapper = LLMMapper(self.config) +all_concepts, all_entities, all_semantic_edges = llm_mapper.map_columns( + all_columns, all_profiles +) + +# add_table 时额外做合并 +if existing_concepts or existing_entities: + all_concepts, all_entities, all_semantic_edges = llm_mapper.merge_with_existing( + all_concepts, all_entities, all_semantic_edges, + existing_concepts, existing_entities, + ) +``` + +与结构边、推断边一并写入 Storage,仍受 `confidence_threshold` 过滤。 + +--- + +## 4. 数据流图 + +``` +ColumnNode + ColumnProfile (all columns, including those with comments) + │ + ▼ + LLMMapper.map_columns() + │ + ├─ ≤15 列 → _map_columns_single → 直接返回 + │ + └─ >15 列 → 分批映射 + 渐进合并 + │ + Batch 1 ─► _map_columns_single ─► concepts/entities/edges + Batch 2 ─► _map_columns_single ─► merge_with_existing(batch2, batch1_accum) + Batch 3 ─► _map_columns_single ─► merge_with_existing(batch3, batch1+2_accum) + ... + │ + ▼ + 返回合并后的 (concepts, entities, edges) + + add_table → merge_with_existing(新结果, 已有图节点) + │ + ├─ _embedding_prefilter (可选) → 候选合并配对 + ├─ _llm_merge_judge → 合并方案 + ├─ _execute_merge_plan → 消歧合并 + 重定向边 + └─ _deduplicate_edges → 去重 + │ + ▼ + GraphStorage.add_nodes_batch(concepts + entities) + GraphStorage.add_edges_batch(semantic_edges) +``` + +--- + +## 5. 失败模式 + +| 情况 | 行为 | +|------|------| +| 无 API Key | LLMMapper 返回空 → 整个概念层为空 | +| LLM 超时/异常 | 记录 error,返回空 | +| JSON 解析失败(单批) | 渐进修复 5 步尝试 → 仍失败则该批次丢弃,其他批次不受影响 | +| JSON 解析失败(所有批次) | 所有概念层为空(只有全部批次失败时才空) | +| Entity 引用未知 concept_name | 产生悬空 has_concept 边 | +| 合并后边重复 | 自动去重,保留 confidence 最高 | +| 跨批次概念/实体重复 | `merge_with_existing` 自动合并,保留先出现的节点 | +| Embedding API 调用失败 | 跳过粗筛,直接走纯 LLM 判断 | +| Embedding 未配置 | 同上——纯 LLM 判断 | +| merge LLM 调用失败 | 不合并,所有新节点直接保留 | + +--- + +## 6. 表描述生成(`generate_table_comments`) + +当表没有 SQL metadata comment 时,`generate_table_comments` 用 LLM 推理出一句描述。 + +### 6.1 调用时机 + +Pipeline Step 5(概念映射)完成后,Step 6(入库)之前。对 init_build 和 add_table 都会调用。 + +### 6.2 输入信号 + +Prompt 向 LLM 提供每张表的信息: +- 表名 + 行数 +- 每列的 name、dtype、semantic_type、null_rate、cardinality +- 每列的 top_values(频率最高的值) +- 每列关联的 concept/entity 名称(如果 Step 5 已推理出来) + +### 6.3 输出格式 + +LLM 返回 JSON:`{"tables": [{"table_id": "...", "comment": "..."}]}` + +每条 comment 限制为一句话(≤200 字符),自动写入 TableNode 的 `properties["comment"]`。 + +### 6.4 跳过条件 + +- 表已有 SQL metadata comment → 不调用 LLM +- 无 API Key → 整批跳过 +- LLM 返回空或解析失败 → 跳过,表无 comment + +### 6.5 Prompt 模板 + +位于 `mapper/prompts/table_comment_prompt.txt`,可独立编辑调整。 + +--- + +## 7. 调优建议 + +- 降低 `llm.temperature`(已默认 0.1)提高映射稳定性 +- 数据库源尽量填写 column comment,LLM 据此做更准确的映射 +- 列数过多时自动分批(每批 15 列),各批独立成功/失败,跨批次合并去重——修改 `_BATCH_SIZE` 可调整批次大小 +- **merge 频率控制**:`merge_batch_interval`(默认 10)决定每隔多少个 batch 才做一次合并。6 个 batch + interval=10 → 只在最后一个 batch 做 1 次 merge(省 5 次 LLM 调用);interval=1 → 旧行为,每个 batch 都 merge。大部分数据源的 batch 数 ≤ 10,设置 interval=10 即可确保只在最后做一次 merge +- merge 合并判断使用 `merge_llm_temperature`(默认 0.0),确保判断确定性 +- 配置 embedding 模型可大幅减少 merge LLM 调用的 token 消耗——粗筛过滤掉不相关的配对,LLM 只需确认少量候选 +- embedding 粗筛阈值 `embedding.similarity_threshold`(默认 0.75)可根据实际效果微调:降低阈值增加候选对(更保守、更少遗漏),提高阈值减少候选对(更激进、LLM 负担更轻) +- 弱模型 JSON 格式问题:`_try_parse_json` 自动修复尾逗号、JS 注释、括号未闭合等常见错误,5 步修复后仍失败则该批次跳过 +- Prompt 模板位于 `mapper/prompts/` 目录下(`mapping_prompt.txt`、`merge_prompt.txt`、`table_comment_prompt.txt`),均可独立编辑调整 diff --git a/services/datalink/docs/pipeline/06-storage-and-retrieval.md b/services/datalink/docs/pipeline/06-storage-and-retrieval.md new file mode 100644 index 00000000..7904e9ec --- /dev/null +++ b/services/datalink/docs/pipeline/06-storage-and-retrieval.md @@ -0,0 +1,539 @@ +# Storage 与 Retrieval 模块实现文档 + +**源码路径:** `src/datalink/graph/`
+**Storage 输入:** 节点、边、Profile 的批量写入
+**Retrieval 输入:** 查询参数 → JSON 可序列化结果或格式化文本 + +--- + +# Part A: GraphStorage + +**文件:** `storage.py` + `schema.sql` + +## A.1 数据库初始化 + +```python +db_path = _resolve_db_path(db_path) # 裸文件名 → ~/.datalink/storage/xxx.db +sqlite3.connect(db_path) +PRAGMA foreign_keys = ON +PRAGMA journal_mode = WAL +executescript(schema.sql) +``` + +**路径解析规则:** + +| 配置值 | 实际路径 | +|--------|----------| +| `"datalink.db"`(裸文件名) | `~/.datalink/storage/datalink.db` | +| `"project_a/main.db"`(相对路径) | `~/.datalink/storage/project_a/main.db` | +| `"/data/graph.db"`(绝对路径) | `/data/graph.db`(原样使用) | + +`~/.datalink/storage/` 目录在首次运行时自动创建。 + +| `remove_edges_by_types` | 批量删除指定类型的边(profile rebuild 时清理旧的推断边) | + +## A.2 表结构 + +| 表 | 主键 | 核心列 | +|----|------|--------| +| `nodes` | `id TEXT` | `type`, `name`, `properties JSON` | +| `edges` | `id TEXT` | `source_id`, `target_id`, `type`, `confidence`, `properties JSON` | +| `column_profiles` | `id TEXT` | `column_id FK→nodes`, `properties JSON` | +| `metadata` | `key TEXT` | `value`, `updated_at` | + +**外键级联:** 删 node → 自动删关联 edges、profiles。 + +### pending_edges 表 + +| 列 | 说明 | +|----|------| +| `id TEXT PK` | 边唯一标识 | +| `source_id TEXT` | 源节点 ID(**无 FK 约束**,允许引用不存在的节点) | +| `target_id TEXT` | 目标节点 ID(**无 FK 约束**,允许引用不存在的节点) | +| `type TEXT` | 边类型(同 EdgeType 枚举值) | +| `confidence REAL` | 置信度 | +| `properties TEXT` | JSON blob | +| `missing_endpoints TEXT` | JSON 数组:`["source"]` / `["target"]` / `["source","target"]`,标记哪端节点缺失 | +| `created_at TIMESTAMP` | 创建时间 | + +**无 FK 约束**是关键设计——`source_id` / `target_id` 是纯 TEXT,允许引用尚未入库的节点 ID。 + +## A.3 节点序列化策略 + +写入时**仅**存四元组 `(id, type, name, properties)`;Column/Table 的 typed 字段(`dtype`, `table_id` 等)由 Pipeline 预先写入 `properties` JSON。 + +读取时 `_row_to_node()` 按 `type` 反序列化为对应 Pydantic 子类: + +```python +NodeType.COLUMN → ColumnNode(table_id=props["table_id"], ...) +NodeType.TABLE → TableNode(source=props["source"], column_ids=props["column_ids"], ...) +NodeType.CONCEPT → ConceptNode(description=props["description"], ...) +NodeType.ENTITY → EntityNode(...) +``` + +## A.4 Profile 序列化 + +```python +props = profile.model_dump(exclude={"id", "column_id"}, mode="json") +INSERT INTO column_profiles (id, column_id, properties) VALUES (...) +``` + +读取:`ColumnProfile(id=..., column_id=..., **props)`。 + +## A.5 批量写入 + +Pipeline 使用: + +- `add_nodes_batch(nodes)` — `executemany` + 单次 commit +- `add_edges_batch(edges)` +- `add_profiles_batch(profiles)` + +策略:`INSERT OR REPLACE`,同 ID 覆盖。 + +## A.6 Pending Edge 操作 + +| 方法 | 说明 | +|------|------| +| `add_pending_edge(edge)` | 写入单条 pending edge | +| `add_pending_edges_batch(edges)` | 批量写入(executemany + 单次 commit) | +| `get_pending_edge(id)` | 查单条 | +| `get_pending_edges_for_node(node_id)` | 查涉及某节点的所有 pending edges | +| `get_all_pending_edges()` | 查全部 | +| `resolve_pending_edges(available_node_ids)` | 核心:扫描 pending_edges,将两端均在 `available_node_ids` 中的边移入 edges 表,删除原 pending 记录 | +| `remove_pending_edge(id)` | 删单条 | +| `cleanup_pending_edges_for_removed_nodes(removed_ids)` | 删除引用已移除节点的 pending edges(永远无法 resolve) | +| `count_pending_edges(edge_type)` | 统计 | + +**`resolve_pending_edges` 算法:** + +``` +1. SELECT * FROM pending_edges +2. 对每条:if source_id ∈ available_node_ids AND target_id ∈ available_node_ids: + → INSERT OR REPLACE INTO edges (same columns) + → DELETE FROM pending_edges WHERE id = ? +3. COMMIT; return resolved_count +``` + +**`cleanup_pending_edges_for_removed_nodes` 算法:** + +对每个被移除的节点 ID,删除 `source_id = nid OR target_id = nid` 的所有 pending edges。这些边永远无法 resolve。 + +## A.7 remove_table 算法 + +``` +1. contains_edges = get_edges_for_node(table_id, CONTAINS) +2. column_ids = [e.target_id for e in contains_edges] +3. all_ids = [table_id] + column_ids +4. 对每个 nid: DELETE edges WHERE source_id=nid OR target_id=nid +5. 对每个 col_id: DELETE column_profiles WHERE column_id=col_id +6. DELETE nodes WHERE id IN column_ids + table_id +7. return column_ids → 供 Pipeline 决定是否 cleanup orphans +``` + +**不**自动删 Concept/Entity;由 `cleanup_orphaned_semantic_nodes()` 处理。 + +## A.8 cleanup_orphaned_semantic_nodes + +两阶段清理,从概念层逐层向外剥离孤立节点: + +**Stage 1 — Concept 清理:** + +```python +anchored_concept_ids = {所有有 represents 边从 Column/Table 指向的 concept ID} +for concept in all_concepts: + if concept not in anchored_concept_ids: + DELETE concept 的所有边 + concept 节点 +``` + +只有被结构层节点(Column/Table)通过 `represents` 边锚定的 Concept 才保留。`has_concept` 入边(来自 Entity)不算锚定——它是概念层内部连接,结构层删了之后没有意义。 + +**Stage 2 — Entity 清理(此时孤立 Concept 已被删除):** + +```python +for entity in all_entities: + outgoing_to_structural = COUNT edges WHERE source=entity AND target IN (Column/Table) + has_concept_to_anchored = COUNT has_concept 边 WHERE target IN anchored_concept_ids + if both == 0: + DELETE entity 的所有边 + entity 节点 +``` + +Entity 通过 `has_concept` 边间接锚定:如果它的 `has_concept` 目标 Concept 在 Stage 1 中存活(被结构层 `represents` 锚定),则 Entity 也保留。 + +## A.9 get_graph_stats + +遍历所有 `NodeType` / `EdgeType` 枚举值分别 `COUNT(*)`,返回汇总 dict(含 0 计数类型)。另外包含 `pending_edge_count` 和 `pending_edge_type_counts`。 + +--- + +# Part B: GraphRetrieval + +**文件:** `retrieval.py` + +## B.1 search_nodes + +**主路径:** `storage.search_nodes_by_name(query, node_type, limit)` + +```sql +SELECT ... FROM nodes WHERE name LIKE '%{query}%' [AND type=?] LIMIT ? +``` + +**扩展路径(Column):** 额外 SQL: + +```sql +SELECT ... FROM nodes +WHERE type='column' AND properties LIKE '%{query}%' LIMIT ? +``` + +对 JSON 做子串匹配,再 `json.loads` 检查 `semantic_type` 是否含 query(小写)。去重后截断至 `limit`。 + +**返回结构:** 每节点含 `id`, `type`, `name`, `properties`, `edge_count`, `edges_summary`(最多 5 条邻接边摘要)。 + +## B.2 get_node + +加载节点 + 全部邻接边;每条边附带 `direction`(`"outgoing"` 或 `"incoming"`,表示对当前节点的方向)和 `other_node`(对端 id/name/type)。 + +- `direction="outgoing"`:当前节点是边的 source(出边) +- `direction="incoming"`:当前节点是边的 target(入边) + +CLI 显示时出边用 `→`,入边用 `←` 标注方向,并显示对方节点名称而非 target_id。 + +Column 类型额外挂载 `profile` 子对象(dtype、semantic_type、null_rate、cardinality、sample_values 等)。 + +**`suggested_edges` 字段:** 返回涉及此节点的所有 pending edges(两端节点缺失的边),每条附带 `missing_endpoints` 和 `note` 人类可读提示。这些边不参与路径发现和子图扩展,仅作为"建议关系"展示。 + +## B.3 find_paths — 路径发现 + +### 目标 + +在 `max_depth` 跳内找从 `source_id` 到 `target_id` 的路径,按**路径置信度积**降序,最多 10 条。 + +路径置信度:`∏ edge.confidence`(各边 confidence 连乘)。 + +### 主算法:SQLite 递归 CTE + +```sql +WITH RECURSIVE paths AS ( + -- 基:起点,空边路径,depth=0,confidence=1.0 + SELECT ?, '[]', 0, 1.0, ? + + UNION ALL + + -- 递归:沿边扩展 + SELECT e.target_id, + json_array_append(p.path_edges, e.id), + p.depth + 1, + p.confidence * e.confidence, + p.source_id + FROM paths p + JOIN edges e ON (e.source_id = p.current_id OR e.target_id = p.current_id) + AND e.source_id != p.current_id + WHERE p.depth < max_depth + AND [edge type filter] + AND cycle prevention (见下) +) +SELECT ... WHERE current_id = target_id ORDER BY confidence DESC LIMIT 10 +``` + +**无向遍历:** 边可从 source 或 target 方向进入,下一跳取 `e.target_id`(实现上对反向边的处理较简化)。 + +**环检测局限:** CTE 中用 `json_each(path_edges)` 存的是 **edge id**,不是 node id,环检测**不完全可靠**。 + +### 降级:`_python_bfs_paths` + +CTE 失败时启用 BFS 队列 `(current_node, edge_path, confidence)`: + +- 扩展邻接边,过滤 `edge_types` +- 用 edge_path 中已有 source/target 集合防环 +- 到达 target 且 path 非空则记录 +- 按 confidence 排序取 top 10 + +Python 版环检测更准确,通常作为 fallback。 + +### 结果组装 + +将 path 中 edge id 序列解析为 `nodes[]` 和 `edges[]` 详情列表。 + +## B.4 extract_subgraph — 子图扩展 + +**层次 BFS(按 hop):** + +``` +visited_nodes = seed node_ids +visited_edges = {} +current_layer = seed set + +for hop in 1..max_hops: + for nid in current_layer: + for edge in get_edges_for_node(nid): + if edge.id not in visited: + visited_edges.add(edge.id) + other = 对端节点 + if other not in visited_nodes: + visited_nodes.add(other) + next_layer.add(other) + current_layer = next_layer +``` + +返回 `nodes[]`, `edges[]`, `stats{node_count, edge_count, hops}`。 + +**无** confidence 过滤、无 edge type 过滤。 + +## B.5 get_pending_edges — 悬空边查询 + +**参数:** `node_id`(可选)、`edge_type`(可选)、`limit` + +**逻辑:** +1. `node_id` → `get_pending_edges_for_node(node_id)`,否则 `get_all_pending_edges()` +2. `edge_type` → Python 侧过滤 +3. 截断至 `limit` + +**返回结构:** 每条 pending edge 含 `id`, `type`, `source_id`, `target_id`, `confidence`, `missing_endpoints`, `note`, `properties`。 + +## B.6 list_datasets + +对所有 `TableNode`: + +```python +column_count = len(CONTAINS edges) +inferred_edge_count = 对每列统计非 CONTAINS/FK 的邻接边数之和 +pending_fk_count = 对每列统计 pending_edges 中 type=foreign_key 的数量 +``` + +返回 `id`, `name`, `source`, `row_count`, `column_count`, `inferred_edge_count`, `pending_fk_count`。 + +--- + +## C. Explore — 万能检索入口 + +**文件:** `retrieval.py` → `GraphRetrieval.explore()` + +### C.1 设计理念 + +DataLink 暴露一个万能检索工具 `datalink_explore`: + +- **单工具入口**:agent 给一个 query,一次调用获得完整上下文 +- **多维度召回**:名称、semantic_type、concept/entity、comment/description 多路匹配 + 沿边扩展 +- **实体自包含输出**:每个列节点自包含所有信息(业务含义 + 数据特征 + 关系 + 查询意义),不再按格式分段 +- **概念层吸收**:Concept/Entity 不作为独立输出节点,它们的描述被吸收进对应列的 meaning 行。搜索阶段仍用 concept/entity 做索引扩展,但输出时只展示 table + column +- **自适应预算**:按 dataset 数量调整输出量 +- **focus 调深**:通过 focus 参数切换输出重心,不需要换工具 + +辅助工具(search_nodes, get_node 等)保留为内部方法,explore 内部调用它们,不默认暴露给 agent。 + +### C.2 方法签名 + +```python +def explore( + self, + query: str, + max_nodes: int | None = None, # None → 自适应预算 + focus: str | None = None, # "join_paths" | "schema" | "data_profile" | None + mask_credential: bool = True, # 是否遮蔽数据库凭证 +) -> str: # 返回格式化文本,不是 JSON dict +``` + +### C.3 内部流程 + +``` +explore(query) + ① _resolve_query(query, limit) → ResolvedNode 列表 + ② get_explore_budget(dataset_count, focus) → ExploreBudget + ③ _build_context(resolved, budget) → NodeContext 列表 + ④ _build_relationship_map(node_ids, budget) → RelationshipMap + (间接路径仅遍历 FK/joinable 边,仅保留跨表路径) + ⑤ _format_output(contexts, rel_map, resolved, budget) → 文本输出 + 过滤掉 concept/entity → 按表分组 → 每列自包含格式化 → 跨表查询模式段 +``` + +### C.4 _resolve_query — 多维度匹配 + 沿边扩展 + +**匹配维度:** + +| 维度 | 覆盖场景 | 实现 | 分数 | +|---|---|---|---| +| 名称精确 | `"orders"` → TableNode:orders | `name.lower() == token.lower()` | 1.0 | +| 名称子串 | `"customer"` → customer_id, customer_name | `LIKE '%token%'` | 0.8 | +| semantic_type | `"email"` → 所有 semantic_type=email 的列 | properties JSON 搜索 | 0.7 | +| comment/description | `"订单金额"` → comment 或 description 包含此文字的节点 | properties JSON 搜索 | 0.5 | + +**沿边扩展(召回关键):** + +| 被匹配节点类型 | 扩展边类型 | 扩展目标 | 分数 | +|---|---|---|---| +| ConceptNode | `represents`(入边) | 所有 ColumnNode | 0.6 × edge.confidence | +| EntityNode | `has_concept`(出边) | 所有 ConceptNode | 0.5 × confidence | +| EntityNode → ConceptNode | `represents` | ColumnNode | 0.4 × 双边 confidence | +| TableNode | `contains` | 所有 ColumnNode | 0.4 | +| ColumnNode | `foreign_key/joinable/semantic_synonym` | 关联 ColumnNode | 0.3 × confidence | + +合并、去重、按 relevance_score 排序,截断至 `budget.max_nodes`。 + +### C.5 ExploreBudget — 自适应输出预算 + +```python +@dataclass +class ExploreBudget: + max_output_chars: int + max_nodes: int + max_edges_per_node: int + max_edges_per_relationship_kind: int + max_sample_values: int + max_columns_per_table: int + max_pending_edges_per_node: int + include_relationships: bool + include_additional_nodes: bool + include_budget_note: bool + include_completeness_signal: bool + include_low_confidence_marker: bool +``` + +| datasets | max_output | max_nodes | max_edges | sample_vals | relationships? | additional? | budget_note? | +|---|---|---|---|---|---|---|---| +| < 3 | 8000 | 8 | 3 | 3 | False | False | False | +| < 10 | 12000 | 12 | 5 | 5 | True | False | True | +| < 50 | 16000 | 15 | 7 | 5 | True | True | True | +| ≥ 50 | 20000 | 20 | 10 | 5 | True | True | True | + +**focus 参数调整:** + +| focus | 效果 | +|---|---| +| `data_profile` | sample_values 翻倍,edges 减半 | +| `join_paths` | edges 翻倍,sample_values 减半,强制 include_relationships | +| `schema` | max_nodes 翻倍,sample_values=0(只给 dtype) | +| None | 均衡(默认) | + +### C.6 _build_context — 节点上下文构建 + +对每个 ResolvedNode: +1. `storage.get_node()` → 基本信息 +2. `storage.get_edges_for_node()` → 邻接边,按类型分组(join/semantic/statistical/contains),每组截断至 `budget.max_edges_per_node` +3. Column 类型 → `storage.get_profile_for_column()` → 关键指标摘录(dtype, semantic_type, null_rate, cardinality, sample_values) +4. `storage.get_pending_edges_for_node()` → 悬空边标注(cap 3 per node) + +### C.7 _build_relationship_map — 关系网络 + +- **直接连接**:resolved 节点之间的边,按类型分组(join/semantic/statistical) +- **间接路径**:resolved 节点间 `find_paths` ≤3 hop,**仅遍历 FK/joinable 边**(不遍历 contains/represents 等结构性边),**仅保留跨表路径**(同一表内的路径无查询价值) + +### C.8 _format_output — 输出格式 + +**核心原则:实体自包含 + 概念层吸收** + +1. 过滤掉 concept/entity contexts — 概念信息通过 `_generate_meaning()` 吸收到列的 meaning 行 +2. 按表分组(`_group_by_table`),每个表一个 `##` 段 +3. 表级 meaning 行:如果表有 comment(来自 SQL metadata 或 LLM 推理),显示为一行 +4. 每列自包含格式化(`_format_column_selfcontained`) +5. 跨表查询模式段(`_format_cross_table_paths`) + +**表级输出格式:** + +``` +## schools — 2 columns, 17,686 rows, source: postgresql://... +- meaning: California schools with eligibility and participation data +``` + +表的 meaning 行只显示表的 comment。没有 comment 的表不显示 meaning 行。 + +**表 comment 的来源:** +- SQL metadata(数据库表注释) — 优先 +- LLM 推理生成(`LLMMapper.generate_table_comments`) — 当 SQL metadata 缺失时的后备 + +**单列输出格式:** + +``` +### customer_id (integer, identifier) +- meaning: foreign key referencing customers.id — use `JOIN orders ON orders.customer_id = customers.id` to link orders with their customer +- data: null 2%, unique 95%, top values: [101, 205, 340] +- also related: synonym↔users.user_id (0.72) — same entity type +- pending foreign_key → ? (Referenced node not yet in graph) +``` + +**meaning 行的组装逻辑:** + +从多个来源组合成一句完整的描述,用 `; ` 连接: +1. column comment(SQL metadata 或 CSV header) +2. concept/entity 吸收(represents 边 → "represents concept 'revenue' (total sales, unit: USD)") +3. FK/joinable 翻译(→ 查询意义,如 "foreign key referencing customers.id — use JOIN ...") + +**also related 行:** + +meaning 行只包含 comment + concept吸收 + FK/joinable。其余关系(synonym, semantic_type_match, correlated, distribution_similar)紧凑列在 also related 行。 + +**data 行的值展示:** + +使用 `top_values`(频率排序、天然不重复)代替随机 `sample_values`。避免高频率值重复出现。 + +**跨表查询模式段:** + +``` +## Cross-table query patterns +orders.customer_id → customers.id (FK, conf 1.00) + SQL: SELECT * FROM orders JOIN customers ON orders.customer_id = customers.id +``` + +只展示跨表的 FK/joinable 连接,附带 SQL JOIN 模板。间接多跳路径在此段展示。 + +**已删除的旧段落:** +- Node semantics 段(含义已内联到 meaning 行) +- Budget note 段(对 agent 无操作意义) +- Additional relevant 段(已内联到 also related) +- 独立 concept/entity 输出段(概念层吸收) + +### C.9 辅助工具启用机制 + +**默认只暴露:** `datalink_explore` + `build_graph` + `add_table` + `remove_table` + +**可选暴露(环境变量 `DATALINK_MCP_TOOLS`):** + +```bash +DATALINK_MCP_TOOLS=datalink_search_nodes,datalink_get_node,datalink_find_paths +``` + +工具名使用全名(不用缩写),避免歧义。 + +| 工具全名 | 能力 | +|---|---| +| `datalink_search_nodes` | 精确名称搜索(位置列表) | +| `datalink_get_node` | 单节点详情 + 全部邻接边 + 完整 profile | +| `datalink_find_paths` | 两节点间路径(可指定 edge_type) | +| `datalink_extract_subgraph` | 从指定节点按 hop 扩展子图 | +| `datalink_list_datasets` | 所有表及统计概览 | +| `datalink_list_pending_edges` | 悬空边列表 | + +--- + +## D. Storage ↔ Retrieval 关系 + +``` +BuildPipeline + → GraphStorage (write) +CLI / MCP + → GraphRetrieval.explore() (万能检索,内部调用 search/get/paths 等) + → GraphRetrieval.search_nodes / get_node / find_paths 等 (辅助方法,供 explore 和 CLI 使用) +``` + +Retrieval **不**直接 SQL 复杂 analytics(除 find_paths CTE),大部分走 Storage 封装方法。 + +--- + +## E. 索引(schema.sql) + +| 索引 | 用途 | +|------|------| +| `idx_nodes_type`, `idx_nodes_name` | 按类型/名称查 | +| `idx_edges_source/target`, `(source,type)`, `(target,type)` | 邻接边、路径遍历 | +| `idx_edges_confidence` | 按置信度过滤(预留) | +| `idx_profiles_column` | 列 → Profile | + +--- + +## F. 已知限制 + +| 项 | 说明 | +|----|------| +| 搜索 | LIKE 子串匹配 + embedding 向量检索(混合检索);embedding 未配置时退化为纯全文检索 | +| find_paths CTE | 环检测与无向边处理不完善 | +| 单 SQLite 文件 | 无分布式、无并发写优化 | +| properties JSON | 无法 SQL 内高效查询嵌套字段(除 LIKE) | +| pending edges | 永远无法 resolve 的 pending 边(目标数据源永不加入)仅作为 suggested_edges 展示,不参与遍历 | diff --git a/services/datalink/docs/pipeline/07-pipeline-orchestration.md b/services/datalink/docs/pipeline/07-pipeline-orchestration.md new file mode 100644 index 00000000..cf96175a --- /dev/null +++ b/services/datalink/docs/pipeline/07-pipeline-orchestration.md @@ -0,0 +1,376 @@ +# Pipeline 编排模块实现文档 + +**源码路径:** `src/datalink/builder/pipeline.py`
+**职责:** 串联 Connector → Extractor → Profiler → Inferrer → Mapper → Storage,提供全量构建与增量更新。 + +--- + +## 1. 入口函数 + +### `_connect_datasource(config: DatasourceConfig) -> DatasourceInfo` + +```python +if config.type == DATABASE: + connector = DatabaseConnector(config) +elif config.type in (CSV, PARQUET): + connector = FileConnector(config) +else: + raise ValueError(...) + +connector.connect() +ds_info = connector.get_datasource_info() +connector.disconnect() +return ds_info +``` + +每次连接**独立** open/close,不在 Pipeline 实例上缓存 Connector。 + +--- + +## 2. init_build — 首次构建 + +### 2.1 执行顺序 + +``` +Step 1 对每个 datasource_config → _connect_datasource → all_ds_infos[] +Step 2–5 _compute_pipeline(all_ds_infos) — 纯计算,返回所有 artifacts + (tables, columns, profiles, concepts, entities, + structural_edges, inferred_edges, semantic_edges) +Step 6 storage.clear_all() + _store_pipeline_result(result) — 批量写入 nodes, edges, profiles + set_metadata("build_type", "init") +``` + +--- + +## 2b. rebuild — 重建图谱 + +从已有 TableNode 元数据重建,不需要用户提供数据源信息。支持三种模式: + +### 2b.1 三种重建模式 + +| 模式 | 覆盖范围 | LLM 调用 | 适用场景 | +|------|----------|-----------|----------| +| `full`(默认) | 完整 pipeline + embedding 向量 | ✅ 有 | 数据大幅变化、全面刷新 | +| `vec` | 仅 embedding 向量 | ❌ 无 | 更换了 embedding 模型 | +| `profile` | 统计值 + 依赖统计的推断边 | ❌ 无 | 数据量变化但概念结构不变 | + +### 2b.2 mode=full — 安全性保证 + +**rebuild 采用 compute-first → clear-last 模式**,确保旧数据安全: + +1. 先从已有 TableNode 读取元数据并连接数据源 +2. 调用 `_compute_pipeline` 纯计算所有结果(不写 DB) +3. **只有 pipeline 成功后才 `clear_all` + `_store_pipeline_result`** +4. 如果 pipeline 失败(LLM 超时等),旧数据完好保留,可再次 rebuild 恢复 + +执行顺序: + +``` +Step 1 读取所有 TableNode → 按 source 分组(同一数据库连接的多张表只需一次连接) +Step 2 对每个 source 组: + - 用 TableNode 的 source + source_type 构造 DatasourceConfig + - _connect_datasource(config) → DatasourceInfo + - 过滤 DatasourceInfo.tables 只保留已有表名的表 +Step 3–5 _compute_pipeline(all_ds_infos) — 纯计算,不写 DB + 返回所有 artifacts(tables, columns, profiles, concepts, entities, + structural_edges, inferred_edges, semantic_edges) +Step 6 仅在 Step 3–5 成功后: + storage.clear_all() + _store_pipeline_result(result) — 批量写入 + set_metadata("build_type", "rebuild") +Step 7 _build_embeddings_from_pipeline_result(result) — 如果 embedding 已配置 +``` + +分组逻辑:同一 `source` 的多张表只需一次连接,避免重复连接数据库。 + +### 2b.3 mode=vec — 向量重建 + +``` +Step 1 检查 embedding 配置是否可用(model + api_key) +Step 2 _collect_nodes_with_profiles() — 从 DB 读取所有节点 + 关联 profile +Step 3 node_to_searchable_text(node, profile) — 为每个节点生成可检索文本 +Step 4 embedding_service.compute_embeddings(texts) — 批量计算向量 +Step 5 storage.clear_embeddings() — 清除旧向量 + storage.add_embeddings_batch([(node_id, model_name, embedding, text)]) + storage.set_metadata("embedding_model", model_name) +``` + +不涉及任何图谱数据修改,不调用 LLM。 + +### 2b.4 mode=profile — 统计重建 + 推断边重建 + +``` +Step 1 读取所有 TableNode → 按 source 分组 +Step 2 对每个 source 组: + - 用 TableNode 元数据构造 DatasourceConfig + - _connect_datasource → DatasourceInfo + - TabularProfiler.profile_datasource → 新 profiles +Step 3 storage.add_profiles_batch(new_profiles) — 更新统计值 + _update_column_properties(column_nodes, new_profiles) — 更新节点属性 + _update_table_properties + add_nodes_batch — 更新表节点属性 +Step 4 storage.remove_edges_by_types([JOINABLE, DISTRIBUTION_SIMILAR, + SEMANTIC_SYNONYM, CORRELATED]) — 删除旧推断边 +Step 5 用更新后的 profiles + sample_data 重新推断: + - JoinableInferrer → new_joinable + - SynonymInferrer → new_synonym + - DistributionInferrer → new_distribution + - CorrelationInferrer → new_correlated (需要 sample_data) +Step 6 _store_edges(new_inferred_edges, all_node_ids) — 存储新推断边 +``` + +**为什么 profile rebuild 也重建推断边**:JOINABLE 依赖 `top_values/sample_values` 的 overlap 率;DISTRIBUTION_SIMILAR 依赖 `min/max/mean` 和 `histogram`;SEMANTIC_SYNONYM 依赖 `semantic_type`(间接来自 profile);CORRELATED 依赖 `dtype` 和 joinable 边。数据源变化后这些统计值可能不同,推断边应随之更新。 + +结构边(CONTAINS、FOREIGN_KEY)和概念关联边(REPRESENTS、HAS_CONCEPT)不受影响。 + +### 2b.5 source_type 恢复 + +`TableNode` 存有 `source_type` 字段("csv"、"parquet"、"database")。重建时直接使用该字段构造 `DatasourceConfig`。如果 `source_type` 为空(旧版本数据),则从 `source` 字符串自动推断(数据库连接串 → database,.parquet → parquet,其余 → csv)。 + +### 2.2 边合并与过滤 + +```python +all_edges = structural_edges + inferred_edges + semantic_edges + +filtered_edges = [ + e for e in all_edges + if e.confidence >= config.confidence_threshold # 默认 0.3 +] +``` + +显式边 confidence=1.0,始终保留。低置信度推断边被丢弃。 + +### 2.3 CorrelationInferrer — 一次性传入全部 datasource + +`CorrelationInferrer` 需要**跨表的 sample_data**做 merge + Pearson 计算, +所以将 `all_ds_infos` 一次性传入,由其内部自行提取所有表的采样数据: + +```python +correlated_edges = correlation_inferrer.infer( + all_columns, all_profiles, joinable_edges, all_ds_infos +) +``` + +内部逻辑:遍历 `all_ds_infos` 构建 `sample_dfs: dict[table_name, DataFrame]`, +再对每个 joinable edge 做 `pd.merge` + Pearson 计算。 + +**`add_table` 中不调用 CorrelationInferrer**——因为已有表的 sample_data 未持久化, +无法获取旧表的采样数据来做跨表 merge。 + +### 2.4 返回值 + +```python +{ + "status": "success", + "datasources": len(datasource_configs), + "stats": storage.get_graph_stats(), +} +``` + +--- + +## 3. add_datasource / add_table — 增量添加(含去重) + +### 3.0 去重校验 + +`add_datasource` 在实际添加前会检查图谱中已存在的表(通过 table ID 匹配)。 +ID 格式为 `table:{source}:{table_name}`,同一 source + 同一表名 → ID 相同 → 视为重复。 + +已存在的表会被**跳过**,只添加新表。返回结果中新增字段: + +```python +{ + "status": "success", # 有新表添加 + "added_tables": ["orders", "transactions"], # 实际添加的表 + "skipped_tables": ["users"], # 已存在、跳过的表 + "stats": {...}, +} + +# 或者全部跳过时: +{ + "status": "skipped", + "added_tables": [], + "skipped_tables": ["users", "orders", "transactions"], + "message": "All tables already exist in the graph. Use remove_table first if you want to re-add.", + "stats": {...}, +} +``` + +如果想重新添加已有表,需先 `remove_table` 再 `add_datasource`。 + +### 3.1 add_datasource 与 init_build 的差异 + +| 步骤 | 行为 | +|------|------| +| Connect | 单 datasource,可选过滤 `ds_info.tables` 仅保留指定表(table_names=None 则保留全部) | +| Extract/Profile | 仅新表 | +| Infer | **合并**已有列 + 新列、已有 Profile + 新 Profile 后推断 | +| Infer 输出 | 只保留 `involves_new_column(edge)` 的边 | +| Correlation | **不调用** | +| Store | **不清库**,`add_nodes_batch` / `add_edges_batch` 追加 | + +### 3.2 已有图数据加载 + +```python +existing_columns = storage.get_nodes_by_type(NodeType.COLUMN) +existing_profiles = [ + storage.get_profile_for_column(col.id) + for col in existing_columns + if profile exists +] +all_columns_combined = existing + new +all_profiles_combined = existing_profiles + new_profiles +``` + +Joinable/Synonym/Distribution 在**合并集合**上 O(C²) 重算,再过滤出新边。 + +### 3.3 过滤新边 + +```python +new_column_ids = {c.id for c in new_columns} + +def involves_new_column(edge): + return edge.source_id in new_column_ids or edge.target_id in new_column_ids +``` + +避免重复处理纯旧列对(边 ID 相同会 OR REPLACE 覆盖,但浪费计算)。 + +--- + +## 4. remove_table — 删除表 + +```python +removed_column_ids = storage.remove_table(table_id) +orphan_count = storage.cleanup_orphaned_semantic_nodes() # if cleanup_orphans +return { + "status": "success", + "removed_columns": len(removed_column_ids), + "removed_orphans": orphan_count, + "stats": get_graph_stats(), +} +``` + +**不**重新计算剩余边的 confidence,**不**删除仍被其他列引用的 Concept(除非 orphan cleanup 触发)。 + +--- + +## 5. 配置项在 Pipeline 中的绑定 + +| 配置字段 | 使用位置 | +|----------|----------| +| `graph_db_path` | `GraphStorage(config.graph_db_path)` | +| `joinable_overlap_threshold` | `JoinableInferrer(...)` | +| `correlation_threshold` | `CorrelationInferrer(...)` | +| `confidence_threshold` | 最终 `filtered_edges` | +| `llm` | `LLMMapper(config.llm)` — LLM 调用(timeout 控制单次请求超时) | +| `embedding` | `EmbeddingService(config.embedding, config.llm)` — 向量构建 + 混合检索 | +| `mapping_batch_size` | `LLMMapper._batch_size` — 每批列数(默认 15,减小可降低推理时间) | +| `embedding` | `EmbeddingService(config.embedding, config.llm)` — 向量构建 + 混合检索 | +| `sample_size` | 在 `DatasourceConfig` 传入 Connector(CLI build 时未显式设置,用 DatasourceConfig 默认 1000) | +| `mcp_tools` | MCP Server 辅助工具注册(`_get_tool_allowlist()`),Pipeline 不使用 | + +**注意:** 所有 CLI/MCP 写命令(`add-table`、`rebuild`、`remove-table`)均通过 `DataLinkConfig.load()` 读取配置,`datalink_config.json` 中的阈值和 LLM 设置都会生效。 + +--- + +## 6. 完整数据流(全量) + +``` +DatasourceConfig[] + │ + ▼ +[DatasourceInfo[]] ← Connector × N + │ + ├──────────────────────────┐ + ▼ ▼ +TableNode[], ColumnNode[] ColumnProfile[] +Edge[CONTAINS|FK] │ + │ │ + └──────────┬───────────────┘ + ▼ + Edge[JOINABLE|SYNONYM|DIST|CORR] + │ + ▼ + ConceptNode[], EntityNode[], Edge[REPRESENTS|HAS_CONCEPT] + │ + ▼ + SQLite (clear + batch insert) + │ + ▼ + EmbeddingService.compute_embeddings() — 可选(embedding 配置时) + │ + ▼ + node_embeddings (float32 BLOB + searchable_text) + │ + ▼ + get_graph_stats() +``` + +--- + +## 7. 资源生命周期 + +```python +pipeline = BuildPipeline(config) +try: + pipeline.rebuild() # 重建(默认 mode=full) + pipeline.rebuild(mode="vec") # 只重建向量 + pipeline.rebuild(mode="profile") # 只重算统计 + 推断边 + pipeline.add_datasource(...) # 增量添加数据源(table_names=None 则加全部) + pipeline.add_table(...) # 增量添加单表(便捷入口) +finally: + pipeline.close() # storage.close() +``` + +CLI/MCP 调用映射: +- `datalink add-table --source X`(不传 --table) → `pipeline.add_datasource(config)` +- `datalink add-table --source X --table T` → `pipeline.add_table(config, T)` +- `datalink rebuild` → `pipeline.rebuild(mode="full")` +- `datalink rebuild --mode vec` → `pipeline.rebuild(mode="vec")` +- `datalink rebuild --mode profile` → `pipeline.rebuild(mode="profile")` + +MCP `add_table` / `rebuild` / `remove_table` 在成功后 reset 全局 `_storage` / `_retrieval` 单例,强制下次查询重新打开 DB。 + +--- + +## 8. 错误处理 + +| 阶段 | 失败行为 | +|------|----------| +| 不支持的 datasource type | `ValueError` 向上抛 | +| add_table 表不存在 | `ValueError("Table 'x' not found")` | +| Connector 路径不存在 | `ValueError` | +| LLM 失败 | 静默跳过概念映射,不中断 build | +| Storage | SQLite 异常向上抛 | + +CLI 捕获 Exception 打印后 `typer.Exit(1)`。 + +--- + +## 9. 扩展 Pipeline 的检查清单 + +新增处理阶段时建议: + +1. 在 `init_build` / `rebuild` Step 4/5 之间或之后插入调用 +2. `add_datasource` 中同步实现增量逻辑(是否合并已有节点) +3. 新边类型加入 `EdgeType` 枚举 +4. 确认新边受 `confidence_threshold` 正确过滤 +5. 更新 `get_graph_stats` / CLI info 展示(EdgeType 已自动枚举) +6. 补充 `tests/test_builder.py` + +--- + +## 10. 性能特征(粗略) + +设 D 个数据源、T 表、C 列、sample_size = S: + +| 阶段 | 复杂度 | +|------|--------| +| Connect + 采样 | O(D × T × S) I/O | +| Extract | O(C) | +| Profile | O(C × S) | +| Joinable/Synonym/Dist | O(C²) 比较 | +| LLM Map | O(1) API 调用(列数影响 token) | +| Storage batch | O(C + E) 写入 | + +多表、多列场景下 **Inferrer 平方级** 为主要 CPU 瓶颈。 diff --git a/services/datalink/evaluate/bird/cursor/eval.config.example.json b/services/datalink/evaluate/bird/cursor/eval.config.example.json new file mode 100644 index 00000000..317b96d5 --- /dev/null +++ b/services/datalink/evaluate/bird/cursor/eval.config.example.json @@ -0,0 +1,20 @@ +{ + "cursor_api_key": "", + "model": "composer-2.5", + "cwd": "evaluate/bird/sandbox", + "mcp_url": "http://127.0.0.1:8082/mcp", + "dataset": "evaluate/bird/dev_questions.json", + "answers": "evaluate/bird/dev_answers.json", + "output_dir": "evaluate/bird/cursor/eval_results", + "sqlite_url": "", + "sqlite_url_template": "sqlite:///{db_dir}/{db_id}/{db_id}.sqlite", + "db_dir": "C:/path/to/bird/dev_databases", + "sqlite_databases": {}, + "limit": 10, + "offset": 0, + "difficulties": [], + "db_ids": [], + "modes": ["with_datalink", "without_datalink"], + "timeout_seconds": 600, + "sandbox_enabled": true +} diff --git a/services/datalink/evaluate/bird/cursor/eval_common.py b/services/datalink/evaluate/bird/cursor/eval_common.py new file mode 100644 index 00000000..1e3d1625 --- /dev/null +++ b/services/datalink/evaluate/bird/cursor/eval_common.py @@ -0,0 +1,782 @@ +#!/usr/bin/env python3 +"""Shared helpers for Bird Cursor eval (run + grade).""" + +from __future__ import annotations + +import json +import os +import re +from dataclasses import asdict, dataclass, field +from pathlib import Path +from typing import Any +from urllib.parse import urlparse + +# cursor-sdk bridge uses os.get_blocking/set_blocking (Unix / newer Python). +# On Windows these may be missing — patch before importing cursor_sdk. +if not hasattr(os, "get_blocking"): + os.get_blocking = lambda fd: True # type: ignore[attr-defined] +if not hasattr(os, "set_blocking"): + os.set_blocking = lambda fd, blocking: None # type: ignore[attr-defined] + +from sqlalchemy import create_engine, text +from sqlalchemy.engine import Engine + +# --------------------------------------------------------------------------- +# Data classes +# --------------------------------------------------------------------------- + + +@dataclass +class EvalConfig: + cursor_api_key: str = "" + model: str = "composer-2.5" + cwd: str = "." + mcp_url: str = "http://127.0.0.1:8080/mcp" + dataset: str = "evaluate/bird/dev_questions.json" + answers: str = "evaluate/bird/dev_answers.json" + output_dir: str = "evaluate/bird/cursor/eval_results" + # SQLite: use ONE of these patterns (set absolute paths in your local config) + sqlite_url: str = "" # single DB for all questions, e.g. sqlite:///C:/path/to/db.sqlite + sqlite_url_template: str = "sqlite:///{db_dir}/{db_id}/{db_id}.sqlite" + db_dir: str = "" + sqlite_databases: dict[str, str] = field(default_factory=dict) + limit: int = 0 + offset: int = 0 + difficulties: list[str] = field(default_factory=list) + db_ids: list[str] = field(default_factory=list) + modes: list[str] = field(default_factory=lambda: ["with_datalink", "without_datalink"]) + timeout_seconds: int = 600 + # When True, Cursor local agent is sandboxed to cwd (blocks grep/glob/read outside). + sandbox_enabled: bool = True + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> EvalConfig: + known = {f.name for f in cls.__dataclass_fields__.values()} # type: ignore[attr-defined] + return cls(**{k: v for k, v in data.items() if k in known}) + + def resolve_api_key(self) -> str: + key = self.cursor_api_key or os.environ.get("CURSOR_API_KEY", "") + if not key: + raise SystemExit( + "Missing Cursor API key. Set CURSOR_API_KEY or pass cursor_api_key in config.\n" + "Get one at: https://cursor.com/dashboard → Integrations → User API Keys" + ) + return key + + def sqlite_url_for(self, db_id: str) -> str: + if self.sqlite_url: + return self.sqlite_url + if db_id in self.sqlite_databases: + return self.sqlite_databases[db_id] + if not self.db_dir: + raise ValueError( + f"No SQLite URL for db_id={db_id!r}. " + "Set sqlite_url, sqlite_databases[db_id], or db_dir + sqlite_url_template." + ) + path = self.sqlite_url_template.format(db_dir=self.db_dir, db_id=db_id) + return path + + +@dataclass +class ToolCallRecord: + name: str + status: str + args: Any = None + result: Any = None + + +@dataclass +class RunMetrics: + duration_ms: int = 0 + input_tokens: int = 0 + output_tokens: int = 0 + total_tokens: int = 0 + tool_call_count: int = 0 + datalink_tool_calls: int = 0 + tool_calls: list[ToolCallRecord] = field(default_factory=list) + + +@dataclass +class RunResult: + """Agent run output (no golden SQL / correctness).""" + + question_id: int + db_id: str + mode: str + question: str + predicted_sql: str | None + error: str | None + metrics: RunMetrics + answer_text: str + difficulty: str = "" + run_id: str = "" + agent_id: str = "" + + +@dataclass +class EvalResult: + """Scored result (run output + golden comparison).""" + + question_id: int + db_id: str + mode: str + question: str + golden_sql: str + predicted_sql: str | None + correct: bool | None + error: str | None + golden_row_count: int | None + predicted_row_count: int | None + metrics: RunMetrics + answer_text: str + difficulty: str = "" + run_id: str = "" + agent_id: str = "" + + +# --------------------------------------------------------------------------- +# SQL helpers +# --------------------------------------------------------------------------- + +_SQL_FENCE_RE = re.compile(r"```(?:sql)?\s*\n(.*?)```", re.IGNORECASE | re.DOTALL) +_SELECT_RE = re.compile(r"\b(SELECT\b[\s\S]*?)(?:;|\n\n|$)", re.IGNORECASE) + + +def extract_sql(text_body: str) -> str | None: + """Pull the most likely SQL statement from agent output.""" + if not text_body: + return None + + fences = _SQL_FENCE_RE.findall(text_body) + for block in reversed(fences): + stmt = block.strip().rstrip(";") + if stmt.upper().startswith("SELECT"): + return stmt + + matches = _SELECT_RE.findall(text_body) + if matches: + return matches[-1].strip().rstrip(";") + + stripped = text_body.strip() + if stripped.upper().startswith("SELECT"): + return stripped.rstrip(";") + return None + + +def _normalize_cell(value: Any) -> Any: + if value is None: + return None + if isinstance(value, float): + return round(value, 6) + if isinstance(value, bytes): + return value.decode("utf-8", errors="replace") + return value + + +def _normalize_rows(rows: list[tuple]) -> list[tuple]: + return [tuple(_normalize_cell(c) for c in row) for row in rows] + + +def execute_sql(engine: Engine, sql: str) -> tuple[list[tuple], str | None]: + try: + with engine.connect() as conn: + result = conn.execute(text(sql)) + rows = result.fetchall() + return _normalize_rows([tuple(r) for r in rows]), None + except Exception as exc: + return [], str(exc) + + +def compare_results(golden_rows: list[tuple], pred_rows: list[tuple]) -> bool: + """Multiset comparison (order-insensitive).""" + from collections import Counter + + return Counter(golden_rows) == Counter(pred_rows) + + +def get_engine(db_id: str, config: EvalConfig, engine_cache: dict[str, Engine]) -> Engine: + if db_id not in engine_cache: + engine_cache[db_id] = create_engine(config.sqlite_url_for(db_id)) + return engine_cache[db_id] + + +# --------------------------------------------------------------------------- +# Cursor SDK runner +# --------------------------------------------------------------------------- + +PROMPT_TEMPLATE = """You are a text-to-SQL assistant. Answer the question by writing a single SQLite SQL query. + +Database id: {db_id} +{sqlite_block}Question: {question} +{evidence_block} + +Rules: +{path_rule}{explore_rule}- Do not use Grep or Glob (they are disabled for this evaluation). +- Use only tables/columns that exist in the database. +- Output exactly one SQL query inside a ```sql code fence. +- Do not execute the query yourself; only write the SQL. +- The query must be valid SQLite syntax.。 +- You are only allowed to access files and data inside your working directory. +- Do not search online. +- Do not access git history. +{python_rule}""" + + +def sqlite_url_to_path(sqlite_url: str) -> str: + """Turn sqlite:///... URL into a filesystem path for the prompt.""" + prefix = "sqlite:///" + if sqlite_url.startswith(prefix): + path = sqlite_url[len(prefix) :] + # SQLAlchemy: sqlite:////C:/... (4 slashes) -> /C:/...; normalize drive paths. + if len(path) >= 3 and path[0] == "/" and path[2] == ":": + path = path[1:] + return path + return sqlite_url + + +def build_prompt( + item: dict[str, Any], + *, + sqlite_url: str = "", + inject_sqlite_path: bool = False, + with_datalink: bool = False, +) -> str: + evidence = item.get("evidence") or "" + evidence_block = f"Hint: {evidence}" if evidence else "" + if inject_sqlite_path and sqlite_url: + sqlite_block = f"SQLite database file: {sqlite_url_to_path(sqlite_url)}\n" + path_rule = ( + "- The database file path above is authoritative — do not search the " + "workspace for other DB files.\n" + ) + else: + sqlite_block = "" + path_rule = "" + if with_datalink: + explore_rule = ( + "- Do not use Python or bash to inspect or probe the database; " + "use DataLink MCP tools (e.g. datalink_explore) instead.\n" + ) + python_rule = "" + else: + explore_rule = "" + python_rule = ( + "- The environment does not have sqlite3 command in the terminal. " + "You have to use python if needed.\n" + ) + return PROMPT_TEMPLATE.format( + db_id=item["db_id"], + sqlite_block=sqlite_block, + question=item["question"], + evidence_block=evidence_block, + path_rule=path_rule, + explore_rule=explore_rule, + python_rule=python_rule, + ) + + +def _mcp_hosts(mcp_url: str) -> list[str]: + """Hosts to allow in sandbox network policy for DataLink MCP.""" + hosts = {"127.0.0.1", "localhost"} + if mcp_url: + parsed = urlparse(mcp_url) + if parsed.hostname: + hosts.add(parsed.hostname) + return sorted(hosts) + + +_DENY_SEARCH_HOOK_PY = '''\ +#!/usr/bin/env python3 +"""Deny Grep/Glob during Bird Cursor eval (preToolUse).""" +from __future__ import annotations + +import json +import sys + + +def main() -> None: + try: + payload = json.load(sys.stdin) + except json.JSONDecodeError: + print(json.dumps({"permission": "allow"})) + return + + name = str(payload.get("tool_name") or "").strip() + if name.lower() in {"grep", "glob"}: + print( + json.dumps( + { + "permission": "deny", + "user_message": f"{name} is disabled for this evaluation.", + "agent_message": ( + f"The {name} tool is disabled. Do not search the workspace " + "with Grep/Glob. Use DataLink MCP tools if available." + ), + } + ) + ) + return + + print(json.dumps({"permission": "allow"})) + + +if __name__ == "__main__": + main() +''' + +_DENY_SEARCH_HOOKS_JSON = { + "version": 1, + "hooks": { + "preToolUse": [ + { + "command": "python .cursor/hooks/deny-search.py", + "failClosed": True, + } + ] + }, +} + + +def ensure_sandbox_policy(cwd: Path, *, mcp_url: str = "") -> Path: + """Ensure cwd/.cursor/sandbox.json allows MCP hosts when sandbox is on. + + Does not remove existing allow entries; merges mcp hosts into networkPolicy.allow. + """ + cursor_dir = cwd / ".cursor" + cursor_dir.mkdir(parents=True, exist_ok=True) + path = cursor_dir / "sandbox.json" + + policy: dict[str, Any] = { + "type": "workspace_readwrite", + "additionalReadwritePaths": [], + "additionalReadonlyPaths": [], + "networkPolicy": { + "default": "deny", + "allow": _mcp_hosts(mcp_url), + }, + } + if path.is_file(): + try: + existing = json.loads(path.read_text(encoding="utf-8")) + if isinstance(existing, dict): + policy = {**policy, **existing} + net = policy.setdefault("networkPolicy", {}) + if not isinstance(net, dict): + net = {} + policy["networkPolicy"] = net + allow = list(net.get("allow") or []) + for h in _mcp_hosts(mcp_url): + if h not in allow: + allow.append(h) + net["allow"] = allow + net.setdefault("default", "deny") + except (json.JSONDecodeError, OSError): + pass + + path.write_text(json.dumps(policy, indent=2) + "\n", encoding="utf-8") + return path + + +def ensure_eval_hooks(cwd: Path) -> Path: + """Install preToolUse hooks that deny Grep/Glob under cwd/.cursor/.""" + hooks_dir = cwd / ".cursor" / "hooks" + hooks_dir.mkdir(parents=True, exist_ok=True) + script_path = hooks_dir / "deny-search.py" + script_path.write_text(_DENY_SEARCH_HOOK_PY, encoding="utf-8") + hooks_json = cwd / ".cursor" / "hooks.json" + hooks_json.write_text( + json.dumps(_DENY_SEARCH_HOOKS_JSON, indent=2) + "\n", + encoding="utf-8", + ) + return hooks_json + + +_KEEP_SUFFIXES = {".sqlite", ".db"} + + +def reset_sandbox_cwd(cwd: Path) -> list[str]: + """Remove agent leftovers under cwd; keep DB files and `.cursor/`. + + Returns relative paths that were deleted (for logging). + """ + import shutil + + cwd = cwd.resolve() + if not cwd.is_dir(): + cwd.mkdir(parents=True, exist_ok=True) + return [] + + removed: list[str] = [] + for entry in list(cwd.iterdir()): + name = entry.name + if name == ".cursor": + continue + if entry.is_file() and entry.suffix.lower() in _KEEP_SUFFIXES: + continue + try: + if entry.is_dir(): + shutil.rmtree(entry) + else: + entry.unlink() + removed.append(name) + except OSError: + pass + return removed + + +def run_cursor_agent( + *, + prompt: str, + config: EvalConfig, + with_datalink: bool, +) -> tuple[str, RunMetrics, str, str]: + from cursor_sdk import ( + Agent, + AgentOptions, + HttpMcpServerConfig, + LocalAgentOptions, + SandboxOptions, + ) + + mcp_servers: dict[str, Any] = {} + if with_datalink: + mcp_servers = { + "datalink": HttpMcpServerConfig(url=config.mcp_url, type="http"), + } + + cwd_path = Path(config.cwd).resolve() + cwd = str(cwd_path) + api_key = config.resolve_api_key() + + removed = reset_sandbox_cwd(cwd_path) + if removed: + print(f" sandbox reset: removed {removed}") + + sandbox_options = None + ensure_eval_hooks(cwd_path) + if config.sandbox_enabled: + ensure_sandbox_policy(cwd_path, mcp_url=config.mcp_url if with_datalink else "") + sandbox_options = SandboxOptions(enabled=True) + + metrics = RunMetrics() + answer_text = "" + run_id = "" + agent_id = "" + + with Agent.create( + AgentOptions( + api_key=api_key, + model=config.model, + local=LocalAgentOptions( + cwd=cwd, + # Need "project" so cwd/.cursor/hooks.json (deny Grep/Glob) is loaded. + # Do not include "user"/"plugins" — avoids ambient MCP/rules outside the sandbox. + setting_sources=["project"], + sandbox_options=sandbox_options, + ), + mcp_servers=mcp_servers or None, + ) + ) as agent: + agent_id = agent.agent_id + run = agent.send(prompt) + run_id = run.id + + for msg in run.messages(): + if msg.type == "tool_call" and msg.status == "completed": + # Cursor MCP tools show as name="mcp"; real tool is in args.toolName. + display_name = msg.name or "" + args = msg.args + if isinstance(args, dict): + tool_name = args.get("toolName") or args.get("tool_name") or "" + provider = args.get("providerIdentifier") or args.get("provider") or "" + if tool_name: + display_name = str(tool_name) + else: + tool_name = "" + provider = "" + + rec = ToolCallRecord( + name=display_name, + status=msg.status, + args=args, + result=msg.result, + ) + metrics.tool_calls.append(rec) + metrics.tool_call_count += 1 + blob = f"{msg.name} {display_name} {provider} {tool_name}".lower() + if "datalink" in blob: + metrics.datalink_tool_calls += 1 + elif msg.type == "usage" and msg.usage is not None: + metrics.input_tokens = msg.usage.input_tokens + metrics.output_tokens = msg.usage.output_tokens + metrics.total_tokens = msg.usage.total_tokens + + final = run.wait() + answer_text = final.result or "" + metrics.duration_ms = final.duration_ms or 0 + if final.usage is not None: + metrics.input_tokens = final.usage.input_tokens + metrics.output_tokens = final.usage.output_tokens + metrics.total_tokens = final.usage.total_tokens + + return answer_text, metrics, run_id, agent_id + + +# --------------------------------------------------------------------------- +# Dataset / config IO +# --------------------------------------------------------------------------- + + +def load_json_list(path: str | Path) -> list[dict[str, Any]]: + with open(path, encoding="utf-8") as f: + data = json.load(f) + if not isinstance(data, list): + raise ValueError(f"Expected a JSON array: {path}") + return data + + +def load_answers(path: str | Path) -> dict[tuple[Any, str], dict[str, Any]]: + """Index answers by (question_id, db_id).""" + items = load_json_list(path) + out: dict[tuple[Any, str], dict[str, Any]] = {} + for item in items: + if "SQL" not in item: + raise ValueError(f"Answer entry missing SQL: {item!r}") + key = (item.get("question_id"), str(item.get("db_id", ""))) + out[key] = item + return out + + +def filter_dataset(items: list[dict[str, Any]], config: EvalConfig) -> list[dict[str, Any]]: + out = items + if config.difficulties: + allowed = {d.lower() for d in config.difficulties} + out = [x for x in out if x.get("difficulty", "").lower() in allowed] + if config.db_ids: + allowed = set(config.db_ids) + out = [x for x in out if x.get("db_id") in allowed] + if config.offset: + out = out[config.offset :] + if config.limit: + out = out[: config.limit] + return out + + +def load_config(config_path: Path | None) -> EvalConfig: + if config_path is not None: + with open(config_path, encoding="utf-8") as f: + return EvalConfig.from_dict(json.load(f)) + return EvalConfig() + + +def resolve_config_path(args_config: str | None, script_file: str) -> Path | None: + """Return config path: --config if set, else sibling eval.config.json if it exists.""" + if args_config: + return Path(args_config) + default = Path(script_file).resolve().parent / "eval.config.json" + return default if default.is_file() else None + + +def repo_root_from(script_file: str) -> Path: + # evaluate/bird/cursor/