diff --git a/Cargo.lock b/Cargo.lock index 50db673..35a831d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -805,6 +805,7 @@ dependencies = [ "chrono", "df-ai-core", "df-core", + "regex", "serde", "serde_json", "tokio", diff --git a/Cargo.toml b/Cargo.toml index db4a179..06bdb00 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,3 +16,4 @@ chrono = { version = "0.4", features = ["serde"] } uuid = { version = "1", features = ["v4"] } rusqlite = { version = "0.31", features = ["bundled"] } tracing = "0.1" +regex = "1" diff --git a/crates/df-ideas/Cargo.toml b/crates/df-ideas/Cargo.toml index 69a6828..7304c28 100644 --- a/crates/df-ideas/Cargo.toml +++ b/crates/df-ideas/Cargo.toml @@ -13,3 +13,4 @@ async-trait = { workspace = true } anyhow = { workspace = true } chrono = { workspace = true } tracing = { workspace = true } +regex = { workspace = true } diff --git a/crates/df-ideas/src/adversarial.rs b/crates/df-ideas/src/adversarial.rs index c534b36..ddf57f8 100644 --- a/crates/df-ideas/src/adversarial.rs +++ b/crates/df-ideas/src/adversarial.rs @@ -162,14 +162,9 @@ impl AdversarialEngine { .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 - }) + // 数值 clamp 由 parse_llm_eval 单点收口(final_score∈[0,10]、confidence∈[0,1], + // 均在 parse 内对所有 Ok 路径完成),此处不再重复 clamp(CR-40-1 去冗余)。 + parse_llm_eval(&resp.text, &idea.id) } /// 启发式评估(基于评分与内容信号,稳定有区分度) @@ -530,7 +525,7 @@ fn parse_llm_eval(text: &str, idea_id: &str) -> Result { positive, negative, analyst, - // clamp 由调用方 evaluate_with_llm 再做一次结构兜底 + // 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 @@ -540,11 +535,16 @@ fn parse_llm_eval(text: &str, idea_id: &str) -> Result { /// 从 LLM 返回文本中提取 JSON 主体。 /// -/// 优先按代码围栏提取;无围栏时整段去首尾空白后原样返回。整段若非合法 JSON -/// 由 [`parse_llm_eval`] 的 serde 步骤报错 bail。 +/// 提取优先级: +/// 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 ... ``` 或 ``` ... ``` 围栏 + // 快路径:围栏在开头(```json ... ``` 或 ``` ... ```) if let Some(rest) = trimmed.strip_prefix("```") { // 跳过语言标记(json/JSON 等)到首个换行 let after_lang = match rest.find('\n') { @@ -557,7 +557,15 @@ fn extract_json(text: &str) -> String { } return body.trim().to_string(); } - trimmed.to_string() + // 兜底:围栏不在开头或混杂前后文字 → 正则提取首个 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 文档约定。 @@ -875,5 +883,22 @@ mod tests { 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); + } } diff --git a/src-tauri/src/commands/task.rs b/src-tauri/src/commands/task.rs index 56cb644..72ff7f0 100644 --- a/src-tauri/src/commands/task.rs +++ b/src-tauri/src/commands/task.rs @@ -97,8 +97,13 @@ pub async fn update_task( field: String, value: String, ) -> Result { - // 字段名注入由 df-storage 白名单兜底;这里补 status / priority 值校验, - // 拦截拼写错误(in-progess / "in progress" / 大小写错)与越界数值(999 / "abc")静默落库。 + // status 值校验保留:仅对「非法值」(拼写错 in-progess / "in progress" / 大小写错 / 越界) + // 给出友好早错误(先于 crud.rs 白名单那串冷冰冰的「字段不在白名单」拒)。合法 status 值 + // 不可经本 IPC 写入——crud.rs tasks 白名单已收口移除 status(F-03 batch64 b94e74a / + // D-260616-04),所有 status 改动须走 advance_task_atomic 状态机(CAS + can_transition + + // review_rounds 累加,唯一 status 写入路径)。即此 is_valid 校验的「通过」分支永不会触达 + // update_field 的 status 写入(白名单会先拒);它只为非法值兜底 UX,不承担合法值写入职责。 + // priority 值校验同理补在下方:拦截 "abc" / 999 等脏数据静默落库。 if field == "status" && !TaskStatus::is_valid(&value) { return Err(format!( "非法 status 值 {:?},合法值: {:?}",