//! 对抗评估 — 纯函数 / 类型 / 常量(自 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, // 证据支持 pub reasoning: Vec, // 推理过程 pub confidence: f64, // 置信度 0-1 } /// AI 分析师综合分析 #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AnalystAnalysis { pub summary: String, // 综合总结 pub strengths: Vec, // 主要优势 pub weaknesses: Vec, // 主要劣势 pub risks: Vec, // 潜在风险 pub opportunities: Vec, // 机会点 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, #[serde(default)] pub(crate) reasoning: Vec, #[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, #[serde(default)] pub(crate) weaknesses: Vec, #[serde(default)] pub(crate) risks: Vec, #[serde(default)] pub(crate) opportunities: Vec, 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. 数值 clamp:confidence ∈ [0,1],final_score ∈ [0,10]。 pub(crate) fn parse_llm_eval(text: &str, idea_id: &str) -> Result { 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 降级。 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 = 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 { 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 { 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 => "持续观察时机", } }