修复: 上下文记忆与工作流注入生产侧空壳接通

WorkingContext 写入链落地(用户轮目标钉扎+压缩后 JSON 卡片回填重锚定,含 6 单测);
工作流 DAG 摘要注入生产侧接通(AI 路径启动写入+终态回写,审批调用点透传会话)。
附知识库与记忆走查诊断(「笨/丢历史」根因:消息压缩视图丢失+知识检索四重收口),
todo 登记修复分档(含随批补登 09-28 交互走查索引条目)。
This commit is contained in:
lxy
2026-09-29 13:28:56 +08:00
parent f57e25a277
commit dfa38f6259
9 changed files with 333 additions and 27 deletions
+133 -2
View File
@@ -724,11 +724,14 @@ impl WorkingContextVersion {
}
}
/// 压缩后重置:将所有字段标记为本轮变更
pub fn reset_all(&mut self, _summary: &str, current_turn: u32) {
/// 压缩后重锚定:将所有脏标记置为 current_turn,未来 recent_turns 轮内各段必注入一次。
/// 不解析 summary 内容,仅为签名兼容保留参数。
pub fn reset_all(&mut self, summary: &str, current_turn: u32) {
let _ = summary;
self.goals_changed_at = current_turn;
self.decisions_changed_at = current_turn;
self.unresolved_changed_at = current_turn;
self.step_changed_at = current_turn;
self.current_turn = current_turn;
}
}
@@ -783,6 +786,64 @@ impl WorkingContext {
parts.join("\n")
}
/// 新用户轮:上一批 Active 目标落 Completed,本条用户请求截断后记为唯一 Active 目标;
/// current_turn 自增。自动目标与 G1 pinned_goals(用户手动钉,另链路)互不覆盖。
/// 已 Completed 的自动目标无读取方,直接丢弃(只保留 Active),防长会话无限膨胀。
pub fn begin_turn(&mut self, user_request: &str, max_chars: usize) {
self.version.current_turn += 1;
// 自动目标单活跃语义:新请求即替换,旧目标不保留(Cancelled 同理无读取方,一并丢弃)
self.goals.clear();
let trimmed = user_request.trim();
if !trimmed.is_empty() {
self.goals.push(GoalItem {
id: format!("t{}", self.version.current_turn),
text: truncate_chars(trimmed, max_chars),
status: GoalStatus::Active,
});
}
self.version.goals_changed_at = self.version.current_turn;
}
/// 压缩后重锚定:历史消息已被摘要替换,从 T3 JSON 卡片(字段 topics/decisions/key_files,
/// decisions 元素形如 {"summary": "..."})恢复决策至上限 5 条(每条 decision 文本截 120 字符,
/// question/reason 留空);随后调 version.reset_all(json_card, 自增后的 current_turn)。
/// Active 目标保留不丢(压缩不应丢目标)。current_turn 自增 1。
pub fn sync_after_compress(&mut self, json_card: &str) {
self.version.current_turn += 1;
if let Ok(card) = serde_json::from_str::<serde_json::Value>(json_card) {
let items: Vec<DecisionItem> = card
.get("decisions")
.and_then(|d| d.as_array())
.map(|arr| {
arr.iter()
.filter_map(|d| d.get("summary").and_then(|s| s.as_str()))
.filter(|s| !s.trim().is_empty())
.take(5)
.map(|s| DecisionItem {
question: String::new(),
decision: truncate_chars(s, 120),
reason: String::new(),
})
.collect()
})
.unwrap_or_default();
// 仅当解析出非空决策才覆盖;脏标记由 reset_all 统一置位
if !items.is_empty() {
self.decisions = items;
}
}
self.version.reset_all(json_card, self.version.current_turn);
}
}
/// 按 chars 计数截断文本,越界补省略号(begin_turn / sync_after_compress 共用)
fn truncate_chars(s: &str, max: usize) -> String {
if s.chars().count() <= max {
return s.to_string();
}
let head: String = s.chars().take(max).collect();
format!("{}…", head)
}
@@ -1416,4 +1477,74 @@ mod tests {
assert_eq!(result, content, "read_file 纯文本应豁免压缩");
assert!(!result.contains("输出已压缩"), "read_file 纯文本不应压缩");
}
// ── WorkingContext 写入侧(begin_turn / sync_after_compress)──
#[test]
fn working_context_begin_turn_replaces_previous_goal() {
let mut ctx = WorkingContext::new();
ctx.begin_turn("第一个请求", 100);
ctx.begin_turn("第二个请求", 100);
assert_eq!(ctx.version.current_turn, 2, "current_turn 应自增两次");
assert_eq!(ctx.goals.len(), 1, "替换语义:仅剩本条 Active 目标");
assert_eq!(ctx.goals[0].status, GoalStatus::Active);
assert_eq!(ctx.goals[0].text, "第二个请求");
assert_eq!(ctx.goals[0].id, "t2");
}
#[test]
fn working_context_begin_turn_empty_request_only_bumps_turn() {
let mut ctx = WorkingContext::new();
ctx.begin_turn(" ", 100);
assert_eq!(ctx.version.current_turn, 1, "空文本仍应 bump turn");
assert!(ctx.goals.is_empty(), "空文本不 push 目标");
assert_eq!(ctx.version.goals_changed_at, 1);
}
#[test]
fn working_context_begin_turn_truncates_long_request() {
let mut ctx = WorkingContext::new();
let long = "长".repeat(50);
ctx.begin_turn(&long, 10);
assert_eq!(ctx.goals[0].text.chars().count(), 11, "10 字符 + 省略号");
assert!(ctx.goals[0].text.ends_with('…'));
}
#[test]
fn working_context_sync_after_compress_restores_decisions() {
let mut ctx = WorkingContext::new();
ctx.begin_turn("压缩前目标", 100);
let card = r#"{"topics":["a"],"decisions":[{"summary":"采用块级 memo 渲染"},{"summary":"降级兜底"}],"key_files":[]}"#;
ctx.sync_after_compress(card);
assert_eq!(ctx.decisions.len(), 2);
assert_eq!(ctx.decisions[0].decision, "采用块级 memo 渲染");
assert!(ctx.decisions[0].question.is_empty(), "question 留空");
assert!(ctx.decisions[0].reason.is_empty(), "reason 留空");
assert_eq!(ctx.goals.len(), 1, "Active 目标保留不丢");
assert_eq!(ctx.goals[0].status, GoalStatus::Active);
let out = ctx.build_injection(3);
assert!(out.contains("目标"), "应含目标段: {}", out);
assert!(out.contains("近期决策"), "应含近期决策段: {}", out);
}
#[test]
fn working_context_sync_after_compress_invalid_json_still_reanchors() {
let mut ctx = WorkingContext::new();
ctx.begin_turn("压缩前目标", 100);
ctx.sync_after_compress("不是 JSON");
assert!(ctx.decisions.is_empty(), "非法 JSON 不恢复决策且不 panic");
let out = ctx.build_injection(3);
assert!(out.contains("目标"), "重锚定后仍应注入目标段: {}", out);
}
#[test]
fn working_context_injection_decays_after_window() {
let mut ctx = WorkingContext::new();
ctx.begin_turn("会过期的目标", 100);
assert!(ctx.build_injection(3).contains("会过期的目标"), "窗口内应注入");
// 模拟多轮未再变更:直接推进 current_turn,脏窗口(3)过期后不再注入
ctx.version.current_turn = 10;
let out = ctx.build_injection(3);
assert!(out.is_empty(), "过期后所有段均不注入, 实际: {}", out);
}
}