修复: 飞书审批决议终态闭环与决议词容错
- F5-1:决议词归一(trim/剥尾标点/同义:批准|同意|拒绝|不同意) - F5-2:审批终态卡原地更新(桌面决议/超时拒后不再恒挂可点); 迟到决议30min窗内提示已处理,不再注入运行中任务 - APPROVAL_CARDS/RESOLVED_CHATS内存句柄,重启后走幂等回执无损失
This commit is contained in:
+247
-62
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user