修复: 飞书审批决议终态闭环与决议词容错

- F5-1:决议词归一(trim/剥尾标点/同义:批准|同意|拒绝|不同意)
- F5-2:审批终态卡原地更新(桌面决议/超时拒后不再恒挂可点);
  迟到决议30min窗内提示已处理,不再注入运行中任务
- APPROVAL_CARDS/RESOLVED_CHATS内存句柄,重启后走幂等回执无损失
This commit is contained in:
lxy
2026-10-18 23:15:51 +08:00
parent 05d0f89c16
commit 302c4bcf52
+247 -62
View File
@@ -63,6 +63,34 @@ fn cancel_watchers(
CANCEL_WATCHERS.get_or_init(|| tokio::sync::Mutex::new(HashMap::new()))
}
/// F5-2 终态窗口:审批前端默认 15min 超时自动拒绝,30min 窗覆盖「终态后用户补发决议词」
/// 的迟到判定;窗口外的决议词视为普通聊天文本(无审批语境,现状语义)
const RESOLVED_WINDOW_SECS: i64 = 1800;
/// F5-2 审批终态闭环:已推飞书侧的审批按钮卡句柄(tc_id → (卡片 message_id, 发卡时刻))。
/// 桌面决议/超时自动拒绝后原卡原地更新为终态,不再恒挂可点。内存态,重启即失——重启后
/// 陈旧卡点击走 ai_approve 幂等「已处理」回执,无功能损失。写入时顺带清过期条目防无界增长
static APPROVAL_CARDS: std::sync::OnceLock<std::sync::Mutex<HashMap<String, (String, i64)>>> =
std::sync::OnceLock::new();
/// F5-2 迟到决议判定:chat → 最近一次审批终态(时刻, 是否批准)。审批已终态但用户仍在
/// 飞书回决议词时,据此前 30min 记录提示「已处理」而非把决议文本注入运行中任务
static RESOLVED_CHATS: std::sync::OnceLock<std::sync::Mutex<HashMap<String, (i64, bool)>>> =
std::sync::OnceLock::new();
fn approval_cards() -> &'static std::sync::Mutex<HashMap<String, (String, i64)>> {
APPROVAL_CARDS.get_or_init(|| std::sync::Mutex::new(HashMap::new()))
}
fn resolved_chats() -> &'static std::sync::Mutex<HashMap<String, (i64, bool)>> {
RESOLVED_CHATS.get_or_init(|| std::sync::Mutex::new(HashMap::new()))
}
/// 终态新鲜度判定(纯函数,迟到决议提示的窗口判据)
fn within_resolved_window(at_secs: i64, now: i64) -> bool {
now - at_secs < RESOLVED_WINDOW_SECS
}
#[derive(Debug, Clone, Deserialize)]
struct FeishuConfig {
#[serde(default)]
@@ -406,6 +434,27 @@ fn parse_command(text: &str) -> Option<(String, String)> {
Some((cmd, tail))
}
/// 审批决议词识别(F5-1):集合对齐按钮卡文案与常见同义词——批准 {同意|批准|通过|approve},
/// 拒绝 {拒绝|驳回|不同意|reject}。容错链:半全角归一 → 前后空白 → 剥尾部标点(「批准。」
/// 「同意!」)→ 小写(英文词)。整词精确匹配非包含(「不同意」含「同意」,靠整词比对防反判),
/// 普通文本(「我同意这个方案」)不命中返回 None,由调用方落常规消息路径
fn parse_approval_verdict(text: &str) -> Option<bool> {
const APPROVE_WORDS: [&str; 4] = ["同意", "批准", "通过", "approve"];
const REJECT_WORDS: [&str; 4] = ["拒绝", "驳回", "不同意", "reject"];
let t = normalize_halfwidth(text);
let t = t
.trim()
.trim_end_matches(['。', '.', ',', ',', '、', '!', '!', '?', '?', '~', '~', ';', ';', ' ', '\t'])
.to_lowercase();
if APPROVE_WORDS.contains(&t.as_str()) {
Some(true)
} else if REJECT_WORDS.contains(&t.as_str()) {
Some(false)
} else {
None
}
}
/// 图片消息喂模型的任务语境(#958):飞书 image 为独立消息类型无法带文字,
/// 用引导词让 vision 模型知道「这是用户发来的图,按图作答」
const IMAGE_INBOUND_PROMPT: &str = "(用户通过飞书发来一张图片,请查看图片并按图片内容处理)";
@@ -921,10 +970,13 @@ async fn handle_message(
}
}
// 24 审批决议通路:该会话有挂起审批时,「批准/拒绝」直调审批原语不入对话链路
// (决议文本若落常规路径会被注入运行中任务,审批永远无人拍板)。决议取该会话最新
// 一条挂起;无挂起不拦截,文本照常落常规路径,零行为变更
if text == "批准" || text == "拒绝" {
// 24 审批决议通路(F5-1 决议词容错):该会话有挂起审批时,决议词(同意/批准/通过 =
// 批准;拒绝/驳回/不同意 = 拒绝,含空白/尾标点/大小写容错,集合对齐按钮卡文案)直调
// 审批原语不入对话链路(决议文本若落常规路径会被注入运行中任务,审批永远无人拍板)。
// 决议取该会话最新一条挂起;无挂起不拦截,文本照常落常规路径,零行为变更——唯一
// 例外(F5-2 迟到决议):该 chat 近期刚有审批终态(桌面已决议/超时自动取消),决议词
// 命中却无可拍板对象,回「已处理」提示而非当补充指令注入任务流
if let Some(approved) = parse_approval_verdict(&text) {
let state = ctx.app.state::<crate::state::AppState>();
let conv_id = ctx.chat_map.lock().await.get(&chat_id).cloned();
let pending = match conv_id {
@@ -932,7 +984,6 @@ async fn handle_message(
None => None,
};
if let Some(tc_id) = pending {
let approved = text == "批准";
feishu_log(&format!("审批决议 chat={chat_id} tc={tc_id} approved={approved}"));
// 2026-10-08 决议后追加执行结果:先订阅(工具执行前,防 publish_event 无订阅者跳过),
// 再调 ai_approve,后 spawn watcher 消费 AiToolCallCompleted 拿结果摘要
@@ -958,7 +1009,7 @@ async fn handle_message(
None => 0,
};
let reply = if remaining > 0 {
format!("{reply}\n\n⚠️ 本会话还有 {remaining} 条待审批,请继续回复「批准」或「拒绝」。")
format!("{reply}\n\n⚠️ 本会话还有 {remaining} 条待审批,请继续回复「同意 / 批准」或「拒绝」。")
} else {
reply
};
@@ -969,6 +1020,29 @@ async fn handle_message(
}
return;
}
// F5-2 迟到决议:无挂起但该 chat 近期有审批终态——如实提示,不落注入旁路
let recently_resolved = {
let now = now_secs();
resolved_chats()
.lock()
.map(|m| {
m.get(&chat_id)
.is_some_and(|(at, _)| within_resolved_window(*at, now))
})
.unwrap_or(false)
};
if recently_resolved {
feishu_log(&format!("迟到决议提示 chat={chat_id} text={}", truncate(&text, 40)));
reply_message(
&ctx.client,
&message_id,
&chat_id,
"ℹ️ 该审批已处理(已批准/已拒绝或超时自动取消),无需再回复;可发 /status 查看任务状态。",
)
.await;
return;
}
// 近期无审批终态:决议词属普通聊天文本,落常规路径(现状语义保留)
}
// ── 纠偏注入旁路(todo.md 16,2026-10-06):会话运行中 → 直接注入运行中 conv,不排队 ──
@@ -1458,48 +1532,77 @@ async fn run_approval_notifier(ctx: Arc<Ctx>) {
let mut rx = state.ai_event_bus.subscribe();
loop {
match rx.recv().await {
Ok(v) => {
if v.get("type").and_then(|t| t.as_str()) != Some("AiApprovalRequired") {
continue;
}
let Some(cid) = v.get("conversation_id").and_then(|c| c.as_str()) else {
continue;
};
let chat_id = ctx
.chat_map
.lock()
.await
.iter()
.find(|(_, c)| c.as_str() == cid)
.map(|(k, _)| k.clone());
let Some(chat_id) = chat_id else {
continue;
};
let name = v.get("name").and_then(|n| n.as_str()).unwrap_or("工具");
let reason = v
.get("reason")
.and_then(|r| r.as_str())
.unwrap_or("")
.replace('\n', " ");
let brief = truncate(&reason, 80);
// 审批卡按钮化(2026-10-08):改用带「同意/拒绝」按钮的交互卡片,
// 按钮 value 携带 {approval_id, approved},回调经 on_card_action →
// handle_card_action → ai_approve 原语(与文本决议通路同原语,双通道并存)。
// 文本「批准/拒绝」通路保留作降级(旧卡片/按钮回调异常时仍可用)。
let tc_id = v.get("id").and_then(|i| i.as_str()).unwrap_or("");
if tc_id.is_empty() {
// 无 tool_call_id 无法构造按钮载荷,降级文本卡(回复「批准/拒绝」)
let card = if brief.is_empty() {
format!("⏸ 需审批: `{name}`。回复「批准」或「拒绝」继续")
} else {
format!("⏸ 需审批: `{name}` {brief}。回复「批准」或「拒绝」继续")
Ok(v) => match v.get("type").and_then(|t| t.as_str()) {
// F5-2 审批终态闭环:批/拒/超时(前端超时亦调 ai_approve(false))全路径 emit
// AiApprovalResult——订阅同 bus 复用 spawn_approval_result_watcher 先例,零后端
// 改动。飞书侧原按钮卡原地更新为终态(不再恒挂可点),并记 chat 维度终态
// (迟到决议提示判据)。事件无决议来源字段,拒绝文案注明含超时自动取消;
// 飞书侧自身决议同经此收口(与决议回执双保险,防旧卡再点)
Some("AiApprovalResult") => {
let Some(cid) = v.get("conversation_id").and_then(|c| c.as_str()) else {
continue;
};
send_markdown_card(&ctx.client, "", &chat_id, &card).await;
} else {
send_approval_button_card(&ctx.client, &chat_id, tc_id, name, &brief).await;
let tc_id = v.get("id").and_then(|i| i.as_str()).unwrap_or("");
let approved = v
.get("approved")
.and_then(|b| b.as_bool())
.unwrap_or(false);
let Some(chat_id) = chat_of_conv(&ctx, cid).await else {
continue;
};
note_approval_resolved(&chat_id, approved);
let mid = approval_cards()
.lock()
.ok()
.and_then(|mut cards| cards.remove(tc_id).map(|(mid, _)| mid))
.filter(|m| !m.is_empty());
// 无卡句柄(降级文本卡/他路无原卡):只记终态,迟到提示仍生效
if let Some(mid) = mid {
let text = if approved {
"✅ 该审批已批准,任务继续执行(按钮已失效)。"
} else {
"🚫 该审批已结束:已拒绝或超时自动取消(按钮已失效)。"
};
let updated = update_card(&ctx.client, &mid, text).await;
feishu_log(&format!(
"审批终态收口 chat={chat_id} tc={tc_id} approved={approved} 更新={updated}"
));
}
}
feishu_log(&format!("审批挂起推送 chat={chat_id} conv={cid} tool={name}"));
}
Some("AiApprovalRequired") => {
let Some(cid) = v.get("conversation_id").and_then(|c| c.as_str()) else {
continue;
};
let Some(chat_id) = chat_of_conv(&ctx, cid).await else {
continue;
};
let name = v.get("name").and_then(|n| n.as_str()).unwrap_or("工具");
let reason = v
.get("reason")
.and_then(|r| r.as_str())
.unwrap_or("")
.replace('\n', " ");
let brief = truncate(&reason, 80);
// 审批卡按钮化(2026-10-08):改用带「同意/拒绝」按钮的交互卡片,
// 按钮 value 携带 {approval_id, approved},回调经 on_card_action →
// handle_card_action → ai_approve 原语(与文本决议通路同原语,双通道并存)。
// 文本决议词通路保留作降级(旧卡片/按钮回调异常时仍可用)。
let tc_id = v.get("id").and_then(|i| i.as_str()).unwrap_or("");
if tc_id.is_empty() {
// 无 tool_call_id 无法构造按钮载荷,降级文本卡(回复决议词)
let card = if brief.is_empty() {
format!("⏸ 需审批: `{name}`。回复「同意 / 批准」或「拒绝」继续")
} else {
format!("⏸ 需审批: `{name}` {brief}。回复「同意 / 批准」或「拒绝」继续")
};
send_markdown_card(&ctx.client, "", &chat_id, &card).await;
} else {
send_approval_button_card(&ctx.client, &chat_id, tc_id, name, &brief).await;
}
feishu_log(&format!("审批挂起推送 chat={chat_id} conv={cid} tool={name}"));
}
_ => {}
},
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
Err(tokio::sync::broadcast::error::RecvError::Closed) => return,
}
@@ -1535,15 +1638,8 @@ async fn handle_card_action(
if chat_id.is_empty() {
let state = ctx.app.state::<crate::state::AppState>();
if let Some(cid) = approval_conversation_id(&state, &approval_id).await {
chat_id = ctx
.chat_map
.lock()
.await
.iter()
.find(|(_, c)| c.as_str() == cid)
.map(|(k, _)| k.clone())
.unwrap_or_default();
if !chat_id.is_empty() {
if let Some(found) = chat_of_conv(&ctx, &cid).await {
chat_id = found;
feishu_log(&format!("卡片回调 chat_id 缺失,反查补齐 chat={chat_id}"));
}
}
@@ -1605,9 +1701,14 @@ async fn handle_card_action(
}
}
/// F5-3 结果 watcher 超时话术:60s 未见完成事件时回告去向,不静默弃守让用户空等
const APPROVAL_RESULT_TIMEOUT_MSG: &str =
"⏳ 工具执行耗时较长(超 60s 仍无结果),已停止自动回推;完成后可在桌面端查看该工具的执行结果。";
/// 审批决议后执行结果 watcher(2026-10-08):订阅 ai_event_bus 的 AiToolCallCompleted
/// (匹配 tool_call_id),拿 result 摘要回推 chat。原决议后只回「已批准」,用户不知工具
/// 执行结果(成功/失败/输出)。设 60s 超时兜底(工具执行通常秒级,长工具超时静默放弃)。
/// 执行结果(成功/失败/输出)。设 60s 超时兜底(工具执行通常秒级,长工具超时回告去向
/// 后放弃,F5-3 修静默弃守)。
///
/// 订阅须在 ai_approve 调用前完成——publish_event 在无订阅者时跳过序列化,晚订阅必丢事件。
fn spawn_approval_result_watcher(
@@ -1622,6 +1723,7 @@ fn spawn_approval_result_watcher(
loop {
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
if remaining.is_zero() {
let _ = send_markdown_card(&ctx.client, "", &chat_id, APPROVAL_RESULT_TIMEOUT_MSG).await;
feishu_log(&format!("[approval-result] 等待工具结果超时 tc={tool_call_id}"));
return;
}
@@ -1647,6 +1749,7 @@ fn spawn_approval_result_watcher(
Ok(Err(tokio::sync::broadcast::error::RecvError::Lagged(_))) => continue,
Ok(Err(tokio::sync::broadcast::error::RecvError::Closed)) => return,
Err(_) => {
let _ = send_markdown_card(&ctx.client, "", &chat_id, APPROVAL_RESULT_TIMEOUT_MSG).await;
feishu_log(&format!("[approval-result] 等待工具结果超时 tc={tool_call_id}"));
return;
}
@@ -1665,10 +1768,12 @@ async fn send_approval_button_card(
tool_name: &str,
brief: &str,
) {
// F5-1 文案统一:副注提示文字决议词(按钮不可用时降级通道),词表与 parse_approval_verdict 同源
let hint = "点击按钮,或回复「同意 / 批准 / 通过 / 拒绝」";
let detail = if brief.is_empty() {
format!("⏸ 需审批: `{tool_name}`")
format!("⏸ 需审批: `{tool_name}`\n\n{hint}")
} else {
format!("⏸ 需审批: `{tool_name}`\n\n{brief}")
format!("⏸ 需审批: `{tool_name}`\n\n{brief}\n\n{hint}")
};
// 按钮 value 需为 SDK 的 JsonValue(其实现了 From<serde_json::Value>,直接 into 转换)
let to_json_value = |v: serde_json::Value| -> larksuite_oapi_sdk_rs::JsonValue { v.into() };
@@ -1699,7 +1804,7 @@ async fn send_approval_button_card(
Ok(d) => d,
Err(e) => {
tracing::warn!("[feishu] 审批按钮卡校验失败,降级文本卡: {e}");
let fallback = format!("⏸ 需审批: `{tool_name}` {brief}。回复「批准」或「拒绝」继续");
let fallback = format!("⏸ 需审批: `{tool_name}` {brief}。回复「同意 / 批准 / 通过 / 拒绝」继续");
send_markdown_card(client, "", chat_id, &fallback).await;
return;
}
@@ -1716,6 +1821,8 @@ async fn send_approval_button_card(
.await
{
Ok(sent) if !sent.message_id.is_empty() => {
// F5-2 登记卡句柄:桌面决议/超时终态事件到达时按 tc_id 原地更新该卡为终态
note_approval_card(approval_id, &sent.message_id);
feishu_log(&format!("审批按钮卡已发 chat={chat_id} card={}", sent.message_id));
}
Ok(_) => {
@@ -1723,12 +1830,38 @@ async fn send_approval_button_card(
}
Err(e) => {
tracing::error!("[feishu] 审批按钮卡发送失败,降级文本卡: {e}");
let fallback = format!("⏸ 需审批: `{tool_name}` {brief}。回复「批准」或「拒绝」继续");
let fallback = format!("⏸ 需审批: `{tool_name}` {brief}。回复「同意 / 批准 / 通过 / 拒绝」继续");
send_markdown_card(client, "", chat_id, &fallback).await;
}
}
}
/// 登记审批按钮卡句柄(终态原地更新用),顺带清窗口外陈旧条目
fn note_approval_card(tc_id: &str, message_id: &str) {
let now = now_secs();
let Ok(mut cards) = approval_cards().lock() else { return };
cards.retain(|_, (_, at)| within_resolved_window(*at, now));
cards.insert(tc_id.to_string(), (message_id.to_string(), now));
}
/// conv → 绑定飞书 chat 反查(notifier 推卡/终态收口共用;无绑定(纯桌面会话)None)
async fn chat_of_conv(ctx: &Ctx, conv_id: &str) -> Option<String> {
ctx.chat_map
.lock()
.await
.iter()
.find(|(_, c)| c.as_str() == conv_id)
.map(|(k, _)| k.clone())
}
/// 记录 chat 维度审批终态(迟到决议提示判据),顺带清窗口外陈旧条目
fn note_approval_resolved(chat_id: &str, approved: bool) {
let now = now_secs();
let Ok(mut chats) = resolved_chats().lock() else { return };
chats.retain(|_, (at, _)| within_resolved_window(*at, now));
chats.insert(chat_id.to_string(), (now, approved));
}
/// 该 conv 最新一条挂起审批的 tool_call_id(决议原语的消费目标);无挂起返回 None。
/// created_at 缺失视为最旧(宁取有时序的),同刻并列取遍历后者(HashMap 无序,可接受)
async fn latest_pending_approval(
@@ -2549,8 +2682,8 @@ mod tests {
ContentPart, DebounceEntry, delivery_blocked, extract_delivery_paths, feishu_file_type,
file_display_name, file_inbound_prompt, file_size_over_limit_error, heartbeat_due,
is_terminal_push, md_to_lark_friendly, merge_debounce_parts, merge_debounce_text,
msg_after_acceptance, parse_command, sniff_image_mime, strip_mention_placeholders,
stream_preview,
msg_after_acceptance, parse_approval_verdict, parse_command, sniff_image_mime,
strip_mention_placeholders, stream_preview, within_resolved_window,
};
#[test]
@@ -2888,4 +3021,56 @@ mod tests {
Some(vec![ContentPart::image_url("https://x/2.png")])
);
}
// ── F5-1 决议词识别(表驱动) ──
#[test]
fn verdict_table_approve_and_reject_with_tolerance() {
// 批准集含按钮卡文案「同意」;拒绝集含「不同意」;空白/尾标点/大小写/半全角容错
let table = [
// (输入, 期望)
("同意", Some(true)),
("批准", Some(true)),
("通过", Some(true)),
("approve", Some(true)),
(" 同意 ", Some(true)),
("批准。", Some(true)),
("同意!", Some(true)),
("通过~", Some(true)),
(" 拒绝 ", Some(false)), // 全角空格
("REJECT", Some(false)),
("Approve", Some(true)), // 全角 ASCII 变体
("拒绝", Some(false)),
("驳回", Some(false)),
("不同意", Some(false)),
("驳回。", Some(false)),
("不同意?", Some(false)),
];
for (input, expected) in table {
assert_eq!(parse_approval_verdict(input), expected, "case {input:?}");
}
}
#[test]
fn verdict_plain_text_not_misjudged() {
// 普通文本/带语气词/长句/子串形态不误判(整词精确匹配,「不同意」含「同意」靠整词防反判)
let plain = [
"你好", "我同意这个方案", "不同意这个做法", "批准吧", "通不过", "同意一下再看看",
"/status", "拒绝的理由如下", "通过率不高", "", "ok 同意吗",
];
for input in plain {
assert_eq!(parse_approval_verdict(input), None, "case {input:?}");
}
}
// ── F5-2 迟到决议窗口判定 ──
#[test]
fn resolved_window_boundary() {
// 30min 窗口内算「刚终态」(迟到决议提示),窗外视为无审批语境(普通消息语义)
assert!(within_resolved_window(1000, 1000 + 1799));
assert!(within_resolved_window(1000, 1000 + 1800 - 1));
assert!(!within_resolved_window(1000, 1000 + 1800));
assert!(!within_resolved_window(1000, 1000 + 7200));
}
}