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/search.js
CHANGED
|
@@ -3,7 +3,8 @@ import { createHash, createHmac, timingSafeEqual } from 'node:crypto'
|
|
|
3
3
|
import { deflateRawSync, inflateRawSync } from 'node:zlib'
|
|
4
4
|
import { activityMatches, aliasesFor } from './timeline.js'
|
|
5
5
|
import { assertCorpusVersion, corpusVersionSnapshot,
|
|
6
|
-
DOCUMENT_ORDERING_VERSION, documentGame, documentUid, naturalDocumentTitle
|
|
6
|
+
DOCUMENT_ORDERING_VERSION, documentGame, documentUid, naturalDocumentTitle,
|
|
7
|
+
DATA_VERSION_REF_PATTERN, dataVersionMatches, shortDataVersion } from './store.js'
|
|
7
8
|
import { projectSearch } from './search-projection.js'
|
|
8
9
|
import { wikiActivityName, wikiActivityRanges, wikiCharacterName, wikiDocumentRole,
|
|
9
10
|
wikiSectionAt, wikiSectionRanges } from './wiki.js'
|
|
@@ -51,7 +52,14 @@ const SEARCH_POLICY_FINGERPRINT = createHash('sha256').update(JSON.stringify({
|
|
|
51
52
|
matchingPolicyVersion: MATCHING_POLICY_VERSION,
|
|
52
53
|
})).digest('base64url').slice(0, 16)
|
|
53
54
|
const FILTER_LIMIT = 16
|
|
54
|
-
|
|
55
|
+
// The high word identifies the installed pack set; the low word retains the
|
|
56
|
+
// candidate ordinal. Keep these bit assignments fixed across plugin releases.
|
|
57
|
+
const PAGE_SCOPE_PACKS = Object.freeze(['official_game', 'endfield_official_game',
|
|
58
|
+
'reviewed_wiki', 'terra_journey', 'endfield_reviewed_knowledge', 'entities', 'references'])
|
|
59
|
+
const PAGE_POSITION_STRIDE = 2 ** 32
|
|
60
|
+
const MAX_CANDIDATE_POSITION = 10_000_000
|
|
61
|
+
const MAX_PAGE_POSITION = (2 ** PAGE_SCOPE_PACKS.length) * PAGE_POSITION_STRIDE
|
|
62
|
+
+ MAX_CANDIDATE_POSITION
|
|
55
63
|
/** 过滤单项最长 512 码点:与上述项数上限共同保证压缩游标可被重新解码。 */
|
|
56
64
|
const FILTER_ITEM_LIMIT = 512
|
|
57
65
|
/**
|
|
@@ -231,14 +239,14 @@ function normalizedRequest(raw = {}) {
|
|
|
231
239
|
const resourceType = normalizeText(raw.after.resource_type)
|
|
232
240
|
const title = normalizeText(raw.after.title)
|
|
233
241
|
const position = raw.after.position
|
|
234
|
-
if (typeof dataVersion !== 'string' || !
|
|
242
|
+
if (typeof dataVersion !== 'string' || !DATA_VERSION_REF_PATTERN.test(dataVersion)
|
|
235
243
|
|| !resourceType || !title || !Number.isSafeInteger(position) || position < 0
|
|
236
|
-
|| position >
|
|
244
|
+
|| position > MAX_PAGE_POSITION || position % PAGE_POSITION_STRIDE > MAX_CANDIDATE_POSITION
|
|
237
245
|
|| Object.keys(raw.after).some((key) => ![
|
|
238
246
|
'data_version', 'resource_type', 'title', 'position',
|
|
239
247
|
].includes(key))) {
|
|
240
248
|
throw Object.assign(new Error(
|
|
241
|
-
'after 必须包含且只能包含
|
|
249
|
+
'after 必须包含且只能包含 data_version(续页给出的版本,至少 8 位)、resource_type、title 和续页给出的安全整数 position'),
|
|
242
250
|
{ code: 'INVALID_REQUEST' })
|
|
243
251
|
}
|
|
244
252
|
after = { data_version: dataVersion, resource_type: resourceType, title, position }
|
|
@@ -307,17 +315,46 @@ function withoutAfter(request) {
|
|
|
307
315
|
return searchRequest
|
|
308
316
|
}
|
|
309
317
|
|
|
318
|
+
function pageScopeMask(store) {
|
|
319
|
+
if (!store.scopeFingerprint) return null
|
|
320
|
+
if (!(store.packs instanceof Map)
|
|
321
|
+
|| [...store.packs.keys()].some((id) => !PAGE_SCOPE_PACKS.includes(id))) {
|
|
322
|
+
throw Object.assign(new Error('无法识别当前安装范围,请重新安装资料包'),
|
|
323
|
+
{ code: 'PAGE_ANCHOR_INVALID' })
|
|
324
|
+
}
|
|
325
|
+
return PAGE_SCOPE_PACKS.reduce((mask, id, index) =>
|
|
326
|
+
mask + (store.packs.has(id) ? 2 ** index : 0), 0)
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function encodePagePosition(store, ordinal) {
|
|
330
|
+
const mask = pageScopeMask(store)
|
|
331
|
+
return mask === null ? ordinal : (mask + 1) * PAGE_POSITION_STRIDE + ordinal
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function decodePagePosition(store, position) {
|
|
335
|
+
const mask = pageScopeMask(store)
|
|
336
|
+
const encodedMask = Math.floor(position / PAGE_POSITION_STRIDE) - 1
|
|
337
|
+
const ordinal = position % PAGE_POSITION_STRIDE
|
|
338
|
+
if ((mask === null ? encodedMask !== -1 : encodedMask !== mask)
|
|
339
|
+
|| ordinal > MAX_CANDIDATE_POSITION) {
|
|
340
|
+
throw Object.assign(new Error('分页锚点绑定的安装范围已经变化,请重新搜索'),
|
|
341
|
+
{ code: 'PAGE_ANCHOR_MISMATCH' })
|
|
342
|
+
}
|
|
343
|
+
return ordinal
|
|
344
|
+
}
|
|
345
|
+
|
|
310
346
|
async function checkpointAfterTitle(store, after, request, { signal, deadline } = {}) {
|
|
311
347
|
assertSearchActive(signal, deadline, '分页锚点校验')
|
|
312
|
-
if (after.data_version
|
|
348
|
+
if (!dataVersionMatches(after.data_version, store.dataVersion)) {
|
|
313
349
|
throw Object.assign(new Error('分页锚点绑定到另一个资料版本,请重新搜索'),
|
|
314
350
|
{ code: 'PAGE_ANCHOR_VERSION_MISMATCH', retryable: false })
|
|
315
351
|
}
|
|
352
|
+
const position = decodePagePosition(store, after.position)
|
|
316
353
|
const regex = request.match_mode === 'regex'
|
|
317
354
|
? new RegExp(safeRegex(request.query).source, 'iu') : null
|
|
318
355
|
const candidates = await candidateDocumentIds(store, request, regex, { signal, deadline })
|
|
319
356
|
assertSearchActive(signal, deadline, '分页锚点校验')
|
|
320
|
-
const documentId = candidates[
|
|
357
|
+
const documentId = candidates[position]
|
|
321
358
|
const location = documentId ? store.documents.get(documentId) : null
|
|
322
359
|
if (!location) throw Object.assign(new Error('找不到分页锚点,请重新搜索'),
|
|
323
360
|
{ code: 'PAGE_ANCHOR_NOT_FOUND' })
|
|
@@ -326,7 +363,7 @@ async function checkpointAfterTitle(store, after, request, { signal, deadline }
|
|
|
326
363
|
throw Object.assign(new Error('分页锚点与当前资料版本不匹配,请重新搜索'),
|
|
327
364
|
{ code: 'PAGE_ANCHOR_MISMATCH' })
|
|
328
365
|
}
|
|
329
|
-
return { nextCandidateIndex:
|
|
366
|
+
return { nextCandidateIndex: position + 1, matchedDocumentsSoFar: 0,
|
|
330
367
|
matchedCountKnown: false }
|
|
331
368
|
}
|
|
332
369
|
|
|
@@ -352,8 +389,8 @@ async function exposeTitleContinuation(store, result, { signal, deadline } = {})
|
|
|
352
389
|
const document = documentId ? store.documents.get(documentId)?.document : null
|
|
353
390
|
if (!document) throw Object.assign(new Error('无法生成下一页的标题锚点'),
|
|
354
391
|
{ code: 'PAGE_ANCHOR_INVALID' })
|
|
355
|
-
nextAfter = { data_version: store.dataVersion, resource_type: publicResourceType(document),
|
|
356
|
-
title: naturalDocumentTitle(document), position: anchorOrdinal }
|
|
392
|
+
nextAfter = { data_version: shortDataVersion(store.dataVersion), resource_type: publicResourceType(document),
|
|
393
|
+
title: naturalDocumentTitle(document), position: encodePagePosition(store, anchorOrdinal) }
|
|
357
394
|
}
|
|
358
395
|
return { ...result, page: { ...page, next_after: nextAfter } }
|
|
359
396
|
}
|
|
@@ -401,7 +438,8 @@ function validCursorSignature(body, received, secret, bytes = 32) {
|
|
|
401
438
|
}
|
|
402
439
|
|
|
403
440
|
async function encodeOffsetCursor(store, request, offset) {
|
|
404
|
-
const payload = [3, cursorVersionTag(store.dataVersion), offset,
|
|
441
|
+
const payload = [3, cursorVersionTag(store.dataVersion), offset,
|
|
442
|
+
compactCursorRequest(request), store.scopeFingerprint ?? null]
|
|
405
443
|
const compressed = deflateRawSync(Buffer.from(JSON.stringify(payload)), { level: 9 })
|
|
406
444
|
const encoded = compressed.toString('base64url')
|
|
407
445
|
const body = `s3.${encoded}`
|
|
@@ -413,7 +451,7 @@ async function encodeOffsetCursor(store, request, offset) {
|
|
|
413
451
|
async function encodeScanCursor(store, request, nextCandidateIndex, matchedDocumentsSoFar) {
|
|
414
452
|
const payload = [4, cursorVersionTag(store.dataVersion), DOCUMENT_ORDERING_VERSION,
|
|
415
453
|
SEARCH_POLICY_FINGERPRINT, nextCandidateIndex, matchedDocumentsSoFar,
|
|
416
|
-
compactCursorRequest(request)]
|
|
454
|
+
compactCursorRequest(request), store.scopeFingerprint ?? null]
|
|
417
455
|
const compressed = deflateRawSync(Buffer.from(JSON.stringify(payload)), { level: 9 })
|
|
418
456
|
const encoded = compressed.toString('base64url')
|
|
419
457
|
const body = `s4.${encoded}`
|
|
@@ -423,6 +461,13 @@ async function encodeScanCursor(store, request, nextCandidateIndex, matchedDocum
|
|
|
423
461
|
return `${body}.${signature}`
|
|
424
462
|
}
|
|
425
463
|
|
|
464
|
+
function assertCursorScope(store, scope) {
|
|
465
|
+
if ((scope ?? null) !== (store.scopeFingerprint ?? null)) {
|
|
466
|
+
throw Object.assign(new Error('cursor 绑定的安装范围已经变化,请重新搜索'),
|
|
467
|
+
{ code: 'CURSOR_VERSION_MISMATCH' })
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
426
471
|
async function decodeCursor(store, cursor) {
|
|
427
472
|
if (String(cursor).length > CURSOR_MAX_LENGTH) {
|
|
428
473
|
throw Object.assign(new Error('cursor 超过长度上限'), { code: 'CURSOR_INVALID' })
|
|
@@ -450,6 +495,7 @@ async function decodeCursor(store, cursor) {
|
|
|
450
495
|
throw Object.assign(new Error('cursor 绑定到另一个资料版本'),
|
|
451
496
|
{ code: 'CURSOR_VERSION_MISMATCH' })
|
|
452
497
|
}
|
|
498
|
+
assertCursorScope(store, value[7])
|
|
453
499
|
if (value[2] !== DOCUMENT_ORDERING_VERSION || value[3] !== SEARCH_POLICY_FINGERPRINT) {
|
|
454
500
|
throw Object.assign(new Error('cursor 绑定的排序或搜索策略已经变化,请重新搜索'),
|
|
455
501
|
{ code: 'CURSOR_POLICY_MISMATCH' })
|
|
@@ -477,6 +523,7 @@ async function decodeCursor(store, cursor) {
|
|
|
477
523
|
{ code: value?.[1] !== cursorVersionTag(store.dataVersion)
|
|
478
524
|
? 'CURSOR_VERSION_MISMATCH' : 'CURSOR_INVALID' })
|
|
479
525
|
}
|
|
526
|
+
assertCursorScope(store, value[4])
|
|
480
527
|
return { kind: 'legacy', request, offset: value[2] }
|
|
481
528
|
}
|
|
482
529
|
|
|
@@ -496,6 +543,7 @@ async function decodeCursor(store, cursor) {
|
|
|
496
543
|
|| !Number.isInteger(value.offset) || value.offset < 0) {
|
|
497
544
|
throw Object.assign(new Error('cursor 内容无效'), { code: 'CURSOR_INVALID' })
|
|
498
545
|
}
|
|
546
|
+
assertCursorScope(store, value.scope_fingerprint)
|
|
499
547
|
return { kind: 'legacy', request: value.request, offset: value.offset }
|
|
500
548
|
}
|
|
501
549
|
|
|
@@ -520,7 +568,53 @@ function documentMatches(document, speakers, filters) {
|
|
|
520
568
|
if (!filters.activity_names.some((name) => activityMatches(activity, name, { exact }))) return false
|
|
521
569
|
}
|
|
522
570
|
if (filters.wiki_sections.length && !wikiRole) return false
|
|
523
|
-
return !filters.speakers.length ||
|
|
571
|
+
return !filters.speakers.length || speakers.some((speaker) => speakerAllowed(filters, speaker))
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* speakers 过滤按别名组匹配:请求「银灰」也命中原文说话人「恩希欧迪斯」。
|
|
576
|
+
* 展开结果挂在不可枚举属性上,游标编码仍只保存用户原始输入。
|
|
577
|
+
*/
|
|
578
|
+
const SPEAKER_ALIASES = Symbol('speakerAliases')
|
|
579
|
+
const SPEAKER_CANONICALS = Symbol('speakerCanonicals')
|
|
580
|
+
|
|
581
|
+
async function expandSpeakerAliases(store, request) {
|
|
582
|
+
const speakers = request?.filters?.speakers || []
|
|
583
|
+
if (!speakers.length) return request
|
|
584
|
+
let groups = []
|
|
585
|
+
try { groups = await aliasesFor(store, speakers) } catch { groups = [] }
|
|
586
|
+
const names = new Set(speakers)
|
|
587
|
+
const canonicals = new Set()
|
|
588
|
+
for (const group of groups) {
|
|
589
|
+
if (group.aliases.length > 1 || group.games.length) canonicals.add(normalizeText(group.canonical))
|
|
590
|
+
for (const alias of group.aliases) {
|
|
591
|
+
const value = normalizeText(alias)
|
|
592
|
+
if (!value) continue
|
|
593
|
+
names.add(value)
|
|
594
|
+
// 原文说话人常只写名不写姓:「恩希欧迪斯·希瓦艾什」→「恩希欧迪斯」。
|
|
595
|
+
const given = value.split(/[·・•]/u)[0].trim()
|
|
596
|
+
if (given && given !== value && [...given].length >= 2) names.add(given)
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
Object.defineProperty(request.filters, SPEAKER_ALIASES, { value: names, enumerable: false })
|
|
600
|
+
Object.defineProperty(request.filters, SPEAKER_CANONICALS, { value: canonicals, enumerable: false })
|
|
601
|
+
return request
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/** 行级兜底:资料包已把说话人链接到实体时,按实体规范名匹配。 */
|
|
605
|
+
function speakerOccurrenceAllowed(filters, line) {
|
|
606
|
+
const canonicals = filters[SPEAKER_CANONICALS]
|
|
607
|
+
if (!canonicals?.size) return false
|
|
608
|
+
return (line.entity_occurrences ?? []).some((item) => item.evidence_kind === 'speaker'
|
|
609
|
+
&& canonicals.has(normalizeText(item.canonical_name)))
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function speakerAllowed(filters, speaker) {
|
|
613
|
+
if (!filters.speakers.length) return true
|
|
614
|
+
const value = normalizeText(speaker)
|
|
615
|
+
if (!value) return false
|
|
616
|
+
return (filters[SPEAKER_ALIASES] || filters.speakers).has?.(value)
|
|
617
|
+
?? filters.speakers.includes(value)
|
|
524
618
|
}
|
|
525
619
|
|
|
526
620
|
function needsWikiActivityHydration(document) {
|
|
@@ -572,6 +666,29 @@ function lineContent(line) {
|
|
|
572
666
|
? text.slice(prefix.length).trimStart() : text
|
|
573
667
|
}
|
|
574
668
|
|
|
669
|
+
/** 展示用正文:只去掉说话人前缀,保留原文全角标点与空白,便于逐字复制引用。 */
|
|
670
|
+
function displayLineContent(line) {
|
|
671
|
+
const text = String(line.text ?? '').trim()
|
|
672
|
+
const speaker = String(line.speaker_raw ?? '').trim()
|
|
673
|
+
if (line.line_type !== 'dialogue' || !speaker) return text
|
|
674
|
+
const escaped = speaker.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&')
|
|
675
|
+
return text.replace(new RegExp(`^${escaped}\\s*[::]\\s*`, 'u'), '')
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/**
|
|
679
|
+
* 用户常把搜索结果中的「说话人: 台词」整行复制回来搜索;台词正文不含说话人,
|
|
680
|
+
* 因此当 query 以本行说话人加冒号开头时,剥掉前缀再匹配正文。
|
|
681
|
+
*/
|
|
682
|
+
function speakerlessQuery(line, query) {
|
|
683
|
+
const speaker = normalizeText(line.speaker_raw)
|
|
684
|
+
if (!query || !speaker || line.line_type !== 'dialogue') return null
|
|
685
|
+
const lowered = query.toLowerCase()
|
|
686
|
+
const prefix = `${speaker.toLowerCase()}:`
|
|
687
|
+
if (!lowered.startsWith(prefix)) return null
|
|
688
|
+
const rest = query.slice(prefix.length).trim()
|
|
689
|
+
return rest || null
|
|
690
|
+
}
|
|
691
|
+
|
|
575
692
|
function matchesText(text, query, mode, regex) {
|
|
576
693
|
if (!query) return true
|
|
577
694
|
if (mode === 'regex') return regex.test(normalizeText(text))
|
|
@@ -638,18 +755,24 @@ function entityOccurrence(record, line, entityGroups, queryPresent, queryRange =
|
|
|
638
755
|
|
|
639
756
|
function lineMatch(record, index, request, regex, entityGroups, bounds = null) {
|
|
640
757
|
const line = record.lines[index]
|
|
641
|
-
if (request.filters
|
|
758
|
+
if (!speakerAllowed(request.filters, line.speaker_raw)
|
|
759
|
+
&& !speakerOccurrenceAllowed(request.filters, line)) return null
|
|
642
760
|
const content = lineContent(line)
|
|
643
|
-
|
|
761
|
+
let query = request.query
|
|
762
|
+
if (!matchesText(content, query, request.match_mode, regex)) {
|
|
763
|
+
const stripped = request.match_mode === 'regex' ? null : speakerlessQuery(line, query)
|
|
764
|
+
if (!stripped || !matchesText(content, stripped, request.match_mode, regex)) return null
|
|
765
|
+
query = stripped
|
|
766
|
+
}
|
|
644
767
|
let start = null
|
|
645
768
|
let end = null
|
|
646
|
-
if (
|
|
769
|
+
if (query) {
|
|
647
770
|
if (regex) {
|
|
648
771
|
const match = new RegExp(regex.source, regex.flags).exec(normalizeText(content))
|
|
649
772
|
if (match) { start = match.index; end = match.index + match[0].length }
|
|
650
773
|
} else {
|
|
651
774
|
const haystack = normalizeText(content).toLowerCase()
|
|
652
|
-
const needle =
|
|
775
|
+
const needle = query.toLowerCase()
|
|
653
776
|
start = haystack.indexOf(needle)
|
|
654
777
|
// 区间终点必须用归一化后 needle 的长度:toLocaleLowerCase 可能改变长度
|
|
655
778
|
// (如土耳其语 İ → i̇),用原 query 长度会让实体邻近度判定错位。
|
|
@@ -742,7 +865,7 @@ function isExactOfficialArchiveTitle(document, request) {
|
|
|
742
865
|
}
|
|
743
866
|
|
|
744
867
|
function lineAllowed(line, filters) {
|
|
745
|
-
return
|
|
868
|
+
return speakerAllowed(filters, line.speaker_raw) || speakerOccurrenceAllowed(filters, line)
|
|
746
869
|
}
|
|
747
870
|
|
|
748
871
|
function readableAnchor(record, filters, ranges = null) {
|
|
@@ -845,7 +968,7 @@ function excerpt(record, item, bounds = null) {
|
|
|
845
968
|
item.passage_end + PREVIEW_OPTIONS.after_lines)
|
|
846
969
|
let truncated = false
|
|
847
970
|
const lines = record.lines.slice(start - 1, end).map((line) => {
|
|
848
|
-
const raw =
|
|
971
|
+
const raw = displayLineContent(line)
|
|
849
972
|
const text = [...raw].length > PREVIEW_OPTIONS.max_chars_per_line
|
|
850
973
|
? `${[...raw].slice(0, PREVIEW_OPTIONS.max_chars_per_line).join('')}…` : raw
|
|
851
974
|
if (text !== raw) truncated = true
|
|
@@ -1003,6 +1126,8 @@ async function executeLegacySearch(store, request, offset,
|
|
|
1003
1126
|
citation: titleOnly ? `《${title}》`
|
|
1004
1127
|
: sectionName ? `《${title}》Wiki·${sectionName}`
|
|
1005
1128
|
: `《${title}》第 ${lineStart === lineEnd ? lineStart : `${lineStart}-${lineEnd}`} 行`,
|
|
1129
|
+
...(titleOnly || sectionName ? {}
|
|
1130
|
+
: { citation_handle: `${documentUid(metadata.document_id)}#L${lineStart}` }),
|
|
1006
1131
|
})
|
|
1007
1132
|
returnedChars += shown.characters
|
|
1008
1133
|
}
|
|
@@ -1017,6 +1142,7 @@ async function executeLegacySearch(store, request, offset,
|
|
|
1017
1142
|
assertSearchActive(signal, deadline, '分页游标生成')
|
|
1018
1143
|
return {
|
|
1019
1144
|
result_kind: resultKind, documents,
|
|
1145
|
+
by_game: gameDistribution(documents),
|
|
1020
1146
|
page: { returned_documents: documents.length,
|
|
1021
1147
|
total_relation: 'unknown', has_more: hasMore, exhausted: false,
|
|
1022
1148
|
next_cursor: nextCursor },
|
|
@@ -1095,6 +1221,19 @@ async function candidateDocumentIds(store, request, regex, { signal, deadline }
|
|
|
1095
1221
|
indexed = await store.findDocumentsByShortLiteral?.(request.query,
|
|
1096
1222
|
{ signal, deadline, packIds: scopedPackIds }) ?? null
|
|
1097
1223
|
}
|
|
1224
|
+
// 「说话人: 台词」整行复制搜索:倒排里没有说话人前缀,另按冒号后的台词补一次候选。
|
|
1225
|
+
const colon = request.query.indexOf(':')
|
|
1226
|
+
const spoken = colon > 0 ? request.query.slice(colon + 1).trim() : ''
|
|
1227
|
+
if (indexed !== null && spoken && [...request.query.slice(0, colon)].length <= FILTER_ITEM_LIMIT) {
|
|
1228
|
+
const spokenNgrams = ngramsFor(spoken)
|
|
1229
|
+
let extra = spokenNgrams.length && indexedLookup
|
|
1230
|
+
? await indexedLookup.call(store, spokenNgrams, { signal, deadline, packIds: scopedPackIds }) : null
|
|
1231
|
+
if (extra === null && spokenNgrams.length && [...spokenNgrams[0]].length < 3) {
|
|
1232
|
+
extra = await store.findDocumentsByShortLiteral?.(spoken,
|
|
1233
|
+
{ signal, deadline, packIds: scopedPackIds }) ?? null
|
|
1234
|
+
}
|
|
1235
|
+
indexed = extra === null ? null : [...new Set([...indexed, ...extra])]
|
|
1236
|
+
}
|
|
1098
1237
|
assertSearchActive(signal, deadline, '候选文档发现')
|
|
1099
1238
|
if (indexed === null) return prioritizeOfficialTitle(scopedIds)
|
|
1100
1239
|
return prioritizeOfficialTitle(
|
|
@@ -1352,6 +1491,8 @@ function buildPublicDocument(store, group, resultKind, request, budget = Infinit
|
|
|
1352
1491
|
citation: titleOnly ? `《${title}》`
|
|
1353
1492
|
: sectionName ? `《${title}》Wiki·${sectionName}`
|
|
1354
1493
|
: `《${title}》第 ${lineStart === lineEnd ? lineStart : `${lineStart}-${lineEnd}`} 行`,
|
|
1494
|
+
...(titleOnly || sectionName ? {}
|
|
1495
|
+
: { citation_handle: `${documentUid(metadata.document_id)}#L${lineStart}` }),
|
|
1355
1496
|
})
|
|
1356
1497
|
characters += shownCharacters
|
|
1357
1498
|
}
|
|
@@ -1359,6 +1500,17 @@ function buildPublicDocument(store, group, resultKind, request, budget = Infinit
|
|
|
1359
1500
|
return { document: result, characters, reasons }
|
|
1360
1501
|
}
|
|
1361
1502
|
|
|
1503
|
+
/** 本页命中的游戏分布:同名实体跨游戏时,让调用方一眼看出该收窄 games。 */
|
|
1504
|
+
function gameDistribution(documents) {
|
|
1505
|
+
const counts = {}
|
|
1506
|
+
for (const document of documents) {
|
|
1507
|
+
const game = document?.game
|
|
1508
|
+
if (game !== 'arknights' && game !== 'endfield') continue
|
|
1509
|
+
counts[game] = (counts[game] || 0) + 1
|
|
1510
|
+
}
|
|
1511
|
+
return counts
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1362
1514
|
async function executeScanSearch(store, request, checkpoint,
|
|
1363
1515
|
{ signal, requestId = null, deadline = Date.now() + SEARCH_TIMEOUT_MS } = {}) {
|
|
1364
1516
|
const started = Date.now()
|
|
@@ -1433,6 +1585,9 @@ async function executeScanSearch(store, request, checkpoint,
|
|
|
1433
1585
|
return {
|
|
1434
1586
|
result_kind: resultKind,
|
|
1435
1587
|
documents,
|
|
1588
|
+
by_game: gameDistribution(documents),
|
|
1589
|
+
// 本次检索覆盖的本地资料规模:零命中时据此说明"检查了多少篇/行"。
|
|
1590
|
+
...(typeof store.corpusTotals === 'function' ? { scope: store.corpusTotals() } : {}),
|
|
1436
1591
|
page: {
|
|
1437
1592
|
returned_documents: documents.length,
|
|
1438
1593
|
...(exhausted && countKnown ? { total_documents: matchedDocuments } : {}),
|
|
@@ -1464,7 +1619,7 @@ export async function executeSearch(store, raw,
|
|
|
1464
1619
|
snapshot = corpusVersionSnapshot(store)
|
|
1465
1620
|
deadline = Date.now() + SEARCH_TIMEOUT_MS
|
|
1466
1621
|
assertSearchActive(signal, deadline)
|
|
1467
|
-
const normalized = normalizedRequest(raw)
|
|
1622
|
+
const normalized = await expandSpeakerAliases(store, normalizedRequest(raw))
|
|
1468
1623
|
const attachCorpusWarnings = (value, request) => {
|
|
1469
1624
|
assertCorpusVersion(store, snapshot)
|
|
1470
1625
|
if (value?.status === 'error' || !(store.packs instanceof Map)) return value
|
|
@@ -1480,6 +1635,7 @@ export async function executeSearch(store, raw,
|
|
|
1480
1635
|
if (normalized.cursor) {
|
|
1481
1636
|
assertSearchActive(signal, deadline, '分页游标校验')
|
|
1482
1637
|
const decoded = await decodeCursor(store, normalized.cursor)
|
|
1638
|
+
await expandSpeakerAliases(store, decoded.request)
|
|
1483
1639
|
assertSearchActive(signal, deadline, '分页游标校验')
|
|
1484
1640
|
const requestedGames = decoded.request.filters?.games?.length
|
|
1485
1641
|
? decoded.request.filters.games : ['arknights', 'endfield']
|
|
@@ -1517,6 +1673,6 @@ export async function executeSearch(store, raw,
|
|
|
1517
1673
|
export function renderSearch(args, value) {
|
|
1518
1674
|
if (value?.error) return [{ type: 'text', text: `[corpus_search:error] ${value.error.code}: ${value.error.message}` }]
|
|
1519
1675
|
return [{ type: 'text', text: projectSearch(value, {
|
|
1520
|
-
query: normalizeText(args?.query),
|
|
1676
|
+
query: normalizeText(args?.query), args,
|
|
1521
1677
|
}) }]
|
|
1522
1678
|
}
|
package/src/store.js
CHANGED
|
@@ -294,6 +294,34 @@ function characterMaterialKey(game, characterName, material) {
|
|
|
294
294
|
return `${String(game || '')}\0${normalizedLookupText(characterName)}\0${String(material || '')}`
|
|
295
295
|
}
|
|
296
296
|
|
|
297
|
+
/** 标题宽松比较键:NFKC、小写、去空白与常见间隔符。 */
|
|
298
|
+
export function looseTitleKey(value) {
|
|
299
|
+
return String(value ?? '').normalize('NFKC').toLowerCase()
|
|
300
|
+
.replace(/[\s·・•‧∙⋅_\-—–]+/gu, '')
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function sharedBigrams(left, right) {
|
|
304
|
+
const grams = (text) => {
|
|
305
|
+
const chars = [...text]
|
|
306
|
+
if (chars.length < 2) return new Set(chars)
|
|
307
|
+
return new Set(chars.slice(1).map((char, index) => chars[index] + char))
|
|
308
|
+
}
|
|
309
|
+
const a = grams(left)
|
|
310
|
+
const b = grams(right)
|
|
311
|
+
if (!a.size || !b.size) return 0
|
|
312
|
+
let shared = 0
|
|
313
|
+
for (const gram of a) if (b.has(gram)) shared += 1
|
|
314
|
+
return shared / a.size
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function isSubsequence(needle, haystack) {
|
|
318
|
+
const chars = [...needle]
|
|
319
|
+
if (chars.length < 2) return false
|
|
320
|
+
let index = 0
|
|
321
|
+
for (const char of haystack) if (char === chars[index]) index += 1
|
|
322
|
+
return index === chars.length
|
|
323
|
+
}
|
|
324
|
+
|
|
297
325
|
function naturalDocumentTitleBase(document = {}, { includeOperatorRecordSegment = true } = {}) {
|
|
298
326
|
const displayTitle = publicMetadataText(document.display_title)
|
|
299
327
|
if (documentGame(document) === 'endfield' && document.game) return displayTitle
|
|
@@ -449,6 +477,8 @@ export class CorpusStore {
|
|
|
449
477
|
this._loaded = false
|
|
450
478
|
this.releaseId = null
|
|
451
479
|
this.dataVersion = null
|
|
480
|
+
this.installationId = null
|
|
481
|
+
this.scopeFingerprint = null
|
|
452
482
|
/** @type {Map<string, { packId: string, shardPath: string, index: number }>} */
|
|
453
483
|
this.documents = new Map()
|
|
454
484
|
/** @type {string[]} 全局稳定 document_ordinal → document_id。 */
|
|
@@ -535,6 +565,8 @@ export class CorpusStore {
|
|
|
535
565
|
this._loaded = false
|
|
536
566
|
this.releaseId = null
|
|
537
567
|
this.dataVersion = null
|
|
568
|
+
this.installationId = null
|
|
569
|
+
this.scopeFingerprint = null
|
|
538
570
|
this.documents.clear()
|
|
539
571
|
this.documentOrder = []
|
|
540
572
|
this.prefixIndex.clear()
|
|
@@ -560,6 +592,7 @@ export class CorpusStore {
|
|
|
560
592
|
this.unstableChars = null
|
|
561
593
|
this._timelineRows = null
|
|
562
594
|
this._aliasGroups = null
|
|
595
|
+
this._looseTitleIndex = null
|
|
563
596
|
}
|
|
564
597
|
|
|
565
598
|
async _init(generation) {
|
|
@@ -568,7 +601,7 @@ export class CorpusStore {
|
|
|
568
601
|
// Store 是 Host 权限下的持久文件入口。每次装载都先验证 release id、声明的
|
|
569
602
|
// 全部 pack、资源路径/尺寸/哈希与符号链接,再读取任何压缩内容。
|
|
570
603
|
const validated = await validateLocalRelease(this.releasesDir, releaseId,
|
|
571
|
-
{ verifyHashes: true, details: true })
|
|
604
|
+
{ verifyHashes: true, details: true, allowPartial: true })
|
|
572
605
|
const { manifest: releaseManifest, packManifests, releaseDir } = validated
|
|
573
606
|
if (current.data_version != null && current.data_version !== releaseManifest.data_version) {
|
|
574
607
|
throw new Error('CorpusStore: current.json data_version does not match release-manifest')
|
|
@@ -594,7 +627,8 @@ export class CorpusStore {
|
|
|
594
627
|
}
|
|
595
628
|
const stableChars = new Set()
|
|
596
629
|
|
|
597
|
-
const
|
|
630
|
+
const installedPackIds = validated.installedPackIds ?? releaseManifest.packs.map((pack) => pack.pack_id)
|
|
631
|
+
const entries = releaseManifest.packs.filter((pack) => installedPackIds.includes(pack.pack_id)).map((descriptor) => ({
|
|
598
632
|
name: descriptor.pack_id,
|
|
599
633
|
manifestPath: descriptor.manifest_path,
|
|
600
634
|
})).sort(packOrder)
|
|
@@ -742,6 +776,9 @@ export class CorpusStore {
|
|
|
742
776
|
if (generation !== this._generation) return false
|
|
743
777
|
this.releaseId = next.releaseId
|
|
744
778
|
this.dataVersion = next.dataVersion
|
|
779
|
+
this.installationId = current.installation_id ?? null
|
|
780
|
+
this.scopeFingerprint = sha256(JSON.stringify([next.dataVersion, [...installedPackIds].sort()]))
|
|
781
|
+
this._looseTitleIndex = null
|
|
745
782
|
this.documents = next.documents
|
|
746
783
|
this.documentOrder = next.documentOrder
|
|
747
784
|
this.prefixIndex = next.prefixIndex
|
|
@@ -899,6 +936,26 @@ export class CorpusStore {
|
|
|
899
936
|
return bytes
|
|
900
937
|
}
|
|
901
938
|
|
|
939
|
+
/**
|
|
940
|
+
* 本地资料包规模:可检索的篇数与行数。零命中时用它说明"检查了多大的范围",
|
|
941
|
+
* 让"没有找到"成为可引用的否定证据。按资料版本缓存,切版自动失效。
|
|
942
|
+
* @returns {{documents: number, lines: number}}
|
|
943
|
+
*/
|
|
944
|
+
corpusTotals() {
|
|
945
|
+
if (this._corpusTotals?.dataVersion === this.dataVersion) return this._corpusTotals.value
|
|
946
|
+
let documents = 0
|
|
947
|
+
let lines = 0
|
|
948
|
+
for (const entry of this.documents.values()) {
|
|
949
|
+
const document = entry?.document
|
|
950
|
+
if (!document) continue
|
|
951
|
+
documents += 1
|
|
952
|
+
lines += Number(document.line_count || 0)
|
|
953
|
+
}
|
|
954
|
+
const value = { documents, lines }
|
|
955
|
+
this._corpusTotals = { dataVersion: this.dataVersion, value }
|
|
956
|
+
return value
|
|
957
|
+
}
|
|
958
|
+
|
|
902
959
|
/**
|
|
903
960
|
* 按 document_id 取完整文档记录。
|
|
904
961
|
* @returns {Promise<{ record: object, packId: string } | null>}
|
|
@@ -996,7 +1053,64 @@ export class CorpusStore {
|
|
|
996
1053
|
`标题“${normalized}”对应 ${documentIds.length} 篇资料;请使用 corpus_search 返回的带资料类型完整标题`,
|
|
997
1054
|
), { code: 'DOCUMENT_AMBIGUOUS' })
|
|
998
1055
|
}
|
|
999
|
-
|
|
1056
|
+
if (documentIds.length) return this.getDocument(documentIds[0])
|
|
1057
|
+
// 宽松回退:忽略空白、间隔号(· ・ •)、斜杠两侧空格、全半角与大小写差异,
|
|
1058
|
+
// 以及角色×活动 Wiki 的内部「第 N 篇」序号;唯一命中才直接返回。
|
|
1059
|
+
const loose = this.looseTitleMatches(normalized)
|
|
1060
|
+
if (loose.length === 1) return this.getDocument(loose[0])
|
|
1061
|
+
return null
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
/** 宽松标题索引:loose key → document_id[],按资料版本惰性构建。 */
|
|
1065
|
+
looseTitleIndex() {
|
|
1066
|
+
if (this._looseTitleIndex?.version === this.dataVersion) return this._looseTitleIndex.index
|
|
1067
|
+
const index = new Map()
|
|
1068
|
+
const add = (title, documentId) => {
|
|
1069
|
+
const key = looseTitleKey(title)
|
|
1070
|
+
if (!key) return
|
|
1071
|
+
const ids = index.get(key) ?? []
|
|
1072
|
+
if (!ids.includes(documentId)) ids.push(documentId)
|
|
1073
|
+
index.set(key, ids)
|
|
1074
|
+
}
|
|
1075
|
+
for (const [title, ids] of this.naturalTitleIndex) {
|
|
1076
|
+
for (const documentId of ids) {
|
|
1077
|
+
add(title, documentId)
|
|
1078
|
+
const withoutSequence = title.replace(/\s*·\s*第\s*\d+\s*篇$/u, '')
|
|
1079
|
+
if (withoutSequence !== title) add(withoutSequence, documentId)
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
for (const [title, ids] of this.titleIndex) for (const documentId of ids) add(title, documentId)
|
|
1083
|
+
this._looseTitleIndex = { version: this.dataVersion, index }
|
|
1084
|
+
return index
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
looseTitleMatches(title) {
|
|
1088
|
+
const key = looseTitleKey(title)
|
|
1089
|
+
return key ? [...(this.looseTitleIndex().get(key) ?? [])] : []
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
/** 读取失败时给出最接近的完整标题候选(最多 limit 条)。 */
|
|
1093
|
+
suggestTitles(title, limit = 5) {
|
|
1094
|
+
const key = looseTitleKey(title)
|
|
1095
|
+
if (!key) return []
|
|
1096
|
+
const scored = []
|
|
1097
|
+
for (const [candidateTitle, ids] of this.naturalTitleIndex) {
|
|
1098
|
+
if (!ids.some((documentId) => this.isPreferredNaturalDocument?.(documentId) !== false)) continue
|
|
1099
|
+
const candidate = looseTitleKey(candidateTitle)
|
|
1100
|
+
let score = 0
|
|
1101
|
+
if (candidate === key) score = 100
|
|
1102
|
+
else if (candidate.includes(key)) score = 80 - Math.min(40, candidate.length - key.length)
|
|
1103
|
+
else if (key.includes(candidate) && candidate.length >= 2) score = 60 - Math.min(40, key.length - candidate.length)
|
|
1104
|
+
else if (isSubsequence(key, candidate)) score = 70 - Math.min(40, candidate.length - key.length)
|
|
1105
|
+
else {
|
|
1106
|
+
const shared = sharedBigrams(key, candidate)
|
|
1107
|
+
if (shared >= 0.6) score = Math.round(shared * 50) - Math.min(20, Math.abs(candidate.length - key.length))
|
|
1108
|
+
}
|
|
1109
|
+
if (score > 0) scored.push({ title: candidateTitle, score })
|
|
1110
|
+
}
|
|
1111
|
+
return scored.sort((left, right) => right.score - left.score
|
|
1112
|
+
|| left.title.length - right.title.length || left.title.localeCompare(right.title, 'zh-CN'))
|
|
1113
|
+
.slice(0, limit).map((item) => item.title)
|
|
1000
1114
|
}
|
|
1001
1115
|
|
|
1002
1116
|
/** 搜索结果若无法仅靠自然标题/合集名稳定回读,就公开短 document_uid。 */
|
|
@@ -1567,3 +1681,13 @@ export class CorpusStore {
|
|
|
1567
1681
|
return matches
|
|
1568
1682
|
}
|
|
1569
1683
|
}
|
|
1684
|
+
|
|
1685
|
+
/** 模型可见的资料版本引用:续页只需前 12 位;校验时接受 ≥8 位前缀。 */
|
|
1686
|
+
export const DATA_VERSION_REF_PATTERN = /^[0-9a-f]{8,64}$/u
|
|
1687
|
+
export function shortDataVersion(value) {
|
|
1688
|
+
return String(value || '').slice(0, 12)
|
|
1689
|
+
}
|
|
1690
|
+
export function dataVersionMatches(given, actual) {
|
|
1691
|
+
return typeof given === 'string' && DATA_VERSION_REF_PATTERN.test(given)
|
|
1692
|
+
&& typeof actual === 'string' && actual.startsWith(given)
|
|
1693
|
+
}
|