//! 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 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" }