Files
DevFlow/crates/df-ideas/src/adversarial_helpers.rs
绝尘 356c8f27f8 重构: 拆adversarial对抗评估(strategy核心库)
- 新建 df-ideas/adversarial_helpers.rs(366行): 6类型(EvaluatedBy/AdversarialEval/Argument/AnalystAnalysis/AssessmentLevel/Recommendation) + SYSTEM_PROMPT + 8纯fn(build_adversarial_prompt/parse_llm_eval/extract_json/parse_assessment_level/parse_recommendation/priority_label/assessment_desc/action_hint) + LlmEvalRaw中间结构
- adversarial.rs 930→596: AdversarialEngine impl保留 + pub use helpers(外部idea.rs路径零变更)
- lib.rs: mod adversarial_helpers(私有)
- ARC-260618-01-e(evaluate_with_llm一致性)逻辑保留(待决策, doc标注)
主代兜底(独立 -p df-ideas 避 df-ai): cargo 0 + test 20
strategy: 核心库, 纯函数/类型抽离, evaluate主入口+ARC-e保留
git add指定(df-ideas/*)
2026-06-19 11:45:26 +08:00

367 lines
15 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.
//! 对抗评估 — 纯函数 / 类型 / 常量(自 adversarial.rs 抽离,纯搬迁,零行为变更)。
//!
//! 本文件承载 [`AdversarialEval`] 及其字段类型、LLM prompt 构造与 JSON 解析等无副作用
//! 逻辑;有状态的引擎 [`AdversarialEngine`](含 evaluate / evaluate_with_llm 主入口与
//! ARC-260618-01-e 待决策逻辑)仍留在 `adversarial.rs`,二者经 [`use`] 互相引用。
//!
//! 抽离边界:类型定义(非 impl+ 常量 + 顶层 free function。impl 主体不动,外部
//! 已用路径(`df_ideas::adversarial::{AdversarialEngine, AdversarialEval, Recommendation}` 等)
//! 由 adversarial.rs 通过 `pub use` 重新导出,调用方零变更。
use anyhow::Result;
use serde::{Deserialize, Serialize};
use df_types::types::{IdeaId, Priority};
use crate::capture::Idea;
/// 评估来源标记
///
/// `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, // 持续监控
}
// ============================================================
// LLM 对抗评估 — prompt 构造 / JSON 解析F-260614-03
// ============================================================
/// LLM 角色 / 输出契约的系统级约束。
///
/// 放为 const 便于在 prompt 头部注入,与 [`build_adversarial_prompt`] 的「想法上下文」
/// 分离:角色设定稳定不变,想法上下文按评估对象动态拼。
pub(crate) 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 单轮产出可解析的结构化评估。
pub(crate) 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 字段省略——由 [`AdversarialEngine::evaluate`] 调用方覆盖)。
///
/// 采用独立中间结构而非直接 serde 到 [`AdversarialEval`]:便于在枚举非法时给出
/// 字段级错误信息(`final_assessment: "GoNow" 不在合法集合`),而非整条记录丢弃;
/// 同时留出数值 clamp 的统一收口。
#[derive(Debug, Deserialize)]
pub(crate) struct LlmEvalRaw {
pub(crate) positive: ArgumentRaw,
pub(crate) negative: ArgumentRaw,
pub(crate) analyst: AnalystRaw,
pub(crate) final_score: f64,
pub(crate) recommendation: String,
}
#[derive(Debug, Deserialize)]
pub(crate) struct ArgumentRaw {
pub(crate) thesis: String,
#[serde(default)]
pub(crate) evidence: Vec<String>,
#[serde(default)]
pub(crate) reasoning: Vec<String>,
#[serde(default)]
pub(crate) confidence: f64,
}
#[derive(Debug, Deserialize)]
pub(crate) struct AnalystRaw {
#[serde(default)]
pub(crate) summary: String,
#[serde(default)]
pub(crate) strengths: Vec<String>,
#[serde(default)]
pub(crate) weaknesses: Vec<String>,
#[serde(default)]
pub(crate) risks: Vec<String>,
#[serde(default)]
pub(crate) opportunities: Vec<String>,
pub(crate) final_assessment: String,
}
/// 解析 LLM 返回文本为 [`AdversarialEval`]。
///
/// 容错策略(任一失败 `bail` → 由 [`AdversarialEngine::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]。
pub(crate) 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 降级。
pub(crate) 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();
}
// 围栏闭合缺失(如 JSON 与 ``` 同行 / 闭合后仍有文字 / 多行 JSON 末尾混噪声):
// 快路径无法干净剥离 → 落到正则兜底提取首个 JSON 对象,提升命中率。
// (原实现在此处 return body.trim() 会把语言标记/尾随文字一起喂 serde,
// 必然失败降级启发式,即便正则本可救回。)
}
// 兜底:围栏不在开头或混杂前后文字 → 正则提取首个 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 文档约定。
pub(crate) 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 文档约定。
pub(crate) 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
),
}
}
pub(crate) fn priority_label(p: &Priority) -> &'static str {
match p {
Priority::Critical => "紧急",
Priority::High => "",
Priority::Medium => "",
Priority::Low => "",
}
}
pub(crate) fn assessment_desc(level: &AssessmentLevel) -> &'static str {
match level {
AssessmentLevel::StrongGo => "价值高且可行性强",
AssessmentLevel::Recommended => "整体值得推进",
AssessmentLevel::Conditional => "有条件地推进",
AssessmentLevel::Revised => "需调整后再评估",
AssessmentLevel::Defer => "建议暂缓",
}
}
pub(crate) fn action_hint(level: &AssessmentLevel) -> &'static str {
match level {
AssessmentLevel::StrongGo => "立即立项启动",
AssessmentLevel::Recommended => "尽快排期",
AssessmentLevel::Conditional => "配置资源后启动",
AssessmentLevel::Revised => "补充信息后重新评估",
AssessmentLevel::Defer => "持续观察时机",
}
}