Skip to content

Commit 0885f86

Browse files
committed
feat(window-recognition): 窗口选择记忆层(标题模板hash+boost+LRU)
1 parent 2e36c5e commit 0885f86

2 files changed

Lines changed: 234 additions & 0 deletions

File tree

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
// @vitest-environment node
2+
/**
3+
* 窗口记忆纯函数测试
4+
* @ai-context: better-sqlite3 经 electron-rebuild 为 Electron ABI,vitest 无法
5+
* 实例化——仅覆盖纯函数层(模板归一化/hash/boost/LRU 淘汰),SQLite 读写为薄封装不测。
6+
*/
7+
import { describe, it, expect } from 'vitest';
8+
import {
9+
normalizeTitleTemplate,
10+
hashTitleTemplate,
11+
computeMemoryBoost,
12+
pickLruEviction,
13+
} from './windowHistory.js';
14+
15+
describe('normalizeTitleTemplate', () => {
16+
it('归一化数字(会议号/章节/未读数)', () => {
17+
expect(normalizeTitleTemplate('腾讯会议 123456789')).toBe('腾讯会议 {n}');
18+
expect(normalizeTitleTemplate('琅琊榜 第12集')).toBe('琅琊榜 第{n}集');
19+
});
20+
it('稳定视频站标题模板', () => {
21+
expect(normalizeTitleTemplate('新手化妆教程 - bilibili.com')).toBe('新手化妆教程 - bilibili.com');
22+
});
23+
});
24+
25+
describe('hashTitleTemplate', () => {
26+
it('同模板同 hash,不同模板不同 hash', () => {
27+
const t = '腾讯会议 {n}';
28+
expect(hashTitleTemplate(t)).toBe(hashTitleTemplate(t));
29+
expect(hashTitleTemplate(t)).not.toBe(hashTitleTemplate('腾讯会议 其他'));
30+
});
31+
});
32+
33+
describe('computeMemoryBoost', () => {
34+
it('useCount 封顶 30(min(count,5)*6),recency 7 天内 +10', () => {
35+
const now = Date.now();
36+
expect(computeMemoryBoost({ useCount: 3, lastUsedAt: now } as any, now)).toBe(18 + 10);
37+
expect(computeMemoryBoost({ useCount: 99, lastUsedAt: now } as any, now)).toBe(30 + 10);
38+
});
39+
it('30 天内 +5,超过 30 天仅 count 分', () => {
40+
const now = Date.now();
41+
const DAY = 24 * 3600 * 1000;
42+
expect(computeMemoryBoost({ useCount: 1, lastUsedAt: now - 10 * DAY } as any, now)).toBe(6 + 5);
43+
expect(computeMemoryBoost({ useCount: 1, lastUsedAt: now - 40 * DAY } as any, now)).toBe(6);
44+
});
45+
it('null 记忆返回 0', () => {
46+
expect(computeMemoryBoost(null, Date.now())).toBe(0);
47+
});
48+
});
49+
50+
describe('pickLruEviction', () => {
51+
it('返回最久未使用条目', () => {
52+
const entries = [
53+
{ titleHash: 'a', lastUsedAt: 100 },
54+
{ titleHash: 'b', lastUsedAt: 50 },
55+
{ titleHash: 'c', lastUsedAt: 200 },
56+
];
57+
expect(pickLruEviction(entries as any)).toBe('b');
58+
});
59+
});

‎client/electron/windowHistory.ts‎

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
/**
2+
* 窗口选择历史记忆(纯函数 + SQLite 薄封装)
3+
*
4+
* 按「进程名 + 标题模板」维度记忆用户选择:模板归一化(会议号/章节数字 →
5+
* 占位符)使同一窗口的反复选择命中同一记忆条目。boost 分 = min(useCount,5)×6
6+
* + recency(7 天内 +10 / 30 天内 +5,封顶 +40)。
7+
*
8+
* @ai-context: 标题模板化是记忆可靠性的关键——"腾讯会议 123456789"与
9+
* "腾讯会议 987654321"必须命中同一条记忆,否则每次会议号变化都失忆。
10+
* @ai-context EN: Choice memory keyed by process + normalized title template.
11+
* SQLite writes are a thin layer over sqliteService; pure functions are
12+
* unit-testable without the Electron-ABI better-sqlite3.
13+
*/
14+
import { getConnection } from './db/sqliteService.js';
15+
16+
// ================================================================
17+
// 类型定义
18+
// ================================================================
19+
20+
export interface WindowMemoryEntry {
21+
processName: string;
22+
titleHash: string;
23+
titleTemplate: string;
24+
courseName?: string;
25+
useCount: number;
26+
lastUsedAt: number;
27+
}
28+
29+
/** 记忆上限(LRU 淘汰) */
30+
export const MEMORY_MAX_ENTRIES = 100;
31+
32+
// ================================================================
33+
// 纯函数
34+
// ================================================================
35+
36+
/** 数字(含千分位/标点包裹的编号)→ 占位符;保留标题其余结构 */
37+
export function normalizeTitleTemplate(title: string): string {
38+
return (title ?? '')
39+
.replace(/\d[\d,,..\-—_]*/g, '{n}')
40+
.replace(/\{n\}[^-\w]*\{n\}/g, '{n}');
41+
}
42+
43+
/** djb2 字符串 hash → hex(稳定、碰撞概率可接受) */
44+
export function hashTitleTemplate(template: string): string {
45+
let h = 5381;
46+
for (let i = 0; i < template.length; i += 1) {
47+
h = ((h << 5) + h + template.charCodeAt(i)) >>> 0;
48+
}
49+
return h.toString(16);
50+
}
51+
52+
/**
53+
* 计算记忆 boost 分(封顶 +40)。
54+
* @param now 当前时间戳(注入便于测试)
55+
*/
56+
export function computeMemoryBoost(entry: WindowMemoryEntry | null, now: number): number {
57+
if (!entry) return 0;
58+
const DAY = 24 * 3600 * 1000;
59+
let boost = Math.min(entry.useCount, 5) * 6;
60+
const age = now - entry.lastUsedAt;
61+
if (age >= 0 && age <= 7 * DAY) boost += 10;
62+
else if (age > 7 * DAY && age <= 30 * DAY) boost += 5;
63+
return Math.min(boost, 40);
64+
}
65+
66+
/** LRU 淘汰:返回最久未使用的 titleHash(entries 为空返回 null) */
67+
export function pickLruEviction(
68+
entries: Array<Pick<WindowMemoryEntry, 'titleHash' | 'lastUsedAt'>>,
69+
): string | null {
70+
let oldest: string | null = null;
71+
let oldestAt = Infinity;
72+
for (const e of entries) {
73+
if (e.lastUsedAt < oldestAt) {
74+
oldestAt = e.lastUsedAt;
75+
oldest = e.titleHash;
76+
}
77+
}
78+
return oldest;
79+
}
80+
81+
// ================================================================
82+
// SQLite 薄封装(运行时路径,异常由调用方降级)
83+
// ================================================================
84+
85+
/** 建表(幂等) */
86+
function ensureTable(): void {
87+
getConnection()
88+
.prepare(
89+
`CREATE TABLE IF NOT EXISTS window_memory (
90+
id INTEGER PRIMARY KEY AUTOINCREMENT,
91+
process_name TEXT NOT NULL,
92+
title_hash TEXT NOT NULL,
93+
title_template TEXT NOT NULL,
94+
course_name TEXT,
95+
use_count INTEGER NOT NULL DEFAULT 1,
96+
last_used_at INTEGER NOT NULL,
97+
UNIQUE(process_name, title_hash)
98+
)`,
99+
)
100+
.run();
101+
}
102+
103+
/** 记录一次选择(upsert:use_count+1、更新 last_used_at 与 course_name) */
104+
export function recordChoice(
105+
processName: string,
106+
title: string,
107+
courseName?: string,
108+
): void {
109+
try {
110+
ensureTable();
111+
const template = normalizeTitleTemplate(title);
112+
const titleHash = hashTitleTemplate(template);
113+
const now = Date.now();
114+
const db = getConnection();
115+
db.prepare(
116+
`INSERT INTO window_memory (process_name, title_hash, title_template, course_name, use_count, last_used_at)
117+
VALUES (?, ?, ?, ?, 1, ?)
118+
ON CONFLICT(process_name, title_hash) DO UPDATE SET
119+
use_count = use_count + 1,
120+
last_used_at = excluded.last_used_at,
121+
course_name = COALESCE(excluded.course_name, window_memory.course_name)`,
122+
).run(processName.toLowerCase(), titleHash, template, courseName ?? null, now);
123+
124+
// LRU 淘汰:超过上限删除最久未使用条目
125+
const row = db
126+
.prepare('SELECT COUNT(*) AS c FROM window_memory')
127+
.get() as { c: number };
128+
if (row.c > MEMORY_MAX_ENTRIES) {
129+
const victims = db
130+
.prepare('SELECT title_hash, last_used_at FROM window_memory ORDER BY last_used_at ASC LIMIT ?')
131+
.all(row.c - MEMORY_MAX_ENTRIES) as Array<{ title_hash: string; last_used_at: number }>;
132+
const evict = pickLruEviction(
133+
victims.map((v) => ({ titleHash: v.title_hash, lastUsedAt: v.last_used_at })),
134+
);
135+
if (evict) {
136+
db.prepare('DELETE FROM window_memory WHERE title_hash = ?').run(evict);
137+
}
138+
}
139+
} catch (err) {
140+
console.warn('[windowHistory] recordChoice failed:', err);
141+
}
142+
}
143+
144+
/** 查询记忆条目(进程名 + 标题模板);无命中返回 null */
145+
export function lookupMemory(
146+
processName: string,
147+
title: string,
148+
): WindowMemoryEntry | null {
149+
try {
150+
ensureTable();
151+
const template = normalizeTitleTemplate(title);
152+
const row = getConnection()
153+
.prepare(
154+
`SELECT process_name AS processName, title_hash AS titleHash, title_template AS titleTemplate,
155+
course_name AS courseName, use_count AS useCount, last_used_at AS lastUsedAt
156+
FROM window_memory WHERE process_name = ? AND title_hash = ?`,
157+
)
158+
.get(processName.toLowerCase(), hashTitleTemplate(template)) as
159+
| WindowMemoryEntry
160+
| undefined;
161+
return row ?? null;
162+
} catch {
163+
return null;
164+
}
165+
}
166+
167+
/** 清空全部记忆(设置页入口) */
168+
export function clearMemory(): void {
169+
try {
170+
ensureTable();
171+
getConnection().prepare('DELETE FROM window_memory').run();
172+
} catch {
173+
// 静默降级
174+
}
175+
}

0 commit comments

Comments
 (0)