From c8c13d302abab460994c5cc7fe765685dc305053 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Sun, 4 Oct 2026 22:30:27 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E:=20LLM=E7=9B=AE=E6=A0=87?= =?UTF-8?q?=E5=88=97=E8=A1=A8=E4=B8=BB=E5=8A=A8=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/commands/ai/agentic/helpers.rs | 131 +++++++++++++++++++ src-tauri/src/commands/ai/agentic/mod.rs | 28 +++- 2 files changed, 157 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/commands/ai/agentic/helpers.rs b/src-tauri/src/commands/ai/agentic/helpers.rs index b3df3ea..68cdef3 100644 --- a/src-tauri/src/commands/ai/agentic/helpers.rs +++ b/src-tauri/src/commands/ai/agentic/helpers.rs @@ -466,10 +466,141 @@ pub(crate) fn is_tool_call_over_limit(count: usize) -> bool { count > super::MAX_TOOL_CALLS_PER_ROUND } +// ── UPDATE_GOALS:LLM 主动修正目标列表(目标维护闭环,2026-10-04) ── +// +// 动机(源:todo.md「AI Chat 体验优化 2026-07-03 批次」暂缓项):G1 目标钉扎的提取源是 +// 工具调用推理(infer_goal_from_tool_calls),只能"看做过的"推目标;当用户中途补充信息 +// 纠偏/意图递进时,之前推理的目标可能已偏,LLM 无法主动修正。本标记给 LLM 一个 +// 显式通道:输出 [UPDATE_GOALS] 块 → 后端**替换** pinned_goals(区别于工具推理的追加) +// → 块从正文剥离(用户看到的是干净回复)。 +// +// 格式(逐行解析,宽松容错): +// [UPDATE_GOALS] +// 1: 完成登录接口 +// 2: 写单测 (completed) +// [END] +// 状态默认 active;行尾 "(completed)" 标记完成。"1:" 编号仅助 LLM 对齐,解析忽略。 + +/// 标记起止串(子串匹配;宽松容忍缩进由行级 trim 处理)。 +pub(crate) const UPDATE_GOALS_OPEN: &str = "[UPDATE_GOALS]"; +pub(crate) const UPDATE_GOALS_CLOSE: &str = "[END]"; + +/// 解析 [UPDATE_GOALS] 块:返回 (目标列表(text, active), 剥离标记后的正文)。 +/// +/// - 无开标记 → None(正文原样,零开销快路径) +/// - 有开无闭 → None(残块不解析,防半截输出误替换;正文原样保留——下一轮 LLM 重发) +/// - 目标 0 条 → None(空块视为噪声,不替空用户目标) +/// - 超 MAX_GOALS 截断(与工具推理提取同上限) +pub(crate) fn extract_update_goals_block(full_text: &str) -> Option<(Vec<(String, bool)>, String)> { + let open_idx = full_text.find(UPDATE_GOALS_OPEN)?; + let after_open = &full_text[open_idx + UPDATE_GOALS_OPEN.len()..]; + let close_rel = after_open.find(UPDATE_GOALS_CLOSE)?; + let block_body = &after_open[..close_rel]; + let cleaned = format!( + "{}{}", + &full_text[..open_idx], + &after_open[close_rel + UPDATE_GOALS_CLOSE.len()..], + ); + + let mut goals: Vec<(String, bool)> = Vec::new(); + for line in block_body.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + // 去掉 "1:" 式编号前缀(编号+冒号,编号须全数字;普通文本含冒号不受影响) + let content = trimmed + .split_once(':') + .filter(|(head, _)| { + let h = head.trim(); + !h.is_empty() && h.chars().all(|c| c.is_ascii_digit()) + }) + .map(|(_, tail)| tail.trim()) + .unwrap_or(trimmed); + if content.is_empty() { + continue; + } + // 行尾 "(completed)" → 已完成;其余默认 active(大小写不敏感,宽松容错) + let (text, active) = match content.strip_suffix(')').and_then(|c| c.rsplit_once('(')) { + Some((t, tag)) if tag.trim().eq_ignore_ascii_case("completed") => (t.trim(), false), + _ => (content, true), + }; + if !text.is_empty() { + goals.push((text.to_string(), active)); + } + } + if goals.is_empty() { + return None; + } + goals.truncate(super::MAX_GOALS); + Some((goals, cleaned)) +} + #[cfg(test)] mod tests { use super::*; + // ── extract_update_goals_block 测试(UPDATE_GOALS 目标修正闭环) ── + + #[test] + fn update_goals_full_block_parsed_and_stripped() { + let text = "收到,我来更新目标列表。\n[UPDATE_GOALS]\n1: 完成登录接口\n2: 写单测 (completed)\n[END]\n继续推进。"; + let (goals, cleaned) = extract_update_goals_block(text).unwrap(); + assert_eq!(goals.len(), 2); + assert_eq!(goals[0], ("完成登录接口".to_string(), true)); + assert_eq!(goals[1], ("写单测".to_string(), false)); + // 标记与块内容剥离,前后正文保留 + assert!(cleaned.contains("收到,我来更新目标列表。")); + assert!(cleaned.contains("继续推进。")); + assert!(!cleaned.contains("UPDATE_GOALS")); + assert!(!cleaned.contains("完成登录接口")); + } + + #[test] + fn update_goals_none_when_no_marker() { + assert!(extract_update_goals_block("普通回复,无标记").is_none()); + assert!(extract_update_goals_block("").is_none()); + } + + #[test] + fn update_goals_unclosed_block_rejected() { + // 有开无闭(流式截断)→ 不解析不剥离,防半截输出误替换 + let text = "回复 [UPDATE_GOALS]\n1: 目标A"; + assert!(extract_update_goals_block(text).is_none()); + } + + #[test] + fn update_goals_empty_block_rejected() { + // 空块/纯空行 → None(不替空用户目标) + let text = "a [UPDATE_GOALS] [END] b"; + assert!(extract_update_goals_block(text).is_none()); + } + + #[test] + fn update_goals_status_defaults_active_and_case_insensitive() { + let text = "[UPDATE_GOALS]\n做A\n做B (COMPLETED)\n做C (Completed)\n[END]"; + let (goals, _) = extract_update_goals_block(text).unwrap(); + assert_eq!(goals[0], ("做A".to_string(), true)); + assert_eq!(goals[1], ("做B".to_string(), false)); + assert_eq!(goals[2], ("做C".to_string(), false)); + } + + #[test] + fn update_goals_number_prefix_optional_and_text_colon_safe() { + // 无编号行也认;普通文本含冒号不误拆(编号须全数字) + let text = "[UPDATE_GOALS]\n目标一: 直接写\n1: 编号目标\n[END]"; + let (goals, _) = extract_update_goals_block(text).unwrap(); + assert_eq!(goals[0], ("目标一: 直接写".to_string(), true)); + assert_eq!(goals[1], ("编号目标".to_string(), true)); + } + + #[test] + fn update_goals_truncated_to_max() { + let text = "[UPDATE_GOALS]\n1: a\n2: b\n3: c\n4: d\n5: e\n6: f\n7: g\n[END]"; + let (goals, _) = extract_update_goals_block(text).unwrap(); + assert_eq!(goals.len(), super::super::MAX_GOALS); + } + // ── tool_call_signature 归一化测试 ── #[test] diff --git a/src-tauri/src/commands/ai/agentic/mod.rs b/src-tauri/src/commands/ai/agentic/mod.rs index 04268e4..838a69a 100644 --- a/src-tauri/src/commands/ai/agentic/mod.rs +++ b/src-tauri/src/commands/ai/agentic/mod.rs @@ -391,6 +391,7 @@ pub(crate) use stall_detector::{ }; pub(crate) use helpers::try_continue_agent_loop; pub(crate) use helpers::infer_goal_from_tool_calls; +pub(crate) use helpers::extract_update_goals_block; pub(crate) use helpers::{ tool_call_signature, is_repetitive_exploration, is_write_evidence_tool, is_pure_greeting, is_tool_call_over_limit, DestructiveLoopSample, destructive_loop_sample, detect_destructive_loop, @@ -1777,16 +1778,39 @@ async fn finalize_round_output( ); return RoundEpilogue::Exit; } + // UPDATE_GOALS 剥离(先于 push):LLM 输出含 [UPDATE_GOALS] 块时,用户看到的 + // assistant 消息用剥离后的干净文本(标记是实现细节,不入历史/不推前端)。 + let (goals_override, text_to_push): (Option>, String) = + match extract_update_goals_block(full_text) { + Some((goals, cleaned)) => (Some(goals), cleaned), + None => (None, full_text.to_string()), + }; push_assistant_message( &mut session, conv_id, has_tool_calls, tool_calls_acc, - full_text, resolved_model, last_reasoning_content, + &text_to_push, resolved_model, last_reasoning_content, round_usage.prompt_tokens, round_usage.completion_tokens, round_usage.prompt_cache_hit_tokens, round_usage.prompt_cache_miss_tokens, round_usage.reasoning_tokens, round_is_estimated, ); if GOAL_PIN_ENABLED { - update_pinned_goals(&mut session, conv_id, tool_calls_acc); + // UPDATE_GOALS 优先:LLM 显式修正标记存在 → **替换** pinned_goals(纠偏语义), + // 跳过工具推理追加(追加会与本轮显式修正打架)。 + if let Some(goals) = goals_override { + let goal_conv = session.conv(conv_id); + goal_conv.pinned_goals = goals + .into_iter() + .map(|(text, active)| { + let mut e = super::GoalEntry::new(text); + if !active { + e.status = super::GoalStatus::Completed; + } + e + }) + .collect(); + } else { + update_pinned_goals(&mut session, conv_id, tool_calls_acc); + } } }