926 lines
41 KiB
Rust
926 lines
41 KiB
Rust
//! 对抗式评估系统 — 正反方辩论 + 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 路径完成),此处不再重复 clamp(CR-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. 数值 clamp:confidence ∈ [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 不再重复 clamp,CR-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 不支持 stream(adversarial 路径不调用)")
|
||
}
|
||
|
||
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 → 降级 HeuristicFallback(evaluated_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);
|
||
}
|
||
|
||
/// 数值越界 → clamp(confidence>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);
|
||
}
|
||
}
|
||
|