prts-terrarchive 0.2.2 → 0.2.3
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/README.en.md +52 -18
- package/README.md +49 -16
- package/bin/build-mainline-catalog.py +195 -0
- package/contracts/corpus_i18n_v1.schema.json +1 -3
- package/contracts/corpus_tools_v1.schema.json +30 -1
- package/docs/endfield-mainline-navigation.md +45 -0
- package/lib/client.js +154 -27
- package/lib/skins/common.css +12 -2
- package/lib/skins/endfield-aic.css +1 -1
- package/lib/skins/prts-agent.css +9 -7
- package/package.json +7 -3
- package/resources/endfield-mainline.json +4632 -0
- package/skills/prts-investigation/SKILL.md +1 -1
- package/skills/prts-investigation/references/board-guide.md +1 -1
- package/skills/prts-retrieval/SKILL.md +2 -0
- package/skills/prts-retrieval/references/cloud-tools.md +3 -1
- package/skills/prts-retrieval/references/local-tools.md +3 -3
- package/skills/prts-retrieval/references/module-arknights.md +1 -1
- package/skills/prts-retrieval/references/module-dual.md +1 -1
- package/skills/prts-retrieval/references/module-endfield.md +3 -0
- package/skills/prts-retrieval/references/tools-runtime.md +22 -10
- package/src/cloud-projection.js +23 -2
- package/src/cloud.js +24 -0
- package/src/entity-recognizer.js +26 -5
- package/src/entity-routing.js +18 -4
- package/src/evidence-state.js +4 -2
- package/src/i18n.js +70 -15
- package/src/index.js +304 -98
- package/src/installer.js +86 -14
- package/src/investigation-tools.js +7 -4
- package/src/navigation.js +342 -0
- package/src/read-args.js +86 -0
- package/src/read.js +66 -15
- package/src/search-projection.js +154 -19
- package/src/search.js +177 -21
- package/src/store.js +127 -3
- package/src/timeline.js +86 -2
- package/src/tool-args.js +53 -0
- package/src/ui.js +158 -34
- package/src/web-guidance.js +14 -2
package/src/read-args.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { dropEmptyPlaceholders } from './tool-args.js'
|
|
2
|
+
|
|
3
|
+
const ZERO_FIELDS = ['line', 'start_line', 'segment', 'position', 'max_lines', 'max_chars']
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 句柄 citation:检索结果发出去的"重新取用"凭据,形如 doc_xxxxxxxxxxxxxxxx#L123
|
|
7
|
+
* 或 doc_xxxxxxxxxxxxxxxx#L120-L135。它把"标题可能同名、抓不到 document_uid"的两跳
|
|
8
|
+
* 缩成一步,Agent 复制粘贴即可回到原文。
|
|
9
|
+
* @returns {{documentUid: string, startLine: number, endLine: number} | null}
|
|
10
|
+
*/
|
|
11
|
+
export function parseCitation(value) {
|
|
12
|
+
const match = String(value ?? '').trim()
|
|
13
|
+
.match(/^(doc_[A-Za-z0-9_-]{16})#L([1-9][0-9]*)(?:-L?([1-9][0-9]*))?$/u)
|
|
14
|
+
if (!match) return null
|
|
15
|
+
const startLine = Number(match[2])
|
|
16
|
+
const endLine = match[3] === undefined ? startLine : Number(match[3])
|
|
17
|
+
if (endLine < startLine) return null
|
|
18
|
+
return { documentUid: match[1], startLine, endLine }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** 把篇章定位与行号区间拼成句柄,供检索结果回显。 */
|
|
22
|
+
export function formatCitation(documentUid, startLine, endLine = startLine) {
|
|
23
|
+
if (!documentUid || !Number.isInteger(startLine)) return ''
|
|
24
|
+
return `${documentUid}#L${startLine === endLine ? startLine : `${startLine}-${endLine}`}`
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Independent anchors, never inferred from an optional modifier alone. */
|
|
28
|
+
export function readTargets(args) {
|
|
29
|
+
return [
|
|
30
|
+
['title', ['title']], ['document_uid', ['document_uid']], ['stage_code', ['stage_code']],
|
|
31
|
+
['record_name', ['character_name', 'record_name']],
|
|
32
|
+
['material', ['character_name', 'material']],
|
|
33
|
+
['activity_name', ['activity_name']], ['collection_name', ['collection_name']],
|
|
34
|
+
].filter(([key]) => args[key] !== undefined && (key !== 'material' || args.character_name !== undefined))
|
|
35
|
+
.map(([key, keys]) => ({ key, args: Object.fromEntries(keys.filter((name) => args[name] !== undefined)
|
|
36
|
+
.map((name) => [name, args[name]])) }))
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Keep every real target and pagination credential. Only known orphan defaults are compatible fillers. */
|
|
40
|
+
export function normalizeReadArguments(raw, { sections = [] } = {}) {
|
|
41
|
+
const args = dropEmptyPlaceholders(raw, { zeroFields: ZERO_FIELDS })
|
|
42
|
+
if (!args || typeof args !== 'object' || Array.isArray(args)) return { args, guidance: '' }
|
|
43
|
+
const notes = []
|
|
44
|
+
if (args.line === undefined) {
|
|
45
|
+
if (args.before === 0) delete args.before
|
|
46
|
+
if (args.after === 0) delete args.after
|
|
47
|
+
}
|
|
48
|
+
if (readTargets(args).length === 1 && args.cursor === undefined) {
|
|
49
|
+
const ignored = []
|
|
50
|
+
const removeDefault = (key, matches) => {
|
|
51
|
+
if (!matches || args[key] === undefined) return
|
|
52
|
+
ignored.push(key)
|
|
53
|
+
delete args[key]
|
|
54
|
+
}
|
|
55
|
+
// 表单式调用会把所有可选字段一并填上(""、0、默认值)。附属参数只要没有对应主定位,
|
|
56
|
+
// 就不表达任何意图:一律忽略并说明,不报错——报错只会让调用方在同一处反复试。
|
|
57
|
+
removeDefault('story_part', args.stage_code === undefined)
|
|
58
|
+
removeDefault('segment', args.record_name === undefined)
|
|
59
|
+
removeDefault('material', args.character_name === undefined)
|
|
60
|
+
removeDefault('start_line', args.mode !== 'document' && !(args.mode === undefined
|
|
61
|
+
&& args.line === undefined && args.section === undefined))
|
|
62
|
+
removeDefault('game', !(args.character_name !== undefined && args.material !== undefined)
|
|
63
|
+
&& ['arknights', 'endfield'].includes(args.game))
|
|
64
|
+
if (ignored.length) notes.push(`参数适配:已忽略没有对应定位信息的默认附属参数 ${ignored.join('、')};未改变目标文档。`)
|
|
65
|
+
if (args.mode === 'document' && sections.includes(args.section)) {
|
|
66
|
+
notes.push(`读取方式:mode=document 已明确选择全文,未应用 section=${args.section}。如只读取该字段,请省略 mode,保留 title 或 document_uid 与 section。`)
|
|
67
|
+
delete args.section
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return { args, guidance: notes.join('\n') }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Concrete conflicting targets and independently executable retries; never choose a target for the caller. */
|
|
74
|
+
export function readTargetError(args) {
|
|
75
|
+
const targets = readTargets(args)
|
|
76
|
+
if (!targets.length) return '缺少主定位信息:请提供 title、document_uid、stage_code、character_name+record_name、character_name+material、activity_name 或 collection_name 中的一种。story_part、segment、material 单独出现不能定位资料。'
|
|
77
|
+
const examples = targets.map(({ key, args: target }) => ({ ...target,
|
|
78
|
+
mode: key === 'activity_name' ? 'activity' : key === 'collection_name' ? 'collection' : 'document' }))
|
|
79
|
+
const titleUidHint = args.title !== undefined && args.document_uid !== undefined
|
|
80
|
+
? 'document_uid 会替代 title,不要同时提交二者。' : ''
|
|
81
|
+
return `必须且只能提供一种定位方式;实际冲突:${targets.map(({ args: target }) => Object.keys(target).join('+')).join(' 与 ')}。请选择目标后仅提交其中一组:${examples.map((example) => JSON.stringify(example)).join(' 或 ')}。${titleUidHint}附属参数不能代替主定位信息。`
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function withReadGuidance(value, guidance) {
|
|
85
|
+
return guidance ? { ...value, guidance: [value.guidance, guidance].filter(Boolean).join('\n') } : value
|
|
86
|
+
}
|
package/src/read.js
CHANGED
|
@@ -14,7 +14,7 @@ import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'
|
|
|
14
14
|
import { assertCorpusVersion, corpusVersionSnapshot, computeLinesIntegrity,
|
|
15
15
|
documentGame, documentUid, naturalDocumentTitle,
|
|
16
16
|
operatorRecordSegment, publicCharacterMaterial, publicStoryPart,
|
|
17
|
-
publicStoryStageCode } from './store.js'
|
|
17
|
+
publicStoryStageCode, shortDataVersion } from './store.js'
|
|
18
18
|
import { WIKI_SECTION_VALUES, wikiSectionRanges } from './wiki.js'
|
|
19
19
|
|
|
20
20
|
export const CONTRACT_VERSION = 'prts-corpus-tools-v1'
|
|
@@ -404,7 +404,17 @@ export async function executeRead(store, rawArgs, runtime) {
|
|
|
404
404
|
throw error
|
|
405
405
|
}
|
|
406
406
|
if (found === null) {
|
|
407
|
-
|
|
407
|
+
const loose = store.looseTitleMatches?.(normalized.locator.display_title) ?? []
|
|
408
|
+
const candidates = loose.length > 1
|
|
409
|
+
? loose.map((documentId) => store.documents.get(documentId)?.document)
|
|
410
|
+
.filter(Boolean).map((document) => naturalDocumentTitle(document))
|
|
411
|
+
: store.suggestTitles?.(normalized.locator.display_title, 5) ?? []
|
|
412
|
+
const unique = [...new Set(candidates)].slice(0, 8)
|
|
413
|
+
const endfieldTip = unique.some((title) => title.startsWith('终末地 · '))
|
|
414
|
+
? '终末地同一任务的多段对话可改用 {"collection_name":"任务名","mode":"collection"} 连续阅读。' : ''
|
|
415
|
+
throw new ContractError('DOCUMENT_NOT_FOUND', unique.length
|
|
416
|
+
? `本地资料包中找不到标题“${normalized.locator.display_title}”。最接近的完整标题:${unique.map((title) => `“${title}”`).join('、')};请改用其一作为 title 重试。${endfieldTip}`
|
|
417
|
+
: `本地资料包中找不到标题“${normalized.locator.display_title}”;请先用 corpus_search 按关键词或 activity_names 列目录,再复制返回的完整标题。`)
|
|
408
418
|
}
|
|
409
419
|
} else {
|
|
410
420
|
const sourceRef = normalized.locator.source_ref
|
|
@@ -466,7 +476,10 @@ export async function executeRead(store, rawArgs, runtime) {
|
|
|
466
476
|
}
|
|
467
477
|
const ranges = wikiSectionRanges(record, [selection.section])
|
|
468
478
|
if (!ranges.length) {
|
|
469
|
-
|
|
479
|
+
const available = [...new Set(wikiSectionRanges(record).map((range) => range.name))]
|
|
480
|
+
throw new ContractError('DOCUMENT_NOT_FOUND', `Wiki 文档“${document.display_title}”没有字段“${selection.section}”;`
|
|
481
|
+
+ (available.length ? `可用字段:${available.join('、')}。` : '该文档没有可单独读取的标签字段,')
|
|
482
|
+
+ '也可省略 section 并设置 mode="document" 读取全文。')
|
|
470
483
|
}
|
|
471
484
|
if (ranges.length > 1) {
|
|
472
485
|
throw new ContractError('DOCUMENT_AMBIGUOUS',
|
|
@@ -883,10 +896,25 @@ export function readableRenderedLine(line, marker = 'L') {
|
|
|
883
896
|
const escaped = speaker.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&')
|
|
884
897
|
text = text.replace(new RegExp(`^${escaped}\\s*[::]\\s*`, 'u'), '')
|
|
885
898
|
}
|
|
899
|
+
if (line.line_type === 'knowledge') text = readableWikiMarkup(text)
|
|
886
900
|
return `${marker}${line.line_number} ${line.line_type || ''} ${speaker ? `${speaker}: ` : ''}${text}`
|
|
887
901
|
.replace(/\s+$/u, '')
|
|
888
902
|
}
|
|
889
903
|
|
|
904
|
+
/**
|
|
905
|
+
* Wiki 资料用 <字段名>…</字段名> 分段;直接展示标签既占篇幅又像 HTML 残留。
|
|
906
|
+
* 只改写整行的字段标记,正文中的尖括号保持原样。
|
|
907
|
+
*/
|
|
908
|
+
function readableWikiMarkup(text) {
|
|
909
|
+
const trimmed = text.trim()
|
|
910
|
+
const inline = /^<([^<>/\s][^<>]{0,40})>([\s\S]*)<\/\1>$/u.exec(trimmed)
|
|
911
|
+
if (inline) return `${inline[1]}:${inline[2].trim()}`
|
|
912
|
+
const open = /^<([^<>/\s][^<>]{0,40})>$/u.exec(trimmed)
|
|
913
|
+
if (open) return `[${open[1]}]`
|
|
914
|
+
if (/^<\/[^<>]{1,40}>$/u.test(trimmed)) return ''
|
|
915
|
+
return text
|
|
916
|
+
}
|
|
917
|
+
|
|
890
918
|
function publicLine(line) {
|
|
891
919
|
const speaker = String(line.speaker_raw || '').trim()
|
|
892
920
|
let text = String(line.text || '')
|
|
@@ -927,7 +955,7 @@ export function projectReadPublic(value) {
|
|
|
927
955
|
returned_lines: Number(value.page?.returned_lines || value.primary.lines?.length || 0),
|
|
928
956
|
has_more: hasMore,
|
|
929
957
|
continuation: hasMore && Number.isInteger(nextLine) && nextLine > 0
|
|
930
|
-
? { ...shortLocator, mode: 'document',
|
|
958
|
+
? { ...shortLocator, mode: 'document', start_line: nextLine,
|
|
931
959
|
...(dataVersion ? { data_version: dataVersion } : {}) }
|
|
932
960
|
: null,
|
|
933
961
|
} }
|
|
@@ -1004,7 +1032,7 @@ export function projectReadPublic(value) {
|
|
|
1004
1032
|
page: { returned_lines: Number(value.page?.returned || lines.length),
|
|
1005
1033
|
has_more: Boolean(value.page?.has_more),
|
|
1006
1034
|
continuation: value.page?.has_more
|
|
1007
|
-
? { ...continuationLocator, mode: 'document',
|
|
1035
|
+
? { ...continuationLocator, mode: 'document', start_line: Number(value.selection?.line_end) + 1,
|
|
1008
1036
|
data_version: String(value.data_version || '') }
|
|
1009
1037
|
: null },
|
|
1010
1038
|
}
|
|
@@ -1024,6 +1052,7 @@ export function renderRead(_args, value) {
|
|
|
1024
1052
|
}
|
|
1025
1053
|
const projected = projectReadPublic(value)
|
|
1026
1054
|
const parts = []
|
|
1055
|
+
if (projected.guidance) parts.push(projected.guidance)
|
|
1027
1056
|
if (projected.primary) {
|
|
1028
1057
|
const primary = projected.primary
|
|
1029
1058
|
parts.push(`# ${primary.title}`)
|
|
@@ -1050,13 +1079,14 @@ export function renderRead(_args, value) {
|
|
|
1050
1079
|
} else {
|
|
1051
1080
|
groupEnd = line.line
|
|
1052
1081
|
}
|
|
1053
|
-
|
|
1054
|
-
speaker_raw: line.speaker, text: line.text })
|
|
1082
|
+
const rendered = readableRenderedLine({ line_number: line.line, line_type: line.line_type,
|
|
1083
|
+
speaker_raw: line.speaker, text: line.text })
|
|
1084
|
+
parts.push(line.source_line_id ? `${rendered}〔${line.source_line_id}〕` : rendered)
|
|
1055
1085
|
}
|
|
1056
1086
|
finishGroup()
|
|
1057
1087
|
if (primary.text) parts.push(primary.text)
|
|
1058
1088
|
if (projected.page.has_more && projected.page.continuation) {
|
|
1059
|
-
parts.push(`继续阅读:corpus_read(${JSON.stringify(projected.page.continuation)})`)
|
|
1089
|
+
parts.push(`继续阅读:corpus_read(${JSON.stringify(compactContinuation(projected.page.continuation))})`)
|
|
1060
1090
|
}
|
|
1061
1091
|
return [{ type: 'text', text: parts.join('\n') }]
|
|
1062
1092
|
}
|
|
@@ -1071,27 +1101,48 @@ export function renderRead(_args, value) {
|
|
|
1071
1101
|
: `${range.line_start}-${range.line_end}`).join('、')
|
|
1072
1102
|
parts.push(`复用上文:第 ${ranges} 行已在上方可见工具结果中;本次只展示尚未覆盖的行。`)
|
|
1073
1103
|
}
|
|
1104
|
+
let hiddenProvenance = 0
|
|
1074
1105
|
for (const line of primary.lines || []) {
|
|
1075
|
-
|
|
1076
|
-
|
|
1106
|
+
if (primary.kind === 'local_document' && isProvenanceLine(line)) { hiddenProvenance += 1; continue }
|
|
1107
|
+
const rendered = readableRenderedLine({ line_number: line.line, line_type: line.line_type,
|
|
1108
|
+
speaker_raw: line.speaker, text: line.text })
|
|
1109
|
+
// 溯源行号(终末地是 dlg_e12m5_6 这类对话 ID)跟着正文一起给出去,
|
|
1110
|
+
// 引用才能被读者回查到具体那一句,而不只是一串行号。
|
|
1111
|
+
parts.push(line.source_line_id ? `${rendered}〔${line.source_line_id}〕` : rendered)
|
|
1077
1112
|
}
|
|
1078
1113
|
if (primary.text) parts.push(primary.text)
|
|
1114
|
+
if (hiddenProvenance) parts.push(`(已省略 ${hiddenProvenance} 行数据来源元信息,行号保持原样。)`)
|
|
1079
1115
|
parts.push(`引用:${primary.citation}`)
|
|
1080
1116
|
if (projected.page.has_more && projected.page.continuation) {
|
|
1081
|
-
const next = projected.page.continuation
|
|
1082
|
-
parts.push(`继续阅读《${primary.title}》,从第 ${next.
|
|
1117
|
+
const next = compactContinuation(projected.page.continuation)
|
|
1118
|
+
parts.push(`继续阅读《${primary.title}》,从第 ${next.start_line} 行开始。`)
|
|
1083
1119
|
if (next.stage_code && next.story_part) {
|
|
1084
1120
|
parts.push(`调用:corpus_read(${JSON.stringify({ stage_code: next.stage_code,
|
|
1085
|
-
story_part: next.story_part, mode: 'document',
|
|
1121
|
+
story_part: next.story_part, mode: 'document', start_line: next.start_line,
|
|
1086
1122
|
data_version: next.data_version })})`)
|
|
1087
1123
|
} else if (next.record_name || next.material) {
|
|
1088
|
-
parts.push(`调用:corpus_read(${JSON.stringify(next)})`)
|
|
1124
|
+
parts.push(`调用:corpus_read(${JSON.stringify({ ...next, mode: 'document' })})`)
|
|
1089
1125
|
} else {
|
|
1090
1126
|
parts.push(`调用:corpus_read(${JSON.stringify({ title: String(next.title || ''),
|
|
1091
|
-
mode: 'document',
|
|
1127
|
+
mode: 'document', start_line: next.start_line, data_version: next.data_version })})`)
|
|
1092
1128
|
}
|
|
1093
1129
|
}
|
|
1094
1130
|
return [{ type: 'text', text: parts.join('\n') }]
|
|
1095
1131
|
}
|
|
1096
1132
|
return [{ type: 'text', text: '[corpus_read:error] 无法投影读取结果' }]
|
|
1097
1133
|
}
|
|
1134
|
+
|
|
1135
|
+
/** 续读调用只展示短版本号;index.js 接受 ≥8 位前缀并在执行前还原为完整版本。 */
|
|
1136
|
+
function compactContinuation(continuation) {
|
|
1137
|
+
if (!continuation?.data_version) return continuation
|
|
1138
|
+
return { ...continuation, data_version: shortDataVersion(continuation.data_version) }
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
/**
|
|
1142
|
+
* 干员/角色资料由导出脚本写入的溯源行(数据表路径、记录 ID、解锁条件枚举):
|
|
1143
|
+
* 对阅读与引用都没有价值,却占档案约三成篇幅。只在非剧情、非 Wiki 资料中隐藏。
|
|
1144
|
+
*/
|
|
1145
|
+
const PROVENANCE_LINE = /^(?:角色 ?ID|来源|来源记录|解锁条件)[::]\s*\S/u
|
|
1146
|
+
function isProvenanceLine(line) {
|
|
1147
|
+
return !line.speaker && line.line_type !== 'dialogue' && PROVENANCE_LINE.test(String(line.text || '').trim())
|
|
1148
|
+
}
|
package/src/search-projection.js
CHANGED
|
@@ -18,10 +18,15 @@ const EVIDENCE_LABELS = Object.freeze({
|
|
|
18
18
|
derived_timeline: '整理性时间线', entity_projection: '实体关联入口', catalog: '资料目录',
|
|
19
19
|
})
|
|
20
20
|
|
|
21
|
+
// 命中高亮用 ⟦⟧:明日方舟档案正文自身大量使用【代号】【性别】,不能再用【】。
|
|
22
|
+
const HIT_OPEN = '⟦'
|
|
23
|
+
const HIT_CLOSE = '⟧'
|
|
24
|
+
|
|
21
25
|
function renderLine(line) {
|
|
22
26
|
const marker = line.role === 'match' ? '>' : line.role === 'constraint' ? '+' : ' '
|
|
23
27
|
const number = line.line == null ? '' : String(line.line).padStart(4, ' ')
|
|
24
|
-
|
|
28
|
+
// 与 corpus_read 的行格式一致:「说话人: 台词」。
|
|
29
|
+
const speaker = line.speaker ? `${line.speaker}: ` : ''
|
|
25
30
|
return `${marker} ${number} ${speaker}${line.rendered_text ?? line.text}${line.truncated ? '(本行已截断)' : ''}`.trimEnd()
|
|
26
31
|
}
|
|
27
32
|
|
|
@@ -29,18 +34,47 @@ function normalizeText(value) {
|
|
|
29
34
|
return String(value ?? '').normalize('NFKC').replace(/\s+/gu, ' ').trim()
|
|
30
35
|
}
|
|
31
36
|
|
|
37
|
+
/**
|
|
38
|
+
* 在原文上高亮:匹配时按 NFKC + 小写比较(与检索一致),输出保留原文字符。
|
|
39
|
+
* 逐码点建立「归一化偏移 → 原文偏移」映射,避免全角标点被改写。
|
|
40
|
+
*/
|
|
41
|
+
function normalizedIndex(text) {
|
|
42
|
+
let normalized = ''
|
|
43
|
+
const starts = []
|
|
44
|
+
const ends = []
|
|
45
|
+
let offset = 0
|
|
46
|
+
let previousSpace = true
|
|
47
|
+
for (const char of text) {
|
|
48
|
+
let piece = char.normalize('NFKC').toLocaleLowerCase()
|
|
49
|
+
if (/^\s+$/u.test(piece)) {
|
|
50
|
+
piece = previousSpace ? '' : ' '
|
|
51
|
+
}
|
|
52
|
+
for (let index = 0; index < piece.length; index += 1) {
|
|
53
|
+
starts.push(offset)
|
|
54
|
+
ends.push(offset + char.length)
|
|
55
|
+
}
|
|
56
|
+
if (piece) previousSpace = piece === ' '
|
|
57
|
+
normalized += piece
|
|
58
|
+
offset += char.length
|
|
59
|
+
}
|
|
60
|
+
return { normalized, starts, ends }
|
|
61
|
+
}
|
|
62
|
+
|
|
32
63
|
function highlightLiteral(text, query) {
|
|
33
|
-
const needle = normalizeText(query)
|
|
64
|
+
const needle = normalizeText(query).toLocaleLowerCase()
|
|
34
65
|
if (!needle) return text
|
|
35
|
-
const
|
|
36
|
-
const loweredNeedle = needle.toLocaleLowerCase()
|
|
66
|
+
const { normalized, starts, ends } = normalizedIndex(text)
|
|
37
67
|
const parts = []
|
|
38
68
|
let cursor = 0
|
|
39
|
-
let offset =
|
|
69
|
+
let offset = normalized.indexOf(needle)
|
|
40
70
|
while (offset >= 0) {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
71
|
+
const from = starts[offset]
|
|
72
|
+
const to = ends[offset + needle.length - 1]
|
|
73
|
+
if (from >= cursor) {
|
|
74
|
+
parts.push(text.slice(cursor, from), `${HIT_OPEN}${text.slice(from, to)}${HIT_CLOSE}`)
|
|
75
|
+
cursor = to
|
|
76
|
+
}
|
|
77
|
+
offset = normalized.indexOf(needle, offset + needle.length)
|
|
44
78
|
}
|
|
45
79
|
return parts.length ? `${parts.join('')}${text.slice(cursor)}` : text
|
|
46
80
|
}
|
|
@@ -48,26 +82,47 @@ function highlightLiteral(text, query) {
|
|
|
48
82
|
function highlightRegex(text, query) {
|
|
49
83
|
try {
|
|
50
84
|
const regex = new RegExp(query, 'giu')
|
|
51
|
-
|
|
85
|
+
// 检索按 NFKC 文本匹配;展示仍在原文上尽力高亮,匹配不上时保持原文不加标记。
|
|
86
|
+
return text.replace(regex, (matched) => matched ? `${HIT_OPEN}${matched}${HIT_CLOSE}` : matched)
|
|
52
87
|
} catch {
|
|
53
88
|
return text
|
|
54
89
|
}
|
|
55
90
|
}
|
|
56
91
|
|
|
92
|
+
function speakerlessQuery(line, query) {
|
|
93
|
+
const speaker = normalizeText(line.speaker)
|
|
94
|
+
const needle = normalizeText(query)
|
|
95
|
+
if (!speaker || !needle.toLocaleLowerCase().startsWith(`${speaker.toLocaleLowerCase()}:`)) return query
|
|
96
|
+
return needle.slice(speaker.length + 1).trim() || query
|
|
97
|
+
}
|
|
98
|
+
|
|
57
99
|
function renderedExcerptLine(line, match, options) {
|
|
58
100
|
if (line.role !== 'match' || !options.query) return line
|
|
59
101
|
const renderedText = match.match_kind === 'regex'
|
|
60
|
-
? highlightRegex(line.text, options.query)
|
|
102
|
+
? highlightRegex(line.text, options.query)
|
|
103
|
+
: highlightLiteral(line.text, speakerlessQuery(line, options.query))
|
|
61
104
|
return renderedText === line.text ? line : { ...line, rendered_text: renderedText }
|
|
62
105
|
}
|
|
63
106
|
|
|
64
107
|
function renderMatch(match, options) {
|
|
108
|
+
// 目录/元数据命中没有正文行:直说"为什么命中",别让调用方以为漏了内容。
|
|
109
|
+
if (match.line_start == null && !(match.excerpt || []).length) {
|
|
110
|
+
const locator = options.documentUid
|
|
111
|
+
? { document_uid: options.documentUid, mode: 'document' }
|
|
112
|
+
: { title: String(options.documentTitle || ''), mode: 'document' }
|
|
113
|
+
return [`命中方式:标题/元数据命中本篇(该篇没有正文命中行)`,
|
|
114
|
+
`读取:corpus_read(${JSON.stringify(locator)})`].join('\n')
|
|
115
|
+
}
|
|
65
116
|
const range = match.line_start == null ? ''
|
|
66
117
|
: `命中:第 ${match.line_start === match.line_end ? match.line_start
|
|
67
118
|
: `${match.line_start}-${match.line_end}`} 行`
|
|
119
|
+
// 句柄:检索结果直接给出可回读的凭据,Agent 不必先查 document_uid 再读。
|
|
120
|
+
const handle = match.citation_handle
|
|
121
|
+
? `句柄:${match.citation_handle} · 回到这几行:corpus_read({"citation":"${match.citation_handle}"})`
|
|
122
|
+
: ''
|
|
68
123
|
return [range, `证据:${EVIDENCE_LABELS[match.evidence_kind] || match.evidence_kind}`,
|
|
69
124
|
...(match.excerpt || []).map((line) => renderLine(renderedExcerptLine(line, match, options))),
|
|
70
|
-
`引用:${match.citation}
|
|
125
|
+
`引用:${match.citation}`, handle].filter(Boolean).join('\n')
|
|
71
126
|
}
|
|
72
127
|
|
|
73
128
|
function renderSection(section) {
|
|
@@ -84,14 +139,41 @@ function renderEntitySummary(summary) {
|
|
|
84
139
|
`引用:${summary.citation}`].filter(Boolean).join('\n')
|
|
85
140
|
}
|
|
86
141
|
|
|
142
|
+
const WIKI_TYPES = ['character_wiki', 'story_wiki', 'character_activity_wiki']
|
|
143
|
+
const WIKI_NOTICE = '引文状态:Wiki 为整理性资料;其中引号内容未核验为当前资料包官方原文,逐字引用前请回查原文。'
|
|
144
|
+
|
|
145
|
+
/** 引用资料(泰拉年表、活动时间线)里存的是原始 wikitext / JSON,展开进检索结果只会烧 token。 */
|
|
146
|
+
function isSerializedDataLine(line) {
|
|
147
|
+
const text = String(line.rendered_text ?? line.text ?? '')
|
|
148
|
+
if (text.length > 240) return true
|
|
149
|
+
if (/^\s*[{[]/u.test(text)) return true
|
|
150
|
+
return /"(?:schema_version|source_location|wikitext_line|raw_wikitext)"\s*:/u.test(text)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function serializedReferenceBody(document, matches) {
|
|
154
|
+
const lines = matches.flatMap((match) => match.excerpt || [])
|
|
155
|
+
if (!lines.length || !lines.some(isSerializedDataLine)) return null
|
|
156
|
+
if (lines.filter(isSerializedDataLine).length < Math.ceil(lines.length / 2)) return null
|
|
157
|
+
return ['本篇为引用资料,正文是原始 wikitext / JSON,不在检索结果里展开。',
|
|
158
|
+
'查事件年代与顺序请用 timeline_search;确需原文时按已知行号读取:',
|
|
159
|
+
`corpus_read(${JSON.stringify(document.document_uid
|
|
160
|
+
? { document_uid: document.document_uid, mode: 'document' }
|
|
161
|
+
: { title: document.title, mode: 'document' })})`].join('\n')
|
|
162
|
+
}
|
|
163
|
+
|
|
87
164
|
function renderDocument(document, options) {
|
|
165
|
+
const serialized = document.resource_type === 'reference'
|
|
166
|
+
? serializedReferenceBody(document, document.matches || []) : null
|
|
88
167
|
const body = document.entity_summary ? renderEntitySummary(document.entity_summary)
|
|
89
168
|
: document.section_content ? renderSection(document.section_content)
|
|
90
|
-
:
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
169
|
+
: serialized ? serialized
|
|
170
|
+
: document.matches?.length ? document.matches.map((match) => renderMatch(match, {
|
|
171
|
+
...options, documentUid: document.document_uid, documentTitle: document.title })).join('\n\n')
|
|
172
|
+
: [document.available_sections?.length ? `可用字段:${document.available_sections.join('、')}` : '',
|
|
173
|
+
`读取:corpus_read(${JSON.stringify(document.document_uid
|
|
174
|
+
? { document_uid: document.document_uid, mode: 'document' }
|
|
175
|
+
: { title: document.title })})`].filter(Boolean).join('\n')
|
|
176
|
+
const wikiNotice = !options.sharedWikiNotice && WIKI_TYPES.includes(document.resource_type)
|
|
95
177
|
? '引文状态:Wiki 为整理性资料;其中引号内容未核验为当前资料包官方原文,逐字引用前请回查原文。'
|
|
96
178
|
: ''
|
|
97
179
|
const game = document.game === 'endfield' ? '终末地'
|
|
@@ -104,6 +186,46 @@ function renderDocument(document, options) {
|
|
|
104
186
|
.filter(Boolean).join('\n')
|
|
105
187
|
}
|
|
106
188
|
|
|
189
|
+
/** 零命中时按实际使用的过滤条件给出具体放宽方向。 */
|
|
190
|
+
export function zeroHitHints(args = {}) {
|
|
191
|
+
const has = (field) => Array.isArray(args?.[field])
|
|
192
|
+
&& args[field].some((item) => typeof item === 'string' && item.trim())
|
|
193
|
+
const hints = []
|
|
194
|
+
if (has('context_terms')) {
|
|
195
|
+
hints.push('context_terms 是强约束:每个词都必须出现在命中行前后 3 行内,不是整篇共现;请先去掉 context_terms 重试。')
|
|
196
|
+
}
|
|
197
|
+
if (has('story_names')) {
|
|
198
|
+
hints.push('story_names 只匹配单关/单篇剧情名(如“鳞的记忆”);活动或章节名(如“昨日海”)请改用 activity_names。')
|
|
199
|
+
}
|
|
200
|
+
if (has('activity_names') || has('collection_names')) {
|
|
201
|
+
hints.push('activity_names / collection_names 需与展示名完全一致;不确定时先省略 query 只按名称列目录核对。')
|
|
202
|
+
}
|
|
203
|
+
if (has('speakers')) {
|
|
204
|
+
hints.push('speakers 只匹配本人亲口台词(已自动展开已登记别名,如 银灰→恩希欧迪斯);提及该角色的旁白或他人台词请改用 entity_names 或 query。')
|
|
205
|
+
const query = normalizeText(args?.query)
|
|
206
|
+
if (query && args.speakers.some((name) => query.includes(normalizeText(name)))) {
|
|
207
|
+
hints.push('query 只匹配台词正文,不含说话人名;按说话人找台词时请省略 query,或只写台词片段。')
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
if (has('resource_types') || has('content_types') || has('wiki_sections')) {
|
|
211
|
+
hints.push('resource_types / content_types / wiki_sections 会缩小资料类型;可先移除再看命中分布。')
|
|
212
|
+
}
|
|
213
|
+
if (args?.match_mode === 'regex') hints.push('regex 模式按受限正则匹配;可改回默认 literal 并缩短为关键短语。')
|
|
214
|
+
return hints
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** 本页命中的游戏分布。跨游戏命中时提醒收窄,避免同名实体(如「博士」)互相污染。 */
|
|
218
|
+
function gameDistributionLine(byGame) {
|
|
219
|
+
const labels = { arknights: '明日方舟', endfield: '终末地' }
|
|
220
|
+
const parts = Object.entries(byGame || {})
|
|
221
|
+
.filter(([game, count]) => labels[game] && Number.isInteger(count) && count > 0)
|
|
222
|
+
.map(([game, count]) => `${labels[game]} ${count} 篇`)
|
|
223
|
+
if (!parts.length) return ''
|
|
224
|
+
const cross = Object.keys(byGame || {}).filter((game) => labels[game]).length > 1
|
|
225
|
+
return `命中分布(按游戏,本页):${parts.join(' · ')}`
|
|
226
|
+
+ (cross ? '。跨游戏命中,做人物向研究时请用 games 限定' : '')
|
|
227
|
+
}
|
|
228
|
+
|
|
107
229
|
export function projectSearch(value, options = {}) {
|
|
108
230
|
const documents = value?.documents || []
|
|
109
231
|
const matches = documents.reduce((total, document) => total
|
|
@@ -118,14 +240,21 @@ export function projectSearch(value, options = {}) {
|
|
|
118
240
|
? `扫描尚未穷尽。继续时保留本次搜索词和过滤条件,并设置 after: ${JSON.stringify(value.page.next_after)}。`
|
|
119
241
|
: value?.page?.next_cursor
|
|
120
242
|
? `旧版分页尚未结束。继续时仅提交 cursor: ${JSON.stringify(value.page.next_cursor)}。` : ''
|
|
243
|
+
const distribution = gameDistributionLine(value?.by_game)
|
|
244
|
+
const hints = zeroHitHints(options.args)
|
|
245
|
+
// 覆盖率:让"没有找到"带上检查范围,成为可引用的否定证据。
|
|
246
|
+
const scopeText = Number.isInteger(value?.scope?.documents)
|
|
247
|
+
? `本地 ${value.scope.documents} 篇 / ${Number(value.scope.lines || 0)} 行`
|
|
248
|
+
: ''
|
|
121
249
|
const zero = documents.length ? '' : exhausted
|
|
122
|
-
? '
|
|
250
|
+
? [`已检查完整检索范围${scopeText ? `(${scopeText})` : ''},没有找到。请检查展示名、缩短连续字面串或移除冲突过滤条件。`,
|
|
251
|
+
...hints.map((hint) => `- ${hint}`)].join('\n')
|
|
123
252
|
: value?.page?.next_after
|
|
124
253
|
? '本页没有发现命中文档,但扫描位置已经推进;这不是全库零命中。'
|
|
125
254
|
: '该旧版分页链已经结束,但不能证明资料范围已经穷尽;需要完整性时请重新搜索。'
|
|
126
255
|
const complete = exhausted && documents.length
|
|
127
256
|
? Number.isInteger(value.page.total_documents)
|
|
128
|
-
?
|
|
257
|
+
? `已检查完整检索范围${scopeText ? `(${scopeText})` : ''},共匹配 ${value.page.total_documents} 篇资料。`
|
|
129
258
|
: '已扫描至当前检索范围末尾。'
|
|
130
259
|
: ''
|
|
131
260
|
const warnings = (value?.warnings || []).length
|
|
@@ -134,6 +263,12 @@ export function projectSearch(value, options = {}) {
|
|
|
134
263
|
? `## 再旅者对应关系(人工审校附属字段)\n${value.retraveler_relations.map((item) =>
|
|
135
264
|
`- 终末地角色:${item.endfield_name};泰拉记忆原型:${item.terra_memory_prototype || '未登记'};` +
|
|
136
265
|
`状态:${item.relation_status}。这是跨游戏关系,不是人物别名。`).join('\n')}` : ''
|
|
137
|
-
|
|
266
|
+
// 多篇 Wiki 同屏时引文提示只说一次,避免每篇重复同一句话。
|
|
267
|
+
const wikiCount = documents.filter((document) => WIKI_TYPES.includes(document.resource_type)).length
|
|
268
|
+
const sharedWikiNotice = wikiCount > 1
|
|
269
|
+
const documentOptions = sharedWikiNotice ? { ...options, sharedWikiNotice } : options
|
|
270
|
+
return [heading, distribution, sharedWikiNotice ? `${WIKI_NOTICE}(适用于下列所有 Wiki 条目)` : '',
|
|
271
|
+
warnings, relations,
|
|
272
|
+
...documents.map((document) => renderDocument(document, documentOptions)), zero, complete, next]
|
|
138
273
|
.filter(Boolean).join('\n\n')
|
|
139
274
|
}
|