fix feishu reauth for online docs
This commit is contained in:
+54
-11
@@ -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,15 +4440,21 @@ 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(
|
||||||
status = _feishu_status_payload(state, account_id)
|
state: AgentState,
|
||||||
if status.get('logged_in'):
|
account_id: str | None,
|
||||||
return status
|
*,
|
||||||
|
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)
|
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:
|
||||||
|
|||||||
@@ -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 {
|
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()
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
Reference in New Issue
Block a user