新增: AI Chat多项增强(审批去重/编辑重发/导出/实体引用/会话置顶搜索)+任务推进链df-nodes落地
This commit is contained in:
@@ -6,6 +6,32 @@ use tokio::sync::broadcast;
|
||||
use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema};
|
||||
use df_core::events::{SelectType, WorkflowEvent};
|
||||
|
||||
/// F-260616-06 阶段2: 拒绝语义化关键字。
|
||||
/// decision 归一化(去空白 + 小写)后命中此集合 → 审批拒绝 → 节点返 Err(触发工作流 failed)。
|
||||
///
|
||||
/// 识别范围(避免误伤):
|
||||
/// - 仅当节点 options 非空且该 decision ∈ options 时拒绝 —— 前端从受控选项中选择,语义可信;
|
||||
/// - options 空(自由文本)时同样按关键字判定 —— 仅当文本明确为拒绝词才拒绝,
|
||||
/// 其余自由文本一律视为通过(向后兼容,不阻断自由反馈)。
|
||||
///
|
||||
/// 关键字集合与前端/IPC 的 options 命名约定对齐:「拒绝/驳回/退回」中文 +
|
||||
/// 「reject/decline/deny/no/block」英文,覆盖默认 options `["同意","拒绝"]`。
|
||||
const REJECT_KEYWORDS: &[&str] = &[
|
||||
"拒绝", "驳回", "退回", "否决",
|
||||
"reject", "decline", "declined", "deny", "denied", "no", "block",
|
||||
];
|
||||
|
||||
/// 判定单个 decision 是否为拒绝选项(归一化后命中 REJECT_KEYWORDS)。
|
||||
fn is_reject_decision(d: &str) -> bool {
|
||||
let normalized = d.trim().to_lowercase();
|
||||
REJECT_KEYWORDS.iter().any(|k| normalized == *k)
|
||||
}
|
||||
|
||||
/// 判定 picked 集合是否含拒绝选项:任一项命中即视为整单拒绝(多选场景「选了驳回」即驳回)。
|
||||
fn contains_reject(picked: &[String]) -> bool {
|
||||
picked.iter().any(|d| is_reject_decision(d))
|
||||
}
|
||||
|
||||
/// 人工审批节点(阻塞节点)
|
||||
pub struct HumanNode;
|
||||
|
||||
@@ -84,6 +110,26 @@ impl Node for HumanNode {
|
||||
let each_valid = picked.iter().all(|d| !d.is_empty())
|
||||
&& (options.is_empty() || picked.iter().all(|d| options.contains(d)));
|
||||
if count_ok && each_valid {
|
||||
// F-260616-06 阶段2: 拒绝语义化。
|
||||
// 审批拒绝此前与同意一样返 Ok —— 语义反转(审批被拒却报"成功"),
|
||||
// 下游无法据 failed 触发退回/重做。
|
||||
// 现:decision 命中拒绝关键字(见 REJECT_KEYWORDS)→ 返 Err
|
||||
// "人工审批被拒绝(用户选择: <decision>)",executor Err 分支 set_failed
|
||||
// → 工作流 failed 状态 → 阶段2 推进链可据 failed 触发退回。
|
||||
// 行为变更:审批拒绝从 Ok → Err,标注(同步通知主代理)。
|
||||
if contains_reject(&picked) {
|
||||
let primary = picked.first().cloned().unwrap_or_default();
|
||||
let comment_str = comment.unwrap_or_default();
|
||||
return Err(anyhow::anyhow!(
|
||||
"人工审批被拒绝(用户选择: {}){}",
|
||||
primary,
|
||||
if comment_str.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(";意见: {}", comment_str)
|
||||
}
|
||||
));
|
||||
}
|
||||
// 输出统一含 decisions 数组;保留 decision 取首项(向后兼容下游消费者)
|
||||
let primary = picked.first().cloned().unwrap_or_default();
|
||||
return Ok(NodeOutput::from_value(serde_json::json!({
|
||||
@@ -636,4 +682,169 @@ mod tests {
|
||||
assert_eq!(out.data["decision"], json!("同意"));
|
||||
assert_eq!(out.data["decisions"], json!(["同意"]), "兼容回退后 decisions 应含单值");
|
||||
}
|
||||
|
||||
// ===== F-260616-06 阶段2: 审批拒绝语义化(行为变更: 拒绝从 Ok → Err) =====
|
||||
|
||||
/// F-260616-06: 默认 options `["同意","拒绝"]` 下选「拒绝」→ Err(不再 Ok)。
|
||||
/// 阶段2 推进链依赖工作流 failed 触发退回,故拒绝必须让节点返 Err → executor set_failed。
|
||||
#[tokio::test]
|
||||
async fn reject_decision_returns_error() {
|
||||
let bus = EventBus::new();
|
||||
let ctx = make_ctx(
|
||||
&bus,
|
||||
"exec-reject",
|
||||
"node-h",
|
||||
json!({ "options": ["同意", "拒绝"] }),
|
||||
);
|
||||
let bus_clone = bus.clone();
|
||||
|
||||
let handle = tokio::spawn(async move { HumanNode.execute(ctx).await });
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
send_response(&bus_clone, "exec-reject", "node-h", "拒绝", Some("方案不行")).await;
|
||||
|
||||
let err = handle.await.unwrap().unwrap_err().to_string();
|
||||
assert!(
|
||||
err.contains("拒绝") && err.contains("人工审批"),
|
||||
"拒绝 decision 应返 Err「人工审批被拒绝...」, 实际: {}",
|
||||
err
|
||||
);
|
||||
assert!(
|
||||
err.contains("方案不行"),
|
||||
"拒绝 Err 应携带 comment, 实际: {}",
|
||||
err
|
||||
);
|
||||
}
|
||||
|
||||
/// F-260616-06: 同一 options 下选「同意」→ Ok(通过路径不回归)。
|
||||
#[tokio::test]
|
||||
async fn approve_decision_still_ok() {
|
||||
let bus = EventBus::new();
|
||||
let ctx = make_ctx(
|
||||
&bus,
|
||||
"exec-approve",
|
||||
"node-h",
|
||||
json!({ "options": ["同意", "拒绝"] }),
|
||||
);
|
||||
let bus_clone = bus.clone();
|
||||
|
||||
let handle = tokio::spawn(async move { HumanNode.execute(ctx).await });
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
send_response(&bus_clone, "exec-approve", "node-h", "同意", None).await;
|
||||
|
||||
let out = handle.await.unwrap().unwrap();
|
||||
assert_eq!(out.data["decision"], json!("同意"));
|
||||
}
|
||||
|
||||
/// F-260616-06: 英文 reject 关键字同样识别为拒绝 → Err(归一化大小写/空白)。
|
||||
/// 多关键字覆盖走 reject_keyword_detection_normalized 纯单元测试,此处仅验证端到端一条。
|
||||
#[tokio::test]
|
||||
async fn english_reject_keyword_returns_error() {
|
||||
let bus = EventBus::new();
|
||||
let ctx = make_ctx(
|
||||
&bus,
|
||||
"exec-en",
|
||||
"node-h",
|
||||
json!({ "options": ["approve", "reject"], "timeout_secs": 5 }),
|
||||
);
|
||||
let bus_clone = bus.clone();
|
||||
|
||||
let handle = tokio::spawn(async move { HumanNode.execute(ctx).await });
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
send_response(&bus_clone, "exec-en", "node-h", "reject", None).await;
|
||||
|
||||
let err = handle.await.unwrap().unwrap_err().to_string();
|
||||
assert!(
|
||||
err.contains("拒绝"),
|
||||
"英文 reject 关键字应返 Err, 实际: {}",
|
||||
err
|
||||
);
|
||||
}
|
||||
|
||||
/// F-260616-06: 多选场景,picked 含一项拒绝 → 整单拒绝 → Err
|
||||
/// (选了「驳回」即驳回,即便同时选了「同意」)。
|
||||
#[tokio::test]
|
||||
async fn multiple_select_with_one_reject_returns_error() {
|
||||
let bus = EventBus::new();
|
||||
let ctx = make_ctx(
|
||||
&bus,
|
||||
"exec-multi-reject",
|
||||
"node-h",
|
||||
json!({
|
||||
"options": ["同意", "驳回", "备注"],
|
||||
"select_type": "multiple"
|
||||
}),
|
||||
);
|
||||
let bus_clone = bus.clone();
|
||||
|
||||
let handle = tokio::spawn(async move { HumanNode.execute(ctx).await });
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
send_response_multi(
|
||||
&bus_clone,
|
||||
"exec-multi-reject",
|
||||
"node-h",
|
||||
&["同意", "驳回"],
|
||||
Some("驳回其中一项"),
|
||||
)
|
||||
.await;
|
||||
|
||||
let err = handle.await.unwrap().unwrap_err().to_string();
|
||||
assert!(err.contains("拒绝"), "多选含拒绝项应返 Err, 实际: {}", err);
|
||||
}
|
||||
|
||||
/// F-260616-06: options 空的自由文本场景 —— 明确拒绝词("拒绝")仍返 Err,
|
||||
/// 其余自由文本(非拒绝词)仍按通过处理(向后兼容,不阻断自由反馈)。
|
||||
#[tokio::test]
|
||||
async fn empty_options_free_text_reject_keyword_still_errors() {
|
||||
let bus = EventBus::new();
|
||||
let ctx = make_ctx(&bus, "exec-free-reject", "node-h", json!({ "options": [] }));
|
||||
let bus_clone = bus.clone();
|
||||
|
||||
let handle = tokio::spawn(async move { HumanNode.execute(ctx).await });
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
send_response(&bus_clone, "exec-free-reject", "node-h", "拒绝", None).await;
|
||||
|
||||
let err = handle.await.unwrap().unwrap_err().to_string();
|
||||
assert!(err.contains("拒绝"), "自由文本明确为拒绝词仍应 Err, 实际: {}", err);
|
||||
}
|
||||
|
||||
/// F-260616-06: options 空的自由文本场景 —— 非拒绝词自由文本仍返 Ok(不误伤自由反馈)。
|
||||
/// (empty_options_allows_free_text 已覆盖 "改成先做B方案" → Ok,此处补一条非拒绝中文短句。)
|
||||
#[tokio::test]
|
||||
async fn empty_options_non_reject_free_text_still_ok() {
|
||||
let bus = EventBus::new();
|
||||
let ctx = make_ctx(&bus, "exec-free-ok", "node-h", json!({ "options": [] }));
|
||||
let bus_clone = bus.clone();
|
||||
|
||||
let handle = tokio::spawn(async move { HumanNode.execute(ctx).await });
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
send_response(&bus_clone, "exec-free-ok", "node-h", "再讨论一下", None).await;
|
||||
|
||||
let out = handle.await.unwrap().unwrap();
|
||||
assert_eq!(out.data["decision"], json!("再讨论一下"));
|
||||
}
|
||||
|
||||
/// F-260616-06 单元: 关键字判定函数归一化(去空白+小写)与边界。
|
||||
#[test]
|
||||
fn reject_keyword_detection_normalized() {
|
||||
assert!(is_reject_decision("拒绝"));
|
||||
assert!(is_reject_decision(" 拒绝 "));
|
||||
assert!(is_reject_decision("Reject"));
|
||||
assert!(is_reject_decision(" decline "));
|
||||
assert!(is_reject_decision("驳回"));
|
||||
assert!(is_reject_decision("退回"));
|
||||
assert!(!is_reject_decision("同意"));
|
||||
assert!(!is_reject_decision("approve"));
|
||||
assert!(!is_reject_decision("再讨论"));
|
||||
assert!(!is_reject_decision(""));
|
||||
// 不是关键字开头,是整词匹配 —— "拒绝啦" 不应误判
|
||||
assert!(!is_reject_decision("拒绝啦"));
|
||||
assert!(contains_reject(&["同意".into()]) == false);
|
||||
assert!(contains_reject(&["同意".into(), "拒绝".into()]) == true);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user