diff --git "a/docs/\351\241\271\347\233\256\346\226\207\346\241\243/03-\350\247\204\345\210\222/01-\347\216\260\347\212\266\350\257\204\344\274\260.md" "b/docs/\351\241\271\347\233\256\346\226\207\346\241\243/03-\350\247\204\345\210\222/01-\347\216\260\347\212\266\350\257\204\344\274\260.md" index 36e5c6d..e05de52 100644 --- "a/docs/\351\241\271\347\233\256\346\226\207\346\241\243/03-\350\247\204\345\210\222/01-\347\216\260\347\212\266\350\257\204\344\274\260.md" +++ "b/docs/\351\241\271\347\233\256\346\226\207\346\241\243/03-\350\247\204\345\210\222/01-\347\216\260\347\212\266\350\257\204\344\274\260.md" @@ -56,7 +56,7 @@ ### 3.5 记忆检索与 notes 串联仍未完成 -检索排序解释性、notes 生命周期和 notes/retrieval 串联第一轮已经具备;note-aware retrieval 已接入 context builder,使 active notes 扩展出的召回结果能够进入角色上下文包。当前剩余缺口主要在 structured notes 与 compaction、章节收束及更高层 runtime 编排的贯通,后续需要继续验证 notes 如何参与长期记忆沉淀,而不是只参与单次上下文构建。 +检索排序解释性、notes 生命周期和 notes/retrieval 串联第一轮已经具备;note-aware retrieval 已接入 context builder,使 active notes 扩展出的召回结果能够进入角色上下文包。structured notes 与 chapter compaction 的第一轮桥接也已具备,manager 可从当前记忆状态自动收集同章 events 与 active / resolved notes 构造章节压缩输入,并默认跳过 archived notes 及 compaction 生成的 summary notes。当前剩余缺口主要在章节收束顺序和更高层 runtime 编排的贯通,后续需要继续验证压缩摘要何时写回、何时归档旧 notes,以及是否需要接入正式章节推进流程。 ### 3.6 下一层安全输出仍不能扩大权限边界 diff --git "a/docs/\351\241\271\347\233\256\346\226\207\346\241\243/03-\350\247\204\345\210\222/02-\345\220\216\347\273\255\350\267\257\347\272\277\345\233\276.md" "b/docs/\351\241\271\347\233\256\346\226\207\346\241\243/03-\350\247\204\345\210\222/02-\345\220\216\347\273\255\350\267\257\347\272\277\345\233\276.md" index 47b69c8..cc76433 100644 --- "a/docs/\351\241\271\347\233\256\346\226\207\346\241\243/03-\350\247\204\345\210\222/02-\345\220\216\347\273\255\350\267\257\347\272\277\345\233\276.md" +++ "b/docs/\351\241\271\347\233\256\346\226\207\346\241\243/03-\350\247\204\345\210\222/02-\345\220\216\347\273\255\350\267\257\347\272\277\345\233\276.md" @@ -92,7 +92,8 @@ - 章节收束时 resolved 自动归档的既有行为已保留 - 与 retrieval 的第一轮串联已完成:active notes 可作为检索上下文桥,archived notes 不参与扩展召回 - 与 context builder 的串联已完成第一轮:角色上下文构建会消费 note-aware retrieval,并在 `source_stats` 中暴露 `noteContext` -- 与 compaction、章节收束和更高层 runtime 编排的更深层串联尚未完成,后续继续推进 +- 与 chapter compaction 的第一轮串联已完成:manager 可自动收集同章 events 与 active / resolved notes 构造章节压缩输入,archived notes 与 compaction 生成的 summary notes 默认不参与重复沉淀 +- 与章节收束顺序、压缩摘要写回和更高层 runtime 编排的更深层串联尚未完成,后续继续推进 ### 任务 6.5:优化角色上下文差异度 diff --git "a/docs/\351\241\271\347\233\256\346\226\207\346\241\243/03-\350\247\204\345\210\222/03-\345\215\217\344\275\234\344\273\273\345\212\241\346\270\205\345\215\225.md" "b/docs/\351\241\271\347\233\256\346\226\207\346\241\243/03-\350\247\204\345\210\222/03-\345\215\217\344\275\234\344\273\273\345\212\241\346\270\205\345\215\225.md" index ee3785e..6ac015e 100644 --- "a/docs/\351\241\271\347\233\256\346\226\207\346\241\243/03-\350\247\204\345\210\222/03-\345\215\217\344\275\234\344\273\273\345\212\241\346\270\205\345\215\225.md" +++ "b/docs/\351\241\271\347\233\256\346\226\207\346\241\243/03-\350\247\204\345\210\222/03-\345\215\217\344\275\234\344\273\273\345\212\241\346\270\205\345\215\225.md" @@ -121,6 +121,7 @@ - 已有测试证明 active notes 会扩大相关 episodic 召回,archived notes 不会扩大召回 - 第二轮已完成:context builder 已消费 note-aware retrieval,让 active notes 扩展出的召回结果进入角色上下文包 - `source_stats` 已加入 `noteContext` 统计,便于后续调试上下文来源 +- 第三轮已完成:manager 层已提供章节 compaction 桥接入口,可从当前 store 自动收集同章 events 与 active / resolved notes,并默认跳过 archived notes 与 compaction 生成的 summary notes - 当前没有修改前端、protocol 或 runtime 主链 ## 5. 当前不再适用的旧任务分法 diff --git "a/docs/\351\241\271\347\233\256\346\226\207\346\241\243/03-\350\247\204\345\210\222/04-\346\234\252\346\235\245\344\270\244\345\221\250\345\274\200\345\217\221\346\216\222\346\234\237.md" "b/docs/\351\241\271\347\233\256\346\226\207\346\241\243/03-\350\247\204\345\210\222/04-\346\234\252\346\235\245\344\270\244\345\221\250\345\274\200\345\217\221\346\216\222\346\234\237.md" index ab998b2..3a28242 100644 --- "a/docs/\351\241\271\347\233\256\346\226\207\346\241\243/03-\350\247\204\345\210\222/04-\346\234\252\346\235\245\344\270\244\345\221\250\345\274\200\345\217\221\346\216\222\346\234\237.md" +++ "b/docs/\351\241\271\347\233\256\346\226\207\346\241\243/03-\350\247\204\345\210\222/04-\346\234\252\346\235\245\344\270\244\345\221\250\345\274\200\345\217\221\346\216\222\346\234\237.md" @@ -295,11 +295,15 @@ - note-aware retrieval 会返回 `note_contexts` 与 explanations,便于调试 notes 如何影响召回 - 第二轮已完成:`NoNameContextBuilder` 已改用 note-aware retrieval,让 active notes 扩展出的召回结果进入角色上下文包 - `source_stats` 已新增 `noteContext` 统计,用于观察本次上下文是否被 active notes 扩展 +- 第三轮已完成:`NoNameMemoryManager` 已新增章节 compaction 桥接入口,可从当前 store 自动收集同章 episodic events 与 structured notes 构造 `NoNameChapterCompactionInput` +- 章节 compaction 默认消费 active / resolved notes,跳过 archived notes 与 compaction 生成的 summary notes,避免已归档历史线索或旧摘要被重复沉淀 - 未修改前端、protocol 或 runtime 主链 -- 已补充测试证明 active notes 会影响 manager 层召回与 context builder 上下文结果,archived notes 不会影响召回 +- 已补充测试证明 active notes 会影响 manager 层召回与 context builder 上下文结果,archived notes 与旧 compaction summary 都不会进入默认章节 compaction 输入 - 已验证: - `cargo test noname_memory_manager -- --nocapture` - `cargo test noname_context_builder -- --nocapture` + - `cargo test noname_memory_compaction -- --nocapture` + - `cargo test noname_memory_store -- --nocapture` ### 任务 B5:调试台信息结构优化 diff --git a/src-tauri/src/noname_memory_manager.rs b/src-tauri/src/noname_memory_manager.rs index 6571e82..34017f4 100644 --- a/src-tauri/src/noname_memory_manager.rs +++ b/src-tauri/src/noname_memory_manager.rs @@ -8,8 +8,8 @@ use crate::noname_memory_retrieval::{ }; use crate::noname_memory_store::NoNameMemoryStore; use crate::noname_memory_types::{ - NoNameEpisodicMemoryItem, NoNameNarrativeMemoryItem, NoNameSemanticMemoryItem, - NoNameWorkingMemoryItem, + NoNameEpisodicMemoryItem, NoNameNarrativeMemoryItem, NoNameNarrativeStatus, + NoNameSemanticMemoryItem, NoNameWorkingMemoryItem, }; use crate::noname_note_store::{ NoNameChapterNoteReview, NoNameNoteLifecycleAction, NoNameNoteLifecycleResult, NoNameNoteStore, @@ -183,6 +183,48 @@ impl NoNameMemoryManager { NoNameMemoryCompactionService::new().compact_chapter(input) } + pub fn build_chapter_compaction_input( + &self, + chapter_id: impl Into, + chapter_index: u32, + title: impl Into, + created_at: u64, + ) -> NoNameChapterCompactionInput { + let notes = self + .notes + .list_by_chapter(chapter_index) + .into_iter() + .filter(|note| { + note.status != NoNameNarrativeStatus::Archived + && !is_compaction_summary_note(¬e.note_id) + }) + .collect(); + + NoNameChapterCompactionInput { + chapter_id: chapter_id.into(), + chapter_index, + title: title.into(), + events: self.store.episodic_by_chapter(chapter_index), + notes, + created_at, + } + } + + pub fn compact_chapter_from_memory( + &self, + chapter_id: impl Into, + chapter_index: u32, + title: impl Into, + created_at: u64, + ) -> NoNameCompactionSummary { + self.compact_chapter_memory(self.build_chapter_compaction_input( + chapter_id, + chapter_index, + title, + created_at, + )) + } + pub fn compact_trace_memory( &self, input: NoNameTraceCompactionInput, @@ -374,6 +416,12 @@ fn note_contains(fields: &[&str], related_entities: &[String], term: &str) -> bo .any(|entity| entity.to_lowercase().contains(&normalized_term)) } +fn is_compaction_summary_note(note_id: &str) -> bool { + note_id.starts_with("compact-turn-") + || note_id.starts_with("compact-chapter-") + || note_id.starts_with("compact-trace-") +} + fn first_non_empty(values: &[&str]) -> Option { values .iter() @@ -386,7 +434,9 @@ fn first_non_empty(values: &[&str]) -> Option { mod tests { use super::*; use crate::memory_layers::{ChapterSummary, MemoryEntry, WorldFact}; - use crate::noname_memory_types::{NoNameNarrativeNoteType, NoNameNarrativeStatus}; + use crate::noname_memory_types::{ + NoNameMemoryImportance, NoNameNarrativeNoteType, NoNameNarrativeStatus, + }; use crate::noname_types::NoNameRole; #[test] @@ -455,6 +505,209 @@ mod tests { assert_eq!(active_notes[0].summary, summary.summary); } + #[test] + fn manager_builds_chapter_compaction_input_from_current_memory() { + let mut manager = NoNameMemoryManager::new(); + manager.push_episodic_memory(NoNameEpisodicMemoryItem { + memory_id: "event-1".to_string(), + event_type: "scene".to_string(), + timestamp: 1, + chapter_index: 1, + location_id: Some("mountain_gate".to_string()), + actors: vec!["Player".to_string(), "Elder Qinghe".to_string()], + summary: "The ward flared while Elder Qinghe guarded the gate.".to_string(), + detail_ref: None, + importance: NoNameMemoryImportance::High, + }); + manager.push_episodic_memory(NoNameEpisodicMemoryItem { + memory_id: "event-other".to_string(), + event_type: "scene".to_string(), + timestamp: 2, + chapter_index: 2, + location_id: Some("spirit_mine".to_string()), + actors: vec!["Miner".to_string()], + summary: "A later chapter happens elsewhere.".to_string(), + detail_ref: None, + importance: NoNameMemoryImportance::Low, + }); + manager.upsert_narrative_memory(NoNameNarrativeMemoryItem { + note_id: "goal-1".to_string(), + chapter_index: 1, + arc_id: None, + note_type: NoNameNarrativeNoteType::Goal, + title: "Hold Gate".to_string(), + summary: "Keep the mountain gate secure.".to_string(), + status: NoNameNarrativeStatus::Active, + related_entities: vec!["Player".to_string()], + updated_at: 1, + }); + manager.upsert_narrative_memory(NoNameNarrativeMemoryItem { + note_id: "thread-1".to_string(), + chapter_index: 1, + arc_id: None, + note_type: NoNameNarrativeNoteType::UnresolvedThread, + title: "Saboteur Trail".to_string(), + summary: "The saboteur clue was resolved by Qinghe's confession.".to_string(), + status: NoNameNarrativeStatus::Resolved, + related_entities: vec!["Elder Qinghe".to_string()], + updated_at: 2, + }); + manager.upsert_narrative_memory(NoNameNarrativeMemoryItem { + note_id: "old-archived".to_string(), + chapter_index: 1, + arc_id: None, + note_type: NoNameNarrativeNoteType::Conflict, + title: "Old Conflict".to_string(), + summary: "This archived conflict should not be compacted again.".to_string(), + status: NoNameNarrativeStatus::Archived, + related_entities: vec!["Old Rival".to_string()], + updated_at: 3, + }); + let compact_turn = manager.compact_turn_memory(NoNameTurnCompactionInput { + turn_id: "turn-1".to_string(), + chapter_index: Some(1), + location_id: Some("mountain_gate".to_string()), + actor_mentions: vec!["Player".to_string()], + goal: Some("keep gate".to_string()), + segments: vec!["The gate barely holds after the ward flares.".to_string()], + conflicts: vec!["ward flares".to_string()], + unresolved_threads: Vec::new(), + relationships: Vec::new(), + created_at: 4, + }); + manager.upsert_compaction_summary(&compact_turn); + let compact_chapter = manager.compact_chapter_from_memory("chapter-1", 1, "Gate Crisis", 5); + manager.upsert_compaction_summary(&compact_chapter); + + let input = manager.build_chapter_compaction_input("chapter-1", 1, "Gate Crisis", 10); + + assert_eq!(input.chapter_id, "chapter-1"); + assert_eq!(input.chapter_index, 1); + assert_eq!(input.title, "Gate Crisis"); + assert_eq!(input.events.len(), 1); + assert_eq!(input.events[0].memory_id, "event-1"); + assert_eq!(input.notes.len(), 2); + assert!(input.notes.iter().any(|note| note.note_id == "goal-1")); + assert!(input.notes.iter().any(|note| note.note_id == "thread-1")); + assert!(!input + .notes + .iter() + .any(|note| note.note_id == "old-archived")); + assert!(!input + .notes + .iter() + .any(|note| note.note_id == compact_turn.summary_id)); + assert!(!input + .notes + .iter() + .any(|note| note.note_id == compact_chapter.summary_id)); + } + + #[test] + fn manager_compacts_chapter_from_memory_with_structured_notes() { + let mut manager = NoNameMemoryManager::new(); + manager.push_episodic_memory(NoNameEpisodicMemoryItem { + memory_id: "event-ward".to_string(), + event_type: "scene".to_string(), + timestamp: 1, + chapter_index: 3, + location_id: Some("mountain_gate".to_string()), + actors: vec!["Player".to_string(), "Elder Qinghe".to_string()], + summary: "The ward collapsed during the night watch.".to_string(), + detail_ref: None, + importance: NoNameMemoryImportance::High, + }); + manager.upsert_narrative_memory(NoNameNarrativeMemoryItem { + note_id: "conflict-ward".to_string(), + chapter_index: 3, + arc_id: None, + note_type: NoNameNarrativeNoteType::Conflict, + title: "Ward Collapse".to_string(), + summary: "The mountain gate ward is failing.".to_string(), + status: NoNameNarrativeStatus::Active, + related_entities: vec!["Mountain Gate".to_string()], + updated_at: 3, + }); + manager.upsert_narrative_memory(NoNameNarrativeMemoryItem { + note_id: "thread-saboteur".to_string(), + chapter_index: 3, + arc_id: None, + note_type: NoNameNarrativeNoteType::UnresolvedThread, + title: "Hidden Saboteur".to_string(), + summary: "The saboteur behind the ward remains unidentified.".to_string(), + status: NoNameNarrativeStatus::Active, + related_entities: vec!["Elder Qinghe".to_string()], + updated_at: 4, + }); + + let summary = manager.compact_chapter_from_memory("chapter-3", 3, "Night Ward", 20); + + assert_eq!(summary.chapter_index, Some(3)); + assert!(summary.source_ids.iter().any(|item| item == "event-ward")); + assert!(summary + .source_ids + .iter() + .any(|item| item == "conflict-ward")); + assert!(summary + .conflicts + .iter() + .any(|item| { item.contains("Ward Collapse") && item.contains("ward is failing") })); + assert!(summary.unresolved_threads.iter().any(|item| { + item.contains("Hidden Saboteur") && item.contains("remains unidentified") + })); + assert!(summary + .diagnostics + .iter() + .any(|item| item == "events_compacted=1")); + assert!(summary + .diagnostics + .iter() + .any(|item| item == "notes_compacted=2")); + } + + #[test] + fn repeated_chapter_compaction_does_not_reingest_previous_compaction_summary() { + let mut manager = NoNameMemoryManager::new(); + manager.push_episodic_memory(NoNameEpisodicMemoryItem { + memory_id: "event-ward".to_string(), + event_type: "scene".to_string(), + timestamp: 1, + chapter_index: 6, + location_id: Some("mountain_gate".to_string()), + actors: vec!["Player".to_string()], + summary: "The ward flickers as the chapter closes.".to_string(), + detail_ref: None, + importance: NoNameMemoryImportance::High, + }); + manager.upsert_narrative_memory(NoNameNarrativeMemoryItem { + note_id: "goal-ward".to_string(), + chapter_index: 6, + arc_id: None, + note_type: NoNameNarrativeNoteType::Goal, + title: "Secure Ward".to_string(), + summary: "Keep the ward stable through the night.".to_string(), + status: NoNameNarrativeStatus::Active, + related_entities: vec!["Player".to_string()], + updated_at: 2, + }); + + let first = manager.compact_chapter_from_memory("chapter-6", 6, "Ward Vigil", 10); + manager.upsert_compaction_summary(&first); + let second = manager.compact_chapter_from_memory("chapter-6", 6, "Ward Vigil", 11); + + assert_eq!(first.summary_id, second.summary_id); + assert!(!second + .source_ids + .iter() + .any(|item| item == &first.summary_id)); + assert!(second.source_ids.iter().any(|item| item == "event-ward")); + assert!(second.source_ids.iter().any(|item| item == "goal-ward")); + assert!(second + .diagnostics + .iter() + .any(|item| item == "notes_compacted=1")); + } + #[test] fn manager_keeps_note_lifecycle_in_sync_with_retrieval_store() { let mut manager = NoNameMemoryManager::new(); diff --git a/src-tauri/src/noname_memory_store.rs b/src-tauri/src/noname_memory_store.rs index a8541fa..e9fe5fe 100644 --- a/src-tauri/src/noname_memory_store.rs +++ b/src-tauri/src/noname_memory_store.rs @@ -64,6 +64,14 @@ impl NoNameMemoryStore { &self.episodic } + pub fn episodic_by_chapter(&self, chapter_index: u32) -> Vec { + self.episodic + .iter() + .filter(|item| item.chapter_index == chapter_index) + .cloned() + .collect() + } + pub fn semantic(&self) -> &[NoNameSemanticMemoryItem] { &self.semantic }