Merge remote-tracking branch 'origin/main' into wsh_dev

# Conflicts:
#	backend/api/server.py
This commit is contained in:
hupenglong1
2026-05-25 11:46:44 +08:00
77 changed files with 11155 additions and 2259 deletions
+47 -2
View File
@@ -583,6 +583,22 @@ bash "$HOME/zk-data-agent/scripts/install-from-git.sh"
- 安装前端依赖并构建。 - 安装前端依赖并构建。
- 安装并启动用户级 systemd 服务。 - 安装并启动用户级 systemd 服务。
如果要启用“平台账号 = Linux 用户”的托管工作区,需要使用 root/systemd system 服务部署:
```bash
cd "$HOME/zk-data-agent"
sudo -E env PATH="$PATH" bash scripts/deploy-ubuntu.sh main --system-service --enable-linux-accounts
```
启用后:
- 平台注册/登录账号时会同步创建同名 Linux 用户。
- 平台密码会同步设置为 Linux 用户密码。
- Linux 用户允许 SSH 登录。
- 本机托管工作区会使用 `/home/<account_id>/zk-agent/`
- `python_exec``python_package``bash` 会在对应 Linux 用户身份下执行。
- Jupyter 远程工作区保持现有逻辑,不参与本机 Linux 用户隔离。
`.env.deploy` 会保存: `.env.deploy` 会保存:
```text ```text
@@ -595,6 +611,8 @@ CLAW_BACKEND_PORT
CLAW_FRONTEND_HOST CLAW_FRONTEND_HOST
CLAW_FRONTEND_PORT CLAW_FRONTEND_PORT
CLAW_API_URL CLAW_API_URL
CLAW_SERVICE_SCOPE
CLAW_ENABLE_LINUX_ACCOUNTS
CLAW_NPM_BIN CLAW_NPM_BIN
CLAW_NPX_BIN CLAW_NPX_BIN
CLAW_NODE_BIN CLAW_NODE_BIN
@@ -618,6 +636,12 @@ bash scripts/update-server-fast.sh
bash scripts/deploy-ubuntu.sh bash scripts/deploy-ubuntu.sh
``` ```
root/system 服务更新:
```bash
sudo -E env PATH="$PATH" bash scripts/deploy-ubuntu.sh main --system-service --enable-linux-accounts
```
部署指定分支: 部署指定分支:
```bash ```bash
@@ -632,7 +656,7 @@ bash scripts/deploy-ubuntu.sh main --force
### 服务管理 ### 服务管理
部署脚本会安装两个用户级 systemd 服务: 部署脚本默认安装用户级 systemd 服务;以 root 执行或指定 `--system-service` 时安装 system 级服务。服务名默认是
```text ```text
zk-data-agent-backend zk-data-agent-backend
@@ -646,6 +670,13 @@ systemctl --user status zk-data-agent-backend
systemctl --user status zk-data-agent-frontend systemctl --user status zk-data-agent-frontend
``` ```
system 级服务使用:
```bash
systemctl status zk-data-agent-backend
systemctl status zk-data-agent-frontend
```
查看日志: 查看日志:
```bash ```bash
@@ -653,12 +684,25 @@ journalctl --user -u zk-data-agent-backend -f
journalctl --user -u zk-data-agent-frontend -f journalctl --user -u zk-data-agent-frontend -f
``` ```
system 级日志使用:
```bash
journalctl -u zk-data-agent-backend -f
journalctl -u zk-data-agent-frontend -f
```
重启服务: 重启服务:
```bash ```bash
systemctl --user restart zk-data-agent-backend zk-data-agent-frontend systemctl --user restart zk-data-agent-backend zk-data-agent-frontend
``` ```
system 级重启使用:
```bash
systemctl restart zk-data-agent-backend zk-data-agent-frontend
```
停止服务: 停止服务:
```bash ```bash
@@ -683,6 +727,7 @@ Ubuntu 部署建议:
- Python `3.10.14` - Python `3.10.14`
- Node.js `20``22` - Node.js `20``22`
- `npm` - `npm`
- 启用 Linux 账号工作区时,还需要 `passwd``python3``python3-venv`,并要求服务以 root/systemd system 方式运行。
首次安装如果缺 Python 编译依赖,可以执行: 首次安装如果缺 Python 编译依赖,可以执行:
@@ -690,7 +735,7 @@ Ubuntu 部署建议:
bash scripts/deploy-ubuntu.sh --bootstrap-system bash scripts/deploy-ubuntu.sh --bootstrap-system
``` ```
`--bootstrap-system` 会使用 `sudo apt-get` 安装系统依赖;应用代码、虚拟环境、前端依赖、运行数据和 systemd 用户服务仍然位于当前用户目录。 `--bootstrap-system` 会使用 `sudo apt-get` 安装系统依赖;普通模式下应用代码、虚拟环境、前端依赖、运行数据和 systemd 用户服务仍然位于当前用户目录。启用 `--enable-linux-accounts` 后,账号工作区位于 `/home/<account_id>/zk-agent/`
## 开发验证 ## 开发验证
+157 -2
View File
@@ -13,6 +13,7 @@ import hashlib
import shlex import shlex
import json import json
import os import os
import pwd
import queue import queue
import re import re
import shutil import shutil
@@ -76,6 +77,9 @@ from src.session_store import (
) )
from src.token_budget import calculate_token_budget from src.token_budget import calculate_token_budget
LINUX_ACCOUNT_WORKSPACE_NAME = 'zk-agent'
LINUX_ACCOUNT_ENV = 'CLAW_ENABLE_LINUX_ACCOUNTS'
STATIC_DIR = Path(__file__).resolve().parents[2] / 'frontend' / 'legacy-static' STATIC_DIR = Path(__file__).resolve().parents[2] / 'frontend' / 'legacy-static'
API_TOOL_CONTENT_MAX_CHARS = 20000 API_TOOL_CONTENT_MAX_CHARS = 20000
@@ -638,6 +642,8 @@ class AgentState:
if not account_id: if not account_id:
return self.session_directory.parent return self.session_directory.parent
safe_id = _safe_account_id(account_id) safe_id = _safe_account_id(account_id)
if _linux_accounts_enabled():
return _linux_account_workspace(safe_id).resolve()
return (self.session_directory.parent / 'accounts' / safe_id).resolve() return (self.session_directory.parent / 'accounts' / safe_id).resolve()
def account_paths(self, account_id: str | None) -> dict[str, Path]: def account_paths(self, account_id: str | None) -> dict[str, Path]:
@@ -837,7 +843,15 @@ class AgentState:
for directory in paths.values(): for directory in paths.values():
if directory.name != '.venv': if directory.name != '.venv':
directory.mkdir(parents=True, exist_ok=True) directory.mkdir(parents=True, exist_ok=True)
self._ensure_python_env(paths['python_env']) runtime_user = (
_safe_account_id(account_id)
if account_id and _linux_accounts_enabled()
else None
)
if runtime_user:
_ensure_linux_user_exists(runtime_user)
_chown_path_for_linux_user(paths['base'], runtime_user, recursive=True)
self._ensure_python_env(paths['python_env'], account_id)
permissions = AgentPermissions( permissions = AgentPermissions(
allow_file_write=config.allow_write, allow_file_write=config.allow_write,
allow_shell_commands=config.allow_shell, allow_shell_commands=config.allow_shell,
@@ -849,6 +863,7 @@ class AgentState:
session_directory=paths['sessions'], session_directory=paths['sessions'],
scratchpad_root=paths['scratchpad'], scratchpad_root=paths['scratchpad'],
python_env_dir=paths['python_env'], python_env_dir=paths['python_env'],
runtime_user=runtime_user,
enabled_skill_names=self.enabled_skill_names(account_id), enabled_skill_names=self.enabled_skill_names(account_id),
auto_compact_threshold_tokens=180_000, auto_compact_threshold_tokens=180_000,
) )
@@ -949,12 +964,37 @@ class AgentState:
def lock(self) -> Lock: def lock(self) -> Lock:
return self._lock return self._lock
def _ensure_python_env(self, env_dir: Path) -> None: def _ensure_python_env(self, env_dir: Path, account_id: str | None = None) -> None:
python_bin = env_dir / 'bin' / 'python' python_bin = env_dir / 'bin' / 'python'
pip_bin = env_dir / 'bin' / 'pip' pip_bin = env_dir / 'bin' / 'pip'
if python_bin.exists() and pip_bin.exists(): if python_bin.exists() and pip_bin.exists():
return return
env_dir.parent.mkdir(parents=True, exist_ok=True) env_dir.parent.mkdir(parents=True, exist_ok=True)
runtime_user = _safe_account_id(account_id) if account_id and _linux_accounts_enabled() else None
if runtime_user:
_ensure_linux_user_exists(runtime_user)
env_dir.parent.mkdir(parents=True, exist_ok=True)
_chown_path_for_linux_user(env_dir.parent, runtime_user, recursive=True)
runtime_python = os.environ.get('CLAW_RUNTIME_PYTHON_BIN') or shutil.which('python3')
if not runtime_python:
raise RuntimeError('Linux runtime requires python3 or CLAW_RUNTIME_PYTHON_BIN')
subprocess.run(
[
'runuser',
'-u',
runtime_user,
'--',
runtime_python,
'-m',
'venv',
str(env_dir),
],
check=True,
capture_output=True,
text=True,
timeout=120,
)
return
venv.EnvBuilder(with_pip=True, symlinks=False).create(env_dir) venv.EnvBuilder(with_pip=True, symlinks=False).create(env_dir)
@@ -4538,6 +4578,107 @@ def _account_id_from_email(email: str | None) -> str | None:
return prefix return prefix
def _linux_accounts_enabled() -> bool:
return os.environ.get(LINUX_ACCOUNT_ENV, '').strip().lower() in {
'1',
'true',
'yes',
'on',
}
def _linux_account_workspace(account_id: str) -> Path:
return Path('/home') / account_id / LINUX_ACCOUNT_WORKSPACE_NAME
def _validate_linux_username(account_id: str) -> None:
if not re.fullmatch(r'[a-z_][a-z0-9_-]{1,31}', account_id):
raise HTTPException(
status_code=400,
detail='启用 Linux 账号时,账号名必须以小写字母或下划线开头,只包含小写字母、数字、下划线或中划线,长度 2-32',
)
def _ensure_linux_account(account_id: str, password: str) -> None:
_validate_linux_username(account_id)
if os.geteuid() != 0:
raise HTTPException(
status_code=500,
detail='CLAW_ENABLE_LINUX_ACCOUNTS=1 需要后端以 root 身份运行',
)
try:
pwd.getpwnam(account_id)
except KeyError:
subprocess.run(
[
'useradd',
'-m',
'-d',
f'/home/{account_id}',
'-s',
'/bin/bash',
account_id,
],
check=True,
capture_output=True,
text=True,
timeout=30,
)
chpasswd = subprocess.run(
['chpasswd'],
input=f'{account_id}:{password}\n',
check=False,
capture_output=True,
text=True,
timeout=30,
)
if chpasswd.returncode != 0:
detail = (chpasswd.stderr or chpasswd.stdout or '').strip()
raise HTTPException(status_code=500, detail=f'同步 Linux 用户密码失败:{detail}')
workspace = _linux_account_workspace(account_id)
for child in (
workspace / 'sessions',
workspace / 'python',
workspace / 'memory',
workspace / 'integrations',
):
child.mkdir(parents=True, exist_ok=True)
_chown_path_for_linux_user(workspace, account_id, recursive=True)
def _ensure_linux_user_exists(account_id: str) -> None:
try:
pwd.getpwnam(account_id)
except KeyError as exc:
raise RuntimeError(
f'Linux runtime user does not exist: {account_id}. '
'Create the account through the platform first, or disable CLAW_ENABLE_LINUX_ACCOUNTS.'
) from exc
def _chown_path_for_linux_user(path: Path, account_id: str, *, recursive: bool = False) -> None:
if os.name != 'posix':
return
try:
user_info = pwd.getpwnam(account_id)
except KeyError:
return
target = path.resolve(strict=False)
try:
os.chown(target, user_info.pw_uid, user_info.pw_gid)
except PermissionError:
return
except FileNotFoundError:
return
if not recursive or not target.is_dir():
return
for child in target.rglob('*'):
try:
os.chown(child, user_info.pw_uid, user_info.pw_gid)
except (PermissionError, FileNotFoundError):
continue
def _admin_token() -> str: def _admin_token() -> str:
return os.environ.get('ZK_ADMIN_TOKEN') or 'admin' return os.environ.get('ZK_ADMIN_TOKEN') or 'admin'
@@ -4686,6 +4827,8 @@ def _admin_create_account(state: AgentState, account_id: str) -> dict[str, Any]:
safe_id = _safe_account_id(account_id) safe_id = _safe_account_id(account_id)
if safe_id == 'default': if safe_id == 'default':
raise HTTPException(status_code=400, detail='账号名不合法') raise HTTPException(status_code=400, detail='账号名不合法')
if _linux_accounts_enabled():
_validate_linux_username(safe_id)
users = _load_users_file(state) users = _load_users_file(state)
user_rows = users.setdefault('users', []) user_rows = users.setdefault('users', [])
if any( if any(
@@ -4694,6 +4837,8 @@ def _admin_create_account(state: AgentState, account_id: str) -> dict[str, Any]:
for item in user_rows for item in user_rows
): ):
raise HTTPException(status_code=400, detail='账号已存在') raise HTTPException(status_code=400, detail='账号已存在')
if _linux_accounts_enabled():
_ensure_linux_account(safe_id, '123456')
user_rows.append( user_rows.append(
{ {
'id': safe_id, 'id': safe_id,
@@ -4737,8 +4882,18 @@ def _admin_delete_account(state: AgentState, account_id: str) -> dict[str, Any]:
encoding='utf-8', encoding='utf-8',
) )
base = _accounts_root(state) / safe_id base = _accounts_root(state) / safe_id
if _linux_accounts_enabled():
base = _linux_account_workspace(safe_id)
if base.exists(): if base.exists():
shutil.rmtree(base) shutil.rmtree(base)
if _linux_accounts_enabled():
subprocess.run(
['passwd', '-l', safe_id],
check=False,
capture_output=True,
text=True,
timeout=30,
)
state._clear_agents_for_account(safe_id) state._clear_agents_for_account(safe_id)
return {'account_id': safe_id, 'deleted': True} return {'account_id': safe_id, 'deleted': True}
@@ -0,0 +1,221 @@
# 01. 基座 Runtime 架构
![基座 Runtime 架构](assets/01-base-runtime.png)
## 1. 项目定位
ZK Data Agent 的定位不是通用聊天机器人,也不是单人本地 IDE Agent,而是面向团队业务流程的 Web Agent 工作台。
它基于已有的本地工程 Agent 能力继续往上搭:
- `LocalCodingAgent` 提供多轮 Agent Loop。
- OpenAI-compatible client 提供模型调用适配。
- Tool registry 和 handler 提供可控执行能力。
- Session workspace 提供每个会话独立的输入、中间文件和产物目录。
- Skill system 把流程协议、业务知识、脚本和样例打包成可复用能力。
- Memory worker 把用户偏好和 Skill 使用经验异步整理为可编辑记忆。
这个项目要解决的核心痛点是:团队里的数据开发、线上挖掘、标签判断等流程,往往散在口头经验、临时 prompt、个人脚本、聊天记录和本地文件里。每个人都能临时做一次,但很难让别人稳定复用、持续维护、形成可审计的产物链。
当前已经验证过的主要业务场景包括:
- `product-data`:从产品定义、标签边界、样例 query 或 badcase 出发,生成 canonical records,并导出流转 CSV、训练 JSONL、评测 CSV。
- `online-mining-v2`:基于 ELK 日志挖掘线上样本,保留 request_id、timestamp、session、模型 prompt、模型输出、domain 等信息,并接入后续数据规范。
- `label-master`:把复杂度、多指令、自动任务、标签定义、function 输出和边界经验整理为可检索知识体系。
- 外部系统 Skill:ELK、SQL、模型迭代、飞书在线文档转换等能力以 Skill 方式接入,不侵入基座。
因此,基座 Runtime 的目标不是把某一条业务链路写死,而是提供一套稳定的承载层,让不同业务能力都能以 Skill 的方式运行、交付、沉淀和更新。
## 2. 基座解决的问题
基座不绑定某一个业务流程。它提供的是通用 Agent runtime
- 多用户 Web 入口。
- 多会话状态管理。
- 模型选择和 OpenAI-compatible 调用。
- Tool registry 和 tool handler 执行。
- Skill 发现、启用和提示词注入。
- 当前会话工作区。
- 运行态、事件流、活动区和刷新恢复。
- 用户记忆和 Skill 记忆后台。
业务能力例如 `product-data``online-mining-v2``label-master` 都跑在这个基座上。
## 3. 文字架构图
```text
浏览器 / Web UI
|
| 用户消息、模型选择、Skill 启用、文件面板、停止运行
v
frontend/app
|
| /api/chat、/api/claw/*、/admin
v
backend/api/server.py
|
| 账号配置、会话目录、run manager、run_state_store、memory_manager
v
LocalCodingAgent
|
| build_session、prompt sections、tool_specs、Agent loop
v
OpenAICompatClient
|
| messages + tools -> model backend
v
模型
|
| assistant text 或 tool_calls
v
Tool Runtime
|
| read/write/python_exec/data_agent/MCP/search/bash
v
session workspace
|
| input / scratchpad / output / session.json
v
前端活动区和文件面板
运行结束后:
AgentRunResult
-> session_store 持久化
-> memory_manager.enqueue_interaction
-> memory worker 异步整理
-> 下一轮 render_injection 注入
```
## 4. 关键代码入口
### 4.1 Web 后端入口
主要文件:
```text
backend/api/server.py
```
关键职责:
- 维护账号和会话配置。
- 创建或复用 `LocalCodingAgent`
- 给 Agent 注入 `runtime_context`、记忆、Jupyter 上下文。
- 记录 run event,并通过 NDJSON streaming 返回前端。
- 运行结束后写入 elapsed、标题、memory event。
关键函数和位置:
```text
backend/api/server.py:691 enabled_skill_names(...)
backend/api/server.py:822 _build_agent(...)
backend/api/server.py:853 agent_for(...)
backend/api/server.py:866 run_lock_for(...)
backend/api/server.py:1720 /api/chat 运行链路开始
backend/api/server.py:1739 memory_manager.render_injection(...)
backend/api/server.py:1751 emit_agent_event(...)
backend/api/server.py:1897 序列化运行结果和 elapsed
backend/api/server.py:1920 memory_manager.enqueue_interaction(...)
backend/api/server.py:2034 StreamingResponse NDJSON
```
### 4.2 Agent Runtime
主要文件:
```text
src/agent_runtime.py
```
关键职责:
- 初始化 tool registry、plugin runtime、MCP runtime、search runtime 等。
- 新会话用 `run(...)`,旧会话用 `resume(...)`
-`_run_prompt(...)` 中执行完整 Agent loop。
- 维护 usage、cost、tool_calls、events、file_history。
- 在结束时持久化 session。
关键函数和位置:
```text
src/agent_runtime.py:158 class LocalCodingAgent
src/agent_runtime.py:195 __post_init__
src/agent_runtime.py:413 run(...)
src/agent_runtime.py:441 resume(...)
src/agent_runtime.py:520 _run_prompt 主链路
src/agent_runtime.py:552 tool_specs = [tool.to_openai_tool() ...]
src/agent_runtime.py:619 for turn_index in range(...)
src/agent_runtime.py:1008 遍历模型返回的 tool_calls
src/agent_runtime.py:1490 _query_model(...)
```
### 4.3 系统提示词
主要文件:
```text
src/agent_prompting.py
```
关键职责:
- 定义中控 Agent 身份。
- 注入工具使用策略。
- 注入工作空间边界。
- 注入 Skill 列表。
- 注入 ask_user 等 runtime 指导。
关键函数和位置:
```text
src/agent_prompting.py:135 get_intro_section
src/agent_prompting.py:155 get_doing_tasks_section
src/agent_prompting.py:182 get_actions_section
src/agent_prompting.py:196 get_workspace_boundary_section
src/agent_prompting.py:210 get_using_your_tools_section
src/agent_prompting.py:274 get_skill_guidance_section
src/agent_prompting.py:414 get_ask_user_guidance_section
```
## 5. 基座的分层职责
```text
Web UI
负责交互、展示、文件面板、活动区、Skill 勾选、管理后台。
Backend API
负责账号、会话、模型配置、运行态、服务端事件流。
Agent Runtime
负责 Agent loop、模型调用、工具调用、预算和持久化。
Prompting
负责把规则、工具、公约、Skill 列表转成模型可见上下文。
Tool Runtime
负责稳定执行动作,并把结果结构化回传给模型。
Skill System
负责让业务流程、知识、脚本可被 Agent 发现和使用。
Workspace
负责隔离每个用户和每个会话的输入、临时文件和交付产物。
Memory Worker
负责从交互历史中异步整理长期偏好和 Skill 使用经验。
```
## 6. 设计边界
基座只应该承载跨业务复用的稳定能力,例如:
- 工具执行。
- 会话状态。
- 运行态事件。
- 工作区路径。
- Skill 发现和启用。
- 模型适配。
- 记忆后台。
业务流程不应该写死在基座里。数据生成、线上挖掘、标签判断等变化快的流程应该沉到 Skill;确定性脚本应该放在对应 Skill 的 `scripts/` 下。
@@ -0,0 +1,246 @@
# 02. Agent Loop 执行机制
![Agent Loop 执行机制](assets/02-agent-loop.png)
## 1. Agent Loop 的基本形态
Agent 每轮不是只调用一次模型,而是一个循环:
```text
用户输入
-> 构造 session 和 prompt
-> 调模型
-> 模型返回 assistant text 或 tool_calls
-> 如果没有 tool_calls:输出最终回复,结束
-> 如果有 tool_calls:执行工具
-> 工具结果写回 session
-> 下一轮模型继续读取工具结果
-> 直到最终回复、预算超限、取消、max_turns 或 review 停止
```
这个循环允许 Agent 做“观察-执行-再观察”的任务,例如:
- 先读文件,再决定是否需要抽取。
- 先生成 plan,等待用户 review。
- 先运行脚本,再根据校验结果修复。
- 先查询线上数据,再抽样展示。
## 2. 新会话和恢复会话
新会话入口:
```text
src/agent_runtime.py:413 run(...)
```
关键动作:
```text
1. 清理当前 managed_agent_id 和 resume_source_session_id。
2. 创建 session_id。
3. 创建 scratchpad_directory。
4. 绑定 plan_runtime/task_runtime 到 scratchpad。
5. 调 _run_prompt(...)
6. 累计 usage。
```
恢复会话入口:
```text
src/agent_runtime.py:441 resume(...)
```
关键动作:
```text
1. 从 StoredAgentSession 恢复 AgentSessionState。
2. 回放 file_history 和 compaction 信息。
3. 设置 active_session_id 和 last_session_path。
4. 恢复 plugin state。
5. 复用已有 scratchpad_directory。
6. 调 _run_prompt(...)
```
这解释了为什么刷新页面、回到旧会话后,Agent 理论上可以继续同一个 session 的上下文,而不是新建一个任务。
## 3. `_run_prompt` 主链路
核心位置:
```text
src/agent_runtime.py:520 _run_prompt 主体
```
主链路关键步骤:
```text
1. slash command 预处理。
2. hook policy / plugin hook 修改 prompt。
3. agent_manager.start_agent(...) 记录运行。
4. 新建或复用 AgentSessionState。
5. runtime_context prepend 到模型可见的用户消息。
6. session.append_user(...)。
7. tool_context 注入 scratchpad、plan_runtime、task_runtime。
8. 生成 tool_specs。
9. 初始化 usage、cost、tool_calls、events。
10. 进入 turn loop。
```
对应代码点:
```text
src/agent_runtime.py:523 agent_manager.start_agent(...)
src/agent_runtime.py:531 session = base_session or build_session(...)
src/agent_runtime.py:539 _prepend_runtime_context(...)
src/agent_runtime.py:543 session.append_user(...)
src/agent_runtime.py:546 replace(self.tool_context, scratchpad_directory=...)
src/agent_runtime.py:552 tool_specs = [tool.to_openai_tool() ...]
src/agent_runtime.py:582 stream_events = _RuntimeEventBuffer(event_sink)
src/agent_runtime.py:619 for turn_index in range(...)
```
## 4. 模型调用和 tool_calls 判断
模型调用入口:
```text
src/agent_runtime.py:1490 _query_model(...)
```
非流式路径中:
```text
turn = self.client.complete(
session.to_openai_messages(),
tool_specs,
output_schema=...
)
```
模型能否返回 `tool_calls` 取决于:
- 当前 `messages`
- 系统提示词。
- Skill 列表。
- 工具描述和参数 schema。
- 模型自身 tool calling 能力。
基座不会强制某个工具被调用。基座只把工具能力暴露给模型,并在模型返回 `tool_calls` 后负责执行。
## 5. 没有 tool_calls 时
如果模型本轮没有工具调用:
```text
src/agent_runtime.py:763 if not turn.tool_calls
```
后续可能有三种情况:
1. 直接输出最终回复。
2. 如果模型输出被截断,自动追加 continuation prompt。
3. 如果达到 continuation 限制,则追加提示并结束。
最终会:
```text
session.append_assistant(...)
_append_final_text_stream_events(...)
AgentRunResult(...)
_persist_session(...)
```
## 6. 有 tool_calls 时
如果模型返回工具调用:
```text
src/agent_runtime.py:1008 for tool_call in turn.tool_calls
```
每个工具调用会:
```text
1. tool_calls 计数 +1。
2. 检查预算。
3. session.start_tool(...) 写入工具开始消息。
4. stream_events 追加 tool_start。
5. 根据工具名执行 handler。
6. 工具结果写回 session。
7. stream_events 追加工具结果。
8. 下一轮模型读取工具结果继续判断。
```
关键代码点:
```text
src/agent_runtime.py:1054 session.start_tool(...)
src/agent_runtime.py:1060 stream_events.append(type='tool_start')
src/agent_tools.py:60 execute_tool(...)
src/agent_tools.py:76 execute_tool_streaming(...)
```
## 7. 为什么 review 可以暂停流程
review 门禁不是特殊 UI 魔法,而是 Agent loop 的自然结果:
1. Skill 要求模型在某一步调用 review 工具。
2. 工具创建 pending state,并返回需要展示的信息。
3. Agent 生成回复,告诉用户需要确认。
4. 当前 run 结束。
5. 用户下一轮回复“确认”。
6. Agent resume 旧 session,调用 confirm 工具。
7. 后续流程继续。
例如 `product-data`
```text
data_agent_prepare_generation_goal
-> pending goal
-> 停止等待用户确认
data_agent_confirm_generation_goal
-> confirmed_goal_id
-> data_agent_prepare_generation_plan
-> pending plan
-> 停止等待用户确认
data_agent_confirm_generation_plan
-> confirmed_plan_id
-> 允许生成 draft 和转换 records
```
## 8. 预算、取消和 max_turns
基座会在模型调用前后、工具请求前检查预算:
```text
src/agent_runtime.py:592 initial_budget = self._check_budget(...)
src/agent_runtime.py:732 budget_after_model = self._check_budget(...)
src/agent_runtime.py:1014 budget_after_tool_request = self._check_budget(...)
```
如果达到最大轮次:
```text
src/agent_runtime.py:1440 _build_max_turns_output(...)
```
输出会包含最后一次阶段说明,提醒用户可以继续补充指令。
取消由后端 run manager 和 tool process registry 处理。Web 点停止后,后端将 run 标记取消,并让工具执行上下文感知 cancel_event。
## 9. 事件流和前端活动区
Agent loop 内会不断追加 `stream_events`,后端用 `event_sink` 把事件推给前端。
后端关键代码:
```text
backend/api/server.py:1751 emit_agent_event(event)
backend/api/server.py:1756 state.run_manager.record_event(...)
backend/api/server.py:1758 state.run_state_store.record_event(...)
backend/api/server.py:2034 StreamingResponse(..., media_type='application/x-ndjson')
```
前端活动区展示的阶段说明、工具开始、工具完成、最终文本,本质上都来自这些 runtime events 或 session replay。
+298
View File
@@ -0,0 +1,298 @@
# 03. 工具体系和 Tool Handler
![工具体系和 Tool Handler](assets/03-tools.png)
## 1. 工具体系的职责
Tool 是执行层。模型只能“请求调用工具”,不能直接执行工具。
后端根据工具名找到 handler,校验参数,执行动作,并把结果写回 session。
工具适合承载:
- 稳定文件读写。
- Python 执行。
- Python 包安装。
- 数据格式转换和校验。
- review 状态机。
- 外部系统访问。
- 需要权限、取消、路径约束或审计的动作。
## 2. Tool registry 构成
入口:
```text
src/agent_tools.py:105 default_tool_registry()
```
它会合并多类工具:
```text
build_file_tools(...)
build_execution_tools(...)
LSP / web_fetch / search / account / config / task / team / workflow 等
Skill 工具
data_agent 工具
```
关键片段:
```text
src/agent_tools.py:107 build_file_tools
src/agent_tools.py:116 build_execution_tools
src/agent_tools.py:1012 AgentTool(name='Skill')
src/agent_tools.py:1033 build_data_agent_tools
```
返回结果是:
```python
return {tool.name: tool for tool in tools}
```
## 3. Tool spec 注入模型
在 Agent loop 中:
```text
src/agent_runtime.py:552
tool_specs = [tool.to_openai_tool() for tool in self.tool_registry.values()]
```
每个工具包含:
```text
name
description
parameters JSON schema
handler
```
模型看到的是 name、description、parameters。handler 不暴露给模型,只在后端执行。
## 4. Tool handler 执行路径
工具执行入口:
```text
src/agent_tools.py:60 execute_tool(...)
src/agent_tools.py:76 execute_tool_streaming(...)
```
执行逻辑:
```text
1. 从 tool_registry 取 tool。
2. 找不到则返回 Unknown tool。
3. bash 走 streaming。
4. 其他工具调用 tool.execute(arguments, context)。
5. 工具结果序列化回模型和前端。
```
## 5. 文件工具
定义位置:
```text
src/agent_tool_specs/files.py
```
主要工具:
- `list_dir`
- `read_file`
- `write_file`
- `edit_file`
- `notebook_edit`
- `glob_search`
- `grep_search`
### 5.1 `write_file` 的边界
`write_file` 当前明确定位为“小文件工具”:
```text
src/agent_tool_specs/files.py
Write or append a SMALL UTF-8 file inside the workspace.
```
适合:
- 短 Markdown。
- 少量配置。
- 小 JSON。
- 少量 JSONL。
- 小 CSV。
不适合:
- 长 Python 脚本。
- 大 JSON。
- JSONL 数据集。
- 长 CSV。
- 大段包含引号和换行的内容。
原因是模型生成工具参数时,需要把内容嵌入 JSON 参数。长文本、引号、换行会显著增加非法 JSON 参数概率。
因此系统提示词也要求:
```text
复杂写文件优先用 python_exec 通过 pathlib/json/csv 写入。
```
## 6. Python 执行工具
定义位置:
```text
src/agent_tool_specs/execution.py
```
主要工具:
- `python_exec`
- `python_package`
- `bash`
- `sleep`
### 6.1 `python_exec`
定位:
```text
结构化文件分析、JSON/JSONL 处理、批量校验、数据抽样、快速计算。
```
支持两种模式:
```text
code
一次性 Python 代码。
script_path
调用项目或 Skill 内已有脚本。
```
重要约束:
- 不用 `bash python ...`
- 不在 `python_exec.code` 里用 subprocess 二次调用 Python 脚本。
- 临时文件写入 `PYTHON_EXEC_SCRATCHPAD`
- 交付产物写入 session/output。
### 6.2 `python_package`
定位:
```text
给当前用户独立 Python venv 安装缺失包。
```
典型场景:
- `pandas`
- `pyarrow`
- `openpyxl`
- `elasticsearch`
- `urllib3`
设计上避免安装到项目 `.venv` 或系统 Python。
### 6.3 `bash`
`bash` 是兜底工具,不是默认 Python 执行方式。
适合:
- git 只读检查。
- 系统命令。
- 进程控制。
- 用户确认后的依赖安装或服务管理。
不适合:
- Python 数据分析。
- JSON/CSV 转换。
- 包安装。
## 7. 路径解析和 session 路由
关键实现:
```text
src/agent_tools.py:1589 _data_agent_output_path(...)
src/agent_tools.py:1624 _data_agent_session_output_root(...)
src/agent_tools.py:1630 _resolve_path(...)
src/agent_tools.py:1658 _session_logical_path(...)
src/agent_tools.py:1685 _execution_cwd(...)
```
逻辑路径会被映射到当前 session:
```text
output/... -> session/output/...
outputs/... -> session/output/...
scratchpad/... -> session/scratchpad/...
scratch/... -> session/scratchpad/...
input/... -> session/input/...
inputs/... -> session/input/...
```
`python_exec` 的执行 cwd 优先是当前会话 scratchpad
```text
src/agent_tools.py:1685 _execution_cwd(...)
```
## 8. data_agent 工具
声明位置:
```text
src/agent_tool_specs/data_agent.py
```
handler 注册位置:
```text
src/agent_tools.py:1033 build_data_agent_tools(...)
```
主要工具分两类:
### 8.1 前链路和 review 状态机
- `data_agent_load_input_sources`
- `data_agent_render_source_context`
- `data_agent_extract_case_evidence`
- `data_agent_prepare_generation_goal`
- `data_agent_confirm_generation_goal`
- `data_agent_prepare_generation_plan`
- `data_agent_show_generation_plan`
- `data_agent_update_generation_plan`
- `data_agent_confirm_generation_plan`
这类工具当前仍属于平台工具,因为它们维护 pending/confirmed 状态,以及产品数据生成的 review 门禁。
### 8.2 历史格式转换工具
- `data_agent_normalize_dataset_draft`
- `data_agent_validate_dataset_records`
- `data_agent_export_dataset_records`
- `data_agent_export_training_jsonl`
- `data_agent_export_planning_eval_csv`
这部分能力已经逐步迁移到 `skills/product-data/scripts/`,平台工具更多是历史兼容和适配层。
## 9. 工具设计原则
当前工具体系的技术取舍:
```text
模型负责决策。
工具负责执行。
Skill 负责流程经验。
脚本负责可迁移确定性能力。
```
工具描述要足够明确,否则模型会选错工具;参数 schema 要尽量简单,否则不同模型后端可能不兼容。
+256
View File
@@ -0,0 +1,256 @@
# 04. Skill 体系和能力包约定
![Skill 体系和能力包约定](assets/04-skills.png)
## 1. Skill 的定位
Skill 是经验层。它不是单纯 prompt,也不是单纯脚本。
一个 Skill 应该回答:
- 什么场景触发。
- 输入材料是什么。
- Agent 应该按什么流程做。
- 哪些地方必须让用户 review。
- 应该调用哪些工具或脚本。
- 产物应该写到哪里。
- 哪些做法是禁止的。
稳定可执行逻辑不应该长期写在 Skill 文本里,而应该进入:
```text
skills/<skill-name>/scripts/
```
或者平台级工具。
## 2. Skill loader 实现
关键文件:
```text
src/bundled_skills.py
```
项目级 Skill 目录:
```text
skills/<skill-name>/SKILL.md
```
核心数据结构:
```text
src/bundled_skills.py:28 BundledSkill
```
字段:
```text
name
description
when_to_use
aliases
allowed_tools
user_invocable
source
path
get_prompt
```
## 3. SKILL.md 解析
解析逻辑:
```text
src/bundled_skills.py:147 _parse_front_matter
src/bundled_skills.py:176 _load_directory_skill
```
`SKILL.md` 必须有 frontmatter
```yaml
---
name: product-data
description: 从产品/标签定义、手写边界规则或示例 query 中提取标签边界...
when_to_use: 当用户提供产品定义、标签规则...
aliases: definition-data, label-data
allowed_tools: read_file, write_file, python_exec
---
```
解析后:
- frontmatter 进入 `BundledSkill` 元数据。
- body 作为真正的 Skill prompt。
- 如果调用 Skill 时带 args,会追加到 `## Invocation Arguments`
对应实现:
```text
src/bundled_skills.py:138 _directory_skill_prompt
```
## 4. Skill 发现顺序
项目 Skill 发现入口:
```text
src/bundled_skills.py:199 load_directory_skills
src/bundled_skills.py:215 load_project_skills
```
系统提示词中可见 Skill 列表由:
```text
src/bundled_skills.py:270 format_skills_for_system_prompt
```
生成。
Web 后端会按当前账号和 session 配置计算启用 Skill
```text
backend/api/server.py:691 enabled_skill_names
backend/api/server.py:706 set_skill_enabled
backend/api/server.py:738 set_all_skills_enabled
```
这意味着:
- Skill 可以存在于项目中,但不一定对某个 session 启用。
- 启用状态影响系统提示词里的 Skill 列表。
- 被禁用的 Skill 不应该被模型主动选择。
## 5. Skill 工具
Skill 本身也是一个工具:
```text
src/agent_tools.py:1012 AgentTool(name='Skill')
```
模型调用:
```json
{
"skill": "product-data",
"args": "用户原始需求或显式参数"
}
```
执行后,Skill body 会被加入对话,让模型按 Skill 中的流程继续做任务。
## 6. Skill 和 Agent Loop 的关系
Skill 不会替代 Agent loop,而是改变 Agent loop 的下一步决策依据。
典型模式:
```text
用户提出任务
-> 模型从 Skill 列表中选择某个 Skill
-> 调用 Skill 工具
-> Skill.md 正文进入上下文
-> 模型按 Skill 指令调用 read_file/python_exec/data_agent 等工具
-> 工具结果进入上下文
-> 模型继续按 Skill 流程推进
```
因此 Skill 的好坏直接影响:
- 模型能否召回正确能力。
- 是否会过早执行。
- 是否能在 review 门禁停下来。
- 是否能使用正确工具而不是手写不稳定逻辑。
## 7. 推荐 Skill 目录结构
```text
skills/<skill-name>/
SKILL.md
README.md
knowledge/
scripts/
examples/
schemas/
tools.yaml
requirements.txt
```
各部分职责:
```text
SKILL.md
运行时入口,写流程、门禁、工具调用方式和禁止事项。
README.md
给维护者看的说明,不一定进入模型上下文。
knowledge/
业务知识、标签规则、字段说明、边界案例。
scripts/
确定性脚本,优先通过 python_exec.script_path 执行。
examples/
示例输入输出,用于回归和讲解。
schemas/
JSON schema 或字段约定。
tools.yaml
描述 portable scripts 如何注册为工具,便于迁移到其他 Agent。
requirements.txt
Skill 脚本的 Python 依赖。
```
## 8. Skill 更新
Web 后端提供 Skill 更新能力:
```text
backend/api/server.py:765 sync_skills_from_git
```
核心行为:
```text
1. 检查当前目录是否是 git 仓库。
2. 检查 tracked 文件是否干净。
3. git fetch origin。
4. git pull --ff-only origin 当前分支。
5. 清理当前账号 agent cache。
6. 重新读取 get_bundled_skills。
```
这让新增或修改 Skill 后,不一定需要重启服务才能让 Skill 列表刷新。
## 9. Skill 设计边界
适合写在 Skill
- 工作流。
- 何时提问。
- 何时 review。
- 哪些工具优先。
- 输出位置约定。
- 常见失败经验。
不适合长期写在 Skill
- 大段可检索知识。
- 复杂代码。
- 格式转换。
- 查询外部系统的具体实现。
- 需要校验的稳定数据结构。
这些应该分别放到:
```text
knowledge/
scripts/
schemas/
platform tools
```
@@ -0,0 +1,243 @@
# 05. 会话工作区、运行态和记忆
![会话工作区、运行态和记忆](assets/05-workspace-memory-observability.png)
## 1. 会话工作区
每个用户、每个会话都有独立目录:
```text
.port_sessions/accounts/<account_id>/sessions/<session_id>/
input/
scratchpad/
output/
session.json
```
目录职责:
```text
input/
用户上传或明确提供的输入资料。
scratchpad/
临时脚本、中间文件、抽样缓存、断点记录。
output/
最终交付产物。
session.json
会话消息、工具调用、usage、events、file_history、runtime metadata。
```
## 2. 工作区路径路由
路径解析实现:
```text
src/agent_tools.py:1658 _session_logical_path
```
逻辑路径:
```text
output/... -> session/output/...
outputs/... -> session/output/...
scratchpad/... -> session/scratchpad/...
scratch/... -> session/scratchpad/...
input/... -> session/input/...
inputs/... -> session/input/...
```
Python 执行 cwd
```text
src/agent_tools.py:1685 _execution_cwd
```
优先使用当前 session scratchpad。这是为了避免临时脚本和缓存污染项目根目录。
## 3. 平台代码写保护
相关实现:
```text
src/agent_tools.py:1118 _PLATFORM_READONLY_DIRS
src/agent_tools.py:1698 _is_platform_code_path
src/agent_tools.py:1708 _ensure_not_platform_code_write
```
当前平台目录:
```text
src
backend
frontend
scripts
```
在数据 Agent 会话中默认视为只读。除非用户明确进入平台开发任务,否则业务任务不应修改平台代码。
## 4. Run 状态和活动区
后端在 `/api/chat` 运行时创建 run record
```text
backend/api/server.py:1720 run_record = state.run_manager.start(...)
backend/api/server.py:1722 state.run_state_store.start(...)
```
运行事件通过 `emit_agent_event` 记录:
```text
backend/api/server.py:1751 emit_agent_event
backend/api/server.py:1756 run_manager.record_event
backend/api/server.py:1758 run_state_store.record_event
```
返回前端:
```text
backend/api/server.py:2034 StreamingResponse
```
前端活动区看到的内容主要来自:
- `run_started`
- `tool_start`
- `tool_result`
- 模型阶段说明
- final text stream events
- run finish/error/cancel 状态
## 5. 会话持久化
运行结束后,后端序列化结果:
```text
backend/api/server.py:2056 _serialize_run_result
backend/api/server.py:2069 _normalize_transcript_entry
```
会话读取:
```text
backend/api/server.py:2094 _serialize_stored_session
```
`agent_runtime` 在多个结束路径都会调用:
```text
_persist_session(session, result)
```
这让刷新后可以恢复:
- 用户消息。
- assistant 文本。
- tool_calls。
- tool result。
- elapsed。
- file_history。
## 6. 记忆体系
实现位置:
```text
src/personal_memory.py
```
### 6.1 文件和数据库
```text
memory.db
user.md
skills/<skill-name>.md
```
常量:
```text
src/personal_memory.py:26 MEMORY_DB_FILENAME
src/personal_memory.py:27 USER_MEMORY_FILENAME
src/personal_memory.py:28 SKILL_MEMORY_DIRNAME
```
### 6.2 注入逻辑
```text
src/personal_memory.py:102 render_injection
```
注入规则:
```text
1. 读取 user.md。
2. 根据 enabled_skill_names 读取对应 skills/<skill>.md。
3. 拼成 # 个性化记忆。
4. 如果用户本轮要求冲突,以本轮要求为准。
```
后端调用:
```text
backend/api/server.py:1739 memory_manager.render_injection(...)
```
### 6.3 入队逻辑
运行结束后:
```text
backend/api/server.py:1920 memory_manager.enqueue_interaction(...)
```
记忆模块内:
```text
src/personal_memory.py:138 enqueue_interaction
src/personal_memory.py:635 detect_memory_signals
```
会检测:
- 显式记忆词:记住、以后、下次、默认、总是、不要、应该、固定。
- 纠错词:不对、不是这样、格式错、之前说过、还是不行。
- Skill/工具/流程/格式相关表述。
- 工具参数非法 JSON 等工具经验。
### 6.4 后台整理
核心逻辑:
```text
src/personal_memory.py:378 _consolidate_events
src/personal_memory.py:419 _generate_memory_updates
src/personal_memory.py:471 _fallback_memory_updates
```
设计取舍:
- 主链路不直接生成记忆。
- 事件先进入 SQLite 队列。
- 后台 worker 批量整理。
- LLM 失败时有 fallback 规则。
- Markdown 是最终可编辑记忆正文。
## 7. 可观测性设计
当前可观测性来自三个层次:
```text
运行态
run_manager + run_state_store,支持运行中刷新、停止、恢复活动区。
会话态
session.json,保存完整消息和工具调用。
产物态
session/input、scratchpad、output,文件面板可查看和下载。
```
这个设计让用户不仅看到最终回复,也能看到 Agent 做了什么、文件在哪里、失败在哪个工具或阶段。
@@ -0,0 +1,292 @@
# 06. product-data Skill 实现
![product-data Skill 实现](assets/06-product-data.png)
## 1. 定位
`product-data` 是数据开发链路的核心 Skill,负责:
```text
产品定义 / 标签规则 / 手写边界 / 示例 query / badcase
-> 输入文本化
-> generation goal
-> 用户 review
-> generation plan
-> 用户确认数量、标签、边界、路径
-> dataset draft text
-> canonical records
-> validate
-> export records / table / training / eval
```
位置:
```text
skills/product-data/SKILL.md
skills/product-data/knowledge/
skills/product-data/scripts/
```
## 2. Skill 目录结构
当前 `SKILL.md` 中定义的能力组织:
```text
skills/product-data/
SKILL.md
tools.yaml
requirements.txt
knowledge/
dataset_draft_v1.md
canonical_record_v1.md
portable_skill_contract.md
schemas/
scripts/
normalize_dataset_draft.py
validate_dataset_records.py
export_dataset_records.py
export_dataset_table.py
export_training_jsonl.py
export_planning_eval_csv.py
```
分工:
```text
SKILL.md
流程协议、门禁、工具调用顺序、禁止事项。
knowledge/
数据草稿、canonical record 和 portable skill contract。
scripts/
确定性转换、校验和导出。
data_agent_* 平台工具
负责输入文本化和 review 状态机。
```
## 3. 输入类型
Skill 将输入分成三类:
```text
文件定义型
产品定义、标签定义、路由规则、表格、Markdown、CSV。
手写规则型
用户直接描述边界,例如“找附近美食给餐饮服务,导航去某地给地图导航”。
示例归纳型
用户只给 query/example/badcase,需要先归纳边界和标签倾向。
```
三类输入最后统一整理成:
```text
dataset_label
target / target_definitions
plan_hint
coverage
exclusions
open_questions
source_refs
complex 规则
```
这一步称为 `generation_goal`
## 4. Review 门禁
`product-data` 有两个门禁模式。
### 4.1 一次确认模式
适用:
- 用户直接给出清晰手写规则。
- target 表达明确。
- 用户已经希望生成数据。
链路:
```text
data_agent_prepare_generation_plan(direct_review=true)
-> 展示目标 + 数量 + 路径
-> 等待“确认,开始生成”
```
### 4.2 两段确认模式
适用:
- 用户提供文件、表格、badcase、长文档。
- 标签、边界、字段有歧义。
- 需要先从资料中抽取 generation goal。
链路:
```text
data_agent_load_input_sources
-> data_agent_render_source_context
-> Agent 整理 generation_goal
-> data_agent_prepare_generation_goal
-> 用户确认目标
-> data_agent_confirm_generation_goal
-> data_agent_prepare_generation_plan
-> 用户确认计划
-> data_agent_confirm_generation_plan
```
## 5. 和 Agent Loop 的关系
`product-data` 明确利用 Agent loop 做分阶段控制。
```text
第一轮:
模型选择 product-data
调输入工具
调 prepare_generation_goal 或 prepare_generation_plan
输出 review 信息
停止
第二轮:
用户确认目标或计划
Agent resume session
调 confirm 工具
继续下一阶段
第三轮:
用户确认计划
Agent 生成 dataset draft
每批调用 normalize 脚本
调 validate 脚本
调 export 脚本
输出最终文件路径
```
重点是:确认状态不是靠自由文本记忆,而是由工具维护 `confirmed_goal_id``confirmed_plan_id`
## 6. 数据格式设计
### 6.1 dataset draft text
面向模型生成,要求模型用较低结构负担描述:
- 用户 / 小爱 对话。
- 当前 query。
- target。
- complex。
- 场景说明。
设计目标是降低模型直接写 JSONL 的难度。
### 6.2 canonical record
面向工具处理,字段稳定。
用于:
- 校验结构。
- 导出流转 CSV。
- 导出训练 JSONL。
- 导出评测 planningPrompt CSV。
### 6.3 complex 独立维度
`complex` 不属于 target。
内部保存:
```text
complex: false
target: Agent(tag="地图导航")
```
训练输出组合:
```text
complex=false
Agent(tag="地图导航")
```
评测 CSV 里:
```text
code标签: Agent(tag="地图导航")
complex: FALSE
```
## 7. 脚本链路
生成式数据确认后,Skill 要求使用 `python_exec.script_path` 调脚本。
典型顺序:
```text
normalize_dataset_draft.py
输入 draft + confirmed_plan_id
输出/追加 scratchpad/normalized_records.jsonl
validate_dataset_records.py
读取 normalized_records.jsonl
校验字段、target、complex、时间戳、多轮上下文
export_dataset_records.py
导出 output/records.jsonl
同时生成 output/records.csv
export_training_jsonl.py
导出 output/training.jsonl
export_planning_eval_csv.py
导出 output/eval_planning.csv
```
为什么不用 `write_file`
- records、CSV、JSONL 都是强格式数据。
- 模型手写容易出错。
- 脚本能统一 timestamp、prev_session、context、complex/target 组合。
## 8. 输出约束
固定逻辑输出:
```text
output/records.jsonl
output/records.csv
output/training.jsonl
output/eval_planning.csv
```
通过路径路由,实际写入:
```text
.port_sessions/accounts/<account>/sessions/<session>/output/
```
Skill 明确禁止:
- 按数据集名创建随机子目录。
- 把 records 写到项目根目录。
-`write_file` 手写最终 JSONL/CSV。
- 在未确认 plan 前生成数据。
## 9. 设计边界
`product-data` 负责:
- 数据目标对齐。
- 生成计划 review。
- dataset draft 生成协议。
- canonical records 转换和导出。
不负责:
- 判断所有标签知识。
- 查询线上数据。
- ELK 检索。
- 模型训练或 git 数据仓库提交。
标签知识应由 `label-master` 辅助,线上数据由 `online-mining-v2` 或相关 Skill 获取。
@@ -0,0 +1,177 @@
# 07. online-mining-v2 Skill 实现
![online-mining-v2 Skill 实现](assets/07-online-mining-v2.png)
## 1. 定位
`online-mining-v2` 用于从线上 ELK 日志中挖掘 case,并把候选样本转换成 `product-data` 兼容的标准数据。
典型链路:
```text
线上挖掘需求
-> 明确目标标签、complex、筛选特征
-> 探索 ELK 表字段
-> 搜索候选
-> 抽样 review
-> 调整策略
-> 直接转 canonical records
-> 或转 product-data 生成补数
```
位置:
```text
skills/online-mining-v2/SKILL.md
skills/online-mining-v2/knowledge/
skills/online-mining-v2/scripts/
```
## 2. 能力组织
`online-mining-v2` 不新增平台注册工具。它把能力放在 Skill 目录下,通过 `python_exec.script_path` 执行。
```text
scripts/
online_mining_common.py
elk_profile_index.py
elk_search_cases.py
elk_fetch_by_request_ids.py
elk_join_request_logs.py
build_dataset_draft.py
build_online_records.py
```
这样做的原因:
- ELK 查询逻辑属于该 Skill 的业务能力。
- 迁移到其他 Agent 时可以直接跑脚本。
- 平台只需要提供通用 `python_exec``python_package`
## 3. 默认数据源
当前重点支持两类索引:
```text
pre-processing*
前处理日志。
适合拿模型 prompt、模型输出、候选 domain、excellent_domains_result。
arch-flat-nlp-log-f-*
主 NLP 日志。
适合拿 query、domain、func、request_id、device_id、device、tts/text/to_speak。
```
Skill 文档中会引导 Agent
- 先用 `elk_profile_index.py` 看字段和样本。
- 再用 `elk_search_cases.py` 根据 query/domain/model output 搜索。
- 必要时用 request_id 做双表 join。
## 4. 和 Agent Loop 的关系
这个 Skill 的交互不是“一次查询结束”,而是策略迭代:
```text
用户描述需求
-> Agent 整理目标标签和筛选特征
-> 如缺目标标签或字段,先问用户
-> python_exec 调 elk_profile_index.py
-> 模型观察字段和样本
-> python_exec 调 elk_search_cases.py
-> 模型观察候选质量
-> 抽样展示给用户 review
-> 用户说哪里不对
-> 调整 filters / regex / domain / model output 条件
-> 再搜索
```
Agent loop 的价值在这里很明显:每次工具结果都会进入上下文,模型可以基于真实候选调整策略。
## 5. 两条分支
`online-mining-v2` 最重要的设计是强制区分两个分支。
### 5.1 直接线上样本分支
适用:
```text
用户想把线上筛出来的真实 case 作为评测集或专项集。
```
行为:
```text
不生成新 query。
不进入 product-data generation plan。
用 build_online_records.py 直接转 canonical records。
```
典型输出:
```text
output/records.jsonl
output/records.csv
```
### 5.2 补充生成分支
适用:
```text
用户想围绕线上问题扩写更多类似 case。
```
行为:
```text
先分析线上 badcase。
整理错误类型和覆盖目标。
再转 product-data 的 generation goal / plan / draft / records 流程。
```
这条边界避免了旧流程里出现的错误:用户只是想“把候选转样本”,Agent 却误走“生成新数据”。
## 6. 和 product-data 的关系
`online-mining-v2` 后处理复用 `product-data` 的标准:
```text
canonical record v1
records.jsonl
records.csv
training.jsonl
eval_planning.csv
```
复用脚本:
```text
skills/product-data/scripts/normalize_dataset_draft.py
skills/product-data/scripts/validate_dataset_records.py
skills/product-data/scripts/export_dataset_records.py
skills/product-data/scripts/export_training_jsonl.py
skills/product-data/scripts/export_planning_eval_csv.py
```
因此线上挖掘和产品定义生成最终可以进入同一套数据格式。
## 7. 设计边界
`online-mining-v2` 负责:
- ELK 字段探索。
- ELK 条件搜索。
- request_id 补全。
- 候选样本 review。
- 线上候选转 canonical records。
不负责:
- 定义 canonical record 标准。
- 生成全新补数。
- 判断复杂标签知识。
- 训练数据提交。
这些分别交给 `product-data``label-master` 或后续数据仓库 Skill。
@@ -0,0 +1,231 @@
# 08. label-master Skill 实现
![label-master Skill 实现](assets/08-label-master.png)
## 1. 定位
`label-master` 是标签知识和边界分析 Skill。
它不把“给 query 打标签”做成一个黑盒工具,而是让 Agent 逐步读取知识、比较候选、解释依据,并在必要时调用脚本校验最终输出格式。
典型链路:
```text
query / 标签边界问题 / target 校验需求
-> 读取决策流程
-> 读取候选召回索引
-> 找 2-5 个候选标签
-> 读取候选标签卡片
-> 命中混淆时读取边界卡
-> 判断 complex / 多指令 / 自动任务等结构维度
-> 判断输出形态
-> 给出推荐、候选、依据、排除项和不确定点
-> 如要落数据,调用 validate_label_output.py
```
位置:
```text
skills/label-master/SKILL.md
skills/label-master/knowledge/
skills/label-master/scripts/
```
## 2. 知识组织
核心目录:
```text
knowledge/
标签总览.md
决策流程.md
索引/
候选召回索引.md
标签索引.md
维度索引.md
label_manifest.json
判断维度/
复杂度/
多指令/
自动任务/
标注输出形态.md
输出能力/
标签/
边界/
边界索引.md
高频混淆/
领域概览/
迁移记录.md
```
设计原则:
```text
索引先行
不直接读全量标签卡。
维度分离
complex、多指令、自动任务不是业务标签。
标签卡片中文维护
方便人工编辑。
输出能力独立
function、intent、object、Agent 包装放在输出能力层。
边界卡优先人工维护
高频混淆写清楚,不只依赖自动迁移总结。
```
## 3. Agent 使用方式
`label-master` 的关键在于利用 Agent 的多轮阅读和规划能力。
典型 Agent loop
```text
模型选择 label-master
-> read_file knowledge/决策流程.md
-> read_file knowledge/索引/候选召回索引.md
-> read_file 候选标签卡片
-> read_file 高频混淆边界卡
-> 必要时 read_file 判断维度/复杂度/*
-> 必要时 read_file 输出能力/*
-> 输出可 review 判断
```
为什么不做成单个 `classify_query(query) -> label` 工具:
- 标签判断常常需要比较候选和排除项。
- function / intent / Agent 包装要看迁移状态。
- complex、多指令、自动任务是独立维度。
- 人类 review 需要看到依据,而不是只看到最终标签。
因此脚本只做索引和校验,不替代语义判断。
## 4. 输出形态
标签知识中存在多种输出形态:
```text
Agent(tag="xxx")
function program
intent
object + function 组合
多指令 JSON
自动任务 JSON
complex=true/false
```
`complex` 是独立维度,不应该混在 target 里。
例如训练输出可以组合成:
```text
complex=false
Agent(tag="地图导航")
```
但内部判断要拆成:
```text
complex: false
target: Agent(tag="地图导航")
```
## 5. 脚本能力
### 5.1 build_label_manifest.py
用途:
```text
把 Markdown 知识生成机器索引。
```
位置:
```text
skills/label-master/scripts/build_label_manifest.py
```
输出:
```text
skills/label-master/knowledge/索引/label_manifest.json
```
使用场景:
- 新增标签卡。
- 修改输出能力。
- 修改判断维度。
- 修改边界知识。
### 5.2 validate_label_output.py
用途:
```text
校验 target/function/intent/Agent/多指令/自动任务输出格式。
```
位置:
```text
skills/label-master/scripts/validate_label_output.py
```
典型调用:
```text
python_exec(script_path="skills/label-master/scripts/validate_label_output.py", args=["--target", "Agent(tag=\"地图导航\")"])
```
批量校验:
```text
--file output/records.jsonl --field target
```
## 6. 和 product-data 的关系
`product-data` 在需要确认 target 或生成边界数据时,可以读取 `label-master` 的知识。
分工:
```text
label-master
判断标签知识、边界、输出形态、target 合法性。
product-data
做数据生成 review、draft、canonical records、导出。
```
当用户给出模糊标签名时,应该先用 `label-master` 辅助确认:
```text
标签是否存在
使用 Agent 包装还是 function
complex 默认是什么
是否有高频混淆边界
```
再进入 `product-data` 的生成计划。
## 7. 设计边界
`label-master` 负责:
- 标签知识检索。
- 候选标签比较。
- 边界解释。
- 输出形态判断。
- target 格式校验。
不负责:
- 生成数据集。
- 线上日志挖掘。
- 导出训练/评测格式。
- 直接替用户确认争议边界。
@@ -0,0 +1,186 @@
# 09. 外部系统 SkillELK、SQL、模型迭代
![外部系统 Skill 接入模式](assets/09-external-skills.png)
## 1. 这类 Skill 的共同特征
外部系统 Skill 的核心不是复杂 prompt,而是把某个内部系统的调用方式、参数约定、依赖和输出格式打包起来。
典型形态:
```text
SKILL.md
写清楚什么时候用、参数怎么选、哪些动作需要确认。
scripts/
执行真实外部系统调用。
python_exec
作为统一执行入口。
python_package
处理依赖缺失。
output/
查询结果或报表写入当前 session output。
```
## 2. elk-fetch
位置:
```text
skills/elk-fetch/SKILL.md
skills/elk-fetch/elk_query.py
```
定位:
```text
按 request id 或查询条件读取小米内网 ELK 日志。
```
实现方式:
- `SKILL.md` 写明支持的 profile 和查询方式。
- 实际执行必须使用 `python_exec.script_path`
-`elasticsearch``urllib3` 时用 `python_package`
- 不让模型用 bash 手写临时 Python 查询脚本。
典型价值:
```text
把“怎么查 ELK”这类个人经验变成团队共享能力。
```
`online-mining-v2` 的区别:
```text
elk-fetch
更偏单次日志查询和调试。
online-mining-v2
更偏批量挖掘、策略迭代、样本转换。
```
## 3. data-factory-sql
位置:
```text
skills/data-factory-sql/SKILL.md
skills/data-factory-sql/run_sql.py
```
定位:
```text
通过 Kyuubi HTTP API 执行数据工场 SQL,轮询状态并下载 CSV 结果。
```
实现方式:
- 用户直接给 SQL 时,可以确认后执行。
- 用户只给分析需求时,Agent 可以先生成 SQL 草稿,再让用户确认。
- 执行必须通过 `python_exec.script_path`
- 输出默认写入当前 session output。
这个 Skill 的门禁重点是:
```text
SQL 执行前确认。
控制查询范围和 limit。
输出路径清晰。
失败时展示错误和可调整建议。
```
## 4. model-iteration
位置:
```text
skills/model-iteration/SKILL.md
skills/model-iteration/scripts/
```
定位:
```text
模型训练、评估、数据准备和迭代流程辅助。
```
这类 Skill 属于混合工程型:
- 有流程。
- 有脚本。
- 有配置。
- 可能调用外部训练平台。
- 高风险动作较多。
因此它更需要明确:
```text
哪些步骤只是分析。
哪些步骤会启动训练。
哪些步骤需要用户确认。
输出目录和实验记录在哪里。
```
## 5. 外部 Skill 的通用约定
### 5.1 调用方式
优先:
```json
{
"script_path": "skills/<skill-name>/scripts/run.py",
"stdin": "{...}",
"timeout_seconds": 120
}
```
或者脚本在 Skill 根目录时:
```json
{
"script_path": "skills/elk-fetch/elk_query.py",
"args": ["..."]
}
```
### 5.2 依赖处理
依赖缺失时:
```text
python_package(action="install", packages=[...])
```
不要:
```text
bash pip install ...
bash python ...
```
### 5.3 输出处理
外部系统查询结果应该:
- stdout 给结构化摘要。
- 大结果写入 `output/``scratchpad/`
- 返回实际文件路径。
- 避免把大量数据直接塞进最终回复。
### 5.4 安全和确认
需要确认的动作:
- 执行大范围 SQL。
- 启动训练。
- 写外部路径。
- 推送代码或数据仓库。
- 导出可能包含敏感字段的线上数据。
只读、小范围、用户明确指定 rid 或 SQL 的查询,可以直接执行,但仍要控制结果规模。
@@ -0,0 +1,478 @@
# 10. Agent 记忆机制调研与 ZK Data Agent 对比
本文整理主流 Agent / AI 产品的记忆实现方式,并对照 ZK Data Agent 当前实现。目标不是判断哪一种“最好”,而是说明不同记忆机制分别解决什么问题,以及为什么我们当前选择“用户记忆 + Skill 使用记忆 + 异步整理队列 + Markdown 可编辑文件”的路线。
调研时间:2026-05-19。
## 1. 结论摘要
主流记忆实现大致分为六类:
| 类型 | 代表 | 核心做法 | 适合场景 |
|------|------|----------|----------|
| 产品级长期记忆 | ChatGPT Memory | 平台自动保存用户偏好和事实,并在后续对话中注入 | 通用个人助手 |
| 会话状态记忆 | OpenAI Agents SDK Sessions、AutoGen Memory | 自动保存历史消息或把外部记忆注入上下文 | 线程连续对话 |
| 文件化项目记忆 | Claude Code `CLAUDE.md`、Claw 基座 memory files | 通过项目/用户级 Markdown 文件向 Agent 注入稳定规则 | 工程项目、团队约定 |
| 图/向量检索记忆 | LangGraph Store、Mem0、Zep/Graphiti | 抽取事实,存入向量库或知识图谱,按语义检索 | 长期、跨会话、海量事实 |
| Agent 自主管理记忆 | Letta / MemGPT | Agent 有显式 memory blocks 和 archival memory,可读写管理 | 状态型 Agent、长期角色 |
| 框架内置任务记忆 | CrewAI | 短期、长期、实体、上下文记忆组合 | 多 Agent 任务协作 |
ZK Data Agent 当前更接近:
```text
文件化项目记忆
+ 产品级用户记忆
+ Skill 作用域记忆
+ 异步记忆整理队列
```
它没有优先做向量库或知识图谱,而是选择 Markdown 文件作为最终记忆正文。这个取舍适合当前团队场景:记忆内容需要能被用户看到、编辑、删除,并且要按 Skill 作用域精准注入。
## 2. 主流实现机制
### 2.1 ChatGPT Memory:产品级个人长期记忆
ChatGPT Memory 的核心是平台级用户记忆。它会保存用户偏好、事实和历史对话中有持续价值的信息,并在后续对话中使用。用户可以查看、管理、删除保存的记忆,也可以关闭相关能力。
机制特点:
- 记忆作用域是用户账号。
- 由产品后台判断哪些内容值得保存。
- 注入方式对用户透明,用户看到的是“助手更了解我”。
- 适合通用个人助手,不适合表达复杂业务流程结构。
和我们的关系:
ZK Data Agent 的“用户记忆”借鉴了这个方向,但没有把全部记忆做成黑盒。我们把最终正文落到 `user.md`,并在 UI 里允许用户编辑。
### 2.2 OpenAI Agents SDK Sessions:会话状态记忆
OpenAI Agents SDK 的 Sessions 主要解决“同一个会话线程里自动保留历史上下文”。开发者不需要每轮手动传入完整历史,Session 会保存对话项,并在下一轮运行时自动带上。
机制特点:
- 更偏 conversation state,而不是长期个人偏好。
- 适合多轮会话连续执行。
- 常见实现是 SQLite / SQLAlchemy / 自定义 session backend。
- 记忆对象主要是消息历史,不是抽象后的长期知识。
和我们的关系:
ZK Data Agent 也有 session 持久化,但我们把它和“长期记忆”分开:
```text
session.json
保存当前会话消息、工具调用、产物和运行事件。
memory/user.md、memory/skills/*.md
保存跨会话长期偏好和 Skill 使用经验。
```
这个区分很重要:会话历史服务“恢复当前任务”,长期记忆服务“下次任务更懂用户和业务”。
### 2.3 Claude Code / OpenClaw / Claw:文件化项目记忆
Claude Code 使用 `CLAUDE.md` 作为项目或用户级记忆文件,常用于保存仓库规则、构建命令、代码风格、项目约定等。OpenClaw / Claw 类 Code Agent 基座通常也会保留这条路线:从全局或工作目录发现记忆文件,并把内容注入上下文。
在当前仓库里,对应实现主要是:
```text
src/agent_context.py
src/session_memory_compact.py
```
其中 `agent_context.py` 负责发现全局和目录级 memory files`session_memory_compact.py` 负责会话压缩场景下的 session memory 摘要。
机制特点:
- 记忆是文本文件,天然可读、可版本化。
- 非常适合工程项目规则和团队约定。
- 注入通常按目录/项目作用域进行。
- 记忆更新更多依赖人工维护,而不是完全自动。
和我们的关系:
ZK Data Agent 继承了“文件化、可编辑、可解释”的优点,但把作用域进一步细分:
```text
user.md
用户级偏好和稳定习惯。
skills/<skill-name>.md
某个 Skill 的使用经验、踩坑、格式偏好和边界修正。
```
也就是说,我们不是只有“项目记忆”,而是增加了“Skill 记忆”这一层。
### 2.4 LangGraph:线程状态 + 长期 Memory Store
LangGraph 把 memory 分成 short-term memory 和 long-term memory。短期记忆通常跟 thread 绑定,用来维持一次会话;长期记忆通过 store 按 namespace 保存,可以跨 thread 召回。它还把长期记忆进一步拆成 semantic、episodic、procedural 等类型。
机制特点:
- thread state 解决会话内上下文。
- store 解决跨会话长期信息。
- 支持按 user id / namespace 组织记忆。
- 长期记忆可以由应用逻辑或 Agent 写入、搜索、更新。
和我们的关系:
ZK Data Agent 当前没有引入通用 Store / VectorStore,而是用文件系统和 SQLite 队列实现一个轻量版本:
```text
namespace = account_id + memory kind + skill_name
storage = Markdown files + SQLite queue
retrieval = user memory always considered, skill memory按启用 Skill 精准注入
```
这比 LangGraph Store 简单,但更直接服务我们当前的 Skill 工作台。
### 2.5 Mem0:独立记忆层
Mem0 更像一个独立 memory layer。典型链路是:从对话中抽取事实,存入记忆系统;后续根据 query 检索相关记忆,再注入给模型。它强调 add / search / update / delete 这类记忆 API,也支持面向用户、Agent、session 等维度组织。
机制特点:
- 记忆层和 Agent 框架解耦。
- 常见存储后端是向量、图或混合检索。
- 强调自动抽取、去重、更新和语义召回。
- 适合大规模个性化 Agent 或跨应用记忆服务。
和我们的关系:
ZK Data Agent 目前没有把记忆做成独立检索服务。原因是我们的高频需求不是“从海量事实里语义搜索”,而是“把少量稳定经验准确注入到对应 Skill”。如果未来 Skill 记忆膨胀,可以在 Markdown 之外增加 Mem0 类似的检索层。
### 2.6 Letta / MemGPTAgent 自主管理内存
Letta 延续 MemGPT 思路,把 Agent 看成有长期状态的主体。它通常区分 core memory 和 archival memorycore memory 是短小、常驻上下文的重要信息;archival memory 是更大的外部记忆空间,Agent 可以通过工具读写。
机制特点:
- Agent 可以主动管理自己的记忆。
- core memory 常驻,archival memory 需要检索。
- 适合长期角色 Agent、个人助理、需要自我状态连续性的 Agent。
- 复杂度更高,需要更强的记忆写入约束和审计。
和我们的关系:
ZK Data Agent 没有让主 Agent 在执行链路里自由修改记忆。我们把记忆写入放到后台 worker,避免主任务因为记忆整理变慢或出错。这是一个更保守的团队平台取舍。
### 2.7 Zep / Graphiti:时间感知知识图谱记忆
Zep / Graphiti 代表的是 temporal knowledge graph 路线:从对话或事件中抽取实体和关系,形成带时间属性的知识图谱。它解决的问题不是简单偏好记忆,而是“事实如何随时间变化”“实体关系如何演进”。
机制特点:
- 记忆结构是实体、关系、事件、时间。
- 适合复杂事实网络和时间演化。
- 检索结果可以包含关系路径和上下文。
- 实现成本和运维复杂度高于 Markdown 或向量检索。
和我们的关系:
标签边界、业务规则、Skill 使用经验目前更适合文本化规则,不一定需要图谱。但如果未来要做“用户、Skill、数据集、标签、错误类型、修复策略”之间的关系分析,图谱路线会有价值。
### 2.8 CrewAI:多 Agent 任务记忆
CrewAI 的记忆体系主要服务多 Agent 协作,通常包含 short-term memory、long-term memory、entity memory 和 contextual memory。它关注的是任务过程中多个 Agent 如何共享上下文和持续改进。
机制特点:
- 和 Crew / Agent / Task 结构绑定。
- 强调任务协作过程中的上下文复用。
- 对实体、任务经验有独立组织方式。
和我们的关系:
ZK Data Agent 当前不是多 Agent 编排优先,而是单个工作台 Agent + Skill 能力包优先。Skill 记忆在某种程度上承担了“任务经验记忆”的角色。
### 2.9 AutoGenMemory 组件注入上下文
AutoGen 的 AgentChat 提供 Memory 抽象,可以把 list memory、vector memory 等组件挂到 AssistantAgent 上。运行时 Memory 会根据消息更新上下文,或把检索结果添加到模型输入。
机制特点:
- Memory 是 Agent 可插拔组件。
- 可以使用简单列表,也可以接向量检索。
- 更偏框架扩展点,而不是产品级记忆管理 UI。
和我们的关系:
ZK Data Agent 的记忆也可以理解为一个可插拔上下文组件,但我们额外做了用户 UI、Skill 作用域和后台队列。
## 3. ZK Data Agent 当前实现
实现入口:
```text
src/personal_memory.py
backend/api/server.py
frontend/app/components/assistant-ui/threadlist-sidebar.tsx
```
### 3.1 存储结构
每个账号有独立记忆目录:
```text
.port_sessions/accounts/<account_id>/memory/
user.md
skills/
<skill-name>.md
memory.db
```
其中:
- `user.md`:用户级长期记忆。
- `skills/<skill>.md`:某个 Skill 的使用记忆。
- `memory.db`:事件队列、状态和 revision 账本。
### 3.2 注入逻辑
模型调用前,后端调用:
```text
memory_manager.render_injection(account_id, enabled_skill_names)
```
注入规则:
```text
用户记忆
账号级,作为长期偏好注入。
Skill 使用记忆
只读取当前启用 Skill 对应的 skills/<skill>.md。
冲突优先级
用户本轮明确要求 > 个性化记忆。
```
这避免了一个常见问题:所有记忆都无差别注入导致上下文污染。
### 3.3 生成时机
每次交互结束后,后端调用:
```text
memory_manager.enqueue_interaction(...)
```
系统不会每轮同步整理记忆,而是先检测信号:
```text
显式记忆词:
记住、以后、下次、默认、总是、不要、应该、固定
纠错词:
不对、不是这样、格式错、之前说过、还是不行
Skill 经验:
skill、工具、流程、格式
工具经验:
模型返回的工具参数不是合法 JSON
```
命中后写入 SQLite pending 队列。显式记忆优先级更高。
### 3.4 异步整理
后台 worker 每 5 秒扫描账号事件,每次最多处理 8 条 pending event
```text
pending -> processing -> done / failed
```
整理方式:
1. 读取已有 `user.md` 和相关 `skills/<skill>.md`
2. 把一批事件交给模型做“整理式合并”。
3. 模型必须输出 JSON
```json
{
"user_memory": "完整 Markdown 或空字符串",
"skill_memories": {
"skill-name": "完整 Markdown"
}
}
```
4. 如果模型输出不可解析,则走 fallback 规则。
5. 写入 Markdown 文件,并更新 revision。
### 3.5 用户可编辑
前端左下角“记忆”入口支持:
- 查看用户记忆行数。
- 查看 Skill 记忆列表。
- 编辑用户记忆。
- 编辑某个 Skill 记忆。
- 查看记忆队列状态。
管理后台只看队列、用量等统计,不展示其他用户具体记忆内容。
## 4. 对比表
| 维度 | ChatGPT | Claude Code / OpenClaw | LangGraph / Mem0 / Zep | Letta | ZK Data Agent |
|------|---------|--------------------|-------------------------|-------|---------------|
| 主要目标 | 个人助手个性化 | 项目规则注入 | 长期检索记忆 | 状态型长期 Agent | 团队 Skill 工作台 |
| 记忆粒度 | 用户 | 用户/项目/目录 | 用户/线程/实体/namespace | Agent memory block | 用户 + Skill |
| 存储形态 | 平台内部 | Markdown 文件 | Store / 向量 / 图 | Core + archival memory | Markdown + SQLite queue |
| 生成时机 | 产品后台自动 | 多为人工维护 | 自动抽取 / API 写入 | Agent 主动管理 | 交互结束后异步整理 |
| 检索方式 | 平台决定 | 直接注入文件 | 语义搜索 / 图检索 | Core 常驻 + archival 检索 | 用户记忆 + 当前 Skill 记忆注入 |
| 可编辑性 | 用户可管理 | 文件可编辑 | 取决于产品/API | 通常需要工具/API | UI 可编辑 Markdown |
| 适合业务流程沉淀 | 中 | 中 | 高,但工程复杂 | 高,但复杂 | 高,且轻量 |
| 风险 | 黑盒、难按业务作用域隔离 | 容易依赖人工维护 | 检索和更新复杂 | 主链路复杂度高 | 暂无语义召回和图谱能力 |
## 5. 为什么当前方案适合我们
### 5.1 我们需要的是 Skill 使用经验,而不只是用户偏好
通用记忆多关注“用户是谁、用户喜欢什么”。我们的高频需求更像:
```text
product-data 生成数据时,用户偏好什么确认流程?
标签大师判断时,哪些边界经常被纠正?
online-mining-v2 查询线上日志时,哪些字段和表更稳定?
某个 Skill 写文件时,模型容易踩什么坑?
```
这些经验天然和 Skill 绑定。因此 `skills/<skill>.md` 比单一用户记忆更准确。
### 5.2 我们需要可审计、可编辑,而不是完全黑盒
团队平台里,记忆不能只存在模型或向量库内部。用户需要能看到:
- 记住了什么。
- 为什么下一次会注入。
- 哪里可以手动修改。
- 哪些记忆是用户级,哪些是 Skill 级。
Markdown 文件在这点上比纯向量库更直接。
### 5.3 主链路不能被记忆整理拖慢
数据生成、线上挖掘、标签判断本身就是长任务。记忆整理如果同步放在主链路里,会增加延迟和失败面。
当前设计是:
```text
主链路:只读取已有记忆 + 入队事件
后台:异步整理、合并、失败重试/记录
```
这和团队生产工具的稳定性要求更匹配。
### 5.4 Skill 作用域注入能降低上下文污染
如果所有记忆每次都注入,模型会被无关偏好干扰。当前只注入启用 Skill 的记忆:
```text
启用 product-data -> 注入 product-data 使用记忆
启用 label-master -> 注入 label-master 使用记忆
未启用某 Skill -> 不注入该 Skill 记忆
```
这使记忆更像“能力使用手册的增量补丁”,而不是一坨全局上下文。
## 6. 当前不足和后续方向
### 6.1 缺少语义召回
当前 Skill 记忆是按 Skill 文件整体注入,不做向量检索。如果某个 Skill 记忆变得很长,可能需要:
- 按章节拆分。
- 引入轻量 embedding 检索。
- 只注入和当前 query 相关的片段。
### 6.2 缺少结构化 schema
Markdown 易编辑,但不方便做强约束。后续可以让 Skill 记忆同时存在:
```text
human.md
structured.json
```
其中 Markdown 给人看,JSON 给程序做筛选和校验。
### 6.3 缺少记忆质量评估
目前能看到队列状态,但还没有系统评估:
- 哪些记忆被注入。
- 注入后是否减少纠错。
- 哪些记忆过期。
- 哪些 Skill 记忆导致误导。
后续可以把 memory revision 与 session outcome 关联起来。
### 6.4 缺少跨用户团队记忆
当前是账号级记忆。团队共性经验仍主要沉淀在 Skill 本体里。未来可以区分:
```text
个人 Skill 记忆
某个用户自己的偏好和使用习惯。
团队 Skill 记忆
多人使用后沉淀的稳定经验,经 review 后合入 Skill。
```
这样可以形成从“个人经验”到“团队 Skill 知识”的晋升路径。
## 7. 建议的技术路线
短期保持当前架构:
```text
Markdown 可编辑记忆
SQLite 异步队列
按 Skill 注入
UI 可查看可修改
后台可观测队列
```
中期增强:
```text
记忆片段化
记忆注入日志
过期/冲突检测
记忆质量指标
```
长期可选:
```text
向量检索:解决 Skill 记忆膨胀后的相关片段召回。
图谱记忆:解决用户、Skill、标签、数据集、错误类型之间的关系分析。
团队记忆晋升:把多用户共性 Skill 经验 review 后写回 Git Skill。
```
## 8. 资料来源
- OpenAI HelpChatGPT Memory FAQ
https://help.openai.com/en/articles/8590148-memory-faq
- OpenAI Agents SDKSessions
https://openai.github.io/openai-agents-python/sessions/
- Anthropic Claude CodeMemory
https://docs.anthropic.com/en/docs/claude-code/memory
- LangChain / LangGraphMemory concepts
https://docs.langchain.com/oss/python/concepts/memory
- Mem0 documentation
https://docs.mem0.ai/
- Letta documentation
https://docs.letta.com/
- Zep / Graphiti documentation
https://help.getzep.com/
- CrewAI Memory concepts
https://docs.crewai.com/concepts/memory
- Microsoft AutoGen AgentChat Memory
https://microsoft.github.io/autogen/dev/user-guide/agentchat-user-guide/memory.html
- ZK Data Agent 当前实现
`src/personal_memory.py``docs/technical-architecture/05-workspace-memory-observability.md`
@@ -0,0 +1,663 @@
# Workspace Runtime 设计稿
## 背景
当前项目已经有了平台账号、会话目录、Jupyter 远程工作区、Skill/Tools 和文件产物管理,但这些能力还没有被一个统一的“执行环境”概念串起来。
现在的问题不是单纯缺少登录账号,而是需要回答:
```text
谁在使用 Agent
-> 当前会话绑定到哪个工作区
-> 工具以什么身份、在什么目录、用什么权限执行
-> 产物在哪里保存、展示和下载
```
因此,账户体系升级不应该只看账号密码,而应该引入 `Workspace Runtime` 作为平台账号和工具执行之间的核心抽象。
## 核心结论
账户体系分两层:
```text
平台账号 Account
负责登录、角色、会话、Skill 配置、模型配置、记忆和集成状态。
工作区运行时 Workspace Runtime
负责执行身份、工作目录、文件读写、Python 环境、远程连接和进程管理。
```
平台账号不直接等价于 Linux 账号,也不直接等价于 Jupyter 账号。平台账号可以绑定不同类型的 runtime。
## 目标
1. 统一本机工作区、Linux 子账户工作区、Jupyter 工作区和未来 SSH 工作区。
2. 让 Tool handler 不关心执行位置,只面向统一 runtime 执行。
3. 明确权限来源,避免把远程工作区误认为平台托管沙盒。
4. 让每个 session 的输入、输出、scratchpad、Python 环境和文件下载有稳定归属。
5. 为后续多用户、资源限制、审计、团队空间和远程执行打基础。
## 非目标
1. 不在第一阶段实现完整企业 SSO。
2. 不把所有账号体系直接迁移到 Linux PAM。
3. 不强制所有远程工作区都变成平台托管沙盒。
4. 不要求 Skill 感知 runtime 的具体实现细节。
## 对象模型
### Account
平台账号是 Web 产品层的身份。
```text
Account
id
username
display_name
role
status
created_at
updated_at
```
职责:
- 登录和会话 token。
- 模型选择。
- Skill 启用状态。
- 用户记忆。
- 第三方集成状态。
- 默认 workspace runtime 策略。
### Session
Session 是一次 Agent 对话任务。
```text
Session
id
account_id
runtime_id
title
status
created_at
updated_at
```
职责:
- 保存对话历史。
- 绑定一个 runtime。
- 保存工具调用、活动步骤和最终结果。
- 关联输入文件和输出 artifact。
Session 一旦绑定远程 runtime,刷新页面后也应该恢复到同一个 runtime。
### Workspace Runtime
Workspace Runtime 是工具执行的真实环境。
```text
WorkspaceRuntime
id
account_id
session_id
type
root
permissions_source
status
created_at
updated_at
```
`type` 可以是:
```text
local_process
local_linux_user
remote_jupyter
remote_ssh
```
职责:
- 决定 bash/python/file 工具在哪里执行。
- 决定输入输出文件在哪里。
- 决定 Python 环境在哪里。
- 决定进程如何启动、停止和清理。
- 决定文件如何展示、下载和转在线文档。
### Artifact
Artifact 是输入和输出文件的统一抽象。
```text
Artifact
id
account_id
session_id
runtime_id
kind: input | output | scratchpad
uri
name
size
mime
created_at
```
`uri` 可以是:
```text
file:///home/<account_id>/zk-agent/sessions/<session_id>/output/a.jsonl
jupyter://<session_id>/root/zk_agent_workspaces/<session_id>/output/a.jsonl
ssh://<runtime_id>/home/user/zk_agent_workspaces/<session_id>/output/a.jsonl
```
文件列表只需要展示 metadata。下载或转在线文档时,再通过 runtime 拉取内容。
### Executor
Executor 是 Tool handler 和 Runtime 之间的执行适配层。
```text
Executor
run_bash(command, cwd, timeout)
run_python(code_or_file, cwd, timeout)
read_file(path)
write_file(path, content)
list_files(path)
open_file_stream(path)
cancel(run_id)
```
Tool handler 不应该自己判断是在本地、Jupyter 还是 SSH。它只调用当前 session 的 executor。
## Runtime 类型
### local_process
当前已有的默认模式。工具在服务进程所在机器上执行,目录由平台约定。
```text
.port_sessions/accounts/<account_id>/sessions/<session_id>/
```
适合:
- 本地开发。
- 单用户调试。
- 早期兼容。
问题:
- 多用户隔离主要靠代码路径约束。
- 工具进程和平台服务权限一致,风险较高。
### local_linux_user
平台托管的标准多用户工作区。
```text
平台账号: banisherwy
Linux runtime user: banisherwy
workspace root: /home/banisherwy/zk-agent/sessions/<session_id>
```
服务进程可以是 root,工具进程切换到普通 Linux 用户执行。
```text
root backend
-> runuser -u banisherwy -- <runner command>
```
职责分工:
```text
root 服务
创建 runtime 用户
初始化 workspace
设置 owner 和权限
启停进程
管理平台账号和 session
普通 Linux 用户
执行 bash/python
拥有自己的 workspace
拥有自己的 Python 虚拟环境
只能写自己的目录
```
推荐目录:
```text
/home/<account_id>/zk-agent/
sessions/
<session_id>/
input/
output/
scratchpad/
session.json
python/
.venv/
memory/
integrations/
```
推荐约定:
```text
平台用户名 = Linux 用户名
平台密码 = Linux 用户密码
Linux 用户允许 SSH 登录
```
这样用户体验更直接:
- 在平台创建账号时,同步创建同名 Linux 用户。
- 用户可以使用同一套账号密码登录 Web 平台和 SSH。
- Agent 工具执行时也使用同一个 Linux 用户身份。
- 文件 owner、进程 owner、SSH 登录用户和平台用户名一致,便于排查和审计。
但两者在架构语义上仍然保留分层:
```text
平台账号体系
登录、角色、session、Skill、模型配置。
Linux 用户体系
执行隔离、文件权限、进程权限、资源限制。
```
也就是说,账号名和密码保持一致,但平台仍然保留自己的登录态、session、角色和配置管理。Linux 账号负责机器级登录和执行权限。
需要注意:
- 用户名必须同时满足平台账号规范和 Linux 用户名规范。
- 修改平台密码时必须同步修改 Linux 密码。
- 禁用平台账号时,也应该禁用 Linux 登录或锁定 Linux 用户。
- 删除平台账号时,需要明确是否保留 `/home/<account_id>/zk-agent/` 数据。
- root 服务创建用户和改密码时必须走受控 helper,不能把用户输入拼成 shell 命令。
### remote_jupyter
用户授权的远程工作区。
语义是:
```text
用户把自己已有权限的 Jupyter 环境接入平台。
平台代替用户在这个环境里执行。
```
这不是平台托管沙盒。权限边界来自用户提供的 Jupyter 凭证。
```text
Account: banisherwy
Session: xxx
Runtime: remote_jupyter
Root: /root/zk_agent_workspaces/<session_id>
Permissions source: Jupyter password/token 对应的远程用户权限
```
平台需要保证:
- Jupyter 凭证只绑定当前 account/session。
- 刷新后 runtime 状态可恢复。
- 文件列表 metadata-only。
- 下载时通过 Jupyter API 流式拉取。
- 转在线文档时按需拉取,不默认同步大文件。
- 用户明确知道 Agent 在远程环境里的权限等同于该 Jupyter 用户。
平台不能保证:
- 远程机器上的文件权限隔离。
- 远程 Jupyter 用户不是 root。
- 远程挂载目录的访问范围。
短期建议:`remote_jupyter` 先保持当前逻辑,不作为账户体系升级的主战场。
当前已经具备:
- session 级 Jupyter 绑定。
- 刷新后恢复远程工作区状态。
- 输出文件 metadata-only 展示。
- 下载时通过 Jupyter API 流式读取。
- 转在线文档时按需拉取。
因此下一步账户体系升级优先处理本机托管 runtime 和平台账号,不主动重构 Jupyter 执行链路。后续只需要让 Jupyter 工作区在概念上挂到 `WorkspaceRuntime` 模型下。
### remote_ssh
未来可扩展的用户授权远程工作区。
语义和 remote_jupyter 类似:
```text
用户提供 SSH 连接能力。
平台代替用户在远程机器上执行。
权限边界来自 SSH 凭证对应的远程用户。
```
remote_ssh 更适合:
- 远程机器没有 Jupyter。
- 需要更完整 shell 能力。
- 需要使用远程开发机的挂载盘、GPU、模型目录。
但它也更复杂:
- SSH 凭证管理。
- 长连接和心跳。
- relay / OTP / 扫码登录。
- 文件传输和断线恢复。
- 进程树管理。
因此优先级应低于 `local_linux_user` 和已有 `remote_jupyter`
## 权限边界
需要在 UI 和文档中明确区分两类工作区:
```text
平台托管工作区
平台负责权限隔离。
典型类型: local_linux_user。
用户授权工作区
用户提供凭证。
平台不创建权限边界,只复用用户已有权限。
典型类型: remote_jupyter, remote_ssh。
```
UI 可以显示:
```text
当前工作区:Jupyter 远程工作区
权限来源:用户提供的 Jupyter 凭证
Agent 权限:等同于该远程环境当前登录用户
```
或者:
```text
当前工作区:平台托管工作区
执行身份:banisherwy
Agent 权限:普通 Linux 用户权限
```
## Tool 调用关系
目标关系:
```text
Agent Loop
-> Tool handler
-> RuntimeResolver(session_id)
-> Executor
-> local process / linux user / jupyter / ssh
```
工具不应该散落处理路径和远程协议。
例如:
```text
python_exec
-> executor.run_python(...)
write_file
-> executor.write_file(...)
download_artifact
-> executor.open_file_stream(...)
```
这样后续新增 runtime 时,尽量只新增 executor,不重写每个工具。
## 文件策略
### 输入文件
输入文件应该同步到当前 runtime 的 `input/`
```text
local_linux_user
上传文件 -> /home/<account_id>/zk-agent/sessions/<session_id>/input/
remote_jupyter
上传文件 -> 通过 Jupyter API 写入 /root/zk_agent_workspaces/<session_id>/input/
```
### 输出文件
输出文件默认放到当前 runtime 的 `output/`
```text
output/
records.jsonl
report.md
samples.csv
```
对远程 runtime,平台只保存 metadata。
```text
name
size
mtime
uri
runtime_id
```
点击下载时再流式读取。点击转在线文档时再按需拉取,并设置大小限制。
## Python 环境策略
每个 runtime 应有自己的 Python 环境。
```text
local_linux_user
/home/<account_id>/zk-agent/python/.venv
remote_jupyter
/root/zk_agent_workspaces/.zk-agent-python/.venv
```
初始化时只做最小准备:
- 创建 venv。
- 配置 pip 源。
- 不预装大量包。
缺包时由 Agent 根据任务安装,安装也发生在当前 runtime 内。
## 进程管理
每个工具执行必须有 run id 和 process group。
```text
run_id
account_id
session_id
runtime_id
executor_pid 或 remote_execution_id
status
started_at
updated_at
```
停止任务时:
- local_process:杀本地进程组。
- local_linux_user:杀对应 runtime 用户下该 run 的进程组。
- remote_jupyter:中断 kernel 或关闭对应执行任务。
- remote_ssh:杀远程进程组。
不能只停止 Web 请求,否则会出现“前端以为停了,后台 Python 还在跑”的问题。
## 持久化建议
建议把当前 JSON 账号体系逐步迁到 SQLite。
第一阶段可新增这些表:
```text
accounts
id
username
password_hash
role
status
created_at
updated_at
account_sessions
token_hash
account_id
created_at
updated_at
expires_at
workspace_runtimes
id
account_id
session_id
type
root
status
config_json
created_at
updated_at
artifacts
id
account_id
session_id
runtime_id
kind
uri
name
size
mime
created_at
```
敏感信息不要直接明文落库。Jupyter 密码、SSH key、token 至少需要加密或放入受控 secret store。
## 与现有实现的关系
当前已有能力可以映射到新模型:
```text
frontend/app/lib/claw-auth.ts
Account 登录态原型。
.port_sessions/accounts/<account_id>
local_process 模式下的 account workspace。
backend/api/server.py::account_paths
Runtime path resolver 的雏形。
src/jupyter_runtime.py
remote_jupyter executor 的雏形。
RunManager / RunStateStore
run id、活动状态、停止任务的雏形。
frontend 文件面板
Artifact list/download 的雏形。
```
所以这不是推翻重来,而是把已有能力抽象成更稳定的边界。
## 演进路线
### Phase 0:明确概念,不改执行路径
- 在代码和文档中引入 Workspace Runtime 术语。
- 把现有 `.port_sessions/accounts/<account_id>` 视为 `local_process` runtime。
- UI 显示当前工作区类型。
- 对 Jupyter 工作区补充权限提示。
### Phase 1:抽象 RuntimeResolver 和 Executor
- 新增 `RuntimeResolver`,根据 account/session 找当前 runtime。
- 新增统一 `Executor` 接口。
- 先把 `python_exec``bash`、文件工具迁到 executor。
- 保持现有 local 和 Jupyter 行为不变。
### Phase 2:账号存储升级
-`users.json``auth_sessions.json` 迁到 SQLite。
- 增加 `account_id``role``status``expires_at`
- 增加 session token 清理。
- 管理后台去掉 `admin/admin` 和默认 `123456`
### Phase 3local_linux_user runtime
- root 服务创建与平台账号同名的 Linux 用户。
- 平台密码同步设置为 Linux 用户密码。
- Linux 用户允许 SSH 登录。
- 初始化 `/home/<account_id>/zk-agent/`
- 工具执行切到普通 Linux 用户。
- Python venv、session、output 全部进入用户 home。
- 停止任务时按 process group 清理。
### Phase 4:资源限制和审计
- ulimit / cgroup。
- 每账号磁盘 quota。
- 工具执行审计。
- 大文件下载限流。
- session/output 清理策略。
### Phase 5remote_ssh runtime
- 在 remote_jupyter 稳定后再考虑。
- 重点解决认证、relay、长连接、文件传输和远程进程清理。
## 关键待决问题
1. 平台账号是否允许用户自注册,还是只允许管理员创建?
2. 用户自注册时,是否允许自动创建同名 Linux 用户?
3. 删除账号时,是否删除 Linux 用户,是否保留 home 目录?
4. 本机平台托管 workspace 是否统一迁到 `/home/<account_id>/zk-agent/`
5. Jupyter 凭证如何加密保存?
6. 远程 workspace 产物保留多久?
7. 大文件下载、在线文档转换和文件预览的大小限制是多少?
8. 是否需要团队空间:一个 workspace 被多个账号共享?
## 推荐决策
短期建议:
```text
保留平台账号体系。
引入 Workspace Runtime 抽象。
继续稳定 remote_jupyter。
账号存储从 JSON 迁到 SQLite。
开始设计 local_linux_user,但不要立即替换所有执行路径。
```
中期建议:
```text
服务可以 root 运行。
平台账号创建时同步创建同名普通 Linux 用户。
平台密码和 Linux 密码保持一致。
Linux 用户允许 SSH 登录。
工具执行统一通过 runtime executor。
本机默认工作区逐步迁到 /home/<account_id>/zk-agent。
```
长期建议:
```text
平台账号负责产品身份。
Workspace Runtime 负责执行环境。
Artifact 负责跨 runtime 文件抽象。
Executor 负责工具执行适配。
```
这样账户体系、Linux 子账户、Jupyter/SSH 远程工作区、文件下载、Python 环境和工具执行可以合到一个统一设计里,而不是继续各自生长。
+60
View File
@@ -0,0 +1,60 @@
# ZK Data Agent 技术架构说明
这组文档先服务于技术 review 和后续讲解材料沉淀,不做宣传表达,不做产品比较。
每个章节尽量对应当前代码里的真实模块、函数和 Skill 实现,后续 `/doc` 页面可以从这里抽取内容做视觉化呈现。
## 阅读顺序
1. [基座 Runtime 架构](01-base-runtime.md)
2. [Agent Loop 执行机制](02-agent-loop.md)
3. [工具体系和 Tool Handler](03-tools.md)
4. [Skill 体系和能力包约定](04-skills.md)
5. [会话工作区、运行态和记忆](05-workspace-memory-observability.md)
6. [product-data Skill 实现](06-product-data.md)
7. [online-mining-v2 Skill 实现](07-online-mining-v2.md)
8. [label-master Skill 实现](08-label-master.md)
9. [外部系统 Skill:ELK、SQL、模型迭代](09-external-skills.md)
10. [Agent 记忆机制调研与对比](10-memory-research.md)
## 一句话定位
ZK Data Agent 的基座是一个 Web 化、多用户、可观测的 Agent runtime。
业务能力通过 `skills/<skill-name>/SKILL.md``knowledge/``scripts/` 组织;稳定执行能力通过 Tool handler 或 Skill 内 portable scripts 承载;每次运行通过 Agent loop 让模型在“判断、调用工具、观察结果、继续判断”之间循环。
## 当前技术主线
```text
Web UI
-> backend/api/server.py
-> LocalCodingAgent
-> agent_prompting 组装系统提示词和 Skill 列表
-> OpenAICompatClient 调模型
-> 模型返回文本或 tool_calls
-> agent_tools 执行 handler
-> session workspace 保存 input/scratchpad/output/session.json
-> run_state_store / event stream 推给前端活动区
-> personal_memory 后台异步整理用户记忆和 Skill 记忆
```
## 代码入口速查
| 主题 | 主要代码 |
|------|----------|
| Agent runtime | `src/agent_runtime.py` |
| 系统提示词 | `src/agent_prompting.py` |
| Tool registry / handler | `src/agent_tools.py``src/agent_tool_specs/` |
| Skill loader | `src/bundled_skills.py` |
| 模型兼容层 | `src/openai_compat.py` |
| Web 后端 | `backend/api/server.py` |
| 会话持久化 | `src/agent_session.py``src/session_store.py` |
| 记忆后台 | `src/personal_memory.py` |
| 数据生成 Skill | `skills/product-data/` |
| 线上挖掘 Skill | `skills/online-mining-v2/` |
| 标签知识 Skill | `skills/label-master/` |
## 后续维护原则
- 如果是在讲“基座怎么运行”,优先改 01-05。
- 如果是在讲“某个 Skill 怎么做事”,优先改 06-09。
- 如果代码实现发生变化,先更新对应章节,再考虑同步 README 或 `/doc` 页面。
- 页面表达、图形素材、演示脚本不写在这里;这里只保留技术事实和设计边界。
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

+5 -1
View File
@@ -11,6 +11,7 @@ import {
accountSessionOutputRoot, accountSessionOutputRoot,
accountSessionRoot, accountSessionRoot,
accountSessionScratchpadRoot, accountSessionScratchpadRoot,
chownAccountPath,
getCurrentAccount, getCurrentAccount,
} from "@/lib/claw-auth"; } from "@/lib/claw-auth";
@@ -334,14 +335,16 @@ async function getLastUserText(
} }
async function ensureSessionDirectories(accountId: string, sessionId: string) { async function ensureSessionDirectories(accountId: string, sessionId: string) {
const sessionRoot = accountSessionRoot(accountId, sessionId);
await Promise.all([ await Promise.all([
mkdir(accountSessionRoot(accountId, sessionId), { recursive: true }), mkdir(sessionRoot, { recursive: true }),
mkdir(accountSessionInputRoot(accountId, sessionId), { recursive: true }), mkdir(accountSessionInputRoot(accountId, sessionId), { recursive: true }),
mkdir(accountSessionOutputRoot(accountId, sessionId), { recursive: true }), mkdir(accountSessionOutputRoot(accountId, sessionId), { recursive: true }),
mkdir(accountSessionScratchpadRoot(accountId, sessionId), { mkdir(accountSessionScratchpadRoot(accountId, sessionId), {
recursive: true, recursive: true,
}), }),
]); ]);
await chownAccountPath(accountId, sessionRoot, true);
} }
function renderSessionRuntimeContext(accountId: string, sessionId: string) { function renderSessionRuntimeContext(accountId: string, sessionId: string) {
@@ -394,6 +397,7 @@ async function saveFilePart(
await mkdir(uploadDir, { recursive: true }); await mkdir(uploadDir, { recursive: true });
const filePath = path.join(uploadDir, `${Date.now()}-${filename}`); const filePath = path.join(uploadDir, `${Date.now()}-${filename}`);
await writeFile(filePath, bytes); await writeFile(filePath, bytes);
await chownAccountPath(accountId, filePath);
return [ return [
`[文件已上传] ${filename}`, `[文件已上传] ${filename}`,
+373
View File
@@ -0,0 +1,373 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import type { Metadata } from "next";
import Image from "next/image";
import { notFound } from "next/navigation";
import type { ReactNode } from "react";
const technicalDocs = {
"01-base-runtime": {
title: "基座 Runtime",
file: "01-base-runtime.md",
image: "/doc-assets/technical/01-base-runtime.png",
},
"02-agent-loop": {
title: "Agent Loop",
file: "02-agent-loop.md",
image: "/doc-assets/technical/02-agent-loop.png",
},
"03-tools": {
title: "工具体系",
file: "03-tools.md",
image: "/doc-assets/technical/03-tools.png",
},
"04-skills": {
title: "Skill 体系",
file: "04-skills.md",
image: "/doc-assets/technical/04-skills.png",
},
"05-workspace-memory-observability": {
title: "工作区、运行态和记忆",
file: "05-workspace-memory-observability.md",
image: "/doc-assets/technical/05-workspace-memory-observability.png",
},
"06-product-data": {
title: "product-data",
file: "06-product-data.md",
image: "/doc-assets/technical/06-product-data.png",
},
"07-online-mining-v2": {
title: "online-mining-v2",
file: "07-online-mining-v2.md",
image: "/doc-assets/technical/07-online-mining-v2.png",
},
"08-label-master": {
title: "标签大师",
file: "08-label-master.md",
image: "/doc-assets/technical/08-label-master.png",
},
"09-external-skills": {
title: "外部系统 Skill",
file: "09-external-skills.md",
image: "/doc-assets/technical/09-external-skills.png",
},
"10-memory-research": {
title: "Agent 记忆机制调研与对比",
file: "10-memory-research.md",
image: null,
},
} as const;
type TechnicalDocSlug = keyof typeof technicalDocs;
function getDoc(slug: string) {
if (slug in technicalDocs) {
return technicalDocs[slug as TechnicalDocSlug];
}
return null;
}
function markdownPath(file: string) {
return path.join(
process.cwd(),
"public",
"doc-assets",
"technical-docs",
file,
);
}
function normalizeMarkdown(markdown: string) {
return markdown
.replace(/^\s*#\s+.+\n+/, "")
.replaceAll("](assets/", "](/doc-assets/technical/");
}
function parseInline(text: string): ReactNode[] {
const nodes: ReactNode[] = [];
const pattern = /(`[^`]+`|\*\*[^*]+\*\*|\[([^\]]+)\]\(([^)]+)\))/g;
let cursor = 0;
while (true) {
const match = pattern.exec(text);
if (match === null) break;
if (match.index > cursor) {
nodes.push(text.slice(cursor, match.index));
}
const token = match[0];
const key = `${match.index}-${token}`;
if (token.startsWith("`")) {
nodes.push(<code key={key}>{token.slice(1, -1)}</code>);
} else if (token.startsWith("**")) {
nodes.push(<strong key={key}>{token.slice(2, -2)}</strong>);
} else {
const href = match[3] ?? "";
const isExternal = /^https?:\/\//.test(href);
nodes.push(
<a
href={href}
key={key}
rel={isExternal ? "noopener" : undefined}
target={isExternal ? "_blank" : undefined}
>
{match[2]}
</a>,
);
}
cursor = pattern.lastIndex;
}
if (cursor < text.length) {
nodes.push(text.slice(cursor));
}
return nodes;
}
function splitTableRow(line: string) {
return line
.trim()
.replace(/^\|/, "")
.replace(/\|$/, "")
.split("|")
.map((cell) => cell.trim());
}
function isTableDivider(line: string) {
return /^\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$/.test(line);
}
function startsBlock(line: string, nextLine?: string) {
return (
line.startsWith("```") ||
/^#{1,6}\s+/.test(line) ||
/^[-*]\s+/.test(line) ||
/^\d+\.\s+/.test(line) ||
/^>\s?/.test(line) ||
/^!\[[^\]]*]\([^)]+\)\s*$/.test(line) ||
(line.includes("|") && nextLine !== undefined && isTableDivider(nextLine))
);
}
function renderHeading(depth: number, text: string, key: string) {
if (depth === 1) return <h1 key={key}>{parseInline(text)}</h1>;
if (depth === 2) return <h2 key={key}>{parseInline(text)}</h2>;
if (depth === 3) return <h3 key={key}>{parseInline(text)}</h3>;
if (depth === 4) return <h4 key={key}>{parseInline(text)}</h4>;
if (depth === 5) return <h5 key={key}>{parseInline(text)}</h5>;
return <h6 key={key}>{parseInline(text)}</h6>;
}
function renderMarkdown(markdown: string) {
const lines = markdown.replace(/\r\n/g, "\n").split("\n");
const blocks: ReactNode[] = [];
let index = 0;
while (index < lines.length) {
const line = lines[index];
const trimmed = line.trim();
if (!trimmed) {
index += 1;
continue;
}
if (trimmed.startsWith("```")) {
const language = trimmed.slice(3).trim() || "text";
const codeLines: string[] = [];
index += 1;
while (index < lines.length && !lines[index].trim().startsWith("```")) {
codeLines.push(lines[index]);
index += 1;
}
if (index < lines.length) index += 1;
blocks.push(
<div className="project-doc-code-block" key={`code-${index}`}>
<div>{language}</div>
<pre>
<code>{codeLines.join("\n")}</code>
</pre>
</div>,
);
continue;
}
const heading = trimmed.match(/^(#{1,6})\s+(.*)$/);
if (heading) {
blocks.push(
renderHeading(heading[1].length, heading[2], `heading-${index}`),
);
index += 1;
continue;
}
const image = trimmed.match(/^!\[([^\]]*)]\(([^)]+)\)\s*$/);
if (image) {
blocks.push(
<Image
alt={image[1]}
height={900}
key={`image-${index}`}
src={image[2]}
width={1600}
/>,
);
index += 1;
continue;
}
if (
trimmed.includes("|") &&
index + 1 < lines.length &&
isTableDivider(lines[index + 1])
) {
const headers = splitTableRow(trimmed);
index += 2;
const rows: string[][] = [];
while (index < lines.length && lines[index].trim().includes("|")) {
rows.push(splitTableRow(lines[index]));
index += 1;
}
blocks.push(
<table key={`table-${index}`}>
<thead>
<tr>
{headers.map((cell) => (
<th key={cell}>{parseInline(cell)}</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.join("|")}>
{row.map((cell) => (
<td key={`${row.join("|")}-${cell}`}>{parseInline(cell)}</td>
))}
</tr>
))}
</tbody>
</table>,
);
continue;
}
if (/^[-*]\s+/.test(trimmed)) {
const items: string[] = [];
while (index < lines.length && /^[-*]\s+/.test(lines[index].trim())) {
items.push(lines[index].trim().replace(/^[-*]\s+/, ""));
index += 1;
}
blocks.push(
<ul key={`ul-${index}`}>
{items.map((item) => (
<li key={item}>{parseInline(item)}</li>
))}
</ul>,
);
continue;
}
if (/^\d+\.\s+/.test(trimmed)) {
const items: string[] = [];
while (index < lines.length && /^\d+\.\s+/.test(lines[index].trim())) {
items.push(lines[index].trim().replace(/^\d+\.\s+/, ""));
index += 1;
}
blocks.push(
<ol key={`ol-${index}`}>
{items.map((item) => (
<li key={item}>{parseInline(item)}</li>
))}
</ol>,
);
continue;
}
if (/^>\s?/.test(trimmed)) {
const quoteLines: string[] = [];
while (index < lines.length && /^>\s?/.test(lines[index].trim())) {
quoteLines.push(lines[index].trim().replace(/^>\s?/, ""));
index += 1;
}
blocks.push(
<blockquote key={`quote-${index}`}>
{parseInline(quoteLines.join(" "))}
</blockquote>,
);
continue;
}
const paragraphLines = [trimmed];
index += 1;
while (
index < lines.length &&
lines[index].trim() &&
!startsBlock(lines[index].trim(), lines[index + 1]?.trim())
) {
paragraphLines.push(lines[index].trim());
index += 1;
}
blocks.push(
<p key={`p-${index}`}>{parseInline(paragraphLines.join(" "))}</p>,
);
}
return blocks;
}
export function generateStaticParams() {
return Object.keys(technicalDocs).map((slug) => ({ slug }));
}
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>;
}): Promise<Metadata> {
const { slug } = await params;
const doc = getDoc(slug);
if (!doc) return {};
return {
title: `${doc.title} - ZK Data Agent`,
};
}
export default async function TechnicalDocPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const doc = getDoc(slug);
if (!doc) notFound();
const rawMarkdown = await readFile(markdownPath(doc.file), "utf8");
const markdown = normalizeMarkdown(rawMarkdown);
return (
<main className="project-doc-page project-doc-detail-page">
<header className="project-doc-detail-hero">
<nav className="project-doc-nav">
<a href="/doc">ZK Data Agent</a>
<div>
<a href="/doc"></a>
</div>
</nav>
<div className="project-doc-detail-head">
<p className="project-doc-kicker"></p>
<h1>{doc.title}</h1>
<a className="project-doc-back-link" href="/doc">
</a>
</div>
</header>
<section className="project-doc-detail-shell">
<article className="project-doc-article">
{renderMarkdown(markdown)}
</article>
</section>
</main>
);
}
File diff suppressed because it is too large Load Diff
+566
View File
@@ -0,0 +1,566 @@
"use client";
import {
type CSSProperties,
type PointerEvent,
useMemo,
useRef,
useState,
} from "react";
type ToolPosition = {
id: string;
name: string;
x: number;
y: number;
z: number;
type: string;
color: string;
summary: string;
fit: string;
limit: string;
};
type Point3D = {
x: number;
y: number;
z: number;
};
type ProjectionMode = "xy" | "xz" | "yz" | "free";
const tools: ToolPosition[] = [
{
id: "manual",
name: "纯人工",
x: 24,
y: 82,
z: 22,
type: "人主导",
color: "#64748b",
summary: "最懂业务,但查、写、跑、改、整理都靠人推进。",
fit: "复杂边界判断、探索性分析、最终质量把关。",
limit: "执行成本高,流程复用依赖个人习惯和文档质量。",
},
{
id: "web-ai",
name: "网页版 AI",
x: 54,
y: 24,
z: 18,
type: "通用 AI",
color: "#38bdf8",
summary: "适合问答、总结、生成草稿,启动快。",
fit: "通用解释、文本生成、局部判断辅助。",
limit: "不天然接入内部日志、标签规则、标准产物和团队流程。",
},
{
id: "copilot",
name: "IDE Copilot",
x: 62,
y: 34,
z: 28,
type: "编码辅助",
color: "#60a5fa",
summary: "在编辑器里提升局部编码效率。",
fit: "补全、局部函数、单文件修改。",
limit: "更偏代码片段,不负责跨系统数据流和业务产物链。",
},
{
id: "cursor",
name: "Cursor",
x: 76,
y: 46,
z: 38,
type: "IDE Agent",
color: "#818cf8",
summary: "围绕代码仓库做理解、修改和多文件协作。",
fit: "工程代码迭代、仓库内上下文开发。",
limit: "团队业务流程、线上数据、标准导出和共享 Skill 需要另行组织。",
},
{
id: "code-agent",
name: "Claude Code / Codex",
x: 84,
y: 58,
z: 45,
type: "工程 Agent",
color: "#a78bfa",
summary: "能读代码、调工具、执行多步工程任务。",
fit: "本地工程闭环、复杂代码修改、命令执行。",
limit: "强在个人工作区;团队级业务流程沉淀和 Web 多用户协作不是默认形态。",
},
{
id: "notebook",
name: "个人自动化脚本",
x: 66,
y: 74,
z: 48,
type: "个人自动化",
color: "#f59e0b",
summary: "贴近业务数据,能把部分流程脚本化。",
fit: "数据清洗、临时统计、可重复实验。",
limit: "入口、权限、review、产物管理和团队复用通常需要人额外维护。",
},
{
id: "prompt-doc",
name: "经验文档",
x: 38,
y: 62,
z: 60,
type: "经验沉淀",
color: "#10b981",
summary: "能记录规则和经验,便于传播。",
fit: "标签边界、流程说明、操作规范。",
limit: "本身不执行任务,仍需要人把文档转成操作。",
},
{
id: "zk",
name: "ZK Data Agent",
x: 80,
y: 88,
z: 86,
type: "业务 Agent 工作台",
color: "#2563eb",
summary:
"把 Agent Loop、工具执行、业务知识、review 和产物管理组织成团队流程。",
fit: "数据生成、线上挖掘、标签判断、外部系统 Skill 接入。",
limit: "不替代人的业务判断;重点是把判断节点放进可复用流程。",
},
];
const presets: Array<{
mode: ProjectionMode;
name: string;
rotateX: number;
rotateY: number;
}> = [
{ mode: "xy", name: "研发 × 业务", rotateX: 0, rotateY: 0 },
{ mode: "xz", name: "研发 × 复用", rotateX: -90, rotateY: 0 },
{ mode: "yz", name: "业务 × 复用", rotateX: 0, rotateY: -90 },
];
const dimensionCards = [
{
title: "研发执行效率",
text: "读代码、调工具、跑脚本、生成产物。",
},
{
title: "业务嵌入深度",
text: "接住日志、标签体系、数据格式和团队规则。",
},
{
title: "流程可复用度",
text: "把一次解决方案沉淀成团队能继续调用的能力。",
},
];
const frictionCases = [
{ text: "标签边界依赖经验", weight: 5 },
{ text: "格式手工补齐", weight: 4 },
{ text: "规则散在文档", weight: 4 },
{ text: "线上数据难取", weight: 4 },
{ text: "经验靠口口相传", weight: 3 },
{ text: "脚本只在个人机器", weight: 3 },
{ text: "Review 断在中间", weight: 3 },
{ text: "样例难复用", weight: 2 },
{ text: "产物路径分散", weight: 2 },
{ text: "路径和权限反复确认", weight: 2 },
];
const cubeSize = 360;
const cubeCenter = cubeSize / 2;
const stageCenter = 280;
const perspective = 950;
const axisOrigin = { x: -cubeCenter, y: cubeCenter, z: -cubeCenter };
const axisDefinitions = [
{
id: "x",
name: "研发执行效率",
color: "#2563eb",
start: axisOrigin,
end: { x: cubeCenter, y: cubeCenter, z: -cubeCenter },
},
{
id: "y",
name: "业务嵌入深度",
color: "#0f766e",
start: axisOrigin,
end: { x: -cubeCenter, y: -cubeCenter, z: -cubeCenter },
},
{
id: "z",
name: "流程可复用度",
color: "#9333ea",
start: axisOrigin,
end: { x: -cubeCenter, y: cubeCenter, z: cubeCenter },
},
];
const visibleAxesByMode: Record<ProjectionMode, string[]> = {
xy: ["x", "y"],
xz: ["x", "z"],
yz: ["z", "y"],
free: ["x", "y", "z"],
};
function score(tool: ToolPosition) {
return Math.round((tool.x + tool.y + tool.z) / 3);
}
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}
function getPointCoordinates(tool: ToolPosition) {
return {
x: (tool.x / 100) * cubeSize - cubeCenter,
y: cubeCenter - (tool.y / 100) * cubeSize,
z: (tool.z / 100) * cubeSize - cubeCenter,
};
}
function projectPoint(
point: Point3D,
rotateX: number,
rotateY: number,
mode: ProjectionMode,
) {
if (mode === "xy") {
return {
x: stageCenter + point.x,
y: stageCenter + point.y,
z: point.z,
scale: 1,
};
}
if (mode === "xz") {
return {
x: stageCenter + point.x,
y: stageCenter - point.z,
z: -point.y,
scale: 1,
};
}
if (mode === "yz") {
return {
x: stageCenter + point.z,
y: stageCenter + point.y,
z: point.x,
scale: 1,
};
}
const angleX = (rotateX * Math.PI) / 180;
const angleY = (rotateY * Math.PI) / 180;
const cosX = Math.cos(angleX);
const sinX = Math.sin(angleX);
const cosY = Math.cos(angleY);
const sinY = Math.sin(angleY);
const afterY = {
x: point.x * cosY + point.z * sinY,
y: point.y,
z: -point.x * sinY + point.z * cosY,
};
const rotated = {
x: afterY.x,
y: afterY.y * cosX - afterY.z * sinX,
z: afterY.y * sinX + afterY.z * cosX,
};
const scale = perspective / (perspective - rotated.z);
return {
x: stageCenter + rotated.x * scale,
y: stageCenter + rotated.y * scale,
z: rotated.z,
scale,
};
}
function toolAnchorStyle(
tool: ToolPosition,
projected: ReturnType<typeof projectPoint>,
) {
const scale = Math.min(1.08, Math.max(0.88, projected.scale));
return {
"--tool-x": `${projected.x}px`,
"--tool-y": `${projected.y}px`,
"--label-scale": `${scale}`,
"--point-color": tool.color,
zIndex: Math.round(500 + projected.z),
} as CSSProperties;
}
function getProjectedAxes(
rotateX: number,
rotateY: number,
mode: ProjectionMode,
) {
const visibleAxes = new Set(visibleAxesByMode[mode]);
return axisDefinitions
.filter((axis) => visibleAxes.has(axis.id))
.map((axis) => {
const start = projectPoint(axis.start, rotateX, rotateY, mode);
const end = projectPoint(axis.end, rotateX, rotateY, mode);
const vectorX = end.x - start.x;
const vectorY = end.y - start.y;
const length = Math.max(1, Math.hypot(vectorX, vectorY));
const labelX = clamp(end.x + (vectorX / length) * 34, 44, 516);
const labelY = clamp(end.y + (vectorY / length) * 34, 32, 528);
return {
...axis,
start,
end,
labelStyle: {
"--axis-label-x": `${labelX}px`,
"--axis-label-y": `${labelY}px`,
"--axis-color": axis.color,
} as CSSProperties,
};
});
}
export function ToolPositionMap() {
const [projectionMode, setProjectionMode] = useState<ProjectionMode>("xy");
const [rotateX, setRotateX] = useState(0);
const [rotateY, setRotateY] = useState(0);
const [selectedId, setSelectedId] = useState("zk");
const [hoveredId, setHoveredId] = useState<string | null>(null);
const [isDragging, setIsDragging] = useState(false);
const dragRef = useRef<{
x: number;
y: number;
rotateX: number;
rotateY: number;
} | null>(null);
const selected = useMemo(
() => tools.find((tool) => tool.id === selectedId) ?? tools[0],
[selectedId],
);
const projectedTools = useMemo(
() =>
tools
.map((tool) => ({
tool,
projected: projectPoint(
getPointCoordinates(tool),
rotateX,
rotateY,
projectionMode,
),
}))
.sort((a, b) => a.projected.z - b.projected.z),
[rotateX, rotateY, projectionMode],
);
const projectedAxes = useMemo(
() => getProjectedAxes(rotateX, rotateY, projectionMode),
[rotateX, rotateY, projectionMode],
);
const handlePointerDown = (event: PointerEvent<HTMLDivElement>) => {
setProjectionMode("free");
dragRef.current = {
x: event.clientX,
y: event.clientY,
rotateX,
rotateY,
};
setIsDragging(true);
event.currentTarget.setPointerCapture(event.pointerId);
};
const handlePointerMove = (event: PointerEvent<HTMLDivElement>) => {
if (!dragRef.current) return;
const deltaX = event.clientX - dragRef.current.x;
const deltaY = event.clientY - dragRef.current.y;
setRotateX(dragRef.current.rotateX - deltaY * 0.45);
setRotateY(dragRef.current.rotateY + deltaX * 0.45);
};
const handlePointerEnd = (event: PointerEvent<HTMLDivElement>) => {
dragRef.current = null;
setIsDragging(false);
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
};
return (
<section className="project-doc-section project-doc-position" id="position">
<div className="position-framing">
<div className="position-framing-copy">
<span></span>
<h3 className="position-formula">
<span></span>
<b></b>
<span></span>
<b>×</b>
<span></span>
<b>×</b>
<span></span>
</h3>
<p>
AI
</p>
</div>
<div className="position-dimension-grid">
{dimensionCards.map((card) => (
<div key={card.title}>
<strong>{card.title}</strong>
<p>{card.text}</p>
</div>
))}
</div>
<div className="position-friction-cloud">
<strong></strong>
{frictionCases.map((item) => (
<span className={`is-weight-${item.weight}`} key={item.text}>
{item.text}
</span>
))}
<em></em>
</div>
</div>
<div className="position-map-layout">
<div className="position-stage-card">
<div className="position-controls">
<div>
<span>
{projectionMode === "free"
? "拖动坐标系自由旋转"
: "二维投影视图"}
</span>
<strong>
{projectionMode === "free"
? `X ${Math.round(rotateX)} / Y ${Math.round(rotateY)}`
: presets.find((preset) => preset.mode === projectionMode)
?.name}
</strong>
</div>
<div className="position-preset-row">
{presets.map((preset) => (
<button
className={
projectionMode === preset.mode ? "is-selected" : undefined
}
key={preset.name}
onClick={() => {
setProjectionMode(preset.mode);
setRotateX(preset.rotateX);
setRotateY(preset.rotateY);
}}
type="button"
>
{preset.name}
</button>
))}
</div>
</div>
<div
className={
isDragging ? "position-stage is-dragging" : "position-stage"
}
onPointerCancel={handlePointerEnd}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerEnd}
>
<div className="position-scene">
<div className="position-space-grid" />
<svg
aria-hidden="true"
className="position-axis-overlay"
viewBox="0 0 560 560"
>
{projectedAxes.map((axis) => (
<line
key={axis.id}
stroke={axis.color}
x1={axis.start.x}
x2={axis.end.x}
y1={axis.start.y}
y2={axis.end.y}
/>
))}
</svg>
<div className="position-axis-label-layer">
{projectedAxes.map((axis) => (
<span
className="position-axis-label"
key={axis.id}
style={axis.labelStyle}
>
{axis.name}
</span>
))}
</div>
<div className="position-tool-layer">
{projectedTools.map(({ tool, projected }) => (
<button
className={[
"position-tool-anchor",
tool.id === selectedId ? "is-selected" : "",
tool.id === hoveredId ? "is-hovered" : "",
]
.filter(Boolean)
.join(" ")}
key={tool.id}
onClick={() => setSelectedId(tool.id)}
onBlur={() => setHoveredId(null)}
onFocus={() => setHoveredId(tool.id)}
onMouseEnter={() => setHoveredId(tool.id)}
onMouseLeave={() => setHoveredId(null)}
onPointerDown={(event) => event.stopPropagation()}
style={toolAnchorStyle(tool, projected)}
type="button"
>
<span className="position-ball" />
<span className="position-tool-label">{tool.name}</span>
</button>
))}
</div>
</div>
</div>
</div>
<aside className="position-detail-card">
<div className="position-score">
<span>{selected.type}</span>
<strong>{score(selected)}</strong>
</div>
<h3>{selected.name}</h3>
<p>{selected.summary}</p>
<div className="position-meter-list">
<div>
<span></span>
<i style={{ width: `${selected.x}%` }} />
</div>
<div>
<span></span>
<i style={{ width: `${selected.y}%` }} />
</div>
<div>
<span></span>
<i style={{ width: `${selected.z}%` }} />
</div>
</div>
<dl>
<div>
<dt></dt>
<dd>{selected.fit}</dd>
</div>
<div>
<dt></dt>
<dd>{selected.limit}</dd>
</div>
</dl>
</aside>
</div>
</section>
);
}
+1092 -1618
View File
File diff suppressed because it is too large Load Diff
+117 -15
View File
@@ -1,17 +1,19 @@
import { spawn } from "node:child_process";
import { randomBytes, scryptSync, timingSafeEqual } from "node:crypto"; import { randomBytes, scryptSync, timingSafeEqual } from "node:crypto";
import { mkdir, readFile, writeFile } from "node:fs/promises"; import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { cookies } from "next/headers"; import { cookies } from "next/headers";
const AUTH_ROOT = path.join( const AUTH_STATE_ROOT = path.join(
/* turbopackIgnore: true */ process.cwd(), /* turbopackIgnore: true */ process.cwd(),
"../../.port_sessions/accounts", "../../.port_sessions/accounts",
); );
const USERS_PATH = path.join(AUTH_ROOT, "users.json"); const USERS_PATH = path.join(AUTH_STATE_ROOT, "users.json");
const SESSIONS_PATH = path.join(AUTH_ROOT, "auth_sessions.json"); const SESSIONS_PATH = path.join(AUTH_STATE_ROOT, "auth_sessions.json");
const COOKIE_NAME = "claw_account_session"; const COOKIE_NAME = "claw_account_session";
const SESSION_MAX_AGE_SECONDS = 60 * 60 * 24 * 30; const SESSION_MAX_AGE_SECONDS = 60 * 60 * 24 * 30;
const SESSION_REFRESH_INTERVAL_MS = 60 * 60 * 12 * 1000; const SESSION_REFRESH_INTERVAL_MS = 60 * 60 * 12 * 1000;
const LINUX_ACCOUNT_WORKSPACE_NAME = "zk-agent";
type UserRecord = { type UserRecord = {
id: string; id: string;
@@ -51,6 +53,7 @@ export async function registerAccount(username: string, password: string) {
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
}; };
usersFile.users.push(account); usersFile.users.push(account);
await ensureLinuxAccount(account.id, password);
await writeUsers(usersFile); await writeUsers(usersFile);
await createAccountDirectories(account.id); await createAccountDirectories(account.id);
return createSession(account); return createSession(account);
@@ -65,6 +68,7 @@ export async function loginAccount(username: string, password: string) {
if (!account || !verifyPassword(password, account.passwordHash)) { if (!account || !verifyPassword(password, account.passwordHash)) {
throw new Error("账号或密码错误"); throw new Error("账号或密码错误");
} }
await ensureLinuxAccount(account.id, password);
await createAccountDirectories(account.id); await createAccountDirectories(account.id);
return createSession(account); return createSession(account);
} }
@@ -153,15 +157,18 @@ export async function getCurrentAccount(): Promise<AccountSession | null> {
} }
export function accountUploadRoot(accountId: string) { export function accountUploadRoot(accountId: string) {
return path.join(AUTH_ROOT, accountId, "sessions"); return path.join(accountBaseRoot(accountId), "sessions");
} }
export function accountOutputRoot(accountId: string) { export function accountOutputRoot(accountId: string) {
return path.join(AUTH_ROOT, accountId, "sessions"); return path.join(accountBaseRoot(accountId), "sessions");
} }
export function accountBaseRoot(accountId: string) { export function accountBaseRoot(accountId: string) {
return path.join(AUTH_ROOT, accountId); if (linuxAccountsEnabled()) {
return path.join("/home", accountId, LINUX_ACCOUNT_WORKSPACE_NAME);
}
return path.join(AUTH_STATE_ROOT, accountId);
} }
export function accountSessionInputRoot(accountId: string, sessionId: string) { export function accountSessionInputRoot(accountId: string, sessionId: string) {
@@ -213,15 +220,18 @@ async function setSessionCookie(token: string) {
} }
async function createAccountDirectories(accountId: string) { async function createAccountDirectories(accountId: string) {
await Promise.all( const base = accountBaseRoot(accountId);
["sessions"].map((name) => await Promise.all([
mkdir(path.join(AUTH_ROOT, accountId, name), { recursive: true }), mkdir(path.join(base, "sessions"), { recursive: true }),
), mkdir(path.join(base, "python"), { recursive: true }),
); mkdir(path.join(base, "memory"), { recursive: true }),
mkdir(path.join(base, "integrations"), { recursive: true }),
]);
await chownAccountPath(accountId, base, true);
} }
async function readUsers(): Promise<UsersFile> { async function readUsers(): Promise<UsersFile> {
await mkdir(AUTH_ROOT, { recursive: true }); await mkdir(AUTH_STATE_ROOT, { recursive: true });
try { try {
return JSON.parse(await readFile(USERS_PATH, "utf8")) as UsersFile; return JSON.parse(await readFile(USERS_PATH, "utf8")) as UsersFile;
} catch { } catch {
@@ -230,12 +240,12 @@ async function readUsers(): Promise<UsersFile> {
} }
async function writeUsers(payload: UsersFile) { async function writeUsers(payload: UsersFile) {
await mkdir(AUTH_ROOT, { recursive: true }); await mkdir(AUTH_STATE_ROOT, { recursive: true });
await writeFile(USERS_PATH, JSON.stringify(payload, null, 2), "utf8"); await writeFile(USERS_PATH, JSON.stringify(payload, null, 2), "utf8");
} }
async function readSessions(): Promise<SessionsFile> { async function readSessions(): Promise<SessionsFile> {
await mkdir(AUTH_ROOT, { recursive: true }); await mkdir(AUTH_STATE_ROOT, { recursive: true });
try { try {
return JSON.parse(await readFile(SESSIONS_PATH, "utf8")) as SessionsFile; return JSON.parse(await readFile(SESSIONS_PATH, "utf8")) as SessionsFile;
} catch { } catch {
@@ -244,12 +254,20 @@ async function readSessions(): Promise<SessionsFile> {
} }
async function writeSessions(payload: SessionsFile) { async function writeSessions(payload: SessionsFile) {
await mkdir(AUTH_ROOT, { recursive: true }); await mkdir(AUTH_STATE_ROOT, { recursive: true });
await writeFile(SESSIONS_PATH, JSON.stringify(payload, null, 2), "utf8"); await writeFile(SESSIONS_PATH, JSON.stringify(payload, null, 2), "utf8");
} }
function normalizeUsername(username: string) { function normalizeUsername(username: string) {
const cleanUsername = username.trim().toLowerCase(); const cleanUsername = username.trim().toLowerCase();
if (linuxAccountsEnabled()) {
if (!/^[a-z_][a-z0-9_-]{1,31}$/.test(cleanUsername)) {
throw new Error(
"启用 Linux 账号时,账号只能包含小写字母、数字、下划线或中划线,且必须以小写字母或下划线开头,长度 2-32",
);
}
return cleanUsername;
}
if (!/^[a-z0-9._-]{2,40}$/.test(cleanUsername)) { if (!/^[a-z0-9._-]{2,40}$/.test(cleanUsername)) {
throw new Error( throw new Error(
"账号只能包含小写字母、数字、点、下划线或中划线,长度 2-40", "账号只能包含小写字母、数字、点、下划线或中划线,长度 2-40",
@@ -282,3 +300,87 @@ function shouldRefreshSession(value?: string) {
if (Number.isNaN(timestamp)) return true; if (Number.isNaN(timestamp)) return true;
return Date.now() - timestamp > SESSION_REFRESH_INTERVAL_MS; return Date.now() - timestamp > SESSION_REFRESH_INTERVAL_MS;
} }
function linuxAccountsEnabled() {
return ["1", "true", "yes", "on"].includes(
(process.env.CLAW_ENABLE_LINUX_ACCOUNTS ?? "").trim().toLowerCase(),
);
}
async function ensureLinuxAccount(accountId: string, password: string) {
if (!linuxAccountsEnabled()) return;
if (process.platform !== "linux") {
throw new Error("CLAW_ENABLE_LINUX_ACCOUNTS=1 只支持 Linux 部署环境");
}
if (typeof process.getuid === "function" && process.getuid() !== 0) {
throw new Error(
"CLAW_ENABLE_LINUX_ACCOUNTS=1 需要 Web 服务以 root 身份运行",
);
}
await runCommand("id", ["-u", accountId], { allowFailure: true }).then(
async (result) => {
if (result.code === 0) return;
await runCommand("useradd", [
"-m",
"-d",
`/home/${accountId}`,
"-s",
"/bin/bash",
accountId,
]);
},
);
await runCommand("chpasswd", [], {
input: `${accountId}:${password}\n`,
});
}
export async function chownAccountPath(
accountId: string,
targetPath: string,
recursive = false,
) {
if (!linuxAccountsEnabled()) return;
await runCommand("chown", [
...(recursive ? ["-R"] : []),
accountId,
targetPath,
]);
}
function runCommand(
command: string,
args: string[],
options: { input?: string; allowFailure?: boolean } = {},
) {
return new Promise<{ code: number; stdout: string; stderr: string }>(
(resolve, reject) => {
const child = spawn(command, args, {
stdio: ["pipe", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
child.stdout.on("data", (chunk) => {
stdout += chunk.toString();
});
child.stderr.on("data", (chunk) => {
stderr += chunk.toString();
});
child.on("error", reject);
child.on("close", (code) => {
const exitCode = code ?? 1;
if (exitCode !== 0 && !options.allowFailure) {
reject(
new Error(
`${command} ${args.join(" ")} 失败:${stderr || stdout || exitCode}`,
),
);
return;
}
resolve({ code: exitCode, stdout, stderr });
});
if (options.input) child.stdin.write(options.input);
child.stdin.end();
},
);
}
@@ -0,0 +1,197 @@
# 01. 基座 Runtime 架构
![基座 Runtime 架构](assets/01-base-runtime.png)
## 1. 基座解决的问题
基座不绑定某一个业务流程。它提供的是通用 Agent runtime
- 多用户 Web 入口。
- 多会话状态管理。
- 模型选择和 OpenAI-compatible 调用。
- Tool registry 和 tool handler 执行。
- Skill 发现、启用和提示词注入。
- 当前会话工作区。
- 运行态、事件流、活动区和刷新恢复。
- 用户记忆和 Skill 记忆后台。
业务能力例如 `product-data``online-mining-v2``label-master` 都跑在这个基座上。
## 2. 文字架构图
```text
浏览器 / Web UI
|
| 用户消息、模型选择、Skill 启用、文件面板、停止运行
v
frontend/app
|
| /api/chat、/api/claw/*、/admin
v
backend/api/server.py
|
| 账号配置、会话目录、run manager、run_state_store、memory_manager
v
LocalCodingAgent
|
| build_session、prompt sections、tool_specs、Agent loop
v
OpenAICompatClient
|
| messages + tools -> model backend
v
模型
|
| assistant text 或 tool_calls
v
Tool Runtime
|
| read/write/python_exec/data_agent/MCP/search/bash
v
session workspace
|
| input / scratchpad / output / session.json
v
前端活动区和文件面板
运行结束后:
AgentRunResult
-> session_store 持久化
-> memory_manager.enqueue_interaction
-> memory worker 异步整理
-> 下一轮 render_injection 注入
```
## 3. 关键代码入口
### 3.1 Web 后端入口
主要文件:
```text
backend/api/server.py
```
关键职责:
- 维护账号和会话配置。
- 创建或复用 `LocalCodingAgent`
- 给 Agent 注入 `runtime_context`、记忆、Jupyter 上下文。
- 记录 run event,并通过 NDJSON streaming 返回前端。
- 运行结束后写入 elapsed、标题、memory event。
关键函数和位置:
```text
backend/api/server.py:691 enabled_skill_names(...)
backend/api/server.py:822 _build_agent(...)
backend/api/server.py:853 agent_for(...)
backend/api/server.py:866 run_lock_for(...)
backend/api/server.py:1720 /api/chat 运行链路开始
backend/api/server.py:1739 memory_manager.render_injection(...)
backend/api/server.py:1751 emit_agent_event(...)
backend/api/server.py:1897 序列化运行结果和 elapsed
backend/api/server.py:1920 memory_manager.enqueue_interaction(...)
backend/api/server.py:2034 StreamingResponse NDJSON
```
### 3.2 Agent Runtime
主要文件:
```text
src/agent_runtime.py
```
关键职责:
- 初始化 tool registry、plugin runtime、MCP runtime、search runtime 等。
- 新会话用 `run(...)`,旧会话用 `resume(...)`
-`_run_prompt(...)` 中执行完整 Agent loop。
- 维护 usage、cost、tool_calls、events、file_history。
- 在结束时持久化 session。
关键函数和位置:
```text
src/agent_runtime.py:158 class LocalCodingAgent
src/agent_runtime.py:195 __post_init__
src/agent_runtime.py:413 run(...)
src/agent_runtime.py:441 resume(...)
src/agent_runtime.py:520 _run_prompt 主链路
src/agent_runtime.py:552 tool_specs = [tool.to_openai_tool() ...]
src/agent_runtime.py:619 for turn_index in range(...)
src/agent_runtime.py:1008 遍历模型返回的 tool_calls
src/agent_runtime.py:1490 _query_model(...)
```
### 3.3 系统提示词
主要文件:
```text
src/agent_prompting.py
```
关键职责:
- 定义中控 Agent 身份。
- 注入工具使用策略。
- 注入工作空间边界。
- 注入 Skill 列表。
- 注入 ask_user 等 runtime 指导。
关键函数和位置:
```text
src/agent_prompting.py:135 get_intro_section
src/agent_prompting.py:155 get_doing_tasks_section
src/agent_prompting.py:182 get_actions_section
src/agent_prompting.py:196 get_workspace_boundary_section
src/agent_prompting.py:210 get_using_your_tools_section
src/agent_prompting.py:274 get_skill_guidance_section
src/agent_prompting.py:414 get_ask_user_guidance_section
```
## 4. 基座的分层职责
```text
Web UI
负责交互、展示、文件面板、活动区、Skill 勾选、管理后台。
Backend API
负责账号、会话、模型配置、运行态、服务端事件流。
Agent Runtime
负责 Agent loop、模型调用、工具调用、预算和持久化。
Prompting
负责把规则、工具、公约、Skill 列表转成模型可见上下文。
Tool Runtime
负责稳定执行动作,并把结果结构化回传给模型。
Skill System
负责让业务流程、知识、脚本可被 Agent 发现和使用。
Workspace
负责隔离每个用户和每个会话的输入、临时文件和交付产物。
Memory Worker
负责从交互历史中异步整理长期偏好和 Skill 使用经验。
```
## 5. 设计边界
基座只应该承载跨业务复用的稳定能力,例如:
- 工具执行。
- 会话状态。
- 运行态事件。
- 工作区路径。
- Skill 发现和启用。
- 模型适配。
- 记忆后台。
业务流程不应该写死在基座里。数据生成、线上挖掘、标签判断等变化快的流程应该沉到 Skill;确定性脚本应该放在对应 Skill 的 `scripts/` 下。
@@ -0,0 +1,246 @@
# 02. Agent Loop 执行机制
![Agent Loop 执行机制](assets/02-agent-loop.png)
## 1. Agent Loop 的基本形态
Agent 每轮不是只调用一次模型,而是一个循环:
```text
用户输入
-> 构造 session 和 prompt
-> 调模型
-> 模型返回 assistant text 或 tool_calls
-> 如果没有 tool_calls:输出最终回复,结束
-> 如果有 tool_calls:执行工具
-> 工具结果写回 session
-> 下一轮模型继续读取工具结果
-> 直到最终回复、预算超限、取消、max_turns 或 review 停止
```
这个循环允许 Agent 做“观察-执行-再观察”的任务,例如:
- 先读文件,再决定是否需要抽取。
- 先生成 plan,等待用户 review。
- 先运行脚本,再根据校验结果修复。
- 先查询线上数据,再抽样展示。
## 2. 新会话和恢复会话
新会话入口:
```text
src/agent_runtime.py:413 run(...)
```
关键动作:
```text
1. 清理当前 managed_agent_id 和 resume_source_session_id。
2. 创建 session_id。
3. 创建 scratchpad_directory。
4. 绑定 plan_runtime/task_runtime 到 scratchpad。
5. 调 _run_prompt(...)
6. 累计 usage。
```
恢复会话入口:
```text
src/agent_runtime.py:441 resume(...)
```
关键动作:
```text
1. 从 StoredAgentSession 恢复 AgentSessionState。
2. 回放 file_history 和 compaction 信息。
3. 设置 active_session_id 和 last_session_path。
4. 恢复 plugin state。
5. 复用已有 scratchpad_directory。
6. 调 _run_prompt(...)
```
这解释了为什么刷新页面、回到旧会话后,Agent 理论上可以继续同一个 session 的上下文,而不是新建一个任务。
## 3. `_run_prompt` 主链路
核心位置:
```text
src/agent_runtime.py:520 _run_prompt 主体
```
主链路关键步骤:
```text
1. slash command 预处理。
2. hook policy / plugin hook 修改 prompt。
3. agent_manager.start_agent(...) 记录运行。
4. 新建或复用 AgentSessionState。
5. runtime_context prepend 到模型可见的用户消息。
6. session.append_user(...)。
7. tool_context 注入 scratchpad、plan_runtime、task_runtime。
8. 生成 tool_specs。
9. 初始化 usage、cost、tool_calls、events。
10. 进入 turn loop。
```
对应代码点:
```text
src/agent_runtime.py:523 agent_manager.start_agent(...)
src/agent_runtime.py:531 session = base_session or build_session(...)
src/agent_runtime.py:539 _prepend_runtime_context(...)
src/agent_runtime.py:543 session.append_user(...)
src/agent_runtime.py:546 replace(self.tool_context, scratchpad_directory=...)
src/agent_runtime.py:552 tool_specs = [tool.to_openai_tool() ...]
src/agent_runtime.py:582 stream_events = _RuntimeEventBuffer(event_sink)
src/agent_runtime.py:619 for turn_index in range(...)
```
## 4. 模型调用和 tool_calls 判断
模型调用入口:
```text
src/agent_runtime.py:1490 _query_model(...)
```
非流式路径中:
```text
turn = self.client.complete(
session.to_openai_messages(),
tool_specs,
output_schema=...
)
```
模型能否返回 `tool_calls` 取决于:
- 当前 `messages`
- 系统提示词。
- Skill 列表。
- 工具描述和参数 schema。
- 模型自身 tool calling 能力。
基座不会强制某个工具被调用。基座只把工具能力暴露给模型,并在模型返回 `tool_calls` 后负责执行。
## 5. 没有 tool_calls 时
如果模型本轮没有工具调用:
```text
src/agent_runtime.py:763 if not turn.tool_calls
```
后续可能有三种情况:
1. 直接输出最终回复。
2. 如果模型输出被截断,自动追加 continuation prompt。
3. 如果达到 continuation 限制,则追加提示并结束。
最终会:
```text
session.append_assistant(...)
_append_final_text_stream_events(...)
AgentRunResult(...)
_persist_session(...)
```
## 6. 有 tool_calls 时
如果模型返回工具调用:
```text
src/agent_runtime.py:1008 for tool_call in turn.tool_calls
```
每个工具调用会:
```text
1. tool_calls 计数 +1。
2. 检查预算。
3. session.start_tool(...) 写入工具开始消息。
4. stream_events 追加 tool_start。
5. 根据工具名执行 handler。
6. 工具结果写回 session。
7. stream_events 追加工具结果。
8. 下一轮模型读取工具结果继续判断。
```
关键代码点:
```text
src/agent_runtime.py:1054 session.start_tool(...)
src/agent_runtime.py:1060 stream_events.append(type='tool_start')
src/agent_tools.py:60 execute_tool(...)
src/agent_tools.py:76 execute_tool_streaming(...)
```
## 7. 为什么 review 可以暂停流程
review 门禁不是特殊 UI 魔法,而是 Agent loop 的自然结果:
1. Skill 要求模型在某一步调用 review 工具。
2. 工具创建 pending state,并返回需要展示的信息。
3. Agent 生成回复,告诉用户需要确认。
4. 当前 run 结束。
5. 用户下一轮回复“确认”。
6. Agent resume 旧 session,调用 confirm 工具。
7. 后续流程继续。
例如 `product-data`
```text
data_agent_prepare_generation_goal
-> pending goal
-> 停止等待用户确认
data_agent_confirm_generation_goal
-> confirmed_goal_id
-> data_agent_prepare_generation_plan
-> pending plan
-> 停止等待用户确认
data_agent_confirm_generation_plan
-> confirmed_plan_id
-> 允许生成 draft 和转换 records
```
## 8. 预算、取消和 max_turns
基座会在模型调用前后、工具请求前检查预算:
```text
src/agent_runtime.py:592 initial_budget = self._check_budget(...)
src/agent_runtime.py:732 budget_after_model = self._check_budget(...)
src/agent_runtime.py:1014 budget_after_tool_request = self._check_budget(...)
```
如果达到最大轮次:
```text
src/agent_runtime.py:1440 _build_max_turns_output(...)
```
输出会包含最后一次阶段说明,提醒用户可以继续补充指令。
取消由后端 run manager 和 tool process registry 处理。Web 点停止后,后端将 run 标记取消,并让工具执行上下文感知 cancel_event。
## 9. 事件流和前端活动区
Agent loop 内会不断追加 `stream_events`,后端用 `event_sink` 把事件推给前端。
后端关键代码:
```text
backend/api/server.py:1751 emit_agent_event(event)
backend/api/server.py:1756 state.run_manager.record_event(...)
backend/api/server.py:1758 state.run_state_store.record_event(...)
backend/api/server.py:2034 StreamingResponse(..., media_type='application/x-ndjson')
```
前端活动区展示的阶段说明、工具开始、工具完成、最终文本,本质上都来自这些 runtime events 或 session replay。
@@ -0,0 +1,298 @@
# 03. 工具体系和 Tool Handler
![工具体系和 Tool Handler](assets/03-tools.png)
## 1. 工具体系的职责
Tool 是执行层。模型只能“请求调用工具”,不能直接执行工具。
后端根据工具名找到 handler,校验参数,执行动作,并把结果写回 session。
工具适合承载:
- 稳定文件读写。
- Python 执行。
- Python 包安装。
- 数据格式转换和校验。
- review 状态机。
- 外部系统访问。
- 需要权限、取消、路径约束或审计的动作。
## 2. Tool registry 构成
入口:
```text
src/agent_tools.py:105 default_tool_registry()
```
它会合并多类工具:
```text
build_file_tools(...)
build_execution_tools(...)
LSP / web_fetch / search / account / config / task / team / workflow 等
Skill 工具
data_agent 工具
```
关键片段:
```text
src/agent_tools.py:107 build_file_tools
src/agent_tools.py:116 build_execution_tools
src/agent_tools.py:1012 AgentTool(name='Skill')
src/agent_tools.py:1033 build_data_agent_tools
```
返回结果是:
```python
return {tool.name: tool for tool in tools}
```
## 3. Tool spec 注入模型
在 Agent loop 中:
```text
src/agent_runtime.py:552
tool_specs = [tool.to_openai_tool() for tool in self.tool_registry.values()]
```
每个工具包含:
```text
name
description
parameters JSON schema
handler
```
模型看到的是 name、description、parameters。handler 不暴露给模型,只在后端执行。
## 4. Tool handler 执行路径
工具执行入口:
```text
src/agent_tools.py:60 execute_tool(...)
src/agent_tools.py:76 execute_tool_streaming(...)
```
执行逻辑:
```text
1. 从 tool_registry 取 tool。
2. 找不到则返回 Unknown tool。
3. bash 走 streaming。
4. 其他工具调用 tool.execute(arguments, context)。
5. 工具结果序列化回模型和前端。
```
## 5. 文件工具
定义位置:
```text
src/agent_tool_specs/files.py
```
主要工具:
- `list_dir`
- `read_file`
- `write_file`
- `edit_file`
- `notebook_edit`
- `glob_search`
- `grep_search`
### 5.1 `write_file` 的边界
`write_file` 当前明确定位为“小文件工具”:
```text
src/agent_tool_specs/files.py
Write or append a SMALL UTF-8 file inside the workspace.
```
适合:
- 短 Markdown。
- 少量配置。
- 小 JSON。
- 少量 JSONL。
- 小 CSV。
不适合:
- 长 Python 脚本。
- 大 JSON。
- JSONL 数据集。
- 长 CSV。
- 大段包含引号和换行的内容。
原因是模型生成工具参数时,需要把内容嵌入 JSON 参数。长文本、引号、换行会显著增加非法 JSON 参数概率。
因此系统提示词也要求:
```text
复杂写文件优先用 python_exec 通过 pathlib/json/csv 写入。
```
## 6. Python 执行工具
定义位置:
```text
src/agent_tool_specs/execution.py
```
主要工具:
- `python_exec`
- `python_package`
- `bash`
- `sleep`
### 6.1 `python_exec`
定位:
```text
结构化文件分析、JSON/JSONL 处理、批量校验、数据抽样、快速计算。
```
支持两种模式:
```text
code
一次性 Python 代码。
script_path
调用项目或 Skill 内已有脚本。
```
重要约束:
- 不用 `bash python ...`
- 不在 `python_exec.code` 里用 subprocess 二次调用 Python 脚本。
- 临时文件写入 `PYTHON_EXEC_SCRATCHPAD`
- 交付产物写入 session/output。
### 6.2 `python_package`
定位:
```text
给当前用户独立 Python venv 安装缺失包。
```
典型场景:
- `pandas`
- `pyarrow`
- `openpyxl`
- `elasticsearch`
- `urllib3`
设计上避免安装到项目 `.venv` 或系统 Python。
### 6.3 `bash`
`bash` 是兜底工具,不是默认 Python 执行方式。
适合:
- git 只读检查。
- 系统命令。
- 进程控制。
- 用户确认后的依赖安装或服务管理。
不适合:
- Python 数据分析。
- JSON/CSV 转换。
- 包安装。
## 7. 路径解析和 session 路由
关键实现:
```text
src/agent_tools.py:1589 _data_agent_output_path(...)
src/agent_tools.py:1624 _data_agent_session_output_root(...)
src/agent_tools.py:1630 _resolve_path(...)
src/agent_tools.py:1658 _session_logical_path(...)
src/agent_tools.py:1685 _execution_cwd(...)
```
逻辑路径会被映射到当前 session:
```text
output/... -> session/output/...
outputs/... -> session/output/...
scratchpad/... -> session/scratchpad/...
scratch/... -> session/scratchpad/...
input/... -> session/input/...
inputs/... -> session/input/...
```
`python_exec` 的执行 cwd 优先是当前会话 scratchpad
```text
src/agent_tools.py:1685 _execution_cwd(...)
```
## 8. data_agent 工具
声明位置:
```text
src/agent_tool_specs/data_agent.py
```
handler 注册位置:
```text
src/agent_tools.py:1033 build_data_agent_tools(...)
```
主要工具分两类:
### 8.1 前链路和 review 状态机
- `data_agent_load_input_sources`
- `data_agent_render_source_context`
- `data_agent_extract_case_evidence`
- `data_agent_prepare_generation_goal`
- `data_agent_confirm_generation_goal`
- `data_agent_prepare_generation_plan`
- `data_agent_show_generation_plan`
- `data_agent_update_generation_plan`
- `data_agent_confirm_generation_plan`
这类工具当前仍属于平台工具,因为它们维护 pending/confirmed 状态,以及产品数据生成的 review 门禁。
### 8.2 历史格式转换工具
- `data_agent_normalize_dataset_draft`
- `data_agent_validate_dataset_records`
- `data_agent_export_dataset_records`
- `data_agent_export_training_jsonl`
- `data_agent_export_planning_eval_csv`
这部分能力已经逐步迁移到 `skills/product-data/scripts/`,平台工具更多是历史兼容和适配层。
## 9. 工具设计原则
当前工具体系的技术取舍:
```text
模型负责决策。
工具负责执行。
Skill 负责流程经验。
脚本负责可迁移确定性能力。
```
工具描述要足够明确,否则模型会选错工具;参数 schema 要尽量简单,否则不同模型后端可能不兼容。
@@ -0,0 +1,256 @@
# 04. Skill 体系和能力包约定
![Skill 体系和能力包约定](assets/04-skills.png)
## 1. Skill 的定位
Skill 是经验层。它不是单纯 prompt,也不是单纯脚本。
一个 Skill 应该回答:
- 什么场景触发。
- 输入材料是什么。
- Agent 应该按什么流程做。
- 哪些地方必须让用户 review。
- 应该调用哪些工具或脚本。
- 产物应该写到哪里。
- 哪些做法是禁止的。
稳定可执行逻辑不应该长期写在 Skill 文本里,而应该进入:
```text
skills/<skill-name>/scripts/
```
或者平台级工具。
## 2. Skill loader 实现
关键文件:
```text
src/bundled_skills.py
```
项目级 Skill 目录:
```text
skills/<skill-name>/SKILL.md
```
核心数据结构:
```text
src/bundled_skills.py:28 BundledSkill
```
字段:
```text
name
description
when_to_use
aliases
allowed_tools
user_invocable
source
path
get_prompt
```
## 3. SKILL.md 解析
解析逻辑:
```text
src/bundled_skills.py:147 _parse_front_matter
src/bundled_skills.py:176 _load_directory_skill
```
`SKILL.md` 必须有 frontmatter
```yaml
---
name: product-data
description: 从产品/标签定义、手写边界规则或示例 query 中提取标签边界...
when_to_use: 当用户提供产品定义、标签规则...
aliases: definition-data, label-data
allowed_tools: read_file, write_file, python_exec
---
```
解析后:
- frontmatter 进入 `BundledSkill` 元数据。
- body 作为真正的 Skill prompt。
- 如果调用 Skill 时带 args,会追加到 `## Invocation Arguments`
对应实现:
```text
src/bundled_skills.py:138 _directory_skill_prompt
```
## 4. Skill 发现顺序
项目 Skill 发现入口:
```text
src/bundled_skills.py:199 load_directory_skills
src/bundled_skills.py:215 load_project_skills
```
系统提示词中可见 Skill 列表由:
```text
src/bundled_skills.py:270 format_skills_for_system_prompt
```
生成。
Web 后端会按当前账号和 session 配置计算启用 Skill
```text
backend/api/server.py:691 enabled_skill_names
backend/api/server.py:706 set_skill_enabled
backend/api/server.py:738 set_all_skills_enabled
```
这意味着:
- Skill 可以存在于项目中,但不一定对某个 session 启用。
- 启用状态影响系统提示词里的 Skill 列表。
- 被禁用的 Skill 不应该被模型主动选择。
## 5. Skill 工具
Skill 本身也是一个工具:
```text
src/agent_tools.py:1012 AgentTool(name='Skill')
```
模型调用:
```json
{
"skill": "product-data",
"args": "用户原始需求或显式参数"
}
```
执行后,Skill body 会被加入对话,让模型按 Skill 中的流程继续做任务。
## 6. Skill 和 Agent Loop 的关系
Skill 不会替代 Agent loop,而是改变 Agent loop 的下一步决策依据。
典型模式:
```text
用户提出任务
-> 模型从 Skill 列表中选择某个 Skill
-> 调用 Skill 工具
-> Skill.md 正文进入上下文
-> 模型按 Skill 指令调用 read_file/python_exec/data_agent 等工具
-> 工具结果进入上下文
-> 模型继续按 Skill 流程推进
```
因此 Skill 的好坏直接影响:
- 模型能否召回正确能力。
- 是否会过早执行。
- 是否能在 review 门禁停下来。
- 是否能使用正确工具而不是手写不稳定逻辑。
## 7. 推荐 Skill 目录结构
```text
skills/<skill-name>/
SKILL.md
README.md
knowledge/
scripts/
examples/
schemas/
tools.yaml
requirements.txt
```
各部分职责:
```text
SKILL.md
运行时入口,写流程、门禁、工具调用方式和禁止事项。
README.md
给维护者看的说明,不一定进入模型上下文。
knowledge/
业务知识、标签规则、字段说明、边界案例。
scripts/
确定性脚本,优先通过 python_exec.script_path 执行。
examples/
示例输入输出,用于回归和讲解。
schemas/
JSON schema 或字段约定。
tools.yaml
描述 portable scripts 如何注册为工具,便于迁移到其他 Agent。
requirements.txt
Skill 脚本的 Python 依赖。
```
## 8. Skill 更新
Web 后端提供 Skill 更新能力:
```text
backend/api/server.py:765 sync_skills_from_git
```
核心行为:
```text
1. 检查当前目录是否是 git 仓库。
2. 检查 tracked 文件是否干净。
3. git fetch origin。
4. git pull --ff-only origin 当前分支。
5. 清理当前账号 agent cache。
6. 重新读取 get_bundled_skills。
```
这让新增或修改 Skill 后,不一定需要重启服务才能让 Skill 列表刷新。
## 9. Skill 设计边界
适合写在 Skill
- 工作流。
- 何时提问。
- 何时 review。
- 哪些工具优先。
- 输出位置约定。
- 常见失败经验。
不适合长期写在 Skill
- 大段可检索知识。
- 复杂代码。
- 格式转换。
- 查询外部系统的具体实现。
- 需要校验的稳定数据结构。
这些应该分别放到:
```text
knowledge/
scripts/
schemas/
platform tools
```
@@ -0,0 +1,243 @@
# 05. 会话工作区、运行态和记忆
![会话工作区、运行态和记忆](assets/05-workspace-memory-observability.png)
## 1. 会话工作区
每个用户、每个会话都有独立目录:
```text
.port_sessions/accounts/<account_id>/sessions/<session_id>/
input/
scratchpad/
output/
session.json
```
目录职责:
```text
input/
用户上传或明确提供的输入资料。
scratchpad/
临时脚本、中间文件、抽样缓存、断点记录。
output/
最终交付产物。
session.json
会话消息、工具调用、usage、events、file_history、runtime metadata。
```
## 2. 工作区路径路由
路径解析实现:
```text
src/agent_tools.py:1658 _session_logical_path
```
逻辑路径:
```text
output/... -> session/output/...
outputs/... -> session/output/...
scratchpad/... -> session/scratchpad/...
scratch/... -> session/scratchpad/...
input/... -> session/input/...
inputs/... -> session/input/...
```
Python 执行 cwd
```text
src/agent_tools.py:1685 _execution_cwd
```
优先使用当前 session scratchpad。这是为了避免临时脚本和缓存污染项目根目录。
## 3. 平台代码写保护
相关实现:
```text
src/agent_tools.py:1118 _PLATFORM_READONLY_DIRS
src/agent_tools.py:1698 _is_platform_code_path
src/agent_tools.py:1708 _ensure_not_platform_code_write
```
当前平台目录:
```text
src
backend
frontend
scripts
```
在数据 Agent 会话中默认视为只读。除非用户明确进入平台开发任务,否则业务任务不应修改平台代码。
## 4. Run 状态和活动区
后端在 `/api/chat` 运行时创建 run record
```text
backend/api/server.py:1720 run_record = state.run_manager.start(...)
backend/api/server.py:1722 state.run_state_store.start(...)
```
运行事件通过 `emit_agent_event` 记录:
```text
backend/api/server.py:1751 emit_agent_event
backend/api/server.py:1756 run_manager.record_event
backend/api/server.py:1758 run_state_store.record_event
```
返回前端:
```text
backend/api/server.py:2034 StreamingResponse
```
前端活动区看到的内容主要来自:
- `run_started`
- `tool_start`
- `tool_result`
- 模型阶段说明
- final text stream events
- run finish/error/cancel 状态
## 5. 会话持久化
运行结束后,后端序列化结果:
```text
backend/api/server.py:2056 _serialize_run_result
backend/api/server.py:2069 _normalize_transcript_entry
```
会话读取:
```text
backend/api/server.py:2094 _serialize_stored_session
```
`agent_runtime` 在多个结束路径都会调用:
```text
_persist_session(session, result)
```
这让刷新后可以恢复:
- 用户消息。
- assistant 文本。
- tool_calls。
- tool result。
- elapsed。
- file_history。
## 6. 记忆体系
实现位置:
```text
src/personal_memory.py
```
### 6.1 文件和数据库
```text
memory.db
user.md
skills/<skill-name>.md
```
常量:
```text
src/personal_memory.py:26 MEMORY_DB_FILENAME
src/personal_memory.py:27 USER_MEMORY_FILENAME
src/personal_memory.py:28 SKILL_MEMORY_DIRNAME
```
### 6.2 注入逻辑
```text
src/personal_memory.py:102 render_injection
```
注入规则:
```text
1. 读取 user.md。
2. 根据 enabled_skill_names 读取对应 skills/<skill>.md。
3. 拼成 # 个性化记忆。
4. 如果用户本轮要求冲突,以本轮要求为准。
```
后端调用:
```text
backend/api/server.py:1739 memory_manager.render_injection(...)
```
### 6.3 入队逻辑
运行结束后:
```text
backend/api/server.py:1920 memory_manager.enqueue_interaction(...)
```
记忆模块内:
```text
src/personal_memory.py:138 enqueue_interaction
src/personal_memory.py:635 detect_memory_signals
```
会检测:
- 显式记忆词:记住、以后、下次、默认、总是、不要、应该、固定。
- 纠错词:不对、不是这样、格式错、之前说过、还是不行。
- Skill/工具/流程/格式相关表述。
- 工具参数非法 JSON 等工具经验。
### 6.4 后台整理
核心逻辑:
```text
src/personal_memory.py:378 _consolidate_events
src/personal_memory.py:419 _generate_memory_updates
src/personal_memory.py:471 _fallback_memory_updates
```
设计取舍:
- 主链路不直接生成记忆。
- 事件先进入 SQLite 队列。
- 后台 worker 批量整理。
- LLM 失败时有 fallback 规则。
- Markdown 是最终可编辑记忆正文。
## 7. 可观测性设计
当前可观测性来自三个层次:
```text
运行态
run_manager + run_state_store,支持运行中刷新、停止、恢复活动区。
会话态
session.json,保存完整消息和工具调用。
产物态
session/input、scratchpad、output,文件面板可查看和下载。
```
这个设计让用户不仅看到最终回复,也能看到 Agent 做了什么、文件在哪里、失败在哪个工具或阶段。
@@ -0,0 +1,292 @@
# 06. product-data Skill 实现
![product-data Skill 实现](assets/06-product-data.png)
## 1. 定位
`product-data` 是数据开发链路的核心 Skill,负责:
```text
产品定义 / 标签规则 / 手写边界 / 示例 query / badcase
-> 输入文本化
-> generation goal
-> 用户 review
-> generation plan
-> 用户确认数量、标签、边界、路径
-> dataset draft text
-> canonical records
-> validate
-> export records / table / training / eval
```
位置:
```text
skills/product-data/SKILL.md
skills/product-data/knowledge/
skills/product-data/scripts/
```
## 2. Skill 目录结构
当前 `SKILL.md` 中定义的能力组织:
```text
skills/product-data/
SKILL.md
tools.yaml
requirements.txt
knowledge/
dataset_draft_v1.md
canonical_record_v1.md
portable_skill_contract.md
schemas/
scripts/
normalize_dataset_draft.py
validate_dataset_records.py
export_dataset_records.py
export_dataset_table.py
export_training_jsonl.py
export_planning_eval_csv.py
```
分工:
```text
SKILL.md
流程协议、门禁、工具调用顺序、禁止事项。
knowledge/
数据草稿、canonical record 和 portable skill contract。
scripts/
确定性转换、校验和导出。
data_agent_* 平台工具
负责输入文本化和 review 状态机。
```
## 3. 输入类型
Skill 将输入分成三类:
```text
文件定义型
产品定义、标签定义、路由规则、表格、Markdown、CSV。
手写规则型
用户直接描述边界,例如“找附近美食给餐饮服务,导航去某地给地图导航”。
示例归纳型
用户只给 query/example/badcase,需要先归纳边界和标签倾向。
```
三类输入最后统一整理成:
```text
dataset_label
target / target_definitions
plan_hint
coverage
exclusions
open_questions
source_refs
complex 规则
```
这一步称为 `generation_goal`
## 4. Review 门禁
`product-data` 有两个门禁模式。
### 4.1 一次确认模式
适用:
- 用户直接给出清晰手写规则。
- target 表达明确。
- 用户已经希望生成数据。
链路:
```text
data_agent_prepare_generation_plan(direct_review=true)
-> 展示目标 + 数量 + 路径
-> 等待“确认,开始生成”
```
### 4.2 两段确认模式
适用:
- 用户提供文件、表格、badcase、长文档。
- 标签、边界、字段有歧义。
- 需要先从资料中抽取 generation goal。
链路:
```text
data_agent_load_input_sources
-> data_agent_render_source_context
-> Agent 整理 generation_goal
-> data_agent_prepare_generation_goal
-> 用户确认目标
-> data_agent_confirm_generation_goal
-> data_agent_prepare_generation_plan
-> 用户确认计划
-> data_agent_confirm_generation_plan
```
## 5. 和 Agent Loop 的关系
`product-data` 明确利用 Agent loop 做分阶段控制。
```text
第一轮:
模型选择 product-data
调输入工具
调 prepare_generation_goal 或 prepare_generation_plan
输出 review 信息
停止
第二轮:
用户确认目标或计划
Agent resume session
调 confirm 工具
继续下一阶段
第三轮:
用户确认计划
Agent 生成 dataset draft
每批调用 normalize 脚本
调 validate 脚本
调 export 脚本
输出最终文件路径
```
重点是:确认状态不是靠自由文本记忆,而是由工具维护 `confirmed_goal_id``confirmed_plan_id`
## 6. 数据格式设计
### 6.1 dataset draft text
面向模型生成,要求模型用较低结构负担描述:
- 用户 / 小爱 对话。
- 当前 query。
- target。
- complex。
- 场景说明。
设计目标是降低模型直接写 JSONL 的难度。
### 6.2 canonical record
面向工具处理,字段稳定。
用于:
- 校验结构。
- 导出流转 CSV。
- 导出训练 JSONL。
- 导出评测 planningPrompt CSV。
### 6.3 complex 独立维度
`complex` 不属于 target。
内部保存:
```text
complex: false
target: Agent(tag="地图导航")
```
训练输出组合:
```text
complex=false
Agent(tag="地图导航")
```
评测 CSV 里:
```text
code标签: Agent(tag="地图导航")
complex: FALSE
```
## 7. 脚本链路
生成式数据确认后,Skill 要求使用 `python_exec.script_path` 调脚本。
典型顺序:
```text
normalize_dataset_draft.py
输入 draft + confirmed_plan_id
输出/追加 scratchpad/normalized_records.jsonl
validate_dataset_records.py
读取 normalized_records.jsonl
校验字段、target、complex、时间戳、多轮上下文
export_dataset_records.py
导出 output/records.jsonl
同时生成 output/records.csv
export_training_jsonl.py
导出 output/training.jsonl
export_planning_eval_csv.py
导出 output/eval_planning.csv
```
为什么不用 `write_file`
- records、CSV、JSONL 都是强格式数据。
- 模型手写容易出错。
- 脚本能统一 timestamp、prev_session、context、complex/target 组合。
## 8. 输出约束
固定逻辑输出:
```text
output/records.jsonl
output/records.csv
output/training.jsonl
output/eval_planning.csv
```
通过路径路由,实际写入:
```text
.port_sessions/accounts/<account>/sessions/<session>/output/
```
Skill 明确禁止:
- 按数据集名创建随机子目录。
- 把 records 写到项目根目录。
-`write_file` 手写最终 JSONL/CSV。
- 在未确认 plan 前生成数据。
## 9. 设计边界
`product-data` 负责:
- 数据目标对齐。
- 生成计划 review。
- dataset draft 生成协议。
- canonical records 转换和导出。
不负责:
- 判断所有标签知识。
- 查询线上数据。
- ELK 检索。
- 模型训练或 git 数据仓库提交。
标签知识应由 `label-master` 辅助,线上数据由 `online-mining-v2` 或相关 Skill 获取。
@@ -0,0 +1,177 @@
# 07. online-mining-v2 Skill 实现
![online-mining-v2 Skill 实现](assets/07-online-mining-v2.png)
## 1. 定位
`online-mining-v2` 用于从线上 ELK 日志中挖掘 case,并把候选样本转换成 `product-data` 兼容的标准数据。
典型链路:
```text
线上挖掘需求
-> 明确目标标签、complex、筛选特征
-> 探索 ELK 表字段
-> 搜索候选
-> 抽样 review
-> 调整策略
-> 直接转 canonical records
-> 或转 product-data 生成补数
```
位置:
```text
skills/online-mining-v2/SKILL.md
skills/online-mining-v2/knowledge/
skills/online-mining-v2/scripts/
```
## 2. 能力组织
`online-mining-v2` 不新增平台注册工具。它把能力放在 Skill 目录下,通过 `python_exec.script_path` 执行。
```text
scripts/
online_mining_common.py
elk_profile_index.py
elk_search_cases.py
elk_fetch_by_request_ids.py
elk_join_request_logs.py
build_dataset_draft.py
build_online_records.py
```
这样做的原因:
- ELK 查询逻辑属于该 Skill 的业务能力。
- 迁移到其他 Agent 时可以直接跑脚本。
- 平台只需要提供通用 `python_exec``python_package`
## 3. 默认数据源
当前重点支持两类索引:
```text
pre-processing*
前处理日志。
适合拿模型 prompt、模型输出、候选 domain、excellent_domains_result。
arch-flat-nlp-log-f-*
主 NLP 日志。
适合拿 query、domain、func、request_id、device_id、device、tts/text/to_speak。
```
Skill 文档中会引导 Agent
- 先用 `elk_profile_index.py` 看字段和样本。
- 再用 `elk_search_cases.py` 根据 query/domain/model output 搜索。
- 必要时用 request_id 做双表 join。
## 4. 和 Agent Loop 的关系
这个 Skill 的交互不是“一次查询结束”,而是策略迭代:
```text
用户描述需求
-> Agent 整理目标标签和筛选特征
-> 如缺目标标签或字段,先问用户
-> python_exec 调 elk_profile_index.py
-> 模型观察字段和样本
-> python_exec 调 elk_search_cases.py
-> 模型观察候选质量
-> 抽样展示给用户 review
-> 用户说哪里不对
-> 调整 filters / regex / domain / model output 条件
-> 再搜索
```
Agent loop 的价值在这里很明显:每次工具结果都会进入上下文,模型可以基于真实候选调整策略。
## 5. 两条分支
`online-mining-v2` 最重要的设计是强制区分两个分支。
### 5.1 直接线上样本分支
适用:
```text
用户想把线上筛出来的真实 case 作为评测集或专项集。
```
行为:
```text
不生成新 query。
不进入 product-data generation plan。
用 build_online_records.py 直接转 canonical records。
```
典型输出:
```text
output/records.jsonl
output/records.csv
```
### 5.2 补充生成分支
适用:
```text
用户想围绕线上问题扩写更多类似 case。
```
行为:
```text
先分析线上 badcase。
整理错误类型和覆盖目标。
再转 product-data 的 generation goal / plan / draft / records 流程。
```
这条边界避免了旧流程里出现的错误:用户只是想“把候选转样本”,Agent 却误走“生成新数据”。
## 6. 和 product-data 的关系
`online-mining-v2` 后处理复用 `product-data` 的标准:
```text
canonical record v1
records.jsonl
records.csv
training.jsonl
eval_planning.csv
```
复用脚本:
```text
skills/product-data/scripts/normalize_dataset_draft.py
skills/product-data/scripts/validate_dataset_records.py
skills/product-data/scripts/export_dataset_records.py
skills/product-data/scripts/export_training_jsonl.py
skills/product-data/scripts/export_planning_eval_csv.py
```
因此线上挖掘和产品定义生成最终可以进入同一套数据格式。
## 7. 设计边界
`online-mining-v2` 负责:
- ELK 字段探索。
- ELK 条件搜索。
- request_id 补全。
- 候选样本 review。
- 线上候选转 canonical records。
不负责:
- 定义 canonical record 标准。
- 生成全新补数。
- 判断复杂标签知识。
- 训练数据提交。
这些分别交给 `product-data``label-master` 或后续数据仓库 Skill。
@@ -0,0 +1,231 @@
# 08. label-master Skill 实现
![label-master Skill 实现](assets/08-label-master.png)
## 1. 定位
`label-master` 是标签知识和边界分析 Skill。
它不把“给 query 打标签”做成一个黑盒工具,而是让 Agent 逐步读取知识、比较候选、解释依据,并在必要时调用脚本校验最终输出格式。
典型链路:
```text
query / 标签边界问题 / target 校验需求
-> 读取决策流程
-> 读取候选召回索引
-> 找 2-5 个候选标签
-> 读取候选标签卡片
-> 命中混淆时读取边界卡
-> 判断 complex / 多指令 / 自动任务等结构维度
-> 判断输出形态
-> 给出推荐、候选、依据、排除项和不确定点
-> 如要落数据,调用 validate_label_output.py
```
位置:
```text
skills/label-master/SKILL.md
skills/label-master/knowledge/
skills/label-master/scripts/
```
## 2. 知识组织
核心目录:
```text
knowledge/
标签总览.md
决策流程.md
索引/
候选召回索引.md
标签索引.md
维度索引.md
label_manifest.json
判断维度/
复杂度/
多指令/
自动任务/
标注输出形态.md
输出能力/
标签/
边界/
边界索引.md
高频混淆/
领域概览/
迁移记录.md
```
设计原则:
```text
索引先行
不直接读全量标签卡。
维度分离
complex、多指令、自动任务不是业务标签。
标签卡片中文维护
方便人工编辑。
输出能力独立
function、intent、object、Agent 包装放在输出能力层。
边界卡优先人工维护
高频混淆写清楚,不只依赖自动迁移总结。
```
## 3. Agent 使用方式
`label-master` 的关键在于利用 Agent 的多轮阅读和规划能力。
典型 Agent loop
```text
模型选择 label-master
-> read_file knowledge/决策流程.md
-> read_file knowledge/索引/候选召回索引.md
-> read_file 候选标签卡片
-> read_file 高频混淆边界卡
-> 必要时 read_file 判断维度/复杂度/*
-> 必要时 read_file 输出能力/*
-> 输出可 review 判断
```
为什么不做成单个 `classify_query(query) -> label` 工具:
- 标签判断常常需要比较候选和排除项。
- function / intent / Agent 包装要看迁移状态。
- complex、多指令、自动任务是独立维度。
- 人类 review 需要看到依据,而不是只看到最终标签。
因此脚本只做索引和校验,不替代语义判断。
## 4. 输出形态
标签知识中存在多种输出形态:
```text
Agent(tag="xxx")
function program
intent
object + function 组合
多指令 JSON
自动任务 JSON
complex=true/false
```
`complex` 是独立维度,不应该混在 target 里。
例如训练输出可以组合成:
```text
complex=false
Agent(tag="地图导航")
```
但内部判断要拆成:
```text
complex: false
target: Agent(tag="地图导航")
```
## 5. 脚本能力
### 5.1 build_label_manifest.py
用途:
```text
把 Markdown 知识生成机器索引。
```
位置:
```text
skills/label-master/scripts/build_label_manifest.py
```
输出:
```text
skills/label-master/knowledge/索引/label_manifest.json
```
使用场景:
- 新增标签卡。
- 修改输出能力。
- 修改判断维度。
- 修改边界知识。
### 5.2 validate_label_output.py
用途:
```text
校验 target/function/intent/Agent/多指令/自动任务输出格式。
```
位置:
```text
skills/label-master/scripts/validate_label_output.py
```
典型调用:
```text
python_exec(script_path="skills/label-master/scripts/validate_label_output.py", args=["--target", "Agent(tag=\"地图导航\")"])
```
批量校验:
```text
--file output/records.jsonl --field target
```
## 6. 和 product-data 的关系
`product-data` 在需要确认 target 或生成边界数据时,可以读取 `label-master` 的知识。
分工:
```text
label-master
判断标签知识、边界、输出形态、target 合法性。
product-data
做数据生成 review、draft、canonical records、导出。
```
当用户给出模糊标签名时,应该先用 `label-master` 辅助确认:
```text
标签是否存在
使用 Agent 包装还是 function
complex 默认是什么
是否有高频混淆边界
```
再进入 `product-data` 的生成计划。
## 7. 设计边界
`label-master` 负责:
- 标签知识检索。
- 候选标签比较。
- 边界解释。
- 输出形态判断。
- target 格式校验。
不负责:
- 生成数据集。
- 线上日志挖掘。
- 导出训练/评测格式。
- 直接替用户确认争议边界。
@@ -0,0 +1,186 @@
# 09. 外部系统 SkillELK、SQL、模型迭代
![外部系统 Skill 接入模式](assets/09-external-skills.png)
## 1. 这类 Skill 的共同特征
外部系统 Skill 的核心不是复杂 prompt,而是把某个内部系统的调用方式、参数约定、依赖和输出格式打包起来。
典型形态:
```text
SKILL.md
写清楚什么时候用、参数怎么选、哪些动作需要确认。
scripts/
执行真实外部系统调用。
python_exec
作为统一执行入口。
python_package
处理依赖缺失。
output/
查询结果或报表写入当前 session output。
```
## 2. elk-fetch
位置:
```text
skills/elk-fetch/SKILL.md
skills/elk-fetch/elk_query.py
```
定位:
```text
按 request id 或查询条件读取小米内网 ELK 日志。
```
实现方式:
- `SKILL.md` 写明支持的 profile 和查询方式。
- 实际执行必须使用 `python_exec.script_path`
-`elasticsearch``urllib3` 时用 `python_package`
- 不让模型用 bash 手写临时 Python 查询脚本。
典型价值:
```text
把“怎么查 ELK”这类个人经验变成团队共享能力。
```
`online-mining-v2` 的区别:
```text
elk-fetch
更偏单次日志查询和调试。
online-mining-v2
更偏批量挖掘、策略迭代、样本转换。
```
## 3. data-factory-sql
位置:
```text
skills/data-factory-sql/SKILL.md
skills/data-factory-sql/run_sql.py
```
定位:
```text
通过 Kyuubi HTTP API 执行数据工场 SQL,轮询状态并下载 CSV 结果。
```
实现方式:
- 用户直接给 SQL 时,可以确认后执行。
- 用户只给分析需求时,Agent 可以先生成 SQL 草稿,再让用户确认。
- 执行必须通过 `python_exec.script_path`
- 输出默认写入当前 session output。
这个 Skill 的门禁重点是:
```text
SQL 执行前确认。
控制查询范围和 limit。
输出路径清晰。
失败时展示错误和可调整建议。
```
## 4. model-iteration
位置:
```text
skills/model-iteration/SKILL.md
skills/model-iteration/scripts/
```
定位:
```text
模型训练、评估、数据准备和迭代流程辅助。
```
这类 Skill 属于混合工程型:
- 有流程。
- 有脚本。
- 有配置。
- 可能调用外部训练平台。
- 高风险动作较多。
因此它更需要明确:
```text
哪些步骤只是分析。
哪些步骤会启动训练。
哪些步骤需要用户确认。
输出目录和实验记录在哪里。
```
## 5. 外部 Skill 的通用约定
### 5.1 调用方式
优先:
```json
{
"script_path": "skills/<skill-name>/scripts/run.py",
"stdin": "{...}",
"timeout_seconds": 120
}
```
或者脚本在 Skill 根目录时:
```json
{
"script_path": "skills/elk-fetch/elk_query.py",
"args": ["..."]
}
```
### 5.2 依赖处理
依赖缺失时:
```text
python_package(action="install", packages=[...])
```
不要:
```text
bash pip install ...
bash python ...
```
### 5.3 输出处理
外部系统查询结果应该:
- stdout 给结构化摘要。
- 大结果写入 `output/``scratchpad/`
- 返回实际文件路径。
- 避免把大量数据直接塞进最终回复。
### 5.4 安全和确认
需要确认的动作:
- 执行大范围 SQL。
- 启动训练。
- 写外部路径。
- 推送代码或数据仓库。
- 导出可能包含敏感字段的线上数据。
只读、小范围、用户明确指定 rid 或 SQL 的查询,可以直接执行,但仍要控制结果规模。
@@ -0,0 +1,478 @@
# 10. Agent 记忆机制调研与 ZK Data Agent 对比
本文整理主流 Agent / AI 产品的记忆实现方式,并对照 ZK Data Agent 当前实现。目标不是判断哪一种“最好”,而是说明不同记忆机制分别解决什么问题,以及为什么我们当前选择“用户记忆 + Skill 使用记忆 + 异步整理队列 + Markdown 可编辑文件”的路线。
调研时间:2026-05-19。
## 1. 结论摘要
主流记忆实现大致分为六类:
| 类型 | 代表 | 核心做法 | 适合场景 |
|------|------|----------|----------|
| 产品级长期记忆 | ChatGPT Memory | 平台自动保存用户偏好和事实,并在后续对话中注入 | 通用个人助手 |
| 会话状态记忆 | OpenAI Agents SDK Sessions、AutoGen Memory | 自动保存历史消息或把外部记忆注入上下文 | 线程连续对话 |
| 文件化项目记忆 | Claude Code `CLAUDE.md`、Claw 基座 memory files | 通过项目/用户级 Markdown 文件向 Agent 注入稳定规则 | 工程项目、团队约定 |
| 图/向量检索记忆 | LangGraph Store、Mem0、Zep/Graphiti | 抽取事实,存入向量库或知识图谱,按语义检索 | 长期、跨会话、海量事实 |
| Agent 自主管理记忆 | Letta / MemGPT | Agent 有显式 memory blocks 和 archival memory,可读写管理 | 状态型 Agent、长期角色 |
| 框架内置任务记忆 | CrewAI | 短期、长期、实体、上下文记忆组合 | 多 Agent 任务协作 |
ZK Data Agent 当前更接近:
```text
文件化项目记忆
+ 产品级用户记忆
+ Skill 作用域记忆
+ 异步记忆整理队列
```
它没有优先做向量库或知识图谱,而是选择 Markdown 文件作为最终记忆正文。这个取舍适合当前团队场景:记忆内容需要能被用户看到、编辑、删除,并且要按 Skill 作用域精准注入。
## 2. 主流实现机制
### 2.1 ChatGPT Memory:产品级个人长期记忆
ChatGPT Memory 的核心是平台级用户记忆。它会保存用户偏好、事实和历史对话中有持续价值的信息,并在后续对话中使用。用户可以查看、管理、删除保存的记忆,也可以关闭相关能力。
机制特点:
- 记忆作用域是用户账号。
- 由产品后台判断哪些内容值得保存。
- 注入方式对用户透明,用户看到的是“助手更了解我”。
- 适合通用个人助手,不适合表达复杂业务流程结构。
和我们的关系:
ZK Data Agent 的“用户记忆”借鉴了这个方向,但没有把全部记忆做成黑盒。我们把最终正文落到 `user.md`,并在 UI 里允许用户编辑。
### 2.2 OpenAI Agents SDK Sessions:会话状态记忆
OpenAI Agents SDK 的 Sessions 主要解决“同一个会话线程里自动保留历史上下文”。开发者不需要每轮手动传入完整历史,Session 会保存对话项,并在下一轮运行时自动带上。
机制特点:
- 更偏 conversation state,而不是长期个人偏好。
- 适合多轮会话连续执行。
- 常见实现是 SQLite / SQLAlchemy / 自定义 session backend。
- 记忆对象主要是消息历史,不是抽象后的长期知识。
和我们的关系:
ZK Data Agent 也有 session 持久化,但我们把它和“长期记忆”分开:
```text
session.json
保存当前会话消息、工具调用、产物和运行事件。
memory/user.md、memory/skills/*.md
保存跨会话长期偏好和 Skill 使用经验。
```
这个区分很重要:会话历史服务“恢复当前任务”,长期记忆服务“下次任务更懂用户和业务”。
### 2.3 Claude Code / OpenClaw / Claw:文件化项目记忆
Claude Code 使用 `CLAUDE.md` 作为项目或用户级记忆文件,常用于保存仓库规则、构建命令、代码风格、项目约定等。OpenClaw / Claw 类 Code Agent 基座通常也会保留这条路线:从全局或工作目录发现记忆文件,并把内容注入上下文。
在当前仓库里,对应实现主要是:
```text
src/agent_context.py
src/session_memory_compact.py
```
其中 `agent_context.py` 负责发现全局和目录级 memory files`session_memory_compact.py` 负责会话压缩场景下的 session memory 摘要。
机制特点:
- 记忆是文本文件,天然可读、可版本化。
- 非常适合工程项目规则和团队约定。
- 注入通常按目录/项目作用域进行。
- 记忆更新更多依赖人工维护,而不是完全自动。
和我们的关系:
ZK Data Agent 继承了“文件化、可编辑、可解释”的优点,但把作用域进一步细分:
```text
user.md
用户级偏好和稳定习惯。
skills/<skill-name>.md
某个 Skill 的使用经验、踩坑、格式偏好和边界修正。
```
也就是说,我们不是只有“项目记忆”,而是增加了“Skill 记忆”这一层。
### 2.4 LangGraph:线程状态 + 长期 Memory Store
LangGraph 把 memory 分成 short-term memory 和 long-term memory。短期记忆通常跟 thread 绑定,用来维持一次会话;长期记忆通过 store 按 namespace 保存,可以跨 thread 召回。它还把长期记忆进一步拆成 semantic、episodic、procedural 等类型。
机制特点:
- thread state 解决会话内上下文。
- store 解决跨会话长期信息。
- 支持按 user id / namespace 组织记忆。
- 长期记忆可以由应用逻辑或 Agent 写入、搜索、更新。
和我们的关系:
ZK Data Agent 当前没有引入通用 Store / VectorStore,而是用文件系统和 SQLite 队列实现一个轻量版本:
```text
namespace = account_id + memory kind + skill_name
storage = Markdown files + SQLite queue
retrieval = user memory always considered, skill memory按启用 Skill 精准注入
```
这比 LangGraph Store 简单,但更直接服务我们当前的 Skill 工作台。
### 2.5 Mem0:独立记忆层
Mem0 更像一个独立 memory layer。典型链路是:从对话中抽取事实,存入记忆系统;后续根据 query 检索相关记忆,再注入给模型。它强调 add / search / update / delete 这类记忆 API,也支持面向用户、Agent、session 等维度组织。
机制特点:
- 记忆层和 Agent 框架解耦。
- 常见存储后端是向量、图或混合检索。
- 强调自动抽取、去重、更新和语义召回。
- 适合大规模个性化 Agent 或跨应用记忆服务。
和我们的关系:
ZK Data Agent 目前没有把记忆做成独立检索服务。原因是我们的高频需求不是“从海量事实里语义搜索”,而是“把少量稳定经验准确注入到对应 Skill”。如果未来 Skill 记忆膨胀,可以在 Markdown 之外增加 Mem0 类似的检索层。
### 2.6 Letta / MemGPTAgent 自主管理内存
Letta 延续 MemGPT 思路,把 Agent 看成有长期状态的主体。它通常区分 core memory 和 archival memorycore memory 是短小、常驻上下文的重要信息;archival memory 是更大的外部记忆空间,Agent 可以通过工具读写。
机制特点:
- Agent 可以主动管理自己的记忆。
- core memory 常驻,archival memory 需要检索。
- 适合长期角色 Agent、个人助理、需要自我状态连续性的 Agent。
- 复杂度更高,需要更强的记忆写入约束和审计。
和我们的关系:
ZK Data Agent 没有让主 Agent 在执行链路里自由修改记忆。我们把记忆写入放到后台 worker,避免主任务因为记忆整理变慢或出错。这是一个更保守的团队平台取舍。
### 2.7 Zep / Graphiti:时间感知知识图谱记忆
Zep / Graphiti 代表的是 temporal knowledge graph 路线:从对话或事件中抽取实体和关系,形成带时间属性的知识图谱。它解决的问题不是简单偏好记忆,而是“事实如何随时间变化”“实体关系如何演进”。
机制特点:
- 记忆结构是实体、关系、事件、时间。
- 适合复杂事实网络和时间演化。
- 检索结果可以包含关系路径和上下文。
- 实现成本和运维复杂度高于 Markdown 或向量检索。
和我们的关系:
标签边界、业务规则、Skill 使用经验目前更适合文本化规则,不一定需要图谱。但如果未来要做“用户、Skill、数据集、标签、错误类型、修复策略”之间的关系分析,图谱路线会有价值。
### 2.8 CrewAI:多 Agent 任务记忆
CrewAI 的记忆体系主要服务多 Agent 协作,通常包含 short-term memory、long-term memory、entity memory 和 contextual memory。它关注的是任务过程中多个 Agent 如何共享上下文和持续改进。
机制特点:
- 和 Crew / Agent / Task 结构绑定。
- 强调任务协作过程中的上下文复用。
- 对实体、任务经验有独立组织方式。
和我们的关系:
ZK Data Agent 当前不是多 Agent 编排优先,而是单个工作台 Agent + Skill 能力包优先。Skill 记忆在某种程度上承担了“任务经验记忆”的角色。
### 2.9 AutoGenMemory 组件注入上下文
AutoGen 的 AgentChat 提供 Memory 抽象,可以把 list memory、vector memory 等组件挂到 AssistantAgent 上。运行时 Memory 会根据消息更新上下文,或把检索结果添加到模型输入。
机制特点:
- Memory 是 Agent 可插拔组件。
- 可以使用简单列表,也可以接向量检索。
- 更偏框架扩展点,而不是产品级记忆管理 UI。
和我们的关系:
ZK Data Agent 的记忆也可以理解为一个可插拔上下文组件,但我们额外做了用户 UI、Skill 作用域和后台队列。
## 3. ZK Data Agent 当前实现
实现入口:
```text
src/personal_memory.py
backend/api/server.py
frontend/app/components/assistant-ui/threadlist-sidebar.tsx
```
### 3.1 存储结构
每个账号有独立记忆目录:
```text
.port_sessions/accounts/<account_id>/memory/
user.md
skills/
<skill-name>.md
memory.db
```
其中:
- `user.md`:用户级长期记忆。
- `skills/<skill>.md`:某个 Skill 的使用记忆。
- `memory.db`:事件队列、状态和 revision 账本。
### 3.2 注入逻辑
模型调用前,后端调用:
```text
memory_manager.render_injection(account_id, enabled_skill_names)
```
注入规则:
```text
用户记忆
账号级,作为长期偏好注入。
Skill 使用记忆
只读取当前启用 Skill 对应的 skills/<skill>.md。
冲突优先级
用户本轮明确要求 > 个性化记忆。
```
这避免了一个常见问题:所有记忆都无差别注入导致上下文污染。
### 3.3 生成时机
每次交互结束后,后端调用:
```text
memory_manager.enqueue_interaction(...)
```
系统不会每轮同步整理记忆,而是先检测信号:
```text
显式记忆词:
记住、以后、下次、默认、总是、不要、应该、固定
纠错词:
不对、不是这样、格式错、之前说过、还是不行
Skill 经验:
skill、工具、流程、格式
工具经验:
模型返回的工具参数不是合法 JSON
```
命中后写入 SQLite pending 队列。显式记忆优先级更高。
### 3.4 异步整理
后台 worker 每 5 秒扫描账号事件,每次最多处理 8 条 pending event
```text
pending -> processing -> done / failed
```
整理方式:
1. 读取已有 `user.md` 和相关 `skills/<skill>.md`
2. 把一批事件交给模型做“整理式合并”。
3. 模型必须输出 JSON
```json
{
"user_memory": "完整 Markdown 或空字符串",
"skill_memories": {
"skill-name": "完整 Markdown"
}
}
```
4. 如果模型输出不可解析,则走 fallback 规则。
5. 写入 Markdown 文件,并更新 revision。
### 3.5 用户可编辑
前端左下角“记忆”入口支持:
- 查看用户记忆行数。
- 查看 Skill 记忆列表。
- 编辑用户记忆。
- 编辑某个 Skill 记忆。
- 查看记忆队列状态。
管理后台只看队列、用量等统计,不展示其他用户具体记忆内容。
## 4. 对比表
| 维度 | ChatGPT | Claude Code / OpenClaw | LangGraph / Mem0 / Zep | Letta | ZK Data Agent |
|------|---------|--------------------|-------------------------|-------|---------------|
| 主要目标 | 个人助手个性化 | 项目规则注入 | 长期检索记忆 | 状态型长期 Agent | 团队 Skill 工作台 |
| 记忆粒度 | 用户 | 用户/项目/目录 | 用户/线程/实体/namespace | Agent memory block | 用户 + Skill |
| 存储形态 | 平台内部 | Markdown 文件 | Store / 向量 / 图 | Core + archival memory | Markdown + SQLite queue |
| 生成时机 | 产品后台自动 | 多为人工维护 | 自动抽取 / API 写入 | Agent 主动管理 | 交互结束后异步整理 |
| 检索方式 | 平台决定 | 直接注入文件 | 语义搜索 / 图检索 | Core 常驻 + archival 检索 | 用户记忆 + 当前 Skill 记忆注入 |
| 可编辑性 | 用户可管理 | 文件可编辑 | 取决于产品/API | 通常需要工具/API | UI 可编辑 Markdown |
| 适合业务流程沉淀 | 中 | 中 | 高,但工程复杂 | 高,但复杂 | 高,且轻量 |
| 风险 | 黑盒、难按业务作用域隔离 | 容易依赖人工维护 | 检索和更新复杂 | 主链路复杂度高 | 暂无语义召回和图谱能力 |
## 5. 为什么当前方案适合我们
### 5.1 我们需要的是 Skill 使用经验,而不只是用户偏好
通用记忆多关注“用户是谁、用户喜欢什么”。我们的高频需求更像:
```text
product-data 生成数据时,用户偏好什么确认流程?
标签大师判断时,哪些边界经常被纠正?
online-mining-v2 查询线上日志时,哪些字段和表更稳定?
某个 Skill 写文件时,模型容易踩什么坑?
```
这些经验天然和 Skill 绑定。因此 `skills/<skill>.md` 比单一用户记忆更准确。
### 5.2 我们需要可审计、可编辑,而不是完全黑盒
团队平台里,记忆不能只存在模型或向量库内部。用户需要能看到:
- 记住了什么。
- 为什么下一次会注入。
- 哪里可以手动修改。
- 哪些记忆是用户级,哪些是 Skill 级。
Markdown 文件在这点上比纯向量库更直接。
### 5.3 主链路不能被记忆整理拖慢
数据生成、线上挖掘、标签判断本身就是长任务。记忆整理如果同步放在主链路里,会增加延迟和失败面。
当前设计是:
```text
主链路:只读取已有记忆 + 入队事件
后台:异步整理、合并、失败重试/记录
```
这和团队生产工具的稳定性要求更匹配。
### 5.4 Skill 作用域注入能降低上下文污染
如果所有记忆每次都注入,模型会被无关偏好干扰。当前只注入启用 Skill 的记忆:
```text
启用 product-data -> 注入 product-data 使用记忆
启用 label-master -> 注入 label-master 使用记忆
未启用某 Skill -> 不注入该 Skill 记忆
```
这使记忆更像“能力使用手册的增量补丁”,而不是一坨全局上下文。
## 6. 当前不足和后续方向
### 6.1 缺少语义召回
当前 Skill 记忆是按 Skill 文件整体注入,不做向量检索。如果某个 Skill 记忆变得很长,可能需要:
- 按章节拆分。
- 引入轻量 embedding 检索。
- 只注入和当前 query 相关的片段。
### 6.2 缺少结构化 schema
Markdown 易编辑,但不方便做强约束。后续可以让 Skill 记忆同时存在:
```text
human.md
structured.json
```
其中 Markdown 给人看,JSON 给程序做筛选和校验。
### 6.3 缺少记忆质量评估
目前能看到队列状态,但还没有系统评估:
- 哪些记忆被注入。
- 注入后是否减少纠错。
- 哪些记忆过期。
- 哪些 Skill 记忆导致误导。
后续可以把 memory revision 与 session outcome 关联起来。
### 6.4 缺少跨用户团队记忆
当前是账号级记忆。团队共性经验仍主要沉淀在 Skill 本体里。未来可以区分:
```text
个人 Skill 记忆
某个用户自己的偏好和使用习惯。
团队 Skill 记忆
多人使用后沉淀的稳定经验,经 review 后合入 Skill。
```
这样可以形成从“个人经验”到“团队 Skill 知识”的晋升路径。
## 7. 建议的技术路线
短期保持当前架构:
```text
Markdown 可编辑记忆
SQLite 异步队列
按 Skill 注入
UI 可查看可修改
后台可观测队列
```
中期增强:
```text
记忆片段化
记忆注入日志
过期/冲突检测
记忆质量指标
```
长期可选:
```text
向量检索:解决 Skill 记忆膨胀后的相关片段召回。
图谱记忆:解决用户、Skill、标签、数据集、错误类型之间的关系分析。
团队记忆晋升:把多用户共性 Skill 经验 review 后写回 Git Skill。
```
## 8. 资料来源
- OpenAI HelpChatGPT Memory FAQ
https://help.openai.com/en/articles/8590148-memory-faq
- OpenAI Agents SDKSessions
https://openai.github.io/openai-agents-python/sessions/
- Anthropic Claude CodeMemory
https://docs.anthropic.com/en/docs/claude-code/memory
- LangChain / LangGraphMemory concepts
https://docs.langchain.com/oss/python/concepts/memory
- Mem0 documentation
https://docs.mem0.ai/
- Letta documentation
https://docs.letta.com/
- Zep / Graphiti documentation
https://help.getzep.com/
- CrewAI Memory concepts
https://docs.crewai.com/concepts/memory
- Microsoft AutoGen AgentChat Memory
https://microsoft.github.io/autogen/dev/user-guide/agentchat-user-guide/memory.html
- ZK Data Agent 当前实现
`src/personal_memory.py``docs/technical-architecture/05-workspace-memory-observability.md`
@@ -0,0 +1,60 @@
# ZK Data Agent 技术架构说明
这组文档先服务于技术 review 和后续讲解材料沉淀,不做宣传表达,不做产品比较。
每个章节尽量对应当前代码里的真实模块、函数和 Skill 实现,后续 `/doc` 页面可以从这里抽取内容做视觉化呈现。
## 阅读顺序
1. [基座 Runtime 架构](01-base-runtime.md)
2. [Agent Loop 执行机制](02-agent-loop.md)
3. [工具体系和 Tool Handler](03-tools.md)
4. [Skill 体系和能力包约定](04-skills.md)
5. [会话工作区、运行态和记忆](05-workspace-memory-observability.md)
6. [product-data Skill 实现](06-product-data.md)
7. [online-mining-v2 Skill 实现](07-online-mining-v2.md)
8. [label-master Skill 实现](08-label-master.md)
9. [外部系统 Skill:ELK、SQL、模型迭代](09-external-skills.md)
10. [Agent 记忆机制调研与对比](10-memory-research.md)
## 一句话定位
ZK Data Agent 的基座是一个 Web 化、多用户、可观测的 Agent runtime。
业务能力通过 `skills/<skill-name>/SKILL.md``knowledge/``scripts/` 组织;稳定执行能力通过 Tool handler 或 Skill 内 portable scripts 承载;每次运行通过 Agent loop 让模型在“判断、调用工具、观察结果、继续判断”之间循环。
## 当前技术主线
```text
Web UI
-> backend/api/server.py
-> LocalCodingAgent
-> agent_prompting 组装系统提示词和 Skill 列表
-> OpenAICompatClient 调模型
-> 模型返回文本或 tool_calls
-> agent_tools 执行 handler
-> session workspace 保存 input/scratchpad/output/session.json
-> run_state_store / event stream 推给前端活动区
-> personal_memory 后台异步整理用户记忆和 Skill 记忆
```
## 代码入口速查
| 主题 | 主要代码 |
|------|----------|
| Agent runtime | `src/agent_runtime.py` |
| 系统提示词 | `src/agent_prompting.py` |
| Tool registry / handler | `src/agent_tools.py``src/agent_tool_specs/` |
| Skill loader | `src/bundled_skills.py` |
| 模型兼容层 | `src/openai_compat.py` |
| Web 后端 | `backend/api/server.py` |
| 会话持久化 | `src/agent_session.py``src/session_store.py` |
| 记忆后台 | `src/personal_memory.py` |
| 数据生成 Skill | `skills/product-data/` |
| 线上挖掘 Skill | `skills/online-mining-v2/` |
| 标签知识 Skill | `skills/label-master/` |
## 后续维护原则
- 如果是在讲“基座怎么运行”,优先改 01-05。
- 如果是在讲“某个 Skill 怎么做事”,优先改 06-09。
- 如果代码实现发生变化,先更新对应章节,再考虑同步 README 或 `/doc` 页面。
- 页面表达、图形素材、演示脚本不写在这里;这里只保留技术事实和设计边界。
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

+99 -5
View File
@@ -3,7 +3,7 @@ set -euo pipefail
# Ubuntu 一键部署/更新脚本。 # Ubuntu 一键部署/更新脚本。
# - 首次执行会交互式生成 .env.deploy,本机保存,不提交 git。 # - 首次执行会交互式生成 .env.deploy,本机保存,不提交 git。
# - 后续执行会拉取代码、更新依赖、构建前端、安装/重启用户级 systemd 服务。 # - 后续执行会拉取代码、更新依赖、构建前端、安装/重启 systemd 服务。
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
ENV_FILE="${ROOT_DIR}/.env.deploy" ENV_FILE="${ROOT_DIR}/.env.deploy"
@@ -12,14 +12,17 @@ DEPLOY_INSTANCE="${CLAW_DEPLOY_INSTANCE:-zk-data-agent}"
BACKEND_SERVICE="${CLAW_BACKEND_SERVICE:-${DEPLOY_INSTANCE}-backend}" BACKEND_SERVICE="${CLAW_BACKEND_SERVICE:-${DEPLOY_INSTANCE}-backend}"
FRONTEND_SERVICE="${CLAW_FRONTEND_SERVICE:-${DEPLOY_INSTANCE}-frontend}" FRONTEND_SERVICE="${CLAW_FRONTEND_SERVICE:-${DEPLOY_INSTANCE}-frontend}"
USER_SYSTEMD_DIR="${HOME}/.config/systemd/user" USER_SYSTEMD_DIR="${HOME}/.config/systemd/user"
SYSTEM_SYSTEMD_DIR="/etc/systemd/system"
BRANCH="" BRANCH=""
FORCE=0 FORCE=0
SKIP_GIT=0 SKIP_GIT=0
BOOTSTRAP_SYSTEM=0 BOOTSTRAP_SYSTEM=0
SERVICE_SCOPE="${CLAW_SERVICE_SCOPE:-}"
ENABLE_LINUX_ACCOUNTS="${CLAW_ENABLE_LINUX_ACCOUNTS:-}"
usage() { usage() {
cat <<EOF cat <<EOF
Usage: bash scripts/deploy-ubuntu.sh [branch] [--force] [--skip-git] [--bootstrap-system] Usage: bash scripts/deploy-ubuntu.sh [branch] [--force] [--skip-git] [--bootstrap-system] [--system-service] [--user-service] [--enable-linux-accounts]
Options: Options:
branch 要部署的 git 分支;不传则使用当前分支 branch 要部署的 git 分支;不传则使用当前分支
@@ -27,6 +30,12 @@ Options:
--skip-git 跳过 git 拉取,只部署当前工作区 --skip-git 跳过 git 拉取,只部署当前工作区
--bootstrap-system --bootstrap-system
强制使用 sudo 安装 Ubuntu 系统依赖;首次安装会默认执行 强制使用 sudo 安装 Ubuntu 系统依赖;首次安装会默认执行
--system-service
安装 root/system 级 systemd 服务;以 root 执行时默认启用
--user-service
安装当前用户级 systemd 服务
--enable-linux-accounts
启用平台账号同名 Linux 用户工作区;要求 system 服务和 root 权限
EOF EOF
} }
@@ -44,6 +53,18 @@ while [[ $# -gt 0 ]]; do
BOOTSTRAP_SYSTEM=1 BOOTSTRAP_SYSTEM=1
shift shift
;; ;;
--system-service)
SERVICE_SCOPE="system"
shift
;;
--user-service)
SERVICE_SCOPE="user"
shift
;;
--enable-linux-accounts)
ENABLE_LINUX_ACCOUNTS="1"
shift
;;
-h|--help) -h|--help)
usage usage
exit 0 exit 0
@@ -60,6 +81,9 @@ while [[ $# -gt 0 ]]; do
esac esac
done done
REQUESTED_SERVICE_SCOPE="${SERVICE_SCOPE}"
REQUESTED_ENABLE_LINUX_ACCOUNTS="${ENABLE_LINUX_ACCOUNTS}"
log() { log() {
printf "\n\033[1;34m==>\033[0m %s\n" "$1" printf "\n\033[1;34m==>\033[0m %s\n" "$1"
} }
@@ -77,6 +101,16 @@ configure_instance_names() {
DEPLOY_INSTANCE="${CLAW_DEPLOY_INSTANCE:-${DEPLOY_INSTANCE:-zk-data-agent}}" DEPLOY_INSTANCE="${CLAW_DEPLOY_INSTANCE:-${DEPLOY_INSTANCE:-zk-data-agent}}"
BACKEND_SERVICE="${CLAW_BACKEND_SERVICE:-${DEPLOY_INSTANCE}-backend}" BACKEND_SERVICE="${CLAW_BACKEND_SERVICE:-${DEPLOY_INSTANCE}-backend}"
FRONTEND_SERVICE="${CLAW_FRONTEND_SERVICE:-${DEPLOY_INSTANCE}-frontend}" FRONTEND_SERVICE="${CLAW_FRONTEND_SERVICE:-${DEPLOY_INSTANCE}-frontend}"
if [[ -z "${SERVICE_SCOPE}" ]]; then
if [[ "${EUID}" == "0" ]]; then
SERVICE_SCOPE="system"
else
SERVICE_SCOPE="user"
fi
fi
if [[ -z "${ENABLE_LINUX_ACCOUNTS}" ]]; then
ENABLE_LINUX_ACCOUNTS="${CLAW_ENABLE_LINUX_ACCOUNTS:-0}"
fi
} }
require_command() { require_command() {
@@ -120,6 +154,9 @@ bootstrap_system_packages() {
libssl-dev zlib1g-dev libbz2-dev libreadline-dev libsqlite3-dev \ libssl-dev zlib1g-dev libbz2-dev libreadline-dev libsqlite3-dev \
libffi-dev liblzma-dev libncursesw5-dev xz-utils tk-dev \ libffi-dev liblzma-dev libncursesw5-dev xz-utils tk-dev \
systemd systemd
if [[ "${ENABLE_LINUX_ACCOUNTS}" == "1" ]]; then
sudo apt-get install -y passwd python3 python3-venv
fi
} }
configure_nginx_upload_limit() { configure_nginx_upload_limit() {
@@ -180,15 +217,42 @@ export CLAW_BACKEND_PORT=$(printf "%q" "${backend_port}")
export CLAW_FRONTEND_HOST=$(printf "%q" "${frontend_host}") export CLAW_FRONTEND_HOST=$(printf "%q" "${frontend_host}")
export CLAW_FRONTEND_PORT=$(printf "%q" "${frontend_port}") export CLAW_FRONTEND_PORT=$(printf "%q" "${frontend_port}")
export CLAW_API_URL=$(printf "%q" "${claw_api_url}") export CLAW_API_URL=$(printf "%q" "${claw_api_url}")
export CLAW_SERVICE_SCOPE=$(printf "%q" "${SERVICE_SCOPE}")
export CLAW_ENABLE_LINUX_ACCOUNTS=$(printf "%q" "${ENABLE_LINUX_ACCOUNTS:-0}")
EOF EOF
chmod 600 "${ENV_FILE}" chmod 600 "${ENV_FILE}"
} }
upsert_env_export() {
local key="$1"
local value="$2"
local tmp_file
tmp_file="$(mktemp)"
grep -v -E "^export ${key}=" "${ENV_FILE}" >"${tmp_file}" || true
printf 'export %s=%s\n' "${key}" "$(printf "%q" "${value}")" >>"${tmp_file}"
install -m 0600 "${tmp_file}" "${ENV_FILE}"
rm -f "${tmp_file}"
}
ensure_env_file() { ensure_env_file() {
if [[ -f "${ENV_FILE}" ]]; then if [[ -f "${ENV_FILE}" ]]; then
# shellcheck disable=SC1090 # shellcheck disable=SC1090
source "${ENV_FILE}" source "${ENV_FILE}"
if [[ -n "${REQUESTED_SERVICE_SCOPE}" ]]; then
SERVICE_SCOPE="${REQUESTED_SERVICE_SCOPE}"
export CLAW_SERVICE_SCOPE="${REQUESTED_SERVICE_SCOPE}"
fi
if [[ -n "${REQUESTED_ENABLE_LINUX_ACCOUNTS}" ]]; then
ENABLE_LINUX_ACCOUNTS="${REQUESTED_ENABLE_LINUX_ACCOUNTS}"
export CLAW_ENABLE_LINUX_ACCOUNTS="${REQUESTED_ENABLE_LINUX_ACCOUNTS}"
fi
configure_instance_names configure_instance_names
if [[ -n "${REQUESTED_SERVICE_SCOPE}" ]]; then
upsert_env_export CLAW_SERVICE_SCOPE "${SERVICE_SCOPE}"
fi
if [[ -n "${REQUESTED_ENABLE_LINUX_ACCOUNTS}" ]]; then
upsert_env_export CLAW_ENABLE_LINUX_ACCOUNTS "${ENABLE_LINUX_ACCOUNTS}"
fi
if [[ -z "${OPENAI_API_KEY:-}" ]]; then if [[ -z "${OPENAI_API_KEY:-}" ]]; then
fail "${ENV_FILE} 缺少 OPENAI_API_KEY,请编辑该文件补齐。" fail "${ENV_FILE} 缺少 OPENAI_API_KEY,请编辑该文件补齐。"
fi fi
@@ -197,6 +261,8 @@ ensure_env_file() {
echo "OPENAI_BASE_URL=${OPENAI_BASE_URL:-}" echo "OPENAI_BASE_URL=${OPENAI_BASE_URL:-}"
echo "OPENAI_MODEL=${OPENAI_MODEL:-}" echo "OPENAI_MODEL=${OPENAI_MODEL:-}"
echo "OPENAI_TIMEOUT_SECONDS=${OPENAI_TIMEOUT_SECONDS:-3600}" echo "OPENAI_TIMEOUT_SECONDS=${OPENAI_TIMEOUT_SECONDS:-3600}"
echo "CLAW_SERVICE_SCOPE=${SERVICE_SCOPE}"
echo "CLAW_ENABLE_LINUX_ACCOUNTS=${CLAW_ENABLE_LINUX_ACCOUNTS:-0}"
echo "OPENAI_API_KEY=已配置" echo "OPENAI_API_KEY=已配置"
return return
fi fi
@@ -214,6 +280,9 @@ ensure_env_file() {
prompt_value base_url "请输入 OPENAI_BASE_URL" "http://model.mify.ai.srv/v1" prompt_value base_url "请输入 OPENAI_BASE_URL" "http://model.mify.ai.srv/v1"
prompt_value model "请输入 OPENAI_MODEL" "tongyi/deepseek-v4-pro" prompt_value model "请输入 OPENAI_MODEL" "tongyi/deepseek-v4-pro"
configure_instance_names configure_instance_names
if [[ "${ENABLE_LINUX_ACCOUNTS}" == "1" && "${SERVICE_SCOPE}" != "system" ]]; then
fail "--enable-linux-accounts 需要 --system-service,并建议使用 sudo/root 执行部署。"
fi
prompt_value backend_host "请输入后端监听地址" "127.0.0.1" prompt_value backend_host "请输入后端监听地址" "127.0.0.1"
prompt_value backend_port "请输入后端端口" "8765" prompt_value backend_port "请输入后端端口" "8765"
prompt_value frontend_host "请输入前端监听地址" "0.0.0.0" prompt_value frontend_host "请输入前端监听地址" "0.0.0.0"
@@ -304,14 +373,21 @@ EOF
install_systemd_service() { install_systemd_service() {
local service_name="$1" local service_name="$1"
local template_path="$2" local template_path="$2"
local target_path="${USER_SYSTEMD_DIR}/${service_name}.service" local target_dir="${USER_SYSTEMD_DIR}"
local wanted_by="default.target"
if [[ "${SERVICE_SCOPE}" == "system" ]]; then
target_dir="${SYSTEM_SYSTEMD_DIR}"
wanted_by="multi-user.target"
fi
local target_path="${target_dir}/${service_name}.service"
local tmp_path local tmp_path
tmp_path="$(mktemp)" tmp_path="$(mktemp)"
mkdir -p "${USER_SYSTEMD_DIR}" mkdir -p "${target_dir}"
sed \ sed \
-e "s#__PROJECT_ROOT__#${ROOT_DIR}#g" \ -e "s#__PROJECT_ROOT__#${ROOT_DIR}#g" \
-e "s#__BACKEND_SERVICE__#${BACKEND_SERVICE}#g" \ -e "s#__BACKEND_SERVICE__#${BACKEND_SERVICE}#g" \
-e "s#__FRONTEND_SERVICE__#${FRONTEND_SERVICE}#g" \ -e "s#__FRONTEND_SERVICE__#${FRONTEND_SERVICE}#g" \
-e "s#WantedBy=default.target#WantedBy=${wanted_by}#g" \
"${template_path}" >"${tmp_path}" "${template_path}" >"${tmp_path}"
install -m 0644 "${tmp_path}" "${target_path}" install -m 0644 "${tmp_path}" "${target_path}"
rm -f "${tmp_path}" rm -f "${tmp_path}"
@@ -319,17 +395,30 @@ install_systemd_service() {
install_services() { install_services() {
require_command systemctl "当前系统不支持 systemd,无法安装服务。" require_command systemctl "当前系统不支持 systemd,无法安装服务。"
log "安装/更新用户级 systemd 服务" if [[ "${ENABLE_LINUX_ACCOUNTS}" == "1" && "${SERVICE_SCOPE}" != "system" ]]; then
fail "CLAW_ENABLE_LINUX_ACCOUNTS=1 需要 system 服务。请用 sudo bash scripts/deploy-ubuntu.sh --system-service --enable-linux-accounts。"
fi
log "安装/更新 ${SERVICE_SCOPE} systemd 服务"
install_systemd_service "${BACKEND_SERVICE}" "${ROOT_DIR}/deploy/systemd/zk-data-agent-backend.service.template" install_systemd_service "${BACKEND_SERVICE}" "${ROOT_DIR}/deploy/systemd/zk-data-agent-backend.service.template"
install_systemd_service "${FRONTEND_SERVICE}" "${ROOT_DIR}/deploy/systemd/zk-data-agent-frontend.service.template" install_systemd_service "${FRONTEND_SERVICE}" "${ROOT_DIR}/deploy/systemd/zk-data-agent-frontend.service.template"
if [[ "${SERVICE_SCOPE}" == "system" ]]; then
systemctl daemon-reload
systemctl enable "${BACKEND_SERVICE}" "${FRONTEND_SERVICE}"
else
systemctl --user daemon-reload systemctl --user daemon-reload
systemctl --user enable "${BACKEND_SERVICE}" "${FRONTEND_SERVICE}" systemctl --user enable "${BACKEND_SERVICE}" "${FRONTEND_SERVICE}"
fi
} }
restart_services() { restart_services() {
log "重启服务" log "重启服务"
if [[ "${SERVICE_SCOPE}" == "system" ]]; then
systemctl restart "${BACKEND_SERVICE}"
systemctl restart "${FRONTEND_SERVICE}"
else
systemctl --user restart "${BACKEND_SERVICE}" systemctl --user restart "${BACKEND_SERVICE}"
systemctl --user restart "${FRONTEND_SERVICE}" systemctl --user restart "${FRONTEND_SERVICE}"
fi
} }
health_check() { health_check() {
@@ -348,8 +437,13 @@ health_check() {
sleep 0.5 sleep 0.5
done done
warn "健康检查失败,输出最近日志。" warn "健康检查失败,输出最近日志。"
if [[ "${SERVICE_SCOPE}" == "system" ]]; then
journalctl -u "${BACKEND_SERVICE}" -n 80 --no-pager || true
journalctl -u "${FRONTEND_SERVICE}" -n 80 --no-pager || true
else
journalctl --user -u "${BACKEND_SERVICE}" -n 80 --no-pager || true journalctl --user -u "${BACKEND_SERVICE}" -n 80 --no-pager || true
journalctl --user -u "${FRONTEND_SERVICE}" -n 80 --no-pager || true journalctl --user -u "${FRONTEND_SERVICE}" -n 80 --no-pager || true
fi
exit 1 exit 1
} }
+17 -1
View File
@@ -11,6 +11,7 @@ ENV_FILE="${ROOT_DIR}/.env.deploy"
DEPLOY_INSTANCE="${CLAW_DEPLOY_INSTANCE:-zk-data-agent}" DEPLOY_INSTANCE="${CLAW_DEPLOY_INSTANCE:-zk-data-agent}"
BACKEND_SERVICE="${CLAW_BACKEND_SERVICE:-${DEPLOY_INSTANCE}-backend}" BACKEND_SERVICE="${CLAW_BACKEND_SERVICE:-${DEPLOY_INSTANCE}-backend}"
FRONTEND_SERVICE="${CLAW_FRONTEND_SERVICE:-${DEPLOY_INSTANCE}-frontend}" FRONTEND_SERVICE="${CLAW_FRONTEND_SERVICE:-${DEPLOY_INSTANCE}-frontend}"
SERVICE_SCOPE="${CLAW_SERVICE_SCOPE:-}"
BRANCH="${1:-}" BRANCH="${1:-}"
log() { log() {
@@ -26,6 +27,13 @@ configure_instance_names() {
DEPLOY_INSTANCE="${CLAW_DEPLOY_INSTANCE:-${DEPLOY_INSTANCE:-zk-data-agent}}" DEPLOY_INSTANCE="${CLAW_DEPLOY_INSTANCE:-${DEPLOY_INSTANCE:-zk-data-agent}}"
BACKEND_SERVICE="${CLAW_BACKEND_SERVICE:-${DEPLOY_INSTANCE}-backend}" BACKEND_SERVICE="${CLAW_BACKEND_SERVICE:-${DEPLOY_INSTANCE}-backend}"
FRONTEND_SERVICE="${CLAW_FRONTEND_SERVICE:-${DEPLOY_INSTANCE}-frontend}" FRONTEND_SERVICE="${CLAW_FRONTEND_SERVICE:-${DEPLOY_INSTANCE}-frontend}"
if [[ -z "${SERVICE_SCOPE}" ]]; then
if [[ "${EUID}" == "0" ]]; then
SERVICE_SCOPE="system"
else
SERVICE_SCOPE="user"
fi
fi
} }
resolve_npm_bin() { resolve_npm_bin() {
@@ -99,12 +107,20 @@ npm_bin="$(resolve_npm_bin)"
export PATH="$(dirname "${npm_bin}"):${PATH}" export PATH="$(dirname "${npm_bin}"):${PATH}"
"${npm_bin}" run build "${npm_bin}" run build
log "重启用户服务" log "重启 ${SERVICE_SCOPE} 服务"
if [[ "${SERVICE_SCOPE}" == "system" ]]; then
systemctl restart "${BACKEND_SERVICE}" "${FRONTEND_SERVICE}"
else
systemctl --user restart "${BACKEND_SERVICE}" "${FRONTEND_SERVICE}" systemctl --user restart "${BACKEND_SERVICE}" "${FRONTEND_SERVICE}"
fi
log "健康检查" log "健康检查"
sleep 1 sleep 1
if [[ "${SERVICE_SCOPE}" == "system" ]]; then
systemctl --no-pager --lines=0 status "${BACKEND_SERVICE}" "${FRONTEND_SERVICE}"
else
systemctl --user --no-pager --lines=0 status "${BACKEND_SERVICE}" "${FRONTEND_SERVICE}" systemctl --user --no-pager --lines=0 status "${BACKEND_SERVICE}" "${FRONTEND_SERVICE}"
fi
echo echo
echo "快速更新完成。" echo "快速更新完成。"
+136
View File
@@ -0,0 +1,136 @@
---
name: intervention-data
description: 生成和校验单句精确干预、正则干预候选 TSV,复用标签大师的 target 语法校验,并从历史干预表推断 code 到下发 domain 的固定映射。
when_to_use: 当用户希望新增、检查或整理干预规则,例如给某个 query 或多轮正则生成干预条目、确认设备和标签是否合法、推断下发 domain 时使用。
aliases: intervention, rule-intervention, 干预数据
allowed_tools: read_file, write_file, edit_file, grep_search, glob_search, ask_user_question, python_exec
---
使用这个 skill 处理“query/多轮表达 -> 干预 TSV 候选 -> 合法性校验”的任务。
干预数据目前有两类:
- **单句精确干预**:一行四列,格式为 `设备\tquery\tcode\t下发domain`
- **正则干预**:一行三列,格式为 `设备\t正则\t标签`
其中单句精确干预的 `code` 和正则干预的 `标签` 使用同一套 target 语法,和 `label-master` 的输出一致,但不包含复杂度判断。校验时必须复用 `skills/label-master/scripts/validate_label_output.py` 的逻辑。
## 关键约定
- `unify` 表示全部设备。
- 设备必须优先从历史干预文件中归纳,不要随手发明设备名。
- 单句精确干预如果用户没有提供 `下发domain`,必须从历史精确干预表中按 `code -> 下发domain` 的多数映射推断。
- 正则干预支持多轮。多轮正则使用 `beforeQuery#上一轮#query#当前轮` 这种片段表达,最终通常包在 `^...$` 中。
- 默认只生成候选 TSV 和校验结果,不直接追加到历史干预文件。只有用户明确要求“追加到某个文件末尾”或“修改某个文件”时,才使用通用文件编辑能力。
- 所有产物优先写到当前会话的 `output/``scratchpad/` 下,不要写到项目根目录的 `output/`
## 推荐流程
1. 先确认用户要哪种干预方式:
- 单句精确干预:query 必须完全命中。
- 正则干预:适合一类表达、多轮上下文或需要泛化的高频 case。
2. 确认设备。用户没说设备时,必须问设备;可以提示 `unify` 表示全部设备。
3. 确认目标标签/code。它必须是完整 target,例如 `Agent(tag="地图导航")``QA()``Chat()` 或合法 function 调用。
4. 如果是单句精确干预:
- 确认 `query`
- 如果没有 `下发domain`,调用脚本根据历史数据推断。
5. 如果是正则干预:
- 确认当前轮 query。
- 如果有多轮,确认前序 query 顺序。
- 用户给了原始正则就保留;没给时由脚本生成 `^query#...$``^beforeQuery#...#query#...$`
6. 调用 `scripts/generate_intervention_candidate.py` 生成候选 TSV 和校验报告。
7. 展示时只展示必要信息:干预类型、设备、候选 TSV、校验是否通过、需要用户确认的问题。
## 脚本能力
```text
skills/intervention-data/
SKILL.md
knowledge/
type_match_agent.json
scripts/
intervention_common.py
analyze_intervention_sources.py
audit_intervention_sources.py
generate_intervention_candidate.py
validate_intervention_records.py
```
### analyze_intervention_sources.py
读取历史干预文件,输出设备枚举、`code -> 下发domain` 多数映射、常见标签,以及 `knowledge/type_match_agent.json` 中的下发表。
示例:
```bash
python skills/intervention-data/scripts/analyze_intervention_sources.py
```
### generate_intervention_candidate.py
从 JSON 输入生成候选 TSV,并自动校验。输入可以来自文件或 stdin。
### audit_intervention_sources.py
审计现有两个历史干预文件,只输出摘要,不修改文件。适合验证当前 skill 的校验规则和存量数据是否贴合。
示例:
```bash
python skills/intervention-data/scripts/audit_intervention_sources.py
```
单句精确干预示例:
```json
{
"mode": "exact",
"device": "unify",
"query": "播放周杰伦的歌",
"target": "Agent(tag=\"音乐\")"
}
```
正则多轮干预示例:
```json
{
"mode": "regex",
"device": "miCar",
"before_queries": ["第一个"],
"query": "红绿灯少的路线",
"target": "Agent(tag=\"地图导航\")"
}
```
### validate_intervention_records.py
校验候选 TSV。支持 `--mode exact``--mode regex`
规则包括:
- 列数正确。
- 设备合法。
- `code` / `标签` 优先通过 label-master 校验;如果 label-master 未收录但历史干预表中已经稳定出现,允许作为候选通过,并给出 warning 让用户确认知识库是否需要补齐。
- 单句精确干预的 `下发domain` 与历史多数映射一致,默认不一致为 warning,`--strict-domain` 时为 error。
- 单句精确干预还会用 `type_match_agent.json` 做配置校对;配置值可以是更宽泛前缀,例如 `音乐 -> contentCopilot` 可以兼容历史里的 `contentCopilot|music`
- 正则可编译,且包含 `query#` 片段。
- 与历史文件重复或冲突时给出 warning/error。
## 展示格式
生成候选后,优先用这个格式回复:
```text
我生成了一条候选干预,先不写入历史文件。
- 类型:单句精确干预 / 正则干预
- 设备:xxx
- 标签:xxx
- 校验:通过 / 有问题
候选 TSV`设备 query code 下发domain`
需要确认:是否写入某个文件,或是否调整设备/标签/正则。
```
如果校验失败,先解释失败原因,不要要求用户直接落盘。
@@ -0,0 +1,211 @@
{
"音乐": "contentCopilot",
"音乐#tvV2": "contentCopilot|video",
"音乐#miCar": "contentCopilot|station",
"音乐#screenSoundbox": "contentCopilot|music",
"音乐#glass": "contentCopilot|ancientPoem",
"电台": "contentCopilot",
"电台#tvV2": "contentCopilot|video",
"视频": "contentCopilot",
"古诗": "contentCopilot",
"歌单": "contentCopilot|controlCopilot|soundboxControl|smartApp",
"应用播放": "contentCopilot|controlCopilot|soundboxControl|smartApp",
"新闻": "contentCopilot",
"笑话": "contentCopilot",
"内容控制": "contentCopilot|soundboxControl|controlCopilot",
"内容控制|系统控制": "contentCopilot|soundboxControl|controlCopilot",
"内容控制|应用控制": "contentCopilot|smartApp|controlCopilot",
"播放状态查询": "contentCopilot|soundboxControl|controlCopilot",
"内容问答": "contentCopilot|qabot|dialogCopilot",
"内容问答#tvV2": "contentCopilot|qabot|dialogCopilot|video",
"内容问答#soundbox": "contentCopilot|qabot|dialogCopilot|michat",
"赛事播放": "contentCopilot|qabot|dialogCopilot|sports",
"赛事预约": "contentCopilot|qabot|dialogCopilot|sports",
"声音": "contentCopilot",
"人物": "dialogCopilot",
"人物#tvV2": "dialogCopilot|video",
"问答": "dialogCopilot|open-class|shopping|productAgent",
"问答#tvV2": "dialogCopilot|open-class|shopping|productAgent|video",
"百科": "dialogCopilot",
"百科#tvV2": "dialogCopilot|video",
"医疗": "dialogCopilot",
"星座": "dialogCopilot",
"菜谱": "dialogCopilot",
"民俗": "dialogCopilot",
"运动": "contentCopilot|dialogCopilot|sports|qabot",
"体育赛事问答": "contentCopilot|dialogCopilot|sports|qabot",
"词典": "dialogCopilot",
"彩票": "dialogCopilot",
"投资": "dialogCopilot",
"股票": "dialogCopilot",
"闲聊": "dialogCopilot|feedback|scenes|fitnessHealth",
"闲聊#tvV2": "dialogCopilot|video|feedback|scenes|help|fitnessHealth|shopping|productAgent",
"用户反馈": "dialogCopilot|feedback",
"角色扮演": "dialogCopilot",
"重说": "dialogCopilot",
"图片问答": "dialogCopilot|imageLU|mapCopilot",
"屏幕问答": "dialogCopilot|imageLU|mapCopilot|smartApp|controlCopilot|productAgent|auto|lifeCopilot|lifeAgent",
"拍照问答": "dialogCopilot|imageLU|mapCopilot",
"搜索": "dialogCopilot|search",
"搜索#tvV2": "dialogCopilot|search|video|contentCopilot",
"搜索#soundbox": "dialogCopilot|search|music|contentCopilot",
"健康监控#glass": "dialogCopilot|toolsCopilot",
"地图导航": "mapCopilot|lifeCopilot|lifeAgent",
"地图导航#tvV2": "mapCopilot|lifeCopilot|lifeAgent|mapApp",
"地图问答": "mapCopilot|qabot|dialogCopilot",
"走哪问哪": "mapCopilot|qabot|dialogCopilot",
"地图设置": "mapCopilot|soundboxControl|controlCopilot|toolsCopilot|todolist",
"违章查询": "mapCopilot",
"限号": "mapCopilot",
"地址设置": "toolsCopilot|mapCopilot|mapApp",
"图片": "aicreativeCopilot|dialogCopilot|qabot",
"图像创作": "aicreativeCopilot|dialogCopilot|qabot",
"视频创作": "aicreativeCopilot|dialogCopilot|qabot",
"作文": "aicreativeCopilot",
"文本创作": "aicreativeCopilot|dialogCopilot|toolsCopilot",
"代码创作": "aicreativeCopilot",
"图像编辑": "aicreativeCopilot",
"时间": "toolsCopilot",
"天气": "toolsCopilot|dialogCopilot|qabot",
"数学": "toolsCopilot",
"数学应用题": "toolsCopilot",
"电话": "toolsCopilot",
"看图打电话": "toolsCopilot",
"翻译": "toolsCopilot",
"翻译#glass": "toolsCopilot|dialogCopilot|qabot",
"闹钟": "toolsCopilot",
"备忘录": "toolsCopilot|dialogCopilot|michat",
"记忆": "toolsCopilot",
"结构记忆": "toolsCopilot|dialogCopilot|michat",
"视频收藏": "toolsCopilot|controlCopilot|smartApp",
"电话问答": "toolsCopilot|dialogCopilot|qabot",
"课程表": "toolsCopilot",
"内容总结": "toolsCopilot|aicreativeCopilot|dialogCopilot|qabot",
"留言": "toolsCopilot|message",
"健康监控": "toolsCopilot|fitnessHealth|todolist|dialogCopilot|qabot",
"健康控制": "toolsCopilot|fitnessHealth|todolist|controlCopilot|soundboxControl|smartApp",
"声纹": "controlCopilot|voiceprint",
"家庭传声": "controlCopilot|iotCopilot|smartMiot|dialogCopilot|qabot|michat|contentCopilot|music|station",
"系统控制": "controlCopilot|iotCopilot|productAgent",
"萌宠控制": "controlCopilot|iotCopilot|productAgent",
"屏幕操作": "controlCopilot|iotCopilot|productAgent",
"音色切换": "controlCopilot|iotCopilot|productAgent",
"车载控制": "controlCopilot|iotCopilot|productAgent",
"车载设备状态查询": "controlCopilot|iotCopilot|productAgent",
"系统查询": "controlCopilot|iotCopilot|productAgent",
"设备控制": "controlCopilot|iotCopilot",
"家用设备状态查询": "controlCopilot|iotCopilot|productAgent",
"应用控制": "controlCopilot|iotCopilot|lifeCopilot|lifeAgent|fitnessHealth",
"生活缴费": "controlCopilot",
"应用窗口控制": "controlCopilot",
"应用控制#tvV2": "controlCopilot|iotCopilot|lifeCopilot|lifeAgent|fitnessHealth|video|soundboxControl|phonecall",
"应用控制#soundbox": "controlCopilot|contentCopilot|music|station|dialogCopilot|michat|qabot|video|fitnessHealth|lifeCopilot",
"应用控制搜索": "controlCopilot|smartApp",
"浏览器搜索": "controlCopilot|search|smartApp",
"相机": "controlCopilot",
"设备查找": "controlCopilot|smartMiot|michat",
"系统定时控制": "controlCopilot|iotCopilot|productAgent",
"设备定时控制": "controlCopilot|iotCopilot",
"应用定时控制": "controlCopilot|iotCopilot|lifeCopilot|lifeAgent|fitnessHealth",
"帮助": "productAgent|soundboxControl|controlCopilot",
"汽车手册": "productAgent|dialogCopilot|qabot",
"客服问答": "productAgent|soundboxControl|controlCopilot",
"购物": "productAgent|soundboxControl|controlCopilot",
"产品问答": "productAgent",
"手车互联": "productAgent|soundboxControl|controlCopilot",
"产品推荐": "productAgent",
"产品购买": "productAgent",
"外卖": "lifeCopilot",
"外卖|应用": "lifeCopilot|controlCopilot|smartApp",
"美食": "lifeCopilot",
"美食|应用": "lifeCopilot|controlCopilot|smartApp",
"景点": "lifeCopilot",
"景点|应用": "lifeCopilot|controlCopilot|smartApp",
"购票": "lifeCopilot",
"购票|应用": "lifeCopilot|controlCopilot|smartApp",
"电影": "lifeCopilot",
"电影|应用": "lifeCopilot|controlCopilot|smartApp",
"酒店": "lifeCopilot",
"酒店|应用": "lifeCopilot|controlCopilot|smartApp",
"生活休闲": "lifeCopilot",
"车服": "lifeCopilot",
"汽车服务": "lifeCopilot",
"团购": "lifeCopilot",
"打车": "lifeCopilot",
"航班查询": "lifeCopilot",
"通讯服务": "lifeCopilot",
"快递服务": "lifeCopilot|productAgent|shopping",
"产品推荐|参数对比": "lifeCopilot|productAgent|shopping",
"产品推荐|信息对比": "lifeCopilot|productAgent|shopping",
"平台比价": "productAgent|shopping",
"找同款": "lifeCopilot|productAgent|shopping",
"订单查询": "lifeCopilot|productAgent",
"订座排号": "lifeCopilot",
"前车识别": "dialogCopilot|imageLU|qabot",
"频道": "contentCopilot",
"频道#tvV2": "contentCopilot|controlCopilot|smartMiot|iotCopilot",
"频道#soundbox": "controlCopilot|smartMiot|iotCopilot",
"地图|搜索": "mapCopilot|controlCopilot|smartApp",
"生活服务|搜索": "lifeCopilot|lifeAgent|controlCopilot|smartApp",
"应用|购买": "controlCopilot|smartApp",
"地图控制": "mapCopilot|soundboxControl|controlCopilot|toolsCopilot|todolist",
"限行": "mapCopilot",
"音乐播放": "contentCopilot",
"音乐播放#tvV2": "contentCopilot|video",
"音乐播放#miCar": "contentCopilot|station",
"音乐播放#screenSoundbox": "contentCopilot|music",
"音乐播放#glass": "contentCopilot|ancientPoem",
"电台播放": "contentCopilot",
"电台播放#tvV2": "contentCopilot|video",
"视频播放": "contentCopilot",
"电视频道": "contentCopilot",
"电视频道#tvV2": "contentCopilot|controlCopilot|smartMiot|iotCopilot",
"电视频道#soundbox": "controlCopilot|smartMiot|iotCopilot",
"媒体资源问答": "contentCopilot|qabot|dialogCopilot",
"媒体资源问答#tvV2": "contentCopilot|qabot|dialogCopilot|video",
"媒体资源问答#soundbox": "contentCopilot|qabot|dialogCopilot|michat",
"媒体应用播放": "contentCopilot|controlCopilot|soundboxControl|smartApp",
"媒体资源切换": "contentCopilot|soundboxControl|controlCopilot",
"播放器控制": "contentCopilot|soundboxControl|controlCopilot",
"体育赛事播放": "contentCopilot|qabot|dialogCopilot|sports",
"体育赛事预约": "contentCopilot|qabot|dialogCopilot|sports",
"古诗词播放": "contentCopilot",
"古诗词问答": "contentCopilot",
"讲笑话": "contentCopilot",
"声音博物馆": "contentCopilot",
"闹钟计时器": "toolsCopilot",
"简单数学问题": "toolsCopilot",
"打电话": "toolsCopilot",
"发短信": "toolsCopilot",
"通讯录": "toolsCopilot",
"电话号码查询": "toolsCopilot|dialogCopilot|qabot",
"外语翻译": "toolsCopilot",
"翻译控制": "toolsCopilot",
"外语词典查询": "toolsCopilot",
"外语问答": "toolsCopilot",
"外语翻译#glass": "toolsCopilot|dialogCopilot|qabot",
"外语问答#glass": "toolsCopilot|dialogCopilot|qabot",
"外语词典查询#glass": "toolsCopilot|dialogCopilot|qabot",
"翻译控制#glass": "toolsCopilot|dialogCopilot|qabot",
"提醒": "toolsCopilot|dialogCopilot|michat",
"信息记忆": "toolsCopilot",
"记忆查询": "toolsCopilot|dialogCopilot|michat",
"个人信息": "toolsCopilot|dialogCopilot|michat",
"声纹设置": "controlCopilot|voiceprint",
"相机控制": "controlCopilot",
"系统状态查询": "controlCopilot|iotCopilot|productAgent",
"小爱帮助": "productAgent|soundboxControl|controlCopilot",
"小米产品帮助": "productAgent",
"商品购买": "productAgent",
"餐饮服务": "lifeCopilot",
"餐饮服务|应用": "lifeCopilot|controlCopilot|smartApp",
"旅游": "lifeCopilot",
"生活服务": "lifeCopilot",
"交通购票": "lifeCopilot",
"电影票购买": "lifeCopilot",
"电影票购买|应用": "lifeCopilot|controlCopilot|smartApp",
"航班信息查询": "lifeCopilot",
"话费流量服务": "lifeCopilot",
"购物订单查询": "lifeCopilot|productAgent",
"餐厅订座排号": "lifeCopilot"
}
@@ -0,0 +1,31 @@
#!/usr/bin/env python3
"""分析历史干预文件,归纳设备枚举和 code 到下发 domain 的映射。"""
from __future__ import annotations
import argparse
from pathlib import Path
from intervention_common import DEFAULT_EXACT_SOURCE, DEFAULT_REGEX_SOURCE, build_source_summary, json_dumps
def main() -> int:
parser = argparse.ArgumentParser(description="分析历史干预 TSV")
parser.add_argument("--exact-source", default=str(DEFAULT_EXACT_SOURCE), help="单句精确干预 TSV 路径")
parser.add_argument("--regex-source", default=str(DEFAULT_REGEX_SOURCE), help="正则干预 TSV 路径")
parser.add_argument("--top-mapping", type=int, default=30, help="输出前 N 个 code-domain 映射")
args = parser.parse_args()
summary = build_source_summary(Path(args.exact_source), Path(args.regex_source))
mapping_items = sorted(
summary["code_domain_mapping"].items(),
key=lambda item: item[1]["count"],
reverse=True,
)
summary["code_domain_mapping"] = dict(mapping_items[: args.top_mapping])
print(json_dumps(summary))
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,247 @@
#!/usr/bin/env python3
"""审计现有干预文件和当前规则的贴合度。
这个脚本用于回答两个问题:
1. 现有单句精确干预/正则干预文件自身是否存在明显格式问题。
2. 当前 label-master 校验、type_match_agent 下发表和历史数据是否匹配。
它不会修改任何文件,只输出 JSON 摘要和少量样例。
"""
from __future__ import annotations
import argparse
import re
from collections import Counter, defaultdict
from pathlib import Path
from typing import Any
from intervention_common import (
DEFAULT_EXACT_SOURCE,
DEFAULT_REGEX_SOURCE,
build_source_summary,
domain_compatible,
infer_configured_dispatch_domain,
json_dumps,
load_exact_records,
load_label_validator,
load_regex_records,
)
def sample_append(bucket: list[dict[str, Any]], item: dict[str, Any], limit: int) -> None:
if len(bucket) < limit:
bucket.append(item)
def validate_unique_targets(targets: set[str]) -> dict[str, dict[str, Any]]:
validator = load_label_validator()
return {target: validator.validate(target) for target in sorted(targets)}
def audit_exact_records(summary: dict[str, Any], target_results: dict[str, dict[str, Any]], sample_limit: int) -> dict[str, Any]:
records = load_exact_records(Path(summary["source_files"]["exact"]))
empty_fields = Counter()
target_invalid = Counter()
configured_mismatch = Counter()
duplicate_keys: dict[tuple[str, str], list[Any]] = defaultdict(list)
samples = {
"empty_fields": [],
"invalid_targets": [],
"configured_domain_mismatch": [],
"conflicts": [],
}
for record in records:
if not record.device:
empty_fields["device"] += 1
if not record.query:
empty_fields["query"] += 1
if not record.target:
empty_fields["target"] += 1
if not record.dispatch_domain:
empty_fields["dispatch_domain"] += 1
if any(not value for value in (record.device, record.query, record.target, record.dispatch_domain)):
sample_append(samples["empty_fields"], record.__dict__, sample_limit)
result = target_results.get(record.target)
if result and not result.get("valid"):
target_invalid[record.target] += 1
sample_append(
samples["invalid_targets"],
{
"line_no": record.line_no,
"target": record.target,
"errors": result.get("errors", []),
},
sample_limit,
)
configured = infer_configured_dispatch_domain(record.target, record.device, summary)
if configured and not domain_compatible(record.dispatch_domain, configured["dispatch_domain"]):
configured_mismatch[configured["matched_key"]] += 1
sample_append(
samples["configured_domain_mismatch"],
{
"line_no": record.line_no,
"device": record.device,
"query": record.query,
"target": record.target,
"dispatch_domain": record.dispatch_domain,
"configured": configured,
},
sample_limit,
)
duplicate_keys[(record.device, record.query)].append(record)
conflicts = []
duplicate_count = 0
for (device, query), items in duplicate_keys.items():
if len(items) <= 1:
continue
duplicate_count += len(items)
variants = {(item.target, item.dispatch_domain) for item in items}
if len(variants) > 1:
conflict = {
"device": device,
"query": query,
"lines": [item.line_no for item in items[:10]],
"variants": sorted([{"target": target, "dispatch_domain": domain} for target, domain in variants], key=str),
}
conflicts.append(conflict)
sample_append(samples["conflicts"], conflict, sample_limit)
return {
"total": len(records),
"empty_fields": dict(empty_fields),
"invalid_target_total": sum(target_invalid.values()),
"invalid_target_top": dict(target_invalid.most_common(20)),
"configured_domain_mismatch_total": sum(configured_mismatch.values()),
"configured_domain_mismatch_top": dict(configured_mismatch.most_common(20)),
"duplicate_row_count_by_device_query": duplicate_count,
"conflict_key_count": len(conflicts),
"samples": samples,
}
def audit_regex_records(target_results: dict[str, dict[str, Any]], summary: dict[str, Any], sample_limit: int) -> dict[str, Any]:
records = load_regex_records(Path(summary["source_files"]["regex"]))
empty_fields = Counter()
regex_errors = Counter()
anchor_warnings = 0
missing_query_fragment = 0
target_invalid = Counter()
duplicate_keys: dict[tuple[str, str], list[Any]] = defaultdict(list)
samples = {
"empty_fields": [],
"regex_errors": [],
"missing_query_fragment": [],
"invalid_targets": [],
"conflicts": [],
}
for record in records:
if not record.device:
empty_fields["device"] += 1
if not record.pattern:
empty_fields["pattern"] += 1
if not record.target:
empty_fields["target"] += 1
if any(not value for value in (record.device, record.pattern, record.target)):
sample_append(samples["empty_fields"], record.__dict__, sample_limit)
if record.pattern:
try:
re.compile(record.pattern)
except re.error as exc:
regex_errors[str(exc)] += 1
sample_append(
samples["regex_errors"],
{"line_no": record.line_no, "pattern": record.pattern, "error": str(exc)},
sample_limit,
)
if "query#" not in record.pattern:
missing_query_fragment += 1
sample_append(samples["missing_query_fragment"], record.__dict__, sample_limit)
if not record.pattern.startswith("^") or not record.pattern.endswith("$"):
anchor_warnings += 1
result = target_results.get(record.target)
if result and not result.get("valid"):
target_invalid[record.target] += 1
sample_append(
samples["invalid_targets"],
{
"line_no": record.line_no,
"target": record.target,
"errors": result.get("errors", []),
},
sample_limit,
)
duplicate_keys[(record.device, record.pattern)].append(record)
conflicts = []
duplicate_count = 0
for (device, pattern), items in duplicate_keys.items():
if len(items) <= 1:
continue
duplicate_count += len(items)
variants = {item.target for item in items}
if len(variants) > 1:
conflict = {
"device": device,
"pattern": pattern,
"lines": [item.line_no for item in items[:10]],
"variants": sorted(variants),
}
conflicts.append(conflict)
sample_append(samples["conflicts"], conflict, sample_limit)
return {
"total": len(records),
"empty_fields": dict(empty_fields),
"regex_error_total": sum(regex_errors.values()),
"regex_error_top": dict(regex_errors.most_common(20)),
"missing_query_fragment": missing_query_fragment,
"anchor_warning_count": anchor_warnings,
"invalid_target_total": sum(target_invalid.values()),
"invalid_target_top": dict(target_invalid.most_common(20)),
"duplicate_row_count_by_device_pattern": duplicate_count,
"conflict_key_count": len(conflicts),
"samples": samples,
}
def main() -> int:
parser = argparse.ArgumentParser(description="审计现有干预文件")
parser.add_argument("--exact-source", default=str(DEFAULT_EXACT_SOURCE), help="单句精确干预 TSV 路径")
parser.add_argument("--regex-source", default=str(DEFAULT_REGEX_SOURCE), help="正则干预 TSV 路径")
parser.add_argument("--sample-limit", type=int, default=5, help="每类问题最多输出样例数")
args = parser.parse_args()
summary = build_source_summary(Path(args.exact_source), Path(args.regex_source))
exact_records = load_exact_records(Path(args.exact_source))
regex_records = load_regex_records(Path(args.regex_source))
target_results = validate_unique_targets({record.target for record in exact_records + regex_records if record.target})
payload = {
"source_files": summary["source_files"],
"devices": summary["devices"],
"counts": summary["counts"],
"target_validation": {
"unique_targets": len(target_results),
"valid_unique_targets": sum(1 for item in target_results.values() if item.get("valid")),
"invalid_unique_targets": sum(1 for item in target_results.values() if not item.get("valid")),
},
"exact": audit_exact_records(summary, target_results, args.sample_limit),
"regex": audit_regex_records(target_results, summary, args.sample_limit),
}
print(json_dumps(payload))
has_hard_errors = bool(payload["exact"]["empty_fields"] or payload["regex"]["regex_error_total"])
return 1 if has_hard_errors else 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,141 @@
#!/usr/bin/env python3
"""生成单句精确干预或正则干预候选 TSV,并返回校验结果。"""
from __future__ import annotations
import argparse
import json
import subprocess
import sys
from pathlib import Path
from typing import Any
from intervention_common import (
DEFAULT_EXACT_SOURCE,
DEFAULT_REGEX_SOURCE,
build_query_pattern,
build_source_summary,
exact_tsv_line,
infer_configured_dispatch_domain,
infer_dispatch_domain_for_device,
json_dumps,
normalize_device,
normalize_target,
regex_tsv_line,
)
SCRIPT_DIR = Path(__file__).resolve().parent
VALIDATE_SCRIPT = SCRIPT_DIR / "validate_intervention_records.py"
def load_payload(path: str | None) -> dict[str, Any]:
text = Path(path).read_text(encoding="utf-8") if path else sys.stdin.read()
if not text.strip():
raise ValueError("输入 JSON 不能为空")
return json.loads(text)
def run_validator(mode: str, tsv: str, exact_source: str, regex_source: str) -> dict[str, Any]:
proc = subprocess.run(
[
sys.executable,
str(VALIDATE_SCRIPT),
"--mode",
mode,
"--exact-source",
exact_source,
"--regex-source",
regex_source,
],
input=tsv,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=False,
)
if not proc.stdout.strip():
return {
"valid": False,
"errors": [proc.stderr.strip() or f"校验脚本退出:{proc.returncode}"],
}
return json.loads(proc.stdout)
def generate_exact(payload: dict[str, Any], exact_source: str, regex_source: str) -> dict[str, Any]:
device = normalize_device(str(payload.get("device", "")))
query = str(payload.get("query", "")).strip()
target = normalize_target(str(payload.get("target") or payload.get("code") or ""))
dispatch_domain = str(payload.get("dispatch_domain") or payload.get("domain") or "").strip()
summary = build_source_summary(Path(exact_source), Path(regex_source))
inferred = None
configured = infer_configured_dispatch_domain(target, device, summary)
if not dispatch_domain:
inferred = infer_dispatch_domain_for_device(target, device, summary)
if inferred:
dispatch_domain = inferred["dispatch_domain"]
tsv = exact_tsv_line(device, query, target, dispatch_domain)
validation = run_validator("exact", tsv, exact_source, regex_source)
return {
"mode": "exact",
"tsv": tsv,
"record": {
"device": device,
"query": query,
"target": target,
"dispatch_domain": dispatch_domain,
},
"inferred_dispatch_domain": inferred,
"configured_dispatch_domain": configured,
"validation": validation,
}
def generate_regex(payload: dict[str, Any], exact_source: str, regex_source: str) -> dict[str, Any]:
device = normalize_device(str(payload.get("device", "")))
target = normalize_target(str(payload.get("target") or payload.get("label") or ""))
raw_pattern = str(payload.get("pattern") or payload.get("regex") or "").strip()
query = str(payload.get("query") or payload.get("current_query") or "").strip()
before_queries = payload.get("before_queries") or []
if not isinstance(before_queries, list):
raise ValueError("before_queries 必须是字符串数组")
pattern = raw_pattern or build_query_pattern(query, [str(item) for item in before_queries])
tsv = regex_tsv_line(device, pattern, target)
validation = run_validator("regex", tsv, exact_source, regex_source)
return {
"mode": "regex",
"tsv": tsv,
"record": {
"device": device,
"pattern": pattern,
"target": target,
},
"validation": validation,
}
def main() -> int:
parser = argparse.ArgumentParser(description="生成干预候选 TSV")
parser.add_argument("--input", help="输入 JSON 文件;不传则读取 stdin")
parser.add_argument("--exact-source", default=str(DEFAULT_EXACT_SOURCE), help="历史单句精确干预 TSV")
parser.add_argument("--regex-source", default=str(DEFAULT_REGEX_SOURCE), help="历史正则干预 TSV")
args = parser.parse_args()
payload = load_payload(args.input)
mode = str(payload.get("mode", "")).strip().lower()
if mode == "exact":
result = generate_exact(payload, args.exact_source, args.regex_source)
elif mode == "regex":
result = generate_regex(payload, args.exact_source, args.regex_source)
else:
raise ValueError("mode 必须是 exact 或 regex")
print(json_dumps(result))
return 0 if result.get("validation", {}).get("valid") else 1
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,330 @@
#!/usr/bin/env python3
"""干预数据脚本的公共能力。
这里尽量只做确定性处理:
- 读取历史干预 TSV。
- 归纳设备枚举和 code 到下发 domain 的多数映射。
- 复用 label-master 校验 target 语法。
- 校验候选干预条目是否和历史数据重复或冲突。
"""
from __future__ import annotations
import csv
import importlib.util
import json
import re
import sys
from collections import Counter, defaultdict
from dataclasses import dataclass
from pathlib import Path
from typing import Any
SCRIPT_DIR = Path(__file__).resolve().parent
SKILL_ROOT = SCRIPT_DIR.parent
REPO_ROOT = SKILL_ROOT.parent.parent
DEFAULT_EXACT_SOURCE = REPO_ROOT / "干预skill" / "20260134"
DEFAULT_REGEX_SOURCE = REPO_ROOT / "干预skill" / "20260207"
LABEL_VALIDATOR_PATH = REPO_ROOT / "skills" / "label-master" / "scripts" / "validate_label_output.py"
CONFIG_MAPPING_PATH = SKILL_ROOT / "knowledge" / "type_match_agent.json"
AGENT_TARGET_RE = re.compile(r'^Agent\(\s*tag\s*=\s*["\'](.+?)["\']\s*\)$')
@dataclass(frozen=True)
class ExactRecord:
device: str
query: str
target: str
dispatch_domain: str
line_no: int
@dataclass(frozen=True)
class RegexRecord:
device: str
pattern: str
target: str
line_no: int
def normalize_device(device: str) -> str:
"""归一化历史数据里的设备写法,但保留常见大小写。"""
value = device.strip()
if value.startswith("#"):
value = value[1:].strip()
aliases = {
"micar": "miCar",
"miai": "miai",
"tVV2": "tvV2",
"tvv2": "tvV2",
"unify": "unify",
}
return aliases.get(value, value)
def normalize_target(target: str) -> str:
return target.replace("", '"').replace("", '"').replace("", "'").replace("", "'").strip()
def read_tsv_rows(path: Path) -> list[list[str]]:
with path.open("r", encoding="utf-8-sig", newline="") as handle:
return list(csv.reader(handle, delimiter="\t"))
def load_exact_records(path: Path = DEFAULT_EXACT_SOURCE) -> list[ExactRecord]:
rows = read_tsv_rows(path)
records: list[ExactRecord] = []
for index, row in enumerate(rows, start=1):
if index == 1 and row[:4] == ["#设备名", "query", "code", "下发domain"]:
continue
if not row or all(not cell.strip() for cell in row):
continue
padded = [*row, "", "", "", ""]
records.append(
ExactRecord(
device=normalize_device(padded[0]),
query=padded[1].strip(),
target=normalize_target(padded[2]),
dispatch_domain=padded[3].strip(),
line_no=index,
)
)
return records
def load_regex_records(path: Path = DEFAULT_REGEX_SOURCE) -> list[RegexRecord]:
rows = read_tsv_rows(path)
records: list[RegexRecord] = []
for index, row in enumerate(rows, start=1):
if not row or all(not cell.strip() for cell in row):
continue
padded = [*row, "", ""]
records.append(
RegexRecord(
device=normalize_device(padded[0]),
pattern=padded[1].strip(),
target=normalize_target(padded[2]),
line_no=index,
)
)
return records
def build_source_summary(
exact_path: Path = DEFAULT_EXACT_SOURCE,
regex_path: Path = DEFAULT_REGEX_SOURCE,
) -> dict[str, Any]:
exact_records = load_exact_records(exact_path) if exact_path.exists() else []
regex_records = load_regex_records(regex_path) if regex_path.exists() else []
exact_devices = Counter(record.device for record in exact_records)
regex_devices = Counter(record.device for record in regex_records)
devices = sorted(set(exact_devices) | set(regex_devices))
code_domain_counts: dict[str, Counter[str]] = defaultdict(Counter)
for record in exact_records:
if record.target and record.dispatch_domain:
code_domain_counts[record.target][record.dispatch_domain] += 1
code_domain_mapping: dict[str, dict[str, Any]] = {}
for target, counts in code_domain_counts.items():
domain, count = counts.most_common(1)[0]
total = sum(counts.values())
code_domain_mapping[target] = {
"dispatch_domain": domain,
"count": count,
"total": total,
"confidence": round(count / total, 4) if total else 0,
"alternatives": [
{"dispatch_domain": item_domain, "count": item_count}
for item_domain, item_count in counts.most_common(5)
],
}
config_mapping = load_type_match_agent_mapping()
return {
"source_files": {
"exact": str(exact_path),
"regex": str(regex_path),
},
"counts": {
"exact_records": len(exact_records),
"regex_records": len(regex_records),
},
"devices": devices,
"device_counts": {
"exact": dict(exact_devices.most_common()),
"regex": dict(regex_devices.most_common()),
},
"code_domain_mapping": code_domain_mapping,
"configured_code_domain_mapping": config_mapping,
"historical_targets": sorted({record.target for record in exact_records + regex_records if record.target}),
"top_exact_targets": dict(Counter(record.target for record in exact_records).most_common(50)),
"top_regex_targets": dict(Counter(record.target for record in regex_records).most_common(50)),
}
def historical_targets(summary: dict[str, Any]) -> set[str]:
targets = set(summary.get("top_exact_targets", {})) | set(summary.get("top_regex_targets", {}))
targets.update(summary.get("code_domain_mapping", {}))
targets.update(summary.get("historical_targets", []))
return {normalize_target(target) for target in targets if target}
def infer_dispatch_domain(target: str, summary: dict[str, Any]) -> dict[str, Any] | None:
normalized = normalize_target(target)
historical = summary.get("code_domain_mapping", {}).get(normalized)
if historical:
return {**historical, "source": "historical"}
configured = infer_configured_dispatch_domain(normalized, "unify", summary)
if configured:
return {
"dispatch_domain": configured["dispatch_domain"],
"count": 0,
"total": 0,
"confidence": None,
"alternatives": [],
"source": "type_match_agent",
"matched_key": configured["matched_key"],
}
return None
def infer_dispatch_domain_for_device(target: str, device: str, summary: dict[str, Any]) -> dict[str, Any] | None:
"""按设备推断推荐下发 domain。
优先级:
1. `标签#设备` 这种明确配置。
2. 历史精确干预表里的全局多数映射。
3. `标签` 通用配置。
"""
normalized = normalize_target(target)
configured = infer_configured_dispatch_domain(normalized, device, summary)
if configured and "#" in configured["matched_key"]:
return {
"dispatch_domain": configured["dispatch_domain"],
"count": 0,
"total": 0,
"confidence": None,
"alternatives": [],
"source": "type_match_agent",
"matched_key": configured["matched_key"],
}
historical = summary.get("code_domain_mapping", {}).get(normalized)
if historical:
return {**historical, "source": "historical"}
if configured:
return {
"dispatch_domain": configured["dispatch_domain"],
"count": 0,
"total": 0,
"confidence": None,
"alternatives": [],
"source": "type_match_agent",
"matched_key": configured["matched_key"],
}
return None
def load_type_match_agent_mapping(path: Path = CONFIG_MAPPING_PATH) -> dict[str, str]:
if not path.exists():
return {}
data = json.loads(path.read_text(encoding="utf-8"))
return {str(key): str(value) for key, value in data.items()}
def extract_agent_tag(target: str) -> str | None:
match = AGENT_TARGET_RE.match(normalize_target(target))
if not match:
return None
return match.group(1)
def infer_configured_dispatch_domain(target: str, device: str, summary: dict[str, Any]) -> dict[str, str] | None:
tag = extract_agent_tag(target)
if not tag:
return None
mapping = summary.get("configured_code_domain_mapping", {})
normalized_device = normalize_device(device)
for key in (f"{tag}#{normalized_device}", tag):
if key in mapping:
return {"matched_key": key, "dispatch_domain": mapping[key]}
return None
def split_domain(domain: str) -> list[str]:
return [part for part in domain.split("|") if part]
def domain_compatible(actual: str, expected: str) -> bool:
"""判断实际下发 domain 是否和配置基线兼容。
下发表里有些是宽泛入口,例如 `音乐 -> contentCopilot`
历史干预里常见更细路径,例如 `contentCopilot|music`。
只要二者一方的管道 token 是另一方的子集,就认为兼容。
"""
actual_parts = set(split_domain(actual))
expected_parts = set(split_domain(expected))
if not actual_parts or not expected_parts:
return False
return actual_parts.issubset(expected_parts) or expected_parts.issubset(actual_parts)
def load_label_validator() -> Any:
spec = importlib.util.spec_from_file_location("label_master_validate_label_output", LABEL_VALIDATOR_PATH)
if spec is None or spec.loader is None:
raise RuntimeError(f"无法加载 label-master 校验器:{LABEL_VALIDATOR_PATH}")
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
manifest_path = module.DEFAULT_OUTPUT
validator = module.LabelOutputValidator(module.load_manifest(manifest_path))
return validator
def validate_target(target: str) -> dict[str, Any]:
validator = load_label_validator()
# 历史 TSV 中多行 function/Agent 目标常以字面量 `\n` 存在,
# label-master 校验器需要真实换行才能按多行程序解析。
return validator.validate(normalize_target(target).replace("\\n", "\n"))
def escape_regex_literal(text: str) -> str:
return re.escape(text.strip())
def build_query_pattern(query: str, before_queries: list[str] | None = None) -> str:
before_queries = before_queries or []
parts: list[str] = []
for before_query in before_queries:
if before_query.strip():
parts.append(f"beforeQuery#{escape_regex_literal(before_query)}")
parts.append(f"query#{escape_regex_literal(query)}")
return "^" + "#".join(parts) + "$"
def exact_tsv_line(device: str, query: str, target: str, dispatch_domain: str) -> str:
return "\t".join([normalize_device(device), query.strip(), normalize_target(target), dispatch_domain.strip()])
def regex_tsv_line(device: str, pattern: str, target: str) -> str:
return "\t".join([normalize_device(device), pattern.strip(), normalize_target(target)])
def parse_candidate_lines(text: str) -> list[list[str]]:
rows = []
for line in text.splitlines():
if line.strip():
rows.append(next(csv.reader([line], delimiter="\t")))
return rows
def json_dumps(payload: Any) -> str:
return json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True)
@@ -0,0 +1,220 @@
#!/usr/bin/env python3
"""校验单句精确干预和正则干预候选 TSV。"""
from __future__ import annotations
import argparse
import json
import re
import sys
from pathlib import Path
from typing import Any
from intervention_common import (
DEFAULT_EXACT_SOURCE,
DEFAULT_REGEX_SOURCE,
build_source_summary,
domain_compatible,
historical_targets,
infer_configured_dispatch_domain,
infer_dispatch_domain_for_device,
load_exact_records,
load_regex_records,
normalize_device,
normalize_target,
parse_candidate_lines,
validate_target,
json_dumps,
)
def load_input_text(path: str | None) -> str:
if path:
return Path(path).read_text(encoding="utf-8")
return sys.stdin.read()
def validate_exact_rows(rows: list[list[str]], args: argparse.Namespace) -> dict[str, Any]:
summary = build_source_summary(Path(args.exact_source), Path(args.regex_source))
devices = set(summary["devices"])
known_historical_targets = historical_targets(summary)
existing: dict[tuple[str, str], list[Any]] = {}
for record in load_exact_records(Path(args.exact_source)):
existing.setdefault((record.device, record.query), []).append(record)
results = []
for index, row in enumerate(rows, start=1):
errors: list[str] = []
warnings: list[str] = []
if len(row) != 4:
errors.append(f"单句精确干预必须是 4 列,当前 {len(row)}")
results.append({"line": index, "valid": False, "errors": errors, "warnings": warnings, "row": row})
continue
device, query, target, dispatch_domain = [cell.strip() for cell in row]
device = normalize_device(device)
target = normalize_target(target)
if device not in devices:
errors.append(f"设备不在历史枚举中:{device},可选:{', '.join(sorted(devices))}")
if not query:
errors.append("query 不能为空")
if "\t" in query or "\n" in query or "\r" in query:
errors.append("query 不能包含制表符或换行")
target_result = validate_target(target)
if not target_result["valid"]:
if target in known_historical_targets:
warnings.append("target 未通过 label-master 知识库校验,但历史干预文件中已存在;建议后续补齐标签知识库")
target_result["normalized_output"] = target
else:
errors.extend([f"target 不合法:{message}" for message in target_result.get("errors", [])])
if not dispatch_domain:
errors.append("下发domain 不能为空")
inferred = infer_dispatch_domain_for_device(target, device, summary)
if inferred and dispatch_domain and dispatch_domain != inferred["dispatch_domain"]:
message = (
f"下发domain 与推断映射不一致:当前 {dispatch_domain}"
f"推断为 {inferred['dispatch_domain']},来源 {inferred.get('source', 'historical')},置信度 {inferred.get('confidence')}"
)
if args.strict_domain:
errors.append(message)
else:
warnings.append(message)
if not inferred:
warnings.append("未在历史精确干预表中找到该 target 的下发domain 映射,需要人工确认")
configured = infer_configured_dispatch_domain(target, device, summary)
if configured and dispatch_domain and not domain_compatible(dispatch_domain, configured["dispatch_domain"]):
warnings.append(
f"下发domain 与 type_match_agent 配置不兼容:当前 {dispatch_domain}"
f"配置 {configured['matched_key']} -> {configured['dispatch_domain']}"
)
if not configured:
warnings.append("type_match_agent 下发表中未找到该 Agent tag 的设备/通用映射")
conflicts = existing.get((device, query), [])
for item in conflicts:
if item.target == target and item.dispatch_domain == dispatch_domain:
warnings.append(f"历史文件已存在相同单句干预:line {item.line_no}")
else:
errors.append(
f"历史文件存在同设备同 query 的不同干预:line {item.line_no}"
f"{item.target} / {item.dispatch_domain}"
)
results.append(
{
"line": index,
"valid": not errors,
"errors": errors,
"warnings": warnings,
"normalized": {
"device": device,
"query": query,
"target": target_result.get("normalized_output", target),
"dispatch_domain": dispatch_domain,
},
"configured_dispatch_domain": configured,
}
)
return summarize_results(results)
def validate_regex_rows(rows: list[list[str]], args: argparse.Namespace) -> dict[str, Any]:
summary = build_source_summary(Path(args.exact_source), Path(args.regex_source))
devices = set(summary["devices"])
known_historical_targets = historical_targets(summary)
existing: dict[tuple[str, str], list[Any]] = {}
for record in load_regex_records(Path(args.regex_source)):
existing.setdefault((record.device, record.pattern), []).append(record)
results = []
for index, row in enumerate(rows, start=1):
errors: list[str] = []
warnings: list[str] = []
if len(row) != 3:
errors.append(f"正则干预必须是 3 列,当前 {len(row)}")
results.append({"line": index, "valid": False, "errors": errors, "warnings": warnings, "row": row})
continue
device, pattern, target = [cell.strip() for cell in row]
device = normalize_device(device)
target = normalize_target(target)
if device not in devices:
errors.append(f"设备不在历史枚举中:{device},可选:{', '.join(sorted(devices))}")
if not pattern:
errors.append("正则不能为空")
else:
try:
re.compile(pattern)
except re.error as exc:
errors.append(f"正则无法编译:{exc}")
if "query#" not in pattern:
errors.append("正则必须包含 query# 片段")
if not pattern.startswith("^") or not pattern.endswith("$"):
warnings.append("历史常见正则通常使用 ^...$ 完整锚定,建议确认是否需要锚定")
target_result = validate_target(target)
if not target_result["valid"]:
if target in known_historical_targets:
warnings.append("target 未通过 label-master 知识库校验,但历史干预文件中已存在;建议后续补齐标签知识库")
target_result["normalized_output"] = target
else:
errors.extend([f"target 不合法:{message}" for message in target_result.get("errors", [])])
conflicts = existing.get((device, pattern), [])
for item in conflicts:
if item.target == target:
warnings.append(f"历史文件已存在相同正则干预:line {item.line_no}")
else:
errors.append(
f"历史文件存在同设备同正则的不同干预:line {item.line_no}{item.target}"
)
results.append(
{
"line": index,
"valid": not errors,
"errors": errors,
"warnings": warnings,
"normalized": {
"device": device,
"pattern": pattern,
"target": target_result.get("normalized_output", target),
},
}
)
return summarize_results(results)
def summarize_results(results: list[dict[str, Any]]) -> dict[str, Any]:
return {
"valid": all(item["valid"] for item in results),
"total": len(results),
"invalid": sum(1 for item in results if not item["valid"]),
"warning_count": sum(len(item.get("warnings", [])) for item in results),
"results": results,
}
def main() -> int:
parser = argparse.ArgumentParser(description="校验干预 TSV 候选")
parser.add_argument("--mode", choices=["exact", "regex"], required=True, help="干预类型")
parser.add_argument("--input", help="候选 TSV 文件路径;不传则读取 stdin")
parser.add_argument("--exact-source", default=str(DEFAULT_EXACT_SOURCE), help="历史单句精确干预 TSV")
parser.add_argument("--regex-source", default=str(DEFAULT_REGEX_SOURCE), help="历史正则干预 TSV")
parser.add_argument("--strict-domain", action="store_true", help="下发domain 和历史多数映射不一致时直接报错")
args = parser.parse_args()
text = load_input_text(args.input)
rows = parse_candidate_lines(text)
if args.mode == "exact":
payload = validate_exact_rows(rows, args)
else:
payload = validate_regex_rows(rows, args)
print(json_dumps(payload))
return 0 if payload["valid"] else 1
if __name__ == "__main__":
raise SystemExit(main())
+35
View File
@@ -0,0 +1,35 @@
# model-labeling
对已有数据调用线上模型接口批量打标。
典型输入:
- `output/records.jsonl`
- `output/training.jsonl`
- `output/eval_planning.csv`
- `output/records.csv`
典型流程:
1. 确认输入文件路径。
2. 确认线上模型 `generate` URL。
3.`batch_label_model.py``dry_run=true` 识别格式。
4. 批量请求模型,输出 `output/model_predictions.jsonl`
执行示例:
```json
{
"script_path": "skills/model-labeling/scripts/batch_label_model.py",
"stdin": {
"input_path": "output/records.jsonl",
"model_url": "http://example/generate",
"output_path": "output/model_predictions.jsonl",
"parameters": {
"max_new_tokens": 64
}
},
"timeout_seconds": 600,
"max_output_chars": 20000
}
```
+159
View File
@@ -0,0 +1,159 @@
---
name: model-labeling
description: 对已有数据集调用线上模型 generate 接口批量打标,支持 canonical records、训练 jsonl、评测 CSV 和同事流转表格的统一归一化。
when_to_use: 当用户已有一份数据,或刚通过 product-data / online-mining-v2 生成数据后,希望指定线上模型 URL 批量请求模型、得到预测标签、对比真实标签或产出标注结果时使用。
aliases: batch-labeling, model-annotation, online-model-labeling, 模型打标
allowed_tools: read_file, write_file, grep_search, glob_search, ask_user_question, python_exec
---
# Model Labeling
使用这个 skill 处理“已有数据 -> 统一样本格式 -> 调线上模型接口批量打标 -> 输出预测结果”的流程。
线上模型 URL 经常变化,**不要猜 URL**。如果用户没有明确提供 `http://.../generate` 或等价接口地址,必须先询问用户。
## 能力组织
```text
skills/model-labeling/
SKILL.md
knowledge/
input_formats.md
scripts/
batch_label_model.py
```
`batch_label_model.py` 是 portable script,只依赖 Python 标准库。它会:
- 识别 canonical records JSON/JSONL。
- 识别 product-data 导出的训练 JSONL。
- 识别 eval/planning CSV,优先使用 `newPrompt`
- 识别同事流转 CSV,使用 `query``prev_session``context``function`
- 对未知格式返回结构化错误,要求用户提供字段映射,不要擅自转换。
- 调用用户提供的模型接口,默认请求体为:
```json
{
"inputs": "<prompt>",
"parameters": {
"max_new_tokens": 64
}
}
```
## 交互规则
开始执行前必须确认:
1. **输入数据路径**:用户给出的文件路径,或上一轮产物路径。
2. **模型接口 URL**:必须是用户明确提供的 URL;没有就问。
3. **输入格式是否可识别**canonical records、训练 jsonl、eval CSV、同事流转表格可以直接处理。
4. **未知格式的字段映射**:如果脚本提示 unknown format,需要问用户:
- 哪列是 query
- 哪列是 prompt
- 哪列是真实标签?
- 是否有历史上下文、context
5. **输出路径**:默认写到当前 session 的 `output/model_predictions.jsonl`
不要在没有 URL 的情况下开始打标。不要把临时结果写到项目根目录。所有输出优先放当前 session 的 `output/`
## 推荐流程
### 1. 找到输入文件
如果用户说“刚才生成的数据”“这份数据”,先用会话文件面板或 `glob_search` / `read_file` 找到实际路径。常见路径:
```text
output/records.jsonl
output/training.jsonl
output/eval_planning.csv
output/records.csv
```
### 2. 确认模型 URL
如果用户没有提供 URL,直接问:
```text
请提供这次要调用的线上模型 generate 接口 URL,例如 http://.../generate。
```
如果 `ask_user_question` 可用,优先使用;不可用就普通回复提问并停止。
### 3. 先 dry run 识别格式
先执行一次 `dry_run=true`,只识别格式和样例,不请求模型:
```json
{
"script_path": "skills/model-labeling/scripts/batch_label_model.py",
"stdin": {
"input_path": "output/records.jsonl",
"model_url": "http://example/generate",
"dry_run": true,
"max_records": 3
},
"timeout_seconds": 60,
"max_output_chars": 20000
}
```
如果返回 `ok=false``needs_mapping=true`,必须把错误和已识别字段展示给用户,让用户说明映射。
### 4. 批量请求模型
确认格式后执行:
```json
{
"script_path": "skills/model-labeling/scripts/batch_label_model.py",
"stdin": {
"input_path": "output/records.jsonl",
"model_url": "http://example/generate",
"output_path": "output/model_predictions.jsonl",
"parameters": {
"max_new_tokens": 64
},
"timeout_seconds": 60
},
"timeout_seconds": 600,
"max_output_chars": 20000
}
```
`output_path` 使用相对 `output/...`,平台会路由到当前 session output 目录。
### 5. 展示结果
执行完成后,简短展示:
- 输入格式。
- 处理条数、成功数、失败数。
- 输出路径。
- 抽 3 条预测样例。
如果存在真实标签,说明输出里包含 `gold_label`,后续可以继续做准确率或错误分析。
## 输出格式
默认输出 JSONL,一行一条紧凑 JSON:
```json
{"index":0,"request_id":"...","query":"...","gold_label":"complex=false\nAgent(tag=\"地图导航\")","prediction":"...","ok":true,"latency_ms":123}
```
如果请求失败:
```json
{"index":0,"query":"...","gold_label":"...","prediction":"","ok":false,"error":"HTTP 500 ...","latency_ms":123}
```
默认不把完整 prompt 写入结果,避免文件过大。如确实需要排查,可传 `include_prompt=true`
## 注意事项
- URL、鉴权 header、特殊请求体字段都以用户提供为准。
- 默认接口字段是 `inputs``parameters`;如果用户说明接口不同,需要在脚本输入里传 `request_template`
- 大批量请求前先小样本 dry run。
- 打标脚本只负责请求模型和记录预测结果,不负责修改原始数据。
- 后续准确率统计、错误聚类、补数计划可以再交给其他 skill。
@@ -0,0 +1,81 @@
# Model Labeling 输入格式
本 skill 的脚本会把不同来源的数据统一成 sample:
```json
{
"index": 0,
"request_id": "",
"query": "",
"prompt": "",
"gold_label": "",
"source_format": ""
}
```
## canonical records
识别条件:
- JSONL 每行是对象,或 JSON 数组 / `{ "records": [...] }`
- 对象包含 `turn.query`
字段来源:
- `query``turn.query`
- `request_id``source.request_id`
- `gold_label``complex=true/false` + `label.target`
- `prompt`:按 product-data 的 planning prompt 规则生成
## training jsonl
识别条件:
- 每行是对象。
- 包含 `instruction``system`
字段来源:
- `prompt``system + instruction` 包成 chat template;如果已有 `prompt` 则直接使用。
- `gold_label``output`
- `query`:尽力从 `[当前query]` 后的 `用户:` 抽取。
## eval/planning CSV
识别条件:
- CSV 表头包含 `newPrompt``query`
字段来源:
- `prompt`:优先 `newPrompt`
- `query``query`
- `gold_label``code标签`,如果有 `complex` 列则组合成两行
## 同事流转 CSV
识别条件:
- CSV 表头包含 `query``function`
字段来源:
- `query``query`
- `request_id``request_id`
- `gold_label``function`
- `prompt`:根据 `query``prev_session``context` 生成 planning prompt
## 未知格式
如果不能识别,脚本返回:
```json
{
"ok": false,
"needs_mapping": true,
"columns": ["..."],
"error": "..."
}
```
此时必须询问用户字段映射,不要猜。
+600
View File
@@ -0,0 +1,600 @@
#!/usr/bin/env python3
from __future__ import annotations
"""批量调用线上模型 generate 接口给数据打标。
输入通过 stdin 或 --input 传 JSON 对象,输出稳定 JSON 对象到 stdout。
脚本只依赖 Python 标准库,便于在本地、Linux runtime 用户或远程工作区迁移执行。
"""
import argparse
import csv
import json
import os
import re
import sys
import time
import urllib.error
import urllib.request
from io import StringIO
from pathlib import Path
from typing import Any
DEFAULT_SYSTEM_PROMPT = "你是小爱同学,中文智能语音助手。"
DEFAULT_PARAMETERS = {"max_new_tokens": 64}
class LabelingError(ValueError):
"""输入参数或数据格式错误。"""
def main() -> int:
try:
payload = load_payload()
result = run(payload)
emit({"ok": True, **result})
return 0
except LabelingError as exc:
emit({"ok": False, "error": str(exc), **getattr(exc, "extra", {})})
return 1
except Exception as exc: # noqa: BLE001 - CLI 需要稳定 JSON 错误
emit({"ok": False, "error": str(exc)})
return 1
def load_payload() -> dict[str, Any]:
parser = argparse.ArgumentParser()
parser.add_argument("--input", "-i", help="JSON 参数文件;不传则从 stdin 读取")
args = parser.parse_args()
text = Path(args.input).read_text(encoding="utf-8") if args.input else sys.stdin.read()
if not text.strip():
raise LabelingError("input JSON is required")
payload = json.loads(text)
if not isinstance(payload, dict):
raise LabelingError("input JSON must be an object")
return payload
def emit(payload: dict[str, Any]) -> None:
print(json.dumps(payload, ensure_ascii=False, separators=(",", ":")))
def run(payload: dict[str, Any]) -> dict[str, Any]:
input_path = require_string(payload, "input_path")
model_url = require_string(payload, "model_url")
dry_run = bool(payload.get("dry_run", False))
include_prompt = bool(payload.get("include_prompt", False))
max_records = optional_int(payload.get("max_records"))
start_index = int(payload.get("start_index") or 0)
timeout_seconds = float(payload.get("timeout_seconds") or 60)
output_path = str(payload.get("output_path") or "output/model_predictions.jsonl")
parameters = payload.get("parameters")
if parameters is None:
parameters = dict(DEFAULT_PARAMETERS)
if not isinstance(parameters, dict):
raise LabelingError("parameters must be an object")
headers = payload.get("headers")
if headers is None:
headers = {"Content-Type": "application/json"}
if not isinstance(headers, dict):
raise LabelingError("headers must be an object")
request_template = payload.get("request_template")
if request_template is not None and not isinstance(request_template, dict):
raise LabelingError("request_template must be an object")
source = read_input_file(input_path)
samples = normalize_samples(
source,
field_mapping=payload.get("field_mapping"),
system_prompt=str(payload.get("system_prompt") or DEFAULT_SYSTEM_PROMPT),
session_num=int(payload.get("session_num") or 5),
session_time_minutes=int(payload.get("session_time_minutes") or 5),
)
if start_index:
samples = samples[start_index:]
if max_records is not None:
samples = samples[:max_records]
summary = {
"input_path": input_path,
"source_format": source["format"],
"total_samples": len(samples),
"sample_preview": preview_samples(samples),
}
if dry_run:
return {**summary, "dry_run": True}
if not model_url.startswith(("http://", "https://")):
raise LabelingError("model_url must start with http:// or https://")
results: list[dict[str, Any]] = []
ok_count = 0
output = resolve_runtime_path(output_path)
output.parent.mkdir(parents=True, exist_ok=True)
with output.open("w", encoding="utf-8") as file:
for sample in samples:
item = request_one(
sample,
model_url=model_url,
parameters=parameters,
headers={str(k): str(v) for k, v in headers.items()},
timeout_seconds=timeout_seconds,
request_template=request_template,
include_prompt=include_prompt,
)
if item.get("ok"):
ok_count += 1
results.append(item)
file.write(json.dumps(item, ensure_ascii=False, separators=(",", ":")) + "\n")
return {
**summary,
"dry_run": False,
"output_path": str(output),
"success_count": ok_count,
"failure_count": len(results) - ok_count,
"result_preview": results[:3],
}
def request_one(
sample: dict[str, Any],
*,
model_url: str,
parameters: dict[str, Any],
headers: dict[str, str],
timeout_seconds: float,
request_template: dict[str, Any] | None,
include_prompt: bool,
) -> dict[str, Any]:
prompt = str(sample["prompt"])
body = build_request_body(prompt, parameters, request_template)
started = time.time()
base = {
"index": sample["index"],
"request_id": sample.get("request_id", ""),
"query": sample.get("query", ""),
"gold_label": sample.get("gold_label", ""),
"source_format": sample.get("source_format", ""),
}
if include_prompt:
base["prompt"] = prompt
try:
request = urllib.request.Request(
model_url,
data=json.dumps(body, ensure_ascii=False).encode("utf-8"),
headers=headers,
method="POST",
)
with urllib.request.urlopen(request, timeout=timeout_seconds) as response:
text = response.read().decode("utf-8", errors="replace")
status = getattr(response, "status", 200)
latency_ms = int((time.time() - started) * 1000)
decoded = try_json(text)
return {
**base,
"prediction": extract_prediction(decoded, text),
"ok": 200 <= int(status) < 300,
"status": int(status),
"latency_ms": latency_ms,
"response": decoded if decoded is not None else text,
}
except urllib.error.HTTPError as exc:
text = exc.read().decode("utf-8", errors="replace")
return {
**base,
"prediction": extract_prediction(try_json(text), text),
"ok": False,
"status": exc.code,
"latency_ms": int((time.time() - started) * 1000),
"error": f"HTTP {exc.code}: {text[:500]}",
}
except Exception as exc: # noqa: BLE001 - 单条失败不中断整体批次
return {
**base,
"prediction": "",
"ok": False,
"latency_ms": int((time.time() - started) * 1000),
"error": str(exc),
}
def build_request_body(
prompt: str,
parameters: dict[str, Any],
request_template: dict[str, Any] | None,
) -> dict[str, Any]:
if request_template is None:
return {"inputs": prompt, "parameters": parameters}
return replace_placeholders(request_template, {"prompt": prompt, "parameters": parameters})
def replace_placeholders(value: Any, variables: dict[str, Any]) -> Any:
if isinstance(value, str):
if value == "{{prompt}}":
return variables["prompt"]
if value == "{{parameters}}":
return variables["parameters"]
return value.replace("{{prompt}}", str(variables["prompt"]))
if isinstance(value, list):
return [replace_placeholders(item, variables) for item in value]
if isinstance(value, dict):
return {key: replace_placeholders(item, variables) for key, item in value.items()}
return value
def read_input_file(path: str) -> dict[str, Any]:
file_path = resolve_runtime_path(path)
if not file_path.exists():
raise LabelingError(f"input_path not found: {path}")
suffix = file_path.suffix.lower()
text = file_path.read_text(encoding="utf-8", errors="replace")
if suffix in {".csv", ".tsv"}:
delimiter = "\t" if suffix == ".tsv" else ","
rows = list(csv.DictReader(StringIO(text), delimiter=delimiter))
return {"format": "table", "path": str(file_path), "rows": rows, "columns": list(rows[0].keys()) if rows else []}
if suffix == ".jsonl":
rows = [json.loads(line) for line in text.splitlines() if line.strip()]
return {"format": "jsonl", "path": str(file_path), "rows": rows}
decoded = json.loads(text)
if isinstance(decoded, dict) and isinstance(decoded.get("records"), list):
decoded = decoded["records"]
if isinstance(decoded, list):
return {"format": "json", "path": str(file_path), "rows": decoded}
raise_with_mapping("JSON input must be an array or {records:[...]}", columns=list(decoded.keys()) if isinstance(decoded, dict) else [])
def normalize_samples(
source: dict[str, Any],
*,
field_mapping: Any,
system_prompt: str,
session_num: int,
session_time_minutes: int,
) -> list[dict[str, Any]]:
rows = source.get("rows")
if not isinstance(rows, list) or not rows:
raise LabelingError("input file contains no rows")
if isinstance(field_mapping, dict):
return samples_from_mapping(rows, field_mapping, source["format"], system_prompt)
first = rows[0]
if not isinstance(first, dict):
raise_with_mapping("rows must be objects")
if is_canonical_record(first):
return [
sample_from_record(index, row, system_prompt, session_num, session_time_minutes)
for index, row in enumerate(rows)
if isinstance(row, dict)
]
if is_training_row(first):
return [sample_from_training(index, row, system_prompt) for index, row in enumerate(rows) if isinstance(row, dict)]
if source["format"] == "table":
columns = list(first.keys())
if "newPrompt" in columns:
return [sample_from_eval_row(index, row) for index, row in enumerate(rows) if isinstance(row, dict)]
if "query" in columns and "function" in columns:
return [sample_from_flow_row(index, row, system_prompt) for index, row in enumerate(rows) if isinstance(row, dict)]
raise_with_mapping("unrecognized table format", columns=columns)
raise_with_mapping("unrecognized JSON/JSONL format", columns=list(first.keys()))
def sample_from_record(
index: int,
record: dict[str, Any],
system_prompt: str,
session_num: int,
session_time_minutes: int,
) -> dict[str, Any]:
source = record.get("source") if isinstance(record.get("source"), dict) else {}
turn = record.get("turn") if isinstance(record.get("turn"), dict) else {}
return {
"index": index,
"request_id": str(source.get("request_id") or ""),
"query": str(turn.get("query") or ""),
"prompt": build_planning_prompt(record, system_prompt, session_num, session_time_minutes),
"gold_label": combined_label(record),
"source_format": "canonical_record_v1",
}
def sample_from_training(index: int, row: dict[str, Any], system_prompt: str) -> dict[str, Any]:
prompt = str(row.get("prompt") or "")
if not prompt:
system = str(row.get("system") or system_prompt)
instruction = str(row.get("instruction") or row.get("input") or "")
prompt = wrap_chat_prompt(system, instruction)
return {
"index": index,
"request_id": str(row.get("request_id") or ""),
"query": extract_query_from_prompt(prompt),
"prompt": prompt,
"gold_label": str(row.get("output") or row.get("target") or ""),
"source_format": "training_jsonl",
}
def sample_from_eval_row(index: int, row: dict[str, Any]) -> dict[str, Any]:
gold = str(row.get("code标签") or row.get("function") or row.get("target") or "")
complex_value = row.get("complex")
if complex_value not in (None, "") and not gold.startswith("complex="):
gold = f"complex={normalize_bool_literal(complex_value)}\n{gold}".rstrip()
prompt = str(row.get("newPrompt") or row.get("prompt") or "")
query = str(row.get("query") or "")
if not prompt:
prompt = build_planning_prompt(minimal_record(query, {}, [], gold), DEFAULT_SYSTEM_PROMPT, 5, 5)
return {
"index": index,
"request_id": str(row.get("request_id") or ""),
"query": query,
"prompt": prompt,
"gold_label": gold,
"source_format": "eval_csv",
}
def sample_from_flow_row(index: int, row: dict[str, Any], system_prompt: str) -> dict[str, Any]:
record = record_from_flow_row(row)
return {
"index": index,
"request_id": str(row.get("request_id") or ""),
"query": str(row.get("query") or ""),
"prompt": build_planning_prompt(record, system_prompt, 5, 5),
"gold_label": str(row.get("function") or ""),
"source_format": "flow_csv",
}
def samples_from_mapping(
rows: list[Any],
mapping: dict[str, Any],
source_format: str,
system_prompt: str,
) -> list[dict[str, Any]]:
query_field = str(mapping.get("query") or "")
prompt_field = str(mapping.get("prompt") or "")
label_field = str(mapping.get("label") or mapping.get("target") or "")
request_id_field = str(mapping.get("request_id") or "")
if not query_field and not prompt_field:
raise LabelingError("field_mapping must provide query or prompt")
samples: list[dict[str, Any]] = []
for index, row in enumerate(rows):
if not isinstance(row, dict):
continue
prompt = str(row.get(prompt_field) or "")
query = str(row.get(query_field) or "")
if not prompt:
record = minimal_record(query, {}, [], str(row.get(label_field) or ""))
prompt = build_planning_prompt(record, system_prompt, 5, 5)
samples.append(
{
"index": index,
"request_id": str(row.get(request_id_field) or ""),
"query": query or extract_query_from_prompt(prompt),
"prompt": prompt,
"gold_label": str(row.get(label_field) or ""),
"source_format": f"{source_format}_mapped",
}
)
return samples
def is_canonical_record(row: dict[str, Any]) -> bool:
return isinstance(row.get("turn"), dict) and bool(row["turn"].get("query"))
def is_training_row(row: dict[str, Any]) -> bool:
return any(key in row for key in ("instruction", "system", "output", "prompt"))
def build_planning_prompt(
record: dict[str, Any],
system_prompt: str,
session_num: int,
session_time_minutes: int,
) -> str:
instruction = build_training_instruction(record, session_num, session_time_minutes)
return wrap_chat_prompt(system_prompt, instruction)
def wrap_chat_prompt(system_prompt: str, instruction: str) -> str:
return (
f"<|im_start|>system\n{system_prompt}<|im_end|>\n"
f"<|im_start|>user\n{instruction}<|im_end|>\n"
"<|im_start|>assistant\n"
)
def build_training_instruction(record: dict[str, Any], session_num: int, session_time_minutes: int) -> str:
turn = record.get("turn") if isinstance(record.get("turn"), dict) else {}
query = str(turn.get("query") or "")
context = record.get("context") if isinstance(record.get("context"), dict) else {}
prev_session = record.get("prev_session") if isinstance(record.get("prev_session"), list) else []
current_ts = optional_int(turn.get("timestamp"))
history = render_history(prev_session, current_ts, session_num, session_time_minutes)
return (
"请参考用户的[当前query]、[对话历史]、[知识注入]、[系统状态]识别出[当前query]的[function]结果,[function]是python的code形式。\n"
"[知识注入]\n"
f"{json.dumps({'location': str(context.get('location') or ''), 'rag': str(context.get('rag') or '')}, ensure_ascii=False, indent=0)}\n"
"[系统状态]\n"
"{}\n"
"[对话历史]\n"
f"{history}"
"[当前query]\n"
f"用户: {query}\n"
"[function]\n"
)
def render_history(prev_session: list[Any], current_ts: int | None, session_num: int, session_time_minutes: int) -> str:
usable: list[dict[str, Any]] = []
for item in prev_session[-session_num:]:
if not isinstance(item, dict):
continue
ts = optional_int(item.get("timestamp"))
if current_ts is not None and ts is not None:
if abs(current_ts - ts) > session_time_minutes * 60_000:
continue
usable.append(item)
if not usable:
return ""
lines: list[str] = []
for item in usable:
query = str(item.get("query") or "").strip()
tts = str(item.get("tts") or "").strip()
if query:
lines.append(f"用户: {query}")
if tts:
lines.append(f"小爱: {tts}")
return "\n".join(lines) + ("\n" if lines else "")
def record_from_flow_row(row: dict[str, Any]) -> dict[str, Any]:
prev_session = parse_json_cell(row.get("prev_session"), default=[])
context = parse_json_cell(row.get("context"), default={})
return minimal_record(
str(row.get("query") or ""),
context if isinstance(context, dict) else {},
prev_session if isinstance(prev_session, list) else [],
str(row.get("function") or ""),
request_id=str(row.get("request_id") or ""),
timestamp=optional_int(row.get("timestamp")),
)
def minimal_record(
query: str,
context: dict[str, Any],
prev_session: list[Any],
target: str,
*,
request_id: str = "",
timestamp: int | None = None,
) -> dict[str, Any]:
return {
"source": {"request_id": request_id, "timestamp": timestamp},
"turn": {"query": query, "timestamp": timestamp},
"prev_session": prev_session,
"context": context,
"label": {"target": target},
"dimensions": {},
}
def combined_label(record: dict[str, Any]) -> str:
target = ""
label = record.get("label") if isinstance(record.get("label"), dict) else {}
if isinstance(label, dict):
target = str(label.get("target") or "")
dimensions = record.get("dimensions") if isinstance(record.get("dimensions"), dict) else {}
complex_value = dimensions.get("complex") if isinstance(dimensions, dict) else None
if isinstance(complex_value, bool):
return f"complex={'true' if complex_value else 'false'}\n{target}".rstrip()
return target
def extract_query_from_prompt(prompt: str) -> str:
match = re.search(r"\[当前query\]\s*\n用户[:]\s*(.+)", prompt)
return match.group(1).strip() if match else ""
def normalize_bool_literal(value: Any) -> str:
text = str(value).strip().lower()
return "true" if text in {"true", "1", "yes", "y", "", "复杂"} else "false"
def parse_json_cell(value: Any, default: Any) -> Any:
if value in (None, ""):
return default
if isinstance(value, (dict, list)):
return value
try:
return json.loads(str(value))
except json.JSONDecodeError:
return default
def resolve_runtime_path(path: str) -> Path:
raw = Path(path).expanduser()
if raw.is_absolute():
return raw
scratchpad = Path(str(Path.cwd()))
if os.environ.get("PYTHON_EXEC_SCRATCHPAD"):
scratchpad = Path(os.environ["PYTHON_EXEC_SCRATCHPAD"]).expanduser()
parts = raw.parts
if parts and parts[0] in {"output", "outputs"}:
return scratchpad.parent / "output" / Path(*parts[1:])
if parts and parts[0] in {"input", "inputs"}:
return scratchpad.parent / "input" / Path(*parts[1:])
if parts and parts[0] in {"scratchpad", "scratch"}:
return scratchpad / Path(*parts[1:])
return raw
def optional_int(value: Any) -> int | None:
if value in (None, ""):
return None
try:
return int(float(str(value)))
except (TypeError, ValueError):
return None
def extract_prediction(decoded: Any, text: str) -> str:
if isinstance(decoded, dict):
for key in ("generated_text", "text", "output", "response", "result"):
value = decoded.get(key)
if isinstance(value, str):
return value.strip()
outputs = decoded.get("outputs")
if isinstance(outputs, list) and outputs:
first = outputs[0]
if isinstance(first, str):
return first.strip()
if isinstance(first, dict):
return extract_prediction(first, json.dumps(first, ensure_ascii=False))
choices = decoded.get("choices")
if isinstance(choices, list) and choices:
first = choices[0]
if isinstance(first, dict):
message = first.get("message")
if isinstance(message, dict) and isinstance(message.get("content"), str):
return message["content"].strip()
if isinstance(first.get("text"), str):
return first["text"].strip()
return text.strip()
def try_json(text: str) -> Any:
try:
return json.loads(text)
except json.JSONDecodeError:
return None
def preview_samples(samples: list[dict[str, Any]]) -> list[dict[str, Any]]:
return [
{
"index": item.get("index"),
"query": item.get("query"),
"gold_label": item.get("gold_label"),
"prompt_preview": str(item.get("prompt") or "")[:200],
}
for item in samples[:3]
]
def require_string(payload: dict[str, Any], key: str) -> str:
value = payload.get(key)
if not isinstance(value, str) or not value.strip():
raise LabelingError(f"{key} is required")
return value.strip()
def raise_with_mapping(message: str, columns: list[str] | None = None) -> None:
exc = LabelingError(message)
exc.extra = {"needs_mapping": True, "columns": columns or []} # type: ignore[attr-defined]
raise exc
if __name__ == "__main__":
raise SystemExit(main())
+2
View File
@@ -41,6 +41,7 @@ class ToolExecutionContext:
permissions: AgentPermissions permissions: AgentPermissions
scratchpad_directory: Path | None = None scratchpad_directory: Path | None = None
python_env_dir: Path | None = None python_env_dir: Path | None = None
runtime_user: str | None = None
extra_env: dict[str, str] = field(default_factory=dict) extra_env: dict[str, str] = field(default_factory=dict)
cancel_event: Any | None = None cancel_event: Any | None = None
process_registry: Any | None = None process_registry: Any | None = None
@@ -169,6 +170,7 @@ def build_tool_context(
if config.python_env_dir if config.python_env_dir
else None else None
), ),
runtime_user=config.runtime_user,
extra_env=dict(extra_env or {}), extra_env=dict(extra_env or {}),
cancel_event=cancel_event, cancel_event=cancel_event,
process_registry=process_registry, process_registry=process_registry,
+56 -3
View File
@@ -6,6 +6,7 @@ import hashlib
import io import io
import json import json
import os import os
import pwd
import re import re
import secrets import secrets
import selectors import selectors
@@ -1648,6 +1649,8 @@ def _resolve_path(
else context.root / expanded else context.root / expanded
) )
resolved = candidate.resolve(strict=not allow_missing) resolved = candidate.resolve(strict=not allow_missing)
if _is_session_workspace_path(resolved, context):
return resolved
try: try:
resolved.relative_to(context.root) resolved.relative_to(context.root)
except ValueError as exc: except ValueError as exc:
@@ -1678,6 +1681,17 @@ def _session_logical_path(path: Path, context: ToolExecutionContext) -> Path | N
return None return None
def _is_session_workspace_path(path: Path, context: ToolExecutionContext) -> bool:
if context.scratchpad_directory is None:
return False
session_root = context.scratchpad_directory.parent.resolve(strict=False)
try:
path.resolve(strict=False).relative_to(session_root)
return True
except ValueError:
return False
def _display_path(path: Path, context: ToolExecutionContext) -> str: def _display_path(path: Path, context: ToolExecutionContext) -> str:
resolved = path.resolve() resolved = path.resolve()
try: try:
@@ -1691,10 +1705,25 @@ def _execution_cwd(context: ToolExecutionContext) -> Path:
return Path(context.jupyter_runtime.binding.workspace_cwd) return Path(context.jupyter_runtime.binding.workspace_cwd)
if context.scratchpad_directory is not None: if context.scratchpad_directory is not None:
context.scratchpad_directory.mkdir(parents=True, exist_ok=True) context.scratchpad_directory.mkdir(parents=True, exist_ok=True)
_chown_to_runtime_user(context.scratchpad_directory, context)
return context.scratchpad_directory return context.scratchpad_directory
return context.root return context.root
def _chown_to_runtime_user(path: Path, context: ToolExecutionContext) -> None:
runtime_user = (context.runtime_user or '').strip()
if not runtime_user or os.name != 'posix':
return
try:
user_info = pwd.getpwnam(runtime_user)
except KeyError:
return
try:
os.chown(path, user_info.pw_uid, user_info.pw_gid)
except (FileNotFoundError, PermissionError, OSError):
return
def _is_platform_app_root(root: Path) -> bool: def _is_platform_app_root(root: Path) -> bool:
return all((root / marker).exists() for marker in _PLATFORM_ROOT_MARKERS) return all((root / marker).exists() for marker in _PLATFORM_ROOT_MARKERS)
@@ -1873,8 +1902,10 @@ def _write_file(arguments: dict[str, Any], context: ToolExecutionContext) -> str
elif target.exists(): elif target.exists():
raise ToolExecutionError(f'Path exists and is not a file: {target}') raise ToolExecutionError(f'Path exists and is not a file: {target}')
target.parent.mkdir(parents=True, exist_ok=True) target.parent.mkdir(parents=True, exist_ok=True)
_chown_to_runtime_user(target.parent, context)
final_text = (previous_text or '') + content if append else content final_text = (previous_text or '') + content if append else content
target.write_text(final_text, encoding='utf-8') target.write_text(final_text, encoding='utf-8')
_chown_to_runtime_user(target, context)
rel = _display_path(target, context) rel = _display_path(target, context)
new_sha256 = hashlib.sha256(final_text.encode('utf-8')).hexdigest() new_sha256 = hashlib.sha256(final_text.encode('utf-8')).hexdigest()
action = 'append_file' if append else 'write_file' action = 'append_file' if append else 'write_file'
@@ -1994,6 +2025,7 @@ def _edit_file(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
before_sha256 = hashlib.sha256(current.encode('utf-8')).hexdigest() before_sha256 = hashlib.sha256(current.encode('utf-8')).hexdigest()
updated = current.replace(old_text, new_text) if replace_all else current.replace(old_text, new_text, 1) updated = current.replace(old_text, new_text) if replace_all else current.replace(old_text, new_text, 1)
target.write_text(updated, encoding='utf-8') target.write_text(updated, encoding='utf-8')
_chown_to_runtime_user(target, context)
rel = _display_path(target, context) rel = _display_path(target, context)
replaced = occurrences if replace_all else 1 replaced = occurrences if replace_all else 1
after_sha256 = hashlib.sha256(updated.encode('utf-8')).hexdigest() after_sha256 = hashlib.sha256(updated.encode('utf-8')).hexdigest()
@@ -2078,6 +2110,7 @@ def _notebook_edit(arguments: dict[str, Any], context: ToolExecutionContext) ->
cell.setdefault('execution_count', None) cell.setdefault('execution_count', None)
updated = json.dumps(notebook, ensure_ascii=True, indent=1) + '\n' updated = json.dumps(notebook, ensure_ascii=True, indent=1) + '\n'
target.write_text(updated, encoding='utf-8') target.write_text(updated, encoding='utf-8')
_chown_to_runtime_user(target, context)
after_sha256 = hashlib.sha256(updated.encode('utf-8')).hexdigest() after_sha256 = hashlib.sha256(updated.encode('utf-8')).hexdigest()
rel = _display_path(target, context) rel = _display_path(target, context)
return ( return (
@@ -2367,7 +2400,7 @@ def _run_python_exec(arguments: dict[str, Any], context: ToolExecutionContext) -
stderr=subprocess.PIPE, stderr=subprocess.PIPE,
text=True, text=True,
env=_build_subprocess_env(context), env=_build_subprocess_env(context),
**_subprocess_isolation_kwargs(), **_subprocess_execution_kwargs(context),
) )
_register_child_process(context, process) _register_child_process(context, process)
if stdin and process.stdin is not None: if stdin and process.stdin is not None:
@@ -2485,6 +2518,24 @@ def _subprocess_isolation_kwargs() -> dict[str, Any]:
return {} return {}
def _subprocess_execution_kwargs(context: ToolExecutionContext) -> dict[str, Any]:
runtime_user = (context.runtime_user or '').strip()
if not runtime_user or os.name != 'posix':
return _subprocess_isolation_kwargs()
try:
user_info = pwd.getpwnam(runtime_user)
except KeyError as exc:
raise ToolExecutionError(f'Linux runtime user not found: {runtime_user}') from exc
def demote() -> None:
os.setsid()
os.initgroups(runtime_user, user_info.pw_gid)
os.setgid(user_info.pw_gid)
os.setuid(user_info.pw_uid)
return {'preexec_fn': demote}
def _terminate_process(process: subprocess.Popen[Any]) -> None: def _terminate_process(process: subprocess.Popen[Any]) -> None:
if process.poll() is not None: if process.poll() is not None:
return return
@@ -2619,6 +2670,7 @@ def _run_python_package(arguments: dict[str, Any], context: ToolExecutionContext
text=True, text=True,
timeout=timeout_seconds, timeout=timeout_seconds,
env=_build_subprocess_env(context), env=_build_subprocess_env(context),
**_subprocess_execution_kwargs(context),
) )
except subprocess.TimeoutExpired as exc: except subprocess.TimeoutExpired as exc:
stdout = exc.stdout if isinstance(exc.stdout, str) else '' stdout = exc.stdout if isinstance(exc.stdout, str) else ''
@@ -2740,6 +2792,7 @@ def _run_bash(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
text=True, text=True,
timeout=context.command_timeout_seconds, timeout=context.command_timeout_seconds,
env=_build_subprocess_env(context), env=_build_subprocess_env(context),
**_subprocess_execution_kwargs(context),
) )
stdout = completed.stdout or '' stdout = completed.stdout or ''
stderr = completed.stderr or '' stderr = completed.stderr or ''
@@ -4344,13 +4397,13 @@ def _stream_bash(
command, command,
shell=True, shell=True,
executable='/bin/bash', executable='/bin/bash',
cwd=context.root, cwd=_execution_cwd(context),
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, stderr=subprocess.PIPE,
text=True, text=True,
bufsize=1, bufsize=1,
env=_build_subprocess_env(context), env=_build_subprocess_env(context),
**_subprocess_isolation_kwargs(), **_subprocess_execution_kwargs(context),
) )
_register_child_process(context, process) _register_child_process(context, process)
except (ToolPermissionError, ToolExecutionError, OSError, subprocess.SubprocessError) as exc: except (ToolPermissionError, ToolExecutionError, OSError, subprocess.SubprocessError) as exc:
+1
View File
@@ -171,6 +171,7 @@ class AgentRuntimeConfig:
session_directory: Path = field(default_factory=lambda: (Path('.port_sessions') / 'agent').resolve()) session_directory: Path = field(default_factory=lambda: (Path('.port_sessions') / 'agent').resolve())
scratchpad_root: Path = field(default_factory=lambda: (Path('.port_sessions') / 'scratchpad').resolve()) scratchpad_root: Path = field(default_factory=lambda: (Path('.port_sessions') / 'scratchpad').resolve())
python_env_dir: Path | None = None python_env_dir: Path | None = None
runtime_user: str | None = None
enabled_skill_names: tuple[str, ...] | None = None enabled_skill_names: tuple[str, ...] | None = None
+6
View File
@@ -194,6 +194,7 @@ def serialize_runtime_config(runtime_config: AgentRuntimeConfig) -> JSONDict:
if runtime_config.python_env_dir is not None if runtime_config.python_env_dir is not None
else None else None
), ),
'runtime_user': runtime_config.runtime_user,
'enabled_skill_names': ( 'enabled_skill_names': (
list(runtime_config.enabled_skill_names) list(runtime_config.enabled_skill_names)
if runtime_config.enabled_skill_names is not None if runtime_config.enabled_skill_names is not None
@@ -258,6 +259,11 @@ def deserialize_runtime_config(payload: JSONDict) -> AgentRuntimeConfig:
if payload.get('python_env_dir') is not None if payload.get('python_env_dir') is not None
else None else None
), ),
runtime_user=(
str(payload['runtime_user'])
if payload.get('runtime_user') is not None
else None
),
enabled_skill_names=enabled_skill_names, enabled_skill_names=enabled_skill_names,
) )