@tencent-rtc/mcp 1.7.0 → 1.7.2
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.
- package/CHANGELOG.md +16 -0
- package/README.md +11 -6
- package/knowledge/doc-url-resolver.js +43 -16
- package/knowledge/search/exact-lookup.js +117 -18
- package/knowledge/search/intent-router.js +21 -5
- package/knowledge/search/l1-enrich.js +159 -0
- package/knowledge/search/query-normalizer.js +8 -1
- package/knowledge/search/search-core.js +25 -6
- package/knowledge/search/search-knowledge.js +159 -46
- package/knowledge/search/search-message.js +13 -12
- package/knowledge/search/url-row-idf.js +120 -0
- package/knowledge/search/url-row-scorer.js +306 -227
- package/package.json +3 -1
- package/resource/index/manifest.json +1 -1
- package/resource/knowledge/chat/urls/product.md +17 -16
- package/resource/knowledge/chat/urls/restapi.md +3 -2
- package/resource/knowledge/chat/urls/sdk/android.md +6 -5
- package/resource/knowledge/chat/urls/sdk/flutter.md +6 -5
- package/resource/knowledge/chat/urls/sdk/ios.md +4 -3
- package/resource/knowledge/chat/urls/sdk/web.md +6 -5
- package/resource/knowledge/chat/urls/webhook.md +0 -5
- package/server.js +4 -4
- package/streamable-http.js +19 -54
- package/tools/common/finalize_answer.js +1 -9
- package/tools/common/search_trtc_knowledge.js +25 -11
- package/tools/definitions/finalize_answer_def.js +2 -2
- package/tools/definitions/search_trtc_knowledge_def.js +4 -2
- package/utils/fetch-doc-content.js +198 -0
- package/utils/preflight-route.js +197 -0
- package/utils/report-cls-client.js +1 -1
- package/utils/report-es-client.js +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# CHANGELOG
|
|
2
2
|
|
|
3
|
+
## Version 1.7.2 @2026.08.13
|
|
4
|
+
|
|
5
|
+
### Changed
|
|
6
|
+
- Improved observability into retrieval routing accuracy.
|
|
7
|
+
|
|
8
|
+
## Version 1.7.1 @2026.08.11
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- Added pre-search normalization: normalizes `product` / `frameworks` based on prompt truth, fixing retrieval bias caused by wrong agent inference.
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
- Optimized the `search_trtc_knowledge` output contract: removed brevity wording such as "1-2 sentence per subsection", replaced with complete synthesized answers.
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
- Fixed the case where mis-passed dual product / dual frameworks (e.g., "安卓 Web TRTC") were not corrected according to the prompt truth.
|
|
18
|
+
|
|
3
19
|
## Version 1.7.0 @2026.08.02
|
|
4
20
|
|
|
5
21
|
### Added
|
package/README.md
CHANGED
|
@@ -57,6 +57,17 @@ npx -y @tencent-rtc/mcp
|
|
|
57
57
|
|
|
58
58
|
> Only recent updates are listed here. For full history, see `CHANGELOG.md`.
|
|
59
59
|
|
|
60
|
+
## Version 1.7.1 @2026.08.11
|
|
61
|
+
|
|
62
|
+
### Added
|
|
63
|
+
- Added pre-search normalization: normalizes `product` / `frameworks` based on prompt truth, fixing retrieval bias caused by wrong agent inference.
|
|
64
|
+
|
|
65
|
+
### Changed
|
|
66
|
+
- Optimized the `search_trtc_knowledge` output contract: removed brevity wording such as "1-2 sentence per subsection", replaced with complete synthesized answers.
|
|
67
|
+
|
|
68
|
+
### Fixed
|
|
69
|
+
- Fixed the case where mis-passed dual product / dual frameworks (e.g., "安卓 Web TRTC") were not corrected according to the prompt truth.
|
|
70
|
+
|
|
60
71
|
### Version 1.7.0 @2026.08.02
|
|
61
72
|
|
|
62
73
|
#### Added
|
|
@@ -74,9 +85,3 @@ npx -y @tencent-rtc/mcp
|
|
|
74
85
|
|
|
75
86
|
#### Added
|
|
76
87
|
- Added support for `get_native_live_uikit_integration`, enabling LiveKit(Android/iOS) UI integration guidance.
|
|
77
|
-
|
|
78
|
-
### Version 1.6.0 @2026.07.01
|
|
79
|
-
|
|
80
|
-
#### Added
|
|
81
|
-
- Refactored MCP retrieval capabilities and consolidated the MCP toolset.
|
|
82
|
-
- Added support for `search_trtc_knowledge` to retrieve real-time audio/video (TRTC/IM) documentation.
|
|
@@ -54,17 +54,42 @@ export function extractUrlMappingPrimaryUrl(content) {
|
|
|
54
54
|
const row = content.match(/\|\s*[^|]+\s*\|\s*`?(https?:\/\/[^|`\s]+)`?\s*\|/);
|
|
55
55
|
return row?.[1] ? normalizeUrl(row[1]) : null;
|
|
56
56
|
}
|
|
57
|
+
/** 产品无关的 URL 映射行判定:knowledge/<product>/urls/... */
|
|
57
58
|
function isUrlMappingSource(source) {
|
|
58
|
-
return /knowledge\/
|
|
59
|
+
return /knowledge\/[a-z0-9_-]+\/urls\/.+\.md$/i.test(source.replace(/\\/g, '/'));
|
|
60
|
+
}
|
|
61
|
+
const RTCENGINE_WEB_API_SOURCE = 'knowledge/rtcengine/web/api.md';
|
|
62
|
+
const RTCENGINE_WEB_API_BASE_URL = 'https://web.sdk.qcloud.com/trtc/webrtc/doc/en/TRTC.html';
|
|
63
|
+
/** api.md 中的非方法标题(示例/成员枚举/类型定义等),不作为锚点生成 */
|
|
64
|
+
const RTCENGINE_WEB_API_NON_METHOD_RE = /^(?:Example|Members|Type|Definitions)$/i;
|
|
65
|
+
/**
|
|
66
|
+
* rtcengine/web/api.md 的 API 章节 chunk(如 `### TRTC.setCurrentSpeaker` / `### setCurrentSpeaker`)
|
|
67
|
+
* 生成稳定参考链接:`.../TRTC.html#.setCurrentSpeaker`
|
|
68
|
+
* 兼容两种标题格式:静态方法带 `TRTC.` 前缀,实例方法不带前缀。
|
|
69
|
+
*/
|
|
70
|
+
function resolveRtcengineWebApiUrl(source, content) {
|
|
71
|
+
if (source !== RTCENGINE_WEB_API_SOURCE)
|
|
72
|
+
return null;
|
|
73
|
+
const methodMatch = content.match(/^###\s+(?:TRTC\.)?([A-Za-z0-9_]+)/m);
|
|
74
|
+
if (methodMatch?.[1] && !RTCENGINE_WEB_API_NON_METHOD_RE.test(methodMatch[1])) {
|
|
75
|
+
return `${RTCENGINE_WEB_API_BASE_URL}#.${methodMatch[1]}`;
|
|
76
|
+
}
|
|
77
|
+
// 未解析到具体方法时回退到类文档首页,避免误用整篇正文里无关 URL。
|
|
78
|
+
return RTCENGINE_WEB_API_BASE_URL;
|
|
59
79
|
}
|
|
60
80
|
/**
|
|
61
81
|
* 为检索 fragment 解析可参考的官网 URL(去重、按优先级)。
|
|
62
|
-
* 1.
|
|
63
|
-
* 2.
|
|
64
|
-
* 3.
|
|
82
|
+
* 1. rtcengine/web/api.md:优先按 API 标题生成锚点 URL(例如 #.setCurrentSpeaker)
|
|
83
|
+
* 2. sync-manifest 落盘的 localPath → 官网 URL(rtcengine 等)
|
|
84
|
+
* 3. L1 URL 映射行内目标 URL
|
|
85
|
+
* 4. 本地文档正文中的官网链接
|
|
65
86
|
*/
|
|
66
87
|
export function resolveFragmentDocUrls(source, content, fullSourceContent) {
|
|
67
88
|
const normalized = source.replace(/\\/g, '/');
|
|
89
|
+
const rtcengineWebApiUrl = resolveRtcengineWebApiUrl(normalized, content);
|
|
90
|
+
if (rtcengineWebApiUrl) {
|
|
91
|
+
return [rtcengineWebApiUrl];
|
|
92
|
+
}
|
|
68
93
|
const urls = [];
|
|
69
94
|
const canonical = loadSourceUrlMap()[normalized];
|
|
70
95
|
if (canonical)
|
|
@@ -87,22 +112,24 @@ export function resolveFragmentDocUrls(source, content, fullSourceContent) {
|
|
|
87
112
|
}
|
|
88
113
|
return urls.slice(0, 3);
|
|
89
114
|
}
|
|
90
|
-
/** 从 fragments 汇总去重后的参考链接列表(按 fragment
|
|
115
|
+
/** 从 fragments 汇总去重后的参考链接列表(按 fragment 相关度排序)。
|
|
116
|
+
* 规则:每个 fragment 仅取一个参考链接(优先 doc_urls[0]),避免 1 个片段膨胀出多个 references。 */
|
|
91
117
|
export function buildReferences(fragments) {
|
|
92
118
|
const refs = [];
|
|
93
119
|
const seen = new Set();
|
|
94
120
|
for (const fragment of fragments) {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
121
|
+
const primaryUrl = fragment.doc_urls.find((u) => isUsefulDocUrl(u));
|
|
122
|
+
if (!primaryUrl)
|
|
123
|
+
continue;
|
|
124
|
+
if (seen.has(primaryUrl))
|
|
125
|
+
continue;
|
|
126
|
+
seen.add(primaryUrl);
|
|
127
|
+
refs.push({
|
|
128
|
+
rank: refs.length + 1,
|
|
129
|
+
title: fragment.title,
|
|
130
|
+
url: primaryUrl,
|
|
131
|
+
source: fragment.source,
|
|
132
|
+
});
|
|
106
133
|
}
|
|
107
134
|
return refs;
|
|
108
135
|
}
|
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import { fileURLToPath } from 'url';
|
|
4
|
-
import {
|
|
4
|
+
import { getSourceFileContent } from '../content-loader.js';
|
|
5
|
+
import { computeCommonSectionTokens, isUrlMappingSource, normalizeUrlRowQuery, queryMatchesSection, scoreUrlRow } from './url-row-scorer.js';
|
|
5
6
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
6
7
|
const SCHEMA_ROOT = path.resolve(__dirname, '../../resource/_schema');
|
|
8
|
+
/**
|
|
9
|
+
* 分组语义命中的基础分:当 scoreUrlRow=0 但该 url 行「分组被 query 命中 + types 匹配」时给予。
|
|
10
|
+
* 取值需满足 E7 strongCandidates 门槛(typeMatch ×1.2 后 ≥500 → base ≥417)。
|
|
11
|
+
* 只在「分组命中」时给(区别于旧的「所有 types 匹配行给 500」),避免无关行涌入。
|
|
12
|
+
*/
|
|
13
|
+
const SECTION_MATCH_BASE_SCORE = 450;
|
|
7
14
|
export const DOC_PATH_MAP = {
|
|
8
15
|
android: {
|
|
9
16
|
core_api: 'TRTCCloud',
|
|
@@ -289,43 +296,135 @@ export function runExactLookup(prompt, pool, norm) {
|
|
|
289
296
|
}
|
|
290
297
|
}
|
|
291
298
|
}
|
|
292
|
-
// E6:
|
|
293
|
-
|
|
299
|
+
// E6: URL 映射文件 boost(L1;产品无关:任意 product 的 urls 均适用)
|
|
300
|
+
const hasUrlChunks = pool.some((c) => isUrlMappingSource(c.source));
|
|
301
|
+
if (hasUrlChunks) {
|
|
294
302
|
if (/payload|usersig|authentication|auth|login|signature|登录|鉴权/i.test(prompt)) {
|
|
295
303
|
for (const chunk of pool) {
|
|
296
|
-
if (chunk.source
|
|
297
|
-
addBoost(chunk, 100, 'E6:
|
|
304
|
+
if (isUrlMappingSource(chunk.source)) {
|
|
305
|
+
addBoost(chunk, 100, 'E6:urls');
|
|
298
306
|
}
|
|
299
307
|
}
|
|
300
308
|
}
|
|
301
309
|
const errorCodeIntent = /错误码|\berror\s*code\b/i.test(prompt) || errorMatches.length > 0 || norm.searchIntent?.includes('error_code');
|
|
302
310
|
if (errorCodeIntent) {
|
|
303
311
|
for (const chunk of pool) {
|
|
304
|
-
if (
|
|
305
|
-
addBoost(chunk, 180, 'E6:
|
|
312
|
+
if (/\/error-code\.md$/i.test(chunk.source)) {
|
|
313
|
+
addBoost(chunk, 180, 'E6:error-code-url');
|
|
306
314
|
}
|
|
307
315
|
}
|
|
308
316
|
}
|
|
309
|
-
// E7: URL
|
|
317
|
+
// E7: URL 映射表行级匹配(chunk.title = 子问题列)
|
|
318
|
+
// 共享 scorer 按「REST 端点 > API token > 语义词对齐(IDF 加权)」打分。
|
|
319
|
+
// 支持 types 领域限定:上游推断出 types 时,优先 types 匹配的子文件行,
|
|
320
|
+
// 不匹配的行降权(避免跨域误命中,如 SDK 问题命中 restapi)。
|
|
310
321
|
if (normalizeUrlRowQuery(prompt).replace(/\s+/g, '').length >= 2) {
|
|
311
322
|
const candidates = [];
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
323
|
+
const hasTypeFilter = Boolean(norm.searchIntent?.length);
|
|
324
|
+
// 预收集 urls 行的分组结构:按 source 读整个 md 源文件,解析 `##` 分组与每行归属。
|
|
325
|
+
// 分组粗筛对所有 urls 资源生效(产品无关),不针对 webhook.md 打补丁。
|
|
326
|
+
const urlChunks = pool.filter((c) => isUrlMappingSource(c.source) && c.title.trim());
|
|
327
|
+
const sectionByChunk = new Map(); // chunk.id -> 分组标题
|
|
328
|
+
const sectionsBySource = new Map(); // source -> 分组标题集合
|
|
329
|
+
const mdRowsBySource = new Map(); // source -> {行title -> 分组标题}
|
|
330
|
+
for (const source of new Set(urlChunks.map((c) => c.source))) {
|
|
331
|
+
let full = '';
|
|
332
|
+
try {
|
|
333
|
+
full = getSourceFileContent(source);
|
|
334
|
+
}
|
|
335
|
+
catch {
|
|
336
|
+
full = '';
|
|
337
|
+
}
|
|
338
|
+
let cur = '';
|
|
339
|
+
const rows = new Map();
|
|
340
|
+
const sections = new Set();
|
|
341
|
+
for (const line of full.split('\n')) {
|
|
342
|
+
const h = line.match(/^#{2,3}\s+(.+)$/);
|
|
343
|
+
if (h) {
|
|
344
|
+
cur = h[1].trim();
|
|
345
|
+
sections.add(cur);
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
const row = line.match(/^\|\s*([^|]+?)\s*\|\s*`(https?:[^`]+)`/);
|
|
349
|
+
if (row)
|
|
350
|
+
rows.set(row[1].trim(), cur);
|
|
351
|
+
}
|
|
352
|
+
mdRowsBySource.set(source, rows);
|
|
353
|
+
sectionsBySource.set(source, sections);
|
|
354
|
+
}
|
|
355
|
+
for (const chunk of urlChunks) {
|
|
356
|
+
const section = mdRowsBySource.get(chunk.source)?.get(chunk.title);
|
|
357
|
+
if (section)
|
|
358
|
+
sectionByChunk.set(chunk.id, section);
|
|
359
|
+
}
|
|
360
|
+
// 计算每个 source 的共有词(该文件所有分组都出现的词)
|
|
361
|
+
const commonTokensBySource = new Map();
|
|
362
|
+
for (const [source, sections] of sectionsBySource) {
|
|
363
|
+
commonTokensBySource.set(source, computeCommonSectionTokens([...sections]));
|
|
364
|
+
}
|
|
365
|
+
for (const chunk of urlChunks) {
|
|
317
366
|
const scoreResult = scoreUrlRow(prompt, chunk.title);
|
|
318
|
-
|
|
319
|
-
|
|
367
|
+
// types 领域限定:query types 与 chunk types 是否有交集
|
|
368
|
+
const typeMatch = hasTypeFilter
|
|
369
|
+
? (norm.searchIntent ?? []).some((t) => chunk.types.includes(t))
|
|
370
|
+
: true;
|
|
371
|
+
// 分组粗筛:query 是否命中该 chunk 所在的分组。
|
|
372
|
+
// 命中分组 → 正常;未命中分组 → 降权(不排除,避免 query 与分组名匹配不准时误杀)。
|
|
373
|
+
const section = sectionByChunk.get(chunk.id);
|
|
374
|
+
const commonTokens = commonTokensBySource.get(chunk.source);
|
|
375
|
+
const sectionMatch = section && commonTokens
|
|
376
|
+
? queryMatchesSection(prompt, section, commonTokens)
|
|
377
|
+
: true; // 无分组信息时不做粗筛(保持原行为)
|
|
378
|
+
// 高 scoreUrlRow 兜底:scoreUrlRow ≥ 700 是极强语义信号(query 2-gram 全覆盖),
|
|
379
|
+
// 此时不应被 sectionMatch 软降权压制。常见场景:短 query 如「群消息发送异常」
|
|
380
|
+
// 跟 section 标题「群组系统回调」共享字符少(仅「群」),sectionMatch 失败,
|
|
381
|
+
// 但 row title 本身完美匹配 user 意图,强行降权会让单聊 row 顶上。
|
|
382
|
+
const highConfidence = scoreResult.score >= 700;
|
|
383
|
+
// 分组语义命中基础分:scoreUrlRow 可能因「同义词/长句」对精确行打 0 分
|
|
384
|
+
// (如「群消息发送前回调」vs「群内发言之前回调」),但分组已语义命中,
|
|
385
|
+
// 给予基础分进入候选,避免该领域行被 score<=0 排除。
|
|
386
|
+
// 区别于旧的「所有 types 匹配行给 500」——只在分组命中时才给,避免无关行涌入。
|
|
387
|
+
if (scoreResult.score <= 0) {
|
|
388
|
+
if (sectionMatch && typeMatch) {
|
|
389
|
+
candidates.push({ chunk, score: SECTION_MATCH_BASE_SCORE, typeMatch, sectionMatch });
|
|
390
|
+
}
|
|
391
|
+
continue;
|
|
320
392
|
}
|
|
393
|
+
// finalScore 排序用:sectionMatch 失败且非高置信时,×0.7 软降权。
|
|
394
|
+
// 高置信(scoreUrlRow ≥ 700)跳过降权,避免短 query + 长 section 标题时的误杀。
|
|
395
|
+
const finalScore = (sectionMatch || highConfidence) ? scoreResult.score : scoreResult.score * 0.7;
|
|
396
|
+
candidates.push({ chunk, score: finalScore, typeMatch, sectionMatch });
|
|
321
397
|
}
|
|
322
398
|
candidates.sort((a, b) => b.score - a.score);
|
|
323
|
-
|
|
324
|
-
|
|
399
|
+
// types 匹配的行加分(更优先);不匹配的行轻度降权但仍可兜底召回。
|
|
400
|
+
// 避免上游 types 推断不准确时误杀正确文档(如「多端登录配置」被推断为 feature 而非 product)。
|
|
401
|
+
const typeScored = candidates
|
|
402
|
+
.map((c) => (c.typeMatch ? { ...c, score: c.score * 1.2 } : { ...c, score: c.score * 0.6 }))
|
|
403
|
+
.filter(({ score }) => score >= 500);
|
|
404
|
+
// 同分 row(典型:query「群消息撤回回调」时「撤回群消息」跟「单聊消息撤回」都 scoreUrlRow=660),
|
|
405
|
+
// 按「query 字符集被 row title 覆盖比例」再排,覆盖更高的 row 排前。
|
|
406
|
+
// 修 E7 boost idx=0 多 40 分的副作用:让字符集更精准的 row 拿 idx=0 → 300 分而不是 260 分。
|
|
407
|
+
const queryChars = new Set(prompt.match(/[\u4e00-\u9fff]/g) ?? []);
|
|
408
|
+
const charCoverage = (title) => {
|
|
409
|
+
if (queryChars.size === 0)
|
|
410
|
+
return 0;
|
|
411
|
+
const titleChars = new Set(title.match(/[\u4e00-\u9fff]/g) ?? []);
|
|
412
|
+
let hit = 0;
|
|
413
|
+
for (const ch of queryChars)
|
|
414
|
+
if (titleChars.has(ch))
|
|
415
|
+
hit += 1;
|
|
416
|
+
return hit / queryChars.size;
|
|
417
|
+
};
|
|
418
|
+
const strongCandidates = typeScored
|
|
419
|
+
.sort((a, b) => {
|
|
420
|
+
// 优先按 typeScored score 降序;同分时按字符集覆盖率降序
|
|
421
|
+
if (b.score !== a.score)
|
|
422
|
+
return b.score - a.score;
|
|
423
|
+
return charCoverage(b.chunk.title) - charCoverage(a.chunk.title);
|
|
424
|
+
})
|
|
325
425
|
.slice(0, 12);
|
|
326
426
|
strongCandidates.forEach(({ chunk, score }, idx) => {
|
|
327
427
|
// 递减 boost:前两条更高,其余保持可召回但不过度压制 BM25
|
|
328
|
-
// token 匹配(600 分)至少给 150,确保子句拆分的行不被 BM25 原始分压掉
|
|
329
428
|
const amount = idx === 0
|
|
330
429
|
? 200 + Math.min(score, 100)
|
|
331
430
|
: idx === 1
|
|
@@ -1,13 +1,29 @@
|
|
|
1
1
|
/** 枚举/目录浏览意图 */
|
|
2
|
-
const DISCOVERY_INTENT_RE =
|
|
2
|
+
const DISCOVERY_INTENT_RE = /有哪些|支持哪些|列一下|列出来|清单|目录|可用|全部|全量|对比|怎么选|选哪个|哪些\s+(组件|功能|接口|能力)|which\s+one|compare|comparison|what\s+(is|are)\s+available|what\s+.*\b(components?|features?|apis?)\b|available\s+(components?|features?|apis?)|supported\s+(components?|features?|apis?)|list\s+(all|available)|catalog/i;
|
|
3
3
|
/** 显式完整文档意图(用户明确说"完整/从零/step by step") */
|
|
4
4
|
const NARRATIVE_INTENT_RE = /从头到尾|一步一步|完整指南|完整教程|完整流程|完整接入|完整集成|完整的\s*(接入|集成|教程|指南|流程)|从零|全流程|接入指南|集成指南|step\s*by(?:\s*step)?|end[-\s]?to[-\s]?end|full\s+(guide|tutorial|flow|integration)|complete\s+(guide|tutorial|integration)|quick\s*start|getting\s*started/i;
|
|
5
|
-
/** 强 point
|
|
6
|
-
|
|
5
|
+
/** 强 point 信号:API token / 错误码 / 具体操作词 → 优先走 point。
|
|
6
|
+
* 注意 camelCase 检测必须区分大小写(不加 /i),否则 React/RoomKit 这类普通专有名词会被误判为 API。 */
|
|
7
|
+
const POINT_STRONG_SIGNAL_RE = /\b\d{5,7}\b|\bERR_[A-Z0-9_]+\b|\buse[A-Z]\w*\b|\b[A-Z][A-Za-z0-9_]*\.[A-Za-z_][A-Za-z0-9_]*\b|\b(TRTC|TX)[A-Za-z0-9_]+\b|\bprops?\b|参数|事件|回调|error\s*code|错误码|怎么用|如何用|怎么实现|如何实现|怎么做|怎么发送|怎么获取|怎么监听|怎么接收|怎么调用|如何发送|如何获取|如何监听|如何调用|参数是什么/i;
|
|
8
|
+
/** 判断是否含 API token(驼峰/点号/错误码/数字),这是最强的 point 结构信号。
|
|
9
|
+
* camelCase 检测区分大小写,避免把 React/RoomKit 等专有名词误判为 API。 */
|
|
10
|
+
function hasApiToken(prompt) {
|
|
11
|
+
return /\b[a-z]+[A-Z][A-Za-z0-9_]*\b|\b[A-Z][A-Za-z0-9_]*\.[A-Za-z_]\w*\b|\b(TRTC|TX)[A-Za-z0-9_]+\b|\b\d{5,7}\b|\bERR_[A-Z0-9_]+\b/.test(prompt);
|
|
12
|
+
}
|
|
7
13
|
export function classifyRetrievalMode(prompt) {
|
|
8
|
-
|
|
14
|
+
// 优先级分层(结构化信号,不依赖句末问号):
|
|
15
|
+
// 1) narrative 优先(保守):用户明确要完整指南/从零/step by step,
|
|
16
|
+
// 即使带操作词(如"完整指南怎么做")也走 narrative,因为意图是完整文档。
|
|
17
|
+
if (NARRATIVE_INTENT_RE.test(prompt) && !hasApiToken(prompt)) {
|
|
9
18
|
return 'narrative';
|
|
10
|
-
|
|
19
|
+
}
|
|
20
|
+
// 2) API token 是最强 point 信号:query 出现具体 API/错误码/数字 → point
|
|
21
|
+
if (hasApiToken(prompt)) {
|
|
22
|
+
return 'point';
|
|
23
|
+
}
|
|
24
|
+
// 3) discovery:枚举词 + 无具体操作词("有哪些接口 怎么实现"这类混合问法 → 走 point)
|
|
25
|
+
if (DISCOVERY_INTENT_RE.test(prompt) && !POINT_STRONG_SIGNAL_RE.test(prompt)) {
|
|
11
26
|
return 'discovery';
|
|
27
|
+
}
|
|
12
28
|
return 'point';
|
|
13
29
|
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* l1-enrich.ts
|
|
3
|
+
*
|
|
4
|
+
* 当检索结果命中 chat/urls 的 L1 URL 映射行(内容只是官网链接,无本地正文)时,
|
|
5
|
+
* server 内部 fetch 目标文档并回填 markdown 正文,避免把"只有一行 URL"的结果交给 AI。
|
|
6
|
+
*
|
|
7
|
+
* 设计:
|
|
8
|
+
* - 复用 fetch-doc-content 的清洗能力(白名单 + gzip 解压 + md 转换 + 截断)。
|
|
9
|
+
* - 仅对"纯 URL 映射行"的 fragment 回填;已有本地正文的 fragment 不动。
|
|
10
|
+
* - 逐条 fetch,但并发受限 + 超时 + 缓存兜底,失败静默(保留原 URL 行)。
|
|
11
|
+
*/
|
|
12
|
+
import { fetchDocContent } from '../../utils/fetch-doc-content.js';
|
|
13
|
+
import { buildReferences } from '../doc-url-resolver.js';
|
|
14
|
+
import { partitionFragments } from './search-core.js';
|
|
15
|
+
import { isUrlMappingSource } from './url-row-scorer.js';
|
|
16
|
+
/** 从 fragment 中取第一个可 fetch 的官方 URL */
|
|
17
|
+
function primaryDocUrl(fragment) {
|
|
18
|
+
const urls = fragment.doc_urls ?? [];
|
|
19
|
+
return urls[0];
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* 单条 fragment 回填:
|
|
23
|
+
* 拉取到正文后,content 替换为官网 markdown,source 改写为官网 URL。
|
|
24
|
+
* 这样已回填片段不再命中 `chat/urls/` 的 L1 判定,AI 会把它当本地正文(L0)直接作答,无需再 fetch。
|
|
25
|
+
*/
|
|
26
|
+
async function enrichFragment(fragment, maxChars) {
|
|
27
|
+
const url = primaryDocUrl(fragment);
|
|
28
|
+
if (!url)
|
|
29
|
+
return null;
|
|
30
|
+
const result = await fetchDocContent(url, { maxChars });
|
|
31
|
+
if (!result)
|
|
32
|
+
return null;
|
|
33
|
+
return {
|
|
34
|
+
...fragment,
|
|
35
|
+
content: result.markdown,
|
|
36
|
+
source: url,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/** 服务端 L1 领域类型:query 为客户端 SDK 意图时跳过,避免给 SDK 问题回填 REST/回调正文。
|
|
40
|
+
* 注意:'product' 不在集合中——产品概念/全局方案(如「内网代理实现方案」)跨平台适用,
|
|
41
|
+
* 客户端 query 命中 product 文档应正常 enrich(如 web SDK 用户搜「内网代理」应拿到正文)。
|
|
42
|
+
*/
|
|
43
|
+
const SERVER_SIDE_TYPES = new Set(['server_api', 'webhook']);
|
|
44
|
+
/**
|
|
45
|
+
* 判定 L1 行是否属于「query 主领域」,用于限领域回填(避免 token 浪费在无关领域)。
|
|
46
|
+
* - query 有 frameworks(客户端 SDK 意图):只回填 sdk/api 等客户端行,跳过 server_api/webhook/product。
|
|
47
|
+
* - query 无 frameworks(服务端/跨域意图):只回填服务端行,跳过 sdk/api 客户端行。
|
|
48
|
+
* 语言无关、产品无关。
|
|
49
|
+
*/
|
|
50
|
+
function isInQueryDomain(fragment, queryFrameworks) {
|
|
51
|
+
if (queryFrameworks?.length) {
|
|
52
|
+
// 客户端意图:跳过服务端领域行
|
|
53
|
+
if (fragment.types.some((t) => SERVER_SIDE_TYPES.has(t)))
|
|
54
|
+
return false;
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
// 无 frameworks:跨域/服务端意图,跳过纯客户端 sdk/api 行
|
|
58
|
+
if (fragment.types.includes('sdk') || fragment.types.includes('api'))
|
|
59
|
+
return false;
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* 全部主领域 L1 行回填成功后,重写 message:
|
|
64
|
+
* 原始 message 是在回填前由 searchKnowledge 生成的(可能提示"需抓官网文档"),
|
|
65
|
+
* 回填后 source 已指向官网正文,应改为"直接基于正文作答",避免 AI 被旧话术误导再去抓官网。
|
|
66
|
+
* 保留原 message 中 finalize_answer / submit_feedback 等必要工作流指令。
|
|
67
|
+
*/
|
|
68
|
+
function buildEnrichedMessage(response) {
|
|
69
|
+
const original = response.message ?? '';
|
|
70
|
+
const tail = original.includes('Final answer drafted')
|
|
71
|
+
? original.slice(original.indexOf('Final answer drafted'))
|
|
72
|
+
: '';
|
|
73
|
+
return `Official content has been fetched and provided below; answer directly. ${tail}`;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* 对 searchKnowledge 的结果做 L1 内容回填(异步,不影响同步检索主流程)。
|
|
77
|
+
* 返回回填后的新 response 副本。
|
|
78
|
+
*
|
|
79
|
+
* 修复(与 V3 `applySingleTopicTrim` 同步改造):
|
|
80
|
+
* - 跨领域 L1 行 filter 提到早 return 之前,覆盖「无主领域 L1 行可 enrich」分支(A: 无 L1 / B: L1 全跨领域 / C: L1 全已回填)。
|
|
81
|
+
* 修复前 B case 早 return,跨领域 L1 行残留 + message 假 "fetch 提示",LLM 拿到 URL 表行 content 无法作答。
|
|
82
|
+
* - targets 用 origIndex 跟踪原数组位置,确保回填后 fragments/references 仍按原顺序、保持一致。
|
|
83
|
+
*/
|
|
84
|
+
export async function enrichL1Fragments(response, opts) {
|
|
85
|
+
const maxChars = opts?.maxChars ?? 50000;
|
|
86
|
+
const concurrency = opts?.concurrency ?? 3;
|
|
87
|
+
const queryFrameworks = response.query?.frameworks;
|
|
88
|
+
const originalFragments = response.fragments ?? [];
|
|
89
|
+
// 公共 filter:跨领域 L1 行一律过滤(无论是否走 enrich 路径)
|
|
90
|
+
const afterFilter = originalFragments.filter((f) => {
|
|
91
|
+
if (!isUrlMappingSource(f.source))
|
|
92
|
+
return true;
|
|
93
|
+
// L1 行:已回填(source 已是官网 URL)或属于主领域 → 保留;否则过滤
|
|
94
|
+
return /^https?:\/\//i.test(f.source) || isInQueryDomain(f, queryFrameworks);
|
|
95
|
+
});
|
|
96
|
+
// 收集需要回填的 L1 fragment(仅纯 URL 映射行 + 属于 query 主领域 + 未回填)
|
|
97
|
+
// 关键:用 origIndex 跟踪原数组位置,enrich 后能正确回填,避免与公共 filter 后的 fragments 错位
|
|
98
|
+
const targets = [];
|
|
99
|
+
for (let i = 0; i < originalFragments.length; i += 1) {
|
|
100
|
+
const f = originalFragments[i];
|
|
101
|
+
if (isUrlMappingSource(f.source)
|
|
102
|
+
&& !/^https?:\/\//i.test(f.source) // 未回填
|
|
103
|
+
&& isInQueryDomain(f, queryFrameworks) // 属于主领域
|
|
104
|
+
) {
|
|
105
|
+
targets.push({ f, origIndex: i });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
// 无主领域 L1 行可 enrich(A: 无 L1 / B: L1 全跨领域 / C: L1 全已回填)
|
|
109
|
+
// 仍按公共 filter 后的 fragments 返回,并重建 references 保持 fragments/references 一致
|
|
110
|
+
if (targets.length === 0) {
|
|
111
|
+
const refs = afterFilter.length > 0 ? buildReferences(afterFilter) : [];
|
|
112
|
+
return {
|
|
113
|
+
...response,
|
|
114
|
+
fragments: afterFilter,
|
|
115
|
+
meta: { ...response.meta, references: refs },
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
// 并发受限地逐条回填:仅成功拉取到正文的 fragment 才写入 Map
|
|
119
|
+
const enriched = new Map();
|
|
120
|
+
let cursor = 0;
|
|
121
|
+
const worker = async () => {
|
|
122
|
+
while (cursor < targets.length) {
|
|
123
|
+
const { f, origIndex } = targets[cursor];
|
|
124
|
+
cursor += 1;
|
|
125
|
+
const enrichedFragment = await enrichFragment(f, maxChars);
|
|
126
|
+
if (enrichedFragment)
|
|
127
|
+
enriched.set(origIndex, enrichedFragment);
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, targets.length) }, () => worker()));
|
|
131
|
+
// 组装新 fragments:
|
|
132
|
+
// - 基于 originalFragments 替换已回填的 L1 行
|
|
133
|
+
// - 跨领域 L1 行再次过滤(保险,理论上已被 afterFilter 过滤)
|
|
134
|
+
const newFragments = originalFragments
|
|
135
|
+
.map((f, i) => enriched.get(i) ?? f)
|
|
136
|
+
.filter((f) => {
|
|
137
|
+
if (!isUrlMappingSource(f.source))
|
|
138
|
+
return true;
|
|
139
|
+
return /^https?:\/\//i.test(f.source) || isInQueryDomain(f, queryFrameworks);
|
|
140
|
+
});
|
|
141
|
+
// 基于回填后的新 fragments 重建 references,
|
|
142
|
+
// 使 meta.references 与 fragments 的 source 保持一致(回填后 source=官网 URL,不再指向 chat/urls 本地路径)。
|
|
143
|
+
const references = newFragments.length > 0 ? buildReferences(newFragments) : [];
|
|
144
|
+
// 仅当回填前「有 L1 行且无本地正文」(对应 buildAgentMessage 的"需抓官网"分支)且本次全部回填成功时,
|
|
145
|
+
// 重写 message 为「已提供官网正文」。用结构判断(而非正则匹配 message 文案),更稳、不随文案变化失效。
|
|
146
|
+
const { urlMapping, localDoc } = partitionFragments(originalFragments);
|
|
147
|
+
const originalNeedsFetch = urlMapping.length > 0 && localDoc.length === 0;
|
|
148
|
+
const allEnriched = originalNeedsFetch && enriched.size > 0 && enriched.size === targets.length;
|
|
149
|
+
const message = allEnriched ? buildEnrichedMessage(response) : response.message;
|
|
150
|
+
return {
|
|
151
|
+
...response,
|
|
152
|
+
fragments: newFragments,
|
|
153
|
+
message,
|
|
154
|
+
meta: {
|
|
155
|
+
...response.meta,
|
|
156
|
+
references,
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
}
|
|
@@ -37,7 +37,6 @@ const BASE_QUERY_NORMALIZATION = {
|
|
|
37
37
|
messages: ['消息', 'chat', 'conversation'],
|
|
38
38
|
group: ['群组', '群聊', 'group chat'],
|
|
39
39
|
c2c: ['单聊', 'one-to-one', 'single chat'],
|
|
40
|
-
webhook: ['回调', 'webhook', 'callbacks'],
|
|
41
40
|
avchatroom: ['直播群'],
|
|
42
41
|
priority: ['重要消息', '优先级'],
|
|
43
42
|
reliability: ['消息可靠性'],
|
|
@@ -104,12 +103,20 @@ export function normalizeQueryPrompt(prompt) {
|
|
|
104
103
|
const config = loadQueryNormalizationConfig();
|
|
105
104
|
const terms = new Set(prompt.split(/\s+/).filter(Boolean));
|
|
106
105
|
const promptLower = prompt.toLowerCase();
|
|
106
|
+
// 纯 EN 提问时跳过 CJK 注入:query expansion 的 CJK 翻译(群组/群聊/回调 等)
|
|
107
|
+
// 会进入 qCjk 触发「混合 query 只走 CJK 路径」分支,导致纯 EN title 行(CJK 路径
|
|
108
|
+
// 返回 0)失去 E7 boost,BM25 排名错误。典型回归:webhook 行「X webhook」全部
|
|
109
|
+
// 被「Before create group webhook」抢首位(+7 row top1 退化)。
|
|
110
|
+
// zh 端几乎都是 CJK 提问 → isPureEn=false → 行为完全不变。
|
|
111
|
+
const isPureEn = !hasCjk(prompt);
|
|
107
112
|
let appendedCount = 0;
|
|
108
113
|
const tryAppend = (tokens) => {
|
|
109
114
|
for (const token of tokens) {
|
|
110
115
|
if (appendedCount >= MAX_APPEND_TOKENS)
|
|
111
116
|
return;
|
|
112
117
|
if (!terms.has(token)) {
|
|
118
|
+
if (isPureEn && hasCjk(token))
|
|
119
|
+
continue;
|
|
113
120
|
terms.add(token);
|
|
114
121
|
appendedCount += 1;
|
|
115
122
|
}
|
|
@@ -87,11 +87,25 @@ export function postProcess(ranked, chunksById, limit, opts) {
|
|
|
87
87
|
// 首条不设分数截止,至少保证 1 条返回
|
|
88
88
|
}
|
|
89
89
|
else if (!isPinnedUrlRow) {
|
|
90
|
-
// 分数衰减到 top 的 5% 以下时停止,不硬填到 limit
|
|
91
|
-
// chat/urls 的行级精确命中(E7)不受该规则截断,保证多子主题可并行返回
|
|
92
90
|
const topScore = fragments[0].score;
|
|
93
|
-
|
|
94
|
-
|
|
91
|
+
// 方案A:分数断崖截断。
|
|
92
|
+
// 当已有 E7 精确命中行(pinned url-row)时,后续非 pinned 行若跌破 Top1 的 20%,
|
|
93
|
+
// 视为「Top1 已精确命中、后续多为泛匹配噪声」,提前截断,避免凑满 limit 返回冗余。
|
|
94
|
+
// 无 E7 命中时不触发(此时答案依赖 best-practice/本地文档,需完整保留)。
|
|
95
|
+
if (topScore > 0) {
|
|
96
|
+
const pinnedChunkIds = opts?.pinnedUrlRowChunkIds;
|
|
97
|
+
const hasPinnedRow = pinnedChunkIds
|
|
98
|
+
? fragments.some((f) => pinnedChunkIds.has(f.chunk_id))
|
|
99
|
+
: false;
|
|
100
|
+
// 原有:分数衰减到 top 的 5% 以下时停止
|
|
101
|
+
if (item.score < topScore * 0.05)
|
|
102
|
+
break;
|
|
103
|
+
// 新增:已有 E7 精确命中 + 后续跌破 35% → 断崖截断
|
|
104
|
+
// 阈值取 35%(非 20%):best-practice 泛匹配分数常在 Top1 的 20%~35% 区间,
|
|
105
|
+
// 用 35% 才能拦住「Top1 精确命中后仍返回一堆无关 best-practice」的冗余。
|
|
106
|
+
if (hasPinnedRow && item.score < topScore * 0.35)
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
95
109
|
}
|
|
96
110
|
const aggregated = Boolean(chunk.aggregable) && fragments.length > 0 && fragments[0].source === chunk.source;
|
|
97
111
|
fragments.push(buildFragment(chunk, item.score, fragments.length + 1, aggregated));
|
|
@@ -100,9 +114,14 @@ export function postProcess(ranked, chunksById, limit, opts) {
|
|
|
100
114
|
}
|
|
101
115
|
return fragments;
|
|
102
116
|
}
|
|
117
|
+
/** 产品无关的 URL 映射行判定:knowledge/<product>/urls/.../xxx.md */
|
|
103
118
|
export function isUrlMappingSource(source) {
|
|
119
|
+
return /knowledge\/[a-z0-9_-]+\/urls\/.+\.md$/i.test(source.replace(/\\/g, '/'));
|
|
120
|
+
}
|
|
121
|
+
/** 产品无关地提取 URL 映射行的「子文件 kind」(如 sdk/web、error-code);非映射行返回 null */
|
|
122
|
+
export function extractUrlMappingKind(source) {
|
|
104
123
|
const normalized = source.replace(/\\/g, '/');
|
|
105
|
-
const match = normalized.match(/knowledge\/
|
|
124
|
+
const match = normalized.match(/knowledge\/[a-z0-9_-]+\/urls\/(.+\.md)$/);
|
|
106
125
|
if (!match)
|
|
107
126
|
return null;
|
|
108
127
|
return match[1].replace(/\.md$/, '').replace(/\/index$/, '');
|
|
@@ -118,7 +137,7 @@ export function partitionFragments(fragments) {
|
|
|
118
137
|
localDoc.push(fragment);
|
|
119
138
|
}
|
|
120
139
|
}
|
|
121
|
-
const urlMappingKinds = [...new Set(urlMapping.map((f) =>
|
|
140
|
+
const urlMappingKinds = [...new Set(urlMapping.map((f) => extractUrlMappingKind(f.source)).filter(Boolean))];
|
|
122
141
|
return { urlMapping, localDoc, urlMappingKinds };
|
|
123
142
|
}
|
|
124
143
|
export function isNarrativeSource(source) {
|