@tencent-rtc/mcp 1.7.0 → 1.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,258 +1,337 @@
1
- const E7_EN_STOPWORDS = new Set([
2
- 'the', 'and', 'for', 'with', 'from', 'into', 'about', 'using', 'via',
3
- 'how', 'what', 'where', 'when', 'which', 'please', 'docs', 'documentation',
4
- 'tencent', 'im', 'rest', 'server', 'webhook', 'webhooks',
5
- ]);
6
- /** Generic terms — excluded from API-token hits to avoid false positives */
7
- const E7_GENERIC_TERMS = new Set([
8
- 'message', 'messages', 'group', 'groups', 'user', 'users', 'account', 'accounts',
9
- 'send', 'get', 'set', 'delete', 'create', 'update', 'query', 'before', 'after',
10
- 'webhook', 'friend', 'single', 'multiple', 'history', 'chat', 'sdk', 'api',
11
- 'profile', 'conversation', 'import', 'recall', 'modify', 'pull', 'mark', 'data',
12
- 'list', 'online', 'status', 'custom', 'attributes', 'counter', 'member', 'members',
13
- 'management', 'overview', 'integration', 'initialization', 'billing', 'topic',
14
- 'topics', 'community', 'robot', 'chatbot', 'streaming', 'broadcast', 'official',
15
- 'extension', 'receipt', 'receipts', 'global', 'mute', 'ban', 'unban', 'forbid',
16
- 'ordinary', 'specified', 'historical', 'unread', 'read', 'configure', 'configuration', 'invalidate',
17
- 'invalidating', 'disband', 'change', 'search', 'cloud', 'local', 'robot', 'bots',
18
- ]);
19
- const E7_QUERY_NOISE = /\b(rest\s*ful?\s*api|rest\s*api|server\s*api|webhook|webhooks|how\s+to|how\s+do\s+i|what\s+is|where\s+is|please|documentation|docs?)\b/gi;
20
- const E7_QUERY_NOISE_CN = /怎么|如何|什么|用哪个|查询|服务端/g;
21
- const E7_PHRASES = [
22
- 'before send one-to-one message',
23
- 'after send one-to-one message',
24
- 'one-to-one message',
25
- 'group message',
26
- 'recall group message',
27
- 'recall one-to-one message',
28
- 'send one-to-one message',
29
- 'send single chat message',
30
- 'getmessagelist',
31
- 'message history',
32
- 'add friend',
33
- 'import single account',
34
- 'import a single account',
35
- 'multi-device login',
36
- 'multi device login',
37
- 'login policy',
38
- 'login strategy',
39
- 'message storage duration',
40
- 'roaming message storage',
41
- ];
42
- function extractRestEndpoints(text) {
43
- return [...text.matchAll(/v4\/[\w/]+/gi)].map((m) => m[0].toLowerCase());
44
- }
45
- function extractE7ApiTokens(text) {
1
+ /**
2
+ * url-row-scorer.ts
3
+ *
4
+ * URL 映射行(chat/urls/...)匹配打分器。
5
+ *
6
+ * 设计目标(重构版):
7
+ * - **语义词对齐**:按「query 与 title 的高区分度语义特征是否对齐」打分,
8
+ * 而不是「子串/单字」粗匹配。
9
+ * - **IDF 自动降权**:高频泛词(`消息`/`message`/`group`)靠 IDF 自动低权重,
10
+ * 稀有语义词(`消息回应`/`reaction`)高权重,**不依赖枚举词表**。
11
+ * - **中英通吃、产品无关**:同一套特征提取同时处理中文/英文标题。
12
+ * - 保留已验证有效的 REST 端点 / API token 高分路径。
13
+ */
14
+ import { idfWeight, isValidBigram, loadIdf } from './url-row-idf.js';
15
+ /**
16
+ * 提取 CJK 有效 2-gram(df>0,过滤「送前」「前回」等滑窗噪声)。
17
+ * 口径与旧版 qBigrams / titleBigramArray **bit-identical**。
18
+ */
19
+ function extractCjkTokens(text) {
46
20
  const tokens = new Set();
47
- for (const match of text.matchAll(/\b([a-z][a-z0-9_]{2,})\b/gi)) {
48
- const token = match[1].toLowerCase();
49
- if (!E7_GENERIC_TERMS.has(token) && !E7_EN_STOPWORDS.has(token))
50
- tokens.add(token);
51
- }
52
- for (const match of text.matchAll(/\b([A-Z][A-Za-z0-9]*(?:\.[A-Za-z]\w*)?)\b/g)) {
53
- const token = match[1].toLowerCase();
54
- if (!E7_GENERIC_TERMS.has(token))
55
- tokens.add(token);
56
- }
57
- for (const match of text.matchAll(/([a-z]+[A-Z][A-Za-z0-9]*)/g)) {
58
- tokens.add(match[1].toLowerCase());
59
- }
60
- return [...tokens];
61
- }
62
- function normalizeE7Text(text) {
63
- return text
64
- .toLowerCase()
65
- .replace(E7_QUERY_NOISE, ' ')
66
- .replace(E7_QUERY_NOISE_CN, '')
67
- .replace(/[^\w\u4e00-\u9fff/]+/g, ' ')
68
- .replace(/\s+/g, ' ')
69
- .trim();
70
- }
71
- function splitPromptIntoClauses(prompt) {
72
- const raw = prompt
73
- .replace(/rest\s*api|服务端|server\s*api|api|怎么|如何|什么|用哪个|查询/g, '')
74
- .replace(/\s+/g, ' ')
75
- .trim();
76
- const parts = raw.split(/[、,,;;、\s]+/).filter((p) => p.length >= 2);
77
- return parts.length > 0 ? parts : [raw];
78
- }
79
- function phraseBonus(query, title) {
80
- const q = query.toLowerCase().replace(/\s+/g, ' ');
81
- const t = title.toLowerCase();
82
- let bonus = 0;
83
- for (const phrase of E7_PHRASES) {
84
- if (q.includes(phrase) && t.includes(phrase))
85
- bonus += 180;
86
- }
87
- if (/send single chat message/.test(q) && /send one-to-one message/.test(t))
88
- bonus += 200;
89
- if (/recall group message/.test(q) && /recall group message/.test(t))
90
- bonus += 200;
91
- if (/before send one-to-one message/.test(q) && /before send one-to-one message/.test(t))
92
- bonus += 220;
93
- if (/\bbefore\b/.test(q) && t.startsWith('before '))
94
- bonus += 120;
95
- if (/\bafter\b/.test(q) && t.startsWith('after '))
96
- bonus += 120;
97
- if (/多端登录/.test(query) && /多端登录|multi[\s-]?device/i.test(title))
98
- bonus += 220;
99
- if (/登录策略|login\s*(policy|strategy)/i.test(query) && /登录策略|login\s*(policy|strategy)/i.test(title))
100
- bonus += 200;
101
- return bonus;
102
- }
103
- function scoreEnglishStyle(query, title) {
104
- const queryEndpoints = extractRestEndpoints(query);
105
- const titleEndpoints = extractRestEndpoints(title);
106
- for (const qe of queryEndpoints) {
107
- if (titleEndpoints.some((te) => te === qe || te.endsWith(qe) || qe.endsWith(te))) {
108
- return 1000;
21
+ for (const seg of text.toLowerCase().match(/[\u4e00-\u9fff]{2,}/g) ?? []) {
22
+ for (let i = 0; i < seg.length - 1; i += 1) {
23
+ const bg = seg.slice(i, i + 2);
24
+ if (isValidBigram(bg))
25
+ tokens.add(bg);
109
26
  }
110
27
  }
111
- let score = phraseBonus(query, title);
112
- const titleLower = title.toLowerCase();
113
- const queryApis = extractE7ApiTokens(query);
114
- let apiHits = 0;
115
- let longApiHit = false;
116
- for (const api of queryApis) {
117
- if (!titleLower.includes(api))
28
+ return tokens;
29
+ }
30
+ /**
31
+ * 提取 EN 有效词(>=4 字母,形态过滤 web/h5/sdk/api/im/tim/app/pc/mini/v\d+)。
32
+ * 仅在 query **纯 EN**(无 CJK)时启用,避免混合 query 的 EN 翻译
33
+ * (如「单聊」展开成「one-to-one c2c single chat message」后,无对应 title 命中,
34
+ * 会稀释 CJK 主干的 coverage 分母,导致「单聊/群聊」互抢位)。
35
+ */
36
+ function extractEnTokens(text) {
37
+ const tokens = new Set();
38
+ for (const m of text.toLowerCase().matchAll(/[a-z][a-z0-9]{2,}/g)) {
39
+ const w = m[0];
40
+ if (w.length < 4)
41
+ continue;
42
+ if (/^(web|h5|sdk|api|im|tim|app|pc|mini|v\d+)$/.test(w))
118
43
  continue;
119
- apiHits += 1;
120
- if (api.length >= 8)
121
- longApiHit = true;
44
+ tokens.add(w);
122
45
  }
123
- if (longApiHit)
124
- score = Math.max(score, 940);
125
- else if (apiHits >= 2)
126
- score = Math.max(score, 900);
127
- else if (apiHits === 1)
128
- score = Math.max(score, 860);
129
- const qNorm = normalizeE7Text(query);
130
- const tNorm = normalizeE7Text(title);
131
- const qTokens = qNorm.split(' ').filter((t) => t.length >= 3 && !E7_GENERIC_TERMS.has(t));
132
- const tTokens = tNorm.split(' ').filter((t) => t.length >= 3 && !E7_GENERIC_TERMS.has(t));
133
- let overlap = 0;
134
- for (const qt of qTokens) {
135
- if (tTokens.some((tt) => tt.includes(qt) || qt.includes(tt)))
136
- overlap += 1;
46
+ return tokens;
47
+ }
48
+ /**
49
+ * 计算单语言路径的语义打分(coverage + meaningfulRatio + titleCoverage 三维度)。
50
+ * 对纯 CJK 输入,与旧版 4 分支结构 + 公式 **bit-identical**。
51
+ * 对纯 EN 输入,引入 IDF 调节(旧版只走 650+30*N 无 IDF 导致泛词虚高)。
52
+ */
53
+ function scoreOneLang(qTokens, tTokens) {
54
+ const queryTokenCount = qTokens.size;
55
+ const titleTokenCount = tTokens.size;
56
+ if (queryTokenCount === 0) {
57
+ return { coverage: 0, titleCoverage: 0, meaningfulRatio: 0, matchedCount: 0, titleTokenCount };
137
58
  }
138
- if (overlap >= 3)
139
- score = Math.max(score, 720 + overlap * 15);
140
- else if (overlap >= 2)
141
- score = Math.max(score, 650);
142
- else if (overlap >= 1)
143
- score = Math.max(score, 520);
144
- const qCompact = qNorm.replace(/\s+/g, '');
145
- const tCompact = tNorm.replace(/\s+/g, '');
146
- if (qCompact.length >= 5 && tCompact.includes(qCompact)) {
147
- score = Math.max(score, 560 - Math.min(tCompact.length - qCompact.length, 80));
59
+ let matchedCount = 0;
60
+ let meaningfulMatched = 0;
61
+ for (const t of qTokens) {
62
+ if (tTokens.has(t)) {
63
+ matchedCount += 1;
64
+ if (idfWeight(t) > 0.5)
65
+ meaningfulMatched += 1;
66
+ }
148
67
  }
149
- else if (tCompact.length >= 5 && qCompact.includes(tCompact)) {
150
- score = Math.max(score, 500 + Math.min(tCompact.length, 30));
68
+ const coverage = matchedCount / queryTokenCount;
69
+ const titleCoverage = titleTokenCount > 0 ? matchedCount / titleTokenCount : 0;
70
+ const meaningfulRatio = meaningfulMatched / queryTokenCount;
71
+ return { coverage, titleCoverage, meaningfulRatio, matchedCount, titleTokenCount };
72
+ }
73
+ /** REST 端点(v4/xxx)精确匹配 */
74
+ function restEndpointHit(query, title) {
75
+ const qe = [...query.matchAll(/v4\/[\w/]+/gi)].map((m) => m[0].toLowerCase());
76
+ const te = [...title.matchAll(/v4\/[\w/]+/gi)].map((m) => m[0].toLowerCase());
77
+ if (!qe.length)
78
+ return 0;
79
+ for (const q of qe) {
80
+ if (te.some((t) => t === q || t.endsWith(q) || q.endsWith(t)))
81
+ return 1000;
151
82
  }
152
- const qCjk = query.replace(/[^\u4e00-\u9fff]/g, '');
153
- const tCjk = title.replace(/[^\u4e00-\u9fff]/g, '');
154
- if (qCjk.length >= 2 && tCjk.includes(qCjk)) {
155
- let cjkScore = 500;
156
- if (/批量|群组|群流|广播|导入|公众号|已读|回执/.test(tCjk) && !/批量|群组|群|已读|回执/.test(qCjk)) {
157
- cjkScore -= 250;
83
+ return 0;
84
+ }
85
+ /**
86
+ * API token(驼峰 / 点号 / useXxx)精确匹配
87
+ *
88
+ * **V2 严格命中(修复 X message row-to-row tie)**:
89
+ * 旧版 `lower.includes(api)` 会把"共享部分 API"也计入命中,导致所有
90
+ * `X message (createXMessage → sendMessage)` 标题因共享 `sendMessage` 都得 940,
91
+ * BM25 决定胜负,倾向"Text message"抢首位(41+ miss)。
92
+ *
93
+ * 新版要求 query 和 title 的 **long API(≥8 字符)集合完全一致**(双向 ⊆)
94
+ * 才算 940。这样:
95
+ * - query "Send message parameter configuration (sendMessage parameters)" → long={sendMessage}
96
+ * - self title: long={sendMessage} → 940
97
+ * - "Text message" title: long={createTextMessage, sendMessage} → sizes differ → 0
98
+ * - 共享 `sendMessage` 但缺独有 API(如 createTextMessage)的 title 拿 0 分
99
+ * - query "Image message (createImageMessage → sendMessage)" → long={createImageMessage, sendMessage}
100
+ * - self title: long={createImageMessage, sendMessage} → 940
101
+ * - "Sound message" title: long={createSoundMessage, sendMessage} → 大小同但内容不同 → 0
102
+ *
103
+ * 兼容:
104
+ * - restapi.md 仍命中(query 和 title 的 v4 API 路径互为完整 token 集合)。
105
+ * - sdk/* 的 createXMessage/sendMessage 自匹配:query 和 title 双向 ⊆ → 940。
106
+ */
107
+ function apiTokenHit(query, title) {
108
+ // 提取 query/title 的 API token(驼峰 / useXxx / 点号)
109
+ const apiRe = /\b([a-z]+[A-Z][A-Za-z0-9_]*)\b|\b([A-Z][A-Za-z0-9_]*\.[A-Za-z_][A-Za-z0-9_]*)\b|\b(use[A-Z]\w*)\b/g;
110
+ const extract = (text) => {
111
+ const out = new Set();
112
+ for (const m of text.matchAll(apiRe)) {
113
+ const api = (m[1] ?? m[2] ?? m[3]).toLowerCase();
114
+ if (api && api.length >= 4)
115
+ out.add(api);
158
116
  }
159
- if (/^单发/.test(tCjk) && /发送|单聊/.test(qCjk))
160
- cjkScore += 120;
161
- score = Math.max(score, cjkScore);
117
+ return out;
118
+ };
119
+ const queryApis = extract(query);
120
+ const titleApis = extract(title);
121
+ if (queryApis.size === 0)
122
+ return 0;
123
+ // 只看 long API(≥8 字符)的双向 ⊆ 严格命中要求
124
+ const longQuery = new Set([...queryApis].filter((a) => a.length >= 8));
125
+ if (longQuery.size === 0) {
126
+ // 没有 long API 的 query 走 hits 计数(兼容短 token 场景,如 useXxx)
127
+ let hits = 0;
128
+ for (const a of queryApis)
129
+ if (titleApis.has(a))
130
+ hits += 1;
131
+ if (hits >= 2)
132
+ return 900;
133
+ if (hits === 1)
134
+ return 860;
135
+ return 0;
162
136
  }
163
- if (score <= 0)
137
+ const longTitle = new Set([...titleApis].filter((a) => a.length >= 8));
138
+ // 严格双向命中:query.long === title.long(Set 视角)
139
+ if (longQuery.size !== longTitle.size)
164
140
  return 0;
165
- const queryL = query.toLowerCase();
166
- const titleL = title.toLowerCase();
167
- if (/group/.test(queryL) && /one-to-one|c2c/.test(titleL) && !/group/.test(titleL))
168
- score -= 400;
169
- if (/one-to-one|c2c|single chat/.test(queryL) && /import.*account|add friend/.test(titleL))
170
- score -= 400;
171
- if (/send.*message|one-to-one message/.test(queryL) && /add friend|import.*account/.test(titleL))
172
- score -= 400;
173
- if (/getmessagelist|message history/.test(qCompact) && /integration|initialization/.test(titleL) && !/getmessagelist|history/.test(tCompact))
174
- score -= 400;
175
- if (/extension/.test(titleL) && !/extension/.test(queryL))
176
- score -= 350;
177
- if (/\bbefore\b/.test(queryL) && !/^before\b/.test(titleL.trim()))
178
- score -= 250;
179
- if (/\bafter\b/.test(queryL) && !/^after\b/.test(titleL.trim()))
180
- score -= 250;
181
- if (/batch|multiple/.test(titleL) && !/batch|multiple/.test(queryL))
182
- score -= 200;
183
- if (/group/.test(titleL) && !/group|channel/.test(queryL) && /one-to-one|c2c|single chat/.test(queryL))
184
- score -= 200;
185
- return Math.max(score, 0);
141
+ for (const a of longQuery)
142
+ if (!longTitle.has(a))
143
+ return 0;
144
+ return 940;
186
145
  }
187
- function scoreChineseClauseStyle(query, title) {
188
- const clauses = splitPromptIntoClauses(query.toLowerCase());
189
- const titleNorm = title.toLowerCase().replace(/\s+/g, '');
190
- if (!titleNorm)
191
- return 0;
192
- let bestScore = 0;
193
- const allParts = [...clauses, query.toLowerCase().replace(/\s+/g, '')];
194
- const titleTokens = titleNorm.split(/[\/(((、,,\s]+/).filter(Boolean);
195
- const seen = new Set();
196
- for (const part of allParts) {
197
- const pn = part.replace(/\s+/g, '');
198
- if (pn.length < 2 || seen.has(pn))
199
- continue;
200
- seen.add(pn);
201
- let s = 0;
202
- if (pn === titleNorm) {
203
- s = 1000;
146
+ /**
147
+ * 语义词对齐打分(双路径版:CJK 路径与 EN 路径独立计算)
148
+ *
149
+ * 设计要点(与旧版兼容性 vs 修复 en 端虚高):
150
+ * 1. **CJK 路径**:与旧版 4 分支 + 公式 **bit-identical**。
151
+ * 混合 query(query expansion 注入 EN 翻译后)只走 CJK 路径,EN tokens 不参与
152
+ * 分母计算,避免「单聊/群聊」互抢位。
153
+ * 2. **EN 路径**:仅在 query 无 CJK 时启用(即纯 EN query)。
154
+ * 引入 IDF 调节:旧版 enWordHits 路径「650+30*N」无 IDF,导致 en 端 row 虚高抢位;
155
+ * 新版用 coverage+meaningfulRatio 与 CJK 路径同口径打分。
156
+ * 3. **惩罚 1/2**:仅当 score > 0 时生效,纯 EN query 不触发 CJK 整段包含惩罚。
157
+ *
158
+ * 关键修复:「修改单聊历史消息 one-to-one c2c single chat message messages」(扩 query)
159
+ * 旧版: enWordHits=0 → 走 CJK 路径 → 自己 700, 对偶 643
160
+ * 新版: CJK 路径独立 → 自己 700, 对偶 643 (**bit-identical**)
161
+ * 纯 EN query: 新版用 coverage+IDF 替代旧 650+30*N,降权泛词。
162
+ */
163
+ function semanticAlign(query, title) {
164
+ const qCjk = extractCjkTokens(query);
165
+ const tCjk = extractCjkTokens(title);
166
+ const qEn = extractEnTokens(query);
167
+ const tEn = extractEnTokens(title);
168
+ let score = 0;
169
+ if (qCjk.size > 0) {
170
+ // CJK 路径:与旧版公式 bit-identical
171
+ const m = scoreOneLang(qCjk, tCjk);
172
+ if (m.coverage >= 0.5 && m.meaningfulRatio >= 0.5) {
173
+ score = 500 + Math.round(m.coverage * 120) + Math.round(m.meaningfulRatio * 80);
204
174
  }
205
- else if (titleNorm.includes(pn)) {
206
- const trailing = titleNorm.slice(titleNorm.indexOf(pn) + pn.length);
207
- s = 800 - trailing.length * 80;
175
+ else if (m.titleTokenCount >= 3 && m.titleCoverage >= 0.7) {
176
+ score = 620 + Math.round(m.titleCoverage * 60);
177
+ }
178
+ else if (m.coverage >= 0.3 && m.meaningfulRatio >= 0.3) {
179
+ score = 400;
180
+ }
181
+ }
182
+ else if (qEn.size > 0) {
183
+ // EN 路径:仅纯 EN query,引入 IDF 调节(旧版 650+30*N 无 IDF 致虚高)
184
+ const m = scoreOneLang(qEn, tEn);
185
+ if (m.coverage >= 0.5 && m.meaningfulRatio >= 0.5) {
186
+ score = 500 + Math.round(m.coverage * 120) + Math.round(m.meaningfulRatio * 80);
208
187
  }
209
- else if (pn.includes(titleNorm)) {
210
- s = 700 + titleNorm.length;
188
+ else if (m.titleTokenCount >= 3 && m.titleCoverage >= 0.7) {
189
+ score = 620 + Math.round(m.titleCoverage * 60);
211
190
  }
212
- else if (titleTokens.some((token) => pn.includes(token) || token.includes(pn))) {
213
- s = 600;
191
+ else if (m.coverage >= 0.3) {
192
+ // 弱命中:旧版无此分支,给软基分 300-400
193
+ score = 300 + Math.round(m.coverage * 200);
214
194
  }
215
- if (s > bestScore)
216
- bestScore = s;
217
195
  }
218
- if (bestScore <= 0 && clauses.length > 0) {
219
- const qn = clauses[0].replace(/发送/g, '发');
220
- if (qn.length >= 3 && titleNorm.includes(qn)) {
221
- bestScore = 500;
222
- if (/批量|群组|群流|广播|导入|公众号|已读|回执/.test(titleNorm)
223
- && !/批量|群组|群|已读|回执/.test(clauses[0])) {
224
- bestScore -= 250;
196
+ if (score === 0)
197
+ return 0;
198
+ // 惩罚1:CJK 路径下,query 有明确 CJK 语义但覆盖双低(coverage<0.5 && titleCoverage<0.7),
199
+ // 封顶 400,避免泛词残留抬分。EN 路径不触发。
200
+ if (qCjk.size > 0) {
201
+ const qHasCjk3 = /[\u4e00-\u9fff]{3,}/.test(query);
202
+ if (qHasCjk3) {
203
+ const m = scoreOneLang(qCjk, tCjk);
204
+ if (m.coverage < 0.5 && m.titleCoverage < 0.7) {
205
+ score = Math.min(score, 400);
225
206
  }
226
- if (/^单发/.test(titleNorm) && /发送|单聊/.test(clauses[0])) {
227
- bestScore += 120;
207
+ }
208
+ }
209
+ // 惩罚2:query 的完整 CJK 整段(≥4 字)「完全包含于」title,但 title 明显更长
210
+ // (多出 ≥3 字独立主题词),说明 title 主题更窄/偏移 → 降权。
211
+ // 例:query「发送单聊消息」vs title「发送单聊消息已读回执」→ 后者多出「已读回执」。
212
+ if (qCjk.size > 0) {
213
+ const qSegments = query.match(/[\u4e00-\u9fff]{3,}/g) ?? [];
214
+ const titleNorm = title.replace(/\s+/g, '');
215
+ const qFullCjk = qSegments.filter((s) => s.length >= 4 && titleNorm.includes(s));
216
+ if (qFullCjk.length > 0) {
217
+ const titleCjkLen = (title.match(/[\u4e00-\u9fff]/g) ?? []).length;
218
+ const queryCjkLen = (query.match(/[\u4e00-\u9fff]/g) ?? []).length;
219
+ const extraCjk = titleCjkLen - queryCjkLen;
220
+ if (extraCjk >= 3) {
221
+ score = Math.max(360, score - 120 * Math.floor(extraCjk / 2));
228
222
  }
229
223
  }
230
224
  }
231
- return Math.max(bestScore, 0);
225
+ return score;
226
+ }
227
+ /** 兼容入口:原有 normalize 逻辑保留(供 exact-lookup 调用) */
228
+ function normalizeE7Text(text) {
229
+ return text
230
+ .toLowerCase()
231
+ .replace(/[^\w\u4e00-\u9fff/]+/g, ' ')
232
+ .replace(/\s+/g, ' ')
233
+ .trim();
232
234
  }
233
235
  export function normalizeUrlRowQuery(query) {
234
236
  return normalizeE7Text(query);
235
237
  }
238
+ /**
239
+ * 产品无关的 URL 映射行判定:knowledge/<product>/urls/.../xxx.md
240
+ * 供 exact-lookup / l1-enrich / doc-url-resolver 复用,避免各处硬编码 chat/urls。
241
+ */
242
+ export function isUrlMappingSource(source) {
243
+ return /knowledge\/[a-z0-9_-]+\/urls\/.+\.md$/i.test(source.replace(/\\/g, '/'));
244
+ }
245
+ /**
246
+ * 对 URL 映射行打分。
247
+ * 优先级:REST 端点 > API token > 语义词对齐。
248
+ */
236
249
  export function scoreUrlRow(query, title) {
237
- const enScore = scoreEnglishStyle(query, title);
238
- const zhScore = scoreChineseClauseStyle(query, title);
239
- if (enScore <= 0 && zhScore <= 0) {
240
- return {
241
- score: 0,
242
- matchedBy: 'none',
243
- debug: { enScore, zhScore },
244
- };
250
+ loadIdf();
251
+ const restScore = restEndpointHit(query, title);
252
+ const apiScore = restScore > 0 ? 0 : apiTokenHit(query, title);
253
+ const semanticScore = restScore > 0 || apiScore >= 860 ? 0 : semanticAlign(query, title);
254
+ if (restScore > 0) {
255
+ return { score: restScore, matchedBy: 'rest', debug: { restScore, apiScore, semanticScore } };
245
256
  }
246
- if (zhScore > enScore) {
247
- return {
248
- score: zhScore,
249
- matchedBy: 'zh',
250
- debug: { enScore, zhScore },
251
- };
257
+ if (apiScore > 0) {
258
+ return { score: apiScore, matchedBy: 'api', debug: { restScore, apiScore, semanticScore } };
259
+ }
260
+ if (semanticScore > 0) {
261
+ return { score: semanticScore, matchedBy: 'semantic', debug: { restScore, apiScore, semanticScore } };
262
+ }
263
+ return {
264
+ score: 0,
265
+ matchedBy: 'none',
266
+ debug: { restScore, apiScore, semanticScore },
267
+ };
268
+ }
269
+ /** 从 chunk body 中提取开头的分组标题(chunker 会把 urls 行的 `##` 分组写成 `### 分组标题` 前缀) */
270
+ export function extractSectionTitle(content) {
271
+ const match = content.match(/^\s*###\s+([^\n]+)/);
272
+ return match ? match[1].trim() : '';
273
+ }
274
+ /**
275
+ * 计算一组分组标题的「共有词」(出现在全部分组标题中的单字/2-gram,无区分度)。
276
+ * 产品无关:对任意 urls 文件的 `##` 分组自动判定,不依赖枚举。
277
+ */
278
+ export function computeCommonSectionTokens(sectionTitles) {
279
+ const n = sectionTitles.length;
280
+ if (n === 0)
281
+ return { chars: new Set(), bigrams: new Set() };
282
+ const charFreq = new Map();
283
+ const bigramFreq = new Map();
284
+ for (const title of sectionTitles) {
285
+ const chars = new Set(title.match(/[\u4e00-\u9fff]/g) ?? []);
286
+ const bigrams = new Set();
287
+ for (const seg of title.match(/[\u4e00-\u9fff]{2,}/g) ?? []) {
288
+ for (let i = 0; i < seg.length - 1; i += 1)
289
+ bigrams.add(seg.slice(i, i + 2));
290
+ }
291
+ for (const c of chars)
292
+ charFreq.set(c, (charFreq.get(c) ?? 0) + 1);
293
+ for (const b of bigrams)
294
+ bigramFreq.set(b, (bigramFreq.get(b) ?? 0) + 1);
252
295
  }
253
296
  return {
254
- score: enScore,
255
- matchedBy: 'en',
256
- debug: { enScore, zhScore },
297
+ chars: new Set([...charFreq].filter(([, v]) => v === n).map(([k]) => k)),
298
+ bigrams: new Set([...bigramFreq].filter(([, v]) => v === n).map(([k]) => k)),
257
299
  };
258
300
  }
301
+ /**
302
+ * 判定 query 是否命中分组标题(通用分组语义粗筛)。
303
+ *
304
+ * 规则(混用单字 + 2-gram,至少 2 个任意词 + 至少 1 个区分词):
305
+ * - 提取 query 与分组标题的中文单字集、2-gram 集。
306
+ * - 排除「共有词」(该文件所有分组都出现的词,如 webhook 里的「回调/相关」),避免其单独触发命中。
307
+ * - 命中条件:query 与分组标题共享「≥1 个区分词」且「≥2 个任意词(区分+共有)」。
308
+ *
309
+ * 例:query「服务端群消息发送前回调」vs 分组「群组系统相关回调」:
310
+ * 共享单字「群/回/调」,排除共有「回/调」后区分词「群」命中,且任意词 3 个 → 命中。
311
+ * 而「在线状态相关回调」只共享共有词「回/调」→ 不命中。
312
+ *
313
+ * 产品无关:对所有产品的 urls 分组都适用,不依赖具体产品词表。
314
+ */
315
+ export function queryMatchesSection(query, sectionTitle, commonTokens) {
316
+ const queryChars = new Set(query.match(/[\u4e00-\u9fff]/g) ?? []);
317
+ const queryBigrams = new Set();
318
+ for (const seg of query.match(/[\u4e00-\u9fff]{2,}/g) ?? []) {
319
+ for (let i = 0; i < seg.length - 1; i += 1)
320
+ queryBigrams.add(seg.slice(i, i + 2));
321
+ }
322
+ const sectionChars = new Set(sectionTitle.match(/[\u4e00-\u9fff]/g) ?? []);
323
+ const sectionBigrams = new Set();
324
+ for (const seg of sectionTitle.match(/[\u4e00-\u9fff]{2,}/g) ?? []) {
325
+ for (let i = 0; i < seg.length - 1; i += 1)
326
+ sectionBigrams.add(seg.slice(i, i + 2));
327
+ }
328
+ // 共享单字 + 共享 2-gram(2-gram 命中算 2 个词)
329
+ const sharedChar = [...sectionChars].filter((c) => queryChars.has(c));
330
+ const sharedBigram = [...sectionBigrams].filter((b) => queryBigrams.has(b));
331
+ // 任意词(共享单字按 1 个词、共享 2-gram 按 1 个词计;2-gram 与单字不重复计)
332
+ const anyCount = sharedChar.length + sharedBigram.length;
333
+ // 区分词:共享但不在共有词集合里
334
+ const distinctCount = sharedChar.filter((c) => !commonTokens.chars.has(c)).length
335
+ + sharedBigram.filter((b) => !commonTokens.bigrams.has(b)).length;
336
+ return distinctCount >= 1 && anyCount >= 2;
337
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tencent-rtc/mcp",
3
- "version": "1.7.0",
3
+ "version": "1.7.1",
4
4
  "description": "Tencent Cloud MCP Server for SDK",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -20,12 +20,14 @@
20
20
  "@types/uuid": "^10.0.0",
21
21
  "agentkeepalive": "^4.1.4",
22
22
  "axios": "^0.25.0",
23
+ "cheerio": "^1.2.0",
23
24
  "debug": "^4.3.1",
24
25
  "express": "^5.1.0",
25
26
  "hashring": "^3.2.0",
26
27
  "protobufjs": "^6.11.2",
27
28
  "semver": "^7.3.5",
28
29
  "shx": "^0.3.4",
30
+ "turndown": "^7.2.4",
29
31
  "typescript": "^5.8.2",
30
32
  "uuid": "^8.3.2",
31
33
  "zod": "^3.24.2"