新增: 批次工作落地(推进链/评估闭环/事件总线/并发/加固) + 技术债清理 + 文档整理

后端:
- 工作流推进链(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:
2026-06-21 20:51:26 +08:00
parent 330bb7f505
commit bd6a41fe6e
111 changed files with 11932 additions and 1034 deletions

View File

@@ -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 0UX-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 IPCai_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连续多条消息混合——

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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 内用,

View 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

File diff suppressed because it is too large Load Diff

View File

@@ -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
);
}
}

View File

@@ -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

View File

@@ -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,
};

View File

@@ -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) {

View File

@@ -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」。
///
/// modelconfig["model"] 非空用之,否则 record.default_model再否则 "gpt-4o-mini" 占位。

View File

@@ -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 }));

View File

@@ -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;

View File

@@ -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 是哪个)

View File

@@ -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());
}
}

View 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 篡改"
);
}
}

View File

@@ -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"));
}
}

View File

@@ -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);
}
}

View File

@@ -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",

View File

@@ -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 || {

View File

@@ -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
);

View File

@@ -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,
}

View 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);
}
}

View File

@@ -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};

View File

@@ -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 {

View File

@@ -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 }

View File

@@ -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
);
}
}

View File

@@ -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,

View File

@@ -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<_>>()
);
}