From 356c8f27f814240daef1dfadd9ceb1046a49ea8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Fri, 19 Jun 2026 11:45:26 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=9E=84:=20=E6=8B=86adversarial?= =?UTF-8?q?=E5=AF=B9=E6=8A=97=E8=AF=84=E4=BC=B0(strategy=E6=A0=B8=E5=BF=83?= =?UTF-8?q?=E5=BA=93)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新建 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/*) --- crates/df-ideas/src/adversarial.rs | 374 ++------------------- crates/df-ideas/src/adversarial_helpers.rs | 366 ++++++++++++++++++++ crates/df-ideas/src/lib.rs | 1 + 3 files changed, 387 insertions(+), 354 deletions(-) create mode 100644 crates/df-ideas/src/adversarial_helpers.rs diff --git a/crates/df-ideas/src/adversarial.rs b/crates/df-ideas/src/adversarial.rs index f08b9ec..86ab69e 100644 --- a/crates/df-ideas/src/adversarial.rs +++ b/crates/df-ideas/src/adversarial.rs @@ -10,86 +10,32 @@ //! LLM prompt 构造与 JSON 解析在 F-260614-03 接入:[`AdversarialEngine::evaluate_with_llm`] //! 构造三角色辩论 prompt(正方/反方/分析师),调一次 `complete()` 要求返回对齐结构的 JSON, //! 解析失败/字段缺失/枚举非法 → `bail` 触发降级([`AdversarialEngine::evaluate`] 已兜底)。 +//! +//! 重构(strategy·自底向上):类型定义 / 常量 / prompt 构造 / JSON 解析等无副作用逻辑抽至 +//! [`adversarial_helpers`],本文件只保留 [`AdversarialEngine`](有状态引擎 + evaluate 主入口, +//! 含 ARC-260618-01-e evaluate_with_llm 一致性待决策逻辑,原样保留)。外部已用路径( +//! `df_ideas::adversarial::{AdversarialEval, Recommendation, …}`)经 `pub use` 不变。 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 df_types::types::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, // 证据支持 - 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, // 持续监控 -} +// 类型 / 常量 / 纯函数自 adversarial_helpers 引回,并 re-export:外部调用方经 +// `df_ideas::adversarial::{...}` 路径访问的类型零变更(pub use 同时将符号带入当前 +// 命名空间供 impl/tests 使用,无需额外 use)。AdversarialEngine 本身定义在本文件。 +pub use crate::adversarial_helpers::{ + AdversarialEval, AnalystAnalysis, Argument, AssessmentLevel, EvaluatedBy, Recommendation, +}; +use crate::adversarial_helpers::{ + SYSTEM_PROMPT, action_hint, assessment_desc, build_adversarial_prompt, parse_llm_eval, + priority_label, +}; /// 对抗评估引擎 pub struct AdversarialEngine { @@ -146,10 +92,13 @@ impl AdversarialEngine { /// /// 构造三角色对抗式辩论 prompt(正方/反方/分析师),调一次 `complete()`,要求 LLM /// 返回对齐 [`AdversarialEval`] 结构的 JSON。任一环节失败(HTTP / 非 JSON / 字段 - /// 缺失 / 枚举非法 / 数值越界无法修正)→ `bail` 由 [`evaluate`] 捕获降级启发式。 + /// 缺失 / 枚举非法 / 数值越界无法修正)→ `bail` 由 [`Self::evaluate`] 捕获降级启发式。 /// /// temperature 取 0.4:低于 0.3 偏机械重复启发式信号,高于 0.5 易发散到无关风险, /// 0.4 在「稳定可复现」与「论点多样性」间取得平衡。 + /// + /// ARC-260618-01-e: evaluate_with_llm 返回值一致性未校验(final_score 与 + /// analyst.final_assessment 自洽性等),待产品决策,当前逻辑原样保留不调整。 async fn evaluate_with_llm(&self, idea: &Idea, provider: &Arc) -> Result { let prompt = build_adversarial_prompt(idea); // F-01 阶段5: 智能路由 — 对抗评估 TaskRequirements(Standard,无工具)。 @@ -368,288 +317,6 @@ impl AdversarialEngine { } } -// ============================================================ -// 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, - #[serde(default)] - reasoning: Vec, - #[serde(default)] - confidence: f64, -} - -#[derive(Debug, Deserialize)] -struct AnalystRaw { - #[serde(default)] - summary: String, - #[serde(default)] - strengths: Vec, - #[serde(default)] - weaknesses: Vec, - #[serde(default)] - risks: Vec, - #[serde(default)] - opportunities: Vec, - 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 { - 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(); - } - // 围栏闭合缺失(如 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 文档约定。 -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 文档约定。 -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 - ), - } -} - -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::*; @@ -927,4 +594,3 @@ mod tests { assert_eq!(eval.recommendation, Recommendation::Soon); } } - diff --git a/crates/df-ideas/src/adversarial_helpers.rs b/crates/df-ideas/src/adversarial_helpers.rs new file mode 100644 index 0000000..ffecde2 --- /dev/null +++ b/crates/df-ideas/src/adversarial_helpers.rs @@ -0,0 +1,366 @@ +//! 对抗评估 — 纯函数 / 类型 / 常量(自 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 => "持续观察时机", + } +} diff --git a/crates/df-ideas/src/lib.rs b/crates/df-ideas/src/lib.rs index c5ab2cc..20e706e 100644 --- a/crates/df-ideas/src/lib.rs +++ b/crates/df-ideas/src/lib.rs @@ -1,6 +1,7 @@ //! df-ideas: 想法池 — 捕获、评估、评分、晋升 pub mod adversarial; +mod adversarial_helpers; pub mod capture; pub mod promotion; pub mod scoring;