diff --git a/crates/df-ai/src/openai_helpers.rs b/crates/df-ai/src/openai_helpers.rs index 5509a95..d0d2019 100644 --- a/crates/df-ai/src/openai_helpers.rs +++ b/crates/df-ai/src/openai_helpers.rs @@ -65,11 +65,30 @@ pub(crate) struct OpenAiResponse { } #[derive(Debug, Deserialize)] -pub(crate) struct OpenAiChoice { - pub message: OpenAiMessageResp, - // OpenAI 响应反序列化字段,保留以备调试/未来消费(如日志记录调用终止原因),标注意图消除 dead_code warning - #[allow(dead_code)] - pub finish_reason: Option, +pub struct OpenAiChoice { + pub(crate) message: OpenAiMessageResp, + /// 调用终止原因。`length` = 输出被 max_tokens 截断(不完整),由 [`FinishReason`] + /// 的 Drop 侧效应打 warn 留痕 — 此前该字段无人读、截断不可见,截断的 JSON 会被 + /// 下游当解析失败整批丢弃而无任何日志线索。 + #[serde(default)] + pub finish_reason: FinishReason, +} + +/// finish_reason 载体(透明 NewType)。 +/// +/// 消费方只取 message,本字段值随 choice 整体 drop 时触发截断告警;对外公开 +/// (pub 模块下的 pub 结构属库 API 面)同时免去字段 dead_code 豁免。 +/// serde 透明,线上 JSON 形态与 Option 一致。 +#[derive(Debug, Default, Deserialize)] +#[serde(transparent)] +pub struct FinishReason(Option); + +impl Drop for FinishReason { + fn drop(&mut self) { + if self.0.as_deref() == Some("length") { + tracing::warn!("[OpenAI] 同步响应因 max_tokens 截断(finish_reason=length),输出不完整"); + } + } } #[derive(Debug, Deserialize)] diff --git a/src-tauri/src/commands/ai/knowledge_inject.rs b/src-tauri/src/commands/ai/knowledge_inject.rs index 09876c7..b37e165 100644 --- a/src-tauri/src/commands/ai/knowledge_inject.rs +++ b/src-tauri/src/commands/ai/knowledge_inject.rs @@ -40,7 +40,7 @@ const KNOWLEDGE_QUERY_MIN_ASCII_LEN: usize = 2; /// 自动提炼窗口:取最近 N 条 user/assistant 消息送 LLM 提炼 /// /// 原 6 条窗口过窄,长会话后半程的经验不在提炼视野内;40 条覆盖近期完整上下文, -/// 同时不撑爆提炼调用的 token 预算(max_tokens 2048 的输出侧不受影响,输入侧由 +/// 同时不撑爆提炼调用的 token 预算(max_tokens 4096 的输出侧不受影响,输入侧由 /// 提炼模型上下文窗口兜底)。 const KNOWLEDGE_EXTRACT_WINDOW: usize = 40; /// 自动提炼频次:距上次提炼至少新增 N 条用户消息才再提炼 @@ -733,28 +733,42 @@ pub(crate) async fn maybe_spawn_extraction( "[knowledge] 提炼成功,水位记为当前进度,新增用户消息达阈值后才再提炼" ); } else { - // 0 条:清哨兵 + 回退水位,允许下次自动触发重试(对话可能后续补充了有价值内容)。 + // 0 条:清哨兵回滚预置位;水位记 Done 保节奏(原回退水位会让下一轮对话结束 + // 立即重提炼,同窗口反复失败时形成逐轮风暴;记进度后须再攒够阈值条新用户消息)。 let mut session = session_arc.lock().await; + let rounds = session + .conv_read(&conv_id) + .map(|c| count_user_rounds(c.messages.iter())) + .unwrap_or(0); session.conv(&conv_id).knowledge_extracted = false; EXTRACT_WATERMARKS .lock() .expect("提炼水位互斥锁中毒") - .remove(&conv_id); + .insert(conv_id.clone(), ExtractWatermark::Done(rounds)); tracing::info!( - conv_id = %conv_id, - "[knowledge] 提炼 0 条(无可提炼内容),清哨兵与水位允许下次自动重试" + conv_id = %conv_id, rounds, + "[knowledge] 提炼 0 条(无可提炼内容),水位记为当前进度,新增用户消息达阈值后才再试" ); } } Err(e) => { - // 失败:清哨兵 + 回退水位,允许下次触发重试(预置位已在,须回滚否则会话被锁死)。 + // 失败:清哨兵回滚预置位;水位同样记 Done 保节奏(原回退水位在持续故障 + // (截断/网络/密钥)时逐轮重试刷爆;达阈值后才再试,手动按钮仍可立即重提炼)。 tracing::warn!("知识提取失败(非阻断): {}", e); let mut session = session_arc.lock().await; + let rounds = session + .conv_read(&conv_id) + .map(|c| count_user_rounds(c.messages.iter())) + .unwrap_or(0); session.conv(&conv_id).knowledge_extracted = false; EXTRACT_WATERMARKS .lock() .expect("提炼水位互斥锁中毒") - .remove(&conv_id); + .insert(conv_id.clone(), ExtractWatermark::Done(rounds)); + tracing::info!( + conv_id = %conv_id, rounds, + "[knowledge] 提炼失败,水位记为当前进度,新增用户消息达阈值后才再试" + ); } } }); @@ -821,7 +835,8 @@ const EXTRACTION_SYSTEM_PROMPT: &str = "你是知识提炼引擎,从 AI 对话 1. 如果适用范围有限制(如仅适用特定语言/框架/场景),必须在 content 或 tags 中明确标注\n\ 2. confidence: high=对话中可直接观察的明确模式, medium=合理推断, low=推测性弱信号\n\ 3. 无可提炼内容时返回空数组 []\n\ -4. 输出纯 JSON,无任何额外文字"; +4. 输出纯 JSON,无任何额外文字\n\ +5. 最多提取 5 条,按复用价值降序排列(只保留高价值条目,宁缺毋滥)"; /// 从对话中提炼知识,产出 candidate 写入知识库 /// @@ -947,7 +962,7 @@ async fn extract_knowledge_from_conversation( model: extract_model, messages: extract_messages, temperature: Some(0.3), - max_tokens: Some(2048), + max_tokens: Some(4096), stream: false, tools: None, tool_choice: None, @@ -970,10 +985,28 @@ async fn extract_knowledge_from_conversation( let json_str = strip_code_fence(raw); let items: Vec = match serde_json::from_str(json_str) { Ok(v) => v, - Err(e) => { - tracing::warn!("知识提炼 JSON 解析失败,整批丢弃(非阻断): {} | 原始: {}", e, raw); - return Ok(0); - } + Err(e) => match salvage_truncated_json_array(json_str) { + // 截断打捞:max_tokens 截断常把数组拦腰砍断,先救回已完整闭合的条目 + Some(salvaged) if !salvaged.is_empty() => { + tracing::warn!("知识提炼截断打捞: 救回 {} 条,丢弃尾部残条", salvaged.len()); + salvaged + } + // 打捞后 0 条才整批丢弃;空输出(content 空/null)与解析失败单独留痕区分 + _ => { + if raw.is_empty() { + tracing::warn!( + "知识提炼输出为空(content 空或 null),整批丢弃, completion_tokens={}", + resp.usage.completion_tokens + ); + } else { + tracing::warn!( + "知识提炼 JSON 解析失败且打捞无果,整批丢弃(非阻断): {} | completion_tokens={} | 原始: {}", + e, resp.usage.completion_tokens, raw + ); + } + return Ok(0); + } + }, }; let knowledge_repo = KnowledgeRepo::new(db); @@ -1058,6 +1091,53 @@ fn strip_code_fence(s: &str) -> &str { s } +/// 截断 JSON 打捞 — 从被截断的数组输出中救回已完整闭合的顶层条目。 +/// +/// 背景:提炼输出被 max_tokens 拦腰截断(`[{"a":1},{"b":` )时 serde 整批报错, +/// 此前整批丢弃。扫描跳过字符串字面量内部(含 `\"` 转义)的括号,记录数组元素层 +/// (depth 归 1)最后一次 `}` 的位置 — 等价于"从尾向前扫到最后一个完整顶层 `}`" +/// (引号开闭与转义只有正向扫描可可靠判定,正向记录末次闭合与反向扫描结果一致)。 +/// 截到该位补 `]` 重解析,serde 通过且非空才由调用方入库;空串/纯垃圾/无完整条目 +/// 返回 None(调用方走原丢弃路径)。 +fn salvage_truncated_json_array(raw: &str) -> Option> { + let bytes = raw.as_bytes(); + let mut in_string = false; + let mut escaped = false; + let mut depth: i64 = 0; + let mut last_elem_close: Option = None; + for (i, &b) in bytes.iter().enumerate() { + if in_string { + if escaped { + escaped = false; // 转义字符本身,不参与状态判定 + } else if b == b'\\' { + escaped = true; + } else if b == b'"' { + in_string = false; + } + continue; + } + match b { + b'"' => in_string = true, + b'{' | b'[' => depth += 1, + b'}' | b']' => { + depth -= 1; + if depth == 1 && b == b'}' { + // 数组元素层闭合:最后一个可打捞条目的右边界 + last_elem_close = Some(i); + } + if depth <= 0 { + // 顶层 `]`(完整数组)或深度为负(垃圾前缀):数组层已到界,停止 + break; + } + } + _ => {} + } + } + let cut = last_elem_close?; + let candidate = format!("{}]", &raw[..=cut]); + serde_json::from_str(&candidate).ok() +} + #[cfg(test)] mod tests { use super::*; @@ -1271,3 +1351,46 @@ mod tests { assert!(out.contains(&"失败".to_string())); } } + +#[cfg(test)] +mod salvage_tests { + use super::*; + + /// 截断点在第二个元素中段:已完整闭合的首条目应救回 + #[test] + fn salvages_complete_prefix_of_truncated_array() { + let raw = r#"[{"kind":"pitfall","title":"t1","content":"c1"},{"kind":"review_r"#; + let out = salvage_truncated_json_array(raw).expect("应打捞出 1 条"); + assert_eq!(out.len(), 1); + assert_eq!(out[0]["title"], "t1"); + } + + /// 括号与引号出现在字符串字面量内(含转义):不得误判为结构边界 + #[test] + fn salvage_respects_string_literals_and_escapes() { + let raw = r#"[{"title":"含}花括号与\"引号","content":"a\\"},{"kind":"pit"#; + let out = salvage_truncated_json_array(raw).expect("应打捞出 1 条"); + assert_eq!(out.len(), 1); + assert_eq!(out[0]["title"], "含}花括号与\"引号"); + assert_eq!(out[0]["content"], "a\\"); + } + + /// 完整数组 + 尾部脏字符:全部条目救回(等价重解析) + #[test] + fn complete_array_with_trailing_garbage_salvages_all() { + let raw = r#"[{"kind":"pitfall","title":"t1"},{"kind":"diagnosis","title":"t2"}] 附带文字"#; + let out = salvage_truncated_json_array(raw).expect("应打捞 2 条"); + assert_eq!(out.len(), 2); + assert_eq!(out[1]["title"], "t2"); + } + + /// 垃圾/空输出不误收:返回 None 走原丢弃路径 + #[test] + fn garbage_or_empty_yields_none() { + assert!(salvage_truncated_json_array("").is_none(), "空输出无物可打捞"); + assert!(salvage_truncated_json_array("模型输出了一段散文,没有 JSON").is_none()); + assert!(salvage_truncated_json_array(r#"}{"a":1}"#).is_none(), "垃圾前缀深度为负"); + assert!(salvage_truncated_json_array(r#"{"kind":"pitfall""#).is_none(), "无完整闭合元素"); + assert!(salvage_truncated_json_array("[").is_none()); + } +}