Skip to content

Commit 0a4a6e1

Browse files
committed
feat(window-recognition): windowScorer 组合入口(信号注入+记忆查找,导出兼容)
1 parent d20054f commit 0a4a6e1

2 files changed

Lines changed: 118 additions & 89 deletions

File tree

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
// @vitest-environment node
2+
/**
3+
* 窗口评分组合入口测试
4+
* @ai-context: 兼容路径(无信号=纯标题,与改造前行为一致);信号注入路径;
5+
* 记忆查找注入(courseName 携带 + boost 计入总分)。
6+
*/
7+
import { describe, it, expect } from 'vitest';
8+
import { scoreAndFilterWindows } from './windowScorer.js';
9+
10+
const WIN = (id: string, title: string) => ({ id, title, thumbnail: '' });
11+
12+
describe('scoreAndFilterWindows — 兼容路径(无信号)', () => {
13+
it('空标题与黑名单窗口被过滤', () => {
14+
const out = scoreAndFilterWindows([WIN('1', ''), WIN('2', 'Program Manager'), WIN('3', '腾讯会议')]);
15+
expect(out.map((w) => w.id)).toEqual(['3']);
16+
});
17+
it('无信号时评分与旧逻辑等价(关键词排序)', () => {
18+
const out = scoreAndFilterWindows([WIN('1', '随便看看'), WIN('2', '腾讯会议 - 网课')]);
19+
expect(out[0].id).toBe('2');
20+
expect(out[0].score).toBeGreaterThanOrEqual(80);
21+
expect(out[1].score).toBe(0);
22+
});
23+
it('matched 兼容:填充最高权重理由', () => {
24+
const out = scoreAndFilterWindows([WIN('1', '腾讯会议 - 网课')]);
25+
expect(out[0].matched).toBeTruthy();
26+
});
27+
});
28+
29+
describe('scoreAndFilterWindows — 信号注入路径', () => {
30+
it('按 source id 注入信号参与评分', () => {
31+
const out = scoreAndFilterWindows(
32+
[WIN('window:111:0', '原神萌新攻略')],
33+
{
34+
signalsBySourceId: new Map([['window:111:0', { title: '原神萌新攻略', processName: 'steam.exe' }]]),
35+
},
36+
);
37+
expect(out[0].processName).toBe('steam.exe');
38+
expect(out[0].confidence).toBeDefined();
39+
expect(out[0].reasons?.length).toBeGreaterThan(0);
40+
});
41+
it('记忆查找注入:携带 courseName 并计入 boost', () => {
42+
const out = scoreAndFilterWindows(
43+
[WIN('window:111:0', '高等数学 - bilibili.com')],
44+
{
45+
signalsBySourceId: new Map([['window:111:0', { title: '高等数学 - bilibili.com' }]]),
46+
memoryLookup: () => ({ courseName: '高等数学', boost: 40 }),
47+
},
48+
);
49+
expect(out[0].memoryCourseName).toBe('高等数学');
50+
expect(out[0].score).toBeGreaterThanOrEqual(40);
51+
});
52+
it('排序:总分降序,同分按 id 稳定', () => {
53+
const out = scoreAndFilterWindows(
54+
[WIN('a', '随便看看'), WIN('b', '腾讯会议 - 网课'), WIN('c', '随便看看')],
55+
{
56+
signalsBySourceId: new Map([
57+
['a', { title: '随便看看' }],
58+
['b', { title: '腾讯会议 - 网课' }],
59+
['c', { title: '随便看看' }],
60+
]),
61+
},
62+
);
63+
expect(out.map((w) => w.id)).toEqual(['b', 'a', 'c']);
64+
});
65+
});

‎client/electron/windowScorer.ts‎

Lines changed: 53 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,18 @@
11
/**
2-
* 窗口智能评分与过滤模块
2+
* 窗口智能评分与过滤模块(组合入口)
33
*
4-
* 基于关键词匹配对 desktopCapturer 返回的窗口列表进行评分和排序,
4+
* 编排信号层(windowSignals)与规则层(windowRules):对 desktopCapturer
5+
* 返回的窗口列表附加系统信号(进程/几何/前台)与记忆 boost 后双向打分排序,
56
* 过滤系统/不可见窗口,将最可能是网课/直播/会议的窗口排在前面。
6-
* 仅针对 Windows 平台优化关键词列表。
7+
* 无信号注入时退化为纯标题评分(与旧版行为一致)。
78
*
8-
* @ai-context: 采集窗口智能评分过滤:排除自身窗口(含旧品牌'课伴'关键词——防旧版本窗口被采集,为兼容保留勿删)与系统窗口。
9+
* @ai-context: 采集窗口智能评分过滤:排除自身窗口(含旧品牌'课伴'关键词——
10+
* 防旧版本窗口被采集,为兼容保留勿删)与系统窗口。信号源全部可选:native
11+
* 缺失时仅标题关键词评分,保证行为不回归。
12+
* @ai-context EN: Composition entry for window scoring. All signal sources
13+
* are optional; without them the output matches legacy title-only behavior.
914
*/
15+
import { scoreWindow, type Confidence, type WindowSignalInput } from './windowRules.js';
1016

1117
// ================================================================
1218
// 类型定义
@@ -17,110 +23,68 @@ export interface ScoredWindow {
1723
title: string;
1824
thumbnail: string;
1925
score: number;
20-
matched?: string; // 命中的关键词(用于前端显示推荐理由)
26+
matched?: string;
27+
processName?: string;
28+
confidence?: Confidence;
29+
reasons?: string[];
30+
/** 未被娱乐负分主导的学习分 */
31+
learningScore?: number;
32+
/** 记忆命中的课程名(供渲染层回填课程) */
33+
memoryCourseName?: string;
34+
/** 是否前台窗口(渲染层自动选中规则②使用) */
35+
isForeground?: boolean;
2136
}
2237

23-
// ================================================================
24-
// 关键词评分规则
25-
// ================================================================
26-
27-
const SCORE_RULES = {
28-
/** 高优先级:网课/直播/会议关键词 (+100) */
29-
highPriority: [
30-
'网课', '直播', '课程', '课堂', '学习', '讲座', '培训',
31-
'腾讯会议', '钉钉', 'Zoom', 'Teams', 'Meet', 'Webex',
32-
'中国大学MOOC', '学堂在线', '智慧树', '学习通', '雨课堂',
33-
'Bilibili', '哔哩哔哩', 'YouTube', '网易公开课',
34-
'Coursera', 'edX', 'Udemy',
35-
],
36-
/** 中优先级:浏览器/视频播放器 (+50) */
37-
mediumPriority: [
38-
'Chrome', 'Edge', 'Firefox', 'Brave', 'Opera',
39-
'PotPlayer', 'VLC', 'mpv', 'Windows Media',
40-
'EV录屏', 'OBS', 'MPC-HC',
41-
],
42-
/** 黑名单:系统/工具窗口(直接过滤) */
43-
blacklist: [
44-
'Program Manager',
45-
'Taskbar',
46-
'Settings',
47-
'设置',
48-
'Windows Input Experience',
49-
'MSCTFIME',
50-
'Default IME',
51-
'Electron',
52-
'Entropy decrease',
53-
'课伴',
54-
'熵减',
55-
],
56-
} as const;
57-
58-
const HIGH_SCORE = 100;
59-
const MEDIUM_SCORE = 50;
60-
61-
// ================================================================
62-
// 评分逻辑
63-
// ================================================================
64-
65-
/**
66-
* 对窗口标题进行关键词匹配评分
67-
* @returns score 和 matched 关键词,若命中黑名单返回 null
68-
*/
69-
function scoreTitle(title: string): { score: number; matched?: string } | null {
70-
const lowerTitle = title.toLowerCase();
71-
72-
// 黑名单检测
73-
for (const keyword of SCORE_RULES.blacklist) {
74-
if (lowerTitle.includes(keyword.toLowerCase())) {
75-
return null; // 过滤
76-
}
77-
}
78-
79-
// 高优先级匹配
80-
for (const keyword of SCORE_RULES.highPriority) {
81-
if (lowerTitle.includes(keyword.toLowerCase())) {
82-
return { score: HIGH_SCORE, matched: keyword };
83-
}
84-
}
85-
86-
// 中优先级匹配
87-
for (const keyword of SCORE_RULES.mediumPriority) {
88-
if (lowerTitle.includes(keyword.toLowerCase())) {
89-
return { score: MEDIUM_SCORE, matched: keyword };
90-
}
91-
}
92-
93-
// 无匹配,基础分 0
94-
return { score: 0 };
38+
export interface ScoreAndFilterOptions {
39+
/** 按 source id 注入的信号 */
40+
signalsBySourceId?: Map<string, WindowSignalInput>;
41+
/** 记忆查找注入(windowHistory 包装) */
42+
memoryLookup?: (processName: string, title: string) => { courseName?: string; boost: number } | null;
9543
}
9644

97-
/**
98-
* 对窗口列表进行评分、过滤和排序
99-
* @param windows 原始窗口列表(来自 desktopCapturer)
100-
* @returns 过滤并排序后的窗口列表(降序)
101-
*/
10245
export function scoreAndFilterWindows(
10346
windows: { id: string; title: string; thumbnail: string }[],
47+
options?: ScoreAndFilterOptions,
10448
): ScoredWindow[] {
10549
const scored: ScoredWindow[] = [];
10650

10751
for (const win of windows) {
108-
// 过滤空标题窗口
10952
if (!win.title || win.title.trim() === '') continue;
11053

111-
const result = scoreTitle(win.title);
112-
if (result === null) continue; // 命中黑名单
54+
const signal: WindowSignalInput = {
55+
title: win.title,
56+
...(options?.signalsBySourceId?.get(win.id) ?? {}),
57+
};
58+
59+
const result = scoreWindow(signal);
60+
if (result.filtered) continue;
11361

114-
scored.push({
62+
const out: ScoredWindow = {
11563
id: win.id,
11664
title: win.title,
11765
thumbnail: win.thumbnail,
11866
score: result.score,
119-
matched: result.matched,
120-
});
67+
matched: result.reasons[0],
68+
reasons: result.reasons,
69+
confidence: result.confidence,
70+
learningScore: result.score - result.entertainmentPenalty,
71+
};
72+
73+
if (signal.processName) out.processName = signal.processName;
74+
if (signal.isForeground) out.isForeground = true;
75+
76+
// 记忆查找以标题为锚:进程缺失时传空串降级调用,不阻断记忆命中
77+
if (options?.memoryLookup) {
78+
const mem = options.memoryLookup(signal.processName ?? '', win.title);
79+
if (mem) {
80+
out.score += mem.boost;
81+
out.memoryCourseName = mem.courseName;
82+
}
83+
}
84+
85+
scored.push(out);
12186
}
12287

123-
// 按 score 降序排列,同分按 id 稳定排序
12488
scored.sort((a, b) => {
12589
if (b.score !== a.score) return b.score - a.score;
12690
return a.id.localeCompare(b.id);

0 commit comments

Comments
 (0)