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 23efc5f..f5e7ced 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" @@ -12,6 +12,11 @@ - Rust 后端已形成明确的状态与规则中心 - `NoName` 的 runtime、trace、guardrail、memory、reviewed apply 已经具备可验证闭环 - 前端调试入口已具备,便于持续验证 +- 记忆检索排序解释性已完成第一轮增强,结果可说明 `relevance / recency / importance / role_boost` 的排序依据 +- Structured Notes 生命周期已完成第一轮收口,`active / resolved / archived` 的合法流转集中到可复用规则 +- 角色上下文差异度已完成第一轮增强,`source_stats` 与 `visible_constraints` 能显示角色级上下文偏好 +- 检索与 notes 已完成第一轮串联,active notes 可作为检索上下文桥帮助召回相关记忆,archived notes 不参与扩展召回 +- 调试台信息结构已完成第一轮优化,trace 面板提供前置快读条,复制摘要提供 `Trace Breakdown` ## 3. 当前主要风险 @@ -38,6 +43,10 @@ 本地若保留 `.review/` 工作树,会让 Vitest 把非当前仓库测试也一起执行,影响结果可信度。 +### 3.5 记忆检索与 notes 串联仍未完成 + +检索排序解释性、notes 生命周期和 notes/retrieval 串联第一轮已经具备,但 structured notes 与 compaction、context builder 的贯通仍不完整。后续需要继续验证 note-aware retrieval 是否应接入更多上下文构建路径,避免新能力只停留在 manager 层报告。 + ## 4. 当前最合理的发展策略 先收口,再扩张。 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 a155d02..683b2d7 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" @@ -67,6 +67,13 @@ - 角色差异化权重 - 与 narrative notes 联动 +当前状态: + +- 排序可解释性第一轮已完成 +- 检索结果可附带 `NoNameRetrievedMemoryReport`,用于解释每条结果的匹配分、角色加权、时间新近度和重要性 +- 角色差异化权重已在解释结果中显式暴露 +- 与 narrative notes 的深度联动尚未开始,后续归入 B4 + ### 任务 6:增强 structured notes 生命周期 - 重点: @@ -74,6 +81,31 @@ - 章节收束时的 note 归档 - 与 compaction、retrieval、context builder 贯通 +当前状态: + +- notes 生命周期第一轮已完成 +- `active / resolved / archived` 的合法流转已集中为可复用规则 +- `NoNameMemoryManager` 已保证 note 状态变化后同步 retrieval store +- 章节收束时 resolved 自动归档的既有行为已保留 +- 与 retrieval 的第一轮串联已完成:active notes 可作为检索上下文桥,archived notes 不参与扩展召回 +- 与 compaction、context builder 的更深层串联尚未完成,后续继续推进 + +### 任务 6.5:优化角色上下文差异度 + +当前状态: + +- 第一轮已完成 +- role context 已通过 source priority stats 和 primary context bias 说明强化 `Director / WorldCurator / NpcIntent` 的差异 +- 当前改动不影响 runtime 主流程,仍属于低耦合 context builder 层增强 + +### 任务 6.6:优化调试台可读性 + +当前状态: + +- 第一轮已完成 +- trace 面板已提供 `Trace 快读` 条,调试台复制摘要已提供 `Trace Breakdown` +- 当前改动不增加后端协议字段,仅重组现有 trace 信息 + ## P3:中期探索 ### 任务 7:推进下一层安全输出接口 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 d3343b2..037e64e 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" @@ -45,6 +45,13 @@ - 提升排序质量与解释性 - 不改主链 apply 编排 +当前状态: + +- 第一轮已完成 +- 检索层已能返回排序解释报告,明确展示 `relevance -> recency -> importance` 的排序依据 +- store 层已提供 explainable retrieval 代理方法 +- 当前仍保持低耦合边界,未修改 `noname_runtime.rs`、`context_builder` 或 apply 主链 + ### 协作任务 2:Structured Notes 生命周期增强 - 目录范围: @@ -55,6 +62,14 @@ - 完成 active / resolved / archived 的清晰流转 - 与 compaction/retrieval 接口对齐 +当前状态: + +- 第一轮已完成 +- notes 状态流转已集中到可复用 lifecycle action,避免调用方直接散写状态 +- `archived` 已被定义为终态,不允许 reopen,降低历史 notes 被误激活的风险 +- manager 层已在 lifecycle action 后同步 narrative memory store,保证后续 retrieval/context builder 能读到一致状态 +- 与 compaction/retrieval 的深度串联仍留到 B4 + ### 协作任务 3:角色上下文质量优化 - 目录范围: @@ -64,6 +79,13 @@ - 提高不同角色的上下文差异度 - 不改 runtime 主流程 +当前状态: + +- 第一轮已完成 +- role context 的 `source_stats` 已显式加入角色优先级标记,便于调试和测试区分不同角色的上下文偏好 +- `visible_constraints` 已补充 primary context bias 文案 +- 未修改 runtime 主流程,也未增加新的 role 运行模式 + ### 协作任务 4:调试与可视化增强 - 目录范围: @@ -74,6 +96,30 @@ - 提升 trace 可读性 - 增强 review / apply 生命周期展示 +当前状态: + +- 第一轮已完成 +- trace 面板已新增前置快读条,便于快速核对 proposal / review / human review / apply / guardrail 概览 +- 调试台复制摘要已补充 `Trace Breakdown` +- 未新增后端协议字段,仍只消费现有 trace 数据 + +### 协作任务 5:检索与 notes 串联增强 + +- 目录范围: + - `src-tauri/src/noname_memory_manager.rs` + - `src-tauri/src/noname_note_store.rs` + - `src-tauri/src/noname_memory_retrieval.rs` +- 目标: + - 让 active structured notes 能作为检索上下文桥接,提升相关记忆召回 + - 保持 archived notes 不参与召回扩展 + +当前状态: + +- 第一轮已完成 +- manager 层已提供 note-aware retrieval 报告,包含召回结果、note contexts 与 explanations +- 已有测试证明 active notes 会扩大相关 episodic 召回,archived notes 不会扩大召回 +- 当前没有修改前端、protocol 或 runtime 主链 + ## 5. 当前不再适用的旧任务分法 旧版 `A1/A2/A3/B2/B3` 等卡片里有多项已不再是“未开始”。 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 f6421e0..a8a46ca 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" @@ -130,6 +130,19 @@ - 最小验证: - `cargo test noname_memory_retrieval -- --nocapture` +当前状态: + +- 第一轮已完成 +- 新增 `retrieve_memories_with_explanations`,在保留原 `retrieve_memories` 兼容行为的前提下返回 `NoNameRetrievedMemoryReport` +- 排序解释已包含 `section / item_id / rank / raw_match / role_boost / relevance / recency / importance / reasons` +- `NoNameMemoryStore` 已提供 `retrieve_with_explanations` 代理入口 +- 已补充针对 relevance、recency、importance、role boost、过滤项解释剔除、store 代理的测试 +- 已验证: + - `cargo test noname_memory_retrieval -- --nocapture` + - `cargo test noname_memory_store -- --nocapture` + - `cargo clippy -- -D warnings` + - `git diff --check` + ### 任务 B2:Structured Notes 生命周期收口 - 目标: @@ -147,6 +160,20 @@ - 最小验证: - `cargo test noname_ -- --nocapture` +当前状态: + +- 第一轮已完成 +- `NoNameNarrativeStatus` 已提供状态 key、终态判断和合法流转判断 +- `NoNameNoteStore` 已新增 `NoNameNoteLifecycleAction` 与 `apply_lifecycle_action`,统一处理 `resolve / archive / reopen` +- 当前规则为:`active -> resolved / archived`,`resolved -> active / archived`,`archived` 为终态不可回滚 +- `NoNameMemoryManager` 已提供 lifecycle action 代理,并在状态变化后同步 retrieval store +- 已补充 active/resolved/archived 流转、archived 拒绝 reopen、manager 同步 retrieval store 的测试 +- 已验证: + - `cargo test noname_note_store -- --nocapture` + - `cargo test noname_memory_manager -- --nocapture` + - `cargo clippy -- -D warnings` + - `git diff --check` + ### 任务 B3:角色上下文差异度优化 - 目标: @@ -163,6 +190,17 @@ - 最小验证: - `cargo test noname_ -- --nocapture` +当前状态: + +- 第一轮已完成 +- `Director / WorldCurator / NpcIntent / CombatNarrator / System` 的 role context 已追加 role-specific source priority stats +- `visible_constraints` 已补充每个角色的 primary context bias,便于调试时判断该角色优先消费哪些上下文 +- 未修改 `noname_runtime.rs`,未新增 role 运行模式 +- 已补充 source stats 差异化测试,覆盖 `Director / WorldCurator / NpcIntent` +- 已验证: + - `cargo test noname_context -- --nocapture` + - `cargo test noname_ -- --nocapture` + ## 5. 第 2 周排期 ## 5.1 主开发线 @@ -219,6 +257,18 @@ - 最小验证: - `cargo test noname_ -- --nocapture` +当前状态: + +- 第一轮已完成 +- `NoNameMemoryManager` 已新增 note-aware retrieval 入口,可用匹配查询意图的 active structured notes 扩展检索上下文 +- active note 会提取 `related_entities / title / summary` 作为补充查询桥,帮助召回相关 episodic / semantic / working memory +- archived notes 不参与扩展召回,避免历史 notes 被误激活 +- note-aware retrieval 会返回 `note_contexts` 与 explanations,便于调试 notes 如何影响召回 +- 未修改前端、protocol 或 runtime 主链 +- 已补充测试证明 active notes 会影响召回,archived notes 不会影响召回 +- 已验证: + - `cargo test noname_memory_manager -- --nocapture` + ### 任务 B5:调试台信息结构优化 - 目标: @@ -236,6 +286,16 @@ - 最小验证: - `npm run test -- --run src/components/__tests__/AgentTracePanel.test.ts src/components/__tests__/NoNameDebugConsole.test.ts` +当前状态: + +- 第一轮已完成 +- `AgentTracePanel` 已新增 `Trace 快读` 条,前置展示提案数、复核数、人工复核进度、执行数和护栏状态 +- `NoNameDebugConsole` 的复制摘要已补充 `Trace Breakdown`,便于 PR/调试交接时快速核对 proposal/review/apply 概览 +- 未新增后端协议字段,未改后端命令 +- 已补充组件测试覆盖快读条与复制摘要 breakdown +- 已验证: + - `npm run test -- --run src/components/__tests__/AgentTracePanel.test.ts src/components/__tests__/NoNameDebugConsole.test.ts` + ## 6. 交接模板 每个任务提交或交接时,必须包含以下内容: diff --git a/src-tauri/src/noname_context_builder.rs b/src-tauri/src/noname_context_builder.rs index 2b0220f..0e75416 100644 --- a/src-tauri/src/noname_context_builder.rs +++ b/src-tauri/src/noname_context_builder.rs @@ -206,12 +206,20 @@ fn director_context(packet: &NoNameContextPacket) -> NoNameRoleContextPacket { visible_constraints: vec![ "May propose low-risk narrative direction.".to_string(), "Should keep unresolved threads visible for later turns.".to_string(), + "Primary context bias: narrative notes, episodic memory, then recent signals." + .to_string(), ], forbidden_scopes: vec![ "Must not directly rewrite final plot state.".to_string(), "Must not invent hard world canon without WorldCurator support.".to_string(), ], - source_stats: packet.source_stats.clone(), + source_stats: role_source_stats( + packet, + &[ + ("rolePriority:director:narrative", 3), + ("rolePriority:director:episodic", 2), + ], + ), token_budget_used: packet.token_budget_used, } } @@ -233,12 +241,20 @@ fn world_curator_context(packet: &NoNameContextPacket) -> NoNameRoleContextPacke visible_constraints: vec![ "May clarify setting rules and location constraints.".to_string(), "Should preserve established facts over dramatic convenience.".to_string(), + "Primary context bias: semantic facts, chapter summaries, then map entities." + .to_string(), ], forbidden_scopes: vec![ "Must not decide NPC private intent.".to_string(), "Must not choose the main plot beat alone.".to_string(), ], - source_stats: packet.source_stats.clone(), + source_stats: role_source_stats( + packet, + &[ + ("rolePriority:worldCurator:semantic", 4), + ("rolePriority:worldCurator:chapterSummary", 2), + ], + ), token_budget_used: packet.token_budget_used, } } @@ -263,12 +279,20 @@ fn npc_intent_context(packet: &NoNameContextPacket) -> NoNameRoleContextPacket { visible_constraints: vec![ "May infer motivation only from visible context.".to_string(), "Should keep uncertainty explicit when evidence is thin.".to_string(), + "Primary context bias: referenced entities, episodic memory, then narrative notes." + .to_string(), ], forbidden_scopes: vec![ "Must not reveal hidden knowledge not present in context.".to_string(), "Must not override world facts or combat outcomes.".to_string(), ], - source_stats: packet.source_stats.clone(), + source_stats: role_source_stats( + packet, + &[ + ("rolePriority:npcIntent:referencedEntities", 4), + ("rolePriority:npcIntent:episodic", 3), + ], + ), token_budget_used: packet.token_budget_used, } } @@ -291,12 +315,20 @@ fn combat_narrator_context(packet: &NoNameContextPacket) -> NoNameRoleContextPac visible_constraints: vec![ "May describe action consequences as low-risk narration anchors.".to_string(), "Should preserve current combat constraints.".to_string(), + "Primary context bias: recent context, episodic combat memory, then working memory." + .to_string(), ], forbidden_scopes: vec![ "Must not determine final damage or victory state.".to_string(), "Must not invent new combat rules.".to_string(), ], - source_stats: packet.source_stats.clone(), + source_stats: role_source_stats( + packet, + &[ + ("rolePriority:combatNarrator:recentContext", 4), + ("rolePriority:combatNarrator:episodic", 3), + ], + ), token_budget_used: packet.token_budget_used, } } @@ -312,11 +344,25 @@ fn system_context(packet: &NoNameContextPacket) -> NoNameRoleContextPacket { recent_signals: take_joined(&[&packet.working_memory, &packet.recent_context], 4), visible_constraints: vec!["May inspect available context shape.".to_string()], forbidden_scopes: vec!["Must not author narrative content.".to_string()], - source_stats: packet.source_stats.clone(), + source_stats: role_source_stats(packet, &[("rolePriority:system:diagnostics", 1)]), token_budget_used: packet.token_budget_used, } } +fn role_source_stats( + packet: &NoNameContextPacket, + role_priorities: &[(&str, usize)], +) -> Vec { + let mut stats = packet.source_stats.clone(); + for (source, count) in role_priorities { + stats.push(NoNameContextSourceStat { + source: (*source).to_string(), + count: *count, + }); + } + stats +} + fn trim_to_budget(items: &mut Vec, remain_budget: usize, used: &mut usize) { let mut current = 0usize; items.retain(|line| { @@ -571,6 +617,23 @@ mod tests { assert_eq!(packets[2].role, NoNameRole::NpcIntent); assert_ne!(packets[0].role_goal, packets[1].role_goal); assert_ne!(packets[1].forbidden_scopes, packets[2].forbidden_scopes); + assert_ne!(packets[0].source_stats, packets[1].source_stats); + assert!(packets[0] + .source_stats + .iter() + .any(|item| item.source == "rolePriority:director:narrative")); + assert!(packets[1] + .source_stats + .iter() + .any(|item| item.source == "rolePriority:worldCurator:semantic")); + assert!(packets[2] + .source_stats + .iter() + .any(|item| item.source == "rolePriority:npcIntent:referencedEntities")); + assert!(packets[2] + .visible_constraints + .iter() + .any(|item| item.contains("Primary context bias"))); assert!(packets[1] .world_facts .iter() @@ -616,4 +679,52 @@ mod tests { .iter() .any(|item| item.contains("hidden knowledge"))); } + + #[test] + fn role_context_source_stats_are_specialized_per_role() { + let packet = NoNameContextPacket { + role: NoNameRole::Director, + hard_facts: vec!["Gate has a ward".to_string()], + working_memory: vec!["Player suspects Elder Qinghe".to_string()], + episodic_memory: vec!["Elder Qinghe hesitated".to_string()], + narrative_notes: vec!["Broken Ward: saboteur unknown".to_string()], + chapter_summaries: vec!["Gate Crisis: ward damaged".to_string()], + recent_context: vec!["Ward flickers".to_string()], + referenced_entities: vec!["Character:ElderQinghe".to_string()], + compressed_summary: None, + token_budget_used: 42, + source_stats: vec![ + NoNameContextSourceStat { + source: "semantic".to_string(), + count: 1, + }, + NoNameContextSourceStat { + source: "episodic".to_string(), + count: 1, + }, + ], + }; + + let director = specialize_context_packet(&packet); + let mut world_packet = packet.clone(); + world_packet.role = NoNameRole::WorldCurator; + let world = specialize_context_packet(&world_packet); + let mut npc_packet = packet.clone(); + npc_packet.role = NoNameRole::NpcIntent; + let npc = specialize_context_packet(&npc_packet); + + assert!(director + .source_stats + .iter() + .any(|item| item.source == "rolePriority:director:narrative" && item.count == 3)); + assert!(world + .source_stats + .iter() + .any(|item| item.source == "rolePriority:worldCurator:semantic" && item.count == 4)); + assert!(npc.source_stats.iter().any(|item| { + item.source == "rolePriority:npcIntent:referencedEntities" && item.count == 4 + })); + assert_ne!(director.source_stats, world.source_stats); + assert_ne!(world.source_stats, npc.source_stats); + } } diff --git a/src-tauri/src/noname_memory_manager.rs b/src-tauri/src/noname_memory_manager.rs index 0bb89bf..6571e82 100644 --- a/src-tauri/src/noname_memory_manager.rs +++ b/src-tauri/src/noname_memory_manager.rs @@ -3,13 +3,18 @@ use crate::noname_memory_compaction::{ NoNameChapterCompactionInput, NoNameCompactionSummary, NoNameMemoryCompactionService, NoNameTraceCompactionInput, NoNameTurnCompactionInput, }; -use crate::noname_memory_retrieval::{NoNameMemoryQuery, NoNameRetrievedMemories}; +use crate::noname_memory_retrieval::{ + NoNameMemoryQuery, NoNameMemorySection, NoNameRetrievedMemories, NoNameRetrievedMemoryReport, +}; use crate::noname_memory_store::NoNameMemoryStore; use crate::noname_memory_types::{ NoNameEpisodicMemoryItem, NoNameNarrativeMemoryItem, NoNameSemanticMemoryItem, NoNameWorkingMemoryItem, }; -use crate::noname_note_store::{NoNameChapterNoteReview, NoNameNoteStore, NoNameNoteUpdate}; +use crate::noname_note_store::{ + NoNameChapterNoteReview, NoNameNoteLifecycleAction, NoNameNoteLifecycleResult, NoNameNoteStore, + NoNameNoteUpdate, +}; #[derive(Debug, Clone)] pub struct NoNameMemoryManager { @@ -17,6 +22,22 @@ pub struct NoNameMemoryManager { notes: NoNameNoteStore, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NoNameNoteRetrievalContext { + pub note_id: String, + pub title: String, + pub related_entities: Vec, + pub derived_actor: Option, + pub derived_keyword: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NoNameNoteAugmentedRetrievalReport { + pub memories: NoNameRetrievedMemories, + pub note_contexts: Vec, + pub explanations: Vec, +} + impl Default for NoNameMemoryManager { fn default() -> Self { Self::new() @@ -92,6 +113,29 @@ impl NoNameMemoryManager { Some(updated) } + pub fn reopen_note( + &mut self, + note_id: &str, + updated_at: u64, + ) -> Option { + let updated = self.notes.reopen(note_id, updated_at)?; + self.store.upsert_narrative(updated.clone()); + Some(updated) + } + + pub fn apply_note_lifecycle_action( + &mut self, + note_id: &str, + action: NoNameNoteLifecycleAction, + updated_at: u64, + ) -> Option { + let result = self + .notes + .apply_lifecycle_action(note_id, action, updated_at)?; + self.store.upsert_narrative(result.note.clone()); + Some(result) + } + pub fn organize_chapter_notes( &mut self, chapter_index: u32, @@ -108,6 +152,26 @@ impl NoNameMemoryManager { self.store.retrieve(query) } + pub fn retrieve_with_active_note_context( + &self, + query: &NoNameMemoryQuery, + ) -> NoNameNoteAugmentedRetrievalReport { + let mut report = self.store.retrieve_with_explanations(query); + let note_contexts = self.matching_note_contexts(query); + + for context in ¬e_contexts { + let augmented_query = context.augment_query(query); + let augmented_report = self.store.retrieve_with_explanations(&augmented_query); + merge_note_augmented_report(&mut report, augmented_report, context, query); + } + + NoNameNoteAugmentedRetrievalReport { + memories: report.memories, + note_contexts, + explanations: report.explanations, + } + } + pub fn compact_turn_memory(&self, input: NoNameTurnCompactionInput) -> NoNameCompactionSummary { NoNameMemoryCompactionService::new().compact_turn(input) } @@ -137,6 +201,185 @@ impl NoNameMemoryManager { pub fn notes_by_chapter(&self, chapter_index: u32) -> Vec { self.notes.list_by_chapter(chapter_index) } + + fn matching_note_contexts(&self, query: &NoNameMemoryQuery) -> Vec { + if !query.has_structured_filters() { + return Vec::new(); + } + + self.notes + .list_active() + .into_iter() + .filter(|note| note_matches_query(note, query)) + .take(query.per_section_limit) + .map(NoNameNoteRetrievalContext::from_note) + .collect() + } +} + +impl NoNameNoteRetrievalContext { + fn from_note(note: NoNameNarrativeMemoryItem) -> Self { + let derived_actor = note.related_entities.first().cloned(); + let derived_keyword = first_non_empty(&[note.title.as_str(), note.summary.as_str()]); + + Self { + note_id: note.note_id, + title: note.title, + related_entities: note.related_entities, + derived_actor, + derived_keyword, + } + } + + fn augment_query(&self, query: &NoNameMemoryQuery) -> NoNameMemoryQuery { + let mut augmented = query.clone(); + if augmented.actor.is_none() { + augmented.actor = self.derived_actor.clone(); + } + if augmented.keyword.is_none() && augmented.search_term.is_none() { + augmented.keyword = self.derived_keyword.clone(); + } + if augmented.goal.is_none() { + augmented.goal = self.derived_keyword.clone(); + } + augmented + } +} + +fn merge_note_augmented_report( + target: &mut NoNameRetrievedMemoryReport, + incoming: NoNameRetrievedMemoryReport, + context: &NoNameNoteRetrievalContext, + query: &NoNameMemoryQuery, +) { + merge_unique_by_id( + &mut target.memories.working, + incoming.memories.working, + query.per_section_limit, + |item| item.memory_id.clone(), + ); + merge_unique_by_id( + &mut target.memories.episodic, + incoming.memories.episodic, + query.per_section_limit, + |item| item.memory_id.clone(), + ); + merge_unique_by_id( + &mut target.memories.semantic, + incoming.memories.semantic, + query.per_section_limit, + |item| item.fact_id.clone(), + ); + merge_unique_by_id( + &mut target.memories.narrative, + incoming.memories.narrative, + query.per_section_limit, + |item| item.note_id.clone(), + ); + + for mut explanation in incoming.explanations { + if target.explanations.iter().any(|existing| { + existing.section == explanation.section && existing.item_id == explanation.item_id + }) { + continue; + } + if !contains_report_memory(&target.memories, explanation.section, &explanation.item_id) { + continue; + } + explanation + .reasons + .push(format!("note_context={}", context.note_id)); + target.explanations.push(explanation); + } +} + +fn contains_report_memory( + memories: &NoNameRetrievedMemories, + section: NoNameMemorySection, + item_id: &str, +) -> bool { + match section { + NoNameMemorySection::Working => memories + .working + .iter() + .any(|item| item.memory_id == item_id), + NoNameMemorySection::Episodic => memories + .episodic + .iter() + .any(|item| item.memory_id == item_id), + NoNameMemorySection::Semantic => { + memories.semantic.iter().any(|item| item.fact_id == item_id) + } + NoNameMemorySection::Narrative => memories + .narrative + .iter() + .any(|item| item.note_id == item_id), + } +} + +fn merge_unique_by_id(target: &mut Vec, incoming: Vec, limit: usize, item_id: F) +where + F: Fn(&T) -> String, +{ + for item in incoming { + if target.len() >= limit { + break; + } + let incoming_id = item_id(&item); + if !target + .iter() + .any(|existing| item_id(existing) == incoming_id) + { + target.push(item); + } + } +} + +fn note_matches_query(note: &NoNameNarrativeMemoryItem, query: &NoNameMemoryQuery) -> bool { + let fields = [ + note.title.as_str(), + note.summary.as_str(), + note.arc_id.as_deref().unwrap_or(""), + ]; + + query + .search_term + .as_deref() + .is_some_and(|term| note_contains(&fields, ¬e.related_entities, term)) + || query + .keyword + .as_deref() + .is_some_and(|term| note_contains(&fields, ¬e.related_entities, term)) + || query + .goal + .as_deref() + .is_some_and(|term| note_contains(&fields, ¬e.related_entities, term)) + || query + .actor + .as_deref() + .is_some_and(|term| note_contains(&fields, ¬e.related_entities, term)) + || query + .location + .as_deref() + .is_some_and(|term| note_contains(&fields, ¬e.related_entities, term)) +} + +fn note_contains(fields: &[&str], related_entities: &[String], term: &str) -> bool { + let normalized_term = term.to_lowercase(); + fields + .iter() + .any(|field| field.to_lowercase().contains(&normalized_term)) + || related_entities + .iter() + .any(|entity| entity.to_lowercase().contains(&normalized_term)) +} + +fn first_non_empty(values: &[&str]) -> Option { + values + .iter() + .map(|value| value.trim()) + .find(|value| !value.is_empty()) + .map(str::to_string) } #[cfg(test)] @@ -237,4 +480,204 @@ mod tests { assert_eq!(chapter_notes.len(), 1); assert_eq!(chapter_notes[0].status, NoNameNarrativeStatus::Archived); } + + #[test] + fn manager_applies_note_lifecycle_actions_and_syncs_store() { + let mut manager = NoNameMemoryManager::new(); + manager.upsert_narrative_memory(NoNameNarrativeMemoryItem { + note_id: "note-1".to_string(), + chapter_index: 1, + arc_id: None, + note_type: NoNameNarrativeNoteType::UnresolvedThread, + title: "Broken Ward".to_string(), + summary: "Find who broke the mountain gate ward.".to_string(), + status: NoNameNarrativeStatus::Active, + related_entities: vec!["player".to_string()], + updated_at: 1, + }); + + let resolved = manager + .apply_note_lifecycle_action("note-1", NoNameNoteLifecycleAction::Resolve, 2) + .expect("manager should resolve note"); + assert_eq!(resolved.next_status, NoNameNarrativeStatus::Resolved); + + manager.reopen_note("note-1", 3); + let reopened_notes = manager.retrieve(&NoNameMemoryQuery { + role: NoNameRole::Director, + search_term: Some("Broken Ward".to_string()), + actor: None, + location: None, + goal: None, + keyword: None, + token_budget: 200, + per_section_limit: 2, + }); + + assert_eq!(reopened_notes.narrative.len(), 1); + assert_eq!( + reopened_notes.narrative[0].status, + NoNameNarrativeStatus::Active + ); + } + + #[test] + fn active_notes_can_expand_retrieval_recall() { + let mut manager = NoNameMemoryManager::new(); + manager.push_episodic_memory(NoNameEpisodicMemoryItem { + memory_id: "event-qinghe".to_string(), + event_type: "dialogue".to_string(), + timestamp: 4, + chapter_index: 1, + location_id: Some("mountain_gate".to_string()), + actors: vec!["Elder Qinghe".to_string()], + summary: "Elder Qinghe hesitated before naming the saboteur.".to_string(), + detail_ref: None, + importance: crate::noname_memory_types::NoNameMemoryImportance::High, + }); + manager.upsert_narrative_memory(NoNameNarrativeMemoryItem { + note_id: "note-ward".to_string(), + chapter_index: 1, + arc_id: None, + note_type: NoNameNarrativeNoteType::UnresolvedThread, + title: "Broken Ward".to_string(), + summary: "The saboteur clue points back to Elder Qinghe.".to_string(), + status: NoNameNarrativeStatus::Active, + related_entities: vec!["Elder Qinghe".to_string()], + updated_at: 5, + }); + + let query = NoNameMemoryQuery { + role: NoNameRole::Director, + search_term: Some("ward".to_string()), + actor: None, + location: None, + goal: None, + keyword: None, + token_budget: 200, + per_section_limit: 4, + }; + let base = manager.retrieve(&query); + let expanded = manager.retrieve_with_active_note_context(&query); + + assert!(base.episodic.is_empty()); + assert_eq!(expanded.note_contexts.len(), 1); + assert_eq!(expanded.note_contexts[0].note_id, "note-ward"); + assert_eq!( + expanded.note_contexts[0].derived_actor.as_deref(), + Some("Elder Qinghe") + ); + assert_eq!(expanded.memories.episodic.len(), 1); + assert_eq!(expanded.memories.episodic[0].memory_id, "event-qinghe"); + assert!(expanded.explanations.iter().any(|item| { + item.item_id == "event-qinghe" + && item + .reasons + .iter() + .any(|reason| reason == "note_context=note-ward") + })); + } + + #[test] + fn note_augmented_retrieval_drops_explanations_for_memories_skipped_by_limit() { + 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: 1, + location_id: Some("mountain_gate".to_string()), + actors: vec!["player".to_string()], + summary: "The mountain gate ward flickered before dawn.".to_string(), + detail_ref: None, + importance: crate::noname_memory_types::NoNameMemoryImportance::Low, + }); + manager.push_episodic_memory(NoNameEpisodicMemoryItem { + memory_id: "event-qinghe".to_string(), + event_type: "dialogue".to_string(), + timestamp: 10, + chapter_index: 1, + location_id: Some("mountain_gate".to_string()), + actors: vec!["Elder Qinghe".to_string()], + summary: "Elder Qinghe hesitated before naming the saboteur.".to_string(), + detail_ref: None, + importance: crate::noname_memory_types::NoNameMemoryImportance::High, + }); + manager.upsert_narrative_memory(NoNameNarrativeMemoryItem { + note_id: "note-ward".to_string(), + chapter_index: 1, + arc_id: None, + note_type: NoNameNarrativeNoteType::UnresolvedThread, + title: "Broken Ward".to_string(), + summary: "The saboteur clue points back to Elder Qinghe.".to_string(), + status: NoNameNarrativeStatus::Active, + related_entities: vec!["Elder Qinghe".to_string()], + updated_at: 5, + }); + + let expanded = manager.retrieve_with_active_note_context(&NoNameMemoryQuery { + role: NoNameRole::Director, + search_term: Some("ward".to_string()), + actor: None, + location: None, + goal: None, + keyword: None, + token_budget: 200, + per_section_limit: 1, + }); + + assert_eq!(expanded.memories.episodic.len(), 1); + assert_eq!(expanded.memories.episodic[0].memory_id, "event-ward"); + assert!(expanded.explanations.iter().all(|explanation| { + contains_report_memory( + &expanded.memories, + explanation.section, + &explanation.item_id, + ) + })); + assert!(!expanded.explanations.iter().any(|explanation| { + explanation.section == NoNameMemorySection::Episodic + && explanation.item_id == "event-qinghe" + })); + } + + #[test] + fn archived_notes_do_not_expand_retrieval_recall() { + let mut manager = NoNameMemoryManager::new(); + manager.push_episodic_memory(NoNameEpisodicMemoryItem { + memory_id: "event-qinghe".to_string(), + event_type: "dialogue".to_string(), + timestamp: 4, + chapter_index: 1, + location_id: None, + actors: vec!["Elder Qinghe".to_string()], + summary: "Elder Qinghe mentioned a saboteur.".to_string(), + detail_ref: None, + importance: crate::noname_memory_types::NoNameMemoryImportance::High, + }); + manager.upsert_narrative_memory(NoNameNarrativeMemoryItem { + note_id: "note-ward".to_string(), + chapter_index: 1, + arc_id: None, + note_type: NoNameNarrativeNoteType::UnresolvedThread, + title: "Broken Ward".to_string(), + summary: "The saboteur clue points back to Elder Qinghe.".to_string(), + status: NoNameNarrativeStatus::Archived, + related_entities: vec!["Elder Qinghe".to_string()], + updated_at: 5, + }); + + let expanded = manager.retrieve_with_active_note_context(&NoNameMemoryQuery { + role: NoNameRole::Director, + search_term: Some("ward".to_string()), + actor: None, + location: None, + goal: None, + keyword: None, + token_budget: 200, + per_section_limit: 4, + }); + + assert!(expanded.note_contexts.is_empty()); + assert!(expanded.memories.episodic.is_empty()); + } } diff --git a/src-tauri/src/noname_memory_retrieval.rs b/src-tauri/src/noname_memory_retrieval.rs index aafc408..41b6a08 100644 --- a/src-tauri/src/noname_memory_retrieval.rs +++ b/src-tauri/src/noname_memory_retrieval.rs @@ -68,11 +68,47 @@ pub struct NoNameRetrievedMemories { pub narrative: Vec, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NoNameMemorySection { + Working, + Episodic, + Semantic, + Narrative, +} + +impl NoNameMemorySection { + pub fn as_str(self) -> &'static str { + match self { + Self::Working => "working", + Self::Episodic => "episodic", + Self::Semantic => "semantic", + Self::Narrative => "narrative", + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)] -struct RetrievalScore { - relevance: u32, - recency: u64, - importance: u32, +pub struct NoNameRetrievalScore { + pub relevance: u32, + pub recency: u64, + pub importance: u32, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NoNameRetrievalExplanation { + pub section: NoNameMemorySection, + pub item_id: String, + pub rank: usize, + pub raw_match: u32, + pub role_boost: u32, + pub score: NoNameRetrievalScore, + pub reasons: Vec, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct NoNameRetrievedMemoryReport { + pub memories: NoNameRetrievedMemories, + pub explanations: Vec, } pub fn retrieve_memories( @@ -82,29 +118,59 @@ pub fn retrieve_memories( semantic: &[NoNameSemanticMemoryItem], narrative: &[NoNameNarrativeMemoryItem], ) -> NoNameRetrievedMemories { + retrieve_memories_with_explanations(query, working, episodic, semantic, narrative).memories +} + +pub fn retrieve_memories_with_explanations( + query: &NoNameMemoryQuery, + working: &[NoNameWorkingMemoryItem], + episodic: &[NoNameEpisodicMemoryItem], + semantic: &[NoNameSemanticMemoryItem], + narrative: &[NoNameNarrativeMemoryItem], +) -> NoNameRetrievedMemoryReport { let working_items = rank_working(query, working); let episodic_items = rank_episodic(query, episodic); let semantic_items = rank_semantic(query, semantic); let narrative_items = rank_narrative(query, narrative); - NoNameRetrievedMemories { - working: working_items, - episodic: episodic_items, - semantic: semantic_items, - narrative: narrative_items, + let explanations = working_items + .explanations + .iter() + .chain(episodic_items.explanations.iter()) + .chain(semantic_items.explanations.iter()) + .chain(narrative_items.explanations.iter()) + .cloned() + .collect(); + + NoNameRetrievedMemoryReport { + memories: NoNameRetrievedMemories { + working: working_items.items, + episodic: episodic_items.items, + semantic: semantic_items.items, + narrative: narrative_items.items, + }, + explanations, } } +#[derive(Debug, Clone, PartialEq, Eq)] +struct RankedSection { + items: Vec, + explanations: Vec, +} + fn rank_working( query: &NoNameMemoryQuery, working: &[NoNameWorkingMemoryItem], -) -> Vec { +) -> RankedSection { rank_items( + NoNameMemorySection::Working, working.iter().enumerate(), query, + |(_, item)| item.memory_id.clone(), |(_, item)| working_match_score(query, item), - |(index, item)| RetrievalScore { - relevance: working_match_score(query, item) + role_section_boost(query.role, "working"), + |(index, item), raw_match| NoNameRetrievalScore { + relevance: raw_match + role_section_boost(query.role, "working"), recency: (*index + 1) as u64, importance: item.priority as u32, }, @@ -115,14 +181,15 @@ fn rank_working( fn rank_episodic( query: &NoNameMemoryQuery, episodic: &[NoNameEpisodicMemoryItem], -) -> Vec { +) -> RankedSection { rank_items( + NoNameMemorySection::Episodic, episodic.iter(), query, + |item| item.memory_id.clone(), |item| episodic_match_score(query, item), - |item| RetrievalScore { - relevance: episodic_match_score(query, item) - + role_section_boost(query.role, "episodic"), + |item, raw_match| NoNameRetrievalScore { + relevance: raw_match + role_section_boost(query.role, "episodic"), recency: item.timestamp, importance: importance_weight(item.importance), }, @@ -133,14 +200,15 @@ fn rank_episodic( fn rank_semantic( query: &NoNameMemoryQuery, semantic: &[NoNameSemanticMemoryItem], -) -> Vec { +) -> RankedSection { rank_items( + NoNameMemorySection::Semantic, semantic.iter(), query, + |item| item.fact_id.clone(), |item| semantic_match_score(query, item), - |item| RetrievalScore { - relevance: semantic_match_score(query, item) - + role_section_boost(query.role, "semantic"), + |item, raw_match| NoNameRetrievalScore { + relevance: raw_match + role_section_boost(query.role, "semantic"), recency: item.updated_at, importance: item.confidence as u32, }, @@ -151,16 +219,17 @@ fn rank_semantic( fn rank_narrative( query: &NoNameMemoryQuery, narrative: &[NoNameNarrativeMemoryItem], -) -> Vec { +) -> RankedSection { rank_items( + NoNameMemorySection::Narrative, narrative .iter() .filter(|item| item.status == NoNameNarrativeStatus::Active), query, + |item| item.note_id.clone(), |item| narrative_match_score(query, item), - |item| RetrievalScore { - relevance: narrative_match_score(query, item) - + role_section_boost(query.role, "narrative"), + |item, raw_match| NoNameRetrievalScore { + relevance: raw_match + role_section_boost(query.role, "narrative"), recency: item.updated_at, importance: narrative_importance_weight(item.note_type), }, @@ -168,17 +237,20 @@ fn rank_narrative( ) } -fn rank_items( +fn rank_items( + section: NoNameMemorySection, items: I, query: &NoNameMemoryQuery, + item_id: FId, match_score: FMatch, score: FScore, clone_item: FClone, -) -> Vec +) -> RankedSection where I: IntoIterator, + FId: Fn(&T) -> String, FMatch: Fn(&T) -> u32, - FScore: Fn(&T) -> RetrievalScore, + FScore: Fn(&T, u32) -> NoNameRetrievalScore, FClone: Fn(&T) -> U, { let mut ranked = items @@ -188,13 +260,71 @@ where if query.has_structured_filters() && raw_match == 0 { return None; } - Some((score(&item), clone_item(&item))) + let score = score(&item, raw_match); + let role_boost = score.relevance.saturating_sub(raw_match); + let explanation = build_retrieval_explanation( + section, + item_id(&item), + raw_match, + role_boost, + score, + query, + ); + Some((score, clone_item(&item), explanation)) }) .collect::>(); ranked.sort_by_key(|item| std::cmp::Reverse(item.0)); ranked.truncate(query.per_section_limit); - ranked.into_iter().map(|(_, item)| item).collect() + + let mut items = Vec::with_capacity(ranked.len()); + let mut explanations = Vec::with_capacity(ranked.len()); + for (index, (_, item, mut explanation)) in ranked.into_iter().enumerate() { + explanation.rank = index + 1; + items.push(item); + explanations.push(explanation); + } + + RankedSection { + items, + explanations, + } +} + +fn build_retrieval_explanation( + section: NoNameMemorySection, + item_id: String, + raw_match: u32, + role_boost: u32, + score: NoNameRetrievalScore, + query: &NoNameMemoryQuery, +) -> NoNameRetrievalExplanation { + let mut reasons = vec![ + "sort=relevance_desc,recency_desc,importance_desc".to_string(), + format!("section={}", section.as_str()), + format!("relevance={raw_match}+{role_boost}"), + format!("recency={}", score.recency), + format!("importance={}", score.importance), + ]; + + if query.has_structured_filters() { + reasons.push(format!("matched_filters_score={raw_match}")); + } else { + reasons.push("default_query_keeps_recent_memory_visible".to_string()); + } + if role_boost > 0 { + reasons.push(format!("role_boost={role_boost}")); + } + + NoNameRetrievalExplanation { + section, + item_id, + rank: 0, + raw_match, + role_boost, + score, + reasons, + } } fn working_match_score(query: &NoNameMemoryQuery, item: &NoNameWorkingMemoryItem) -> u32 { @@ -512,6 +642,103 @@ mod tests { assert_eq!(result.working[0].summary, "敌修与玩家激烈交锋"); } + #[test] + fn retrieval_report_explains_rank_score_components() { + let report = retrieve_memories_with_explanations( + &NoNameMemoryQuery::by_keyword(NoNameRole::CombatNarrator, "交锋", 200, 2), + &[], + &[ + sample_episodic( + "episode-old", + vec!["玩家"], + Some("山门"), + "敌修与玩家交锋,火花四溅", + 1, + NoNameMemoryImportance::High, + ), + sample_episodic( + "episode-new", + vec!["玩家"], + Some("山门"), + "敌修再次与玩家交锋,剑气更盛", + 5, + NoNameMemoryImportance::Medium, + ), + ], + &[], + &[], + ); + + assert_eq!(report.memories.episodic[0].memory_id, "episode-new"); + let new_explanation = report + .explanations + .iter() + .find(|item| item.item_id == "episode-new") + .expect("newer episodic memory should have an explanation"); + assert_eq!(new_explanation.section, NoNameMemorySection::Episodic); + assert_eq!(new_explanation.rank, 1); + assert_eq!(new_explanation.raw_match, 3); + assert_eq!(new_explanation.role_boost, 4); + assert_eq!(new_explanation.score.relevance, 7); + assert_eq!(new_explanation.score.recency, 5); + assert_eq!(new_explanation.score.importance, 50); + assert!(new_explanation + .reasons + .iter() + .any(|item| item == "sort=relevance_desc,recency_desc,importance_desc")); + } + + #[test] + fn retrieval_report_records_role_boost_for_world_curator_semantic_memory() { + let report = retrieve_memories_with_explanations( + &NoNameMemoryQuery::by_keyword(NoNameRole::WorldCurator, "山门", 200, 2), + &[], + &[], + &[sample_semantic( + "fact-1", + "青河长老", + "山门", + 88, + 7, + vec!["地点"], + )], + &[], + ); + + assert_eq!(report.memories.semantic[0].fact_id, "fact-1"); + let explanation = report + .explanations + .iter() + .find(|item| item.item_id == "fact-1") + .expect("semantic memory should have an explanation"); + assert_eq!(explanation.section, NoNameMemorySection::Semantic); + assert_eq!(explanation.raw_match, 3); + assert_eq!(explanation.role_boost, 5); + assert_eq!(explanation.score.relevance, 8); + assert!(explanation + .reasons + .iter() + .any(|item| item == "role_boost=5")); + } + + #[test] + fn retrieval_report_excludes_filtered_out_items_from_explanations() { + let report = retrieve_memories_with_explanations( + &NoNameMemoryQuery::by_keyword(NoNameRole::Director, "山门", 200, 2), + &[ + sample_working("青河长老要求死守山门", 8), + sample_working("普通巡山记录", 9), + ], + &[], + &[], + &[], + ); + + assert_eq!(report.memories.working.len(), 1); + assert_eq!(report.explanations.len(), 1); + assert_eq!(report.explanations[0].item_id, "work-8"); + } + #[test] fn retrieval_keeps_search_term_backward_compatible() { let result = retrieve_memories( diff --git a/src-tauri/src/noname_memory_store.rs b/src-tauri/src/noname_memory_store.rs index 5d0bd68..a8541fa 100644 --- a/src-tauri/src/noname_memory_store.rs +++ b/src-tauri/src/noname_memory_store.rs @@ -1,5 +1,6 @@ use crate::noname_memory_retrieval::{ - retrieve_memories, NoNameMemoryQuery, NoNameRetrievedMemories, + retrieve_memories, retrieve_memories_with_explanations, NoNameMemoryQuery, + NoNameRetrievedMemories, NoNameRetrievedMemoryReport, }; use crate::noname_memory_types::{ NoNameEpisodicMemoryItem, NoNameNarrativeMemoryItem, NoNameSemanticMemoryItem, @@ -80,6 +81,19 @@ impl NoNameMemoryStore { self.narrative(), ) } + + pub fn retrieve_with_explanations( + &self, + query: &NoNameMemoryQuery, + ) -> NoNameRetrievedMemoryReport { + retrieve_memories_with_explanations( + query, + self.working(), + self.episodic(), + self.semantic(), + self.narrative(), + ) + } } #[cfg(test)] @@ -192,4 +206,35 @@ mod tests { assert_eq!(result.episodic.len(), 1); assert_eq!(result.episodic[0].memory_id, "event-1"); } + + #[test] + fn store_can_delegate_explainable_retrieval() { + let mut store = NoNameMemoryStore::new(); + store.push_episodic(NoNameEpisodicMemoryItem { + memory_id: "event-1".to_string(), + event_type: "battle".to_string(), + timestamp: 3, + chapter_index: 1, + location_id: Some("山门".to_string()), + actors: vec!["青河长老".to_string()], + summary: "青河长老在山门布阵".to_string(), + detail_ref: None, + importance: NoNameMemoryImportance::High, + }); + + let report = store.retrieve_with_explanations(&NoNameMemoryQuery { + role: NoNameRole::NpcIntent, + search_term: None, + actor: Some("青河长老".to_string()), + location: Some("山门".to_string()), + goal: None, + keyword: Some("布阵".to_string()), + token_budget: 200, + per_section_limit: 2, + }); + + assert_eq!(report.memories.episodic[0].memory_id, "event-1"); + assert_eq!(report.explanations[0].item_id, "event-1"); + assert_eq!(report.explanations[0].rank, 1); + } } diff --git a/src-tauri/src/noname_memory_types.rs b/src-tauri/src/noname_memory_types.rs index 32f84f2..f9718e8 100644 --- a/src-tauri/src/noname_memory_types.rs +++ b/src-tauri/src/noname_memory_types.rs @@ -27,6 +27,33 @@ pub enum NoNameNarrativeStatus { Archived, } +impl NoNameNarrativeStatus { + pub fn as_str(self) -> &'static str { + match self { + Self::Active => "active", + Self::Resolved => "resolved", + Self::Archived => "archived", + } + } + + pub fn can_transition_to(self, next: Self) -> bool { + matches!( + (self, next), + (Self::Active, Self::Active) + | (Self::Active, Self::Resolved) + | (Self::Active, Self::Archived) + | (Self::Resolved, Self::Resolved) + | (Self::Resolved, Self::Active) + | (Self::Resolved, Self::Archived) + | (Self::Archived, Self::Archived) + ) + } + + pub fn is_terminal(self) -> bool { + self == Self::Archived + } +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct NoNameWorkingMemoryItem { diff --git a/src-tauri/src/noname_note_store.rs b/src-tauri/src/noname_note_store.rs index 22cee36..ab13d20 100644 --- a/src-tauri/src/noname_note_store.rs +++ b/src-tauri/src/noname_note_store.rs @@ -27,6 +27,27 @@ pub struct NoNameChapterNoteReview { pub updated_at: u64, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum NoNameNoteLifecycleAction { + Resolve, + Archive, + Reopen, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NoNameNoteLifecycleResult { + pub note_id: String, + pub action: NoNameNoteLifecycleAction, + pub previous_status: NoNameNarrativeStatus, + pub next_status: NoNameNarrativeStatus, + pub applied: bool, + pub reason: String, + pub updated_at: u64, + pub note: NoNameNarrativeMemoryItem, +} + #[derive(Debug, Default, Clone)] pub struct NoNameNoteStore { notes: Vec, @@ -94,7 +115,7 @@ impl NoNameNoteStore { note.summary = summary; } if let Some(status) = update.status { - note.status = status; + note.status = transition_status(note.status, status)?; } if let Some(related_entities) = update.related_entities { note.related_entities = dedupe_strings(related_entities); @@ -120,7 +141,8 @@ impl NoNameNoteStore { } pub fn resolve(&mut self, note_id: &str, updated_at: u64) -> Option { - self.set_status(note_id, NoNameNarrativeStatus::Resolved, updated_at) + self.apply_lifecycle_action(note_id, NoNameNoteLifecycleAction::Resolve, updated_at) + .map(|result| result.note) } pub fn close(&mut self, note_id: &str, updated_at: u64) -> Option { @@ -128,7 +150,37 @@ impl NoNameNoteStore { } pub fn archive(&mut self, note_id: &str, updated_at: u64) -> Option { - self.set_status(note_id, NoNameNarrativeStatus::Archived, updated_at) + self.apply_lifecycle_action(note_id, NoNameNoteLifecycleAction::Archive, updated_at) + .map(|result| result.note) + } + + pub fn reopen(&mut self, note_id: &str, updated_at: u64) -> Option { + self.apply_lifecycle_action(note_id, NoNameNoteLifecycleAction::Reopen, updated_at) + .map(|result| result.note) + } + + pub fn apply_lifecycle_action( + &mut self, + note_id: &str, + action: NoNameNoteLifecycleAction, + updated_at: u64, + ) -> Option { + let note = self.notes.iter_mut().find(|item| item.note_id == note_id)?; + let previous_status = note.status; + let next_status = status_for_lifecycle_action(previous_status, action)?; + note.status = next_status; + note.updated_at = updated_at; + + Some(NoNameNoteLifecycleResult { + note_id: note.note_id.clone(), + action, + previous_status, + next_status, + applied: previous_status != next_status, + reason: lifecycle_reason(action, previous_status, next_status), + updated_at, + note: note.clone(), + }) } pub fn organize_chapter_end( @@ -197,6 +249,42 @@ impl NoNameNoteStore { } } +fn status_for_lifecycle_action( + current: NoNameNarrativeStatus, + action: NoNameNoteLifecycleAction, +) -> Option { + let next = match action { + NoNameNoteLifecycleAction::Resolve => NoNameNarrativeStatus::Resolved, + NoNameNoteLifecycleAction::Archive => NoNameNarrativeStatus::Archived, + NoNameNoteLifecycleAction::Reopen => NoNameNarrativeStatus::Active, + }; + transition_status(current, next) +} + +fn transition_status( + current: NoNameNarrativeStatus, + next: NoNameNarrativeStatus, +) -> Option { + current.can_transition_to(next).then_some(next) +} + +fn lifecycle_reason( + action: NoNameNoteLifecycleAction, + previous_status: NoNameNarrativeStatus, + next_status: NoNameNarrativeStatus, +) -> String { + if previous_status == next_status { + return format!("note already {}", next_status.as_str()); + } + + format!( + "{:?} transitioned note from {} to {}", + action, + previous_status.as_str(), + next_status.as_str() + ) +} + fn dedupe_strings(values: Vec) -> Vec { let mut deduped = Vec::new(); for value in values { @@ -278,6 +366,70 @@ mod tests { ); } + #[test] + fn note_store_applies_reusable_lifecycle_actions() { + let mut store = NoNameNoteStore::new(); + store.upsert(note("note-1", NoNameNarrativeNoteType::Goal)); + + let resolved = store + .apply_lifecycle_action("note-1", NoNameNoteLifecycleAction::Resolve, 2) + .expect("active note should resolve"); + assert!(resolved.applied); + assert_eq!(resolved.previous_status, NoNameNarrativeStatus::Active); + assert_eq!(resolved.next_status, NoNameNarrativeStatus::Resolved); + assert_eq!(resolved.note.status, NoNameNarrativeStatus::Resolved); + + let reopened = store + .apply_lifecycle_action("note-1", NoNameNoteLifecycleAction::Reopen, 3) + .expect("resolved note should reopen"); + assert!(reopened.applied); + assert_eq!(reopened.previous_status, NoNameNarrativeStatus::Resolved); + assert_eq!(reopened.next_status, NoNameNarrativeStatus::Active); + + let archived = store + .apply_lifecycle_action("note-1", NoNameNoteLifecycleAction::Archive, 4) + .expect("active note should archive"); + assert!(archived.applied); + assert_eq!(archived.next_status, NoNameNarrativeStatus::Archived); + } + + #[test] + fn note_store_rejects_reopening_archived_notes() { + let mut store = NoNameNoteStore::new(); + store.upsert(note("note-1", NoNameNarrativeNoteType::Goal)); + store.archive("note-1", 2); + + let rejected = store.apply_lifecycle_action("note-1", NoNameNoteLifecycleAction::Reopen, 3); + + assert!(rejected.is_none()); + assert_eq!( + store.get("note-1").expect("note should exist").status, + NoNameNarrativeStatus::Archived + ); + } + + #[test] + fn note_update_rejects_invalid_status_transition() { + let mut store = NoNameNoteStore::new(); + store.upsert(note("note-1", NoNameNarrativeNoteType::Goal)); + store.archive("note-1", 2); + + let rejected = store.update( + "note-1", + NoNameNoteUpdate { + status: Some(NoNameNarrativeStatus::Active), + updated_at: 3, + ..NoNameNoteUpdate::default() + }, + ); + + assert!(rejected.is_none()); + assert_eq!( + store.get("note-1").expect("note should exist").updated_at, + 2 + ); + } + #[test] fn chapter_end_review_archives_resolved_and_carries_active_notes() { let mut store = NoNameNoteStore::new(); diff --git a/src-tauri/src/tauri_commands.rs b/src-tauri/src/tauri_commands.rs index 5707615..2eadef8 100644 --- a/src-tauri/src/tauri_commands.rs +++ b/src-tauri/src/tauri_commands.rs @@ -243,7 +243,10 @@ fn apply_noname_outcome_with_engine( apply: F, ) -> Result where - F: FnOnce(NoNameTrace, PlotState) -> Result, + F: FnOnce( + NoNameTrace, + PlotState, + ) -> Result, { let trace = load_noname_trace(trace_id)?; let result = { diff --git a/src/components/AgentTracePanel.vue b/src/components/AgentTracePanel.vue index a70f0f5..8b3625d 100644 --- a/src/components/AgentTracePanel.vue +++ b/src/components/AgentTracePanel.vue @@ -29,6 +29,24 @@ +
+

+ Trace 快读 +

+
+ {{ item.label }} + {{ item.value }} +
+
+

@@ -482,6 +500,47 @@ const controlledOutputReviews = computed(() => props.trace?.controlledOutputRevi const applyLifecycleSteps = computed(() => ( props.trace ? buildNoNameApplyLifecycle(props.trace, props.reviewDecisions) : [] )); +const traceDigestItems = computed(() => { + if (!props.trace) { + return []; + } + + const reviews = controlledOutputReviews.value; + const humanReviewCount = reviews.filter((review) => review.requiresHumanReview).length; + const approvedCount = reviews.filter((review) => ( + humanReviewDecision(review.requestId) === 'approvedForHigherApply' + )).length; + const guardrailOutcome = props.trace.guardrailResult?.outcome ?? '无'; + const applyExecutionCount = props.trace.applyExecutionLog?.length ?? 0; + + return [ + { + label: '提案', + value: `${props.trace.proposals.length}`, + tone: props.trace.proposals.length > 0 ? 'active' : 'quiet', + }, + { + label: '复核', + value: `${reviews.length}`, + tone: reviews.length > 0 ? 'review' : 'quiet', + }, + { + label: '人工', + value: `${approvedCount}/${humanReviewCount}`, + tone: approvedCount > 0 ? 'safe' : humanReviewCount > 0 ? 'review' : 'quiet', + }, + { + label: '执行', + value: `${applyExecutionCount}`, + tone: applyExecutionCount > 0 ? 'safe' : 'quiet', + }, + { + label: '护栏', + value: guardrailOutcome, + tone: guardrailOutcome === 'reject' || guardrailOutcome === 'fallback' ? 'warn' : 'safe', + }, + ]; +}); const guardrailLabel = computed(() => { const result = props.trace?.guardrailResult; @@ -701,6 +760,57 @@ function applyManualPlotTextHint(requestId: string) { color: #9b4d2e; } +.agent-trace-digest { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 10px; +} + +.agent-trace-digest-title { + grid-column: 1 / -1; + margin: 0; + color: var(--ink-text-muted); + font-size: 12px; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.agent-trace-digest-item { + border-radius: 16px; + border: 1px solid color-mix(in srgb, var(--ink-border-soft) 72%, transparent); + background: color-mix(in srgb, var(--ink-card-bg) 90%, transparent); + padding: 10px 12px; +} + +.agent-trace-digest-item.is-active { + border-color: color-mix(in srgb, var(--ink-accent-main) 48%, var(--ink-border-soft)); +} + +.agent-trace-digest-item.is-review { + border-color: color-mix(in srgb, #9b7b2e 48%, var(--ink-border-soft)); +} + +.agent-trace-digest-item.is-safe { + border-color: color-mix(in srgb, #2f6b4b 48%, var(--ink-border-soft)); +} + +.agent-trace-digest-item.is-warn { + border-color: color-mix(in srgb, #9b4d2e 54%, var(--ink-border-soft)); +} + +.agent-trace-digest-label { + display: block; + color: var(--ink-text-muted); + font-size: 12px; +} + +.agent-trace-digest-value { + display: block; + margin-top: 5px; + color: var(--ink-text-primary); + font-size: 18px; +} + .agent-trace-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); @@ -897,6 +1007,10 @@ function applyManualPlotTextHint(requestId: string) { grid-template-columns: 1fr; } + .agent-trace-digest { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .agent-trace-lifecycle { grid-template-columns: 1fr; } diff --git a/src/components/NoNameDebugConsole.vue b/src/components/NoNameDebugConsole.vue index 2bc7104..836c987 100644 --- a/src/components/NoNameDebugConsole.vue +++ b/src/components/NoNameDebugConsole.vue @@ -272,6 +272,7 @@ function buildTraceReport( const protocolEvents = trace.protocolEvents ?? []; const controlledReviews = trace.controlledOutputReviews ?? []; const humanReviewCount = controlledReviews.filter((review) => review.requiresHumanReview).length; + const applyExecutionCount = trace.applyExecutionLog?.length ?? 0; const reviewDecisionCounts = controlledReviews.reduce( (counts, review) => { const decision = reviewDecisions[review.requestId] ?? 'pending'; @@ -299,6 +300,7 @@ function buildTraceReport( `Graph: ${graphPath}`, `Proposals: ${readyCount}/${trace.proposals.length} applyable`, `Latest Proposal: ${latestProposal ? `${latestProposal.producerRole}:${latestProposal.kind}:${latestProposal.focus}` : '无'}`, + `Trace Breakdown: proposals=${trace.proposals.length}, reviews=${controlledReviews.length}, humanReview=${humanReviewCount}, applyExecutions=${applyExecutionCount}`, `Related Observations: ${relatedObservations.length}`, `Protocol Events: ${protocolEvents.length}`, `Controlled Reviews: ${controlledReviews.length} (${humanReviewCount} needs human review)`, diff --git a/src/components/__tests__/AgentTracePanel.test.ts b/src/components/__tests__/AgentTracePanel.test.ts index d4e8d14..3e309e2 100644 --- a/src/components/__tests__/AgentTracePanel.test.ts +++ b/src/components/__tests__/AgentTracePanel.test.ts @@ -106,6 +106,12 @@ describe('AgentTracePanel', () => { }); expect(wrapper.text()).toContain('trace-2'); + expect(wrapper.text()).toContain('Trace 快读'); + expect(wrapper.text()).toContain('提案'); + expect(wrapper.text()).toContain('复核'); + expect(wrapper.text()).toContain('人工'); + expect(wrapper.text()).toContain('执行'); + expect(wrapper.text()).toContain('护栏'); expect(wrapper.text()).toContain('Director提案:山门危机'); expect(wrapper.text()).toContain('current_turn_tail'); expect(wrapper.text()).toContain('plot_text_hint'); diff --git a/src/components/__tests__/NoNameDebugConsole.test.ts b/src/components/__tests__/NoNameDebugConsole.test.ts index 03e0f9d..fcab97d 100644 --- a/src/components/__tests__/NoNameDebugConsole.test.ts +++ b/src/components/__tests__/NoNameDebugConsole.test.ts @@ -110,6 +110,7 @@ describe('NoNameDebugConsole', () => { expect(wrapper.text()).toContain('当前模式:assisted'); expect(wrapper.text()).toContain('Protocol Events: 1'); expect(wrapper.text()).toContain('Controlled Reviews: 1 (1 needs human review)'); + expect(wrapper.text()).toContain('Trace Breakdown: proposals=1, reviews=1, humanReview=1, applyExecutions=1'); expect(wrapper.text()).toContain('Human Review Decisions: 0 approved / 0 rejected / 1 pending'); expect(wrapper.text()).toContain('Apply Lifecycle:'); expect(wrapper.text()).toContain('Proposals: 1/1 applyable'); @@ -192,6 +193,7 @@ describe('NoNameDebugConsole', () => { expect(writeText).toHaveBeenCalledWith(expect.stringContaining('Trace: trace-2')); expect(writeText).toHaveBeenCalledWith(expect.stringContaining('Protocol Events: 1')); expect(writeText).toHaveBeenCalledWith(expect.stringContaining('Controlled Reviews: 1')); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('Trace Breakdown: proposals=1')); expect(writeText).toHaveBeenCalledWith(expect.stringContaining('Apply Lifecycle:')); expect(wrapper.text()).toContain('摘要已复制'); });