docs: update dev guides for the current agent runtime

The guides still described the agents removed in the agent-runtime refactor
(DataAgent, DataRecAgent, DataTransformationAgent, InteractiveExploreAgent,
ReportGenAgent, ChartInsightAgent, DataLoadingAgent, ...) and pointed at
files, functions, routes, scripts and tests that no longer exist. Each guide
was checked against the code and now describes AnalystAgent, the helper
agents and the workflow agent as they are:

- 1 streaming: analyst-streaming and workflows/run events, interact, errors
- 3/5 data loaders and connector API: current hooks, routes, storage, plugins
- 6 i18n: current agents' language injection, 8 locales, message codes
- 7 errors: existing tests in place of the missing guardrail script
- 10 knowledge: the current (unused) knowledge integration and reasoning log
- 11 catalog: discovery tools, cache format, find_catalog_cache, matrices
- 12 sandbox session: who uses SandboxSession and what persists
- 13 row limits: ephemeral server-side storage, data-operation loading
- 14 model capability: runtime fallbacks in Client, current tiers
- tests/backend/README.md and tests/test_plan.md: removed files and agents

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Chenglong WangandCopilot committed 2026-10-13 16:40:41 -07:00
1 parent fce6fee0ef
commit ab5ad3001f
12 files changed
+847 -718

No files matched your search

+92 -68
View File
@@ -1,8 +1,8 @@
# 前后端流式通信协议
> **维护者**: DF 核心团队
> **最后更新**: 2026-04-30
> **适用范围**: 所有 `/api/agent/*` 流式端点
> **最后更新**: 2026-10-13
> **适用范围**: 所有 NDJSON 流式端点(`/api/agent/analyst-streaming`、`/api/workflows/run`)
## 1. 协议总览
@@ -15,7 +15,7 @@
的统一错误 envelope;流建立后的错误通过流内事件传递。
```
{"type": "question", "text": "...", "goal": "...", "tag": "..."}\n
{"type": "thinking_text", "content": "..."}\n
{"type": "warning", "warning": {"message": "..."}}\n
{"type": "error", "error": {"code": "...", "message": "..."}}\n
```
@@ -30,22 +30,27 @@
| 端点 | 事件 type | 说明 |
|------|-----------|------|
| `data-agent-streaming` | `"text_delta"`, `"completion"`, `"clarify"` 等 | 顶层 `type` 事件 |
| `generate-report-chat` | `"text_delta"`, `"embed_chart"`, `"embed_table"` | 报告生成流 |
| `data-loading-chat` | `"text_delta"`, `"tool_call"`, `"tool_result"`, `"done"` | 数据加载对话 |
| `/api/agent/analyst-streaming` | `"thinking_text"`, `"tool_start"`, `"tool_result"`, `"skill_loaded"`, `"context_info"`, `"action"`, `"text_delta"`, `"result"`, `"data_operation_result"`, `"terminal_started"`, `"terminal_running"`, `"terminal_result"` | `AnalystAgent` 运行过程事件;`text_delta` 带 `channel`(如 `"report"`,报告正文流) |
| `/api/agent/analyst-streaming` | `"completion"`, `"interact"` | 终止事件:运行结束 / 暂停等待用户回复 |
| `/api/workflows/run` | `"workflow_state"`, `"activity"`, `"action"`, `"text_delta"`, `"terminal_running"`, `"terminal_result"` | Workflow 运行状态与报告流 |
| (两端点通用) | `"heartbeat"` | 空闲时的保活行,前端忽略 |
| (跨端点通用) | `"thinking_text"` | Agent 推理/思考过程文本(参见 2.4) |
`data-agent-streaming` 的 `result.type === "clarify"` 使用结构化多问题格式。后端和前端都以
`questions[]` 为唯一澄清问题结构。问题与选项均不带 ID —— 通过它们在数组中的位置来对应。
`analyst-streaming` 的 `interact` 事件用于暂停运行。`ask_user` action(`analyst/skills/meta/skill.py`)
产生结构化多问题格式,后端和前端都以 `questions[]` 为唯一澄清问题结构。问题与选项均不带 ID ——
通过它们在数组中的位置来对应。Agent 外壳(`AnalystAgent._route_skill_events`)会在 `interact`
事件上补充恢复所需的 `trajectory` 和 `completed_step_count`。
```json
{
"type": "clarify",
"type": "interact",
"thought": "...",
"questions": [
{
"text": "Which metric should I use?",
"responseType": "single_choice",
"options": ["Revenue", "Orders"]
"required": true,
"options": [{"label": "Revenue"}, {"label": "Orders"}]
}
],
"trajectory": [],
@@ -57,17 +62,23 @@
| 字段 | 类型 | 必需 | 说明 |
|------|------|------|------|
| `questions` | array | 是 | 本轮所有需要用户澄清的问题,最多 3 个 |
| `questions[].text` | string | 是 | 英文 fallback 或 LLM 生成的问题文本 |
| `questions[].text_code` | string | 否 | 固定后端问题文案的 i18n key,例如 `agent.clarifyExhausted` |
| `questions` | array | 是 | 本轮所有需要用户回复的问题 |
| `questions[].text` | string | 否* | 英文 fallback 或 LLM 生成的问题文本(`text` 与 `text_code` 至少一个) |
| `questions[].text_code` | string | 否 | 固定后端问题文案的 i18n key |
| `questions[].text_params` | object | 否 | `text_code` 的插值参数 |
| `questions[].responseType` | `"single_choice"` / `"free_text"` | 否 | 默认按是否有 options 推断 |
| `questions[].options` | array | 否 | 单选选项;可以是字符串数组或 `{label, label_code?}` 对象数组 |
| `questions[].options[].label` | string | 是 | 英文 fallback 或 LLM 生成的选项文本 |
| `questions[].responseType` | `"single_choice"` / `"multi_choice"` / `"free_text"` | 是 | 取值非法时后端按是否有 options 推断 |
| `questions[].required` | boolean | 是 | 默认 `true`;可选的说明/跟进问题为 `false` |
| `questions[].options` | array | 否 | 选项对象数组 `{label, label_code?}`(LLM 给出的字符串会被规范化为对象) |
| `questions[].options[].label` | string | 否* | 英文 fallback 或 LLM 生成的选项文本(`label` 与 `label_code` 至少一个) |
| `questions[].options[].label_code` | string | 否 | 固定后端选项文案的 i18n key |
恢复请求只需把已经组装好的用户回复作为普通的 `user_question` 字段传回,前端负责把
点选 + 自由输入合并成形如 `1. <a1>; 2. <a2>\n<freeform>` 的字符串:
其他 `interact` 变体:数据加载计划选择(附带 `data_operation`,选项带 `value` = plan id)、
连接器表单(`form`)、终端命令审批(`terminal_request`)。前端在 `SimpleChartRecBox.tsx`
中按字段分别处理,带 `questions[]` 的 `interact` 统一走澄清面板。
恢复请求只需把已经组装好的用户回复作为普通的 `user_question` 字段传回,前端
(`src/app/clarification.ts` 的 `formatClarificationResponses()`)负责把点选 + 自由输入合并成形如
`1. <a1>; 2. <a2>\n<freeform>` 的字符串:
```json
{
@@ -78,7 +89,9 @@
```
后端把 `user_question` 作为普通的 user 消息追加到 trajectory,再交给 LLM 继续推理。
不再有专用的 `clarification_responses` / `auto_select` / `[USER CLARIFICATION]` 包装层。
带 `trajectory` 恢复时 `user_question` 不能为空。数据加载计划选择和终端审批分别额外携带
`interaction_response`(`{operation_id, plan_id}` 或 `{operation_id, action: "elaborate"}`)和
`terminal_response`(`{request_id, decision: "approve" | "reject"}`)。
### 2.2 错误事件(统一格式)
@@ -118,6 +131,14 @@ from data_formulator.errors import AppError, ErrorCode
yield stream_error_event(AppError(ErrorCode.TABLE_NOT_FOUND, "Table not found"))
```
`stream_error_event()` 返回的已是以 `\n` 结尾的 NDJSON 字符串,route 直接 yield。
`AnalystAgent` 自身遇到运行终止类错误(如 LLM API 失败)时,还会 yield 一种 Agent 级 error 事件:
`{"type": "error", "message": "...", "message_code": "agent.llmApiError"}`(见 `AnalystAgent._error_event`)。
skill handler yield 的 error 事件属于可恢复的内部重试信号,会被 `_route_skill_events` 丢弃,不进入流。
前端同时兼容这两种 error 形态(有 `error` 对象时用 `getErrorMessage()`,否则用
`translateBackend(message, message_code, message_params)`)。
### 2.3 警告事件(非致命,不中断流)
后端遇到非致命问题(如某张表不可读但不影响整体请求)时发送 warning 事件。
@@ -143,7 +164,7 @@ yield stream_error_event(AppError(ErrorCode.TABLE_NOT_FOUND, "Table not found"))
**后端生成方式有两种**:
```python
# 方式 1: 在 generator 中直接 yield(适合 agent 的 run() 方法)
# 方式 1: 在 generator 中直接 yield
from data_formulator.error_handler import stream_warning_event
yield stream_warning_event("Table unavailable", message_code="TABLE_READ_FAILED")
@@ -153,6 +174,8 @@ collect_stream_warning("Table unavailable", message_code="TABLE_READ_FAILED")
# → 由 route 层的 _with_warnings() wrapper 自动刷新到流中
```
当前使用方式 2 的位置:`agents/agent_utils.py`(`TABLE_READ_FAILED`)和 `agents/context.py`(`TABLE_INSPECT_FAILED`)。
**前端处理**: 收到 warning 事件后 dispatch `dfActions.addMessages` 显示为黄色 Snackbar,不中断当前流处理。
### 2.4 思考过程事件(`thinking_text`)
@@ -173,87 +196,91 @@ Agent 在执行过程中产生的推理/思考文本。前端应实时展示为
**事件来源**:
1. **Agent 层面的 think tool**:`DataAgent` 使用 `think` 工具时,将 tool message 以 `thinking_text` 事件输出。
2. **LLM 伴随内容**:当 LLM 在 tool_calls 旁返回文本 content 时,Route 层将其作为 `thinking_text` 事件输出。
3. **模型原生推理链保持**:部分推理模型(当前为 DeepSeek V4)在响应中返回 `reasoning_content` 字段,该字段在多轮对话中必须回传至 assistant 消息。已通过 `agent_utils.attach_reasoning_content()` 和 `accumulate_reasoning_content()` 在所有 tool-loop agent 中统一处理。未来 Anthropic extended thinking 或 OpenAI reasoning tokens 如需类似处理,可复用同一机制。
1. **LLM 伴随内容**:当 LLM 在 tool_calls 旁返回文本 content 时,`AnalystAgent` 将其作为 `thinking_text` 事件输出。
2. **模型原生推理链保持**:部分推理模型(当前为 DeepSeek V4)在响应中返回 `reasoning_content` 字段,该字段在多轮对话中必须回传至 assistant 消息。`AnalystAgent` 和 `WorkflowAgent` 通过 `agent_utils.attach_reasoning_content()` 和 `accumulate_reasoning_content()` 统一处理。该内容只回传给模型,不作为 `thinking_text` 输出。
**后端生成**:
```python
# Agent 的 think tool 输出
yield {"type": "thinking_text", "content": thought_msg}
# LLM 响应中的伴随文本(非 tool_calls 结果)
if content.strip():
yield {"type": "thinking_text", "content": content.strip()}
# LLM 响应中的伴随文本(与 tool_calls 一起返回)
if tool_calls:
if content.strip():
yield {"type": "thinking_text", "content": content.strip()}
```
**前端处理**:
**前端处理**(`SimpleChartRecBox.tsx`):
- 累积 `thinking_text` 事件到 `thinkingSteps` 数组
- 在 UI 中显示为可折叠的思考过程面板(类似 ChatGPT thinking block)
- 当后续出现 `tool_start` 等行动事件时,将累积的 thinking 作为一个完整步骤展示
- 累积 `thinking_text` 内容,暂存为待展示的 thought
- 尚无工具/行动步骤时,显示"正在思考..."进度项
- 当后续出现 `tool_start` 等行动事件时,将累积的 thinking 作为该步骤的一部分展示
- thinking 内容不应触发 snackbar 或错误提示
**与其他事件的关系**:
- `thinking_text` 是非致命、非阻塞事件,不影响流的继续
- 可以与 `tool_start`、`tool_result`、`text_delta` 交替出现
- 如果流中只有 `thinking_text` 没有后续行动,前端应显示为"正在思考..."状态
## 3. Route 层职责
Route 层(`routes/agents.py`)是后端流式协议的**序列化边界**:
Route 层(`routes/agents.py`、`routes/workflows.py`)是后端流式协议的**序列化边界**:
### 3.1 序列化规则
| Agent 输出 | Route 层处理 | 最终输出 |
|---|---|---|
| `yield dict` | `json.dumps(dict) + '\n'` | 标准 NDJSON 行 |
| `yield str`(LLM 文本碎片) | 累积 → 按 `\n` 拆行 → `json.loads` 验证 → `json.dumps` + `'\n'` | 标准 NDJSON 行 |
| `yield dict` | `json.dumps(event, ensure_ascii=False) + '\n'` | 标准 NDJSON 行 |
| `stream_error_event()` / `stream_warning_event()` 返回的字符串 | 直接 yield | 标准 NDJSON 行 |
> **原则**: Agent 层不负责 NDJSON 序列化,Route 层统一处理。Agent 只 yield Python dict 或原始文本。
> **原则**: Agent 层不负责 NDJSON 序列化,Route 层统一处理。Agent 只 yield Python dict。
### 3.2 Warning 注入
### 3.2 Warning 注入与后台执行
所有流式端点的 `generate()` 函数通过 `_with_warnings()` wrapper 包裹:
`analyst-streaming` 的 `generate()` 先经 `_with_warnings()` 包裹,再交给
`_cancellable_agent_stream()`:
```python
response = Response(
stream_with_context(_with_warnings(generate())),
return Response(
stream_with_context(_cancellable_agent_stream(_with_warnings(generate()))),
mimetype='application/x-ndjson',
)
```
深层代码(agent helpers)中调用 `collect_stream_warning()` 收集的 warning 会在每个 chunk 之前自动刷新到流中。
- `_with_warnings()`:深层代码(agent helpers)中调用 `collect_stream_warning()` 收集的 warning 会在每个 chunk 之前自动刷新到流中。
- `_cancellable_agent_stream()`:在后台线程中运行生成器;队列空闲 0.5 秒输出一行 `heartbeat`;生成器抛出的异常转为 `stream_error_event`;客户端断开时设置取消信号,中止正在执行的数据源查询。
`/api/workflows/run` 同样在后台线程中执行,空闲 1 秒输出 `heartbeat`。客户端断开后运行继续,
状态写入 checkpoint;该端点未使用 `_with_warnings()`。
## 4. 前端消费规范
### 4.1 标准解析器
`apiClient.ts` 提供 `parseStreamLine()` 和 `streamRequest()` 作为标准工具。**新端点应优先使用这些函数**。
`src/app/apiClient.ts` 提供 `parseStreamLine()` 和 `streamRequest()` 作为标准工具。当前流式消费方
(`SimpleChartRecBox.tsx` → `analyst-streaming`,`WorkflowPanel.tsx` → `workflows/run`)均使用
`streamRequest()`。**新端点必须使用这些函数**。
### 4.2 手动解析(已有端点)
`streamRequest()` 在流开始前处理两类错误:HTTP 非 2xx,以及 `200 application/json` 的预检错误 envelope,
均抛出 `ApiRequestError`。
已有端点中手动解析 NDJSON 的代码应遵循以下模式:
### 4.2 事件处理顺序
```typescript
const parsed = JSON.parse(trimmed);
for await (const data of streamRequest(url, options, signal)) {
// 1. 先检查 error — 终止当前流处理
if (data.type === 'error') {
dispatch(dfActions.addMessages({ type: 'error', ... }));
return;
}
// 1. 先检查 error — 可能需要中断流
if (parsed.type === 'error') {
dispatch(dfActions.addMessages([{ type: 'error', ... }]));
return; // 或 continue,取决于语义
// 2. 再检查 warning — 显示通知,继续处理
if (data.type === 'warning') {
dispatch(dfActions.addMessages({ type: 'warning', ... }));
continue;
}
// 3. 处理业务事件
...
}
// 2. 再检查 warning — 显示通知,继续处理
if (parsed.type === 'warning') {
dispatch(dfActions.addMessages([{ type: 'warning', ... }]));
continue;
}
// 3. 处理业务事件
if (parsed.text) { ... }
```
### 4.3 禁止事项
@@ -266,11 +293,8 @@ if (parsed.text) { ... }
| 端点 | MIME | 序列化方式 | error 格式 | warning 支持 |
|------|------|------------|------------|-------------|
| `/data-agent-streaming` | `x-ndjson` | route `json.dumps(event)` | `stream_error_event` | ✅ `_with_warnings` |
| `/generate-report-chat` | `x-ndjson` | route `json.dumps(event)` | `stream_error_event` | ✅ `_with_warnings` |
| `/data-loading-chat` | `x-ndjson` | route `json.dumps(event)` | `stream_error_event` | ✅ `_with_warnings` |
> **注意**: `/refine-data` 曾出现在此表中,但实际实现为普通 JSON endpoint(`jsonify` 返回),不使用 NDJSON 流。已于 2026-04-30 Phase 0 盘点中确认并移除。详见 `design-docs/20` 附录 A.10。
| `/api/agent/analyst-streaming` | `x-ndjson` | route `json.dumps(event)` | `stream_error_event` | ✅ `_with_warnings` |
| `/api/workflows/run` | `x-ndjson` | route `json.dumps(event)` | `stream_error_event` | ❌ |
## 6. 新增端点 Checklist
@@ -279,8 +303,8 @@ if (parsed.text) { ... }
- [ ] `mimetype='application/x-ndjson'`
- [ ] 使用 `stream_with_context(_with_warnings(generate()))` 包裹
- [ ] `generate()` 中的 `except` 使用 `stream_error_event(classify_and_wrap_llm_error(e))`
- [ ] 流建立前的校验失败返回 `200 application/json` + `{"status": "error", ...}`,不创建 NDJSON 流
- [ ] 流建立前的校验失败使用 `stream_preflight_error()` 返回 `200 application/json` + `{"status": "error", ...}`,不创建 NDJSON 流
- [ ] Agent yield 的是 dict(Route 层负责 `json.dumps`)
- [ ] 前端消费代码处理 `type: "error"` 和 `type: "warning"`
- [ ] 前端使用 `streamRequest()` 消费,并处理 `type: "error"` 和 `type: "warning"`
- [ ] 不在响应体中使用 `str(e)` / `str(exc)`
- [ ] 如返回 Data Agent `clarify`,使用结构化 `questions[]`,resume 使用 `clarification_responses[]`
- [ ] 如需暂停等待用户回复,使用 `interact` 事件和结构化 `questions[]`,resume 时通过 `trajectory` + `user_question` 回传
@@ -1,11 +1,13 @@
# Agent 知识系统与推理日志开发规范
> **维护者**: DF 核心团队
> **最后更新**: 2026-10-12
> **最后更新**: 2026-10-13
> **适用范围**: 后端 Agent、知识库路由、推理日志、前端知识面板、工作流提炼流程
本规范记录已经落地的 Agent 知识系统、推理日志和工作流提炼实现。设计背景和历史计划见 `design-docs/15-agent-knowledge-reasoning-log.md`;仍未完成的后续优化见 `design-docs/15.2-knowledge-system-followup-improvements.md`。
> **当前状态**:Agent 运行时已统一为 `AnalystAgent`(`py-src/data_formulator/analyst/`)。重构后知识库(rules / workflows)**尚未重新接入 Agent prompt 或工具**,详见 §6。知识存储、API、推理日志和工作流提炼本身仍然可用。
## 1. 架构概览
Agent 知识系统由三部分组成:
@@ -13,7 +15,7 @@ Agent 知识系统由三部分组成:
| 组件 | 位置 | 职责 |
|---|---|---|
| `KnowledgeStore` | `py-src/data_formulator/knowledge/store.py` | 用户知识文件的 CRUD、搜索、front matter 解析、路径安全和内容长度限制 |
| `ReasoningLogger` | `py-src/data_formulator/agents/reasoning_log.py` | DataAgent 会话的结构化 JSONL 调试日志 |
| `ReasoningLogger` | `py-src/data_formulator/agents/reasoning_log.py` | `AnalystAgent`(及继承它的 `WorkflowAgent`)会话的结构化 JSONL 调试日志 |
| `WorkflowDistillAgent` | `py-src/data_formulator/agents/agent_workflow_distill.py` | 从前端传入的 `workflow_context` 提炼可复用工作流文档 |
知识库存储在当前用户 home 下:
@@ -25,7 +27,7 @@ Agent 知识系统由三部分组成:
└── workflows/
```
`rules` 只允许扁平 `.md` 文件;`workflows` 最多允许一级子目录。所有知识文件访问必须通过 `KnowledgeStore`,不要在调用方直接拼接 `Path(user_home) / "knowledge" / user_input`。旧版本留下的 `knowledge/experiences/` 文件会在 `KnowledgeStore` 初始化时一次性迁移到 `workflows/`。
`rules` 只允许扁平 `.md` 文件;`workflows` 的路径校验允许一级子目录(`folder/file.md`),但 `KnowledgeStore` 每次初始化都会执行 `_migrate_flat()`,把子目录中的 workflow 移到 `workflows/` 根目录(重名时追加 `-N` 后缀),因此磁盘上实际是扁平的。所有知识文件访问必须通过 `KnowledgeStore`,不要在调用方直接拼接 `Path(user_home) / "knowledge" / user_input`。旧版本留下的 `knowledge/experiences/` 文件会在 `KnowledgeStore` 初始化时一次性迁移到 `workflows/`。
## 2. 知识文件格式
@@ -34,7 +36,8 @@ Agent 知识系统由三部分组成:
```markdown
---
title: ROI 计算标准
tags: [finance, computation]
description: ROI 统一口径
alwaysApply: true
created: 2026-04-27
updated: 2026-04-27
source: manual
@@ -48,19 +51,19 @@ ROI = (revenue - cost) / cost
| 字段 | 说明 |
|---|---|
| `title` | 标题。缺失时列表和搜索会退回文件名 stem |
| `tags` | 搜索标签。应使用数组 |
| `created` / `updated` | 日期或时间戳 |
| `source` | `manual` 或 `agent_summarized` |
| `source_context` | 工作流提炼来源上下文 ID,可选 |
| `source` | `manual`(默认)或 `distill`(工作流提炼写入) |
Rules 额外支持:
| 字段 | 说明 |
|---|---|
| `description` | 规则短描述,最多 `KNOWLEDGE_LIMITS["rule_description_max"]` 字符 |
| `alwaysApply` | `true` 时总是注入 Agent prompt;`false` 时只通过相关知识搜索注入 |
| `description` | 规则短描述,最多 `KNOWLEDGE_LIMITS["rule_description_max"]`(100)字符 |
| `alwaysApply` | 缺省为 `true`。`load_always_apply_rules()` 只返回 `alwaysApply=true` 的 rules;`search()` 只搜索 `alwaysApply=false` 的 rules |
`KnowledgeStore.write()` 会在缺少 front matter 时补一个最小头部,并按 `KNOWLEDGE_LIMITS` 校验正文长度。
工作流提炼写入的 workflows 额外带有 `source_context`(提炼上下文 ID)、`source_workspace_id`、`source_workspace_name`;后两者用于同一会话重复提炼时 upsert,`list_all("workflows")` 以 `sourceWorkspaceId` / `sourceWorkspaceName` 返回。
`KnowledgeStore.write()` 会在缺少 front matter 时补一个最小头部(rules 默认 `alwaysApply: true`),并校验长度:rules 正文超过 `KNOWLEDGE_LIMITS["rules"]`(350)字符直接拒绝;workflows 的 `KNOWLEDGE_LIMITS["workflows"]`(6000)只是提炼目标长度,超过 `WORKFLOW_HARD_MAX`(24000)才拒绝。
## 3. KnowledgeStore
@@ -81,9 +84,9 @@ results = store.search("missing values", categories=["workflows"])
- 只使用 `rules`、`workflows` 两个 category。
- 所有知识文件必须是 `.md`。
- `rules` 路径只能是 `file.md`;`workflows` 路径最多是 `folder/file.md`。
- `rules` 路径只能是 `file.md`;`workflows` 路径最多是 `folder/file.md`(初始化时会被扁平化,见 §1)。
- 路径穿越、绝对路径和 symlink 逃逸由 `ConfinedDir` 拦截。
- 搜索结果只返回摘要,完整内容通过 `read()` 读取。
- 搜索结果只返回摘要(`category`、`title`、`path`、`snippet`、`source`),完整内容通过 `read()` 读取。
### 搜索算法
@@ -91,7 +94,7 @@ results = store.search("missing values", categories=["workflows"])
**分词规则**(`_tokenize_query`):
- 空格分词后过滤英文停用词和长度 ≤ 2 的 ASCII 短词。
- 小写后按空格分词,过滤英文停用词和长度 ≤ 2 的 ASCII 短词。
- 中英混合 token(如 `"帮我分析ROI"`)自动拆分为 CJK 段和 ASCII 段,各自独立参与匹配。
- 纯中文 token 保持整串,以子串方式匹配(暂不做中文分词,后续可引入 jieba)。
@@ -102,7 +105,6 @@ results = store.search("missing values", categories=["workflows"])
| 字段 | 权重 |
|------|------|
| title | 100 / N |
| tags | 50 / N |
| filename stem | 30 / N |
| body[:200] | 10 / N |
@@ -111,8 +113,7 @@ results = store.search("missing values", categories=["workflows"])
| 条件 | 加分 |
|------|------|
| 完整 query 是 title 的子串 | +50 |
| 完整 query 是某个 tag 的子串 | +50 |
| 会话 table_name 出现在 tags 中 | 每个 +30 |
| 传入的 table_name 出现在 title 或 body[:200] 中 | 每个 +30 |
| `source != "manual"` | 总分 ×0.9 |
**调用方式**:
@@ -121,25 +122,25 @@ results = store.search("missing values", categories=["workflows"])
results = store.search(
"分析各区域的ROI",
categories=["rules", "workflows"],
max_results=5,
table_names=["sales_data", "regions"], # 可选,用于 tag 加分
max_results=5, # 默认 10
table_names=["sales_data", "regions"], # 可选,用于加分
)
```
`alwaysApply=true` 的 rules 始终被搜索跳过(它们通过 system prompt 注入)。
`alwaysApply=true` 的 rules 始终被搜索跳过(它们设计为通过 system prompt 注入)。
## 4. Knowledge API
所有知识 API 在 `py-src/data_formulator/routes/knowledge.py`,Blueprint 前缀为 `/api/knowledge`。所有 application error 遵循统一错误协议:HTTP 200 + `status: "error"`。
所有知识 API 在 `py-src/data_formulator/routes/knowledge.py`,Blueprint 前缀为 `/api/knowledge`。成功响应为 `json_ok()` 信封 `{ status: "success", data }`;application error 遵循统一错误协议:`AppError` → HTTP 200 + `status: "error"`(仅认证类错误码返回非 200)。
| 端点 | 方法 | 请求体 | 说明 |
|---|---|---|---|
| `/api/knowledge/limits` | POST | `{}` | 返回知识正文和 rule 描述长度限制 |
| `/api/knowledge/limits` | POST | `{}` | 返回 `KNOWLEDGE_LIMITS` |
| `/api/knowledge/list` | POST | `{ category }` | 列出某类知识条目 |
| `/api/knowledge/read` | POST | `{ category, path }` | 读取单个知识文件完整内容 |
| `/api/knowledge/write` | POST | `{ category, path, content }` | 创建或更新知识文件 |
| `/api/knowledge/delete` | POST | `{ category, path }` | 删除知识文件 |
| `/api/knowledge/search` | POST | `{ query, categories? }` | 跨类别搜索知识 |
| `/api/knowledge/search` | POST | `{ query, categories? }` | 跨类别搜索知识(不传 `table_names`,默认最多 10 条) |
| `/api/knowledge/distill-workflow` | POST | `{ workflow_context, model, user_instruction?, category_hint?, timeout_seconds? }` | 提炼并保存工作流 |
路由通过 `get_identity_id()` 和 `get_user_home(identity_id)` 定位当前用户目录。不要让前端传入用户目录、workspace 目录或绝对路径。
@@ -156,7 +157,7 @@ DATA_FORMULATOR_HOME/
└── <session_id>-<agent_type>.jsonl
```
开关由 `DF_AGENT_LOG` 控制:
开关由 `DF_AGENT_LOG` 控制(无法识别的值按 `on` 处理):
| 值 | 行为 |
|---|---|
@@ -164,72 +165,50 @@ DATA_FORMULATOR_HOME/
| `on` | 写结构化摘要,防御性剥离 `messages` 字段 |
| `verbose` | 写更完整的 kwargs,并通过 `log_sanitizer.sanitize_params()` 脱敏 |
DataAgent 当前记录的主要事件:
非 `off` 时,`ReasoningLogger` 初始化会在后台线程清理超过 30 天(`_LOG_RETENTION_DAYS`)的日期目录。
当前只有 `AnalystAgent.__init__` 创建 `ReasoningLogger`(`agent_type="AnalystAgent"`;无 `identity_id` 或初始化失败时使用 `_NullReasoningLogger`),`WorkflowAgent` 继承该初始化逻辑。主要事件:
| 事件 | 说明 |
|---|---|
| `session_start` | 会话开始,包含问题、表名、模型和文本 rules 摘要 |
| `context_built` | 初始上下文构建完成,包含 token 估算和注入知识摘要 |
| `knowledge_search` | 自动知识注入或工具搜索 |
| `session_start` | 会话开始,包含问题、输入表名、模型、`execution_config` 和请求传入的 rules 文本 |
| `context_built` | 初始上下文构建完成(续接会话或全新开始),包含 token 估算和 `knowledge_injected` |
| `llm_request` | 发送 LLM 请求前的计数和工具列表 |
| `llm_response` | LLM 响应延迟、finish_reason 和工具调用摘要 |
| `tool_execution` | Agent 内部工具执行结果摘要 |
| `action_execution` | 用户可见 action 执行结果 |
| `repair_attempt` | 代码修复尝试摘要 |
| `progress_reminder` | 注入进度提醒(`WorkflowAgent` 也会记录) |
| `tool_execution` | 工具执行结果摘要;action 提交也以 `input_summary="action_committed"` / `"action_missing_fields"` 记录在此 |
| `action_gated` | action 所属 skill 未加载而被拦截 |
| `action_execution` | 用户可见 action 执行结果(`ok` / `no_handler`) |
| `session_end` | 会话结束状态、迭代次数、LLM 调用次数和耗时 |
注意:当前 `on` 模式仍会记录 `user_question` / `query` 等问题文本;如果新增埋点涉及 credentials、连接串、tokens 或原始数据正文,必须先脱敏或只记录摘要。
注意:当前 `on` 模式仍会记录 `user_question` 等问题文本;如果新增埋点涉及 credentials、连接串、tokens 或原始数据正文,必须先脱敏或只记录摘要。
## 6. Agent 知识集成
路由层通过 `routes/agents.py` 的 `_get_knowledge_store(identity_id)` 创建 `KnowledgeStore`,并传给对应 Agent。知识不可用时必须优雅降级,不能影响原有 Agent 流程。
### 当前状态
### 模式 A:Rules 系统提示词注入
统一 `AnalystAgent` 重构后,旧的按 Agent 区分的三种集成模式(rules 注入、知识上下文注入、`search_knowledge` / `read_knowledge` 工具)已随旧 Agent 一起移除,尚未在新运行时重新接入。现状如下:
适用于**所有 Agent**(DataAgent、DataTransformationAgent、DataRecAgent、DataLoadingAgent、InteractiveExploreAgent、ChartInsightAgent)。只把 `alwaysApply != false` 的 rules 注入系统提示词;非 always-apply rules 通过搜索注入。
- `AnalystAgent.__init__` 会用 `workspace.user_home` 创建 `self._knowledge_store = KnowledgeStore(...)`(失败时记录 warning 并保持 `None`),但运行时不读取它。
- `_build_system_prompt()` 只拼接请求体传入的 `agent_exploration_rules`(替换 `SYSTEM_PROMPT` 中的 `{agent_exploration_rules}` 槽位)和 `agent_coding_rules`(追加为 `## Agent Coding Rules`)。`/api/agent/analyst-streaming` 接受这两个字段,但当前前端(`SimpleChartRecBox.tsx`)不发送它们。
- `self._injected_knowledge` 在 `_build_initial_messages()` 中重置为空且不会被填充,因此 `context_info` 事件不会发出;前端 `SimpleChartRecBox.tsx` 仍保留对 `context_info`(`rules_injected` / `knowledge_injected`)的展示逻辑。
- 所有 skill 的 `tools.json` 中都没有知识工具;`analyst/agent.py` 的 `_TOOL_PROGRESS_ARG_KEYS` 和 `tool_start` 事件中残留的 `search_knowledge` 名称没有对应实现。
- `routes/agents.py` 的 `_get_knowledge_store(identity_id)` 仍然定义,但没有调用方。
- 辅助 Agent(`agents/` 下的 `SimpleAgents`、`DataLoadAgent`、`ChartRestyleAgent`、`CodeExplanationAgent`、`SortDataAgent`、`StarterQuestionsAgent`)和 `workflows/agent.py` 的 `WorkflowAgent` 都不使用知识库;`WorkflowDistillAgent` 只负责生成 workflow 文档。
所有 Agent 统一通过 `KnowledgeStore` 的两个方法完成规则注入:
### 可复用的 KnowledgeStore 接口
- **`load_always_apply_rules()`** — 返回 `[{"title":..., "body":...}]` 列表,适用于需要额外处理原始数据的场景(如 DataAgent 需追踪 `_injected_rules` 标题)。
- **`format_rules_block(rules=None)`** — 直接返回格式化好的 prompt 块(`## User Rules` + `### {title}\n{body}`),内部自动加载规则并处理异常;也可传入 `load_always_apply_rules()` 预加载结果避免二次读取。
重新接入时应复用 `KnowledgeStore` 已有的接口(目前只有测试调用):
各 Agent 典型用法:
- **`load_always_apply_rules()`** — 返回 `[{"title":..., "body":...}]` 列表(只含 `alwaysApply=true` 且正文非空的 rules),失败时返回空列表。
- **`format_rules_block(rules=None)`** — 返回格式化好的 prompt 块(`## ⚠ User Rules (MANDATORY — override defaults)` 标题 + 每条 `### {title}\n{body}`),内部处理异常;无 rules 时返回空字符串。可传入 `load_always_apply_rules()` 的结果避免二次读取。
- **`search(query, categories, max_results, table_names)`** — 用于非 always-apply rules 和 workflows 的按需检索,见 §3。
```python
# 简单场景(大多数 Agent)—— 一行搞定
if self._knowledge_store:
prompt += self._knowledge_store.format_rules_block()
# 需要追踪注入了哪些规则(DataAgent)
if self._knowledge_store:
rules = self._knowledge_store.load_always_apply_rules()
self._injected_rules = [r["title"] for r in rules]
prompt += self._knowledge_store.format_rules_block(rules)
# 需要与 agent_coding_rules 合并(DataRecAgent / DataTransformationAgent)
knowledge_rules = knowledge_store.load_always_apply_rules() if knowledge_store else []
combined_rules = _combine_rules(agent_coding_rules, knowledge_rules)
```
### 模式 B:Library 知识上下文注入
适用于 DataAgent、DataLoadingAgent、InteractiveExploreAgent、ChartInsightAgent。运行时搜索相关 `workflows` 条目,把摘要注入上下文。`KnowledgeStore.search()` 自动跳过 `alwaysApply=true` 的 rules,避免与 system prompt 重复注入。
- **DataAgent** 使用 `_search_relevant_knowledge(user_question, table_names)` 搜索,user question 作为 query,table_names 通过 `search()` 的 `table_names` 参数独立传入以获得 tag 加分。
- **DataLoadingAgent** 使用最近一条用户消息作为搜索 query;无用户消息时回退到 `"data loading cleaning preparation"`。
### 模式 C:DataAgent 工具调用
DataAgent 提供两个内部工具:
- `search_knowledge(query, categories?)`:返回匹配条目的 category、title、path、snippet。
- `read_knowledge(category, path)`:读取完整 Markdown 内容。
工具错误应返回安全、简短的文本结果,并在服务端日志中记录异常类型;不要把 traceback 或内部路径返回给 LLM/用户。
知识不可用时必须优雅降级,不能影响原有 Agent 流程。工具错误应返回安全、简短的文本结果,并在服务端日志中记录异常类型;不要把 traceback 或内部路径返回给 LLM/用户。
## 7. 工作流提炼
提炼以会话(workspace)为单位。`KnowledgePanel` 中的提炼入口打开 `SessionDistillDialog`(`src/views/SessionDistill.tsx`)。`collectSessionThreads()` 找出当前会话里所有 leaf derived table,每个 leaf 对应一个 thread。leaf table 的判断标准是:该表是 derived table,且没有其他 derived table 从它继续派生。
提炼以会话(workspace)为单位。`KnowledgePanel` 中的提炼入口打开 `SessionDistillDialog`(`src/views/SessionDistill.tsx`);注意 `KnowledgePanel` 当前没有挂载到任何界面(见 §8),因此这条前端入口暂时不可达,后端端点仍可用。`collectSessionThreads()` 找出当前会话里所有 leaf derived table,每个 leaf 对应一个 thread。leaf table 的判断标准(`isLeafDerivedTable()`)是:该表是 derived table,且没有其他 derived table 从它继续派生。
`buildLeafEvents()`(`src/views/workflowContext.ts`)从 leaf table 沿当前 Redux 中仍存在的链向上回溯到 root,按时间顺序生成 `events`,类型包括:
@@ -239,7 +218,9 @@ DataAgent 提供两个内部工具:
用户已删除的中间节点不会进入工作流上下文,没有用户消息的 thread 会被丢弃。`buildSessionWorkflowContext()` 把各 thread 组装为 `workflow_context`(`context_id`、`workspace_id`、`workspace_name`、`threads`、可选 `payload_notes`)。超出长度预算时依次丢弃 tool call 事件、把 `sample_rows` 缩减到 1 行、丢弃最早的 thread。
后端 `/api/knowledge/distill-workflow` 要求 `workflow_context`(含非空 `threads`、`workspace_id`、`workspace_name`)和 `model`,使用 `WorkflowDistillAgent` 提炼后写入 `knowledge/workflows/`,响应为 `{ path, category: "workflows" }`。同一个 workspace 重复提炼时按 `source_workspace_id` 更新同一个文件。`WorkflowDistillAgent` 的 prompt 引导提取可复用的通用方法论,而非特定案例细节。这个端点不读取管理员推理日志。
后端 `/api/knowledge/distill-workflow` 要求 `workflow_context`(含非空 `threads`、`workspace_id`、`workspace_name`)和 `model`,使用 `WorkflowDistillAgent` 提炼后由 `_apply_session_front_matter()` 设置 `title`、`source: distill`、`source_workspace_id` / `source_workspace_name`,写入 `knowledge/workflows/`(传入 `category_hint` 时写到对应子目录),响应为 `{ path, category: "workflows" }`。同一个 workspace 重复提炼时通过 `find_workflow_by_workspace_id()` 按 `source_workspace_id` upsert:路径变化时写入新文件后删除旧文件。`WorkflowDistillAgent` 的 prompt 引导提取可复用的通用方法论,而非特定案例细节。这个端点不读取管理员推理日志。
`KnowledgePanel` 还提供 workflow「重放」:读取 workflow 正文,用 `knowledge.replayPrompt` 模板拼成提示,通过 `df-replay-workflow` 事件交给 `SimpleChartRecBox`,作为普通用户问题发给 `AnalystAgent`。
## 8. 前端实现约定
@@ -249,13 +230,14 @@ DataAgent 提供两个内部工具:
|---|---|
| `src/api/knowledgeApi.ts` | Knowledge API client,使用 `fetchWithIdentity` |
| `src/app/useKnowledgeStore.ts` | 知识面板状态管理,使用 React hooks,不走 Redux |
| `src/views/KnowledgePanel.tsx` | 知识树、搜索、编辑、删除 |
| `src/views/AgentRulesDialog.tsx` | knowledge-backed rules 快捷编辑器 |
| `src/views/KnowledgePanel.tsx` | 知识树、搜索、编辑、删除、提炼入口、workflow 重放(**当前未被任何组件挂载**) |
| `src/views/AgentRulesDialog.tsx` | knowledge-backed rules 快捷编辑器(挂载在 `DataThread.tsx`,但当前没有代码把 `agentRulesOpen` 置为 `true`,界面上无法打开) |
| `src/views/SessionDistill.tsx` | 会话级工作流提炼对话框和 `workflow_context` 构建 |
| `src/views/workflowContext.ts` | leaf table 事件构建和提炼 payload 辅助函数 |
| `src/views/DataSourceSidebar.tsx` | 知识库侧栏入口 |
所有用户可见文本必须走 i18n。知识系统当前使用 `common.json` 的 `knowledge.*` key,新增 key 时必须同步更新 `src/i18n/locales/en/common.json` 和 `src/i18n/locales/zh/common.json`。
`DataSourceSidebar.tsx` 中的 `knowledge` 标签页当前渲染的是 `WorkflowPanel`(自动化工作流,对应 `py-src/data_formulator/workflows/`),不是 `KnowledgePanel`。
所有用户可见文本必须走 i18n。知识系统当前使用 `common.json` 的 `knowledge.*` key,新增 key 时必须同步更新 `src/i18n/locales/*/common.json` 中的所有语言(de、en、fr、hi、id、ja、ko、zh)。
跨组件刷新使用自定义事件:
@@ -269,16 +251,16 @@ window.dispatchEvent(new CustomEvent('knowledge-changed', {
## 9. 新模块清单
添加新 Agent 或修改知识集成时:
重新接入 Agent 知识集成或修改知识模块时:
- [ ] 明确该 Agent 需要 `rules`、`workflows` 中哪些类别。
- [ ] 在 Agent 构造函数中添加可选 `knowledge_store` 参数。
- [ ] 在 `routes/agents.py` 对应路由中传入 `_get_knowledge_store(identity_id)`。
- [ ] 选择集成模式:Rules 注入、上下文注入或 DataAgent 工具调用。
- [ ] 明确需要 `rules`、`workflows` 中哪些类别,以及是注入 system prompt、注入初始上下文还是作为工具按需读取。
- [ ] 在 `AnalystAgent` 中复用已创建的 `self._knowledge_store`,不要另起一套路径解析;路由层 `_get_knowledge_store()` 目前无调用方。
- [ ] rules 注入优先用 `load_always_apply_rules()` + `format_rules_block()`;注入内容同步记录到 `rules_injected` / `self._injected_knowledge`,让 `session_start`、`context_built` 日志和 `context_info` 事件如实反映。
- [ ] 作为工具暴露时,在对应 skill 的 `tools.json` 中声明,并在该 skill 的 `skill.py` 中实现(参考 `analyst/skills/*/`),同时检查 `_TOOL_PROGRESS_ARG_KEYS` 中已有的 `search_knowledge` 条目。
- [ ] 搜索失败时优雅降级,并用 `logger.warning(..., exc_info=True)` 记录。
- [ ] 通过 `KnowledgeStore` 访问知识文件,不直接操作 `knowledge/` 文件系统。
- [ ] 涉及推理日志时尊重 `DF_AGENT_LOG` 开关,并避免记录敏感信息。
- [ ] 涉及前端文本时同步 i18n key。
- [ ] 涉及前端文本时同步所有语言的 i18n key。
- [ ] 为新行为添加聚焦测试;后端至少运行相关 `pytest`。
## 10. 测试
@@ -286,18 +268,19 @@ window.dispatchEvent(new CustomEvent('knowledge-changed', {
后端相关测试:
```bash
python -m pytest \
uv run pytest \
tests/backend/agents/test_reasoning_logger.py \
tests/backend/knowledge/test_knowledge_store.py \
tests/backend/routes/test_knowledge_routes.py \
tests/backend/agents/test_agent_knowledge_integration.py -q
tests/backend/routes/test_knowledge_contract.py \
tests/backend/agents/test_workflow_distill.py -q
```
前端相关流程当前主要依赖手动验证和通用 `yarn test`。修改 `KnowledgePanel`、`SessionDistill` 或 leaf-table 逻辑时,至少手动覆盖:
前端:`tests/frontend/unit/views/SessionDistill.test.tsx` 覆盖 `buildSessionWorkflowContext` 等纯函数,通过 `yarn test`(vitest)运行。修改 `KnowledgePanel`、`SessionDistill` 或 leaf-table 逻辑时(`KnowledgePanel` 需先挂载到界面),至少手动覆盖:
- 创建、编辑、删除 rules / workflows。
- 搜索知识条目。
- 多步 DataAgent 分析后,会话中存在 leaf table 时才可以在知识面板打开工作流提炼对话框。
- 多步 `AnalystAgent` 分析后,会话中存在 leaf table 时才可以在知识面板打开工作流提炼对话框。
- 删除中间 table 后,工作流上下文不包含已删除节点。
- 工作流保存成功后知识面板自动刷新。
@@ -305,9 +288,9 @@ python -m pytest \
以下事项尚未完全落地,不应在新代码中假定已经完成:
- ~~`KnowledgeStore.search()` 仍是整串子串匹配~~ → 已改为分词 + 多字段加权匹配(2026-05-03)。
- `test_relevant_knowledge_injected` 仍允许无命中兜底,搜索注入效果需要加强测试。
- 统一 `AnalystAgent` 尚未重新接入知识库(rules 注入、相关 workflows 检索、知识工具),见 §6。
- 前端 `KnowledgePanel` 未挂载、`AgentRulesDialog` 无打开入口,见 §8。
- `KnowledgeStore.list_all()` / `search()` 内部仍有 `Path.read_text()` 风格不统一问题,安全影响低但建议改成 `jail.read_text(rel)`。
- 中文搜索目前保持整串子串匹配(CJK/ASCII 混合文本中的 ASCII 部分会独立拆出),后续可引入 `jieba` 做中文分词。
- 知识预加载(后台线程 + 0.5s 超时 + 降级)尚未实现,当前仍为同步搜索。见 `design-docs/21` §4。
- 知识预加载(后台线程 + 0.5s 超时 + 降级)尚未实现。见 `design-docs/21` §4。
- 上下文预算管理(分块 token 预算 + trajectory 压缩)尚未实现。见 `design-docs/21` §6。
+183 -119
View File
@@ -2,36 +2,40 @@
## 概述
本模块提供远端数据源的全量 catalog metadata 同步。
支撑 Agent 搜索、前端目录浏览、以及未导入数据集的 metadata 预览。
本模块把远端数据源的 catalog(表列表 + 表/列 metadata)缓存到磁盘,
支撑 Agent 数据发现、前端目录浏览、以及未导入数据集的 metadata 预览。
## 架构
```
catalog_cache/<source>.json — 从远端自动同步(刷新时覆盖)
catalog_cache/<source>.json — catalog 快照(listing + metadata),见「存储格式」
catalog_discovery/<source>.json — 后台 discovery 状态(另有同名 .lock / .state.lock)
```
### 数据流
**展开连接器 / 连接后首次浏览(tree,后台 discovery):**
1. 前端 `fetchConnectorCatalog()`(`src/app/utils.tsx`)以 `background: true` 调用 `POST /get-catalog-tree`
2. 有 cache 时,后端直接返回由 cache 构建的目录树(`discovery.status = "complete"`)
3. 无 cache、请求带 `refresh`、本地文件夹 loader、或上次 discovery 为 `failed` / `interrupted`(请求带 `retry`)时,`start_catalog_discovery()`(`datalake/catalog_refresh.py`)在后台线程执行 `loader.list_tables()` + `ensure_table_keys()`,结果以 `refresh_kind="listing"` 写入 cache,接口立即返回 `discovery.status = "running"`
4. 前端以 `poll: true` 轮询(指数退避,总时限 5 分钟),`complete` 后取树;`failed` / `interrupted` 时显示错误和「重试 discovery」
`POST /connect` 本身不读写 cache。
**非 background 的 `get-catalog-tree` 调用:** 优先读 cache;无 cache 时实时 `list_tables()` 且不写 cache。
例外:本地文件夹 loader 每次都重新扫描,无 filter 时以 `refresh_kind="listing"` 写回 cache。
**用户点击刷新(sync):**
1. 前端调用 `POST /sync-catalog-metadata`
2. 后端调用 `loader.sync_catalog_metadata()` → 返回增强的表列表
3. 结果以 `mode="replace"` 写入 `catalog_cache/`
4. 完整目录树**直接返回给前端**(前端不再二次调用 `GET_CATALOG_TREE`)
3. 结果以 `mode="replace"`、`refresh_kind="full"` 写入 `catalog_cache/`
4. 目录树**直接返回给前端**(前端不再二次调用 `GET_CATALOG_TREE`)
**页面重载/普通展开(tree):**
1. 前端调用 `POST /get-catalog-tree`
2. 后端**优先读 `catalog_cache`**,如有则直接返回
3. 无 cache 时降级调用 `loader.list_tables()`,但**不写入 cache**
4. 前端将轻量树存入 React state
**首次连接(connect):**
1. 后端调用 `loader.list_tables()` 获取表信息(Superset 的 `list_tables()` 已包含并行列信息拉取,其他 SQL loader 通过 `information_schema` 自带列信息)
2. 以 `mode="seed_if_missing"` 写入 cache — **仅在无 cache 时写入**,不覆盖已有富数据
**Agent 数据发现:**
1. `search_data_tables` 搜索 workspace + cache(返回 `source_id`/`table_key`)
2. `read_catalog_metadata` 读取 cache 中的 metadata
**Agent 数据发现(按策略自动刷新):**
1. `DataDiscoveryService`(`data_operations/discovery.py`)的 `summarize_data_sources` / `list_data` / `find_data` 先调用 `ensure_catalogs_current()`,对每个已启用且可用的 source 调用 `ensure_catalog_freshness()`;`describe_data` 经 `handle_read_catalog_metadata()` 也会触发同一流程
2. 按 loader 的 `CatalogCachePolicy` 计算 listing / metadata freshness;允许自动刷新且已过期时:无 snapshot 则同步刷新,有 snapshot 则先返回旧 snapshot、在后台线程池刷新
3. 刷新失败通过 `record_catalog_refresh_failure()` 记录错误,不覆盖上次成功的 tables;失败后在 `minimum_retry_seconds` 内不重试
4. `find_data` 为前几个未导入命中补 preview:cache 中无列信息时在 5 秒内实时调用 `loader.get_metadata()`,结果通过 `update_table_metadata()` 合并回 cache
### 身份隔离
@@ -39,63 +43,111 @@ catalog_cache/<source>.json — 从远端自动同步(刷新时覆盖)
`DataConnector` 身份模型(参见 dev-guide-3 §5):
- **用户连接器**:`users/<identity>/catalog_cache/`
- 写入路径通过 `get_user_home(identity)` 获取,与 `save_catalog()` 一致。
- 写入路径通过 `get_user_home(identity)` 获取;Agent 侧从 `workspace.user_home` 派生同一目录。
- 关闭外部连接器(`user_connectors_disabled()`)时,`load_catalog()` / `list_cached_sources()` 只暴露 admin source(如 `sample_datasets`);被断开(disabled)的连接器也会从 `list_cached_sources()` 和 `find_catalog_cache()` 中过滤掉。
## 关键文件
| 文件 | 职责 |
|------|------|
| `py-src/.../external_data_loader.py` | 基类 `sync_catalog_metadata()` + `ensure_table_keys()` |
| `py-src/.../superset_data_loader.py` | Superset loader,`list_tables()` 包含并行列信息拉取 + `extra` JSON 铺平 |
| `py-src/.../superset_client.py` | `get_dataset_columns()`,含 fallback 到完整详情接口 |
| `py-src/.../datalake/catalog_cache.py` | 磁盘缓存,含 `synced_at`、DuckDB/Python 双路径搜索 |
| `py-src/.../data_connector.py` | API 端点:sync、catalog tree 等 |
| `py-src/.../agents/context.py` | `handle_read_catalog_metadata()` + `handle_search_data_tables()` |
| `py-src/.../agents/agent_utils.py` | `build_catalog_metadata_lookups()` — Agent 上下文自动注入 |
| `py-src/.../data_loader/external_data_loader.py` | 基类 `sync_catalog_metadata()`、`ensure_table_keys()`、`search_catalog()`、`CatalogCachePolicy` |
| `py-src/.../data_loader/__init__.py` | 内置 loader 的 `_BUILTIN_CATALOG_POLICIES` |
| `py-src/.../data_loader/superset_data_loader.py` | Superset loader,`list_tables()` 包含并行列信息拉取 + `extra` JSON 铺平 |
| `py-src/.../data_loader/superset_client.py` | `get_dataset_columns()`,含 fallback 到完整详情接口 |
| `py-src/.../datalake/catalog_cache.py` | 磁盘缓存(schema v2,原子写入)、`find_catalog_cache()` 正则搜索、`list_path_children()` / `summarize_catalog_sources()` 层级浏览 |
| `py-src/.../datalake/catalog_refresh.py` | 后台 discovery、`ensure_catalog_freshness()` 自动刷新 |
| `py-src/.../data_connector.py` | API 端点:sync、catalog tree、search 等 |
| `py-src/.../data_operations/discovery.py` | `DataDiscoveryService`:Agent 数据发现工具的实现 |
| `py-src/.../agents/context.py` | `handle_read_catalog_metadata()`、`handle_inspect_source_data()` |
| `py-src/.../agents/agent_utils.py` | `build_catalog_metadata_lookups()`、`generate_data_summary()` — Agent 上下文自动注入 |
| `py-src/.../analyst/session_layer/layer.py` | `AnalystAgent` 会话视图中表的 description / 列 metadata 叠加 |
| `src/views/DataSourceSidebar.tsx` | 前端:sync API + 本地树渲染 |
| `src/components/VirtualizedCatalogTree.tsx` | 虚拟化树渲染 + tooltip |
| `src/app/utils.tsx` | `CONNECTOR_ACTION_URLS.SYNC_CATALOG_METADATA` |
| `src/components/VirtualizedCatalogTree.tsx` | 虚拟化树渲染 + hover card |
| `src/app/utils.tsx` | `CONNECTOR_ACTION_URLS`、`fetchConnectorCatalog()` |
## 缓存写入边界
`save_catalog()` 支持两种写入模式:
`save_catalog()` 有两个维度的参数:
| 模式 | 行为 | 使用场景 |
|------|------|---------|
| `"replace"` (默认) | 无条件覆盖 cache 文件 | `sync-catalog-metadata`(用户点击刷新) |
| `"seed_if_missing"` | 仅在文件不存在时写入 | `connect`(首次连接时种子 cache) |
| 参数 | 取值 | 行为 |
|------|------|------|
| `mode` | `"replace"`(默认) | 写入新快照 |
| | `"seed_if_missing"` | 仅在文件不存在时写入(当前无调用方) |
| `refresh_kind` | `"full"`(默认) | 用新结果替换 tables,更新 `metadata_refreshed_at` |
| | `"listing"` | 与旧 tables 按 `table_key` / `path` / `name` 合并:新 listing 中非空的 metadata 覆盖旧值,旧的富 metadata(如 columns)保留;`metadata_refreshed_at` 不变 |
**核心原则:** `GET_CATALOG_TREE`(浏览接口)**不写 cache**。只有 sync(刷新)和
connect(首次种子)才写入。对于 `list_tables()` 已包含完整列信息的 Loader(如
Superset),connect 写入的种子 cache 即为富 metadata;对于其他 Loader,
`seed_if_missing` 防止轻量结果覆盖后续 sync 拉到的富 metadata。
写入方:
| 场景 | 写入方式 |
|------|---------|
| `sync-catalog-metadata`(用户点击刷新) | `replace` + `full` |
| `get-catalog-tree` 后台 discovery | `listing` |
| `get-catalog-tree` 本地文件夹(非 background、无 filter) | `replace` + `listing` |
| `ensure_catalog_freshness()` 自动刷新 | 按策略的 `automatic_refresh_kind`(`listing` 调 `list_tables()`,`full` 调 `sync_catalog_metadata()`) |
| `find_data` preview 实时拉取 | `update_table_metadata()`,按 `table_key` 合并非空字段 |
| 刷新失败 | `record_catalog_refresh_failure()`,只更新状态字段 |
**核心原则:** 轻量的 listing 刷新不能降级 sync 拉到的富 metadata(由 `refresh_kind="listing"` 的合并保证)。
所有写入都是 best-effort,异常只记 debug 日志,不中断请求。
### CatalogCachePolicy
每个 loader 类通过 `CATALOG_CACHE_POLICY` / `catalog_cache_policy()` 声明缓存策略:
| 字段 | 默认值 | 说明 |
|------|-------|------|
| `listing_ttl_seconds` | 21600 | listing 新鲜期 |
| `metadata_ttl_seconds` | 86400 | metadata 新鲜期 |
| `refresh_cost` | `"expensive"` | `local` / `free` / `cheap` / `moderate` / `expensive` |
| `automatic_refresh` | `"never"` | `always` / `while_connected` / `never` |
| `automatic_refresh_kind` | `"listing"` | `listing` / `full` |
| `minimum_retry_seconds` | 300 | 刷新失败后的最短重试间隔 |
内置 loader 的策略在 `data_loader/__init__.py` 的 `_BUILTIN_CATALOG_POLICIES` 中覆盖(例如 `sample_datasets` 为 `always` + `full`,MySQL/PostgreSQL/MSSQL/ClickHouse/Cube 为 `while_connected` + `full`,本地文件夹 TTL 为 0)。
## API 端点
| 端点 | 方法 | 用途 |
|------|------|------|
| `/api/connectors/sync-catalog-metadata` | POST | 全量 metadata 同步 → 直接返回目录树 + 同步摘要 |
| `/api/connectors/get-catalog-tree` | POST | 浏览树(优先读 cache,不写 cache) |
| `/api/connectors/get-cached-catalog-tree` | POST | 纯读 cache(页面重载) |
| `/api/connectors/sync-catalog-metadata` | POST | 全量 metadata 同步 → 写 cache,返回目录树 + 同步摘要 |
| `/api/connectors/get-catalog-tree` | POST | 浏览树;`background: true` 时走后台 discovery(见数据流) |
| `/api/connectors/get-catalog-progress` | POST | 非 background 的实时 listing 进度消息(前端当前未使用) |
| `/api/connectors/get-cached-catalog-tree` | POST | 纯读 cache,返回 `cache_hit` + 树 + `synced_at`(前端当前未使用) |
| `/api/connectors/search-catalog` | POST | 调用 `loader.search_catalog()` 实时搜索源端,不读写 cache |
### Sync API 响应
`json_ok()` 信封中的 `data`:
```json
{
"status": "ok",
"message": "Catalog sync complete",
"message_code": "catalog.syncComplete",
"message_params": { "synced": 10, "failed": 1, "total": 13 },
"hierarchy": [...],
"effective_hierarchy": [...],
"tree": [...],
"sync_summary": { "synced": 10, "partial": 2, "failed": 1, "total": 13 }
}
```
存在 `partial` 或 `failed` 时 `message_code` 为 `catalog.syncPartial`。
## 存储格式
### catalog_cache
```json
{
"schema_version": 2,
"source_id": "superset_prod",
"synced_at": "2026-04-28T10:00:00Z",
"listing_refreshed_at": "2026-04-28T10:00:00Z",
"metadata_refreshed_at": "2026-04-28T10:00:00Z",
"refresh_kind": "full",
"refresh_status": "complete",
"last_refresh_attempt_at": "2026-04-28T10:00:00Z",
"last_refresh_error": null,
"tables": [
{
"table_key": "a1b2c3d4-...",
@@ -114,14 +166,18 @@ Superset),connect 写入的种子 cache 即为富 metadata;对于其他 Lo
}
```
`load_catalog_snapshot()` 读取时按策略 TTL 计算 `listing_freshness` / `metadata_freshness`;没有新字段的旧文件回退到 `synced_at`。
### source_metadata_status 枚举
| 值 | 含义 | 典型场景 |
|----|------|---------|
| `"synced"` | 已完整同步(表描述 + 列信息) | SQL loader 从 info_schema 拿到列;Superset 列 API 返回数据 |
| `"synced"` | 已完整同步(表描述 + 列信息) | SQL loader 从 info_schema 拿到列;Superset 列 API 返回数据;基类 `sync_catalog_metadata()` 对未设置状态的记录补 `synced` |
| `"partial"` | 部分成功(有描述但列信息为空或缺失) | Superset 列 API 返回空列表;SQL 表存在但无列 |
| `"unavailable"` | metadata 获取完全失败(API 错误) | 网络超时、认证失败 |
| `"not_synced"` | 从未执行过全量同步(仅有轻量结果) | 非 Superset 类 loader 初始连接后未点刷新(Superset 的 `list_tables()` 已包含列信息,不会出现此状态) |
| `"not_synced"` | 记录未带状态 | `handle_read_catalog_metadata()` 在 metadata 无该字段时显示的默认值 |
未显式设置时,`infer_source_metadata_status()` 按是否有 columns / description 推断。
## table_key 契约
@@ -139,19 +195,25 @@ Superset),connect 写入的种子 cache 即为富 metadata;对于其他 Lo
**对 Loader 开发者的要求:**
- `list_tables()` 和 `sync_catalog_metadata()` 必须在每条记录中包含 `table_key`。
- `table_key` 不能为空。它作为 JSON 字典 key 使用(无文件系统路径风险)。
- 其他辅助标识(`uuid`、`dataset_id`、`_source_name`)保留在 `metadata` 中备查。
- 其他辅助标识(`uuid`、`dataset_id`、`_source_name`)保留在 `metadata` 中备查;`resolve_load_table()` 和 `build_catalog_metadata_lookups()` 也接受这些标识匹配。
## 搜索
`find_catalog_cache()`(`datalake/catalog_cache.py`)按以下权重对 cache 中的表打分:
`find_catalog_cache()`(`datalake/catalog_cache.py`)在 cache 中查找 folder / table 节点:
- `query` 为可选的大小写不敏感**正则**;省略时枚举 `path_prefix` 下的全部后代;非法正则抛 `CatalogSearchError`。
- 可用 `filter_by`(`folder` / `table`)、`fields`(`name` / `description` / `columns`)、`path_prefix`、`exclude_tables` 收窄;`limit` 上限 500,返回 `(results, truncated)`。
- 按 `score` 降序排序,打分如下:
| 匹配类型 | 分数 |
|---------|------|
| 表名 | +10 |
| 表名(叶子名或 `name`)/ 文件夹名 | +10 |
| 远端描述 | +5 |
| 列名 | +2 |
| 远端列描述 | +1 |
`find_data` 未指定 `source_id` 时先搜索 workspace 已导入的表,再搜索 cache;整句无命中且 query 是普通短语时,会按词再搜一次(`_find_any_word()`)。
## Loader 实现
### 基类默认实现
@@ -165,7 +227,7 @@ def sync_catalog_metadata(self, table_filter=None):
for t in tables:
meta = t.get("metadata")
if meta and "source_metadata_status" not in meta:
meta["source_metadata_status"] = "synced"
meta["source_metadata_status"] = SOURCE_METADATA_SYNCED
return tables
```
@@ -194,20 +256,20 @@ def sync_catalog_metadata(self, table_filter=None):
| 操作 | catalog_cache |
|------|--------------|
| 断开连接 | 保留 |
| 删除连接器 | 删除 |
| 重新连接 | 下次 sync 时覆盖 |
| 连接(`connect`) | 不读写;连接器重新标记为 enabled |
| 断开连接 | 文件保留;连接器标记为 disabled,Agent 发现工具和 `list_cached_sources()` 跳过它 |
| 删除连接器 | `cancel_catalog_discovery()` + `delete_catalog(user_home, source_id)` |
| 重新连接 | 复用已有 cache;之后按策略自动刷新或 sync 时更新 |
| 重新同步(刷新) | 覆盖 |
- 删除连接器调用 `delete_catalog(user_home, source_id)`。
## 前端浏览模型
### 设计原则
1. **一次全量取轻量树** — 展开连接器时通过 `GET_CATALOG_TREE` 一次获取完整嵌套树(所有层级),不做逐级懒加载
2. **虚拟化渲染** — 使用 `react-window` 的 `FixedSizeList`,即使 6000+ 节点也只渲染可视区域
2. **虚拟化渲染** — 侧栏使用 `react-virtuoso`(`customScrollParent`),无 `scrollParent` 时回退到 `react-window` 的 `FixedSizeList`,即使 6000+ 节点也只渲染可视区域
3. **本地过滤** — 用户输入时在内存中的轻量树上做客户端过滤,零网络请求
4. **后端搜索** — 回车/按钮触发 `SEARCH_CATALOG` 服务端全文检索,结果替换显示树
4. **后端搜索** — 回车/按钮触发 `SEARCH_CATALOG`,由 loader 在源端搜索,结果替换显示树
5. **节点展开/收起完全由 React state 控制** — 搜索模式下也允许用户自由操作
### 前后端交互流程
@@ -217,13 +279,16 @@ def sync_catalog_metadata(self, table_filter=None):
│ 用户操作 → API 调用 │
├───────────────────────────────────────────────────────────────────────┤
│ 首次展开连接器 → POST /api/connectors/get-catalog-tree │
│ 返回: { tree: [...nested...], hierarchy: [...] } │
│ (background: true;discovery 运行中则轮询) │
│ 返回: { tree, hierarchy, effective_hierarchy } │
│ 前端: 完整树存入 catalogByConnector state │
│ 顶层 namespace ≤ 10 个时自动展开 │
│ │
│ 点击刷新按钮 → POST /api/connectors/sync-catalog-metadata │
│ (全量拉取远端 metadata,更新磁盘缓存) │
│ 后端直接返回目录树(不再二次调 tree 接口) │
│ 前端: 直接将返回的 tree 存入 state │
│ (服务端搜索激活时,刷新按钮改为重新搜索) │
│ │
│ 展开/收起文件夹 → 纯前端操作,更新 treeExpanded state,零网络请求 │
│ │
@@ -232,7 +297,7 @@ def sync_catalog_metadata(self, table_filter=None):
│ 用户仍可手动收起/展开 │
│ │
│ 搜索框回车/按钮 → POST /api/connectors/search-catalog │
│ 返回: { tree: [...] } │
│ 返回: { tree: [...], truncated } │
│ 前端: 结果存入 searchCatalogCache,自动全展开 │
│ │
│ 清空搜索 → 回到原始 catalogByConnector 树,恢复之前展开状态 │
@@ -243,28 +308,29 @@ def sync_catalog_metadata(self, table_filter=None):
| 端点 | 用途 | 返回数据量 |
|------|------|-----------|
| `GET_CATALOG_TREE` | 获取完整嵌套轻量树 | 轻量(name/path/基础metadata,含 children 嵌套) |
| `SYNC_CATALOG_METADATA` | 全量 metadata 同步(含列信息) | 重量(完整 columns/row_count 等) |
| `SEARCH_CATALOG` | 服务端全文搜索 | 轻量(匹配结果的嵌套树) |
| `GET_CATALOG` | 单级 ls()(保留用于特殊场景) | 轻量(单级扁平节点) |
| `GET_CATALOG_TREE` | 获取完整嵌套轻量树 | 轻量(经 `_lightweight_tree_for_response()` 裁剪) |
| `SYNC_CATALOG_METADATA` | 全量 metadata 同步(含列信息) | 磁盘 cache 为完整 metadata;响应树同样裁剪为轻量 |
| `SEARCH_CATALOG` | 服务端搜索 | 轻量(匹配结果的嵌套树) |
| `GET_CATALOG` | 单级 ls()(保留用于特殊场景,前端当前未使用) | 轻量(单级扁平节点) |
### 数据结构
`GET_CATALOG_TREE` 和 `SYNC_CATALOG_METADATA` 返回的树节点(`CatalogTreeNode`):
`GET_CATALOG_TREE` 和 `SYNC_CATALOG_METADATA` 返回的树节点(`CatalogTreeNode`,定义在 `src/components/CatalogTree.tsx`):
```typescript
{
name: string; // 显示名
node_type: 'namespace' | 'table' | 'table_group';
node_type: 'namespace' | 'table' | 'table_group' | 'load_more';
path: string[]; // 从根到当前节点的路径
metadata: { // 轻量 metadata
metadata: { // 轻量 metadata(_lightweight_tree_for_response 裁剪后)
_source_name?: string;
dataset_id?: number;
row_count?: number;
description?: string; // 源系统描述
source_description?: string; // 远端系统描述
description?: string;
source_metadata_status?: string;
// 注意:不含 columns(轻量版)
columns?: { name: string; type?: string }[]; // 最多 50 列,仅 name/type
column_count?: number;
// semantic model(query_model === "semantic")保留完整 columns
} | null;
children?: CatalogTreeNode[]; // namespace 节点含子节点(递归嵌套)
}
@@ -272,11 +338,11 @@ def sync_catalog_metadata(self, table_filter=None):
### 虚拟化渲染(`VirtualizedCatalogTree.tsx`)
- 使用 `react-window` 的 `FixedSizeList`
- 传入 `scrollParent` 时使用 `react-virtuoso` 的 `customScrollParent`(侧栏即如此,避免嵌套滚动条);否则使用 `react-window` 的 `FixedSizeList`
- 将嵌套树 flatten 为一维行列表,根据展开状态动态计算
- 当 `maxHeight="none"` 时,高度上限取 `window.innerHeight - 200`(确保虚拟化生效)
- `FixedSizeList` 在 `maxHeight="none"` 时,高度上限取 `max(400, window.innerHeight - 200)`(确保虚拟化生效)
- 少于 100 行时不启用虚拟化,直接 DOM 渲染
- 每行高度固定 28px
- 默认行高 `px(24)`
### 展开/收起 state 管理
@@ -301,8 +367,8 @@ onExpandedChange={(newIds) => {
| 决策 | 原因 | 禁止的做法 |
|------|------|-----------|
| 一次取全树 | 避免逐级拉取的竞态、badge 闪烁、多级嵌套支持 | ❌ 用 `GET_CATALOG` 逐级 fetch + mergeChildrenAtPath |
| 轻量树用于浏览 | `GET_CATALOG_TREE` 调 `list_tables()` 不含列信息,数据量小 | ❌ 把 `SYNC_CATALOG_METADATA` 的重数据存入浏览 state |
| FixedSizeList 有界高度 | 确保 react-window 只渲染可视行 | ❌ height=totalHeight 导致所有行都被渲染 |
| 轻量树用于浏览 | 树响应经 `_lightweight_tree_for_response()` 裁剪,完整 metadata 只留在磁盘 cache | ❌ 在树响应或浏览 state 中放入完整 columns / sample_rows |
| 有界高度 / 外部滚动容器 | 确保只渲染可视行 | ❌ height=totalHeight 导致所有行都被渲染 |
| 搜索不锁定展开状态 | 用户需要收起不关心的文件夹 | ❌ 搜索时强制 expanded=collectNamespaceIds() 覆盖用户操作 |
### 回归风险清单
@@ -314,27 +380,35 @@ onExpandedChange={(newIds) => {
- [ ] 搜索时可以收起文件夹
- [ ] 清空搜索后恢复原始树和展开状态
- [ ] 刷新按钮触发远端同步后树正确刷新
- [ ] 无 cache 的连接器展开时显示 discovery 进度,失败后可重试
## Agent 工具链
| 工具 | 层级 | 输入 | 输出 |
|------|------|------|------|
| `search_data_tables` | Search | keyword + scope | 候选列表:名称、描述、匹配列 + **source_id/table_key**(not-imported) |
| `read_catalog_metadata` | Read | source_id + table_key + workspace | 完整 metadata:列、类型、描述、schema、row_count |
| `inspect_source_data` | Data | table_names + workspace(仅已导入) | Parquet schema、样例行、统计信息 |
`AnalystAgent` 通过 skill 暴露数据发现工具(声明在 `analyst/skills/*/tools.json`,使用指引见 `connected_data` skill 的 `SKILL.md`):
工作流:搜索 → 读取 metadata → (需要时导入) → 检查数据。
| 工具 | 所属 skill | 层级 | 输入 | 输出 |
|------|-----------|------|------|------|
| `summarize_data_sources` | workspace | Overview | — | 每个已连接 source 的层级统计、顶层条目、样例表、freshness |
| `list_data` | workspace | Browse | source_id + path + filter_by | 当前路径下的直接子节点(类似 `ls`) |
| `find_data` | workspace | Search | query(正则)+ source_id/path + filter_by/fields | workspace 已导入表 + cache 命中(`source_id`/`table_key`、匹配列、preview) |
| `describe_data` | workspace | Read | source_id + table_key(+ 列分页/过滤) | 完整 metadata:列、类型、描述、schema、row_count、sample rows |
| `probe_data` | workspace | Probe | source_id + table_key + query | 对源端执行有界只读查询(每轮有 probe 预算) |
| `inspect_source_data` | analysis | Data | table_names(仅已导入) | `generate_data_summary()`:schema、字段统计、样例行 |
工作流:概览/浏览/搜索 → 读取 metadata →(需要时 probe)→ `propose_data_operation` 导入 → 检查数据。
`workspace` skill 中的这些工具由 `analyst/skills/workspace/data_loading.py` 分发到 `DataDiscoveryService`。
### Agent 上下文自动注入
Agent 构建数据摘要时(`generate_data_summary`、`build_lightweight_table_context`),
会自动通过 `build_catalog_metadata_lookups(workspace)` 将 catalog cache 元数据
注入到上下文中。该函数:
`build_catalog_metadata_lookups(workspace)`(`agent_utils.py`)把 catalog cache 元数据叠加到已导入表上,
被 `generate_data_summary()` 和 `AnalystAgent` 会话视图(`session_layer/layer.py` 的 `_descriptions()`)使用。该函数:
1. 从 `workspace.user_home` 自动解析用户目录(无需外部传参)
2. 加载所有 source 的 `catalog_cache`
3. 按 `table_key` / `_source_name` / `name` 匹配 workspace 中的表
4. 提取表描述、列描述(含 `verbose_name`、`expression`)叠加到 LLM 上下文
2. 加载 `list_cached_sources()` 返回的所有 source 的 `catalog_cache`
3. 用 workspace 中表的 `TableMetadata.source_table` 匹配 catalog 条目的 `table_key` / `name` / `_source_name` / `dataset_id` / `uuid`
4. 提取表描述、列描述(`source_description` 优先)以及列的 `verbose_name`、`expression`
表描述在两个入口中都以 workspace metadata 优先、catalog 只补缺失;列描述在 `generate_data_summary()` 中由 catalog 覆盖同名列,在会话视图中仍以 workspace 优先。
**设计原则:** `user_home` 不通过函数参数传播。`Workspace` 对象持有 `_identity_id`,
通过 `user_home` property 自动派生用户目录。所有需要 catalog 数据的函数从
@@ -342,10 +416,10 @@ workspace 内部获取,调用方无需关心存储路径。
### import_options 加载口径注入
当表通过数据连接器导入时,用户选择的过滤条件、排序和行数限制
当表通过数据连接器导入时,用户选择的过滤条件、列、排序和行数限制
(统称 `import_options`)会存储在 `TableMetadata.import_options` 中。
Agent 在构建数据摘要时会自动读取该字段并生成人类可读的 provenance 描述,
例如:`Data subset: 2 filter(s), sorted by created_at desc, row limit 2,000,000`。
例如:`Data subset: filters [...], selected columns ["id", "amount"], sorted by created_at desc, row limit 2,000,000`。
**传递路径:**
@@ -353,41 +427,30 @@ Agent 在构建数据摘要时会自动读取该字段并生成人类可读的 p
将 `import_options` 持久化到 workspace metadata。
2. **数据刷新** — `refresh_parquet_from_arrow()` 自动从旧 metadata 复制
`import_options` 到新 metadata,确保刷新不丢失加载口径。
3. **API 暴露** — `/api/tables/list-tables` 在 `source_metadata` 中返回
3. **API 暴露** — `GET /api/tables/list-tables` 在 `source_metadata` 中返回
`import_options`,前端可展示或传递。
4. **Agent 上下文** — `generate_data_summary()` 和 `build_lightweight_table_context()`
通过 `_format_import_options()` 将 `import_options` 格式化后注入表描述。
4. **Agent 上下文** — `generate_data_summary()` 和 `_get_workspace_metadata_lookups()`(`context.py`,
供会话视图的 `loaded_with` 字段使用)通过 `_format_import_options()` 将 `import_options` 格式化后注入表描述。
**格式化函数** `_format_import_options(opts)` 位于 `agent_utils.py`,
返回如 `Data subset: 1 filter(s), sorted by id asc, row limit 2,000,000` 的字符串。
空 opts 返回空字符串。
依次拼接 `filters <json>`、`selected columns <json>`、`sorted by ... asc|desc`、`row limit N`,
以 `Data subset: ` 开头。空 opts 或无可识别字段时返回空字符串。
## 错误码
| 错误码 | 触发场景 |
|--------|---------|
| `CATALOG_SYNC_TIMEOUT` | sync_catalog_metadata 超时(>120s) |
| `CATALOG_NOT_FOUND` | connector_id 不存在或未连接 |
| `CATALOG_SYNC_TIMEOUT` | `sync-catalog-metadata` 中 loader 抛出 `TimeoutError`(如 Superset 列拉取超过 120s),`retry=True` |
| `CATALOG_NOT_FOUND` | 已在 `ErrorCode` 和前端 `errorCodes.ts` 中定义,后端当前未抛出 |
其余连接器错误通过 `classify_and_raise_connector_error(e, operation="catalog")` 分类。
## i18n Keys
前端字符串在 `dataLoading` namespace:
- 同步状态:`syncInProgress`、`syncComplete`、`syncPartial`
- Metadata 状态徽章:`metadataStatusSynced`、`metadataStatusPartial`、`metadataStatusUnavailable`、`metadataStatusNotSynced`
错误翻译在 `errors` namespace:
- `catalogNotFound`
### 后端 message_code(TODO)
设计文档原计划后端响应携带 `message_code` 字段供前端 `translateBackend()` 消费。
当前状态:
| message_code | 状态 | 说明 |
|-------------|------|------|
| `catalog_sync_timeout` | ❌ 缺失 | Sync API 未显式捕获超时;应 raise `AppError(ErrorCode.CATALOG_SYNC_TIMEOUT)` |
`catalog_sync_timeout` 应在同步超时处理加固时一并解决。
- 同步结果:后端 `message_code` 为 `catalog.syncComplete` / `catalog.syncPartial`(`messages.json`),前端通过 `translateBackend()` 显示;`dataLoading.syncPartial` 用作同步/discovery 失败时的兜底文本。
- `dataLoading` namespace 中另有 `syncInProgress`、`syncComplete`、`metadataStatusSynced`、`metadataStatusPartial`、`metadataStatusUnavailable`、`metadataStatusNotSynced`,当前没有组件引用。
- 错误翻译在 `errors` namespace:`catalogSyncTimeout`、`catalogNotFound`(映射见 `src/app/errorCodes.ts`)。
- 侧栏刷新按钮使用 `sidebar.refreshCatalog`(`common.json`);discovery 失败文案 `sidebar.discoveryIncomplete`、`sidebar.retryDiscovery` 目前只依赖代码中的 `defaultValue`,locale 文件中尚无这两个 key。
## 新 Loader 检查清单
@@ -399,6 +462,8 @@ Agent 在构建数据摘要时会自动读取该字段并生成人类可读的 p
- [ ] 为每张表设置 `source_metadata_status`:`"synced"`、`"partial"` 或 `"unavailable"`
- [ ] 在 `list_tables()` 或 `sync_catalog_metadata()` 返回前调用 `self.ensure_table_keys(tables)`
- [ ] 对于 info_schema 按库隔离的 SQL loader,在 `sync_catalog_metadata()` 中遍历所有可访问数据库
- [ ] 按刷新成本设置 `CATALOG_CACHE_POLICY`(内置 loader 在 `_BUILTIN_CATALOG_POLICIES` 中登记);昂贵的源保持 `automatic_refresh="never"`
- [ ] 长时间 listing 可通过 `_report_progress()` 上报进度,供后台 discovery 显示
## Agent 元数据传递审计表
@@ -408,11 +473,11 @@ Agent 在构建数据摘要时会自动读取该字段并生成人类可读的 p
| 字段 | 来源 | 说明 |
|------|------|------|
| `source_description` | Loader → catalog_cache | 来自源系统的原始描述 |
| `description` / `source_description` | Loader → catalog_cache | 来自源系统的表/列描述 |
| `verbose_name` | Loader(如 Superset) | 列的显示别名(如中文名) |
| `expression` | Loader(如 Superset) | 计算列公式(如 `SUM(line_items.amount)`) |
| `extra` | Loader(Superset) | Superset 列的 `extra` JSON 字段,包含 `certification`、`warning_markdown` 等信息。`_build_column_entry()` 自动铺平到 `description` |
| `import_options` | 数据导入/刷新 | 用户选择的过滤/排序/行限制条件(与本次分析相关) |
| `import_options` | 数据导入/刷新 | 用户选择的过滤/列/排序/行限制条件(与本次分析相关) |
### 管线节点传递矩阵
@@ -427,17 +492,16 @@ Agent 在构建数据摘要时会自动读取该字段并生成人类可读的 p
| **`_format_import_options`** (`agent_utils.py`) | — | — | — | ✅ 格式化 |
| **`get_field_summary`** (`agent_utils.py`) | ✅ `(description)` | ✅ `[verbose_name]` | ✅ `[calc: expr]` | — |
| **`generate_data_summary`** (`agent_utils.py`) | ✅ | ✅ | ✅ | ✅ |
| **`build_lightweight_table_context`** (`context.py`) | ✅ | ✅ | ✅ | ✅ |
| **`_get_workspace_metadata_lookups`** (`context.py`) | ✅(workspace metadata) | — | — | ✅ |
| **`SessionLayer._data_profile`** (`session_layer/layer.py`) | ✅ | ✅ 列字段 | ✅ 列字段 | ✅ `loaded_with` |
| **`handle_read_catalog_metadata`** (`context.py`) | ✅ | ✅ `[verbose_name]` | ✅ `[calc: expr]` | — |
### Agent 受益矩阵
| Agent | 上下文入口 | description | verbose_name | expression | import_options |
| Agent / 入口 | 上下文入口 | description | verbose_name | expression | import_options |
|-------|-----------|:-----------:|:------------:|:----------:|:--------------:|
| DataAgent | `generate_data_summary` + `read_catalog_metadata` tool | ✅ | ✅ | ✅ | ✅ |
| DataTransformationAgent | `generate_data_summary` | ✅ | ✅ | ✅ | ✅ |
| DataRecAgent | `generate_data_summary` | ✅ | ✅ | ✅ | ✅ |
| InteractiveExploreAgent | `build_lightweight_table_context` | ✅ | ✅ | ✅ | ✅ |
| ReportGenAgent | `build_lightweight_table_context` | ✅ | ✅ | ✅ | ✅ |
| ChartInsightAgent | `generate_data_summary` | ✅ | ✅ | ✅ | ✅ |
| CodeExplanationAgent | `generate_data_summary` | ✅ | ✅ | ✅ | ✅ |
| AnalystAgent 会话视图(`session/tables/<name>.json`,焦点表内联到初始上下文) | `SessionLayer._data_profile` | ✅ | ✅ | ✅ | ✅ |
| AnalystAgent `inspect_source_data` | `generate_data_summary` | ✅ | ✅ | ✅ | ✅ |
| AnalystAgent `describe_data` | `handle_read_catalog_metadata` | ✅ | ✅ | ✅ | — |
| DataLoadAgent(`/api/agent/process-data-on-load`) | `generate_data_summary` | ✅ | ✅ | ✅ | ✅ |
| CodeExplanationAgent(`/api/agent/code-expl`) | `generate_data_summary` | ✅ | ✅ | ✅ | ✅ |
+73 -99
View File
@@ -4,47 +4,53 @@
## 背景
Sandbox worker 执行 `explore()` / `execute_python()` 代码时,默认每次创建全新的
Python namespace。在 DataAgent 和 DataLoadingAgent 的 agentic loop 中,LLM 可能
多次调用这些工具,但变量(DataFrame、中间计算结果)在调用之间全部丢失,迫使每次
重新 import 和重新读取数据,浪费 tool rounds。
Sandbox worker 执行代码时,默认每次创建全新的 Python namespace。`AnalystAgent`
的 agentic loop 中,LLM 可能在一次 action 之前多次调用 Python 工具,如果变量
(DataFrame、中间计算结果)在调用之间全部丢失,就要每次重新 import 和重新读取数据,
浪费 tool rounds。
`SandboxSession` 解决了这个问题:在同一个 agent turn 内,多次 sandbox 调用共享
同一个 Python namespace,变量在调用间存活(类似 Jupyter kernel)。
`SandboxSession` 解决了这个问题:在同一个 `_get_next_action()` 调用内,多次
sandbox 调用共享同一个 Python namespace,变量在调用间存活(类似 Jupyter kernel)。
## 架构
```
_WarmWorkerPool
└── Worker Process (pre-imported pandas/numpy/duckdb)
_WarmWorkerPool (local_sandbox.py 模块级 _worker_pool)
└── Worker Process (pre-imported pandas/numpy/duckdb/pyarrow, 可选 scipy/sklearn)
└── namespace dict ← SandboxSession 让它在调用间保留
SandboxSession
├── __enter__() → 从池中获取 worker,独占使用
├── execute() → 发送 4-tuple (code, objs, path, persist=True)
SandboxSession(workspace_path)
├── __init__() → 给定 workspace_path 时立即从池中获取 worker;否则在首次 execute() 时获取
├── execute() → 发送 4-tuple (code, objs, path, True)
├── execute() → 同一 worker,namespace 保留
└── __exit__() → 发送 "__clear_ns__",归还 worker 到池
└── close() / __exit__()
→ 发送 "__clear_ns__",5s 内收到 ack 则归还 worker,否则丢弃 worker
```
在 OS confinement 可用时(macOS seatbelt / Linux bwrap,见 `sandbox/confinement.py`),
worker 按 `worker_key(workspace_path)` 分池:Data Formulator 自己的 workspace 共享一个 key。
不可用时退回到仅有 audit hook 的 worker。
## Worker 协议
| 消息 | 方向 | 说明 |
|---|---|---|
| `(code, allowed_objects, workspace_path)` | Host → Worker | 3-tuple: 传统模式,每次创建新 namespace |
| `(code, allowed_objects, workspace_path, True)` | Host → Worker | 4-tuple: 持久模式,namespace 在调用间保留 |
| `(code, allowed_objects, workspace_path)` | Host → Worker | 3-tuple:每次创建新 namespace(`LocalSandbox` 非 session 路径) |
| `(code, allowed_objects, workspace_path, True)` | Host → Worker | 4-tuple:持久模式,namespace 在调用间保留(`SandboxSession.execute`) |
| `"__clear_ns__"` | Host → Worker | 清空持久化 namespace,回复 `{"status": "ok"}` |
| `None` | Host → Worker | 终止 worker 进程 |
3-tuple 调用完全向后兼容,不受影响。
| `{"status": "ok", "allowed_objects": {...}, "df_names": [...]}` | Worker → Host | 成功;`df_names` 为 namespace 中的用户 DataFrame 名(诊断用) |
| `{"status": "error", "error_message": "..."}` | Worker → Host | 代码抛异常;worker 和 namespace 保留 |
| `{"status": "interrupted", ...}` | Worker → Host | 收到 SIGINT(用户中断);之后 worker 进程退出 |
## 使用方式
### 在 Agent 中使用(推荐)
### 在 Agent 中使用
```python
from data_formulator.sandbox.local_sandbox import SandboxSession
with SandboxSession() as session:
with SandboxSession(workspace_path) as session:
# 第 1 次调用:定义变量
r1 = session.execute(code1, {"_pack": None}, workspace_path)
@@ -53,47 +59,32 @@ with SandboxSession() as session:
# __exit__ 自动清空 namespace 并归还 worker
```
### 在非 agentic 场景中(传统方式,不受影响)
### 非 session 场景(每次独立 namespace)
```python
from data_formulator.sandbox import create_sandbox
sandbox = create_sandbox("local")
sandbox = create_sandbox(sandbox_mode) # "local" 或 "docker"
result = sandbox.run_python_code(code, workspace, "output_df")
```
## 受影响的 Agent
## 接入方
| Agent | 接入方式 | 说明 |
| 调用方 | 接入方式 | 说明 |
|---|---|---|
| `DataAgent` | `_get_next_action` 创建 session,`_run_explore_code` 使用 `self._explore_session` | `explore()` 工具调用共享 namespace |
| `DataLoadingAgent` | `stream_chat` 创建 session,`_tool_execute_python` 使用 `self._sandbox_session` | `execute_python` 工具调用共享 namespace |
| `DataTransformationAgent` | 不受影响 | 重试模式,每次执行独立完整代码 |
| `DataRecAgent` | 不受影响 | 同上 |
| `AnalystAgent` (`analyst/agent.py`) | `_get_next_action()` 用 `with SandboxSession(ws_path)` 包住 `_tool_loop()`,并设置 `self._explore_session`;`_run_explore_code()` 有 session 时调用 `session.execute()` | 一次 `_get_next_action()`(若干 inspection tool rounds,直到一个 committing action 或纯文本结束)内共享 namespace;下一次 action 会新建 session |
| 经 `_run_explore_code()` 的工具 | 循环中的 `execute_python_script`,以及 skill 通过 `ctx.runtime.run_explore_code()` 执行代码(`analysis` skill 的 `execute_python_script`;`workspace` skill 的 `create_data` / `update_data` / `create_file` / `edit_file` 带 `code` 时) | 共享同一 session namespace |
| `WorkflowAgent` (`workflows/agent.py`) | 继承 `AnalystAgent`,但用自己的 `run_workflow()` 循环,不进入 `_get_next_action()` | `_explore_session` 未设置,`execute_python_script` 回退到 `create_sandbox(mode)._run_in_warm_subprocess(...)`,每次新 namespace;支持 `execution_cancellation` 中断 |
| `_run_visualize_code()`、`materialize_memory_table()` | `create_sandbox(mode).run_python_code(...)` | 不使用 session |
## 跨 Turn 持久化 (Cross-turn Namespace Persistence)
注意:`_get_next_action()` 无条件使用 `SandboxSession`,即本地 warm worker pool,
与 `--sandbox` 配置无关;`create_sandbox(mode)` 只作用于非 session 路径。
发送给 worker 的代码会先经过 `sandbox/session_imports.py` 的 `with_session_imports()`,
使 agent 写入的 `files/lib/*.py` helper 和 `scratch/.packages` 中的包可被 import。
在单次 agent turn 内,`SandboxSession` 的 context manager 保证了变量在多次
`execute()` 调用间存活。但 DataAgent 是逐 HTTP 请求创建的短生命对象,当 tool
rounds 耗尽用户点击"继续探索"时,新的请求会创建全新的 `DataAgent` 和全新的
`SandboxSession`,之前 turn 中计算的 DataFrame 和中间结果全部丢失。
## 跨 Turn 持久化(save / restore)
### 解决方案:磁盘序列化 + 恢复
```
Turn N (tool_rounds_exhausted) Turn N+1 (用户选择"继续探索")
───────────────────────────── ──────────────────────────────
SandboxSession SandboxSession (new)
├── explore() × 12 ├── restore_namespace()
└── save_namespace() │ ← 从磁盘读回 DataFrame + 标量
↓ ├── explore() ...
scratch/_explore_ns/ └── close()
├── df1.parquet ↓
├── df2.parquet scratch/_explore_ns/ (已清理)
└── _manifest.json
```
### API
`SandboxSession` 提供把 namespace 序列化到磁盘并在新 session 中恢复的 API:
```python
# 保存(在 session 关闭前调用)
@@ -105,86 +96,69 @@ ok = SandboxSession.restore_namespace(new_session, save_dir, workspace_path)
**`save_namespace(save_dir, workspace_path)`**:
1. 向 worker 发送一段 collect 代码,遍历 `globals()` 收集用户变量
2. 变量通过 multiprocessing Pipe(pickle 序列化)传回 host 进程
3. **host 端写入文件**——worker 的审计钩子禁止一切文件写入(`"open"` 事件 mode != `"r"`),
所以必须由 host 接收 DataFrame 对象后写 parquet
4. 返回 `True` 表示成功保存,`False` 表示无用户变量可保存
1. 在 session 中执行一段 collect 代码,遍历 `globals()` 收集用户变量
2. 变量通过 Pipe(pickle)传回 host 进程
3. **host 端写文件**——worker 的审计钩子禁止以 `"r"` / `"rb"` 以外的模式 open,
所以由 host 收到 DataFrame 后写 parquet 和 `_manifest.json`
4. 返回 `True` 表示已保存,`False` 表示 collect 失败或无用户变量
变量过滤规则:
| 条件 | 行为 |
|---|---|
| 变量名以 `_` 开头 | 跳过(内部变量 / 临时变量) |
| `__builtins__` | 跳过 |
| 变量名以 `_` 开头(含 `__builtins__`) | 跳过 |
| `isinstance(v, pd.DataFrame)` | 保存为 `<name>.parquet` |
| `isinstance(v, (int, float, str, bool))` | 保存到 `_manifest.json` 的 `scalars` 字段 |
| 其他类型(list, dict, numpy array, 自定义对象等) | **不保存** |
**`restore_namespace(session, save_dir, workspace_path)`**:
1. 读取 `_manifest.json` 获取 DataFrame 文件名列表和标量值
2. 生成 Python 代码:`pd.read_parquet()` + 标量赋值语句
3. 在 session 中执行该代码,将变量注入 worker namespace
4. worker 的审计钩子允许从 workspace 目录读取文件,所以 `pd.read_parquet()` 可以正常工作
5. 返回 `True`/`False`
1. 读取 `_manifest.json`(不存在则返回 `False`)
2. 生成 `pd.read_parquet()` + 标量赋值代码并在 session 中执行
3. 成功返回 `True`;执行失败只打 warning 并返回 `False`
**失败降级**:save 或 restore 失败时仅打 warning 日志,不中断 agent 流程。
下一个 turn 将从零开始(没有恢复的变量),行为退回到无跨 turn 持久化时的状态。
### DataAgent 接入点
### AnalystAgent 中的现状
| 位置 | 行为 |
|---|---|
| `run()` — `trajectory is None` | 新对话:清理 `scratch/_explore_ns/` 防止旧状态泄漏 |
| `_get_next_action()` 进入 | 如果 `_explore_ns/` 存在 → 恢复 namespace → 清理目录 |
| `_get_next_action()` 退出 | 如果 `tool_rounds_exhausted` → 保存 namespace 到 `_explore_ns/` |
| `run()` — 新对话(无 trajectory 且 `SessionHistory.continuable()` 无可继续历史) | 删除 `scratch/_explore_ns/`,防止旧状态泄漏 |
| `_get_next_action()` 进入 | 如果 `_explore_ns/` 存在 → `restore_namespace()` → 删除目录 |
save/restore 决策通过 `self._tool_loop_exit_reason` 标记实现:`_tool_loop` 在
耗尽 tool rounds 时设置该标记,`_get_next_action` 在 `yield from _tool_loop()`
返回后检查它,在 session `__exit__` 关闭 namespace 之前完成保存。
目前产品代码中**没有调用 `save_namespace()`**(旧的 "tool rounds 耗尽时保存" 触发点已不存在;
`AnalystExecutionConfig.max_tool_rounds_per_action` 仅为兼容保留,不再限制执行)。因此
`_explore_ns/` 正常情况下不会被写入,Python 变量不会跨 action 或跨请求保留;会话续接
(`analyst/history.py` 的 `SessionHistory`)只保留消息,不保留 namespace。
save/restore API 本身由 `TestSandboxSessionSaveRestore` 覆盖。
### 用户隔离
保存目录位于每个 workspace 的受限 scratch 下(`workspace.confined_scratch.root / "_explore_ns"`),
天然按用户/workspace 隔离,并且不会出现在 agent 可见的 scratch 文件列表中。
保存目录 `scratch/_explore_ns/` 位于每个用户的 workspace 下
(`workspace.confined_scratch.root / "_explore_ns"`),天然实现用户级隔离。
## 安全与生命周期
### 限制
- **类型覆盖有限**:仅保存 DataFrame 和 `int/float/str/bool` 标量。list、dict、numpy array、
自定义类、函数等均不保存。实际使用中 agent explore 的中间结果绝大多数是 DataFrame 和简单标量,
覆盖率足够(生产环境验证:20-23 个 DataFrame + 2 个标量被成功保存恢复)
- **Pipe 传输开销**:save 时 DataFrame 经 multiprocessing Pipe(pickle)从 worker 传回 host,
然后 host 写 parquet。对于极大 DataFrame(>100MB)可能有秒级延迟,但远低于重新执行全部数据处理代码
- **parquet 磁盘开销**:临时文件写入 workspace `scratch/` 目录,restore 后立即清理。
如果 agent 在 save 后进程崩溃导致未清理,新对话开始时 `run()` 也会清理
- DataLoadingAgent 不需要跨 turn 持久化(无 resume 机制),仅使用 within-turn 持久化
## 安全保证
- **作用域**: namespace 持久化严格限定在单次 agent turn(一次 `with SandboxSession()` 块)内
- **跨 turn 保存**: 仅在 `tool_rounds_exhausted` 时触发,保存到用户 workspace 的受限目录
- **自动清理**: context manager 的 `__exit__` 保证 turn 结束时清空 namespace 并归还 worker;新对话时自动清理 `_explore_ns/`
- **进程隔离**: 不同 session 使用不同 worker 进程,天然内存隔离
- **超时处理**: 执行超时后 worker 被 kill,session 标记为 closed,namespace 随进程消亡
- **作用域**: namespace 持久化限定在一次 `with SandboxSession()` 块(一次 `_get_next_action()`)内
- **自动清理**: `__exit__` 发送 `"__clear_ns__"` 后归还 worker;ack 超时或通信失败则丢弃 worker
- **进程隔离**: 同一时刻一个 session 独占一个 worker 进程
- **超时处理**: `EXECUTION_TIMEOUT`(环境变量 `DF_SANDBOX_TIMEOUT`,默认 120s)到期后 worker 被丢弃,session 标记为 closed,之后的 `execute()` 返回 `"Session is closed"` 错误
- **错误恢复**: 单次执行报错(如 `NameError`)不会杀死 session,后续调用仍可使用已有变量
- **中断**: `SandboxSession.execute()` 不检查 `execution_cancellation`;用户中断只在 `LocalSandbox._run_in_warm_subprocess()` 路径生效
## 相关文件
| 文件 | 角色 |
|---|---|
| `py-src/data_formulator/sandbox/local_sandbox.py` | `SandboxSession` 类 + Worker 协议扩展 + `save_namespace` / `restore_namespace` |
| `py-src/data_formulator/sandbox/__init__.py` | 导出 `SandboxSession` |
| `py-src/data_formulator/agents/data_agent.py` | DataAgent 接入 session + 跨 turn save/restore |
| `py-src/data_formulator/agents/agent_data_loading_chat.py` | DataLoadingAgent 接入 session(within-turn only) |
| `py-src/data_formulator/sandbox/local_sandbox.py` | `_warm_worker_loop` 协议、`_WarmWorkerPool`、`SandboxSession`(含 `save_namespace` / `restore_namespace`)、`LocalSandbox` |
| `py-src/data_formulator/sandbox/confinement.py` | OS confinement 与 worker 分池 key |
| `py-src/data_formulator/sandbox/session_imports.py` | `with_session_imports()`:helper 模块与已安装包的导入 |
| `py-src/data_formulator/sandbox/__init__.py` | 导出 `SandboxSession`、`create_sandbox` |
| `py-src/data_formulator/analyst/agent.py` | `AnalystAgent._get_next_action` / `_run_explore_code` 接入 session,`_explore_ns` 恢复与清理 |
| `tests/backend/security/test_sandbox.py` | `TestSandboxSession` + `TestSandboxSessionSaveRestore` 测试 |
## 新模块 Checklist
当修改 sandbox 或 agent 的代码执行路径时:
- [ ] 确认 `SandboxSession` 的 `close()` 在所有退出路径(正常/异常/超时)都会被调用
- [ ] 确认不会跨用户请求复用 session(session 的生命周期 = 一次 agent turn)
- [ ] 新增的 agent 如果有 agentic loop + 多次 sandbox 调用,应该接入 `SandboxSession`
- [ ] 如果 agent 支持 resume trajectory,考虑是否需要跨 turn 的 save/restore
- [ ] 运行 `python -m pytest tests/backend/security/test_sandbox.py -q` 验证
- [ ] 确认 `SandboxSession` 的 `close()` 在所有退出路径(正常/异常/超时)都会被调用(优先用 `with`)
- [ ] 确认不会跨用户请求复用 session(生命周期 ≤ 一次 agent 请求)
- [ ] 有 agentic loop + 多次 sandbox 调用的新路径,考虑接入 `SandboxSession`,并经 `_run_explore_code()` 执行
- [ ] 如需跨 action 保留变量,需要显式调用 `save_namespace()`(当前没有调用方)
- [ ] 运行 `uv run pytest tests/backend/security/test_sandbox.py -q` 验证
+45 -47
View File
@@ -1,38 +1,31 @@
# 13 — 统一行数限制
> **Scope**: 数据加载行数限制的架构设计、两种存储模式下的行为差异、前后端限制协同。
> **最后更新**: 2026-05-05
> **Scope**: 数据加载行数限制的架构设计、各 Workspace 后端下的行为、前后端限制协同。
> **最后更新**: 2026-10-13
---
## 1. 设计目标
- **唯一用户入口**: Settings 对话框的 `frontendRowLimit` 是用户控制行数的唯一设置
- **手动导入的用户入口**: Settings 对话框的 `frontendRowLimit` 控制从连接器手动导入表时的行数上限
- **后端兜底**: `MAX_IMPORT_ROWS = 2_000_000` 硬上限,防止异常请求
- **两种模式统一**: Workspace 和 Ephemeral 模式使用相同的限制语义
- **存储模式统一**: 所有 Workspace 后端(`local` / `azure_blob` / `ephemeral`)都在服务端存储表数据,使用相同的限制语义
---
## 2. 两种存储模式
## 2. Workspace 存储模式
```mermaid
flowchart TD
subgraph WorkspaceMode ["Workspace 模式 (local / azure_blob)"]
W1[用户触发加载] --> W2["前端传 size = frontendRowLimit"]
W2 --> W3["后端 DataLoader: min(size, MAX_IMPORT_ROWS)"]
W3 --> W4[写入 Parquet 到 Workspace]
W4 --> W5["前端只持有 virtual 引用 (sample rows)"]
end
subgraph EphemeralMode ["Ephemeral 模式 (无服务端存储)"]
E1[用户触发加载] --> E2["前端传 size = frontendRowLimit"]
E2 --> E3["后端 DataLoader: min(size, MAX_IMPORT_ROWS)"]
E3 --> E4[返回全部行给前端]
E4 --> E5[前端存入 IndexedDB + Redux sample]
end
A[用户触发加载] --> B["后端 DataLoader: min(size, MAX_IMPORT_ROWS)"]
B --> C["写入 Parquet 到服务端 Workspace"]
C --> D["前端只持有 virtual 引用 (sample rows)"]
```
- **Workspace 模式** (`WORKSPACE_BACKEND = 'local'` 或 `'azure_blob'`): 数据持久化在服务端 parquet 文件中,前端只保留 sample。Agent 运算在后端完成。
- **Ephemeral 模式** (`WORKSPACE_BACKEND = 'ephemeral'`): 服务端无状态,数据全部存在浏览器 IndexedDB 中。用于演示/匿名部署。
- **`local`**(默认)/ **`azure_blob`**: 数据持久化在服务端 parquet 文件中,前端只保留 sample。Agent 运算在后端完成。
- **`ephemeral`**: 同样使用服务端磁盘上的 workspace 格式,但存放在独立根目录下,由 TTL / LRU 自动清理(`py-src/data_formulator/datalake/ephemeral_workspace.py`),用于演示/匿名部署。浏览器 IndexedDB(`src/app/workspaceDB.ts`)只保存会话结构和元数据的恢复快照,不保存表行。
三种模式下行数限制的行为一致。
---
@@ -40,9 +33,14 @@ flowchart TD
| 常量 | 值 | 位置 | 说明 |
|------|---|------|------|
| `MAX_IMPORT_ROWS` | 2,000,000 | `py-src/data_formulator/data_loader/external_data_loader.py` | 后端硬上限,所有 DataLoader 强制执行 |
| `DEFAULT_ROW_LIMIT` | 2,000,000 | `src/app/dfSlice.tsx` | 前端默认值(Workspace 模式) |
| `max_display_rows` | 10,000 | `py-src/data_formulator/app.py` CLI 参数 | Agent 执行结果返回前端的**展示**行数上限,不限制存储 |
| `MAX_IMPORT_ROWS` | 2,000,000 | `py-src/data_formulator/data_loader/external_data_loader.py` | 后端硬上限,DataLoader 的 `fetch_data_as_arrow()` 强制执行 |
| `DEFAULT_ROW_LIMIT` | 2,000,000 | `src/app/dfSlice.tsx` | `frontendRowLimit` 默认值,同时是 Settings 输入上限 |
| `MAX_AGGREGATE_ROWS` | 10,000 | `py-src/data_formulator/data_operations/executor.py` | Agent 加载中聚合 / native / semantic 查询的结果行数上限 |
| `external_table_max_rows` | 1,000,000 | `py-src/data_formulator/configuration.py`(`EXTERNAL_TABLE_MAX_ROWS`) | 超过该行数的连接器表以引用方式加入,不直接导入 |
| `external_table_max_bytes` | 512 MB | `py-src/data_formulator/configuration.py`(`EXTERNAL_TABLE_MAX_SIZE_MB`) | 同上,按文件 / 表大小判断 |
| `max_display_rows` | 10,000 | `py-src/data_formulator/configuration.py`(`MAX_DISPLAY_ROWS`) | Agent 执行结果返回前端的**展示**行数上限,不限制存储 |
`configuration.py` 中的 `LIMITS` 通过 `effective_limit()` 读取:CLI 参数 / 环境变量优先,其次是 Configuration 视图 Limits 标签保存的覆盖值。设置了对应环境变量时该项在 UI 中锁定。
---
@@ -51,47 +49,45 @@ flowchart TD
```mermaid
flowchart TD
UserAction[用户触发加载] --> Source{数据来源}
Source -->|"Connector Sidebar"| DirectLoad["直接使用 frontendRowLimit"]
Source -->|"Agent 对话加载"| AgentLoad["Agent 使用 frontendRowLimit"]
Source -->|"Connector 侧边栏 / 预览"| DirectLoad["loadTable: min(请求 size, frontendRowLimit)"]
Source -->|"Agent 对话加载"| AgentLoad["DataOperationExecutor: size = 计划中的 query.limit"]
Source -->|"文件上传"| FileUpload["无行限制 (文件本身大小)"]
DirectLoad --> BackendLoader["后端 DataLoader"]
AgentLoad --> BackendLoader
FileUpload --> BackendLoader
BackendLoader --> HardCap["后端硬上限: min(size, MAX_IMPORT_ROWS=200万)"]
HardCap --> Store{存储模式}
Store -->|Workspace| Parquet["写 Parquet"]
Store -->|Ephemeral| ReturnToFE["返回前端"]
HardCap --> Parquet["写 Parquet 到 Workspace"]
FileUpload --> Parquet
```
### 4.1 Connector Sidebar / DBTableManager 加载
### 4.1 Connector 侧边栏 / DBTableManager 加载
1. 前端 `tableThunks.ts` 中 `loadTable` thunk 统一注入 `import_options.size = frontendRowLimit`
2. 后端 DataLoader 的 `fetch_data_as_arrow()` 执行 `size = min(opts.get("size", MAX_IMPORT_ROWS), MAX_IMPORT_ROWS)`
3. 数据写入 Workspace 或返回前端(取决于模式)
1. 前端 `src/app/tableThunks.ts` 的 `loadTable` thunk 对数据库来源调用 `resolveDatabaseImportLimit(importOptions.size, frontendRowLimit)`:请求了 size 时取 `min(size, frontendRowLimit)`,否则取 `frontendRowLimit`,写入 `import_options.size` 后 POST `/api/connectors/import-data`
2. 后端 `ingest_to_workspace()` 调用 DataLoader 的 `fetch_data_as_arrow()`,执行 `size = min(opts.get("size", MAX_IMPORT_ROWS), MAX_IMPORT_ROWS)`
3. 数据写入 Workspace parquet。若生效的是 `frontendRowLimit` 上限且返回行数达到该上限,`loadTable` 返回 `truncated: true`,由调用方(如 `DataSourceSidebar`)提示用户
**大表 / 语义模型**: `src/app/workspaceService.ts` 的 `loadsAsConnectorReference()` 判断表的 `row_count` 超过 `EXTERNAL_TABLE_MAX_ROWS`、大小超过 `EXTERNAL_TABLE_MAX_BYTES`,或是 semantic 模型时,以外部表引用加入,由 Agent 在源端查询。对引用执行"导入完整副本"(`importExternalTableReference`,`full_copy: true`)时,后端先统计行数,超过 `MAX_IMPORT_ROWS` 直接拒绝。
### 4.2 Agent 对话加载
1. 前端 `DataLoadingChat.tsx` 在请求体中传入 `row_limit: frontendRowLimit`
2. 后端 `DataLoadingAgent.__init__` 接收 `row_limit` 参数
3. Agent 规划 `propose_load_plan` 时,默认使用 `self.row_limit`
4. 前端确认 load plan 后,通过 `loadTable` thunk 执行(同样注入 `frontendRowLimit`)
1. `AnalystAgent` 的数据加载 skill(`py-src/data_formulator/analyst/skills/workspace/data_loading.py`)提出 DataOperation 计划,用户选择后由 `DataOperationExecutor`(`py-src/data_formulator/data_operations/executor.py`)执行
2. `_build_import_options()` 仅在计划步骤有 `query.limit` 时设置 `size`;否则使用 Loader 默认值(通常为 `MAX_IMPORT_ROWS`)。此路径不使用 `frontendRowLimit`
3. 含 `group_by` / `aggregates` / `native` 查询或 semantic 来源时走 `execute_aggregate_query()`,结果最多 `MAX_AGGREGATE_ROWS`(10,000)行;未显式指定 limit 且超出时报错
4. 无具体查询的整表加载,若源表超过 `external_table_max_rows` / `external_table_max_bytes` 或为 semantic 来源,只作为外部表引用加入,不导入
### 4.3 文件上传
文件上传不受 `frontendRowLimit` 限制。文件全量存入 Workspace parquet。在 Ephemeral 模式下,`frontendRowLimit` 对前端本地持有的行数进行截断。
文件上传不受 `frontendRowLimit` 限制。文件全量存入 Workspace parquet。
---
## 5. 前端 Settings 对话框
- 路径: `src/app/App.tsx` → Settings Dialog
- 输入范围: `[100, 2_000_000]`
- 默认值:
- Workspace 模式: 2,000,000
- Ephemeral 模式: 自动降为 20,000(在 `setServerConfig` reducer 中处理)
- 用户修改后立即生效,后续所有数据加载使用新值
- 输入范围: `[100, DEFAULT_ROW_LIMIT]`,即 `[100, 2_000_000]`
- 默认值: 2,000,000(所有 Workspace 模式相同)
- 保存时通过 `dfActions.setConfig` 写入 `config.frontendRowLimit`,后续连接器导入使用新值
---
@@ -108,6 +104,8 @@ size = min(opts.get("size", MAX_IMPORT_ROWS), MAX_IMPORT_ROWS)
即使前端传入超过 200 万的 size,后端也会截断。这是安全兜底,防止异常请求导致 OOM。
个别 Loader 的写法不同但同样以 `MAX_IMPORT_ROWS` 封顶:`local_folder` 未传 size 时默认取 1,000,000 行;`clickhouse` 通过 `_validated_size()` 校验后取 `min(size, MAX_IMPORT_ROWS)`。
---
## 7. 新 Loader 开发注意
@@ -136,8 +134,8 @@ class MyDataLoader(ExternalDataLoader):
| | `frontendRowLimit` / `MAX_IMPORT_ROWS` | `max_display_rows` |
|---|---|---|
| 控制什么 | 从数据源拉取并存储的最大行数 | Agent 执行结果返回前端**展示**的行数 |
| 何时生效 | 数据导入时 | Agent 代码执行后返回结果时 |
| 控制什么 | 从数据源拉取并存储的最大行数 | Agent 执行结果 / 派生数据刷新返回前端**展示**的行数 |
| 何时生效 | 数据导入时 | Agent 代码执行后、`/api/agent/refresh-derived-data` 返回结果时 |
| 数据是否保留 | 是(全量存 Workspace) | 是(全量在 Workspace,只是前端展示截断) |
| 默认值 | 2,000,000 | 10,000 |
| 用户可配 | Settings 对话框 | CLI `--max-display-rows` 参数 |
| 用户可配 | Settings 对话框 | CLI `--max-display-rows` / 环境变量 `MAX_DISPLAY_ROWS` / Configuration 视图 Limits 标签 |
@@ -2,15 +2,16 @@
## 概述
所有 LLM 调用统一通过 LiteLLM,利用 `drop_params=True` 实现运行时自动降级。
前端和后端不再预判模型能力(vision、reasoning 等),而是让模型调用自然失败后自动重试。
所有 LLM 调用统一通过 `agents/client_utils.py` 的 `Client`,底层走 LiteLLM,并利用
`drop_params=True` 和失败后重试实现运行时降级。前端和后端都不按模型名预判模型能力
(vision、reasoning 等),而是让调用自然失败后自动降级重试。
## 核心设计原则
1. **唯一调用路径** — 所有 Agent 通过 `Client.get_completion()` 或 `Client.get_completion_with_tools()` 调用 LLM,两者内部均走 `litellm.completion()`。
1. **唯一调用路径** — 所有 Agent 通过 `Client.get_completion()` 或 `Client.get_completion_with_tools()` 调用 LLM,两者内部均走 `Client._dispatch()` → `litellm.completion()`。
2. **`drop_params=True`** — LiteLLM 自动丢弃模型不支持的参数(如 `reasoning_effort`、`parallel_tool_calls`),不会报错。
3. **图片降级** — 如果模型不支持图片,`_is_image_deserialize_error()` 捕获异常后自动剥离图片重试;对于信息不明确的上游请求失败,仅在原请求确实包含图片时降级。
4. **无前端预检查** — 前端始终允许用户上传图片;后端自动处理。
3. **图片降级** — 如果模型不支持图片,`_is_image_deserialize_error()` 识别错误后用 `_strip_images_from_messages()` 剥离 `image_url` 块重试;对于信息不明确的错误(`upstream request failed`、`image ... not support/unsupported`),仅在原请求确实包含图片时降级。
4. **无前端预检查** — 前端(`src/views/SimpleChartRecBox.tsx` 的图片附件,经 `attached_images` 发到 `/analyst-streaming`)不检查模型是否支持图片;后端自动处理。代码中没有按模型名判断 vision 能力的函数,也没有 `supports_vision` 字段。
## Client API
@@ -18,7 +19,7 @@
class Client:
def get_completion(self, messages, stream=False,
reasoning_effort="low", **kwargs):
"""通用 completion 调用。"""
"""通用 completion 调用。**kwargs 合并进请求参数(如 response_format)。"""
def get_completion_with_tools(self, messages, tools, stream=False,
reasoning_effort="low", **kwargs):
@@ -28,8 +29,24 @@ class Client:
两个方法共享相同的内部逻辑:
- 拷贝 `self.params`,注入 `reasoning_effort`
- 调用 `litellm.completion(model=..., drop_params=True, ...)`
- 捕获 image deserialize 错误 → 剥离图片 → 重试
- 调用 `_dispatch()` → `litellm.completion(model=..., drop_params=True, ...)`
- 捕获错误后按下表降级重试一次,否则抛出
## 运行时降级一览
| 触发 | 处理 | 位置 |
|---|---|---|
| 图片相关错误(见原则 3) | 剥离 `image_url` 块后重试 | `get_completion` / `get_completion_with_tools` |
| 错误信息含 `reasoning_effort` / `reasoning.effort` / `does not support thinking` | 去掉 `reasoning_effort` 重试;若是 `unsupported parameter`,记入 `_NO_REASONING`,该路由之后不再发送 | 同上,`_drop_reasoning_after()` |
| OpenAI/Azure 且 LiteLLM 认为模型不支持 reasoning | 发送前移除 `reasoning_effort` | `_dispatch()` / `_supports_reasoning()` |
| OpenAI/Azure 一方 host,LiteLLM 不认识该模型(如自定义 Azure deployment 名) | 设置 `allowed_openai_params=["reasoning_effort"]`,避免被静默丢弃 | `_dispatch()` |
| `response_format` 相关错误 | 去掉 `response_format` 重试;非 OpenAI/Azure endpoint 发送前直接去掉 | 仅 `get_completion` |
| OpenAI/Azure 一方 host 默认走 Responses API,但该部署不可用(旧 api-version 等) | 回退 Chat Completions,并记入 `_CHAT_ONLY` | `_dispatch()` |
| OpenAI/Azure endpoint 未走 Responses(如第三方 host)时,报错要求 tools 走 `/v1/responses` | 改走 Responses API 重试,并记入 `_RESPONSES_FOR_TOOLS` | `get_completion_with_tools` |
| Ollama | 始终以非流式调用,再用 `_synthesize_stream()` 回放为流;从文本内容中补救工具调用(`_salvage_tool_calls_from_content()`) | `_dispatch()` |
`_NO_REASONING` / `_CHAT_ONLY` / `_RESPONSES_FOR_TOOLS` 是进程内集合,按
`(endpoint, api_base, api_version, model)` 记录路由。
## reasoning_effort 分层
@@ -44,49 +61,51 @@ class Client:
|---|---|---|
| `none` | 仅 GPT-5 `codex` / `pro`(最轻档) | 其它模型回退到 `low` |
| `minimal` | 仅 OpenAI GPT-5 base / mini / nano / 5.x | GPT-5 codex/pro → `none`;其它 → `low` |
| `low` / `medium` / `high` | 所有支持 reasoning 的模型(LiteLLM 统一映射) | 不支持 reasoning 的模型由 `drop_params=True` 静默忽略 |
| `low` / `medium` / `high` | 所有支持 reasoning 的模型(LiteLLM 统一映射) | 不支持 reasoning 的模型由 `drop_params=True` 或上表的重试处理 |
> 选择原则:**挑能产出可接受质量的最低档**。重型代码生成 / 多步工具
> 调用使用 `low`;单轮抽取 / 分类 / 格式化使用 `minimal`。
### 当前配置(来源:`agent_config.py`)
| Agent ID | Tier | 备注 |
| Agent ID | Tier | 调用方 |
|---|---|---|
| `data_transform` | `low` | 生成 Python 转换脚本 |
| `data_rec` | `low` | 图表 / 转换推荐 |
| `data_agent` | `low` | 多步探索 agent |
| `report_gen` | `low` | 叙述 + inspect/embed 工具 |
| `interactive_explore` | `low` | 探索想法 agent |
| `data_loading_chat` | `low` | 会话式数据加载(带工具) |
| `data_load` | `minimal` | 一次性类型推断 |
| `experience_distill` | `minimal` | 总结分析上下文 |
| `chart_insight` | `minimal` | 图表标题 + 1–3 个 takeaway |
| `chart_restyle` | `minimal` | 对 Vega-Lite spec 做样式编辑 |
| `code_explanation` | `minimal` | 解释衍生字段 |
| `sort_data` | `minimal` | 小列表的自然顺序排序 |
| `simple` | `minimal` | workspace_name |
| `analyst` | `low` | `AnalystAgent._open_stream()`(`WorkflowAgent` 继承同一调用) |
| `data_load` | `minimal` | `DataLoadAgent`(`agent_data_load.py`) |
| `workflow_distill` | `minimal` | `WorkflowDistillAgent`(`agent_workflow_distill.py`) |
| `chart_restyle` | `minimal` | `ChartRestyleAgent`(`agent_chart_restyle.py`) |
| `code_explanation` | `minimal` | `CodeExplanationAgent`(`agent_code_explanation.py`) |
| `sort_data` | `minimal` | `SortDataAgent`(`agent_sort_data.py`) |
| `simple` | `minimal` | `SimpleAgents`(`agent_simple.py`,workspace_name) |
| `data_transform`、`data_rec`、`interactive_explore` | `low` | 字典中仍有,当前无调用方 |
| `data_clean`、`chart_insight` | `minimal` | 字典中仍有,当前无调用方 |
`DEFAULT_REASONING_EFFORT = "low"` —— 未在表中列出的 agent id 走默认值。
`DEFAULT_REASONING_EFFORT = "low"` —— 未在表中列出的 agent id 走默认值
(例如 `StarterQuestionsAgent` 的 `starter_questions`)。
### 运行时覆盖
通过环境变量 `DF_REASONING_EFFORT_<AGENT_ID>` 可在不改代码的情况下临时
调整某个 agent 的 tier:
1. **模型配置的 Thinking 级别**:模型配置中的 `reasoning_effort`(只允许
`MODEL_REASONING_LEVELS` = `low` / `medium` / `high`)由 `routes/agents.py`
校验后写入 `client.reasoning_effort`。`AnalystAgent` 把它作为
`reasoning_effort_for(..., preference)` 传入,优先于下面两种来源;其它 helper agent 不传。
2. **环境变量** `DF_REASONING_EFFORT_<AGENT_ID>`,不改代码即可临时调整某个 agent 的 tier:
```bash
DF_REASONING_EFFORT_DATA_TRANSFORM=medium
DF_REASONING_EFFORT_REPORT_GEN=high
DF_REASONING_EFFORT_ANALYST=medium
DF_REASONING_EFFORT_DATA_LOAD=low
```
合法取值:`none` / `minimal` / `low` / `medium` / `high`。
3. 否则使用 `AGENT_REASONING_EFFORT`,再否则 `DEFAULT_REASONING_EFFORT`。
### Agent 调用模板
```python
from data_formulator.agent_config import reasoning_effort_for
_AGENT_ID = "data_transform"
_AGENT_ID = "sort_data"
response = self.client.get_completion(
messages=messages,
@@ -101,25 +120,12 @@ response = self.client.get_completion(
grep -nE '"\w+": +"' py-src/data_formulator/agent_config.py
# 所有调用点
grep -rn 'reasoning_effort_for' py-src/data_formulator/agents/
grep -rn 'reasoning_effort_for' py-src/data_formulator/
```
## 已删除的机制
| 删除项 | 原位置 | 理由 |
|---|---|---|
| `import openai` + 直连分支 | `client_utils.py`、5 个 Agent | 统一走 LiteLLM |
| `get_response()` | `client_utils.py` | 死代码 |
| `is_likely_text_only_model()` | `model_registry.py` | 硬编码模型名检查 |
| `model_supports_vision()` | `model_registry.py` | 前端/路由不再预检查 |
| `supports_vision` 字段 | `ModelConfig` (dfSlice)、`_reload`/`list_public` | 前端不消费 |
| `checkIsLikelyTextOnlyModel()` | `DataLoadingChat.tsx` | 前端不预检查 |
| `checkModelSupportsImageInput()` | `DataLoadingChat.tsx` | 前端不预检查 |
| vision 路由预检查 | `routes/agents.py` chart-insight、data-loading-chat | 改为运行时降级 |
## 新增 Agent 检查清单
- [ ] Agent 继承正确的 base class 并使用 `self.client`
- [ ] Agent 在构造函数中接收 `client` 并保存为 `self.client`(helper agent 没有公共基类)
- [ ] 在 `agent_config.py` 的 `AGENT_REASONING_EFFORT` 中为新 agent_id 设置默认 tier
- [ ] 调用 LLM 时使用 `reasoning_effort=reasoning_effort_for(_AGENT_ID, self.client.model)`
- [ ] 需要 tools → 使用 `self.client.get_completion_with_tools(messages, tools, ...)`
@@ -131,6 +137,7 @@ grep -rn 'reasoning_effort_for' py-src/data_formulator/agents/
| 厂商 | `reasoning_effort` 效果 |
|---|---|
| OpenAI (o1/o3/gpt-5) | 直接透传 |
| Anthropic (Claude) | 映射为 `thinking.budget_tokens` |
| 其他(Gemini、Ollama 等) | `drop_params=True` 静默忽略 |
| OpenAI / Azure (o 系列 / gpt-5) | 透传;LiteLLM 判定不支持时移除,未知模型在一方 host 上强制透传 |
| Anthropic (Claude) | 由 LiteLLM 映射为 extended thinking |
| Ollama | LiteLLM 映射为 `think`;模型报 `does not support thinking` 时去掉后重试 |
| 其他(Gemini 等) | 由 LiteLLM 统一映射;不支持时 `drop_params=True` 静默忽略 |
+61 -42
View File
@@ -1,7 +1,7 @@
# 数据加载器(ExternalDataLoader)开发规范
> **维护者**: DF 核心团队
> **最后更新**: 2026-04-28
> **最后更新**: 2026-10-13
> **适用范围**: `py-src/data_formulator/data_loader/` 下的所有 Loader 实现
---
@@ -22,7 +22,7 @@ Data Loader 模块负责从外部数据源加载数据到 Workspace(parquet
```
- **存储格式**:数据以 **parquet** 写入 Workspace。DuckDB 仅作为计算引擎,不参与存储。
- **内存格式**:统一使用 **PyArrow** 作为中间表示。各 Loader 可按源系统 SDK 读取后转换为 `pyarrow.Table`;PostgreSQL、MySQL、MSSQL 当前分别使用 `psycopg2`、`pymysql`、`pyodbc`。
- **内存格式**:统一使用 **PyArrow** 作为中间表示。各 Loader 可按源系统 SDK 读取后转换为 `pyarrow.Table`;PostgreSQL、MySQL、MSSQL 当前分别使用 `psycopg2`、`pymysql`、`mssql-python`。
- **扩展边界**:新增数据源通常只实现 `ExternalDataLoader`,由
`DataConnector.from_loader(...)` 自动包装;不要为每个数据源新增专有 Flask
Blueprint 或 per-instance route。
@@ -43,7 +43,6 @@ Data Loader 模块负责从外部数据源加载数据到 Workspace(parquet
|------|------|------|
| `__init__(self, params)` | 实例 | 保存并验证连接参数;可建立客户端或延迟到首次调用时连接 |
| `list_params()` | static | 连接参数定义;用于 `/api/data-loaders` 和前端创建连接表单 |
| `auth_instructions()` | static | 获取凭证或配置连接的简要说明文本 |
| `list_tables(table_filter=None)` | 实例 | 返回当前 pinned scope 下可导入表/文件/数据集列表 |
| `fetch_data_as_arrow(source_table, import_options=None)` | 实例 | 从数据源获取数据,返回 `pyarrow.Table` |
@@ -51,12 +50,15 @@ Data Loader 模块负责从外部数据源加载数据到 Workspace(parquet
| 方法 | 类型 | 说明 |
|------|------|------|
| `DISPLAY_NAME` / `DESCRIPTION` | 类属性 | UI 显示名(未设置时由注册 key 标题化)和给 Agent 的一句话用途说明(未设置时回退到 `DISPLAY_NAME`) |
| `AUTH_GUIDE` / `auth_instructions()` | 类属性 / classmethod | 连接说明 Markdown。内置 loader 用 `AUTH_GUIDE` 指向 `data_loader/guides/<name>.md`;插件可直接覆盖 `auth_instructions()` |
| `catalog_hierarchy()` | static | 声明完整 catalog 层级,如 `database -> schema -> table` |
| `ls(path=None, filter=None, limit=None, offset=0)` | 实例 | 按层级懒加载 catalog 节点;大目录应支持源端分页 |
| `ls(path=None, filter=None)` | 实例 | 按层级懒加载 catalog 节点;默认只在根层级回退到 `list_tables()`。大目录可在签名中额外加 `limit`/`offset` 做源端分页 |
| `sync_catalog_metadata(table_filter=None)` | 实例 | 全量 metadata sync;默认调用 `list_tables()` + `ensure_table_keys()`。若 `list_tables()` 已包含列信息(如 Superset),则无需 override |
| `search_catalog(query, limit=100)` | 实例 | 跨层级搜索;默认回退到 `list_tables(table_filter=query)` |
| `get_metadata(path)` | 实例 | 返回指定 catalog 节点的表/列元数据和样例;源系统描述字段可选 |
| `get_column_types(source_table)` | 实例 | 返回源系统列类型,供预览和筛选控件使用 |
| `get_column_types(source_table)` | 实例 | 返回源系统列类型和描述;导入/刷新在 catalog cache 缺少该表 metadata 时用它补充 |
| `preview_data(source_table, import_options=None)` | 实例 | 返回有界预览;默认基于 `fetch_data_as_arrow()`,文件类 loader 可覆盖以先投影再扫描 |
| `get_column_values(source_table, column_name, keyword, limit, offset)` | 实例 | 返回列值枚举,供智能筛选自动补全使用 |
| `test_connection()` | 实例 | 用轻量请求验证连接是否可用;默认会调用 `list_tables("__ping__")` |
@@ -65,6 +67,8 @@ Data Loader 模块负责从外部数据源加载数据到 Workspace(parquet
| 方法 | 类型 | 说明 |
|------|------|------|
| `auth_config()` | static | 声明 credentials、sso_exchange、delegated、oauth2 等认证模式 |
| `auth_paths()` | classmethod | 声明互斥的认证方式(表单分组);默认把 `tier="auth"` 字段合成一个 credentials path |
| `discover_param_options(param_name, params)` | classmethod | 用户显式触发时为某个参数列出可选值(`/api/data-loaders/discover-options`) |
| `auth_mode()` | static | 旧接口;新 loader 优先实现 `auth_config()` |
| `delegated_login_config()` | static | 声明 popup/delegated login 配置,如 Superset SSO bridge |
@@ -73,11 +77,11 @@ Data Loader 模块负责从外部数据源加载数据到 Workspace(parquet
- `fetch_data_as_arrow` 只接受 `source_table`(表/集合/文件/数据集标识符),**不接受原始 SQL 查询**,以确保安全性和方言一致性。
- `list_tables` 返回 catalog metadata(含列信息为佳)。部分 Loader(如 Superset)在 `list_tables()` 中并行拉取列信息,使首次连接即写入完整 cache。不要为了列表页或 Agent 缓存读取样本行或逐表计数。
- 新增或修改层级型 Loader 时,优先实现 `ls()`。`path=[]` 返回第一层,展开节点时返回下一层;`filter` 只作用于当前层级。
- 对可能返回大量节点的层级(例如 database/schema 下有几千张表),`ls()` 应支持 `limit`/`offset`,由 `/api/connectors/get-catalog` 透传。
- 对可能返回大量节点的层级(例如 database/schema 下有几千张表),`ls()` 应支持 `limit`/`offset`;`/api/connectors/get-catalog` 仅在 `ls()` 签名包含 `limit` 时透传,否则在内存中切片。
- 大目录应覆盖 `search_catalog()`,只返回轻量搜索结果;前端只在 Enter/search button 时调用 `/api/connectors/search-catalog`。
- table 节点如果显示名不足以唯一定位源对象,应在 `metadata["_source_name"]` 中放入稳定源标识符。例如 PostgreSQL 使用 `database.schema.table`,用于 preview/import/refresh。
- 基类提供 `ingest_to_workspace(workspace, ...)`,自动调用 `fetch_data_as_arrow()` 并写入 Workspace。Loader 无需实现 ingest 逻辑。
- 表/列描述是接口层可承载的可选元数据。导入和 refresh 链路会 best-effort 合并 `get_column_types()` 返回的表/列描述到 Workspace。
- 表/列描述是接口层可承载的可选元数据。导入和 refresh 链路会 best-effort 把源描述合并到 Workspace:优先使用 catalog cache 中的 metadata,缺失时调用 `get_column_types()`。
能力映射:
@@ -89,7 +93,7 @@ Data Loader 模块负责从外部数据源加载数据到 Workspace(parquet
| 跨层级搜索表/数据集 | `search_catalog(query)` |
| 表/列元数据和样例;描述为可选扩展 | `get_metadata(path)`、`get_column_types(source_table)`、`CatalogNode.metadata` |
| 筛选控件的列值枚举 | `get_column_values(...)` |
| 预览、导入、刷新源数据 | `fetch_data_as_arrow()` + 基类 `ingest_to_workspace()` |
| 预览、导入、刷新源数据 | `fetch_data_as_arrow()` + 基类 `preview_data()` / `ingest_to_workspace()` |
最小示例:
@@ -173,7 +177,7 @@ class MyReportDataLoader(ExternalDataLoader):
}
```
`get_metadata(path)` 用于单个 catalog 节点的详细元数据,`get_column_types(source_table)` 用于 preview/import/refresh 阶段的源类型和描述增强。二者应尽量保持字段一致:
`get_metadata(path)` 用于单个 catalog 节点的详细元数据,`get_column_types(source_table)` 用于 import/refresh 阶段的源类型和描述增强(基类默认实现即通过 `get_metadata()` 获取)。二者应尽量保持字段一致:
```python
{
@@ -192,14 +196,14 @@ class MyReportDataLoader(ExternalDataLoader):
内置合并语义由 `ExternalDataLoader.ingest_to_workspace()` 和 refresh 路由统一处理:
- 导入和刷新会 best-effort 调用 `get_column_types(source_table)`。
- 导入和刷新优先使用 catalog cache 中该表的 metadata;缺失时 best-effort 调用 `get_column_types(source_table)`。
- `description` 合并到 `TableMetadata.description`。
- `columns[].description` 按列名合并到 `ColumnInfo.description`。
- metadata 增强失败时保留数据导入/刷新结果。
### 2.1.2 Catalog 浏览规范
`DataConnector` 的 `/api/connectors/get-catalog` 是前端推荐使用的懒加载 catalog API。请求体:
`DataConnector` 的 `/api/connectors/get-catalog` 是按层级懒加载 catalog 的 API(前端数据源面板目前通过 `get-catalog-tree` 加载整棵树,见 `docs/dev-guides/5-data-connector-api.md` §10)。请求体:
```json
{
@@ -248,22 +252,24 @@ Loader type 注册后,`DataConnector` 会自动把它包装成 connector insta
1. **内置 loader**:修改源码,在 `data_loader/__init__.py` 的 `_LOADER_SPECS`
列表中添加 `(registry_key, module_path, class_name, pip_package)` 条目。
2. **外部 loader 插件**:不修改仓库源码,把 `*_data_loader.py` 放到
`DF_PLUGIN_DIR` 指向的目录。默认目录是 `~/.data_formulator/plugins/`。
2. **外部 loader 插件**:不修改仓库源码,把 `*_data_loader.py` 放到插件目录。目录按
`DF_PLUGIN_DIR` > `DATA_FORMULATOR_HOME/plugins` > `~/.data_formulator/plugins/` 解析。
内置 loader 当前**不是**包内自动发现;新增内置 loader 必须显式更新 `_LOADER_SPECS`。
零源码修改只适用于外部 `DF_PLUGIN_DIR` 插件。
零源码修改只适用于外部插件。
外部 loader 文件会在服务启动时被扫描。系统会导入每个匹配 `*_data_loader.py` 的文件,
找到其中公开的 `ExternalDataLoader` 子类,并注册到 `DATA_LOADERS`。注册 key 来自文件名:
外部 loader 文件会在服务启动时被扫描(仅当 `WORKSPACE_BACKEND=local`(默认)或设置
`DF_ALLOW_PLUGINS=1` 时)。系统会导入每个匹配 `*_data_loader.py` 的文件,找到其中定义的
`ExternalDataLoader` 子类(有多个时取第一个),并注册到 `DATA_LOADERS`。注册 key 来自文件名:
```text
my_report_data_loader.py -> my_report
```
如果外部 loader 的 key 与内置 loader 相同,外部 loader 会覆盖内置实现。这个机制适合
管理员或部署方在不改源码的情况下接入新的报表系统、数据库或文件系统;它不是普通浏览器
终端用户上传任意代码的自助插件机制。
插件 key 不能与内置 loader(即使该内置 loader 因缺依赖未加载)或更早加载的插件相同;
冲突的插件会被拒绝并记录到 `PLUGIN_ERRORS`,在 `/api/data-loaders` 的 `plugins.errors`
中返回。导入失败的插件记录在 `DISABLED_LOADERS`。这个机制适合管理员或部署方在不改源码的
情况下接入新的报表系统、数据库或文件系统;它不是普通浏览器终端用户上传任意代码的自助插件机制。
注册完成后:
@@ -278,14 +284,21 @@ my_report_data_loader.py -> my_report
|--------|--------|---------|
| `PostgreSQLDataLoader` | PostgreSQL | `psycopg2` |
| `MySQLDataLoader` | MySQL | `pymysql` |
| `MSSQLDataLoader` | SQL Server | `pyodbc` |
| `MSSQLDataLoader` | SQL Server | `mssql-python` |
| `ClickHouseDataLoader` | ClickHouse | `clickhouse-connect` |
| `DatabricksDataLoader` | Databricks | `databricks-sql-connector` |
| `BigQueryDataLoader` | Google BigQuery | `google-cloud-bigquery`,查询结果可直接 `to_arrow()` |
| `AthenaDataLoader` | AWS Athena | SQL on S3 |
| `KustoDataLoader` | Azure Data Explorer | `azure-kusto-data`,KQL 结果转 Arrow |
| `S3DataLoader` | Amazon S3 文件 | PyArrow S3 filesystem |
| `AzureBlobDataLoader` | Azure Blob Storage | PyArrow |
| `MongoDBDataLoader` | MongoDB | |
| `LocalFolderDataLoader` | 本机目录 | 仅桌面模式可用,见 §3 |
| `S3DataLoader` | Amazon S3 文件 | `boto3` |
| `AzureBlobDataLoader` | Azure Blob Storage | `azure-storage-blob` |
| `MongoDBDataLoader` | MongoDB | `pymongo` |
| `CosmosDBDataLoader` | Azure Cosmos DB | `azure-cosmos` |
| `SupersetLoader` | Apache Superset | REST API;dashboard 以 `table_group` 返回 |
| `CubeDataLoader` | Cube 语义层 | REST API;semantic 模型 |
| `PowerBIDataLoader` | Power BI 语义模型 | `azure-identity` |
| `SampleDatasetsLoader` | 内置示例数据集 | 始终注册为 `sample_datasets` admin connector |
| `LocalFolderDataLoader` | 本机目录 | 仅 `WORKSPACE_BACKEND=local` 时可用,见 §3 |
### 2.4 新 Loader 检查清单
@@ -295,7 +308,7 @@ my_report_data_loader.py -> my_report
- [ ] 内置 loader 文件放在 `py-src/data_formulator/data_loader/`,命名为 `<type>_data_loader.py`。
- [ ] 内置 loader 已加入 `data_loader/__init__.py` 的 `_LOADER_SPECS`,并填写缺失依赖的 pip 包名。
- [ ] 外部 loader 放在 `DF_PLUGIN_DIR` 指向的目录,文件名匹配 `*_data_loader.py`,并定义公开的 `ExternalDataLoader` 子类。
- [ ] 外部 loader 放在插件目录,文件名匹配 `*_data_loader.py`,定义 `ExternalDataLoader` 子类,且 key 不与内置 loader 冲突。
- [ ] 若 loader 访问用户可控的宿主文件路径,已在 `_enforce_deployment_restrictions()` 中添加多用户部署禁用规则。
**认证与参数**
@@ -303,7 +316,7 @@ my_report_data_loader.py -> my_report
- [ ] 实现 `auth_config()`;旧 loader 可保留 `auth_mode()`,但新代码以 `auth_config()` 为准。
- [ ] `auth_config()` 不包含明文密钥;client id、client secret、token URL 等可部署差异通过环境变量或非敏感 params 注入。
- [ ] `list_params()` 中密码、token、access key、connection string 等字段标记 `sensitive=True` 或 `type="password"`;认证专用字段标记 `tier="auth"`。
- [ ] 连接成功后需要复用的敏感凭据只进入 vault,不写入 `connectors.yaml`,也不作为 `pinned_params` 返回前端。
- [ ] 连接成功后需要复用的敏感凭据只进入 vault,不写入 connector 配置文件,也不作为 `pinned_params` 返回前端。
- [ ] `delegated` 或 `sso_exchange` loader 已在 `__init__` 中真正消费 `access_token` / `sso_access_token`,不能只声明 `auth_config()`。
**数据与 catalog**
@@ -325,7 +338,7 @@ my_report_data_loader.py -> my_report
- [ ] 缺少可选依赖时只禁用该 loader,不让整个应用启动失败。
- [ ] `test_connection()` 做轻量真实验证,用于连接创建和 vault 自动重连校验。
- [ ] 覆盖连接成功/失败、凭据持久化、vault 自动重连、source filters、metadata best-effort 失败不阻断等关键路径。
- [ ] 数据库类 loader 的集成测试放在 `tests/database-dockers/`,普通行为测试放在 `tests/backend/`。
- [ ] 数据库类 loader 的集成测试放在 `tests/database-dockers/`,普通行为测试放在 `tests/backend/data_loader/` 或 `tests/backend/data/`。
---
@@ -337,7 +350,7 @@ my_report_data_loader.py -> my_report
- 在 `data_loader/__init__.py` 的 `_enforce_deployment_restrictions()` 中注册禁用规则
- 当 `WORKSPACE_BACKEND != "local"` 时,该 Loader 自动从 `DATA_LOADERS` 移入 `DISABLED_LOADERS`
- `create_connector()` 会拒绝已禁用的类型
- `create_connector()` 找不到已禁用的类型,会以 `Unknown loader type` 拒绝;`/api/data-loaders` 在非本地模式下也不会返回 `local_folder`
**判断标准**:如果 Loader 的构造函数接受一个用户可控的本机路径(如 `root_dir`),它就需要部署守卫。
@@ -351,7 +364,7 @@ my_report_data_loader.py -> my_report
### 4.1 `get_column_types(source_table)` — 列类型与描述查询
当 Loader 能提供比 pandas dtype 更精确的列类型信息,或能读取源系统表/列描述时,应实现此方法。`DataConnector` 的 `preview-data` 路由会在返回预览数据时调用它,将 `source_type` 和 `description` 注入到列元数据中;导入和 refresh 流程也会用它更新 Workspace 中的只读源描述。
当 Loader 能提供比 pandas dtype 更精确的列类型信息,或能读取源系统表/列描述时,应实现此方法。导入和 refresh 流程在 catalog cache 中没有该表 metadata 时会调用它,更新 Workspace 中的只读源描述。`preview-data` 路由不调用它:预览只返回内容,前端把 catalog 中已有的列 metadata 合并到预览列上。
**返回格式**:
```python
@@ -400,7 +413,7 @@ my_report_data_loader.py -> my_report
| 路由 | 方法 | 说明 |
|------|------|------|
| `/api/connectors/preview-data` | POST | 返回预览数据,自动附带 `source_type`、表描述和列描述 |
| `/api/connectors/preview-data` | POST | 返回预览数据(调用 `loader.preview_data()`),不实时查询源 metadata |
| `/api/connectors/column-values` | POST | 返回列的可选值(智能筛选自动补全) |
| `/api/connectors/import-group` | POST | 批量导入,支持 `source_filters` 参数 |
| `/api/connectors/disconnect` | POST | 断开当前 identity 下的 connector,清除该连接的 in-memory loader、vault 凭据和 session service token,但保留 connector 定义 |
@@ -419,7 +432,7 @@ my_report_data_loader.py -> my_report
**合法运算符**: `EQ`, `NEQ`, `GT`, `GTE`, `LT`, `LTE`, `IN`, `NOT_IN`, `LIKE`, `ILIKE`, `IS_NULL`, `IS_NOT_NULL`, `BETWEEN`。
Loader 应在 SQL 构建时使用参数化或白名单校验运算符(参考 `SupersetLoader._build_source_filter_clauses`)。
Loader 应在 SQL 构建时使用参数化或白名单校验运算符。SQL 类 loader 可复用 `external_data_loader.build_source_filter_where_clause_inline()`(MySQL、PostgreSQL 已使用;按 `_SOURCE_FILTER_OPERATOR_MAP` 映射运算符并转义标识符);Superset 通过 `SupersetLoader._build_chart_data_filters()` 转为 Chart Data API filters。
---
@@ -433,11 +446,13 @@ Loader 应在 SQL 构建时使用参数化或白名单校验运算符(参考 `
连接参数分为两类:
- **Connector metadata**:可持久化到 `users/<identity>/connectors.yaml`,例如 host、
port、database、bucket、root_dir 等非敏感、非 auth-tier 参数。
- **Credentials**:用户名、密码、token、access key、connection string、auth-tier 参数等。
这些只能用于本次连接并通过 vault 按 `identity + source_id` 保存,不能写入
`connectors.yaml`,也不能作为 `pinned_params` 返回前端。
- **Connector metadata**:持久化到 `users/<identity>/connectors/<source_id>.json` 的
`default_params`,例如 host、port、database、bucket、root_dir,以及非敏感的 auth-tier
标识(如 `user`)。
- **Credentials**:`list_params()` 中标记 `sensitive=True` / `type="password"` 的字段,以及
`SENSITIVE_PARAMS` 中的名称(password、token、access_key 等)。这些只能用于本次连接并通过
vault 按 `identity + source_id` 保存,不能写入 connector 配置文件,也不能作为
`pinned_params` 返回前端。
匿名用户登录后不会自动继承匿名 identity 下的 connector 或 vault 凭据。若产品需要
迁移匿名 connector,必须像 workspace migration 一样提供显式确认流程。
@@ -455,11 +470,15 @@ Loader 应在 SQL 构建时使用参数化或白名单校验运算符(参考 `
- `list_tables()` 返回值必须包含统一的轻量 metadata 字段(至少稳定源标识、列名/类型;有低成本来源时包含表/列描述)
- 覆盖 `get_column_types()` 或 `get_metadata()` 时,测试表级 `description`、列级 `description`、空描述清空、缺 key 保留和 metadata 失败不阻断导入/预览
数据库类 Loader 的集成测试使用 Docker 容器,位于 `tests/database-dockers/`:
数据库类 Loader 的集成测试使用 Docker 容器,位于 `tests/database-dockers/`(不在默认
pytest 路径中,需显式运行,详见该目录的 `README.md`):
```bash
./tests/database-dockers/run_test_dbs.sh start # 启动所有测试数据库
./tests/database-dockers/run_test_dbs.sh test # 运行全部 Loader 测试
./tests/database-dockers/run_test_dbs.sh test mysql # 单独测试 MySQL
./tests/database-dockers/run_test_dbs.sh stop # 关闭
```powershell
.\tests\database-dockers\test-dbs.ps1 start core # 启动常用测试数据库
.\tests\database-dockers\test-dbs.ps1 test mysql # 单独测试 MySQL
.\tests\database-dockers\test-dbs.ps1 test all # 运行全部 Loader 测试
.\tests\database-dockers\test-dbs.ps1 stop core # 关闭
```
macOS / Linux 可在各服务子目录中直接使用 `docker compose`,例如
`cd tests/database-dockers/mysql && docker compose up -d --build --wait`。
+82 -49
View File
@@ -1,7 +1,7 @@
# DataConnector API 与连接实例规范
> **维护者**: DF 核心团队
> **最后更新**: 2026-04-28
> **最后更新**: 2026-10-13
> **适用范围**: `py-src/data_formulator/data_connector.py`、前端连接器调用、数据源实例配置
---
@@ -26,7 +26,7 @@
- 如果报表系统、数据库或文件系统已有 loader type,用户只需要在 UI/API 中创建
connector instance,填写 URL、token、账号等参数。
- 如果是全新的数据源类型,管理员可以实现一个 `ExternalDataLoader` 子类,并通过
内置注册或 `DF_PLUGIN_DIR` 外部 loader 机制加入 `DATA_LOADERS`。之后用户仍然通过
内置注册(`_LOADER_SPECS`)或插件目录中的外部 loader 加入 `DATA_LOADERS`。之后用户仍然通过
同一套 connector UI/API 创建和使用连接。
---
@@ -37,11 +37,11 @@
|------|------|
| Loader type | `DATA_LOADERS` 中的注册 key,如 `postgresql`、`mysql`、`superset` |
| Connector instance | 一个具体连接实例,如 `postgresql:analytics` |
| Admin connector | 管理员通过 `DATA_FORMULATOR_HOME/connectors.yaml` 或 `DF_SOURCES__*` 配置的全局连接 |
| User connector | 用户通过 `POST /api/connectors` 创建的个人连接 |
| Admin connector | 管理员通过 `DATA_FORMULATOR_HOME/connectors.yaml`、`DF_SOURCES__*` 或安装配置(`configuration.json`)提供的全局连接;内置 `sample_datasets` 也是 admin connector |
| User connector | 用户通过 `POST /api/connectors` 创建的个人连接,保存在 `users/<identity>/connectors/<id>.json` |
| Live loader | 当前 identity 下已连接的 `ExternalDataLoader` 实例,保存在内存中 |
| Credential vault | 按 `identity + connector_id` 加密保存密码、token 等敏感凭证 |
| External loader plugin | `DF_PLUGIN_DIR` 中的 `*_data_loader.py` 文件,用于不改源码注册新的 loader type |
| External loader plugin | 插件目录中的 `*_data_loader.py` 文件,用于不改源码注册新的 loader type(见 §7) |
一个用户可以创建多个同类型连接,例如 `mysql:prod` 和 `mysql:staging`。前端和 API
都以 connector instance 为单位工作,不再以 loader type 为单位存储连接状态。
@@ -50,19 +50,24 @@
## 3. 配置来源
运行时连接实例由三层配置合并而来:
运行时连接实例由以下来源合并而来:
```text
DATA_FORMULATOR_HOME/connectors.yaml # admin,全局只读
DF_SOURCES__* # admin,环境变量覆盖/补充
DATA_FORMULATOR_HOME/users/<identity>/connectors.yaml # user,可由用户 CRUD
DF_SOURCES__* / PLG_SUPERSET_URL # admin,环境变量(优先级最高)
DATA_FORMULATOR_HOME/connectors.yaml # admin,全局只读
configuration.json 中的 connections.connectors # admin,安装配置;参数/密钥存于 vault
DATA_FORMULATOR_HOME/users/<identity>/connectors/<id>.json # user,可由用户 CRUD
```
敏感凭证不写入 `connectors.yaml`。用户名、密码、token、access key、connection
此外,`register_data_connectors()` 总会注册内置的 `sample_datasets` admin connector。
管理员可通过 `--disable-data-connectors` / `DISABLE_DATA_CONNECTORS`(或安装配置中的
`disable_user_connectors`)禁止用户创建连接,此时 `POST /api/connectors` 返回 `ACCESS_DENIED`。
敏感凭证不写入 connector 配置文件。用户名、密码、token、access key、connection
string 等只在连接时使用,并按 `identity + connector_id` 写入 vault。
注意:这些配置只创建 connector instance,不定义新的 loader type。新的 loader type
来自内置 `DATA_LOADERS` 注册表,或来自 `DF_PLUGIN_DIR` 扫描到的外部
来自内置 `DATA_LOADERS` 注册表,或来自插件目录扫描到的外部
`ExternalDataLoader` 子类。
`GET /api/connectors` 返回合并后的可见连接列表,并用字段区分来源:
@@ -83,7 +88,7 @@ Admin connector 对用户可见但不可删除,`deletable` 为 `false`。
## 4. `connectors.yaml`
Admin connector 和 user connector 使用同一种 YAML 结构:
Admin connector 使用以下 YAML 结构:
```yaml
connectors:
@@ -106,7 +111,6 @@ connectors:
| `name` | 前端显示名称 |
| `icon` | 可选,前端图标 key |
| `params` | 非敏感默认参数或 pinned 参数 |
| `auto_connect` | admin 配置可用;用于启动时自动建立连接的场景 |
`params` 支持 `${ENV_VAR}` 引用:
@@ -119,9 +123,10 @@ connectors:
url: ${SUPERSET_URL}
```
不要在 user connector 的 `params` 中写入密码、token、access key、connection string
等敏感字段。创建用户连接时,后端会根据 loader 的参数定义过滤敏感字段,只把安全参数写入
`users/<identity>/connectors.yaml`。
User connector 不使用 YAML,而是每个连接一个 JSON 文件
`users/<identity>/connectors/<id>.json`,字段为 `source_id`、`loader_type`、
`display_name`、`default_params`、`icon`。创建用户连接时,后端会根据 loader 的参数定义
(`sensitive` / `type="password"` 等)过滤敏感字段,只把安全参数写入 `default_params`。
---
@@ -148,8 +153,8 @@ DF_SOURCES__pg_prod__params__database=analytics
环境变量优先级高于 `DATA_FORMULATOR_HOME/connectors.yaml`。如果两者使用相同
`connector_id`,环境变量定义会覆盖 YAML 中的同名 admin connector。
兼容快捷方式:设置 `PLG_SUPERSET_URL` 时,如果没有显式配置 `superset` connector,
系统会自动注册一个 Superset admin connector。
兼容快捷方式:设置 `PLG_SUPERSET_URL` 时,如果 `DF_SOURCES__*` 中没有 ID 为 `superset`
的 connector,系统会自动注册一个 ID 为 `superset` 的 admin connector(同样优先于 YAML 中的同名定义)。
---
@@ -160,10 +165,10 @@ User connector 在内存中使用 identity-scoped key 保存,避免不同用
```text
Alice public id: postgresql:local
Alice registry key: user:alice::postgresql:local
Alice registry key: user::user:alice::postgresql:local
Bob public id: postgresql:local
Bob registry key: user:bob::postgresql:local
Bob registry key: user::user:bob::postgresql:local
```
解析规则:
@@ -182,22 +187,34 @@ Vault 凭证同样按 `identity + connector_id` 隔离。两个用户连接同
| 方法 | 路径 | 用途 |
|------|------|------|
| GET | `/api/data-loaders` | 返回可创建的 loader type、参数表单、层级信息、认证说明 |
| GET | `/api/data-loaders` | 返回可创建的 loader type、参数表单、层级信息、认证说明,以及 `disabled`(缺依赖/被禁用的 loader)和 `plugins`(插件目录、已加载/被拒绝的插件) |
| POST | `/api/data-loaders/discover-options` | `{loader_type, param_name, params?, connector_id?}`,调用 `discover_param_options()` 获取某个参数的可选值 |
| GET | `/api/connectors` | 返回当前 identity 可见的连接实例及状态 |
| POST | `/api/connectors` | 创建用户连接实例,并在提供参数时自动连接 |
| DELETE | `/api/connectors/{id}` | 删除用户连接实例,清除内存 loader、用户配置和 vault 凭证 |
| PATCH | `/api/connectors/{id}` | `{display_name}`,重命名用户连接(admin connector 不可重命名) |
| DELETE | `/api/connectors/{id}` | 删除用户连接实例,清除内存 loader、vault 凭证、catalog cache 和用户配置 |
`/api/data-loaders` 是创建连接表单的 discovery 入口。`local_folder` 有独立入口,
不会出现在 Add Connection 列表中。
仅本地模式(`is_local_mode()`)可用的辅助路由,服务器模式下返回 `ACCESS_DENIED`:
| 方法 | 路径 | 用途 |
|------|------|------|
| POST | `/api/local/pick-directory` | 打开系统目录选择框,返回 `{path}`(取消时为 `null`;无可用对话框时报 `CONNECTOR_ERROR`) |
| POST | `/api/local/azure-status` | 返回本机 Azure CLI 是否安装及登录状态 |
| POST | `/api/local/azure-login` | 在本机执行 `az login` |
`/api/data-loaders` 是创建连接表单的 discovery 入口。`sample_datasets` 不会出现在列表中;
`local_folder` 仅在本地模式下返回。
Loader type discovery 来自 `DATA_LOADERS`:
- 内置 loader 在 `data_loader/__init__.py` 的 `_LOADER_SPECS` 中注册。
- 外部 loader 从 `DF_PLUGIN_DIR` 扫描,默认目录为 `~/.data_formulator/plugins/`。
- 外部 loader 从插件目录扫描:`DF_PLUGIN_DIR` > `DATA_FORMULATOR_HOME/plugins` >
`~/.data_formulator/plugins/`。仅在 `WORKSPACE_BACKEND=local`(默认)或设置
`DF_ALLOW_PLUGINS=1` 时扫描。
- 文件名必须匹配 `*_data_loader.py`,例如 `my_report_data_loader.py` 会注册为
`my_report`。
- 文件中需要定义公开的 `ExternalDataLoader` 子类;如果 key 与内置 loader 相同,
外部 loader 会覆盖内置实现。
- 文件中需要定义 `ExternalDataLoader` 子类。插件 key 不能与内置 loader 或更早加载的
插件相同;冲突的插件会被拒绝并记录到 `PLUGIN_ERRORS`,不会覆盖内置实现。
---
@@ -211,10 +228,15 @@ Loader type discovery 来自 `DATA_LOADERS`:
| POST | `/api/connectors/disconnect` | `{connector_id}` |
| POST | `/api/connectors/get-status` | `{connector_id}`,无自动重连副作用 |
| POST | `/api/connectors/get-catalog` | `{connector_id, path?, filter?, limit?, offset?}` |
| POST | `/api/connectors/get-catalog-tree` | `{connector_id, filter?}` |
| POST | `/api/connectors/get-catalog-tree` | `{connector_id, filter?, background?, poll?, retry?, refresh?}` |
| POST | `/api/connectors/get-catalog-progress` | `{connector_id}`,返回进行中的 catalog 列举进度消息 |
| POST | `/api/connectors/get-cached-catalog-tree` | `{connector_id}`,只读磁盘 catalog cache,返回 `cache_hit` |
| POST | `/api/connectors/sync-catalog-metadata` | `{connector_id, filter?}`,调用 `loader.sync_catalog_metadata()` 并写入 catalog cache |
| POST | `/api/connectors/search-catalog` | `{connector_id, query, limit?}` |
| POST | `/api/connectors/preview-data` | `{connector_id, source_table, import_options?}` |
| POST | `/api/connectors/import-data` | `{connector_id, source_table, table_name?, import_options?}` |
| POST | `/api/connectors/preview-data` | `{connector_id, source_table, import_options?, limit?}` |
| POST | `/api/connectors/import-data` | `{connector_id, source_table, table_name?, import_options?, full_copy?}` |
| POST | `/api/connectors/import-file` | `{connector_id, source_path}`,仅 `local_folder`:把文件复制为 workspace file |
| POST | `/api/connectors/preview-file` | `{connector_id, source_path}`,仅 `local_folder`:返回文件内容(下载响应) |
| POST | `/api/connectors/import-group` | `{connector_id, tables, row_limit?, source_filters?, group_name?}` |
| POST | `/api/connectors/refresh-data` | `{connector_id, table_name}` |
| POST | `/api/connectors/column-values` | `{connector_id, source_table, column_name, keyword?, limit?, offset?}` |
@@ -222,9 +244,8 @@ Loader type discovery 来自 `DATA_LOADERS`:
不要新增 `/api/connectors/{id}/...` 风格的 per-instance route。Flask blueprint
不能在首个请求后动态注册,固定 action route 能避免 ghost endpoint 和动态注册问题。
当前没有独立的 `/api/connectors/get-node-detail` 路由。需要获取某个 catalog 节点详细
metadata 时,前端继续调用 `/api/connectors/get-catalog` 并传入该节点的非空 `path`;
后端会 best-effort 调用 `loader.get_metadata(path)` 并在响应中附加 `metadata`。
没有单独的节点详情路由。获取某个 catalog 节点的详细 metadata 时,调用
`/api/connectors/get-catalog` 并传入该节点的非空 `path`(见 §10)。
---
@@ -267,7 +288,7 @@ metadata 时,前端继续调用 `/api/connectors/get-catalog` 并传入该节
## 10. Catalog 与数据加载
推荐前端使用 `/api/connectors/get-catalog` 做懒加载浏览。它只返回当前层级:
`/api/connectors/get-catalog` 用于按层级懒加载浏览,只返回当前层级:
```json
{
@@ -282,16 +303,27 @@ metadata 时,前端继续调用 `/api/connectors/get-catalog` 并传入该节
返回体包含 `nodes`、`has_more`、`next_offset`。大目录 loader 应在 `ls()` 中实现
源端分页;框架只为不支持分页的 loader 做内存切片兜底。
当请求包含非空 `path` 时,响应还可能包含当前节点的 `metadata`。该 metadata 用于按需
展示列、类型、描述、样例等详细信息;读取失败必须降级为无 metadata,不应阻断目录浏览。
前端主路径以 `get-catalog` 懒加载为准,`get-catalog-tree` 仅用于需要 eager tree 的兼容
场景或测试。
当请求包含非空 `path` 时,响应还可能包含当前节点的 `metadata`:优先从该 connector 的
catalog cache 读取;只有缓存中缺少 `columns` 时才 best-effort 调用
`loader.get_metadata(path)` 补齐。读取失败降级为无 metadata,不阻断目录浏览。
前端数据源面板通过 `get-catalog-tree` 加载完整目录树(`src/app/utils.tsx` 的
`fetchConnectorCatalog()`):传 `background: true` 时后端启动后台 catalog discovery,
返回 `discovery.status`,前端用 `poll: true` 轮询直到 `complete`,再拿到 `tree`。
不传 `background` 时同步构建 tree:有 catalog cache 时用缓存,否则调用
`loader.list_tables()`,期间可通过 `get-catalog-progress` 查询进度。`local_folder`
每次都会重新扫描磁盘。
导入和刷新统一通过 workspace parquet 元数据记录源信息:
- `import-data` 调用 `loader.ingest_to_workspace(...)`
- `refresh-data` 使用表元数据中的 `source_table` 和 `import_options` 重新拉取
- `preview-data` 调用 `fetch_data_as_arrow(...)`,并尽量用 `get_column_types()` 补充源类型
- `import-data` 调用 `loader.ingest_to_workspace(...)`,并传入 catalog cache 中的源
metadata(缺失时 loader 回退到 `get_column_types()`)。`full_copy: true` 时改为通过
`query_data_as_arrow()` 完整复制(受 `MAX_IMPORT_ROWS` 限制),结果不可刷新。
- `refresh-data` 使用表元数据中的 `source_table` 和 `import_options` 重新拉取;若
`import_options.structured_query` 存在,则重新执行该结构化查询。
- `preview-data` 调用 `loader.preview_data(...)`(默认基于 `fetch_data_as_arrow()`,
最多 50 行、20 列),只返回内容,不再实时查询源 metadata;semantic 模型额外返回
`semantic_fields` / `relationships`。
---
@@ -300,10 +332,11 @@ metadata 时,前端继续调用 `/api/connectors/get-catalog` 并传入该节
BI 类 loader 可以返回 `node_type="table_group"`,表示一个可批量导入的数据包,例如
Superset dashboard。成员表放在节点 `metadata["tables"]` 中。
`import-group` 会遍历请求中的 `tables`,为每个成员表单独写入 workspace。
每张表的行数受全局 `MAX_IMPORT_ROWS`(200 万)硬上限约束(前端不再传 `row_limit`,
由后端 DataLoader 统一使用 `frontendRowLimit`;详见 `docs/dev-guides/13-unified-row-limits.md`)。
若传入 `source_filters`,只把 `applies_to` 命中的筛选条件传给对应成员表。
`import-group` 会遍历请求中的 `tables`(每项含 `dataset_id`、`name`),为每个成员表
单独调用 `ingest_to_workspace()`,单表失败只记录在该项结果中。可选 `row_limit` > 0 时
作为每张表的 `size`;否则由 loader 按全局 `MAX_IMPORT_ROWS`(200 万)截断(详见
`docs/dev-guides/13-unified-row-limits.md`)。若传入 `source_filters`,只把 `applies_to`
为空或命中该 `dataset_id` 的筛选条件传给对应成员表。
`source_filters` 是数据源原生筛选条件,不等同于通用前端 WHERE 构造器。格式与
`docs/dev-guides/3-data-loader-development.md` 保持一致:
@@ -329,13 +362,13 @@ Loader 应在 `fetch_data_as_arrow(source_table, import_options)` 中读取
| Disconnect | 清除 | 保留 | 清除当前服务 token/凭证 | 保留,可重新连接 |
| Delete | 清除 | 删除 | 删除 | 不保留 |
当前实现中 `disconnect` 会调用 connector 的 vault/token 清理逻辑。用户连接定义仍保留,
因此卡片继续显示;admin connector 不能 delete。
`disconnect` 保留 catalog cache(供 Agent 离线搜索),`delete` 会删除 catalog cache。
用户连接定义在 disconnect 后仍保留,因此卡片继续显示;admin connector 不能 delete。
生命周期边界:
- `disconnect` 清除当前 identity 下的 in-memory loader、该 connector 的 vault 凭据,并调用
`TokenStore.clear_service_token(connector_id)` 清理 service token。
- `disconnect` 清除当前 identity 下的 in-memory loader、该 connector 的 vault 凭据(无认证
loader 除外),并调用 `TokenStore.clear_service_token(connector_id)` 清理 service token。
- `delete` 在 `disconnect` 的基础上删除用户 connector 定义;admin connector 不允许普通用户删除。
- OIDC logout 不等同于 connector disconnect。OIDC logout 只清当前 Flask Session 中的 SSO 和
service token,不删除按 `identity + connector_id` 保存的 vault 凭据。
+93 -73
View File
@@ -1,7 +1,7 @@
# 多语言与 Agent 语言注入开发规范
> **维护者**: DF 核心团队
> **最后更新**: 2026-04-26
> **最后更新**: 2026-10-13
> **适用范围**: Agent prompt、Agent 路由、后端用户可见消息、前端 UI 文案、i18n locale 文件
---
@@ -28,10 +28,12 @@ frontend i18n.language
| 模块 | 职责 |
|------|------|
| `src/app/utils.tsx` | `getAgentLanguage()`、`fetchWithIdentity()`、`translateBackend()` |
| `src/app/App.tsx` | `LanguageSwitcher`,基于已注册的前端 locale 切换语言 |
| `src/app/App.tsx` | `LanguageSwitcher`(`LANGUAGE_LABELS`),基于 `SUPPORTED_UI_LANGUAGES` 切换语言 |
| `src/i18n/index.ts` | 注册 locale 资源,导出 `SUPPORTED_UI_LANGUAGES` |
| `py-src/data_formulator/routes/agents.py` | `_get_ui_lang()`、`get_language_instruction()` |
| `py-src/data_formulator/agents/agent_language.py` | `build_language_instruction()`、`inject_language_instruction()` |
| `src/i18n/locales/{en,zh,hi}/` | 前端翻译资源 |
| `py-src/data_formulator/analyst/agent.py` | `AnalystAgent` 在 `_build_system_prompt()` 末尾追加 `language_instruction` |
| `src/i18n/locales/{en,zh,hi,id,ja,de,fr,ko}/` | 前端翻译资源 |
### 1.1 当前代码对照状态
@@ -39,13 +41,15 @@ frontend i18n.language
| 项目 | 当前状态 | 说明 |
|------|----------|------|
| `AnalystAgent` | 已接入 | `analyst-streaming` route 使用 `full` 模式;`SkillContext.language_instruction` 向 skill 传递同一指令 |
| `SortDataAgent` | 已接入 | 构造函数接收 `language_instruction`,route 使用 `compact` 模式 |
| `workspace-name` | 已接入 | `SimpleAgents` 接收 `language_instruction`,生成 session/workspace 展示名使用 `full` |
| `WorkflowAgent` / `WorkflowDistillAgent` | 已接入(例外路径) | 不经过 `get_language_instruction()`,见 2.1 |
| `test-model` | 明确豁免 | 健康检查需要固定返回,不应被语言指令影响 |
| `rec_language_instruction` | 已清理 | 当前 `routes/agents.py` 未再保留该误导性参数 |
| `message_code` / `content_code` / `option_codes` | 已落地 | Python 固定用户消息由前端翻译,后端保留英文 fallback |
| 前端 i18n | 已有 en/zh 主链路 | `LanguageSwitcher`、`fetchWithIdentity()`、locale 资源已接入 |
| 静态检查 / CI | 未落地 | `scripts/check_language_injection.py`、pre-commit 强制检查仍是未来项 |
| `message_code` / `content_code` / `text_code` / `label_code` | 已落地 | Python 固定用户消息由前端翻译,后端保留英文 fallback |
| 前端 i18n | 已有 8 种 locale | en/zh/hi/id/ja/de/fr/ko,`LanguageSwitcher`、`fetchWithIdentity()`、locale 资源已接入 |
| 静态检查 / CI | 未落地 | 没有语言注入静态检查脚本或 pre-commit 强制检查 |
---
@@ -86,19 +90,29 @@ agent = SortDataAgent(client=client, language_instruction=language_instruction)
| Mode | 适用场景 |
|------|----------|
| `full` | 文本型 Agent:探索、报告、解释、聊天、洞察、数据加载对话 |
| `compact` | 代码生成、数据转换、排序、自动命名、短文本生成、结构化输出 |
| `full` | 文本型 Agent:探索、报告、解释、聊天、会话命名 |
| `compact` | 代码生成、数据清洗、排序、图表样式、短文本生成、结构化输出 |
建议模式:
当前各 Agent 的模式:
| 场景 | Mode |
|------|------|
| `DataAgent`、`ChartInsightAgent`、`InteractiveExploreAgent`、`ReportGenAgent` | `full` |
| `CodeExplanationAgent`、`DataLoadingAgent` | `full` |
| `DataRecAgent`、`DataTransformationAgent`、`DataLoadAgent` | `compact` |
| `SortDataAgent`、`ChartRestyleAgent` | `compact` |
| `workspace-name` | `full` |
| `test-model`、模型列表、纯状态检查 | 不注入 |
| Agent | Route | Mode |
|-------|-------|------|
| `AnalystAgent` | `/api/agent/analyst-streaming` | `full` |
| `CodeExplanationAgent` | `/api/agent/code-expl` | `full`(默认值) |
| `SimpleAgents.workspace_name()` | `/api/agent/workspace-name` | `full` |
| `DataLoadAgent` | `/api/agent/process-data-on-load` | `compact` |
| `SortDataAgent` | `/api/agent/sort-data` | `compact` |
| `StarterQuestionsAgent` | `/api/agent/derive-starter-questions` | `compact` |
| `ChartRestyleAgent` | `/api/agent/chart-restyle` | `compact` |
| `test-model`、模型列表、纯状态检查 | — | 不注入 |
以下两处不经过 `get_language_instruction()`:
- `WorkflowAgent`(`workflows/agent.py`):`/api/workflows/run` 新建运行时把 `_get_ui_lang()` 存入 run state 的
`language`;定时运行使用 schedule 配置中的 `language`(前端默认 `getAgentLanguage()`)。Agent 构造时调用
`build_language_instruction(state["language"])`(`full` 模式)。运行状态会持久化并可能由调度器在无请求上下文时执行,因此保存语言代码。
- `WorkflowDistillAgent`(`/api/knowledge/distill-workflow`):route 传入 `language_code=_get_ui_lang()`,Agent
用自带的 `_LANG_NAMES` 生成语言块;不在该表中的语言回退为英文。
### 2.2 Agent 层
@@ -111,22 +125,25 @@ from data_formulator.agents.agent_language import inject_language_instruction
system_prompt = inject_language_instruction(system_prompt, language_instruction)
```
复杂 prompt 可以指定 marker,把语言指令插入到技术细节之前:
复杂 prompt 可以指定 marker,把语言指令插入到 marker 之前(找不到 marker 时回退为末尾追加):
```python
system_prompt = inject_language_instruction(
system_prompt,
language_instruction,
marker="**About the execution environment:**",
marker="<marker text in the prompt>",
)
```
当前 `agents/` 下的 Agent 均使用末尾追加;`AnalystAgent` 不调用 `inject_language_instruction()`,
直接在 `_build_system_prompt()` 末尾追加 `language_instruction`。
规则:
- 不要在 prompt 中硬编码 `"回答请使用中文"` 之类的语言要求。
- 不要用进程级环境变量决定语言;语言必须来自每个请求的 `Accept-Language`。
- 不要新增并行的 `MessageBuilder` 或 LLM client 全局拦截器。
- 不要跳过 `get_language_instruction()` 在 route 中直接调用 `build_language_instruction()`。
- 不要跳过 `get_language_instruction()` 在 route 中直接调用 `build_language_instruction()`(上面的 Workflow 例外路径除外)。
- 不要把语言指令塞进 user message;语言约束应放在 system prompt。
- `build_language_instruction("en")` 返回空字符串,英文用户不需要额外 prompt。
@@ -149,20 +166,20 @@ Python 中固定的用户可见消息不能只靠 `language_instruction`,因
### 3.1 单条消息
```python
yield {
"type": "error",
"message": "Output DataFrame is empty (0 rows).",
"message_code": "agent.emptyDataframe",
}
yield self._error_event(
iteration, f"Unknown action: {action_type}",
message_code="agent.unknownAction",
)
# → {"type": "error", "iteration": ..., "message": "Unknown action: ...", "message_code": "agent.unknownAction"}
```
### 3.2 结果 content
```python
result = {
"status": "error",
"content": "No code block found in the response.",
"content_code": "agent.noCodeBlock",
"status": "other error",
"content": "unable to extract script from response",
"content_code": "agent.unableExtractScript",
}
```
@@ -170,21 +187,19 @@ result = {
```python
event = {
"type": "clarify",
"type": "interact",
"questions": [{
"id": "continue_after_tool_rounds",
"text": "How would you like to proceed?",
"text_code": "agent.clarifyExhausted",
"text_params": {"steps": steps_desc},
"responseType": "single_choice",
"required": True,
"options": [
{
"id": "continue",
"label": "Continue exploring",
"label_code": "agent.clarifyOptionContinue",
},
{
"id": "simplify",
"label": "Simplify the task",
"label_code": "agent.clarifyOptionSimplify",
},
@@ -193,37 +208,37 @@ event = {
}
```
问题与选项均不带 ID,按数组位置对应。`MetaSkill._sanitize_clarification_questions()` 会保留
`text_code` / `text_params` / `label_code`。当前 `ask_user` 的问题都由 LLM 生成,只有 `text` / `label`;
上例只说明固定文案的格式。
命名规则:
- Agent 相关 key 放在 `messages.agent.*`。
- 后端字段中只写 `agent.emptyDataframe`,前端会拼成 `messages.agent.emptyDataframe`。
- 有参数时使用 `message_params`、`content_params`、`text_params`,例如 `{"missing": "...", "available": "..."}`。
- `clarify` 事件使用 `questions[].text_code` 和 `questions[].options[].label_code`,不要再新增顶层 `message/options/option_codes` 协议。
- `interact` 事件使用 `questions[].text_code` 和 `questions[].options[].label_code`,不要再新增顶层 `message/options/option_codes` 协议。
- LLM 根据当前 UI 语言生成的问题和选项通常只需要 `text` / `label`;固定后端文案才需要同时提供 fallback 文本和 code。
- `questions[].options[]` 的翻译只作用于当前问题的选项,不要把多个问题的选项合并到同一个数组。
- 不要新增 Python 侧翻译表或 `agent_messages.py`;早期设计中的该方案已由前端 `message_code` 翻译模式取代。
### 3.4 已迁移的 Agent 消息 key
### 3.4 当前后端使用的 Agent 消息 code
当前第一批高频 Agent 固定消息已经在 `src/i18n/locales/{en,zh}/messages.json`
中提供翻译:
`messages.agent.*` 的翻译位于 `src/i18n/locales/<lang>/messages.json`。当前后端代码使用的 code:
| Key | 来源 |
|-----|------|
| `agent.clarifyExhausted` | `DataAgent` clarify |
| `agent.clarifyOptionContinue` / `Simplify` / `Present` | `DataAgent` clarify options |
| `agent.maxIterationsSummary` | `DataAgent` completion summary |
| `agent.emptyDataframe` | `DataAgent` error event |
| `agent.fieldsNotFound` | `DataAgent` chart field validation |
| `agent.llmApiError` | `DataAgent` LLM error |
| `agent.llmEmptyResponse` | `DataAgent` empty model response |
| `agent.parseActionFailed` | `DataAgent` action parse failure |
| `agent.unknownAction` | `DataAgent` action dispatch |
| `agent.noCodeBlock` | `DataRecAgent` / `DataTransformationAgent` |
| `agent.unexpectedError` | `DataRecAgent` fallback |
| `agent.codeExecError` | code execution fallback |
| `agent.unableExtractScript` | `DataLoadAgent` / `SortDataAgent` |
| `agent.errorCallingModel` | `DataLoadingChatAgent` |
| Code | 来源 | 是否到达前端 |
|------|------|--------------|
| `agent.llmApiError` | `AnalystAgent` LLM 调用失败且无已分类错误消息时的 error 事件 | 是,有翻译 |
| `agent.unknownAction` | `AnalystAgent` 未知 action 的 error 事件;各 skill `handle_action` | `AnalystAgent` 发出的到达前端,有翻译 |
| `agent.skillNoHandler` | `AnalystAgent`:skill 缺少 `handle_action` | 是,locale 中无翻译,显示英文 fallback |
| `agent.parseActionFailed` | visualization / report / html_app / meta skill | 否,skill error 事件被 `_route_skill_events` 丢弃 |
| `agent.invalidDataOperation` | workspace 数据加载 skill | 否(同上) |
| `agent.invalidConnector` / `agent.connectorsDisabled` | configure skill | 否(同上) |
| `agent.fieldsNotFound` / `agent.emptyDataframe` | `AnalystAgent` 可视化执行结果的 `error_code`(附 `error_params`) | 否,只作为工具结果返回给 LLM |
| `agent.unableExtractScript` | `DataLoadAgent` / `SortDataAgent` 结果的 `content_code` | 否,route 只返回 `status == "ok"` 的结果 |
`messages.json` 中还保留部分当前没有后端使用方的 `agent.*` key(如 `clarifyExhausted`、`clarifyOption*`、
`maxIterationsSummary`、`noCodeBlock`)。
后端普通 HTTP 响应优先走统一错误处理的 `ErrorCode` / `AppError`;还未纳入统一错误体系的
`jsonify(message="...")` 不应在本文档中宣称已经全部完成国际化。
@@ -255,8 +270,10 @@ const optionLabel = translateBackend(option.label, option.label_code);
如果没有 code 或没有翻译,函数会回退到后端英文 fallback。
结构化问题的逐题翻译由 `src/app/clarification.ts` 的 `normalizeClarifyEvent()` 完成。
澄清面板自己的固定 UI 文案,例如标题、按钮、占位符和“直接说明”标签,放在
`src/i18n/locales/{en,zh}/common.json` 的 `chartRec` 下;不要从后端事件里下发这些前端壳层文案。
`src/i18n/locales/<lang>/common.json` 的 `chartRec` 下;不要从后端事件里下发这些前端壳层文案。
---
@@ -287,26 +304,25 @@ return <Button>{t('common.save')}</Button>;
翻译文件:
```text
src/i18n/locales/en/
src/i18n/locales/zh/
src/i18n/locales/{en,zh,hi,id,ja,de,fr,ko}/
```
新增 key 时必须同时更新 en 和 zh。命名空间按现有文件选择:`common`、`upload`、
`chart`、`model`、`encoding`、`messages`、`navigation`、`dataLoading`、`errors` 等。
新增 key 时必须同时更新所有已注册 locale:`tests/frontend/unit/app/i18nLocales.test.ts` 要求每种
locale 的 key 集合与 en 一致。命名空间按现有文件选择:`common`、`upload`、
`chart`、`model`、`encoding`、`messages`、`navigation`、`dataLoading`、`loader`、`errors`。
### 5.1 翻译 key 命名
优先使用现有 namespace。大量独立功能文案可以新建 namespace,但必须同时添加 en/zh
资源并注册到 locale index。
优先使用现有 namespace。大量独立功能文案可以新建 namespace,但必须为所有已注册 locale
添加资源并在各自的 `src/i18n/locales/<lang>/index.ts` 中合并。
```text
<namespace>.<component-or-feature>.<element>
dataLoading.toolLabels.readingFile
dataLoading.actions.loadTable
dataLoading.placeholder.describeData
common.actions.close
messages.error.failedToOpenWorkspace
chartRec.skipAnswer
config.localRowLimit
messages.agent.llmApiError
```
设计文档中曾列出一批前端硬编码审计结果;迁移到本规范后,这些清单不再作为“当前待办事实”
@@ -320,10 +336,12 @@ messages.error.failedToOpenWorkspace
1. 在 `agents/agent_language.py` 的 `LANGUAGE_DISPLAY_NAMES` 中添加语言代码和显示名。
2. 如有特殊要求,添加到 `LANGUAGE_EXTRA_RULES`。
3. 在 `src/i18n/locales/<lang>/` 添加完整翻译资源。
4. 在 `src/i18n/index.ts` 注册 locale,让前端语言切换器显示该语言。
5. 验证 `fetchWithIdentity()` 请求头、Agent 输出、固定 UI 文案都使用新语言。
4. 在 `src/i18n/locales/index.ts` 导出,并在 `src/i18n/index.ts` 的 `resources` 中注册;`SUPPORTED_UI_LANGUAGES`
由此派生,语言切换器随之显示该语言。可在 `App.tsx` 的 `LANGUAGE_LABELS` 中添加显示名。
5. 在 `tests/frontend/unit/app/i18nLocales.test.ts` 中加入 key 对齐检查。
6. 验证 `fetchWithIdentity()` 请求头、Agent 输出、固定 UI 文案都使用新语言。
每种新语言至少需要与 en/zh 等价的 locale 结构:
每种新语言需要与 en 等价的 locale 结构:
```text
src/i18n/locales/<lang>/
@@ -340,7 +358,7 @@ src/i18n/locales/<lang>/
└── index.ts
```
`agent_language.py` 支持的 20 种 LLM 输出语言不等于前端 UI 已完整翻译 20 种语言。只有
`agent_language.py` 支持的 20 种 LLM 输出语言不等于前端 UI 已完整翻译 20 种语言(当前前端注册 8 种)。只有
locale 文件完整并在 `src/i18n/index.ts` 注册的语言,才应出现在前端语言切换器中。
---
@@ -350,12 +368,14 @@ locale 文件完整并在 `src/i18n/index.ts` 注册的语言,才应出现在
当前已有:
- 前端 `fetchWithIdentity` 测试覆盖身份和认证 header 行为。
- 前端 error code/i18n 映射测试覆盖结构化错误翻译。
- 后端 Agent 和 error handler 测试覆盖部分 `message_code` / warning 事件。
- 前端 error code/i18n 映射测试(`errorCodes.test.ts`、`errorHandler.test.ts`)覆盖结构化错误翻译;`clarification.test.ts` 覆盖 `text_code` / `label_code` 翻译。
- 前端 `i18nLocales.test.ts` 检查所有已注册 locale 的 key 与 en 对齐。
- 后端 `tests/backend/agents/test_agent_language.py` 覆盖 `build_language_instruction()` / `inject_language_instruction()`。
- 后端 `tests/backend/errors/test_error_handler.py` 覆盖 warning 事件的 `message_code`。
当前未作为已完成能力声明:
- 没有专门的 `scripts/check_language_injection.py` 静态检查脚本。
- 没有专门的语言注入静态检查脚本。
- 没有 pre-commit/CI 强制扫描所有新增 LLM 调用点。
- 普通后端 HTTP 响应消息仍在逐步迁移到 error code / message code 体系。
@@ -386,13 +406,13 @@ locale 文件完整并在 `src/i18n/index.ts` 注册的语言,才应出现在
- [ ] 读取 `Accept-Language` 派生语言指令
- [ ] `test-model` 这类健康检查明确记录为不注入
- [ ] 纯结构化 JSON route 若新增自然语言输出,需要重新评估注入
- [ ] 流式事件中的错误、clarify、summary 使用 message code
- [ ] 流式事件中的固定错误、问题、summary 文案使用 message code
- [ ] 前端消费路径调用 `translateBackend()`
### 新增前端组件
- [ ] 使用 `useTranslation()` 和 `t()`
- [ ] en/zh 都添加翻译 key
- [ ] 所有已注册 locale 都添加翻译 key
- [ ] 不翻译内部 sentinel value
- [ ] 后端 message code 用 `translateBackend()` 消费
@@ -400,7 +420,7 @@ locale 文件完整并在 `src/i18n/index.ts` 注册的语言,才应出现在
- [ ] 判断消息是否用户可见
- [ ] 用户可见则提供英文 fallback + code
- [ ] code 在 `src/i18n/locales/en/messages.json` 和 `zh/messages.json` 中都有翻译
- [ ] code 在所有 `src/i18n/locales/<lang>/messages.json` 中都有翻译
- [ ] 如属于错误处理体系,优先使用统一 `ErrorCode` / `AppError`
### Review Checklist
+16 -15
View File
@@ -1,7 +1,7 @@
# 统一错误处理开发规范
> **维护者**: DF 核心团队
> **最后更新**: 2026-05-01
> **最后更新**: 2026-10-13
> **适用范围**: 后端 API route、流式端点、前端 API 调用、错误码/i18n、错误相关测试
> **核心原则**: 业务/校验错误一律 HTTP 200,仅认证/授权和不可控传输错误使用非 200
@@ -332,7 +332,7 @@ try {
|------|------|
| 用户主动操作失败 | 必须通知用户,例如 `addMessages` 或 `handleApiError()` |
| 后台 best-effort 加载 | 可以静默,但要加注释说明为何可忽略 |
| RTK thunk rejected | 必须有 `.rejected` handler,按 `error.name` 区分 `AbortError`(静默)、`TimeoutError`(超时提示)和业务错误(通用提示),参见 `fetchChartInsight` |
| RTK thunk rejected | 必须有 `.rejected` handler,按 `action.error.name` 区分 `AbortError`(静默)和其他错误(提示),参见 `dfSlice.tsx` 中 `fetchCodeExpl` / `fetchAvailableModels` 的 rejected handler |
| `AbortError` | 可直接忽略 |
### 4.5 RTK `createAsyncThunk` 序列化边界
@@ -435,14 +435,14 @@ LLM / Agent 请求不要硬编码短 timeout。timeout 来源必须可解释:
| 数据库 / connector connect | 独立连接 timeout,并显示连接超时文案 |
| best-effort preview / debounce | 可短超时或无提示,但必须注释说明 |
系统 timeout 应与用户取消区分。推荐使用 `AbortController.abort(reason)` 传入
`DOMException(..., "TimeoutError")`,rejected reducer 通过 `action.error.name`
区分:
系统 timeout 应与用户取消区分。当前做法(参见 `src/views/SimpleChartRecBox.tsx`
的 Agent 请求)是在 timeout 回调中先设置本地 `timedOut` 标记再调用
`controller.abort()`,catch 中按以下方式区分:
| `action.error.name` | 含义 | 处理 |
| 条件 | 含义 | 处理 |
|---|---|---|
| `TimeoutError` | 系统超时 | 显示 warning,文案包含配置秒数或任务名称 |
| `AbortError` | 用户取消或组件卸载 | 可静默 |
| `AbortError` 且 `timedOut` | 系统超时 | 显示 warning,文案包含配置秒数(如 `messages.agent.requestTimedOut`) |
| `AbortError` 且非 `timedOut` | 用户取消或组件卸载 | 可静默或显示“已取消” |
| 其他错误 | API / 业务错误 | 使用 `getErrorMessage()` 或本地 i18n 文案提示 |
`fetchCodeExpl` 和 `fetchFieldSemanticType` 不设置前端硬编码 timeout;新增同类后台
@@ -455,7 +455,8 @@ metadata 请求不要复制短客户端 abort 模式。
1. `py-src/data_formulator/errors.py`: 添加 `ErrorCode` 常量(无需添加 HTTP 映射,默认 200)
2. `src/app/errorCodes.ts`: 添加 `ERROR_CODE_I18N_MAP`
3. `src/i18n/locales/en/errors.json`: 添加英文文案
4. `src/i18n/locales/zh/errors.json`: 添加中文文案
4. 其他 locale(`zh`、`de`、`fr`、`hi`、`id`、`ja`、`ko`)的 `errors.json`: 同步添加对应 key;
`tests/frontend/unit/app/i18nLocales.test.ts` 会校验各 locale 与英文 key 对齐
前端通过 `getErrorMessage(apiError)` 优先使用本地 i18n,缺失时回退到后端英文 `message`。
@@ -530,12 +531,12 @@ DataLoader/connector 只做简单实用分类,不为每个 SDK 维护专门错
### 7.1 自动化护栏现状
当前已有后端协议合约测试、前端 `apiClient` 测试和轻量 ESLint 护栏。其他尚未
落地的自动化护栏不要在设计文档或评审中标记为已完成:
当前的自动化护栏只有以下几类,没有独立的 API error 静态扫描脚本:
- `scripts/check_api_error_guardrails.py` 静态扫描脚本暂不实现;只有在误用反复出现时再考虑。
- CI 暂不强制接入 API error guardrail 扫描,避免维护复杂扫描器和误报。
- ESLint 已禁止业务代码新增直接 `fetchWithIdentity().json()` / `(await fetchWithIdentity()).json()`;
- 后端协议合约测试:`tests/backend/errors/test_api_error_protocol_contract.py`、
`tests/backend/errors/test_error_handler.py`、`tests/backend/errors/test_errors.py`。
- 前端测试:`tests/frontend/unit/app/apiClient.test.ts`、`errorHandler.test.ts`、`errorCodes.test.ts`。
- ESLint(`eslint.config.js`)已禁止业务代码新增直接 `fetchWithIdentity().json()` / `(await fetchWithIdentity()).json()`;
更复杂的 `fetchWithIdentity` 用法仍按人工评审和统一 API 规范判断。
## 8. 新 endpoint checklist
@@ -553,7 +554,7 @@ DataLoader/connector 只做简单实用分类,不为每个 SDK 维护专门错
### 流式 endpoint
- [ ] `mimetype='application/x-ndjson'`
- [ ] 使用 `stream_with_context(_with_warnings(generate()))` 包裹
- [ ] 使用 `stream_with_context(_with_warnings(generate()))` 包裹(`_with_warnings` 定义在 `routes/agents.py`)
- [ ] 预检失败使用 `stream_preflight_error(AppError(...))`
- [ ] 流内错误使用 `stream_error_event(classify_and_wrap_llm_error(e))`
- [ ] 前端消费代码处理 `type: "error"` 和 `type: "warning"`
+20 -13
View File
@@ -1,20 +1,27 @@
# Backend Tests
Organized by concern. All tests run with `uv run pytest tests/backend/`.
Organized by concern. All tests run with `uv run pytest tests/backend/`
(tests marked `slow` are skipped unless `--run-slow` is passed).
```
tests/backend/
auth/ ← identity, providers, vault, session config
routes/ ← Flask endpoint tests (tables, agents, sessions, credentials)
data/ ← loaders, connectors, workspace, parquet, table names
includes test_workspace_path_safety.py (FINDING-4)
agents/ ← AI agents, model registry, prompts
includes test_tool_path_safety.py (FINDING-2)
security/ ← code signing, sanitization, URL allowlist, sandbox,
scratch_serve path safety (FINDING-1),
local_folder deployment restriction (FINDING-3),
startup safety checks (FINDING-5)
fixtures/ ← shared test data (CSV, JSON, parquet)
auth/ ← identity, auth providers (OIDC, GitHub, EasyAuth, Azure CLI), vault, token store, session config
routes/ ← Flask endpoint tests (tables, sessions, credentials, knowledge, workspace files, data loaders)
data/ ← connectors, catalog cache/sync, plugin scanner, workspace, parquet, table names
includes test_workspace_path_safety.py (FINDING-4)
data_loader/ ← individual loaders (ClickHouse, Cube, Databricks, Kusto, MSSQL, Power BI, S3, Azure Blob), probe, SQL escaping
data_operations/ ← structured data operation models, executor, repository
agents/ ← analyst skills, agents, model registry, prompts, sessions, workflows
includes test_analyst_workspace_files.py (workspace file tool path checks, FINDING-2)
errors/ ← AppError, global error handler, API error protocol contract
knowledge/ ← knowledge store
security/ ← code signing, sanitization, URL allowlist, sandbox, configuration,
scratch_serve path safety (FINDING-1),
local_folder deployment restriction (FINDING-3),
startup safety checks (FINDING-5)
benchmarks/ ← manual benchmarks (sandbox, workspace); not collected by pytest
fixtures/ ← shared test data (Excel/CSV files)
test_*.py ← desktop packaging, crash report, startup spinner, model endpoints, text edit
```
Security audit test files (added per `design-docs/issues/002-arbitrary-file-read-audit.md`):
@@ -22,7 +29,7 @@ Security audit test files (added per `design-docs/issues/002-arbitrary-file-read
| File | FINDING | What it tests |
|------|---------|---------------|
| `security/test_scratch_serve.py` | #1 | `scratch_serve` path traversal, `send_file` usage |
| `agents/test_tool_path_safety.py` | #2 | Agent tool path confinement (`_tool_read_file`, etc.) |
| `agents/test_analyst_workspace_files.py` | #2 | Agent workspace file tools (`create_file`, `edit_file`, scratch listing) reject traversal / absolute paths |
| `security/test_local_folder_deployment.py` | #3 | `local_folder` disabled in multi-user mode |
| `data/test_workspace_path_safety.py` | #4 | `Workspace.__init__` sanitizes identity paths and uses `ConfinedDir` |
| `security/test_startup_safety.py` | #5 | Startup warning for unsafe sandbox config |
+57 -58
View File
@@ -14,65 +14,64 @@ in `py-src/tests/` — those have been consolidated here).
```
tests/
conftest.py # adds py-src to sys.path
conftest.py # adds py-src to sys.path, --run-slow option
test_plan.md # ← this file
run_test_dbs.sh # unified helper: start/stop/test all DBs
backend/ # included in default pytest (no Docker needed)
unit/ # pure functions, no Flask/network
security/ # security-focused tests
test_sandbox_security.py # sandbox confinement (file write, process exec)
test_code_signing.py # HMAC sign/verify for transformation code
test_sanitize.py # error message redaction
test_auth.py # identity extraction & namespace isolation
test_global_model_security.py # credential isolation, error sanitization
test_url_allowlist.py # SSRF protection for user-provided api_base
integration/ # Flask routes, workspace, sandbox
test_sandbox.py # sandbox functional tests (transforms, errors)
...
contract/ # API boundary guarantees
benchmarks/ # performance benchmarks (not in CI)
plugin/ # data loader tests (requires Docker, run separately)
mysql/ # MySQL loader (Dockerfile + init.sql)
mongodb/ # MongoDB loader (Dockerfile + init_data.js)
postgres/ # PostgreSQL loader (Dockerfile + init.sql)
bigquery/ # BigQuery emulator (Dockerfile + init_data.yaml)
cosmosdb/ # Cosmos DB emulator (Dockerfile + seed_data.py)
run_test_dbs.sh # unified script to start/stop/test databases
agents/ # analyst skills, agents, model registry, workflows
auth/ # identity, auth providers, vault, token store
data/ # connectors, catalog, workspace, parquet, table names
data_loader/ # individual loader tests (mocked connections)
data_operations/ # structured data operations
errors/ # AppError, error handler, API error contract
knowledge/ # knowledge store
routes/ # Flask endpoint tests
security/ # sandbox, code signing, sanitization, URL allowlist, path safety
benchmarks/ # performance benchmarks (manual, not collected)
fixtures/ # shared Excel/CSV fixtures
database-dockers/ # data loader integration tests (requires Docker, run separately)
mysql/ postgres/ mongodb/ # each with docker-compose.yml, setup files, test module
bigquery/ cosmosdb/ superset/
docker-compose.test.yml # optional unified compose entry point
test-dbs.ps1 # PowerShell helper: start/stop/test services
eval/ # manual LLM evaluation harnesses (intent_routing)
frontend/
setup.ts # jest-dom matchers
unit/ # vitest tests for src/
```
See `tests/backend/README.md` for per-directory details.
### Running tests
```bash
# Default: backend + frontend (no Docker needed, fast)
# Backend (default pytest paths; no Docker needed, fast)
pytest
pytest tests/backend --run-slow # include tests marked slow
# Plugin tests: data loader integrations (requires Docker)
./tests/database-dockers/run_test_dbs.sh start # start all test databases
./tests/database-dockers/run_test_dbs.sh test # run all loader tests
./tests/database-dockers/run_test_dbs.sh stop # tear down
# Frontend
npm test
# Or one-shot per service
./tests/database-dockers/run_test_dbs.sh test mysql
# Data loader integrations (requires Docker; see tests/database-dockers/README.md)
.\tests\database-dockers\test-dbs.ps1 start core # PowerShell
.\tests\database-dockers\test-dbs.ps1 test mysql
.\tests\database-dockers\test-dbs.ps1 stop core
# macOS / Linux: per-service compose
cd tests/database-dockers/mysql && docker compose up -d --build --wait
```
### What exists today
| Layer | Location | Runner | Count |
|-------|----------|--------|-------|
| Backend unit | `tests/backend/unit/` | pytest | 20 files |
| Backend security | `tests/backend/security/` | pytest | 9 files |
| Backend integration | `tests/backend/integration/` | pytest | 8 files (7 route tests + sandbox) |
| Backend contract | `tests/backend/contract/` | pytest | 2 files |
| Backend benchmarks | `tests/backend/benchmarks/` | manual | 2 files |
| Plugin (data loaders) | `tests/plugin/` | pytest (manual) | 7 suites (requires Docker) |
| Frontend unit | `tests/frontend/unit/` | vitest | 4 files |
| Layer | Location | Runner |
|-------|----------|--------|
| Backend (unit, route, contract, security) | `tests/backend/` | pytest |
| Backend benchmarks | `tests/backend/benchmarks/` | manual |
| Data loader integrations | `tests/database-dockers/` | pytest (manual, requires Docker) |
| Frontend unit | `tests/frontend/unit/` | vitest |
`tests/backend/` runs by default with `pytest` — no Docker required.
`tests/plugin/` is excluded from default runs and requires
`./tests/run_test_dbs.sh` to start test databases first.
`tests/backend/` runs by default with `pytest` (see `pytest.ini`) — no Docker
required. `tests/database-dockers/` is excluded from
default runs and requires the test services to be started first.
### Key gaps
@@ -102,7 +101,7 @@ whitespace sensitivity, Unicode, `sign_result()` helper.
#### 2. Auth & identity (`auth.py`) ✅ covered
Tests in `tests/backend/security/test_auth.py`:
Tests in `tests/backend/auth/test_auth.py`:
Azure principal → `user:` prefix, browser → `browser:` prefix, client
cannot spoof `user:` namespace, Azure takes priority, missing headers,
malformed values rejected, validation edge cases.
@@ -136,9 +135,9 @@ instead of `send_from_directory` (FINDING-1 from issue-002).
#### 4d. Agent tool path confinement ✅ covered
Tests in `tests/backend/agents/test_tool_path_safety.py`:
`_tool_read_file` / `_tool_list_directory` / `_preview_scratch_files` reject
traversal paths (`../../etc/passwd`), normal workspace paths work (FINDING-2 from issue-002).
Tests in `tests/backend/agents/test_analyst_workspace_files.py`:
agent workspace file tools (`create_file`, `edit_file`, scratch file listing) reject
traversal and absolute paths, normal workspace paths work (FINDING-2 from issue-002).
#### 4e. `local_folder` deployment restriction ✅ covered
@@ -159,7 +158,7 @@ Tests in `tests/backend/security/test_startup_safety.py`:
multi-user + no sandbox → `logger.critical` warning, safe configs → no warning
(FINDING-5 from issue-002).
#### 5. Session sensitive-field stripping (`session_routes.py`)
#### 5. Session sensitive-field stripping (`datalake/workspace_manager.py`)
- `_strip_sensitive()` removes model credentials, identity, API keys
- round-trip: save → load preserves data but strips secrets
@@ -183,14 +182,14 @@ multi-user + no sandbox → `logger.critical` warning, safe configs → no warni
- file type validation (reject unsupported formats)
- (some coverage already exists — extend, don't duplicate)
#### 8. Metadata persistence (`datalake/metadata.py`)
#### 8. Metadata persistence (`datalake/workspace_metadata.py`)
- YAML save / load round-trip
- cross-platform file locking
- concurrent writers don't corrupt metadata
- schema migration: old metadata format still loads
#### 9. Table routes — end-to-end (`tables_routes.py`)
#### 9. Table routes — end-to-end (`routes/tables.py`)
- `/create-table` with JSON, CSV, Parquet payloads
- `/parse-file` with Excel, CSV, TSV files
@@ -220,12 +219,11 @@ multi-user + no sandbox → `logger.critical` warning, safe configs → no warni
#### 12. Individual agents (mock `Client` / LiteLLM)
- `DataRecAgent` — given a mock LLM response, produces correct Vega-Lite spec + code
- `DataTransformationAgent` — generates valid Python transformation code
- `AnalystAgent` (`analyst/agent.py`) — tool loop terminates correctly, produces charts/tables
- `WorkflowAgent` (`workflows/agent.py`) — workflow runs on top of the analyst loop
- `DataLoadAgent` — infers semantic types and suggests table names from raw data
- `CodeExplanationAgent` — produces explanation from code input
- `ChartInsightAgent` — generates insight from chart spec
- `DataAgent` — observe→think→act loop terminates correctly
- `ChartRestyleAgent`, `SortDataAgent`, `StarterQuestionsAgent`, `WorkflowDistillAgent`
Focus on: prompt assembly, response parsing, error recovery, repair loops.
@@ -233,7 +231,7 @@ Focus on: prompt assembly, response parsing, error recovery, repair loops.
- type classification: temporal, categorical, measure sets are disjoint
- type resolution from sample data (dates, currencies, percentages)
- consistency with frontend `type-registry.ts` constants
- consistency with `type-registry.ts` in the `flint-chart` package
### P3 — Visualization & workflows
@@ -255,11 +253,12 @@ Focus on: prompt assembly, response parsing, error recovery, repair loops.
#### 16. Data loader framework ✅ partially covered
Integration tests with Dockerized services exist for MySQL, MongoDB,
PostgreSQL, and BigQuery (in `tests/backend/integration/test_*/`).
PostgreSQL, BigQuery, Cosmos DB, and Superset (in `tests/database-dockers/`).
A standalone MySQL-to-datalake round-trip test also exists
(`test_mysql_datalake.py`). Still needed:
(`tests/database-dockers/mysql/test_mysql_datalake.py`). Mocked loader tests
live in `tests/backend/data_loader/` and `tests/backend/data/`. Still needed:
- `BaseDataLoader` interface contract test (shared across all loaders)
- `ExternalDataLoader` interface contract test (shared across all loaders)
- Remaining loaders (require mocked connections or emulators):
- MSSQL: SQL query → DataFrame
- S3: file listing, download, Athena integration
@@ -324,8 +323,8 @@ These would likely use Playwright or Cypress and are out of scope initially.
| Marker | Meaning |
|--------|---------|
| `@pytest.mark.backend` | Backend test (already configured) |
| `@pytest.mark.contract` | API contract test (already configured) |
| `@pytest.mark.slow` | Tests that take > 5s (DB, Docker sandbox) |
| `@pytest.mark.slow` | Takes a second or more; skipped unless `--run-slow` (already configured) |
| `@pytest.mark.live` | Hits a real external service; skipped without credentials (already configured) |
| `@pytest.mark.requires_docker` | Needs Docker daemon running |
| `@pytest.mark.requires_llm` | Needs real LLM API key (skip in CI by default) |