Merge remote-tracking branch 'origin/main' into wsh_dev
# Conflicts: # backend/api/server.py
@@ -583,6 +583,22 @@ bash "$HOME/zk-data-agent/scripts/install-from-git.sh"
|
||||
- 安装前端依赖并构建。
|
||||
- 安装并启动用户级 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` 会保存:
|
||||
|
||||
```text
|
||||
@@ -595,6 +611,8 @@ CLAW_BACKEND_PORT
|
||||
CLAW_FRONTEND_HOST
|
||||
CLAW_FRONTEND_PORT
|
||||
CLAW_API_URL
|
||||
CLAW_SERVICE_SCOPE
|
||||
CLAW_ENABLE_LINUX_ACCOUNTS
|
||||
CLAW_NPM_BIN
|
||||
CLAW_NPX_BIN
|
||||
CLAW_NODE_BIN
|
||||
@@ -618,6 +636,12 @@ bash scripts/update-server-fast.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
|
||||
@@ -632,7 +656,7 @@ bash scripts/deploy-ubuntu.sh main --force
|
||||
|
||||
### 服务管理
|
||||
|
||||
部署脚本会安装两个用户级 systemd 服务:
|
||||
部署脚本默认安装用户级 systemd 服务;以 root 执行或指定 `--system-service` 时安装 system 级服务。服务名默认是:
|
||||
|
||||
```text
|
||||
zk-data-agent-backend
|
||||
@@ -646,6 +670,13 @@ systemctl --user status zk-data-agent-backend
|
||||
systemctl --user status zk-data-agent-frontend
|
||||
```
|
||||
|
||||
system 级服务使用:
|
||||
|
||||
```bash
|
||||
systemctl status zk-data-agent-backend
|
||||
systemctl status zk-data-agent-frontend
|
||||
```
|
||||
|
||||
查看日志:
|
||||
|
||||
```bash
|
||||
@@ -653,12 +684,25 @@ journalctl --user -u zk-data-agent-backend -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
|
||||
systemctl --user restart zk-data-agent-backend zk-data-agent-frontend
|
||||
```
|
||||
|
||||
system 级重启使用:
|
||||
|
||||
```bash
|
||||
systemctl restart zk-data-agent-backend zk-data-agent-frontend
|
||||
```
|
||||
|
||||
停止服务:
|
||||
|
||||
```bash
|
||||
@@ -683,6 +727,7 @@ Ubuntu 部署建议:
|
||||
- Python `3.10.14`
|
||||
- Node.js `20` 或 `22`
|
||||
- `npm`
|
||||
- 启用 Linux 账号工作区时,还需要 `passwd`、`python3`、`python3-venv`,并要求服务以 root/systemd system 方式运行。
|
||||
|
||||
首次安装如果缺 Python 编译依赖,可以执行:
|
||||
|
||||
@@ -690,7 +735,7 @@ Ubuntu 部署建议:
|
||||
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/`。
|
||||
|
||||
## 开发验证
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import hashlib
|
||||
import shlex
|
||||
import json
|
||||
import os
|
||||
import pwd
|
||||
import queue
|
||||
import re
|
||||
import shutil
|
||||
@@ -76,6 +77,9 @@ from src.session_store import (
|
||||
)
|
||||
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'
|
||||
API_TOOL_CONTENT_MAX_CHARS = 20000
|
||||
@@ -638,6 +642,8 @@ class AgentState:
|
||||
if not account_id:
|
||||
return self.session_directory.parent
|
||||
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()
|
||||
|
||||
def account_paths(self, account_id: str | None) -> dict[str, Path]:
|
||||
@@ -837,7 +843,15 @@ class AgentState:
|
||||
for directory in paths.values():
|
||||
if directory.name != '.venv':
|
||||
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(
|
||||
allow_file_write=config.allow_write,
|
||||
allow_shell_commands=config.allow_shell,
|
||||
@@ -849,6 +863,7 @@ class AgentState:
|
||||
session_directory=paths['sessions'],
|
||||
scratchpad_root=paths['scratchpad'],
|
||||
python_env_dir=paths['python_env'],
|
||||
runtime_user=runtime_user,
|
||||
enabled_skill_names=self.enabled_skill_names(account_id),
|
||||
auto_compact_threshold_tokens=180_000,
|
||||
)
|
||||
@@ -949,12 +964,37 @@ class AgentState:
|
||||
def lock(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'
|
||||
pip_bin = env_dir / 'bin' / 'pip'
|
||||
if python_bin.exists() and pip_bin.exists():
|
||||
return
|
||||
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)
|
||||
|
||||
|
||||
@@ -4538,6 +4578,107 @@ def _account_id_from_email(email: str | None) -> str | None:
|
||||
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:
|
||||
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)
|
||||
if safe_id == 'default':
|
||||
raise HTTPException(status_code=400, detail='账号名不合法')
|
||||
if _linux_accounts_enabled():
|
||||
_validate_linux_username(safe_id)
|
||||
users = _load_users_file(state)
|
||||
user_rows = users.setdefault('users', [])
|
||||
if any(
|
||||
@@ -4694,6 +4837,8 @@ def _admin_create_account(state: AgentState, account_id: str) -> dict[str, Any]:
|
||||
for item in user_rows
|
||||
):
|
||||
raise HTTPException(status_code=400, detail='账号已存在')
|
||||
if _linux_accounts_enabled():
|
||||
_ensure_linux_account(safe_id, '123456')
|
||||
user_rows.append(
|
||||
{
|
||||
'id': safe_id,
|
||||
@@ -4737,8 +4882,18 @@ def _admin_delete_account(state: AgentState, account_id: str) -> dict[str, Any]:
|
||||
encoding='utf-8',
|
||||
)
|
||||
base = _accounts_root(state) / safe_id
|
||||
if _linux_accounts_enabled():
|
||||
base = _linux_account_workspace(safe_id)
|
||||
if base.exists():
|
||||
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)
|
||||
return {'account_id': safe_id, 'deleted': True}
|
||||
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
# 01. 基座 Runtime 架构
|
||||
|
||||

|
||||
|
||||
## 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 执行机制
|
||||
|
||||

|
||||
|
||||
## 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
|
||||
|
||||

|
||||
|
||||
## 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 体系和能力包约定
|
||||
|
||||

|
||||
|
||||
## 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. 会话工作区、运行态和记忆
|
||||
|
||||

|
||||
|
||||
## 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 实现
|
||||
|
||||

|
||||
|
||||
## 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 实现
|
||||
|
||||

|
||||
|
||||
## 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 实现
|
||||
|
||||

|
||||
|
||||
## 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. 外部系统 Skill:ELK、SQL、模型迭代
|
||||
|
||||

|
||||
|
||||
## 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 / MemGPT:Agent 自主管理内存
|
||||
|
||||
Letta 延续 MemGPT 思路,把 Agent 看成有长期状态的主体。它通常区分 core memory 和 archival memory:core 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 AutoGen:Memory 组件注入上下文
|
||||
|
||||
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 Help:ChatGPT Memory FAQ
|
||||
https://help.openai.com/en/articles/8590148-memory-faq
|
||||
- OpenAI Agents SDK:Sessions
|
||||
https://openai.github.io/openai-agents-python/sessions/
|
||||
- Anthropic Claude Code:Memory
|
||||
https://docs.anthropic.com/en/docs/claude-code/memory
|
||||
- LangChain / LangGraph:Memory 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 3:local_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 5:remote_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 环境和工具执行可以合到一个统一设计里,而不是继续各自生长。
|
||||
@@ -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` 页面。
|
||||
- 页面表达、图形素材、演示脚本不写在这里;这里只保留技术事实和设计边界。
|
||||
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
@@ -11,6 +11,7 @@ import {
|
||||
accountSessionOutputRoot,
|
||||
accountSessionRoot,
|
||||
accountSessionScratchpadRoot,
|
||||
chownAccountPath,
|
||||
getCurrentAccount,
|
||||
} from "@/lib/claw-auth";
|
||||
|
||||
@@ -334,14 +335,16 @@ async function getLastUserText(
|
||||
}
|
||||
|
||||
async function ensureSessionDirectories(accountId: string, sessionId: string) {
|
||||
const sessionRoot = accountSessionRoot(accountId, sessionId);
|
||||
await Promise.all([
|
||||
mkdir(accountSessionRoot(accountId, sessionId), { recursive: true }),
|
||||
mkdir(sessionRoot, { recursive: true }),
|
||||
mkdir(accountSessionInputRoot(accountId, sessionId), { recursive: true }),
|
||||
mkdir(accountSessionOutputRoot(accountId, sessionId), { recursive: true }),
|
||||
mkdir(accountSessionScratchpadRoot(accountId, sessionId), {
|
||||
recursive: true,
|
||||
}),
|
||||
]);
|
||||
await chownAccountPath(accountId, sessionRoot, true);
|
||||
}
|
||||
|
||||
function renderSessionRuntimeContext(accountId: string, sessionId: string) {
|
||||
@@ -394,6 +397,7 @@ async function saveFilePart(
|
||||
await mkdir(uploadDir, { recursive: true });
|
||||
const filePath = path.join(uploadDir, `${Date.now()}-${filename}`);
|
||||
await writeFile(filePath, bytes);
|
||||
await chownAccountPath(accountId, filePath);
|
||||
|
||||
return [
|
||||
`[文件已上传] ${filename}`,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,19 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { randomBytes, scryptSync, timingSafeEqual } from "node:crypto";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
const AUTH_ROOT = path.join(
|
||||
const AUTH_STATE_ROOT = path.join(
|
||||
/* turbopackIgnore: true */ process.cwd(),
|
||||
"../../.port_sessions/accounts",
|
||||
);
|
||||
const USERS_PATH = path.join(AUTH_ROOT, "users.json");
|
||||
const SESSIONS_PATH = path.join(AUTH_ROOT, "auth_sessions.json");
|
||||
const USERS_PATH = path.join(AUTH_STATE_ROOT, "users.json");
|
||||
const SESSIONS_PATH = path.join(AUTH_STATE_ROOT, "auth_sessions.json");
|
||||
const COOKIE_NAME = "claw_account_session";
|
||||
const SESSION_MAX_AGE_SECONDS = 60 * 60 * 24 * 30;
|
||||
const SESSION_REFRESH_INTERVAL_MS = 60 * 60 * 12 * 1000;
|
||||
const LINUX_ACCOUNT_WORKSPACE_NAME = "zk-agent";
|
||||
|
||||
type UserRecord = {
|
||||
id: string;
|
||||
@@ -51,6 +53,7 @@ export async function registerAccount(username: string, password: string) {
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
usersFile.users.push(account);
|
||||
await ensureLinuxAccount(account.id, password);
|
||||
await writeUsers(usersFile);
|
||||
await createAccountDirectories(account.id);
|
||||
return createSession(account);
|
||||
@@ -65,6 +68,7 @@ export async function loginAccount(username: string, password: string) {
|
||||
if (!account || !verifyPassword(password, account.passwordHash)) {
|
||||
throw new Error("账号或密码错误");
|
||||
}
|
||||
await ensureLinuxAccount(account.id, password);
|
||||
await createAccountDirectories(account.id);
|
||||
return createSession(account);
|
||||
}
|
||||
@@ -153,15 +157,18 @@ export async function getCurrentAccount(): Promise<AccountSession | null> {
|
||||
}
|
||||
|
||||
export function accountUploadRoot(accountId: string) {
|
||||
return path.join(AUTH_ROOT, accountId, "sessions");
|
||||
return path.join(accountBaseRoot(accountId), "sessions");
|
||||
}
|
||||
|
||||
export function accountOutputRoot(accountId: string) {
|
||||
return path.join(AUTH_ROOT, accountId, "sessions");
|
||||
return path.join(accountBaseRoot(accountId), "sessions");
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -213,15 +220,18 @@ async function setSessionCookie(token: string) {
|
||||
}
|
||||
|
||||
async function createAccountDirectories(accountId: string) {
|
||||
await Promise.all(
|
||||
["sessions"].map((name) =>
|
||||
mkdir(path.join(AUTH_ROOT, accountId, name), { recursive: true }),
|
||||
),
|
||||
);
|
||||
const base = accountBaseRoot(accountId);
|
||||
await Promise.all([
|
||||
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> {
|
||||
await mkdir(AUTH_ROOT, { recursive: true });
|
||||
await mkdir(AUTH_STATE_ROOT, { recursive: true });
|
||||
try {
|
||||
return JSON.parse(await readFile(USERS_PATH, "utf8")) as UsersFile;
|
||||
} catch {
|
||||
@@ -230,12 +240,12 @@ async function readUsers(): Promise<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");
|
||||
}
|
||||
|
||||
async function readSessions(): Promise<SessionsFile> {
|
||||
await mkdir(AUTH_ROOT, { recursive: true });
|
||||
await mkdir(AUTH_STATE_ROOT, { recursive: true });
|
||||
try {
|
||||
return JSON.parse(await readFile(SESSIONS_PATH, "utf8")) as SessionsFile;
|
||||
} catch {
|
||||
@@ -244,12 +254,20 @@ async function readSessions(): Promise<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");
|
||||
}
|
||||
|
||||
function normalizeUsername(username: string) {
|
||||
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)) {
|
||||
throw new Error(
|
||||
"账号只能包含小写字母、数字、点、下划线或中划线,长度 2-40",
|
||||
@@ -282,3 +300,87 @@ function shouldRefreshSession(value?: string) {
|
||||
if (Number.isNaN(timestamp)) return true;
|
||||
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 架构
|
||||
|
||||

|
||||
|
||||
## 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 执行机制
|
||||
|
||||

|
||||
|
||||
## 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
|
||||
|
||||

|
||||
|
||||
## 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 体系和能力包约定
|
||||
|
||||

|
||||
|
||||
## 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. 会话工作区、运行态和记忆
|
||||
|
||||

|
||||
|
||||
## 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 实现
|
||||
|
||||

|
||||
|
||||
## 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 实现
|
||||
|
||||

|
||||
|
||||
## 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 实现
|
||||
|
||||

|
||||
|
||||
## 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. 外部系统 Skill:ELK、SQL、模型迭代
|
||||
|
||||

|
||||
|
||||
## 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 / MemGPT:Agent 自主管理内存
|
||||
|
||||
Letta 延续 MemGPT 思路,把 Agent 看成有长期状态的主体。它通常区分 core memory 和 archival memory:core 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 AutoGen:Memory 组件注入上下文
|
||||
|
||||
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 Help:ChatGPT Memory FAQ
|
||||
https://help.openai.com/en/articles/8590148-memory-faq
|
||||
- OpenAI Agents SDK:Sessions
|
||||
https://openai.github.io/openai-agents-python/sessions/
|
||||
- Anthropic Claude Code:Memory
|
||||
https://docs.anthropic.com/en/docs/claude-code/memory
|
||||
- LangChain / LangGraph:Memory 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` 页面。
|
||||
- 页面表达、图形素材、演示脚本不写在这里;这里只保留技术事实和设计边界。
|
||||
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
@@ -3,7 +3,7 @@ set -euo pipefail
|
||||
|
||||
# Ubuntu 一键部署/更新脚本。
|
||||
# - 首次执行会交互式生成 .env.deploy,本机保存,不提交 git。
|
||||
# - 后续执行会拉取代码、更新依赖、构建前端、安装/重启用户级 systemd 服务。
|
||||
# - 后续执行会拉取代码、更新依赖、构建前端、安装/重启 systemd 服务。
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
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}"
|
||||
FRONTEND_SERVICE="${CLAW_FRONTEND_SERVICE:-${DEPLOY_INSTANCE}-frontend}"
|
||||
USER_SYSTEMD_DIR="${HOME}/.config/systemd/user"
|
||||
SYSTEM_SYSTEMD_DIR="/etc/systemd/system"
|
||||
BRANCH=""
|
||||
FORCE=0
|
||||
SKIP_GIT=0
|
||||
BOOTSTRAP_SYSTEM=0
|
||||
SERVICE_SCOPE="${CLAW_SERVICE_SCOPE:-}"
|
||||
ENABLE_LINUX_ACCOUNTS="${CLAW_ENABLE_LINUX_ACCOUNTS:-}"
|
||||
|
||||
usage() {
|
||||
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:
|
||||
branch 要部署的 git 分支;不传则使用当前分支
|
||||
@@ -27,6 +30,12 @@ Options:
|
||||
--skip-git 跳过 git 拉取,只部署当前工作区
|
||||
--bootstrap-system
|
||||
强制使用 sudo 安装 Ubuntu 系统依赖;首次安装会默认执行
|
||||
--system-service
|
||||
安装 root/system 级 systemd 服务;以 root 执行时默认启用
|
||||
--user-service
|
||||
安装当前用户级 systemd 服务
|
||||
--enable-linux-accounts
|
||||
启用平台账号同名 Linux 用户工作区;要求 system 服务和 root 权限
|
||||
EOF
|
||||
}
|
||||
|
||||
@@ -44,6 +53,18 @@ while [[ $# -gt 0 ]]; do
|
||||
BOOTSTRAP_SYSTEM=1
|
||||
shift
|
||||
;;
|
||||
--system-service)
|
||||
SERVICE_SCOPE="system"
|
||||
shift
|
||||
;;
|
||||
--user-service)
|
||||
SERVICE_SCOPE="user"
|
||||
shift
|
||||
;;
|
||||
--enable-linux-accounts)
|
||||
ENABLE_LINUX_ACCOUNTS="1"
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
@@ -60,6 +81,9 @@ while [[ $# -gt 0 ]]; do
|
||||
esac
|
||||
done
|
||||
|
||||
REQUESTED_SERVICE_SCOPE="${SERVICE_SCOPE}"
|
||||
REQUESTED_ENABLE_LINUX_ACCOUNTS="${ENABLE_LINUX_ACCOUNTS}"
|
||||
|
||||
log() {
|
||||
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}}"
|
||||
BACKEND_SERVICE="${CLAW_BACKEND_SERVICE:-${DEPLOY_INSTANCE}-backend}"
|
||||
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() {
|
||||
@@ -120,6 +154,9 @@ bootstrap_system_packages() {
|
||||
libssl-dev zlib1g-dev libbz2-dev libreadline-dev libsqlite3-dev \
|
||||
libffi-dev liblzma-dev libncursesw5-dev xz-utils tk-dev \
|
||||
systemd
|
||||
if [[ "${ENABLE_LINUX_ACCOUNTS}" == "1" ]]; then
|
||||
sudo apt-get install -y passwd python3 python3-venv
|
||||
fi
|
||||
}
|
||||
|
||||
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_PORT=$(printf "%q" "${frontend_port}")
|
||||
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
|
||||
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() {
|
||||
if [[ -f "${ENV_FILE}" ]]; then
|
||||
# shellcheck disable=SC1090
|
||||
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
|
||||
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
|
||||
fail "${ENV_FILE} 缺少 OPENAI_API_KEY,请编辑该文件补齐。"
|
||||
fi
|
||||
@@ -197,6 +261,8 @@ ensure_env_file() {
|
||||
echo "OPENAI_BASE_URL=${OPENAI_BASE_URL:-}"
|
||||
echo "OPENAI_MODEL=${OPENAI_MODEL:-}"
|
||||
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=已配置"
|
||||
return
|
||||
fi
|
||||
@@ -214,6 +280,9 @@ ensure_env_file() {
|
||||
prompt_value base_url "请输入 OPENAI_BASE_URL" "http://model.mify.ai.srv/v1"
|
||||
prompt_value model "请输入 OPENAI_MODEL" "tongyi/deepseek-v4-pro"
|
||||
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_port "请输入后端端口" "8765"
|
||||
prompt_value frontend_host "请输入前端监听地址" "0.0.0.0"
|
||||
@@ -304,14 +373,21 @@ EOF
|
||||
install_systemd_service() {
|
||||
local service_name="$1"
|
||||
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
|
||||
tmp_path="$(mktemp)"
|
||||
mkdir -p "${USER_SYSTEMD_DIR}"
|
||||
mkdir -p "${target_dir}"
|
||||
sed \
|
||||
-e "s#__PROJECT_ROOT__#${ROOT_DIR}#g" \
|
||||
-e "s#__BACKEND_SERVICE__#${BACKEND_SERVICE}#g" \
|
||||
-e "s#__FRONTEND_SERVICE__#${FRONTEND_SERVICE}#g" \
|
||||
-e "s#WantedBy=default.target#WantedBy=${wanted_by}#g" \
|
||||
"${template_path}" >"${tmp_path}"
|
||||
install -m 0644 "${tmp_path}" "${target_path}"
|
||||
rm -f "${tmp_path}"
|
||||
@@ -319,17 +395,30 @@ install_systemd_service() {
|
||||
|
||||
install_services() {
|
||||
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 "${FRONTEND_SERVICE}" "${ROOT_DIR}/deploy/systemd/zk-data-agent-frontend.service.template"
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable "${BACKEND_SERVICE}" "${FRONTEND_SERVICE}"
|
||||
if [[ "${SERVICE_SCOPE}" == "system" ]]; then
|
||||
systemctl daemon-reload
|
||||
systemctl enable "${BACKEND_SERVICE}" "${FRONTEND_SERVICE}"
|
||||
else
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable "${BACKEND_SERVICE}" "${FRONTEND_SERVICE}"
|
||||
fi
|
||||
}
|
||||
|
||||
restart_services() {
|
||||
log "重启服务"
|
||||
systemctl --user restart "${BACKEND_SERVICE}"
|
||||
systemctl --user restart "${FRONTEND_SERVICE}"
|
||||
if [[ "${SERVICE_SCOPE}" == "system" ]]; then
|
||||
systemctl restart "${BACKEND_SERVICE}"
|
||||
systemctl restart "${FRONTEND_SERVICE}"
|
||||
else
|
||||
systemctl --user restart "${BACKEND_SERVICE}"
|
||||
systemctl --user restart "${FRONTEND_SERVICE}"
|
||||
fi
|
||||
}
|
||||
|
||||
health_check() {
|
||||
@@ -348,8 +437,13 @@ health_check() {
|
||||
sleep 0.5
|
||||
done
|
||||
warn "健康检查失败,输出最近日志。"
|
||||
journalctl --user -u "${BACKEND_SERVICE}" -n 80 --no-pager || true
|
||||
journalctl --user -u "${FRONTEND_SERVICE}" -n 80 --no-pager || true
|
||||
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 "${FRONTEND_SERVICE}" -n 80 --no-pager || true
|
||||
fi
|
||||
exit 1
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ ENV_FILE="${ROOT_DIR}/.env.deploy"
|
||||
DEPLOY_INSTANCE="${CLAW_DEPLOY_INSTANCE:-zk-data-agent}"
|
||||
BACKEND_SERVICE="${CLAW_BACKEND_SERVICE:-${DEPLOY_INSTANCE}-backend}"
|
||||
FRONTEND_SERVICE="${CLAW_FRONTEND_SERVICE:-${DEPLOY_INSTANCE}-frontend}"
|
||||
SERVICE_SCOPE="${CLAW_SERVICE_SCOPE:-}"
|
||||
BRANCH="${1:-}"
|
||||
|
||||
log() {
|
||||
@@ -26,6 +27,13 @@ configure_instance_names() {
|
||||
DEPLOY_INSTANCE="${CLAW_DEPLOY_INSTANCE:-${DEPLOY_INSTANCE:-zk-data-agent}}"
|
||||
BACKEND_SERVICE="${CLAW_BACKEND_SERVICE:-${DEPLOY_INSTANCE}-backend}"
|
||||
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() {
|
||||
@@ -99,12 +107,20 @@ npm_bin="$(resolve_npm_bin)"
|
||||
export PATH="$(dirname "${npm_bin}"):${PATH}"
|
||||
"${npm_bin}" run build
|
||||
|
||||
log "重启用户服务"
|
||||
systemctl --user restart "${BACKEND_SERVICE}" "${FRONTEND_SERVICE}"
|
||||
log "重启 ${SERVICE_SCOPE} 服务"
|
||||
if [[ "${SERVICE_SCOPE}" == "system" ]]; then
|
||||
systemctl restart "${BACKEND_SERVICE}" "${FRONTEND_SERVICE}"
|
||||
else
|
||||
systemctl --user restart "${BACKEND_SERVICE}" "${FRONTEND_SERVICE}"
|
||||
fi
|
||||
|
||||
log "健康检查"
|
||||
sleep 1
|
||||
systemctl --user --no-pager --lines=0 status "${BACKEND_SERVICE}" "${FRONTEND_SERVICE}"
|
||||
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}"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "快速更新完成。"
|
||||
|
||||
@@ -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())
|
||||
@@ -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
|
||||
}
|
||||
```
|
||||
@@ -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": "..."
|
||||
}
|
||||
```
|
||||
|
||||
此时必须询问用户字段映射,不要猜。
|
||||
@@ -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())
|
||||
@@ -41,6 +41,7 @@ class ToolExecutionContext:
|
||||
permissions: AgentPermissions
|
||||
scratchpad_directory: Path | None = None
|
||||
python_env_dir: Path | None = None
|
||||
runtime_user: str | None = None
|
||||
extra_env: dict[str, str] = field(default_factory=dict)
|
||||
cancel_event: Any | None = None
|
||||
process_registry: Any | None = None
|
||||
@@ -169,6 +170,7 @@ def build_tool_context(
|
||||
if config.python_env_dir
|
||||
else None
|
||||
),
|
||||
runtime_user=config.runtime_user,
|
||||
extra_env=dict(extra_env or {}),
|
||||
cancel_event=cancel_event,
|
||||
process_registry=process_registry,
|
||||
|
||||
@@ -6,6 +6,7 @@ import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import pwd
|
||||
import re
|
||||
import secrets
|
||||
import selectors
|
||||
@@ -1648,6 +1649,8 @@ def _resolve_path(
|
||||
else context.root / expanded
|
||||
)
|
||||
resolved = candidate.resolve(strict=not allow_missing)
|
||||
if _is_session_workspace_path(resolved, context):
|
||||
return resolved
|
||||
try:
|
||||
resolved.relative_to(context.root)
|
||||
except ValueError as exc:
|
||||
@@ -1678,6 +1681,17 @@ def _session_logical_path(path: Path, context: ToolExecutionContext) -> Path | N
|
||||
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:
|
||||
resolved = path.resolve()
|
||||
try:
|
||||
@@ -1691,10 +1705,25 @@ def _execution_cwd(context: ToolExecutionContext) -> Path:
|
||||
return Path(context.jupyter_runtime.binding.workspace_cwd)
|
||||
if context.scratchpad_directory is not None:
|
||||
context.scratchpad_directory.mkdir(parents=True, exist_ok=True)
|
||||
_chown_to_runtime_user(context.scratchpad_directory, context)
|
||||
return context.scratchpad_directory
|
||||
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:
|
||||
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():
|
||||
raise ToolExecutionError(f'Path exists and is not a file: {target}')
|
||||
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
|
||||
target.write_text(final_text, encoding='utf-8')
|
||||
_chown_to_runtime_user(target, context)
|
||||
rel = _display_path(target, context)
|
||||
new_sha256 = hashlib.sha256(final_text.encode('utf-8')).hexdigest()
|
||||
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()
|
||||
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')
|
||||
_chown_to_runtime_user(target, context)
|
||||
rel = _display_path(target, context)
|
||||
replaced = occurrences if replace_all else 1
|
||||
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)
|
||||
updated = json.dumps(notebook, ensure_ascii=True, indent=1) + '\n'
|
||||
target.write_text(updated, encoding='utf-8')
|
||||
_chown_to_runtime_user(target, context)
|
||||
after_sha256 = hashlib.sha256(updated.encode('utf-8')).hexdigest()
|
||||
rel = _display_path(target, context)
|
||||
return (
|
||||
@@ -2367,7 +2400,7 @@ def _run_python_exec(arguments: dict[str, Any], context: ToolExecutionContext) -
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
env=_build_subprocess_env(context),
|
||||
**_subprocess_isolation_kwargs(),
|
||||
**_subprocess_execution_kwargs(context),
|
||||
)
|
||||
_register_child_process(context, process)
|
||||
if stdin and process.stdin is not None:
|
||||
@@ -2485,6 +2518,24 @@ def _subprocess_isolation_kwargs() -> dict[str, Any]:
|
||||
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:
|
||||
if process.poll() is not None:
|
||||
return
|
||||
@@ -2619,6 +2670,7 @@ def _run_python_package(arguments: dict[str, Any], context: ToolExecutionContext
|
||||
text=True,
|
||||
timeout=timeout_seconds,
|
||||
env=_build_subprocess_env(context),
|
||||
**_subprocess_execution_kwargs(context),
|
||||
)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
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,
|
||||
timeout=context.command_timeout_seconds,
|
||||
env=_build_subprocess_env(context),
|
||||
**_subprocess_execution_kwargs(context),
|
||||
)
|
||||
stdout = completed.stdout or ''
|
||||
stderr = completed.stderr or ''
|
||||
@@ -4344,13 +4397,13 @@ def _stream_bash(
|
||||
command,
|
||||
shell=True,
|
||||
executable='/bin/bash',
|
||||
cwd=context.root,
|
||||
cwd=_execution_cwd(context),
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
env=_build_subprocess_env(context),
|
||||
**_subprocess_isolation_kwargs(),
|
||||
**_subprocess_execution_kwargs(context),
|
||||
)
|
||||
_register_child_process(context, process)
|
||||
except (ToolPermissionError, ToolExecutionError, OSError, subprocess.SubprocessError) as exc:
|
||||
|
||||
@@ -171,6 +171,7 @@ class AgentRuntimeConfig:
|
||||
session_directory: Path = field(default_factory=lambda: (Path('.port_sessions') / 'agent').resolve())
|
||||
scratchpad_root: Path = field(default_factory=lambda: (Path('.port_sessions') / 'scratchpad').resolve())
|
||||
python_env_dir: Path | None = None
|
||||
runtime_user: str | None = None
|
||||
enabled_skill_names: tuple[str, ...] | None = None
|
||||
|
||||
|
||||
|
||||
@@ -194,6 +194,7 @@ def serialize_runtime_config(runtime_config: AgentRuntimeConfig) -> JSONDict:
|
||||
if runtime_config.python_env_dir is not None
|
||||
else None
|
||||
),
|
||||
'runtime_user': runtime_config.runtime_user,
|
||||
'enabled_skill_names': (
|
||||
list(runtime_config.enabled_skill_names)
|
||||
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
|
||||
else None
|
||||
),
|
||||
runtime_user=(
|
||||
str(payload['runtime_user'])
|
||||
if payload.get('runtime_user') is not None
|
||||
else None
|
||||
),
|
||||
enabled_skill_names=enabled_skill_names,
|
||||
)
|
||||
|
||||
|
||||