重构: 拆ai_node AI节点(strategy核心库)
- 新建 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/*)
This commit is contained in:
@@ -5,248 +5,24 @@
|
|||||||
//!
|
//!
|
||||||
//! 与 AI Chat(侧边栏交互对话)的区别:AI Node 由 DAG Executor 自动驱动,
|
//! 与 AI Chat(侧边栏交互对话)的区别:AI Node 由 DAG Executor 自动驱动,
|
||||||
//! 适合嵌入自动化链路(如 想法 → AI分析 → 脚本落地 → 人工审批)。
|
//! 适合嵌入自动化链路(如 想法 → AI分析 → 脚本落地 → 人工审批)。
|
||||||
|
//!
|
||||||
|
//! provider 解析/参数构造/自审 JSON 解析等纯逻辑见 `ai_node_helpers`(本文件只保留
|
||||||
|
//! AiNode / AiSelfReviewNode 的 struct + impl,impl 块约束)。
|
||||||
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use df_ai::df_ai_core::model::{Modality, ModelConfig};
|
|
||||||
use df_ai::provider::{ChatMessage, CompletionRequest, LlmProvider};
|
use df_ai::provider::{ChatMessage, CompletionRequest, LlmProvider};
|
||||||
// F-01 阶段5: AiNode 路由 — 节点 config.model_id 优先;否则按 TaskRequirements 路由
|
use df_storage::crud::TaskRepo;
|
||||||
// (默认 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::db::Database;
|
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};
|
use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema};
|
||||||
|
|
||||||
/// AI 节点解析后的参数(execute 与参数解析解耦,便于单测覆盖取值/默认/校验逻辑)
|
// 抽离的纯函数/类型(glob 引入,tests `use super::*` 间接可见)
|
||||||
///
|
#[allow(unused_imports)]
|
||||||
/// provider 配置(base_url/api_key/protocol/default_model)经 `resolve_provider` 从 DB
|
use crate::ai_node_helpers::{
|
||||||
/// ai_providers 表查 record + 经 df_storage::secret 解析密钥得到,**不进 config(FR-S1)**。
|
gate_should_block, parse_review_json, provider_from_params, resolve_and_parse,
|
||||||
/// api_key 仅存于本结构体内存(AiNode 进程内存),不落 NodeContext.config / NodeOutput.data。
|
truncate_for_summary, ResolvedProvider, REVIEW_SYSTEM_PROMPT,
|
||||||
#[derive(Debug)]
|
|
||||||
struct AiNodeParams {
|
|
||||||
/// 解析后的 provider 构造要素(含明文 api_key,仅 AiNode 内存可见)
|
|
||||||
provider: ResolvedProvider,
|
|
||||||
prompt: String,
|
|
||||||
system_prompt: Option<String>,
|
|
||||||
model: String,
|
|
||||||
temperature: Option<f32>,
|
|
||||||
max_tokens: Option<u32>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 经 `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<ModelConfig>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 经 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<Database>,
|
|
||||||
config: &serde_json::Value,
|
|
||||||
inputs: &HashMap<String, NodeOutput>,
|
|
||||||
) -> anyhow::Result<AiNodeParams> {
|
|
||||||
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<dyn LlmProvider> {
|
|
||||||
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<Database>,
|
|
||||||
config: &serde_json::Value,
|
|
||||||
) -> anyhow::Result<ResolvedProvider> {
|
|
||||||
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<ResolvedProvider> {
|
|
||||||
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<String, NodeOutput>,
|
|
||||||
provider: ResolvedProvider,
|
|
||||||
) -> anyhow::Result<AiNodeParams> {
|
|
||||||
// ── 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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// AI 节点
|
/// AI 节点
|
||||||
///
|
///
|
||||||
@@ -383,57 +159,6 @@ impl Node for AiNode {
|
|||||||
// 节点返 Ok 让 human_review 继续,人在审批卡看到 fail 结论再定(human reject 走 Err→工作流
|
// 节点返 Ok 让 human_review 继续,人在审批卡看到 fail 结论再定(human reject 走 Err→工作流
|
||||||
// failed→②-4 回调退回 in_review)。
|
// 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::<serde_json::Value>(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 步骤③)
|
/// AI 自审节点(决策 a 步骤③)
|
||||||
pub struct AiSelfReviewNode {
|
pub struct AiSelfReviewNode {
|
||||||
db: Arc<Database>,
|
db: Arc<Database>,
|
||||||
@@ -668,9 +393,12 @@ impl Node for AiSelfReviewNode {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use df_storage::crud::ProjectRepo;
|
use crate::ai_node_helpers::{parse_params, resolve_provider};
|
||||||
use df_storage::models::{ProjectRecord, TaskRecord};
|
use df_storage::crud::{AiProviderRepo, ProjectRepo};
|
||||||
|
use df_storage::db::Database;
|
||||||
|
use df_storage::models::{AiProviderRecord, ProjectRecord, TaskRecord};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
/// 测试用 ResolvedProvider 桩:固定 protocol/base_url/api_key/default_model。
|
/// 测试用 ResolvedProvider 桩:固定 protocol/base_url/api_key/default_model。
|
||||||
fn provider_stub() -> ResolvedProvider {
|
fn provider_stub() -> ResolvedProvider {
|
||||||
|
|||||||
298
crates/df-nodes/src/ai_node_helpers.rs
Normal file
298
crates/df-nodes/src/ai_node_helpers.rs
Normal file
@@ -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<String>,
|
||||||
|
pub model: String,
|
||||||
|
pub temperature: Option<f32>,
|
||||||
|
pub max_tokens: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 经 `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<ModelConfig>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 经 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<Database>,
|
||||||
|
config: &serde_json::Value,
|
||||||
|
inputs: &HashMap<String, NodeOutput>,
|
||||||
|
) -> anyhow::Result<AiNodeParams> {
|
||||||
|
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<dyn LlmProvider> {
|
||||||
|
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<Database>,
|
||||||
|
config: &serde_json::Value,
|
||||||
|
) -> anyhow::Result<ResolvedProvider> {
|
||||||
|
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<ResolvedProvider> {
|
||||||
|
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<String, NodeOutput>,
|
||||||
|
provider: ResolvedProvider,
|
||||||
|
) -> anyhow::Result<AiNodeParams> {
|
||||||
|
// ── 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::<serde_json::Value>(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"
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
//! df-nodes: 内置节点集合 — AI、脚本、人工审批
|
//! df-nodes: 内置节点集合 — AI、脚本、人工审批
|
||||||
|
|
||||||
pub mod ai_node;
|
pub mod ai_node;
|
||||||
|
#[allow(dead_code)]
|
||||||
|
mod ai_node_helpers;
|
||||||
pub mod human_node;
|
pub mod human_node;
|
||||||
pub mod script_node;
|
pub mod script_node;
|
||||||
pub mod task_advance_node;
|
pub mod task_advance_node;
|
||||||
|
|||||||
Reference in New Issue
Block a user