fix feishu reauth for online docs

This commit is contained in:
wuyang6
2026-07-02 14:02:01 +08:00
parent 0007d99763
commit 8eadc25b19
5 changed files with 243 additions and 13 deletions
+51 -8
View File
@@ -1132,6 +1132,7 @@ class SessionUpdate(BaseModel):
class FeishuAccountRequest(BaseModel): class FeishuAccountRequest(BaseModel):
account_id: str | None = None account_id: str | None = None
force: bool = False
class FeishuOnlineDocRequest(BaseModel): class FeishuOnlineDocRequest(BaseModel):
@@ -1616,7 +1617,7 @@ def create_app(state: AgentState) -> FastAPI:
@app.post('/api/integrations/feishu/login') @app.post('/api/integrations/feishu/login')
async def feishu_login(payload: FeishuAccountRequest) -> dict[str, Any]: 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') @app.post('/api/integrations/feishu/logout')
async def feishu_logout(payload: FeishuAccountRequest) -> dict[str, Any]: async def feishu_logout(payload: FeishuAccountRequest) -> dict[str, Any]:
@@ -1684,6 +1685,20 @@ def create_app(state: AgentState) -> FastAPI:
except OSError as exc: except OSError as exc:
raise HTTPException(status_code=400, detail=f'读取文件失败: {exc}') raise HTTPException(status_code=400, detail=f'读取文件失败: {exc}')
except Exception as 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 '飞书文档' target_name = '飞书表格' if kind == 'sheet' else '飞书文档'
raise HTTPException(status_code=502, detail=f'{target_name}创建失败: {exc}') raise HTTPException(status_code=502, detail=f'{target_name}创建失败: {exc}')
url = _extract_first_url(rendered) 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]: def _feishu_status_payload(state: AgentState, account_id: str | None) -> dict[str, Any]:
paths = _feishu_paths(state, account_id) paths = _feishu_paths(state, account_id)
_reap_feishu_login(account_id) _reap_feishu_login(account_id)
result = _run_feishu_cli(paths, ['status'], timeout_seconds=30) result = _run_feishu_cli(paths, ['status'], timeout_seconds=30)
output = str(result.get('output') or '') output = str(result.get('output') or '')
lowered = output.lower() logged_in = result.get('returncode') == 0 and not _is_feishu_auth_error(output)
logged_in = result.get('returncode') == 0 and not any(
marker in lowered
for marker in ('not logged in', '未登录', 'no credentials')
)
pending = _feishu_login_snapshot(account_id) pending = _feishu_login_snapshot(account_id)
payload: dict[str, Any] = { payload: dict[str, Any] = {
'logged_in': logged_in, 'logged_in': logged_in,
@@ -4405,7 +4440,13 @@ def _feishu_status_payload(state: AgentState, account_id: str | None) -> dict[st
return payload return payload
def _start_feishu_login(state: AgentState, account_id: str | None) -> dict[str, Any]: 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) status = _feishu_status_payload(state, account_id)
if status.get('logged_in'): if status.get('logged_in'):
return status return status
@@ -4413,7 +4454,7 @@ def _start_feishu_login(state: AgentState, account_id: str | None) -> dict[str,
key = _feishu_login_key(account_id) key = _feishu_login_key(account_id)
with _FEISHU_LOGIN_LOCK: with _FEISHU_LOGIN_LOCK:
existing = _FEISHU_LOGIN_PROCESSES.get(key) 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 { return {
'logged_in': False, 'logged_in': False,
'status': 'login_pending', '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) _FEISHU_LOGIN_PROCESSES.pop(key, None)
paths = _feishu_paths(state, account_id) paths = _feishu_paths(state, account_id)
if force:
_run_feishu_cli(paths, ['logout'], timeout_seconds=30)
env = os.environ.copy() env = os.environ.copy()
env.update(_feishu_env(paths)) env.update(_feishu_env(paths))
try: try:
+145
View File
@@ -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 分钟内 | 约 612 倍 | |
| 线上配置修改(熟练工) | 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 { const body = (await request.json().catch(() => ({}))) as {
action?: unknown; action?: unknown;
force?: unknown;
}; };
const action = typeof body.action === "string" ? body.action : "login"; const action = typeof body.action === "string" ? body.action : "login";
const endpoint = const endpoint =
@@ -30,7 +31,10 @@ export async function POST(request: Request) {
const response = await fetch(`${CLAW_API_URL}${endpoint}`, { const response = await fetch(`${CLAW_API_URL}${endpoint}`, {
method: "POST", method: "POST",
headers: { "content-type": "application/json" }, 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", cache: "no-store",
}); });
return proxyResponse(response); return proxyResponse(response);
@@ -574,10 +574,20 @@ function SessionFileRow({ file }: { file: SessionFile }) {
unknown unknown
>; >;
if (response.status === 409) { 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", { const loginResponse = await fetch("/api/claw/integrations/feishu", {
method: "POST", method: "POST",
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
body: JSON.stringify({ action: "login" }), body: JSON.stringify({ action: "login", force: forceLogin }),
}); });
const loginPayload = (await loginResponse const loginPayload = (await loginResponse
.json() .json()
+28
View File
@@ -109,6 +109,34 @@ class FeishuIntegrationTests(unittest.TestCase):
) )
self.assertEqual(online_docs['files'][str(file_path)]['kind'], 'doc') 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: def test_create_online_doc_converts_csv_to_feishu_sheet(self) -> None:
with tempfile.TemporaryDirectory() as tmp_dir: with tempfile.TemporaryDirectory() as tmp_dir:
client, state = _build_client(Path(tmp_dir)) client, state = _build_client(Path(tmp_dir))