@tencent-rtc/mcp 1.6.8 → 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.
- package/CHANGELOG.md +36 -0
- package/README.md +58 -39
- package/knowledge/build-index.js +30 -6
- package/knowledge/chunker.js +82 -14
- package/knowledge/chunking-policy.js +100 -0
- package/knowledge/content-loader.js +3 -3
- package/knowledge/doc-url-resolver.js +43 -16
- package/knowledge/frontmatter-meta.js +167 -0
- package/knowledge/path-meta.js +28 -6
- package/knowledge/search/exact-lookup.js +120 -239
- package/knowledge/search/filter-pool.js +12 -29
- package/knowledge/search/framework-fallback.js +22 -0
- package/knowledge/search/intent-router.js +29 -0
- package/knowledge/search/l1-enrich.js +159 -0
- package/knowledge/search/query-normalizer.js +63 -3
- package/knowledge/search/search-core.js +410 -0
- package/knowledge/search/search-knowledge.js +421 -379
- package/knowledge/search/search-message.js +88 -0
- package/knowledge/search/search-pass.js +428 -0
- package/knowledge/search/search-types.js +1 -0
- package/knowledge/search/url-row-idf.js +120 -0
- package/knowledge/search/url-row-scorer.js +337 -0
- package/package.json +3 -1
- package/resource/_schema/feature-aliases.yaml +1 -1
- package/resource/index/manifest.json +1 -1
- package/resource/knowledge/callkit/best-practice/callkit-miniprogram/uniapp-shrink.md +60 -0
- package/resource/knowledge/callkit/best-practice/common/call-autoplay-blocked.md +77 -0
- package/resource/knowledge/callkit/best-practice/common/call-message-in-chat.md +78 -0
- package/resource/knowledge/callkit/best-practice/common/call-vs-calls.md +110 -0
- package/resource/knowledge/callkit/best-practice/common/callengine-call-flow.md +123 -0
- package/resource/knowledge/callkit/best-practice/common/callengine-log-level.md +67 -0
- package/resource/knowledge/callkit/best-practice/common/callkit-get-engine-instance.md +81 -0
- package/resource/knowledge/callkit/best-practice/common/callkit-get-roomid.md +78 -0
- package/resource/knowledge/callkit/best-practice/common/callkit-limitations.md +37 -0
- package/resource/knowledge/callkit/best-practice/common/callkit-user-register-and-kicked.md +98 -0
- package/resource/knowledge/chat/best-practice/common/avchatroom-message-loss-diagnosis.md +65 -0
- package/resource/knowledge/chat/best-practice/common/group-conversation-cleanup-on-system-notice.md +62 -0
- package/resource/knowledge/chat/best-practice/common/image-message-url-lifecycle.md +59 -0
- package/resource/knowledge/chat/best-practice/common/login-repeat-2025-diagnosis.md +61 -0
- package/resource/knowledge/chat/best-practice/common/message-read-report-and-receipt.md +64 -0
- package/resource/knowledge/chat/best-practice/common/message-receive-event.md +65 -0
- package/resource/knowledge/chat/best-practice/common/message-risk-error-and-render.md +65 -0
- package/resource/knowledge/chat/best-practice/common/message-unread-count.md +71 -0
- package/resource/knowledge/chat/best-practice/miniprogram/audio-message-send.md +113 -0
- package/resource/knowledge/chat/best-practice/miniprogram/login-timeout-2801-diagnosis.md +63 -0
- package/resource/knowledge/chat/best-practice/miniprogram/media-upload-fail-diagnosis.md +64 -0
- package/resource/knowledge/chat/best-practice/web/audio-message-send.md +118 -0
- 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 +7 -6
- package/resource/knowledge/chat/urls/webhook.md +0 -5
- package/resource/knowledge/livekit/best-practice/android/voice-room-anchor-start.md +120 -0
- package/resource/knowledge/livekit/best-practice/common/admin-take-seat.md +73 -0
- package/resource/knowledge/livekit/best-practice/common/audience-leave-live.md +81 -0
- package/resource/knowledge/livekit/best-practice/common/voice-room-no-audio-troubleshoot.md +100 -0
- package/resource/knowledge/livekit/best-practice/common/voice-room-seat-list-change.md +65 -0
- package/resource/knowledge/livekit/best-practice/flutter/voice-room-anchor-start.md +122 -0
- package/resource/knowledge/livekit/best-practice/ios/voice-room-anchor-start.md +126 -0
- package/resource/knowledge/roomkit/best-practice/common/room-ghost-member.md +70 -0
- package/resource/knowledge/roomkit/best-practice/common/room-limit-and-multi.md +83 -0
- package/resource/knowledge/roomkit/best-practice/common/room-screen-share-preview.md +83 -0
- package/resource/knowledge/roomkit/best-practice/common/room-status-after-all-leave.md +69 -0
- package/resource/knowledge/roomkit/best-practice/vue/room-uikit-vs-atomicx.md +111 -0
- package/resource/knowledge/rtcengine/web/plugin/30-advanced-cross-room-link.md +2 -0
- package/resource/knowledge/rtcengine/web/plugin/39-advanced-video-decoder.md +2 -0
- package/server.js +28 -23
- package/streamable-http.js +19 -54
- package/tools/common/search_trtc_knowledge.js +24 -12
- package/tools/definitions/finalize_answer_def.js +2 -2
- package/tools/definitions/integration-boundary.js +2 -1
- package/tools/definitions/search_trtc_knowledge_def.js +5 -3
- 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/cursor-setting.png +0 -0
- package/mcp-server-prompt.png +0 -0
- package/resource/knowledge/callkit/best-practice/web/callengine-invitation.md +0 -80
- package/resource/knowledge/callkit/best-practice/web/callengine-log-control.md +0 -24
- package/resource/knowledge/callkit/best-practice/web/callkit-call-vs-calls.md +0 -116
- package/resource/knowledge/callkit/preset/react/uikit-api-reference.md +0 -444
- package/resource/knowledge/callkit/preset/vue/uikit-api-reference.md +0 -464
- package/resource/knowledge/chat/best-practice/web/image-message-url-handling.md +0 -16
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import { fileURLToPath } from 'url';
|
|
4
|
-
import { attachPoolContent, getSourceFileContent } from '../content-loader.js';
|
|
5
|
-
import { buildReferences, resolveFragmentDocUrls } from '../doc-url-resolver.js';
|
|
6
|
-
import { rankSubsetWithIndex } from '../bm25.js';
|
|
7
|
-
import { getProductsBM25Index } from '../bm25-cache.js';
|
|
8
4
|
import { filterPool, normalizeParams } from './filter-pool.js';
|
|
9
|
-
import { runExactLookup } from './exact-lookup.js';
|
|
10
5
|
import { requiresFramework } from './requires-framework.js';
|
|
6
|
+
import { classifyRetrievalMode } from './intent-router.js';
|
|
7
|
+
import { rankSubsetWithIndex } from '../bm25.js';
|
|
8
|
+
import { getProductsBM25Index } from '../bm25-cache.js';
|
|
9
|
+
import { hasExplicitFrameworkMatch } from './framework-fallback.js';
|
|
10
|
+
import { assessConfidence, assessNarrativeCompleteness, buildNarrativeDocument, hasNativeClientFramework, hasNativeTrtcApiSignal, hasProductScopeIntent, inferQueryApiTokens, pickNarrativeSource, } from './search-core.js';
|
|
11
|
+
import { scoreUrlRow } from './url-row-scorer.js';
|
|
12
|
+
import { buildAnswerContract, buildAgentMessage } from './search-message.js';
|
|
13
|
+
import { runSearchPass } from './search-pass.js';
|
|
14
|
+
import { buildReferences } from '../doc-url-resolver.js';
|
|
11
15
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
12
16
|
let cachedManifest = null;
|
|
13
17
|
function resolveResourcePath(...segments) {
|
|
@@ -18,317 +22,173 @@ function loadManifest() {
|
|
|
18
22
|
return cachedManifest;
|
|
19
23
|
const manifestPath = resolveResourcePath('index', 'manifest.json');
|
|
20
24
|
if (!fs.existsSync(manifestPath)) {
|
|
21
|
-
throw new Error('manifest.json not found. Run `npm run build`
|
|
25
|
+
throw new Error('manifest.json not found. Run `npm run build` first.');
|
|
22
26
|
}
|
|
23
27
|
cachedManifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
|
|
24
28
|
return cachedManifest;
|
|
25
29
|
}
|
|
26
|
-
function
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
const chunk = chunksById.get(item.id);
|
|
34
|
-
if (!chunk)
|
|
35
|
-
continue;
|
|
36
|
-
const isUrlMapping = Boolean(isUrlMappingSource(chunk.source));
|
|
37
|
-
const isPinnedUrlRow = isUrlMapping && (opts?.pinnedUrlRowChunkIds?.has(chunk.id) ?? false);
|
|
38
|
-
if (isUrlMapping) {
|
|
39
|
-
const urlCount = urlMappingSourceCount[chunk.source] ?? 0;
|
|
40
|
-
if (!isPinnedUrlRow && urlCount >= 1)
|
|
41
|
-
continue;
|
|
42
|
-
urlMappingSourceCount[chunk.source] = urlCount + 1;
|
|
43
|
-
}
|
|
44
|
-
else {
|
|
45
|
-
const count = sourceCount[chunk.source] ?? 0;
|
|
46
|
-
if (count >= 3)
|
|
47
|
-
continue;
|
|
48
|
-
sourceCount[chunk.source] = count + 1;
|
|
49
|
-
}
|
|
50
|
-
// 当用户明确问某厂商时,抑制同目录下其他厂商文档的噪声
|
|
51
|
-
// 规则:如果已有 config/android/{vendor}.md 入选,
|
|
52
|
-
// 后续其他 config/android/{other}.md 跳过
|
|
53
|
-
const isVendorDoc = chunk.source.match(/^knowledge\/push\/config\/android\/[a-z]+\.md$/);
|
|
54
|
-
if (isVendorDoc) {
|
|
55
|
-
if (!selectedVendorFile) {
|
|
56
|
-
selectedVendorFile = chunk.source;
|
|
57
|
-
}
|
|
58
|
-
else if (chunk.source !== selectedVendorFile) {
|
|
59
|
-
continue; // 跳过其他厂商
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
// 当已明确命中了厂商配置页后,后续的 push 通用文件(integration/、其他 config/)不再有意义
|
|
63
|
-
// 只保留同厂商文件(已由 vendor dedup 处理)和 overview 综述,其他 push 通用文档视为噪声
|
|
64
|
-
if (selectedVendorFile && chunk.source.startsWith('knowledge/push/') && !chunk.source.includes('/overview/')) {
|
|
65
|
-
if (chunk.source !== selectedVendorFile)
|
|
66
|
-
continue;
|
|
67
|
-
}
|
|
68
|
-
const firstPush = fragments.length === 0;
|
|
69
|
-
if (firstPush) {
|
|
70
|
-
// 首条不设分数截止,至少保证 1 条返回
|
|
71
|
-
}
|
|
72
|
-
else if (!isPinnedUrlRow) {
|
|
73
|
-
// 分数衰减到 top 的 5% 以下时停止,不硬填到 limit
|
|
74
|
-
// chat/urls 的行级精确命中(E7)不受该规则截断,保证多子主题可并行返回
|
|
75
|
-
const topScore = fragments[0].score;
|
|
76
|
-
if (topScore > 0 && item.score < topScore * 0.05)
|
|
77
|
-
break;
|
|
78
|
-
}
|
|
79
|
-
const contentWithHint = chunk.contains_code
|
|
80
|
-
? `【代码示例,须与目标端一致】\n${chunk.content}`
|
|
81
|
-
: chunk.content;
|
|
82
|
-
fragments.push({
|
|
83
|
-
rank: fragments.length + 1,
|
|
84
|
-
score: item.score,
|
|
85
|
-
title: chunk.title,
|
|
86
|
-
content: contentWithHint,
|
|
87
|
-
source: chunk.source,
|
|
88
|
-
doc_urls: resolveFragmentDocUrls(chunk.source, chunk.content, getSourceFileContent(chunk.source)),
|
|
89
|
-
product: chunk.product,
|
|
90
|
-
framework: chunk.framework,
|
|
91
|
-
types: chunk.types,
|
|
92
|
-
variant: chunk.variant,
|
|
93
|
-
scope: chunk.scope,
|
|
94
|
-
doc_kind: chunk.doc_kind,
|
|
95
|
-
capability_status: chunk.capability_status,
|
|
96
|
-
api_names: chunk.api_names,
|
|
97
|
-
contains_code: chunk.contains_code,
|
|
98
|
-
citation: `[来源: ${chunk.id}]`,
|
|
99
|
-
chunk_id: chunk.id,
|
|
100
|
-
});
|
|
101
|
-
if (fragments.length >= limit)
|
|
102
|
-
break;
|
|
30
|
+
function buildCoverageReport(allChunks, fragments, preferredSource) {
|
|
31
|
+
if (!fragments.length) {
|
|
32
|
+
return {
|
|
33
|
+
files: [],
|
|
34
|
+
allow_pending_confirm: true,
|
|
35
|
+
retry_suggested: false,
|
|
36
|
+
};
|
|
103
37
|
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
38
|
+
const hitChunkIds = new Set(fragments.map((f) => f.chunk_id));
|
|
39
|
+
const sources = [...new Set(fragments.map((f) => f.source))];
|
|
40
|
+
const files = sources.map((source) => {
|
|
41
|
+
const sourceChunks = allChunks.filter((chunk) => chunk.source === source);
|
|
42
|
+
const fileToken = sourceChunks.reduce((sum, chunk) => sum + Math.max(0, chunk.char_end - chunk.char_start), 0);
|
|
43
|
+
const hitToken = sourceChunks
|
|
44
|
+
.filter((chunk) => hitChunkIds.has(chunk.id))
|
|
45
|
+
.reduce((sum, chunk) => sum + Math.max(0, chunk.char_end - chunk.char_start), 0);
|
|
46
|
+
const completeness = fileToken > 0 ? Number((hitToken / fileToken).toFixed(2)) : 0;
|
|
47
|
+
const missingChunkIds = sourceChunks.filter((chunk) => !hitChunkIds.has(chunk.id)).map((chunk) => chunk.id).slice(0, 12);
|
|
48
|
+
return {
|
|
49
|
+
source,
|
|
50
|
+
hit_token: hitToken,
|
|
51
|
+
file_token: fileToken,
|
|
52
|
+
completeness,
|
|
53
|
+
missing_chunk_ids: missingChunkIds,
|
|
54
|
+
};
|
|
55
|
+
});
|
|
56
|
+
const primarySource = preferredSource ?? fragments[0]?.source;
|
|
57
|
+
const primaryCompleteness = primarySource
|
|
58
|
+
? files.find((file) => file.source === primarySource)?.completeness ?? 0
|
|
59
|
+
: 0;
|
|
60
|
+
return {
|
|
61
|
+
files,
|
|
62
|
+
allow_pending_confirm: primaryCompleteness < 0.9,
|
|
63
|
+
primary_source: primarySource,
|
|
64
|
+
primary_completeness: primaryCompleteness,
|
|
65
|
+
retry_suggested: primaryCompleteness < 0.6,
|
|
66
|
+
};
|
|
112
67
|
}
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
68
|
+
/**
|
|
69
|
+
* 提取 title 的主题特征 token 集合(跨语言统一)。
|
|
70
|
+
* - CJK 路径: 字符 2-gram 滑动(与 V2 版本 bit-identical,zh 端 0 退化)。
|
|
71
|
+
* - EN 路径: 原子 token + word 2-gram。
|
|
72
|
+
* - 原子 token: 单词 [a-z0-9]+ 或带连字符的复合词 [a-z0-9]+(?:-[a-z0-9]+)+
|
|
73
|
+
* 关键: `one-to-one` 整体作为一个 token,不拆成 ['one', 'to', 'one'](域语义:C2C 类别标签,
|
|
74
|
+
* 跟 CJK「单聊」等价;拆开会产生 'one one' 这种无意义 2-gram 噪声)。
|
|
75
|
+
* - word 2-gram: 原子 token 之间的 2-gram 滑动(不跨复合词内部),跟 CJK 2-gram 同构。
|
|
76
|
+
* - stopword 过滤: the/and/for/with/from/how/use/api/sdk/app/set/get/add/to/of/in/on/by 等功能词。
|
|
77
|
+
*
|
|
78
|
+
* 产品无关: 纯字面特征,不依赖 IDF / 分词词表。
|
|
79
|
+
*/
|
|
80
|
+
function extractTitleTokens(text) {
|
|
81
|
+
const out = new Set();
|
|
82
|
+
// CJK 路径: 字符 2-gram(与原 extractTitleBigrams bit-identical)
|
|
83
|
+
for (const seg of text.match(/[\u4e00-\u9fff]{2,}/g) ?? []) {
|
|
84
|
+
for (let i = 0; i < seg.length - 1; i += 1) {
|
|
85
|
+
out.add(seg.slice(i, i + 2));
|
|
122
86
|
}
|
|
123
87
|
}
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
function inferQueryApiTokens(prompt) {
|
|
131
|
-
const tokens = new Set();
|
|
132
|
-
// Keep API-shaped tokens only; exclude product/platform brand words to avoid false low-confidence downgrades.
|
|
133
|
-
const upperNoise = new Set([
|
|
134
|
-
'SDK', 'API', 'HTTP', 'HTTPS', 'JSON', 'CALL', 'NAME', 'STATUS',
|
|
135
|
-
'TRTC', 'TENCENT', 'WEB', 'VUE', 'REACT', 'ROOMKIT', 'LIVEKIT', 'CALLKIT',
|
|
136
|
-
]);
|
|
137
|
-
const platformNoise = new Set([
|
|
138
|
-
'ios', 'android', 'flutter', 'web', 'react', 'vue', 'miniprogram',
|
|
139
|
-
'harmonyos', 'react-native', 'unity', 'unreal', 'unreal-engine', 'donut', 'uni-app',
|
|
140
|
-
'pc', 'h5',
|
|
88
|
+
// EN 路径: 原子 token(含连字符复合词整体保留)+ 原子间的 2-gram
|
|
89
|
+
// 正则说明: [a-z][a-z0-9]*(?:-[a-z][a-z0-9]*)* — 匹配单词或带连字符的复合词,
|
|
90
|
+
// 一次匹配即可拿到完整 token,不会在复合词内部产生 2-gram。
|
|
91
|
+
const EN_STOPS = new Set([
|
|
92
|
+
'the', 'and', 'for', 'with', 'from', 'how', 'what', 'why', 'when', 'where',
|
|
93
|
+
'use', 'api', 'sdk', 'app', 'set', 'get', 'add', 'to', 'of', 'in', 'on', 'by',
|
|
141
94
|
]);
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
for (const match of prompt.matchAll(/\b(use[A-Z]\w*)\b/g)) {
|
|
149
|
-
tokens.add(match[1]);
|
|
150
|
-
}
|
|
151
|
-
for (const match of prompt.matchAll(/\b(I[A-Z][A-Za-z0-9_]*)\b/g)) {
|
|
152
|
-
tokens.add(match[1]);
|
|
95
|
+
const atomic = (text.toLowerCase().match(/[a-z][a-z0-9]*(?:-[a-z][a-z0-9]*)*/g) ?? [])
|
|
96
|
+
.filter((w) => w.length >= 3 && !EN_STOPS.has(w));
|
|
97
|
+
for (const w of atomic)
|
|
98
|
+
out.add(w);
|
|
99
|
+
for (let i = 0; i < atomic.length - 1; i += 1) {
|
|
100
|
+
out.add(`${atomic[i]} ${atomic[i + 1]}`);
|
|
153
101
|
}
|
|
154
|
-
|
|
155
|
-
tokens.add(match[1]);
|
|
156
|
-
}
|
|
157
|
-
for (const match of prompt.matchAll(/\b([A-Z_]{3,})\b/g)) {
|
|
158
|
-
const token = match[1];
|
|
159
|
-
if (!upperNoise.has(token))
|
|
160
|
-
tokens.add(token);
|
|
161
|
-
}
|
|
162
|
-
return [...tokens].filter((token) => !platformNoise.has(token.toLowerCase()));
|
|
163
|
-
}
|
|
164
|
-
function tokenInChunk(token, chunk) {
|
|
165
|
-
if (chunk.api_names?.some((name) => name.toLowerCase() === token.toLowerCase()))
|
|
166
|
-
return true;
|
|
167
|
-
if (chunk.symbols?.some((name) => name.toLowerCase() === token.toLowerCase()))
|
|
168
|
-
return true;
|
|
169
|
-
const low = token.toLowerCase();
|
|
170
|
-
return chunk.title.toLowerCase().includes(low) || chunk.source.toLowerCase().includes(low);
|
|
171
|
-
}
|
|
172
|
-
function computeApiNameCoverage(tokens, candidates) {
|
|
173
|
-
if (!tokens.length)
|
|
174
|
-
return 1;
|
|
175
|
-
const hit = tokens.filter((token) => candidates.some((chunk) => tokenInChunk(token, chunk))).length;
|
|
176
|
-
return Number((hit / tokens.length).toFixed(2));
|
|
177
|
-
}
|
|
178
|
-
function computeMissingApiTokens(tokens, candidates) {
|
|
179
|
-
if (!tokens.length)
|
|
180
|
-
return [];
|
|
181
|
-
return tokens.filter((token) => !candidates.some((chunk) => tokenInChunk(token, chunk)));
|
|
182
|
-
}
|
|
183
|
-
function hasProductScopeIntent(prompt) {
|
|
184
|
-
return /控制台|开通|开通服务|计费|套餐|价格|pricing|billing|plan|console|activate|activation|service/i.test(prompt);
|
|
185
|
-
}
|
|
186
|
-
function hasNativeTrtcApiSignal(prompt, queryApiTokens) {
|
|
187
|
-
if (/\bTRTCCloud\b|\bTRTCRenderParams\b|\bTXDeviceManager\b|setLocalRenderParams|startLocalPreview|stopLocalPreview/i.test(prompt)) {
|
|
188
|
-
return true;
|
|
189
|
-
}
|
|
190
|
-
return queryApiTokens.some((token) => /^(TRTC|TX)[A-Za-z0-9_]+$/.test(token)
|
|
191
|
-
|| /^(set|get|start|stop|enable|disable)[A-Z][A-Za-z0-9_]+$/.test(token));
|
|
192
|
-
}
|
|
193
|
-
function hasNativeClientFramework(frameworks) {
|
|
194
|
-
if (!frameworks?.length)
|
|
195
|
-
return false;
|
|
196
|
-
return frameworks.some((fw) => ['android', 'ios', 'flutter', 'c++'].includes(fw));
|
|
197
|
-
}
|
|
198
|
-
function applyRankMultipliers(ranked, chunksById, opts) {
|
|
199
|
-
const adjusted = ranked.map((item) => {
|
|
200
|
-
const chunk = chunksById.get(item.id);
|
|
201
|
-
if (!chunk)
|
|
202
|
-
return item;
|
|
203
|
-
let multiplier = 1;
|
|
204
|
-
// A2: 传了 product 时,命中 scope 的通配语料允许召回但降权
|
|
205
|
-
if (opts.product?.length && chunk.product === '*') {
|
|
206
|
-
multiplier *= 0.2;
|
|
207
|
-
}
|
|
208
|
-
// F2: framework 软降权(保留硬过滤,处理 fallback/边界残留)
|
|
209
|
-
if (opts.framework && opts.frameworks?.length && !chunk.framework.includes('*')) {
|
|
210
|
-
const explicitMatch = opts.frameworks.some((fw) => chunk.framework.includes(fw));
|
|
211
|
-
const callCoreWebFallback = Boolean(opts.product?.includes('call')
|
|
212
|
-
&& (opts.framework === 'react' || opts.framework === 'vue')
|
|
213
|
-
&& chunk.variant === 'core-sdk'
|
|
214
|
-
&& chunk.framework.includes('web'));
|
|
215
|
-
const liveWebVueFallback = Boolean(opts.product?.includes('live')
|
|
216
|
-
&& opts.framework === 'web'
|
|
217
|
-
&& chunk.variant === 'core-sdk'
|
|
218
|
-
&& chunk.framework.includes('vue')
|
|
219
|
-
&& chunk.source.includes('/api-reference/'));
|
|
220
|
-
const roomWebVueFallback = Boolean(opts.product?.includes('room')
|
|
221
|
-
&& opts.framework === 'web'
|
|
222
|
-
&& chunk.variant === 'core-sdk'
|
|
223
|
-
&& chunk.framework.includes('vue')
|
|
224
|
-
&& chunk.source.includes('knowledge/roomkit/core-sdk/vue/'));
|
|
225
|
-
if (!explicitMatch && !callCoreWebFallback && !liveWebVueFallback && !roomWebVueFallback) {
|
|
226
|
-
multiplier *= 0.2;
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
return { ...item, score: item.score * multiplier };
|
|
230
|
-
});
|
|
231
|
-
return adjusted.sort((a, b) => b.score - a.score);
|
|
102
|
+
return out;
|
|
232
103
|
}
|
|
233
104
|
/**
|
|
234
|
-
*
|
|
105
|
+
* 单主题截断:基于 scoreUrlRow + 字面 token Jaccard 判定 query 是否有「精确单主题」命中。
|
|
106
|
+
*
|
|
107
|
+
* 设计(V3 跨语言对称版,V2 基础上扩展 EN 路径):
|
|
108
|
+
* - scoreUrlRow 是「URL row 跟 query 的纯净相关度」(不被 E7 boost 稀释),
|
|
109
|
+
* 500+ 代表覆盖率 + 有效信号占比都达标的语义对齐强信号。
|
|
110
|
+
* - **但 scoreUrlRow 数值相近(615-700)时无法区分「同主题相关 row」和「泛命中 row」**。
|
|
111
|
+
* 例:query「单聊消息发送前回调」+ types=['webhook']:
|
|
112
|
+
* - top1=单聊消息发送之前回调 (scoreUrlRow=700) ← 真正答案
|
|
113
|
+
* - top2=单聊消息发送之后回调 (scoreUrlRow=672) ← 时态近义,保留
|
|
114
|
+
* - top3-6=单聊消息撤回/扩展/编辑/已读 (scoreUrlRow=615) ← 不同动作,应砍
|
|
115
|
+
* - top7-8=群消息发送之前/之后 (scoreUrlRow=643/615) ← 动作相同时态差异,保留"前"砍"后"
|
|
116
|
+
* 同样例 EN 端: query "One-to-one message send before webhook" 返回 5 个 webhook 切片
|
|
117
|
+
* (before/after C2C、before/after group、group exception),其中 group 类 3 个应砍。
|
|
118
|
+
* - 解决: 加 **token Jaccard** 作为「是否跟 top1 同主题」的二次过滤——
|
|
119
|
+
* 只保留跟 top1 字面 Jaccard ≥ 0.5 的 row(共享核心动作词/时态词)。
|
|
120
|
+
* - CJK: 2-gram 字符滑动(与 V2 bit-identical,zh 端 0 退化)
|
|
121
|
+
* - EN: 原子 token(含连字符复合词 one-to-one 整体保留)+ 原子间 word 2-gram,
|
|
122
|
+
* 跟 CJK 2-gram 同构
|
|
123
|
+
* - 多主题(保留 ≥ 2 个 row)/ 弱信号(top1 < 500)/ 不足 2 个候选 → 保持原 limit。
|
|
124
|
+
*
|
|
125
|
+
* 关键不变量:
|
|
126
|
+
* - 不动 top1(避免误判导致用户拿不到正确答案)
|
|
127
|
+
* - 不减少「真的有第二个相关 row」的场景(如「群消息发送前/之后」同主题)
|
|
128
|
+
* - 截断发生在 narrative 模式之外(narrative 模式本身按 full document 走,不受影响)
|
|
235
129
|
*/
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
const
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
? 'fabrication_risk=true 时,若片段未覆盖该细节,必须输出“待确认:知识片段未覆盖此项”。'
|
|
265
|
-
: '';
|
|
266
|
-
return [
|
|
267
|
-
'【输出模板(必须遵守)】',
|
|
268
|
-
'使用“问题导向”标题组织答案,优先复用用户提问原句:一、<问题1> 二、<问题2> 三、<问题3> …',
|
|
269
|
-
'每个小节先给直接结论,再补 1-2 句必要说明;避免模板化术语(如“依据/操作”字样)。',
|
|
270
|
-
'信息缺口统一放在对应小节末尾,使用“待确认:xxx”;若全都确认则可省略“待确认”小节。',
|
|
271
|
-
'末尾保留“参考文档”小节,仅列文档标题或 URL;正文不展示检索过程、证据链、打分细节。',
|
|
272
|
-
'用户可见正文禁止出现系统检索话术(如“命中片段/检索结果/置信度/分数/召回/重排”)。',
|
|
273
|
-
'硬约束:不得引入 fragments 未出现的 API 名、字段值、枚举项、事件名。',
|
|
274
|
-
uncertaintyRule,
|
|
275
|
-
riskRule,
|
|
276
|
-
].filter(Boolean).join(' ');
|
|
130
|
+
const SINGLE_TOPIC_URL_SCORE_THRESHOLD = 500;
|
|
131
|
+
const SINGLE_TOPIC_JACCARD_THRESHOLD = 0.5;
|
|
132
|
+
function applySingleTopicTrim(fragments, prompt, limit) {
|
|
133
|
+
if (fragments.length < 2)
|
|
134
|
+
return fragments;
|
|
135
|
+
// top1 必须有强 scoreUrlRow 信号(避免弱信号 top1 误判导致砍掉正确答案)
|
|
136
|
+
const top1UrlScore = scoreUrlRow(prompt, fragments[0].title).score;
|
|
137
|
+
if (top1UrlScore < SINGLE_TOPIC_URL_SCORE_THRESHOLD)
|
|
138
|
+
return fragments;
|
|
139
|
+
// 用 top1 标题的 token 集合作为「主题特征」,过滤出字面同主题的 row。
|
|
140
|
+
// CJK 标题走 2-gram 字符路径(与 V2 等价,zh 端 0 退化),
|
|
141
|
+
// EN 标题走原子 token + word 2-gram 路径(与 CJK 2-gram 同构,en 端获得对等的单主题判定)。
|
|
142
|
+
const top1Tokens = extractTitleTokens(fragments[0].title);
|
|
143
|
+
if (top1Tokens.size < 2)
|
|
144
|
+
return fragments; // 标题太短或纯 stopword,不做主题判定
|
|
145
|
+
const related = fragments.filter((f) => {
|
|
146
|
+
const fTokens = extractTitleTokens(f.title);
|
|
147
|
+
if (fTokens.size === 0)
|
|
148
|
+
return false;
|
|
149
|
+
const intersection = [...fTokens].filter((t) => top1Tokens.has(t)).length;
|
|
150
|
+
const union = new Set([...fTokens, ...top1Tokens]).size;
|
|
151
|
+
return intersection / union >= SINGLE_TOPIC_JACCARD_THRESHOLD;
|
|
152
|
+
});
|
|
153
|
+
// 过滤后只剩 1 个 → 单主题精确返回 [top1]
|
|
154
|
+
if (related.length === 1)
|
|
155
|
+
return [related[0]];
|
|
156
|
+
// 多个同主题 row(topN)→ 保留全部(受 limit 约束)
|
|
157
|
+
return related.slice(0, limit);
|
|
277
158
|
}
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
const
|
|
283
|
-
if (
|
|
284
|
-
return
|
|
285
|
-
|
|
286
|
-
const
|
|
287
|
-
const
|
|
288
|
-
const
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
return `当前问题在该平台 / 形态下 **不支持**${scopeLabel}。回答 MUST:① 第一段先明确写“**不支持**”;② 说明这是当前平台 Core 的能力边界,而不是缺少示例代码;③ 仅提供当前平台已支持的替代方案(如对应平台 preset 预制 UI);若当前知识库未给出明确替代形态,应直接说明暂无可执行替代路径;④ **禁止**输出当前平台 Core 的 API 调用步骤或集成步骤。${tail}`;
|
|
296
|
-
}
|
|
297
|
-
if (hasUrlMapping && !hasLocalDoc) {
|
|
298
|
-
return `当前可用信息主要是文档入口链接(${urlKindLabel})。请先匹配用户问题对应的目标 URL,抓取官网文档后再合成答案;禁止仅复述映射表或臆造 API/错误码细节。${tail}`;
|
|
299
|
-
}
|
|
300
|
-
if (hasUrlMapping && hasLocalDoc) {
|
|
301
|
-
if (hasBestPractice) {
|
|
302
|
-
return `优先基于已命中的 best-practice 与本地片段直接作答;URL 映射仅作补充参考,不要再抓官网做二次求证。${tail}`;
|
|
303
|
-
}
|
|
304
|
-
return `本地文档片段可直接用于作答;若本地片段未覆盖关键细节,再从 URL 映射中提取目标 URL 抓取官网文档补齐。${tail}`;
|
|
305
|
-
}
|
|
306
|
-
return `请仅基于已返回 fragments 合成答案;不要并行 web_search 或额外抓取官网文档。${tail}`;
|
|
307
|
-
}
|
|
308
|
-
if (status === 'low_confidence') {
|
|
309
|
-
if (hasUrlMapping) {
|
|
310
|
-
if (hasBestPractice) {
|
|
311
|
-
return `优先基于 best-practice 与本地片段给出可执行结论;未覆盖的内容统一写入“待确认”,不要默认抓官网二次求证。${tail}`;
|
|
312
|
-
}
|
|
313
|
-
return `请先从 URL 映射里提取目标 URL 并抓取官网文档补齐关键信息,再给出答案;仍无法确认的内容明确写“待确认”。${tail}`;
|
|
314
|
-
}
|
|
315
|
-
return `请基于已有 fragments 给出最可执行答案,并将未覆盖细节明确写为“待确认”;可引导用户补充组件名/API 名/错误码后重新检索。不要默认并行 web_search。${tail}`;
|
|
316
|
-
}
|
|
317
|
-
return `知识库未命中有效片段。请向用户说明并确认 product/framework 是否准确;若仍无 platform 信息且问题依赖具体客户端,应先 \`present_framework_choice\`。${tail}`;
|
|
159
|
+
/** 轻量覆盖度:仅算主文件完整度,用于非收敛场景的 allow_pending_confirm 判定,不产出逐文件明细 */
|
|
160
|
+
function computePrimaryCompleteness(allChunks, fragments, preferredSource) {
|
|
161
|
+
if (!fragments.length)
|
|
162
|
+
return { primary_completeness: 0 };
|
|
163
|
+
const primarySource = preferredSource ?? fragments[0]?.source;
|
|
164
|
+
if (!primarySource)
|
|
165
|
+
return { primary_completeness: 0 };
|
|
166
|
+
const hitChunkIds = new Set(fragments.map((f) => f.chunk_id));
|
|
167
|
+
const sourceChunks = allChunks.filter((chunk) => chunk.source === primarySource);
|
|
168
|
+
const fileToken = sourceChunks.reduce((sum, chunk) => sum + Math.max(0, chunk.char_end - chunk.char_start), 0);
|
|
169
|
+
const hitToken = sourceChunks
|
|
170
|
+
.filter((chunk) => hitChunkIds.has(chunk.id))
|
|
171
|
+
.reduce((sum, chunk) => sum + Math.max(0, chunk.char_end - chunk.char_start), 0);
|
|
172
|
+
return {
|
|
173
|
+
primary_source: primarySource,
|
|
174
|
+
primary_completeness: fileToken > 0 ? Number((hitToken / fileToken).toFixed(2)) : 0,
|
|
175
|
+
};
|
|
318
176
|
}
|
|
319
177
|
export function searchKnowledge(params) {
|
|
320
178
|
const norm = normalizeParams(params);
|
|
179
|
+
const retrievalMode = classifyRetrievalMode(norm.prompt);
|
|
321
180
|
if (requiresFramework(norm)) {
|
|
322
|
-
|
|
181
|
+
loadManifest();
|
|
323
182
|
return {
|
|
324
183
|
status: 'needs_framework',
|
|
325
184
|
confidence: 'none',
|
|
326
|
-
message: buildAgentMessage('needs_framework', [], 'none', undefined),
|
|
185
|
+
message: buildAgentMessage('needs_framework', retrievalMode, [], 'none', undefined),
|
|
327
186
|
query: params,
|
|
187
|
+
retrieval_mode: retrievalMode,
|
|
188
|
+
documents: [],
|
|
189
|
+
catalog: [],
|
|
328
190
|
meta: {
|
|
329
|
-
|
|
330
|
-
filtered_size: 0,
|
|
331
|
-
exact_hits: [],
|
|
191
|
+
answer_contract: buildAnswerContract('needs_framework', 'none', retrievalMode, { fabricationRisk: false }),
|
|
332
192
|
},
|
|
333
193
|
fragments: [],
|
|
334
194
|
};
|
|
@@ -336,66 +196,93 @@ export function searchKnowledge(params) {
|
|
|
336
196
|
const manifest = loadManifest();
|
|
337
197
|
const allChunks = manifest.chunks;
|
|
338
198
|
const queryApiTokens = inferQueryApiTokens(norm.prompt);
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
199
|
+
let pass = runSearchPass({
|
|
200
|
+
allChunks,
|
|
201
|
+
passNorm: norm,
|
|
202
|
+
retrievalMode,
|
|
203
|
+
queryApiTokens,
|
|
204
|
+
});
|
|
205
|
+
// best-practice 懒补充:仅在主 pass 无本地片段(仅 URL 映射行或完全为空)时触发。
|
|
206
|
+
// 典型场景:Agent 传 types=['sdk'],filterPool 把 best-practice(type=faq)硬过滤掉,
|
|
207
|
+
// 只剩 URL 映射行(有 E7 boost 但内容只是一个 URL 链接)。
|
|
208
|
+
// 不侵入 runSearchPass 层,避免每次调用都执行重复检索。
|
|
209
|
+
// 修复 B1 副作用:之前 `types: undefined` 会同时把 chat/preset/{flutter,android,...}/integration.md
|
|
210
|
+
// (types=['integration'])也放进 pool,integration 文档多 section 命中 generic 词会把 URL row 顶掉。
|
|
211
|
+
// 改为 `types: ['faq']` 后:
|
|
212
|
+
// - best-practice(types=['faq'])走 filterPool 的 bp-bypass 保留
|
|
213
|
+
// - chat/preset/.../integration.md(types=['integration'])被硬过滤
|
|
214
|
+
// - URL row(types 与原请求一致)继续保留
|
|
215
|
+
// 合并策略:append BP fragments 到原 pass(不去重 URL row),保留 URL row 在 top。
|
|
216
|
+
const passRetryReasons = [];
|
|
217
|
+
const hasLocalContent = pass.fragments.some((f) => !f.source.includes('/urls/'));
|
|
218
|
+
// 当主 pass 命中一个明确的 URL row(scoreUrlRow ≥ 300 强信号)时,**跳过 B1 补充**,
|
|
219
|
+
// 避免 BP 兜底把无关 BP 文档(message-unread-count、avchatroom-message-loss-diagnosis 等)塞满 fragments。
|
|
220
|
+
// 典型反例:query「im 内网代理」types=['product'],pass 命中「内网代理实现方案」(scoreUrlRow=460),
|
|
221
|
+
// 旧 B1 会触发 → 拉 8 个无关 BP → fragments 膨胀到 9 个。
|
|
222
|
+
// 进一步保护:当 top1 finalScore < 20(零命中场景,如「im 怎么发火箭」finalScore=16.8),
|
|
223
|
+
// 也不触发 B1——无意义 query 不该被 BP 兜底撑满。
|
|
224
|
+
const top1UrlScore = !hasLocalContent && pass.fragments[0]?.title
|
|
225
|
+
? scoreUrlRow(norm.prompt, pass.fragments[0].title).score
|
|
226
|
+
: 0;
|
|
227
|
+
const top1FinalScore = pass.fragments[0]?.score ?? 0;
|
|
228
|
+
if (!hasLocalContent && top1UrlScore < 300 && top1FinalScore >= 20 && norm.product?.length && !norm.types?.includes('faq')) {
|
|
229
|
+
const primaryProduct = norm.product[0];
|
|
230
|
+
const bpCandidates = allChunks.filter((chunk) => {
|
|
231
|
+
if (!/\/best-practice\//i.test(chunk.source))
|
|
232
|
+
return false;
|
|
233
|
+
if (chunk.product !== primaryProduct && chunk.product !== '*')
|
|
234
|
+
return false;
|
|
235
|
+
if (norm.frameworks?.length) {
|
|
236
|
+
const explicitMatch = hasExplicitFrameworkMatch(chunk.framework, norm.frameworks);
|
|
237
|
+
const wildcardAllowed = chunk.framework.includes('*');
|
|
238
|
+
if (!explicitMatch && !wildcardAllowed)
|
|
239
|
+
return false;
|
|
352
240
|
}
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
if (
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
241
|
+
return true;
|
|
242
|
+
});
|
|
243
|
+
if (bpCandidates.length > 0) {
|
|
244
|
+
const bpIndex = getProductsBM25Index(allChunks, [norm.product[0]]);
|
|
245
|
+
const bpRanked = rankSubsetWithIndex(norm.prompt, bpCandidates.map((c) => c.id), bpIndex, {});
|
|
246
|
+
if ((bpRanked[0]?.score ?? 0) >= 6) {
|
|
247
|
+
const bpPass = runSearchPass({
|
|
248
|
+
allChunks,
|
|
249
|
+
passNorm: { ...norm, types: ['faq'] },
|
|
250
|
+
retrievalMode,
|
|
251
|
+
queryApiTokens,
|
|
252
|
+
});
|
|
253
|
+
if (bpPass.fragments.length > 0) {
|
|
254
|
+
// 合并:保留原 URL row 在前,追加 BP fragments 去重
|
|
255
|
+
const existingIds = new Set(pass.fragments.map((f) => f.chunk_id));
|
|
256
|
+
const newBpFragments = bpPass.fragments.filter((f) => !existingIds.has(f.chunk_id));
|
|
257
|
+
if (newBpFragments.length > 0) {
|
|
258
|
+
// 重新编号 rank:原 URL row 保持 1..N,BP 从 N+1 开始
|
|
259
|
+
const merged = [
|
|
260
|
+
...pass.fragments,
|
|
261
|
+
...newBpFragments.map((f, i) => ({ ...f, rank: pass.fragments.length + i + 1 })),
|
|
262
|
+
];
|
|
263
|
+
pass = {
|
|
264
|
+
...pass,
|
|
265
|
+
fragments: merged,
|
|
266
|
+
};
|
|
267
|
+
passRetryReasons.push('best_practice_supplement');
|
|
268
|
+
}
|
|
359
269
|
}
|
|
360
270
|
}
|
|
361
271
|
}
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
const chunksById = new Map(poolWithContent.map((chunk) => [chunk.id, chunk]));
|
|
365
|
-
const ranked = rankSubsetWithIndex(passNorm.prompt, pool.map((chunk) => chunk.id), productIndex, boosts);
|
|
366
|
-
const rankedWithMultipliers = applyRankMultipliers(ranked, chunksById, {
|
|
367
|
-
product: passNorm.product,
|
|
368
|
-
framework: passNorm.framework,
|
|
369
|
-
frameworks: passNorm.frameworks,
|
|
370
|
-
});
|
|
371
|
-
const pinnedUrlRowChunkIds = new Set(urlRowHitChunkIds);
|
|
372
|
-
const fragments = postProcess(rankedWithMultipliers, chunksById, passNorm.limit, {
|
|
373
|
-
pinnedUrlRowChunkIds,
|
|
374
|
-
});
|
|
375
|
-
const urlMappingSources = [...new Set(fragments.map((f) => f.source).filter((source) => isUrlMappingSource(source)))];
|
|
376
|
-
const references = buildReferences(fragments);
|
|
377
|
-
const apiNameCoverage = computeApiNameCoverage(queryApiTokens, poolWithContent);
|
|
378
|
-
const missingApiTokens = computeMissingApiTokens(queryApiTokens, poolWithContent);
|
|
379
|
-
return {
|
|
380
|
-
pool,
|
|
381
|
-
fragments,
|
|
382
|
-
exactHits,
|
|
383
|
-
urlMappingSources,
|
|
384
|
-
references,
|
|
385
|
-
apiNameCoverage,
|
|
386
|
-
missingApiTokens,
|
|
387
|
-
urlMapRelaxed: pinnedUrlRowChunkIds.size > 0,
|
|
388
|
-
urlMapExactHitCount: pinnedUrlRowChunkIds.size,
|
|
389
|
-
};
|
|
390
|
-
};
|
|
391
|
-
let pass = runSearchPass(norm);
|
|
392
|
-
const retryReasons = [];
|
|
272
|
+
}
|
|
273
|
+
const retryReasons = [...passRetryReasons];
|
|
393
274
|
// B2: API token 覆盖不足时触发一次扩检(去掉 types 约束 + api/integration 轻提权)
|
|
394
275
|
const missingRatio = queryApiTokens.length > 0
|
|
395
276
|
? Number((pass.missingApiTokens.length / queryApiTokens.length).toFixed(2))
|
|
396
277
|
: 0;
|
|
397
278
|
if (queryApiTokens.length > 0 && (pass.apiNameCoverage === 0 || missingRatio >= 0.3)) {
|
|
398
|
-
const retryPass = runSearchPass({
|
|
279
|
+
const retryPass = runSearchPass({
|
|
280
|
+
allChunks,
|
|
281
|
+
passNorm: { ...norm, types: undefined },
|
|
282
|
+
retrievalMode,
|
|
283
|
+
queryApiTokens,
|
|
284
|
+
opts: { apiRetry: true },
|
|
285
|
+
});
|
|
399
286
|
const retryMissingRatio = queryApiTokens.length > 0
|
|
400
287
|
? Number((retryPass.missingApiTokens.length / queryApiTokens.length).toFixed(2))
|
|
401
288
|
: 0;
|
|
@@ -406,10 +293,15 @@ export function searchKnowledge(params) {
|
|
|
406
293
|
retryReasons.push(`api_gap_retry:coverage=${pass.apiNameCoverage},missing_ratio=${retryMissingRatio}`);
|
|
407
294
|
}
|
|
408
295
|
}
|
|
409
|
-
// B3:
|
|
296
|
+
// B3: 混合“集成 + 产品开通/计费/控制台”问法时,补充 product type,避免仅搜 integration/feature。
|
|
410
297
|
if (norm.types?.length && !norm.types.includes('product') && hasProductScopeIntent(norm.prompt)) {
|
|
411
298
|
const widenedTypes = [...new Set([...norm.types, 'product'])];
|
|
412
|
-
const retryPass = runSearchPass({
|
|
299
|
+
const retryPass = runSearchPass({
|
|
300
|
+
allChunks,
|
|
301
|
+
passNorm: { ...norm, types: widenedTypes },
|
|
302
|
+
retrievalMode,
|
|
303
|
+
queryApiTokens,
|
|
304
|
+
});
|
|
413
305
|
const baseHasProduct = pass.fragments.some((f) => f.types.includes('product'));
|
|
414
306
|
const retryHasProduct = retryPass.fragments.some((f) => f.types.includes('product'));
|
|
415
307
|
if ((retryHasProduct && !baseHasProduct)
|
|
@@ -419,7 +311,7 @@ export function searchKnowledge(params) {
|
|
|
419
311
|
retryReasons.push('product_scope_retry:types+=product');
|
|
420
312
|
}
|
|
421
313
|
}
|
|
422
|
-
// B4:
|
|
314
|
+
// B4: 若疑似 Native TRTC API 问法却路由到 live/room/call,自动补扩 native_trtc_sdk 产品重检。
|
|
423
315
|
const shouldRetryNativeProduct = hasNativeClientFramework(norm.frameworks)
|
|
424
316
|
&& hasNativeTrtcApiSignal(norm.prompt, queryApiTokens)
|
|
425
317
|
&& norm.product?.some((p) => ['live', 'room', 'call'].includes(p))
|
|
@@ -428,7 +320,13 @@ export function searchKnowledge(params) {
|
|
|
428
320
|
&& pass.apiNameCoverage < 0.5;
|
|
429
321
|
if (shouldRetryNativeProduct) {
|
|
430
322
|
const expandedProducts = [...new Set([...(norm.product ?? []), 'native_trtc_sdk'])];
|
|
431
|
-
const retryPass = runSearchPass({
|
|
323
|
+
const retryPass = runSearchPass({
|
|
324
|
+
allChunks,
|
|
325
|
+
passNorm: { ...norm, product: expandedProducts, types: undefined },
|
|
326
|
+
retrievalMode,
|
|
327
|
+
queryApiTokens,
|
|
328
|
+
opts: { apiRetry: true },
|
|
329
|
+
});
|
|
432
330
|
const retryMissingRatio = queryApiTokens.length > 0
|
|
433
331
|
? Number((retryPass.missingApiTokens.length / queryApiTokens.length).toFixed(2))
|
|
434
332
|
: 0;
|
|
@@ -440,33 +338,166 @@ export function searchKnowledge(params) {
|
|
|
440
338
|
retryReasons.push(`native_product_retry:products=${expandedProducts.join('+')},coverage=${retryPass.apiNameCoverage}`);
|
|
441
339
|
}
|
|
442
340
|
}
|
|
443
|
-
const
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
341
|
+
const resolveNarrativeState = (passSnapshot) => {
|
|
342
|
+
const source = retrievalMode === 'narrative'
|
|
343
|
+
? pickNarrativeSource(passSnapshot.exactHits, passSnapshot.rankedWithMultipliers, passSnapshot.poolWithContent)
|
|
344
|
+
: null;
|
|
345
|
+
const document = source ? buildNarrativeDocument(source, allChunks) : null;
|
|
346
|
+
const quality = assessNarrativeCompleteness(document, norm.prompt);
|
|
347
|
+
return { source, document, quality };
|
|
348
|
+
};
|
|
349
|
+
let narrativeState = resolveNarrativeState(pass);
|
|
350
|
+
// P0: narrative 自动扩检(一次)——当“完整流程”候选文档缺失或完整度不足时,放宽 types 并提高 limit。
|
|
351
|
+
const shouldRetryNarrative = retrievalMode === 'narrative'
|
|
352
|
+
&& (narrativeState.document === null || !narrativeState.quality.isComplete);
|
|
353
|
+
if (shouldRetryNarrative) {
|
|
354
|
+
const narrativeRetryNorm = {
|
|
355
|
+
...norm,
|
|
356
|
+
types: undefined,
|
|
357
|
+
limit: Math.max(norm.limit ?? 8, 12),
|
|
358
|
+
};
|
|
359
|
+
const retryPass = runSearchPass({
|
|
360
|
+
allChunks,
|
|
361
|
+
passNorm: narrativeRetryNorm,
|
|
362
|
+
retrievalMode,
|
|
363
|
+
queryApiTokens,
|
|
364
|
+
opts: { apiRetry: true },
|
|
365
|
+
});
|
|
366
|
+
const retryNarrativeState = resolveNarrativeState(retryPass);
|
|
367
|
+
if (retryNarrativeState.quality.score > narrativeState.quality.score
|
|
368
|
+
|| (narrativeState.document === null && retryNarrativeState.document !== null)
|
|
369
|
+
|| (retryPass.exactHits.length > pass.exactHits.length && retryNarrativeState.document !== null)) {
|
|
370
|
+
pass = retryPass;
|
|
371
|
+
narrativeState = retryNarrativeState;
|
|
372
|
+
retryReasons.push(`narrative_retry:score=${retryNarrativeState.quality.score}`);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
// 覆盖度深检(全量 files 明细 + 自动扩检)仅在收敛到单一 best-practice 时启用,
|
|
376
|
+
// 避免对普通点查场景做无差别二次检索与全量 coverage_report 外发。
|
|
377
|
+
const shouldDeepCoverageCheck = Boolean(pass.preferredSource || pass.fullDocumentMode) && pass.fragments.length > 0;
|
|
378
|
+
let coverageReport;
|
|
379
|
+
if (shouldDeepCoverageCheck) {
|
|
380
|
+
coverageReport = buildCoverageReport(allChunks, pass.fragments, pass.preferredSource);
|
|
381
|
+
if (coverageReport.retry_suggested) {
|
|
382
|
+
const coverageRetryPass = runSearchPass({
|
|
383
|
+
allChunks,
|
|
384
|
+
passNorm: { ...norm, types: undefined, limit: Math.max(norm.limit ?? 8, 12) },
|
|
385
|
+
retrievalMode,
|
|
386
|
+
queryApiTokens,
|
|
387
|
+
opts: { apiRetry: true },
|
|
388
|
+
});
|
|
389
|
+
const retriedCoverageReport = buildCoverageReport(allChunks, coverageRetryPass.fragments, coverageRetryPass.preferredSource);
|
|
390
|
+
const currentCoverage = coverageReport.primary_completeness ?? 0;
|
|
391
|
+
const retriedCoverage = retriedCoverageReport.primary_completeness ?? 0;
|
|
392
|
+
if (retriedCoverage > currentCoverage || coverageRetryPass.fragments.length > pass.fragments.length) {
|
|
393
|
+
pass = coverageRetryPass;
|
|
394
|
+
coverageReport = retriedCoverageReport;
|
|
395
|
+
retryReasons.push(`coverage_check_retry:primary=${retriedCoverage}`);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
// 单 chunk 文档 completeness 恒为 1,不代表问题被完整覆盖;
|
|
399
|
+
// 仅在 fullDocumentMode 聚合多 chunk(>1)且覆盖度足够时才禁止“待确认”。
|
|
400
|
+
const primaryChunkCount = coverageReport.primary_source
|
|
401
|
+
? allChunks.filter((chunk) => chunk.source === coverageReport.primary_source).length
|
|
402
|
+
: 0;
|
|
403
|
+
coverageReport = {
|
|
404
|
+
...coverageReport,
|
|
405
|
+
allow_pending_confirm: !(pass.fullDocumentMode
|
|
406
|
+
&& primaryChunkCount > 1
|
|
407
|
+
&& (coverageReport.primary_completeness ?? 0) >= 0.9),
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
else {
|
|
411
|
+
const primary = computePrimaryCompleteness(allChunks, pass.fragments, pass.preferredSource);
|
|
412
|
+
coverageReport = {
|
|
413
|
+
allow_pending_confirm: true,
|
|
414
|
+
...(primary.primary_source ? { primary_source: primary.primary_source } : {}),
|
|
415
|
+
primary_completeness: primary.primary_completeness,
|
|
416
|
+
retry_suggested: false,
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
narrativeState = resolveNarrativeState(pass);
|
|
420
|
+
// P0 单主题截断:基于 scoreUrlRow(URL row 跟 query 的纯净相关度,不被 E7 boost 污染)
|
|
421
|
+
// + 2-gram 字面 Jaccard 判定 query 是否有「精确单主题」命中。
|
|
422
|
+
// 详见 applySingleTopicTrim 函数注释。
|
|
423
|
+
let passFragments = applySingleTopicTrim(pass.fragments, norm.prompt, norm.limit ?? 8);
|
|
424
|
+
// P1 低置信裁剪:0 命中 query(top1 finalScore < 20)的 fragments 是 BM25 凑出来的
|
|
425
|
+
// 无关内容(如「im 怎么发火箭」top1=audio-message-send finalScore=16.8),
|
|
426
|
+
// 直接裁剪到 1 个 top fragment,避免 BP 凑数撑爆。
|
|
427
|
+
// 适用:first pass 已经拉了 BP 文档但 top1 仍很弱的情况。
|
|
428
|
+
if (passFragments.length > 1 && (passFragments[0]?.score ?? 0) < 20) {
|
|
429
|
+
passFragments = [passFragments[0]];
|
|
430
|
+
}
|
|
431
|
+
if (passFragments !== pass.fragments) {
|
|
432
|
+
// 同步重建 references:V3 applySingleTopicTrim 砍 fragment 时不砍 references 会导致
|
|
433
|
+
// fragments/references 数量错位(实测:5→3 fragment 砍后 references 仍 5 个,LLM 困惑)。
|
|
434
|
+
// 用 trim 后的 fragments 重建 references,保持一致。
|
|
435
|
+
pass = { ...pass, fragments: passFragments, references: buildReferences(passFragments) };
|
|
436
|
+
}
|
|
447
437
|
const { confidence, status } = assessConfidence(pass.fragments, pass.exactHits, pass.apiNameCoverage, queryApiTokens);
|
|
448
438
|
const fabricationRisk = (queryApiTokens.length > 0 && pass.apiNameCoverage < 0.3)
|
|
449
439
|
|| (confidence === 'low' && pass.fragments.length < 2);
|
|
450
|
-
|
|
451
|
-
const
|
|
452
|
-
|
|
440
|
+
// narrative 模式返回完整文档;point/discovery 通过 fragments 提供内容(已含正文)。
|
|
441
|
+
const documents = retrievalMode === 'narrative'
|
|
442
|
+
? (narrativeState.document ? [narrativeState.document] : [])
|
|
443
|
+
: [];
|
|
444
|
+
let finalStatus = status;
|
|
445
|
+
let finalConfidence = confidence;
|
|
446
|
+
if (retrievalMode === 'narrative' && documents.length > 0) {
|
|
447
|
+
if (narrativeState.quality.isComplete) {
|
|
448
|
+
finalStatus = 'success';
|
|
449
|
+
finalConfidence = 'high';
|
|
450
|
+
}
|
|
451
|
+
else {
|
|
452
|
+
finalStatus = 'low_confidence';
|
|
453
|
+
finalConfidence = 'low';
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
if (retrievalMode === 'discovery' && pass.fragments.length > 0) {
|
|
457
|
+
finalStatus = 'success';
|
|
458
|
+
finalConfidence = finalConfidence === 'none' ? 'medium' : finalConfidence;
|
|
459
|
+
}
|
|
460
|
+
const aggregatedFragments = pass.fragments
|
|
461
|
+
.filter((fragment) => fragment.aggregated || fragment.citation?.includes('[aggregated]'))
|
|
462
|
+
.map((fragment) => ({
|
|
463
|
+
chunk_id: fragment.chunk_id,
|
|
464
|
+
source: fragment.source,
|
|
465
|
+
citation: fragment.citation ?? `[Source: ${fragment.chunk_id}][aggregated]`,
|
|
466
|
+
}));
|
|
467
|
+
const fullDocumentRequired = retrievalMode === 'narrative'
|
|
468
|
+
&& /完整的?\s*(接入|集成|教程|指南|流程)|完整\s*md|全文|原文|不要总结|含\s*(示例)?代码|全部步骤|完整步骤/i.test(norm.prompt);
|
|
469
|
+
const answerContract = buildAnswerContract(finalStatus, finalConfidence, retrievalMode, {
|
|
470
|
+
fabricationRisk,
|
|
471
|
+
fullDocumentRequired,
|
|
472
|
+
hasAggregatedFragments: aggregatedFragments.length > 0,
|
|
473
|
+
});
|
|
453
474
|
const hasProductScope = hasProductScopeIntent(norm.prompt);
|
|
454
475
|
const hasNativeSignal = hasNativeTrtcApiSignal(norm.prompt, queryApiTokens);
|
|
455
|
-
const hasProductContent = pass.fragments.some((f) => f.types.includes('product') || /\/product\.md$/i.test(f.source));
|
|
476
|
+
const hasProductContent = pass.fragments.some((f) => f.types.includes('product') || /\/product\.md$/i.test(f.source) || /\/best-practice\//i.test(f.source));
|
|
456
477
|
const hasIntegrationContent = pass.fragments.some((f) => f.types.includes('integration') || /\/integration\.md$/i.test(f.source) || /\/preset\//i.test(f.source));
|
|
457
478
|
const hasApiQueryTokens = queryApiTokens.length > 0;
|
|
458
|
-
const
|
|
479
|
+
const finalMissingRatio = hasApiQueryTokens
|
|
480
|
+
? Number((pass.missingApiTokens.length / queryApiTokens.length).toFixed(2))
|
|
481
|
+
: 0;
|
|
482
|
+
const apiGapDetected = hasApiQueryTokens && (pass.apiNameCoverage === 0 || finalMissingRatio >= 0.3);
|
|
459
483
|
const retryApiGapApplied = retryReasons.some((r) => r.startsWith('api_gap_retry:'));
|
|
460
484
|
const retryProductScopeApplied = retryReasons.includes('product_scope_retry:types+=product');
|
|
461
485
|
const retryNativeProductApplied = retryReasons.some((r) => r.startsWith('native_product_retry:'));
|
|
462
|
-
const
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
486
|
+
const narrativeConverged = retrievalMode !== 'narrative'
|
|
487
|
+
|| (documents.length > 0 && narrativeState.quality.isComplete);
|
|
488
|
+
const convergenceReached = (retrievalMode === 'narrative' && narrativeConverged)
|
|
489
|
+
|| (retrievalMode === 'discovery' && pass.fragments.length > 0)
|
|
490
|
+
|| (finalStatus === 'success'
|
|
491
|
+
&& (finalConfidence === 'high' || finalConfidence === 'medium')
|
|
492
|
+
&& (!hasApiQueryTokens || pass.apiNameCoverage >= 0.5)
|
|
493
|
+
&& (!hasProductScope || hasProductContent || hasIntegrationContent));
|
|
466
494
|
const retryAdvice = (() => {
|
|
467
|
-
if (
|
|
495
|
+
if (finalStatus === 'needs_framework') {
|
|
468
496
|
return { stop_retry: true, next_action: 'ask_framework', reason: 'missing_framework' };
|
|
469
497
|
}
|
|
498
|
+
if (retrievalMode === 'narrative' && !narrativeConverged) {
|
|
499
|
+
return { stop_retry: false, next_action: 'retry_or_clarify', reason: 'narrative_incomplete' };
|
|
500
|
+
}
|
|
470
501
|
if (convergenceReached) {
|
|
471
502
|
return { stop_retry: true, next_action: 'answer', reason: 'converged' };
|
|
472
503
|
}
|
|
@@ -479,34 +510,45 @@ export function searchKnowledge(params) {
|
|
|
479
510
|
if (hasNativeSignal && hasNativeClientFramework(norm.frameworks) && !retryNativeProductApplied) {
|
|
480
511
|
return { stop_retry: false, next_action: 'retry_add_native_trtc_sdk', reason: 'possible_native_product_misroute' };
|
|
481
512
|
}
|
|
482
|
-
if (
|
|
513
|
+
if (finalStatus === 'empty' || finalConfidence === 'low') {
|
|
483
514
|
return { stop_retry: false, next_action: 'retry_or_clarify', reason: 'insufficient_retrieval_signal' };
|
|
484
515
|
}
|
|
485
516
|
return { stop_retry: true, next_action: 'answer', reason: 'default_stop' };
|
|
486
517
|
})();
|
|
487
518
|
return {
|
|
488
|
-
status,
|
|
489
|
-
confidence,
|
|
490
|
-
message: buildAgentMessage(
|
|
519
|
+
status: finalStatus,
|
|
520
|
+
confidence: finalConfidence,
|
|
521
|
+
message: buildAgentMessage(finalStatus, retrievalMode, pass.fragments, finalConfidence, pass.references, {
|
|
522
|
+
fabricationRisk,
|
|
523
|
+
hasDocuments: documents.length > 0,
|
|
524
|
+
fullDocumentRequired,
|
|
525
|
+
preferDocuments: pass.fullDocumentMode,
|
|
526
|
+
hasAggregatedFragments: aggregatedFragments.length > 0,
|
|
527
|
+
}),
|
|
528
|
+
// query 保留:l1-enrich 依赖 response.query.frameworks 做限领域回填(内部消费)
|
|
491
529
|
query: params,
|
|
530
|
+
retrieval_mode: retrievalMode,
|
|
531
|
+
documents,
|
|
492
532
|
meta: {
|
|
493
|
-
|
|
494
|
-
filtered_size: pass.pool.length,
|
|
495
|
-
exact_hits: pass.exactHits,
|
|
496
|
-
...(pass.urlMappingSources.length ? { url_mapping_sources: pass.urlMappingSources } : {}),
|
|
497
|
-
...(pass.urlMapRelaxed ? { url_map_relaxed: pass.urlMapRelaxed } : {}),
|
|
498
|
-
...(pass.urlMapExactHitCount > 0 ? { url_map_exact_hit_count: pass.urlMapExactHitCount } : {}),
|
|
533
|
+
// 参考文档(AI 输出「参考文档」小节依据):
|
|
499
534
|
...(pass.references.length ? { references: pass.references } : {}),
|
|
500
|
-
...(topScore !== undefined ? { top_score: Number(topScore.toFixed(2)) } : {}),
|
|
501
|
-
...(secondScore !== undefined ? { second_score: Number(secondScore.toFixed(2)) } : {}),
|
|
502
|
-
...(queryApiTokens.length ? { query_api_tokens: queryApiTokens } : {}),
|
|
503
|
-
...(pass.missingApiTokens.length ? { missing_api_tokens: pass.missingApiTokens } : {}),
|
|
504
|
-
...(retryReason ? { retry_reason: retryReason } : {}),
|
|
505
535
|
answer_contract: answerContract,
|
|
506
|
-
|
|
536
|
+
// 防幻觉信号(search-message 依赖):
|
|
507
537
|
fabrication_risk: fabricationRisk,
|
|
508
|
-
|
|
538
|
+
// 内部重试决策(供上游判断是否需重搜):
|
|
509
539
|
retry_advice: retryAdvice,
|
|
540
|
+
// narrative 模式的完整度报告(仅 narrative 模式返回,用于验收/判断完整指南是否完整)
|
|
541
|
+
...(retrievalMode === 'narrative'
|
|
542
|
+
? {
|
|
543
|
+
narrative_completeness: {
|
|
544
|
+
is_complete: narrativeState.quality.isComplete,
|
|
545
|
+
score: narrativeState.quality.score,
|
|
546
|
+
section_count: narrativeState.quality.sectionCount,
|
|
547
|
+
total_chars: narrativeState.quality.totalChars,
|
|
548
|
+
reason: narrativeState.quality.reason,
|
|
549
|
+
},
|
|
550
|
+
}
|
|
551
|
+
: {}),
|
|
510
552
|
},
|
|
511
553
|
fragments: pass.fragments,
|
|
512
554
|
};
|