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.
Files changed (40) hide show
  1. package/README.en.md +52 -18
  2. package/README.md +49 -16
  3. package/bin/build-mainline-catalog.py +195 -0
  4. package/contracts/corpus_i18n_v1.schema.json +1 -3
  5. package/contracts/corpus_tools_v1.schema.json +30 -1
  6. package/docs/endfield-mainline-navigation.md +45 -0
  7. package/lib/client.js +154 -27
  8. package/lib/skins/common.css +12 -2
  9. package/lib/skins/endfield-aic.css +1 -1
  10. package/lib/skins/prts-agent.css +9 -7
  11. package/package.json +7 -3
  12. package/resources/endfield-mainline.json +4632 -0
  13. package/skills/prts-investigation/SKILL.md +1 -1
  14. package/skills/prts-investigation/references/board-guide.md +1 -1
  15. package/skills/prts-retrieval/SKILL.md +2 -0
  16. package/skills/prts-retrieval/references/cloud-tools.md +3 -1
  17. package/skills/prts-retrieval/references/local-tools.md +3 -3
  18. package/skills/prts-retrieval/references/module-arknights.md +1 -1
  19. package/skills/prts-retrieval/references/module-dual.md +1 -1
  20. package/skills/prts-retrieval/references/module-endfield.md +3 -0
  21. package/skills/prts-retrieval/references/tools-runtime.md +22 -10
  22. package/src/cloud-projection.js +23 -2
  23. package/src/cloud.js +24 -0
  24. package/src/entity-recognizer.js +26 -5
  25. package/src/entity-routing.js +18 -4
  26. package/src/evidence-state.js +4 -2
  27. package/src/i18n.js +70 -15
  28. package/src/index.js +304 -98
  29. package/src/installer.js +86 -14
  30. package/src/investigation-tools.js +7 -4
  31. package/src/navigation.js +342 -0
  32. package/src/read-args.js +86 -0
  33. package/src/read.js +66 -15
  34. package/src/search-projection.js +154 -19
  35. package/src/search.js +177 -21
  36. package/src/store.js +127 -3
  37. package/src/timeline.js +86 -2
  38. package/src/tool-args.js +53 -0
  39. package/src/ui.js +158 -34
  40. package/src/web-guidance.js +14 -2
@@ -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
- throw new ContractError('DOCUMENT_NOT_FOUND', '本地资料包中找不到该完整标题对应的文档')
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
- throw new ContractError('DOCUMENT_NOT_FOUND', `Wiki 文档“${document.display_title}”没有字段“${selection.section}”`)
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', line: nextLine,
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', line: Number(value.selection?.line_end) + 1,
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
- parts.push(readableRenderedLine({ line_number: line.line, line_type: line.line_type,
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
- parts.push(readableRenderedLine({ line_number: line.line, line_type: line.line_type,
1076
- speaker_raw: line.speaker, text: line.text }))
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.line} 行开始。`)
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', line: next.line,
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', line: next.line, data_version: next.data_version })})`)
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
+ }
@@ -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
- const speaker = line.speaker ? `${line.speaker}:` : ''
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 haystack = text.toLocaleLowerCase()
36
- const loweredNeedle = needle.toLocaleLowerCase()
66
+ const { normalized, starts, ends } = normalizedIndex(text)
37
67
  const parts = []
38
68
  let cursor = 0
39
- let offset = haystack.indexOf(loweredNeedle)
69
+ let offset = normalized.indexOf(needle)
40
70
  while (offset >= 0) {
41
- parts.push(text.slice(cursor, offset), `【${text.slice(offset, offset + needle.length)}】`)
42
- cursor = offset + needle.length
43
- offset = haystack.indexOf(loweredNeedle, cursor)
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
- return text.replace(regex, (matched) => `【${matched}】`)
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) : highlightLiteral(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}`].filter(Boolean).join('\n')
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
- : document.matches?.length ? document.matches.map((match) => renderMatch(match, options)).join('\n\n')
91
- : document.available_sections?.length
92
- ? `可用字段:${document.available_sections.join('、')}` : '资料入口'
93
- const wikiNotice = ['character_wiki', 'story_wiki', 'character_activity_wiki']
94
- .includes(document.resource_type)
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
- ? `已检查完整检索范围,共匹配 ${value.page.total_documents} 篇资料。`
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
- return [heading, warnings, relations, ...documents.map((document) => renderDocument(document, options)), zero, complete, next]
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
  }