Skip to content

Commit 15f7839

Browse files
committed
feat(window-recognition): 窗口双向评分规则纯函数(学习意图正信号↔娱乐负分)
1 parent f9c0636 commit 15f7839

4 files changed

Lines changed: 274 additions & 14 deletions

File tree

‎client/electron/tsconfig.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,5 +21,6 @@
2121
"../src/lib/storage/interfaces.ts",
2222
"../src/types/models.ts",
2323
"../src/lib/capture/audioSourceStrategy.ts"
24-
]
24+
],
25+
"exclude": ["**/*.test.ts", "**/*.spec.ts"]
2526
}

‎client/electron/windowRules.smoke.test.ts‎

Lines changed: 0 additions & 13 deletions
This file was deleted.
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
// @vitest-environment node
2+
/**
3+
* 窗口双向评分规则测试
4+
* @ai-context: 覆盖学习意图正信号/娱乐负分对冲、标题叠加、系统黑名单、置信度分级。
5+
*/
6+
import { describe, it, expect } from 'vitest';
7+
import { scoreWindow, HIGH_CONFIDENCE_MIN, MEDIUM_CONFIDENCE_MIN } from './windowRules.js';
8+
9+
describe('scoreWindow — 系统黑名单', () => {
10+
it('标题黑名单直接过滤(含旧品牌课伴)', () => {
11+
const r = scoreWindow({ title: 'Program Manager' });
12+
expect(r.filtered).toBe(true);
13+
expect(r.score).toBe(0);
14+
});
15+
it('宽泛词 Settings/设置 不再过滤(移除误伤)', () => {
16+
const r = scoreWindow({ title: '我的设置中心' });
17+
expect(r.filtered).toBe(false);
18+
});
19+
it('进程黑名单过滤系统窗口', () => {
20+
const r = scoreWindow({ title: '任务栏', processName: 'explorer.exe' });
21+
expect(r.filtered).toBe(true);
22+
});
23+
});
24+
25+
describe('scoreWindow — 双向计分', () => {
26+
it('学习意图正信号 +60(攻略类标题)', () => {
27+
const r = scoreWindow({ title: '原神萌新攻略:开荒机制解析' });
28+
expect(r.reasons).toContain('学习意图');
29+
expect(r.score).toBeGreaterThanOrEqual(60);
30+
});
31+
it('娱乐负分对冲:影视剧形态 -40', () => {
32+
const r = scoreWindow({ title: '琅琊榜 第12集', processName: 'chrome.exe' });
33+
expect(r.score).toBeLessThanOrEqual(20);
34+
});
35+
it('攻略正信号强过娱乐负分:游戏攻略可进推荐', () => {
36+
const r = scoreWindow({ title: '只狼全 Boss 打法教学', processName: 'steam.exe' });
37+
expect(r.score).toBeGreaterThanOrEqual(30); // +60 -30
38+
expect(r.filtered).toBe(false);
39+
});
40+
it('标题关键词叠加计分(多词命中累加)', () => {
41+
const r = scoreWindow({ title: '腾讯会议 - 网课课堂' });
42+
expect(r.score).toBeGreaterThanOrEqual(80); // 40x2
43+
});
44+
});
45+
46+
describe('scoreWindow — 系统信号', () => {
47+
it('进程白名单加权(浏览器 +25)', () => {
48+
const r = scoreWindow({ title: '随便看看', processName: 'chrome.exe' });
49+
expect(r.score).toBeGreaterThanOrEqual(25);
50+
});
51+
it('几何信号:16:9 宽高比 +30', () => {
52+
const r = scoreWindow({ title: '随便看看', aspectRatio: 16 / 9 });
53+
expect(r.score).toBeGreaterThanOrEqual(30);
54+
});
55+
it('前台窗口 +80(最强意图先验)', () => {
56+
const r = scoreWindow({ title: '随便看看', isForeground: true });
57+
expect(r.score).toBeGreaterThanOrEqual(80);
58+
});
59+
});
60+
61+
describe('scoreWindow — 置信度分级', () => {
62+
it('score>=130 为 high,>=70 为 medium,否则 low', () => {
63+
expect(scoreWindow({ title: '腾讯会议 - 网课课堂', processName: 'wemeet.exe', isForeground: true }).confidence).toBe('high');
64+
expect(scoreWindow({ title: '随便看看', processName: 'chrome.exe' }).confidence).toBe('medium');
65+
expect(scoreWindow({ title: '随便看看' }).confidence).toBe('low');
66+
// 边界常量存在且顺序正确
67+
expect(HIGH_CONFIDENCE_MIN).toBeGreaterThan(MEDIUM_CONFIDENCE_MIN);
68+
});
69+
it('空标题返回 low 且不崩溃', () => {
70+
const r = scoreWindow({ title: '' });
71+
expect(r.score).toBe(0);
72+
expect(r.confidence).toBe('low');
73+
});
74+
});

‎client/electron/windowRules.ts‎

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
/**
2+
* 窗口双向评分规则(纯函数)
3+
*
4+
* 双向打分模型:学习意图正信号(标题关键词/攻略词/进程白名单/几何/前台/记忆)
5+
* 对冲娱乐负分(游戏/影视/直播进程与标题形态),总分决定推荐排序与置信度。
6+
* 系统窗口经黑名单硬过滤;宽泛标题词(Settings/设置)已移除,避免误伤。
7+
*
8+
* @ai-context: 双向评分——负向排除(娱乐特征负分沉底但不误杀)优于单一维度
9+
* 硬排除(游戏攻略/影视纪录片是合法学习内容);意图先验:用户打开课堂助手
10+
* 即声明"采集正在看的学习内容",前台窗口 +80 是最高置信信号。
11+
* @ai-context EN: Bidirectional window scoring. Negative entertainment
12+
* penalties (never hard-filter, so game guides / documentaries survive)
13+
* offset positive learning-intent signals. Foreground window (+80) is the
14+
* strongest intent prior.
15+
*/
16+
17+
// ================================================================
18+
// 类型定义
19+
// ================================================================
20+
21+
export type Confidence = 'low' | 'medium' | 'high';
22+
23+
export interface WindowSignalInput {
24+
title: string;
25+
/** 进程可执行文件名(小写,如 chrome.exe);native 不可用时缺失 */
26+
processName?: string;
27+
/** 窗口宽高比(width/height) */
28+
aspectRatio?: number;
29+
/** 窗口面积占显示器面积比例(0~1) */
30+
areaRatio?: number;
31+
alwaysOnTop?: boolean;
32+
/** 是否为前台窗口(native GetForegroundWindow 命中) */
33+
isForeground?: boolean;
34+
}
35+
36+
export interface WindowScoreResult {
37+
score: number;
38+
confidence: Confidence;
39+
/** 命中理由(前端展示用),如 '学习意图' / '进程: wemeet.exe' */
40+
reasons: string[];
41+
/** true = 命中系统黑名单,应被过滤 */
42+
filtered: boolean;
43+
/** 娱乐负分合计(供"未被娱乐负分主导"判定,learningScore = score - entertainmentPenalty) */
44+
entertainmentPenalty: number;
45+
}
46+
47+
// ================================================================
48+
// 规则常量
49+
// ================================================================
50+
51+
/** 置信度分级阈值 */
52+
export const HIGH_CONFIDENCE_MIN = 130;
53+
export const MEDIUM_CONFIDENCE_MIN = 25;
54+
55+
/** 标题高优先级词(原 windowScorer 迁移,+40/词可叠加) */
56+
const HIGH_PRIORITY_WORDS = [
57+
'网课', '直播', '课程', '课堂', '学习', '讲座', '培训',
58+
'腾讯会议', '钉钉', 'zoom', 'teams', 'meet', 'webex',
59+
'mooc', '学堂在线', '智慧树', '学习通', '雨课堂',
60+
'bilibili', '哔哩哔哩', 'youtube', '网易公开课',
61+
'coursera', 'edx', 'udemy',
62+
];
63+
64+
/** 标题中优先级词(原 windowScorer 迁移,+20/词可叠加) */
65+
const MEDIUM_PRIORITY_WORDS = [
66+
'chrome', 'edge', 'firefox', 'brave', 'opera',
67+
'potplayer', 'vlc', 'mpv', 'windows media',
68+
'ev录屏', 'obs', 'mpc-hc',
69+
];
70+
71+
/** 学习意图正信号词(攻略/教程类,跨场景最强,+60) */
72+
const LEARNING_INTENT_RE =
73+
/攻略|教程|指南|教学|讲解|解析|入门|进阶|技巧|打法|开荒|机制|评测|实测|心得/;
74+
75+
/** 娱乐标题形态负分词(-40) */
76+
const ENTERTAINMENT_TITLE_RE = /第\s*\d+\s*集|剧场版|预告片|\bmv\b|演唱会|番剧/;
77+
78+
/** 进程白名单(+50:会议/视频站客户端) */
79+
const PROCESS_HIGH_WHITELIST = new Set([
80+
'wemeet.exe', 'dingtalk.exe', 'zoom.exe', 'teams.exe', 'webex.exe',
81+
'bilibili.exe', 'bililive.exe', 'youku.exe', 'iqiyi.exe',
82+
]);
83+
84+
/** 进程白名单(+25:浏览器/播放器) */
85+
const PROCESS_MEDIUM_WHITELIST = new Set([
86+
'chrome.exe', 'msedge.exe', 'firefox.exe', 'brave.exe', 'opera.exe',
87+
'potplayermini64.exe', 'vlc.exe', 'mpv.exe',
88+
]);
89+
90+
/** 娱乐进程负分(-30:游戏/影视客户端/直播平台) */
91+
const PROCESS_ENTERTAINMENT = new Set([
92+
'steam.exe', 'wegame.exe', 'leagueclient.exe', 'league of legends.exe',
93+
'gta5.exe', 'dota2.exe', 'csgo.exe', 'valorant.exe', 'genshinimpact.exe',
94+
'douyu.exe', 'huya.exe', 'netflix.exe', 'mangotv.exe',
95+
]);
96+
97+
/** 系统窗口进程黑名单(硬过滤) */
98+
const PROCESS_BLACKLIST = new Set([
99+
'explorer.exe', 'searchhost.exe', 'startmenuexperiencehost.exe',
100+
'shellexperiencehost.exe', 'systemsettings.exe',
101+
]);
102+
103+
/** 标题黑名单(硬过滤;保留旧品牌'课伴'防旧版本窗口被采集) */
104+
const TITLE_BLACKLIST = [
105+
'program manager', 'taskbar', 'windows input experience',
106+
'msctfime', 'default ime', 'electron', 'entropy decrease', '课伴', '熵减',
107+
];
108+
109+
// ================================================================
110+
// 评分实现
111+
// ================================================================
112+
113+
function countWords(title: string, words: string[]): number {
114+
let count = 0;
115+
for (const w of words) {
116+
if (title.includes(w)) count += 1;
117+
}
118+
return count;
119+
}
120+
121+
/**
122+
* 对单个窗口的标题+系统信号双向打分。
123+
* @returns 总分、置信度、命中理由、过滤标记、娱乐负分
124+
*/
125+
export function scoreWindow(input: WindowSignalInput): WindowScoreResult {
126+
const title = (input.title ?? '').trim().toLowerCase();
127+
const reasons: string[] = [];
128+
let score = 0;
129+
let entertainmentPenalty = 0;
130+
131+
// 黑名单检测(标题 + 进程)
132+
if (!title || TITLE_BLACKLIST.some((w) => title.includes(w))) {
133+
return { score: 0, confidence: 'low', reasons: [], filtered: true, entertainmentPenalty: 0 };
134+
}
135+
if (input.processName && PROCESS_BLACKLIST.has(input.processName)) {
136+
return { score: 0, confidence: 'low', reasons: [], filtered: true, entertainmentPenalty: 0 };
137+
}
138+
139+
// 正向:标题关键词叠加
140+
const highHits = countWords(title, HIGH_PRIORITY_WORDS);
141+
const mediumHits = countWords(title, MEDIUM_PRIORITY_WORDS);
142+
score += highHits * 40 + mediumHits * 20;
143+
if (highHits > 0) reasons.push(`标题×${highHits}`);
144+
if (mediumHits > 0) reasons.push(`播放器/浏览器×${mediumHits}`);
145+
146+
// 正向:学习意图正信号(+60)
147+
if (LEARNING_INTENT_RE.test(title)) {
148+
score += 60;
149+
reasons.push('学习意图');
150+
}
151+
152+
// 负向:娱乐标题形态(-40)
153+
if (ENTERTAINMENT_TITLE_RE.test(title)) {
154+
entertainmentPenalty += 40;
155+
score -= 40;
156+
reasons.push('娱乐形态');
157+
}
158+
159+
// 进程信号
160+
if (input.processName) {
161+
if (PROCESS_HIGH_WHITELIST.has(input.processName)) {
162+
score += 50;
163+
reasons.push(`进程: ${input.processName}`);
164+
} else if (PROCESS_MEDIUM_WHITELIST.has(input.processName)) {
165+
score += 25;
166+
reasons.push(`进程: ${input.processName}`);
167+
} else if (PROCESS_ENTERTAINMENT.has(input.processName)) {
168+
entertainmentPenalty += 30;
169+
score -= 30;
170+
reasons.push('娱乐进程');
171+
}
172+
}
173+
174+
// 几何信号:视频/PPT 宽高比(1.2~2.4)+30
175+
if (input.aspectRatio !== undefined && input.aspectRatio > 1.2 && input.aspectRatio < 2.4) {
176+
score += 30;
177+
reasons.push('宽高比');
178+
}
179+
// 面积占比 ≥30% +20
180+
if (input.areaRatio !== undefined && input.areaRatio >= 0.3) {
181+
score += 20;
182+
reasons.push('大窗口');
183+
}
184+
// 置顶 +10
185+
if (input.alwaysOnTop) {
186+
score += 10;
187+
}
188+
// 前台窗口 +80(意图先验)
189+
if (input.isForeground) {
190+
score += 80;
191+
reasons.push('前台窗口');
192+
}
193+
194+
const confidence: Confidence =
195+
score >= HIGH_CONFIDENCE_MIN ? 'high' : score >= MEDIUM_CONFIDENCE_MIN ? 'medium' : 'low';
196+
197+
return { score, confidence, reasons, filtered: false, entertainmentPenalty };
198+
}

0 commit comments

Comments
 (0)