fix feishu reauth for online docs
This commit is contained in:
+54
-11
@@ -1132,6 +1132,7 @@ class SessionUpdate(BaseModel):
|
||||
|
||||
class FeishuAccountRequest(BaseModel):
|
||||
account_id: str | None = None
|
||||
force: bool = False
|
||||
|
||||
|
||||
class FeishuOnlineDocRequest(BaseModel):
|
||||
@@ -1616,7 +1617,7 @@ def create_app(state: AgentState) -> FastAPI:
|
||||
|
||||
@app.post('/api/integrations/feishu/login')
|
||||
async def feishu_login(payload: FeishuAccountRequest) -> dict[str, Any]:
|
||||
return _start_feishu_login(state, payload.account_id)
|
||||
return _start_feishu_login(state, payload.account_id, force=payload.force)
|
||||
|
||||
@app.post('/api/integrations/feishu/logout')
|
||||
async def feishu_logout(payload: FeishuAccountRequest) -> dict[str, Any]:
|
||||
@@ -1684,6 +1685,20 @@ def create_app(state: AgentState) -> FastAPI:
|
||||
except OSError as exc:
|
||||
raise HTTPException(status_code=400, detail=f'读取文件失败: {exc}')
|
||||
except Exception as exc:
|
||||
if _is_feishu_auth_error(str(exc)):
|
||||
_stop_feishu_login(payload.account_id)
|
||||
logout_result = _run_feishu_cli(paths, ['logout'], timeout_seconds=30)
|
||||
status = _feishu_status_payload(state, payload.account_id)
|
||||
status['logout_output'] = logout_result.get('output')
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={
|
||||
'code': 'feishu_auth_expired',
|
||||
'message': '飞书登录已过期或未登录,请重新授权后再转换。',
|
||||
'force_login': True,
|
||||
'status': status,
|
||||
},
|
||||
)
|
||||
target_name = '飞书表格' if kind == 'sheet' else '飞书文档'
|
||||
raise HTTPException(status_code=502, detail=f'{target_name}创建失败: {exc}')
|
||||
url = _extract_first_url(rendered)
|
||||
@@ -4379,16 +4394,36 @@ def _run_feishu_cli(
|
||||
}
|
||||
|
||||
|
||||
def _is_feishu_auth_error(text: str) -> bool:
|
||||
lowered = text.lower()
|
||||
markers = (
|
||||
'not logged in',
|
||||
'no credentials',
|
||||
'unauthorized',
|
||||
'unauthenticated',
|
||||
'login expired',
|
||||
'token expired',
|
||||
'invalid token',
|
||||
'access token expired',
|
||||
'refresh token expired',
|
||||
'登录已过期',
|
||||
'授权已过期',
|
||||
'未登录',
|
||||
'未授权',
|
||||
'凭证已过期',
|
||||
'认证失败',
|
||||
'请重新登录',
|
||||
'重新授权',
|
||||
)
|
||||
return any(marker in lowered for marker in markers)
|
||||
|
||||
|
||||
def _feishu_status_payload(state: AgentState, account_id: str | None) -> dict[str, Any]:
|
||||
paths = _feishu_paths(state, account_id)
|
||||
_reap_feishu_login(account_id)
|
||||
result = _run_feishu_cli(paths, ['status'], timeout_seconds=30)
|
||||
output = str(result.get('output') or '')
|
||||
lowered = output.lower()
|
||||
logged_in = result.get('returncode') == 0 and not any(
|
||||
marker in lowered
|
||||
for marker in ('not logged in', '未登录', 'no credentials')
|
||||
)
|
||||
logged_in = result.get('returncode') == 0 and not _is_feishu_auth_error(output)
|
||||
pending = _feishu_login_snapshot(account_id)
|
||||
payload: dict[str, Any] = {
|
||||
'logged_in': logged_in,
|
||||
@@ -4405,15 +4440,21 @@ def _feishu_status_payload(state: AgentState, account_id: str | None) -> dict[st
|
||||
return payload
|
||||
|
||||
|
||||
def _start_feishu_login(state: AgentState, account_id: str | None) -> dict[str, Any]:
|
||||
status = _feishu_status_payload(state, account_id)
|
||||
if status.get('logged_in'):
|
||||
return status
|
||||
def _start_feishu_login(
|
||||
state: AgentState,
|
||||
account_id: str | None,
|
||||
*,
|
||||
force: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
if not force:
|
||||
status = _feishu_status_payload(state, account_id)
|
||||
if status.get('logged_in'):
|
||||
return status
|
||||
|
||||
key = _feishu_login_key(account_id)
|
||||
with _FEISHU_LOGIN_LOCK:
|
||||
existing = _FEISHU_LOGIN_PROCESSES.get(key)
|
||||
if existing is not None and existing.process.poll() is None:
|
||||
if not force and existing is not None and existing.process.poll() is None:
|
||||
return {
|
||||
'logged_in': False,
|
||||
'status': 'login_pending',
|
||||
@@ -4422,6 +4463,8 @@ def _start_feishu_login(state: AgentState, account_id: str | None) -> dict[str,
|
||||
_FEISHU_LOGIN_PROCESSES.pop(key, None)
|
||||
|
||||
paths = _feishu_paths(state, account_id)
|
||||
if force:
|
||||
_run_feishu_cli(paths, ['logout'], timeout_seconds=30)
|
||||
env = os.environ.copy()
|
||||
env.update(_feishu_env(paths))
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
# 从个人 Agent 到团队 Agent:沉淀可复用的 AI 提效探索
|
||||
|
||||
## 平台地址
|
||||
|
||||
- 平台入口:http://10.189.47.6/
|
||||
- 平台文档:http://10.189.47.6/doc
|
||||
|
||||
## Git 地址
|
||||
|
||||
- 仓库:https://git.n.xiaomi.com/wuyang6/zk-data-agent.git
|
||||
|
||||
现有 Skill 数量 12 个,涵盖线上数据挖掘、数据生成、日志分析、标签知识、模型打标、训练发起 / 评测等业务功能,提升组内工作效率。
|
||||
|
||||
## 1. 为什么要做这个
|
||||
|
||||
个人 AI 提效已经发生了,下一步要解决的是团队复用和流程沉淀。
|
||||
|
||||
[图片]
|
||||
|
||||
最初的目标,是提升中控 planning 模型迭代中的数据开发效率。
|
||||
|
||||
- 日志挖掘、问题分析、数据构造、数据格式化....
|
||||
|
||||
做的过程中发现,我们发现低效并不只是某个步骤慢,而是三类能力分散在个人手里:
|
||||
|
||||
- 业务知识分散:哪些日志表可查、哪些字段有用、标签边界怎么判断、问题应该怎么归因。
|
||||
- 执行工具分散:脚本、参数、路径、格式转换逻辑和产物规范各自维护。
|
||||
- AI 经验分散:有效的 AI 流程、会话历史、修正经验、个人偏好难以共享和继承。
|
||||
|
||||
类似情况也存在于评测、训练、结果分析、问题排查、线上配置修改等工作中,用于数据开发提效的逻辑也同样适用...
|
||||
|
||||
所以,这个项目逐渐从一个数据开发提效实践,演化成一个中控数据 Agent:把已经验证有效的业务知识、执行工具和 AI 经验,整理成可以共享、组合和继续迭代的 Skill 与工具链。
|
||||
|
||||
## 2. 目前做了什么
|
||||
|
||||
中控数据 Agent 以 Agent Loop 作为执行底座,以业务 Skill 作为核心组织方式,再通过工作区、工具链和记忆机制,把个人经验沉淀成团队可以继续使用和改造的能力。
|
||||
|
||||
系统的架构:http://10.189.47.6/doc
|
||||
|
||||
[图片]
|
||||
|
||||
### 2.1 Skill 热更新与同步
|
||||
|
||||
平台支持在页面中更新 Skill:当有人新增或修改 Skill 后,可以通过 Skill 面板同步到当前服务,让新的能力进入模型可见列表。这样业务能力可以跟随实际需求快速迭代,不需要每次都改核心 Agent 代码。
|
||||
|
||||
这一点适合团队协作:
|
||||
|
||||
- 个人可以先在自己的分支里开发 Skill;
|
||||
- 稳定后同步到平台;
|
||||
- 其他人可以直接启用和复用;
|
||||
- Skill 可以继续被修改、补充和演进。
|
||||
|
||||
[图片]
|
||||
|
||||
### 2.2 记忆机制
|
||||
|
||||
平台目前把记忆分成两类:用户记忆和 Skill 记忆。
|
||||
|
||||
- 用户记忆:记录个人偏好、常用环境、输出习惯等。
|
||||
- Skill 记忆:记录某个 Skill 使用过程中积累的经验、边界和常见坑。
|
||||
|
||||
这类记忆可以在页面中查看和编辑。使用次数越多,某个 Skill 相关的经验越容易被沉淀下来,后续类似任务可以少重复解释。
|
||||
|
||||
和常见的“全局用户偏好记忆”相比,这里的重点是把记忆绑定到业务能力上。
|
||||
|
||||
例如线上挖掘中,哪些字段更可靠、session 应该怎么抽、哪些输出格式容易出错,这些经验更适合沉淀到对应 Skill,而不是混在一个全局记忆里。
|
||||
|
||||
[图片]
|
||||
|
||||
典型记忆方法的对比:
|
||||
|
||||
| 记忆方案 | 主要解决什么 | 典型做法 | 对我们的启发 | ZK Data Agent 的取舍 |
|
||||
|---|---|---|---|---|
|
||||
| ChatGPT Memory | 个人助手的长期个性化 | 从用户对话、历史聊天、文件、记忆摘要等来源形成个性化上下文;用户可以查看 Memory Sources、纠正、删除或关闭记忆。OpenAI 文档也强调,删除某条记忆并不等于删除所有来源,完整删除需要处理过去聊天、文件、连接应用等源头。(OpenAI Help Center) | 记忆必须用户可管理、可纠正、可删除、最好能追踪来源。 | 保留“用户记忆”,记录个人偏好、输出习惯、常用约定 |
|
||||
| Claude Code / CLAUDE.md + Auto Memory | 项目规则、工程约定、代码库经验沉淀 | CLAUDE.md 保存显式项目规则;Auto Memory 使用 MEMORY.md 作为索引,并把详细内容拆到 debugging.md、api-conventions.md 等主题文件。Claude Code 会在启动时加载 MEMORY.md 前 200 行或 25KB,主题文件按需读取;这些 memory 文件是 Markdown,可编辑、可删除。(Claude Platform Docs) | 记忆应该透明、文件化、可编辑、可版本化;同时要用“索引 + 主题文件”避免全量注入。 | 记忆正文落到 Markdown,用户可在页面编辑 |
|
||||
| LangGraph / LangMem | Agent 的会话状态、长期记忆、异步整理 | LangGraph 区分 thread-scoped short-term memory 和跨 session 的 long-term memory;长期记忆通过 namespace 隔离。LangMem 则提供从对话中抽取、合并、更新长期记忆的工具,并支持 hot path 写入和 background memory manager。(LangChain 文档) (LangChain AI) | 记忆需要分层:会话状态、用户长期记忆、Skill 规则、历史经验不是一类东西;写记忆也应区分同步和异步。 | 第一阶段不先上复杂检索框架,但借鉴其结构:主链路只记录候选记忆事件,后台异步整理;注入时按 user_id + skill_id + task_type 精准选择。 |
|
||||
| Mem0 | 通用 AI 应用 / Agent 的持久记忆层 | Mem0 把记忆分为 conversation、session、user、organizational 等层;通过 user_id、run_id、metadata 做作用域隔离,并在查询时合并召回不同层级的记忆。(Mem0) | 记忆的 scope 和生命周期应该是数据模型的一等字段,而不是临时写进 prompt。 | 当前先按用户和 Skill 精准注入,不先上复杂检索层 |
|
||||
| Zep | 企业级 Agent Memory、时序知识图谱、动态事实更新 | Zep 从聊天、业务数据、文档、JSON 等来源构建 temporal Context Graph;图中包含实体、关系和事实,事实变更后会 invalidating outdated facts,同时保留历史。它还会生成 token-efficient Context Block 给 Agent 使用。(Zep) | 对业务 Agent 很重要的一点是:记忆不能只追加,还要能处理事实过期、口径变化、历史保留、当前有效状态。 | 采用后台异步整理,避免影响主任务执行 |
|
||||
| ZK Data Agent | 团队业务 Skill 的持续复用 | 用户记忆 + Skill 核心记忆 + Skill 归档记忆 + 异步整理队列 + Markdown 可编辑。主任务执行时,只注入当前用户和当前 Skill 相关的高优先级记忆。 | 核心目标不是“什么都记住”,而是把反复纠正、反复复用、业务相关的经验沉淀到对应 Skill 上。 | 第一阶段采用轻量、可控方案:Markdown 可编辑、按 Skill 精准注入、后台异步整理、作用域隔离。 |
|
||||
|
||||
### 2.3 Jupyter 工作区入口
|
||||
|
||||
很多实际工作最终还是要落到远端环境里执行,例如:
|
||||
|
||||
- 修改线上配置
|
||||
- 发起模型训练-cloudml / 分析训练结果
|
||||
- 读取 juiceFS 数据和产物
|
||||
|
||||
平台支持把 Jupyter / 远端工作区绑定到当前用户或会话。Agent 在执行任务时,可以围绕当前工作区读写文件、运行脚本、整理结果。
|
||||
|
||||
这个能力的价值在于:AI 不只停留在本地聊天和文本生成,而是可以进入真实工作环境,衔接已有的数据、脚本和训练流程。
|
||||
|
||||
[图片]
|
||||
|
||||
## 3. 我们在怎么用
|
||||
|
||||
### 3.1 数据挖掘的例子
|
||||
|
||||
会话链接:http://10.189.47.6/session/__LOCALID_0lj28j5i
|
||||
|
||||
[图片]
|
||||
|
||||
[图片]
|
||||
|
||||
### 3.2 标签大师的例子
|
||||
|
||||
会话链接:
|
||||
|
||||
- http://10.189.47.6/session/__LOCALID_HP8pkRE
|
||||
- http://10.189.47.6/session/__LOCALID_bextxgj0
|
||||
|
||||
[图片]
|
||||
|
||||
### 3.3 线上正则干预的例子
|
||||
|
||||
- http://10.189.47.6/session/__LOCALID_dgjr9shm
|
||||
|
||||
## 4. 实际效果
|
||||
|
||||
| 场景 | 原流程 | 当前流程 | 粗略提效 | 产出 |
|
||||
|---|---:|---:|---:|---|
|
||||
| 线上问题分析 / 评测集构建 | 1~2 天 | 30 分钟内 | 约 16~32 倍 | 目前已构造 5 个评测集,2000+ 数据 |
|
||||
| 数据构造与格式转换 | 1~2 小时 | 10 分钟内 | 约 6~12 倍 | |
|
||||
| 线上配置修改(熟练工) | 20 分钟 | 2 分钟 | 约 10 倍 | |
|
||||
|
||||
[图片]
|
||||
|
||||
## 5. 总结
|
||||
|
||||
这次实践的起点很具体:提升中控 planning 模型迭代中的数据开发效率。
|
||||
|
||||
做下来之后,我们验证了一个更通用的方向:很多 AI 提效能力,真正有复用价值的部分,往往不是某一次对话结果,而是背后的业务知识、执行脚本、流程经验和修正记录。
|
||||
|
||||
中控数据 Agent 目前做的事情,就是把这些分散能力整理成团队可以复用的 Skill 和工具链:
|
||||
|
||||
- 个人经验可以沉淀成 Skill。
|
||||
- Skill 可以被同步、启用和组合。
|
||||
- 使用过程中的修正经验可以进入记忆。
|
||||
- Jupyter 工作区让 Agent 能连接真实执行环境。
|
||||
|
||||
后续继续迭代的重点可以放在三件事:
|
||||
|
||||
- 扩展更多高频业务 Skill。
|
||||
- 提升 Skill 记忆和团队经验沉淀质量。
|
||||
- 把数据开发、评测、训练、线上问题分析这些流程打通得更稳定。
|
||||
@@ -20,6 +20,7 @@ export async function POST(request: Request) {
|
||||
|
||||
const body = (await request.json().catch(() => ({}))) as {
|
||||
action?: unknown;
|
||||
force?: unknown;
|
||||
};
|
||||
const action = typeof body.action === "string" ? body.action : "login";
|
||||
const endpoint =
|
||||
@@ -30,7 +31,10 @@ export async function POST(request: Request) {
|
||||
const response = await fetch(`${CLAW_API_URL}${endpoint}`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ account_id: account.id }),
|
||||
body: JSON.stringify({
|
||||
account_id: account.id,
|
||||
...(action !== "logout" && body.force === true ? { force: true } : {}),
|
||||
}),
|
||||
cache: "no-store",
|
||||
});
|
||||
return proxyResponse(response);
|
||||
|
||||
@@ -574,10 +574,20 @@ function SessionFileRow({ file }: { file: SessionFile }) {
|
||||
unknown
|
||||
>;
|
||||
if (response.status === 409) {
|
||||
const detail = payload.detail;
|
||||
const forceLogin =
|
||||
Boolean(
|
||||
detail &&
|
||||
typeof detail === "object" &&
|
||||
(detail as { force_login?: unknown }).force_login,
|
||||
) ||
|
||||
(detail &&
|
||||
typeof detail === "object" &&
|
||||
(detail as { code?: unknown }).code === "feishu_auth_expired");
|
||||
const loginResponse = await fetch("/api/claw/integrations/feishu", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ action: "login" }),
|
||||
body: JSON.stringify({ action: "login", force: forceLogin }),
|
||||
});
|
||||
const loginPayload = (await loginResponse
|
||||
.json()
|
||||
|
||||
@@ -109,6 +109,34 @@ class FeishuIntegrationTests(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(online_docs['files'][str(file_path)]['kind'], 'doc')
|
||||
|
||||
def test_create_online_doc_returns_login_required_when_mcp_token_expired(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
client, state = _build_client(Path(tmp_dir))
|
||||
file_path = _write_account_file(state, 'alice', 'draft.md', '# hello')
|
||||
|
||||
with patch.object(
|
||||
gui_server,
|
||||
'_feishu_status_payload',
|
||||
return_value={'logged_in': True, 'status': 'logged_in'},
|
||||
), patch.object(
|
||||
gui_server.MCPRuntime,
|
||||
'call_tool',
|
||||
side_effect=RuntimeError('登录已过期或未登录,请重新授权'),
|
||||
), patch.object(
|
||||
gui_server,
|
||||
'_run_feishu_cli',
|
||||
return_value={'returncode': 0, 'output': 'logged out'},
|
||||
):
|
||||
response = client.post(
|
||||
'/api/files/online-doc',
|
||||
json={'account_id': 'alice', 'path': str(file_path)},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 409)
|
||||
detail = response.json()['detail']
|
||||
self.assertEqual(detail['code'], 'feishu_auth_expired')
|
||||
self.assertTrue(detail['force_login'])
|
||||
|
||||
def test_create_online_doc_converts_csv_to_feishu_sheet(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
client, state = _build_client(Path(tmp_dir))
|
||||
|
||||
Reference in New Issue
Block a user