新增: LLM目标列表主动修正

This commit is contained in:
lxy
2026-10-05 00:35:01 +08:00
parent 7dea2b86c4
commit c8c13d302a
2 changed files with 157 additions and 2 deletions
@@ -466,10 +466,141 @@ pub(crate) fn is_tool_call_over_limit(count: usize) -> bool {
count > super::MAX_TOOL_CALLS_PER_ROUND 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; 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 归一化测试 ── // ── tool_call_signature 归一化测试 ──
#[test] #[test]
+26 -2
View File
@@ -391,6 +391,7 @@ pub(crate) use stall_detector::{
}; };
pub(crate) use helpers::try_continue_agent_loop; pub(crate) use helpers::try_continue_agent_loop;
pub(crate) use helpers::infer_goal_from_tool_calls; pub(crate) use helpers::infer_goal_from_tool_calls;
pub(crate) use helpers::extract_update_goals_block;
pub(crate) use helpers::{ pub(crate) use helpers::{
tool_call_signature, is_repetitive_exploration, is_write_evidence_tool, is_pure_greeting, is_tool_call_over_limit, 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, DestructiveLoopSample, destructive_loop_sample, detect_destructive_loop,
@@ -1777,16 +1778,39 @@ async fn finalize_round_output(
); );
return RoundEpilogue::Exit; return RoundEpilogue::Exit;
} }
// UPDATE_GOALS 剥离(先于 push):LLM 输出含 [UPDATE_GOALS] 块时,用户看到的
// assistant 消息用剥离后的干净文本(标记是实现细节,不入历史/不推前端)。
let (goals_override, text_to_push): (Option<Vec<(String, bool)>>, String) =
match extract_update_goals_block(full_text) {
Some((goals, cleaned)) => (Some(goals), cleaned),
None => (None, full_text.to_string()),
};
push_assistant_message( push_assistant_message(
&mut session, conv_id, has_tool_calls, tool_calls_acc, &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_tokens, round_usage.completion_tokens,
round_usage.prompt_cache_hit_tokens, round_usage.prompt_cache_miss_tokens, round_usage.prompt_cache_hit_tokens, round_usage.prompt_cache_miss_tokens,
round_usage.reasoning_tokens, round_usage.reasoning_tokens,
round_is_estimated, round_is_estimated,
); );
if GOAL_PIN_ENABLED { 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);
}
} }
} }