@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.
Files changed (86) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +58 -39
  3. package/knowledge/build-index.js +30 -6
  4. package/knowledge/chunker.js +82 -14
  5. package/knowledge/chunking-policy.js +100 -0
  6. package/knowledge/content-loader.js +3 -3
  7. package/knowledge/doc-url-resolver.js +43 -16
  8. package/knowledge/frontmatter-meta.js +167 -0
  9. package/knowledge/path-meta.js +28 -6
  10. package/knowledge/search/exact-lookup.js +120 -239
  11. package/knowledge/search/filter-pool.js +12 -29
  12. package/knowledge/search/framework-fallback.js +22 -0
  13. package/knowledge/search/intent-router.js +29 -0
  14. package/knowledge/search/l1-enrich.js +159 -0
  15. package/knowledge/search/query-normalizer.js +63 -3
  16. package/knowledge/search/search-core.js +410 -0
  17. package/knowledge/search/search-knowledge.js +421 -379
  18. package/knowledge/search/search-message.js +88 -0
  19. package/knowledge/search/search-pass.js +428 -0
  20. package/knowledge/search/search-types.js +1 -0
  21. package/knowledge/search/url-row-idf.js +120 -0
  22. package/knowledge/search/url-row-scorer.js +337 -0
  23. package/package.json +3 -1
  24. package/resource/_schema/feature-aliases.yaml +1 -1
  25. package/resource/index/manifest.json +1 -1
  26. package/resource/knowledge/callkit/best-practice/callkit-miniprogram/uniapp-shrink.md +60 -0
  27. package/resource/knowledge/callkit/best-practice/common/call-autoplay-blocked.md +77 -0
  28. package/resource/knowledge/callkit/best-practice/common/call-message-in-chat.md +78 -0
  29. package/resource/knowledge/callkit/best-practice/common/call-vs-calls.md +110 -0
  30. package/resource/knowledge/callkit/best-practice/common/callengine-call-flow.md +123 -0
  31. package/resource/knowledge/callkit/best-practice/common/callengine-log-level.md +67 -0
  32. package/resource/knowledge/callkit/best-practice/common/callkit-get-engine-instance.md +81 -0
  33. package/resource/knowledge/callkit/best-practice/common/callkit-get-roomid.md +78 -0
  34. package/resource/knowledge/callkit/best-practice/common/callkit-limitations.md +37 -0
  35. package/resource/knowledge/callkit/best-practice/common/callkit-user-register-and-kicked.md +98 -0
  36. package/resource/knowledge/chat/best-practice/common/avchatroom-message-loss-diagnosis.md +65 -0
  37. package/resource/knowledge/chat/best-practice/common/group-conversation-cleanup-on-system-notice.md +62 -0
  38. package/resource/knowledge/chat/best-practice/common/image-message-url-lifecycle.md +59 -0
  39. package/resource/knowledge/chat/best-practice/common/login-repeat-2025-diagnosis.md +61 -0
  40. package/resource/knowledge/chat/best-practice/common/message-read-report-and-receipt.md +64 -0
  41. package/resource/knowledge/chat/best-practice/common/message-receive-event.md +65 -0
  42. package/resource/knowledge/chat/best-practice/common/message-risk-error-and-render.md +65 -0
  43. package/resource/knowledge/chat/best-practice/common/message-unread-count.md +71 -0
  44. package/resource/knowledge/chat/best-practice/miniprogram/audio-message-send.md +113 -0
  45. package/resource/knowledge/chat/best-practice/miniprogram/login-timeout-2801-diagnosis.md +63 -0
  46. package/resource/knowledge/chat/best-practice/miniprogram/media-upload-fail-diagnosis.md +64 -0
  47. package/resource/knowledge/chat/best-practice/web/audio-message-send.md +118 -0
  48. package/resource/knowledge/chat/urls/product.md +17 -16
  49. package/resource/knowledge/chat/urls/restapi.md +3 -2
  50. package/resource/knowledge/chat/urls/sdk/android.md +6 -5
  51. package/resource/knowledge/chat/urls/sdk/flutter.md +6 -5
  52. package/resource/knowledge/chat/urls/sdk/ios.md +4 -3
  53. package/resource/knowledge/chat/urls/sdk/web.md +7 -6
  54. package/resource/knowledge/chat/urls/webhook.md +0 -5
  55. package/resource/knowledge/livekit/best-practice/android/voice-room-anchor-start.md +120 -0
  56. package/resource/knowledge/livekit/best-practice/common/admin-take-seat.md +73 -0
  57. package/resource/knowledge/livekit/best-practice/common/audience-leave-live.md +81 -0
  58. package/resource/knowledge/livekit/best-practice/common/voice-room-no-audio-troubleshoot.md +100 -0
  59. package/resource/knowledge/livekit/best-practice/common/voice-room-seat-list-change.md +65 -0
  60. package/resource/knowledge/livekit/best-practice/flutter/voice-room-anchor-start.md +122 -0
  61. package/resource/knowledge/livekit/best-practice/ios/voice-room-anchor-start.md +126 -0
  62. package/resource/knowledge/roomkit/best-practice/common/room-ghost-member.md +70 -0
  63. package/resource/knowledge/roomkit/best-practice/common/room-limit-and-multi.md +83 -0
  64. package/resource/knowledge/roomkit/best-practice/common/room-screen-share-preview.md +83 -0
  65. package/resource/knowledge/roomkit/best-practice/common/room-status-after-all-leave.md +69 -0
  66. package/resource/knowledge/roomkit/best-practice/vue/room-uikit-vs-atomicx.md +111 -0
  67. package/resource/knowledge/rtcengine/web/plugin/30-advanced-cross-room-link.md +2 -0
  68. package/resource/knowledge/rtcengine/web/plugin/39-advanced-video-decoder.md +2 -0
  69. package/server.js +28 -23
  70. package/streamable-http.js +19 -54
  71. package/tools/common/search_trtc_knowledge.js +24 -12
  72. package/tools/definitions/finalize_answer_def.js +2 -2
  73. package/tools/definitions/integration-boundary.js +2 -1
  74. package/tools/definitions/search_trtc_knowledge_def.js +5 -3
  75. package/utils/fetch-doc-content.js +198 -0
  76. package/utils/preflight-route.js +197 -0
  77. package/utils/report-cls-client.js +1 -1
  78. package/utils/report-es-client.js +1 -1
  79. package/cursor-setting.png +0 -0
  80. package/mcp-server-prompt.png +0 -0
  81. package/resource/knowledge/callkit/best-practice/web/callengine-invitation.md +0 -80
  82. package/resource/knowledge/callkit/best-practice/web/callengine-log-control.md +0 -24
  83. package/resource/knowledge/callkit/best-practice/web/callkit-call-vs-calls.md +0 -116
  84. package/resource/knowledge/callkit/preset/react/uikit-api-reference.md +0 -444
  85. package/resource/knowledge/callkit/preset/vue/uikit-api-reference.md +0 -464
  86. package/resource/knowledge/chat/best-practice/web/image-message-url-handling.md +0 -16
@@ -0,0 +1,88 @@
1
+ import { hasBestPracticeFragment, partitionFragments, } from './search-core.js';
2
+ export function buildAnswerContract(status, confidence, retrievalMode, opts) {
3
+ const uncertaintyRule = 'Do not use speculative wording (e.g., "maybe/probably/should/it seems"). When the user asks for a specific detail (API/method/enum/event/error code) that is not present in the returned fragments, do not invent it; instead, explicitly mark it as "[not found in docs]" or "[to be confirmed]" in the answer so the gap is visible to the user.';
4
+ const riskRule = opts?.fabricationRisk ? 'fabrication_risk=true: do not fill unmatched API details. For every detail that cannot be grounded in the returned fragments, explicitly mark it as "[not found in docs]" or "[to be confirmed]"; the user-facing answer must surface every gap, not omit silently.' : '';
5
+ const aggregatedRule = opts?.hasAggregatedFragments
6
+ ? 'If a field comes from an aggregated fragment, explicitly mark it as "[supplement]" or a footnote to avoid confusion with directly matched fragments.'
7
+ : '';
8
+ const modeRule = retrievalMode === 'narrative'
9
+ ? (opts?.fullDocumentRequired
10
+ ? 'Narrative mode (full-document priority): output the complete body in documents[0].sections order, preserving sample code blocks/commands/key notes; do not compress into a summary.'
11
+ : 'Narrative mode: organize the answer in documents.sections order; do not skip sections.')
12
+ : retrievalMode === 'discovery'
13
+ ? 'Discovery mode: list every candidate from the returned fragments with full identifying detail (name, purpose, platform scope); then give complete selection guidance with trade-offs; do not fabricate nonexistent items.'
14
+ : 'Point mode: synthesize a complete answer derived from the matched fragments, preserving relevant depth and source-faithful detail; do not artificially shorten, the reporting layer enforces any necessary truncation.';
15
+ const structureRule = opts?.fullDocumentRequired
16
+ ? 'If the user explicitly asks for a "full guide/full markdown/full text/include sample code", expand the body in full section order; do not omit steps or code except minimal heading cleanup.'
17
+ : 'Use problem-oriented headings when the answer benefits from sections; otherwise write a continuous synthesized answer (no per-section length constraint).';
18
+ const sectionRule = opts?.fullDocumentRequired
19
+ ? 'Each section may contain complete steps/commands/code; no "1-2 sentence" limit applies.'
20
+ : 'For each section (or in continuous prose): synthesize a complete answer from the matched fragments, including problem analysis, code samples, citations, and examples as relevant. Preserve the source depth. Avoid template wording. The reporting layer enforces truncation, so do not artificially summarize.';
21
+ return [
22
+ '[Output contract — MUST follow]',
23
+ 'User-visible answer MUST be complete; never artificially shorten or summarize for brevity.',
24
+ structureRule,
25
+ sectionRule,
26
+ 'If details are not covered by fragments, mark them as "[not found in docs]" or "[to be confirmed]" instead of inventing or silently omitting.',
27
+ 'Keep a final "References" section with only document titles or URLs; do not show retrieval process/evidence chain/scoring details in the body.',
28
+ 'User-visible content MUST NOT include retrieval-system wording (e.g., "matched fragments / retrieval result / confidence / score / recall / rerank").',
29
+ 'User-visible content MUST NOT include tool/routing decision wording (e.g., "should call / call first / let me search / consultation task / not codegen / search_trtc_knowledge / finalize_answer / submit_feedback").',
30
+ 'Hard constraint: do not introduce API names/field values/enums/events that are absent from fragments.',
31
+ modeRule,
32
+ uncertaintyRule,
33
+ riskRule,
34
+ aggregatedRule,
35
+ ].filter(Boolean).join(' ');
36
+ }
37
+ export function buildAgentMessage(status, retrievalMode, fragments, confidence, _references, opts) {
38
+ const riskRule = opts?.fabricationRisk ? 'Current retrieval has hallucination risk (fabrication_risk=true): do not fill unmatched API details.' : '';
39
+ // Keep the output contract in meta.answer_contract only, to avoid duplicated instruction blocks in message.
40
+ const tail = `${riskRule} Output contract is in meta.answer_contract and MUST be followed. User-visible output must answer the question directly; do not start with tool-selection or retrieval-plan narrative. Final answer drafted -> MUST call \`finalize_answer\` before replying, and \`prompt\`, \`answer\`, \`queryID\` are all required (queryID is from this tool output). Explicit follow-up feedback -> call \`submit_feedback\`.`;
41
+ if (status === 'needs_framework') {
42
+ return '[Hard rule] The question depends on a concrete platform implementation, but frameworks are missing. Cross-platform mixed retrieval is disallowed due to low precision. MUST call `present_framework_choice` first (with product), then call this tool again after user selects web/miniprogram/android/ios/flutter/react/vue/uni-app etc, and MUST include both **product + frameworks**. Do not hard-search with empty frameworks.';
43
+ }
44
+ if (status === 'success' && retrievalMode === 'narrative' && opts?.hasDocuments) {
45
+ if (opts?.fullDocumentRequired) {
46
+ return `This request is narrative (full guide) mode and user explicitly asks for full text: output complete content in documents[0].sections order, preserving sample code and commands; do not summarize. Fragments are for cross-checking only. ${tail}`;
47
+ }
48
+ return `This request is narrative (full guide) mode: prioritize documents.sections order and do not skip sections. Fragments are for cross-checking only. ${tail}`;
49
+ }
50
+ if (status === 'success' && retrievalMode === 'discovery' && fragments.length > 0) {
51
+ return `This request is discovery (catalog/list) mode: list every candidate from the returned fragments with full identifying detail, then give complete selection guidance; do not treat a single fragment as a full workflow, and do not fabricate nonexistent items. ${tail}`;
52
+ }
53
+ if (status === 'success' && retrievalMode === 'point' && opts?.preferDocuments) {
54
+ return `Current results have converged to a single best-practice document: fragments already contain the full document in original section order. Answer in that order, preserve code samples and key notes, and do not compress into a short summary. ${tail}`;
55
+ }
56
+ const { urlMapping, localDoc, urlMappingKinds } = partitionFragments(fragments);
57
+ const hasUrlMapping = urlMapping.length > 0;
58
+ const hasLocalDoc = localDoc.length > 0;
59
+ const hasBestPractice = hasBestPracticeFragment(fragments);
60
+ const urlKindLabel = urlMappingKinds.join(' / ') || 'urls';
61
+ if (status === 'success' && fragments.length > 0) {
62
+ const topFragment = fragments[0];
63
+ if (topFragment?.capability_status === 'unsupported') {
64
+ const scopeLabel = topFragment.scope ? ` (${topFragment.scope})` : '';
65
+ return `This capability is **NOT supported** on the current platform/form${scopeLabel}. Answer MUST: (1) clearly state "**not supported**" in the first paragraph; (2) explain this is a current platform Core capability boundary, not a missing sample-code issue; (3) provide only supported alternatives on this platform (e.g., corresponding preset UI); if no explicit alternative exists in knowledge, clearly state no executable alternative path is available; (4) DO NOT output Core API invocation steps or integration steps for this unsupported capability. ${tail}`;
66
+ }
67
+ if (hasUrlMapping && !hasLocalDoc) {
68
+ return `Available information is mainly documentation-entry links (${urlKindLabel}). First map user question to target URL(s), fetch official docs, then synthesize the answer. Do not merely repeat URL mappings or invent API/error-code details. ${tail}`;
69
+ }
70
+ if (hasUrlMapping && hasLocalDoc) {
71
+ if (hasBestPractice) {
72
+ return `Prioritize direct answering from matched best-practice and local fragments; use URL mapping only as supplemental reference. Do not fetch official docs again for secondary verification by default. ${tail}`;
73
+ }
74
+ return `Local documentation fragments are sufficient for direct answering. Only if key details are still missing, extract target URL from URL mapping and fetch official docs to fill gaps. ${tail}`;
75
+ }
76
+ return `Synthesize the answer strictly from returned fragments; do not run parallel web_search or extra official-doc fetching. ${tail}`;
77
+ }
78
+ if (status === 'low_confidence') {
79
+ if (hasUrlMapping) {
80
+ if (hasBestPractice) {
81
+ return `Prioritize actionable conclusions from best-practice and local fragments. ${tail}`;
82
+ }
83
+ return `First extract target URL(s) from URL mapping and fetch official docs for key details, then answer. ${tail}`;
84
+ }
85
+ return `Provide the most actionable answer from existing fragments; you may guide user to provide component/API/error-code specifics for another retrieval round. Do not run parallel web_search by default. ${tail}`;
86
+ }
87
+ return `No effective fragment was retrieved from the knowledge base. Ask user to verify product/framework; if platform is still missing and the question depends on client-specific implementation, call \`present_framework_choice\` first. ${tail}`;
88
+ }
@@ -0,0 +1,428 @@
1
+ import { attachPoolContent } from '../content-loader.js';
2
+ import { buildReferences } from '../doc-url-resolver.js';
3
+ import { rankSubsetWithIndex } from '../bm25.js';
4
+ import { getProductsBM25Index } from '../bm25-cache.js';
5
+ import { filterPool } from './filter-pool.js';
6
+ import { runExactLookup } from './exact-lookup.js';
7
+ import { applyRankMultipliers, computeApiNameCoverage, computeMissingApiTokens, isUrlMappingSource, postProcess, } from './search-core.js';
8
+ function isBestPracticeSource(source) {
9
+ return /\/best-practice\//i.test(source);
10
+ }
11
+ function normalizeTopicText(text) {
12
+ return text
13
+ .toLowerCase()
14
+ .replace(/[(())【】\[\]{}《》"""'`]/g, '')
15
+ .replace(/[\s/\\,,。::;;|·\-—_]+/g, '');
16
+ }
17
+ const TOPIC_EN_NOISE_WORDS = new Set([
18
+ 'a', 'an', 'the', 'and', 'or', 'to', 'of', 'in', 'on', 'for', 'with',
19
+ 'is', 'are', 'was', 'were', 'be', 'how', 'what', 'why', 'when', 'where',
20
+ 'chat', 'message', 'messages', 'api', 'sdk', 'doc', 'docs',
21
+ ]);
22
+ function extractTopicTokens(text) {
23
+ const tokens = new Set();
24
+ for (const match of text.matchAll(/\b\d{2,}\b/g)) {
25
+ tokens.add(match[0]);
26
+ }
27
+ for (const match of text.matchAll(/\b[a-z][a-z0-9_]{1,}\b/gi)) {
28
+ const token = match[0].toLowerCase();
29
+ if (TOPIC_EN_NOISE_WORDS.has(token))
30
+ continue;
31
+ tokens.add(token);
32
+ }
33
+ const cjkSegments = text.match(/[\u4e00-\u9fff]+/g) ?? [];
34
+ for (const seg of cjkSegments) {
35
+ if (seg.length >= 2)
36
+ tokens.add(seg);
37
+ for (let i = 0; i < seg.length - 1; i += 1) {
38
+ tokens.add(seg.slice(i, i + 2));
39
+ }
40
+ }
41
+ return [...tokens].filter((token) => token.length >= 2);
42
+ }
43
+ function countTopicTokenHits(tokens, normalizedTarget) {
44
+ if (!tokens.length || !normalizedTarget)
45
+ return 0;
46
+ let hits = 0;
47
+ for (const token of tokens) {
48
+ const normalizedToken = normalizeTopicText(token);
49
+ if (normalizedToken.length < 2)
50
+ continue;
51
+ if (normalizedTarget.includes(normalizedToken))
52
+ hits += 1;
53
+ }
54
+ return hits;
55
+ }
56
+ const TOPIC_GENERIC_GUARD_WORDS = new Set([
57
+ ...TOPIC_EN_NOISE_WORDS,
58
+ 'web', 'ios', 'android', 'flutter', 'react', 'vue',
59
+ '直播', '消息', '文档', '教程', '指南', '重要', '保证', '丢失', '聊天',
60
+ ]);
61
+ /**
62
+ * 提取更强区分度的话题锚点(通用策略,不针对任何单 case):
63
+ * - 英文:长度 >= 4 或包含数字(如 AVChatRoom / trtc123)
64
+ * - 中文:连续 3 字以上片段(避免 2-gram 过宽)
65
+ */
66
+ function extractStrongTopicAnchors(text) {
67
+ const anchors = new Set();
68
+ for (const match of text.matchAll(/\b[a-z][a-z0-9_]{3,}\b/gi)) {
69
+ const token = match[0].toLowerCase();
70
+ if (TOPIC_GENERIC_GUARD_WORDS.has(token))
71
+ continue;
72
+ anchors.add(token);
73
+ }
74
+ for (const segment of text.match(/[\u4e00-\u9fff]{3,}/g) ?? []) {
75
+ if (TOPIC_GENERIC_GUARD_WORDS.has(segment))
76
+ continue;
77
+ anchors.add(segment);
78
+ }
79
+ return [...anchors];
80
+ }
81
+ function extractPrimaryHeading(content) {
82
+ const heading = content.split('\n').find((line) => /^#\s+/.test(line.trim()));
83
+ return heading ? heading.replace(/^#\s+/, '').trim() : '';
84
+ }
85
+ function buildBestPracticeTopicText(chunk) {
86
+ if (!chunk)
87
+ return '';
88
+ return normalizeTopicText([
89
+ chunk.title,
90
+ ...(chunk.key_phrases ?? []),
91
+ extractPrimaryHeading(chunk.content),
92
+ ].filter(Boolean).join(' '));
93
+ }
94
+ const DIAGNOSIS_INTENT_RE = /丢消息|消息丢失|message\s*loss|消息可靠性|message\s*reliability|超时|timeout|失败|fail(?:ed|ure)?|排查|诊断|troubleshoot(?:ing)?/i;
95
+ const DIAGNOSIS_DOC_RE = /diagnosis|troubleshoot|排查|诊断|丢失|message\s*loss|reliability|可靠性|timeout|fail(?:ed|ure)?/i;
96
+ const TUTORIAL_DOC_RE = /audio[\s-]*message|voice[\s-]*message|语音消息|录音|recorder|create[a-z]+message|sendmessage|standard\s*(approach|usage)|标准用法|如何发送|how\s*to\s*send/i;
97
+ function isDiagnosisIntent(prompt) {
98
+ return DIAGNOSIS_INTENT_RE.test(prompt);
99
+ }
100
+ function buildChunkIntentText(chunk) {
101
+ return [
102
+ chunk.title,
103
+ ...(chunk.key_phrases ?? []),
104
+ extractPrimaryHeading(chunk.content),
105
+ ].filter(Boolean).join(' ');
106
+ }
107
+ function isDiagnosisStyleChunk(chunk) {
108
+ return DIAGNOSIS_DOC_RE.test(buildChunkIntentText(chunk));
109
+ }
110
+ function isTutorialStyleChunk(chunk) {
111
+ return TUTORIAL_DOC_RE.test(buildChunkIntentText(chunk));
112
+ }
113
+ /**
114
+ * 通用门控:诊断/排查类问法下,抑制教程型文档误抢首位。
115
+ * 仅对“教程型且非诊断型”文档施加轻度降权,不做任何单文档特化。
116
+ */
117
+ function applyDiagnosisIntentGuard(ranked, chunksById, prompt) {
118
+ if (!isDiagnosisIntent(prompt) || ranked.length < 2)
119
+ return ranked;
120
+ const topChunk = chunksById.get(ranked[0].id);
121
+ const topIsTutorial = topChunk ? isTutorialStyleChunk(topChunk) && !isDiagnosisStyleChunk(topChunk) : false;
122
+ const penalty = topIsTutorial ? 90 : 60;
123
+ let adjusted = false;
124
+ const next = ranked.map((item) => {
125
+ const chunk = chunksById.get(item.id);
126
+ if (!chunk)
127
+ return item;
128
+ if (!isTutorialStyleChunk(chunk) || isDiagnosisStyleChunk(chunk))
129
+ return item;
130
+ adjusted = true;
131
+ return { ...item, score: item.score - penalty };
132
+ });
133
+ return adjusted ? next.sort((a, b) => b.score - a.score) : ranked;
134
+ }
135
+ /**
136
+ * A3: 同 prompt 下 best-practice 优先(仅在分数接近时触发,避免召回退化)
137
+ * 触发条件:
138
+ * - 当前 Top1 不是 best-practice
139
+ * - 存在 best-practice 候选
140
+ * - 该候选分数与 Top1 差距不超过 18%
141
+ */
142
+ function prioritizeBestPracticeWhenCompetitive(ranked, chunksById, prompt) {
143
+ if (ranked.length < 2)
144
+ return ranked;
145
+ const top = ranked[0];
146
+ const topChunk = chunksById.get(top.id);
147
+ if (!topChunk || isBestPracticeSource(topChunk.source) || top.score <= 0) {
148
+ return ranked;
149
+ }
150
+ // 收集所有 best-practice 候选,取分数最高者,避免"首个 best-practice 不一定最相关"的偏置。
151
+ let bestPracticeIdx = -1;
152
+ let bestPracticeScore = -Infinity;
153
+ for (let i = 1; i < ranked.length; i += 1) {
154
+ const chunk = chunksById.get(ranked[i].id);
155
+ if (!chunk)
156
+ continue;
157
+ if (!isBestPracticeSource(chunk.source))
158
+ continue;
159
+ if (ranked[i].score > bestPracticeScore) {
160
+ bestPracticeScore = ranked[i].score;
161
+ bestPracticeIdx = i;
162
+ }
163
+ }
164
+ if (bestPracticeIdx < 0)
165
+ return ranked;
166
+ const candidate = ranked[bestPracticeIdx];
167
+ const gapRatio = (top.score - candidate.score) / top.score;
168
+ // 常规场景:仅在分数接近时提升(避免召回退化)
169
+ const normalCompetitive = gapRatio <= 0.35;
170
+ // URL 映射/FAQ 抢首位场景:只要 best-practice 达到基础相关阈值,也应优先返回可执行内容
171
+ // 这样可避免"只有 URL 映射行或 FAQ 简答,没有本地内容"的体验退化。
172
+ // chat/urls 的 URL 行:需额外校验 BP 和 URL 行是否同一话题,
173
+ // 避免"问地理位置消息,URL 行是地理位置,但 BP 是语音消息"的跨话题 shadow。
174
+ const topIsUrlMapping = Boolean(isUrlMappingSource(topChunk.source));
175
+ const topIsChatUrlRow = topIsUrlMapping && topChunk.source.includes('knowledge/chat/urls/');
176
+ const topIsFaq = topChunk.source.includes('/faq/');
177
+ // chat/urls 话题一致性:收紧为“高精度 + 多信号”
178
+ // 1) prompt 中数字/错误码(高区分信号)
179
+ // 2) prompt 切词与 BP 主题 token 命中
180
+ // 3) URL 行标题切词与 BP 主题 token 命中(跨语言兜底)
181
+ // 4) 通用强锚点(长 token / CJK>=3)需与 BP 主题有交集(若 prompt 存在强锚点)
182
+ let chatUrlRowTopicMatch = true;
183
+ let chatUrlTopicSignalCount = 0;
184
+ if (topIsChatUrlRow) {
185
+ const bpChunk = chunksById.get(candidate.id);
186
+ const bpTopicText = buildBestPracticeTopicText(bpChunk);
187
+ const promptDigits = prompt.match(/\b\d{2,}\b/g) ?? [];
188
+ const digitMatch = promptDigits.some((d) => bpTopicText.includes(d));
189
+ const promptTokens = extractTopicTokens(prompt);
190
+ const promptTokenHitCount = countTopicTokenHits(promptTokens, bpTopicText);
191
+ const promptTokenMatch = promptTokenHitCount >= 1;
192
+ const urlTopicRaw = (topChunk.title ?? '').replace(/[((].*$/, '').trim();
193
+ const urlTopicTokens = extractTopicTokens(urlTopicRaw);
194
+ const urlTitleHitCount = countTopicTokenHits(urlTopicTokens, bpTopicText);
195
+ const urlTitleMatch = urlTitleHitCount >= 1;
196
+ const strongAnchors = extractStrongTopicAnchors(prompt);
197
+ const strongAnchorHitCount = countTopicTokenHits(strongAnchors, bpTopicText);
198
+ const strongAnchorMatch = strongAnchors.length === 0 || strongAnchorHitCount >= 1;
199
+ chatUrlTopicSignalCount = Number(digitMatch) + Number(promptTokenMatch) + Number(urlTitleMatch);
200
+ const hasHighPrecisionSignal = digitMatch || urlTitleMatch;
201
+ chatUrlRowTopicMatch = hasHighPrecisionSignal
202
+ && chatUrlTopicSignalCount >= 2
203
+ && strongAnchorMatch;
204
+ }
205
+ const bestPracticeStrongEnough = candidate.score >= Math.max(12, top.score * 0.05);
206
+ const urlRatioThreshold = topIsChatUrlRow ? 0.045 : 0.05;
207
+ const urlAbsoluteThreshold = topIsChatUrlRow ? 20 : 16;
208
+ const urlStrongEnough = candidate.score >= Math.max(urlAbsoluteThreshold, top.score * urlRatioThreshold);
209
+ const urlMappingShadowing = topIsUrlMapping
210
+ && urlStrongEnough
211
+ && chatUrlRowTopicMatch;
212
+ const faqShadowing = topIsFaq && bestPracticeStrongEnough;
213
+ // Feature/API 文档抢首位场景:top1 是功能文档(非 integration、非 BP),
214
+ // BP 的精准实践应优先于功能文档的泛匹配。
215
+ // 条件更严格:BP 分数需达到 top 的 40%(避免完全不相关的 BP 被误提)。
216
+ const topIsFeatureDoc = !topIsUrlMapping && !topIsFaq
217
+ && !isBestPracticeSource(topChunk.source)
218
+ && !isIntegrationSource(topChunk.source);
219
+ const featureDocShadowing = topIsFeatureDoc && candidate.score >= top.score * 0.4;
220
+ if (!normalCompetitive && !urlMappingShadowing && !faqShadowing && !featureDocShadowing)
221
+ return ranked;
222
+ const next = [...ranked];
223
+ next[bestPracticeIdx] = { ...candidate, score: top.score + 2 };
224
+ return next.sort((a, b) => b.score - a.score);
225
+ }
226
+ /**
227
+ * P0-R1/P0-R2:best-practice 单 chunk 直通。
228
+ * 在高置信且无强竞争时,直接锁定 preferredSource + fullDocumentMode。
229
+ * 当 promote 已发生(faqShadowing 触发后 best-practice 排到首位),
230
+ * 即使分数未达 80 也信任 promote 结果执行收敛。
231
+ */
232
+ function pickDominantBestPracticeSource(ranked, chunksById) {
233
+ const top = ranked[0];
234
+ if (!top || top.score <= 0)
235
+ return { fullDocumentMode: false };
236
+ const topChunk = chunksById.get(top.id);
237
+ if (!topChunk || !isBestPracticeSource(topChunk.source)) {
238
+ return { fullDocumentMode: false };
239
+ }
240
+ const source = topChunk.source;
241
+ const topWindow = ranked.slice(0, 12);
242
+ // URL 映射行和 FAQ 语料是导航指针/概览而非内容竞争者:
243
+ // 若计入两者竞争,1.2 倍 dominance 门槛在 best-practice 被命中时永远无法达成。
244
+ const isCrossCompetitor = (item) => {
245
+ const chunk = chunksById.get(item.id);
246
+ if (!chunk || chunk.source === source)
247
+ return false;
248
+ if (isUrlMappingSource(chunk.source))
249
+ return false;
250
+ if (chunk.source.includes('/faq/'))
251
+ return false;
252
+ return true;
253
+ };
254
+ const secondCrossSource = ranked.find((item) => isCrossCompetitor(item));
255
+ const secondCrossScore = secondCrossSource?.score ?? 0;
256
+ const strongCrossSourceCount = topWindow
257
+ .filter((item) => isCrossCompetitor(item) && item.score >= top.score * 0.92)
258
+ .length;
259
+ const sourceSections = [...chunksById.values()].filter((chunk) => chunk.source === source);
260
+ const sourceTotalChars = sourceSections.reduce((sum, chunk) => sum + chunk.content.length, 0);
261
+ const isPromotedResult = top.score >= 20 && ranked.length >= 2 && (top.score - ranked[1].score) >= 0.05;
262
+ const scoreStrongEnough = top.score >= 80 || isPromotedResult;
263
+ const dominanceStrongEnough = secondCrossScore === 0 || (top.score / secondCrossScore) >= 1.2 || isPromotedResult;
264
+ const crossSourceNotCompeting = strongCrossSourceCount === 0 || isPromotedResult;
265
+ const withinLengthBudget = sourceTotalChars <= 7000;
266
+ if (scoreStrongEnough && dominanceStrongEnough && crossSourceNotCompeting && withinLengthBudget) {
267
+ return {
268
+ preferredSource: source,
269
+ fullDocumentMode: true,
270
+ };
271
+ }
272
+ return { fullDocumentMode: false };
273
+ }
274
+ function isIntegrationSource(source) {
275
+ return /\/integration\.md$/i.test(source)
276
+ || /\/preset\/.+integration/i.test(source);
277
+ }
278
+ /**
279
+ * 覆盖度竞争:在 A3 之前执行。
280
+ * 当 integration 的多个 section 被查询广泛命中时,选择 integration 完整文档;
281
+ * 否则返回 null,交由 A3 + BP 收敛处理。
282
+ *
283
+ * 规则:
284
+ * - 按 source 聚合 integration 的 section 命中数,选命中数最多的 source
285
+ * - ≥3 个 section 命中 + integration top 分数 ≥ BP top × 0.6 → 选 integration
286
+ * - BP 分数远高于 integration(>1.2×)→ 直接返回 null
287
+ * - 否则 → 返回 null
288
+ */
289
+ function resolveContentPriority(ranked, chunksById) {
290
+ if (ranked.length < 2)
291
+ return null;
292
+ // 找 BP 最高分(在 top-30 范围内)
293
+ let bpTopScore = -1;
294
+ for (let i = 0; i < Math.min(ranked.length, 30); i++) {
295
+ const chunk = chunksById.get(ranked[i].id);
296
+ if (!chunk || !isBestPracticeSource(chunk.source))
297
+ continue;
298
+ if (ranked[i].score > bpTopScore) {
299
+ bpTopScore = ranked[i].score;
300
+ }
301
+ }
302
+ // 按 source 聚合 integration 的 section 命中(在全部 ranked 范围内,但有 minScore 门槛)
303
+ const minScore = bpTopScore > 0 ? Math.max(bpTopScore * 0.3, 5) : 5;
304
+ const intSourceStats = new Map();
305
+ for (const item of ranked) {
306
+ const chunk = chunksById.get(item.id);
307
+ if (!chunk || !isIntegrationSource(chunk.source))
308
+ continue;
309
+ if (item.score < minScore)
310
+ continue;
311
+ const prev = intSourceStats.get(chunk.source);
312
+ if (!prev) {
313
+ intSourceStats.set(chunk.source, { hitCount: 1, topScore: item.score });
314
+ }
315
+ else {
316
+ intSourceStats.set(chunk.source, {
317
+ hitCount: prev.hitCount + 1,
318
+ topScore: Math.max(prev.topScore, item.score),
319
+ });
320
+ }
321
+ }
322
+ if (intSourceStats.size === 0)
323
+ return null;
324
+ // 选命中数最多的 integration source(同命中数则选分数最高的)
325
+ let bestIntSource = '';
326
+ let bestHitCount = 0;
327
+ let bestIntTopScore = 0;
328
+ for (const [source, stats] of intSourceStats) {
329
+ if (stats.hitCount > bestHitCount
330
+ || (stats.hitCount === bestHitCount && stats.topScore > bestIntTopScore)) {
331
+ bestIntSource = source;
332
+ bestHitCount = stats.hitCount;
333
+ bestIntTopScore = stats.topScore;
334
+ }
335
+ }
336
+ // 无 BP 候选 → 选 integration(如果有足够 section 命中)
337
+ if (bpTopScore < 0) {
338
+ return bestHitCount >= 2
339
+ ? { preferredSource: bestIntSource, fullDocumentMode: true }
340
+ : null;
341
+ }
342
+ // BP 分数远高于 integration → 不走 integration 路径
343
+ if (bpTopScore > bestIntTopScore * 1.2)
344
+ return null;
345
+ // 覆盖度判定
346
+ const BREADTH_THRESHOLD = 3;
347
+ if (bestHitCount >= BREADTH_THRESHOLD && bestIntTopScore >= bpTopScore * 0.6) {
348
+ return { preferredSource: bestIntSource, fullDocumentMode: true };
349
+ }
350
+ return null;
351
+ }
352
+ export function runSearchPass(params) {
353
+ const { allChunks, passNorm, retrievalMode, queryApiTokens, opts, } = params;
354
+ const effectivePassNorm = retrievalMode === 'discovery'
355
+ ? { ...passNorm, types: undefined }
356
+ : passNorm;
357
+ let pool = filterPool(allChunks, effectivePassNorm);
358
+ // types 误传自愈:首轮保留,二次扩检也同样保留该能力
359
+ if (pool.length === 0 && effectivePassNorm.types?.length) {
360
+ pool = filterPool(allChunks, { ...effectivePassNorm, types: undefined });
361
+ }
362
+ const { boosts, exactHits, urlRowHitChunkIds } = runExactLookup(effectivePassNorm.prompt, pool, effectivePassNorm);
363
+ // A2 补充正向路由:UserSig 问法下允许 share 命中且优先于无关 urls 噪声
364
+ if (/usersig|sdkappid|secretkey|鉴权|签名|gentestusersig/i.test(passNorm.prompt)) {
365
+ for (const chunk of pool) {
366
+ if (chunk.product === '*' && chunk.source.includes('knowledge/share/userSig.md')) {
367
+ boosts[chunk.id] = (boosts[chunk.id] ?? 0) + 220;
368
+ }
369
+ }
370
+ }
371
+ // B2: API 覆盖不足扩检时,对 api/integration 语料做轻量重排提权
372
+ if (opts?.apiRetry) {
373
+ for (const chunk of pool) {
374
+ if (chunk.types.includes('api') || chunk.types.includes('integration')) {
375
+ boosts[chunk.id] = (boosts[chunk.id] ?? 0) + 40;
376
+ }
377
+ }
378
+ }
379
+ const productIndex = getProductsBM25Index(allChunks, passNorm.product);
380
+ const poolWithContent = attachPoolContent(pool);
381
+ const chunksById = new Map(poolWithContent.map((chunk) => [chunk.id, chunk]));
382
+ const ranked = rankSubsetWithIndex(effectivePassNorm.prompt, pool.map((chunk) => chunk.id), productIndex, boosts);
383
+ const rankedWithMultipliers = applyRankMultipliers(ranked, chunksById, {
384
+ product: passNorm.product,
385
+ framework: passNorm.framework,
386
+ frameworks: passNorm.frameworks,
387
+ });
388
+ const rankedAfterIntentGuard = applyDiagnosisIntentGuard(rankedWithMultipliers, chunksById, passNorm.prompt);
389
+ // 覆盖度竞争先于 A3 执行
390
+ const integrationPriority = resolveContentPriority(rankedAfterIntentGuard, chunksById);
391
+ let dominantBestPractice;
392
+ let rankedAfterBestPractice;
393
+ if (integrationPriority) {
394
+ // integration 胜出 → 跳过 A3,直接走完整文档路径
395
+ rankedAfterBestPractice = rankedAfterIntentGuard;
396
+ dominantBestPractice = integrationPriority;
397
+ }
398
+ else {
399
+ // integration 未胜出 → A3 提升 BP → BP 收敛判定
400
+ rankedAfterBestPractice = prioritizeBestPracticeWhenCompetitive(rankedAfterIntentGuard, chunksById, passNorm.prompt);
401
+ dominantBestPractice = pickDominantBestPracticeSource(rankedAfterBestPractice, chunksById);
402
+ }
403
+ const pinnedUrlRowChunkIds = new Set(urlRowHitChunkIds);
404
+ const fragments = postProcess(rankedAfterBestPractice, chunksById, passNorm.limit, {
405
+ pinnedUrlRowChunkIds,
406
+ preferredSource: dominantBestPractice.preferredSource,
407
+ fullDocumentMode: dominantBestPractice.fullDocumentMode,
408
+ });
409
+ const urlMappingSources = [...new Set(fragments.map((f) => f.source).filter((source) => isUrlMappingSource(source)))];
410
+ const references = buildReferences(fragments);
411
+ const apiNameCoverage = computeApiNameCoverage(queryApiTokens, poolWithContent);
412
+ const missingApiTokens = computeMissingApiTokens(queryApiTokens, poolWithContent);
413
+ return {
414
+ pool,
415
+ poolWithContent,
416
+ rankedWithMultipliers: rankedAfterBestPractice,
417
+ fragments,
418
+ exactHits,
419
+ urlMappingSources,
420
+ references,
421
+ apiNameCoverage,
422
+ missingApiTokens,
423
+ urlMapRelaxed: pinnedUrlRowChunkIds.size > 0,
424
+ urlMapExactHitCount: pinnedUrlRowChunkIds.size,
425
+ preferredSource: dominantBestPractice.preferredSource,
426
+ fullDocumentMode: dominantBestPractice.fullDocumentMode,
427
+ };
428
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,120 @@
1
+ /**
2
+ * url-row-idf.ts
3
+ *
4
+ * 为 url-row 匹配提供 **IDF(逆文档频率)加权**,用于自动降权高频泛词
5
+ * (`消息`/`message`/`group` 等),而不依赖任何枚举词表。
6
+ *
7
+ * 语言无关:同时支持中文(CJK 切词)与英文(空格切词)。
8
+ * 产品无关:词频统计对当前语料中所有 `urls/` 行的标题构建,未来新增产品自动纳入。
9
+ *
10
+ * 懒加载 + 内存缓存:首次调用时从 manifest 索引统计,之后复用。
11
+ */
12
+ import fs from 'fs';
13
+ import path from 'path';
14
+ import { fileURLToPath } from 'url';
15
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
16
+ let cachedIdf = null;
17
+ let cachedManifestPath = '';
18
+ /** 判断 source 是否 url 映射行(产品无关:knowledge/<product>/urls/) */
19
+ function isUrlMappingSource(source) {
20
+ return /knowledge\/[a-z0-9_-]+\/urls\/.+\.md$/i.test(source.replace(/\\/g, '/'));
21
+ }
22
+ /**
23
+ * 中文连续段切为 2-gram 词片;英文按空白切词。
24
+ * 返回小写 token 列表(未去重,用于统计文档频率)。
25
+ */
26
+ function tokenizeTitle(title) {
27
+ const tokens = [];
28
+ const lower = title.toLowerCase();
29
+ // 英文词(>=2 字母)
30
+ for (const match of lower.matchAll(/[a-z][a-z0-9]{1,}/g)) {
31
+ tokens.push(match[0]);
32
+ }
33
+ // 中文连续段 → 2-gram(避免单字「消息」过于泛,同时保留「消息回应」这类相邻组合的贡献)
34
+ for (const seg of lower.match(/[\u4e00-\u9fff]{2,}/g) ?? []) {
35
+ for (let i = 0; i < seg.length - 1; i += 1) {
36
+ tokens.push(seg.slice(i, i + 2));
37
+ }
38
+ // 整段作为复合词也计入(比 2-gram 更有区分度)
39
+ tokens.push(seg);
40
+ }
41
+ return tokens;
42
+ }
43
+ function resolveManifestPath() {
44
+ return path.resolve(__dirname, '../../resource/index/manifest.json');
45
+ }
46
+ /** 构建 IDF 索引:统计所有 urls 行标题的文档频率 */
47
+ function buildIdfIndex() {
48
+ const manifestPath = resolveManifestPath();
49
+ const df = new Map();
50
+ const seenTitles = new Set();
51
+ let totalDocs = 0;
52
+ if (fs.existsSync(manifestPath)) {
53
+ try {
54
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
55
+ for (const chunk of manifest.chunks ?? []) {
56
+ if (!chunk.title)
57
+ continue;
58
+ if (!isUrlMappingSource(chunk.source))
59
+ continue;
60
+ if (seenTitles.has(chunk.title))
61
+ continue;
62
+ seenTitles.add(chunk.title);
63
+ totalDocs += 1;
64
+ const seen = new Set();
65
+ for (const token of tokenizeTitle(chunk.title)) {
66
+ if (seen.has(token))
67
+ continue;
68
+ seen.add(token);
69
+ df.set(token, (df.get(token) ?? 0) + 1);
70
+ }
71
+ }
72
+ }
73
+ catch {
74
+ // manifest 读取失败时退化为空索引(IDF 全视为高区分度)
75
+ }
76
+ }
77
+ return { df, totalDocs };
78
+ }
79
+ /**
80
+ * 计算单个 token 的 IDF 权重。
81
+ * - 未出现在统计中(稀有/新词)→ 高权重(IDF 高)
82
+ * - 高频泛词 → 低权重
83
+ * - totalDocs 为 0 时退化为 1(不降权)
84
+ */
85
+ export function idfWeight(token) {
86
+ if (!cachedIdf)
87
+ loadIdf();
88
+ const { df, totalDocs } = cachedIdf;
89
+ if (!totalDocs)
90
+ return 1;
91
+ const docFreq = df.get(token.toLowerCase()) ?? 0;
92
+ if (docFreq <= 0)
93
+ return 1;
94
+ // IDF = ln((N - df + 0.5) / (df + 0.5)) + 1,平滑处理避免极端
95
+ const idf = Math.log((totalDocs - docFreq + 0.5) / (docFreq + 0.5)) + 1;
96
+ return Math.max(idf, 0.2);
97
+ }
98
+ /** 加载(或复用)IDF 索引 */
99
+ export function loadIdf() {
100
+ const manifestPath = resolveManifestPath();
101
+ if (cachedIdf && cachedManifestPath === manifestPath)
102
+ return;
103
+ cachedIdf = buildIdfIndex();
104
+ cachedManifestPath = manifestPath;
105
+ }
106
+ /**
107
+ * 判断 2-gram 是否为「有效词」(在 urls 标题语料中出现过,df>0)。
108
+ * 跨词边界的 2-gram(如「送前」「前回」「端群」)在标题语料中 df=0(从未作为词出现),
109
+ * 属于 N-gram 滑窗产生的噪声,不应参与覆盖率分母。df>0 的才是真实语义词。
110
+ * 产品无关:对所有产品的 urls 标题统计,不依赖枚举词表。
111
+ */
112
+ export function isValidBigram(token) {
113
+ if (!cachedIdf)
114
+ loadIdf();
115
+ return (cachedIdf.df.get(token.toLowerCase()) ?? 0) > 0;
116
+ }
117
+ /** 供测试/热更使用 */
118
+ export function resetIdfCache() {
119
+ cachedIdf = null;
120
+ }