From 35c976793bc1178d15d28f000bebca1b08098a1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Fri, 19 Jun 2026 11:39:26 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=9E=84:=20=E6=8B=86ai=5Fnode=20AI?= =?UTF-8?q?=E8=8A=82=E7=82=B9(strategy=E6=A0=B8=E5=BF=83=E5=BA=93)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新建 df-nodes/ai_node_helpers.rs(298行): AiNodeParams/ResolvedProvider + resolve_and_parse/provider_from_params/resolve_provider/resolve_from_record/parse_params/parse_review_json/truncate_for_summary/gate_should_block + REVIEW_SYSTEM_PROMPT - ai_node.rs 1107→835: AiNode/AiSelfReviewNode struct+impl+tests保留 + use helpers - lib.rs: mod ai_node_helpers 主代兜底(独立 -p df-nodes 避 df-ai/前端): cargo 0 + test 82(1 ignored GLM真调) strategy: 核心库自底向上, 纯函数/类型抽离, impl保留, SW-09 helper随之搬; 私有mod零外部可见性变化 git add指定(df-nodes/*) --- crates/df-nodes/src/ai_node.rs | 302 ++----------------------- crates/df-nodes/src/ai_node_helpers.rs | 298 ++++++++++++++++++++++++ crates/df-nodes/src/lib.rs | 2 + 3 files changed, 315 insertions(+), 287 deletions(-) create mode 100644 crates/df-nodes/src/ai_node_helpers.rs diff --git a/crates/df-nodes/src/ai_node.rs b/crates/df-nodes/src/ai_node.rs index 6d678b9..2bfa49e 100644 --- a/crates/df-nodes/src/ai_node.rs +++ b/crates/df-nodes/src/ai_node.rs @@ -5,248 +5,24 @@ //! //! 与 AI Chat(侧边栏交互对话)的区别:AI Node 由 DAG Executor 自动驱动, //! 适合嵌入自动化链路(如 想法 → AI分析 → 脚本落地 → 人工审批)。 +//! +//! provider 解析/参数构造/自审 JSON 解析等纯逻辑见 `ai_node_helpers`(本文件只保留 +//! AiNode / AiSelfReviewNode 的 struct + impl,impl 块约束)。 -use std::collections::HashMap; use std::sync::Arc; use async_trait::async_trait; -use df_ai::df_ai_core::model::{Modality, ModelConfig}; use df_ai::provider::{ChatMessage, CompletionRequest, LlmProvider}; -// F-01 阶段5: AiNode 路由 — 节点 config.model_id 优先;否则按 TaskRequirements 路由 -// (默认 Standard + needs_tool_use=true)。池空/无匹配兜底 record.default_model。 -use df_ai::router::{select_model_id, TaskRequirements}; -use df_storage::crud::{AiProviderRepo, TaskRepo}; +use df_storage::crud::TaskRepo; use df_storage::db::Database; -use df_storage::models::AiProviderRecord; -use df_storage::secret::{ensure_resolved_key, resolve_provider_secret}; use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema}; -/// AI 节点解析后的参数(execute 与参数解析解耦,便于单测覆盖取值/默认/校验逻辑) -/// -/// provider 配置(base_url/api_key/protocol/default_model)经 `resolve_provider` 从 DB -/// ai_providers 表查 record + 经 df_storage::secret 解析密钥得到,**不进 config(FR-S1)**。 -/// api_key 仅存于本结构体内存(AiNode 进程内存),不落 NodeContext.config / NodeOutput.data。 -#[derive(Debug)] -struct AiNodeParams { - /// 解析后的 provider 构造要素(含明文 api_key,仅 AiNode 内存可见) - provider: ResolvedProvider, - prompt: String, - system_prompt: Option, - model: String, - temperature: Option, - max_tokens: Option, -} - -/// 经 `resolve_provider` 从 ai_providers 表 + df_storage::secret 解析后的 provider 构造要素。 -/// -/// api_key 字段:明文,FR-S1 下全程不出 AiNode 进程内存(不进 config/output/schema)。 -#[derive(Debug, Clone)] -struct ResolvedProvider { - /// 协议类型:openai_compat(默认)/ anthropic(GLM 订阅 / Claude 官方)— 从 record.provider_type 映射 - protocol: String, - base_url: String, - /// 明文 api_key,经 `resolve_provider_secret`(DB 优先→keyring) 解析;仅 AiNode 内存可见 - api_key: String, - /// model 为空时的占位(record.default_model 或 "gpt-4o-mini"),避免 provider 构造 panic - default_model: String, - /// F-01 阶段5: 候选模型池(来自 record.model_configs)。parse_params 路由用: - /// config.model 留空时经 select_model_id 选最优;池空兜底 default_model。 - model_pool: Vec, -} - -/// 经 ai_providers 表 + df_storage::secret 解析 provider 构造要素(FR-S1 注入链核心)。 -/// -/// 三路径(优先级从高到低): -/// 1. **provider_id 优先**:config["provider_id"] 存在 → `AiProviderRepo::get_by_id` 查 record → -/// `resolve_provider_secret`(DB 优先→keyring) → `ensure_resolved_key` 空键早失败。 -/// 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 链。 -/// 无任何 provider → 友好错误「未配置 AI Provider」。 -/// -/// model:config["model"] 非空用之,否则 record.default_model,再否则 "gpt-4o-mini" 占位。 -/// SW-260618-09: provider 三件套 DRY —— resolve_provider + parse_params 合并 -/// (AiNode/AiSelfReviewNode execute 逐字重复)。 -async fn resolve_and_parse( - db: &Arc, - config: &serde_json::Value, - inputs: &HashMap, -) -> anyhow::Result { - let provider_cfg = resolve_provider(db, config).await?; - parse_params(config, inputs, provider_cfg) -} - -/// SW-260618-09: build_provider 5 行封装 DRY(AiNode/AiSelfReviewNode execute 逐字重复)。 -fn provider_from_params(p: &AiNodeParams) -> Box { - df_ai::build_provider( - &p.provider.protocol, - &p.provider.base_url, - &p.provider.api_key, - &p.provider.default_model, - ) -} - -async fn resolve_provider( - db: &Arc, - config: &serde_json::Value, -) -> anyhow::Result { - let repo = AiProviderRepo::new(db); - let config_model = config - .get("model") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - - // ── 路径 1:provider_id 优先 ── - if let Some(pid) = config.get("provider_id").and_then(|v| v.as_str()) { - if !pid.is_empty() { - let record = repo - .get_by_id(pid) - .await - .map_err(|e| anyhow::anyhow!("AiNode 查 provider 失败: {}", e))? - .ok_or_else(|| anyhow::anyhow!("AiNode provider_id={} 不存在", pid))?; - return resolve_from_record(&record, &config_model); - } - } - - // ── 路径 2:老明文路径兼容(过渡,warn) ── - // base_url / api_key 各解析一次(复用于 has 判定与取值,避免 DRY 重复解析同字段)。 - let plain_base = config.get("base_url").and_then(|v| v.as_str()); - let plain_key = config.get("api_key").and_then(|v| v.as_str()); - if let (Some(base_url_str), Some(api_key_str)) = (plain_base, plain_key) { - tracing::warn!( - "AiNode 明文 api_key/base_url 经 config 注入已废弃, 改用 provider_id (FR-S1). \ - 老路径将在后续版本移除" - ); - let base_url = base_url_str.to_string(); - let api_key = api_key_str.to_string(); - ensure_resolved_key("(明文注入)", &api_key) - .map_err(anyhow::Error::msg)?; - let protocol = config - .get("protocol") - .and_then(|v| v.as_str()) - .unwrap_or("openai_compat") - .to_string(); - let default_model = if config_model.is_empty() { - "gpt-4o-mini".to_string() - } else { - config_model.clone() - }; - return Ok(ResolvedProvider { - protocol, - base_url, - api_key, - default_model, - // 老明文路径无 record,候选池空(无路由能力,兜底 default_model)。 - model_pool: Vec::new(), - }); - } - - // ── 路径 3:兜底 is_default=true 首条 provider ── - let providers = repo - .list_all() - .await - .map_err(|e| anyhow::anyhow!("AiNode 列 provider 失败: {}", e))?; - let picked = providers - .iter() - .find(|p| p.is_default) - .cloned() - .or_else(|| providers.into_iter().next()) - .ok_or_else(|| anyhow::anyhow!("未配置 AI Provider,请在设置中添加并保存密钥"))?; - resolve_from_record(&picked, &config_model) -} - -/// 从 record 解析 provider 构造要素:resolve_provider_secret(DB 优先→keyring) + ensure 空键早失败。 -/// protocol 从 record.provider_type 映射;default_model 取 config_model > record.default_model > 占位。 -fn resolve_from_record( - record: &AiProviderRecord, - config_model: &str, -) -> anyhow::Result { - let api_key = resolve_provider_secret(record); - ensure_resolved_key(&record.name, &api_key).map_err(anyhow::Error::msg)?; - let default_model = if !config_model.is_empty() { - config_model.to_string() - } else if !record.default_model.is_empty() { - record.default_model.clone() - } else { - "gpt-4o-mini".to_string() - }; - Ok(ResolvedProvider { - protocol: record.provider_type.clone(), - base_url: record.base_url.clone(), - api_key, - default_model, - model_pool: record.model_configs.clone(), - }) -} - -/// 从节点 config + 上游输入解析 AI 节点 prompt 与可选参数(provider 经 `resolve_provider` 异步解析)。 -/// -/// prompt 取值优先级:上游 `inputs["prompt"]` > `config.prompt`,两者皆无则报错。 -fn parse_params( - config: &serde_json::Value, - inputs: &HashMap, - provider: ResolvedProvider, -) -> anyhow::Result { - // ── prompt(必填):优先取上游节点 "prompt" 输出,回退 config.prompt ── - let prompt = inputs - .get("prompt") - .and_then(|o| o.data.as_str()) - .map(|s| s.to_string()) - .or_else(|| { - config - .get("prompt") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()) - }) - .ok_or_else(|| anyhow::anyhow!("AiNode 缺少必填参数: prompt(config 或上游输入均无)"))?; - - // ── 可选参数 ── - // model 解析优先级(F-01 阶段5):config.model 显式指定 > 路由选优(provider.model_pool 非空时) - // > 空(CompletionRequest.model 留空由 provider impl 回填 default_model,行为不变)。 - // 注:provider.model_pool 在 provider move 进 AiNodeParams 前先借引用路由,选中的 model_id - // 填入 CompletionRequest.model;provider.default_model 仍是 build_provider 兜底用。 - let config_model = config - .get("model") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - let model = if !config_model.is_empty() { - config_model - } else { - // F-01 阶段5: AiNode 默认路由 — needs_tool_use=true(工作流无人值守 AI 步骤 - // 常含工具调用,如检索/生成;无需工具的节点应在 config 显式指定 model)。 - // select_model_id None(池空/无匹配)→ 空串(由 provider impl 回填 default_model)。 - let node_req = TaskRequirements { - modalities: vec![Modality::Text], - needs_tool_use: true, - estimated_context: 0, - }; - select_model_id(&node_req, &provider.model_pool).unwrap_or_default() - }; - let temperature = config - .get("temperature") - .and_then(|v| v.as_f64()) - .map(|f| f as f32); - let max_tokens = config - .get("max_tokens") - .and_then(|v| v.as_u64()) - .map(|n| n as u32); - let system_prompt = config - .get("system_prompt") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - - Ok(AiNodeParams { - provider, - prompt, - system_prompt, - model, - temperature, - max_tokens, - }) -} +// 抽离的纯函数/类型(glob 引入,tests `use super::*` 间接可见) +#[allow(unused_imports)] +use crate::ai_node_helpers::{ + gate_should_block, parse_review_json, provider_from_params, resolve_and_parse, + truncate_for_summary, ResolvedProvider, REVIEW_SYSTEM_PROMPT, +}; /// AI 节点 /// @@ -383,57 +159,6 @@ impl Node for AiNode { // 节点返 Ok 让 human_review 继续,人在审批卡看到 fail 结论再定(human reject 走 Err→工作流 // failed→②-4 回调退回 in_review)。 -/// 自审四维度 system prompt:严格审查员角色 + 只输出 JSON 强约束。 -const REVIEW_SYSTEM_PROMPT: &str = "\ -你是严格的代码/产出审查员。审查任务产出是否符合需求,按四维度给出结构化结论。\ -只输出 JSON,不要任何额外文字、不要 markdown 代码块包裹。"; - -/// 解析 LLM 自审输出为结构化 review JSON。 -/// -/// 成功路径:serde_json::from_str 得到 Object 且含 verdict 字段 → 原样返回。 -/// 兜底路径:解析失败 / 非 Object / 缺 verdict → 返回 verdict=unknown + summary=原文, -/// 防 LLM 不按要求输出导致下游崩溃。dimensions 留空对象(前端容缺展示)。 -fn parse_review_json(raw: &str) -> serde_json::Value { - // 先尝试整段解析;LLM 偶尔会包 markdown 代码块,剥离 ```json ... ``` 后重试一次。 - let trimmed = raw.trim(); - let cleaned = trimmed - .strip_prefix("```json") - .or_else(|| trimmed.strip_prefix("```")) - .map(|s| s.trim_end_matches("```").trim()) - .unwrap_or(trimmed); - - if let Ok(v) = serde_json::from_str::(cleaned) { - if v.is_object() && v.get("verdict").and_then(|x| x.as_str()).is_some() { - return v; - } - } - // 兜底:保留原文供人查阅,verdict=unknown 不阻断流程(自审辅助,人定)。 - serde_json::json!({ - "verdict": "unknown", - "dimensions": {}, - "summary": format!("(自审输出解析失败,原文: {})", truncate_for_summary(cleaned)), - "suggestions": [], - }) -} - -/// summary 截断(防原文过长撑爆 output_json / 审批卡)。 -fn truncate_for_summary(s: &str) -> String { - const MAX: usize = 300; - if s.chars().count() <= MAX { - return s.to_string(); - } - let truncated: String = s.chars().take(MAX).collect(); - format!("{truncated}…") -} - -/// 阶段3 自审闸门决策(纯函数,便于单测覆盖各 verdict/gate 组合)。 -/// -/// 仅当 `gate==true` 且 `verdict=="fail"` 时阻断。verdict="unknown"(LLM 输出不可靠) -/// 与 "pass" 均不阻断 —— unknown 保持人定权(阶段2 保守语义不变)。 -fn gate_should_block(gate: bool, verdict: &str) -> bool { - gate && verdict == "fail" -} - /// AI 自审节点(决策 a 步骤③) pub struct AiSelfReviewNode { db: Arc, @@ -668,9 +393,12 @@ impl Node for AiSelfReviewNode { #[cfg(test)] mod tests { use super::*; - use df_storage::crud::ProjectRepo; - use df_storage::models::{ProjectRecord, TaskRecord}; + use crate::ai_node_helpers::{parse_params, resolve_provider}; + use df_storage::crud::{AiProviderRepo, ProjectRepo}; + use df_storage::db::Database; + use df_storage::models::{AiProviderRecord, ProjectRecord, TaskRecord}; use serde_json::json; + use std::collections::HashMap; /// 测试用 ResolvedProvider 桩:固定 protocol/base_url/api_key/default_model。 fn provider_stub() -> ResolvedProvider { diff --git a/crates/df-nodes/src/ai_node_helpers.rs b/crates/df-nodes/src/ai_node_helpers.rs new file mode 100644 index 0000000..1fbca1e --- /dev/null +++ b/crates/df-nodes/src/ai_node_helpers.rs @@ -0,0 +1,298 @@ +//! AI 节点辅助函数/类型 — provider 解析、参数构造、自审 JSON 解析等纯逻辑 +//! +//! 从 ai_node.rs 抽离的纯函数/类型(execute 与解析解耦,便于单测覆盖)。 +//! AiNode / AiSelfReviewNode 的 struct + impl 仍保留在 ai_node.rs(impl 块约束)。 +//! +//! SW-09 已抽 resolve_and_parse / provider_from_params helper(本文件随之搬迁)。 + +use std::collections::HashMap; +use std::sync::Arc; + +use df_ai::df_ai_core::model::{Modality, ModelConfig}; +use df_ai::provider::LlmProvider; +// F-01 阶段5: AiNode 路由 — 节点 config.model_id 优先;否则按 TaskRequirements 路由 +// (默认 Standard + needs_tool_use=true)。池空/无匹配兜底 record.default_model。 +use df_ai::router::{select_model_id, TaskRequirements}; +use df_storage::crud::AiProviderRepo; +use df_storage::db::Database; +use df_storage::models::AiProviderRecord; +use df_storage::secret::{ensure_resolved_key, resolve_provider_secret}; +use df_workflow::node::{NodeOutput}; + +/// AI 节点解析后的参数(execute 与参数解析解耦,便于单测覆盖取值/默认/校验逻辑) +/// +/// provider 配置(base_url/api_key/protocol/default_model)经 `resolve_provider` 从 DB +/// ai_providers 表查 record + 经 df_storage::secret 解析密钥得到,**不进 config(FR-S1)**。 +/// api_key 仅存于本结构体内存(AiNode 进程内存),不落 NodeContext.config / NodeOutput.data。 +#[derive(Debug)] +pub(crate) struct AiNodeParams { + /// 解析后的 provider 构造要素(含明文 api_key,仅 AiNode 内存可见) + pub provider: ResolvedProvider, + pub prompt: String, + pub system_prompt: Option, + pub model: String, + pub temperature: Option, + pub max_tokens: Option, +} + +/// 经 `resolve_provider` 从 ai_providers 表 + df_storage::secret 解析后的 provider 构造要素。 +/// +/// api_key 字段:明文,FR-S1 下全程不出 AiNode 进程内存(不进 config/output/schema)。 +#[derive(Debug, Clone)] +pub(crate) struct ResolvedProvider { + /// 协议类型:openai_compat(默认)/ anthropic(GLM 订阅 / Claude 官方)— 从 record.provider_type 映射 + pub protocol: String, + pub base_url: String, + /// 明文 api_key,经 `resolve_provider_secret`(DB 优先→keyring) 解析;仅 AiNode 内存可见 + pub api_key: String, + /// model 为空时的占位(record.default_model 或 "gpt-4o-mini"),避免 provider 构造 panic + pub default_model: String, + /// F-01 阶段5: 候选模型池(来自 record.model_configs)。parse_params 路由用: + /// config.model 留空时经 select_model_id 选最优;池空兜底 default_model。 + pub model_pool: Vec, +} + +/// 经 ai_providers 表 + df_storage::secret 解析 provider 构造要素(FR-S1 注入链核心)。 +/// +/// 三路径(优先级从高到低): +/// 1. **provider_id 优先**:config["provider_id"] 存在 → `AiProviderRepo::get_by_id` 查 record → +/// `resolve_provider_secret`(DB 优先→keyring) → `ensure_resolved_key` 空键早失败。 +/// 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 链。 +/// 无任何 provider → 友好错误「未配置 AI Provider」。 +/// +/// model:config["model"] 非空用之,否则 record.default_model,再否则 "gpt-4o-mini" 占位。 +/// SW-260618-09: provider 三件套 DRY —— resolve_provider + parse_params 合并 +/// (AiNode/AiSelfReviewNode execute 逐字重复)。 +pub(crate) async fn resolve_and_parse( + db: &Arc, + config: &serde_json::Value, + inputs: &HashMap, +) -> anyhow::Result { + let provider_cfg = resolve_provider(db, config).await?; + parse_params(config, inputs, provider_cfg) +} + +/// SW-260618-09: build_provider 5 行封装 DRY(AiNode/AiSelfReviewNode execute 逐字重复)。 +pub(crate) fn provider_from_params(p: &AiNodeParams) -> Box { + df_ai::build_provider( + &p.provider.protocol, + &p.provider.base_url, + &p.provider.api_key, + &p.provider.default_model, + ) +} + +pub(crate) async fn resolve_provider( + db: &Arc, + config: &serde_json::Value, +) -> anyhow::Result { + let repo = AiProviderRepo::new(db); + let config_model = config + .get("model") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + // ── 路径 1:provider_id 优先 ── + if let Some(pid) = config.get("provider_id").and_then(|v| v.as_str()) { + if !pid.is_empty() { + let record = repo + .get_by_id(pid) + .await + .map_err(|e| anyhow::anyhow!("AiNode 查 provider 失败: {}", e))? + .ok_or_else(|| anyhow::anyhow!("AiNode provider_id={} 不存在", pid))?; + return resolve_from_record(&record, &config_model); + } + } + + // ── 路径 2:老明文路径兼容(过渡,warn) ── + // base_url / api_key 各解析一次(复用于 has 判定与取值,避免 DRY 重复解析同字段)。 + let plain_base = config.get("base_url").and_then(|v| v.as_str()); + let plain_key = config.get("api_key").and_then(|v| v.as_str()); + if let (Some(base_url_str), Some(api_key_str)) = (plain_base, plain_key) { + tracing::warn!( + "AiNode 明文 api_key/base_url 经 config 注入已废弃, 改用 provider_id (FR-S1). \ + 老路径将在后续版本移除" + ); + let base_url = base_url_str.to_string(); + let api_key = api_key_str.to_string(); + ensure_resolved_key("(明文注入)", &api_key) + .map_err(anyhow::Error::msg)?; + let protocol = config + .get("protocol") + .and_then(|v| v.as_str()) + .unwrap_or("openai_compat") + .to_string(); + let default_model = if config_model.is_empty() { + "gpt-4o-mini".to_string() + } else { + config_model.clone() + }; + return Ok(ResolvedProvider { + protocol, + base_url, + api_key, + default_model, + // 老明文路径无 record,候选池空(无路由能力,兜底 default_model)。 + model_pool: Vec::new(), + }); + } + + // ── 路径 3:兜底 is_default=true 首条 provider ── + let providers = repo + .list_all() + .await + .map_err(|e| anyhow::anyhow!("AiNode 列 provider 失败: {}", e))?; + let picked = providers + .iter() + .find(|p| p.is_default) + .cloned() + .or_else(|| providers.into_iter().next()) + .ok_or_else(|| anyhow::anyhow!("未配置 AI Provider,请在设置中添加并保存密钥"))?; + resolve_from_record(&picked, &config_model) +} + +/// 从 record 解析 provider 构造要素:resolve_provider_secret(DB 优先→keyring) + ensure 空键早失败。 +/// protocol 从 record.provider_type 映射;default_model 取 config_model > record.default_model > 占位。 +pub(crate) fn resolve_from_record( + record: &AiProviderRecord, + config_model: &str, +) -> anyhow::Result { + let api_key = resolve_provider_secret(record); + ensure_resolved_key(&record.name, &api_key).map_err(anyhow::Error::msg)?; + let default_model = if !config_model.is_empty() { + config_model.to_string() + } else if !record.default_model.is_empty() { + record.default_model.clone() + } else { + "gpt-4o-mini".to_string() + }; + Ok(ResolvedProvider { + protocol: record.provider_type.clone(), + base_url: record.base_url.clone(), + api_key, + default_model, + model_pool: record.model_configs.clone(), + }) +} + +/// 从节点 config + 上游输入解析 AI 节点 prompt 与可选参数(provider 经 `resolve_provider` 异步解析)。 +/// +/// prompt 取值优先级:上游 `inputs["prompt"]` > `config.prompt`,两者皆无则报错。 +pub(crate) fn parse_params( + config: &serde_json::Value, + inputs: &HashMap, + provider: ResolvedProvider, +) -> anyhow::Result { + // ── prompt(必填):优先取上游节点 "prompt" 输出,回退 config.prompt ── + let prompt = inputs + .get("prompt") + .and_then(|o| o.data.as_str()) + .map(|s| s.to_string()) + .or_else(|| { + config + .get("prompt") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + }) + .ok_or_else(|| anyhow::anyhow!("AiNode 缺少必填参数: prompt(config 或上游输入均无)"))?; + + // ── 可选参数 ── + // model 解析优先级(F-01 阶段5):config.model 显式指定 > 路由选优(provider.model_pool 非空时) + // > 空(CompletionRequest.model 留空由 provider impl 回填 default_model,行为不变)。 + // 注:provider.model_pool 在 provider move 进 AiNodeParams 前先借引用路由,选中的 model_id + // 填入 CompletionRequest.model;provider.default_model 仍是 build_provider 兜底用。 + let config_model = config + .get("model") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let model = if !config_model.is_empty() { + config_model + } else { + // F-01 阶段5: AiNode 默认路由 — needs_tool_use=true(工作流无人值守 AI 步骤 + // 常含工具调用,如检索/生成;无需工具的节点应在 config 显式指定 model)。 + // select_model_id None(池空/无匹配)→ 空串(由 provider impl 回填 default_model)。 + let node_req = TaskRequirements { + modalities: vec![Modality::Text], + needs_tool_use: true, + estimated_context: 0, + }; + select_model_id(&node_req, &provider.model_pool).unwrap_or_default() + }; + let temperature = config + .get("temperature") + .and_then(|v| v.as_f64()) + .map(|f| f as f32); + let max_tokens = config + .get("max_tokens") + .and_then(|v| v.as_u64()) + .map(|n| n as u32); + let system_prompt = config + .get("system_prompt") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + Ok(AiNodeParams { + provider, + prompt, + system_prompt, + model, + temperature, + max_tokens, + }) +} + +/// 自审四维度 system prompt:严格审查员角色 + 只输出 JSON 强约束。 +pub(crate) const REVIEW_SYSTEM_PROMPT: &str = "\ +你是严格的代码/产出审查员。审查任务产出是否符合需求,按四维度给出结构化结论。\ +只输出 JSON,不要任何额外文字、不要 markdown 代码块包裹。"; + +/// 解析 LLM 自审输出为结构化 review JSON。 +/// +/// 成功路径:serde_json::from_str 得到 Object 且含 verdict 字段 → 原样返回。 +/// 兜底路径:解析失败 / 非 Object / 缺 verdict → 返回 verdict=unknown + summary=原文, +/// 防 LLM 不按要求输出导致下游崩溃。dimensions 留空对象(前端容缺展示)。 +pub(crate) fn parse_review_json(raw: &str) -> serde_json::Value { + // 先尝试整段解析;LLM 偶尔会包 markdown 代码块,剥离 ```json ... ``` 后重试一次。 + let trimmed = raw.trim(); + let cleaned = trimmed + .strip_prefix("```json") + .or_else(|| trimmed.strip_prefix("```")) + .map(|s| s.trim_end_matches("```").trim()) + .unwrap_or(trimmed); + + if let Ok(v) = serde_json::from_str::(cleaned) { + if v.is_object() && v.get("verdict").and_then(|x| x.as_str()).is_some() { + return v; + } + } + // 兜底:保留原文供人查阅,verdict=unknown 不阻断流程(自审辅助,人定)。 + serde_json::json!({ + "verdict": "unknown", + "dimensions": {}, + "summary": format!("(自审输出解析失败,原文: {})", truncate_for_summary(cleaned)), + "suggestions": [], + }) +} + +/// summary 截断(防原文过长撑爆 output_json / 审批卡)。 +pub(crate) fn truncate_for_summary(s: &str) -> String { + const MAX: usize = 300; + if s.chars().count() <= MAX { + return s.to_string(); + } + let truncated: String = s.chars().take(MAX).collect(); + format!("{truncated}…") +} + +/// 阶段3 自审闸门决策(纯函数,便于单测覆盖各 verdict/gate 组合)。 +/// +/// 仅当 `gate==true` 且 `verdict=="fail"` 时阻断。verdict="unknown"(LLM 输出不可靠) +/// 与 "pass" 均不阻断 —— unknown 保持人定权(阶段2 保守语义不变)。 +pub(crate) fn gate_should_block(gate: bool, verdict: &str) -> bool { + gate && verdict == "fail" +} diff --git a/crates/df-nodes/src/lib.rs b/crates/df-nodes/src/lib.rs index b0d43a9..eef29b1 100644 --- a/crates/df-nodes/src/lib.rs +++ b/crates/df-nodes/src/lib.rs @@ -1,6 +1,8 @@ //! df-nodes: 内置节点集合 — AI、脚本、人工审批 pub mod ai_node; +#[allow(dead_code)] +mod ai_node_helpers; pub mod human_node; pub mod script_node; pub mod task_advance_node;