重构: ai_node.rs 拆分(836→423,抽 AiSelfReviewNode,REFACTOR-260619-06)
纯结构拆分,逻辑等价,Node trait/AiNode API 不变: - 抽 ai_self_review_node.rs(447,AiSelfReviewNode 结构体+Node impl+build_review_prompt, 符合 crate 一节点一文件约定:human_node/script_node/task_advance_node) - ai_node.rs 瘦身至 423 行(保留 AiNode+测试,params/self_review 纯逻辑已在 ai_node_helpers.rs) - 测试共置(9 自审测试移 ai_self_review_node::tests) - state.rs 路径引用更新(ai_node::AiSelfReviewNode → ai_self_review_node::AiSelfReviewNode) - 注册键 "ai_self_review" 字符串 + node_type() 不变 注:任务清单标 1107 行实为 836(标注有误),params/self_review 已在 ai_node_helpers(298) 自验: df-nodes 82 passed + workspace EXIT 0
This commit is contained in:
@@ -6,8 +6,10 @@
|
|||||||
//! 与 AI Chat(侧边栏交互对话)的区别:AI Node 由 DAG Executor 自动驱动,
|
//! 与 AI Chat(侧边栏交互对话)的区别:AI Node 由 DAG Executor 自动驱动,
|
||||||
//! 适合嵌入自动化链路(如 想法 → AI分析 → 脚本落地 → 人工审批)。
|
//! 适合嵌入自动化链路(如 想法 → AI分析 → 脚本落地 → 人工审批)。
|
||||||
//!
|
//!
|
||||||
//! provider 解析/参数构造/自审 JSON 解析等纯逻辑见 `ai_node_helpers`(本文件只保留
|
//! 拆分边界(纯结构,逻辑等价):
|
||||||
//! AiNode / AiSelfReviewNode 的 struct + impl,impl 块约束)。
|
//! - provider 解析/参数构造/自审 JSON 解析等纯逻辑 → `ai_node_helpers`
|
||||||
|
//! - AiSelfReviewNode(自审闭环独立节点) → `ai_self_review_node`
|
||||||
|
//! - 本文件:仅 AiNode 的 struct + impl + tests。
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
@@ -17,12 +19,10 @@ use df_storage::crud::TaskRepo;
|
|||||||
use df_storage::db::Database;
|
use df_storage::db::Database;
|
||||||
use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema};
|
use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema};
|
||||||
|
|
||||||
// 抽离的纯函数/类型(glob 引入,tests `use super::*` 间接可见)
|
// AiNode::execute 用的纯函数(resolve_and_parse / provider_from_params);
|
||||||
#[allow(unused_imports)]
|
// 其余 helpers(parse_review_json/gate_should_block/REVIEW_SYSTEM_PROMPT/ResolvedProvider/
|
||||||
use crate::ai_node_helpers::{
|
// truncate_for_summary)由 ai_self_review_node 与 tests 引入,不在此处 glob。
|
||||||
gate_should_block, parse_review_json, provider_from_params, resolve_and_parse,
|
use crate::ai_node_helpers::{provider_from_params, resolve_and_parse};
|
||||||
truncate_for_summary, ResolvedProvider, REVIEW_SYSTEM_PROMPT,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// AI 节点
|
/// AI 节点
|
||||||
///
|
///
|
||||||
@@ -143,260 +143,13 @@ impl Node for AiNode {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// ai_self_review 节点 — AI 自审闭环(决策 a 步骤③)
|
|
||||||
// ============================================================
|
|
||||||
//
|
|
||||||
// 复用 AiNode 的 provider 调用逻辑(parse_params + build_provider + complete),差异:
|
|
||||||
// 1. prompt 固定四维度审查模板(需求符合度/完整性/正确性/边界),不从 config/inputs 取 prompt
|
|
||||||
// 2. system_prompt 强约束「只输出 JSON」+ temperature=0
|
|
||||||
// 3. LLM 输出按结构化 JSON 解析,失败兜底 verdict=unknown(防 LLM 不按要求输出)
|
|
||||||
// 4. 自审输入:task.description(需求,经 ctx.config["task_id"] 读 task) + task.output_json.text(产出)
|
|
||||||
// 5. 自审结果写回 task.output_json 加 review 子字段(不覆盖 text,保留对照)
|
|
||||||
// 6. review 摘要塞 NodeOutput.data,供下游 human_review 经 DAG inputs["ai_self_review"] 读
|
|
||||||
//
|
|
||||||
// verdict 处理(首版保守):自审是辅助,最终决策权在人。verdict=fail 不主动 Err,
|
|
||||||
// 节点返 Ok 让 human_review 继续,人在审批卡看到 fail 结论再定(human reject 走 Err→工作流
|
|
||||||
// failed→②-4 回调退回 in_review)。
|
|
||||||
|
|
||||||
/// AI 自审节点(决策 a 步骤③)
|
|
||||||
pub struct AiSelfReviewNode {
|
|
||||||
db: Arc<Database>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AiSelfReviewNode {
|
|
||||||
pub fn new(db: Arc<Database>) -> Self {
|
|
||||||
Self { db }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 拼装自审 user prompt:任务需求 + 待审产出 + 四维度审查要求 + 输出格式。
|
|
||||||
/// description / output_text 缺失时给占位(不报错,信任调用方注入合法 task_id)。
|
|
||||||
fn build_review_prompt(description: &str, output_text: &str) -> String {
|
|
||||||
format!(
|
|
||||||
"\
|
|
||||||
## 任务需求
|
|
||||||
{description}
|
|
||||||
|
|
||||||
## 待审产出
|
|
||||||
{output_text}
|
|
||||||
|
|
||||||
## 审查维度
|
|
||||||
1. 需求符合度:产出是否覆盖需求描述的所有要点
|
|
||||||
2. 产出完整性:是否有遗漏、未完成的部分
|
|
||||||
3. 正确性:逻辑/事实/语法是否正确
|
|
||||||
4. 边界处理:异常输入、空值、错误路径是否考虑
|
|
||||||
|
|
||||||
## 输出格式(严格 JSON)
|
|
||||||
{{\"verdict\":\"pass\"|\"fail\",\"dimensions\":{{\"requirement_fit\":{{\"score\":0-10,\"issues\":[\"...\"]}},\"completeness\":{{\"score\":0-10,\"issues\":[\"...\"]}},\"correctness\":{{\"score\":0-10,\"issues\":[\"...\"]}},\"boundary\":{{\"score\":0-10,\"issues\":[\"...\"]}}}},\"summary\":\"一句话总结\",\"suggestions\":[\"改进建议\"]}}
|
|
||||||
verdict=fail 当且仅当任一维度 score<6 或有阻断性 issue。"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl Node for AiSelfReviewNode {
|
|
||||||
async fn execute(&self, ctx: NodeContext) -> NodeResult {
|
|
||||||
tracing::info!("AiSelfReviewNode 执行: node_id={}", ctx.node_id);
|
|
||||||
|
|
||||||
// FR-S1 注入链:provider 经 df_storage::secret 在 AiNode 内存解析,api_key 不进 config。
|
|
||||||
let p = resolve_and_parse(&self.db, &ctx.config, &ctx.inputs).await?;
|
|
||||||
|
|
||||||
// ── 读任务(需求 + 产出) ──
|
|
||||||
// task_id 必填(自审对象是具体任务的产出);缺失报错而非静默跳过。
|
|
||||||
let task_id = ctx
|
|
||||||
.config
|
|
||||||
.get("task_id")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("AiSelfReviewNode 缺少必填参数: task_id"))?;
|
|
||||||
|
|
||||||
let repo = TaskRepo::new(&self.db);
|
|
||||||
let task = repo
|
|
||||||
.get_by_id(task_id)
|
|
||||||
.await
|
|
||||||
.map_err(|e| anyhow::anyhow!("自审读任务失败: {}", e))?
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("自审任务 {} 不存在", task_id))?;
|
|
||||||
|
|
||||||
// output_json 可能无(ai_execute 未跑或失败) → 待审产出占位提示,不报错。
|
|
||||||
let output_text = task
|
|
||||||
.output_json
|
|
||||||
.as_deref()
|
|
||||||
.and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok())
|
|
||||||
.and_then(|v| v.get("text").and_then(|t| t.as_str()).map(String::from))
|
|
||||||
.unwrap_or_else(|| "(无产出,仅依据需求审查)".to_string());
|
|
||||||
|
|
||||||
let prompt = Self::build_review_prompt(&task.description, &output_text);
|
|
||||||
|
|
||||||
let provider: Box<dyn LlmProvider> = provider_from_params(&p);
|
|
||||||
|
|
||||||
let request = CompletionRequest {
|
|
||||||
model: p.model.clone(),
|
|
||||||
messages: vec![
|
|
||||||
ChatMessage::system(REVIEW_SYSTEM_PROMPT),
|
|
||||||
ChatMessage::user(prompt),
|
|
||||||
],
|
|
||||||
temperature: Some(0.0), // 强约束确定性输出
|
|
||||||
max_tokens: p.max_tokens,
|
|
||||||
stream: false,
|
|
||||||
tools: None,
|
|
||||||
tool_choice: None,
|
|
||||||
reasoning_content: None,
|
|
||||||
};
|
|
||||||
|
|
||||||
tracing::info!(
|
|
||||||
"AiSelfReviewNode 调用 LLM: model={}, base_url={}",
|
|
||||||
p.provider.default_model,
|
|
||||||
p.provider.base_url
|
|
||||||
);
|
|
||||||
let response = provider.complete(request).await?;
|
|
||||||
|
|
||||||
// ── 解析自审 JSON(失败兜底 verdict=unknown) ──
|
|
||||||
let review = parse_review_json(&response.text);
|
|
||||||
let verdict = review
|
|
||||||
.get("verdict")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("unknown")
|
|
||||||
.to_string();
|
|
||||||
// summary / suggestions 在 move review 前先取走(供 NodeOutput.data 平铺)
|
|
||||||
let summary = review
|
|
||||||
.get("summary")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("")
|
|
||||||
.to_string();
|
|
||||||
let suggestions = review
|
|
||||||
.get("suggestions")
|
|
||||||
.cloned()
|
|
||||||
.unwrap_or(serde_json::json!([]));
|
|
||||||
|
|
||||||
tracing::info!(
|
|
||||||
"AiSelfReviewNode 完成: verdict={}, model={}",
|
|
||||||
verdict,
|
|
||||||
response.model
|
|
||||||
);
|
|
||||||
|
|
||||||
// ── 写回 task.output_json 加 review 子字段(不覆盖 text) ──
|
|
||||||
// 读现有 output_json(若有),合并 review 子字段后整体写回。
|
|
||||||
let mut output_obj: serde_json::Map<String, serde_json::Value> = task
|
|
||||||
.output_json
|
|
||||||
.as_deref()
|
|
||||||
.and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok())
|
|
||||||
.and_then(|v| v.as_object().cloned())
|
|
||||||
.unwrap_or_default();
|
|
||||||
// review 子字段:自审结论 + reviewed_at + reviewer_model(便于前端展示与人对照)
|
|
||||||
let mut review_with_meta = match review {
|
|
||||||
serde_json::Value::Object(m) => m,
|
|
||||||
other => {
|
|
||||||
let mut m = serde_json::Map::new();
|
|
||||||
m.insert("verdict".into(), other);
|
|
||||||
m
|
|
||||||
}
|
|
||||||
};
|
|
||||||
review_with_meta.insert(
|
|
||||||
"reviewed_at".into(),
|
|
||||||
serde_json::Value::Number(df_types::now_millis().into()),
|
|
||||||
);
|
|
||||||
review_with_meta.insert("reviewer_model".into(), serde_json::json!(response.model));
|
|
||||||
output_obj.insert("review".into(), serde_json::Value::Object(review_with_meta));
|
|
||||||
|
|
||||||
let merged = serde_json::Value::Object(output_obj);
|
|
||||||
if let Ok(json_str) = serde_json::to_string(&merged) {
|
|
||||||
if let Err(e) = repo.update_field(task_id, "output_json", &json_str).await {
|
|
||||||
tracing::warn!(
|
|
||||||
task_id = task_id,
|
|
||||||
error = %e,
|
|
||||||
"AiSelfReviewNode 落 review 子字段失败(摘要已在 NodeOutput,节点继续)"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── NodeOutput.data 塞 review 摘要,供下游 human_review 经 inputs["ai_self_review"] 读 ──
|
|
||||||
// human_review(HumanNode)从 config/inputs 拼 description;此处把 verdict/summary/suggestions
|
|
||||||
// 平铺进 data,HumanNode 零改动即可透传(前端/模板层可据此拼审批卡描述)。
|
|
||||||
let output = NodeOutput::from_value(serde_json::json!({
|
|
||||||
"verdict": verdict,
|
|
||||||
"summary": summary,
|
|
||||||
"suggestions": suggestions,
|
|
||||||
"review": merged.get("review").cloned().unwrap_or(serde_json::Value::Null),
|
|
||||||
"model": response.model,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// ── 阶段3: 自审闸门(F-260616-07 决策 a 步骤③) ──
|
|
||||||
// config["gate"]==true 时,AiSelfReviewNode 从「自审辅助」升级为「DAG 节点闸门」:
|
|
||||||
// verdict="fail" → 返回 Err → executor first_err 中止后续层(下游 human_review 不跑)
|
|
||||||
// → 工作流 failed → ②-4 回调退回 in_review(对齐工作流失败语义)
|
|
||||||
// verdict="unknown" → 保持 Ok(LLM 输出不可靠时不阻断,人定权)
|
|
||||||
// verdict="pass" → Ok 放行下游
|
|
||||||
//
|
|
||||||
// 落库在前(已 update_field output_json),故闸门 fail 时 task.output_json.review
|
|
||||||
// 已含结论,前端/二次工作流可读自审详情(不丢失审查痕迹)。
|
|
||||||
//
|
|
||||||
// 设计选型(三方案对比,见 commit/设计文档):
|
|
||||||
// A) AiNode 内部门控(本方案):1 处 return Err,复用 executor first_err + ②-4 回调,
|
|
||||||
// 零 executor 核心改动,零依赖暂缓的条件引擎(T-260614-11)。最简。
|
|
||||||
// B) DAG edges 条件 + ConditionEngine 求值:依赖 T-260614-11(暂缓),executor 当前
|
|
||||||
// 完全不评估 edge.condition(topological_layers 无条件收录所有边),须先做条件
|
|
||||||
// 引擎 Phase1+2 才能用 → 拆波。
|
|
||||||
// C) executor 闸门检查钩子(节点 execute 后 executor 读 output.verdict):改 DagExecutor
|
|
||||||
// 核心循环,牵动所有节点,风险/范围不符「最简不破坏」。
|
|
||||||
// 选 A:语义最贴近「自审结果作为闸门」(自审节点自行决定放行/阻断),且 gate 可按节点
|
|
||||||
// config 开关(默认 false = 阶段2 行为不变,模板/前端零强制改动,向后兼容)。
|
|
||||||
let gate_enabled = ctx
|
|
||||||
.config
|
|
||||||
.get("gate")
|
|
||||||
.and_then(|v| v.as_bool())
|
|
||||||
.unwrap_or(false);
|
|
||||||
if gate_should_block(gate_enabled, &verdict) {
|
|
||||||
tracing::info!(
|
|
||||||
node_id = %ctx.node_id,
|
|
||||||
task_id,
|
|
||||||
"AiSelfReviewNode 闸门触发: verdict=fail, 阻断下游(工作流将 failed)"
|
|
||||||
);
|
|
||||||
return Err(anyhow::anyhow!(
|
|
||||||
"AI 自审闸门未通过(verdict=fail): {}",
|
|
||||||
if summary.is_empty() { "自审结论 fail" } else { &summary }
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(output)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn schema(&self) -> NodeSchema {
|
|
||||||
NodeSchema {
|
|
||||||
params: serde_json::json!({
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"task_id": { "type": "string", "description": "自审目标任务 ID(必填)" },
|
|
||||||
"provider_id": { "type": "string", "description": "AI Provider ID(FR-S1:密钥经 secret 解析不进 config;留空走默认 provider)" },
|
|
||||||
"model": { "type": "string", "description": "模型名(可选,留空用 record.default_model)" },
|
|
||||||
"max_tokens": { "type": "integer" },
|
|
||||||
"gate": { "type": "boolean", "description": "阶段3 闸门开关:false(默认)=自审辅助,verdict 仅透传展示;true=自审结果作 DAG 闸门,verdict=fail 返回 Err 阻断下游(工作流 failed → ②-4 退回),verdict=unknown/pass 放行" }
|
|
||||||
},
|
|
||||||
"required": ["task_id", "provider_id"]
|
|
||||||
}),
|
|
||||||
output: serde_json::json!({
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"verdict": { "type": "string", "enum": ["pass", "fail", "unknown"] },
|
|
||||||
"summary": { "type": "string" },
|
|
||||||
"suggestions": { "type": "array", "items": { "type": "string" } },
|
|
||||||
"review": { "type": "object" },
|
|
||||||
"model": { "type": "string" }
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn node_type(&self) -> &str {
|
|
||||||
"ai_self_review"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::ai_node_helpers::{parse_params, resolve_provider};
|
use crate::ai_node_helpers::{parse_params, resolve_provider, ResolvedProvider};
|
||||||
use df_storage::crud::{AiProviderRepo, ProjectRepo};
|
use df_storage::crud::AiProviderRepo;
|
||||||
use df_storage::db::Database;
|
use df_storage::db::Database;
|
||||||
use df_storage::models::{AiProviderRecord, ProjectRecord, TaskRecord};
|
use df_storage::models::AiProviderRecord;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
@@ -664,173 +417,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// 自审闭环相关单测(parse_review_json / truncate_for_summary / gate_should_block /
|
||||||
// 决策 a 自审闭环单测 — output_json 写回 + review JSON 解析兜底
|
// build_review_prompt / update_field_writes_output_json)已随 AiSelfReviewNode 迁移至
|
||||||
// ============================================================
|
// ai_self_review_node.rs(与被测代码同位,纯搬运)。
|
||||||
|
|
||||||
/// 内存 DB 构造 helper:插父项目 + 指定 task,返回 (db, task_id)。
|
|
||||||
async fn setup_task_db(task_output_json: Option<&str>) -> (Database, String) {
|
|
||||||
let db = Database::open_in_memory().await.expect("open_in_memory");
|
|
||||||
ProjectRepo::new(&db)
|
|
||||||
.insert(ProjectRecord {
|
|
||||||
id: "p1".to_string(),
|
|
||||||
name: "proj".to_string(),
|
|
||||||
description: "".to_string(),
|
|
||||||
status: "planning".to_string(),
|
|
||||||
idea_id: None,
|
|
||||||
path: None,
|
|
||||||
stack: None,
|
|
||||||
created_at: "0".to_string(),
|
|
||||||
updated_at: "0".to_string(),
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.expect("insert project");
|
|
||||||
TaskRepo::new(&db)
|
|
||||||
.insert(TaskRecord {
|
|
||||||
id: "t1".to_string(),
|
|
||||||
project_id: "p1".to_string(),
|
|
||||||
title: "t1".to_string(),
|
|
||||||
description: "实现登录接口".to_string(),
|
|
||||||
status: "testing".to_string(),
|
|
||||||
priority: 2,
|
|
||||||
branch_name: None,
|
|
||||||
assignee: None,
|
|
||||||
workflow_def_id: None,
|
|
||||||
base_branch: None,
|
|
||||||
review_rounds: 0,
|
|
||||||
output_json: task_output_json.map(String::from),
|
|
||||||
idea_id: None,
|
|
||||||
created_at: "0".to_string(),
|
|
||||||
updated_at: "0".to_string(),
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.expect("insert task");
|
|
||||||
(db, "t1".to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 决策 a 步骤②:AiNode.execute 落产出验证 — 有 task_id 时 output_json 写入。
|
|
||||||
/// 用 mock provider 避免真调 LLM:自定义 Node 替换不便,改为直接验证 update_field 路径
|
|
||||||
/// (execute 内部调 provider.complete 需真 LLM,此处降级验证 parse_review_json 兜底 +
|
|
||||||
/// 直接 repo.update_field 路径,与 execute 落库逻辑同一条 crud.update_field)。
|
|
||||||
#[tokio::test]
|
|
||||||
async fn update_field_writes_output_json() {
|
|
||||||
// 验证 output_json 在 tasks 白名单(update_field 不报「不允许的字段名」)。
|
|
||||||
// 这正是 AiNode.execute / AiSelfReviewNode.execute 落产出的底层路径。
|
|
||||||
let (db, task_id) = setup_task_db(None).await;
|
|
||||||
let repo = TaskRepo::new(&db);
|
|
||||||
let payload = r#"{"text":"产出内容","model":"glm-4","usage":{}}"#;
|
|
||||||
let ok = repo
|
|
||||||
.update_field(&task_id, "output_json", payload)
|
|
||||||
.await
|
|
||||||
.expect("update_field output_json");
|
|
||||||
assert!(ok, "update_field 应返回 affected>0=true");
|
|
||||||
let after = repo.get_by_id(&task_id).await.unwrap().unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
after.output_json.as_deref(),
|
|
||||||
Some(payload),
|
|
||||||
"output_json 应被写入"
|
|
||||||
);
|
|
||||||
// 原始 text 字段(产出对照)保留
|
|
||||||
let parsed: serde_json::Value = serde_json::from_str(after.output_json.as_deref().unwrap()).unwrap();
|
|
||||||
assert_eq!(parsed["text"], json!("产出内容"));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 步骤③:parse_review_json 合规 JSON 直通(保留 verdict/dimensions/summary/suggestions)。
|
|
||||||
#[test]
|
|
||||||
fn parse_review_json_valid_passes_through() {
|
|
||||||
let raw = r#"{"verdict":"pass","dimensions":{"requirement_fit":{"score":9,"issues":[]}},"summary":"符合需求","suggestions":["可加单测"]}"#;
|
|
||||||
let v = parse_review_json(raw);
|
|
||||||
assert_eq!(v["verdict"], json!("pass"));
|
|
||||||
assert_eq!(v["summary"], json!("符合需求"));
|
|
||||||
assert_eq!(v["suggestions"], json!(["可加单测"]));
|
|
||||||
assert_eq!(v["dimensions"]["requirement_fit"]["score"], json!(9));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 步骤③:parse_review_json 非 JSON / 缺 verdict → 兜底 verdict=unknown + summary 含原文。
|
|
||||||
#[test]
|
|
||||||
fn parse_review_json_invalid_falls_back_unknown() {
|
|
||||||
// 非 JSON
|
|
||||||
let v = parse_review_json("这不是 JSON,LLM 没按要求输出");
|
|
||||||
assert_eq!(v["verdict"], json!("unknown"));
|
|
||||||
assert!(v["summary"].as_str().unwrap().contains("不是 JSON"));
|
|
||||||
assert!(v["summary"].as_str().unwrap().contains("解析失败"));
|
|
||||||
|
|
||||||
// JSON 但缺 verdict
|
|
||||||
let v = parse_review_json(r#"{"foo":"bar"}"#);
|
|
||||||
assert_eq!(v["verdict"], json!("unknown"));
|
|
||||||
|
|
||||||
// JSON 非 object(数组)
|
|
||||||
let v = parse_review_json("[1,2,3]");
|
|
||||||
assert_eq!(v["verdict"], json!("unknown"));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 步骤③:LLM 偶尔包 ```json 代码块,parse_review_json 应剥离后解析成功。
|
|
||||||
#[test]
|
|
||||||
fn parse_review_json_strips_markdown_code_fence() {
|
|
||||||
let raw = "```json\n{\"verdict\":\"fail\",\"summary\":\"缺边界处理\"}\n```";
|
|
||||||
let v = parse_review_json(raw);
|
|
||||||
assert_eq!(v["verdict"], json!("fail"));
|
|
||||||
assert_eq!(v["summary"], json!("缺边界处理"));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 步骤③:truncate_for_summary 长文截断。
|
|
||||||
#[test]
|
|
||||||
fn truncate_for_summary_long_text() {
|
|
||||||
let short = "短文";
|
|
||||||
assert_eq!(truncate_for_summary(short), "短文");
|
|
||||||
let long: String = "字".repeat(500);
|
|
||||||
let t = truncate_for_summary(&long);
|
|
||||||
assert!(t.ends_with("…"));
|
|
||||||
assert!(t.chars().count() <= 302, "截断后含省略号应 ≈300 字");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 步骤③:AiSelfReviewNode.build_review_prompt 含需求 + 产出 + 四维度。
|
|
||||||
#[test]
|
|
||||||
fn build_review_prompt_contains_inputs() {
|
|
||||||
let p = AiSelfReviewNode::build_review_prompt("登录接口", "已实现 /login");
|
|
||||||
assert!(p.contains("登录接口"), "prompt 应含需求");
|
|
||||||
assert!(p.contains("已实现 /login"), "prompt 应含产出");
|
|
||||||
assert!(p.contains("需求符合度"), "prompt 应含四维度");
|
|
||||||
assert!(p.contains("边界处理"));
|
|
||||||
assert!(p.contains("verdict"), "prompt 应含输出格式约束");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// 阶段3 自审闸门(gate_should_block)单测
|
|
||||||
// ============================================================
|
|
||||||
//
|
|
||||||
// gate 决策矩阵:
|
|
||||||
// gate=false(默认,阶段2 行为) → 任何 verdict 都放行(辅助模式)
|
|
||||||
// gate=true + verdict=pass → 放行
|
|
||||||
// gate=true + verdict=unknown → 放行(LLM 不可靠时不阻断,人定权)
|
|
||||||
// gate=true + verdict=fail → 阻断(返回 Err,工作流 failed)
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn gate_disabled_never_blocks_any_verdict() {
|
|
||||||
// gate 默认 false(阶段2 兼容):无论 verdict 如何都不阻断,自审仅辅助展示
|
|
||||||
assert!(!gate_should_block(false, "fail"), "gate 关闭时 fail 也不阻断");
|
|
||||||
assert!(!gate_should_block(false, "pass"), "gate 关闭时 pass 放行");
|
|
||||||
assert!(!gate_should_block(false, "unknown"), "gate 关闭时 unknown 放行");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn gate_enabled_blocks_only_on_fail() {
|
|
||||||
// gate 开启:仅 fail 阻断
|
|
||||||
assert!(gate_should_block(true, "fail"), "gate 开 + fail 应阻断");
|
|
||||||
assert!(!gate_should_block(true, "pass"), "gate 开 + pass 放行");
|
|
||||||
assert!(
|
|
||||||
!gate_should_block(true, "unknown"),
|
|
||||||
"gate 开 + unknown 放行(LLM 不可靠时保人定权)"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn gate_unknown_verdict_does_not_block() {
|
|
||||||
// 防御:verdict 非标准值(如解析异常)不应触发阻断(对齐 unknown 保守不阻断语义)
|
|
||||||
assert!(
|
|
||||||
!gate_should_block(true, "weird"),
|
|
||||||
"非标准 verdict 不应阻断(对齐 unknown 保守)"
|
|
||||||
);
|
|
||||||
assert!(!gate_should_block(true, ""), "空 verdict 不应阻断");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
447
crates/df-nodes/src/ai_self_review_node.rs
Normal file
447
crates/df-nodes/src/ai_self_review_node.rs
Normal file
@@ -0,0 +1,447 @@
|
|||||||
|
//! AI 自审节点(决策 a 步骤③)— AiSelfReviewNode
|
||||||
|
//!
|
||||||
|
//! 从 ai_node.rs 拆出的独立节点(纯结构搬运,逻辑等价)。复用 AiNode 的 provider
|
||||||
|
//! 调用链(resolve_and_parse + provider_from_params),差异在 prompt 模板/JSON 解析/
|
||||||
|
//! verdict 闸门。provider 解析与自审 JSON 解析等纯逻辑见 `ai_node_helpers`。
|
||||||
|
//!
|
||||||
|
//! 节点类型注册 key:`ai_self_review`(state.rs build_registry)。
|
||||||
|
//! 模板对齐:task_workflow_templates.rs testing 模板 `ai_self_review` 节点 + gate:true。
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use df_ai::provider::{ChatMessage, CompletionRequest, LlmProvider};
|
||||||
|
use df_storage::crud::TaskRepo;
|
||||||
|
use df_storage::db::Database;
|
||||||
|
use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema};
|
||||||
|
|
||||||
|
// 抽离的纯函数/类型(与 AiNode 共用)。
|
||||||
|
use crate::ai_node_helpers::{
|
||||||
|
gate_should_block, parse_review_json, provider_from_params, resolve_and_parse,
|
||||||
|
REVIEW_SYSTEM_PROMPT,
|
||||||
|
};
|
||||||
|
|
||||||
|
// AiSelfReviewNode 节点 — AI 自审闭环(决策 a 步骤③)
|
||||||
|
//
|
||||||
|
// 复用 AiNode 的 provider 调用逻辑(parse_params + build_provider + complete),差异:
|
||||||
|
// 1. prompt 固定四维度审查模板(需求符合度/完整性/正确性/边界),不从 config/inputs 取 prompt
|
||||||
|
// 2. system_prompt 强约束「只输出 JSON」+ temperature=0
|
||||||
|
// 3. LLM 输出按结构化 JSON 解析,失败兜底 verdict=unknown(防 LLM 不按要求输出)
|
||||||
|
// 4. 自审输入:task.description(需求,经 ctx.config["task_id"] 读 task) + task.output_json.text(产出)
|
||||||
|
// 5. 自审结果写回 task.output_json 加 review 子字段(不覆盖 text,保留对照)
|
||||||
|
// 6. review 摘要塞 NodeOutput.data,供下游 human_review 经 DAG inputs["ai_self_review"] 读
|
||||||
|
//
|
||||||
|
// verdict 处理(首版保守):自审是辅助,最终决策权在人。verdict=fail 不主动 Err,
|
||||||
|
// 节点返 Ok 让 human_review 继续,人在审批卡看到 fail 结论再定(human reject 走 Err→工作流
|
||||||
|
// failed→②-4 回调退回 in_review)。
|
||||||
|
|
||||||
|
/// AI 自审节点(决策 a 步骤③)
|
||||||
|
pub struct AiSelfReviewNode {
|
||||||
|
db: Arc<Database>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AiSelfReviewNode {
|
||||||
|
pub fn new(db: Arc<Database>) -> Self {
|
||||||
|
Self { db }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 拼装自审 user prompt:任务需求 + 待审产出 + 四维度审查要求 + 输出格式。
|
||||||
|
/// description / output_text 缺失时给占位(不报错,信任调用方注入合法 task_id)。
|
||||||
|
fn build_review_prompt(description: &str, output_text: &str) -> String {
|
||||||
|
format!(
|
||||||
|
"\
|
||||||
|
## 任务需求
|
||||||
|
{description}
|
||||||
|
|
||||||
|
## 待审产出
|
||||||
|
{output_text}
|
||||||
|
|
||||||
|
## 审查维度
|
||||||
|
1. 需求符合度:产出是否覆盖需求描述的所有要点
|
||||||
|
2. 产出完整性:是否有遗漏、未完成的部分
|
||||||
|
3. 正确性:逻辑/事实/语法是否正确
|
||||||
|
4. 边界处理:异常输入、空值、错误路径是否考虑
|
||||||
|
|
||||||
|
## 输出格式(严格 JSON)
|
||||||
|
{{\"verdict\":\"pass\"|\"fail\",\"dimensions\":{{\"requirement_fit\":{{\"score\":0-10,\"issues\":[\"...\"]}},\"completeness\":{{\"score\":0-10,\"issues\":[\"...\"]}},\"correctness\":{{\"score\":0-10,\"issues\":[\"...\"]}},\"boundary\":{{\"score\":0-10,\"issues\":[\"...\"]}}}},\"summary\":\"一句话总结\",\"suggestions\":[\"改进建议\"]}}
|
||||||
|
verdict=fail 当且仅当任一维度 score<6 或有阻断性 issue。"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Node for AiSelfReviewNode {
|
||||||
|
async fn execute(&self, ctx: NodeContext) -> NodeResult {
|
||||||
|
tracing::info!("AiSelfReviewNode 执行: node_id={}", ctx.node_id);
|
||||||
|
|
||||||
|
// FR-S1 注入链:provider 经 df_storage::secret 在 AiNode 内存解析,api_key 不进 config。
|
||||||
|
let p = resolve_and_parse(&self.db, &ctx.config, &ctx.inputs).await?;
|
||||||
|
|
||||||
|
// ── 读任务(需求 + 产出) ──
|
||||||
|
// task_id 必填(自审对象是具体任务的产出);缺失报错而非静默跳过。
|
||||||
|
let task_id = ctx
|
||||||
|
.config
|
||||||
|
.get("task_id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("AiSelfReviewNode 缺少必填参数: task_id"))?;
|
||||||
|
|
||||||
|
let repo = TaskRepo::new(&self.db);
|
||||||
|
let task = repo
|
||||||
|
.get_by_id(task_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("自审读任务失败: {}", e))?
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("自审任务 {} 不存在", task_id))?;
|
||||||
|
|
||||||
|
// output_json 可能无(ai_execute 未跑或失败) → 待审产出占位提示,不报错。
|
||||||
|
let output_text = task
|
||||||
|
.output_json
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok())
|
||||||
|
.and_then(|v| v.get("text").and_then(|t| t.as_str()).map(String::from))
|
||||||
|
.unwrap_or_else(|| "(无产出,仅依据需求审查)".to_string());
|
||||||
|
|
||||||
|
let prompt = Self::build_review_prompt(&task.description, &output_text);
|
||||||
|
|
||||||
|
let provider: Box<dyn LlmProvider> = provider_from_params(&p);
|
||||||
|
|
||||||
|
let request = CompletionRequest {
|
||||||
|
model: p.model.clone(),
|
||||||
|
messages: vec![
|
||||||
|
ChatMessage::system(REVIEW_SYSTEM_PROMPT),
|
||||||
|
ChatMessage::user(prompt),
|
||||||
|
],
|
||||||
|
temperature: Some(0.0), // 强约束确定性输出
|
||||||
|
max_tokens: p.max_tokens,
|
||||||
|
stream: false,
|
||||||
|
tools: None,
|
||||||
|
tool_choice: None,
|
||||||
|
reasoning_content: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
"AiSelfReviewNode 调用 LLM: model={}, base_url={}",
|
||||||
|
p.provider.default_model,
|
||||||
|
p.provider.base_url
|
||||||
|
);
|
||||||
|
let response = provider.complete(request).await?;
|
||||||
|
|
||||||
|
// ── 解析自审 JSON(失败兜底 verdict=unknown) ──
|
||||||
|
let review = parse_review_json(&response.text);
|
||||||
|
let verdict = review
|
||||||
|
.get("verdict")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("unknown")
|
||||||
|
.to_string();
|
||||||
|
// summary / suggestions 在 move review 前先取走(供 NodeOutput.data 平铺)
|
||||||
|
let summary = review
|
||||||
|
.get("summary")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
let suggestions = review
|
||||||
|
.get("suggestions")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or(serde_json::json!([]));
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
"AiSelfReviewNode 完成: verdict={}, model={}",
|
||||||
|
verdict,
|
||||||
|
response.model
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── 写回 task.output_json 加 review 子字段(不覆盖 text) ──
|
||||||
|
// 读现有 output_json(若有),合并 review 子字段后整体写回。
|
||||||
|
let mut output_obj: serde_json::Map<String, serde_json::Value> = task
|
||||||
|
.output_json
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok())
|
||||||
|
.and_then(|v| v.as_object().cloned())
|
||||||
|
.unwrap_or_default();
|
||||||
|
// review 子字段:自审结论 + reviewed_at + reviewer_model(便于前端展示与人对照)
|
||||||
|
let mut review_with_meta = match review {
|
||||||
|
serde_json::Value::Object(m) => m,
|
||||||
|
other => {
|
||||||
|
let mut m = serde_json::Map::new();
|
||||||
|
m.insert("verdict".into(), other);
|
||||||
|
m
|
||||||
|
}
|
||||||
|
};
|
||||||
|
review_with_meta.insert(
|
||||||
|
"reviewed_at".into(),
|
||||||
|
serde_json::Value::Number(df_types::now_millis().into()),
|
||||||
|
);
|
||||||
|
review_with_meta.insert("reviewer_model".into(), serde_json::json!(response.model));
|
||||||
|
output_obj.insert("review".into(), serde_json::Value::Object(review_with_meta));
|
||||||
|
|
||||||
|
let merged = serde_json::Value::Object(output_obj);
|
||||||
|
if let Ok(json_str) = serde_json::to_string(&merged) {
|
||||||
|
if let Err(e) = repo.update_field(task_id, "output_json", &json_str).await {
|
||||||
|
tracing::warn!(
|
||||||
|
task_id = task_id,
|
||||||
|
error = %e,
|
||||||
|
"AiSelfReviewNode 落 review 子字段失败(摘要已在 NodeOutput,节点继续)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── NodeOutput.data 塞 review 摘要,供下游 human_review 经 inputs["ai_self_review"] 读 ──
|
||||||
|
// human_review(HumanNode)从 config/inputs 拼 description;此处把 verdict/summary/suggestions
|
||||||
|
// 平铺进 data,HumanNode 零改动即可透传(前端/模板层可据此拼审批卡描述)。
|
||||||
|
let output = NodeOutput::from_value(serde_json::json!({
|
||||||
|
"verdict": verdict,
|
||||||
|
"summary": summary,
|
||||||
|
"suggestions": suggestions,
|
||||||
|
"review": merged.get("review").cloned().unwrap_or(serde_json::Value::Null),
|
||||||
|
"model": response.model,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ── 阶段3: 自审闸门(F-260616-07 决策 a 步骤③) ──
|
||||||
|
// config["gate"]==true 时,AiSelfReviewNode 从「自审辅助」升级为「DAG 节点闸门」:
|
||||||
|
// verdict="fail" → 返回 Err → executor first_err 中止后续层(下游 human_review 不跑)
|
||||||
|
// → 工作流 failed → ②-4 回调退回 in_review(对齐工作流失败语义)
|
||||||
|
// verdict="unknown" → 保持 Ok(LLM 输出不可靠时不阻断,人定权)
|
||||||
|
// verdict="pass" → Ok 放行下游
|
||||||
|
//
|
||||||
|
// 落库在前(已 update_field output_json),故闸门 fail 时 task.output_json.review
|
||||||
|
// 已含结论,前端/二次工作流可读自审详情(不丢失审查痕迹)。
|
||||||
|
//
|
||||||
|
// 设计选型(三方案对比,见 commit/设计文档):
|
||||||
|
// A) AiNode 内部门控(本方案):1 处 return Err,复用 executor first_err + ②-4 回调,
|
||||||
|
// 零 executor 核心改动,零依赖暂缓的条件引擎(T-260614-11)。最简。
|
||||||
|
// B) DAG edges 条件 + ConditionEngine 求值:依赖 T-260614-11(暂缓),executor 当前
|
||||||
|
// 完全不评估 edge.condition(topological_layers 无条件收录所有边),须先做条件
|
||||||
|
// 引擎 Phase1+2 才能用 → 拆波。
|
||||||
|
// C) executor 闸门检查钩子(节点 execute 后 executor 读 output.verdict):改 DagExecutor
|
||||||
|
// 核心循环,牵动所有节点,风险/范围不符「最简不破坏」。
|
||||||
|
// 选 A:语义最贴近「自审结果作为闸门」(自审节点自行决定放行/阻断),且 gate 可按节点
|
||||||
|
// config 开关(默认 false = 阶段2 行为不变,模板/前端零强制改动,向后兼容)。
|
||||||
|
let gate_enabled = ctx
|
||||||
|
.config
|
||||||
|
.get("gate")
|
||||||
|
.and_then(|v| v.as_bool())
|
||||||
|
.unwrap_or(false);
|
||||||
|
if gate_should_block(gate_enabled, &verdict) {
|
||||||
|
tracing::info!(
|
||||||
|
node_id = %ctx.node_id,
|
||||||
|
task_id,
|
||||||
|
"AiSelfReviewNode 闸门触发: verdict=fail, 阻断下游(工作流将 failed)"
|
||||||
|
);
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"AI 自审闸门未通过(verdict=fail): {}",
|
||||||
|
if summary.is_empty() { "自审结论 fail" } else { &summary }
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(output)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn schema(&self) -> NodeSchema {
|
||||||
|
NodeSchema {
|
||||||
|
params: serde_json::json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"task_id": { "type": "string", "description": "自审目标任务 ID(必填)" },
|
||||||
|
"provider_id": { "type": "string", "description": "AI Provider ID(FR-S1:密钥经 secret 解析不进 config;留空走默认 provider)" },
|
||||||
|
"model": { "type": "string", "description": "模型名(可选,留空用 record.default_model)" },
|
||||||
|
"max_tokens": { "type": "integer" },
|
||||||
|
"gate": { "type": "boolean", "description": "阶段3 闸门开关:false(默认)=自审辅助,verdict 仅透传展示;true=自审结果作 DAG 闸门,verdict=fail 返回 Err 阻断下游(工作流 failed → ②-4 退回),verdict=unknown/pass 放行" }
|
||||||
|
},
|
||||||
|
"required": ["task_id", "provider_id"]
|
||||||
|
}),
|
||||||
|
output: serde_json::json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"verdict": { "type": "string", "enum": ["pass", "fail", "unknown"] },
|
||||||
|
"summary": { "type": "string" },
|
||||||
|
"suggestions": { "type": "array", "items": { "type": "string" } },
|
||||||
|
"review": { "type": "object" },
|
||||||
|
"model": { "type": "string" }
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn node_type(&self) -> &str {
|
||||||
|
"ai_self_review"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::ai_node_helpers::{gate_should_block, parse_review_json, truncate_for_summary};
|
||||||
|
use df_storage::crud::{ProjectRepo, TaskRepo};
|
||||||
|
use df_storage::db::Database;
|
||||||
|
use df_storage::models::{ProjectRecord, TaskRecord};
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 决策 a 自审闭环单测 — output_json 写回 + review JSON 解析兜底
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/// 内存 DB 构造 helper:插父项目 + 指定 task,返回 (db, task_id)。
|
||||||
|
async fn setup_task_db(task_output_json: Option<&str>) -> (Database, String) {
|
||||||
|
let db = Database::open_in_memory().await.expect("open_in_memory");
|
||||||
|
ProjectRepo::new(&db)
|
||||||
|
.insert(ProjectRecord {
|
||||||
|
id: "p1".to_string(),
|
||||||
|
name: "proj".to_string(),
|
||||||
|
description: "".to_string(),
|
||||||
|
status: "planning".to_string(),
|
||||||
|
idea_id: None,
|
||||||
|
path: None,
|
||||||
|
stack: None,
|
||||||
|
created_at: "0".to_string(),
|
||||||
|
updated_at: "0".to_string(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("insert project");
|
||||||
|
TaskRepo::new(&db)
|
||||||
|
.insert(TaskRecord {
|
||||||
|
id: "t1".to_string(),
|
||||||
|
project_id: "p1".to_string(),
|
||||||
|
title: "t1".to_string(),
|
||||||
|
description: "实现登录接口".to_string(),
|
||||||
|
status: "testing".to_string(),
|
||||||
|
priority: 2,
|
||||||
|
branch_name: None,
|
||||||
|
assignee: None,
|
||||||
|
workflow_def_id: None,
|
||||||
|
base_branch: None,
|
||||||
|
review_rounds: 0,
|
||||||
|
output_json: task_output_json.map(String::from),
|
||||||
|
idea_id: None,
|
||||||
|
created_at: "0".to_string(),
|
||||||
|
updated_at: "0".to_string(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("insert task");
|
||||||
|
(db, "t1".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 决策 a 步骤②:AiNode.execute 落产出验证 — 有 task_id 时 output_json 写入。
|
||||||
|
/// 用 mock provider 避免真调 LLM:自定义 Node 替换不便,改为直接验证 update_field 路径
|
||||||
|
/// (execute 内部调 provider.complete 需真 LLM,此处降级验证 parse_review_json 兜底 +
|
||||||
|
/// 直接 repo.update_field 路径,与 execute 落库逻辑同一条 crud.update_field)。
|
||||||
|
#[tokio::test]
|
||||||
|
async fn update_field_writes_output_json() {
|
||||||
|
// 验证 output_json 在 tasks 白名单(update_field 不报「不允许的字段名」)。
|
||||||
|
// 这正是 AiNode.execute / AiSelfReviewNode.execute 落产出的底层路径。
|
||||||
|
let (db, task_id) = setup_task_db(None).await;
|
||||||
|
let repo = TaskRepo::new(&db);
|
||||||
|
let payload = r#"{"text":"产出内容","model":"glm-4","usage":{}}"#;
|
||||||
|
let ok = repo
|
||||||
|
.update_field(&task_id, "output_json", payload)
|
||||||
|
.await
|
||||||
|
.expect("update_field output_json");
|
||||||
|
assert!(ok, "update_field 应返回 affected>0=true");
|
||||||
|
let after = repo.get_by_id(&task_id).await.unwrap().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
after.output_json.as_deref(),
|
||||||
|
Some(payload),
|
||||||
|
"output_json 应被写入"
|
||||||
|
);
|
||||||
|
// 原始 text 字段(产出对照)保留
|
||||||
|
let parsed: serde_json::Value = serde_json::from_str(after.output_json.as_deref().unwrap()).unwrap();
|
||||||
|
assert_eq!(parsed["text"], json!("产出内容"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 步骤③:parse_review_json 合规 JSON 直通(保留 verdict/dimensions/summary/suggestions)。
|
||||||
|
#[test]
|
||||||
|
fn parse_review_json_valid_passes_through() {
|
||||||
|
let raw = r#"{"verdict":"pass","dimensions":{"requirement_fit":{"score":9,"issues":[]}},"summary":"符合需求","suggestions":["可加单测"]}"#;
|
||||||
|
let v = parse_review_json(raw);
|
||||||
|
assert_eq!(v["verdict"], json!("pass"));
|
||||||
|
assert_eq!(v["summary"], json!("符合需求"));
|
||||||
|
assert_eq!(v["suggestions"], json!(["可加单测"]));
|
||||||
|
assert_eq!(v["dimensions"]["requirement_fit"]["score"], json!(9));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 步骤③:parse_review_json 非 JSON / 缺 verdict → 兜底 verdict=unknown + summary 含原文。
|
||||||
|
#[test]
|
||||||
|
fn parse_review_json_invalid_falls_back_unknown() {
|
||||||
|
// 非 JSON
|
||||||
|
let v = parse_review_json("这不是 JSON,LLM 没按要求输出");
|
||||||
|
assert_eq!(v["verdict"], json!("unknown"));
|
||||||
|
assert!(v["summary"].as_str().unwrap().contains("不是 JSON"));
|
||||||
|
assert!(v["summary"].as_str().unwrap().contains("解析失败"));
|
||||||
|
|
||||||
|
// JSON 但缺 verdict
|
||||||
|
let v = parse_review_json(r#"{"foo":"bar"}"#);
|
||||||
|
assert_eq!(v["verdict"], json!("unknown"));
|
||||||
|
|
||||||
|
// JSON 非 object(数组)
|
||||||
|
let v = parse_review_json("[1,2,3]");
|
||||||
|
assert_eq!(v["verdict"], json!("unknown"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 步骤③:LLM 偶尔包 ```json 代码块,parse_review_json 应剥离后解析成功。
|
||||||
|
#[test]
|
||||||
|
fn parse_review_json_strips_markdown_code_fence() {
|
||||||
|
let raw = "```json\n{\"verdict\":\"fail\",\"summary\":\"缺边界处理\"}\n```";
|
||||||
|
let v = parse_review_json(raw);
|
||||||
|
assert_eq!(v["verdict"], json!("fail"));
|
||||||
|
assert_eq!(v["summary"], json!("缺边界处理"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 步骤③:truncate_for_summary 长文截断。
|
||||||
|
#[test]
|
||||||
|
fn truncate_for_summary_long_text() {
|
||||||
|
let short = "短文";
|
||||||
|
assert_eq!(truncate_for_summary(short), "短文");
|
||||||
|
let long: String = "字".repeat(500);
|
||||||
|
let t = truncate_for_summary(&long);
|
||||||
|
assert!(t.ends_with("…"));
|
||||||
|
assert!(t.chars().count() <= 302, "截断后含省略号应 ≈300 字");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 步骤③:AiSelfReviewNode.build_review_prompt 含需求 + 产出 + 四维度。
|
||||||
|
#[test]
|
||||||
|
fn build_review_prompt_contains_inputs() {
|
||||||
|
let p = AiSelfReviewNode::build_review_prompt("登录接口", "已实现 /login");
|
||||||
|
assert!(p.contains("登录接口"), "prompt 应含需求");
|
||||||
|
assert!(p.contains("已实现 /login"), "prompt 应含产出");
|
||||||
|
assert!(p.contains("需求符合度"), "prompt 应含四维度");
|
||||||
|
assert!(p.contains("边界处理"));
|
||||||
|
assert!(p.contains("verdict"), "prompt 应含输出格式约束");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 阶段3 自审闸门(gate_should_block)单测
|
||||||
|
// ============================================================
|
||||||
|
//
|
||||||
|
// gate 决策矩阵:
|
||||||
|
// gate=false(默认,阶段2 行为) → 任何 verdict 都放行(辅助模式)
|
||||||
|
// gate=true + verdict=pass → 放行
|
||||||
|
// gate=true + verdict=unknown → 放行(LLM 不可靠时不阻断,人定权)
|
||||||
|
// gate=true + verdict=fail → 阻断(返回 Err,工作流 failed)
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gate_disabled_never_blocks_any_verdict() {
|
||||||
|
// gate 默认 false(阶段2 兼容):无论 verdict 如何都不阻断,自审仅辅助展示
|
||||||
|
assert!(!gate_should_block(false, "fail"), "gate 关闭时 fail 也不阻断");
|
||||||
|
assert!(!gate_should_block(false, "pass"), "gate 关闭时 pass 放行");
|
||||||
|
assert!(!gate_should_block(false, "unknown"), "gate 关闭时 unknown 放行");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gate_enabled_blocks_only_on_fail() {
|
||||||
|
// gate 开启:仅 fail 阻断
|
||||||
|
assert!(gate_should_block(true, "fail"), "gate 开 + fail 应阻断");
|
||||||
|
assert!(!gate_should_block(true, "pass"), "gate 开 + pass 放行");
|
||||||
|
assert!(
|
||||||
|
!gate_should_block(true, "unknown"),
|
||||||
|
"gate 开 + unknown 放行(LLM 不可靠时保人定权)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gate_unknown_verdict_does_not_block() {
|
||||||
|
// 防御:verdict 非标准值(如解析异常)不应触发阻断(对齐 unknown 保守不阻断语义)
|
||||||
|
assert!(
|
||||||
|
!gate_should_block(true, "weird"),
|
||||||
|
"非标准 verdict 不应阻断(对齐 unknown 保守)"
|
||||||
|
);
|
||||||
|
assert!(!gate_should_block(true, ""), "空 verdict 不应阻断");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
//! df-nodes: 内置节点集合 — AI、脚本、人工审批
|
//! df-nodes: 内置节点集合 — AI、脚本、人工审批
|
||||||
|
|
||||||
pub mod ai_node;
|
pub mod ai_node;
|
||||||
|
pub mod ai_self_review_node;
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
mod ai_node_helpers;
|
mod ai_node_helpers;
|
||||||
pub mod human_node;
|
pub mod human_node;
|
||||||
|
|||||||
@@ -705,13 +705,13 @@ fn build_registry(db: Arc<Database>) -> NodeRegistry {
|
|||||||
registry.register("ai", move |_config| {
|
registry.register("ai", move |_config| {
|
||||||
Box::new(df_nodes::ai_node::AiNode::new(ai_db.clone()))
|
Box::new(df_nodes::ai_node::AiNode::new(ai_db.clone()))
|
||||||
});
|
});
|
||||||
// AiSelfReviewNode(df_nodes::ai_node, impl Node trait):
|
// AiSelfReviewNode(df_nodes::ai_self_review_node, impl Node trait):
|
||||||
// 决策 a 步骤③:四维度自审(prompt 固定+JSON 解析兜底),写回 output_json 加 review 子字段,
|
// 决策 a 步骤③:四维度自审(prompt 固定+JSON 解析兜底),写回 output_json 加 review 子字段,
|
||||||
// review 摘要塞 NodeOutput.data 供下游 human_review 经 DAG inputs 透传。
|
// review 摘要塞 NodeOutput.data 供下游 human_review 经 DAG inputs 透传。
|
||||||
// testing 模板 ai_self_review 节点类型对齐此注册 key。
|
// testing 模板 ai_self_review 节点类型对齐此注册 key。
|
||||||
let review_db = db.clone();
|
let review_db = db.clone();
|
||||||
registry.register("ai_self_review", move |_config| {
|
registry.register("ai_self_review", move |_config| {
|
||||||
Box::new(df_nodes::ai_node::AiSelfReviewNode::new(review_db.clone()))
|
Box::new(df_nodes::ai_self_review_node::AiSelfReviewNode::new(review_db.clone()))
|
||||||
});
|
});
|
||||||
// TaskAdvanceNode(df_nodes::task_advance_node, impl Node trait):
|
// TaskAdvanceNode(df_nodes::task_advance_node, impl Node trait):
|
||||||
// 推进链阶段 2 工作流联动入口 — DAG 内触发 advance_task。
|
// 推进链阶段 2 工作流联动入口 — DAG 内触发 advance_task。
|
||||||
|
|||||||
Reference in New Issue
Block a user