@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
|
@@ -7,9 +7,11 @@ import { classifyRetrievalMode } from './intent-router.js';
|
|
|
7
7
|
import { rankSubsetWithIndex } from '../bm25.js';
|
|
8
8
|
import { getProductsBM25Index } from '../bm25-cache.js';
|
|
9
9
|
import { hasExplicitFrameworkMatch } from './framework-fallback.js';
|
|
10
|
-
import { assessConfidence, assessNarrativeCompleteness,
|
|
10
|
+
import { assessConfidence, assessNarrativeCompleteness, buildNarrativeDocument, hasNativeClientFramework, hasNativeTrtcApiSignal, hasProductScopeIntent, inferQueryApiTokens, pickNarrativeSource, } from './search-core.js';
|
|
11
|
+
import { scoreUrlRow } from './url-row-scorer.js';
|
|
11
12
|
import { buildAnswerContract, buildAgentMessage } from './search-message.js';
|
|
12
13
|
import { runSearchPass } from './search-pass.js';
|
|
14
|
+
import { buildReferences } from '../doc-url-resolver.js';
|
|
13
15
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
14
16
|
let cachedManifest = null;
|
|
15
17
|
function resolveResourcePath(...segments) {
|
|
@@ -63,6 +65,97 @@ function buildCoverageReport(allChunks, fragments, preferredSource) {
|
|
|
63
65
|
retry_suggested: primaryCompleteness < 0.6,
|
|
64
66
|
};
|
|
65
67
|
}
|
|
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));
|
|
86
|
+
}
|
|
87
|
+
}
|
|
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',
|
|
94
|
+
]);
|
|
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]}`);
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
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 走,不受影响)
|
|
129
|
+
*/
|
|
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);
|
|
158
|
+
}
|
|
66
159
|
/** 轻量覆盖度:仅算主文件完整度,用于非收敛场景的 allow_pending_confirm 判定,不产出逐文件明细 */
|
|
67
160
|
function computePrimaryCompleteness(allChunks, fragments, preferredSource) {
|
|
68
161
|
if (!fragments.length)
|
|
@@ -85,7 +178,7 @@ export function searchKnowledge(params) {
|
|
|
85
178
|
const norm = normalizeParams(params);
|
|
86
179
|
const retrievalMode = classifyRetrievalMode(norm.prompt);
|
|
87
180
|
if (requiresFramework(norm)) {
|
|
88
|
-
|
|
181
|
+
loadManifest();
|
|
89
182
|
return {
|
|
90
183
|
status: 'needs_framework',
|
|
91
184
|
confidence: 'none',
|
|
@@ -95,9 +188,7 @@ export function searchKnowledge(params) {
|
|
|
95
188
|
documents: [],
|
|
96
189
|
catalog: [],
|
|
97
190
|
meta: {
|
|
98
|
-
|
|
99
|
-
filtered_size: 0,
|
|
100
|
-
exact_hits: [],
|
|
191
|
+
answer_contract: buildAnswerContract('needs_framework', 'none', retrievalMode, { fabricationRisk: false }),
|
|
101
192
|
},
|
|
102
193
|
fragments: [],
|
|
103
194
|
};
|
|
@@ -115,9 +206,26 @@ export function searchKnowledge(params) {
|
|
|
115
206
|
// 典型场景:Agent 传 types=['sdk'],filterPool 把 best-practice(type=faq)硬过滤掉,
|
|
116
207
|
// 只剩 URL 映射行(有 E7 boost 但内容只是一个 URL 链接)。
|
|
117
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。
|
|
118
216
|
const passRetryReasons = [];
|
|
119
217
|
const hasLocalContent = pass.fragments.some((f) => !f.source.includes('/urls/'));
|
|
120
|
-
|
|
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')) {
|
|
121
229
|
const primaryProduct = norm.product[0];
|
|
122
230
|
const bpCandidates = allChunks.filter((chunk) => {
|
|
123
231
|
if (!/\/best-practice\//i.test(chunk.source))
|
|
@@ -138,13 +246,26 @@ export function searchKnowledge(params) {
|
|
|
138
246
|
if ((bpRanked[0]?.score ?? 0) >= 6) {
|
|
139
247
|
const bpPass = runSearchPass({
|
|
140
248
|
allChunks,
|
|
141
|
-
passNorm: { ...norm, types:
|
|
249
|
+
passNorm: { ...norm, types: ['faq'] },
|
|
142
250
|
retrievalMode,
|
|
143
251
|
queryApiTokens,
|
|
144
252
|
});
|
|
145
253
|
if (bpPass.fragments.length > 0) {
|
|
146
|
-
|
|
147
|
-
|
|
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
|
+
}
|
|
148
269
|
}
|
|
149
270
|
}
|
|
150
271
|
}
|
|
@@ -296,21 +417,30 @@ export function searchKnowledge(params) {
|
|
|
296
417
|
};
|
|
297
418
|
}
|
|
298
419
|
narrativeState = resolveNarrativeState(pass);
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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
|
+
}
|
|
303
437
|
const { confidence, status } = assessConfidence(pass.fragments, pass.exactHits, pass.apiNameCoverage, queryApiTokens);
|
|
304
438
|
const fabricationRisk = (queryApiTokens.length > 0 && pass.apiNameCoverage < 0.3)
|
|
305
439
|
|| (confidence === 'low' && pass.fragments.length < 2);
|
|
306
|
-
// point
|
|
307
|
-
// 再回 documents 会造成同一篇内容重复外发。
|
|
440
|
+
// narrative 模式返回完整文档;point/discovery 通过 fragments 提供内容(已含正文)。
|
|
308
441
|
const documents = retrievalMode === 'narrative'
|
|
309
442
|
? (narrativeState.document ? [narrativeState.document] : [])
|
|
310
443
|
: [];
|
|
311
|
-
const catalog = retrievalMode === 'discovery'
|
|
312
|
-
? buildDiscoveryCatalog(pass.rankedWithMultipliers, pass.poolWithContent, norm.limit)
|
|
313
|
-
: [];
|
|
314
444
|
let finalStatus = status;
|
|
315
445
|
let finalConfidence = confidence;
|
|
316
446
|
if (retrievalMode === 'narrative' && documents.length > 0) {
|
|
@@ -323,18 +453,16 @@ export function searchKnowledge(params) {
|
|
|
323
453
|
finalConfidence = 'low';
|
|
324
454
|
}
|
|
325
455
|
}
|
|
326
|
-
if (retrievalMode === 'discovery' &&
|
|
456
|
+
if (retrievalMode === 'discovery' && pass.fragments.length > 0) {
|
|
327
457
|
finalStatus = 'success';
|
|
328
458
|
finalConfidence = finalConfidence === 'none' ? 'medium' : finalConfidence;
|
|
329
459
|
}
|
|
330
|
-
const topScore = pass.fragments[0]?.score;
|
|
331
|
-
const secondScore = pass.fragments[1]?.score;
|
|
332
460
|
const aggregatedFragments = pass.fragments
|
|
333
461
|
.filter((fragment) => fragment.aggregated || fragment.citation?.includes('[aggregated]'))
|
|
334
462
|
.map((fragment) => ({
|
|
335
463
|
chunk_id: fragment.chunk_id,
|
|
336
464
|
source: fragment.source,
|
|
337
|
-
citation: fragment.citation ?? `[
|
|
465
|
+
citation: fragment.citation ?? `[Source: ${fragment.chunk_id}][aggregated]`,
|
|
338
466
|
}));
|
|
339
467
|
const fullDocumentRequired = retrievalMode === 'narrative'
|
|
340
468
|
&& /完整的?\s*(接入|集成|教程|指南|流程)|完整\s*md|全文|原文|不要总结|含\s*(示例)?代码|全部步骤|完整步骤/i.test(norm.prompt);
|
|
@@ -358,7 +486,7 @@ export function searchKnowledge(params) {
|
|
|
358
486
|
const narrativeConverged = retrievalMode !== 'narrative'
|
|
359
487
|
|| (documents.length > 0 && narrativeState.quality.isComplete);
|
|
360
488
|
const convergenceReached = (retrievalMode === 'narrative' && narrativeConverged)
|
|
361
|
-
|| (retrievalMode === 'discovery' &&
|
|
489
|
+
|| (retrievalMode === 'discovery' && pass.fragments.length > 0)
|
|
362
490
|
|| (finalStatus === 'success'
|
|
363
491
|
&& (finalConfidence === 'high' || finalConfidence === 'medium')
|
|
364
492
|
&& (!hasApiQueryTokens || pass.apiNameCoverage >= 0.5)
|
|
@@ -393,33 +521,23 @@ export function searchKnowledge(params) {
|
|
|
393
521
|
message: buildAgentMessage(finalStatus, retrievalMode, pass.fragments, finalConfidence, pass.references, {
|
|
394
522
|
fabricationRisk,
|
|
395
523
|
hasDocuments: documents.length > 0,
|
|
396
|
-
hasCatalog: catalog.length > 0,
|
|
397
524
|
fullDocumentRequired,
|
|
398
525
|
preferDocuments: pass.fullDocumentMode,
|
|
399
526
|
hasAggregatedFragments: aggregatedFragments.length > 0,
|
|
400
527
|
}),
|
|
528
|
+
// query 保留:l1-enrich 依赖 response.query.frameworks 做限领域回填(内部消费)
|
|
401
529
|
query: params,
|
|
402
530
|
retrieval_mode: retrievalMode,
|
|
403
531
|
documents,
|
|
404
|
-
catalog,
|
|
405
532
|
meta: {
|
|
406
|
-
|
|
407
|
-
filtered_size: pass.pool.length,
|
|
408
|
-
exact_hits: pass.exactHits,
|
|
409
|
-
...(pass.urlMappingSources.length ? { url_mapping_sources: pass.urlMappingSources } : {}),
|
|
410
|
-
...(pass.urlMapRelaxed ? { url_map_relaxed: pass.urlMapRelaxed } : {}),
|
|
411
|
-
...(pass.urlMapExactHitCount > 0 ? { url_map_exact_hit_count: pass.urlMapExactHitCount } : {}),
|
|
533
|
+
// 参考文档(AI 输出「参考文档」小节依据):
|
|
412
534
|
...(pass.references.length ? { references: pass.references } : {}),
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
...(pass.preferredSource ? { preferred_source: pass.preferredSource } : {}),
|
|
420
|
-
...(pass.fullDocumentMode ? { full_document_mode: pass.fullDocumentMode } : {}),
|
|
421
|
-
coverage_report: coverageReport,
|
|
422
|
-
...(aggregatedFragments.length ? { aggregated_fragments: aggregatedFragments } : {}),
|
|
535
|
+
answer_contract: answerContract,
|
|
536
|
+
// 防幻觉信号(search-message 依赖):
|
|
537
|
+
fabrication_risk: fabricationRisk,
|
|
538
|
+
// 内部重试决策(供上游判断是否需重搜):
|
|
539
|
+
retry_advice: retryAdvice,
|
|
540
|
+
// narrative 模式的完整度报告(仅 narrative 模式返回,用于验收/判断完整指南是否完整)
|
|
423
541
|
...(retrievalMode === 'narrative'
|
|
424
542
|
? {
|
|
425
543
|
narrative_completeness: {
|
|
@@ -431,11 +549,6 @@ export function searchKnowledge(params) {
|
|
|
431
549
|
},
|
|
432
550
|
}
|
|
433
551
|
: {}),
|
|
434
|
-
answer_contract: answerContract,
|
|
435
|
-
api_name_coverage: pass.apiNameCoverage,
|
|
436
|
-
fabrication_risk: fabricationRisk,
|
|
437
|
-
route_inconsistency_warning: routeInconsistencyWarning,
|
|
438
|
-
retry_advice: retryAdvice,
|
|
439
552
|
},
|
|
440
553
|
fragments: pass.fragments,
|
|
441
554
|
};
|
|
@@ -1,28 +1,29 @@
|
|
|
1
1
|
import { hasBestPracticeFragment, partitionFragments, } from './search-core.js';
|
|
2
2
|
export function buildAnswerContract(status, confidence, retrievalMode, opts) {
|
|
3
|
-
const uncertaintyRule = '
|
|
4
|
-
const riskRule = opts?.fabricationRisk ? '
|
|
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
5
|
const aggregatedRule = opts?.hasAggregatedFragments
|
|
6
|
-
? 'If a field comes from an aggregated fragment, explicitly mark it as "[supplement]" or footnote to avoid confusion with directly matched fragments.'
|
|
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
7
|
: '';
|
|
8
8
|
const modeRule = retrievalMode === 'narrative'
|
|
9
9
|
? (opts?.fullDocumentRequired
|
|
10
|
-
? 'Narrative mode (full-document priority): output complete body
|
|
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
11
|
: 'Narrative mode: organize the answer in documents.sections order; do not skip sections.')
|
|
12
12
|
: retrievalMode === 'discovery'
|
|
13
|
-
? 'Discovery mode:
|
|
14
|
-
: 'Point mode:
|
|
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
15
|
const structureRule = opts?.fullDocumentRequired
|
|
16
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
|
|
17
|
+
: 'Use problem-oriented headings when the answer benefits from sections; otherwise write a continuous synthesized answer (no per-section length constraint).';
|
|
18
18
|
const sectionRule = opts?.fullDocumentRequired
|
|
19
19
|
? 'Each section may contain complete steps/commands/code; no "1-2 sentence" limit applies.'
|
|
20
|
-
: 'For each
|
|
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
21
|
return [
|
|
22
22
|
'[Output contract — MUST follow]',
|
|
23
|
+
'User-visible answer MUST be complete; never artificially shorten or summarize for brevity.',
|
|
23
24
|
structureRule,
|
|
24
25
|
sectionRule,
|
|
25
|
-
'If details are not covered,
|
|
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.',
|
|
26
27
|
'Keep a final "References" section with only document titles or URLs; do not show retrieval process/evidence chain/scoring details in the body.',
|
|
27
28
|
'User-visible content MUST NOT include retrieval-system wording (e.g., "matched fragments / retrieval result / confidence / score / recall / rerank").',
|
|
28
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").',
|
|
@@ -35,7 +36,7 @@ export function buildAnswerContract(status, confidence, retrievalMode, opts) {
|
|
|
35
36
|
}
|
|
36
37
|
export function buildAgentMessage(status, retrievalMode, fragments, confidence, _references, opts) {
|
|
37
38
|
const riskRule = opts?.fabricationRisk ? 'Current retrieval has hallucination risk (fabrication_risk=true): do not fill unmatched API details.' : '';
|
|
38
|
-
// Keep output contract in meta.answer_contract only, to avoid duplicated instruction blocks in message.
|
|
39
|
+
// Keep the output contract in meta.answer_contract only, to avoid duplicated instruction blocks in message.
|
|
39
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\`.`;
|
|
40
41
|
if (status === 'needs_framework') {
|
|
41
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.';
|
|
@@ -46,8 +47,8 @@ export function buildAgentMessage(status, retrievalMode, fragments, confidence,
|
|
|
46
47
|
}
|
|
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}`;
|
|
48
49
|
}
|
|
49
|
-
if (status === 'success' && retrievalMode === 'discovery' &&
|
|
50
|
-
return `This request is discovery (catalog/list) mode:
|
|
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}`;
|
|
51
52
|
}
|
|
52
53
|
if (status === 'success' && retrievalMode === 'point' && opts?.preferDocuments) {
|
|
53
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}`;
|
|
@@ -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
|
+
}
|