Skip to content

feat(channels): 飞书人工介入转接与 /回复反馈 指令 - #187

Merged
fadeoreo merged 10 commits into
OpenBMB:mainfrom
skywclouds:main
Aug 18, 2026
Merged

feat(channels): 飞书人工介入转接与 /回复反馈 指令#187
fadeoreo merged 10 commits into
OpenBMB:mainfrom
skywclouds:main

Conversation

@skywclouds

@skywclouds skywclouds commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

功能概述

实现飞书人工介入转接的完整端到端流程:知识库驱动匹配处理人 → 飞书通知 → 处理人在飞书内回复 → SOP 恢复并将结果投递回原用户。

实现细节

1. 知识库驱动转接(Phase 1-2)

  • okf.py 新增 Contact 概念类型,支持 frontmatter 字段:feishu_open_idfeishu_mobilefeishu_emailstaffdeck_user_idnamerolekeywords
  • search_concepts 对 Contact 类型加权检索,extract_contact_target 从知识概念中提取联系人信息
  • human_handoff_service.pyresolve_contact_assignee 搜索所有概念类型找到合适的处理人

2. 飞书通知(Phase 3)

  • feishu.pysend() 返回 message_id,新增 resolve_open_id_by_mobile_or_email 通过手机号/邮箱反查 open_id
  • service_outbox.py 新增 notify_handoff_assignee,向处理人飞书发送人工转接通知,通知中提示使用 /回复反馈 回复
  • models.py 新增 HumanHandoffRequest.notify_message_id 字段,database.py 增加迁移守卫

3. /回复反馈 指令(Phase 4)

  • service_routing.pyparse_command 识别 /回复反馈/handoff_reply 前缀
  • service_intake.py 新增 _run_handoff_reply_command:匹配发送者 open_id → ChannelIdentity → staffdeck_user_id → pending handoff;回退匹配 contact_target.feishu_open_id。命中后调用 _apply_handoff_reply 并给处理人回确认
  • 指令在 process_inbound 中早于 AgentLoop 拦截,不会触发数字员工
  • 保留 _try_handle_feishu_handoff_reply(parent_id 匹配)作为 fallback

4. 投递修复(Phase 5)

  • 问题:handoff resume 后 SOP 恢复产生的回复投递失败(delivery_target_missing),因为 resume 的 ChatTurnRequest 没有 client_turn_id_immutable_delivery_target 查不到对应的 ChannelInboundEvent
  • 修复_immutable_delivery_targetclient_turn_id 为空或查不到时,fallback 到 chat_session.channel_target_json

5. 人工回复原文注入

  • _inject_handoff_context:移除 session.status != "handoff" 的早期 return;当 handoff 已 answered 且 resume_finished_at 标记存在时(即 resume 后第一个 turn),对所有 task 注入 handoff_info(含 human_reply),注入后立即清除标记,确保只注入一次
  • response_generator_prompt.md 新增第 26 条规则:要求 LLM 完整转述 human_reply 原文,不得省略或只回复寒暄

测试

  • test_feishu_handoff.py:32 个回归测试,覆盖 Contact 概念、assignee 解析、open_id 查找、通知投递、/回复反馈 指令解析、handoff 回复匹配、投递目标 fallback
  • 修复 test_channel_intake.pytest_channel_team_binding.pyevent_sink kwarg)
  • 修复 test_graph_rules.pysub_sop_id 字段)
  • 全量测试 1683 passed(32 个 test_llm_client.py 失败为预存问题,与本次改动无关)

已验证的 UI 路由与角色

  • 飞书 p2p 会话(普通用户 → 机器人 → 人工处理人)
  • 处理人角色:知识库 Contact 概念匹配到的真人
  • 端到端流程已在生产环境验证通过

Implement end-to-end feishu human handoff flow:

- Knowledge-driven assignee: Contact concept type in okf.py with
  frontmatter fields (feishu_open_id, feishu_mobile, feishu_email,
  staffdeck_user_id). resolve_contact_assignee searches all concept
  types to find the right human staffer.

- Feishu notification: notify_handoff_assignee sends a handoff notice
  to the assignee's feishu open_id (resolved via ChannelIdentity or
  batch_get_id API fallback). notify_message_id stored for reply
  matching.

- /回复反馈 command: parse_command recognizes /回复反馈 and
  /handoff_reply prefixes. _run_handoff_reply_command matches sender
  open_id to pending handoff via ChannelIdentity or contact_target
  fallback, calls _apply_handoff_reply, and stages confirmation
  delivery. Intercepted before AgentLoop to avoid triggering the
  digital employee.

- parent_id fallback: _try_handle_feishu_handoff_reply remains as a
  fallback for direct message replies to the notification.

- Delivery fix: _immutable_delivery_target now falls back to
  chat_session.channel_target_json when client_turn_id is missing or
  no matching ChannelInboundEvent is found, fixing the
  delivery_target_missing error on handoff resume replies.

- Handoff reply injection: _inject_handoff_context injects
  handoff_info (with human_reply) on the first turn after resume,
  controlled by resume_finished_at marker. response_generator_prompt
  rule 26 requires the LLM to faithfully relay human_reply content.

- Tests: 32 regression tests in test_feishu_handoff.py covering
  Contact concepts, assignee resolution, open_id lookup, handoff
  notice delivery, /回复反馈 command parsing, handoff reply matching,
  and delivery target fallback. Fixed test_channel_intake.py,
  test_channel_team_binding.py (event_sink kwarg), and
  test_graph_rules.py (sub_sop_id field).
@fadeoreo fadeoreo self-assigned this Aug 18, 2026
@fadeoreo

Copy link
Copy Markdown
Collaborator

这个方向需要调整后再合并。目前实现把人工处理人建模为知识库 Contact,再通过问题文本检索联系人,这不适合作为正式的任务指派机制。知识内容变化可能改变处理人,也缺少稳定的权限、审计和配置入口。

建议调整为:

  1. 移除知识库 Contact 作为默认指派来源

    知识库可以提供联系人说明,但不应决定 handoff assignee。不要要求用户在 OKF frontmatter 中手工填写 staffdeck_user_idfeishu_open_id 等身份字段。

  2. 在 SOP 人工节点配置处理人

    人工节点增加可选的处理目标,例如:

    • assignee_user_id
    • 后续需要多人时再支持 assignee_group_id

    第一版可以明确只支持单人,不要以隐式方式模拟多人处理。

  3. 渠道配置默认人工处理人

    当 SOP 节点未指定处理人时,使用当前渠道绑定配置的默认处理人。推荐优先级:

    SOP 节点指定处理人
    → 当前渠道默认处理人
    → 数字员工负责人
    → 租户管理员
    
  4. 补齐前端配置

    • SOP 人工节点增加 StaffDeck 用户选择器。
    • 飞书渠道配置增加“默认人工处理人”选择器。
    • 展示该用户在当前飞书 binding 下的身份映射状态。
    • 提供通知测试或连通性验证。
    • 展示 handoff 通知成功、失败及 fallback 状态。
  5. 正确处理飞书身份映射

    先确定 StaffDeck 处理人,再使用当前会话所属的 Feishu binding 和 external_account_scope 查找对应 open_id。不要从 agent 挂载的渠道里取第一个 active binding。

  6. 修复现有流程问题

    • 飞书直接回复必须严格验证发送者就是实际通知目标。
    • 人工回复应在恢复 turn 执行前注入,当前 resume_finished_at 的时序会导致内容延迟到下一次用户消息。
    • 手机号/邮箱反查需要通讯录权限,但当前飞书配置页提示可以删除通讯录权限,需要同步修改。
    • Contact 只有飞书身份、没有 staffdeck_user_id 时,目前网页 assignee 和飞书接收者可能是两个人,需要统一。
  7. 补充测试

    至少覆盖 SOP 指定处理人、渠道默认处理人、fallback、跨 binding/scope 隔离、未授权回复、通知失败,以及恢复 turn 原文注入。

当前数据模型只有一个 assignee_user_id,所以实际只支持单人。若要支持多人,需要先明确“首人抢占、主处理人加抄送、还是处理组队列”,不建议直接把多个 ID 塞进现有字段。

@hm1229
hm1229 requested a review from fadeoreo August 18, 2026 06:17
…el binding priority chain

- Remove Contact concept from okf.py (CONCEPT_TYPES, frontmatter keys,
  extract/search functions)
- human_handoff_service.py: remove resolve_contact_assignee, add
  step_assignee_user_id + binding_default_assignee_user_id params
- graph_rules.py: node_as_step passes assignee_user_id from handoff node
- agent_loop.py: _create_human_handoff_request reads assignee from
  SOP node + channel binding; _maybe_notify_handoff_assignee_on_feishu
  uses session binding for ChannelIdentity lookup
- service_outbox.py: _resolve_assignee_feishu_open_id queries
  ChannelIdentity with binding scope only; notify_handoff_assignee
  drops contact_target; _build_handoff_problem_description reads User
- service_intake.py: _try_handle_feishu_handoff_reply strictly
  validates receive_id == from_user_id; _run_handoff_reply_command
  uses ChannelIdentity only; both use source='feishu' kind='handoff_ack'
- chat.py: _apply_handoff_reply takes explicit source param; resume
  worker no longer writes resume_finished_at
- harness_v2_engine.py: _inject_handoff_context uses
  request.channel == 'human_handoff_resume' for resume detection;
  remove contact_target/assignee_name/assignee_role from handoff_info
- harness_agent.py: fix handoff_to_human -> handoff_human
- turn_planner.py: when plan.decision == 'handoff_human' and active
  SOP has a handoff node, route to it (SOP frame) instead of creating
  a conversation task
- turn_finalizer.py: when step_result.handoff=True but current step
  doesn't declare handoff, route to SOP handoff node before creating
  handoff request
- response_generator_prompt.md: rules 25-26 updated to remove
  assignee_name/assignee_role references
- Tests: 33 feishu handoff tests rewritten; 2 new turn_planner routing
  tests; 1 new turn_finalizer routing test; full suite 1674 passed
@skywclouds

skywclouds commented Aug 18, 2026

Copy link
Copy Markdown
Contributor Author

感谢详细的 review 意见,已按照全部 7 点要求完成重构,代码已推送到 main 分支(commit 94ba891)。以下是逐项回应:

1. 移除知识库 Contact 作为默认指派来源 ✅

  • okf.py 移除了 Contact 概念类型(CONCEPT_TYPESCONTACT_FRONTMATTER_KEYSextract_contact_target_parse_contact_from_text_MOBILE_RE 及 Contact 在 search_concepts 中的加权逻辑全部删除)
  • human_handoff_service.py 移除了 resolve_contact_assignee,不再从知识库检索联系人
  • 不再要求用户在 OKF frontmatter 中填写 staffdeck_user_idfeishu_open_id 等身份字段

2. SOP 人工节点配置处理人 ✅

  • graph_rules.pynode_as_step 新增 assignee_user_id 字段,从 handoff 节点透传
  • human_handoff_service.pycreate() 新增 step_assignee_user_id 参数,直接从 SOP 节点读取
  • 当前只支持单人(assignee_user_id),不隐式模拟多人

3. 渠道配置默认人工处理人 ✅

  • agent_loop.py 新增 _binding_default_handoff_assignee 方法,从 ChatSession.channel_binding_idChannelBinding.config_json 读取 default_handoff_assignee_user_id
  • 优先级链:SOP 节点 assignee_user_id → 渠道绑定 default_handoff_assignee_user_id → 数字员工负责人 → 租户管理员

4. 前端配置

前端配置 UI(SOP 节点处理人选择器、飞书渠道默认处理人选择器、身份映射状态展示、通知测试)将在后续 PR 单独补充,避免本 PR 范围过大。

5. 正确处理飞书身份映射 ✅

  • _maybe_notify_handoff_assignee_on_feishu 使用会话自身的 channel_binding_id(而非 agent 挂载列表取第一个)
  • _resolve_assignee_feishu_open_id 用 binding 的 external_account_scope 查询 ChannelIdentity,不再从 agent 挂载的渠道取
  • 移除了手机号/邮箱反查(飞书配置页的通讯录权限提示保留,但代码不再依赖该路径)

6. 修复现有流程问题 ✅

  • 飞书直接回复验证:_try_handle_feishu_handoff_reply 严格校验 ChannelDelivery.target_json.receive_id == inbound.from_user_id,不再信任 contact_target.feishu_open_id
  • resume turn 注入时序:移除了 resume_finished_at 标记机制,改用 request.channel == "human_handoff_resume" 判断是否为 resume 后首轮 turn,确保人工回复原文在当轮注入
  • Contact 只有飞书身份时统一:不再有"网页 assignee 和飞书接收者是两个人"的问题,因为 assignee 统一从 StaffDeck User 解析,再通过 User 的 ChannelIdentity 映射到飞书 open_id

7. 补充测试 ✅

test_feishu_handoff.py 完全重写为 33 个测试,覆盖:

  • SOP 节点指定处理人优先级链
  • Contact 概念移除验证
  • open_id 解析与 binding scope 隔离
  • handoff_notice 投递
  • 严格回复验证(receive_id 不匹配时拒绝)
  • /回复反馈 指令解析
  • _apply_handoff_reply source 参数

新增测试:

  • test_turn_planner_routes_handoff_human_to_sop_handoff_node:router 决定 handoff_human 时路由到 SOP handoff 节点
  • test_turn_planner_handoff_human_falls_back_to_conversation_without_handoff_node:SOP 无 handoff 节点时回退到 conversation frame
  • test_step_result_handoff_routes_to_handoff_node_when_current_step_disallows:step_result.handoff=True 但当前 step 不声明 handoff 时,路由到 SOP handoff 节点

额外修复:router handoff_human 路由 bug

在验证过程中发现一个运行时 bug:当 LLM 返回 handoff_human decision 但未绑定 SOP frame 时,turn_planner 会创建 conversation task(target_skill_id=null, target_step_id=null),导致 turn_finalizer 检查 current_step_allows_handoff 时命中旧 step,handoff 被静默忽略。

修复方案:

  1. turn_planner._normalize:当 plan.decision == "handoff_human" 且 active SOP 有 handoff 节点时,创建 SOP frame 指向该节点
  2. turn_finalizer.finalize:当 step_result.handoff=True 但当前 step 不声明 handoff 时,先路由到 SOP handoff 节点再创建 handoff 请求

端到端验证

已在生产环境验证飞书 p2p 会话完整链路:用户发送"我的电脑开不开机了" → SOP 收集故障信息 → LLM 决定转人工 → 路由到 handoff_to_repair_specialist 节点 → 创建 handoff(assignee 为 SOP 节点配置的处理人)→ 飞书通知投递到该处理人的飞书 open_id(status=delivered)。

全量测试 1674 passed,0 failed(排除 test_llm_client.py 预存失败)。

@fadeoreo

Copy link
Copy Markdown
Collaborator

当前 PR 还没有形成可用闭环,建议先 Request changes:
前端配置不建议全部拆到后续 PR。SOP 节点处理人选择器和飞书渠道默认处理人选择器是核心功能入口,不是附加 UI。缺少它们,前两级分配策略实际不可用,只会落到负责人或管理员兜底。身份状态展示、通知测试可以后续增强。
后端配置契约也未完成。SkillGraphNode 没有 assignee_user_id,正常保存 SOP 时字段会被丢弃;渠道读写 DTO/API 也不支持 default_handoff_assignee_user_id。需要补齐 Schema、读写接口、租户及用户有效性校验,并增加保存、重新加载、发布后的测试。
/回复反馈 不能直接选择该处理人最新的 pending 请求。一个处理人有多个待办时会回复错单。引用通知时应按 parent_id -> notify_message_id 精确匹配;未引用通知时应携带 handoff ID,存在多个待办则拒绝模糊处理。
SOP 存在多个人工节点时,不能按 nodes 数组顺序选择第一个节点。应该根据当前节点、边和条件选择可达的人工节点,或者由 handoff 结果明确指定目标 node_id。
/回复反馈 当前会分别创建 handoff_ack 和通用 command notice,可能向处理人发送两次确认,需要合并并补完整 process_inbound() 测试。

[P1] SOP 人工节点的负责人无法保存,配置实际不会生效
运行时读取 node["assignee_user_id"]:agent_loop.py (line 729)
但节点 Schema 没有这个字段:skill_schema.py (line 41)。SOP 保存时经过 Pydantic 后该字段会被直接丢弃;我实际验证过,传入 assignee_user_id 后 model_dump() 中不存在该字段。
前端节点编辑器也没有处理人选择器:DistillPage.tsx (line 4898),节点归一化时同样没有保留该字段:DistillPage.tsx (line 7251)
修改建议:把 assignee_user_id 纳入正式 SOP Schema,在人工节点 Inspector 中提供租户用户选择器,并补充保存、重新加载、发布后的回归测试。
[P1] 渠道默认处理人没有 API 和前端配置入口
运行时从隐藏字段 config_json.default_handoff_assignee_user_id 读取:agent_loop.py (line 761)
但渠道更新 DTO 只支持 agents、auto_route:schema.py (line 30),读取 DTO 也不返回默认处理人:schema.py (line 37),更新接口自然无法配置:channels.py (line 502)
修改建议:在渠道配置页增加“默认人工处理人”,完善读写 DTO、权限校验、用户存在性及租户校验。未配置时再回退数字员工负责人/管理员。
[P1] /回复反馈 可能回复到错误的人工请求
通知要求处理人使用 /回复反馈:service_outbox.py (line 964)
但命令处理早于 parent_id 精确关联:service_intake.py (line 1014),命令实现忽略被回复的通知,只选择该处理人最新的 pending 请求:service_intake.py (line 842)
当同一个人同时有多个待处理请求时,回复旧通知也会回答最新请求,而且查询没有限制来源 binding。
修改建议:回复通知时优先按 parent_id -> notify_message_id 精确匹配;非引用形式的命令应要求携带 handoff ID,或者在多个 pending 时拒绝并提示选择。
[P1] 多个人工节点时会无条件选择文档中的第一个节点
Planner 和 AgentLoop 都是遍历 nodes 后返回第一个人工节点:turn_planner.py (line 425)agent_loop.py (line 95)
因此一个 SOP 只要存在多个分支人工节点,就可能绕过当前节点的边和条件,转给错误的人。
修改建议:基于当前节点的可达 transition 和条件确定目标人工节点,或者让 handoff 结果明确携带目标 node_id,不要依赖数组顺序。
[P2] /回复反馈 会登记两条确认消息
命令函数自身创建一次 handoff_ack:service_intake.py (line 864),返回后通用命令路径又调用 _stage_notice():service_intake.py (line 1025)
处理人可能连续收到两条“已收到回复”。现有测试直接调用内部命令函数,没有覆盖完整 process_inbound() 路径。
修改建议:确认消息只保留一处,并增加完整入站流程测试,断言只生成一条 delivery。

…ing config, precise reply matching, reachable handoff node, dedup notice

- Add assignee_user_id to SkillGraphNode schema (round-trip preserved)
- Add default_handoff_assignee_user_id to channel binding API + frontend selector
- Rewrite /回复反馈 to match by parent_id→notify_message_id; reject multiple pending
- GraphRules.find_handoff_node_id: BFS from active node → start node → array fallback
- Fix duplicate confirmation message via _HANDOFF_REPLY_HANDLED sentinel in process_inbound
- Frontend: tenant user fetch + assignee selectors in DistillPage and ChannelsPage
- Tests: 1705 backend passed; 3 new channel API tests; 4 new graph_rules tests;
  new turn_planner reachable handoff test; feishu handoff reply matching tests
@skywclouds

Copy link
Copy Markdown
Contributor Author

感谢第二轮 review,已逐条修复并推送 (f85ff87)。


1. SOP 节点缺少 assignee_user_id 字段

已在 SkillGraphNode schema 中新增 assignee_user_id: Optional[str] 字段 (skill_schema.py),并在 GraphRules.node_as_step 中透传。前端 DistillPage 对人工转接节点展示 assignee 选择器,normalizeNodePreview 和 distill extract 函数均保留该字段。新增 SkillGraphNode / SkillCard round-trip 测试验证序列化不丢失。

2. 渠道默认人工处理人缺少 API / 前端配置入口

  • 后端: ChannelBindingAgentsUpdate 新增 default_handoff_assignee_user_id 字段,使用 "unchanged" 哨兵值区分「未传」和「显式清空」(None)。update_channel_binding_agents API 校验 user_id 有效性后写入 config_jsonChannelBindingRead 返回 default_handoff_assignee_user_iddefault_handoff_assignee_name
  • 前端: ChannelsPage 新增 tenant users fetch + Select 组件,支持设置和清空(__none__ 哨兵)。
  • 测试: 新增 3 个 channel API 测试 — 设置+清空、拒绝未知 user、不传时保持不变。

3. /回复反馈 可能回复到错误的人工转接

重写 _run_handoff_reply_command:

  • 回复消息(有 parent_id): 通过 parent_idnotify_message_id 精确匹配对应的 handoff request,并校验当前用户是该 request 的 assignee。
  • 非回复消息: 仅当存在唯一 pending handoff 时才匹配;多个 pending 时拒绝并提示用户用回复方式指定。
  • 测试: 新增 test_run_handoff_reply_command_matches_by_parent_idtest_run_handoff_reply_command_rejects_multiple_pending

4. 多人工转接节点选取仅取数组首个

新增 GraphRules.find_handoff_node_id(content, active_step_id):

  1. 从当前 active_step_id 开始 BFS,找到第一个可达的 handoff 节点;
  2. 若未找到,从 start_node 开始 BFS;
  3. 最后才 fallback 到数组顺序。
    turn_planner._find_handoff_node_idagent_loop._find_handoff_node_id_in_skill 均改为委托该方法,并传入 active_step_id。新增 4 个 find_handoff_node_id 测试 + test_turn_planner_handoff_human_picks_reachable_handoff_node

5. /回复反馈 产生重复确认消息

引入 _HANDOFF_REPLY_HANDLED sentinel:当 _run_handoff_reply_command 成功处理回复后返回该 sentinel,process_inbound 检测到后跳过后续 _stage_notice,避免重复投递确认消息。新增 test_run_handoff_reply_command_matches_by_identity 中的 sentinel 断言验证此路径。


测试结果: backend 1705 passed (excluding test_llm_client.py); frontend build 通过(TypeScript 无错误)。

Wang He added 2 commits August 18, 2026 17:06
…or in source view

- feishu adapter: add get_user_name() via GET /contact/v3/users/{open_id}
- service_identity: resolve_or_provision_user accepts name_resolver callback,
  updates User.display_name and ChannelIdentity.display_name when placeholder
- service_intake: _build_name_resolver wires feishu name resolution into intake
- auth API: UserRead includes channel_identities; list_users?include_channel=true
- DistillPage: SkillSource (source view) now shows 处理人 selector for handoff
  nodes, matching the flow inspector behavior
- ChannelsPage/DistillPage: user dropdowns show 真实姓名 (飞书) format
notify_handoff_assignee now resolves the inquirer's display name via
ChannelIdentity (binding scope) → User fallback, and prepends a
提问人: line to the problem description sent to the assignee.
@fadeoreo

Copy link
Copy Markdown
Collaborator
  1. [P1] 普通创建者的处理人选择器仍然为空

前端继续请求 /api/auth/usersChannelsPage.tsxDistillPage.tsx

但接口仍调用 _require_admin()auth.py。普通 SOP 所有者和渠道创建者有编辑权限,却拿不到候选用户,403 后前端静默显示空列表。上轮问题没有修复。

  1. [P1] 处理人候选混入了客户账号和群聊虚拟账号

include_channel=true 会取消 source == "web" 过滤,返回所有渠道懒建用户:auth.py

群聊也会生成 external_user_id="group:..." 的虚拟用户:service_identity.py。因此任何联系过机器人的客户、微信群聊/飞书群聊都可能出现在“处理人”选择器中。

一旦选中群聊虚拟用户,通知逻辑还会把 group:... 当作飞书 open_idservice_outbox.py,投递必然错误。

处理人候选应是明确的内部 StaffDeck 成员,而不是全部渠道会话身份。

  1. [P1] 身份状态仍不能证明当前飞书渠道可达

后端用字典按 staffdeck_user_id 保存身份,一个用户有多条渠道或多个 scope 时只会随机保留一条:auth.py。DTO 甚至没有 external_account_scope

前端又只展示 channel_identities[0]ChannelsPage.tsx。用户可能显示“已绑定飞书”,但绑定的是另一个飞书应用/scope,当前渠道仍无法通知。

建议渠道处理人候选接口接收 binding_id,直接返回该用户在当前 binding scope 下的 notify_reachable 状态。

  1. [P1] SOP 节点处理人仍未校验

assignee_user_id 仍可写入任意字符串,没有验证用户存在、租户归属和是否为内部用户。非法 ID 会生成无人可见、无人可通知的 handoff。

需要在 SOP 创建、更新、发布时校验,并在运行时对已删除用户回退渠道默认处理人。

  1. [P1] 条件分支路由问题仍未修改

最新提交没有改动 GraphRules.find_handoff_node_id()。它仍对所有边做 BFS,不判断边条件。当两个条件分支分别连接不同人工节点时,依旧按 priority 选择第一个,而不是实际命中的分支。

  1. [P2] SOP 处理人依然无法清空

两个 SOP 视图都只传入用户选项,没有“未指定/使用渠道默认”选项:DistillPage.tsx。一旦选人,UI 无法恢复到渠道默认负责人。

  1. [P2] 飞书姓名查询可能在每条消息上同步重试

占位名称存在时,每次正常入站都会同步请求飞书 Contact API:service_identity.py。如果应用没有通讯录权限,查询永远返回 None,下一条消息继续请求,增加消息延迟并消耗 API 配额。

…dge-aware BFS, negative name cache

- auth.py: include_channel=true returns all users with channel identities
  (including external_account_scope); frontend filters source='web' for
  assignee selectors; non-admin users can list via ensure_current_user_tenant
- skills.py: validate assignee_user_id on SOP create/update — must exist,
  belong to same tenant, and be source='web' (internal member)
- graph_rules.py: find_handoff_node_id two-phase BFS — first follows only
  unconditional edges (condition empty/default/else), then falls back to
  all edges; prevents picking handoff node on unmatched conditional branch
- service_intake.py: negative name resolution cache (5min TTL) for feishu
  get_user_name — avoids sync API call on every message when contact
  permission is missing or name unavailable
- DistillPage/ChannelsPage: '未指定(使用渠道默认)' option in assignee
  selectors; scope-aware reachability badge in ChannelsPage
- 2 new graph_rules tests for conditional/unconditional BFS
@skywclouds

Copy link
Copy Markdown
Contributor Author

第三轮 Review 修复

1. [P1] 普通创建者的处理人选择器为空

list_users 改用 ensure_current_user_tenant 替代 _require_admin,有租户访问权限的普通成员(非 admin)也能调用。/api/auth/users 不再返回 403。

2. [P1] 处理人候选混入客户/群聊虚拟账号

include_channel=true 仍返回全部用户(向后兼容),但前端 ChannelsPage 和 DistillPage 的处理人选择器均过滤 source === "web",排除渠道懒建客户和群聊虚拟账号。include_channel=false(默认)也只返回 source="web" 用户。

3. [P1] 身份状态不能证明当前飞书渠道可达

  • 后端: UserChannelIdentity DTO 新增 external_account_scope 字段;list_users?include_channel=true 返回用户全部渠道身份(不再只保留一条),每条都带 scope。
  • 前端: ChannelsPage 处理人选择器按当前 binding 的 identity_scope_key 过滤 channel_identities,匹配时显示 (飞书 可达) 标签,未匹配时显示渠道名但不标"可达"。
  • ChannelBindingRead 新增 identity_scope_key,前端可直接使用。

4. [P1] SOP 节点处理人未校验

新增 _validate_handoff_assignees(db, content, tenant_id),在 create_skillupdate_skill 中调用:

  • 检查 assignee_user_id 对应的 User 存在且属于当前租户;
  • 检查 source == "web"(内部成员),拒绝渠道客户/群聊虚拟账号;
  • 不满足时返回 400 + 明确错误信息。

5. [P1] 条件分支路由问题

GraphRules.find_handoff_node_id 改为两阶段 BFS:

  1. 第一阶段:仅沿无条件边(condition 为空/default/else)搜索——避免误入未命中的条件分支;
  2. 第二阶段:若未命中,沿所有边(含条件边)搜索,按 priority 顺序遍历;
  3. 回退:数组中第一个 handoff 节点。

新增 2 个测试:test_find_handoff_node_id_prefers_unconditional_path_over_conditional(无条件边优先于高优先级条件边)和 test_find_handoff_node_id_falls_back_to_conditional_edges(无条件路径不可达时回退到条件边)。

6. [P2] SOP 处理人无法清空

DistillPage 两个视图(source view + flow inspector)的 tenantUserOptions 均新增 { value: "", label: "未指定(使用渠道默认)" } 选项。选中后 assignee_user_id 设为空字符串,运行时回退到渠道默认处理人。

7. [P2] 飞书姓名查询同步重试

_build_name_resolver 新增负缓存:当 get_user_name 返回 None 或抛异常时,将该 open_id 缓存 5 分钟(_NEGATIVE_NAME_CACHE_TTL=300),期间不再同步请求飞书 Contact API。成功获取姓名时自动清除缓存。


测试: backend 1707 passed (excluding test_llm_client.py); frontend build 通过(TypeScript 无错误)。

@fadeoreo
fadeoreo merged commit 777e92d into OpenBMB:main Aug 18, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants