Skip to content

Commit 3c1fcce

Browse files
committed
fix(rag): v0.19.2/3 审查即修——锁窗外移/启动兜底/建议区竞态/文案收口
1 parent 118f733 commit 3c1fcce

10 files changed

Lines changed: 305 additions & 107 deletions

File tree

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

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ use crate::kb_search::KbHit;
2424
pub const DISCOVERY_LIMIT: usize = 10;
2525
/// 相似概念提示上限(提示型——控制噪音)。
2626
pub const SIMILAR_LIMIT: usize = 6;
27+
/// 建议查询字符上界(essence 无落库上限——纵深防御,见 kb_discovery_suggest)。
28+
const DISCOVERY_QUERY_MAX_CHARS: usize = 240;
2729

2830
/// 跨体系相似概念提示(展示型:人工处置,不自动合并)。
2931
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
@@ -81,11 +83,16 @@ impl Db {
8183
.map(|l| (l.target_type, l.target_id))
8284
.collect();
8385
// ③ 证据候选:name + essence 混合检索 → 排除已链接 → 截顶
86+
// (v0.19.3 审查即修:essence 落库无长度上限——查询字符截断 240,
87+
// 防跨 token 无上限的 FTS 表达式膨胀;kb_fts 96 硬顶只约束 CJK 段)
8488
let mut query = name.clone();
8589
if let Some(e) = essence.as_deref().map(str::trim).filter(|e| !e.is_empty()) {
8690
query.push(' ');
8791
query.push_str(e);
8892
}
93+
if query.chars().count() > DISCOVERY_QUERY_MAX_CHARS {
94+
query = query.chars().take(DISCOVERY_QUERY_MAX_CHARS).collect();
95+
}
8996
let hits = self.kb_search(&query, DISCOVERY_LIMIT)?;
9097
let evidence: Vec<KbHit> = hits
9198
.into_iter()
@@ -130,6 +137,8 @@ impl Db {
130137
let mut hints: Vec<SimilarHint> = concepts
131138
.into_iter()
132139
.filter(|c| c.id != concept_id && c.system_id != system_id)
140+
// archived(归档)概念不参与提示(展示噪声;v0.19.3 审查即修)
141+
.filter(|c| c.status != "archived")
133142
.filter(|c| concept_name_overlap(&norm, &normalize_name(&c.name)))
134143
.map(|c| SimilarHint {
135144
system_id: c.system_id,
@@ -141,9 +150,11 @@ impl Db {
141150
concept_name: c.name,
142151
reason: "名称高度相似(互相包含)".to_string(),
143152
})
144-
.take(SIMILAR_LIMIT)
145153
.collect();
154+
// 提示型防噪:按体系/id 序稳定取前 N(无相关度排序——二进制判据,
155+
// 先到先得即防噪音上限;不自动合并,人工处置)
146156
hints.sort_by(|a, b| a.system_id.cmp(&b.system_id).then(a.concept_id.cmp(&b.concept_id)));
157+
hints.truncate(SIMILAR_LIMIT);
147158
Ok(hints)
148159
}
149160
}

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

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,3 +139,42 @@ fn missing_concept_returns_none() {
139139
let db = mem_db();
140140
assert!(db.kb_discovery_suggest(9999).expect("suggest").is_none());
141141
}
142+
143+
#[test]
144+
fn archived_cross_system_concept_not_hinted() {
145+
// Arrange:跨体系归档概念不参与相似提示(v0.19.3 审查即修)
146+
let db = mem_db();
147+
let sys_a = insert_system(&db, "化妆体系");
148+
let sys_b = insert_system(&db, "绘画体系");
149+
let x = insert_concept(&db, sys_a, "眼影晕染");
150+
insert_concept(&db, sys_b, "眼影晕染技巧");
151+
db.with_conn(|c| {
152+
c.execute("UPDATE knowledge_concepts SET status='archived' WHERE name='眼影晕染技巧'", [])?;
153+
Ok(())
154+
})
155+
.expect("archive");
156+
// Act
157+
let res = db.kb_discovery_suggest(x).expect("suggest").expect("exists");
158+
// Assert
159+
assert!(res.similar.is_empty(), "归档概念不得提示: {:?}", res.similar);
160+
}
161+
162+
#[test]
163+
fn unbounded_essence_is_truncated_before_search() {
164+
// Arrange:essence 无落库上限——查询字符必须截断(纵深防御,审查即修)
165+
let db = mem_db();
166+
let sys = insert_system(&db, "化妆体系");
167+
let concept = insert_concept(&db, sys, "眼影晕染");
168+
let long_essence: String = "blending ".repeat(200);
169+
db.with_conn(|c| {
170+
c.execute(
171+
"UPDATE knowledge_concepts SET essence=?1 WHERE id=?2",
172+
rusqlite::params![long_essence, concept],
173+
)?;
174+
Ok(())
175+
})
176+
.expect("essence");
177+
// Act/Assert:不 panic、evidence 有界(此处无命中 → 空列表合法)
178+
let res = db.kb_discovery_suggest(concept).expect("suggest").expect("exists");
179+
assert!(res.evidence.len() <= 10);
180+
}

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

Lines changed: 164 additions & 86 deletions
Large diffs are not rendered by default.

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,8 +81,13 @@ const SYSTEM_PROCESSES: &[&str] = &[
8181
];
8282

8383
/// 判定是否系统/工具窗口(纯函数;进程名空 → false 保留兜底选择)。
84+
///
85+
/// @ai-context: 生产输入来自 QueryFullProcessImageNameW(其 lpdwSize 语义在
86+
/// MSDN 历史版本表述不一致——含/不含终止符两种口径都出现过),
87+
/// 尾部 \0/空白会使下方精确相等静默全失效——先 trim 防御
88+
/// (v0.19.3 审查即修;单测直喂纯字符串未覆盖该路径)。
8489
pub fn is_system_window(_title: &str, process_name: &str) -> bool {
85-
let p = process_name.trim().to_lowercase();
90+
let p = process_name.trim().trim_end_matches(['\0', ' ', '\t']).to_lowercase();
8691
!p.is_empty() && SYSTEM_PROCESSES.iter().any(|s| *s == p)
8792
}
8893

‎app/src/components/DiscoverySuggestSection.tsx‎

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
* 既有 link_knowledge_target 落库(白名单 note/fragment,零迁移,
88
* 幂等——建议零双写,ADR-024);跨体系相似概念为提示型只展示。
99
*/
10-
import { useCallback, useEffect, useState } from "react";
10+
import { useCallback, useEffect, useRef, useState } from "react";
1111
import { invoke } from "@tauri-apps/api/core";
1212
import type { KbDiscoveryResult, KbHit } from "../types";
1313
import { hitLabel } from "../utils/kbHits";
@@ -30,21 +30,29 @@ export default function DiscoverySuggestSection({ systemId, conceptId, onChanged
3030
const [selected, setSelected] = useState<Set<number>>(new Set());
3131
const [busy, setBusy] = useState(false);
3232
const [msg, setMsg] = useState("");
33+
// v0.19.3 审查 MED-3:请求序号守卫——概念快速切换时旧响应必须丢弃
34+
// (A 的候选挂到 B 概念 = 真实错链;切换/拉取即失效)
35+
const seqRef = useRef(0);
3336

34-
const load = useCallback(async () => {
35-
if (!enabled) return;
37+
const load = useCallback(async (reset: boolean) => {
38+
const seq = ++seqRef.current;
3639
setBusy(true);
37-
setMsg("");
40+
if (reset) {
41+
// 切换概念/首次拉取:立即清屏(旧候选不得在加载失败后残留可勾选)
42+
setResult(null);
43+
setSelected(new Set());
44+
}
3845
try {
3946
const r = await invoke<KbDiscoveryResult>("kb_discovery_suggest", { conceptId });
47+
if (seq !== seqRef.current) return; // 过期响应丢弃
4048
setResult(r);
4149
setSelected(new Set());
4250
} catch (e) {
43-
setMsg(String(e));
51+
if (seq === seqRef.current) setMsg(String(e));
4452
} finally {
45-
setBusy(false);
53+
if (seq === seqRef.current) setBusy(false);
4654
}
47-
}, [enabled, conceptId]);
55+
}, [conceptId]);
4856

4957
useEffect(() => {
5058
let disposed = false;
@@ -61,8 +69,8 @@ export default function DiscoverySuggestSection({ systemId, conceptId, onChanged
6169
}, []);
6270

6371
useEffect(() => {
64-
if (enabled === true) void load();
65-
// 概念切换 → 清旧建议(enabled 常驻变化才拉取;依赖包含 enabled 防竞态陈旧)
72+
if (enabled === true) void load(true);
73+
// 概念切换即重拉(reset=true 清旧屏);enabled 转 true 时才首拉
6674
// eslint-disable-next-line react-hooks/exhaustive-deps
6775
}, [enabled, conceptId]);
6876

@@ -100,7 +108,8 @@ export default function DiscoverySuggestSection({ systemId, conceptId, onChanged
100108
}
101109
onChanged();
102110
setMsg(`已挂接 ${selected.size} 条引用——建议不落库,仅在确认后经引用通道落库`);
103-
await load();
111+
// 刷新候选(已链者随即消失);成功文案保留(load 不主动清 msg——审查 LOW-4)
112+
await load(false);
104113
} catch (e) {
105114
setMsg(`挂接失败(部分成功需重试): ${e}`);
106115
} finally {
@@ -116,7 +125,7 @@ export default function DiscoverySuggestSection({ systemId, conceptId, onChanged
116125
本地检索候选 · 人工确认后经引用通道落库(零双写)
117126
</span>
118127
</div>
119-
<button onClick={() => void load()} disabled={busy} style={{ fontSize: 11, color: "#0d9488", cursor: "pointer", border: "none", background: "none", padding: 0 }}>
128+
<button onClick={() => void load(false)} disabled={busy} style={{ fontSize: 11, color: "#0d9488", cursor: "pointer", border: "none", background: "none", padding: 0 }}>
120129
{busy ? "计算中…" : result ? "⟳ 重新建议" : "🔍 生成建议"}
121130
</button>
122131
{result && (

‎app/src/components/WindowSelectCard.tsx‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,19 @@ export function WindowSelectCard({ windows, selected, onSelect, onRefresh, loadi
133133
{selected ? (
134134
<>
135135
<div style={{ flex: 1, minWidth: 0 }}>
136-
<div style={{ whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{selected.title}</div>
136+
<div style={{ whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
137+
{selected.title}
138+
{/* v0.19.3 审查 LOW-3:折叠卡也标记系统窗口(标题常为终端提示符/
139+
目录名,无标记易与"已隐藏"心智冲突) */}
140+
{selected.systemWindow && (
141+
<span
142+
style={{ marginLeft: 6, fontSize: 10, color: "#9ca3af", background: "#f3f4f6", borderRadius: 4, padding: "0 4px" }}
143+
title="系统/工具窗口——列表默认隐藏,可在选择器上方勾选显示系统窗口找回"
144+
>
145+
🖥 系统窗口
146+
</span>
147+
)}
148+
</div>
137149
<div style={{ fontSize: 11, color: "#0d9488" }}>
138150
进程: {selected.processName || "未知"} (PID {selected.pid})
139151
</div>

‎app/src/pages/ClassroomPage.tsx‎

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -121,8 +121,9 @@ export default function ClassroomPage({ onOpenSessions }: { onOpenSessions?: (se
121121
warmUp();
122122
// TD-004:课堂助手页常驻挂载(display:none 切换不卸载)——本 cleanup 只
123123
// 在应用卸载/StrictMode replay 时触发,**不在此释放**:release 会取消正在
124-
// 加载的预热线程造成 dev 双加载与"已完成→随即取消"误读;真实回收由后端
125-
// 15min TTL 与显式 release_live_prepare 命令承担(浮窗/退出路径保留)
124+
// 加载的预热线程造成 dev 双加载与"已完成→随即取消"误读;页面无卸载时机,
125+
// 引擎回收由 15min TTL 兜底(release_live_prepare 命令保留供未来显式
126+
// 回收扩展,当前无调用方——v0.19.3 审查 LOW-2 注释如实化)
126127
// eslint-disable-next-line react-hooks/exhaustive-deps
127128
}, []);
128129

@@ -148,11 +149,16 @@ export default function ClassroomPage({ onOpenSessions }: { onOpenSessions?: (se
148149
if (e.payload === "recording") {
149150
// 后端确认录制中:保持/恢复活动态(invoke resolve 可能更早到达);
150151
// v0.19.2:此刻引擎真正就绪、音频与画面同刻启动——结束启动过渡态
152+
liveActiveRef.current = true; // ref 同步直写——防 resolve 读旧值(审查 LOW-1)
151153
setLiveActive(true);
152154
setStopping(false);
153155
setStarting(false);
156+
// v0.19.3 审查 MED-1:starting→recording 迁移统一覆写文案——
157+
// 非就绪启动成功路径不得残留「引擎就绪中…」常驻状态行
158+
setStatus("实时捕获已开始");
154159
} else {
155160
// stopped / failed:会话已结束
161+
liveActiveRef.current = false;
156162
setLiveActive(false);
157163
setLiveSessionId(null);
158164
setStopping(false);
@@ -322,9 +328,17 @@ export default function ClassroomPage({ onOpenSessions }: { onOpenSessions?: (se
322328
if (starting) return; // 防双击双会话(后端 active 检查前即挡住)
323329
setLiveError("");
324330
setStarting(true);
331+
// v0.19.3 审查 LOW-2:点击时重同步一次预热状态——TTL 静默回收后本地
332+
// prepareState 可能仍为陈旧 'ready'(wasEngineReady 误判 → 控件提前/
333+
// 丢音频头);prepare 幂等:已有就绪秒返,无预备则新起加载(start 同槽
334+
// 有界等待,单引擎语义不变)
335+
const freshPrepare = await invoke<PrepareState>("prepare_live_session")
336+
.then((s) => s as PrepareState)
337+
.catch(() => "idle" as PrepareState);
338+
setPrepareState(freshPrepare);
325339
// 就绪态点击=交接路径(引擎已加载)——resolve 即已开录,可直接置活动;
326340
// 非就绪态 resolve 可能只是"已排队/内联加载中"——等 recording 事件
327-
const wasEngineReady = prepareState === "ready";
341+
const wasEngineReady = freshPrepare === "ready";
328342
try {
329343
const title = selectedWindow ? selectedWindow.title.slice(0, NOTE_TITLE_MAX_LEN) : "实时课堂";
330344
const id = await invoke<number>("start_live_session", {
@@ -354,6 +368,7 @@ export default function ClassroomPage({ onOpenSessions }: { onOpenSessions?: (se
354368
if (String(e).includes("已有进行中的实时会话")) {
355369
try {
356370
const s = await invoke<LiveSessionStatus>("live_session_status");
371+
liveActiveRef.current = s.active;
357372
setLiveActive(s.active);
358373
setLiveSessionId(s.sessionId);
359374
setLiveError(s.active ? "检测到采集仍在进行,已恢复状态;如需重启请先停止" : "状态已恢复");
@@ -366,6 +381,34 @@ export default function ClassroomPage({ onOpenSessions }: { onOpenSessions?: (se
366381
}
367382
};
368383

384+
// v0.19.3 审查 MED-2:starting 过渡态看门狗(对齐 TD-042 stopping 先例)——
385+
// recording/error/stopped 事件全部丢失时 20s(>后端 15s 上限)后查询真实
386+
// 状态收口:恢复活动态或退出等待态,避免"按钮永久禁用且无停止出口"死锁
387+
useEffect(() => {
388+
if (!starting || liveActive) return;
389+
const timer = setTimeout(() => {
390+
void (async () => {
391+
try {
392+
const s = await invoke<LiveSessionStatus>("live_session_status");
393+
if (s.active) {
394+
liveActiveRef.current = true;
395+
setLiveActive(true);
396+
setLiveSessionId(s.sessionId);
397+
setStatus("检测到采集进行中,已恢复状态(引擎就绪后同刻开录)");
398+
} else {
399+
setLiveSessionId(null);
400+
setStatus("启动状态未确认——引擎未开录;可直接重试(就绪即秒开)");
401+
}
402+
} catch (err) {
403+
setStatus(`启动状态查询失败: ${err}——请重试`);
404+
} finally {
405+
setStarting(false);
406+
}
407+
})();
408+
}, 20_000);
409+
return () => clearTimeout(timer);
410+
}, [starting, liveActive]);
411+
369412
/** 停止实时捕获 */
370413
const stopLive = async () => {
371414
// 停止过渡期:面板保持显示(live:status stopped 到达后由监听清除)

‎app/src/types/kb.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
/**
22
* 检索与发现层前端类型(v0.19 REQ-258/REQ-260——与 Rust serde camelCase 契约对齐)。
33
*/
4+
import type { KbHit } from "./chat";
45

56
/** 索引统计(Rust kb_reindex::KbIndexStats——设置「学习库」段 + 角标数据源) */
67
export interface KbIndexStats {
@@ -56,6 +57,6 @@ export interface SimilarHint {
5657
/** v0.19.3(REQ-261):概念「相关素材建议」发现结果(Rust DiscoveryResult) */
5758
export interface KbDiscoveryResult {
5859
/** 证据候选(KbHit 契约——snippet/来源与引用卡片一致;已链接者已排除) */
59-
evidence: import("./chat").KbHit[];
60+
evidence: KbHit[];
6061
similar: SimilarHint[];
6162
}

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,13 +70,13 @@
7070
| app/src-tauri/src/db_ai_chat.rs | 309 | AI 对话双表仓储 + v0.19.1(REQ-260)retrieval/meta_json 补列与行映射(+42)——SQL/行映射内聚(db_* 文件先例) | 若再增长:消息侧读写拆至 db_ai_chat_messages.rs |
7171
| app/src-tauri/src/db_fragments.rs | 309 | 碎片数据层 + v0.19.0(REQ-258)索引生命周期钩子(建/删/升笔记事务内清块重建,+16)——promote 显式事务与知识链接清理同域 | 若再增长:promote_fragment_to_note 事务拆至 db_fragments_promote.rs |
7272
| app/src-tauri/src/windows.rs | 342 | 窗口枚举/评分域 + v0.19.2(REQ-271/272):CaptureWindow.zOrder/systemWindow 字段 + 抖音/快手/B站客户端评分表(+28)——枚举系统副作用与评分纯函数同域便于单测(既有模式) | 若再增长:z 序/系统标记纯函数拆至 windows_meta.rs |
73-
| app/src-tauri/src/live_session_lifecycle.rs | 301 | 会话启动/预热生命周期 + v0.19.2(REQ-273)15s 就绪等待与超时回滚(+11)——start/prepare/release 与等待/回退策略强耦合于 impl 生命周期域 | 若再增长:wait_prepared_ready 与回退策略拆至 live_session_start.rs |
73+
| app/src-tauri/src/live_session_lifecycle.rs | 379 | 会话启动/预热生命周期 + v0.19.2/3(REQ-273 + 审查即修:锁外等待+枚举化+辅助函数抽取,+78)——start/prepare/release 与等待/回退策略强耦合于 impl 生命周期域 | 若再增长:wait_prepared_ready 与回退策略拆至 live_session_start.rs |
7474

7575
## 前端(app/src/,数字来自前端审查快照;Task #9/10 拆分进行中)
7676

7777
| 文件 | 行数 | 豁免理由 | 拆分计划 |
7878
|------|------|---------|---------|
79-
| app/src/pages/ClassroomPage.tsx | 702 | 装配层页面:左栏配置区(就绪清单/窗口选择/实时捕获/视频导入/OCR 设备/词表/素材)+ 右栏内容区;v0.15 左栏列状态再增;v0.19.2(REQ-271/273)系统窗口开关与启动过渡态(+59,实测 702)——**超 600 硬限为预存债务(TD-2026-08-30-A:v0.14 前已越线)持续累增,拆分计划(LiveCaptureCard)顺延待执行** | 若再增长:将实时捕获卡片拆出 LiveCaptureCard(状态与事件监听下沉)——超硬限必须拆 |
79+
| app/src/pages/ClassroomPage.tsx | 745 | 装配层页面:左栏配置区(就绪清单/窗口选择/实时捕获/视频导入/OCR 设备/词表/素材)+ 右栏内容区;v0.15 左栏列状态再增;v0.19.2/3(REQ-271/273 + 审查即修:状态机看门狗/预同步/文案收口,+43,实测 745)——**超 600 硬限为预存债务(TD-2026-08-30-A:v0.14 前已越线)持续累增,拆分计划(LiveCaptureCard)顺延待执行** | 若再增长:将实时捕获卡片拆出 LiveCaptureCard(状态与事件监听下沉)——超硬限必须拆 |
8080
| app/src/components/LiveActivityPanel.tsx | 510 | 实时活动面板:会话状态/转录流/OCR 预览/控制区多状态面板内聚(前端审查登记) | 若再增长:转录流与 OCR 预览拆至 LiveTranscriptStream.tsx / LiveOcrPreview.tsx |
8181
| app/src/components/SessionListPanel.tsx | 491 | v0.7.1 拆分产物:列表域 UI(双模式搜索/筛选排序/课程分组折叠/批量操作栏/内联转化)内聚——筛选/排序/选择为面板本地状态;v0.15 宽度 prop 适配(实测较登记值 501 回落) | 若再增长:批量操作栏与列表项拆至 SessionListRow.tsx |
8282
| app/src/components/AiServicePanel.tsx | 390 | v0.8.0 M1(REQ-138/139/140)AI 服务设置面板:全局开关/密钥管理(掩码+DPAPI 保存)/端点模型/测试连接/余额卡片/授权确认卡/审计列表——配置面板 UI 内聚 | 若再增长:余额卡片与审计列表拆至 AiBalanceCard.tsx / AiAuditList.tsx |

‎docs/versions/v0.19.3.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020

2121
## 测试与验证
2222

23-
- Rust:kb_discovery 6 用例(overlap 双向/单字拒绝/全角归一化、证据排除已链 note 与 fragment、跨体系相似仅跨体系、概念不存在 None)+ feature_flags kb_discovery roundtrip/legacy——`kb_discovery feature_flags` 过滤全绿
23+
- Rust:kb_discovery 7 用例(overlap 双向/单字拒绝/全角归一化、证据排除已链 note 与 fragment、跨体系相似仅跨体系、归档概念不提示、超长 essence 截断、概念不存在 None——审查即修补 2 例)+ feature_flags kb_discovery roundtrip/legacy——`kb_discovery feature_flags` 过滤全绿
2424
- 前端:DiscoverySuggestSection 3 用例(开关关不渲染零噪音 / 开启候选逐条确认仅走 link_knowledge_target(note+fragment 两路断言)+ onChanged + 刷新 / 空候选诚实文案)——vitest 71 文件 528 用例全绿;tsc 零错误
2525
- Rust 全量:**2052 通过 / 2 失败(note_filter 预存 TD-30-B)**;clippy 零告警
2626

0 commit comments

Comments
 (0)