Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/项目文档/03-规划/01-现状评估.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 下一层安全输出仍不能扩大权限边界

Expand Down
3 changes: 2 additions & 1 deletion docs/项目文档/03-规划/02-后续路线图.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:优化角色上下文差异度

Expand Down
1 change: 1 addition & 0 deletions docs/项目文档/03-规划/03-协作任务清单.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. 当前不再适用的旧任务分法
Expand Down
6 changes: 5 additions & 1 deletion docs/项目文档/03-规划/04-未来两周开发排期.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:调试台信息结构优化

Expand Down
259 changes: 256 additions & 3 deletions src-tauri/src/noname_memory_manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -183,6 +183,48 @@ impl NoNameMemoryManager {
NoNameMemoryCompactionService::new().compact_chapter(input)
}

pub fn build_chapter_compaction_input(
&self,
chapter_id: impl Into<String>,
chapter_index: u32,
title: impl Into<String>,
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(&note.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<String>,
chapter_index: u32,
title: impl Into<String>,
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,
Expand Down Expand Up @@ -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<String> {
values
.iter()
Expand All @@ -386,7 +434,9 @@ fn first_non_empty(values: &[&str]) -> Option<String> {
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]
Expand Down Expand Up @@ -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();
Expand Down
8 changes: 8 additions & 0 deletions src-tauri/src/noname_memory_store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,14 @@ impl NoNameMemoryStore {
&self.episodic
}

pub fn episodic_by_chapter(&self, chapter_index: u32) -> Vec<NoNameEpisodicMemoryItem> {
self.episodic
.iter()
.filter(|item| item.chapter_index == chapter_index)
.cloned()
.collect()
}

pub fn semantic(&self) -> &[NoNameSemanticMemoryItem] {
&self.semantic
}
Expand Down
Loading