From 8eadc25b19f0ced3244c961e713b0704618da68b Mon Sep 17 00:00:00 2001 From: wuyang6 Date: Thu, 2 Jul 2026 14:02:01 +0800 Subject: [PATCH] fix feishu reauth for online docs --- backend/api/server.py | 65 ++++++-- docs/share-report-v1.md | 145 ++++++++++++++++++ .../app/api/claw/integrations/feishu/route.ts | 6 +- .../assistant-ui/activity-panel.tsx | 12 +- tests/test_feishu_integration.py | 28 ++++ 5 files changed, 243 insertions(+), 13 deletions(-) create mode 100644 docs/share-report-v1.md diff --git a/backend/api/server.py b/backend/api/server.py index 17ac1fb..c299293 100644 --- a/backend/api/server.py +++ b/backend/api/server.py @@ -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: diff --git a/docs/share-report-v1.md b/docs/share-report-v1.md new file mode 100644 index 0000000..7e6a358 --- /dev/null +++ b/docs/share-report-v1.md @@ -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 记忆和团队经验沉淀质量。 +- 把数据开发、评测、训练、线上问题分析这些流程打通得更稳定。 diff --git a/frontend/app/app/api/claw/integrations/feishu/route.ts b/frontend/app/app/api/claw/integrations/feishu/route.ts index e2886e9..4cb8ecc 100644 --- a/frontend/app/app/api/claw/integrations/feishu/route.ts +++ b/frontend/app/app/api/claw/integrations/feishu/route.ts @@ -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); diff --git a/frontend/app/components/assistant-ui/activity-panel.tsx b/frontend/app/components/assistant-ui/activity-panel.tsx index cafa143..e4fc297 100644 --- a/frontend/app/components/assistant-ui/activity-panel.tsx +++ b/frontend/app/components/assistant-ui/activity-panel.tsx @@ -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() diff --git a/tests/test_feishu_integration.py b/tests/test_feishu_integration.py index b09e3f3..e22d0dc 100644 --- a/tests/test_feishu_integration.py +++ b/tests/test_feishu_integration.py @@ -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))