Skip to content

Commit 17d3309

Browse files
committed
fix(live): 记忆键改取净化前标题修复全屏路径跨会话语义
R-11 独立评审实测(控制方 §C51.4 授权):`source_window == None`(未选窗口/全屏)时, 档位记忆键取的是**已净化去重**的会话标题,而键空间是「课程/窗口标识」(前端写入侧 用的是检测卡的窗口标题)⇒ 去重后缀("(2)" 等)**逐会话派生**,一旦进键则同源第二次 会话就换了键 ⇒ 🔴 该路径的跨会话记忆**必读不回**(用户症状:选了档位、下次开课没记住)。 - 新增纯函数 `memory_key_for_start(source_window, session_title)`:窗口标题优先, 未选窗口时用**净化去重之前**的会话标题(取键点前移到 REQ-282 去重块之前) - 登记该路径的记忆语义(如实):未选窗口无窗口级身份,键是前端占位标题 (`ClassroomPage` 传常量「实时课堂」),且检测卡在该路径不渲染 ⇒ UI 无写入口; 本路径的记忆只在**同一占位标题**下自洽,跨标题不迁移(符合「没有身份」) - 判据 +3(`commands_live_tests.rs`):① `memory_key_prefers_window_title`(键规则, 含窗口优先/未选窗口两种路径)② 🔴 `none_window_path_tier_roundtrip_across_reload` ——`source_window == None` 路径:写入 → **重新 load**(= 新进程)→ `resolve_start_tier` 读回同一档;**负控**:无记忆 ⇒ `None`(不覆写 ⇒ 既有默认档行为)③ 🔴 `memory_key_taken_before_dedupe`(根因判据:键取值点必须在 `dedupe_title` 之前, 且 `let memory_title` 绑定**恰一处**——防后置同名键把前置键遮蔽) - 同步豁免表 `commands_live.rs` 行 471 → 487(§C18.2 blob 构造) 门禁:`cargo test` 全量串行 + `cargo build` dead_code 0 + clippy 集合差异 + 三闸全绿 (未用 --no-verify)。前端本轮零改动(未跑 vitest;文件清单见报告)。
1 parent 3d49342 commit 17d3309

3 files changed

Lines changed: 78 additions & 4 deletions

File tree

‎app/src-tauri/src/commands_live.rs‎

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,11 @@ pub async fn start_live_session(
7474
trimmed.chars().take(100).collect()
7575
};
7676

77+
// 批 7 T19(R-11/§C51.4):记忆键必须在**净化去重之前**取——键空间是「课程/窗口标识」,
78+
// 而 `dedupe_title` 的产物是**逐会话唯一**的展示标题(同源第二次会话即带 "(2)" 后缀)
79+
// ⇒ 一旦去重后缀进键,跨会话记忆**必读不回**(R-11 实测的失效路径)。
80+
let memory_title = memory_key_for_start(source_window.as_deref(), &title);
81+
7782
// REQ-282(v0.19.6):标题内容化 A 层——来源名净化(剥「 - 抖音」等固定
7883
// 平台尾缀)+ 近 90 天同源去重(抖音 #2)。查询失败不阻断采集(降级原标题)。
7984
let title = match state.db.recent_session_titles(90) {
@@ -87,9 +92,7 @@ pub async fn start_live_session(
8792
};
8893

8994
// 批 7 T19(§11-8「档位选完真生效 / 跨会话记住」):档位解析 = 显式入参 > 记忆体
90-
// (键用**窗口标题**——与检测卡写记忆时同一个键;会话标题已过同源去重/平台尾缀
91-
// 净化,不能当记忆键)> None(不覆写 ⇒ 既有默认档行为逐字不变)。
92-
let memory_title = source_window.clone().unwrap_or_else(|| title.clone());
95+
// (键见上一段的 `memory_title`)> None(不覆写 ⇒ 既有默认档行为逐字不变)。
9396
let resolved_tier = {
9497
let memory = state
9598
.profile_memory
@@ -171,6 +174,19 @@ fn seed_initial_tier(
171174
true
172175
}
173176

177+
/// 会话启动的**记忆键**(批 7 T19 · R-11/§C51.4;纯函数,可离线单测)。
178+
///
179+
/// @ai-context: 键空间 = **课程/窗口标识**(检测卡写记忆时用的同一个键)。窗口标题优先;
180+
/// 未选窗口(全屏)时用**净化去重之前**的会话标题——去重后缀("(2)" 等)是
181+
/// 逐会话派生的展示标题,不是稳定标识,一旦进键则记忆必读不回。
182+
/// @ai-context: 未选窗口路径的语义(如实登记):该路径无窗口级身份,键是前端传入的占位
183+
/// 标题(`ClassroomPage` 传常量「实时课堂」);且检测卡在该路径不渲染
184+
/// (`ProfileDetector` 无 windowTitle 即 return null)⇒ UI 无写入口,
185+
/// 本路径的记忆只在**同一占位标题**下自洽(跨标题不迁移,符合"没有身份")。
186+
fn memory_key_for_start(source_window: Option<&str>, session_title: &str) -> String {
187+
source_window.unwrap_or(session_title).to_string()
188+
}
189+
174190
/// 会话启动档位解析(批 7 T19 · 规格 §11-8 的单一裁决点;纯函数,可离线单测)。
175191
///
176192
/// @ai-context: 优先级 = 显式入参(前端仅在**用户显式改档**时传)> 记忆体(同标题/

‎app/src-tauri/src/commands_live_tests.rs‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,3 +93,61 @@ fn start_path_seeds_tier_into_override_slot() {
9393
// 且 starter 内部确实先解析档位(防"接了空值")
9494
assert!(code.contains("resolve_start_tier(tier.as_deref()"), "档位解析未接入 start 路径");
9595
}
96+
97+
/// 批 7 T19(R-11/§C51.4)记忆键规则:窗口标题优先;未选窗口用会话标题原文。
98+
#[test]
99+
fn memory_key_prefers_window_title() {
100+
assert_eq!(memory_key_for_start(Some("零基础化妆教程"), "实时课堂"), "零基础化妆教程");
101+
assert_eq!(memory_key_for_start(None, "实时课堂"), "实时课堂");
102+
}
103+
104+
/// 🔴 批 7 T19(R-11/§C51.4)**未选窗口路径的跨会话判据**:写入 → 重新 load → 读回同一档;
105+
/// 负控:无记忆 ⇒ None(不覆写 ⇒ 既有默认档行为)。
106+
#[test]
107+
fn none_window_path_tier_roundtrip_across_reload() {
108+
// Arrange:未选窗口(全屏)——键 = 前端传入的占位标题(净化去重之前)
109+
let key = memory_key_for_start(None, "实时课堂");
110+
let dir = tempfile::tempdir().unwrap();
111+
let path = dir.path().join("profile_memory.json");
112+
let mut memory = ProfileMemory::default();
113+
memory.remember_tier(&key, VisualTier::Rich);
114+
memory.save(&path).unwrap();
115+
// Act:**重新 load**(= 新进程/新会话)→ 走 start 的档位解析
116+
let reopened = ProfileMemory::load(&path);
117+
// Assert:新会话读到的档 == 写入的档
118+
assert_eq!(
119+
resolve_start_tier(None, &reopened, &key),
120+
Some(VisualTier::Rich),
121+
"未选窗口路径的跨会话记忆必须成立(R-11 缺陷点)"
122+
);
123+
// 负控:无记忆 ⇒ None(不覆写)
124+
assert_eq!(resolve_start_tier(None, &ProfileMemory::default(), &key), None);
125+
}
126+
127+
/// 🔴 批 7 T19(R-11/§C51.4)**根因判据**:记忆键必须在 `dedupe_title` **之前**取,且**恰一处**。
128+
///
129+
/// @ai-context: 去重后缀("(2)" 等)由 `dedupe_title` 逐会话派生 ⇒ 一旦它进键,同源第二次
130+
/// 会话就换了键,跨会话记忆必读不回。此判据按**源码顺序**钉住该不变式,并数
131+
/// 「`let memory_title` 绑定数 == 1」——防在 dedupe 之后再绑一次同名键把前置键
132+
/// **遮蔽**(R-11 的缺陷形态正是"键取自去重后")。注释已剥(注释里写一句不算)。
133+
#[test]
134+
fn memory_key_taken_before_dedupe() {
135+
let code: String = include_str!("commands_live.rs")
136+
.lines()
137+
.map(|l| if l.contains("://") { l } else { l.split("//").next().unwrap_or("") })
138+
.collect::<Vec<_>>()
139+
.join("\n");
140+
assert_eq!(
141+
code.matches("let memory_title").count(),
142+
1,
143+
"记忆键绑定必须恰一处(多于一处 ⇒ 可被后置键遮蔽,R-11 缺陷形态)"
144+
);
145+
let key_pos = code.find("let memory_title = memory_key_for_start(").expect("记忆键取值点缺失");
146+
let dedupe_pos = code.find("dedupe_title(").expect("去重调用点缺失");
147+
let resolve_pos = code.find("resolve_start_tier(").expect("档位解析调用点缺失");
148+
assert!(
149+
key_pos < dedupe_pos,
150+
"记忆键必须在 dedupe_title 之前取(去重后缀进键 ⇒ 跨会话记忆必读不回,R-11 根因)"
151+
);
152+
assert!(dedupe_pos < resolve_pos, "档位解析必须在键确定之后(用同一把键)");
153+
}

‎docs/standards/line-limit-exemptions.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@
6969
| app/src/components/NoteEditView.tsx | 412 | v0.13.6(审查 H1 修复):forwardRef 命令式 flushSave 出口(ESC 先保存后刷新)+ flushLatest 最终保存;v0.15 剪贴板图片 paste(useClipboardImagePaste+插入)+ 外链图下载导入——编辑视图保存/快捷键/工具栏/图片入口内聚(textarea 降级路径与 CM 版同步) | 若再增长:工具栏与 MarkdownEdit 快捷键拆至 NoteToolbar.tsx |
7070
| app/src-tauri/src/live_session_loop.rs | 410 | v0.7.0 M0 拆分产物(音频编排循环):主循环 + 长静音/音量骤变/VAD 段事件写入 + drain/停止 flush;LiveSessionCtx 聚合上下文;A1 暂停边沿 + P1 停止 drain 重构;H1 修复(drain_deadline 改 Option,draining 置位时才计算) | 若再增长:事件写入块拆至 live_session_events.rs |
7171
| app/src/components/RichEditorView.tsx | 409 | v0.20.13 批 8(REQ-317)编辑态选区右键菜单接线(CM contextmenu extension + 动作分发,323→399;**前置 323 超 300 为 v0.20.8 偏差登记未登记债务**,本次实测纠偏并登记)——选区纯逻辑已拆 utils/noteSelectionMenu.ts 与 note-selection/SelectionActionMenu.tsx,本文件仅留宿主接线 | 若再增长:工具栏 action 分派拆至 commands/ 域(既有 toolbarCommands/headingCommand 范式) |
72-
| app/src-tauri/src/commands_live.rs | 471 | 实时采集命令域(启动/停止/档案热切换/剪辑监听);v0.13.6(REQ-220)update_live_profile 细目参数与校验 + 审查轮(clear sentinel 空串语义/fine 计入至少一项)再增 | 若再增长:档案覆写命令拆至 commands_live_profile.rs |
72+
| app/src-tauri/src/commands_live.rs | 487 | 实时采集命令域(启动/停止/档案热切换/剪辑监听);v0.13.6(REQ-220)update_live_profile 细目参数与校验 + 审查轮(clear sentinel 空串语义/fine 计入至少一项)再增 | 若再增长:档案覆写命令拆至 commands_live_profile.rs |
7373
| app/src/components/ProfileDetector.tsx | 448 | 档案检测组件:投票/确认流/记忆偏好 UI + v0.11.5 Task 5 冲突提示内聚 + v0.13.6(REQ-219~222)形态 10 下拉/领域 20 下拉/细目多选 chips/分区映射形态优先 + 审查轮(onProfileChange ref/独立 try/fine_ids 同步,实测 2026-08-24) | 若再增长:确认流与细目 chips 拆至 ProfileConfirmFlow.tsx |
7474
| app/src-tauri/src/live_session_pause_tests.rs | 395 | plan_edge_observation 为纯函数(无 epoch/DB/emit 依赖——会话时刻由调用方以参数传入),可完整离线测真值表;事件对时刻单调性在此验证(DB 插入顺序 = events 顺序,单调即时间轴有序)。(自动摘取,待细化) | 若再增长:按职责拆分 |
7575
| app/src-tauri/src/live_session.rs | 394 | 会话装配/状态(LiveSessionParams 聚合 + run_session_after_engine 骨架 + ProfileOverride 细目字段);v0.13.6 +2 | 若再增长:ProfileOverride 与参数分拆至 live_session_params.rs |

0 commit comments

Comments
 (0)