新增: F-03对抗评估接LLM+F-02纯技能调用标题+i18n@转义

This commit is contained in:
2026-06-16 21:07:42 +08:00
parent 35b8eac46b
commit dfe0096498
9 changed files with 1248 additions and 23 deletions

View File

@@ -7,8 +7,9 @@
//! 评估来源由 [`EvaluatedBy`] 三态标记:`Llm`LLM 深度评估)/ `Heuristic`(主动选启发式,
//! 无 provider/ `HeuristicFallback`LLM 调用失败降级)。前端可据此显示评估深度标签。
//!
//! LLM prompt 构造与 JSON 解析在 F-260614-03(已由本任务解锁)接入,当前 `evaluate_with_llm`
//! 返回 Err 触发降级路径——机制完整,仅缺 prompt/解析实现。
//! LLM prompt 构造与 JSON 解析在 F-260614-03 接入:[`AdversarialEngine::evaluate_with_llm`]
//! 构造三角色辩论 prompt正方/反方/分析师),调一次 `complete()` 要求返回对齐结构的 JSON
//! 解析失败/字段缺失/枚举非法 → `bail` 触发降级([`AdversarialEngine::evaluate`] 已兜底)。
use std::sync::Arc;
@@ -133,10 +134,42 @@ impl AdversarialEngine {
/// LLM 对抗评估(注入 provider 后走此路)。
///
/// prompt 构造 + JSON 解析在 F-260614-03已由本任务解锁接入。当前返回 Err
/// 触发降级路径——降级机制与启发式评估路径完整,仅缺 LLM 调用实现。
async fn evaluate_with_llm(&self, _idea: &Idea, _provider: &Arc<dyn LlmProvider>) -> Result<AdversarialEval> {
anyhow::bail!("LLM 对抗评估尚未实现(F-260614-03)")
/// 构造三角色对抗式辩论 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);
let request = df_ai_core::provider::CompletionRequest {
// provider 自带 default_modelmodel 留空让 provider impl 回填自身默认模型。
// (OpenAICompatProvider::convert_request 在 req.model.is_empty() 时回退 default_model)
model: String::new(),
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}"))?;
parse_llm_eval(&resp.text, &idea.id).map(|mut eval| {
// LLM 路径 final_score 由分析师裁决给出,统一 clamp 到启发式同等量纲 [0,10]
eval.final_score = eval.final_score.clamp(0.0, 10.0);
// confidence clamp [0,1]parse_llm_eval 已对各 Argument 做过,此处为结构兜底)
eval.positive.confidence = eval.positive.confidence.clamp(0.0, 1.0);
eval.negative.confidence = eval.negative.confidence.clamp(0.0, 1.0);
eval
})
}
/// 启发式评估(基于评分与内容信号,稳定有区分度)
@@ -320,6 +353,243 @@ 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<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,
// clamp 由调用方 evaluate_with_llm 再做一次结构兜底
final_score: raw.final_score.clamp(0.0, 10.0),
recommendation,
// 由 evaluate() 按 LLM 调度路径覆盖为 EvaluatedBy::Llm
evaluated_by: EvaluatedBy::Llm,
})
}
/// 从 LLM 返回文本中提取 JSON 主体。
///
/// 优先按代码围栏提取;无围栏时整段去首尾空白后原样返回。整段若非合法 JSON
/// 由 [`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();
}
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 => "紧急",
@@ -451,5 +721,159 @@ mod tests {
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");
}
}