prts-terrarchive 0.2.0
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/GAME_ASSETS.md +26 -0
- package/LICENSE +21 -0
- package/README.en.md +274 -0
- package/README.md +423 -0
- package/THIRD_PARTY_NOTICES.md +61 -0
- package/bin/bake-rhine-details.mjs +34 -0
- package/bin/install.js +273 -0
- package/bin/pack-map-assets.mjs +110 -0
- package/bin/preview-rhine.mjs +71 -0
- package/bin/rhine-detail-baker.ts +84 -0
- package/contracts/cloud_tools_v1.schema.json +243 -0
- package/contracts/corpus_i18n_v1.schema.json +179 -0
- package/contracts/corpus_tools_v1.schema.json +1801 -0
- package/cordis.patch.yml +32 -0
- package/docs/endfield-official-i18n.md +103 -0
- package/lib/client.js +3912 -0
- package/lib/endfield-map/map.js.br +0 -0
- package/lib/endfield-map/map.js.gz +0 -0
- package/lib/endfield-map/resources/map-00b0d0744a1b4404.json.br +0 -0
- package/lib/endfield-map/resources/map-00b0d0744a1b4404.json.gz +0 -0
- package/lib/endfield-map/resources/map-01616f534aaaeca1.png +0 -0
- package/lib/endfield-map/resources/map-1562e0dff4be3d23.png +0 -0
- package/lib/endfield-map/resources/map-160fe040f080f6a3.png +0 -0
- package/lib/endfield-map/resources/map-17b9d8be25dc2b39.json.br +0 -0
- package/lib/endfield-map/resources/map-17b9d8be25dc2b39.json.gz +0 -0
- package/lib/endfield-map/resources/map-1c4245c4e8b80c43.json.br +0 -0
- package/lib/endfield-map/resources/map-1c4245c4e8b80c43.json.gz +0 -0
- package/lib/endfield-map/resources/map-1d30baaaef3e29cc.png +0 -0
- package/lib/endfield-map/resources/map-21a61386af79cd12.json.br +0 -0
- package/lib/endfield-map/resources/map-21a61386af79cd12.json.gz +0 -0
- package/lib/endfield-map/resources/map-24b920b8dab49b44.json.br +0 -0
- package/lib/endfield-map/resources/map-24b920b8dab49b44.json.gz +0 -0
- package/lib/endfield-map/resources/map-26caf8d71247abb7.png +0 -0
- package/lib/endfield-map/resources/map-2729942c89545e73.json.br +0 -0
- package/lib/endfield-map/resources/map-2729942c89545e73.json.gz +0 -0
- package/lib/endfield-map/resources/map-29063a9802f67014.json.br +0 -0
- package/lib/endfield-map/resources/map-29063a9802f67014.json.gz +0 -0
- package/lib/endfield-map/resources/map-2f35d09a3ab60a63.json.br +0 -0
- package/lib/endfield-map/resources/map-2f35d09a3ab60a63.json.gz +0 -0
- package/lib/endfield-map/resources/map-2f98a873da81425e.json.br +0 -0
- package/lib/endfield-map/resources/map-2f98a873da81425e.json.gz +0 -0
- package/lib/endfield-map/resources/map-3ab978ae44190f80.json.br +0 -0
- package/lib/endfield-map/resources/map-3ab978ae44190f80.json.gz +0 -0
- package/lib/endfield-map/resources/map-3b62c80d14208902.json.br +0 -0
- package/lib/endfield-map/resources/map-3b62c80d14208902.json.gz +0 -0
- package/lib/endfield-map/resources/map-3d486e887ae121ac.png +0 -0
- package/lib/endfield-map/resources/map-418dc4865608fce5.png +0 -0
- package/lib/endfield-map/resources/map-4366b84607335f76.json.br +0 -0
- package/lib/endfield-map/resources/map-4366b84607335f76.json.gz +0 -0
- package/lib/endfield-map/resources/map-4371259d3ab7aac7.png +0 -0
- package/lib/endfield-map/resources/map-48dcc0fd55e13f7f.png +0 -0
- package/lib/endfield-map/resources/map-4aabda0e41197570.json.br +0 -0
- package/lib/endfield-map/resources/map-4aabda0e41197570.json.gz +0 -0
- package/lib/endfield-map/resources/map-505f5687b287043c.json.br +0 -0
- package/lib/endfield-map/resources/map-505f5687b287043c.json.gz +0 -0
- package/lib/endfield-map/resources/map-5177e9d1d46f47e2.png +0 -0
- package/lib/endfield-map/resources/map-51baeb59b7f8c28d.png +0 -0
- package/lib/endfield-map/resources/map-549561b46e621874.png +0 -0
- package/lib/endfield-map/resources/map-56c6a0bef799c8dc.json.br +0 -0
- package/lib/endfield-map/resources/map-56c6a0bef799c8dc.json.gz +0 -0
- package/lib/endfield-map/resources/map-5a6963909423b674.png +0 -0
- package/lib/endfield-map/resources/map-5a9c533c0552d7b7.png +0 -0
- package/lib/endfield-map/resources/map-5d20ae9ffa9a02b0.png +0 -0
- package/lib/endfield-map/resources/map-626c8847393f60bf.png +0 -0
- package/lib/endfield-map/resources/map-63d43cbec3bed3b8.png +0 -0
- package/lib/endfield-map/resources/map-661b6ec56e722574.png +0 -0
- package/lib/endfield-map/resources/map-6ddd384eca190828.png +0 -0
- package/lib/endfield-map/resources/map-6fc1aa9144f8156c.json.br +0 -0
- package/lib/endfield-map/resources/map-6fc1aa9144f8156c.json.gz +0 -0
- package/lib/endfield-map/resources/map-738ebaa002c33cfc.png +0 -0
- package/lib/endfield-map/resources/map-758c13be6085ec07.json.br +0 -0
- package/lib/endfield-map/resources/map-758c13be6085ec07.json.gz +0 -0
- package/lib/endfield-map/resources/map-78929f26d6844509.png +0 -0
- package/lib/endfield-map/resources/map-7f09c142950a0f74.png +0 -0
- package/lib/endfield-map/resources/map-8385e11f39e1a51c.json.br +0 -0
- package/lib/endfield-map/resources/map-8385e11f39e1a51c.json.gz +0 -0
- package/lib/endfield-map/resources/map-881343926a568f27.json.br +0 -0
- package/lib/endfield-map/resources/map-881343926a568f27.json.gz +0 -0
- package/lib/endfield-map/resources/map-8d1ade3f01d952b0.png +0 -0
- package/lib/endfield-map/resources/map-8fef3495bb7ed60f.png +0 -0
- package/lib/endfield-map/resources/map-907a7ecf8a0ce352.png +0 -0
- package/lib/endfield-map/resources/map-9113d5951c4db903.json.br +0 -0
- package/lib/endfield-map/resources/map-9113d5951c4db903.json.gz +0 -0
- package/lib/endfield-map/resources/map-94ad6634436224e3.json.br +0 -0
- package/lib/endfield-map/resources/map-94ad6634436224e3.json.gz +0 -0
- package/lib/endfield-map/resources/map-99722e61da5f3e53.png +0 -0
- package/lib/endfield-map/resources/map-9be9596cc059abe3.png +0 -0
- package/lib/endfield-map/resources/map-a4db9a4763e47fbf.png +0 -0
- package/lib/endfield-map/resources/map-a4e8ef07159997e7.json.br +0 -0
- package/lib/endfield-map/resources/map-a4e8ef07159997e7.json.gz +0 -0
- package/lib/endfield-map/resources/map-ab35d06875ee418f.png +0 -0
- package/lib/endfield-map/resources/map-ac51bb3e7623cd8b.png +0 -0
- package/lib/endfield-map/resources/map-ac830a66296bc83a.png +0 -0
- package/lib/endfield-map/resources/map-b3fd04209718e55e.json.br +0 -0
- package/lib/endfield-map/resources/map-b3fd04209718e55e.json.gz +0 -0
- package/lib/endfield-map/resources/map-b8164f1af7c5d895.png +0 -0
- package/lib/endfield-map/resources/map-b8cde93b965384d8.json.br +0 -0
- package/lib/endfield-map/resources/map-b8cde93b965384d8.json.gz +0 -0
- package/lib/endfield-map/resources/map-b8e48511502e0e8d.json.br +0 -0
- package/lib/endfield-map/resources/map-b8e48511502e0e8d.json.gz +0 -0
- package/lib/endfield-map/resources/map-bfc58277d5ba649d.png +0 -0
- package/lib/endfield-map/resources/map-c013f82048c6891a.png +0 -0
- package/lib/endfield-map/resources/map-c49c947841ae4ab5.json.br +0 -0
- package/lib/endfield-map/resources/map-c49c947841ae4ab5.json.gz +0 -0
- package/lib/endfield-map/resources/map-c4d1b1c6d6706c81.json.br +0 -0
- package/lib/endfield-map/resources/map-c4d1b1c6d6706c81.json.gz +0 -0
- package/lib/endfield-map/resources/map-c607f8b4c8ad8967.png +0 -0
- package/lib/endfield-map/resources/map-c6f20276b8781326.json.br +0 -0
- package/lib/endfield-map/resources/map-c6f20276b8781326.json.gz +0 -0
- package/lib/endfield-map/resources/map-d51adf5952af1e8e.png +0 -0
- package/lib/endfield-map/resources/map-d695eface22f8b74.json.br +0 -0
- package/lib/endfield-map/resources/map-d695eface22f8b74.json.gz +0 -0
- package/lib/endfield-map/resources/map-db11d394456a7465.png +0 -0
- package/lib/endfield-map/resources/map-dcc4d218581e3dc6.png +0 -0
- package/lib/endfield-map/resources/map-ddd43dbb8ab8a4e3.json.br +0 -0
- package/lib/endfield-map/resources/map-ddd43dbb8ab8a4e3.json.gz +0 -0
- package/lib/endfield-map/resources/map-defe5db7dabf748a.png +0 -0
- package/lib/endfield-map/resources/map-e1aeb833e22520aa.json.br +0 -0
- package/lib/endfield-map/resources/map-e1aeb833e22520aa.json.gz +0 -0
- package/lib/endfield-map/resources/map-e25ee9af6e493500.png +0 -0
- package/lib/endfield-map/resources/map-e3824082cd6f747d.json.br +0 -0
- package/lib/endfield-map/resources/map-e3824082cd6f747d.json.gz +0 -0
- package/lib/endfield-map/resources/map-e694a91e630b1bd6.png +0 -0
- package/lib/endfield-map/resources/map-eaa93af79d12a87d.png +0 -0
- package/lib/endfield-map/resources/map-ec9575ee65d7766f.json.br +0 -0
- package/lib/endfield-map/resources/map-ec9575ee65d7766f.json.gz +0 -0
- package/lib/endfield-map/resources/map-ede421a329dc78a4.png +0 -0
- package/lib/endfield-map/resources/map-eff3bce8f6888fc5.json.br +0 -0
- package/lib/endfield-map/resources/map-eff3bce8f6888fc5.json.gz +0 -0
- package/lib/endfield-map/resources/map-fb5f4a59a181452c.png +0 -0
- package/lib/endfield-map/resources/map-fdeaa17eeba90d8e.json.br +0 -0
- package/lib/endfield-map/resources/map-fdeaa17eeba90d8e.json.gz +0 -0
- package/lib/rhine/assets/archive-assembly.glb +0 -0
- package/lib/rhine/assets/archive-cassette.glb +0 -0
- package/lib/rhine/fonts/MiSans-Bold.woff2 +0 -0
- package/lib/rhine/fonts/MiSans-Demibold.woff2 +0 -0
- package/lib/rhine/fonts/MiSans-Light.woff2 +0 -0
- package/lib/rhine/fonts/MiSans-Regular.woff2 +0 -0
- package/lib/rhine/fonts/MiSans-license.pdf +0 -0
- package/lib/rhine/fonts/NOTICE.txt +6 -0
- package/lib/rhine/licenses/RhineLabUI-MIT.txt +21 -0
- package/lib/rhine/licenses/entities-BSD-2-Clause.txt +11 -0
- package/lib/rhine/licenses/linkify-it-MIT.txt +22 -0
- package/lib/rhine/licenses/markdown-it-MIT.txt +22 -0
- package/lib/rhine/licenses/mdurl-MIT.txt +45 -0
- package/lib/rhine/licenses/punycode.js-MIT.txt +20 -0
- package/lib/rhine/licenses/rolling-number-MIT.txt +21 -0
- package/lib/rhine/licenses/three-MIT.txt +21 -0
- package/lib/rhine/licenses/uc.micro-MIT.txt +20 -0
- package/lib/rhine/rhine.css +1 -0
- package/lib/rhine/rhine.js +5106 -0
- package/lib/skins/common.css +31 -0
- package/lib/skins/endfield-aic.css +141 -0
- package/lib/skins/prts-agent.css +136 -0
- package/lib/skins/rhine-lab.css +70 -0
- package/package.json +112 -0
- package/presets/definition.js +26 -0
- package/presets/prts/agent.cordis.yml +25 -0
- package/presets/prts/preset.yml +3 -0
- package/presets/register.js +86 -0
- package/resources/entity-routing.json +21 -0
- package/skills/prts-investigation/SKILL.md +121 -0
- package/skills/prts-retrieval/SKILL.md +35 -0
- package/skills/prts-retrieval/references/cloud-tools.md +13 -0
- package/skills/prts-retrieval/references/diagnostics.md +15 -0
- package/skills/prts-retrieval/references/local-tools.md +24 -0
- package/skills/prts-retrieval/references/module-arknights.md +23 -0
- package/skills/prts-retrieval/references/module-dual.md +15 -0
- package/skills/prts-retrieval/references/module-endfield.md +21 -0
- package/skills/prts-retrieval/references/retrieval-process.md +44 -0
- package/skills/prts-retrieval/references/retrieval-recipes-dual.md +34 -0
- package/skills/prts-retrieval/references/retrieval-recipes-endfield.md +37 -0
- package/skills/prts-retrieval/references/retrieval-recipes.md +39 -0
- package/skills/prts-retrieval/references/tools-runtime.md +48 -0
- package/skills/prts-retrieval/references/wiki-schema.md +38 -0
- package/src/archive-presentation.js +287 -0
- package/src/cloud-projection.js +124 -0
- package/src/cloud.js +498 -0
- package/src/entity-recognizer.js +311 -0
- package/src/entity-routing.js +82 -0
- package/src/evidence-state.js +332 -0
- package/src/i18n.js +365 -0
- package/src/index.js +1261 -0
- package/src/installer.js +1362 -0
- package/src/investigation-review.js +87 -0
- package/src/investigation-store.js +579 -0
- package/src/investigation-tools.js +113 -0
- package/src/localization-format.js +23 -0
- package/src/manual-search.js +103 -0
- package/src/read.js +1097 -0
- package/src/release-compatibility.js +73 -0
- package/src/release-watch.js +40 -0
- package/src/search-projection.js +139 -0
- package/src/search.js +1522 -0
- package/src/skill.js +98 -0
- package/src/source-map.js +348 -0
- package/src/state.js +314 -0
- package/src/store.js +1569 -0
- package/src/timeline.js +386 -0
- package/src/ui.js +1353 -0
- package/src/wiki.js +111 -0
- package/tsconfig.rhine.json +15 -0
- package/ui/rhine/archive-navigation-limit.ts +31 -0
- package/ui/rhine/archive-rack.ts +83 -0
- package/ui/rhine/assets/evidence-observatory.webp +0 -0
- package/ui/rhine/board-plane-transform.ts +24 -0
- package/ui/rhine/catalogue.ts +72 -0
- package/ui/rhine/deferred-preparation.ts +146 -0
- package/ui/rhine/evidence-board-controls.css +22 -0
- package/ui/rhine/evidence-board-history.ts +73 -0
- package/ui/rhine/evidence-board-model.ts +189 -0
- package/ui/rhine/evidence-board-panel.ts +762 -0
- package/ui/rhine/evidence-board-scene.ts +548 -0
- package/ui/rhine/evidence-board.css +191 -0
- package/ui/rhine/evidence-card-art.ts +470 -0
- package/ui/rhine/evidence-card-style.ts +17 -0
- package/ui/rhine/evidence-inbox-scene.ts +43 -0
- package/ui/rhine/evidence-inbox.css +29 -0
- package/ui/rhine/evidence-inbox.ts +104 -0
- package/ui/rhine/index.ts +12 -0
- package/ui/rhine/interaction.css +20 -0
- package/ui/rhine/investigation-board-plane.ts +5 -0
- package/ui/rhine/investigation-board.css +34 -0
- package/ui/rhine/investigation-board.ts +348 -0
- package/ui/rhine/investigation-edits.ts +46 -0
- package/ui/rhine/investigation-layout.ts +29 -0
- package/ui/rhine/label-prefetch.ts +34 -0
- package/ui/rhine/looping-title.ts +40 -0
- package/ui/rhine/manual-search.css +41 -0
- package/ui/rhine/manual-search.ts +89 -0
- package/ui/rhine/original/appearance.ts +180 -0
- package/ui/rhine/original/archive-label.ts +84 -0
- package/ui/rhine/original/archive-lighting.ts +39 -0
- package/ui/rhine/original/archive-loop.ts +82 -0
- package/ui/rhine/original/archive-refill.ts +64 -0
- package/ui/rhine/original/array-detail-data.ts +2 -0
- package/ui/rhine/original/array-detail.ts +41 -0
- package/ui/rhine/original/array-interior.ts +411 -0
- package/ui/rhine/original/array-occlusion.ts +166 -0
- package/ui/rhine/original/array-shell.ts +38 -0
- package/ui/rhine/original/array-visibility.ts +309 -0
- package/ui/rhine/original/asset-url.ts +4 -0
- package/ui/rhine/original/brand.ts +12 -0
- package/ui/rhine/original/cassette-lod.ts +81 -0
- package/ui/rhine/original/data.ts +21 -0
- package/ui/rhine/original/decryption.ts +185 -0
- package/ui/rhine/original/detail-signature.ts +18 -0
- package/ui/rhine/original/glass-reveal.ts +45 -0
- package/ui/rhine/original/internal-optics.ts +70 -0
- package/ui/rhine/original/label-renderer.ts +119 -0
- package/ui/rhine/original/label-textures.ts +117 -0
- package/ui/rhine/original/model-viewer.ts +658 -0
- package/ui/rhine/original/motion.ts +116 -0
- package/ui/rhine/original/opaque-backfaces.ts +64 -0
- package/ui/rhine/original/program-preparation.ts +111 -0
- package/ui/rhine/original/quality-renderer.ts +97 -0
- package/ui/rhine/original/quality-settings.ts +111 -0
- package/ui/rhine/original/render-quality.ts +163 -0
- package/ui/rhine/original/scene.ts +2051 -0
- package/ui/rhine/original/shelf-interior.ts +142 -0
- package/ui/rhine/original/ui-transitions.ts +111 -0
- package/ui/rhine/original/viewer-camera.ts +92 -0
- package/ui/rhine/original/viewport-layout.ts +41 -0
- package/ui/rhine/original-ui.css +2334 -0
- package/ui/rhine/readability.css +46 -0
- package/ui/rhine/reading-object.ts +96 -0
- package/ui/rhine/reading-origin.ts +28 -0
- package/ui/rhine/reading-room.css +55 -0
- package/ui/rhine/report-markdown.ts +98 -0
- package/ui/rhine/scene-edge-navigation.ts +84 -0
- package/ui/rhine/scene.ts +1726 -0
- package/ui/rhine/shelf-layout.ts +38 -0
- package/ui/rhine/snapshot-update-gate.ts +22 -0
- package/ui/rhine/source-reading.ts +15 -0
- package/ui/rhine/source-selection.css +164 -0
- package/ui/rhine/temporary-performance-observers.ts +163 -0
- package/ui/rhine/temporary-performance-panel.ts +124 -0
- package/ui/rhine/temporary-performance.css +19 -0
- package/ui/rhine/temporary-performance.ts +552 -0
- package/ui/rhine/temporary-render-diagnostics.ts +105 -0
- package/ui/rhine/tool-activity.ts +69 -0
- package/ui/rhine/types.ts +219 -0
- package/ui/rhine/visible-scene-hit.ts +13 -0
- package/ui/rhine/workbench.css +818 -0
- package/ui/rhine/workbench.ts +2245 -0
- package/ui/rhine/workspace-travel.ts +32 -0
- package/vite.rhine.config.ts +19 -0
package/src/search.js
ADDED
|
@@ -0,0 +1,1522 @@
|
|
|
1
|
+
/** grep 风格 corpus_search:复用资料包索引,公开结果只使用自然标题与行号。 */
|
|
2
|
+
import { createHash, createHmac, timingSafeEqual } from 'node:crypto'
|
|
3
|
+
import { deflateRawSync, inflateRawSync } from 'node:zlib'
|
|
4
|
+
import { activityMatches, aliasesFor } from './timeline.js'
|
|
5
|
+
import { assertCorpusVersion, corpusVersionSnapshot,
|
|
6
|
+
DOCUMENT_ORDERING_VERSION, documentGame, documentUid, naturalDocumentTitle } from './store.js'
|
|
7
|
+
import { projectSearch } from './search-projection.js'
|
|
8
|
+
import { wikiActivityName, wikiActivityRanges, wikiCharacterName, wikiDocumentRole,
|
|
9
|
+
wikiSectionAt, wikiSectionRanges } from './wiki.js'
|
|
10
|
+
|
|
11
|
+
export const SEARCH_CONTRACT_VERSION = 'prts-corpus-tools-v1'
|
|
12
|
+
|
|
13
|
+
const SEARCH_ERROR_CODES = new Set(['INVALID_REQUEST', 'PACKAGE_NOT_INSTALLED',
|
|
14
|
+
'PACKAGE_VERSION_MISMATCH', 'INDEX_UNAVAILABLE', 'INDEX_CORRUPT', 'DOCUMENT_NOT_FOUND',
|
|
15
|
+
'SOURCE_REF_INVALID', 'LINE_RANGE_INVALID', 'CURSOR_INVALID', 'CURSOR_VERSION_MISMATCH',
|
|
16
|
+
'CURSOR_POLICY_MISMATCH', 'PAGE_ANCHOR_INVALID', 'PAGE_ANCHOR_MISMATCH',
|
|
17
|
+
'PAGE_ANCHOR_NOT_FOUND', 'PAGE_ANCHOR_VERSION_MISMATCH', 'REGEX_REJECTED', 'TIMEOUT',
|
|
18
|
+
'BUDGET_EXCEEDED', 'CANCELLED'])
|
|
19
|
+
|
|
20
|
+
function publicSearchError(error) {
|
|
21
|
+
const candidate = String(error?.code || '')
|
|
22
|
+
const code = SEARCH_ERROR_CODES.has(candidate) ? candidate : 'INTERNAL_ERROR'
|
|
23
|
+
return {
|
|
24
|
+
code,
|
|
25
|
+
message: code === 'INTERNAL_ERROR'
|
|
26
|
+
? '本地语料搜索失败;详情请查看 DSH Host 日志' : (error?.message || String(error)),
|
|
27
|
+
retryable: code === 'INTERNAL_ERROR' ? false : (error?.retryable ?? false),
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const PAGE_DOCUMENTS = 12
|
|
32
|
+
const MAX_PASSAGES_PER_DOCUMENT = 3
|
|
33
|
+
const PASSAGE_CLUSTER_GAP = 2
|
|
34
|
+
const RANK_POOL_CAP = 500
|
|
35
|
+
const SHORT_LITERAL_RANK_POOL_CAP = 128
|
|
36
|
+
const SIMPLE_LITERAL_MATCH_CAP_PER_DOCUMENT = 24
|
|
37
|
+
const SCAN_DOCUMENTS_PER_PAGE = 256
|
|
38
|
+
// 冷缓存下短字面量需要并发解压本地 JSONL 分片;8 秒会把正常检索误判为
|
|
39
|
+
// 超时。保留扫描上限和结果池上限,并把协作式时间预算提高到 15 秒。
|
|
40
|
+
const SEARCH_TIMEOUT_MS = 15000
|
|
41
|
+
const PREVIEW_OPTIONS = Object.freeze({ before_lines: 1, after_lines: 1,
|
|
42
|
+
max_chars_per_line: 2000, max_total_chars: 12000 })
|
|
43
|
+
const MATCHING_POLICY_VERSION = 1
|
|
44
|
+
const SEARCH_POLICY_FINGERPRINT = createHash('sha256').update(JSON.stringify({
|
|
45
|
+
pageDocuments: PAGE_DOCUMENTS,
|
|
46
|
+
scanDocumentsPerPage: SCAN_DOCUMENTS_PER_PAGE,
|
|
47
|
+
passagesPerDocument: MAX_PASSAGES_PER_DOCUMENT,
|
|
48
|
+
passageClusterGap: PASSAGE_CLUSTER_GAP,
|
|
49
|
+
simpleLiteralMatchCapPerDocument: SIMPLE_LITERAL_MATCH_CAP_PER_DOCUMENT,
|
|
50
|
+
preview: PREVIEW_OPTIONS,
|
|
51
|
+
matchingPolicyVersion: MATCHING_POLICY_VERSION,
|
|
52
|
+
})).digest('base64url').slice(0, 16)
|
|
53
|
+
const FILTER_LIMIT = 16
|
|
54
|
+
const DATA_VERSION_PATTERN = /^[0-9a-f]{64}$/
|
|
55
|
+
/** 过滤单项最长 512 码点:与上述项数上限共同保证压缩游标可被重新解码。 */
|
|
56
|
+
const FILTER_ITEM_LIMIT = 512
|
|
57
|
+
/**
|
|
58
|
+
* 游标的物理上限:decodeCursor 的长度检查与解压输出上限必须容纳
|
|
59
|
+
* FILTER_LIMIT × FILTER_ITEM_LIMIT 的最坏合法请求(CJK 直存约 3 字节/字符,
|
|
60
|
+
* 再计 JSON 键与转义余量),否则自己签发的游标会翻页失败。
|
|
61
|
+
*/
|
|
62
|
+
const CURSOR_MAX_LENGTH = 65_536
|
|
63
|
+
const CURSOR_MAX_INFLATED = 262_144
|
|
64
|
+
const ENTITY_QUERY_MAX_DISTANCE = 256
|
|
65
|
+
const PROFILE_CATEGORIES = new Set(['干员档案', '招聘合同', '潜能与信物'])
|
|
66
|
+
const FILTER_FIELDS = {
|
|
67
|
+
story_names: 'story_name',
|
|
68
|
+
}
|
|
69
|
+
const CURSOR_FILTER_KEYS = Object.freeze({
|
|
70
|
+
resource_types: 'r', character_names: 'c', story_names: 's', activity_names: 'a',
|
|
71
|
+
entity_names: 'e', speakers: 'p', wiki_sections: 'w', games: 'g',
|
|
72
|
+
content_types: 't', collection_names: 'n',
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
function assertSearchActive(signal, deadline, phase = '本地语料搜索') {
|
|
76
|
+
if (signal?.aborted) throw Object.assign(new Error('搜索已取消'), { code: 'CANCELLED' })
|
|
77
|
+
if (Number.isFinite(deadline) && Date.now() >= deadline) {
|
|
78
|
+
throw Object.assign(new Error(`${phase}超时`), { code: 'TIMEOUT', retryable: true })
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function normalizeText(value) {
|
|
83
|
+
return String(value ?? '').normalize('NFKC').replace(/\s+/gu, ' ').trim()
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function ngramsFor(value) {
|
|
87
|
+
const chars = [...normalizeText(value).toLowerCase()]
|
|
88
|
+
if (!chars.length) return []
|
|
89
|
+
const size = Math.min(3, chars.length)
|
|
90
|
+
return [...new Set(Array.from({ length: chars.length - size + 1 }, (_, index) =>
|
|
91
|
+
chars.slice(index, index + size).join('')))]
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function resourceMatches(document, requested) {
|
|
95
|
+
if (!requested.length) return true
|
|
96
|
+
const explicit = String(document.resource_type || '')
|
|
97
|
+
const type = String(document.document_type || '')
|
|
98
|
+
const kind = String(document.document_kind || '')
|
|
99
|
+
const category = String(document.document_category || '')
|
|
100
|
+
const wikiRole = wikiDocumentRole(document)
|
|
101
|
+
return requested.some((resource) => {
|
|
102
|
+
// v2 documents carry a narrow resource_type, but callers may deliberately
|
|
103
|
+
// request a semantic union. Exact-match short-circuiting here used to make
|
|
104
|
+
// character_bundle/reviewed_wiki/wiki silently exclude every Endfield v2
|
|
105
|
+
// document.
|
|
106
|
+
if (resource === explicit) return true
|
|
107
|
+
if (resource === 'story' && explicit === 'original_story') return true
|
|
108
|
+
if (resource === 'character_bundle'
|
|
109
|
+
&& ['character_profile', 'character_module', 'character_voice',
|
|
110
|
+
'operator_record'].includes(explicit)) return true
|
|
111
|
+
if (resource === 'reviewed_wiki'
|
|
112
|
+
&& ['character_wiki', 'story_wiki', 'character_activity_wiki',
|
|
113
|
+
'knowledge'].includes(explicit)) return true
|
|
114
|
+
if (resource === 'wiki'
|
|
115
|
+
&& ['character_wiki', 'story_wiki', 'character_activity_wiki',
|
|
116
|
+
'knowledge'].includes(explicit)) return true
|
|
117
|
+
// v2 common vocabulary over legacy Arknights v1 metadata.
|
|
118
|
+
if (resource === 'original_story') return type === 'story' && category !== 'memory' && kind !== 'synopsis'
|
|
119
|
+
if (resource === 'character_story') return type === 'story' && category === 'memory'
|
|
120
|
+
if (resource === 'archive') return type === 'knowledge' && kind === 'official_archive'
|
|
121
|
+
if (resource === 'knowledge') return type === 'knowledge' && kind === 'wiki'
|
|
122
|
+
if (resource === 'wiki') return type === 'knowledge' && kind === 'wiki'
|
|
123
|
+
if (resource === 'timeline') return type === 'reference'
|
|
124
|
+
if (resource === 'story') return type === 'story' && category !== 'memory' && kind !== 'synopsis'
|
|
125
|
+
if (resource === 'operator_record') return type === 'story' && category === 'memory'
|
|
126
|
+
if (resource === 'character_profile') return type === 'character' && PROFILE_CATEGORIES.has(category)
|
|
127
|
+
if (resource === 'character_module') return type === 'character' && category === '模组文案'
|
|
128
|
+
if (resource === 'character_voice') return type === 'character' && category === '干员语音'
|
|
129
|
+
if (resource === 'character_skin') return type === 'character' && category === '时装文案'
|
|
130
|
+
if (resource === 'character_bundle') return (type === 'character'
|
|
131
|
+
&& (PROFILE_CATEGORIES.has(category) || category === '模组文案' || category === '干员语音'))
|
|
132
|
+
|| (type === 'story' && category === 'memory')
|
|
133
|
+
if (resource === 'character_wiki') return type === 'knowledge' && kind === 'wiki'
|
|
134
|
+
&& wikiRole === 'character'
|
|
135
|
+
if (resource === 'story_wiki') return type === 'knowledge' && kind === 'wiki'
|
|
136
|
+
&& wikiDocumentRole(document) === 'story'
|
|
137
|
+
if (resource === 'character_activity_wiki') return type === 'knowledge' && kind === 'wiki'
|
|
138
|
+
&& wikiDocumentRole(document) === 'character_activity'
|
|
139
|
+
if (resource === 'reviewed_wiki') return type === 'knowledge' && kind === 'wiki'
|
|
140
|
+
if (resource === 'terra_journey') return type === 'knowledge' && kind === 'terra_journey'
|
|
141
|
+
if (resource === 'entity_profile') return type === 'entity'
|
|
142
|
+
return resource === 'reference' && type === 'reference'
|
|
143
|
+
})
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* 线性正则子集。
|
|
148
|
+
*
|
|
149
|
+
* JavaScript RegExp 没有可抢占的单次匹配超时;仅靠识别少数“嵌套量词”无法
|
|
150
|
+
* 阻止 (a|aa)+$ 一类 ReDoS。因此这里只接受没有分支和可变量词的子集:
|
|
151
|
+
* 字面量、锚点、点号、字符类、转义字符,以及上限很小的固定次数 {n}。
|
|
152
|
+
* 该语法没有输入相关的回溯分支,最坏执行时间随文本和 pattern 线性增长。
|
|
153
|
+
*/
|
|
154
|
+
function safeRegex(pattern) {
|
|
155
|
+
const rejected = (reason) => Object.assign(new Error(
|
|
156
|
+
`正则表达式超出安全线性子集(${reason});仅允许字面量、^/$、点号、字符类、转义和固定次数 {n}`,
|
|
157
|
+
), { code: 'REGEX_REJECTED' })
|
|
158
|
+
if (pattern.length > 256) throw rejected('长度超过 256')
|
|
159
|
+
let inClass = false
|
|
160
|
+
let canRepeat = false
|
|
161
|
+
for (let index = 0; index < pattern.length; index += 1) {
|
|
162
|
+
const character = pattern[index]
|
|
163
|
+
if (character === '\\') {
|
|
164
|
+
const escaped = pattern[index + 1]
|
|
165
|
+
if (escaped === undefined) break // 交给 RegExp 编译器报告不完整转义
|
|
166
|
+
if (/[1-9]/.test(escaped) || escaped === 'k') throw rejected('不允许反向引用')
|
|
167
|
+
if ((escaped === 'p' || escaped === 'P') && pattern[index + 2] === '{') {
|
|
168
|
+
throw rejected('不允许 Unicode 属性转义')
|
|
169
|
+
}
|
|
170
|
+
if (escaped === 'u' && pattern[index + 2] === '{') {
|
|
171
|
+
throw rejected('不允许带花括号的 Unicode 转义')
|
|
172
|
+
}
|
|
173
|
+
index += 1
|
|
174
|
+
if (!inClass) canRepeat = escaped !== 'b' && escaped !== 'B'
|
|
175
|
+
continue
|
|
176
|
+
}
|
|
177
|
+
if (inClass) {
|
|
178
|
+
if (character === ']') {
|
|
179
|
+
inClass = false
|
|
180
|
+
canRepeat = true
|
|
181
|
+
}
|
|
182
|
+
continue
|
|
183
|
+
}
|
|
184
|
+
if (character === '[') {
|
|
185
|
+
inClass = true
|
|
186
|
+
canRepeat = false
|
|
187
|
+
continue
|
|
188
|
+
}
|
|
189
|
+
if ('()|*+?'.includes(character)) {
|
|
190
|
+
throw rejected(`不允许 ${character}`)
|
|
191
|
+
}
|
|
192
|
+
if (character === '{') {
|
|
193
|
+
if (!canRepeat) throw rejected('固定次数前缺少可重复字符')
|
|
194
|
+
const fixed = /^\{([0-9]{1,2})\}/.exec(pattern.slice(index))
|
|
195
|
+
if (!fixed) throw rejected('只允许 {n},不允许范围或开放式量词')
|
|
196
|
+
const count = Number(fixed[1])
|
|
197
|
+
if (count > 64) throw rejected('固定次数不能超过 64')
|
|
198
|
+
index += fixed[0].length - 1
|
|
199
|
+
canRepeat = false
|
|
200
|
+
continue
|
|
201
|
+
}
|
|
202
|
+
if (character === '}') throw rejected('未转义的 }')
|
|
203
|
+
canRepeat = character !== '^' && character !== '$'
|
|
204
|
+
}
|
|
205
|
+
try {
|
|
206
|
+
return new RegExp(pattern, 'u')
|
|
207
|
+
} catch (error) {
|
|
208
|
+
throw Object.assign(new Error(`正则表达式无法编译: ${error.message}`), { code: 'REGEX_REJECTED' })
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function normalizedRequest(raw = {}) {
|
|
213
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
214
|
+
throw Object.assign(new Error('搜索参数必须是对象'), { code: 'INVALID_REQUEST' })
|
|
215
|
+
}
|
|
216
|
+
if (raw.cursor != null) {
|
|
217
|
+
if (typeof raw.cursor !== 'string' || raw.cursor.length < 1
|
|
218
|
+
|| raw.cursor.length > CURSOR_MAX_LENGTH) {
|
|
219
|
+
throw Object.assign(new Error(`cursor 必须是 1..${CURSOR_MAX_LENGTH} 字符的字符串`),
|
|
220
|
+
{ code: 'INVALID_REQUEST' })
|
|
221
|
+
}
|
|
222
|
+
return { cursor: raw.cursor }
|
|
223
|
+
}
|
|
224
|
+
let after = null
|
|
225
|
+
if (raw.after != null) {
|
|
226
|
+
if (!raw.after || typeof raw.after !== 'object' || Array.isArray(raw.after)) {
|
|
227
|
+
throw Object.assign(new Error('after 必须包含 data_version、resource_type、title 和 position'),
|
|
228
|
+
{ code: 'INVALID_REQUEST' })
|
|
229
|
+
}
|
|
230
|
+
const dataVersion = raw.after.data_version
|
|
231
|
+
const resourceType = normalizeText(raw.after.resource_type)
|
|
232
|
+
const title = normalizeText(raw.after.title)
|
|
233
|
+
const position = raw.after.position
|
|
234
|
+
if (typeof dataVersion !== 'string' || !DATA_VERSION_PATTERN.test(dataVersion)
|
|
235
|
+
|| !resourceType || !title || !Number.isSafeInteger(position) || position < 0
|
|
236
|
+
|| position > 10_000_000
|
|
237
|
+
|| Object.keys(raw.after).some((key) => ![
|
|
238
|
+
'data_version', 'resource_type', 'title', 'position',
|
|
239
|
+
].includes(key))) {
|
|
240
|
+
throw Object.assign(new Error(
|
|
241
|
+
'after 必须包含且只能包含 64 位小写 data_version、resource_type、title 和 0..10000000 的安全整数 position'),
|
|
242
|
+
{ code: 'INVALID_REQUEST' })
|
|
243
|
+
}
|
|
244
|
+
after = { data_version: dataVersion, resource_type: resourceType, title, position }
|
|
245
|
+
}
|
|
246
|
+
const query = normalizeText(raw.query)
|
|
247
|
+
if ([...query].length > 512) throw Object.assign(new Error('literal query 最多 512 个字符'),
|
|
248
|
+
{ code: 'INVALID_REQUEST' })
|
|
249
|
+
const filters = {}
|
|
250
|
+
for (const field of ['resource_types', 'character_names', 'story_names', 'activity_names',
|
|
251
|
+
'entity_names', 'speakers', 'wiki_sections', 'games', 'content_types', 'collection_names']) {
|
|
252
|
+
if (raw[field] !== undefined && (!Array.isArray(raw[field]) || !raw[field].length)) {
|
|
253
|
+
throw Object.assign(new Error(`${field} 必须是非空数组`), { code: 'INVALID_REQUEST' })
|
|
254
|
+
}
|
|
255
|
+
filters[field] = [...new Set((raw[field] || []).map(normalizeText))]
|
|
256
|
+
if (filters[field].some((value) => !value)) {
|
|
257
|
+
throw Object.assign(new Error(`${field} 不能包含空字符串`), { code: 'INVALID_REQUEST' })
|
|
258
|
+
}
|
|
259
|
+
if (filters[field].length > FILTER_LIMIT) {
|
|
260
|
+
throw Object.assign(new Error(`${field} 最多 ${FILTER_LIMIT} 项`), { code: 'INVALID_REQUEST' })
|
|
261
|
+
}
|
|
262
|
+
// 单项长度也设上限:合法请求的压缩游标必须能被 decodeCursor 的
|
|
263
|
+
// 长度/解压上限重新解码,否则首次搜索成功、翻页却 CURSOR_INVALID。
|
|
264
|
+
if (filters[field].some((value) => [...value].length > FILTER_ITEM_LIMIT)) {
|
|
265
|
+
throw Object.assign(new Error(`${field} 单项最长 ${FILTER_ITEM_LIMIT} 个字符`), { code: 'INVALID_REQUEST' })
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
if (filters.games.some((value) => !['arknights', 'endfield'].includes(value.toLocaleLowerCase()))) {
|
|
269
|
+
throw Object.assign(new Error('games 仅支持 arknights / endfield'), { code: 'INVALID_REQUEST' })
|
|
270
|
+
}
|
|
271
|
+
filters.games = filters.games.map((value) => value.toLocaleLowerCase())
|
|
272
|
+
if (!query && !Object.values(filters).some((items) => items.length)) {
|
|
273
|
+
throw Object.assign(new Error('请提供 query 或至少一个资料/人物/篇章/说话人过滤条件'), { code: 'INVALID_REQUEST' })
|
|
274
|
+
}
|
|
275
|
+
const matchMode = raw.match_mode ?? 'literal'
|
|
276
|
+
if (!['literal', 'regex'].includes(matchMode)) {
|
|
277
|
+
throw Object.assign(new Error('match_mode 仅支持 literal / regex'), { code: 'INVALID_REQUEST' })
|
|
278
|
+
}
|
|
279
|
+
if (matchMode === 'regex') {
|
|
280
|
+
if (!query) throw Object.assign(new Error('regex 模式必须提供 query'), { code: 'INVALID_REQUEST' })
|
|
281
|
+
safeRegex(query)
|
|
282
|
+
}
|
|
283
|
+
const contextTerms = raw.context_terms === undefined ? [] : raw.context_terms
|
|
284
|
+
if (!Array.isArray(contextTerms) || contextTerms.length > 8 || contextTerms.some((item) => !normalizeText(item))) {
|
|
285
|
+
throw Object.assign(new Error('context_terms 必须是最多 8 项的非空字符串数组'), { code: 'INVALID_REQUEST' })
|
|
286
|
+
}
|
|
287
|
+
if (contextTerms.some((item) => [...normalizeText(item)].length > FILTER_ITEM_LIMIT)) {
|
|
288
|
+
throw Object.assign(new Error(`context_terms 单项最长 ${FILTER_ITEM_LIMIT} 个字符`), { code: 'INVALID_REQUEST' })
|
|
289
|
+
}
|
|
290
|
+
if (contextTerms.length && !query && !filters.speakers.length && !filters.entity_names.length) {
|
|
291
|
+
throw Object.assign(new Error('context_terms 需要 query、speakers 或 entity_names 作为主条件'),
|
|
292
|
+
{ code: 'INVALID_REQUEST' })
|
|
293
|
+
}
|
|
294
|
+
if (!query && filters.wiki_sections.length > 1) {
|
|
295
|
+
throw Object.assign(new Error('无 query 的完整字段查询一次只能选择一个 wiki_sections 值'),
|
|
296
|
+
{ code: 'INVALID_REQUEST' })
|
|
297
|
+
}
|
|
298
|
+
return {
|
|
299
|
+
query, filters, match_mode: matchMode,
|
|
300
|
+
context_terms: [...new Set(contextTerms.map(normalizeText))],
|
|
301
|
+
after,
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function withoutAfter(request) {
|
|
306
|
+
const { after: _after, ...searchRequest } = request
|
|
307
|
+
return searchRequest
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
async function checkpointAfterTitle(store, after, request, { signal, deadline } = {}) {
|
|
311
|
+
assertSearchActive(signal, deadline, '分页锚点校验')
|
|
312
|
+
if (after.data_version !== store.dataVersion) {
|
|
313
|
+
throw Object.assign(new Error('分页锚点绑定到另一个资料版本,请重新搜索'),
|
|
314
|
+
{ code: 'PAGE_ANCHOR_VERSION_MISMATCH', retryable: false })
|
|
315
|
+
}
|
|
316
|
+
const regex = request.match_mode === 'regex'
|
|
317
|
+
? new RegExp(safeRegex(request.query).source, 'iu') : null
|
|
318
|
+
const candidates = await candidateDocumentIds(store, request, regex, { signal, deadline })
|
|
319
|
+
assertSearchActive(signal, deadline, '分页锚点校验')
|
|
320
|
+
const documentId = candidates[after.position]
|
|
321
|
+
const location = documentId ? store.documents.get(documentId) : null
|
|
322
|
+
if (!location) throw Object.assign(new Error('找不到分页锚点,请重新搜索'),
|
|
323
|
+
{ code: 'PAGE_ANCHOR_NOT_FOUND' })
|
|
324
|
+
if (publicResourceType(location.document) !== after.resource_type
|
|
325
|
+
|| normalizeText(naturalDocumentTitle(location.document)) !== normalizeText(after.title)) {
|
|
326
|
+
throw Object.assign(new Error('分页锚点与当前资料版本不匹配,请重新搜索'),
|
|
327
|
+
{ code: 'PAGE_ANCHOR_MISMATCH' })
|
|
328
|
+
}
|
|
329
|
+
return { nextCandidateIndex: after.position + 1, matchedDocumentsSoFar: 0,
|
|
330
|
+
matchedCountKnown: false }
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
async function exposeTitleContinuation(store, result, { signal, deadline } = {}) {
|
|
334
|
+
if (result?.error || !result?.page) return result
|
|
335
|
+
assertSearchActive(signal, deadline, '分页锚点生成')
|
|
336
|
+
const { next_cursor: internalCursor, ...page } = result.page
|
|
337
|
+
let nextAfter = null
|
|
338
|
+
if (internalCursor) {
|
|
339
|
+
const decoded = await decodeCursor(store, internalCursor)
|
|
340
|
+
assertSearchActive(signal, deadline, '分页锚点生成')
|
|
341
|
+
// Legacy offsets index a relevance-ranked pool, not the ordered candidate
|
|
342
|
+
// list. Preserve that continuation rather than inventing a scan anchor.
|
|
343
|
+
if (decoded.kind === 'legacy') {
|
|
344
|
+
return { ...result, page: { ...page, next_after: null, next_cursor: internalCursor } }
|
|
345
|
+
}
|
|
346
|
+
const regex = decoded.request.match_mode === 'regex'
|
|
347
|
+
? new RegExp(safeRegex(decoded.request.query).source, 'iu') : null
|
|
348
|
+
const candidates = await candidateDocumentIds(store, decoded.request, regex, { signal, deadline })
|
|
349
|
+
assertSearchActive(signal, deadline, '分页锚点生成')
|
|
350
|
+
const anchorOrdinal = decoded.nextCandidateIndex - 1
|
|
351
|
+
const documentId = candidates[anchorOrdinal]
|
|
352
|
+
const document = documentId ? store.documents.get(documentId)?.document : null
|
|
353
|
+
if (!document) throw Object.assign(new Error('无法生成下一页的标题锚点'),
|
|
354
|
+
{ code: 'PAGE_ANCHOR_INVALID' })
|
|
355
|
+
nextAfter = { data_version: store.dataVersion, resource_type: publicResourceType(document),
|
|
356
|
+
title: naturalDocumentTitle(document), position: anchorOrdinal }
|
|
357
|
+
}
|
|
358
|
+
return { ...result, page: { ...page, next_after: nextAfter } }
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function cursorVersionTag(dataVersion) {
|
|
362
|
+
return createHash('sha256').update(String(dataVersion)).digest().subarray(0, 12).toString('base64url')
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function compactCursorRequest(request) {
|
|
366
|
+
const filters = {}
|
|
367
|
+
for (const [field, key] of Object.entries(CURSOR_FILTER_KEYS)) {
|
|
368
|
+
if (request.filters[field]?.length) filters[key] = request.filters[field]
|
|
369
|
+
}
|
|
370
|
+
return [request.query || '', Object.keys(filters).length ? filters : 0,
|
|
371
|
+
request.match_mode === 'regex' ? 'r' : 0,
|
|
372
|
+
request.context_terms?.length ? request.context_terms : 0]
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function expandCursorRequest(compact) {
|
|
376
|
+
if (!Array.isArray(compact) || compact.length !== 4) return null
|
|
377
|
+
const [query, encodedFilters, mode, contextTerms] = compact
|
|
378
|
+
if (typeof query !== 'string' || (encodedFilters !== 0
|
|
379
|
+
&& (!encodedFilters || typeof encodedFilters !== 'object' || Array.isArray(encodedFilters)))
|
|
380
|
+
|| ![0, 'r'].includes(mode) || (contextTerms !== 0 && !Array.isArray(contextTerms))) return null
|
|
381
|
+
const filters = Object.fromEntries(Object.keys(CURSOR_FILTER_KEYS).map((field) => [field, []]))
|
|
382
|
+
if (encodedFilters !== 0) {
|
|
383
|
+
const reverse = Object.fromEntries(Object.entries(CURSOR_FILTER_KEYS).map(([field, key]) => [key, field]))
|
|
384
|
+
for (const [key, values] of Object.entries(encodedFilters)) {
|
|
385
|
+
const field = reverse[key]
|
|
386
|
+
if (!field || !Array.isArray(values)) return null
|
|
387
|
+
filters[field] = values
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
return { query, filters, match_mode: mode === 'r' ? 'regex' : 'literal',
|
|
391
|
+
context_terms: contextTerms === 0 ? [] : contextTerms }
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function validCursorSignature(body, received, secret, bytes = 32) {
|
|
395
|
+
const expected = createHmac('sha256', secret).update(body).digest().subarray(0, bytes)
|
|
396
|
+
const encoded = String(received || '')
|
|
397
|
+
let actual
|
|
398
|
+
try { actual = Buffer.from(encoded, 'base64url') } catch { actual = Buffer.alloc(0) }
|
|
399
|
+
return encoded === actual.toString('base64url')
|
|
400
|
+
&& actual.length === expected.length && timingSafeEqual(actual, expected)
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
async function encodeOffsetCursor(store, request, offset) {
|
|
404
|
+
const payload = [3, cursorVersionTag(store.dataVersion), offset, compactCursorRequest(request)]
|
|
405
|
+
const compressed = deflateRawSync(Buffer.from(JSON.stringify(payload)), { level: 9 })
|
|
406
|
+
const encoded = compressed.toString('base64url')
|
|
407
|
+
const body = `s3.${encoded}`
|
|
408
|
+
const secret = await store.getOrCreateCursorSecret()
|
|
409
|
+
const signature = createHmac('sha256', secret).update(body).digest().subarray(0, 16).toString('base64url')
|
|
410
|
+
return `${body}.${signature}`
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
async function encodeScanCursor(store, request, nextCandidateIndex, matchedDocumentsSoFar) {
|
|
414
|
+
const payload = [4, cursorVersionTag(store.dataVersion), DOCUMENT_ORDERING_VERSION,
|
|
415
|
+
SEARCH_POLICY_FINGERPRINT, nextCandidateIndex, matchedDocumentsSoFar,
|
|
416
|
+
compactCursorRequest(request)]
|
|
417
|
+
const compressed = deflateRawSync(Buffer.from(JSON.stringify(payload)), { level: 9 })
|
|
418
|
+
const encoded = compressed.toString('base64url')
|
|
419
|
+
const body = `s4.${encoded}`
|
|
420
|
+
const secret = await store.getOrCreateCursorSecret()
|
|
421
|
+
const signature = createHmac('sha256', secret).update(body).digest()
|
|
422
|
+
.subarray(0, 16).toString('base64url')
|
|
423
|
+
return `${body}.${signature}`
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
async function decodeCursor(store, cursor) {
|
|
427
|
+
if (String(cursor).length > CURSOR_MAX_LENGTH) {
|
|
428
|
+
throw Object.assign(new Error('cursor 超过长度上限'), { code: 'CURSOR_INVALID' })
|
|
429
|
+
}
|
|
430
|
+
const secret = await store.getOrCreateCursorSecret()
|
|
431
|
+
const parts = String(cursor).split('.')
|
|
432
|
+
if (parts.length === 3 && parts[0] === 's4') {
|
|
433
|
+
const body = `${parts[0]}.${parts[1]}`
|
|
434
|
+
if (!validCursorSignature(body, parts[2], secret, 16)) {
|
|
435
|
+
throw Object.assign(new Error('cursor 签名无效'), { code: 'CURSOR_INVALID' })
|
|
436
|
+
}
|
|
437
|
+
let value
|
|
438
|
+
try {
|
|
439
|
+
value = JSON.parse(inflateRawSync(Buffer.from(parts[1], 'base64url'),
|
|
440
|
+
{ maxOutputLength: CURSOR_MAX_INFLATED }).toString('utf8'))
|
|
441
|
+
} catch {
|
|
442
|
+
throw Object.assign(new Error('cursor 无法解析'), { code: 'CURSOR_INVALID' })
|
|
443
|
+
}
|
|
444
|
+
const request = Array.isArray(value) ? expandCursorRequest(value[6]) : null
|
|
445
|
+
if (value?.[0] !== 4 || !request || !Number.isInteger(value[4]) || value[4] < 0
|
|
446
|
+
|| !Number.isInteger(value[5]) || value[5] < 0) {
|
|
447
|
+
throw Object.assign(new Error('cursor 内容无效'), { code: 'CURSOR_INVALID' })
|
|
448
|
+
}
|
|
449
|
+
if (value[1] !== cursorVersionTag(store.dataVersion)) {
|
|
450
|
+
throw Object.assign(new Error('cursor 绑定到另一个资料版本'),
|
|
451
|
+
{ code: 'CURSOR_VERSION_MISMATCH' })
|
|
452
|
+
}
|
|
453
|
+
if (value[2] !== DOCUMENT_ORDERING_VERSION || value[3] !== SEARCH_POLICY_FINGERPRINT) {
|
|
454
|
+
throw Object.assign(new Error('cursor 绑定的排序或搜索策略已经变化,请重新搜索'),
|
|
455
|
+
{ code: 'CURSOR_POLICY_MISMATCH' })
|
|
456
|
+
}
|
|
457
|
+
return { kind: 'scan', request, nextCandidateIndex: value[4],
|
|
458
|
+
matchedDocumentsSoFar: value[5] }
|
|
459
|
+
}
|
|
460
|
+
if (parts.length === 3 && parts[0] === 's3') {
|
|
461
|
+
const body = `${parts[0]}.${parts[1]}`
|
|
462
|
+
if (!validCursorSignature(body, parts[2], secret, 16)) {
|
|
463
|
+
throw Object.assign(new Error('cursor 签名无效'), { code: 'CURSOR_INVALID' })
|
|
464
|
+
}
|
|
465
|
+
let value
|
|
466
|
+
try {
|
|
467
|
+
value = JSON.parse(inflateRawSync(Buffer.from(parts[1], 'base64url'),
|
|
468
|
+
{ maxOutputLength: CURSOR_MAX_INFLATED }).toString('utf8'))
|
|
469
|
+
} catch {
|
|
470
|
+
throw Object.assign(new Error('cursor 无法解析'), { code: 'CURSOR_INVALID' })
|
|
471
|
+
}
|
|
472
|
+
const request = Array.isArray(value) ? expandCursorRequest(value[3]) : null
|
|
473
|
+
if (value?.[0] !== 3 || value[1] !== cursorVersionTag(store.dataVersion)
|
|
474
|
+
|| !Number.isInteger(value[2]) || value[2] < 0 || !request) {
|
|
475
|
+
throw Object.assign(new Error(value?.[1] !== cursorVersionTag(store.dataVersion)
|
|
476
|
+
? 'cursor 绑定到另一个资料版本' : 'cursor 内容无效'),
|
|
477
|
+
{ code: value?.[1] !== cursorVersionTag(store.dataVersion)
|
|
478
|
+
? 'CURSOR_VERSION_MISMATCH' : 'CURSOR_INVALID' })
|
|
479
|
+
}
|
|
480
|
+
return { kind: 'legacy', request, offset: value[2] }
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// v2 长游标兼容:已发给模型或持久化在旧会话中的 cursor 仍可继续分页。
|
|
484
|
+
const [body, received] = parts
|
|
485
|
+
if (parts.length !== 2 || !validCursorSignature(body || '', received, secret)) {
|
|
486
|
+
throw Object.assign(new Error('cursor 签名无效'), { code: 'CURSOR_INVALID' })
|
|
487
|
+
}
|
|
488
|
+
let value
|
|
489
|
+
try { value = JSON.parse(Buffer.from(body, 'base64url').toString('utf8')) } catch {
|
|
490
|
+
throw Object.assign(new Error('cursor 无法解析'), { code: 'CURSOR_INVALID' })
|
|
491
|
+
}
|
|
492
|
+
if (value.data_version !== store.dataVersion) {
|
|
493
|
+
throw Object.assign(new Error('cursor 绑定到另一个资料版本'), { code: 'CURSOR_VERSION_MISMATCH' })
|
|
494
|
+
}
|
|
495
|
+
if (value.v !== 2 || value.tool !== 'corpus_search' || !value.request
|
|
496
|
+
|| !Number.isInteger(value.offset) || value.offset < 0) {
|
|
497
|
+
throw Object.assign(new Error('cursor 内容无效'), { code: 'CURSOR_INVALID' })
|
|
498
|
+
}
|
|
499
|
+
return { kind: 'legacy', request: value.request, offset: value.offset }
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function documentMatches(document, speakers, filters) {
|
|
503
|
+
if (filters.games?.length && !filters.games.includes(documentGame(document))) return false
|
|
504
|
+
if (!resourceMatches(document, filters.resource_types)) return false
|
|
505
|
+
if (filters.content_types?.length
|
|
506
|
+
&& !filters.content_types.includes(publicContentType(document))) return false
|
|
507
|
+
if (filters.collection_names?.length
|
|
508
|
+
&& !filters.collection_names.includes(publicCollectionName(document))) return false
|
|
509
|
+
for (const [filter, field] of Object.entries(FILTER_FIELDS)) {
|
|
510
|
+
if (filters[filter].length && !filters[filter].includes(normalizeText(document[field]))) return false
|
|
511
|
+
}
|
|
512
|
+
const wikiRole = wikiDocumentRole(document)
|
|
513
|
+
if (filters.character_names.length
|
|
514
|
+
&& (wikiRole !== 'character_activity' || normalizeText(document.character_name))
|
|
515
|
+
&& !filters.character_names.includes(normalizeText(document.character_name))) return false
|
|
516
|
+
if (filters.activity_names.length && !needsWikiActivityHydration(document)) {
|
|
517
|
+
const activity = { ...document, activity_name: wikiActivityName(document)
|
|
518
|
+
|| normalizeText(document.activity_name) }
|
|
519
|
+
const exact = wikiRole === 'story' || wikiRole === 'character_activity'
|
|
520
|
+
if (!filters.activity_names.some((name) => activityMatches(activity, name, { exact }))) return false
|
|
521
|
+
}
|
|
522
|
+
if (filters.wiki_sections.length && !wikiRole) return false
|
|
523
|
+
return !filters.speakers.length || filters.speakers.some((speaker) => speakers.includes(speaker))
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function needsWikiActivityHydration(document) {
|
|
527
|
+
return wikiDocumentRole(document) === 'character_activity'
|
|
528
|
+
&& !normalizeText(document.activity_name)
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/** null 表示不限定活动行;旧包必须在加载正文后按活动块精确过滤。 */
|
|
532
|
+
function requestedWikiActivityRanges(record, filters) {
|
|
533
|
+
if (!filters.activity_names.length || !needsWikiActivityHydration(record.document)) return null
|
|
534
|
+
return wikiActivityRanges(record).filter((range) => filters.activity_names.some((name) =>
|
|
535
|
+
activityMatches({ activity_name: range.name }, name, { exact: true })))
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* 将已由轻量元数据过滤过的文档范围投影为 pack 范围。旧测试替身或第三方
|
|
540
|
+
* Store 若没有暴露 packId,则返回 null,保留全库查询语义,避免假阴性。
|
|
541
|
+
*/
|
|
542
|
+
function packIdsForDocumentIds(store, documentIds) {
|
|
543
|
+
if (!documentIds.length) return []
|
|
544
|
+
const packIds = new Set()
|
|
545
|
+
for (const documentId of documentIds) {
|
|
546
|
+
const packId = store.documents.get(documentId)?.packId
|
|
547
|
+
if (!packId) return null
|
|
548
|
+
packIds.add(packId)
|
|
549
|
+
}
|
|
550
|
+
return [...packIds]
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function documentScopeForFilters(store, filters) {
|
|
554
|
+
const documentIds = []
|
|
555
|
+
for (const [documentId, item] of store.documents) {
|
|
556
|
+
if (documentMatches(item.document, item.speakers ?? [], filters)) documentIds.push(documentId)
|
|
557
|
+
}
|
|
558
|
+
return { documentIds, packIds: packIdsForDocumentIds(store, documentIds) }
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function hydratedRecordMatches(record, filters) {
|
|
562
|
+
if (filters.character_names.length && wikiDocumentRole(record.document) === 'character_activity'
|
|
563
|
+
&& !normalizeText(record.document.character_name)
|
|
564
|
+
&& !filters.character_names.includes(normalizeText(wikiCharacterName(record)))) return false
|
|
565
|
+
return true
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function lineContent(line) {
|
|
569
|
+
const text = normalizeText(line.text)
|
|
570
|
+
const prefix = `${normalizeText(line.speaker_raw)}:`
|
|
571
|
+
return line.line_type === 'dialogue' && prefix !== ':' && text.startsWith(prefix)
|
|
572
|
+
? text.slice(prefix.length).trimStart() : text
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function matchesText(text, query, mode, regex) {
|
|
576
|
+
if (!query) return true
|
|
577
|
+
if (mode === 'regex') return regex.test(normalizeText(text))
|
|
578
|
+
const haystack = normalizeText(text).toLowerCase()
|
|
579
|
+
const needle = query.toLowerCase()
|
|
580
|
+
return haystack.includes(needle)
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function aliasNearQuery(text, alias, queryRange) {
|
|
584
|
+
const haystack = normalizeText(text)
|
|
585
|
+
if (!queryRange) return haystack.includes(alias)
|
|
586
|
+
let offset = haystack.indexOf(alias)
|
|
587
|
+
while (offset >= 0) {
|
|
588
|
+
const end = offset + alias.length
|
|
589
|
+
const distance = end < queryRange.start ? queryRange.start - end
|
|
590
|
+
: offset > queryRange.end ? offset - queryRange.end : 0
|
|
591
|
+
if (distance <= ENTITY_QUERY_MAX_DISTANCE) return true
|
|
592
|
+
offset = haystack.indexOf(alias, offset + 1)
|
|
593
|
+
}
|
|
594
|
+
return false
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function textAliases(entity) {
|
|
598
|
+
const aliases = [...new Set(entity.aliases.map(normalizeText).filter(Boolean))]
|
|
599
|
+
const longer = aliases.filter((alias) => [...alias].length > 1)
|
|
600
|
+
return longer.length ? longer : aliases
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function entityOccurrence(record, line, entityGroups, queryPresent, queryRange = null) {
|
|
604
|
+
if (!entityGroups.length) return null
|
|
605
|
+
for (const stored of line.entity_occurrences ?? []) {
|
|
606
|
+
const entity = entityGroups.find((item) => item.canonical === stored.canonical_name
|
|
607
|
+
|| item.aliases.includes(stored.raw_name))
|
|
608
|
+
if (!entity) continue
|
|
609
|
+
const rawName = String(stored.raw_name || '')
|
|
610
|
+
if (queryPresent && stored.evidence_kind !== 'speaker'
|
|
611
|
+
&& !aliasNearQuery(line.text, rawName, queryRange)) continue
|
|
612
|
+
return {
|
|
613
|
+
entity_id: String(stored.entity_id || ''), canonical_entity: entity.canonical,
|
|
614
|
+
matched_alias: rawName, presence_status: String(stored.presence_status || ''),
|
|
615
|
+
evidence_kind: String(stored.evidence_kind || ''), occurrence_id: String(stored.occurrence_id || ''),
|
|
616
|
+
confidence: Number(stored.confidence || 0),
|
|
617
|
+
...(stored.ambiguity_candidates?.length
|
|
618
|
+
? { ambiguity_candidates: stored.ambiguity_candidates.map((item) => String(item)) } : {}),
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
for (const entity of entityGroups) {
|
|
622
|
+
const speakerAlias = entity.aliases.find((alias) => line.speaker_raw === alias)
|
|
623
|
+
if (speakerAlias) return { canonical_entity: entity.canonical, matched_alias: speakerAlias,
|
|
624
|
+
presence_status: 'explicit', evidence_kind: 'speaker' }
|
|
625
|
+
const textAlias = textAliases(entity).find((alias) =>
|
|
626
|
+
aliasNearQuery(line.text, alias, queryPresent ? queryRange : null))
|
|
627
|
+
if (textAlias) return { canonical_entity: entity.canonical, matched_alias: textAlias,
|
|
628
|
+
presence_status: 'mentioned', evidence_kind: 'text_mention' }
|
|
629
|
+
if (!queryPresent && entity.aliases.includes(String(record.document.character_name || ''))
|
|
630
|
+
&& line.line_number === 1) {
|
|
631
|
+
return { canonical_entity: entity.canonical,
|
|
632
|
+
matched_alias: String(record.document.character_name || entity.canonical),
|
|
633
|
+
presence_status: 'explicit', evidence_kind: 'metadata_link' }
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
return null
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
function lineMatch(record, index, request, regex, entityGroups, bounds = null) {
|
|
640
|
+
const line = record.lines[index]
|
|
641
|
+
if (request.filters.speakers.length && !request.filters.speakers.includes(line.speaker_raw)) return null
|
|
642
|
+
const content = lineContent(line)
|
|
643
|
+
if (!matchesText(content, request.query, request.match_mode, regex)) return null
|
|
644
|
+
let start = null
|
|
645
|
+
let end = null
|
|
646
|
+
if (request.query) {
|
|
647
|
+
if (regex) {
|
|
648
|
+
const match = new RegExp(regex.source, regex.flags).exec(normalizeText(content))
|
|
649
|
+
if (match) { start = match.index; end = match.index + match[0].length }
|
|
650
|
+
} else {
|
|
651
|
+
const haystack = normalizeText(content).toLowerCase()
|
|
652
|
+
const needle = request.query.toLowerCase()
|
|
653
|
+
start = haystack.indexOf(needle)
|
|
654
|
+
// 区间终点必须用归一化后 needle 的长度:toLocaleLowerCase 可能改变长度
|
|
655
|
+
// (如土耳其语 İ → i̇),用原 query 长度会让实体邻近度判定错位。
|
|
656
|
+
end = start + needle.length
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
const occurrence = entityOccurrence(record, line, entityGroups, Boolean(request.query),
|
|
660
|
+
start === null ? null : { start, end })
|
|
661
|
+
if (entityGroups.length && !occurrence) return null
|
|
662
|
+
if (!request.context_terms.length) return { occurrence, start, end }
|
|
663
|
+
const nearby = record.lines.slice(Math.max((bounds?.start_line || 1) - 1, index - 3),
|
|
664
|
+
Math.min(bounds?.end_line || record.lines.length, index + 4))
|
|
665
|
+
const constraintLines = []
|
|
666
|
+
for (const term of request.context_terms) {
|
|
667
|
+
const found = nearby.find((item) => normalizeText(item.text).toLowerCase()
|
|
668
|
+
.includes(term.toLowerCase()))
|
|
669
|
+
if (!found) return null
|
|
670
|
+
constraintLines.push(found.line_number)
|
|
671
|
+
}
|
|
672
|
+
return { occurrence, start, end, constraint_lines: [...new Set(constraintLines)] }
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
function relevanceScore(match, request, field) {
|
|
676
|
+
if (field === 'title') return match.exact ? 1 : 0.96
|
|
677
|
+
let score = !request.query ? 0.5 : request.match_mode === 'regex' ? 0.7 : 1
|
|
678
|
+
if (Number(match.start) > 0) score -= Math.min(0.2, Number(match.start) / 200)
|
|
679
|
+
if (match.occurrence?.evidence_kind === 'speaker') score += 0.3
|
|
680
|
+
else if (match.occurrence?.presence_status === 'explicit') score += 0.2
|
|
681
|
+
else if (match.occurrence?.presence_status === 'mentioned') score += 0.05
|
|
682
|
+
return Math.max(0, Math.min(1, Math.round(score * 1000) / 1000))
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
function queryableEntities(record, maximum = 24) {
|
|
686
|
+
const entities = new Map()
|
|
687
|
+
const remember = (value) => {
|
|
688
|
+
const name = String(value.canonical_name || value.canonical_entity || '').trim()
|
|
689
|
+
const entityId = String(value.entity_id || '').trim()
|
|
690
|
+
if (!name && !entityId) return
|
|
691
|
+
const key = entityId || `name:${name}`
|
|
692
|
+
const existing = entities.get(key) || { entity_id: entityId, name,
|
|
693
|
+
entity_type: String(value.entity_type || ''), presence_statuses: [] }
|
|
694
|
+
const status = String(value.presence_status || '')
|
|
695
|
+
if (status && !existing.presence_statuses.includes(status)) existing.presence_statuses.push(status)
|
|
696
|
+
entities.set(key, existing)
|
|
697
|
+
}
|
|
698
|
+
for (const line of record.lines ?? []) for (const occurrence of line.entity_occurrences ?? []) remember(occurrence)
|
|
699
|
+
if (record.document.character_name) remember({ canonical_name: record.document.character_name,
|
|
700
|
+
entity_id: record.document.char_id || '', entity_type: 'character', presence_status: 'explicit' })
|
|
701
|
+
const all = [...entities.values()]
|
|
702
|
+
return { total: all.length, shown: all.slice(0, maximum), truncated: all.length > maximum }
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
function clusterPassages(candidates, forcedTruncated = false) {
|
|
706
|
+
const clusters = []
|
|
707
|
+
for (const candidate of candidates) {
|
|
708
|
+
const constraintLines = candidate.match?.constraint_lines || []
|
|
709
|
+
const candidateStart = Math.min(candidate.line.line_number, ...constraintLines)
|
|
710
|
+
const candidateEnd = Math.max(candidate.line.line_number, ...constraintLines)
|
|
711
|
+
const previous = clusters.at(-1)
|
|
712
|
+
if (previous && candidateStart - previous.end <= PASSAGE_CLUSTER_GAP
|
|
713
|
+
&& previous.best.activity_range === candidate.activity_range
|
|
714
|
+
&& previous.best.wiki_section === candidate.wiki_section) {
|
|
715
|
+
previous.end = Math.max(previous.end, candidateEnd)
|
|
716
|
+
previous.candidates.push(candidate)
|
|
717
|
+
if (candidate.score > previous.best.score) previous.best = candidate
|
|
718
|
+
} else clusters.push({ start: candidateStart, end: candidateEnd, candidates: [candidate], best: candidate })
|
|
719
|
+
}
|
|
720
|
+
const ranked = clusters.sort((left, right) => right.best.score - left.best.score
|
|
721
|
+
|| left.start - right.start)
|
|
722
|
+
return ranked.slice(0, MAX_PASSAGES_PER_DOCUMENT).map((cluster) => ({ ...cluster.best,
|
|
723
|
+
passage_start: cluster.start, passage_end: cluster.end,
|
|
724
|
+
passage_match_count: cluster.candidates.length,
|
|
725
|
+
match_lines: [...new Set(cluster.candidates.map((item) => item.line.line_number))],
|
|
726
|
+
constraint_lines: [...new Set(cluster.candidates.flatMap((item) => item.match?.constraint_lines || []))],
|
|
727
|
+
document_passages_truncated: forcedTruncated || ranked.length > MAX_PASSAGES_PER_DOCUMENT }))
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
function searchableTitleText(document) {
|
|
731
|
+
return [document.display_title, document.story_name, document.activity_name,
|
|
732
|
+
document.character_name, document.story_code, document.part_label,
|
|
733
|
+
document.collection_name, document.mission_title, document.story_key]
|
|
734
|
+
.map((item) => normalizeText(item)).filter(Boolean).join('\n')
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
function isExactOfficialArchiveTitle(document, request) {
|
|
738
|
+
if (!request.query || publicResourceType(document) !== 'archive') return false
|
|
739
|
+
const query = normalizeText(request.query).toLowerCase()
|
|
740
|
+
return [document.display_title, document.story_name]
|
|
741
|
+
.some((value) => normalizeText(value).toLowerCase() === query)
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
function lineAllowed(line, filters) {
|
|
745
|
+
return !filters.speakers.length || filters.speakers.includes(line.speaker_raw)
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
function readableAnchor(record, filters, ranges = null) {
|
|
749
|
+
const eligible = (line) => normalizeText(line.text) && lineAllowed(line, filters)
|
|
750
|
+
&& (!ranges || wikiSectionAt(ranges, line.line_number))
|
|
751
|
+
return record.lines.find((line) => line.line_type === 'dialogue' && eligible(line))
|
|
752
|
+
|| record.lines.find(eligible) || null
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
function publicResourceType(document) {
|
|
756
|
+
if (document.resource_type) return String(document.resource_type)
|
|
757
|
+
const type = String(document.document_type || '')
|
|
758
|
+
const kind = String(document.document_kind || '')
|
|
759
|
+
const category = String(document.document_category || '')
|
|
760
|
+
if (type === 'story') return category === 'memory' ? 'operator_record' : 'story'
|
|
761
|
+
if (type === 'character') {
|
|
762
|
+
if (category === '模组文案') return 'character_module'
|
|
763
|
+
if (category === '干员语音') return 'character_voice'
|
|
764
|
+
if (category === '时装文案') return 'character_skin'
|
|
765
|
+
return 'character_profile'
|
|
766
|
+
}
|
|
767
|
+
if (type === 'knowledge' && kind === 'wiki') {
|
|
768
|
+
const role = wikiDocumentRole(document)
|
|
769
|
+
return role === 'story' ? 'story_wiki'
|
|
770
|
+
: role === 'character_activity' ? 'character_activity_wiki' : 'character_wiki'
|
|
771
|
+
}
|
|
772
|
+
if (type === 'knowledge' && kind === 'terra_journey') return 'terra_journey'
|
|
773
|
+
if (type === 'entity') return 'entity_profile'
|
|
774
|
+
return 'reference'
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
function publicContentType(document) {
|
|
778
|
+
if (document.content_type) return normalizeText(document.content_type)
|
|
779
|
+
const kind = normalizeText(document.kind || document.document_kind).toLocaleLowerCase()
|
|
780
|
+
const mapping = {
|
|
781
|
+
dlg: 'dialogue', story: 'dialogue', cutscene: 'cutscene', radio: 'radio',
|
|
782
|
+
remotecomm: 'remote_comm', black: 'black_screen', env: 'environment_talk',
|
|
783
|
+
sns: 'sns_chat', topic: 'sns_topic', wiki: 'knowledge', reference: 'knowledge',
|
|
784
|
+
}
|
|
785
|
+
return mapping[kind] || (document.document_type === 'story' ? 'dialogue' : 'knowledge')
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
function publicCollectionName(document) {
|
|
789
|
+
return normalizeText(document.collection_name || document.mission_title
|
|
790
|
+
|| document.activity_name || document.story_name)
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
function boundedSummaryText(value, maximum) {
|
|
794
|
+
const text = normalizeText(value)
|
|
795
|
+
if (!text || maximum <= 0) return { text: '', truncated: Boolean(text) }
|
|
796
|
+
const characters = [...text]
|
|
797
|
+
return characters.length <= maximum
|
|
798
|
+
? { text, truncated: false }
|
|
799
|
+
: { text: `${characters.slice(0, Math.max(0, maximum - 1)).join('')}…`, truncated: true }
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
function publicEntitySummary(record, maximum = 2400) {
|
|
803
|
+
const entity = record.entity || {}
|
|
804
|
+
const attributes = entity.attributes || {}
|
|
805
|
+
const canonicalName = normalizeText(entity.canonical_name || record.document.display_title)
|
|
806
|
+
const description = boundedSummaryText(attributes.description || entity.description, Math.min(800, maximum))
|
|
807
|
+
const remaining = Math.max(0, maximum - [...description.text].length)
|
|
808
|
+
const history = boundedSummaryText(attributes.history_summary || entity.history_summary,
|
|
809
|
+
Math.min(1600, remaining))
|
|
810
|
+
return {
|
|
811
|
+
canonical_name: canonicalName,
|
|
812
|
+
...(description.text ? { description: description.text } : {}),
|
|
813
|
+
...(history.text && history.text !== description.text ? { history_summary: history.text } : {}),
|
|
814
|
+
truncated: description.truncated || history.truncated,
|
|
815
|
+
citation: `《${naturalDocumentTitle(record.document)}》`,
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
function evidenceKind(document, field, occurrence) {
|
|
820
|
+
const resource = publicResourceType(document)
|
|
821
|
+
if (field === 'catalog' || field === 'title') return 'catalog'
|
|
822
|
+
if (occurrence?.evidence_kind === 'metadata_link') return 'entity_projection'
|
|
823
|
+
if (resource.endsWith('_wiki')) return 'wiki_curated'
|
|
824
|
+
if (resource === 'story' || resource === 'original_story' || resource === 'operator_record') {
|
|
825
|
+
return 'official_canonical'
|
|
826
|
+
}
|
|
827
|
+
if (resource === 'archive' || resource.startsWith('character_')) return 'official_structured'
|
|
828
|
+
return 'wiki_curated'
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
function matchKind(item, request) {
|
|
832
|
+
if (item.field === 'title') return 'title'
|
|
833
|
+
if (item.field === 'catalog') return 'catalog'
|
|
834
|
+
if (item.wiki_section) return 'section'
|
|
835
|
+
if (item.match?.occurrence?.evidence_kind === 'speaker') return 'entity_speaker'
|
|
836
|
+
if (item.match?.occurrence?.evidence_kind === 'text_mention') return 'entity_mention'
|
|
837
|
+
if (item.match?.occurrence) return 'entity_projection'
|
|
838
|
+
if (!request.query && request.filters.speakers.length) return 'speaker'
|
|
839
|
+
return request.match_mode === 'regex' ? 'regex' : 'literal'
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
function excerpt(record, item, bounds = null) {
|
|
843
|
+
const start = Math.max(bounds?.start_line || 1, item.passage_start - PREVIEW_OPTIONS.before_lines)
|
|
844
|
+
const end = Math.min(bounds?.end_line || record.lines.length,
|
|
845
|
+
item.passage_end + PREVIEW_OPTIONS.after_lines)
|
|
846
|
+
let truncated = false
|
|
847
|
+
const lines = record.lines.slice(start - 1, end).map((line) => {
|
|
848
|
+
const raw = String(lineContent(line) || '')
|
|
849
|
+
const text = [...raw].length > PREVIEW_OPTIONS.max_chars_per_line
|
|
850
|
+
? `${[...raw].slice(0, PREVIEW_OPTIONS.max_chars_per_line).join('')}…` : raw
|
|
851
|
+
if (text !== raw) truncated = true
|
|
852
|
+
return { line: line.line_number,
|
|
853
|
+
role: item.match_lines?.includes(line.line_number) ? 'match'
|
|
854
|
+
: item.constraint_lines?.includes(line.line_number) ? 'constraint' : 'context',
|
|
855
|
+
line_type: line.line_type ?? '', speaker: line.speaker_raw ?? '', text,
|
|
856
|
+
truncated: text !== raw }
|
|
857
|
+
})
|
|
858
|
+
return { lines, characters: lines.reduce((total, line) => total + line.text.length, 0), truncated }
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
async function executeLegacySearch(store, request, offset,
|
|
862
|
+
{ signal, requestId = null, deadline = Date.now() + SEARCH_TIMEOUT_MS } = {}) {
|
|
863
|
+
const started = Date.now()
|
|
864
|
+
const resolvedRequestId = String(requestId || `req-${createHash('sha256')
|
|
865
|
+
.update(`${started}:${Math.random()}`).digest('hex').slice(0, 16)}`)
|
|
866
|
+
try {
|
|
867
|
+
assertSearchActive(signal, deadline)
|
|
868
|
+
await store.ready()
|
|
869
|
+
assertSearchActive(signal, deadline)
|
|
870
|
+
const entityAliasGroups = await aliasesFor(store, request.filters.entity_names)
|
|
871
|
+
assertSearchActive(signal, deadline)
|
|
872
|
+
// regex 模式的 query 是模式文本而非字面量,跳过 trigram 预过滤(与浏览器一致)。
|
|
873
|
+
// 资料包缺失 search-index 时同样跳过倒排:此时倒排空结果不代表正文零命中,
|
|
874
|
+
// 不能据此把搜索降级成"仅标题兜底"。
|
|
875
|
+
const regex = request.match_mode === 'regex'
|
|
876
|
+
? new RegExp(safeRegex(request.query).source, 'iu') : null
|
|
877
|
+
const queryNgrams = ngramsFor(request.query)
|
|
878
|
+
const rankPoolCap = request.query && !regex && queryNgrams.length
|
|
879
|
+
&& [...queryNgrams[0]].length < 3
|
|
880
|
+
? SHORT_LITERAL_RANK_POOL_CAP : RANK_POOL_CAP
|
|
881
|
+
const legacyScope = documentScopeForFilters(store, request.filters)
|
|
882
|
+
const indexedLookup = store.findDocumentsByNgrams || store.findDocumentsByTrigrams
|
|
883
|
+
let indexed = request.query && !regex && queryNgrams.length && indexedLookup
|
|
884
|
+
? await indexedLookup.call(store, queryNgrams,
|
|
885
|
+
{ signal, deadline, packIds: legacyScope.packIds }) : null
|
|
886
|
+
if (indexed === null && request.query && !regex && queryNgrams.length
|
|
887
|
+
&& [...queryNgrams[0]].length < 3) {
|
|
888
|
+
indexed = await store.findDocumentsByShortLiteral?.(request.query,
|
|
889
|
+
{ signal, deadline, packIds: legacyScope.packIds }) ?? null
|
|
890
|
+
}
|
|
891
|
+
assertSearchActive(signal, deadline, '本地语料搜索初始化')
|
|
892
|
+
const hasLineScope = request.filters.speakers.length || request.filters.entity_names.length
|
|
893
|
+
|| request.filters.wiki_sections.length || request.context_terms.length
|
|
894
|
+
const titleIds = []
|
|
895
|
+
if (request.query && !hasLineScope) {
|
|
896
|
+
let checked = 0
|
|
897
|
+
for (const [id, item] of store.documents) {
|
|
898
|
+
if ((checked++ & 255) === 0) assertSearchActive(signal, deadline, '候选标题发现')
|
|
899
|
+
if (documentMatches(item.document, [], request.filters)
|
|
900
|
+
&& matchesText(searchableTitleText(item.document), request.query,
|
|
901
|
+
request.match_mode, regex)) titleIds.push(id)
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
// 正文倒排与标题候选取并集;索引不可用时回退全量扫描。
|
|
905
|
+
const documentIds = indexed !== null ? [...new Set([...indexed, ...titleIds])] : null
|
|
906
|
+
const pool = []
|
|
907
|
+
for await (const record of store.iterateDocuments({
|
|
908
|
+
documentIds,
|
|
909
|
+
predicate: (document, speakers) => documentMatches(document, speakers, request.filters),
|
|
910
|
+
})) {
|
|
911
|
+
assertSearchActive(signal, deadline)
|
|
912
|
+
if (store.isPreferredNaturalDocument?.(record.document.document_id) === false) continue
|
|
913
|
+
const group = collectDocumentGroup(record, request, regex, entityAliasGroups, deadline, signal)
|
|
914
|
+
if (group) pool.push(...group.items)
|
|
915
|
+
if (pool.length >= rankPoolCap) break
|
|
916
|
+
}
|
|
917
|
+
pool.sort((left, right) => right.score - left.score
|
|
918
|
+
|| String(left.record.document.collection_id || '').localeCompare(
|
|
919
|
+
String(right.record.document.collection_id || ''), 'zh-CN', { numeric: true }))
|
|
920
|
+
const resultKind = request.query ? 'text_matches'
|
|
921
|
+
: request.filters.speakers.length || request.filters.entity_names.length
|
|
922
|
+
? 'structured_matches' : request.filters.wiki_sections.length
|
|
923
|
+
? 'complete_sections' : 'documents'
|
|
924
|
+
const grouped = new Map()
|
|
925
|
+
for (const item of pool) {
|
|
926
|
+
const key = item.record.document.document_id
|
|
927
|
+
const group = grouped.get(key) || { record: item.record, score: item.score, items: [] }
|
|
928
|
+
group.score = Math.max(group.score, item.score)
|
|
929
|
+
group.items.push(item)
|
|
930
|
+
grouped.set(key, group)
|
|
931
|
+
}
|
|
932
|
+
const allDocuments = [...grouped.values()].sort((left, right) => right.score - left.score
|
|
933
|
+
|| String(left.record.document.collection_id || '').localeCompare(
|
|
934
|
+
String(right.record.document.collection_id || ''), 'zh-CN', { numeric: true }))
|
|
935
|
+
const pageGroups = allDocuments.slice(offset, offset + PAGE_DOCUMENTS)
|
|
936
|
+
const documents = []
|
|
937
|
+
const reasons = new Set()
|
|
938
|
+
let returnedChars = 0
|
|
939
|
+
for (const group of pageGroups) {
|
|
940
|
+
assertSearchActive(signal, deadline)
|
|
941
|
+
if (documents.length && returnedChars >= PREVIEW_OPTIONS.max_total_chars) {
|
|
942
|
+
reasons.add('output_chars')
|
|
943
|
+
break
|
|
944
|
+
}
|
|
945
|
+
const metadata = group.record.document
|
|
946
|
+
const title = naturalDocumentTitle(metadata)
|
|
947
|
+
const result = {
|
|
948
|
+
game: documentGame(metadata), title, resource_type: publicResourceType(metadata),
|
|
949
|
+
content_type: publicContentType(metadata),
|
|
950
|
+
...(store.requiresDocumentUid?.(metadata.document_id)
|
|
951
|
+
? { document_uid: documentUid(metadata.document_id) } : {}),
|
|
952
|
+
...(publicCollectionName(metadata) ? { collection_name: publicCollectionName(metadata) } : {}),
|
|
953
|
+
...(metadata.activity_name ? { activity_name: metadata.activity_name } : {}),
|
|
954
|
+
...(metadata.character_name ? { character_name: metadata.character_name } : {}),
|
|
955
|
+
matches: [], matches_truncated: group.items.some((item) => item.document_passages_truncated),
|
|
956
|
+
}
|
|
957
|
+
if (result.matches_truncated) reasons.add('document_passages')
|
|
958
|
+
if (resultKind !== 'documents' && result.resource_type === 'entity_profile') {
|
|
959
|
+
const remaining = Math.max(0, PREVIEW_OPTIONS.max_total_chars - returnedChars)
|
|
960
|
+
result.entity_summary = publicEntitySummary(group.record, Math.min(2400, remaining))
|
|
961
|
+
result.matches_truncated = false
|
|
962
|
+
returnedChars += [...(result.entity_summary.description || '')].length
|
|
963
|
+
+ [...(result.entity_summary.history_summary || '')].length
|
|
964
|
+
if (result.entity_summary.truncated) reasons.add('entity_summary')
|
|
965
|
+
} else if (resultKind === 'complete_sections') {
|
|
966
|
+
const section = group.items[0]?.wiki_section
|
|
967
|
+
const lines = sectionPreviewLines(group)
|
|
968
|
+
const blocks = []
|
|
969
|
+
for (const line of lines) {
|
|
970
|
+
const text = String(line.text || '')
|
|
971
|
+
// 与 v4 路径一致按码点计数,避免含增补平面字符的行在两条路径下预算不一致
|
|
972
|
+
const width = [...text].length
|
|
973
|
+
if (returnedChars + width > PREVIEW_OPTIONS.max_total_chars) {
|
|
974
|
+
reasons.add('section_content')
|
|
975
|
+
break
|
|
976
|
+
}
|
|
977
|
+
blocks.push({ type: 'text', text })
|
|
978
|
+
returnedChars += width
|
|
979
|
+
}
|
|
980
|
+
result.section_content = { section: section?.name || request.filters.wiki_sections[0],
|
|
981
|
+
completeness: blocks.length === lines.length ? 'complete' : 'partial', blocks,
|
|
982
|
+
citation: `《${title}》Wiki·${section?.name || request.filters.wiki_sections[0]}` }
|
|
983
|
+
} else if (resultKind !== 'documents') {
|
|
984
|
+
for (const item of group.items.slice(0, MAX_PASSAGES_PER_DOCUMENT)) {
|
|
985
|
+
const titleOnly = item.field === 'title'
|
|
986
|
+
const shown = titleOnly ? { lines: [], characters: 0, truncated: false }
|
|
987
|
+
: excerpt(group.record, item, item.wiki_section || item.activity_range)
|
|
988
|
+
if (result.matches.length && returnedChars + shown.characters > PREVIEW_OPTIONS.max_total_chars) {
|
|
989
|
+
result.matches_truncated = true
|
|
990
|
+
reasons.add('output_chars')
|
|
991
|
+
reasons.add('document_passages')
|
|
992
|
+
break
|
|
993
|
+
}
|
|
994
|
+
if (shown.truncated) reasons.add('line_chars')
|
|
995
|
+
const lineStart = item.passage_start
|
|
996
|
+
const lineEnd = item.passage_end
|
|
997
|
+
const sectionName = item.wiki_section?.name
|
|
998
|
+
result.matches.push({
|
|
999
|
+
...(sectionName || titleOnly ? {} : { line_start: lineStart, line_end: lineEnd }),
|
|
1000
|
+
match_kind: matchKind(item, request),
|
|
1001
|
+
evidence_kind: evidenceKind(metadata, item.field, item.match?.occurrence),
|
|
1002
|
+
excerpt: shown.lines,
|
|
1003
|
+
citation: titleOnly ? `《${title}》`
|
|
1004
|
+
: sectionName ? `《${title}》Wiki·${sectionName}`
|
|
1005
|
+
: `《${title}》第 ${lineStart === lineEnd ? lineStart : `${lineStart}-${lineEnd}`} 行`,
|
|
1006
|
+
})
|
|
1007
|
+
returnedChars += shown.characters
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
documents.push(result)
|
|
1011
|
+
}
|
|
1012
|
+
const hasMore = offset + documents.length < allDocuments.length
|
|
1013
|
+
if (hasMore) reasons.add('more_documents')
|
|
1014
|
+
if (pool.length >= rankPoolCap) reasons.add('document_passages')
|
|
1015
|
+
assertSearchActive(signal, deadline)
|
|
1016
|
+
const nextCursor = hasMore ? await encodeOffsetCursor(store, request, offset + documents.length) : null
|
|
1017
|
+
assertSearchActive(signal, deadline, '分页游标生成')
|
|
1018
|
+
return {
|
|
1019
|
+
result_kind: resultKind, documents,
|
|
1020
|
+
page: { returned_documents: documents.length,
|
|
1021
|
+
total_relation: 'unknown', has_more: hasMore, exhausted: false,
|
|
1022
|
+
next_cursor: nextCursor },
|
|
1023
|
+
truncated: true,
|
|
1024
|
+
truncation_reasons: [...new Set([...reasons, 'legacy_pool_incomplete'])],
|
|
1025
|
+
}
|
|
1026
|
+
} catch (error) {
|
|
1027
|
+
return { contract_version: SEARCH_CONTRACT_VERSION, status: 'error', request_id: resolvedRequestId,
|
|
1028
|
+
data_version: store.dataVersion ?? null,
|
|
1029
|
+
error: publicSearchError(error) }
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
function resultKindFor(request) {
|
|
1034
|
+
if (request.query) return 'text_matches'
|
|
1035
|
+
if (request.filters.speakers.length || request.filters.entity_names.length) {
|
|
1036
|
+
return 'structured_matches'
|
|
1037
|
+
}
|
|
1038
|
+
return request.filters.wiki_sections.length ? 'complete_sections' : 'documents'
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
function lineScopeFor(request) {
|
|
1042
|
+
return Boolean(request.filters.speakers.length || request.filters.entity_names.length
|
|
1043
|
+
|| request.filters.wiki_sections.length || request.context_terms.length)
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
async function candidateDocumentIds(store, request, regex, { signal, deadline } = {}) {
|
|
1047
|
+
assertSearchActive(signal, deadline, '候选文档发现')
|
|
1048
|
+
const hasLineScope = lineScopeFor(request)
|
|
1049
|
+
// 结构化过滤只依赖初始化时保留的轻量 metadata,可以先安全缩小范围;
|
|
1050
|
+
// hydratedRecordMatches 仍在读取正文后复核 character_activity Wiki 等特殊项。
|
|
1051
|
+
const scopedIds = []
|
|
1052
|
+
const orderedIds = store.orderedDocumentIds()
|
|
1053
|
+
for (let index = 0; index < orderedIds.length; index += 1) {
|
|
1054
|
+
if ((index & 255) === 0) assertSearchActive(signal, deadline, '候选文档发现')
|
|
1055
|
+
const documentId = orderedIds[index]
|
|
1056
|
+
const item = store.documents.get(documentId)
|
|
1057
|
+
if (item && documentMatches(item.document, item.speakers, request.filters)
|
|
1058
|
+
&& store.isPreferredNaturalDocument?.(documentId) !== false) scopedIds.push(documentId)
|
|
1059
|
+
}
|
|
1060
|
+
const scoped = new Set(scopedIds)
|
|
1061
|
+
const scopedPackIds = packIdsForDocumentIds(store, scopedIds)
|
|
1062
|
+
const titleIds = []
|
|
1063
|
+
if (request.query && !hasLineScope) {
|
|
1064
|
+
for (let index = 0; index < scopedIds.length; index += 1) {
|
|
1065
|
+
if ((index & 255) === 0) assertSearchActive(signal, deadline, '候选标题发现')
|
|
1066
|
+
const id = scopedIds[index]
|
|
1067
|
+
if (matchesText(searchableTitleText(store.documents.get(id).document),
|
|
1068
|
+
request.query, request.match_mode, regex)) titleIds.push(id)
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
const prioritizeOfficialTitle = (ids) => {
|
|
1072
|
+
const exact = []
|
|
1073
|
+
for (let index = 0; index < ids.length; index += 1) {
|
|
1074
|
+
if ((index & 255) === 0) assertSearchActive(signal, deadline, '候选文档排序')
|
|
1075
|
+
const id = ids[index]
|
|
1076
|
+
if (isExactOfficialArchiveTitle(store.documents.get(id)?.document || {}, request)) exact.push(id)
|
|
1077
|
+
}
|
|
1078
|
+
if (!exact.length) return interleaveGameCandidates(store, ids, request, { signal, deadline })
|
|
1079
|
+
const selected = new Set(exact)
|
|
1080
|
+
const remaining = []
|
|
1081
|
+
for (let index = 0; index < ids.length; index += 1) {
|
|
1082
|
+
if ((index & 255) === 0) assertSearchActive(signal, deadline, '候选文档排序')
|
|
1083
|
+
if (!selected.has(ids[index])) remaining.push(ids[index])
|
|
1084
|
+
}
|
|
1085
|
+
return [...exact, ...interleaveGameCandidates(store, remaining, request, { signal, deadline })]
|
|
1086
|
+
}
|
|
1087
|
+
if (!request.query || regex) return prioritizeOfficialTitle(scopedIds)
|
|
1088
|
+
const queryNgrams = ngramsFor(request.query)
|
|
1089
|
+
// v2 包的 unigram/bigram 直接服务 1—2 字查询;旧包仍复用无损分片预筛。
|
|
1090
|
+
const indexedLookup = store.findDocumentsByNgrams || store.findDocumentsByTrigrams
|
|
1091
|
+
let indexed = queryNgrams.length && indexedLookup
|
|
1092
|
+
? await indexedLookup.call(store, queryNgrams,
|
|
1093
|
+
{ signal, deadline, packIds: scopedPackIds }) : null
|
|
1094
|
+
if (indexed === null && queryNgrams.length && [...queryNgrams[0]].length < 3) {
|
|
1095
|
+
indexed = await store.findDocumentsByShortLiteral?.(request.query,
|
|
1096
|
+
{ signal, deadline, packIds: scopedPackIds }) ?? null
|
|
1097
|
+
}
|
|
1098
|
+
assertSearchActive(signal, deadline, '候选文档发现')
|
|
1099
|
+
if (indexed === null) return prioritizeOfficialTitle(scopedIds)
|
|
1100
|
+
return prioritizeOfficialTitle(
|
|
1101
|
+
store.orderedDocumentIds([...new Set([...indexed.filter((id) => scoped.has(id)), ...titleIds])]))
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
function interleaveGameCandidates(store, documentIds, request, { signal, deadline } = {}) {
|
|
1105
|
+
const requested = request.filters.games?.length
|
|
1106
|
+
? request.filters.games : ['arknights', 'endfield']
|
|
1107
|
+
if (requested.length < 2) return documentIds
|
|
1108
|
+
const queues = new Map(requested.map((game) => [game, []]))
|
|
1109
|
+
const other = []
|
|
1110
|
+
for (let index = 0; index < documentIds.length; index += 1) {
|
|
1111
|
+
if ((index & 255) === 0) assertSearchActive(signal, deadline, '候选文档排序')
|
|
1112
|
+
const documentId = documentIds[index]
|
|
1113
|
+
const game = documentGame(store.documents.get(documentId)?.document || {})
|
|
1114
|
+
const queue = queues.get(game)
|
|
1115
|
+
if (queue) queue.push(documentId)
|
|
1116
|
+
else other.push(documentId)
|
|
1117
|
+
}
|
|
1118
|
+
const nonempty = [...queues.values()].filter((queue) => queue.length)
|
|
1119
|
+
if (nonempty.length < 2) return documentIds
|
|
1120
|
+
const result = []
|
|
1121
|
+
const maximum = Math.max(...nonempty.map((queue) => queue.length))
|
|
1122
|
+
for (let index = 0; index < maximum; index += 1) {
|
|
1123
|
+
if ((index & 255) === 0) assertSearchActive(signal, deadline, '候选文档排序')
|
|
1124
|
+
for (const game of requested) {
|
|
1125
|
+
const documentId = queues.get(game)?.[index]
|
|
1126
|
+
if (documentId) result.push(documentId)
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
return result.concat(other)
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
function collectDocumentGroup(record, request, regex, entityAliasGroups, deadline, signal = null) {
|
|
1133
|
+
if (!hydratedRecordMatches(record, request.filters)) return null
|
|
1134
|
+
const metadata = record.document
|
|
1135
|
+
const activityRanges = requestedWikiActivityRanges(record, request.filters)
|
|
1136
|
+
if (activityRanges && !activityRanges.length) return null
|
|
1137
|
+
let sectionRanges = request.filters.wiki_sections.length
|
|
1138
|
+
? wikiSectionRanges(record, request.filters.wiki_sections) : []
|
|
1139
|
+
if (activityRanges) {
|
|
1140
|
+
// 容器字段可能跨越全部活动;取交集后仅返回本次选中的活动正文。
|
|
1141
|
+
sectionRanges = sectionRanges.flatMap((section) => activityRanges.map((activity) => ({
|
|
1142
|
+
...section, start_line: Math.max(section.start_line, activity.start_line),
|
|
1143
|
+
end_line: Math.min(section.end_line, activity.end_line),
|
|
1144
|
+
})).filter((section) => section.start_line <= section.end_line))
|
|
1145
|
+
}
|
|
1146
|
+
if (request.filters.wiki_sections.length && !sectionRanges.length) return null
|
|
1147
|
+
const hasLineScope = lineScopeFor(request) || Boolean(activityRanges)
|
|
1148
|
+
const catalogMode = !request.query && !request.filters.speakers.length && !entityAliasGroups.length
|
|
1149
|
+
const items = []
|
|
1150
|
+
const titleText = searchableTitleText(metadata)
|
|
1151
|
+
if (request.query && !hasLineScope
|
|
1152
|
+
&& matchesText(titleText, request.query, request.match_mode, regex)) {
|
|
1153
|
+
const firstSection = sectionRanges[0] || null
|
|
1154
|
+
const anchor = firstSection
|
|
1155
|
+
? record.lines.slice(firstSection.start_line - 1, firstSection.end_line)
|
|
1156
|
+
.find((line) => normalizeText(line.text))
|
|
1157
|
+
: readableAnchor(record, request.filters)
|
|
1158
|
+
if (anchor) {
|
|
1159
|
+
const exact = normalizeText(metadata.story_name) === request.query
|
|
1160
|
+
|| normalizeText(metadata.display_title) === request.query
|
|
1161
|
+
items.push({ record, line: anchor, score: relevanceScore({ exact }, request, 'title'),
|
|
1162
|
+
field: 'title', passage_start: anchor.line_number, passage_end: anchor.line_number,
|
|
1163
|
+
passage_match_count: 1, match: { occurrence: null, start: null, end: null },
|
|
1164
|
+
...(firstSection ? { wiki_section: firstSection } : {}) })
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
if (catalogMode) {
|
|
1168
|
+
if (sectionRanges.length) {
|
|
1169
|
+
for (const section of sectionRanges) {
|
|
1170
|
+
const anchor = record.lines.slice(section.start_line - 1, section.end_line)
|
|
1171
|
+
.find((line) => normalizeText(line.text))
|
|
1172
|
+
if (anchor) items.push({ record, line: anchor, score: 1, field: 'wiki_section',
|
|
1173
|
+
passage_start: section.start_line, passage_end: section.end_line,
|
|
1174
|
+
passage_match_count: 1, match: { occurrence: null, start: null, end: null },
|
|
1175
|
+
wiki_section: section })
|
|
1176
|
+
}
|
|
1177
|
+
} else {
|
|
1178
|
+
const anchor = readableAnchor(record, request.filters, activityRanges)
|
|
1179
|
+
if (anchor) items.push({ record, line: anchor, score: 1, field: 'catalog',
|
|
1180
|
+
passage_start: anchor.line_number, passage_end: anchor.line_number,
|
|
1181
|
+
passage_match_count: 1, match: { occurrence: null, start: null, end: null } })
|
|
1182
|
+
}
|
|
1183
|
+
} else {
|
|
1184
|
+
const documentMatches = []
|
|
1185
|
+
let documentMatchesTruncated = false
|
|
1186
|
+
for (let index = 0; index < record.lines.length; index += 1) {
|
|
1187
|
+
if ((index & 255) === 0) {
|
|
1188
|
+
// 与 legacy 路径对齐:逐行扫描既响应外部取消,也受时间预算约束。
|
|
1189
|
+
if (signal?.aborted) throw Object.assign(new Error('搜索已取消'), { code: 'CANCELLED' })
|
|
1190
|
+
if (Date.now() > deadline) {
|
|
1191
|
+
throw Object.assign(new Error('本地语料搜索超时'), { code: 'TIMEOUT', retryable: true })
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
const activityRange = activityRanges
|
|
1195
|
+
? wikiSectionAt(activityRanges, record.lines[index].line_number) : null
|
|
1196
|
+
if (activityRanges && !activityRange) continue
|
|
1197
|
+
const wikiSection = sectionRanges.length
|
|
1198
|
+
? wikiSectionAt(sectionRanges, record.lines[index].line_number) : null
|
|
1199
|
+
if (sectionRanges.length && !wikiSection) continue
|
|
1200
|
+
const match = lineMatch(record, index, request, regex, entityAliasGroups, activityRange)
|
|
1201
|
+
if (!match) continue
|
|
1202
|
+
documentMatches.push({ record, line: record.lines[index],
|
|
1203
|
+
score: relevanceScore(match, request, 'content'), field: request.query ? 'content'
|
|
1204
|
+
: match.occurrence ? 'entity' : 'speaker_raw', match,
|
|
1205
|
+
...(wikiSection ? { wiki_section: wikiSection } : {}),
|
|
1206
|
+
...(activityRange ? { activity_range: activityRange } : {}) })
|
|
1207
|
+
if (!hasLineScope && !regex
|
|
1208
|
+
&& documentMatches.length >= SIMPLE_LITERAL_MATCH_CAP_PER_DOCUMENT) {
|
|
1209
|
+
documentMatchesTruncated = true
|
|
1210
|
+
break
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
items.push(...clusterPassages(documentMatches, documentMatchesTruncated))
|
|
1214
|
+
}
|
|
1215
|
+
if (!items.length) return null
|
|
1216
|
+
items.sort((left, right) => (left.field === 'title') - (right.field === 'title')
|
|
1217
|
+
|| right.score - left.score || left.passage_start - right.passage_start)
|
|
1218
|
+
return { record, score: Math.max(...items.map((item) => item.score)), items }
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
function previewLinesCost(lines) {
|
|
1222
|
+
return lines.reduce((total, line) => total + [...String(line.text || '')].length, 0)
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
/**
|
|
1226
|
+
* measureDocument 与 buildPublicDocument 共用同一份命中预览:此前两条路径
|
|
1227
|
+
* 各自构建 excerpt(切片 + NFKC 归一化 + 逐行截断),对同一候选文档做双倍
|
|
1228
|
+
* 字符串处理;首次计算后缓存在 item/group 上,构建阶段直接复用。
|
|
1229
|
+
*/
|
|
1230
|
+
function previewExcerpt(group, item) {
|
|
1231
|
+
if (!item.preview) item.preview = excerpt(group.record, item, item.wiki_section || item.activity_range)
|
|
1232
|
+
return item.preview
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
/** complete_sections 的字段行列表;measure 与 build 共用,避免重复 slice+filter。 */
|
|
1236
|
+
function sectionPreviewLines(group) {
|
|
1237
|
+
if (!group.sectionLines) {
|
|
1238
|
+
const sections = group.items.map((item) => item.wiki_section).filter(Boolean)
|
|
1239
|
+
group.sectionLines = group.record.lines.filter((line) => normalizeText(line.text)
|
|
1240
|
+
&& sections.some((section) => section.start_line <= line.line_number
|
|
1241
|
+
&& line.line_number <= section.end_line))
|
|
1242
|
+
}
|
|
1243
|
+
return group.sectionLines
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
function fitLinesToBudget(lines, budget) {
|
|
1247
|
+
const fitted = lines.map((line) => ({ ...line }))
|
|
1248
|
+
while (previewLinesCost(fitted) > budget && fitted.some((line) => line.role === 'context')) {
|
|
1249
|
+
const lastContext = fitted.findLastIndex((line) => line.role === 'context')
|
|
1250
|
+
fitted.splice(lastContext, 1)
|
|
1251
|
+
}
|
|
1252
|
+
if (previewLinesCost(fitted) <= budget) return fitted
|
|
1253
|
+
const target = fitted.find((line) => line.role === 'match') || fitted[0]
|
|
1254
|
+
if (!target) return []
|
|
1255
|
+
const otherCost = previewLinesCost(fitted) - [...String(target.text || '')].length
|
|
1256
|
+
const allowed = Math.max(80, budget - otherCost)
|
|
1257
|
+
if ([...String(target.text || '')].length > allowed) {
|
|
1258
|
+
target.text = `${[...String(target.text || '')].slice(0, Math.max(1, allowed - 1)).join('')}…`
|
|
1259
|
+
target.truncated = true
|
|
1260
|
+
}
|
|
1261
|
+
return fitted
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
function measureDocument(group, resultKind, request) {
|
|
1265
|
+
if (resultKind !== 'documents' && publicResourceType(group.record.document) === 'entity_profile') {
|
|
1266
|
+
group.entitySummary ??= publicEntitySummary(group.record, 2400)
|
|
1267
|
+
return [...String(group.entitySummary.description || '')].length
|
|
1268
|
+
+ [...String(group.entitySummary.history_summary || '')].length
|
|
1269
|
+
}
|
|
1270
|
+
if (resultKind === 'complete_sections') {
|
|
1271
|
+
return sectionPreviewLines(group)
|
|
1272
|
+
.reduce((total, line) => total + [...String(line.text || '')].length, 0)
|
|
1273
|
+
}
|
|
1274
|
+
if (resultKind === 'documents') return 0
|
|
1275
|
+
return group.items.slice(0, MAX_PASSAGES_PER_DOCUMENT).reduce((total, item) => {
|
|
1276
|
+
if (item.field === 'title') return total
|
|
1277
|
+
// 与 buildPublicDocument 的 previewLinesCost 同口径(码点计数),
|
|
1278
|
+
// 避免 UTF-16 估宽导致预算判定与实际构建不一致。
|
|
1279
|
+
return total + previewLinesCost(previewExcerpt(group, item).lines)
|
|
1280
|
+
}, 0)
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
function buildPublicDocument(store, group, resultKind, request, budget = Infinity) {
|
|
1284
|
+
const metadata = group.record.document
|
|
1285
|
+
const title = naturalDocumentTitle(metadata)
|
|
1286
|
+
const result = {
|
|
1287
|
+
game: documentGame(metadata), title, resource_type: publicResourceType(metadata),
|
|
1288
|
+
content_type: publicContentType(metadata),
|
|
1289
|
+
...(store.requiresDocumentUid?.(metadata.document_id)
|
|
1290
|
+
? { document_uid: documentUid(metadata.document_id) } : {}),
|
|
1291
|
+
...(publicCollectionName(metadata) ? { collection_name: publicCollectionName(metadata) } : {}),
|
|
1292
|
+
...(metadata.activity_name ? { activity_name: metadata.activity_name } : {}),
|
|
1293
|
+
...(metadata.character_name ? { character_name: metadata.character_name } : {}),
|
|
1294
|
+
matches: [], matches_truncated: group.items.some((item) => item.document_passages_truncated),
|
|
1295
|
+
}
|
|
1296
|
+
const reasons = new Set()
|
|
1297
|
+
let characters = 0
|
|
1298
|
+
if (result.matches_truncated) reasons.add('document_passages')
|
|
1299
|
+
if (resultKind !== 'documents' && result.resource_type === 'entity_profile') {
|
|
1300
|
+
const cap = Math.min(2400, budget)
|
|
1301
|
+
result.entity_summary = cap === 2400 && group.entitySummary
|
|
1302
|
+
? group.entitySummary : publicEntitySummary(group.record, cap)
|
|
1303
|
+
result.matches_truncated = false
|
|
1304
|
+
characters = [...String(result.entity_summary.description || '')].length
|
|
1305
|
+
+ [...String(result.entity_summary.history_summary || '')].length
|
|
1306
|
+
if (result.entity_summary.truncated) reasons.add('entity_summary')
|
|
1307
|
+
} else if (resultKind === 'complete_sections') {
|
|
1308
|
+
const lines = sectionPreviewLines(group)
|
|
1309
|
+
const blocks = []
|
|
1310
|
+
for (const line of lines) {
|
|
1311
|
+
const text = String(line.text || '')
|
|
1312
|
+
if (characters + [...text].length > budget) {
|
|
1313
|
+
if (!blocks.length && budget > 0) {
|
|
1314
|
+
blocks.push({ type: 'text', text: `${[...text].slice(0, Math.max(1, budget - 1)).join('')}…` })
|
|
1315
|
+
characters = Math.min([...text].length, budget)
|
|
1316
|
+
}
|
|
1317
|
+
reasons.add('section_content')
|
|
1318
|
+
break
|
|
1319
|
+
}
|
|
1320
|
+
blocks.push({ type: 'text', text })
|
|
1321
|
+
characters += [...text].length
|
|
1322
|
+
}
|
|
1323
|
+
const sectionName = group.items[0]?.wiki_section?.name || request.filters.wiki_sections[0]
|
|
1324
|
+
result.section_content = { section: sectionName,
|
|
1325
|
+
completeness: !reasons.has('section_content') && blocks.length === lines.length
|
|
1326
|
+
? 'complete' : 'partial', blocks,
|
|
1327
|
+
citation: `《${title}》Wiki·${sectionName}` }
|
|
1328
|
+
} else if (resultKind !== 'documents') {
|
|
1329
|
+
for (const item of group.items.slice(0, MAX_PASSAGES_PER_DOCUMENT)) {
|
|
1330
|
+
const titleOnly = item.field === 'title'
|
|
1331
|
+
const shown = titleOnly ? { lines: [], characters: 0, truncated: false }
|
|
1332
|
+
: previewExcerpt(group, item)
|
|
1333
|
+
if (result.matches.length && characters + shown.characters > budget) {
|
|
1334
|
+
result.matches_truncated = true
|
|
1335
|
+
reasons.add('output_chars')
|
|
1336
|
+
reasons.add('document_passages')
|
|
1337
|
+
break
|
|
1338
|
+
}
|
|
1339
|
+
const available = Math.max(0, budget - characters)
|
|
1340
|
+
const shownLines = shown.characters > available
|
|
1341
|
+
? fitLinesToBudget(shown.lines, available) : shown.lines
|
|
1342
|
+
const shownCharacters = previewLinesCost(shownLines)
|
|
1343
|
+
if (shown.truncated || shownCharacters < shown.characters) reasons.add('line_chars')
|
|
1344
|
+
const lineStart = item.passage_start
|
|
1345
|
+
const lineEnd = item.passage_end
|
|
1346
|
+
const sectionName = item.wiki_section?.name
|
|
1347
|
+
result.matches.push({
|
|
1348
|
+
...(sectionName || titleOnly ? {} : { line_start: lineStart, line_end: lineEnd }),
|
|
1349
|
+
match_kind: matchKind(item, request),
|
|
1350
|
+
evidence_kind: evidenceKind(metadata, item.field, item.match?.occurrence),
|
|
1351
|
+
excerpt: shownLines,
|
|
1352
|
+
citation: titleOnly ? `《${title}》`
|
|
1353
|
+
: sectionName ? `《${title}》Wiki·${sectionName}`
|
|
1354
|
+
: `《${title}》第 ${lineStart === lineEnd ? lineStart : `${lineStart}-${lineEnd}`} 行`,
|
|
1355
|
+
})
|
|
1356
|
+
characters += shownCharacters
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
return { document: result, characters, reasons }
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
async function executeScanSearch(store, request, checkpoint,
|
|
1363
|
+
{ signal, requestId = null, deadline = Date.now() + SEARCH_TIMEOUT_MS } = {}) {
|
|
1364
|
+
const started = Date.now()
|
|
1365
|
+
const resolvedRequestId = String(requestId || `req-${createHash('sha256')
|
|
1366
|
+
.update(`${started}:${Math.random()}`).digest('hex').slice(0, 16)}`)
|
|
1367
|
+
try {
|
|
1368
|
+
assertSearchActive(signal, deadline)
|
|
1369
|
+
const regex = request.match_mode === 'regex'
|
|
1370
|
+
? new RegExp(safeRegex(request.query).source, 'iu') : null
|
|
1371
|
+
const entityAliasGroups = await aliasesFor(store, request.filters.entity_names)
|
|
1372
|
+
assertSearchActive(signal, deadline)
|
|
1373
|
+
const candidateIds = await candidateDocumentIds(store, request, regex, { signal, deadline })
|
|
1374
|
+
assertSearchActive(signal, deadline, '本地语料搜索初始化')
|
|
1375
|
+
const resultKind = resultKindFor(request)
|
|
1376
|
+
const documents = []
|
|
1377
|
+
const reasons = new Set()
|
|
1378
|
+
let characters = 0
|
|
1379
|
+
let scanned = 0
|
|
1380
|
+
let matchedDocuments = checkpoint.matchedDocumentsSoFar
|
|
1381
|
+
let nextCandidateIndex = checkpoint.nextCandidateIndex
|
|
1382
|
+
let stopped = false
|
|
1383
|
+
for (let candidateIndex = 0; candidateIndex < candidateIds.length; candidateIndex += 1) {
|
|
1384
|
+
if ((candidateIndex & 255) === 0) assertSearchActive(signal, deadline)
|
|
1385
|
+
const documentId = candidateIds[candidateIndex]
|
|
1386
|
+
if (candidateIndex < checkpoint.nextCandidateIndex) continue
|
|
1387
|
+
if (documents.length >= PAGE_DOCUMENTS || scanned >= SCAN_DOCUMENTS_PER_PAGE) {
|
|
1388
|
+
nextCandidateIndex = candidateIndex
|
|
1389
|
+
stopped = true
|
|
1390
|
+
break
|
|
1391
|
+
}
|
|
1392
|
+
assertSearchActive(signal, deadline)
|
|
1393
|
+
const location = store.documents.get(documentId)
|
|
1394
|
+
scanned += 1
|
|
1395
|
+
if (!location || !documentMatches(location.document, location.speakers, request.filters)
|
|
1396
|
+
|| store.isPreferredNaturalDocument?.(documentId) === false) {
|
|
1397
|
+
nextCandidateIndex = candidateIndex + 1
|
|
1398
|
+
continue
|
|
1399
|
+
}
|
|
1400
|
+
const found = await store.getDocument(documentId)
|
|
1401
|
+
assertSearchActive(signal, deadline)
|
|
1402
|
+
if (!found) {
|
|
1403
|
+
nextCandidateIndex = candidateIndex + 1
|
|
1404
|
+
continue
|
|
1405
|
+
}
|
|
1406
|
+
const group = collectDocumentGroup(found.record, request, regex, entityAliasGroups, deadline, signal)
|
|
1407
|
+
if (!group) {
|
|
1408
|
+
nextCandidateIndex = candidateIndex + 1
|
|
1409
|
+
continue
|
|
1410
|
+
}
|
|
1411
|
+
const remaining = Math.max(0, PREVIEW_OPTIONS.max_total_chars - characters)
|
|
1412
|
+
const measured = measureDocument(group, resultKind, request)
|
|
1413
|
+
if (documents.length && measured > remaining) {
|
|
1414
|
+
nextCandidateIndex = candidateIndex
|
|
1415
|
+
reasons.add('output_chars')
|
|
1416
|
+
stopped = true
|
|
1417
|
+
break
|
|
1418
|
+
}
|
|
1419
|
+
const built = buildPublicDocument(store, group, resultKind, request,
|
|
1420
|
+
documents.length ? remaining : PREVIEW_OPTIONS.max_total_chars)
|
|
1421
|
+
documents.push(built.document)
|
|
1422
|
+
characters += built.characters
|
|
1423
|
+
for (const reason of built.reasons) reasons.add(reason)
|
|
1424
|
+
matchedDocuments += 1
|
|
1425
|
+
nextCandidateIndex = candidateIndex + 1
|
|
1426
|
+
}
|
|
1427
|
+
const exhausted = !stopped
|
|
1428
|
+
const countKnown = checkpoint.matchedCountKnown !== false
|
|
1429
|
+
if (!exhausted) reasons.add('scan_incomplete')
|
|
1430
|
+
const nextCursor = exhausted ? null
|
|
1431
|
+
: await encodeScanCursor(store, request, nextCandidateIndex, matchedDocuments)
|
|
1432
|
+
assertSearchActive(signal, deadline, '分页游标生成')
|
|
1433
|
+
return {
|
|
1434
|
+
result_kind: resultKind,
|
|
1435
|
+
documents,
|
|
1436
|
+
page: {
|
|
1437
|
+
returned_documents: documents.length,
|
|
1438
|
+
...(exhausted && countKnown ? { total_documents: matchedDocuments } : {}),
|
|
1439
|
+
total_relation: exhausted && countKnown ? 'eq' : 'unknown',
|
|
1440
|
+
has_more: !exhausted,
|
|
1441
|
+
exhausted,
|
|
1442
|
+
next_cursor: nextCursor,
|
|
1443
|
+
},
|
|
1444
|
+
truncated: reasons.size > 0,
|
|
1445
|
+
truncation_reasons: [...reasons],
|
|
1446
|
+
}
|
|
1447
|
+
} catch (error) {
|
|
1448
|
+
return { contract_version: SEARCH_CONTRACT_VERSION, status: 'error', request_id: resolvedRequestId,
|
|
1449
|
+
data_version: store.dataVersion ?? null,
|
|
1450
|
+
error: publicSearchError(error) }
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
export async function executeSearch(store, raw,
|
|
1455
|
+
{ signal, requestId = null, allowedGames = ['arknights', 'endfield'] } = {}) {
|
|
1456
|
+
let deadline = Infinity
|
|
1457
|
+
let snapshot = null
|
|
1458
|
+
try {
|
|
1459
|
+
assertSearchActive(signal, deadline)
|
|
1460
|
+
// 首次打开真实资料包需要建立全局轻量索引,这是 Store 就绪阶段,
|
|
1461
|
+
// 不应吃掉本次检索的 15s 预算。就绪后只创建一个绝对 deadline,
|
|
1462
|
+
// 短词预筛、主扫描和分页锚点全部共用它。
|
|
1463
|
+
await store.ready()
|
|
1464
|
+
snapshot = corpusVersionSnapshot(store)
|
|
1465
|
+
deadline = Date.now() + SEARCH_TIMEOUT_MS
|
|
1466
|
+
assertSearchActive(signal, deadline)
|
|
1467
|
+
const normalized = normalizedRequest(raw)
|
|
1468
|
+
const attachCorpusWarnings = (value, request) => {
|
|
1469
|
+
assertCorpusVersion(store, snapshot)
|
|
1470
|
+
if (value?.status === 'error' || !(store.packs instanceof Map)) return value
|
|
1471
|
+
const games = request.filters.games?.length
|
|
1472
|
+
? request.filters.games : ['arknights', 'endfield']
|
|
1473
|
+
const packByGame = { arknights: 'official_game', endfield: 'endfield_official_game' }
|
|
1474
|
+
const warnings = games.filter((game) => !store.packs.has(packByGame[game])).map((game) => ({
|
|
1475
|
+
code: 'CORPUS_GAME_NOT_INSTALLED', game,
|
|
1476
|
+
message: `${game === 'endfield' ? '终末地' : '明日方舟'}本地语料未安装,本次只检索其余已安装资料。`,
|
|
1477
|
+
}))
|
|
1478
|
+
return warnings.length ? { ...value, warnings } : value
|
|
1479
|
+
}
|
|
1480
|
+
if (normalized.cursor) {
|
|
1481
|
+
assertSearchActive(signal, deadline, '分页游标校验')
|
|
1482
|
+
const decoded = await decodeCursor(store, normalized.cursor)
|
|
1483
|
+
assertSearchActive(signal, deadline, '分页游标校验')
|
|
1484
|
+
const requestedGames = decoded.request.filters?.games?.length
|
|
1485
|
+
? decoded.request.filters.games : ['arknights', 'endfield']
|
|
1486
|
+
if (requestedGames.some((game) => !allowedGames.includes(game))) {
|
|
1487
|
+
throw Object.assign(new Error('该游标包含当前未启用的游戏资料,请重新搜索'),
|
|
1488
|
+
{ code: 'CURSOR_POLICY_MISMATCH', retryable: false })
|
|
1489
|
+
}
|
|
1490
|
+
if (decoded.kind === 'legacy') {
|
|
1491
|
+
return attachCorpusWarnings(await exposeTitleContinuation(store,
|
|
1492
|
+
await executeLegacySearch(store, decoded.request, decoded.offset,
|
|
1493
|
+
{ signal, requestId, deadline }), { signal, deadline }),
|
|
1494
|
+
decoded.request)
|
|
1495
|
+
}
|
|
1496
|
+
return attachCorpusWarnings(await exposeTitleContinuation(store, await executeScanSearch(store, decoded.request, {
|
|
1497
|
+
nextCandidateIndex: decoded.nextCandidateIndex,
|
|
1498
|
+
matchedDocumentsSoFar: decoded.matchedDocumentsSoFar,
|
|
1499
|
+
matchedCountKnown: true,
|
|
1500
|
+
}, { signal, requestId, deadline }), { signal, deadline }), decoded.request)
|
|
1501
|
+
}
|
|
1502
|
+
const request = withoutAfter(normalized)
|
|
1503
|
+
const checkpoint = normalized.after
|
|
1504
|
+
? await checkpointAfterTitle(store, normalized.after, request, { signal, deadline })
|
|
1505
|
+
: { nextCandidateIndex: 0, matchedDocumentsSoFar: 0, matchedCountKnown: true }
|
|
1506
|
+
return attachCorpusWarnings(await exposeTitleContinuation(store,
|
|
1507
|
+
await executeScanSearch(store, request, checkpoint,
|
|
1508
|
+
{ signal, requestId, deadline }), { signal, deadline }), request)
|
|
1509
|
+
} catch (error) {
|
|
1510
|
+
try { if (snapshot) assertCorpusVersion(store, snapshot) } catch (changed) { error = changed }
|
|
1511
|
+
return { contract_version: SEARCH_CONTRACT_VERSION, status: 'error',
|
|
1512
|
+
request_id: String(requestId || ''), data_version: store.dataVersion ?? null,
|
|
1513
|
+
error: publicSearchError(error) }
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
export function renderSearch(args, value) {
|
|
1518
|
+
if (value?.error) return [{ type: 'text', text: `[corpus_search:error] ${value.error.code}: ${value.error.message}` }]
|
|
1519
|
+
return [{ type: 'text', text: projectSearch(value, {
|
|
1520
|
+
query: normalizeText(args?.query),
|
|
1521
|
+
}) }]
|
|
1522
|
+
}
|