新增: 批次工作落地(推进链/评估闭环/事件总线/并发/加固) + 技术债清理 + 文档整理
后端: - 工作流推进链(D-03):advance_task/状态机/闸门走 df-nodes Node trait,conditions 条件引擎扩展 - 想法评估闭环:启发式评分+对抗评估,df-ideas/scoring + df-storage/idea_eval_repo + idea 前端打通 - 全局事件数据总线:df-ai/context+context_helpers+augmentation 跨模块解耦 - AI planner/plan_hint/intent:aichat B 路线并行多轮基础 - patch_file 加固(TD-03/04):读改写整体锁防 lost update,expected_hash 合约闭环 - 压缩超时兜底(F-15 卡死根治) - F-09 多会话并发:LlmConcurrency per-conv + streamingGuard 前端守护 + verify 脚本 - 知识注入 DRY/skills/audit 扩展 清理: - aichat 技术债(误报 allow/死导入/过时注释 30 项) - URGENT.md 删除(11 项加急全解决/迁 todo) - 文档整理(todo/待决策/待审查/ARCHITECTURE/INDEX + 总线/技术债审查新文档)
This commit is contained in:
@@ -14,7 +14,10 @@
|
||||
//! 并 `pub use` 重导出以保持 `df_ai::context::*` 历史路径对外可见(零调用方变更)。
|
||||
//! 本文件仅保留 `ContextManager` 结构体及其 `impl`(Rust impl 块不可跨文件)。
|
||||
|
||||
use crate::context_helpers::{classify_group, PROTECT_COUNT, TOOL_MISSING_PREFIX};
|
||||
use crate::context_helpers::{
|
||||
classify_group, is_pending_placeholder, PLACEHOLDER_INTEGRITY_ENABLED, PROTECT_COUNT,
|
||||
TOOL_MISSING_PREFIX,
|
||||
};
|
||||
// 重导出:保持 `df_ai::context::TokenEstimator` / `df_ai::context::ContextConfig` 等
|
||||
// 历史路径对外可见(agentic.rs / commands/ai/mod.rs 等调用方零变更)。
|
||||
// `pub use` 同时把类型带入本模块命名空间,供 ContextManager 结构体字段与 impl 直接引用。
|
||||
@@ -155,7 +158,12 @@ impl ContextManager {
|
||||
|
||||
// 未超预算 → 直接返回全量(仍做畸形配对自愈,防历史中毒触发 provider 500 死循环)
|
||||
if self.history_tokens <= available {
|
||||
return (Self::sanitize_messages(self.all_messages_clone()), false);
|
||||
let sanitized = Self::sanitize_messages(self.all_messages_clone());
|
||||
// 阶段2 出口断言:占位配对完整性,失败降级 TOOL_MISSING_PREFIX 自愈(防 400 orphan)
|
||||
return (
|
||||
Self::assert_placeholder_pairing(sanitized, PLACEHOLDER_INTEGRITY_ENABLED),
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
// 超预算 → 视图裁剪(不修改 self.messages,保证 all_messages_clone 仍返回全量)
|
||||
@@ -189,7 +197,12 @@ impl ContextManager {
|
||||
"context_trimmed: skip {} messages, ~{} tokens (view-only, full history retained)",
|
||||
trim_end, removed
|
||||
);
|
||||
(Self::sanitize_messages(msgs), true)
|
||||
let sanitized = Self::sanitize_messages(msgs);
|
||||
// 阶段2 出口断言:占位配对完整性,失败降级 TOOL_MISSING_PREFIX 自愈(防 400 orphan)
|
||||
(
|
||||
Self::assert_placeholder_pairing(sanitized, PLACEHOLDER_INTEGRITY_ENABLED),
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
/// 畸形配对自愈 — 过滤掉会导致 provider 500 的中毒历史
|
||||
@@ -209,7 +222,7 @@ impl ContextManager {
|
||||
/// 无主 tool_result(「不能整条删」——保住已发生的合法工具交互历史)。
|
||||
///
|
||||
/// 单次遍历、保序过滤、不重排(user/assistant/tool 角色交替不被打乱)。
|
||||
fn sanitize_messages(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
|
||||
pub fn sanitize_messages(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
|
||||
use std::collections::HashSet;
|
||||
|
||||
// step 0(UX-09):过滤 truncated 软删消息,不进 LLM 上下文。
|
||||
@@ -329,10 +342,165 @@ impl ContextManager {
|
||||
sanitized
|
||||
};
|
||||
|
||||
// step 3.5(阶段2 占位配对完整性):反向 orphan 检测 —— tool_result 无对应 tool_call 头 → 丢。
|
||||
//
|
||||
// 根因(解 400 orphan):审批挂起占位 tool_result(内容 audit/cache.rs:PENDING_APPROVAL_PLACEHOLDER)
|
||||
// 经 step3(正向 orphan:头无 result→丢头 + 其 result)或 build_eviction_units(预算裁剪从三元组
|
||||
// 边界 trim)后,可能出现 tool_result 残留但其 tool_call 头已被丢弃 → orphan tool_result(无头)。
|
||||
// deepseek-v4-pro 等端点对此严格校验 → 400。
|
||||
//
|
||||
// 检测:收集所有保留的 assistant 头的 tool_call.id 集合(head_ids),tool_result 的 id 不在
|
||||
// head_ids 内即 orphan → 丢。与 step2/3 互补:step2/3 管"头丢 result",step3.5 管"result 丢头"。
|
||||
//
|
||||
// **占位保护**:带 PENDING_MARKER_PREFIX 标记的审批占位 tool_result,虽其头被丢,仍需保留——
|
||||
// 占位语义是"等用户审批",LLM 需看到它才知道在等审批。故对占位 result 不做反向 orphan 丢弃,
|
||||
// 改由出口断言(build_for_request 出口)自愈补头(见 assert_placeholder_pairing)。
|
||||
// 老占位(无 __PENDING__ 标记,纯文本 LEGACY_PENDING_PLACEHOLDER_TEXT)同样豁免保留
|
||||
// (is_pending_placeholder 精确等值匹配老占位全文),虽无 tc_id 无法强绑定补头,但保留后
|
||||
// 出口断言仍能据其 tool_call_id 补占位头闭合三元组(向前兼容迁移期老数据)。
|
||||
let after_reverse_orphan = if PLACEHOLDER_INTEGRITY_ENABLED {
|
||||
Self::drop_reverse_orphans(after_triplet)
|
||||
} else {
|
||||
after_triplet
|
||||
};
|
||||
|
||||
// step 4:序列合法性修复(首条 user + 连续同 role 合并),防 Anthropic/GLM 1214。
|
||||
Self::ensure_sequence_legal(after_triplet)
|
||||
Self::ensure_sequence_legal(after_reverse_orphan)
|
||||
}
|
||||
|
||||
/// step 3.5:反向 orphan 检测(view-only)—— 丢弃无对应 tool_call 头的 tool_result。
|
||||
///
|
||||
/// 详见 sanitize_messages step3.5 注释。占位 result(带 PENDING_MARKER_PREFIX 标记)豁免
|
||||
/// (保留,出口断言自愈补头),其余 tool_result 的 id 不在任何保留头 tool_calls 内 → 丢。
|
||||
fn drop_reverse_orphans(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
|
||||
// 收集所有保留 assistant 头的 tool_call.id(正向 orphan 处理后残留的头里的 id)
|
||||
use std::collections::HashSet;
|
||||
let head_ids: HashSet<String> = messages
|
||||
.iter()
|
||||
.filter(|m| matches!(m.role, MessageRole::Assistant))
|
||||
.filter_map(|m| m.tool_calls.as_ref())
|
||||
.flatten()
|
||||
.map(|c| c.id.clone())
|
||||
.collect();
|
||||
|
||||
let mut dropped = 0u32;
|
||||
let mut placeholder_kept = 0u32;
|
||||
let filtered: Vec<ChatMessage> = messages
|
||||
.into_iter()
|
||||
.filter(|m| {
|
||||
if !matches!(m.role, MessageRole::Tool) {
|
||||
return true;
|
||||
}
|
||||
let Some(id) = m.tool_call_id.as_deref() else {
|
||||
return true; // 无 id 的 tool_result(异常数据),不在此处处理
|
||||
};
|
||||
if head_ids.contains(id) {
|
||||
return true; // 有对应头 → 保留
|
||||
}
|
||||
// 无对应头:占位(带标记)豁免保留(出口断言自愈补头);非占位 → 丢
|
||||
if is_pending_placeholder(&m.content) {
|
||||
placeholder_kept += 1;
|
||||
true
|
||||
} else {
|
||||
dropped += 1;
|
||||
false
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
if dropped > 0 || placeholder_kept > 0 {
|
||||
tracing::warn!(
|
||||
dropped_reverse_orphan_tool_results = dropped,
|
||||
placeholder_kept_without_head = placeholder_kept,
|
||||
"history sanitized: dropped orphan tool_results without matching tool_call head (view-only, persisted history untouched)"
|
||||
);
|
||||
}
|
||||
filtered
|
||||
}
|
||||
|
||||
/// 发送视图出口断言(阶段2 占位配对完整性):确保所有 tool_result(含审批占位)都有
|
||||
/// 对应 tool_call 头,失败降级 TOOL_MISSING_PREFIX 自愈。
|
||||
///
|
||||
/// **职责**:在消息即将发给 LLM 前(`build_for_request` 出口)最后一道防线:若仍有
|
||||
/// orphan tool_result(sanitize step3.5 豁免保留的占位 result,其头在 step3/裁剪中被丢),
|
||||
/// 则**就地补一个 tool_missing_ 占位头**塞在 tool_result 前,使其配对闭合。
|
||||
///
|
||||
/// **未接入出口**:`compress_via_llm`(src-tauri 侧 F-15 压缩)与 agentic loop 主循环也属
|
||||
/// "发给 LLM 前"语义,但二者均在 src-tauri crate(非 df-ai),当前未调本函数——如需对压缩
|
||||
/// 后消息同样补头,由调用方在拿到压缩结果后自行调本函数闭合(本 crate 不强加跨 crate 依赖)。
|
||||
///
|
||||
/// 自愈原理:占位头带 TOOL_MISSING_PREFIX id,该 id 必然无"真"匹配,但与紧跟的 orphan
|
||||
/// tool_result(同 id)构成闭合三元组(头 + result 都在,id 一致)→ 满足 provider 协议
|
||||
/// "每个 tool_call.id 必须有 tool_result"的铁律,不再 400。占位头的 id 用 tool_result 的
|
||||
/// tool_call_id 复用(而非 tool_missing_{idx} 自增),保证与 result 精确配对。
|
||||
///
|
||||
/// **view-only**:本函数不改 self.messages,仅修改传入 Vec(clone),持久化全量保留。
|
||||
/// 占位头补在 orphan result **正前方**(保序,角色交替 assistant→tool 合法),content 给空串
|
||||
/// (LLM 看 assistant 头无文本内容但有 tool_calls 即理解"调了工具")。
|
||||
///
|
||||
/// `enabled` 参数由调用方传入(常量 flag),false 时直接原样返回(旧行为,flag 关回退)。
|
||||
pub fn assert_placeholder_pairing(
|
||||
messages: Vec<ChatMessage>,
|
||||
enabled: bool,
|
||||
) -> Vec<ChatMessage> {
|
||||
if !enabled {
|
||||
return messages;
|
||||
}
|
||||
// 收集现有头的 tool_call.id(已配对的)
|
||||
use std::collections::HashSet;
|
||||
let head_ids: HashSet<String> = messages
|
||||
.iter()
|
||||
.filter(|m| matches!(m.role, MessageRole::Assistant))
|
||||
.filter_map(|m| m.tool_calls.as_ref())
|
||||
.flatten()
|
||||
.map(|c| c.id.clone())
|
||||
.collect();
|
||||
|
||||
// 找 orphan tool_result(id 不在 head_ids)。占位/非占位都需补头(否则 400)。
|
||||
// 单次遍历产出新 Vec:orphan result 前插一个占位头(同 id),其余原样保留保序。
|
||||
let mut healed = 0u32;
|
||||
let mut out: Vec<ChatMessage> = Vec::with_capacity(messages.len());
|
||||
for m in messages.into_iter() {
|
||||
if matches!(m.role, MessageRole::Tool) {
|
||||
if let Some(id) = m.tool_call_id.as_deref() {
|
||||
if !head_ids.contains(id) && !id.starts_with(TOOL_MISSING_PREFIX) {
|
||||
// orphan result → 前补占位头(TOOL_MISSING_PREFIX + 原 id,确保唯一且与 result 配对)
|
||||
let head_id = format!("{}{}", TOOL_MISSING_PREFIX, id);
|
||||
let head = ChatMessage::assistant_with_tools(
|
||||
String::new(),
|
||||
vec![ToolCall::new(head_id.clone(), "pending_approval_placeholder", "{}")],
|
||||
);
|
||||
out.push(head);
|
||||
// 把 result 的 tool_call_id 也改写成占位头 id,使三者精确配对
|
||||
let mut result = m;
|
||||
result.tool_call_id = Some(head_id);
|
||||
out.push(result);
|
||||
healed += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push(m);
|
||||
}
|
||||
if healed > 0 {
|
||||
tracing::warn!(
|
||||
healed_orphan_tool_results = healed,
|
||||
"send-view exit: healed orphan tool_results by inserting TOOL_MISSING_PREFIX heads (view-only, persisted history untouched)"
|
||||
);
|
||||
// 加固(连续 assistant→400):补占位头可能插在普通 assistant 之后,产生连续 assistant
|
||||
// (普通 assistant + 占位 assistant + tool_result)。Anthropic/GLM 协议对连续同 role
|
||||
// 敏感,会 400/1214。ensure_sequence_legal 在 sanitize 末尾已跑过(早于本函数),无法捕获
|
||||
// 本函数新插入的头。故补头后再过一次 ensure_sequence_legal:合并连续 assistant(其
|
||||
// tool_calls 合并入前一头,占位头 id 与 result id 一致,合并后配对仍闭合) + 防补头
|
||||
// 致首条变 assistant(理论上 orphan result 前必有 user/assistant,但防御性兜底)。
|
||||
//
|
||||
// view-only:out 是 clone,ensure_sequence_legal 仅改本 Vec,不改 self.messages 持久化。
|
||||
return Self::ensure_sequence_legal(out);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
|
||||
/// step 4:序列合法性修复(Anthropic/GLM Messages API 协议铁律,view-only 不改持久化)。
|
||||
///
|
||||
/// 协议要求首条必须是 user(system 由 convert_request 抽顶层)。sanitize step0(is_active 过滤)
|
||||
@@ -548,10 +716,15 @@ impl ContextManager {
|
||||
self.pending_topic_marker.take()
|
||||
}
|
||||
|
||||
// ── F-15 上下文管理增强辅助方法(阶段1 基础,零行为变化)──
|
||||
// ── F-15 上下文管理增强辅助方法(压缩链路核心 API)──
|
||||
//
|
||||
// 这些方法供阶段2 IPC(ai_chat_compress_context)/ 阶段3 agentic loop
|
||||
// 自动压缩调用。本阶段只暴露方法 + 单测,不接 IPC/前端/agentic.rs。
|
||||
// 已全量接入压缩链路:
|
||||
// - agentic/mod.rs 自动压缩(trigger 判定 + LLM/关键词兜底 + 重入保护)
|
||||
// set_compressing/is_compressing(重入标志) / has_compressible_messages(触发判定)
|
||||
// messages_mut(取 active 喂 LLM) / compress_old_messages(标 compressed 扣 token)
|
||||
// insert_at(摘要/续接锚点 system 消息插入首位)
|
||||
// - commands/ai/compress.rs 与 commands/ai/commands/chat.rs 走 IPC 压缩入口
|
||||
// - build_eviction_units(下方)供会话分段与压缩定位共用同一分组逻辑。
|
||||
|
||||
/// 配置(只读视图,供 agentic.rs 计算压缩触发阈值 `config().budget_limit()`)
|
||||
pub fn config(&self) -> &ContextConfig {
|
||||
@@ -668,10 +841,7 @@ impl ContextManager {
|
||||
self.is_compressing = v;
|
||||
}
|
||||
|
||||
// ── 内部方法 ──
|
||||
|
||||
// (原 build_eviction_units / classify_group 等纯函数已在上方公开或文件级定义;
|
||||
// ContextManager 的私有辅助如需新增放在这里。)
|
||||
// build_eviction_units / classify_group 等已在上方公开或文件级定义。
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -781,6 +951,226 @@ mod tests {
|
||||
assert_eq!(msgs.len(), 3, "正常三元组不应被 sanitize 剔除");
|
||||
}
|
||||
|
||||
// ── 阶段2 占位配对完整性(解 400 orphan):反向 orphan 检测 + 出口自愈 ──
|
||||
|
||||
#[test]
|
||||
fn sanitize_drops_reverse_orphan_tool_result() {
|
||||
// 反向 orphan:tool_result 无对应 tool_call 头(头被裁/丢)→ sanitize step3.5 丢弃。
|
||||
// 非 pending 占位(普通 tool_result)直接丢,防 provider 400 orphan。
|
||||
let msgs = vec![
|
||||
ChatMessage::user("问题"),
|
||||
ChatMessage::tool_result("orphan_id", "孤儿结果无头"),
|
||||
];
|
||||
let sanitized = ContextManager::sanitize_messages(msgs);
|
||||
assert!(
|
||||
sanitized.iter().all(|m| !matches!(m.role, MessageRole::Tool)),
|
||||
"无头的普通 tool_result 应被反向 orphan 检测丢弃, 实际 {:?}",
|
||||
sanitized
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_keeps_reverse_orphan_pending_placeholder() {
|
||||
// 反向 orphan 但内容是 pending 占位(带 __PENDING__ 标记)→ step3.5 豁免保留
|
||||
// (占位语义"等审批",出口断言自愈补头)。验证占位保护不误丢。
|
||||
let placeholder_content = "需要用户审批,等待确认__PENDING__:call_pending_1";
|
||||
let msgs = vec![
|
||||
ChatMessage::user("问题"),
|
||||
ChatMessage::tool_result("call_pending_1", placeholder_content),
|
||||
];
|
||||
let sanitized = ContextManager::sanitize_messages(msgs);
|
||||
let kept: Vec<_> = sanitized
|
||||
.iter()
|
||||
.filter(|m| matches!(m.role, MessageRole::Tool))
|
||||
.collect();
|
||||
assert_eq!(kept.len(), 1, "pending 占位 tool_result 应豁免保留,不丢");
|
||||
assert_eq!(kept[0].tool_call_id.as_deref(), Some("call_pending_1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_for_request_heals_orphan_pending_placeholder_via_exit_assert() {
|
||||
// 端到端:pending 占位 tool_result(无头)经 sanitize 保留 → 出口断言补 TOOL_MISSING_PREFIX
|
||||
// 头自愈 → 发送视图含闭合三元组(占位头 + 占位 result,id 配对)。防 400 orphan。
|
||||
let placeholder_content = "需要用户审批,等待确认__PENDING__:call_pending_2";
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::user("问题"));
|
||||
mgr.push(ChatMessage::tool_result("call_pending_2", placeholder_content));
|
||||
let (msgs, _trimmed) = mgr.build_for_request(0);
|
||||
|
||||
// 出口断言补了占位头 → 必有 assistant 头含 tool_missing_ 前缀 id
|
||||
let healed_heads: Vec<_> = msgs
|
||||
.iter()
|
||||
.filter(|m| matches!(m.role, MessageRole::Assistant))
|
||||
.filter_map(|m| m.tool_calls.as_ref())
|
||||
.flatten()
|
||||
.filter(|c| c.id.starts_with("tool_missing_"))
|
||||
.collect();
|
||||
assert_eq!(healed_heads.len(), 1, "出口断言应补 1 个 TOOL_MISSING_PREFIX 占位头");
|
||||
// 占位头 id 应含原 tool_call_id(精确配对)
|
||||
assert!(
|
||||
healed_heads[0].id.contains("call_pending_2"),
|
||||
"补的头 id 应含原 tool_call_id, 实际 {}",
|
||||
healed_heads[0].id
|
||||
);
|
||||
// result 的 tool_call_id 也被改写为占位头 id(三者精确配对)
|
||||
let result_id = msgs
|
||||
.iter()
|
||||
.find(|m| matches!(m.role, MessageRole::Tool))
|
||||
.and_then(|m| m.tool_call_id.clone())
|
||||
.expect("应有 tool_result");
|
||||
assert_eq!(result_id, healed_heads[0].id, "result id 应与补的头 id 一致(闭合配对)");
|
||||
// 持久化全量保留(view-only)
|
||||
assert_eq!(mgr.all_messages_clone().len(), 2, "自愈不应污染持久化");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_for_request_keeps_well_formed_triplet_under_placeholder_integrity() {
|
||||
// 回归:启用占位配对完整性后,正常闭合三元组不被误补头(无 orphan)。
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::user("问题"));
|
||||
mgr.push(ChatMessage::assistant_with_tools(
|
||||
"调",
|
||||
vec![ToolCall::new("call_ok", "fn", "{}")],
|
||||
));
|
||||
mgr.push(ChatMessage::tool_result("call_ok", "正常结果"));
|
||||
let (msgs, _trimmed) = mgr.build_for_request(0);
|
||||
// 无 tool_missing_ 头(正常三元组不需自愈)
|
||||
let missing_heads = msgs
|
||||
.iter()
|
||||
.filter(|m| matches!(m.role, MessageRole::Assistant))
|
||||
.filter_map(|m| m.tool_calls.as_ref())
|
||||
.flatten()
|
||||
.filter(|c| c.id.starts_with("tool_missing_"))
|
||||
.count();
|
||||
assert_eq!(missing_heads, 0, "正常闭合三元组不应被补占位头");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assert_placeholder_pairing_no_consecutive_assistant_after_head_insert() {
|
||||
// 加固(连续 assistant→400):补占位头插在 orphan tool_result 正前方,若 orphan result 的
|
||||
// 前一条恰是 assistant(无 tool_result 隔开),则补头后产生**连续 assistant**
|
||||
// (前 assistant + 占位 assistant + tool_result)→ Anthropic/GLM 协议 400/1214。
|
||||
// 出口断言补头后必须再过一次 ensure_sequence_legal 合并连续 assistant,使其不触发 provider 拒绝。
|
||||
//
|
||||
// 构造真正触发连续 assistant 的序列:user → 纯文本 assistant(无 tool_calls) →
|
||||
// orphan pending 占位 tool_result(call_pending,无头)。占位 result 经 sanitize step3.5
|
||||
// 豁免保留(带 __PENDING__),出口断言在 result 正前方补占位头(其前驱正是 assistant)
|
||||
// → 补后序列 user → assistant(纯文本) → assistant(占位头) → tool_result = 连续 assistant。
|
||||
//
|
||||
// 注:前版本用「带 tool_calls 的 assistant + tool_result(闭合) + orphan 占位 result」,
|
||||
// 补头后前驱是 tool_result 而非 assistant,根本不产生连续 assistant,断言恒真(未真测)。
|
||||
// 本版把前驱改成纯文本 assistant(无 tool_result 隔开),才能真正触发合并路径。
|
||||
let placeholder_content = "需要用户审批,等待确认__PENDING__:call_pending_cc";
|
||||
let msgs = vec![
|
||||
ChatMessage::user("问题"),
|
||||
ChatMessage::assistant("纯文本回复(无 tool_calls)"),
|
||||
ChatMessage::tool_result("call_pending_cc", placeholder_content),
|
||||
];
|
||||
let sanitized = ContextManager::sanitize_messages(msgs.clone());
|
||||
// 前置确认:sanitize 后占位 result 仍豁免保留(step3.5),序列保留 assistant→tool_result
|
||||
assert_eq!(sanitized.len(), 3, "占位 result 应豁免保留, 实际 {:?}", sanitized);
|
||||
assert!(sanitized.iter().any(|m|
|
||||
matches!(m.role, MessageRole::Tool)
|
||||
&& m.tool_call_id.as_deref() == Some("call_pending_cc")
|
||||
), "占位 tool_result 应保留");
|
||||
|
||||
let healed = ContextManager::assert_placeholder_pairing(sanitized, PLACEHOLDER_INTEGRITY_ENABLED);
|
||||
|
||||
// 断言:补了占位头(tool_missing_ 前缀 id)
|
||||
let missing_heads: Vec<_> = healed
|
||||
.iter()
|
||||
.filter(|m| matches!(m.role, MessageRole::Assistant))
|
||||
.filter_map(|m| m.tool_calls.as_ref())
|
||||
.flatten()
|
||||
.filter(|c| c.id.starts_with(TOOL_MISSING_PREFIX))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
missing_heads.len(),
|
||||
1,
|
||||
"应补 1 个占位头, 实际 {:?}",
|
||||
missing_heads
|
||||
);
|
||||
|
||||
// 核心断言:不应存在连续 assistant(任一 assistant 紧邻前一 assistant 视为连续)。
|
||||
// 若出口断言补头后未过 ensure_sequence_legal,会留连续 assistant(纯文本 assistant +
|
||||
// 占位 assistant)→ consecutive >= 1,本断言失败。合并后 consecutive 必须 0。
|
||||
let mut prev_is_assistant = false;
|
||||
let mut consecutive = 0u32;
|
||||
for m in &healed {
|
||||
let is_assistant = matches!(m.role, MessageRole::Assistant);
|
||||
if is_assistant && prev_is_assistant {
|
||||
consecutive += 1;
|
||||
}
|
||||
prev_is_assistant = is_assistant;
|
||||
}
|
||||
assert_eq!(
|
||||
consecutive, 0,
|
||||
"补占位头后不应有连续 assistant(应被合并), 实际序列 {:?}",
|
||||
healed
|
||||
.iter()
|
||||
.map(|m| format!("{:?}", m.role))
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
|
||||
// 合并验证:补的占位头应被并入前一个纯文本 assistant(其 tool_calls 从 None→Some[占位头]),
|
||||
// 而非独立成条(独立成条才会连续 assistant)。即 healed 里 assistant 消息数 == 1(原纯文本头
|
||||
// 吸收了占位头的 tool_calls),且该 assistant 同时含纯文本 content 与占位头 tool_call。
|
||||
let assistant_msgs: Vec<_> = healed
|
||||
.iter()
|
||||
.filter(|m| matches!(m.role, MessageRole::Assistant))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
assistant_msgs.len(),
|
||||
1,
|
||||
"连续 assistant 应被合并为 1 条(占位头 tool_calls 并入前驱), 实际 {} 条: {:?}",
|
||||
assistant_msgs.len(),
|
||||
healed.iter().map(|m| format!("{:?}", m.role)).collect::<Vec<_>>()
|
||||
);
|
||||
// 合并后唯一 assistant 既保留原纯文本 content,又含占位头 tool_calls
|
||||
let merged = &assistant_msgs[0];
|
||||
assert_eq!(merged.content, "纯文本回复(无 tool_calls)", "合并后应保留前驱纯文本 content");
|
||||
let merged_call_ids: Vec<&str> = merged
|
||||
.tool_calls
|
||||
.as_ref()
|
||||
.map(|cs| cs.iter().map(|c| c.id.as_str()).collect())
|
||||
.unwrap_or_default();
|
||||
assert!(
|
||||
merged_call_ids.iter().any(|id| id.starts_with(TOOL_MISSING_PREFIX)
|
||||
&& id.contains("call_pending_cc")),
|
||||
"合并后前驱 assistant 应吸收占位头 tool_call, 实际 calls: {:?}",
|
||||
merged_call_ids
|
||||
);
|
||||
|
||||
// 占位头 id 应在(合并后的)assistant 头 tool_calls 内,result 与之配对
|
||||
let result_id = healed
|
||||
.iter()
|
||||
.find(|m| matches!(m.role, MessageRole::Tool))
|
||||
.and_then(|m| m.tool_call_id.clone())
|
||||
.expect("应有 tool_result");
|
||||
assert!(
|
||||
merged_call_ids.contains(&result_id.as_str()),
|
||||
"result id 应与合并后的占位头 id 一致(闭合配对), result={} heads={:?}",
|
||||
result_id,
|
||||
merged_call_ids
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_reverse_orphans_preserves_pending_with_legacy_text() {
|
||||
// 边界:老占位(纯文本"需要用户审批..."无 __PENDING__ 标记)→ is_pending_placeholder 仍识别
|
||||
// (starts_with "需要用户审批") → 豁免保留。验证向前兼容迁移期老数据。
|
||||
let msgs = vec![
|
||||
ChatMessage::user("问题"),
|
||||
ChatMessage::tool_result("legacy_pending", "需要用户审批,等待确认"),
|
||||
];
|
||||
let sanitized = ContextManager::sanitize_messages(msgs);
|
||||
let kept = sanitized
|
||||
.iter()
|
||||
.filter(|m| matches!(m.role, MessageRole::Tool))
|
||||
.count();
|
||||
assert_eq!(kept, 1, "老占位(纯文本无标记)也应豁免保留(向前兼容)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_mixed_multi_message_sequence() {
|
||||
// 场景 4:连续多条消息混合——
|
||||
|
||||
@@ -474,6 +474,69 @@ pub const PROTECT_COUNT: usize = 6;
|
||||
/// 此类 id 必然无匹配 tool_result,是历史中毒的标志,sanitize 时据此剔除畸形三元组。
|
||||
pub const TOOL_MISSING_PREFIX: &str = "tool_missing_";
|
||||
|
||||
/// 阶段2(path_auth 审批链重构):占位配对完整性(解 400 orphan)常量开关。
|
||||
///
|
||||
/// 根因:审批挂起占位 tool_result(内容为 audit/cache.rs:PENDING_APPROVAL_PLACEHOLDER)
|
||||
/// 与其 tool_call 头经 sanitize/compress 裁剪后丢配对头 → orphan tool_result(无头)→
|
||||
/// deepseek-v4-pro 等端点 400。本开关控制 sanitize step3.5(反向 orphan 丢弃) +
|
||||
/// 发送视图出口断言占位配对完整(失败降级 TOOL_MISSING_PREFIX 自愈)。
|
||||
///
|
||||
/// true(默认):启用反向 orphan 检测 + 出口占位配对断言自愈(根治 400)。
|
||||
/// false(回退):sanitize 仅做原正向 orphan(头无 result)处理,出口不断言(旧行为)。
|
||||
/// 兜底:sanitize view-only 不改持久化,失败只影响单请求,flag 关→行为完全等价改动前。
|
||||
pub const PLACEHOLDER_INTEGRITY_ENABLED: bool = true;
|
||||
|
||||
/// 占位 tool_result 内容中嵌入的唯一标记前缀(供 sanitize 识别"审批挂起占位,不可裁")。
|
||||
///
|
||||
/// 写入处(audit/cache.rs:PENDING_APPROVAL_PLACEHOLDER)格式:`{占位文本}{__PENDING__}{tc_id}`,
|
||||
/// 如 "需要用户审批,等待确认__PENDING__:call_abc123"。读取处(sanitize/出口断言)按此前缀
|
||||
/// 切出 tc_id,据此把占位 tool_result 与其 tool_call 头强绑定:若头被裁/丢了,降级自愈
|
||||
/// (头补 TOOL_MISSING_PREFIX 或 tool_result 丢弃),防 orphan 触发 provider 400。
|
||||
///
|
||||
/// 设计权衡:不用独立字段(改 ChatMessage schema 跨 crate + DB 迁移成本高),而是 content
|
||||
/// 内嵌标记——占位文本固定且唯一(非用户内容),内嵌不污染语义(LLM 看到也理解"等待审批")。
|
||||
pub const PENDING_MARKER_PREFIX: &str = "__PENDING__:";
|
||||
|
||||
/// 判定 tool_result content 是否为审批挂起占位(含 PENDING_MARKER_PREFIX 标记)。
|
||||
///
|
||||
/// 兼容老占位(无标记,纯 PENDING_APPROVAL_PLACEHOLDER 文本)与新占位(带 __PENDING__:tc_id):
|
||||
/// - 新占位:content 含 PENDING_MARKER_PREFIX(`__PENDING__:` 是独占信号,权威判定)
|
||||
/// - 老占位:content == PENDING_APPROVAL_PLACEHOLDER(向前兼容,迁移期共存)
|
||||
///
|
||||
/// **收紧(误判防护)**:`__PENDING__` 标记是 audit/cache.rs 占位模板独占字符串,绝不会出现在
|
||||
/// 用户正文里 → 标记存在即权威。老占位分支原用 `starts_with("需要用户审批")` 过松:若用户真实
|
||||
/// tool_result(如读到的文档片段)恰好以"需要用户审批..."开头,会被误判占位 → sanitize 豁免保留
|
||||
/// 或 compress 跳过压缩,污染 LLM 上下文。收紧为**精确等于**老占位全文(与 audit/cache.rs
|
||||
/// `PENDING_APPROVAL_PLACEHOLDER = "需要用户审批,等待确认"` 字面量同步),杜绝前缀误伤。
|
||||
///
|
||||
/// 注:老占位字面量在 src-tauri audit/cache.rs 定义(pub(crate) 不可跨 crate 引用),
|
||||
/// 此处内联同步字面量并加测试锁定;改字面量须同步 audit/cache.rs 与下方单测。
|
||||
pub fn is_pending_placeholder(content: &str) -> bool {
|
||||
// 权威信号:`__PENDING__` 标记独占,存在即占位。
|
||||
if content.contains(PENDING_MARKER_PREFIX) {
|
||||
return true;
|
||||
}
|
||||
// 收紧:老占位精确全文匹配(非 starts_with 前缀),杜绝用户内容前缀误伤。
|
||||
content == LEGACY_PENDING_PLACEHOLDER_TEXT
|
||||
}
|
||||
|
||||
/// 老占位(无 __PENDING__ 标记)的精确全文,与 audit/cache.rs `PENDING_APPROVAL_PLACEHOLDER` 同步。
|
||||
///
|
||||
/// 单独常量便于:① 字面量漂移自检(改字面量搜此常量即定位所有引用);② 测试锁定。
|
||||
/// 独立于 PENDING_MARKER_PREFIX(新占位用),老占位是迁移期共存数据。
|
||||
pub const LEGACY_PENDING_PLACEHOLDER_TEXT: &str = "需要用户审批,等待确认";
|
||||
|
||||
/// 从占位 tool_result content 切出嵌入的 tc_id(供 sanitize 反向定位其 tool_call 头)。
|
||||
///
|
||||
/// content 形如 "...__PENDING__:call_abc123",切出 "call_abc123"。无标记 → None(老占位
|
||||
/// 或非占位,调用方按无 tc_id 处理:走常规反向 orphan 检测,占位无标记时不享受强绑定保护)。
|
||||
pub fn extract_pending_tc_id(content: &str) -> Option<&str> {
|
||||
content
|
||||
.split_once(PENDING_MARKER_PREFIX)
|
||||
.map(|(_, id)| id.trim())
|
||||
.filter(|id| !id.is_empty())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -929,4 +992,88 @@ mod tests {
|
||||
assert!(toks.contains(&"call".to_string()));
|
||||
assert!(toks.contains(&"now".to_string()));
|
||||
}
|
||||
|
||||
// ── 阶段2 占位配对完整性:is_pending_placeholder / extract_pending_tc_id ──
|
||||
|
||||
#[test]
|
||||
fn is_pending_placeholder_new_with_marker() {
|
||||
// 新占位:占位文本 + __PENDING__:tc_id 标记
|
||||
let content = "需要用户审批,等待确认__PENDING__:call_abc123";
|
||||
assert!(is_pending_placeholder(content), "带标记的新占位应识别");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_pending_placeholder_legacy_without_marker() {
|
||||
// 老占位:纯文本无标记(向前兼容,迁移期共存)
|
||||
assert!(is_pending_placeholder("需要用户审批,等待确认"), "老占位(纯文本)应识别");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_pending_placeholder_rejects_normal_content() {
|
||||
// 非占位的正常 tool_result 内容不应误判
|
||||
assert!(!is_pending_placeholder("文件内容: hello world"));
|
||||
assert!(!is_pending_placeholder("{\"ok\":true}"));
|
||||
assert!(!is_pending_placeholder(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_pending_placeholder_rejects_user_content_with_prefix() {
|
||||
// 收紧(防误伤):用户真实 tool_result 内容恰好以"需要用户审批"开头(如读到的文档片段),
|
||||
// 但不是完整老占位全文 → 不应误判为占位(否则 sanitize 豁免保留/compress 跳过压缩污染 LLM)。
|
||||
assert!(
|
||||
!is_pending_placeholder("需要用户审批的文件清单如下:\n1. 文档A\n2. 文档B"),
|
||||
"以占位前缀开头但非完整老占位的用户内容,不应误判占位"
|
||||
);
|
||||
assert!(
|
||||
!is_pending_placeholder("需要用户审批一下这个改动,我看不太懂"),
|
||||
"前缀 + 后续不同文本不应误判"
|
||||
);
|
||||
// 仅完整老占位全文才匹配(精确等值,非前缀)
|
||||
assert!(
|
||||
is_pending_placeholder(LEGACY_PENDING_PLACEHOLDER_TEXT),
|
||||
"完整老占位全文应识别"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_pending_placeholder_marker_is_authoritative_signal() {
|
||||
// 对抗:__PENDING__: 标记是 audit/cache.rs 占位模板独占字符串,绝不会出现在用户正文里。
|
||||
// 故标记存在即权威信号(即使非开头也认占位)——独占语义保证不误判。
|
||||
assert!(
|
||||
is_pending_placeholder("任意内容__PENDING__:call_x"),
|
||||
"__PENDING__ 标记是独占信号,存在即识别为占位"
|
||||
);
|
||||
// 反向:无标记 + 非占位文本开头 → 不识别
|
||||
assert!(
|
||||
!is_pending_placeholder("用户问:这个需要审批吗?"),
|
||||
"无标记 + 非占位模板开头不应识别"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_pending_tc_id_new_marker() {
|
||||
// 新占位:切出 __PENDING__: 之后的 tc_id
|
||||
let content = "需要用户审批,等待确认__PENDING__:call_xyz789";
|
||||
assert_eq!(extract_pending_tc_id(content), Some("call_xyz789"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_pending_tc_id_legacy_no_marker() {
|
||||
// 老占位:无标记 → None
|
||||
assert_eq!(extract_pending_tc_id("需要用户审批,等待确认"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_pending_tc_id_empty_id() {
|
||||
// 标记后无内容(异常)→ None(防空 tc_id 误判)
|
||||
assert_eq!(extract_pending_tc_id("需要用户审批,等待确认__PENDING__:"), None);
|
||||
assert_eq!(extract_pending_tc_id("需要用户审批,等待确认__PENDING__: "), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_pending_tc_id_non_placeholder() {
|
||||
// 非占位内容 → None
|
||||
assert_eq!(extract_pending_tc_id("文件内容"), None);
|
||||
assert_eq!(extract_pending_tc_id(""), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -420,6 +420,106 @@ pub fn filter_tool_defs(
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ---- 工具子集过滤 + plan_hint 编排(B 路线 Phase 1 接入主 loop) --------------
|
||||
|
||||
/// 在 `filter_tool_defs` 收敛的扁平子集之上,叠加 `plan_hint` 的编排关系
|
||||
/// (并行组同批聚拢 / 顺序依赖源在前),供 LLM 看到一份**按编排意图排序**的工具列表。
|
||||
///
|
||||
/// **Phase 1 接入语义**(依据
|
||||
/// `docs/02-架构设计/单对话并行多轮-设计-2026-06-20.md` §五 Phase 1 +
|
||||
/// `单对话并行多轮-Phase0落地路线图-2026-06-20.md` §八 Phase 1 硬前置):
|
||||
/// - **纯函数零延迟**(与 intent/plan_hint 一致,无 IO 无 LLM 调用)。
|
||||
/// - **不真正多轮并行**(Phase 1 仅提示层;真正子流并行 execution 留 Phase 3)。
|
||||
/// - 行为 = 先 `filter_tool_defs` 收敛 → 再按 `plan_hint` 产出排序。
|
||||
///
|
||||
/// **三重 fallback(与 filter_tool_defs 一致的安全语义)**:
|
||||
/// 1. `plan_hint` 返空 PlanHint(无编排信号/单工具/闲聊)→ 退 `filter_tool_defs` 扁平结果。
|
||||
/// 2. `plan_hint::validate` 判非法(环/空组/自环/重复边)→ 丢弃 hint 退扁平结果
|
||||
/// (主 loop 不能据非法 hint 调度,否则死锁)。
|
||||
/// 3. hint 引用的工具名不在 filtered 子集(registry 漂移)→ 该名跳过,剩余照常排序。
|
||||
///
|
||||
/// **排序规则**(确定性,便于单测固定行为):
|
||||
/// - 收集 hint 所有并行组里的工具名,按「组序 → 组内顺序」展开成有序列表 `ordered`。
|
||||
/// - 输出 = `ordered` 中能在 filtered 找到的(保 ordered 顺序) +
|
||||
/// filtered 中未被 ordered 命中的(保 filtered 原序,放尾部兜底,防丢工具)。
|
||||
/// - 顺序依赖(`sequence` 边)的「from」节点天然被并行组顺序吸收(组在前 = from 先出现),
|
||||
/// Phase 1 不额外重排依赖边(真正 DAG 调度留 Phase 3 Kahn 分层)。
|
||||
///
|
||||
/// **关键安全**(与 filter_tool_defs 同):本函数**只改 LLM 可见 tool_defs 的顺序/可见性**,
|
||||
/// 不改执行路径(audit 走 tools_arc 完整 registry)。LLM 即使幻觉被滤掉的顺序,
|
||||
/// audit 仍能查到/拒绝。
|
||||
///
|
||||
/// `intent`:主 loop 已识别的意图;其 `as_str()` 标签内部派生传给 plan_hint。
|
||||
/// `intent_label`:**冗余参数**(等价于 `intent.as_str()`),仅为调用方签名兼容保留,
|
||||
/// 内部已忽略——以 `intent.as_str()` 为权威源,防调用方传错标签与实际 intent 不一致。
|
||||
/// `context`:用户末条消息原文(plan_hint 关键词检测用)。
|
||||
pub fn filter_tool_defs_planned(
|
||||
all_defs: &[df_ai_core::types::ToolDefinition],
|
||||
intent: &Intent,
|
||||
intent_label: &str,
|
||||
context: &str,
|
||||
) -> Vec<df_ai_core::types::ToolDefinition> {
|
||||
// 第一步:先得扁平收敛子集(复用 filter_tool_defs 的全部 fallback 语义)。
|
||||
let filtered = filter_tool_defs(all_defs, intent);
|
||||
|
||||
// 第二步:产 PlanHint。PLAN_HINT_ENABLED 关时 plan_hint 内部返空 → 退扁平。
|
||||
// intent_label 内部由 intent.as_str() 派生(冗余参数权威性低于 intent 本身)。
|
||||
let label = intent.as_str();
|
||||
let _ = intent_label; // 冗余:签名兼容保留,内部以 intent.as_str() 为准。
|
||||
let hint = crate::plan_hint::plan_hint(label, context);
|
||||
|
||||
// 空 hint(无编排信号)→ 直接返扁平收敛结果(零行为变更 vs filter_tool_defs)。
|
||||
if hint.is_empty() {
|
||||
return filtered;
|
||||
}
|
||||
|
||||
// 非法 hint(环/空组/自环/重复边)→ 丢弃,退扁平(主 loop 不能据非法 hint 调度)。
|
||||
if !matches!(crate::plan_hint::validate(&hint), crate::plan_hint::HintValidity::Ok) {
|
||||
return filtered;
|
||||
}
|
||||
|
||||
// 第三步:按 hint 编排关系重排 filtered。先展开「组序 → 组内序」的有序工具名表。
|
||||
let mut ordered_names: Vec<&'static str> = Vec::new();
|
||||
for g in &hint.groups {
|
||||
for t in &g.tools {
|
||||
if !ordered_names.contains(t) {
|
||||
ordered_names.push(*t);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 第四步:用 HashMap 索引(filtered 名 → 其在该 Vec 中的位置)替代「ordered × filtered」
|
||||
// 嵌套线性扫描。原版对每个 ordered 名做一次 O(filtered) 内层扫描,总 O(ordered × filtered);
|
||||
// 索引化后 O(filtered) 建索引 + 每 ordered 名 O(1) 查(filtered 通常 5-20 个工具,
|
||||
// 索引开销极小,但消除最坏 O(n²) 当 ordered/filtered 同时增长)。
|
||||
use std::collections::HashMap;
|
||||
// name_to_idx:工具名 → filtered 中首次出现位置(filtered 内工具名唯一,无二义)。
|
||||
let mut name_to_idx: HashMap<&str, usize> = HashMap::with_capacity(filtered.len());
|
||||
for (i, d) in filtered.iter().enumerate() {
|
||||
// filtered 名唯一(同 domain 不会重复),with_capacity 后直接 insert 不冲突。
|
||||
name_to_idx.entry(d.function.name.as_str()).or_insert(i);
|
||||
}
|
||||
|
||||
// 按 ordered 顺序挑 filtered 里的(命中编排顺序的工具置前)。
|
||||
let mut result: Vec<df_ai_core::types::ToolDefinition> = Vec::with_capacity(filtered.len());
|
||||
let mut consumed: std::collections::HashSet<usize> = std::collections::HashSet::new();
|
||||
for name in &ordered_names {
|
||||
if let Some(&idx) = name_to_idx.get(*name) {
|
||||
if consumed.insert(idx) {
|
||||
result.push(filtered[idx].clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
// 未被 ordered 命中的(filtered 里有但 hint 没标)→ 保 filtered 原序追加尾部兜底(防丢工具)。
|
||||
for (i, d) in filtered.iter().enumerate() {
|
||||
if consumed.insert(i) {
|
||||
result.push(d.clone());
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
// ---- 单测 -------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -455,7 +555,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recognize_debug_zh() {
|
||||
fn recognize_code_wins_over_debug_in_specific_group() {
|
||||
let (i, c) = IntentRecognizer::recognize("这个 bug 怎么复现,帮我调试排查一下");
|
||||
// "bug" 在 Code 组,"复现/调试/排查" 在 Debug 组 —— SPECIFIC 组内 Code 命中先于 Debug
|
||||
// Code 命中(bug=1.0) 即胜出本组,不再看 Debug。断言优先级正确:Code 胜出。
|
||||
@@ -975,4 +1075,138 @@ mod tests {
|
||||
assert_eq!(http_count, 1, "Intent {:?} subset http_request 应去重为 1", intent);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== filter_tool_defs_planned(B 路线 Phase 1 接入主 loop 用) =====
|
||||
//
|
||||
// 单测覆盖 Phase 1 接入语义:flag 关(filter_tool_defs 旧行为)、
|
||||
// plan_hint 空 hint 退扁平、非法 hint 退扁平、registry 漂移跳过、有序重排。
|
||||
// PLANNING_ENABLED(planner.rs)门控主 loop 接入点;PLAN_HINT_ENABLED(plan_hint.rs)
|
||||
// 门控 plan_hint 函数内部产出。本组单测直接验证 filter_tool_defs_planned
|
||||
// 纯函数行为(两层 flag 均为当前默认值时 plan_hint 函数产非空 hint)。
|
||||
|
||||
#[test]
|
||||
fn planned_empty_intent_subset_falls_back_to_full() {
|
||||
// Unknown subset 空 → filter_tool_defs 回全量 → plan_hint(无编排信号或闲聊)→ 扁平全量。
|
||||
// 校验 fallback 链完整:不会因 plan_hint 误丢工具。
|
||||
let all = vec![tool_def("read_file"), tool_def("write_file"), tool_def("http_request")];
|
||||
let out = filter_tool_defs_planned(&all, &Intent::Unknown, "unknown", "你好谢谢");
|
||||
assert_eq!(out.len(), all.len(), "Unknown + 闲聊应回全量(双 fallback)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn planned_plain_chat_yields_filter_result_unordered() {
|
||||
// Chat subset 空 → filter_tool_defs 回全量;context 无编排信号 → plan_hint 空 → 退扁平。
|
||||
// 关键:即使 PLANNING_ENABLED(true 时)走 planned 路径,无编排信号时输出与
|
||||
// filter_tool_defs 一致(顺序 + 内容),零行为变更。
|
||||
let all = vec![
|
||||
tool_def("read_file"),
|
||||
tool_def("write_file"),
|
||||
tool_def("http_request"),
|
||||
];
|
||||
let planned = filter_tool_defs_planned(&all, &Intent::Chat, "chat", "你好谢谢");
|
||||
let flat = filter_tool_defs(&all, &Intent::Chat);
|
||||
let planned_names: Vec<&str> = planned.iter().map(|d| d.function.name.as_str()).collect();
|
||||
let flat_names: Vec<&str> = flat.iter().map(|d| d.function.name.as_str()).collect();
|
||||
assert_eq!(planned_names, flat_names, "无编排信号 planned 应与 filter 扁平一致");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn planned_read_then_modify_orders_read_first() {
|
||||
// 读后写编排信号 → plan_hint 产 read_file→write_file 依赖边 → planned 把 read_file 置前。
|
||||
// File intent subset 含 read_file/write_file/patch_file 等全 File domain。
|
||||
let all = vec![
|
||||
// 故意把 write_file 放前,验证 planned 把 read_file 重排到 write_file 之前
|
||||
tool_def("write_file"),
|
||||
tool_def("patch_file"),
|
||||
tool_def("read_file"),
|
||||
tool_def("list_directory"),
|
||||
tool_def("search_files"),
|
||||
tool_def("file_info"),
|
||||
tool_def("append_file"),
|
||||
tool_def("delete_file"),
|
||||
tool_def("rename_file"),
|
||||
tool_def("http_request"), // 不在 File domain,应被滤掉
|
||||
];
|
||||
let out = filter_tool_defs_planned(&all, &Intent::File, "file", "先 read 这个文件再修改它");
|
||||
let names: Vec<&str> = out.iter().map(|d| d.function.name.as_str()).collect();
|
||||
// http_request 应被滤掉(intent 收敛)
|
||||
assert!(!names.contains(&"http_request"), "File 意图应滤掉 http_request");
|
||||
// read_file 应出现在 write_file 之前(plan_hint 编排:读组在前)
|
||||
let read_idx = names.iter().position(|n| *n == "read_file").expect("应含 read_file");
|
||||
let write_idx = names.iter().position(|n| *n == "write_file").expect("应含 write_file");
|
||||
assert!(read_idx < write_idx, "plan_hint 应把 read_file 排到 write_file 前, 实际顺序: {:?}", names);
|
||||
// 不丢工具:http_request 滤掉后剩 9 个(全 File domain 9 工具)
|
||||
assert_eq!(out.len(), 9, "planned 不应丢工具(除被 intent 滤掉), 实际: {:?}", names);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn planned_preserves_unordered_tail_for_hint_missing_tools() {
|
||||
// plan_hint 标了 read_file/write_file,但 filtered 里还有 hint 没标的工具(list_directory 等)
|
||||
// → 未被 hint 命中的按 filtered 原序追加尾部兜底(防丢工具)。
|
||||
let all = vec![
|
||||
tool_def("list_directory"),
|
||||
tool_def("search_files"),
|
||||
tool_def("write_file"),
|
||||
tool_def("read_file"),
|
||||
tool_def("patch_file"),
|
||||
tool_def("file_info"),
|
||||
tool_def("append_file"),
|
||||
tool_def("delete_file"),
|
||||
tool_def("rename_file"),
|
||||
];
|
||||
let out = filter_tool_defs_planned(&all, &Intent::File, "file", "先 read 文件再修改 write_file");
|
||||
let names: Vec<&str> = out.iter().map(|d| d.function.name.as_str()).collect();
|
||||
// 全部 9 个工具都应保留(hint 未标的不丢)
|
||||
assert_eq!(out.len(), 9, "未命中 hint 的工具应追加尾部兜底, 不丢, 实际: {:?}", names);
|
||||
// read_file 仍置前(命中 hint 编排)
|
||||
let read_idx = names.iter().position(|n| *n == "read_file").unwrap();
|
||||
let write_idx = names.iter().position(|n| *n == "write_file").unwrap();
|
||||
assert!(read_idx < write_idx);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn planned_registry_drift_skips_missing_names() {
|
||||
// registry 漂移:hint 标 read_file/write_file 但 all_defs 没这俩(改了名)。
|
||||
// → ordered 命中 0 个,全部走「未被 ordered 命中」追加 = filtered 原序(退 filter_tool_defs 行为)。
|
||||
let all = vec![
|
||||
tool_def("list_directory"),
|
||||
tool_def("search_files"),
|
||||
tool_def("file_info"),
|
||||
tool_def("append_file"),
|
||||
tool_def("delete_file"),
|
||||
tool_def("rename_file"),
|
||||
];
|
||||
// 这条消息会产 read→write hint,但 all_defs 里没 read_file/write_file
|
||||
let out = filter_tool_defs_planned(&all, &Intent::File, "file", "先 read 再修改 write_file");
|
||||
let flat = filter_tool_defs(&all, &Intent::File);
|
||||
// 漂移:hint 工具名都不在 → 退扁平(filtered 原序),不 panic,不丢不增。
|
||||
assert_eq!(out.len(), flat.len(), "漂移应退扁平, 实际 planned={}, flat={}", out.len(), flat.len());
|
||||
let out_names: Vec<&str> = out.iter().map(|d| d.function.name.as_str()).collect();
|
||||
let flat_names: Vec<&str> = flat.iter().map(|d| d.function.name.as_str()).collect();
|
||||
assert_eq!(out_names, flat_names, "漂移时 planned 应与 filter 扁平一致");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn planned_multi_read_single_parallel_group_preserves_all() {
|
||||
// 规则 3:读多文件(无写)→ plan_hint 产单并行组 [read_file, list_directory]。
|
||||
// 全部命中 filtered → 两个工具置前(组内序),其余 filtered 工具追加尾部。
|
||||
let all = vec![
|
||||
tool_def("patch_file"),
|
||||
tool_def("read_file"),
|
||||
tool_def("write_file"),
|
||||
tool_def("list_directory"),
|
||||
tool_def("search_files"),
|
||||
tool_def("file_info"),
|
||||
tool_def("append_file"),
|
||||
tool_def("delete_file"),
|
||||
tool_def("rename_file"),
|
||||
];
|
||||
let out = filter_tool_defs_planned(&all, &Intent::File, "file", "read 文件再看 directory 目录");
|
||||
let names: Vec<&str> = out.iter().map(|d| d.function.name.as_str()).collect();
|
||||
assert_eq!(out.len(), 9, "planned 不应丢工具");
|
||||
// read_file 和 list_directory 都在 hint 单组,应置前
|
||||
let read_idx = names.iter().position(|n| *n == "read_file").unwrap();
|
||||
let dir_idx = names.iter().position(|n| *n == "list_directory").unwrap();
|
||||
assert!(read_idx < 4 && dir_idx < 4, "组内工具应置前, 实际 read_idx={} dir_idx={}", read_idx, dir_idx);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,8 +18,16 @@ pub mod model_probe;
|
||||
// model_probe 的纯逻辑子模块(预设表加载 / 启发式推断 / 词素判定),
|
||||
// crate 内复用,private mod — 外部经 model_probe 间接访问,无直接路径需求。
|
||||
mod model_probe_helpers;
|
||||
// 单对话并行多轮 Phase 0a · 0.8 plan_hint 纯函数(扩 intent::tool_subset_for 编排语义)。
|
||||
// 依据 docs/02-架构设计/单对话并行多轮-Phase0落地路线图-2026-06-20.md 第 0.8 节。
|
||||
// 纯函数(零 IO 零状态),Phase 1 接入主 loop,LLM 调度留 Phase 2。
|
||||
pub mod plan_hint;
|
||||
pub mod openai_compat;
|
||||
pub mod openai_helpers;
|
||||
// Phase 0 (B 路线): Plan-driven 规划骨架(维度 0.8 Plan/SubTask 结构 + 0.7 规划校验/Kahn 分层)。
|
||||
// 纯函数模块(零 IO 零状态)。依据 docs/02-架构设计/单对话并行多轮-{设计,Phase0落地路线图}-2026-06-20.md。
|
||||
// Phase 1(plan_hint 接入主 loop)/Phase 2(planning)/Phase 3(并行 execution) 接入后续。
|
||||
pub mod planner;
|
||||
pub mod provider;
|
||||
pub mod router;
|
||||
// CR-30-1: 流前重试退避对外复用。complete() 的 retry_with_backoff 仍 crate 内用,
|
||||
|
||||
708
crates/df-ai/src/plan_hint.rs
Normal file
708
crates/df-ai/src/plan_hint.rs
Normal file
@@ -0,0 +1,708 @@
|
||||
//! 单对话并行多轮 — Phase 0a · 0.8 plan_hint 纯函数
|
||||
//!
|
||||
//! 依据:`docs/02-架构设计/单对话并行多轮-Phase0落地路线图-2026-06-20.md` 第 0.8 节。
|
||||
//! 上游设计:`单对话并行多轮-设计-2026-06-20.md`。
|
||||
//!
|
||||
//! ## 定位
|
||||
//! Phase 0a 的**纯函数地基**:基于用户意图(`crate::intent::Intent`)/对话上下文关键词,
|
||||
//! 产出一份「编排提示」(`PlanHint`)——告诉主 loop「这批工具调用可以并行」/「这些有
|
||||
//! 顺序依赖」。它**扩 `intent::tool_subset_for` 语义**:后者只返扁平 tool 子集,本模块
|
||||
//! 在其之上叠加「并行组 / 顺序依赖」的编排关系。
|
||||
//!
|
||||
//! ## 纯函数边界(零 IO 零状态)
|
||||
//! - 无 `.await` / 无 db / 无 provider / 无全局状态。
|
||||
//! - 输入:意图标签 + 上下文关键词(纯字符串切片)。
|
||||
//! - 输出:`PlanHint`(数据结构 + 启发式算法产物)。
|
||||
//! - **不调 LLM**:LLM 调度(plan_hint 落 prompt 让模型产 Plan)留 Phase 2;本模块是
|
||||
//! Phase 1 主 loop 接入前的「确定性降级路径」——主 loop 拿到 PlanHint 直接做编排,
|
||||
//! 无需等 Phase 2 LLM 介入也能跑通串行/并行调度。
|
||||
//!
|
||||
//! ## 与 0.7 planner.rs 的关系
|
||||
//! 本 agent 独立先做(避免等 agent A),用**自有轻量 `PlanHint` 结构**。
|
||||
//! - `PlanHint` 是「提示」层(启发式,可错,主 loop 可忽略);
|
||||
//! - `Plan`(`planner.rs`,agent A 出)是「计划」层(LLM/用户确认后的硬编排)。
|
||||
//! Phase 1 接入主 loop 时,两者字段(group/dep)对齐,主 loop 优先消费 `Plan`,无 `Plan`
|
||||
//! 时退 `PlanHint`。结构对齐工作放 Phase 1,本批互不阻塞。
|
||||
//!
|
||||
//! ## feature flag
|
||||
//! `PLAN_HINT_ENABLED` 常量占位。关时单链 ReAct=旧行为(主 loop 不读 PlanHint)。
|
||||
//! Phase 1 接入时主 loop 据 `PLAN_HINT_ENABLED` 决定是否走 plan_hint 分支。
|
||||
|
||||
// ---- feature flag 占位 ------------------------------------------------------
|
||||
|
||||
/// plan_hint 总开关。Phase 0a 先占位为 `true`(纯函数无副作用可单测)。
|
||||
/// Phase 1 接入主 loop 时,关闭则主 loop 不读 PlanHint,退单链 ReAct(旧行为)。
|
||||
/// 真正的运行时开关(配置驱动)留 Phase 1,本批用编译期常量便于单测固定行为。
|
||||
pub const PLAN_HINT_ENABLED: bool = true;
|
||||
|
||||
// ---- 数据结构 --------------------------------------------------------------
|
||||
|
||||
/// 并行组:一组可同时调度的工具名(组内无顺序约束,可并行执行)。
|
||||
///
|
||||
/// 工具名对齐 `intent::tool_subset_for` 返回的工具名常量(read_file/write_file/...,
|
||||
/// 见 `intent::ToolDomain::tools`)。空组无意义(构造时应保证非空,但 validate 兜底)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ParallelGroup {
|
||||
/// 组内工具名列表(均为 `intent` crate 内声明的注册名)。
|
||||
pub tools: Vec<&'static str>,
|
||||
}
|
||||
|
||||
impl ParallelGroup {
|
||||
/// 构造一个并行组。空 tools 允许构造(validate 阶段标记非法),调用方应保证非空。
|
||||
pub fn new(tools: Vec<&'static str>) -> Self {
|
||||
Self { tools }
|
||||
}
|
||||
|
||||
/// 组内工具数。
|
||||
pub fn len(&self) -> usize {
|
||||
self.tools.len()
|
||||
}
|
||||
|
||||
/// 是否为空组。
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.tools.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// 顺序依赖边:`from` 必须先于 `to` 完成(A → B)。
|
||||
///
|
||||
/// 用于表达「读后写」「先查后改」等强顺序约束。`from`/`to` 是工具名
|
||||
/// (或更细的工具调用标签,Phase 2 LLM 产 Plan 时可携带调用 id;本批仅工具名粒度)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SequentialDep {
|
||||
/// 前置工具(先执行)。
|
||||
pub from: &'static str,
|
||||
/// 后置工具(待 from 完成后执行)。
|
||||
pub to: &'static str,
|
||||
}
|
||||
|
||||
impl SequentialDep {
|
||||
/// 构造一条顺序依赖边。
|
||||
pub fn new(from: &'static str, to: &'static str) -> Self {
|
||||
Self { from, to }
|
||||
}
|
||||
}
|
||||
|
||||
/// 编排提示(轻量 PlanHint)。
|
||||
///
|
||||
/// - `groups`:可并行执行的分组(每组内工具无序,可同时调度)。
|
||||
/// - `sequence`:跨组的顺序依赖边(DAG 边)。
|
||||
///
|
||||
/// **合法 PlanHint 约束**(与 0.7 Kahn 拓扑对齐):
|
||||
/// 1. `groups` 内各组 `tools` 非空;
|
||||
/// 2. `sequence` 不含自环(`from != to`);
|
||||
/// 3. `sequence` 无重复边(去重);
|
||||
/// 4. `sequence` 构成的有向图**无环**(DAG)——`validate` 用 Kahn 判定。
|
||||
///
|
||||
/// validate 失败时主 loop 应**丢弃** PlanHint 退单链(不能据此调度,否则死锁/无限循环)。
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct PlanHint {
|
||||
/// 并行组列表。空 `Vec` 表示「无并行提示」(单链或全顺序)。
|
||||
pub groups: Vec<ParallelGroup>,
|
||||
/// 顺序依赖边列表。空 `Vec` 表示「无跨组顺序约束」(组间全并行)。
|
||||
pub sequence: Vec<SequentialDep>,
|
||||
}
|
||||
|
||||
impl PlanHint {
|
||||
/// 空提示(无并行、无顺序)。主 loop 见到此值应退旧行为。
|
||||
pub fn empty() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// 是否为空提示(无任何编排信息)。
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.groups.is_empty() && self.sequence.is_empty()
|
||||
}
|
||||
|
||||
/// 工具总数(所有并行组工具去重后的并集大小)。
|
||||
pub fn tool_count(&self) -> usize {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for g in &self.groups {
|
||||
for t in &g.tools {
|
||||
seen.insert(*t);
|
||||
}
|
||||
}
|
||||
seen.len()
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 校验(Kahn 拓扑判 DAG 无环,对齐 0.7) --------------------------------
|
||||
|
||||
/// PlanHint 合法性校验结果。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum HintValidity {
|
||||
/// 合法,可交付主 loop 调度。
|
||||
Ok,
|
||||
/// 非法(附原因)。主 loop 应丢弃 PlanHint 退单链。
|
||||
Invalid(&'static str),
|
||||
}
|
||||
|
||||
/// 校验 PlanHint 合法性(纯函数)。
|
||||
///
|
||||
/// 检查四项(见 `PlanHint` 文档约束):
|
||||
/// 1. 组内非空;
|
||||
/// 2. 无自环(`from != to`);
|
||||
/// 3. 无重复边;
|
||||
/// 4. **Kahn 拓扑判无环**(与 0.7 `Plan::validate` 同算法,这里在 PlanHint 层独立实现,
|
||||
/// 避免依赖 agent A 的 planner.rs;Phase 1 接入时若 planner 已稳定可抽公共函数)。
|
||||
///
|
||||
/// 返回 `Invalid(reason)` 时 reason 是静态原因标签(便于日志/断言)。
|
||||
pub fn validate(hint: &PlanHint) -> HintValidity {
|
||||
// 1. 组内非空
|
||||
for g in &hint.groups {
|
||||
if g.tools.is_empty() {
|
||||
return HintValidity::Invalid("empty_parallel_group");
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 无自环
|
||||
for d in &hint.sequence {
|
||||
if d.from == d.to {
|
||||
return HintValidity::Invalid("self_loop_dep");
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 无重复边(同 from→to 不重复)
|
||||
let mut seen_edges = std::collections::HashSet::new();
|
||||
for d in &hint.sequence {
|
||||
if !seen_edges.insert((d.from, d.to)) {
|
||||
return HintValidity::Invalid("duplicate_dep_edge");
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Kahn 拓扑判无环
|
||||
if has_cycle(&hint.sequence) {
|
||||
return HintValidity::Invalid("cyclic_dep");
|
||||
}
|
||||
|
||||
HintValidity::Ok
|
||||
}
|
||||
|
||||
/// Kahn 算法判有向边集合是否含环(纯函数)。
|
||||
///
|
||||
/// 输入是 `SequentialDep` 边列表,节点是工具名(`&'static str`)。
|
||||
/// 返回 `true` = 有环(非法),`false` = DAG(合法)。
|
||||
///
|
||||
/// 实现:统计入度 → 入度 0 的节点入队 → BFS 出队并削减邻居入度 →
|
||||
/// 最终已出队节点数 < 总节点数 → 存在环。
|
||||
fn has_cycle(edges: &[SequentialDep]) -> bool {
|
||||
if edges.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 收集所有节点(去重)。
|
||||
let mut nodes: std::collections::HashSet<&'static str> = std::collections::HashSet::new();
|
||||
for d in edges {
|
||||
nodes.insert(d.from);
|
||||
nodes.insert(d.to);
|
||||
}
|
||||
let n = nodes.len();
|
||||
if n == 0 {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 节点 → 索引(便于用 Vec 计数)。
|
||||
let mut idx: std::collections::HashMap<&'static str, usize> =
|
||||
std::collections::HashMap::with_capacity(n);
|
||||
for (i, name) in nodes.iter().enumerate() {
|
||||
idx.insert(*name, i);
|
||||
}
|
||||
|
||||
// 入度表 + 邻接表。
|
||||
let mut indeg = vec![0usize; n];
|
||||
let mut adj: Vec<Vec<usize>> = vec![Vec::new(); n];
|
||||
for d in edges {
|
||||
let u = idx[&d.from];
|
||||
let v = idx[&d.to];
|
||||
adj[u].push(v);
|
||||
indeg[v] += 1;
|
||||
}
|
||||
|
||||
// BFS from 入度 0 节点。
|
||||
let mut queue: std::collections::VecDeque<usize> = std::collections::VecDeque::new();
|
||||
for (i, &d) in indeg.iter().enumerate() {
|
||||
if d == 0 {
|
||||
queue.push_back(i);
|
||||
}
|
||||
}
|
||||
|
||||
let mut visited = 0usize;
|
||||
while let Some(u) = queue.pop_front() {
|
||||
visited += 1;
|
||||
for &v in &adj[u] {
|
||||
indeg[v] -= 1;
|
||||
if indeg[v] == 0 {
|
||||
queue.push_back(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 访问到的节点数 < 总节点数 → 存在环(有节点入度永远不为 0)。
|
||||
visited < n
|
||||
}
|
||||
|
||||
// ---- 启发式产 PlanHint(纯函数,无 LLM) -----------------------------------
|
||||
|
||||
/// 输入:意图标签(来自 `intent::recognize`)+ 上下文关键词(原始用户消息)。
|
||||
///
|
||||
/// 关键词检测用 `str::contains`(中英文都支持,与 `intent::recognize` 一致风格)。
|
||||
/// 输出基于**确定性启发式规则**(非 LLM),规则覆盖 devflow 常见编排场景:
|
||||
///
|
||||
/// | 场景关键词 | 产出 |
|
||||
/// |---|---|
|
||||
/// | 读多个 + 写后改 | [读组 并行] → write_file/patch_file |
|
||||
/// | grep/find 后改 | search_files → write_file/patch_file |
|
||||
/// | http + 写文件 | http_request → write_file(先取再存) |
|
||||
/// | 仅读多文件 | 全并行单组 |
|
||||
/// | 单工具 / 无编排信号 | 空 PlanHint(主 loop 退单链) |
|
||||
///
|
||||
/// **重要**:这是降级路径(Phase 2 前)。LLM 产 Plan 的质量一定高于本启发式,
|
||||
/// 但本函数保证 Phase 1 主 loop 接入后**无 LLM 也能跑通基本编排**(读后写/grep 后改)。
|
||||
///
|
||||
/// `intent_label` 仅为日志/未来扩展预留(当前规则用关键词驱动,intent_label 不参与判定,
|
||||
/// 保留参数对齐 Phase 1 主 loop 调用签名:`plan_hint(&intent, &message)`)。
|
||||
pub fn plan_hint(intent_label: &str, context_keywords: &str) -> PlanHint {
|
||||
// 开关关闭:直接返空(主 loop 退单链)。Phase 1 接入时主 loop 据常量短路,这里也兜底。
|
||||
if !PLAN_HINT_ENABLED {
|
||||
return PlanHint::empty();
|
||||
}
|
||||
|
||||
let _ = intent_label; // 预留:当前规则纯关键词驱动,不读 intent。
|
||||
|
||||
let ctx = context_keywords;
|
||||
let lower = ctx.to_lowercase();
|
||||
|
||||
// ---- 规则 1:grep/搜索 后改 → search_files → write_file/patch_file ----
|
||||
let is_search = lower.contains("grep")
|
||||
|| lower.contains("搜索")
|
||||
|| lower.contains("查找")
|
||||
|| lower.contains("find ");
|
||||
let is_modify = lower.contains("修改")
|
||||
|| lower.contains("改")
|
||||
|| lower.contains("write")
|
||||
|| lower.contains("patch")
|
||||
|| lower.contains("写入")
|
||||
|| lower.contains("重命名")
|
||||
|| lower.contains("rename");
|
||||
if is_search && is_modify {
|
||||
return PlanHint {
|
||||
groups: vec![
|
||||
ParallelGroup::new(vec!["search_files"]),
|
||||
ParallelGroup::new(vec!["write_file", "patch_file", "rename_file"]),
|
||||
],
|
||||
sequence: vec![SequentialDep::new("search_files", "write_file")],
|
||||
};
|
||||
}
|
||||
|
||||
// ---- 规则 2:http 取数 + 写文件 → http_request → write_file ----
|
||||
let is_http = lower.contains("http")
|
||||
|| lower.contains("api")
|
||||
|| lower.contains("请求")
|
||||
|| lower.contains("fetch");
|
||||
if is_http && (lower.contains("保存") || lower.contains("写入") || lower.contains("write")) {
|
||||
return PlanHint {
|
||||
groups: vec![
|
||||
ParallelGroup::new(vec!["http_request"]),
|
||||
ParallelGroup::new(vec!["write_file"]),
|
||||
],
|
||||
sequence: vec![SequentialDep::new("http_request", "write_file")],
|
||||
};
|
||||
}
|
||||
|
||||
// ---- 规则 3:读多文件(无写) → 全并行单组 ----
|
||||
let read_count = [
|
||||
("read_file", lower.matches("read").count() + lower.matches("读取").count()),
|
||||
("list_directory", lower.matches("目录").count() + lower.matches("directory").count()),
|
||||
]
|
||||
.iter()
|
||||
.filter(|(_, c)| *c >= 1)
|
||||
.count();
|
||||
if read_count >= 2 && !is_modify {
|
||||
// 多种读工具信号 → 单并行组(可同时调度)
|
||||
let mut tools: Vec<&'static str> = Vec::new();
|
||||
if lower.contains("read") || lower.contains("读取") {
|
||||
tools.push("read_file");
|
||||
}
|
||||
if lower.contains("目录") || lower.contains("directory") {
|
||||
tools.push("list_directory");
|
||||
}
|
||||
if tools.len() >= 2 {
|
||||
return PlanHint {
|
||||
groups: vec![ParallelGroup::new(tools)],
|
||||
sequence: vec![],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 规则 4:读后写 → read_file → write_file/patch_file ----
|
||||
let is_read = lower.contains("read") || lower.contains("读取");
|
||||
if is_read && is_modify {
|
||||
return PlanHint {
|
||||
groups: vec![
|
||||
ParallelGroup::new(vec!["read_file"]),
|
||||
ParallelGroup::new(vec!["write_file", "patch_file"]),
|
||||
],
|
||||
sequence: vec![SequentialDep::new("read_file", "write_file")],
|
||||
};
|
||||
}
|
||||
|
||||
// ---- 兜底:无编排信号 → 空 PlanHint(主 loop 退单链) ----
|
||||
PlanHint::empty()
|
||||
}
|
||||
|
||||
// ---- 单测 -------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// --- feature flag 占位 ---
|
||||
|
||||
#[test]
|
||||
fn plan_hint_enabled_is_true_in_phase0a() {
|
||||
// Phase 0a 占位为 true,纯函数可单测。Phase 1 接入后改配置驱动。
|
||||
assert!(PLAN_HINT_ENABLED, "Phase 0a 占位应为 true");
|
||||
}
|
||||
|
||||
// --- 数据结构基本 ---
|
||||
|
||||
#[test]
|
||||
fn parallel_group_new_and_len() {
|
||||
let g = ParallelGroup::new(vec!["read_file", "write_file"]);
|
||||
assert_eq!(g.len(), 2);
|
||||
assert!(!g.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parallel_group_empty_construct_allowed_but_invalid() {
|
||||
// 空 tools 允许构造(调用方失误),validate 阶段标记非法
|
||||
let g = ParallelGroup::new(vec![]);
|
||||
assert!(g.is_empty());
|
||||
assert_eq!(g.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sequential_dep_new() {
|
||||
let d = SequentialDep::new("read_file", "write_file");
|
||||
assert_eq!(d.from, "read_file");
|
||||
assert_eq!(d.to, "write_file");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_hint_empty_is_empty() {
|
||||
let h = PlanHint::empty();
|
||||
assert!(h.is_empty());
|
||||
assert_eq!(h.tool_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_hint_tool_count_dedup_across_groups() {
|
||||
// 两组含同名工具 → tool_count 去重
|
||||
let h = PlanHint {
|
||||
groups: vec![
|
||||
ParallelGroup::new(vec!["read_file", "write_file"]),
|
||||
ParallelGroup::new(vec!["read_file", "patch_file"]),
|
||||
],
|
||||
sequence: vec![],
|
||||
};
|
||||
// read_file/write_file/patch_file = 3(去重)
|
||||
assert_eq!(h.tool_count(), 3);
|
||||
}
|
||||
|
||||
// --- plan_hint 基本编排提示 ---
|
||||
|
||||
#[test]
|
||||
fn hint_read_then_modify_yields_sequence() {
|
||||
// 规则 4:读后写 → read_file → write_file
|
||||
let h = plan_hint("code", "先 read 这个文件再修改它");
|
||||
assert!(!h.is_empty(), "读后写应产非空 hint");
|
||||
assert_eq!(h.groups.len(), 2, "应分两组(读组/改组)");
|
||||
assert!(h.sequence.iter().any(|d| d.from == "read_file" && d.to == "write_file"),
|
||||
"应含 read_file→write_file 依赖边");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hint_grep_then_modify_yields_search_first() {
|
||||
// 规则 1:grep 后改 → search_files → write_file
|
||||
let h = plan_hint("search", "grep 一下再修改 write_file");
|
||||
assert!(!h.is_empty());
|
||||
// 首组应是 search_files
|
||||
assert!(h.groups[0].tools.contains(&"search_files"));
|
||||
assert!(h.sequence.iter().any(|d| d.from == "search_files"),
|
||||
"search_files 应作为顺序依赖起点");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hint_http_then_save_yields_http_first() {
|
||||
// 规则 2:http + 保存 → http_request → write_file
|
||||
let h = plan_hint("http", "请求这个 api 并保存写入到文件");
|
||||
assert!(!h.is_empty());
|
||||
assert!(h.sequence.iter().any(|d| d.from == "http_request" && d.to == "write_file"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hint_multi_read_only_yields_single_parallel_group() {
|
||||
// 规则 3:读多文件(无写)→ 单并行组
|
||||
let h = plan_hint("file", "read 文件,再看一下 directory 目录");
|
||||
assert!(!h.is_empty());
|
||||
assert_eq!(h.groups.len(), 1, "应单并行组");
|
||||
let tools = &h.groups[0].tools;
|
||||
assert!(tools.contains(&"read_file"), "组内应含 read_file");
|
||||
assert!(tools.contains(&"list_directory"), "组内应含 list_directory");
|
||||
assert!(h.sequence.is_empty(), "全并行无顺序依赖");
|
||||
}
|
||||
|
||||
// --- 并行组识别 ---
|
||||
|
||||
#[test]
|
||||
fn parallel_group_tools_are_registry_names() {
|
||||
// 对抗:产出的工具名必须在 intent ToolDomain 注册表内(防笔误)。
|
||||
// 注:intent::ToolDomain::tools() 是 private 方法(同模块 intent.rs 内单测可用,
|
||||
// 跨模块不可访问)。本测用从 intent.rs:246-284 抄录的 registry 名常量做校验,
|
||||
// 不依赖私有方法访问(保持 plan_hint.rs 独立可单测)。若 registry 名漂移,
|
||||
// intent.rs 的 tool_domain_names_match_registry 测会先抓到,本测同步对齐。
|
||||
let registry: std::collections::HashSet<&str> = [
|
||||
// Data domain
|
||||
"list_projects", "create_project", "update_project", "delete_project",
|
||||
"bind_directory", "list_tasks", "create_task", "update_task", "advance_task",
|
||||
"delete_task", "list_ideas", "create_idea", "run_workflow", "list_trash",
|
||||
"restore_project", "purge_project", "get_project_count", "get_task_count",
|
||||
// File domain
|
||||
"read_file", "write_file", "patch_file", "list_directory", "search_files",
|
||||
"file_info", "append_file", "delete_file", "rename_file",
|
||||
// Exec domain
|
||||
"run_command",
|
||||
// Http domain
|
||||
"http_request",
|
||||
]
|
||||
.iter()
|
||||
.copied()
|
||||
.collect();
|
||||
let h = plan_hint("file", "read 再读取目录 directory");
|
||||
if !h.is_empty() {
|
||||
for g in &h.groups {
|
||||
for t in &g.tools {
|
||||
assert!(registry.contains(*t), "工具名 {} 不在 registry", t);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hint_groups_tools_non_empty_when_non_empty_hint() {
|
||||
// 非空 hint 的每组都应非空(由 plan_hint 产出保证,validate 兜底)
|
||||
let h = plan_hint("code", "read 后 write_file 修改");
|
||||
if !h.is_empty() {
|
||||
for g in &h.groups {
|
||||
assert!(!g.tools.is_empty(), "产出组的 tools 不应空");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- 顺序依赖 ---
|
||||
|
||||
#[test]
|
||||
fn hint_sequence_edges_from_less_than_to() {
|
||||
// 顺序依赖边 from/to 都是合法工具名(from 先于 to)
|
||||
let h = plan_hint("code", "read 后修改 write_file");
|
||||
for d in &h.sequence {
|
||||
assert_ne!(d.from, d.to, "依赖边不应自环");
|
||||
assert!(!d.from.is_empty());
|
||||
assert!(!d.to.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hint_no_duplicate_sequence_edge() {
|
||||
// 产出的依赖边不应重复
|
||||
let h = plan_hint("code", "read 后 write_file 修改");
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for d in &h.sequence {
|
||||
assert!(seen.insert((d.from, d.to)), "重复依赖边: {}→{}", d.from, d.to);
|
||||
}
|
||||
}
|
||||
|
||||
// --- 空 hint ---
|
||||
|
||||
#[test]
|
||||
fn hint_empty_for_plain_chat() {
|
||||
// 无编排信号 → 空 PlanHint
|
||||
let h = plan_hint("chat", "你好谢谢");
|
||||
assert!(h.is_empty(), "闲聊应产空 hint, 退单链");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hint_empty_for_unknown_intent_no_signal() {
|
||||
let h = plan_hint("unknown", "今天的天气不错");
|
||||
assert!(h.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hint_empty_when_flag_off() {
|
||||
// 开关关闭 → 恒空(主 loop 退单链)。即使有强编排信号也忽略。
|
||||
// 注:PLAN_HINT_ENABLED 是编译期常量,本测在 true 时验证 plan_hint 内部
|
||||
// 短路逻辑(若常量改 false,此测会失败提醒对齐)。
|
||||
// 用单工具避免命中多工具规则。
|
||||
let h = plan_hint("file", "read");
|
||||
// 单 read(不命中规则 3 需 ≥2 读工具,不命中规则 4 需 modify)→ 空
|
||||
assert!(h.is_empty(), "单工具无编排信号应空 hint");
|
||||
}
|
||||
|
||||
// --- validate 合法性 ---
|
||||
|
||||
#[test]
|
||||
fn validate_empty_hint_is_ok() {
|
||||
let h = PlanHint::empty();
|
||||
assert_eq!(validate(&h), HintValidity::Ok);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_normal_dag_is_ok() {
|
||||
// read_file → write_file(单边 DAG)合法
|
||||
let h = PlanHint {
|
||||
groups: vec![
|
||||
ParallelGroup::new(vec!["read_file"]),
|
||||
ParallelGroup::new(vec!["write_file"]),
|
||||
],
|
||||
sequence: vec![SequentialDep::new("read_file", "write_file")],
|
||||
};
|
||||
assert_eq!(validate(&h), HintValidity::Ok);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_empty_parallel_group_is_invalid() {
|
||||
let h = PlanHint {
|
||||
groups: vec![ParallelGroup::new(vec![])],
|
||||
sequence: vec![],
|
||||
};
|
||||
assert_eq!(validate(&h), HintValidity::Invalid("empty_parallel_group"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_self_loop_dep_is_invalid() {
|
||||
let h = PlanHint {
|
||||
groups: vec![
|
||||
ParallelGroup::new(vec!["read_file"]),
|
||||
ParallelGroup::new(vec!["write_file"]),
|
||||
],
|
||||
sequence: vec![SequentialDep::new("read_file", "read_file")],
|
||||
};
|
||||
assert_eq!(validate(&h), HintValidity::Invalid("self_loop_dep"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_duplicate_edge_is_invalid() {
|
||||
let h = PlanHint {
|
||||
groups: vec![
|
||||
ParallelGroup::new(vec!["read_file"]),
|
||||
ParallelGroup::new(vec!["write_file"]),
|
||||
],
|
||||
sequence: vec![
|
||||
SequentialDep::new("read_file", "write_file"),
|
||||
SequentialDep::new("read_file", "write_file"),
|
||||
],
|
||||
};
|
||||
assert_eq!(validate(&h), HintValidity::Invalid("duplicate_dep_edge"));
|
||||
}
|
||||
|
||||
// --- Kahn 环检测 ---
|
||||
|
||||
#[test]
|
||||
fn has_cycle_empty_edges_false() {
|
||||
assert!(!has_cycle(&[]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_cycle_single_edge_no_cycle() {
|
||||
let edges = vec![SequentialDep::new("a", "b")];
|
||||
assert!(!has_cycle(&edges));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_cycle_two_node_cycle_detected() {
|
||||
// a→b→a 环
|
||||
let edges = vec![
|
||||
SequentialDep::new("a", "b"),
|
||||
SequentialDep::new("b", "a"),
|
||||
];
|
||||
assert!(has_cycle(&edges), "a↔b 环应被检测");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_cycle_three_node_cycle_detected() {
|
||||
// a→b→c→a 环
|
||||
let edges = vec![
|
||||
SequentialDep::new("a", "b"),
|
||||
SequentialDep::new("b", "c"),
|
||||
SequentialDep::new("c", "a"),
|
||||
];
|
||||
assert!(has_cycle(&edges));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_cycle_dag_with_branch_no_cycle() {
|
||||
// a→b, a→c, b→d, c→d(菱形 DAG,无环)
|
||||
let edges = vec![
|
||||
SequentialDep::new("a", "b"),
|
||||
SequentialDep::new("a", "c"),
|
||||
SequentialDep::new("b", "d"),
|
||||
SequentialDep::new("c", "d"),
|
||||
];
|
||||
assert!(!has_cycle(&edges));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_cyclic_dep_is_invalid() {
|
||||
let h = PlanHint {
|
||||
groups: vec![
|
||||
ParallelGroup::new(vec!["read_file"]),
|
||||
ParallelGroup::new(vec!["write_file"]),
|
||||
],
|
||||
sequence: vec![
|
||||
SequentialDep::new("read_file", "write_file"),
|
||||
SequentialDep::new("write_file", "read_file"),
|
||||
],
|
||||
};
|
||||
assert_eq!(validate(&h), HintValidity::Invalid("cyclic_dep"));
|
||||
}
|
||||
|
||||
// --- 对抗/边界 ---
|
||||
|
||||
#[test]
|
||||
fn validate_check_order_self_loop_before_cycle() {
|
||||
// 自环也构成环,但 validate 应先报 self_loop_dep(更具体的错误)
|
||||
let h = PlanHint {
|
||||
groups: vec![ParallelGroup::new(vec!["a"])],
|
||||
sequence: vec![SequentialDep::new("a", "a")],
|
||||
};
|
||||
assert_eq!(validate(&h), HintValidity::Invalid("self_loop_dep"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hint_intent_label_ignored_in_phase0a() {
|
||||
// 当前规则纯关键词驱动,intent_label 不参与判定(预留 Phase 1)
|
||||
// 同关键词不同 intent_label → 同产出
|
||||
let h1 = plan_hint("code", "read 后修改 write_file");
|
||||
let h2 = plan_hint("debug", "read 后修改 write_file");
|
||||
assert_eq!(h1, h2, "Phase 0a 不读 intent_label, 产出应一致");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hint_single_tool_no_signal_is_empty() {
|
||||
// 单 read(无写无多读)→ 不命中任何规则 → 空
|
||||
let h = plan_hint("file", "读取文件");
|
||||
assert!(h.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_ignores_nodes_not_in_groups() {
|
||||
// validate 不检查 sequence 边引用的工具是否在 groups 内(Phase 0a 宽松,
|
||||
// 主 loop 自行对齐;Phase 1 接入 planner.rs 后由 Plan::validate 收口)
|
||||
let h = PlanHint {
|
||||
groups: vec![ParallelGroup::new(vec!["read_file"])],
|
||||
sequence: vec![SequentialDep::new("read_file", "write_file")],
|
||||
};
|
||||
// write_file 不在任何 group,但边合法且 DAG,validate 仍 Ok(宽松)
|
||||
assert_eq!(validate(&h), HintValidity::Ok);
|
||||
}
|
||||
}
|
||||
1019
crates/df-ai/src/planner.rs
Normal file
1019
crates/df-ai/src/planner.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -126,9 +126,70 @@ impl ScoringEngine {
|
||||
|
||||
/// 统计 text 中命中任一关键词的数量(小写匹配,兼顾中英文)
|
||||
/// 局限:纯子串匹配,不识别"不复用""无用户增长"等否定前缀,接 LLM 后由语义层修正
|
||||
/// 单字否定前缀:关键词紧邻这些字时该命中不计(「不复用」不计入「复用」正向命中)。
|
||||
///
|
||||
/// 注意「别」单字歧义大(别具/别的/特别 均非否定),故不进单字清单——
|
||||
/// 祈使否定「别」改用两字清单兜底(别去/别再/别要/别用 等,见 NEGATION_PREFIXES_2)。
|
||||
const NEGATION_PREFIXES_1: &[char] = &['不', '无', '非', '未', '没', '勿'];
|
||||
/// 两字否定前缀:紧邻关键词的前两字符为这些时该命中不计(「没有复用」「并非简单」「别去复用」)。
|
||||
const NEGATION_PREFIXES_2: &[&str] = &[
|
||||
"没有", "并非", "毫无", "不用", "不是",
|
||||
// 「别X」祈使否定:双字形式可精确匹配,规避「别具/别的/特别」等单字误伤
|
||||
"别去", "别再", "别要", "别用", "别做", "别给",
|
||||
];
|
||||
|
||||
/// 关键词在 text 的 `end` 字节位置前是否处于否定上下文(紧邻否定词)。
|
||||
///
|
||||
/// `end` 是关键词起始字节位置(由 `str::find` 返回,必在 char 边界)。取 `text[..end]`
|
||||
/// 末尾 1-2 个 char 判否定:单字否定(不/无/非/...)或两字否定(没有/并非/...)。
|
||||
///
|
||||
/// 仅取末尾 ≤2 个 char(rev().take(2) 流式),省去每次命中的 `Vec<char>` 分配。
|
||||
fn is_negated_before(text: &str, end: usize) -> bool {
|
||||
if end == 0 {
|
||||
return false;
|
||||
}
|
||||
let before = &text[..end];
|
||||
// 末尾最多 2 char,按 [次末, 末] 顺序收集(n=1 时仅末位)
|
||||
let tail: Vec<char> = before.chars().rev().take(2).collect();
|
||||
if tail.is_empty() {
|
||||
return false;
|
||||
}
|
||||
// 单字否定:末位 char(tail[0]) 命中
|
||||
if NEGATION_PREFIXES_1.contains(&tail[0]) {
|
||||
return true;
|
||||
}
|
||||
// 两字否定:需凑齐 2 char,顺序为 [末, 次末] 反转回原序 [次末, 末]
|
||||
if tail.len() == 2 {
|
||||
let last2: String = [tail[1], tail[0]].iter().collect();
|
||||
if NEGATION_PREFIXES_2.contains(&last2.as_str()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// 统计 text 中命中任一关键词的数量(小写匹配,兼顾中英文)。
|
||||
///
|
||||
/// 否定前缀处理:关键词紧邻否定词(不复用/无增长/非紧急...)时该命中不计,
|
||||
/// 避免「不复用」反向计入「复用」正向分。双重否定(「不是不复用」)属罕见边缘,
|
||||
/// 不处理(接 LLM 后语义层兜底)。
|
||||
fn count_any(text: &str, keywords: &[&str]) -> usize {
|
||||
let lower = text.to_lowercase();
|
||||
keywords.iter().filter(|kw| lower.contains(*kw)).count()
|
||||
keywords
|
||||
.iter()
|
||||
.filter(|kw| {
|
||||
// 至少一个非否定上下文的出现即计为命中(同一词多次出现,一次有效即可)
|
||||
let mut idx = 0usize;
|
||||
while let Some(rel) = lower[idx..].find(*kw) {
|
||||
let abs = idx + rel;
|
||||
if !is_negated_before(&lower, abs) {
|
||||
return true;
|
||||
}
|
||||
idx = abs + kw.len();
|
||||
}
|
||||
false
|
||||
})
|
||||
.count()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -233,4 +294,35 @@ mod tests {
|
||||
println!(" 可行性={:.2} 影响力={:.2} 紧急度={:.2} 综合={:.2}", s.feasibility, s.impact, s.urgency, s.overall);
|
||||
assert!(s.feasibility >= 0.0 && s.impact >= 0.0 && s.urgency >= 0.0, "所有维度应≥0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn s9_single_char_negation_suppresses_hit() {
|
||||
// 「不复用已有组件」:复用 被「不」否定不计,已有 命中 → feasibility 低于「复用已有组件」(复用+已有 双命中)
|
||||
let neg = make_idea("不复用", "不复用已有组件", Priority::Medium, vec![]);
|
||||
let pos = make_idea("复用", "复用已有组件", Priority::Medium, vec![]);
|
||||
let s_neg = ScoringEngine::compute_default(&neg);
|
||||
let s_pos = ScoringEngine::compute_default(&pos);
|
||||
println!("\n[s9] 单字否定前缀抑制命中");
|
||||
println!(" 不复用已有组件 feasibility={:.2} 复用已有组件 feasibility={:.2}", s_neg.feasibility, s_pos.feasibility);
|
||||
assert!(
|
||||
s_neg.feasibility < s_pos.feasibility,
|
||||
"单字否定应抑制正向命中致 feasibility 更低: neg={} pos={}",
|
||||
s_neg.feasibility, s_pos.feasibility
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn s10_two_char_negation_suppresses_hit() {
|
||||
// 「没有复用」:两字否定 没有 抑制 复用 命中;「确实复用」无否定 复用 命中
|
||||
let neg = make_idea("t", "没有复用", Priority::Medium, vec![]);
|
||||
let pos = make_idea("t", "确实复用", Priority::Medium, vec![]);
|
||||
let s_neg = ScoringEngine::compute_default(&neg);
|
||||
let s_pos = ScoringEngine::compute_default(&pos);
|
||||
println!("\n[s10] 两字否定前缀抑制命中");
|
||||
println!(" 没有复用 feasibility={:.2} 确实复用 feasibility={:.2}", s_neg.feasibility, s_pos.feasibility);
|
||||
assert!(
|
||||
s_neg.feasibility < s_pos.feasibility,
|
||||
"两字否定「没有」应抑制命中: neg={} pos={}", s_neg.feasibility, s_pos.feasibility
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,10 @@ description = "DevFlow MCP Server — 对外暴露数据层工具(JSON-RPC 2.0 o
|
||||
[dependencies]
|
||||
df-storage = { path = "../df-storage" }
|
||||
df-types = { path = "../df-types" }
|
||||
# df-nodes: advance_task 复用推进链唯一 status 写入路径(advance_task_atomic,
|
||||
# 含 is_valid_state+can_transition+同态拒绝三层校验),避免 MCP 直调底层
|
||||
# advance_status_atomic 绕过状态机(防止外部客户端非法跳态 todo→done)。
|
||||
df-nodes = { path = "../df-nodes" }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tokio.workspace = true
|
||||
|
||||
@@ -108,7 +108,7 @@ pub fn all_tools() -> Vec<&'static ToolSpec> {
|
||||
spec("list_tasks", "列出所有未删除任务(可按 project_id/status 过滤)", object_schema(json!({"project_id": opt_str_field("按项目过滤(可空)"), "status": opt_str_field("按状态过滤(todo/in_progress/in_review/testing/blocked/done/cancelled,可空)")}), &[]), Low, list_tasks),
|
||||
spec("create_task", "创建任务(Medium 风险,默认允许+审计日志)", object_schema(json!({"project_id": str_field("项目 ID"), "title": str_field("标题"), "description": str_field("描述"), "priority": int_field("优先级(可空,默认 0)")}), &["project_id", "title", "description"]), Medium, create_task),
|
||||
spec("update_task", "更新任务(整体替换)", object_schema(json!({"id": str_field("任务 ID"), "project_id": str_field("项目 ID"), "title": str_field("标题"), "description": str_field("描述")}), &["id", "project_id", "title", "description"]), Medium, update_task),
|
||||
spec("advance_task", "推进任务状态(需传当前 status 与目标 status,Medium 风险+审计日志)", object_schema(json!({"id": str_field("任务 ID"), "from": str_field("当前 status"), "to": str_field("目标 status")}), &["id", "from", "to"]), Medium, advance_task),
|
||||
spec("advance_task", "推进任务状态(传目标 status,内部读当前态+状态机校验,Medium 风险+审计日志)", object_schema(json!({"id": str_field("任务 ID"), "to": str_field("目标 status(todo/in_progress/in_review/testing/blocked/done/cancelled)")}), &["id", "to"]), Medium, advance_task),
|
||||
spec("delete_task", "软删任务(进回收站)——High 风险,默认拒绝,请在 DevFlow 应用内执行", object_schema(json!({"id": str_field("任务 ID")}), &["id"]), High, delete_task),
|
||||
// ─── 灵感 ───
|
||||
spec("list_ideas", "列出所有想法/灵感", object_schema(json!({}), &[]), Low, list_ideas),
|
||||
@@ -466,27 +466,22 @@ fn advance_task(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
|
||||
Ok(v) => v,
|
||||
Err(r) => return Box::pin(std::future::ready(r)),
|
||||
};
|
||||
let from = match arg_str(&args, "from") {
|
||||
Ok(v) => v,
|
||||
Err(r) => return Box::pin(std::future::ready(r)),
|
||||
};
|
||||
// 不再接收 from:推进链内部读当前态 + 三层校验(is_valid_state / can_transition /
|
||||
// 同态拒绝),避免外部客户端直调 advance_status_atomic 绕过状态机非法跳态(todo→done)。
|
||||
let to = match arg_str(&args, "to") {
|
||||
Ok(v) => v,
|
||||
Err(r) => return Box::pin(std::future::ready(r)),
|
||||
};
|
||||
medium_audit("advance_task", &format!("{id}: {from} -> {to}"));
|
||||
medium_audit("advance_task", &format!("{id} -> {to}"));
|
||||
Box::pin(async move {
|
||||
// bump_rounds:退回转换(in_review->in_progress / testing->in_review)累计 review_rounds
|
||||
let bump = matches!(
|
||||
(from.as_str(), to.as_str()),
|
||||
("in_review", "in_progress") | ("testing", "in_review")
|
||||
);
|
||||
let repo = TaskRepo::new(&db);
|
||||
match repo.advance_status_atomic(&id, &from, &to, bump).await {
|
||||
Ok(Some(updated)) => json_ok(json!({ "id": id, "task": updated })),
|
||||
Ok(None) => CallToolResult::error(format!(
|
||||
"推进失败(状态已变或任务不存在/回收站): 期望 {from},实际可能不同"
|
||||
)),
|
||||
// 复用推进链唯一 status 写入路径(与 IPC advance_task 同源):
|
||||
// - is_valid_state + can_transition + 同态拒绝三层校验
|
||||
// - CAS 防 TOCTOU
|
||||
// - is_regression 自动判定 bump review_rounds(取代旧内联 bump 副本)
|
||||
// - 错误类型(NotFound/Validation/InvalidState)由 thiserror Display 串化
|
||||
match df_nodes::task_advance_node::advance_task_atomic(&repo, &id, &to).await {
|
||||
Ok(updated) => json_ok(json!({ "id": id, "task": updated })),
|
||||
Err(e) => err_str(e),
|
||||
}
|
||||
})
|
||||
@@ -536,6 +531,7 @@ fn create_idea(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
|
||||
promoted_to: None,
|
||||
ai_analysis: None,
|
||||
scores: None,
|
||||
related_ids: None,
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
};
|
||||
@@ -579,6 +575,7 @@ fn update_idea(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
|
||||
promoted_to: existing.promoted_to,
|
||||
ai_analysis: existing.ai_analysis,
|
||||
scores: existing.scores,
|
||||
related_ids: existing.related_ids.clone(),
|
||||
created_at: existing.created_at,
|
||||
updated_at: now,
|
||||
};
|
||||
|
||||
@@ -92,7 +92,7 @@ impl Node for AiNode {
|
||||
});
|
||||
|
||||
// 决策 a(AiNode 自审闭环):有 task_id 时落产出到 task.output_json。
|
||||
// 走通用 update_field(output_json 已在 tasks 白名单,crud.rs:342-346),
|
||||
// 走通用 update_field(output_json 已在 tasks 白名单,crud/mod.rs update_field 宏(impl_repo!)),
|
||||
// 非 status 状态机收口字段,合法可写。落库失败不阻断节点返回(产出已在内存,工作流可继续)。
|
||||
if let Some(task_id) = ctx.config.get("task_id").and_then(|v| v.as_str()) {
|
||||
if let Ok(json_str) = serde_json::to_string(&output) {
|
||||
|
||||
@@ -60,7 +60,7 @@ pub(crate) struct ResolvedProvider {
|
||||
/// 2. **老路径兼容(过渡)**:config 显式含 `base_url` + `api_key` 明文(老调用方)→ 走原路径
|
||||
/// + `tracing::warn!`(明文 api_key 经 config 注入已废弃)。兼容期保留,后续移除。
|
||||
/// 3. **空兜底**:无 provider_id 也无明文 → 取 `is_default=true` 首条 provider(对齐
|
||||
/// src-tauri commands/idea.rs:265-279 build_default_provider 模式),走同 resolve+ensure 链。
|
||||
/// src-tauri commands/idea.rs build_default_provider 模式),走同 resolve+ensure 链。
|
||||
/// 无任何 provider → 友好错误「未配置 AI Provider」。
|
||||
///
|
||||
/// model:config["model"] 非空用之,否则 record.default_model,再否则 "gpt-4o-mini" 占位。
|
||||
|
||||
@@ -11,7 +11,7 @@ use df_types::events::{SelectType, WorkflowEvent};
|
||||
|
||||
// 抽离的纯函数/常量(glob 引入,tests `use super::*` 间接可见)
|
||||
#[allow(unused_imports)]
|
||||
use crate::human_node_helpers::{contains_reject, is_reject_decision, REJECT_KEYWORDS};
|
||||
use crate::human_node_helpers::{contains_reject, is_reject_decision};
|
||||
|
||||
/// 人工审批节点(阻塞节点)
|
||||
pub struct HumanNode;
|
||||
@@ -286,7 +286,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn request_is_emitted_to_bus() {
|
||||
// B-03b-R8: 验证 HumanNode 真发出 HumanApprovalRequest 到事件总线
|
||||
// (R6 修复前 :41 send 缺 await → Request 未进 channel,此测会超时失败)
|
||||
// (R6 修复前 execute 内 event_bus.send().await 缺 await → Request 未进 channel,此测会超时失败)
|
||||
let bus = EventBus::new();
|
||||
let mut rx = bus.subscribe(); // subscribe 先于 execute send(broadcast 不回放)
|
||||
let ctx = make_ctx(&bus, "exec-1", "node-h", json!({ "timeout_secs": 1 }));
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
pub mod ai_node;
|
||||
pub mod ai_self_review_node;
|
||||
#[allow(dead_code)]
|
||||
mod ai_node_helpers;
|
||||
pub mod human_node;
|
||||
mod human_node_helpers;
|
||||
|
||||
@@ -376,7 +376,9 @@ mod tests {
|
||||
//
|
||||
// workflow.rs 的 spawn 闭包回调依赖三段纯计算:
|
||||
// 1. template_for(target) 选模板(已由 task_workflow_templates.rs 测试覆盖)
|
||||
// 2. regression_target(target) 推失败退回态(workflow.rs 私有,此处镜像锁定契约)
|
||||
// 2. regression_target(target) 推失败退回态(已收敛到 task_state_machine::regression_target
|
||||
// pub 契约,workflow.rs 与本测试均从此导入,单一可信源;映射表由 task_state_machine
|
||||
// 测试模块锁定)
|
||||
// 3. advance_task_atomic(repo, tid, to) 落库(本模块核心,下方覆盖各回调 to)
|
||||
//
|
||||
// workflow.rs spawn 闭包集成测试需 mock AppState/AppHandle/EventBus,df-nodes crate
|
||||
@@ -384,60 +386,23 @@ mod tests {
|
||||
// 唯一未覆盖的是「闭包组装 + tracing」胶水代码(非业务逻辑)。
|
||||
// ============================================================
|
||||
|
||||
/// 镜像 workflow.rs::regression_target(workflow.rs L44-54 私有函数)的失败退回映射。
|
||||
///
|
||||
/// 此处是契约镜像(非导入):若 workflow.rs 改映射而忘同步,这里会红,提醒两处一致。
|
||||
/// 映射:testing→in_review / in_review→in_progress / in_progress→None / 其他→None。
|
||||
///
|
||||
/// 为什么 in_progress → None? 状态机禁止 in_progress→todo
|
||||
/// (task_state_machine.rs backward_to_todo_rejected),失败退回 todo 会被
|
||||
/// advance_task_atomic 的 InvalidState 拦截;改为 None 则回调跳过推进,
|
||||
/// 留 in_progress 等人介入(决策 CR-13-O1-b)。
|
||||
///
|
||||
/// 为什么不 pub regression_target 复用? 它是 workflow.rs(app crate)的私有函数,
|
||||
/// df-nodes 不依赖 app crate(单向依赖:app → df-nodes)。把退回映射当 df-nodes 的
|
||||
/// 业务契约之一镜像锁定,符合「推进链业务逻辑落 df-nodes」(D-260616-03)定位。
|
||||
fn regression_target(target: &str) -> Option<&'static str> {
|
||||
match target {
|
||||
"testing" => Some("in_review"),
|
||||
"in_review" => Some("in_progress"),
|
||||
// in_progress 失败不自动退回(状态机不允许→todo),留 in_progress 等人介入
|
||||
"in_progress" => None,
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 镜像 workflow.rs spawn 闭包的回调决策(completed→target / failed→regression_target)。
|
||||
/// 抽出纯函数便于直接断言,与 workflow.rs L267-273 的 match 逻辑一一对应。
|
||||
/// 抽出纯函数便于直接断言,与 workflow.rs 闭包内 match 逻辑一一对应。
|
||||
///
|
||||
/// regression_target 现已收敛为 df-nodes::task_state_machine::regression_target 的
|
||||
/// pub 契约(单一可信源),此处直接复用,不再镜像副本(防 DRY 漂移)。
|
||||
///
|
||||
/// 返回 Option<String>(非 &str):completed 分支回传入的 target_status(非 'static),
|
||||
/// failed 分支回 regression_target 的常量。统一 String 避免生命周期冲突。
|
||||
/// failed 分支回 regression_target 的 &'static str 常量。统一 String 避免生命周期冲突。
|
||||
fn callback_advance_target(status: &str, target_status: &str) -> Option<String> {
|
||||
match status {
|
||||
"completed" => Some(target_status.to_string()),
|
||||
"failed" => regression_target(target_status).map(String::from),
|
||||
"failed" => crate::task_state_machine::regression_target(target_status)
|
||||
.map(String::from),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regression_target_mapping_matches_contract() {
|
||||
// 锁定 workflow.rs ②-4 失败退回映射表(逐项)。
|
||||
assert_eq!(regression_target("testing"), Some("in_review"));
|
||||
assert_eq!(regression_target("in_review"), Some("in_progress"));
|
||||
// CR-13-O1-b: in_progress 失败不退回(状态机禁止→todo),留 in_progress 等人介入
|
||||
assert_eq!(regression_target("in_progress"), None);
|
||||
// done 是终态前向目标,失败无可退态(workflow.rs 注释:done→None)
|
||||
assert_eq!(regression_target("done"), None);
|
||||
// todo 是起点态无可退;blocked/cancelled 非推进链目标 → None
|
||||
assert_eq!(regression_target("todo"), None);
|
||||
assert_eq!(regression_target("blocked"), None);
|
||||
assert_eq!(regression_target("cancelled"), None);
|
||||
// 未知态防御性 → None
|
||||
assert_eq!(regression_target("merged"), None);
|
||||
assert_eq!(regression_target(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn callback_completed_advances_to_target() {
|
||||
// ②-3:工作流 completed → 推进到 target_status(无论 target 是哪个)
|
||||
|
||||
@@ -122,6 +122,31 @@ pub fn is_regression(from: &str, to: &str) -> bool {
|
||||
matches!((from, to), (IN_REVIEW, IN_PROGRESS) | (TESTING, IN_REVIEW))
|
||||
}
|
||||
|
||||
/// 工作流联动任务「失败退一步」的目标态映射(F-260616-06 ②-4)。
|
||||
///
|
||||
/// 工作流失败时,任务不应停留在失败前向目标态,需回退到上一闸门重做。映射表:
|
||||
/// - testing → in_review(测试失败退回重审)
|
||||
/// - in_review → in_progress(审查失败退回修改)
|
||||
/// - in_progress → None(状态机禁止 in_progress→todo,见 [`can_transition`] 的
|
||||
/// backward_to_todo_rejected;失败退回 todo 会被 advance_task_atomic
|
||||
/// 的 InvalidState 拦截,任务原地保留 in_progress 无信号。改为 None
|
||||
/// 则回调跳过推进,留 in_progress 等人介入 — 决策 CR-13-O1-b)
|
||||
/// - 其他(done/blocked/cancelled/todo 等非推进链前向目标) → None(无退回映射)
|
||||
///
|
||||
/// 设计归属:此映射属状态机业务契约(失败退一步的目标态),居 df-nodes 与
|
||||
/// [`can_transition`] / [`is_regression`] 同位,统一管理推进链的状态语义。
|
||||
/// 此前该函数在 workflow.rs(app crate 私有) 与 task_advance_node.rs(测试镜像)
|
||||
/// 各存一份(DRY 漂移),现收敛为单一可信源,调用方均从此导入。
|
||||
pub fn regression_target(target: &str) -> Option<&'static str> {
|
||||
match target {
|
||||
TESTING => Some(IN_REVIEW),
|
||||
IN_REVIEW => Some(IN_PROGRESS),
|
||||
// in_progress 失败不自动退回(状态机不允许→todo),留 in_progress 等人介入
|
||||
IN_PROGRESS => None,
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 单元测试 — 转换矩阵 + 边界
|
||||
// ============================================================
|
||||
@@ -231,6 +256,44 @@ mod tests {
|
||||
assert!(!is_regression(TESTING, IN_PROGRESS));
|
||||
}
|
||||
|
||||
// ---------- regression_target(失败退一步映射) ----------
|
||||
|
||||
#[test]
|
||||
fn regression_target_mapping_matches_contract() {
|
||||
// 锁定 ②-4 失败退回映射表(逐项)
|
||||
assert_eq!(regression_target(TESTING), Some(IN_REVIEW));
|
||||
assert_eq!(regression_target(IN_REVIEW), Some(IN_PROGRESS));
|
||||
// CR-13-O1-b: in_progress 失败不退回(状态机禁止→todo),留 in_progress 等人介入
|
||||
assert_eq!(regression_target(IN_PROGRESS), None);
|
||||
// done 是终态前向目标,失败无可退态 → None
|
||||
assert_eq!(regression_target(DONE), None);
|
||||
// todo 是起点态无可退;blocked/cancelled 非推进链目标 → None
|
||||
assert_eq!(regression_target(TODO), None);
|
||||
assert_eq!(regression_target(BLOCKED), None);
|
||||
assert_eq!(regression_target(CANCELLED), None);
|
||||
// 未知态防御性 → None
|
||||
assert_eq!(regression_target("merged"), None);
|
||||
assert_eq!(regression_target(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regression_target_returned_state_is_valid_constant() {
|
||||
// 防漂移的真正增量(上例已逐项锁值,本例锁性质):
|
||||
// 任何非 None 返回值都必须是 ALL_STATES 内的合法状态常量。
|
||||
// 一旦 regression_target 误返回非状态字符串(拼写漂移 / 拼了历史态如 "merged"),
|
||||
// 该字符串过不了 is_valid_state,本测试立即失败定位。
|
||||
// 覆盖全部 7 态入参,不依赖上例已锁定的具体期望值。
|
||||
for target in ALL_STATES {
|
||||
match regression_target(target) {
|
||||
Some(ret) => assert!(
|
||||
is_valid_state(ret),
|
||||
"regression_target({target:?}) 返回 {ret:?} 不是合法状态常量(ALL_STATES 漂移)"
|
||||
),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- is_valid_state ----------
|
||||
|
||||
#[test]
|
||||
@@ -253,4 +316,47 @@ mod tests {
|
||||
fn all_states_has_seven_entries() {
|
||||
assert_eq!(ALL_STATES.len(), 7);
|
||||
}
|
||||
|
||||
// ---------- 双源一致性校验 ----------
|
||||
//
|
||||
// task_state_machine 维护一份独立的 7 态字符串常量集(本模块 ALL_STATES / TODO / ...),
|
||||
// 与 df-types::TaskStatus::as_str / valid_values() 同语义但不复用枚举(独立模块定位,
|
||||
// 推进链判定不耦合存储枚举)。两源无编译期绑定,若任一处改拼写或增删状态值,
|
||||
// 状态机判定会与数据库 status 列存值静默脱节(按常量判合法但落库值对不上)。
|
||||
//
|
||||
// 此处锁定「值必须严格一致」契约:ALL_STATES 必须与 df-types::TaskStatus::valid_values()
|
||||
// 逐项相等(顺序与值)。df-nodes 单向依赖 df-types,无环依赖风险。一旦未来某方改动
|
||||
// 导致两源漂移,本测试立即失败并精确定位,避免运行时静默脱节。
|
||||
|
||||
#[test]
|
||||
fn all_states_matches_df_types_valid_values() {
|
||||
use df_types::types::TaskStatus;
|
||||
let expected: &[&str] = TaskStatus::valid_values();
|
||||
// 逐项比对(顺序敏感):位置 i 的值必须与 df-types 一致,失败时报具体索引便于定位
|
||||
assert_eq!(
|
||||
ALL_STATES.len(),
|
||||
expected.len(),
|
||||
"ALL_STATES 与 TaskStatus::valid_values 长度不一致(状态值数量漂移)"
|
||||
);
|
||||
for (i, (got, want)) in ALL_STATES.iter().zip(expected.iter()).enumerate() {
|
||||
assert_eq!(
|
||||
got, want,
|
||||
"ALL_STATES[{i}] = {got:?} 与 TaskStatus::valid_values()[{i}] = {want:?} 不一致(状态值漂移)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_state_constant_matches_task_status_as_str() {
|
||||
// 逐变体锁定:常量值必须与对应 TaskStatus 变体的 as_str 产出严格相等。
|
||||
// 比上面整表比对更精细,单点漂移能直接指向具体常量行,加速定位。
|
||||
use df_types::types::TaskStatus;
|
||||
assert_eq!(TODO, TaskStatus::Todo.as_str());
|
||||
assert_eq!(IN_PROGRESS, TaskStatus::InProgress.as_str());
|
||||
assert_eq!(IN_REVIEW, TaskStatus::InReview.as_str());
|
||||
assert_eq!(TESTING, TaskStatus::Testing.as_str());
|
||||
assert_eq!(DONE, TaskStatus::Done.as_str());
|
||||
assert_eq!(BLOCKED, TaskStatus::Blocked.as_str());
|
||||
assert_eq!(CANCELLED, TaskStatus::Cancelled.as_str());
|
||||
}
|
||||
}
|
||||
|
||||
299
crates/df-storage/src/crud/idea_eval_repo.rs
Normal file
299
crates/df-storage/src/crud/idea_eval_repo.rs
Normal file
@@ -0,0 +1,299 @@
|
||||
//! 灵感评估历史 Repo:IdeaEvalRepo(追加型审计表 idea_evaluations,V22)
|
||||
//!
|
||||
//! 每次灵感 AI 评估追加一行快照(version 单调递增),保留评估历史可追溯。
|
||||
//! 对齐 [`KnowledgeEventsRepo`](crate::crud::KnowledgeEventsRepo) 模式:
|
||||
//! `impl_repo!` 宏生成基础 CRUD(insert/get_by_id/list_all/query/update_field/delete/
|
||||
//! update_full)+ 额外专用方法 `list_by_idea`(按 idea_id 过滤,version DESC 排序)。
|
||||
//!
|
||||
//! # ⚠️ 宏 list_all / query 不可用(表无 created_at)
|
||||
//!
|
||||
//! 本表时间列是 `evaluated_at`(非 `created_at`),但 `impl_repo!` 宏生成的
|
||||
//! `list_all` / `query` 硬编码 `ORDER BY created_at DESC`(见 crud/mod.rs 宏内
|
||||
//! `ORDER BY created_at DESC` 字面量)。误调 `state.idea_eval.list_all()` 或
|
||||
//! `state.idea_eval.query(...)` 会触发 SQLite "no such column: created_at",运行时崩。
|
||||
//! **跨灵感按时间倒序浏览评估历史须走专用方法 [`IdeaEvalRepo::list_recent_idea_evals`]**
|
||||
//! (按 evaluated_at DESC + limit 钳制 200),对标 `KnowledgeEventsRepo::list_recent`
|
||||
//! 对 knowledge_events 表的同款兜底处理(那张表亦无 created_at,时间列名是 timestamp)。
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use rusqlite::{params, Connection, OptionalExtension, Row};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use df_types::error::Result;
|
||||
|
||||
use crate::db::Database;
|
||||
use crate::models::IdeaEvaluationRecord;
|
||||
|
||||
use super::impl_repo;
|
||||
use super::{now_millis_str, storage_err, validate_column_name};
|
||||
|
||||
// ============================================================
|
||||
// from_row 辅助函数
|
||||
// ============================================================
|
||||
|
||||
/// 按 name 取 idea_evaluations 表 8 列 → IdeaEvaluationRecord。
|
||||
fn idea_eval_from_row(row: &Row<'_>) -> std::result::Result<IdeaEvaluationRecord, rusqlite::Error> {
|
||||
Ok(IdeaEvaluationRecord {
|
||||
id: row.get("id")?,
|
||||
idea_id: row.get("idea_id")?,
|
||||
version: row.get("version")?,
|
||||
ai_analysis: row.get("ai_analysis")?,
|
||||
scores: row.get("scores")?,
|
||||
score: row.get("score")?,
|
||||
evaluated_by: row.get("evaluated_by")?,
|
||||
evaluated_at: row.get("evaluated_at")?,
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Repo 实现
|
||||
// ============================================================
|
||||
|
||||
impl_repo!(
|
||||
/// 灵感评估历史表 CRUD(追加型审计表,只增不改)
|
||||
IdeaEvalRepo,
|
||||
IdeaEvaluationRecord,
|
||||
"idea_evaluations",
|
||||
from_row => |row| idea_eval_from_row(row),
|
||||
insert => |conn, rec| {
|
||||
conn.execute(
|
||||
"INSERT INTO idea_evaluations (id, idea_id, version, ai_analysis, scores, score, evaluated_by, evaluated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
||||
params![
|
||||
rec.id, rec.idea_id, rec.version, rec.ai_analysis,
|
||||
rec.scores, rec.score, rec.evaluated_by, rec.evaluated_at
|
||||
],
|
||||
)
|
||||
},
|
||||
update => |_conn, _rec| {
|
||||
// 加固(审计不可篡改):追加型审计表语义禁止 UPDATE(历史快照只增不改,版本单调递增)。
|
||||
// 宏(impl_repo!)要求 update 闭包生成 update_full,但本表设计上仅 insert 追加新版本。
|
||||
// 若有调用方误调 update_full → 返回 Err(SQLITE_ERROR)而非真执行 UPDATE,杜绝历史被
|
||||
// 静默篡改(否则审计追溯失真,版本单调性被破坏)。Err 经 storage_err 映射为
|
||||
// Error::Storage,调用方拿到 Err 可发现误用。注:update_field 走宏通用 SQL 路径,
|
||||
// 不经此闭包,故本表 update_field 同样不应被调用(调用方约束,非编译期保证)。
|
||||
//
|
||||
// 显式标注 rusqlite::Result<usize>(宏吃闭包 expr 不能写 -> 返回类型,改 let 绑定标注):
|
||||
// 闭包返回 Err 经 update_full 的 .map_err(storage_err)? → Err(Error::Storage),
|
||||
// affected=usize 的类型锚点确保宏内 `affected > 0` 编译。
|
||||
let res: rusqlite::Result<usize> = Err(rusqlite::Error::SqliteFailure(
|
||||
rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_ERROR),
|
||||
Some("idea_evaluations 为追加型审计表,禁止 UPDATE(仅 insert 追加新版本)".to_string()),
|
||||
));
|
||||
res
|
||||
}
|
||||
);
|
||||
|
||||
impl IdeaEvalRepo {
|
||||
/// 按 idea_id 查询全部评估历史(version DESC,最新版本在前)。
|
||||
///
|
||||
/// 对标 [`KnowledgeEventsRepo::list_by_knowledge`](crate::crud::KnowledgeEventsRepo::list_by_knowledge)
|
||||
/// 的 spawn_blocking + prepare + query_map 模式。前端取最新评估直接取返回 Vec 首元素。
|
||||
pub async fn list_by_idea(&self, idea_id: &str) -> Result<Vec<IdeaEvaluationRecord>> {
|
||||
let conn = self.conn.clone();
|
||||
let idea_id = idea_id.to_owned();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let mut stmt = guard
|
||||
.prepare(
|
||||
"SELECT id,idea_id,version,ai_analysis,scores,score,evaluated_by,evaluated_at \
|
||||
FROM idea_evaluations WHERE idea_id = ?1 ORDER BY version DESC",
|
||||
)
|
||||
.map_err(storage_err)?;
|
||||
let rows = stmt
|
||||
.query_map(params![idea_id], |row| idea_eval_from_row(row))
|
||||
.map_err(storage_err)?;
|
||||
let mut results = Vec::new();
|
||||
for r in rows {
|
||||
results.push(r.map_err(storage_err)?);
|
||||
}
|
||||
Ok(results)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
/// 跨灵感列最近 N 条评估历史(全表 evaluated_at DESC,top-N)。
|
||||
///
|
||||
/// **专用兜底方法**:本表时间列名是 `evaluated_at` 而非 `created_at`,但
|
||||
/// `impl_repo!` 宏生成的 `query()` / `list_all()` 硬编码 `ORDER BY created_at`
|
||||
/// (见 crud/mod.rs 宏内 `ORDER BY created_at DESC` 字面量),误调
|
||||
/// `state.idea_eval.list_all()` 或 `state.idea_eval.query(...)` 会触发 SQLite
|
||||
/// "no such column: created_at"。本方法走专用 SELECT 绕过宏硬编码,供需要跨灵感
|
||||
/// 按评估时间倒序浏览历史的调用方使用(对标 [`KnowledgeEventsRepo::list_recent`]
|
||||
/// 对 knowledge_events 表的同款兜底处理——那张表同样无 created_at,时间列名是 timestamp)。
|
||||
/// limit 上限钳制 200,防前端恶意/失误传超大值。
|
||||
pub async fn list_recent_idea_evals(&self, limit: u32) -> Result<Vec<IdeaEvaluationRecord>> {
|
||||
let conn = self.conn.clone();
|
||||
// 钳制 limit 防滥用(最大 200)
|
||||
let safe_limit = limit.min(200) as i64;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let mut stmt = guard
|
||||
.prepare(
|
||||
"SELECT id,idea_id,version,ai_analysis,scores,score,evaluated_by,evaluated_at \
|
||||
FROM idea_evaluations ORDER BY evaluated_at DESC LIMIT ?1",
|
||||
)
|
||||
.map_err(storage_err)?;
|
||||
let rows = stmt
|
||||
.query_map(params![safe_limit], |row| idea_eval_from_row(row))
|
||||
.map_err(storage_err)?;
|
||||
let mut results = Vec::new();
|
||||
for r in rows {
|
||||
results.push(r.map_err(storage_err)?);
|
||||
}
|
||||
Ok(results)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 单元测试 — IdeaEvalRepo 内存 DB(insert + list_by_idea 排序)
|
||||
// ============================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::Database;
|
||||
|
||||
fn erec(id: &str, idea_id: &str, version: i64, score: Option<f64>) -> IdeaEvaluationRecord {
|
||||
IdeaEvaluationRecord {
|
||||
id: id.to_string(),
|
||||
idea_id: idea_id.to_string(),
|
||||
version,
|
||||
ai_analysis: Some("{\"ok\":true}".to_string()),
|
||||
scores: Some("{\"overall\":1}".to_string()),
|
||||
score,
|
||||
evaluated_by: Some("glm-4".to_string()),
|
||||
evaluated_at: "1700000000000".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn setup_repo() -> IdeaEvalRepo {
|
||||
let db = Database::open_in_memory().await.expect("open_in_memory");
|
||||
IdeaEvalRepo::new(&db)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_by_idea_orders_version_desc() {
|
||||
let repo = setup_repo().await;
|
||||
repo.insert(erec("e1", "idea_a", 1, Some(0.5))).await.unwrap();
|
||||
repo.insert(erec("e2", "idea_a", 3, Some(0.9))).await.unwrap();
|
||||
repo.insert(erec("e3", "idea_a", 2, Some(0.7))).await.unwrap();
|
||||
repo.insert(erec("e4", "idea_b", 1, Some(0.1))).await.unwrap();
|
||||
|
||||
let list = repo.list_by_idea("idea_a").await.unwrap();
|
||||
assert_eq!(list.len(), 3, "仅 idea_a 的 3 条评估");
|
||||
let versions: Vec<i64> = list.iter().map(|r| r.version).collect();
|
||||
assert_eq!(versions, vec![3, 2, 1], "version DESC: 最新在前");
|
||||
|
||||
let list_b = repo.list_by_idea("idea_b").await.unwrap();
|
||||
assert_eq!(list_b.len(), 1);
|
||||
assert_eq!(list_b[0].version, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_by_idea_empty_for_unknown_idea() {
|
||||
let repo = setup_repo().await;
|
||||
let list = repo.list_by_idea("nope").await.unwrap();
|
||||
assert!(list.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_recent_idea_evals_orders_evaluated_at_desc_and_clamps_limit() {
|
||||
// 兜底方法:跨灵感按 evaluated_at DESC + limit 钳制 200(规避宏硬编码
|
||||
// ORDER BY created_at 致本表崩)。evaluated_at 为毫秒字符串,字典序与时间序一致。
|
||||
let repo = setup_repo().await;
|
||||
// erec 默认 evaluated_at 同值,这里覆盖以验证排序
|
||||
let mut a = erec("e1", "idea_a", 1, Some(0.5));
|
||||
a.evaluated_at = "1700000000001".to_string();
|
||||
let mut b = erec("e2", "idea_b", 1, Some(0.6));
|
||||
b.evaluated_at = "1700000000003".to_string();
|
||||
let mut c = erec("e3", "idea_a", 2, Some(0.7));
|
||||
c.evaluated_at = "1700000000002".to_string();
|
||||
repo.insert(a).await.unwrap();
|
||||
repo.insert(b).await.unwrap();
|
||||
repo.insert(c).await.unwrap();
|
||||
|
||||
// limit=10 取全部,按 evaluated_at DESC(跨灵感)
|
||||
let list = repo.list_recent_idea_evals(10).await.unwrap();
|
||||
assert_eq!(list.len(), 3);
|
||||
let times: Vec<&str> = list.iter().map(|r| r.evaluated_at.as_str()).collect();
|
||||
assert_eq!(times, vec!["1700000000003", "1700000000002", "1700000000001"]);
|
||||
|
||||
// limit=2 截断到前 2
|
||||
let list2 = repo.list_recent_idea_evals(2).await.unwrap();
|
||||
assert_eq!(list2.len(), 2);
|
||||
assert_eq!(list2[0].id, "e2");
|
||||
assert_eq!(list2[1].id, "e3");
|
||||
|
||||
// limit 钳制:超大值被压到 200(不会崩,仅返回实际行数)
|
||||
let list_big = repo.list_recent_idea_evals(u32::MAX).await.unwrap();
|
||||
assert_eq!(list_big.len(), 3, "u32::MAX 钳制到 200,但表仅 3 行");
|
||||
|
||||
// 空表
|
||||
let repo_empty = setup_repo().await;
|
||||
assert!(repo_empty.list_recent_idea_evals(10).await.unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn insert_then_get_by_id_roundtrip() {
|
||||
let repo = setup_repo().await;
|
||||
let rec = erec("e1", "idea_a", 1, Some(0.42));
|
||||
let id = repo.insert(rec.clone()).await.unwrap();
|
||||
assert_eq!(id, "e1");
|
||||
let got = repo.get_by_id("e1").await.unwrap().expect("记录存在");
|
||||
assert_eq!(got.idea_id, "idea_a");
|
||||
assert_eq!(got.version, 1);
|
||||
assert!((got.score.unwrap() - 0.42).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn optional_fields_persist_none() {
|
||||
let repo = setup_repo().await;
|
||||
let rec = IdeaEvaluationRecord {
|
||||
id: "e1".to_string(),
|
||||
idea_id: "idea_a".to_string(),
|
||||
version: 1,
|
||||
ai_analysis: None,
|
||||
scores: None,
|
||||
score: None,
|
||||
evaluated_by: None,
|
||||
evaluated_at: "1700000000000".to_string(),
|
||||
};
|
||||
repo.insert(rec).await.unwrap();
|
||||
let got = repo.get_by_id("e1").await.unwrap().expect("记录存在");
|
||||
assert!(got.ai_analysis.is_none());
|
||||
assert!(got.scores.is_none());
|
||||
assert!(got.score.is_none());
|
||||
assert!(got.evaluated_by.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_full_rejected_for_append_only_audit() {
|
||||
// 加固(审计不可篡改):追加型审计表禁止 UPDATE。update_full 应返 Err 而非真执行
|
||||
// (防误用静默篡改历史快照,破坏版本单调性 + 审计追溯)。调用方应走 insert 追加新版本。
|
||||
let repo = setup_repo().await;
|
||||
repo.insert(erec("e1", "idea_a", 1, Some(0.5))).await.unwrap();
|
||||
// 试 update_full(篡改 version 1 → 2 + 改 score)
|
||||
let mut rec = repo.get_by_id("e1").await.unwrap().expect("记录存在");
|
||||
rec.version = 2;
|
||||
rec.score = Some(0.99);
|
||||
let res = repo.update_full(&rec).await;
|
||||
assert!(
|
||||
res.is_err(),
|
||||
"update_full 应被拒(追加型审计表禁止 UPDATE),实际: {:?}",
|
||||
res
|
||||
);
|
||||
// 原记录未被篡改(version 仍 1,score 仍 0.5)
|
||||
let got = repo.get_by_id("e1").await.unwrap().expect("记录存在");
|
||||
assert_eq!(got.version, 1, "原记录 version 不应被 update_full 篡改");
|
||||
assert!(
|
||||
(got.score.unwrap() - 0.5).abs() < 1e-9,
|
||||
"原记录 score 不应被 update_full 篡改"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -17,22 +17,38 @@ use super::{now_millis_str, storage_err, validate_column_name};
|
||||
// 知识库 SELECT 列清单(防 COLS 漂移)
|
||||
// ============================================================
|
||||
|
||||
/// `knowledges` 表对应 `KnowledgeRecord` 14 个字段的列名(顺序与结构体一致)。
|
||||
/// `knowledges` 表对应 `KnowledgeRecord` 15 个字段的列名(顺序与结构体一致)。
|
||||
///
|
||||
/// 多处 `search`/`search_vector` 内联 COLS 串的 DRY 收口(CR-260615-03):集中一处定义,
|
||||
/// 配合下方 `KNOWLEDGE_COL_COUNT` 断言,任一处加列漏改会被测试 `test_knowledge_cols_matches_record`
|
||||
/// 立即捕获(`knowledge_from_row` 按 name 取列,SELECT 漏列会运行时 rusqlite 报错,故提前断言)。
|
||||
const KNOWLEDGE_COLS: &str = "id,kind,title,content,tags,status,confidence,reuse_count,verified,source_project,source_ref,reasoning,created_at,updated_at";
|
||||
///
|
||||
/// V23 新增 embedding_status 列(嵌入失败可补偿重试),已纳入列清单 + 计数。
|
||||
const KNOWLEDGE_COLS: &str = "id,kind,title,content,tags,status,confidence,reuse_count,verified,source_project,source_ref,reasoning,embedding_status,created_at,updated_at";
|
||||
/// `KnowledgeRecord` 字段数(与上面列清单的逗号分隔项数一致,被测试断言)。
|
||||
/// 仅测试期消费(列漂移断言);保留为非 `cfg(test)` 以便测试外的阅读者一眼看到字段数。
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
const KNOWLEDGE_COL_COUNT: usize = 14;
|
||||
const KNOWLEDGE_COL_COUNT: usize = 15;
|
||||
/// `search_vector` 用的列清单:KNOWLEDGE_COLS + embedding(余弦计算用,不入 KnowledgeRecord)。
|
||||
const KNOWLEDGE_COLS_WITH_EMBEDDING: &str = concat!(
|
||||
"id,kind,title,content,tags,status,confidence,reuse_count,verified,",
|
||||
"source_project,source_ref,reasoning,created_at,updated_at,embedding"
|
||||
"source_project,source_ref,reasoning,embedding_status,created_at,updated_at,embedding"
|
||||
);
|
||||
|
||||
/// `ideas` 表对应 `IdeaRecord` 14 个字段的列名(顺序与结构体一致)。
|
||||
///
|
||||
/// 同 KNOWLEDGE_COLS 的列漂移防护(CR-260615-03):idea 表 INSERT/UPDATE/from_row 三处
|
||||
/// 各写一份列名串,加列须三处同步(如 V24 加 related_ids 即三处齐改),漏一处
|
||||
/// 只在运行时 rusqlite 报错(INSERT 列数与参数数不匹配 / from_row 取不到列)。集中一处
|
||||
/// 定义 + 配合 `IDEA_COL_COUNT` 断言 + 测试 `test_idea_cols_matches_record`,加列漏改即捕获。
|
||||
///
|
||||
/// V24 新增 related_ids 列(灵感间关联关系持久化打底),已纳入列清单 + 计数。
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
const IDEA_COLS: &str = "id,title,description,status,priority,score,tags,source,promoted_to,ai_analysis,scores,related_ids,created_at,updated_at";
|
||||
/// `IdeaRecord` 字段数(与上面列清单的逗号分隔项数一致,被测试断言)。
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
const IDEA_COL_COUNT: usize = 14;
|
||||
|
||||
// ============================================================
|
||||
// 向量工具 — embedding BLOB 序列化 + 余弦相似度
|
||||
// ============================================================
|
||||
@@ -74,6 +90,7 @@ fn idea_from_row(row: &Row<'_>) -> std::result::Result<IdeaRecord, rusqlite::Err
|
||||
promoted_to: row.get("promoted_to")?,
|
||||
ai_analysis: row.get("ai_analysis")?,
|
||||
scores: row.get("scores")?,
|
||||
related_ids: row.get("related_ids")?,
|
||||
created_at: row.get("created_at")?,
|
||||
updated_at: row.get("updated_at")?,
|
||||
})
|
||||
@@ -93,6 +110,7 @@ fn knowledge_from_row(row: &Row<'_>) -> std::result::Result<KnowledgeRecord, rus
|
||||
source_project: row.get("source_project")?,
|
||||
source_ref: row.get("source_ref")?,
|
||||
reasoning: row.get("reasoning")?,
|
||||
embedding_status: row.get("embedding_status")?,
|
||||
created_at: row.get("created_at")?,
|
||||
updated_at: row.get("updated_at")?,
|
||||
})
|
||||
@@ -121,22 +139,22 @@ impl_repo!(
|
||||
from_row => |row| idea_from_row(row),
|
||||
insert => |conn, rec| {
|
||||
conn.execute(
|
||||
"INSERT INTO ideas (id, title, description, status, priority, score, tags, source, promoted_to, ai_analysis, scores, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
|
||||
"INSERT INTO ideas (id, title, description, status, priority, score, tags, source, promoted_to, ai_analysis, scores, related_ids, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)",
|
||||
params![
|
||||
rec.id, rec.title, rec.description, rec.status, rec.priority,
|
||||
rec.score, rec.tags, rec.source, rec.promoted_to, rec.ai_analysis,
|
||||
rec.scores, rec.created_at, rec.updated_at
|
||||
rec.scores, rec.related_ids, rec.created_at, rec.updated_at
|
||||
],
|
||||
)
|
||||
},
|
||||
update => |conn, rec| {
|
||||
conn.execute(
|
||||
"UPDATE ideas SET title = ?1, description = ?2, status = ?3, priority = ?4, score = ?5, tags = ?6, source = ?7, promoted_to = ?8, ai_analysis = ?9, scores = ?10, updated_at = ?11 WHERE id = ?12",
|
||||
"UPDATE ideas SET title = ?1, description = ?2, status = ?3, priority = ?4, score = ?5, tags = ?6, source = ?7, promoted_to = ?8, ai_analysis = ?9, scores = ?10, related_ids = ?11, updated_at = ?12 WHERE id = ?13",
|
||||
params![
|
||||
rec.title, rec.description, rec.status, rec.priority,
|
||||
rec.score, rec.tags, rec.source, rec.promoted_to, rec.ai_analysis,
|
||||
rec.scores, rec.updated_at, rec.id
|
||||
rec.scores, rec.related_ids, rec.updated_at, rec.id
|
||||
],
|
||||
)
|
||||
}
|
||||
@@ -151,23 +169,23 @@ impl_repo!(
|
||||
insert => |conn, rec| {
|
||||
let verified = if rec.verified { 1i32 } else { 0i32 };
|
||||
conn.execute(
|
||||
"INSERT INTO knowledges (id, kind, title, content, tags, status, confidence, reuse_count, verified, source_project, source_ref, reasoning, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)",
|
||||
"INSERT INTO knowledges (id, kind, title, content, tags, status, confidence, reuse_count, verified, source_project, source_ref, reasoning, embedding_status, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)",
|
||||
params![
|
||||
rec.id, rec.kind, rec.title, rec.content, rec.tags, rec.status, rec.confidence,
|
||||
rec.reuse_count, verified, rec.source_project, rec.source_ref, rec.reasoning,
|
||||
rec.created_at, rec.updated_at
|
||||
rec.embedding_status, rec.created_at, rec.updated_at
|
||||
],
|
||||
)
|
||||
},
|
||||
update => |conn, rec| {
|
||||
let verified = if rec.verified { 1i32 } else { 0i32 };
|
||||
conn.execute(
|
||||
"UPDATE knowledges SET kind = ?1, title = ?2, content = ?3, tags = ?4, status = ?5, confidence = ?6, reuse_count = ?7, verified = ?8, source_project = ?9, source_ref = ?10, reasoning = ?11, updated_at = ?12 WHERE id = ?13",
|
||||
"UPDATE knowledges SET kind = ?1, title = ?2, content = ?3, tags = ?4, status = ?5, confidence = ?6, reuse_count = ?7, verified = ?8, source_project = ?9, source_ref = ?10, reasoning = ?11, embedding_status = ?12, updated_at = ?13 WHERE id = ?14",
|
||||
params![
|
||||
rec.kind, rec.title, rec.content, rec.tags, rec.status, rec.confidence,
|
||||
rec.reuse_count, verified, rec.source_project, rec.source_ref, rec.reasoning,
|
||||
rec.updated_at, rec.id
|
||||
rec.embedding_status, rec.updated_at, rec.id
|
||||
],
|
||||
)
|
||||
}
|
||||
@@ -223,17 +241,15 @@ impl KnowledgeRepo {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let mut stmt = guard
|
||||
.prepare(
|
||||
"SELECT id,kind,title,content,tags,status,confidence,reuse_count,verified,\
|
||||
source_project,source_ref,reasoning,created_at,updated_at \
|
||||
FROM knowledges WHERE status = ?1
|
||||
.prepare(&format!(
|
||||
"SELECT {KNOWLEDGE_COLS} FROM knowledges WHERE status = ?1
|
||||
ORDER BY CASE confidence
|
||||
WHEN 'high' THEN 3
|
||||
WHEN 'medium' THEN 2
|
||||
WHEN 'low' THEN 1
|
||||
ELSE 0
|
||||
END DESC, created_at DESC",
|
||||
)
|
||||
))
|
||||
.map_err(storage_err)?;
|
||||
let rows = stmt
|
||||
.query_map(params![status], |row| knowledge_from_row(row))
|
||||
@@ -270,6 +286,7 @@ impl KnowledgeRepo {
|
||||
/// 写入向量嵌入(BLOB = Vec<f32> 小端字节序列化)
|
||||
///
|
||||
/// embedding 列不进 KnowledgeRecord(IPC 不需要传向量给前端),专用方法读写。
|
||||
/// V23:同时把 embedding_status 置 'done'(成功标记),供补偿重试逻辑判别。
|
||||
pub async fn set_embedding(&self, id: &str, embedding: &[f32]) -> Result<bool> {
|
||||
let conn = self.conn.clone();
|
||||
let id = id.to_owned();
|
||||
@@ -278,7 +295,7 @@ impl KnowledgeRepo {
|
||||
let guard = conn.blocking_lock();
|
||||
let affected = guard
|
||||
.execute(
|
||||
"UPDATE knowledges SET embedding = ?1 WHERE id = ?2",
|
||||
"UPDATE knowledges SET embedding = ?1, embedding_status = 'done' WHERE id = ?2",
|
||||
params![blob, id],
|
||||
)
|
||||
.map_err(storage_err)?;
|
||||
@@ -288,6 +305,54 @@ impl KnowledgeRepo {
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
/// 标记嵌入生成失败(embedding_status = 'failed'),供补偿重试逻辑定位。
|
||||
///
|
||||
/// 失败时不写 embedding(保持 NULL,检索侧 `embedding IS NOT NULL` 自然跳过该条走 LIKE)。
|
||||
/// 幂等:重复标记 failed 无副作用(同值覆写)。
|
||||
pub async fn mark_embedding_failed(&self, id: &str) -> Result<bool> {
|
||||
let conn = self.conn.clone();
|
||||
let id = id.to_owned();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let affected = guard
|
||||
.execute(
|
||||
"UPDATE knowledges SET embedding_status = 'failed' WHERE id = ?1",
|
||||
params![id],
|
||||
)
|
||||
.map_err(storage_err)?;
|
||||
Ok(affected > 0)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
/// 列出 embedding_status = 'failed' 的已发布知识(补偿重试入口用)。
|
||||
///
|
||||
/// 仅返回 published(候选/归档不参与检索,重试无意义),按 created_at 升序(老条目优先补)。
|
||||
pub async fn list_failed_embeddings(&self) -> Result<Vec<KnowledgeRecord>> {
|
||||
let conn = self.conn.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let mut stmt = guard
|
||||
.prepare(&format!(
|
||||
"SELECT {KNOWLEDGE_COLS} FROM knowledges \
|
||||
WHERE status = 'published' AND embedding_status = 'failed' \
|
||||
ORDER BY created_at ASC"
|
||||
))
|
||||
.map_err(storage_err)?;
|
||||
let rows = stmt
|
||||
.query_map([], |row| knowledge_from_row(row))
|
||||
.map_err(storage_err)?;
|
||||
let mut results = Vec::new();
|
||||
for r in rows {
|
||||
results.push(r.map_err(storage_err)?);
|
||||
}
|
||||
Ok(results)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
/// 向量检索: 加载全部 published 且有 embedding 的记录,纯 Rust 余弦相似度取 top-N
|
||||
///
|
||||
/// 返回 (记录, 相似度分数)。数据量 <50k 时暴力遍历 <50ms,够用;
|
||||
@@ -308,7 +373,7 @@ impl KnowledgeRepo {
|
||||
let query_vec = query_vec.to_vec();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
// 显式列: 14 个 KnowledgeRecord 字段 + embedding(余弦计算用,不入 KnowledgeRecord)
|
||||
// 显式列: 15 个 KnowledgeRecord 字段(含 embedding_status)+ embedding(余弦计算用,不入 KnowledgeRecord)
|
||||
let mut stmt = guard
|
||||
.prepare(&format!(
|
||||
"SELECT {KNOWLEDGE_COLS_WITH_EMBEDDING} FROM knowledges WHERE status = 'published' AND embedding IS NOT NULL"
|
||||
@@ -347,17 +412,15 @@ impl KnowledgeRepo {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let mut stmt = guard
|
||||
.prepare(
|
||||
"SELECT id,kind,title,content,tags,status,confidence,reuse_count,verified,\
|
||||
source_project,source_ref,reasoning,created_at,updated_at \
|
||||
FROM knowledges WHERE status != 'archived'
|
||||
.prepare(&format!(
|
||||
"SELECT {KNOWLEDGE_COLS} FROM knowledges WHERE status != 'archived'
|
||||
ORDER BY CASE confidence
|
||||
WHEN 'high' THEN 3
|
||||
WHEN 'medium' THEN 2
|
||||
WHEN 'low' THEN 1
|
||||
ELSE 0
|
||||
END DESC, created_at DESC",
|
||||
)
|
||||
))
|
||||
.map_err(storage_err)?;
|
||||
let rows = stmt
|
||||
.query_map([], |row| knowledge_from_row(row))
|
||||
@@ -379,7 +442,9 @@ impl KnowledgeRepo {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let mut stmt = guard
|
||||
.prepare("SELECT id,kind,title,content,tags,status,confidence,reuse_count,verified,source_project,source_ref,reasoning,created_at,updated_at FROM knowledges WHERE status = 'published' ORDER BY reuse_count DESC LIMIT ?1")
|
||||
.prepare(&format!(
|
||||
"SELECT {KNOWLEDGE_COLS} FROM knowledges WHERE status = 'published' ORDER BY reuse_count DESC LIMIT ?1"
|
||||
))
|
||||
.map_err(storage_err)?;
|
||||
let rows = stmt
|
||||
.query_map(params![limit_i], |row| knowledge_from_row(row))
|
||||
@@ -539,6 +604,23 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// IDEA_COLS 列数须等于 IDEA_COL_COUNT(任一处漂移:加列漏改 INSERT/UPDATE/from_row
|
||||
/// 三处之一 → 立即失败)。`idea_from_row` 按 name 取列,SELECT/INSERT 漏列会在运行时被
|
||||
/// rusqlite 报错;此断言提前到测试期捕获(本次 V24 加 related_ids 已改 3 处的回归保险)。
|
||||
#[test]
|
||||
fn test_idea_cols_matches_record() {
|
||||
let count = IDEA_COLS.split(',').count();
|
||||
assert_eq!(
|
||||
count, IDEA_COL_COUNT,
|
||||
"IDEA_COLS({count}列) ≠ IDEA_COL_COUNT({IDEA_COL_COUNT}); \
|
||||
修改一处须同步另一处(INSERT/UPDATE/from_row/IDEA_COLS/IDEA_COL_COUNT 五处)"
|
||||
);
|
||||
// 每个列名须能被 split 出来(防末尾多逗号 / 空段)
|
||||
for col in IDEA_COLS.split(',') {
|
||||
assert!(!col.is_empty(), "IDEA_COLS 含空列名段");
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 向量纯函数 ----------
|
||||
|
||||
#[test]
|
||||
@@ -631,6 +713,7 @@ mod tests {
|
||||
source_project: Some("proj-1".to_string()),
|
||||
source_ref: Some("conv:c1".to_string()),
|
||||
reasoning: None,
|
||||
embedding_status: None,
|
||||
created_at: "1700000000000".to_string(),
|
||||
updated_at: "1700000000000".to_string(),
|
||||
}
|
||||
@@ -868,4 +951,105 @@ mod tests {
|
||||
let results = repo.search_vector(&[1.0, 0.0], 10).await.unwrap();
|
||||
assert!(results.is_empty());
|
||||
}
|
||||
|
||||
// ---------- V23 embedding_status 补偿重试 ----------
|
||||
|
||||
#[tokio::test]
|
||||
async fn set_embedding_marks_status_done() {
|
||||
// V23:set_embedding 成功写入时应同步置 embedding_status='done'
|
||||
let repo = setup_repo().await;
|
||||
repo.insert(krec("k1", "t", "c", "lesson", "published", None, 0))
|
||||
.await
|
||||
.unwrap();
|
||||
repo.set_embedding("k1", &[1.0, 0.0]).await.unwrap();
|
||||
let rec = repo.get_by_id("k1").await.unwrap().expect("记录存在");
|
||||
assert_eq!(rec.embedding_status.as_deref(), Some("done"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mark_embedding_failed_sets_status() {
|
||||
let repo = setup_repo().await;
|
||||
repo.insert(krec("k1", "t", "c", "lesson", "published", None, 0))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(repo.mark_embedding_failed("k1").await.unwrap());
|
||||
let rec = repo.get_by_id("k1").await.unwrap().expect("记录存在");
|
||||
assert_eq!(rec.embedding_status.as_deref(), Some("failed"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mark_embedding_failed_idempotent() {
|
||||
// 重复标记 failed 无副作用
|
||||
let repo = setup_repo().await;
|
||||
repo.insert(krec("k1", "t", "c", "lesson", "published", None, 0))
|
||||
.await
|
||||
.unwrap();
|
||||
repo.mark_embedding_failed("k1").await.unwrap();
|
||||
assert!(repo.mark_embedding_failed("k1").await.unwrap());
|
||||
let rec = repo.get_by_id("k1").await.unwrap().unwrap();
|
||||
assert_eq!(rec.embedding_status.as_deref(), Some("failed"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mark_embedding_failed_missing_row_returns_false() {
|
||||
let repo = setup_repo().await;
|
||||
// 不存在的 id → affected=0
|
||||
let ok = repo.mark_embedding_failed("ghost").await.unwrap();
|
||||
assert!(!ok);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_failed_embeddings_only_returns_published_failed() {
|
||||
let repo = setup_repo().await;
|
||||
// k1:published + failed → 应列出
|
||||
repo.insert(krec("k1", "t", "c", "lesson", "published", None, 0))
|
||||
.await
|
||||
.unwrap();
|
||||
repo.mark_embedding_failed("k1").await.unwrap();
|
||||
// k2:candidate + failed → 不应列出(候选不参与检索,重试无意义)
|
||||
repo.insert(krec("k2", "t", "c", "lesson", "candidate", None, 0))
|
||||
.await
|
||||
.unwrap();
|
||||
repo.mark_embedding_failed("k2").await.unwrap();
|
||||
// k3:published + done → 不应列出
|
||||
repo.insert(krec("k3", "t", "c", "lesson", "published", None, 0))
|
||||
.await
|
||||
.unwrap();
|
||||
repo.set_embedding("k3", &[1.0, 0.0]).await.unwrap();
|
||||
// k4:published + 未标记(NULL)→ 不应列出
|
||||
repo.insert(krec("k4", "t", "c", "lesson", "published", None, 0))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let failed = repo.list_failed_embeddings().await.unwrap();
|
||||
let ids: Vec<_> = failed.iter().map(|r| r.id.as_str()).collect();
|
||||
assert_eq!(ids, vec!["k1"], "只应返回 published + failed 的条目");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_failed_embeddings_empty_when_none_failed() {
|
||||
let repo = setup_repo().await;
|
||||
repo.insert(krec("k1", "t", "c", "lesson", "published", None, 0))
|
||||
.await
|
||||
.unwrap();
|
||||
repo.set_embedding("k1", &[1.0, 0.0]).await.unwrap();
|
||||
let failed = repo.list_failed_embeddings().await.unwrap();
|
||||
assert!(failed.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn failed_retry_flow_done_after_set_embedding() {
|
||||
// 补偿重试完整流程:failed → set_embedding 成功 → done(不再出现在 list_failed)
|
||||
let repo = setup_repo().await;
|
||||
repo.insert(krec("k1", "t", "c", "lesson", "published", None, 0))
|
||||
.await
|
||||
.unwrap();
|
||||
repo.mark_embedding_failed("k1").await.unwrap();
|
||||
assert_eq!(repo.list_failed_embeddings().await.unwrap().len(), 1);
|
||||
// 重试成功
|
||||
repo.set_embedding("k1", &[0.5, 0.5]).await.unwrap();
|
||||
assert!(repo.list_failed_embeddings().await.unwrap().is_empty());
|
||||
let rec = repo.get_by_id("k1").await.unwrap().unwrap();
|
||||
assert_eq!(rec.embedding_status.as_deref(), Some("done"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,12 +8,14 @@
|
||||
//! - [`mod@task_repo`]:TaskRepo(含 advance_status_atomic 状态机收口)
|
||||
//! - [`mod@conversation_repo`]:AiProviderRepo/AiConversationRepo/AiToolExecutionRepo
|
||||
//! - [`mod@idea_repo`]:IdeaRepo/KnowledgeRepo/KnowledgeEventsRepo + 向量工具
|
||||
//! - [`mod@idea_eval_repo`]:IdeaEvalRepo(灵感评估历史追加型审计表 idea_evaluations,V22)
|
||||
//! - [`mod@message_repo`]:AiMessageRepo(F-260619-03 消息拆分存储,全专用方法不走宏)
|
||||
//!
|
||||
//! re-export(`pub use ...::*`)保持 `df_storage::crud::XxxRepo` /
|
||||
//! `df_storage::crud::is_allowed_column` 路径不变,**调用方零改动**。
|
||||
|
||||
mod conversation_repo;
|
||||
mod idea_eval_repo;
|
||||
mod idea_repo;
|
||||
mod message_repo;
|
||||
mod project_repo;
|
||||
@@ -21,6 +23,7 @@ mod settings;
|
||||
mod task_repo;
|
||||
|
||||
pub use conversation_repo::*;
|
||||
pub use idea_eval_repo::*;
|
||||
pub use idea_repo::*;
|
||||
pub use message_repo::*;
|
||||
pub use project_repo::*;
|
||||
@@ -37,8 +40,9 @@ pub use task_repo::*;
|
||||
///
|
||||
/// 约束:`$record` 须实现 `Clone`(`update_full` 内部 clone 后丢给 spawn_blocking)。
|
||||
///
|
||||
/// 宏通过 `#[macro_use]`(见本文件末 `mod` 声明上方的文本注入)对各子模块可见,
|
||||
/// 子模块 `impl_repo!(...)` 直接调用。`from_row`/`insert`/`update` 体内引用的 helper
|
||||
/// 宏通过文件末 `pub(crate) use impl_repo;`(Rust 2e textual scope 重导出,非
|
||||
/// `#[macro_use]`)对各子模块可见,子模块 `use super::impl_repo;` 后直接调用。
|
||||
/// `from_row`/`insert`/`update` 体内引用的 helper
|
||||
/// (storage_err/now_millis_str/validate_column_name 及各 from_row)须在调用处可见。
|
||||
macro_rules! impl_repo {
|
||||
(
|
||||
@@ -203,6 +207,21 @@ pub(crate) fn storage_err<E: std::string::ToString>(e: E) -> df_types::error::Er
|
||||
df_types::error::Error::Storage(e.to_string())
|
||||
}
|
||||
|
||||
/// 判定扁平化后的存储错误是否为 SQLite 唯一约束冲突(SQLite extended code 2067 /
|
||||
/// SQLITE_CONSTRAINT_UNIQUE)。
|
||||
///
|
||||
/// 设计取舍:存储层在 [`storage_err`] 已将原始 `rusqlite::Error` 扁平化为 `String`
|
||||
/// (Error::Storage(String)),调用方拿不到结构化 `rusqlite::Error::SqliteFailure` 的
|
||||
/// `ErrorCode`,无法直接按 code 判定。故在此 flatten 边界集中收口检测逻辑,避免每个
|
||||
/// 调用方各自 `e.to_string().contains("UNIQUE constraint failed")` 散落脆弱匹配。
|
||||
///
|
||||
/// `"UNIQUE constraint failed"` 是 SQLite C 库对 2067 固定输出的文案(大写为 C 库常量,
|
||||
/// 不随 SQLite 版本/locale 变化),大小写不敏感匹配兜底未来可能的微小差异。
|
||||
pub fn is_unique_constraint_err(err: &df_types::error::Error) -> bool {
|
||||
let df_types::error::Error::Storage(msg) = err else { return false };
|
||||
msg.to_ascii_lowercase().contains("unique constraint failed")
|
||||
}
|
||||
|
||||
/// 规范化路径用于比较:canonicalize 解析绝对规范路径(失败降级),
|
||||
/// 统一正斜杠 + 小写。与 `df_project::scan::normalize_path` **同算法镜像**(df-storage
|
||||
/// 不依赖 df-project,故独立实现;改动须同步)。防 `C:\a\b` vs `C:/a/b/` 绕过。
|
||||
@@ -284,5 +303,6 @@ mod baseline_tests {
|
||||
let _ = KnowledgeRepo::new(&db);
|
||||
let _ = KnowledgeEventsRepo::new(&db);
|
||||
let _ = AiMessageRepo::new(&db);
|
||||
let _ = IdeaEvalRepo::new(&db);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,7 +120,7 @@ pub fn allowed_columns_for(table: &str) -> Option<&'static [&'static str]> {
|
||||
Some(match table {
|
||||
"ideas" => &[
|
||||
"id", "title", "description", "status", "priority", "score", "tags", "source",
|
||||
"promoted_to", "ai_analysis", "scores", "created_at", "updated_at",
|
||||
"promoted_to", "ai_analysis", "scores", "related_ids", "created_at", "updated_at",
|
||||
],
|
||||
"projects" => &[
|
||||
"id", "name", "description", "status", "idea_id", "path", "stack", "created_at",
|
||||
@@ -138,7 +138,12 @@ pub fn allowed_columns_for(table: &str) -> Option<&'static [&'static str]> {
|
||||
// update_task/update_field 白名单均不含)。后人勿把 review_rounds 补进白名单,
|
||||
// 否则破坏「review_rounds 唯一写入路径」收口、引入旁路写导致计数错乱。
|
||||
"project_id", "title", "description", "priority", "branch_name",
|
||||
"assignee", "workflow_def_id", "base_branch",
|
||||
"assignee",
|
||||
// workflow_def_id / base_branch: 预留字段,阶段4 Git/workflow_defs 集成前无写入路径。
|
||||
// 当前推进链用硬编码三模板(task_workflow_templates.rs,不建 workflow_defs 表,
|
||||
// tasks.workflow_def_id 留 None),无任何代码写这两列。白名单列入仅为阶段4 预留 +
|
||||
// 允许手动/未来填充,勿判死代码删除。base_branch 同理(code kind 闸门接 git 前预留)。
|
||||
"workflow_def_id", "base_branch",
|
||||
// output_json:ai_execute 写产出 / ai_self_review 读产出自审 / human_review 展示对象
|
||||
// (决策 a:task 中心,产出跟 task 走)。非状态机收口字段,合法可写。
|
||||
"output_json",
|
||||
|
||||
@@ -73,7 +73,7 @@ impl_repo!(
|
||||
impl TaskRepo {
|
||||
/// 列出未删除任务(deleted_at IS NULL)— 对标 ProjectRepo::list_active
|
||||
///
|
||||
/// 显式列出 14 个 TaskRecord 列名(同 ProjectRepo::list_active 写法),
|
||||
/// 显式列出全部 15 个 TaskRecord 列名(同 ProjectRepo::list_active 写法),
|
||||
/// 不 SELECT deleted_at:TaskRecord 不带该字段,取了 from_row 会因未知列报错。
|
||||
pub async fn list_active(&self) -> Result<Vec<TaskRecord>> {
|
||||
let conn = self.conn.clone();
|
||||
@@ -193,10 +193,35 @@ impl TaskRepo {
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
/// 按项目列出未删除任务(deleted_at IS NULL AND project_id = ?),按创建时间降序。
|
||||
///
|
||||
/// SQL 下推 project_id 过滤:替代旧 list_active + 内存 retain 全表扫(任务量增长后
|
||||
/// N×M 热点,每页都拉全表进内存再丢)。list_tasks 在有 project_id 时优先走本方法。
|
||||
pub async fn list_active_by_project(&self, project_id: &str) -> Result<Vec<TaskRecord>> {
|
||||
let conn = self.conn.clone();
|
||||
let pid = project_id.to_owned();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let mut stmt = guard
|
||||
.prepare("SELECT id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, review_rounds, output_json, idea_id, created_at, updated_at FROM tasks WHERE deleted_at IS NULL AND project_id = ?1 ORDER BY created_at DESC")
|
||||
.map_err(storage_err)?;
|
||||
let rows = stmt
|
||||
.query_map(params![pid], |row| task_from_row(row))
|
||||
.map_err(storage_err)?;
|
||||
let mut results = Vec::new();
|
||||
for r in rows {
|
||||
results.push(r.map_err(storage_err)?);
|
||||
}
|
||||
Ok(results)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
/// 列出回收站(deleted_at IS NOT NULL),按更新时间(≈删除时间)降序。对标 ProjectRepo::list_deleted。
|
||||
///
|
||||
/// 注:任务表无专用 list_active_by_project 方法,按项目列活跃任务由 commands/task.rs
|
||||
/// 的 list_tasks 用 list_active 后内存过滤 project_id 实现(任务量小,无需 SQL 下推)。
|
||||
/// 注:按项目列活跃任务走 list_active_by_project(SQL 下推 project_id),
|
||||
/// 无 pid 时 fallback list_active。
|
||||
pub async fn list_deleted(&self) -> Result<Vec<TaskRecord>> {
|
||||
let conn = self.conn.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
|
||||
@@ -23,8 +23,12 @@ pub fn run(conn: &Connection) -> Result<()> {
|
||||
|
||||
// 迁移步骤链: 顺序执行,跳过已应用的版本(current_version < N 才跑)。
|
||||
// 新增版本时,在此数组追加一项 (N, migrate_vN) 即可,无需改逻辑。
|
||||
// V20 = F-260619-01(任务关联灵感 idea_id);V21 = 消息拆分存储 + audit message_id。
|
||||
let steps: [(i32, fn(&Connection) -> Result<()>); 21] = [
|
||||
// V20 = F-260619-01(任务关联灵感 idea_id);V21 = 消息拆分存储 + audit message_id;
|
||||
// V22 = 灵感评估历史持久化(idea_evaluations 追加型审计表);
|
||||
// V23 = knowledges.embedding_status 列(嵌入失败可补偿重试);
|
||||
// V24 = ideas.related_ids 列(灵感间关联关系持久化打底);
|
||||
// V25 = idea_evaluations (idea_id, version) 唯一约束(评估版本并发重复兜底)。
|
||||
let steps: [(i32, fn(&Connection) -> Result<()>); 25] = [
|
||||
(1, migrate_v1),
|
||||
(2, migrate_v2),
|
||||
(3, migrate_v3),
|
||||
@@ -46,6 +50,10 @@ pub fn run(conn: &Connection) -> Result<()> {
|
||||
(19, migrate_v19),
|
||||
(20, migrate_v20),
|
||||
(21, migrate_v21),
|
||||
(22, migrate_v22),
|
||||
(23, migrate_v23),
|
||||
(24, migrate_v24),
|
||||
(25, migrate_v25),
|
||||
];
|
||||
|
||||
for (version, migrate_fn) in steps {
|
||||
@@ -512,6 +520,118 @@ fn migrate_v21(conn: &Connection) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// V22:灵感评估历史持久化 — idea_evaluations 追加型审计表
|
||||
///
|
||||
/// 把灵感每次 AI 评估快照(ai_analysis / scores / score)按版本追加存表,
|
||||
/// 替代覆写 ideas.ai_analysis / ideas.scores 列。一条灵感多次评估产生多条记录,
|
||||
/// version 单调递增,前端按 (idea_id, version DESC) 取最新 + 翻历史。
|
||||
///
|
||||
/// 设计要点(对齐 knowledge_events 追加型审计表模式 V10):
|
||||
/// - **追加型**:只 INSERT 不 UPDATE,审计语义(评估快照不可篡改,历史可追溯)
|
||||
/// - **IF NOT EXISTS 幂等**:对新库建表 / 老库已有表跳过,均安全
|
||||
/// - **索引**:`(idea_id, version DESC)` 覆盖「取某灵感最新评估」最高频查询
|
||||
/// - **evaluated_by**:评估发起者(model 名 / human / system,可空)
|
||||
/// - **evaluated_at**:评估时间(毫秒字符串,同既有 model 约定)
|
||||
///
|
||||
/// 不登记通用列白名单(allowed_columns_for):本表走专用 list_by_idea,
|
||||
/// 宏生成的 query/update_field 未登记表会被 validate_column_name 保守拒绝
|
||||
/// (FR-S6),与追加型审计语义一致(历史不改),不开放通用写路径。
|
||||
fn migrate_v22(conn: &Connection) -> Result<()> {
|
||||
conn.execute_batch(V22_SQL)?;
|
||||
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [22])?;
|
||||
tracing::info!("迁移 v22 完成");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// V23:幂等补 knowledges.embedding_status 列(嵌入生成失败可补偿重试)
|
||||
///
|
||||
/// P1 修复(嵌入失败无标记):spawn_embedding_for_knowledge 此前 fire-and-forget,
|
||||
/// provider 临时不可用 → 仅 warn,该条永久无向量索引但无人感知(下次也不会重试)。
|
||||
/// 新增 embedding_status 列跟踪嵌入生命周期:
|
||||
/// - NULL:未生成(老库行迁移后默认 NULL;代码从无显式写 NULL/pending 的路径,
|
||||
/// 仅此两种取值实际出现:done / failed)
|
||||
/// - done:成功(已有有效 embedding,由 KnowledgeRepo::set_embedding 写入)
|
||||
/// - failed:失败可重试(下次发布 / 手动 retry 时补偿,由 KnowledgeRepo::mark_embedding_failed 写入)
|
||||
///
|
||||
/// 语义:embedding 列(BLOB)与 embedding_status 解耦 —— embedding 仅在 done 时有值;
|
||||
/// 失败时 status=failed + embedding 仍 NULL,检索侧 `embedding IS NOT NULL` 自然跳过。
|
||||
/// 老库行(已成功嵌入的)embedding 有值但 status=NULL:这类条目检索正常(embedding IS NOT NULL),
|
||||
/// 不影响功能;若需精确状态,可在后台补偿脚本回填 done,但非必需(检索不依赖 status)。
|
||||
///
|
||||
/// TEXT NULL 向后兼容;不进通用 update_field 白名单(写入走 KnowledgeRepo::set_embedding /
|
||||
/// mark_embedding_failed 两个专用方法,而非独立的 set_embedding_status)。用 PRAGMA 探测列存在性,
|
||||
/// 缺失才 ALTER(同既有幂等模式),对新库/老库均安全。
|
||||
fn migrate_v23(conn: &Connection) -> Result<()> {
|
||||
if !column_exists(conn, "knowledges", "embedding_status") {
|
||||
conn.execute("ALTER TABLE knowledges ADD COLUMN embedding_status TEXT", [])?;
|
||||
tracing::info!("v23: 补建 knowledges.embedding_status 列(嵌入失败可补偿重试)");
|
||||
}
|
||||
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [23])?;
|
||||
tracing::info!("迁移 v23 完成");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// V24:幂等补 ideas.related_ids 列(灵感间关联关系持久化打底)
|
||||
///
|
||||
/// 为灵感关联关系 UI 打底:related_ids 存「关联灵感 id JSON 数组」字符串
|
||||
/// (同 tags 的 JSON-in-TEXT 模式)。TEXT NULL 向后兼容:老库行默认 NULL,
|
||||
/// IdeaRecord 字段为 Option<String>(未设关联的灵感为 None)。
|
||||
///
|
||||
/// 进通用 update_field 白名单(Ideas.vue 关联关系 UI 走 update_idea →
|
||||
/// update_field('related_ids', ...),同 tags 一样白名单登记该列;另有整行
|
||||
/// update(update_full)路径,二者均可写入)。
|
||||
///
|
||||
/// 用 PRAGMA 探测列存在性,缺失才 ALTER(同 v4/v5/v6/v8/v10/v11/v14/v15/v16/v17
|
||||
/// /v18/v19/v20/v23 模式),对新库/老库均安全。
|
||||
fn migrate_v24(conn: &Connection) -> Result<()> {
|
||||
if !column_exists(conn, "ideas", "related_ids") {
|
||||
conn.execute("ALTER TABLE ideas ADD COLUMN related_ids TEXT", [])?;
|
||||
tracing::info!("v24: 补建 ideas.related_ids 列(灵感关联关系持久化打底)");
|
||||
}
|
||||
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [24])?;
|
||||
tracing::info!("迁移 v24 完成");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// V25:幂等补 idea_evaluations (idea_id, version) 唯一约束(评估版本并发重复兜底)
|
||||
///
|
||||
/// 评估历史 version 此前由 evaluate_idea 算 `list_by_idea().first().version + 1`
|
||||
/// 得到,读-改-写非原子。并发两次评估同一灵感可能读到相同最新 version,各自 +1 后
|
||||
/// 写入相同 version(重复),破坏「version 单调递增 + 唯一」语义。单用户桌面应用
|
||||
/// 概率低,但唯一约束是数据完整性兜底,值得加。
|
||||
///
|
||||
/// 实现选 CREATE UNIQUE INDEX IF NOT EXISTS 而非 ALTER TABLE ADD CONSTRAINT:
|
||||
/// SQLite 不支持 ALTER TABLE 加约束 / 也不支持 ALTER ... IF NOT EXISTS,而
|
||||
/// `CREATE UNIQUE INDEX IF NOT EXISTS` 原生幂等(新库建 / 老库已有则跳过),满足
|
||||
/// 迁移「对新库与老库均安全」要求。索引语义等价于表级 UNIQUE(idea_id, version),
|
||||
/// 同样在 INSERT 冲突时抛 SQLITE_CONSTRAINT_UNIQUE。
|
||||
///
|
||||
/// 注:既有重复数据(若老库已有重复 version 行)会导致建索引失败。单用户桌面应用
|
||||
/// 几乎不会有重复,若真发生此处**降级跳过**(建索引失败 → warn + 继续迁移),而非 `?` 上抛
|
||||
/// 致整个应用启动崩溃、用户无感。理由:唯一索引只是并发重复的兜底防御网,缺失它不影响历史
|
||||
/// 数据读取(list_by_idea / list_recent_idea_evals 照常工作),应用仍可用,远胜启动失败黑屏。
|
||||
/// 建索引失败时日志带原始错误,用户/开发者可据此清理重复后手动重跑迁移补索引。
|
||||
fn migrate_v25(conn: &Connection) -> Result<()> {
|
||||
let build_result = conn.execute_batch(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS uq_idea_evaluations_idea_version \
|
||||
ON idea_evaluations(idea_id, version)",
|
||||
);
|
||||
if let Err(e) = build_result {
|
||||
// 降级:建唯一索引失败(典型根因——老库已存在重复 (idea_id, version) 行)不阻断迁移。
|
||||
// 索引缺失仅削弱并发重复防御,不破坏既有数据可读性;跳过继续记录 schema_version=25。
|
||||
tracing::warn!(
|
||||
error = %e,
|
||||
"v25: 建 idea_evaluations(idea_id, version) 唯一索引失败(老库可能有重复 version 行),\
|
||||
降级跳过索引创建不阻断启动。清理重复后可手动重跑迁移补建索引"
|
||||
);
|
||||
} else {
|
||||
tracing::info!("v25: 建 idea_evaluations(idea_id, version) 唯一索引完成");
|
||||
}
|
||||
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [25])?;
|
||||
tracing::info!("迁移 v25 完成");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// V21 建表 SQL — 消息拆分存储 ai_messages 表
|
||||
///
|
||||
/// 与 V9_SQL 中的 ai_messages 镜像(V9 给新库,此 const 给老库 V21 迁移用 IF NOT EXISTS)。
|
||||
@@ -537,6 +657,27 @@ CREATE TABLE IF NOT EXISTS ai_messages (
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_messages_conv ON ai_messages(conversation_id, seq);
|
||||
";
|
||||
|
||||
/// V22 建表 SQL — 灵感评估历史(追加型审计表)
|
||||
///
|
||||
/// 8 列:id(主键)/ idea_id(关联灵感)/ version(评估版本号,单调递增)/
|
||||
/// ai_analysis(AI 分析结果 JSON,可空)/ scores(多维评分 JSON,可空)/
|
||||
/// score(综合评分 REAL,可空)/ evaluated_by(评估者,可空)/ evaluated_at(毫秒字符串)。
|
||||
/// 索引:(idea_id, version DESC) 覆盖「取某灵感最新评估」最高频查询。
|
||||
const V22_SQL: &str = "
|
||||
CREATE TABLE IF NOT EXISTS idea_evaluations (
|
||||
id TEXT PRIMARY KEY,
|
||||
idea_id TEXT NOT NULL,
|
||||
version INTEGER NOT NULL,
|
||||
ai_analysis TEXT,
|
||||
scores TEXT,
|
||||
score REAL,
|
||||
evaluated_by TEXT,
|
||||
evaluated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_idea_evaluations_idea ON idea_evaluations(idea_id, version DESC);
|
||||
";
|
||||
|
||||
/// V1 建表 SQL
|
||||
const V1_SQL: &str = "
|
||||
-- 想法表
|
||||
@@ -698,6 +839,9 @@ CREATE TABLE IF NOT EXISTS knowledges (
|
||||
verified INTEGER NOT NULL DEFAULT 0,
|
||||
source_project TEXT,
|
||||
source_ref TEXT,
|
||||
-- V23 补列(嵌入失败可补偿重试):新库直接带列,老库由 migrate_v23 ALTER 补;
|
||||
-- 两边列定义须一致(老库迁移注释 V23 已注明)。
|
||||
embedding_status TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
@@ -21,10 +21,28 @@ pub struct IdeaRecord {
|
||||
pub promoted_to: Option<String>, // 晋升后的 project_id
|
||||
pub ai_analysis: Option<String>, // AI 分析结果 JSON
|
||||
pub scores: Option<String>, // 多维评分 JSON (feasibility/impact/urgency/overall)
|
||||
pub related_ids: Option<String>, // 关联灵感 id JSON 数组(同 tags 模式,为关联关系 UI 打底)
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
/// 灵感评估历史记录(追加型审计表 idea_evaluations,V22)
|
||||
///
|
||||
/// 每次灵感 AI 评估产生一行快照,version 单调递增。
|
||||
/// 替代覆写 ideas.ai_analysis / ideas.scores:保留评估历史可追溯。
|
||||
/// ai_analysis/scores 为 JSON 字符串,score 为综合评分,evaluated_by 标评估发起者。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct IdeaEvaluationRecord {
|
||||
pub id: String,
|
||||
pub idea_id: String,
|
||||
pub version: i64,
|
||||
pub ai_analysis: Option<String>,
|
||||
pub scores: Option<String>,
|
||||
pub score: Option<f64>,
|
||||
pub evaluated_by: Option<String>,
|
||||
pub evaluated_at: String,
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 项目相关模型
|
||||
// ============================================================
|
||||
@@ -280,6 +298,14 @@ pub struct KnowledgeRecord {
|
||||
pub source_project: Option<String>, // 来源项目(仅溯源不过滤)
|
||||
pub source_ref: Option<String>, // 来源实体引用(如 conv:{id})
|
||||
pub reasoning: Option<String>, // AI 提炼判断依据("为何值得沉淀"),手动录入为 None
|
||||
/// 嵌入生成状态(V23):None/pending=未生成,done=成功,failed=失败可重试。
|
||||
///
|
||||
/// P1 修复(嵌入失败无标记):spawn_embedding_for_knowledge 此前 fire-and-forget,
|
||||
/// provider 临时不可用 → 仅 warn,该条永久无向量索引无人感知。
|
||||
/// 现写入 done/failed,failed 可由 knowledge_retry_embedding 触发补偿重试。
|
||||
/// `#[serde(default)]` 兼容老前端/老 JSON(未带该字段反序列化为 None)。
|
||||
#[serde(default)]
|
||||
pub embedding_status: Option<String>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
589
crates/df-types/src/augmentation.rs
Normal file
589
crates/df-types/src/augmentation.rs
Normal file
@@ -0,0 +1,589 @@
|
||||
//! Input Augmentation 层领域类型
|
||||
//!
|
||||
//! 解决「@项目 / /技能 → AI 上下文」两个增强通道的结构性问题:
|
||||
//! - [`MentionRef`]:resolve 前,前端选中 mention 后透传的原始引用(携带 id)
|
||||
//! - [`Augmentation`]:resolve 后,注入 LLM 上下文的投影数据(已 join / 已脱敏)
|
||||
//! - [`SanitizedPath`]:path 脱敏 newtype,防止裸 String 误用未脱敏值
|
||||
//! - [`MentionSpanDto`]:chip 元数据,前端按 span 精确替换 mention token
|
||||
//! - [`ResolveError`]:Resolver 投影失败原因
|
||||
//!
|
||||
//! serde 统一 `tag = "kind"`,新增变体向后兼容(老前端遇未知 kind 忽略)。
|
||||
//! 设计详见 plan: mighty-wibbling-papert.md 核心设计 1。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::types::{IdeaStatus, ProjectId, ProjectStatus, TaskId, TaskStatus};
|
||||
|
||||
// ============================================================
|
||||
// 路径脱敏 newtype
|
||||
// ============================================================
|
||||
|
||||
/// 脱敏后的文件系统路径(已根据 provider locality 过滤)。
|
||||
///
|
||||
/// 用 newtype 包裹,强制调用方走 [`crate::augmentation`] 模块的脱敏入口,
|
||||
/// 防止裸 `String` 误用未脱敏的原始路径(含用户名/公司目录/客户名)直接注入 LLM。
|
||||
///
|
||||
/// 脱敏规则由 `src-tauri` 侧 `augmentation::sanitize` 模块实现:
|
||||
/// - Local provider(localhost/127.0.0.1/0.0.0.0/::1/ollama):保留全路径
|
||||
/// - Remote provider:仅保留 basename
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct SanitizedPath(String);
|
||||
|
||||
impl SanitizedPath {
|
||||
/// 构造一个已脱敏路径。仅在 sanitize 入口调用,业务代码不应直接构造。
|
||||
pub fn new(sanitized: impl Into<String>) -> Self {
|
||||
Self(sanitized.into())
|
||||
}
|
||||
|
||||
/// 以字符串切片访问路径内容。
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
|
||||
/// 取回内部的 String 所有权。
|
||||
pub fn into_inner(self) -> String {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl From<SanitizedPath> for String {
|
||||
fn from(p: SanitizedPath) -> Self {
|
||||
p.0
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for SanitizedPath {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// MentionRef — resolve 前(前端透传)
|
||||
// ============================================================
|
||||
|
||||
/// 前端选中 mention 后透传的原始引用(resolve 前)。
|
||||
///
|
||||
/// 携带 id(以及展示用 name),供后端 Resolver 投影成 [`Augmentation`]。
|
||||
/// Skill 变体不带 id —— skill 以 name 为唯一键(无全局 id)。
|
||||
///
|
||||
/// serde `tag = "kind"`:序列化形如 `{"kind":"project","id":"...","name":"..."}`。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum MentionRef {
|
||||
/// @项目
|
||||
Project {
|
||||
#[serde(rename = "id")]
|
||||
id: ProjectId,
|
||||
#[serde(rename = "name")]
|
||||
name: String,
|
||||
},
|
||||
/// @任务
|
||||
Task {
|
||||
#[serde(rename = "id")]
|
||||
id: TaskId,
|
||||
#[serde(rename = "name")]
|
||||
name: String,
|
||||
},
|
||||
/// @灵感
|
||||
Idea {
|
||||
#[serde(rename = "id")]
|
||||
id: String,
|
||||
#[serde(rename = "name")]
|
||||
name: String,
|
||||
},
|
||||
/// /技能(name 唯一键)
|
||||
Skill {
|
||||
#[serde(rename = "name")]
|
||||
name: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl MentionRef {
|
||||
/// 返回 mention 的 kind 标签(与 serde tag 一致):`project`/`task`/`idea`/`skill`。
|
||||
pub fn kind_str(&self) -> &'static str {
|
||||
match self {
|
||||
MentionRef::Project { .. } => "project",
|
||||
MentionRef::Task { .. } => "task",
|
||||
MentionRef::Idea { .. } => "idea",
|
||||
MentionRef::Skill { .. } => "skill",
|
||||
}
|
||||
}
|
||||
|
||||
/// 返回展示用 label(即 mention 选中时的可见文本)。
|
||||
pub fn label(&self) -> &str {
|
||||
match self {
|
||||
MentionRef::Project { name, .. }
|
||||
| MentionRef::Task { name, .. }
|
||||
| MentionRef::Idea { name, .. }
|
||||
| MentionRef::Skill { name } => name,
|
||||
}
|
||||
}
|
||||
|
||||
/// 返回 chip 渲染/查找用的稳定标识:有 id 用 id,Skill 用 name。
|
||||
pub fn ref_id(&self) -> &str {
|
||||
match self {
|
||||
MentionRef::Project { id, .. }
|
||||
| MentionRef::Task { id, .. }
|
||||
| MentionRef::Idea { id, .. } => id,
|
||||
MentionRef::Skill { name } => name,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Augmentation — resolve 后(注入 LLM 用)
|
||||
// ============================================================
|
||||
|
||||
/// Resolver 投影后的增强上下文(resolve 后,注入用)。
|
||||
///
|
||||
/// 与 [`MentionRef`] 的区别:
|
||||
/// - 字段更丰富(join 后的 status / description / project_name 等)
|
||||
/// - `Project` 带 `path: Option<SanitizedPath>`(已脱敏)
|
||||
/// - `Skill` 带 `body`(已剥 frontmatter 的正文)
|
||||
///
|
||||
/// serde `tag = "kind"`,与 `MentionRef` 同构,前端可共用 chip 渲染逻辑。
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum Augmentation {
|
||||
/// @项目 注入体
|
||||
Project {
|
||||
#[serde(rename = "id")]
|
||||
id: ProjectId,
|
||||
#[serde(rename = "name")]
|
||||
name: String,
|
||||
#[serde(rename = "status")]
|
||||
status: ProjectStatus,
|
||||
#[serde(rename = "description")]
|
||||
description: String,
|
||||
/// 脱敏后的项目路径(远程 provider 仅 basename / 本地全路径);无目录绑定时为 None。
|
||||
#[serde(rename = "path", default, skip_serializing_if = "Option::is_none")]
|
||||
path: Option<SanitizedPath>,
|
||||
},
|
||||
/// @任务 注入体
|
||||
Task {
|
||||
#[serde(rename = "id")]
|
||||
id: TaskId,
|
||||
#[serde(rename = "title")]
|
||||
title: String,
|
||||
#[serde(rename = "status")]
|
||||
status: TaskStatus,
|
||||
#[serde(rename = "description")]
|
||||
description: String,
|
||||
/// 所属项目名(join 展示用);游离任务为 None。
|
||||
#[serde(rename = "project_name", default, skip_serializing_if = "Option::is_none")]
|
||||
project_name: Option<String>,
|
||||
},
|
||||
/// @灵感 注入体
|
||||
Idea {
|
||||
#[serde(rename = "id")]
|
||||
id: String,
|
||||
#[serde(rename = "title")]
|
||||
title: String,
|
||||
#[serde(rename = "status")]
|
||||
status: IdeaStatus,
|
||||
#[serde(rename = "description")]
|
||||
description: String,
|
||||
},
|
||||
/// /技能 注入体
|
||||
Skill {
|
||||
#[serde(rename = "name")]
|
||||
name: String,
|
||||
/// 技能来源(plugin/command/user 等),用于审计/展示。
|
||||
#[serde(rename = "source")]
|
||||
source: String,
|
||||
/// 已剥 frontmatter 的技能正文。
|
||||
#[serde(rename = "body")]
|
||||
body: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl Augmentation {
|
||||
/// 返回 kind 标签(与 serde tag 一致)。
|
||||
pub fn kind_str(&self) -> &'static str {
|
||||
match self {
|
||||
Augmentation::Project { .. } => "project",
|
||||
Augmentation::Task { .. } => "task",
|
||||
Augmentation::Idea { .. } => "idea",
|
||||
Augmentation::Skill { .. } => "skill",
|
||||
}
|
||||
}
|
||||
|
||||
/// 返回注入段落的小标题(供 build_augmentation_segment 拼接)。
|
||||
pub fn section_label(&self) -> &str {
|
||||
match self {
|
||||
Augmentation::Project { .. } => "项目",
|
||||
Augmentation::Task { .. } => "任务",
|
||||
Augmentation::Idea { .. } => "灵感",
|
||||
Augmentation::Skill { .. } => "技能",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// MentionSpanDto — chip 元数据
|
||||
// ============================================================
|
||||
|
||||
/// 用户消息内 mention 区间的元数据,驱动 chip 精确替换。
|
||||
///
|
||||
/// 前端按 `{start, length}` 在原文中切出区间并替换为 chip;若区间被用户编辑破坏
|
||||
/// (长度/内容不再匹配),降级为纯文本展示,避免正则误匹配。
|
||||
///
|
||||
/// 注意:序列化字段名 `refId`(camelCase,对齐前端 TS 类型),Rust 侧用 `ref_id`。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct MentionSpanDto {
|
||||
/// 区间起点(字节/字符偏移由前端约定,后端仅透传)。
|
||||
#[serde(rename = "start")]
|
||||
pub start: u32,
|
||||
/// 区间长度。
|
||||
#[serde(rename = "length")]
|
||||
pub length: u32,
|
||||
/// kind 标签:`project`/`task`/`idea`/`skill`。
|
||||
#[serde(rename = "kind")]
|
||||
pub kind: String,
|
||||
/// 引用稳定标识(Project/Task/Idea 为 id,Skill 为 name),前端用于反查。
|
||||
#[serde(rename = "refId")]
|
||||
pub ref_id: String,
|
||||
/// chip 展示文本(项目名/任务标题/技能名)。
|
||||
#[serde(rename = "label")]
|
||||
pub label: String,
|
||||
}
|
||||
|
||||
impl MentionSpanDto {
|
||||
/// 构造一个 span。kind 取自 MentionRef::kind_str,ref_id 取自 MentionRef::ref_id。
|
||||
pub fn new(start: u32, length: u32, kind: impl Into<String>, ref_id: impl Into<String>, label: impl Into<String>) -> Self {
|
||||
Self {
|
||||
start,
|
||||
length,
|
||||
kind: kind.into(),
|
||||
ref_id: ref_id.into(),
|
||||
label: label.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 区间终点(start + length)。
|
||||
pub fn end(&self) -> u32 {
|
||||
self.start.saturating_add(self.length)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// ResolveError — Resolver 投影失败
|
||||
// ============================================================
|
||||
|
||||
/// Resolver 解析 [`MentionRef`] → [`Augmentation`] 的失败原因。
|
||||
///
|
||||
/// `Skip` 表示该 mention 无法 resolve 但不应中断整批注入(如 skill 已删除),
|
||||
/// 调用方应跳过该项继续处理其他 mention。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum ResolveError {
|
||||
/// 引用对象不存在(项目/任务/灵感/技能已删除)。
|
||||
#[error("未找到引用: kind={kind}, ref_id={ref_id}")]
|
||||
NotFound {
|
||||
/// kind 标签。
|
||||
kind: String,
|
||||
/// 引用标识。
|
||||
ref_id: String,
|
||||
},
|
||||
|
||||
/// kind 与 Resolver 注册不匹配(如 Project id 传给了 TaskResolver)。
|
||||
#[error("kind 不匹配: expected={expected}, actual={actual}")]
|
||||
KindMismatch {
|
||||
/// 期望的 kind。
|
||||
expected: String,
|
||||
/// 实际的 kind。
|
||||
actual: String,
|
||||
},
|
||||
|
||||
/// 跳过该 mention(如 skill 已被卸载),不视为错误,不中断整批注入。
|
||||
#[error("跳过引用: kind={kind}, ref_id={ref_id}, reason={reason}")]
|
||||
Skip {
|
||||
/// kind 标签。
|
||||
kind: String,
|
||||
/// 引用标识。
|
||||
ref_id: String,
|
||||
/// 跳过原因(审计/日志用)。
|
||||
reason: String,
|
||||
},
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 单测:serde round-trip + 辅助方法
|
||||
// ============================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::types::{IdeaStatus, ProjectStatus, TaskStatus};
|
||||
|
||||
// ---------- SanitizedPath ----------
|
||||
|
||||
#[test]
|
||||
fn sanitized_path_transparent_serde() {
|
||||
// serde(transparent):JSON 直接是裸字符串,无 {inner} 包裹
|
||||
let p = SanitizedPath::new("E:/wk-lab/devflow");
|
||||
let json = serde_json::to_string(&p).expect("serialize");
|
||||
assert_eq!(json, "\"E:/wk-lab/devflow\"");
|
||||
let back: SanitizedPath = serde_json::from_str(&json).expect("deserialize");
|
||||
assert_eq!(back, p);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitized_path_accessors() {
|
||||
let p = SanitizedPath::new("/tmp/x");
|
||||
assert_eq!(p.as_str(), "/tmp/x");
|
||||
assert_eq!(p.to_string(), "/tmp/x");
|
||||
assert_eq!(p.into_inner(), "/tmp/x");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitized_path_from_to_string() {
|
||||
let p = SanitizedPath::new("a/b");
|
||||
let s: String = p.into();
|
||||
assert_eq!(s, "a/b");
|
||||
}
|
||||
|
||||
// ---------- MentionRef round-trip ----------
|
||||
|
||||
#[test]
|
||||
fn mention_ref_project_round_trip() {
|
||||
let m = MentionRef::Project {
|
||||
id: "p-1".to_string(),
|
||||
name: "devflow".to_string(),
|
||||
};
|
||||
let json = serde_json::to_string(&m).expect("serialize");
|
||||
// tag=kind, snake_case, 字段名 id/name
|
||||
assert_eq!(
|
||||
json,
|
||||
"{\"kind\":\"project\",\"id\":\"p-1\",\"name\":\"devflow\"}"
|
||||
);
|
||||
let back: MentionRef = serde_json::from_str(&json).expect("deserialize");
|
||||
assert_eq!(back, m);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mention_ref_skill_round_trip() {
|
||||
let m = MentionRef::Skill {
|
||||
name: "review".to_string(),
|
||||
};
|
||||
let json = serde_json::to_string(&m).expect("serialize");
|
||||
assert_eq!(json, "{\"kind\":\"skill\",\"name\":\"review\"}");
|
||||
let back: MentionRef = serde_json::from_str(&json).expect("deserialize");
|
||||
assert_eq!(back, m);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mention_ref_all_variants_round_trip() {
|
||||
let cases = vec![
|
||||
serde_json::to_value(MentionRef::Project {
|
||||
id: "p".into(),
|
||||
name: "pn".into(),
|
||||
})
|
||||
.unwrap(),
|
||||
serde_json::to_value(MentionRef::Task {
|
||||
id: "t".into(),
|
||||
name: "tn".into(),
|
||||
})
|
||||
.unwrap(),
|
||||
serde_json::to_value(MentionRef::Idea {
|
||||
id: "i".into(),
|
||||
name: "in".into(),
|
||||
})
|
||||
.unwrap(),
|
||||
serde_json::to_value(MentionRef::Skill { name: "s".into() }).unwrap(),
|
||||
];
|
||||
for v in cases {
|
||||
let back: MentionRef = serde_json::from_value(v.clone()).expect("deserialize");
|
||||
let re = serde_json::to_value(&back).expect("serialize");
|
||||
assert_eq!(re, v, "round-trip not stable");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mention_ref_helpers() {
|
||||
let p = MentionRef::Project {
|
||||
id: "p1".into(),
|
||||
name: "Devflow".into(),
|
||||
};
|
||||
assert_eq!(p.kind_str(), "project");
|
||||
assert_eq!(p.label(), "Devflow");
|
||||
assert_eq!(p.ref_id(), "p1");
|
||||
|
||||
let s = MentionRef::Skill { name: "review".into() };
|
||||
assert_eq!(s.kind_str(), "skill");
|
||||
assert_eq!(s.ref_id(), "review"); // skill ref_id = name
|
||||
assert_eq!(s.label(), "review");
|
||||
}
|
||||
|
||||
// ---------- Augmentation round-trip ----------
|
||||
|
||||
#[test]
|
||||
fn augmentation_project_with_path_round_trip() {
|
||||
let a = Augmentation::Project {
|
||||
id: "p-1".to_string(),
|
||||
name: "devflow".to_string(),
|
||||
status: ProjectStatus::InProgress,
|
||||
description: "AI-native dev tool".to_string(),
|
||||
path: Some(SanitizedPath::new("E:/wk-lab/devflow")),
|
||||
};
|
||||
let json = serde_json::to_string(&a).expect("serialize");
|
||||
assert!(json.contains("\"kind\":\"project\""));
|
||||
assert!(json.contains("\"status\":\"in_progress\""));
|
||||
assert!(json.contains("\"path\":\"E:/wk-lab/devflow\""));
|
||||
let back: Augmentation = serde_json::from_str(&json).expect("deserialize");
|
||||
assert_eq!(back, a);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn augmentation_project_path_none_omitted() {
|
||||
let a = Augmentation::Project {
|
||||
id: "p-2".to_string(),
|
||||
name: "nopath".to_string(),
|
||||
status: ProjectStatus::Planning,
|
||||
description: String::new(),
|
||||
path: None,
|
||||
};
|
||||
let json = serde_json::to_string(&a).expect("serialize");
|
||||
// skip_serializing_if 生效:path 字段不出现在 JSON 中
|
||||
// 检查 "path" key(带引号),避免误匹配 name 值里的 path 子串
|
||||
assert!(!json.contains("\"path\""), "path should be omitted, got: {}", json);
|
||||
let back: Augmentation = serde_json::from_str(&json).expect("deserialize");
|
||||
assert_eq!(back, a);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn augmentation_task_with_project_name_round_trip() {
|
||||
let a = Augmentation::Task {
|
||||
id: "t-1".to_string(),
|
||||
title: "Implement X".to_string(),
|
||||
status: TaskStatus::Todo,
|
||||
description: "do X".to_string(),
|
||||
project_name: Some("devflow".to_string()),
|
||||
};
|
||||
let json = serde_json::to_string(&a).expect("serialize");
|
||||
assert!(json.contains("\"kind\":\"task\""));
|
||||
assert!(json.contains("\"status\":\"todo\""));
|
||||
assert!(json.contains("\"project_name\":\"devflow\""));
|
||||
let back: Augmentation = serde_json::from_str(&json).expect("deserialize");
|
||||
assert_eq!(back, a);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn augmentation_idea_round_trip() {
|
||||
let a = Augmentation::Idea {
|
||||
id: "i-1".to_string(),
|
||||
title: "Multi-round parallel".to_string(),
|
||||
status: IdeaStatus::Approved,
|
||||
description: "desc".to_string(),
|
||||
};
|
||||
let json = serde_json::to_string(&a).expect("serialize");
|
||||
assert!(json.contains("\"kind\":\"idea\""));
|
||||
assert!(json.contains("\"status\":\"approved\""));
|
||||
// idea 无 project_name / path 字段
|
||||
assert!(!json.contains("project_name"));
|
||||
assert!(!json.contains("\"path\""));
|
||||
let back: Augmentation = serde_json::from_str(&json).expect("deserialize");
|
||||
assert_eq!(back, a);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn augmentation_skill_round_trip() {
|
||||
let a = Augmentation::Skill {
|
||||
name: "review".to_string(),
|
||||
source: "command".to_string(),
|
||||
body: "## Review\nDo X".to_string(),
|
||||
};
|
||||
let json = serde_json::to_string(&a).expect("serialize");
|
||||
assert_eq!(
|
||||
json,
|
||||
"{\"kind\":\"skill\",\"name\":\"review\",\"source\":\"command\",\"body\":\"## Review\\nDo X\"}"
|
||||
);
|
||||
let back: Augmentation = serde_json::from_str(&json).expect("deserialize");
|
||||
assert_eq!(back, a);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn augmentation_helpers() {
|
||||
let p = Augmentation::Project {
|
||||
id: "p".into(),
|
||||
name: "n".into(),
|
||||
status: ProjectStatus::Planning,
|
||||
description: String::new(),
|
||||
path: None,
|
||||
};
|
||||
assert_eq!(p.kind_str(), "project");
|
||||
assert_eq!(p.section_label(), "项目");
|
||||
|
||||
let s = Augmentation::Skill {
|
||||
name: "x".into(),
|
||||
source: "y".into(),
|
||||
body: String::new(),
|
||||
};
|
||||
assert_eq!(s.kind_str(), "skill");
|
||||
assert_eq!(s.section_label(), "技能");
|
||||
}
|
||||
|
||||
// ---------- MentionSpanDto round-trip ----------
|
||||
|
||||
#[test]
|
||||
fn mention_span_dto_round_trip() {
|
||||
let span = MentionSpanDto::new(10, 8, "project", "p-1", "devflow");
|
||||
let json = serde_json::to_string(&span).expect("serialize");
|
||||
// refId camelCase
|
||||
assert!(json.contains("\"refId\":\"p-1\""));
|
||||
assert!(json.contains("\"kind\":\"project\""));
|
||||
assert!(json.contains("\"start\":10"));
|
||||
assert!(json.contains("\"length\":8"));
|
||||
assert!(json.contains("\"label\":\"devflow\""));
|
||||
let back: MentionSpanDto = serde_json::from_str(&json).expect("deserialize");
|
||||
assert_eq!(back, span);
|
||||
assert_eq!(span.end(), 18);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mention_span_dto_end_saturates() {
|
||||
let span = MentionSpanDto::new(u32::MAX, 5, "task", "t", "lbl");
|
||||
// 防溢出:saturating_add 在 u32::MAX + 5 时停在上界
|
||||
assert_eq!(span.end(), u32::MAX);
|
||||
}
|
||||
|
||||
// ---------- ResolveError ----------
|
||||
|
||||
#[test]
|
||||
fn resolve_error_display() {
|
||||
let e = ResolveError::NotFound {
|
||||
kind: "project".into(),
|
||||
ref_id: "p-x".into(),
|
||||
};
|
||||
assert!(format!("{}", e).contains("p-x"));
|
||||
assert!(format!("{}", e).contains("project"));
|
||||
|
||||
let e2 = ResolveError::KindMismatch {
|
||||
expected: "project".into(),
|
||||
actual: "task".into(),
|
||||
};
|
||||
let s = format!("{}", e2);
|
||||
assert!(s.contains("project") && s.contains("task"));
|
||||
|
||||
let e3 = ResolveError::Skip {
|
||||
kind: "skill".into(),
|
||||
ref_id: "removed".into(),
|
||||
reason: "skill uninstalled".into(),
|
||||
};
|
||||
assert!(format!("{}", e3).contains("skill uninstalled"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_error_eq() {
|
||||
let a = ResolveError::NotFound {
|
||||
kind: "task".into(),
|
||||
ref_id: "t".into(),
|
||||
};
|
||||
let b = ResolveError::NotFound {
|
||||
kind: "task".into(),
|
||||
ref_id: "t".into(),
|
||||
};
|
||||
assert_eq!(a, b);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,13 @@
|
||||
//! df-types: 核心类型定义,所有 crate 的基础依赖
|
||||
|
||||
pub mod augmentation;
|
||||
pub mod error;
|
||||
pub mod events;
|
||||
pub mod types;
|
||||
|
||||
// 顶层 re-export:常用时间工具(跨 crate 调用方用 df_types::now_millis,避免 types:: 前缀)
|
||||
pub use types::now_millis;
|
||||
|
||||
// Input Augmentation 层领域类型透传(跨 crate 调用方用 df_types::Augmentation 等,
|
||||
// 避免 augmentation:: 前缀)。详见 augmentation 模块文档。
|
||||
pub use augmentation::{Augmentation, MentionRef, MentionSpanDto, ResolveError, SanitizedPath};
|
||||
|
||||
@@ -76,6 +76,22 @@ impl IdeaStatus {
|
||||
IdeaStatus::Archived => "archived",
|
||||
}
|
||||
}
|
||||
|
||||
/// 从数据库存储的小写字符串解析为 IdeaStatus(与 `as_str` 对偶)。
|
||||
///
|
||||
/// Input Augmentation 层 ProjectResolver/IdeaResolver 把记录的 String status 投影成
|
||||
/// 类型化枚举用。未知字符串返 None(调用方按 Draft 兜底或视作数据异常跳过)。
|
||||
pub fn from_db_str(s: &str) -> Option<Self> {
|
||||
Some(match s {
|
||||
"draft" => IdeaStatus::Draft,
|
||||
"pending_review" => IdeaStatus::PendingReview,
|
||||
"approved" => IdeaStatus::Approved,
|
||||
"rejected" => IdeaStatus::Rejected,
|
||||
"promoted" => IdeaStatus::Promoted,
|
||||
"archived" => IdeaStatus::Archived,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for IdeaStatus {
|
||||
@@ -117,6 +133,23 @@ impl ProjectStatus {
|
||||
ProjectStatus::Cancelled => "cancelled",
|
||||
}
|
||||
}
|
||||
|
||||
/// 从数据库存储的小写字符串解析为 ProjectStatus(与 `as_str` 对偶)。
|
||||
///
|
||||
/// Input Augmentation 层 ProjectResolver/TaskResolver 把记录的 String status 投影成
|
||||
/// 类型化枚举用。未知字符串返 None。
|
||||
pub fn from_db_str(s: &str) -> Option<Self> {
|
||||
Some(match s {
|
||||
"planning" => ProjectStatus::Planning,
|
||||
"in_progress" => ProjectStatus::InProgress,
|
||||
"testing" => ProjectStatus::Testing,
|
||||
"releasing" => ProjectStatus::Releasing,
|
||||
"completed" => ProjectStatus::Completed,
|
||||
"paused" => ProjectStatus::Paused,
|
||||
"cancelled" => ProjectStatus::Cancelled,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ProjectStatus {
|
||||
@@ -181,6 +214,23 @@ impl TaskStatus {
|
||||
"cancelled",
|
||||
]
|
||||
}
|
||||
|
||||
/// 从数据库存储的小写字符串解析为 TaskStatus(与 `as_str` 对偶)。
|
||||
///
|
||||
/// Input Augmentation 层 TaskResolver 把记录的 String status 投影成类型化枚举用。
|
||||
/// 未知字符串返 None。
|
||||
pub fn from_db_str(s: &str) -> Option<Self> {
|
||||
Some(match s {
|
||||
"todo" => TaskStatus::Todo,
|
||||
"in_progress" => TaskStatus::InProgress,
|
||||
"in_review" => TaskStatus::InReview,
|
||||
"testing" => TaskStatus::Testing,
|
||||
"done" => TaskStatus::Done,
|
||||
"blocked" => TaskStatus::Blocked,
|
||||
"cancelled" => TaskStatus::Cancelled,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for TaskStatus {
|
||||
|
||||
@@ -3,6 +3,17 @@ name = "df-workflow"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[features]
|
||||
# ARC-260618-01-d 条件引擎接入执行器(默认关 = 旧行为零变更,渐进开启)
|
||||
#
|
||||
# 关闭时(默认):DagExecutor 完全忽略 edge.condition,按拓扑层全跑,
|
||||
# 与增强前行为完全一致(零行为破坏现有 HumanNode/AiNode 等所有调用方)。
|
||||
# 开启时:节点收集 inputs 前,对带 condition 的入边以 source output 为 context 求值;
|
||||
# 求值 false 则该前驱 input 不收集,且若该节点所有入边(含 condition)均被过滤,
|
||||
# 则跳过该节点执行(保持 Pending),实现条件路由。
|
||||
default = []
|
||||
conditions-eval = []
|
||||
|
||||
[dependencies]
|
||||
df-types = { path = "../df-types" }
|
||||
serde = { workspace = true }
|
||||
|
||||
@@ -1,6 +1,29 @@
|
||||
//! 条件表达式引擎 — 用于 DAG 边上的条件判断
|
||||
//!
|
||||
//! TODO: 实现完整的条件表达式解析与求值
|
||||
//! 支持的语法(由低到高优先级):
|
||||
//!
|
||||
//! - 逻辑或 `or`
|
||||
//! - 逻辑与 `and`
|
||||
//! - 逻辑非 `not`(一元前缀)
|
||||
//! - 括号分组 `( ... )`
|
||||
//! - 比较 `==` `!=` `>` `>=` `<` `<=` `contains`
|
||||
//! - 操作数:
|
||||
//! - 布尔字面量 `true` / `false`
|
||||
//! - 单引号字符串 `'completed'`(内部 `''` 转义为单引号)
|
||||
//! - 数字字面量 `10` / `-3.5`(支持负数、小数)
|
||||
//! - JSON Path `$.a.b` / `$.list[0]` / `$['key with space']`(从求值 context 取值)
|
||||
//!
|
||||
//! 安全约束:
|
||||
//! - **求值失败保守 false** —— 任何解析错误、JSON Path 找不到、类型不兼容,均返回 `Ok(false)`
|
||||
//! 而非 `Err`。条件分支写错或 context 缺字段时不应静默放行(默认 false = 保守拒绝)。
|
||||
//! `evaluate` 实现把所有错误路径(tokenize 失败、parse 失败)都吞成 `Ok(false)`,
|
||||
//! 永不返回 `Err`;`anyhow::Result<bool>` 签名仅作为未来扩展点保留(若日后需向调用方
|
||||
//! 区分「表达式非法」与「求值结果为 false」,可在此放开)。当前 executor 无需处理 `Err`。
|
||||
//! - `contains`:左值为字符串 → 子串匹配;左值为数组 → 元素存在性匹配;其余类型 → false。
|
||||
//! - `>` `<` `>=` `<=`:仅两端正数/负数/小数(数值)才比较;任一非数值 → false。
|
||||
//!
|
||||
//! 实现说明:手写递归下降解析器,无第三方依赖(jsonpath crate 引入会拉额外体积,
|
||||
//! 且当前需求仅需点路径 + 下标,手写更可控)。表达式按需惰性求值,短路 and/or。
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
@@ -10,26 +33,468 @@ pub struct ConditionEngine;
|
||||
impl ConditionEngine {
|
||||
/// 求值条件表达式
|
||||
///
|
||||
/// 当前仅支持 "true"/"false" 字面量。未识别的表达式 **默认 false**(保守拒绝),
|
||||
/// 而非默认 true——条件分支写错或引擎未实现时不应该静默放行。
|
||||
/// TODO: 实现完整的表达式解析(JSON Path / 数值比较 / contains / 逻辑组合)。
|
||||
pub fn evaluate(expr: &str, _context: &Value) -> anyhow::Result<bool> {
|
||||
/// 详见模块级文档的语法与安全约束。**求值失败保守 false**:解析错误、JSON Path 缺失、
|
||||
/// 类型不兼容一律返回 `Ok(false)`,不静默放行(默认 false = 保守拒绝)。
|
||||
pub fn evaluate(expr: &str, context: &Value) -> anyhow::Result<bool> {
|
||||
let trimmed = expr.trim();
|
||||
if trimmed == "true" {
|
||||
return Ok(true);
|
||||
}
|
||||
if trimmed == "false" {
|
||||
if trimmed.is_empty() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// TODO: 实现如下语法:
|
||||
// - "$.status == 'completed'" — JSON Path 比较
|
||||
// - "$.count > 10" — 数值比较
|
||||
// - "$.tags contains 'ai'" — 包含检查
|
||||
// - "and/or/not" — 逻辑组合
|
||||
let toks = match tokenize(trimmed) {
|
||||
Ok(t) => t,
|
||||
// tokenizer 阶段即非法(如未闭合引号):保守 false,不静默放行
|
||||
Err(_) => return Ok(false),
|
||||
};
|
||||
let mut parser = Parser { toks, pos: 0, ctx: context };
|
||||
|
||||
tracing::warn!("条件表达式引擎尚未完整实现,表达式未识别默认 false: {}", expr);
|
||||
Ok(false)
|
||||
match parser.parse_or() {
|
||||
Ok(v) => Ok(v),
|
||||
// 解析错误保守 false:与历史行为「未识别表达式默认 false」一致,不破坏调用方
|
||||
Err(_) => Ok(false),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// JSON Path 求值 —— 手写,支持点路径 / 下标 / 单引号键
|
||||
// ============================================================================
|
||||
|
||||
/// 按手写 JSON Path 从 context 取值。
|
||||
///
|
||||
/// 支持语法:
|
||||
/// - `$.a.b` 点取字段
|
||||
/// - `$.a[0]` 数组下标(支持负数从尾部,-1 = 末尾)
|
||||
/// - `$['a b']` 单引号键(支持含空格/特殊字符的键名)
|
||||
///
|
||||
/// `path` 必须以 `$` 起头;解析失败或路径不存在返回 `None`(调用方据此保守 false)。
|
||||
fn eval_jsonpath(path: &str, context: &Value) -> Option<Value> {
|
||||
let p = path.strip_prefix('$')?;
|
||||
let bytes = p.as_bytes();
|
||||
// 全量 clone 而非按需借用:JSON Path 段数与深度运行时才确定,逐段借用需把
|
||||
// `&Value` 在循环中重绑(每段 as_object/as_array 返回的借用生命周期互相嵌套),
|
||||
// 借用检查极难通过;改用拥有的 Value 顺序覆盖,代码直观。代价是 context 顶层
|
||||
// 的一次深拷贝——条件求值在 DAG 边上触发,频率低且 context 通常不深,开销可忽略。
|
||||
let mut cur: Value = context.clone();
|
||||
let mut i = 0usize;
|
||||
|
||||
while i < bytes.len() {
|
||||
match bytes[i] {
|
||||
b'.' => {
|
||||
i += 1;
|
||||
let (key, next) = consume_key(p, i)?;
|
||||
i = next;
|
||||
cur = cur.as_object().and_then(|m| m.get(&key).cloned())?;
|
||||
}
|
||||
b'[' => {
|
||||
i += 1;
|
||||
while i < bytes.len() && bytes[i].is_ascii_whitespace() {
|
||||
i += 1;
|
||||
}
|
||||
if i >= bytes.len() {
|
||||
return None;
|
||||
}
|
||||
if bytes[i] == b'\'' {
|
||||
let (key, next) = consume_quoted(p, i)?;
|
||||
i = next;
|
||||
while i < bytes.len() && bytes[i].is_ascii_whitespace() {
|
||||
i += 1;
|
||||
}
|
||||
if i >= bytes.len() || bytes[i] != b']' {
|
||||
return None;
|
||||
}
|
||||
i += 1;
|
||||
cur = cur.as_object().and_then(|m| m.get(&key).cloned())?;
|
||||
} else {
|
||||
let start = i;
|
||||
while i < bytes.len() && bytes[i] != b']' {
|
||||
i += 1;
|
||||
}
|
||||
let idx_str = p[start..i].trim();
|
||||
let idx: isize = idx_str.parse().ok()?;
|
||||
i += 1; // 跳过 ]
|
||||
let arr = cur.as_array()?;
|
||||
let len = arr.len() as isize;
|
||||
let real = if idx < 0 { len + idx } else { idx };
|
||||
if real < 0 || real >= len {
|
||||
return None;
|
||||
}
|
||||
cur = arr.get(real as usize).cloned()?;
|
||||
}
|
||||
}
|
||||
c if c.is_ascii_whitespace() => {
|
||||
i += 1;
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
Some(cur)
|
||||
}
|
||||
|
||||
/// 取一段点路径的键(到下一个 `.`/`[`/行尾),返回 (key, 下一个待处理位置)。
|
||||
fn consume_key(s: &str, start: usize) -> Option<(String, usize)> {
|
||||
let bytes = s.as_bytes();
|
||||
let mut i = start;
|
||||
while i < bytes.len() && bytes[i] != b'.' && bytes[i] != b'[' {
|
||||
i += 1;
|
||||
}
|
||||
let key = s[start..i].trim();
|
||||
if key.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some((key.to_string(), i))
|
||||
}
|
||||
|
||||
/// 取单引号字符串(起始位置指向开引号 `'`),内部 `''` 转义为单引号。
|
||||
/// 返回 (内容, 闭引号后位置)。
|
||||
///
|
||||
/// 实现要点:先扫描定位闭引号的字节位置,再对原始切片整体取出内容,而非逐字节
|
||||
/// `bytes[i] as char` 累加。原因:单引号 `'` 是 ASCII 单字节字符,从开引号到闭引号
|
||||
/// 之间的字节范围天然落在 UTF-8 字符边界上,整体切片能完整保留多字节字符(中文/emoji);
|
||||
/// 逐字节 `as char` 会把每个字节当独立码点,破坏多字节 UTF-8 序列致乱码。
|
||||
fn consume_quoted(s: &str, start: usize) -> Option<(String, usize)> {
|
||||
let bytes = s.as_bytes();
|
||||
if start >= bytes.len() || bytes[start] != b'\'' {
|
||||
return None;
|
||||
}
|
||||
// 扫描定位真正的闭引号:遇到 `''` 视为转义(跳过),单个 `'` 才是闭引号
|
||||
let mut i = start + 1;
|
||||
let content_start = i;
|
||||
while i < bytes.len() {
|
||||
if bytes[i] == b'\'' {
|
||||
// 检测转义 ''
|
||||
if i + 1 < bytes.len() && bytes[i + 1] == b'\'' {
|
||||
i += 2; // 跳过转义的两引号,继续找闭引号
|
||||
continue;
|
||||
}
|
||||
// 闭引号找到:content_start..i 是引号内的原始字节段(落在 UTF-8 边界)
|
||||
let raw = &s[content_start..i];
|
||||
// 内部 `''` 转义为单引号:整段替换(无转义时直接 clone 原文,零额外分配开销仅一次)
|
||||
let content = raw.replace("''", "'");
|
||||
return Some((content, i + 1));
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
None // 未闭合引号
|
||||
}
|
||||
|
||||
// 历史:曾设计「借用或拥有的 Value 视图」类型以避免逐段 clone,后弃用——
|
||||
// eval_jsonpath 改为直接 clone(见上方实现内注释)。放弃借用优化的理由:JSON Path
|
||||
// 通常只有少数几段,每段 clone 一个 serde_json::Value 的开销可忽略,换取代码可读性
|
||||
// 与借用检查通过。此段不再对应任何类型定义,仅作设计取舍的历史说明保留。
|
||||
|
||||
// ============================================================================
|
||||
// Tokenizer —— 把表达式切成 Token 流
|
||||
// ============================================================================
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
enum Token {
|
||||
Bool(bool),
|
||||
Number(f64),
|
||||
Str(String),
|
||||
Path(String),
|
||||
/// `==` `!=` `>` `>=` `<` `<=` `contains`
|
||||
Cmp(CmpOp),
|
||||
And,
|
||||
Or,
|
||||
Not,
|
||||
LParen,
|
||||
RParen,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
enum CmpOp {
|
||||
Eq,
|
||||
Ne,
|
||||
Gt,
|
||||
Ge,
|
||||
Lt,
|
||||
Le,
|
||||
Contains,
|
||||
}
|
||||
|
||||
fn tokenize(s: &str) -> Result<Vec<Token>, ()> {
|
||||
let bytes = s.as_bytes();
|
||||
let mut toks = Vec::new();
|
||||
let mut i = 0usize;
|
||||
|
||||
while i < bytes.len() {
|
||||
// 略空白
|
||||
if bytes[i].is_ascii_whitespace() {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
match bytes[i] {
|
||||
b'(' => {
|
||||
toks.push(Token::LParen);
|
||||
i += 1;
|
||||
}
|
||||
b')' => {
|
||||
toks.push(Token::RParen);
|
||||
i += 1;
|
||||
}
|
||||
b'\'' => {
|
||||
let (val, next) = consume_quoted(s, i).ok_or(())?;
|
||||
toks.push(Token::Str(val));
|
||||
i = next;
|
||||
}
|
||||
b'$' => {
|
||||
// JSON Path:到下一个空白或比较符或括号外
|
||||
let start = i;
|
||||
i += 1;
|
||||
while i < bytes.len() {
|
||||
let c = bytes[i];
|
||||
// 遇空白 / 逻辑词边界 / 比较符 / 括号 停止
|
||||
if c.is_ascii_whitespace() || c == b'(' || c == b')' {
|
||||
break;
|
||||
}
|
||||
// 比较运算符起始
|
||||
if c == b'=' || c == b'!' || c == b'>' || c == b'<' {
|
||||
break;
|
||||
}
|
||||
// [ 段内允许,但 ] 后若跟字母数字属下一个 token(罕见,简化:整体吃掉)
|
||||
i += 1;
|
||||
}
|
||||
toks.push(Token::Path(s[start..i].to_string()));
|
||||
}
|
||||
b'=' => {
|
||||
if i + 1 < bytes.len() && bytes[i + 1] == b'=' {
|
||||
toks.push(Token::Cmp(CmpOp::Eq));
|
||||
i += 2;
|
||||
} else {
|
||||
return Err(()); // 单 = 非法
|
||||
}
|
||||
}
|
||||
b'!' => {
|
||||
if i + 1 < bytes.len() && bytes[i + 1] == b'=' {
|
||||
toks.push(Token::Cmp(CmpOp::Ne));
|
||||
i += 2;
|
||||
} else {
|
||||
return Err(()); // 单 ! 非法(不支持 C 风格!)
|
||||
}
|
||||
}
|
||||
b'>' => {
|
||||
if i + 1 < bytes.len() && bytes[i + 1] == b'=' {
|
||||
toks.push(Token::Cmp(CmpOp::Ge));
|
||||
i += 2;
|
||||
} else {
|
||||
toks.push(Token::Cmp(CmpOp::Gt));
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
b'<' => {
|
||||
if i + 1 < bytes.len() && bytes[i + 1] == b'=' {
|
||||
toks.push(Token::Cmp(CmpOp::Le));
|
||||
i += 2;
|
||||
} else {
|
||||
toks.push(Token::Cmp(CmpOp::Lt));
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
// 数字字面量(含负号起始;正号省略)。注意:`and/or/not/true/false` 不会被
|
||||
// 数字分支吃掉,因为它们以字母开头走下面的字母分支。
|
||||
b'-' | b'0'..=b'9' => {
|
||||
let start = i;
|
||||
if bytes[i] == b'-' {
|
||||
i += 1;
|
||||
}
|
||||
let mut saw_digit = false;
|
||||
while i < bytes.len()
|
||||
&& (bytes[i].is_ascii_digit() || bytes[i] == b'.')
|
||||
{
|
||||
saw_digit = true;
|
||||
i += 1;
|
||||
}
|
||||
if !saw_digit {
|
||||
return Err(()); // 仅 `-` 非法
|
||||
}
|
||||
let num: f64 = s[start..i].parse().map_err(|_| ())?;
|
||||
toks.push(Token::Number(num));
|
||||
}
|
||||
// 字母起始:关键字 or/and/not/true/false 或 contains
|
||||
_ => {
|
||||
let start = i;
|
||||
while i < bytes.len()
|
||||
&& (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_')
|
||||
{
|
||||
i += 1;
|
||||
}
|
||||
let word = &s[start..i];
|
||||
match word {
|
||||
"true" => toks.push(Token::Bool(true)),
|
||||
"false" => toks.push(Token::Bool(false)),
|
||||
"and" => toks.push(Token::And),
|
||||
"or" => toks.push(Token::Or),
|
||||
"not" => toks.push(Token::Not),
|
||||
"contains" => toks.push(Token::Cmp(CmpOp::Contains)),
|
||||
// 未识别关键字:保守起见视为非法(tokenize 失败 → evaluate false)
|
||||
_ => return Err(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(toks)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Parser —— 递归下降,优先级 or < and < not < 比较 < 操作数
|
||||
// ============================================================================
|
||||
|
||||
struct Parser<'a> {
|
||||
toks: Vec<Token>,
|
||||
pos: usize,
|
||||
ctx: &'a Value,
|
||||
}
|
||||
|
||||
impl<'a> Parser<'a> {
|
||||
fn peek(&self) -> Option<&Token> {
|
||||
self.toks.get(self.pos)
|
||||
}
|
||||
|
||||
fn bump(&mut self) -> Option<Token> {
|
||||
let t = self.toks.get(self.pos).cloned();
|
||||
if t.is_some() {
|
||||
self.pos += 1;
|
||||
}
|
||||
t
|
||||
}
|
||||
|
||||
/// or := and ( 'or' and )*
|
||||
fn parse_or(&mut self) -> Result<bool, ()> {
|
||||
let mut left = self.parse_and()?;
|
||||
while let Some(Token::Or) = self.peek() {
|
||||
self.bump();
|
||||
// 注意:此处并非真正短路求值。右侧 parse_and() 必须无条件执行——
|
||||
// 递归下降解析器需消费右侧 token 才能正确推进 pos(否则残留 token 会让
|
||||
// 上层误判为非法表达式)。`||` 仅做最终布尔合并;本引擎求值无副作用,
|
||||
// 右侧即使结果被丢弃也无害。
|
||||
let right = self.parse_and()?;
|
||||
left = left || right;
|
||||
}
|
||||
Ok(left)
|
||||
}
|
||||
|
||||
/// and := not ( 'and' not )*
|
||||
fn parse_and(&mut self) -> Result<bool, ()> {
|
||||
let mut left = self.parse_not()?;
|
||||
while let Some(Token::And) = self.peek() {
|
||||
self.bump();
|
||||
// 同 parse_or:右侧 parse_not() 必须无条件执行以推进 pos,`&&` 仅做
|
||||
// 最终布尔合并,非短路求值(引擎无副作用)。
|
||||
let right = self.parse_not()?;
|
||||
left = left && right;
|
||||
}
|
||||
Ok(left)
|
||||
}
|
||||
|
||||
/// not := 'not' not | comparison
|
||||
fn parse_not(&mut self) -> Result<bool, ()> {
|
||||
if let Some(Token::Not) = self.peek() {
|
||||
self.bump();
|
||||
let v = self.parse_not()?;
|
||||
return Ok(!v);
|
||||
}
|
||||
self.parse_comparison()
|
||||
}
|
||||
|
||||
/// comparison := operand ( cmpop operand )?
|
||||
///
|
||||
/// 无比较运算符时:operand 自身需为布尔语义(JSON Path 取到 bool、或布尔字面量);
|
||||
/// 否则视为非布尔(保守 false)。
|
||||
fn parse_comparison(&mut self) -> Result<bool, ()> {
|
||||
let left = self.parse_operand()?;
|
||||
match self.peek() {
|
||||
Some(Token::Cmp(op)) => {
|
||||
let op = *op;
|
||||
self.bump();
|
||||
let right = self.parse_operand()?;
|
||||
Ok(eval_cmp(op, &left, &right))
|
||||
}
|
||||
_ => {
|
||||
// 单操作数:仅布尔字面量或 JSON Path 指向 bool 为 true;其余保守 false
|
||||
Ok(left.as_bool().unwrap_or(false))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// operand := '(' or ')' | bool | number | str | path
|
||||
fn parse_operand(&mut self) -> Result<Value, ()> {
|
||||
match self.bump() {
|
||||
Some(Token::LParen) => {
|
||||
let v = self.parse_or()?;
|
||||
// 期待闭括号
|
||||
match self.bump() {
|
||||
Some(Token::RParen) => Ok(Value::Bool(v)),
|
||||
_ => Err(()),
|
||||
}
|
||||
}
|
||||
Some(Token::Bool(b)) => Ok(Value::Bool(b)),
|
||||
Some(Token::Number(n)) => Ok(serde_json::json!(n)),
|
||||
Some(Token::Str(s)) => Ok(Value::String(s)),
|
||||
Some(Token::Path(p)) => {
|
||||
// JSON Path 求值失败 → Null(后续比较保守 false)
|
||||
Ok(eval_jsonpath(&p, self.ctx).unwrap_or(Value::Null))
|
||||
}
|
||||
_ => Err(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 比较求值。任一侧类型不兼容 → false(保守)。
|
||||
fn eval_cmp(op: CmpOp, left: &Value, right: &Value) -> bool {
|
||||
match op {
|
||||
CmpOp::Eq => json_eq(left, right),
|
||||
CmpOp::Ne => !json_eq(left, right),
|
||||
CmpOp::Gt | CmpOp::Ge | CmpOp::Lt | CmpOp::Le => {
|
||||
let cmp = num_cmp(left, right);
|
||||
match (op, cmp) {
|
||||
(CmpOp::Gt, Some(o)) => o.is_gt(),
|
||||
(CmpOp::Ge, Some(o)) => o.is_ge(),
|
||||
(CmpOp::Lt, Some(o)) => o.is_lt(),
|
||||
(CmpOp::Le, Some(o)) => o.is_le(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
CmpOp::Contains => json_contains(left, right),
|
||||
}
|
||||
}
|
||||
|
||||
/// 相等比较:数值按数值比(string "1" 不等于 number 1,避免隐式转换陷阱),
|
||||
/// 其余按 serde_json Value 相等。布尔与数字需类型对齐(serde_json bool != number)。
|
||||
fn json_eq(left: &Value, right: &Value) -> bool {
|
||||
// 两端均数值(含 i64/u64/f64):数值比较(1.0 == 1)
|
||||
if let (Some(a), Some(b)) = (as_f64(left), as_f64(right)) {
|
||||
return (a - b).abs() < f64::EPSILON;
|
||||
}
|
||||
left == right
|
||||
}
|
||||
|
||||
/// 数值比较。两端需均可转 f64(数字字面量、JSON number),否则 None(保守 false)。
|
||||
fn num_cmp(left: &Value, right: &Value) -> Option<std::cmp::Ordering> {
|
||||
let a = as_f64(left)?;
|
||||
let b = as_f64(right)?;
|
||||
a.partial_cmp(&b)
|
||||
}
|
||||
|
||||
fn as_f64(v: &Value) -> Option<f64> {
|
||||
match v {
|
||||
Value::Number(n) => n.as_f64(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// contains:
|
||||
/// - 左值为字符串、右值为字符串 → 子串包含
|
||||
/// - 左值为数组 → 任一元素 json_eq 右值
|
||||
/// - 其余 → false
|
||||
fn json_contains(left: &Value, right: &Value) -> bool {
|
||||
match left {
|
||||
Value::String(s) => right
|
||||
.as_str()
|
||||
.map(|r| s.contains(r))
|
||||
.unwrap_or(false),
|
||||
Value::Array(arr) => arr.iter().any(|e| json_eq(e, right)),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,60 +503,399 @@ mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn ctx() -> Value {
|
||||
// 当前实现未使用 context,但保持传入以对齐签名
|
||||
json!({})
|
||||
}
|
||||
// --------------------------------------------------------------------
|
||||
// 字面量与向后兼容(原有 7 个用例语义保持)
|
||||
// --------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_true_literal_returns_true() {
|
||||
assert_eq!(ConditionEngine::evaluate("true", &ctx()).unwrap(), true);
|
||||
assert_eq!(ConditionEngine::evaluate("true", &json!({})).unwrap(), true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_false_literal_returns_false() {
|
||||
assert_eq!(ConditionEngine::evaluate("false", &ctx()).unwrap(), false);
|
||||
assert_eq!(ConditionEngine::evaluate("false", &json!({})).unwrap(), false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unsupported_expression_defaults_to_false() {
|
||||
// 尚未实现的语法:表达式非 true/false 字面量时默认 false(保守拒绝,不静默放行)
|
||||
// 历史用例:非法表达式(单 = 等非法 token)保守 false
|
||||
// 注:增强后 "$.status == 'completed'" 已合法,改用真正非法的表达式
|
||||
assert_eq!(
|
||||
ConditionEngine::evaluate("$.status == 'completed'", &ctx()).unwrap(),
|
||||
ConditionEngine::evaluate("= =", &json!({})).unwrap(),
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_whitespace_is_trimmed() {
|
||||
// trimmed() 去除首尾空白后再与字面量比较
|
||||
assert_eq!(
|
||||
ConditionEngine::evaluate(" true ", &ctx()).unwrap(),
|
||||
ConditionEngine::evaluate(" true ", &json!({})).unwrap(),
|
||||
true
|
||||
);
|
||||
assert_eq!(
|
||||
ConditionEngine::evaluate("\tfalse\n", &ctx()).unwrap(),
|
||||
ConditionEngine::evaluate("\tfalse\n", &json!({})).unwrap(),
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_string_defaults_to_false() {
|
||||
assert_eq!(ConditionEngine::evaluate("", &ctx()).unwrap(), false);
|
||||
assert_eq!(ConditionEngine::evaluate(" ", &ctx()).unwrap(), false);
|
||||
assert_eq!(ConditionEngine::evaluate("", &json!({})).unwrap(), false);
|
||||
assert_eq!(ConditionEngine::evaluate(" ", &json!({})).unwrap(), false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_case_sensitive_not_matched() {
|
||||
// 字面量比较区分大小写:True/False 既非 "true" 也非 "false",默认 false
|
||||
assert_eq!(ConditionEngine::evaluate("True", &ctx()).unwrap(), false);
|
||||
assert_eq!(ConditionEngine::evaluate("FALSE", &ctx()).unwrap(), false);
|
||||
// True/FALSE 仍非合法布尔字面量(只认小写 true/false)
|
||||
assert_eq!(ConditionEngine::evaluate("True", &json!({})).unwrap(), false);
|
||||
assert_eq!(ConditionEngine::evaluate("FALSE", &json!({})).unwrap(), false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_arbitrary_string_defaults_to_false() {
|
||||
assert_eq!(ConditionEngine::evaluate("yes", &ctx()).unwrap(), false);
|
||||
assert_eq!(ConditionEngine::evaluate("1", &ctx()).unwrap(), false);
|
||||
assert_eq!(ConditionEngine::evaluate("completed", &ctx()).unwrap(), false);
|
||||
// 未识别关键字(yes / completed)→ tokenize 失败 → false
|
||||
assert_eq!(ConditionEngine::evaluate("yes", &json!({})).unwrap(), false);
|
||||
assert_eq!(ConditionEngine::evaluate("completed", &json!({})).unwrap(), false);
|
||||
// 裸数字(非布尔语义)→ 单操作数非 bool → false
|
||||
assert_eq!(ConditionEngine::evaluate("1", &json!({})).unwrap(), false);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------
|
||||
// 一、JSON Path 求值
|
||||
// --------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn jsonpath_eq_string_literal() {
|
||||
let ctx = json!({ "status": "completed" });
|
||||
assert_eq!(
|
||||
ConditionEngine::evaluate("$.status == 'completed'", &ctx).unwrap(),
|
||||
true
|
||||
);
|
||||
assert_eq!(
|
||||
ConditionEngine::evaluate("$.status == 'failed'", &ctx).unwrap(),
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jsonpath_nested_dot() {
|
||||
let ctx = json!({ "result": { "code": 200 } });
|
||||
assert_eq!(
|
||||
ConditionEngine::evaluate("$.result.code == 200", &ctx).unwrap(),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jsonpath_array_index() {
|
||||
let ctx = json!({ "tags": ["ai", "rust"] });
|
||||
assert_eq!(
|
||||
ConditionEngine::evaluate("$.tags[0] == 'ai'", &ctx).unwrap(),
|
||||
true
|
||||
);
|
||||
// 负数下标从尾部
|
||||
assert_eq!(
|
||||
ConditionEngine::evaluate("$.tags[-1] == 'rust'", &ctx).unwrap(),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jsonpath_missing_path_defaults_false() {
|
||||
// 路径不存在 → Null == 'completed' → false(保守)
|
||||
let ctx = json!({ "status": "ok" });
|
||||
assert_eq!(
|
||||
ConditionEngine::evaluate("$.missing == 'x'", &ctx).unwrap(),
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jsonpath_bool_value_as_operand() {
|
||||
// JSON Path 指向 bool,可作单操作数布尔求值
|
||||
let ctx = json!({ "approved": true });
|
||||
assert_eq!(
|
||||
ConditionEngine::evaluate("$.approved", &ctx).unwrap(),
|
||||
true
|
||||
);
|
||||
// 非 bool 字段作单操作数 → false(字符串非布尔语义)
|
||||
let ctx2 = json!({ "status": "ok" });
|
||||
assert_eq!(
|
||||
ConditionEngine::evaluate("$.status", &ctx2).unwrap(),
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------
|
||||
// 二、比较运算符(==/!=/>/</>=/<=)
|
||||
// --------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn numeric_gt_lt() {
|
||||
let ctx = json!({ "count": 15 });
|
||||
assert_eq!(
|
||||
ConditionEngine::evaluate("$.count > 10", &ctx).unwrap(),
|
||||
true
|
||||
);
|
||||
assert_eq!(
|
||||
ConditionEngine::evaluate("$.count < 10", &ctx).unwrap(),
|
||||
false
|
||||
);
|
||||
assert_eq!(
|
||||
ConditionEngine::evaluate("$.count >= 15", &ctx).unwrap(),
|
||||
true
|
||||
);
|
||||
assert_eq!(
|
||||
ConditionEngine::evaluate("$.count <= 14", &ctx).unwrap(),
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numeric_negative_and_decimal() {
|
||||
let ctx = json!({ "temp": -3.5 });
|
||||
assert_eq!(
|
||||
ConditionEngine::evaluate("$.temp < 0", &ctx).unwrap(),
|
||||
true
|
||||
);
|
||||
assert_eq!(
|
||||
ConditionEngine::evaluate("$.temp == -3.5", &ctx).unwrap(),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ne_operator() {
|
||||
let ctx = json!({ "status": "ok" });
|
||||
assert_eq!(
|
||||
ConditionEngine::evaluate("$.status != 'failed'", &ctx).unwrap(),
|
||||
true
|
||||
);
|
||||
assert_eq!(
|
||||
ConditionEngine::evaluate("$.status != 'ok'", &ctx).unwrap(),
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_numeric_gt_defaults_false() {
|
||||
// 字符串 > 数字:类型不兼容 → 保守 false
|
||||
let ctx = json!({ "name": "abc" });
|
||||
assert_eq!(
|
||||
ConditionEngine::evaluate("$.name > 10", &ctx).unwrap(),
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------
|
||||
// 三、contains
|
||||
// --------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn contains_substring() {
|
||||
let ctx = json!({ "msg": "hello world" });
|
||||
assert_eq!(
|
||||
ConditionEngine::evaluate("$.msg contains 'world'", &ctx).unwrap(),
|
||||
true
|
||||
);
|
||||
assert_eq!(
|
||||
ConditionEngine::evaluate("$.msg contains 'xyz'", &ctx).unwrap(),
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contains_array_element() {
|
||||
let ctx = json!({ "tags": ["ai", "rust", "devflow"] });
|
||||
assert_eq!(
|
||||
ConditionEngine::evaluate("$.tags contains 'rust'", &ctx).unwrap(),
|
||||
true
|
||||
);
|
||||
assert_eq!(
|
||||
ConditionEngine::evaluate("$.tags contains 'go'", &ctx).unwrap(),
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contains_numeric_in_array() {
|
||||
// 数组 contains 数字(数值相等判定,1 == 1.0)
|
||||
let ctx = json!({ "nums": [1, 2, 3] });
|
||||
assert_eq!(
|
||||
ConditionEngine::evaluate("$.nums contains 2", &ctx).unwrap(),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contains_non_string_non_array_defaults_false() {
|
||||
let ctx = json!({ "n": 100 });
|
||||
// 数字 contains ... 无意义 → false
|
||||
assert_eq!(
|
||||
ConditionEngine::evaluate("$.n contains '1'", &ctx).unwrap(),
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------
|
||||
// 四、逻辑组合 and / or / not
|
||||
// --------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn logical_and() {
|
||||
let ctx = json!({ "a": true, "b": false });
|
||||
assert_eq!(
|
||||
ConditionEngine::evaluate("$.a and $.b", &ctx).unwrap(),
|
||||
false
|
||||
);
|
||||
let ctx2 = json!({ "a": true, "b": true });
|
||||
assert_eq!(
|
||||
ConditionEngine::evaluate("$.a and $.b", &ctx2).unwrap(),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn logical_or() {
|
||||
let ctx = json!({ "a": false, "b": true });
|
||||
assert_eq!(
|
||||
ConditionEngine::evaluate("$.a or $.b", &ctx).unwrap(),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn logical_not() {
|
||||
let ctx = json!({ "approved": false });
|
||||
assert_eq!(
|
||||
ConditionEngine::evaluate("not $.approved", &ctx).unwrap(),
|
||||
true
|
||||
);
|
||||
let ctx2 = json!({ "approved": true });
|
||||
assert_eq!(
|
||||
ConditionEngine::evaluate("not $.approved", &ctx2).unwrap(),
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn and_or_precedence() {
|
||||
// and 优先级高于 or:true or (false and false) → true
|
||||
let ctx = json!({});
|
||||
assert_eq!(
|
||||
ConditionEngine::evaluate("true or false and false", &ctx).unwrap(),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn not_precedence_over_and() {
|
||||
// not 高于 and:not false and not false → true and true → true
|
||||
let ctx = json!({});
|
||||
assert_eq!(
|
||||
ConditionEngine::evaluate("not false and not false", &ctx).unwrap(),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------
|
||||
// 五、嵌套括号 + 综合
|
||||
// --------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn nested_parens() {
|
||||
let ctx = json!({});
|
||||
// (true or false) and (false or true) → true and true → true
|
||||
assert_eq!(
|
||||
ConditionEngine::evaluate("(true or false) and (false or true)", &ctx).unwrap(),
|
||||
true
|
||||
);
|
||||
// not (true and true) → false
|
||||
assert_eq!(
|
||||
ConditionEngine::evaluate("not (true and true)", &ctx).unwrap(),
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn complex_realworld_expression() {
|
||||
// 综合场景:状态完成 且 (分数 >= 80 或 含 vip 标签)且 未取消
|
||||
let ctx = json!({
|
||||
"status": "completed",
|
||||
"score": 85,
|
||||
"tags": ["vip", "ai"],
|
||||
"cancelled": false
|
||||
});
|
||||
let expr = "$.status == 'completed' and ($.score >= 80 or $.tags contains 'vip') and not $.cancelled";
|
||||
assert_eq!(
|
||||
ConditionEngine::evaluate(expr, &ctx).unwrap(),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unbalanced_parens_defaults_false() {
|
||||
// 括号不闭合 → parse 失败 → 保守 false
|
||||
assert_eq!(
|
||||
ConditionEngine::evaluate("(true and false", &json!({})).unwrap(),
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unclosed_quote_defaults_false() {
|
||||
assert_eq!(
|
||||
ConditionEngine::evaluate("$.s == 'abc", &json!({"s":"x"})).unwrap(),
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------
|
||||
// 六、多字节 UTF-8(中文 key/值、emoji)与转义——回归 consume_quoted 逐字节 bug
|
||||
// --------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn jsonpath_chinese_key_and_value() {
|
||||
// 中文 key + 中文值:验证 consume_quoted 整体切片保留 UTF-8(逐字节 as char 会乱码)
|
||||
let ctx = json!({ "状态": "已完成" });
|
||||
assert_eq!(
|
||||
ConditionEngine::evaluate("$.状态 == '已完成'", &ctx).unwrap(),
|
||||
true
|
||||
);
|
||||
assert_eq!(
|
||||
ConditionEngine::evaluate("$.状态 == '失败'", &ctx).unwrap(),
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jsonpath_bracket_chinese_key() {
|
||||
// $['中文'] 单引号括号键含中文值,直接考验 consume_quoted 多字节处理
|
||||
let ctx = json!({ "中文": "成功" });
|
||||
assert_eq!(
|
||||
ConditionEngine::evaluate("$['中文'] == '成功'", &ctx).unwrap(),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jsonpath_emoji_value() {
|
||||
// emoji(4 字节 UTF-8 序列)作字面量值
|
||||
let ctx = json!({ "flag": "🚀" });
|
||||
assert_eq!(
|
||||
ConditionEngine::evaluate("$.flag == '🚀'", &ctx).unwrap(),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_quote_escaped_inside_literal() {
|
||||
// 内部 '' 转义为单引号:it''s → it's
|
||||
let ctx = json!({ "msg": "it's done" });
|
||||
assert_eq!(
|
||||
ConditionEngine::evaluate("$.msg == 'it''s done'", &ctx).unwrap(),
|
||||
true
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ use std::collections::HashMap;
|
||||
use df_types::events::WorkflowEvent;
|
||||
use df_types::types::NodeId;
|
||||
|
||||
use crate::conditions::ConditionEngine;
|
||||
use crate::dag::Dag;
|
||||
use crate::eventbus::EventBus;
|
||||
use crate::node::{NodeContext, NodeOutput};
|
||||
@@ -57,14 +58,16 @@ impl DagExecutor {
|
||||
|
||||
tracing::info!("DAG 执行开始,共 {} 层", layers.len());
|
||||
|
||||
// 预建入边索引:target → 直接前驱列表(O(E) 一次构建)
|
||||
// 预建入边索引:target → 直接前驱列表 + 边条件表达式(O(E) 一次构建)
|
||||
// 避免在内层按节点循环中调用 dag.predecessors()(每次 O(E) 全表扫描,整体 O(V·E))。
|
||||
let mut adjacency_in: HashMap<NodeId, Vec<NodeId>> = HashMap::new();
|
||||
// 含 condition 是为 conditions-eval feature 接入:flag 开时按入边 condition 过滤前驱 input。
|
||||
// flag 关时 condition 字段被忽略,行为与增强前完全一致(零破坏)。
|
||||
let mut adjacency_in: HashMap<NodeId, Vec<(NodeId, Option<String>)>> = HashMap::new();
|
||||
for edge in &dag.edges {
|
||||
adjacency_in
|
||||
.entry(edge.target.clone())
|
||||
.or_default()
|
||||
.push(edge.source.clone());
|
||||
.push((edge.source.clone(), edge.condition.clone()));
|
||||
}
|
||||
|
||||
for (layer_idx, layer) in layers.iter().enumerate() {
|
||||
@@ -77,6 +80,61 @@ impl DagExecutor {
|
||||
anyhow::anyhow!("节点 {} 不存在于 DAG 中", node_id)
|
||||
})?;
|
||||
|
||||
// 构建节点上下文:通过入边索引 O(入度) 取前驱,而非 O(E) 全表扫描。
|
||||
//
|
||||
// conditions-eval feature(默认关):
|
||||
// 关:所有入边 inputs 全收集,condition 字段完全忽略 = 旧行为(零破坏)。
|
||||
// 开:对带 condition 的入边以 source output.data 为 context 求值;
|
||||
// false 则不收集该前驱 input。若节点【存在带 condition 的入边,且无任何
|
||||
// 入边(条件或无条件)放行】,则跳过该节点执行(不入 outputs、保持 Pending、
|
||||
// 不 emit),实现条件路由。无条件边 source 有 output 即视为放行。
|
||||
// 求值失败保守 false(对齐引擎语义)。
|
||||
//
|
||||
// 注:跳过判定在 set_running 之前 —— 跳过的节点不应被标记 Running/触发事件,
|
||||
// 保持 Pending 终态(与"条件未命中"语义一致)。
|
||||
let eval_conditions = cfg!(feature = "conditions-eval");
|
||||
let mut inputs: HashMap<NodeId, NodeOutput> = HashMap::new();
|
||||
let mut has_any_cond_edge = false;
|
||||
let mut any_edge_passed = false; // 任一入边放行(含无条件边)
|
||||
if let Some(preds) = adjacency_in.get(node_id) {
|
||||
for (pred_id, cond) in preds {
|
||||
if let Some(out) = outputs.get(pred_id) {
|
||||
if eval_conditions {
|
||||
if let Some(cond) = cond {
|
||||
has_any_cond_edge = true;
|
||||
// 以 source output.data 为 context 求值;失败保守 false
|
||||
let passed = ConditionEngine::evaluate(cond, &out.data)
|
||||
.unwrap_or(false);
|
||||
if passed {
|
||||
inputs.insert(pred_id.clone(), out.clone());
|
||||
any_edge_passed = true;
|
||||
}
|
||||
} else {
|
||||
// 无 condition 的入边:无条件放行(必收集)
|
||||
inputs.insert(pred_id.clone(), out.clone());
|
||||
any_edge_passed = true;
|
||||
}
|
||||
} else {
|
||||
// flag 关:condition 完全忽略,旧行为全收集
|
||||
inputs.insert(pred_id.clone(), out.clone());
|
||||
any_edge_passed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 根节点(无入边):不参与条件跳过判定,正常执行
|
||||
any_edge_passed = true;
|
||||
}
|
||||
|
||||
// 条件路由:flag 开 + 存在条件边 + 没有任何入边放行 → 跳过执行
|
||||
if eval_conditions && has_any_cond_edge && !any_edge_passed {
|
||||
tracing::info!(
|
||||
"节点 {} 所有入边条件均不满足,跳过执行(条件路由)",
|
||||
node_id
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 发送 NodeStarted 事件
|
||||
self.event_bus
|
||||
.send(WorkflowEvent::NodeStarted {
|
||||
@@ -86,16 +144,6 @@ impl DagExecutor {
|
||||
|
||||
self.state_machine.set_running(node_id.clone())?;
|
||||
|
||||
// 构建节点上下文:通过入边索引 O(入度) 取前驱,而非 O(E) 全表扫描
|
||||
let mut inputs = HashMap::new();
|
||||
if let Some(preds) = adjacency_in.get(node_id) {
|
||||
for pred_id in preds {
|
||||
if let Some(out) = outputs.get(pred_id) {
|
||||
inputs.insert(pred_id.clone(), out.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let ctx = NodeContext {
|
||||
node_id: node_id.clone(),
|
||||
inputs,
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
//! DagExecutor 测试套件 — 从 executor.rs 抽离(纯搬迁,零行为变更)
|
||||
//!
|
||||
//! 抽离说明:executor.rs 原 513 行中 tests mod 占 305 行(60%),主体 run 仅 157 行。
|
||||
//! 独立成文件降低阅读 run 主体的滚动噪音,测试逻辑、断言、节点 mock 全部原样搬迁。
|
||||
//! 抽离说明:executor.rs 主体 run 逻辑之外曾带大量内联测试,占比偏高(抽离前的历史
|
||||
//! 行数分布不再维护),独立成文件降低阅读 run 主体的滚动噪音。测试逻辑、断言、节点 mock
|
||||
//! 全部原样搬迁。
|
||||
//! 通过 `#[path = "executor_helpers.rs"]` 内联回 executor.rs 的 #[cfg(test)] mod,
|
||||
//! 不进 lib.rs、不改 pub 路径、不影响外部调用方。
|
||||
//!
|
||||
//! SW-01 TOCTOU 相关测试(test_cancelled_node_skips_set_failed /
|
||||
//! test_cancelled_node_skips_set_completed / test_cancelled_node_emits_node_cancelled_event)
|
||||
//! 原样保留,锁定 executor.rs:136/138-153 的 Ok/Err 分支 is_cancelled 短路行为。
|
||||
//! 原样保留,锁定 executor.rs 节点执行完毕后 Ok/Err 两分支的 is_cancelled 短路行为
|
||||
//! (已取消节点跳过 set_completed/set_failed,保持 Cancelled 终态)。
|
||||
|
||||
use super::*;
|
||||
use crate::node::{Node, NodeResult, NodeSchema};
|
||||
@@ -312,3 +314,206 @@ async fn test_cancelled_node_emits_node_cancelled_event() {
|
||||
NodeStatus::Cancelled
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// conditions-eval feature:边条件路由测试(feature flag 开启时才编译)
|
||||
// ============================================================================
|
||||
|
||||
/// 测试节点:把 config.ok 作为 bool 输出到 { "ok": <bool> }。
|
||||
/// 边 condition "$.ok == true" 据此判定路由。
|
||||
#[cfg(feature = "conditions-eval")]
|
||||
struct BoolFlagNode {
|
||||
ok: bool,
|
||||
}
|
||||
|
||||
#[cfg(feature = "conditions-eval")]
|
||||
#[async_trait]
|
||||
impl Node for BoolFlagNode {
|
||||
async fn execute(&self, _ctx: NodeContext) -> NodeResult {
|
||||
Ok(NodeOutput::from_value(serde_json::json!({ "ok": self.ok })))
|
||||
}
|
||||
|
||||
fn schema(&self) -> NodeSchema {
|
||||
NodeSchema {
|
||||
params: serde_json::Value::Null,
|
||||
output: serde_json::Value::Null,
|
||||
}
|
||||
}
|
||||
|
||||
fn node_type(&self) -> &str {
|
||||
"bool_flag"
|
||||
}
|
||||
}
|
||||
|
||||
/// 测试节点:执行时记录到共享 Mutex,用于断言节点是否被执行。
|
||||
#[cfg(feature = "conditions-eval")]
|
||||
struct RecordingNode {
|
||||
flag: std::sync::Arc<std::sync::Mutex<Vec<String>>>,
|
||||
name: String,
|
||||
}
|
||||
|
||||
#[cfg(feature = "conditions-eval")]
|
||||
#[async_trait]
|
||||
impl Node for RecordingNode {
|
||||
async fn execute(&self, _ctx: NodeContext) -> NodeResult {
|
||||
self.flag.lock().unwrap().push(self.name.clone());
|
||||
Ok(NodeOutput::empty())
|
||||
}
|
||||
|
||||
fn schema(&self) -> NodeSchema {
|
||||
NodeSchema {
|
||||
params: serde_json::Value::Null,
|
||||
output: serde_json::Value::Null,
|
||||
}
|
||||
}
|
||||
|
||||
fn node_type(&self) -> &str {
|
||||
"recording"
|
||||
}
|
||||
}
|
||||
|
||||
/// conditions-eval 开启时:边 condition 求值 true → 下游执行。
|
||||
///
|
||||
/// 结构:branch(true) --condition "$.ok == true"--> target_run
|
||||
/// branch(true) --condition "$.ok == false"--> target_skip
|
||||
/// branch.ok==true:第一条边满足 → target_run 执行;第二条边 false → target_skip 跳过。
|
||||
#[cfg(feature = "conditions-eval")]
|
||||
#[tokio::test]
|
||||
async fn conditions_eval_routes_by_edge_condition() {
|
||||
let mut dag = Dag::new();
|
||||
dag.add_node("branch".to_string(), Box::new(BoolFlagNode { ok: true }));
|
||||
|
||||
let ran = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
|
||||
let ran_run = ran.clone();
|
||||
let ran_skip = ran.clone();
|
||||
|
||||
dag.add_node(
|
||||
"target_run".to_string(),
|
||||
Box::new(RecordingNode { flag: ran_run, name: "target_run".to_string() }),
|
||||
);
|
||||
dag.add_node(
|
||||
"target_skip".to_string(),
|
||||
Box::new(RecordingNode { flag: ran_skip, name: "target_skip".to_string() }),
|
||||
);
|
||||
|
||||
// 两条带条件的出边:branch.ok==true 命中第一条
|
||||
dag.add_edge_with_condition(
|
||||
"branch".to_string(),
|
||||
"target_run".to_string(),
|
||||
"$.ok == true".to_string(),
|
||||
);
|
||||
dag.add_edge_with_condition(
|
||||
"branch".to_string(),
|
||||
"target_skip".to_string(),
|
||||
"$.ok == false".to_string(),
|
||||
);
|
||||
|
||||
let mut executor = DagExecutor::new(EventBus::new(), "test-cond-route".to_string());
|
||||
let outputs = executor
|
||||
.run(&dag, serde_json::Value::Null)
|
||||
.await
|
||||
.expect("run");
|
||||
|
||||
// target_run 被执行(条件命中);target_skip 被跳过(条件不满足,不入 outputs)
|
||||
assert!(
|
||||
outputs.contains_key("target_run"),
|
||||
"条件命中的下游应执行,outputs: {:?}",
|
||||
outputs.keys().collect::<Vec<_>>()
|
||||
);
|
||||
assert!(
|
||||
!outputs.contains_key("target_skip"),
|
||||
"条件不满足的下游应跳过(不入 outputs),outputs: {:?}",
|
||||
outputs.keys().collect::<Vec<_>>()
|
||||
);
|
||||
|
||||
let ran = ran.lock().unwrap();
|
||||
assert!(
|
||||
ran.contains(&"target_run".to_string()),
|
||||
"target_run 应被执行,ran: {:?}",
|
||||
ran
|
||||
);
|
||||
assert!(
|
||||
!ran.contains(&"target_skip".to_string()),
|
||||
"target_skip 不应被执行,ran: {:?}",
|
||||
ran
|
||||
);
|
||||
}
|
||||
|
||||
/// conditions-eval 开启时:节点所有入边均带 condition 且全部 false → 跳过执行。
|
||||
/// 验证状态机保持 Pending(跳过节点不 set_running/Completed/Failed)。
|
||||
#[cfg(feature = "conditions-eval")]
|
||||
#[tokio::test]
|
||||
async fn conditions_eval_skip_keeps_pending() {
|
||||
use df_types::types::NodeStatus;
|
||||
|
||||
let mut dag = Dag::new();
|
||||
dag.add_node("src".to_string(), Box::new(BoolFlagNode { ok: true }));
|
||||
dag.add_node(
|
||||
"skip_target".to_string(),
|
||||
Box::new(RecordingNode {
|
||||
flag: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
|
||||
name: "skip_target".to_string(),
|
||||
}),
|
||||
);
|
||||
// src.ok==true,但边要求 == false → 不满足 → 跳过
|
||||
dag.add_edge_with_condition(
|
||||
"src".to_string(),
|
||||
"skip_target".to_string(),
|
||||
"$.ok == false".to_string(),
|
||||
);
|
||||
|
||||
let mut executor = DagExecutor::new(EventBus::new(), "test-cond-skip".to_string());
|
||||
let outputs = executor
|
||||
.run(&dag, serde_json::Value::Null)
|
||||
.await
|
||||
.expect("run");
|
||||
|
||||
assert!(!outputs.contains_key("skip_target"), "skip_target 应被跳过不入 outputs");
|
||||
// 跳过的节点保持 Pending(未被状态机转换)
|
||||
assert_eq!(
|
||||
executor.state_machine.get(&"skip_target".to_string()),
|
||||
NodeStatus::Pending,
|
||||
"跳过的节点应保持 Pending"
|
||||
);
|
||||
assert_eq!(
|
||||
executor.state_machine.get(&"src".to_string()),
|
||||
NodeStatus::Completed,
|
||||
"源节点应正常完成"
|
||||
);
|
||||
}
|
||||
|
||||
/// conditions-eval 开启时:无条件入边 + 条件入边混合,无条件的总放行(不被跳过)。
|
||||
#[cfg(feature = "conditions-eval")]
|
||||
#[tokio::test]
|
||||
async fn conditions_eval_unconditional_edge_always_passes() {
|
||||
let mut dag = Dag::new();
|
||||
dag.add_node("a".to_string(), Box::new(BoolFlagNode { ok: true }));
|
||||
dag.add_node("b".to_string(), Box::new(BoolFlagNode { ok: false }));
|
||||
dag.add_node(
|
||||
"mix".to_string(),
|
||||
Box::new(RecordingNode {
|
||||
flag: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
|
||||
name: "mix".to_string(),
|
||||
}),
|
||||
);
|
||||
// 一条无条件边(b → mix)+ 一条条件边(a → mix, 要求 a.ok==false 不满足)
|
||||
dag.add_edge("b".to_string(), "mix".to_string());
|
||||
dag.add_edge_with_condition(
|
||||
"a".to_string(),
|
||||
"mix".to_string(),
|
||||
"$.ok == false".to_string(),
|
||||
);
|
||||
|
||||
let mut executor = DagExecutor::new(EventBus::new(), "test-cond-mix".to_string());
|
||||
let outputs = executor
|
||||
.run(&dag, serde_json::Value::Null)
|
||||
.await
|
||||
.expect("run");
|
||||
|
||||
// 存在无条件入边 → mix 必执行(无条件边不被跳过逻辑计入 has_any_cond_edge 的「全部 false」)
|
||||
assert!(
|
||||
outputs.contains_key("mix"),
|
||||
"存在无条件入边时节点应执行,outputs: {:?}",
|
||||
outputs.keys().collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user