Files
DevFlow/crates/df-nodes/src/ai_node_helpers.rs
绝尘 bd6a41fe6e 新增: 批次工作落地(推进链/评估闭环/事件总线/并发/加固) + 技术债清理 + 文档整理
后端:
- 工作流推进链(D-03):advance_task/状态机/闸门走 df-nodes Node trait,conditions 条件引擎扩展
- 想法评估闭环:启发式评分+对抗评估,df-ideas/scoring + df-storage/idea_eval_repo + idea 前端打通
- 全局事件数据总线:df-ai/context+context_helpers+augmentation 跨模块解耦
- AI planner/plan_hint/intent:aichat B 路线并行多轮基础
- patch_file 加固(TD-03/04):读改写整体锁防 lost update,expected_hash 合约闭环
- 压缩超时兜底(F-15 卡死根治)
- F-09 多会话并发:LlmConcurrency per-conv + streamingGuard 前端守护 + verify 脚本
- 知识注入 DRY/skills/audit 扩展

清理:
- aichat 技术债(误报 allow/死导入/过时注释 30 项)
- URGENT.md 删除(11 项加急全解决/迁 todo)
- 文档整理(todo/待决策/待审查/ARCHITECTURE/INDEX + 总线/技术债审查新文档)
2026-06-21 20:51:26 +08:00

299 lines
13 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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 解析密钥得到,**不进 configFR-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默认/ anthropicGLM 订阅 / 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 build_default_provider 模式),走同 resolve+ensure 链。
/// 无任何 provider → 友好错误「未配置 AI Provider」。
///
/// modelconfig["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();
// ── 路径 1provider_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 缺少必填参数: promptconfig 或上游输入均无)"))?;
// ── 可选参数 ──
// 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"
}