Files
DevFlow/crates/df-ideas/src/adversarial.rs

926 lines
41 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 分析师
//!
//! 双轨实现:
//! - **启发式**(默认/降级):基于评分与内容信号生成正反方论点,稳定有区分度。
//! - **LLM**(注入 provider 后):调一次 `complete()` 让论点由 LLM 生成,失败自动降级启发式。
//!
//! 评估来源由 [`EvaluatedBy`] 三态标记:`Llm`LLM 深度评估)/ `Heuristic`(主动选启发式,
//! 无 provider/ `HeuristicFallback`LLM 调用失败降级)。前端可据此显示评估深度标签。
//!
//! LLM prompt 构造与 JSON 解析在 F-260614-03 接入:[`AdversarialEngine::evaluate_with_llm`]
//! 构造三角色辩论 prompt正方/反方/分析师),调一次 `complete()` 要求返回对齐结构的 JSON
//! 解析失败/字段缺失/枚举非法 → `bail` 触发降级([`AdversarialEngine::evaluate`] 已兜底)。
use std::sync::Arc;
use anyhow::Result;
use serde::{Deserialize, Serialize};
use df_ai_core::model::ModelConfig;
use df_ai_core::provider::LlmProvider;
use df_types::types::{IdeaId, Priority};
use crate::capture::Idea;
use crate::scoring::IdeaScores;
/// 评估来源标记
///
/// `Default = Heuristic`老数据F-07 之前)序列化时无 evaluated_by 字段,
/// 反序列化回落启发式(与 F-07 之前行为一致)。
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub enum EvaluatedBy {
/// LLM 深度评估
Llm,
/// 启发式评估(无 LLM 配置时的默认模式,也是老数据反序列化默认值)
#[default]
Heuristic,
/// 启发式降级LLM 调用失败后 fallback
HeuristicFallback,
}
/// 对抗评估结果
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AdversarialEval {
pub idea_id: IdeaId,
pub positive: Argument,
pub negative: Argument,
pub analyst: AnalystAnalysis,
pub final_score: f64,
pub recommendation: Recommendation,
/// 评估来源Llm / Heuristic / HeuristicFallback前端据此显示评估深度标签
#[serde(default)]
pub evaluated_by: EvaluatedBy,
}
/// 论点(正方/反方共用同一结构)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Argument {
pub thesis: String, // 核心观点
pub evidence: Vec<String>, // 证据支持
pub reasoning: Vec<String>, // 推理过程
pub confidence: f64, // 置信度 0-1
}
/// AI 分析师综合分析
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AnalystAnalysis {
pub summary: String, // 综合总结
pub strengths: Vec<String>, // 主要优势
pub weaknesses: Vec<String>, // 主要劣势
pub risks: Vec<String>, // 潜在风险
pub opportunities: Vec<String>, // 机会点
pub final_assessment: AssessmentLevel, // 最终评估
}
/// 评估等级
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum AssessmentLevel {
StrongGo, // 强烈推荐执行
Recommended, // 推荐执行
Conditional, // 有条件执行
Revised, // 需要修改后执行
Defer, // 推迟执行
}
/// 最终建议
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum Recommendation {
ImmediateAction, // 立即行动
Soon, // 尽快行动
WithResources, // 配置资源后行动
ResearchMore, // 需要更多研究
Monitor, // 持续监控
}
/// 对抗评估引擎
pub struct AdversarialEngine {
/// 可选 LLM provider。Some → 优先 LLM 评估失败降级启发式None → 纯启发式。
/// 构造注入(与 IdeaPromoter::new(policy) 同一模式),批量评估复用同一 provider。
provider: Option<Arc<dyn LlmProvider>>,
/// F-01 阶段5: 候选模型池。非空时 evaluate_with_llm 经 select_model_id 路由选模型;
/// 空(None provider 或未注入池)→ model 留空由 provider impl 回填自身 default_model
/// (与接入前行为一致,平稳过渡)。
model_pool: Vec<ModelConfig>,
}
impl AdversarialEngine {
/// 注入 LLM provider 构造provider Some 时走 LLM调用失败自动降级启发式
pub fn new(provider: Arc<dyn LlmProvider>) -> Self {
Self { provider: Some(provider), model_pool: Vec::new() }
}
/// F-01 阶段5: 注入 provider + 候选模型池构造。池非空时 evaluate_with_llm 走路由。
pub fn with_pool(provider: Arc<dyn LlmProvider>, model_pool: Vec<ModelConfig>) -> Self {
Self { provider: Some(provider), model_pool }
}
/// 纯启发式构造(无 LLM 配置时的默认模式)
pub fn heuristic() -> Self {
Self { provider: None, model_pool: Vec::new() }
}
/// 执行完整的对抗评估(内部按 provider 有无调度 LLM / 启发式,失败降级)
pub async fn evaluate(&self, idea: &Idea) -> Result<AdversarialEval> {
match &self.provider {
Some(p) => match self.evaluate_with_llm(idea, p).await {
Ok(mut eval) => {
eval.evaluated_by = EvaluatedBy::Llm;
Ok(eval)
}
Err(e) => {
// LLM 调用失败/超时/格式异常 → 自动降级启发式,保证前端结构完整返回
tracing::warn!("LLM 对抗评估失败, 降级到启发式: {e}");
let mut eval = self.evaluate_heuristic(idea)?;
eval.evaluated_by = EvaluatedBy::HeuristicFallback;
Ok(eval)
}
},
None => {
let mut eval = self.evaluate_heuristic(idea)?;
eval.evaluated_by = EvaluatedBy::Heuristic;
Ok(eval)
}
}
}
/// LLM 对抗评估(注入 provider 后走此路)。
///
/// 构造三角色对抗式辩论 prompt正方/反方/分析师),调一次 `complete()`,要求 LLM
/// 返回对齐 [`AdversarialEval`] 结构的 JSON。任一环节失败HTTP / 非 JSON / 字段
/// 缺失 / 枚举非法 / 数值越界无法修正)→ `bail` 由 [`evaluate`] 捕获降级启发式。
///
/// temperature 取 0.4:低于 0.3 偏机械重复启发式信号,高于 0.5 易发散到无关风险,
/// 0.4 在「稳定可复现」与「论点多样性」间取得平衡。
async fn evaluate_with_llm(&self, idea: &Idea, provider: &Arc<dyn LlmProvider>) -> Result<AdversarialEval> {
let prompt = build_adversarial_prompt(idea);
// F-01 阶段5: 智能路由 — 对抗评估 TaskRequirements(Standard,无工具)。
// 池非空 → select_model_id 选最优 model_id;池空/无匹配 → 留空由 provider impl
// 回填自身 default_model(与接入前行为一致,平稳过渡)。
let eval_req = df_ai::router::TaskRequirements {
modalities: vec![df_ai_core::model::Modality::Text],
needs_tool_use: false,
min_intelligence: df_ai_core::model::IntelligenceTier::Standard,
max_cost: None,
estimated_context: 0,
};
let model = df_ai::router::select_model_id(&eval_req, &self.model_pool).unwrap_or_default();
let request = df_ai_core::provider::CompletionRequest {
// 路由命中 → 用 model_id;否则留空让 provider impl 回填自身 default_model。
// (OpenAICompatProvider::convert_request 在 req.model.is_empty() 时回退 default_model)
model,
messages: vec![
df_ai_core::provider::ChatMessage::system(SYSTEM_PROMPT),
df_ai_core::provider::ChatMessage::user(prompt),
],
temperature: Some(0.4),
max_tokens: Some(2048),
stream: false,
tools: None,
tool_choice: None,
};
let resp = provider
.complete(request)
.await
.map_err(|e| anyhow::anyhow!("LLM complete 调用失败: {e}"))?;
// 数值 clamp 由 parse_llm_eval 单点收口final_score∈[0,10]、confidence∈[0,1]
// 均在 parse 内对所有 Ok 路径完成),此处不再重复 clampCR-40-1 去冗余)。
parse_llm_eval(&resp.text, &idea.id)
}
/// 启发式评估(基于评分与内容信号,稳定有区分度)
fn evaluate_heuristic(&self, idea: &Idea) -> Result<AdversarialEval> {
// 先做多维评分,作为正反方论点与置信度的依据
let scores = crate::scoring::ScoringEngine::compute_default(idea);
let positive = self.generate_positive_argument(idea, &scores)?;
let negative = self.generate_negative_argument(idea, &scores)?;
let analyst = self.analyst_analysis(idea, &scores)?;
let recommendation = self.recommendation_for(&analyst.final_assessment);
Ok(AdversarialEval {
idea_id: idea.id.clone(),
positive,
negative,
analyst,
final_score: scores.overall,
recommendation,
// 由 evaluate() 调用方按调度路径覆盖Heuristic / HeuristicFallback
evaluated_by: EvaluatedBy::Heuristic,
})
}
/// 生成正方观点(支持执行)— confidence 由可行性 + 影响力驱动
/// 注:返回 Result 为后续 LLM 注入失败预留,启发式阶段恒 Ok
fn generate_positive_argument(&self, idea: &Idea, scores: &IdeaScores) -> Result<Argument> {
let desc = idea.description.trim();
let mut evidence = Vec::new();
evidence.push(format!("优先级:{}", priority_label(&idea.priority)));
if desc.is_empty() {
evidence.push("需求待补充(建议补全描述)".to_string());
} else {
let head: String = desc.chars().take(60).collect();
evidence.push(format!("明确需求:{}", head));
}
if idea.tags.is_empty() {
evidence.push("关联领域待界定".to_string());
} else {
evidence.push(format!("关联领域:{}", idea.tags.join("")));
}
if scores.impact >= 7.0 {
evidence.push("业务价值显著,影响面较广".to_string());
}
// 正方置信度:可行性+影响力等权折算到 [0.1, 0.95],满分≈0.95 留质疑余地
let confidence =
((scores.feasibility * 0.5 + scores.impact * 0.5) / 10.0).clamp(0.1, 0.95);
let reasoning = vec![
format!("可行性评分 {:.1}/10路径相对清晰", scores.feasibility),
format!("影响力评分 {:.1}/10预期回报可观", scores.impact),
"整体风险可控,适合推进".to_string(),
];
Ok(Argument {
thesis: format!("「{}」具备明确价值与可行性,建议优先推进", idea.title),
evidence,
reasoning,
confidence,
})
}
/// 生成反方观点(反对或谨慎)— 论点基于想法实际缺陷confidence 随风险上升
fn generate_negative_argument(&self, idea: &Idea, scores: &IdeaScores) -> Result<Argument> {
let desc = idea.description.trim();
let mut evidence = Vec::new();
if desc.is_empty() {
evidence.push("描述过于简略,需求边界不清".to_string());
} else if desc.chars().count() < 50 {
evidence.push("描述偏短,实现细节尚未论证".to_string());
}
if idea.tags.is_empty() {
evidence.push("缺少标签,影响范围未界定".to_string());
}
if scores.feasibility < 6.0 {
evidence.push(format!("可行性 {:.1}/10 偏低,实现路径存疑", scores.feasibility));
}
if matches!(idea.priority, Priority::Low) {
evidence.push("优先级偏低,可能非当前关键路径".to_string());
}
if evidence.is_empty() {
evidence.push("机会成本需权衡,可能存在更优替代方案".to_string());
}
// 反方强度:feasibility 每降 1 分 +0.04,impact 每降 1 分 +0.03,基线 0.25(满分也保留最低质疑),clamp [0.1, 0.9]
let confidence = ((10.0 - scores.feasibility) * 0.04 + (10.0 - scores.impact) * 0.03 + 0.25)
.clamp(0.1, 0.9);
let reasoning = vec![
format!("资源投入与当前综合评分 {:.1} 需匹配", scores.overall),
"ROI 需进一步验证".to_string(),
"需评估是否存在更优解".to_string(),
];
Ok(Argument {
thesis: format!("「{}」需谨慎评估,存在风险与机会成本", idea.title),
evidence,
reasoning,
confidence,
})
}
/// AI 分析师综合分析 — 评估等级由综合评分决定,优势/劣势按维度动态生成
fn analyst_analysis(&self, idea: &Idea, scores: &IdeaScores) -> Result<AnalystAnalysis> {
let final_assessment = match scores.overall {
x if x >= 7.5 => AssessmentLevel::StrongGo,
x if x >= 6.0 => AssessmentLevel::Recommended,
x if x >= 4.5 => AssessmentLevel::Conditional,
x if x >= 3.0 => AssessmentLevel::Revised,
_ => AssessmentLevel::Defer,
};
let mut strengths = Vec::new();
if scores.impact >= 6.0 {
strengths.push("业务价值明确".to_string());
}
if scores.feasibility >= 6.0 {
strengths.push("技术路径清晰".to_string());
}
if scores.urgency >= 7.0 {
strengths.push("时间窗口合适".to_string());
}
if strengths.is_empty() {
strengths.push("方向值得探索".to_string());
}
let mut weaknesses = Vec::new();
if scores.feasibility < 6.0 {
weaknesses.push("可行性论证不足".to_string());
}
if idea.description.trim().is_empty() {
weaknesses.push("需求描述缺失".to_string());
}
if scores.urgency < 4.0 {
weaknesses.push("紧急度偏低,易被搁置".to_string());
}
if weaknesses.is_empty() {
weaknesses.push("资源需求待评估".to_string());
}
// 启发式占位:固定风险模板,与具体想法无关,接 LLM 后改动态生成
let risks = vec![
"技术实现难度可能超出预期".to_string(),
"优先级与资源争夺".to_string(),
"需求范围蔓延".to_string(),
];
let opportunities = vec![
"可能形成可复用能力".to_string(),
"积累技术资产".to_string(),
];
let summary = format!(
"{}」综合评分 {:.1}/10{}。建议{}",
idea.title,
scores.overall,
assessment_desc(&final_assessment),
action_hint(&final_assessment)
);
Ok(AnalystAnalysis {
summary,
strengths,
weaknesses,
risks,
opportunities,
final_assessment,
})
}
/// 评估等级 → 最终建议
fn recommendation_for(&self, level: &AssessmentLevel) -> Recommendation {
match level {
AssessmentLevel::StrongGo => Recommendation::ImmediateAction,
AssessmentLevel::Recommended => Recommendation::Soon,
AssessmentLevel::Conditional => Recommendation::WithResources,
AssessmentLevel::Revised => Recommendation::ResearchMore,
AssessmentLevel::Defer => Recommendation::Monitor,
}
}
}
// ============================================================
// LLM 对抗评估 — prompt 构造 / JSON 解析F-260614-03
// ============================================================
/// LLM 角色 / 输出契约的系统级约束。
///
/// 放为 const 便于在 prompt 头部注入,与 [`build_adversarial_prompt`] 的「想法上下文」
/// 分离:角色设定稳定不变,想法上下文按评估对象动态拼。
const SYSTEM_PROMPT: &str = "\
你是一位资深的技术产品决策顾问。你将主持一场三角色对抗式辩论,对一个软件想法做结构化评估:
- 正方Advocate论证为什么应该立即推进该想法挖掘价值与可行性证据。
- 反方Skeptic质疑该想法挖掘风险、机会成本与替代方案不放过任何隐患。
- 分析师Analyst综合正反方观点给出客观、平衡的最终裁决。
规则:
1. 你必须只输出一个 JSON 对象不要输出任何解释性文字、Markdown 代码块标记或前后缀。
2. 所有枚举字段只能取下方 schema 列出的字符串字面量(区分大小写)。
3. 数值字段必须落在指定区间内。
4. 论点thesis / evidence / reasoning / summary 等)用中文表达,简洁有信息密度。";
/// 构造对抗评估 prompt三角色辩论 + 严格 JSON schema
///
/// 输出 prompt 包含想法上下文title/description/priority/tags+ 角色 + JSON 输出
/// 契约(含字段说明与枚举字面量),让 LLM 单轮产出可解析的结构化评估。
fn build_adversarial_prompt(idea: &Idea) -> String {
// 描述截断防 prompt 过长:> 800 字按 800 截并附省略号提示。
let desc_raw = idea.description.trim();
let description = if desc_raw.chars().count() > 800 {
let head: String = desc_raw.chars().take(800).collect();
format!("{head}……(描述已截断,原长超过 800 字)")
} else if desc_raw.is_empty() {
"(描述为空,需求边界待补充)".to_string()
} else {
desc_raw.to_string()
};
let tags = if idea.tags.is_empty() {
"(无标签)".to_string()
} else {
idea.tags.join("")
};
format!(
"\
请对以下软件想法进行三角色对抗式评估。
【想法上下文】
标题:{title}
描述:{description}
优先级:{priority}
标签:{tags}
【输出 JSON schema】只输出该 JSON 对象,字段名与枚举字面量严格一致)
{{
\"positive\": {{
\"thesis\": \"正方核心观点(一句话)\",
\"evidence\": [\"证据 1\", \"证据 2\"],
\"reasoning\": [\"推理 1\", \"推理 2\"],
\"confidence\": 0.0到1.0之间的浮点数,正方置信度
}},
\"negative\": {{
\"thesis\": \"反方核心观点(一句话)\",
\"evidence\": [\"证据 1\"],
\"reasoning\": [\"推理 1\"],
\"confidence\": 0.0到1.0之间的浮点数,反方置信度
}},
\"analyst\": {{
\"summary\": \"分析师综合总结(一段话)\",
\"strengths\": [\"主要优势 1\"],
\"weaknesses\": [\"主要劣势 1\"],
\"risks\": [\"潜在风险 1\"],
\"opportunities\": [\"机会点 1\"],
\"final_assessment\": \"StrongGo | Recommended | Conditional | Revised | Defer\"
}},
\"final_score\": 0到10之间的浮点数综合评分,
\"recommendation\": \"ImmediateAction | Soon | WithResources | ResearchMore | Monitor\"
}}
枚举字段说明:
- final_assessment: StrongGo=强烈推荐执行 / Recommended=推荐执行 / Conditional=有条件执行 / Revised=需修改后执行 / Defer=推迟执行
- recommendation: ImmediateAction=立即行动 / Soon=尽快行动 / WithResources=配置资源后行动 / ResearchMore=需更多研究 / Monitor=持续监控
要求:
- positive 与 negative 的论点必须真实基于上方想法上下文,不要泛泛而谈。
- analyst.summary 必须总结双方并给出明确倾向,不要模棱两可。
- final_score 与 recommendation 应与 analyst.final_assessment 自洽(如 Defer 对应低分与 Monitor
- 只输出 JSON不要任何额外文字。",
title = idea.title,
description = description,
priority = priority_label(&idea.priority),
tags = tags,
)
}
/// LLM 返回的非类型化 JSON 表示(与 [`AdversarialEval`] 结构对齐,但枚举为字符串、
/// evaluated_by 字段省略——由 [`evaluate`] 调用方覆盖)。
///
/// 采用独立中间结构而非直接 serde 到 [`AdversarialEval`]:便于在枚举非法时给出
/// 字段级错误信息(`final_assessment: "GoNow" 不在合法集合`),而非整条记录丢弃;
/// 同时留出数值 clamp 的统一收口。
#[derive(Debug, Deserialize)]
struct LlmEvalRaw {
positive: ArgumentRaw,
negative: ArgumentRaw,
analyst: AnalystRaw,
final_score: f64,
recommendation: String,
}
#[derive(Debug, Deserialize)]
struct ArgumentRaw {
thesis: String,
#[serde(default)]
evidence: Vec<String>,
#[serde(default)]
reasoning: Vec<String>,
#[serde(default)]
confidence: f64,
}
#[derive(Debug, Deserialize)]
struct AnalystRaw {
#[serde(default)]
summary: String,
#[serde(default)]
strengths: Vec<String>,
#[serde(default)]
weaknesses: Vec<String>,
#[serde(default)]
risks: Vec<String>,
#[serde(default)]
opportunities: Vec<String>,
final_assessment: String,
}
/// 解析 LLM 返回文本为 [`AdversarialEval`]。
///
/// 容错策略(任一失败 `bail` → 由 [`evaluate`] 降级启发式):
/// 1. 剥离 ```json … ``` 代码块围栏与首尾空白(部分 LLM 会无视「只输出 JSON」要求
/// 2. `serde_json` 反序列化为 [`LlmEvalRaw`];字段类型错 / 缺失必填 → bail。
/// 3. 枚举字符串映射([`parse_assessment_level`] / [`parse_recommendation`]);非法值 → bail。
/// 4. 数值 clampconfidence ∈ [0,1]final_score ∈ [0,10]。
fn parse_llm_eval(text: &str, idea_id: &str) -> Result<AdversarialEval> {
let json_str = extract_json(text);
let raw: LlmEvalRaw = serde_json::from_str(&json_str).map_err(|e| {
anyhow::anyhow!("LLM 返回非合法 JSON 或字段缺失: {e}")
})?;
let final_assessment = parse_assessment_level(&raw.analyst.final_assessment)?;
let recommendation = parse_recommendation(&raw.recommendation)?;
let positive = Argument {
thesis: raw.positive.thesis,
evidence: raw.positive.evidence,
reasoning: raw.positive.reasoning,
confidence: raw.positive.confidence.clamp(0.0, 1.0),
};
let negative = Argument {
thesis: raw.negative.thesis,
evidence: raw.negative.evidence,
reasoning: raw.negative.reasoning,
confidence: raw.negative.confidence.clamp(0.0, 1.0),
};
let analyst = AnalystAnalysis {
summary: raw.analyst.summary,
strengths: raw.analyst.strengths,
weaknesses: raw.analyst.weaknesses,
risks: raw.analyst.risks,
opportunities: raw.analyst.opportunities,
final_assessment,
};
Ok(AdversarialEval {
idea_id: idea_id.to_string(),
positive,
negative,
analyst,
// final_score clamp 单点收口于此evaluate_with_llm 不再重复 clampCR-40-1
final_score: raw.final_score.clamp(0.0, 10.0),
recommendation,
// 由 evaluate() 按 LLM 调度路径覆盖为 EvaluatedBy::Llm
evaluated_by: EvaluatedBy::Llm,
})
}
/// 从 LLM 返回文本中提取 JSON 主体。
///
/// 提取优先级:
/// 1. 代码围栏在开头(```json … ``` 或 ``` … ```)→ 快路径剥离围栏返回主体。
/// 2. 围栏不在开头LLM 输出「前缀文字 + ```json + {...} + ```」)→ 正则
/// `(?s)\{.*\}` 兜底提取首个 `{` 到最后一个 `}` 的片段(增强命中率)。
///
/// 注意:兜底返回的是「原始文本中首个 JSON 对象字面量」,**不再** trim 后整段交给
/// serde——由 [`parse_llm_eval`] 的 serde 步骤校验合法性,失败即 bail 降级。
fn extract_json(text: &str) -> String {
let trimmed = text.trim();
// 快路径:围栏在开头(```json ... ``` 或 ``` ... ```
if let Some(rest) = trimmed.strip_prefix("```") {
// 跳过语言标记json/JSON 等)到首个换行
let after_lang = match rest.find('\n') {
Some(idx) => &rest[idx + 1..],
None => rest,
};
let body = after_lang.trim_end();
if let Some(body_inner) = body.strip_suffix("```") {
return body_inner.trim().to_string();
}
return body.trim().to_string();
}
// 兜底:围栏不在开头或混杂前后文字 → 正则提取首个 JSON 对象CR-40-2
// (?s) 让 . 匹配换行,贪婪 {*} 取首 { 到末 },覆盖嵌套对象。
// 提取失败(无 { })则返回 trimmed 走原文 serde 报错降级,语义不变。
static JSON_RE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
let re = JSON_RE.get_or_init(|| regex::Regex::new(r"(?s)\{.*\}").expect("合法静态正则"));
match re.captures(trimmed) {
Some(c) => c.get(0).map(|m| m.as_str().to_string()).unwrap_or_else(|| trimmed.to_string()),
None => trimmed.to_string(),
}
}
/// 枚举字面量 → [`AssessmentLevel`]。区分大小写匹配 schema 文档约定。
fn parse_assessment_level(s: &str) -> Result<AssessmentLevel> {
match s.trim() {
"StrongGo" => Ok(AssessmentLevel::StrongGo),
"Recommended" => Ok(AssessmentLevel::Recommended),
"Conditional" => Ok(AssessmentLevel::Conditional),
"Revised" => Ok(AssessmentLevel::Revised),
"Defer" => Ok(AssessmentLevel::Defer),
other => anyhow::bail!(
"final_assessment 枚举值非法: {:?}(合法: StrongGo|Recommended|Conditional|Revised|Defer",
other
),
}
}
/// 枚举字面量 → [`Recommendation`]。区分大小写匹配 schema 文档约定。
fn parse_recommendation(s: &str) -> Result<Recommendation> {
match s.trim() {
"ImmediateAction" => Ok(Recommendation::ImmediateAction),
"Soon" => Ok(Recommendation::Soon),
"WithResources" => Ok(Recommendation::WithResources),
"ResearchMore" => Ok(Recommendation::ResearchMore),
"Monitor" => Ok(Recommendation::Monitor),
other => anyhow::bail!(
"recommendation 枚举值非法: {:?}(合法: ImmediateAction|Soon|WithResources|ResearchMore|Monitor",
other
),
}
}
fn priority_label(p: &Priority) -> &'static str {
match p {
Priority::Critical => "紧急",
Priority::High => "",
Priority::Medium => "",
Priority::Low => "",
}
}
fn assessment_desc(level: &AssessmentLevel) -> &'static str {
match level {
AssessmentLevel::StrongGo => "价值高且可行性强",
AssessmentLevel::Recommended => "整体值得推进",
AssessmentLevel::Conditional => "有条件地推进",
AssessmentLevel::Revised => "需调整后再评估",
AssessmentLevel::Defer => "建议暂缓",
}
}
fn action_hint(level: &AssessmentLevel) -> &'static str {
match level {
AssessmentLevel::StrongGo => "立即立项启动",
AssessmentLevel::Recommended => "尽快排期",
AssessmentLevel::Conditional => "配置资源后启动",
AssessmentLevel::Revised => "补充信息后重新评估",
AssessmentLevel::Defer => "持续观察时机",
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::capture::Idea;
use crate::scoring::ScoringEngine;
use df_types::types::{IdeaStatus, Priority};
fn make_idea(title: &str, desc: &str, priority: Priority, tags: Vec<&str>) -> Idea {
Idea {
id: "test-id".to_string(),
title: title.to_string(),
description: desc.to_string(),
status: IdeaStatus::Draft,
priority,
scores: None,
tags: tags.into_iter().map(String::from).collect(),
source: None,
related_ids: Vec::new(),
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
}
}
#[tokio::test]
async fn a1_high_score_immediate_action() {
let desc = "面向用户的核心功能,带来显著增长,大幅提升效率。集成成熟方案,复用已有组件。".repeat(3);
let idea = make_idea("AI增长引擎", &desc, Priority::Critical, vec!["增长", "核心"]);
let scores = ScoringEngine::compute_default(&idea);
let eval = AdversarialEngine::heuristic().evaluate(&idea).await.unwrap();
println!("\n[a1] 高分想法 → 期望 ImmediateAction");
println!(" scores: feas={:.2} impact={:.2} urg={:.2} overall={:.2}", scores.feasibility, scores.impact, scores.urgency, scores.overall);
println!(" eval: final_score={:.2} recommendation={:?}", eval.final_score, eval.recommendation);
println!(" 正方 confidence={:.2} 反方 confidence={:.2}", eval.positive.confidence, eval.negative.confidence);
assert!(eval.final_score >= 7.5, "final_score 应≥7.5, 实际 {:.2}", eval.final_score);
assert_eq!(eval.recommendation, Recommendation::ImmediateAction);
}
#[tokio::test]
async fn a2_mid_score_soon() {
let desc = "面向用户的功能,集成已有方案,提升体验".to_string();
let idea = make_idea("体验优化", &desc, Priority::Medium, vec!["体验"]);
let scores = ScoringEngine::compute_default(&idea);
let eval = AdversarialEngine::heuristic().evaluate(&idea).await.unwrap();
println!("\n[a2] 中分想法 → 期望 Soon");
println!(" scores overall={:.2} eval final_score={:.2} recommendation={:?}", scores.overall, eval.final_score, eval.recommendation);
assert_eq!(eval.recommendation, Recommendation::Soon);
}
#[tokio::test]
async fn a3_low_score_monitor() {
let desc = "重构迁移大规模分布式重写从零全新架构高并发底层".to_string();
let idea = make_idea("过度工程", &desc, Priority::Low, vec![]);
let scores = ScoringEngine::compute_default(&idea);
let eval = AdversarialEngine::heuristic().evaluate(&idea).await.unwrap();
println!("\n[a3] 低分想法 → 期望 Monitor");
println!(" scores overall={:.2} eval final_score={:.2} recommendation={:?}", scores.overall, eval.final_score, eval.recommendation);
assert!(eval.final_score < 3.0, "final_score 应<3.0, 实际 {:.2}", eval.final_score);
assert_eq!(eval.recommendation, Recommendation::Monitor);
}
#[tokio::test]
async fn a4_confidence_ranges() {
let idea = make_idea("普通想法", "一般描述", Priority::Medium, vec!["标签"]);
let eval = AdversarialEngine::heuristic().evaluate(&idea).await.unwrap();
println!("\n[a4] confidence 区间校验");
println!(" 正方={:.2} (应∈[0.1, 0.95]) 反方={:.2} (应∈[0.1, 0.9])", eval.positive.confidence, eval.negative.confidence);
assert!(eval.positive.confidence >= 0.1 && eval.positive.confidence <= 0.95);
assert!(eval.negative.confidence >= 0.1 && eval.negative.confidence <= 0.9);
}
#[tokio::test]
async fn a5_positive_thesis_contains_title() {
let idea = make_idea("独家创意", "描述内容", Priority::High, vec![]);
let eval = AdversarialEngine::heuristic().evaluate(&idea).await.unwrap();
println!("\n[a5] 正方论点含标题");
println!(" thesis: {}", eval.positive.thesis);
assert!(eval.positive.thesis.contains("独家创意"), "正方 thesis 应含标题");
}
#[tokio::test]
async fn a6_negative_evidence_nonempty() {
let idea = make_idea("待质疑想法", "", Priority::Low, vec![]);
let eval = AdversarialEngine::heuristic().evaluate(&idea).await.unwrap();
println!("\n[a6] 反方证据非空 ({} 条)", eval.negative.evidence.len());
for (i, e) in eval.negative.evidence.iter().enumerate() {
println!(" 证据{}: {}", i + 1, e);
}
assert!(!eval.negative.evidence.is_empty(), "反方 evidence 不应为空");
}
#[tokio::test]
async fn a7_final_score_consistency() {
let desc = "面向用户的核心功能".to_string();
let idea = make_idea("一致性测试", &desc, Priority::High, vec!["核心"]);
let scores = ScoringEngine::compute_default(&idea);
let eval = AdversarialEngine::heuristic().evaluate(&idea).await.unwrap();
println!("\n[a7] final_score == scores.overall 一致性");
println!(" scores.overall={:.2} eval.final_score={:.2}", scores.overall, eval.final_score);
println!(" analyst.summary: {}", eval.analyst.summary);
assert!((eval.final_score - scores.overall).abs() < 0.001, "final_score 应等于 overall");
assert!(eval.analyst.summary.contains("一致性测试"), "summary 应含标题");
}
// ────────────────────────────────────────────────────────────
// LLM 路径测试F-260614-03
// ────────────────────────────────────────────────────────────
/// mock LlmProvider按构造时给定的响应文本回放仅供 adversarial 单测。
///
/// `complete` 返回预设 `text`(或预设错误),`stream` 不被 adversarial 路径调用,
/// 返回 Err 即可。不引入 futures 依赖。
struct MockProvider {
text: String,
}
#[async_trait::async_trait]
impl LlmProvider for MockProvider {
async fn complete(
&self,
_request: df_ai_core::provider::CompletionRequest,
) -> anyhow::Result<df_ai_core::provider::CompletionResponse> {
Ok(df_ai_core::provider::CompletionResponse {
text: self.text.clone(),
model: "mock-model".to_string(),
usage: df_ai_core::provider::TokenUsage::default(),
tool_calls: None,
})
}
async fn stream(
&self,
_request: df_ai_core::provider::CompletionRequest,
) -> anyhow::Result<df_ai_core::provider::StreamResult> {
anyhow::bail!("MockProvider 不支持 streamadversarial 路径不调用)")
}
fn name(&self) -> &str {
"mock-model"
}
}
/// LLM mock 返回结构良好的 JSON → 解析正确evaluated_by=Llm
#[tokio::test]
async fn a8_llm_mock_parse_success() {
let idea = make_idea(
"AI 增长引擎",
"面向用户的核心功能,带来显著增长。集成成熟方案。",
Priority::Critical,
vec!["增长"],
);
// 一份对齐 schema 的合法 LLM 返回(含 ```json 围栏,验证容错)
let llm_text = r#"```json
{
"positive": {
"thesis": "该想法价值高,路径清晰,应立即推进",
"evidence": ["用户增长是当前关键路径", "已有成熟方案可复用"],
"reasoning": ["可行性高", "ROI 明显"],
"confidence": 0.9
},
"negative": {
"thesis": "资源投入与替代方案需权衡",
"evidence": ["机会成本存在"],
"reasoning": ["需验证更优解"],
"confidence": 0.35
},
"analyst": {
"summary": "综合双方,价值明确且路径清晰,建议立即推进",
"strengths": ["业务价值明确", "技术路径清晰"],
"weaknesses": ["资源需求待评估"],
"risks": ["需求范围蔓延"],
"opportunities": ["可形成可复用能力"],
"final_assessment": "StrongGo"
},
"final_score": 8.5,
"recommendation": "ImmediateAction"
}
```"#;
let provider = Arc::new(MockProvider { text: llm_text.to_string() });
let engine = AdversarialEngine::new(provider);
let eval = engine.evaluate(&idea).await.unwrap();
println!("\n[a8] LLM mock 成功路径");
println!(" evaluated_by = {:?}", eval.evaluated_by);
println!(" final_score = {:.2} recommendation = {:?}", eval.final_score, eval.recommendation);
println!(" positive.confidence = {:.2} negative.confidence = {:.2}",
eval.positive.confidence, eval.negative.confidence);
println!(" analyst.final_assessment = {:?}", eval.analyst.final_assessment);
assert_eq!(eval.evaluated_by, EvaluatedBy::Llm, "成功路径应为 Llm");
assert!((eval.final_score - 8.5).abs() < 1e-9);
assert_eq!(eval.recommendation, Recommendation::ImmediateAction);
assert_eq!(eval.analyst.final_assessment, AssessmentLevel::StrongGo);
assert!((eval.positive.confidence - 0.9).abs() < 1e-9);
assert!((eval.negative.confidence - 0.35).abs() < 1e-9);
assert_eq!(eval.positive.evidence.len(), 2);
assert!(eval.positive.thesis.contains("立即推进"));
}
/// LLM mock 返回非法 JSON → bail → 降级 HeuristicFallbackevaluated_by 标记)
#[tokio::test]
async fn a9_llm_mock_bad_json_fallback() {
let idea = make_idea("坏想法", "测试降级", Priority::Medium, vec![]);
let provider = Arc::new(MockProvider { text: "这不是 JSON".to_string() });
let engine = AdversarialEngine::new(provider);
let eval = engine.evaluate(&idea).await.unwrap();
println!("\n[a9] LLM mock 非 JSON → 降级");
println!(" evaluated_by = {:?} (期望 HeuristicFallback)", eval.evaluated_by);
assert_eq!(eval.evaluated_by, EvaluatedBy::HeuristicFallback, "非 JSON 应降级");
}
/// LLM mock 返回枚举非法值 → bail → 降级
#[tokio::test]
async fn a10_llm_mock_bad_enum_fallback() {
let idea = make_idea("枚举非法", "测试枚举映射", Priority::High, vec![]);
let llm_text = r#"{
"positive": {"thesis":"x","evidence":[],"reasoning":[],"confidence":0.5},
"negative": {"thesis":"y","evidence":[],"reasoning":[],"confidence":0.5},
"analyst": {"summary":"s","strengths":[],"weaknesses":[],"risks":[],"opportunities":[],"final_assessment":"GoNow"},
"final_score": 5.0,
"recommendation": "Soon"
}"#;
let provider = Arc::new(MockProvider { text: llm_text.to_string() });
let engine = AdversarialEngine::new(provider);
let eval = engine.evaluate(&idea).await.unwrap();
println!("\n[a10] LLM mock 枚举非法 → 降级");
println!(" evaluated_by = {:?} (期望 HeuristicFallback)", eval.evaluated_by);
assert_eq!(eval.evaluated_by, EvaluatedBy::HeuristicFallback);
}
/// 数值越界 → clampconfidence>1 / final_score>10应被截断不触发降级
#[tokio::test]
async fn a11_llm_mock_value_clamp() {
let idea = make_idea("越界测试", "数值超出区间", Priority::Medium, vec![]);
let llm_text = r#"{
"positive": {"thesis":"p","evidence":[],"reasoning":[],"confidence":1.5},
"negative": {"thesis":"n","evidence":[],"reasoning":[],"confidence":-0.3},
"analyst": {"summary":"s","strengths":[],"weaknesses":[],"risks":[],"opportunities":[],"final_assessment":"Recommended"},
"final_score": 99.0,
"recommendation": "Soon"
}"#;
let provider = Arc::new(MockProvider { text: llm_text.to_string() });
let engine = AdversarialEngine::new(provider);
let eval = engine.evaluate(&idea).await.unwrap();
println!("\n[a11] LLM mock 数值越界 clamp");
println!(" positive.confidence={:.2} (原 1.5 → 期望 1.0)", eval.positive.confidence);
println!(" negative.confidence={:.2} (原 -0.3 → 期望 0.0)", eval.negative.confidence);
println!(" final_score={:.2} (原 99 → 期望 10.0)", eval.final_score);
assert_eq!(eval.evaluated_by, EvaluatedBy::Llm, "仅越界不降级");
assert!((eval.positive.confidence - 1.0).abs() < 1e-9, "confidence>1 应 clamp 到 1.0");
assert!((eval.negative.confidence - 0.0).abs() < 1e-9, "confidence<0 应 clamp 到 0.0");
assert!((eval.final_score - 10.0).abs() < 1e-9, "final_score>10 应 clamp 到 10.0");
}
/// LLM 返回「前缀文字 + ```json + {...} + ```」(围栏不在开头)→ extract_json
/// 正则兜底提取首个 JSON 对象 → 解析成功不降级CR-40-2 兜底路径)。
#[tokio::test]
async fn a12_llm_mock_fenced_not_at_start() {
let idea = make_idea("围栏不在开头", "测试正则兜底", Priority::High, vec![]);
let llm_text = "好的,以下是我的评估:\n```json\n{\n \"positive\": {\"thesis\":\"p\",\"evidence\":[],\"reasoning\":[],\"confidence\":0.7},\n \"negative\": {\"thesis\":\"n\",\"evidence\":[],\"reasoning\":[],\"confidence\":0.4},\n \"analyst\": {\"summary\":\"s\",\"strengths\":[],\"weaknesses\":[],\"risks\":[],\"opportunities\":[],\"final_assessment\":\"Recommended\"},\n \"final_score\": 7.0,\n \"recommendation\": \"Soon\"\n}\n```\n希望对你有帮助。";
let provider = Arc::new(MockProvider { text: llm_text.to_string() });
let engine = AdversarialEngine::new(provider);
let eval = engine.evaluate(&idea).await.unwrap();
println!("\n[a12] 围栏不在开头 → 正则兜底提取");
println!(" evaluated_by = {:?} (期望 Llm不应降级)", eval.evaluated_by);
assert_eq!(eval.evaluated_by, EvaluatedBy::Llm, "围栏不在开头应正则兜底解析成功");
assert!((eval.final_score - 7.0).abs() < 1e-9);
assert_eq!(eval.recommendation, Recommendation::Soon);
}
}