prts-terrarchive 0.2.2 → 0.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +52 -18
- package/README.md +49 -16
- package/bin/build-mainline-catalog.py +195 -0
- package/contracts/corpus_i18n_v1.schema.json +1 -3
- package/contracts/corpus_tools_v1.schema.json +30 -1
- package/docs/endfield-mainline-navigation.md +45 -0
- package/lib/client.js +154 -27
- package/lib/skins/common.css +12 -2
- package/lib/skins/endfield-aic.css +1 -1
- package/lib/skins/prts-agent.css +9 -7
- package/package.json +7 -3
- package/resources/endfield-mainline.json +4632 -0
- package/skills/prts-investigation/SKILL.md +1 -1
- package/skills/prts-investigation/references/board-guide.md +1 -1
- package/skills/prts-retrieval/SKILL.md +2 -0
- package/skills/prts-retrieval/references/cloud-tools.md +3 -1
- package/skills/prts-retrieval/references/local-tools.md +3 -3
- package/skills/prts-retrieval/references/module-arknights.md +1 -1
- package/skills/prts-retrieval/references/module-dual.md +1 -1
- package/skills/prts-retrieval/references/module-endfield.md +3 -0
- package/skills/prts-retrieval/references/tools-runtime.md +22 -10
- package/src/cloud-projection.js +23 -2
- package/src/cloud.js +24 -0
- package/src/entity-recognizer.js +26 -5
- package/src/entity-routing.js +18 -4
- package/src/evidence-state.js +4 -2
- package/src/i18n.js +70 -15
- package/src/index.js +326 -103
- package/src/installer.js +86 -14
- package/src/investigation-tools.js +7 -4
- package/src/navigation.js +342 -0
- package/src/read-args.js +86 -0
- package/src/read.js +66 -15
- package/src/search-projection.js +154 -19
- package/src/search.js +177 -21
- package/src/store.js +127 -3
- package/src/timeline.js +86 -2
- package/src/tool-args.js +53 -0
- package/src/ui.js +158 -34
- package/src/web-guidance.js +14 -2
|
@@ -25,7 +25,7 @@ description: 在莱茵工作区延续或新建调查,筛选证据、整理线
|
|
|
25
25
|
4. 及时 `update` 保存有价值的线索;同一判断更新已有 C 编号,新建省略 id、使用 client_key 暂名,后续以 created_ids 为准。引用的盒内材料会自动上板;无价值的 Agent 暂存材料可 stage/remove。
|
|
26
26
|
5. 一条卡片表达一个关键点;title/summary 简短,背景和推导放 detail。只连接有意义的线索,重复判断用 merges 归并。事实标 observation,推断标 inference,未解问题标 question/unresolved。
|
|
27
27
|
|
|
28
|
-
引用只用当前会话真实来源;quote
|
|
28
|
+
引用只用当前会话真实来源;quote 逐字复制实际返回的原文,去掉搜索高亮⟦⟧;行号必须落在已读范围。搜索命中、入架和用户收藏不等于原文已核验。验证失败时按 issues 的来源标题、clue_index 和 read_ranges 查来源或补读,不猜行号、不提交测试线索。
|
|
29
29
|
|
|
30
30
|
## 发布与修订
|
|
31
31
|
|
|
@@ -90,7 +90,7 @@ summary 用一句话写最重要的依据或结论,优先 25–60 个汉字;
|
|
|
90
90
|
无来源的猜想不能伪装成原文证据。
|
|
91
91
|
|
|
92
92
|
sources 只使用当前会话已登记的 ID。quote 必须逐字出自实际返回的文本;
|
|
93
|
-
line_start/line_end
|
|
93
|
+
line_start/line_end 必须落在已读范围中。搜索命中不是已读原文,复制引文时不要带搜索高亮的⟦⟧。
|
|
94
94
|
若校验失败,按 issues 中的 clue_index、source_title 和 read_ranges 定位;标题不是目标文档时先查来源目录,不能反复改行号。
|
|
95
95
|
标题正确再按 read 指引补读,保留 source_id 与文档的对应关系。全半角兼容字符会还原成原文保存,其余改写不视为逐字引文。
|
|
96
96
|
整批失败不会保存任何线索,也不会推进 revision。仅修正错误部分后重试,不要用“测试引文/测试”污染真实调查板。
|
|
@@ -21,6 +21,8 @@ description: 使用 PRTS.chat 本地与云端资料检索、核验并回答《
|
|
|
21
21
|
- 情节过程、关系、动机或多资料综合:默认 `cloud_search` 发现候选;需要精确台词、说话人、行动或因果时,再用映射出的标题与行号做本地核验。
|
|
22
22
|
- 云端不可用但本地足够时回退本地;工具故障不等于资料不存在。取得足够证据后停止。
|
|
23
23
|
|
|
24
|
+
读取时,展示标题填写 `title`;`document_uid` 只能复制工具返回的 `doc_…` 定位,不能填写标题或自行构造。二者互斥。
|
|
25
|
+
|
|
24
26
|
## 证据和结论
|
|
25
27
|
|
|
26
28
|
官方剧情原文适合支持台词、行动、场景与上下文判断;官方档案等结构化资料适合支持身份和设定;Wiki、实体资料和时间线适合概括、清单、导航与候选发现。整理性材料中带引号的句子不能自动视为核验过的原文。实质冲突时回读相关原文上下文,并说明差异。
|
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
|
|
9
9
|
`evidence_policy="mixed"` 使用主站混合检索;`original_only` 只运行官方剧情原文向量路线。两者都不改变“最终结论应按来源层级表述”的要求。结果中的可读取标题与行号可直接交给 `corpus_read`。
|
|
10
10
|
|
|
11
|
+
综合分析、人物经历、关系类问题不要设置 `options.search_intent` 或 `original_only`:它们会关闭档案、Wiki、图谱和年表。云端返回“没有可用材料”时,先去掉这两个字段用默认路线重试一次,仍无结果就改用 `corpus_search`;不要以相同参数反复调用。主站在 `single_sentence_search` 无材料时会自动改用默认路线重试一次,并在“警告”中注明;显式 `original_only` 不会被放宽。
|
|
12
|
+
|
|
11
13
|
## `cloud_inspect`
|
|
12
14
|
|
|
13
|
-
只在截断、来源不清或召回异常时检查。优先查看 `selected_sources`、`answer_context`、`candidates`;`events` 和 `trace_steps` 用于诊断。通常由运行时关联最近请求,分页时原样复制整数 `next_cursor
|
|
15
|
+
只在截断、来源不清或召回异常时检查。优先查看 `selected_sources`、`answer_context`、`candidates`;`events` 和 `trace_steps` 用于诊断。通常由运行时关联最近请求,分页时原样复制整数 `next_cursor`。不需要的过滤字段直接省略;`event_sequence_*` 从 1 开始,`event_time_*` 必须是带时区的 ISO 8601(如 `2025-01-01T08:00:00+08:00`)。
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
- `resource_types`:两款游戏共用字段,但可选值不同。明日方舟使用 `story`、`operator_record`、`character_*`、`*_wiki` 等;终末地使用 `original_story`、`archive`、`knowledge`、`wiki`、`character_story`、`timeline`。不要跨模块套类型。
|
|
9
9
|
- `content_types`:内容形式,终末地原文尤其适合用它区分对话、过场、广播、远程通讯、黑屏文字、环境对话和 SNS。
|
|
10
10
|
- `collection_names`:统一的上级集合字段,可表示明日方舟活动或终末地任务/资料集合。
|
|
11
|
-
- `character_names` 是资料归属;`speakers`
|
|
11
|
+
- `character_names` 是资料归属;`speakers` 是亲口说话(自动展开已登记别名);`entity_names` 是正文或结构化关系中出现。
|
|
12
12
|
|
|
13
13
|
本地资料不包含云端图谱、向量候选和 Cleaner 结果;本地零命中不能推出云端也无结果。
|
|
14
14
|
|
|
@@ -16,9 +16,9 @@
|
|
|
16
16
|
|
|
17
17
|
优先使用玩家可见的稳定定位器:
|
|
18
18
|
|
|
19
|
-
- 明日方舟关卡用 `stage_code
|
|
19
|
+
- 明日方舟关卡用 `stage_code`;同一代号存在多篇时可加 `story_part`:`before`=行动前、`after`=行动后、`story`=纯剧情或幕间;省略时默认读行动前并提示其他篇。
|
|
20
20
|
- 干员密录用 `character_name + record_name`;多段密录加 `segment`。
|
|
21
21
|
- 角色官方资料用 `character_name + material`;双模块同名时加 `game`。
|
|
22
22
|
- 整个明日方舟活动用 `activity_name + mode:"activity"`;终末地任务用 `collection_name + mode:"collection"`,可用 `content_types` 收窄表现形式。
|
|
23
23
|
|
|
24
|
-
这些字段已足够时不要先搜索或拼完整标题。同名篇章或同名多合集会要求消歧:先搜索具体篇章,只在结果给出 `document_uid` 时原样复制。`document_uid` 会替代 `title`,不得同时提交两者;单篇用 `{document_uid, line}` 或 `{document_uid, mode:"document"}`,所属活动/任务通读用 `{document_uid, mode:"activity"}` 或 `{document_uid, mode:"collection"}`。不得自行选第一项,也不要把 `document_uid` 当作人物或剧情证据。搜索分页保留原条件并原样提交 `page.next_after
|
|
24
|
+
这些字段已足够时不要先搜索或拼完整标题。同名篇章或同名多合集会要求消歧:先搜索具体篇章,只在结果给出 `document_uid` 时原样复制。`document_uid` 会替代 `title`,不得同时提交两者;单篇用 `{document_uid, line}` 或 `{document_uid, mode:"document"}`,所属活动/任务通读用 `{document_uid, mode:"activity"}` 或 `{document_uid, mode:"collection"}`。不得自行选第一项,也不要把 `document_uid` 当作人物或剧情证据。搜索分页保留原条件并原样提交 `page.next_after`;全文和合集续页直接复制结果末尾给出的调用。around 读取的 `has_more=false` 只描述该次窗口,不能证明整篇或合集已结束。
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
- 整理资料:`character_wiki`、`story_wiki`、`character_activity_wiki`;确需跨 Wiki 搜索才用 `reviewed_wiki`。
|
|
10
10
|
- 设定与导航:`terra_journey`、`entity_profile`、`reference`。
|
|
11
11
|
|
|
12
|
-
`character_names` 是资料归属,`speakers` 是亲口说话,`entity_names`
|
|
12
|
+
`character_names` 是资料归属,`speakers` 是亲口说话,`entity_names` 是正文或结构化关系中出现;不同字段取交集。活动或章节名(如“昨日海”)使用 `activity_names`,单关/单篇剧情名(如“鳞的记忆”)使用 `story_names`,上级集合也可使用 `collection_names`。`context_terms` 要求每个词都出现在命中行前后 3 行内,不是整篇共现,不确定时省略。
|
|
13
13
|
|
|
14
14
|
常见路线:
|
|
15
15
|
|
|
@@ -10,6 +10,6 @@
|
|
|
10
10
|
|
|
11
11
|
双模块检索涉及再旅者时,结果可能带 `retraveler_relations`。这是 PRTS 人工审校的关系登记,可以直接支持“某终末地再旅者登记的泰拉记忆原型是谁”这一有限结论,回答时注明资料性质。它不表示两者是同一个人物,也不能证明游戏剧情正式确认、共享记忆或其他未登记关系。用字段中的两个规范名分别检索两侧人物资料;附加字段缺失也不能反证不存在该关系。
|
|
12
12
|
|
|
13
|
-
本地搜索返回的每篇资料都有 `game`。联合结果中至少检查两边是否都有命中;若出现 `CORPUS_GAME_NOT_INSTALLED` 或某侧无结果,只能给出已覆盖模块的部分结论并标明缺口。分页时保留原 `games`
|
|
13
|
+
本地搜索返回的每篇资料都有 `game`。联合结果中至少检查两边是否都有命中;若出现 `CORPUS_GAME_NOT_INSTALLED` 或某侧无结果,只能给出已覆盖模块的部分结论并标明缺口。分页时保留原 `games` 和其他检索条件,并原样提交 `next_after`(含短版本号 `data_version`);资料版本切换后重新搜索。
|
|
14
14
|
|
|
15
15
|
跨游戏推荐流程:先用完整问题联合 `cloud_search` 或窄字面联合 `corpus_search` 发现候选;再按游戏拆分读取和核验;最后区分“各自资料明示的事实”和“基于两侧事实作出的比较或推断”。
|
|
@@ -2,12 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
调用本地或云端检索时使用 `games:["endfield"]`,除非运行时上下文表明问题还涉及明日方舟。
|
|
4
4
|
|
|
5
|
+
玩家询问“主线顺序”“最新主线”“1.6 主线”“丹青渡”“第二章第八进程”或某一段的前后剧情时,先用 `corpus_navigate({query:玩家给出的版本、章节或主线问法})`;省略 query 返回有序主线目录。它按来源支持的章、进程与任务层级定位,返回可直接传给 `corpus_read/corpus_search` 的参数,玩家无需知道“入丹青”等内部篇章名。最新只指当前资料覆盖的最新主线;查看返回的资料版本边界与映射状态。版本没有映射时不得推断该版本没有新增剧情。章/进程的顺序不等于任务内部全部对话碎片的先后。
|
|
6
|
+
|
|
5
7
|
官方名称、原句或档案的跨语言对照使用 `corpus_i18n`,不必重新搜索目标语言剧情。已有标题/文档定位及行号时直接定位;只有原句时反查。同文可能有不同译法,必须保留来源语境;默认不请求内部文本 ID。
|
|
6
8
|
|
|
7
9
|
终末地采用跨游戏统一类型,不要套用明日方舟干员资料类型:
|
|
8
10
|
|
|
9
11
|
- `original_story`:官方剧情原文;可用 `content_types` 区分 `dialogue`、`cutscene`、`radio`、`remote_comm`、`black_screen`、`environment_talk`、`sns_topic`、`sns_chat`、`narration`。
|
|
10
12
|
- `archive`:官方档案类资料。
|
|
13
|
+
- `character_name` + `material`:**两款游戏通用**的官方角色资料直读。终末地的《管理员 / 角色档案》《佩丽卡 / 角色档案》这类,用 `character_name:"管理员"` + `material:"profile"` 直接读,不必先搜索;男女管理员正文相同时会自动选稳定的一份。
|
|
11
14
|
- `knowledge`:知识与设定资料。
|
|
12
15
|
- `wiki`:整理性 Wiki。
|
|
13
16
|
- `character_story`:角色故事资料。
|
|
@@ -1,25 +1,35 @@
|
|
|
1
1
|
# 当前工具契约
|
|
2
2
|
|
|
3
|
+
## `corpus_navigate`
|
|
4
|
+
|
|
5
|
+
终末地主线导航(明日方舟的时间位置用 `timeline_search`)。参数只有可选 `query` 和 `game:"endfield"`;省略 query 返回有序目录,不需要先知道任务名。支持“最新主线”“1.6 主线”“丹青渡”“第二章第八进程”及准确任务名。返回章/进程顺序、所属任务、前后主线,以及可直接提交给 `corpus_read` 的参数;任务元数据只展示 id/名称/类型等判别字段,来源 URL 与源文件路径保留在结构化结果中。版本映射与顺序带来源;最新仅表示当前安装资料覆盖的最新。缺少版本映射或本地正文时保留缺口提示。章/进程/任务顺序不代表任务内部所有原文碎片的严格时间顺序。目录不能充当台词证据,具体剧情仍读正文。
|
|
6
|
+
|
|
3
7
|
## `corpus_search`
|
|
4
8
|
|
|
5
9
|
它是本地结构化字面检索,不接收完整研究问题。可用参数为 `query`、`games`、`resource_types`、`content_types`、`collection_names`、`character_names`、`story_names`、`activity_names`、`wiki_sections`、`entity_names`、`speakers`、`match_mode`、`context_terms`、`after`。
|
|
6
10
|
|
|
7
|
-
同一数组内 OR,不同过滤字段间 AND。`query` 默认连续字面匹配;只有确需模式时才用受限 `regex`。省略 `query` 可按归属列目录;省略 `query` 且指定一个 Wiki 字段可返回完整字段。已有证据足以回答时停止;只有需要完整清单、确认零命中或当前页证据不足时才继续分页。续页保留原条件并把 `page.next_after` 原样放进 `after`;要证明搜索范围已穷尽则继续到 `page.exhausted=true`。`next_after`
|
|
11
|
+
同一数组内 OR,不同过滤字段间 AND。`query` 默认连续字面匹配;只有确需模式时才用受限 `regex`。省略 `query` 可按归属列目录;省略 `query` 且指定一个 Wiki 字段可返回完整字段。已有证据足以回答时停止;只有需要完整清单、确认零命中或当前页证据不足时才继续分页。续页保留原条件并把 `page.next_after` 原样放进 `after`;要证明搜索范围已穷尽则继续到 `page.exhausted=true`。`next_after` 带短版本号 `data_version`(前 12 位;至少 8 位前缀即可);资料版本切换后旧锚点会被拒绝,须从首屏重新搜索。
|
|
12
|
+
|
|
13
|
+
`speakers` 匹配亲口台词,会自动展开已登记别名(如 `银灰` 也匹配原文说话人 `恩希欧迪斯`)。`query` 只匹配台词正文,不含说话人名;从命中复制的 `说话人: 台词` 形式也能直接搜。命中行显示原文标点,命中片段以 `⟦⟧` 标出,引用时去掉 `⟦⟧`。目录型结果每篇附可直接执行的 `corpus_read` 调用。
|
|
8
14
|
|
|
9
15
|
## `corpus_read`
|
|
10
16
|
|
|
11
|
-
- 明日方舟单篇关卡:`{stage_code:"TW-ST-1"}
|
|
17
|
+
- 明日方舟单篇关卡:`{stage_code:"TW-ST-1"}`;同代号存在多篇且未给 `story_part` 时默认读行动前(无行动前则读幕间),结果开头说明其他篇的 `story_part`。提供 `line` 时读上下文,否则直接读全文首段。
|
|
12
18
|
- 干员密录:`{character_name:"安洁莉娜", record_name:"没写收件人的包裹", segment:2}`;只有一段时可省略 `segment`。
|
|
13
19
|
- 角色资料:`{character_name:"凯尔希", material:"profile"}`;`material` 支持 `profile/module/voice/skin/recruitment/potential`,双模块同名时加 `game`。
|
|
14
20
|
- 明日方舟活动连续阅读:`{activity_name:"孤星", mode:"activity"}`。
|
|
15
|
-
- 终末地任务连续阅读:`{collection_name:"武陵特厨", mode:"collection"}`;可加 `content_types
|
|
21
|
+
- 终末地任务连续阅读:`{collection_name:"武陵特厨", mode:"collection"}`;可加 `content_types`。两款游戏的连续阅读是同一入口:`activity_name` 填了终末地任务名、或 `collection_name` 填了明日方舟活动名时,工具会自动改道并说明。
|
|
16
22
|
- 其他资料定点上下文:`{title, line, before?, after?}`。
|
|
17
|
-
- Wiki 字段:`{title, section}`。
|
|
18
|
-
- 其他资料全文首段:`{title
|
|
19
|
-
-
|
|
20
|
-
-
|
|
23
|
+
- Wiki 字段:`{title, section}` 或 `{document_uid, section}`;只读字段时省略 `mode`。
|
|
24
|
+
- 其他资料全文首段:`{title}`(可加 `max_lines/max_chars`;`mode:"document"` 可省略)。
|
|
25
|
+
- 标题不必一字不差:分隔符 `·`、空格、全半角差异和 Wiki 的“ · 第 N 篇”后缀可省略;唯一匹配时直接读取并在开头说明实际标题,多个候选或找不到时错误信息列出最接近的完整标题。
|
|
26
|
+
- 搜索结果给出 `document_uid` 时表示标题同名:复制其 `doc_…` 值替代 `title`,不能把展示标题填入 `document_uid`,两者不得同时提交;单篇用 `{document_uid, line}` 或 `{document_uid, mode:"document"}`。
|
|
27
|
+
- 所有续读:直接复制结果末尾给出的 `corpus_read(...)` 调用。单篇使用 `line`,活动/任务合集使用 `position`;`data_version` 为短版本号且可省略(省略时按当前版本读取),可以另加 `max_lines/max_chars`。
|
|
28
|
+
- 角色资料中导出脚本写入的溯源行(数据表路径、记录 ID、解锁条件枚举)默认隐藏,行号保持原样;Wiki 的 `<字段>` 标签显示为 `字段:值` 或 `[字段]`。
|
|
21
29
|
|
|
22
|
-
每次只选一种主定位方式:`title`、`document_uid`、`stage_code`(可带 `story_part`)、角色密录组合、角色资料组合、`activity_name` 或 `collection_name`。`max_lines/max_chars` 只限制输出量,不会选择读取方式;裸 `title`
|
|
30
|
+
每次只选一种主定位方式:`title`、`document_uid`、`stage_code`(可带 `story_part`)、角色密录组合、角色资料组合、`activity_name` 或 `collection_name`。`max_lines/max_chars` 只限制输出量,不会选择读取方式;裸 `title` 默认读取全文首段。
|
|
31
|
+
|
|
32
|
+
直接复制导航/搜索返回的最小读取参数,不要填齐其他用途的字段。可选枚举允许空字符串,等同省略。`story_part` 依附 `stage_code`,`segment` 依附 `record_name`,`material` 依附 `character_name`;已明确唯一目标时,兼容清理已知孤立默认值并在结果中说明,真实的双重定位仍报错并列出最小重试参数。显式 `mode:"document"` 表示读取全文;误带的合法 `section` 不应用,并在结果提示。只读 Wiki 字段时保留 `section`,省略 `mode`。非默认的孤立修饰参数、非零的无效分页位置及过期版本仍严格校验。
|
|
23
33
|
|
|
24
34
|
稳定定位字段已经足够时直接读取,不要先搜索标题。合集结果的每行都带所属篇章标题和篇内行号,应按它们引用。工具返回歧义时收窄条件,不能猜第一篇。新调用不要创建 cursor;旧会话已有 cursor 时才使用兼容入口。
|
|
25
35
|
|
|
@@ -29,9 +39,11 @@
|
|
|
29
39
|
|
|
30
40
|
默认无需请求底层 ID。需要核验时才设 `include_ids:true`,返回的 `text_id` 必须当作字符串使用,后续可传 `text_ids`。同文不同译保留候选,按 references 中的篇章和中文定位消歧,不能任选第一个。每个结果最多列出 8 个引用;`reference_count` 表示总数。
|
|
31
41
|
|
|
32
|
-
`alignment:"record"` 是整条角色档案,`alignment:"document"` 是档案库内容块;不保证各语言段落一一对应。引用注明工具返回的篇章、语言和中文定位,不能把中文第 N 行说成英文第 N 行。长文本按字符分块,`character_start/end`
|
|
42
|
+
`alignment:"record"` 是整条角色档案,`alignment:"document"` 是档案库内容块;不保证各语言段落一一对应。引用注明工具返回的篇章、语言和中文定位,不能把中文第 N 行说成英文第 N 行。长文本按字符分块,`character_start/end` 是该语言自己的位置,不表示译文逐字符对应;续页直接复制结果末尾的 `corpus_i18n(...)` 调用(`after` 只需 `position/text_offset`,短 `data_version/request_hash` 用于校验,可省略)。
|
|
43
|
+
|
|
44
|
+
结果以文本展示。按名称查询时,原文与所有请求语言译文完全相同的条目合并为一组,出处汇总为“共 N 处,示例”;只有译文确有不同才分成多组。
|
|
33
45
|
|
|
34
|
-
`missing_localization
|
|
46
|
+
`missing_localization`(显示为“该语言无对应译文”)表示该语言官方文本为空;`unmapped_source` 表示没有建立来源映射;`unavailable` 表示附件/语言未安装。均不能自行翻译后当成官方文本。旧资料包仍能正常搜索和阅读,但需要安装带本地化附件的版本才能使用此工具。
|
|
35
47
|
|
|
36
48
|
## `timeline_search`
|
|
37
49
|
|
package/src/cloud-projection.js
CHANGED
|
@@ -84,7 +84,22 @@ export function projectedAnchorPoints(mappings) {
|
|
|
84
84
|
}))
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
-
|
|
87
|
+
/** 云端无材料时的恢复指引:说明路线并给出下一步,而不是返回空文本。 */
|
|
88
|
+
export function emptyCloudSearchGuidance(args = {}, { routeFallback = false } = {}) {
|
|
89
|
+
const singleSentence = args?.options?.search_intent === 'single_sentence_search'
|
|
90
|
+
// 主站已自动改用默认路线仍无材料时,不再建议模型重复同一次回退。
|
|
91
|
+
const strict = !routeFallback && (singleSentence || args?.evidence_policy === 'original_only')
|
|
92
|
+
const route = singleSentence
|
|
93
|
+
? 'options.search_intent=single_sentence_search(只检索官方原文的单句定位路线)'
|
|
94
|
+
: 'evidence_policy=original_only(只检索官方原文)'
|
|
95
|
+
return [
|
|
96
|
+
'云端检索没有返回可用材料(不代表资料中不存在)。',
|
|
97
|
+
strict ? `本次使用了 ${route},档案、Wiki、图谱和年表均未参与。综合分析请去掉 options 和 evidence_policy,用默认 mixed 路线重试一次。` : '',
|
|
98
|
+
'也可以改用 corpus_search 以人物名、活动名或原句短片段在本地检索;不要用相同参数重复调用。',
|
|
99
|
+
].filter(Boolean).join('\n')
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function projectCloudSearch(value, { args } = {}) {
|
|
88
103
|
const data = value?.data || {}
|
|
89
104
|
const mappings = value?.local_source_mappings || []
|
|
90
105
|
const sources = addSourceAnchors(data.sources || data.selected_sources || [], anchorIndex(mappings))
|
|
@@ -105,7 +120,13 @@ export function projectCloudSearch(value) {
|
|
|
105
120
|
`状态:${item.relation_status}。这是跨游戏关系,不是人物别名。`)
|
|
106
121
|
const warnings = [...(data.errors || []),
|
|
107
122
|
...(value?.local_source_mapping_warning ? [value.local_source_mapping_warning] : [])]
|
|
108
|
-
|
|
123
|
+
// 只有警告或跨游戏关系、没有回答材料和原文定位时,同样视为“无材料”并给出下一步。
|
|
124
|
+
// 仅面向模型的渲染传入 args;证据板等用户侧展示不混入操作提示。
|
|
125
|
+
const guidance = args && !answerContext.trim() && !anchorLines.length
|
|
126
|
+
? emptyCloudSearchGuidance(args, {
|
|
127
|
+
routeFallback: (data.errors || []).some((item) => item?.stage === 'route_fallback'),
|
|
128
|
+
}) : ''
|
|
129
|
+
return [answerContext.trim(), guidance, relationLines.length
|
|
109
130
|
? `## 再旅者对应关系(人工审校附属字段)\n${relationLines.join('\n')}` : '',
|
|
110
131
|
anchorLines.length ? `## 可读取原文\n${anchorLines.join('\n')}` : '',
|
|
111
132
|
warnings.length ? `## 警告\n${warnings.map((item) => item.message || item).join('\n')}` : '']
|
package/src/cloud.js
CHANGED
|
@@ -496,3 +496,27 @@ export function cloudErrorResponse(error) {
|
|
|
496
496
|
return { contract_version: CLOUD_CONTRACT_VERSION, status: 'error',
|
|
497
497
|
error: { code: 'CLOUD_INTERNAL_ERROR', message: '云端检索执行失败;详情请查看 DSH Host 日志', retryable: false } }
|
|
498
498
|
}
|
|
499
|
+
|
|
500
|
+
const CLOUD_RECOVERY_HINTS = Object.freeze({
|
|
501
|
+
INVALID_REQUEST: '请按提示修正或删除对应字段;不确定的可选字段直接省略,不要填空字符串、空数组或 0。',
|
|
502
|
+
TRACE_NOT_FOUND: '没有可检查的云端检索记录,请先成功调用 cloud_search。',
|
|
503
|
+
CLOUD_BUSY: '云端繁忙,可稍后重试一次,或改用 corpus_search 本地检索。',
|
|
504
|
+
CLOUD_TIMEOUT: '可稍后重试一次,或改用 corpus_search 本地检索。',
|
|
505
|
+
CLOUD_UNAVAILABLE: '云端暂不可用,请改用 corpus_search / corpus_read 本地检索。',
|
|
506
|
+
})
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* 工具执行层:把云端失败转换成可抛出的 Error,交由宿主标记 isError。
|
|
510
|
+
* 消息带上错误码和恢复指引;未知内部异常仍只暴露通用说明。
|
|
511
|
+
*/
|
|
512
|
+
export function cloudFaultError(error, toolName = 'cloud') {
|
|
513
|
+
if (error?.name === 'AbortError' || error?.code === 'CANCELLED') return error
|
|
514
|
+
const known = error instanceof CloudFault
|
|
515
|
+
|| (error?.code === 'INVALID_REQUEST' && typeof error?.message === 'string')
|
|
516
|
+
const { code, message, retryable, details } = known
|
|
517
|
+
? { code: error.code, message: error.message, retryable: Boolean(error.retryable), details: error.details }
|
|
518
|
+
: cloudErrorResponse(error).error
|
|
519
|
+
const hint = CLOUD_RECOVERY_HINTS[code] || (retryable ? '可稍后重试一次。' : '')
|
|
520
|
+
return Object.assign(new Error(`[${toolName}:${code}] ${message}${hint ? `\n${hint}` : ''}`),
|
|
521
|
+
{ code, retryable, ...(details ? { details } : {}), cause: error })
|
|
522
|
+
}
|
package/src/entity-recognizer.js
CHANGED
|
@@ -96,7 +96,7 @@ export class EntityAliasAutomaton {
|
|
|
96
96
|
// 单字别名在普通句子中误报率过高;仅当整个输入就是该字时保留。
|
|
97
97
|
if (output.length === 1 && characters.length > 1) continue
|
|
98
98
|
found.push({ canonical: output.canonical, alias: output.alias, games: output.games,
|
|
99
|
-
start, end: index + 1 })
|
|
99
|
+
curated: Boolean(this.curated?.has(normalized(output.alias))), start, end: index + 1 })
|
|
100
100
|
}
|
|
101
101
|
}
|
|
102
102
|
found.sort((left, right) => left.start - right.start
|
|
@@ -130,6 +130,7 @@ export async function prepareEntityRecognition(store, { signal } = {}) {
|
|
|
130
130
|
}
|
|
131
131
|
store._aliasGroups ||= groups
|
|
132
132
|
state.automaton = new EntityAliasAutomaton(groups)
|
|
133
|
+
state.automaton.curated = store._curatedAliases || new Set()
|
|
133
134
|
return state.automaton
|
|
134
135
|
})
|
|
135
136
|
.catch((error) => {
|
|
@@ -159,7 +160,7 @@ export function createEntityRecognizer(store) {
|
|
|
159
160
|
if (signal?.aborted) throw cancelledError()
|
|
160
161
|
const catalog = await loadEntityRelationCatalog(store)
|
|
161
162
|
if (signal?.aborted) throw cancelledError()
|
|
162
|
-
return detectEntities(automaton, catalog, text)
|
|
163
|
+
return withAliasConflicts(store, detectEntities(automaton, catalog, text), text)
|
|
163
164
|
}
|
|
164
165
|
}
|
|
165
166
|
}
|
|
@@ -186,13 +187,26 @@ function detectEntities(automaton, catalog, text) {
|
|
|
186
187
|
relation_hints: relationHints }
|
|
187
188
|
}
|
|
188
189
|
|
|
190
|
+
/** 本轮问题里出现的冲突别名字面:不做实体归属,只在上下文里点出数据缺陷。 */
|
|
191
|
+
function aliasConflictNotes(store, text) {
|
|
192
|
+
const conflicts = store?._aliasConflicts
|
|
193
|
+
if (!conflicts?.length) return []
|
|
194
|
+
const normalizedText = normalized(text)
|
|
195
|
+
return conflicts.filter((item) => normalizedText.includes(normalized(item.alias)))
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function withAliasConflicts(store, result, text) {
|
|
199
|
+
const conflicts = aliasConflictNotes(store, text)
|
|
200
|
+
return conflicts.length ? { ...result, alias_conflicts: conflicts } : result
|
|
201
|
+
}
|
|
202
|
+
|
|
189
203
|
/** 仅在后台预热已经完成时同步识别;prompt assembly 不能等待异步 I/O。 */
|
|
190
204
|
function detectPreparedEntities(store, text) {
|
|
191
205
|
const state = preparedRecognizers.get(store)
|
|
192
206
|
const catalog = store?._endfieldRelationCatalog
|
|
193
207
|
if (!store?.loaded || !state?.automaton || state.dataVersion !== store.dataVersion
|
|
194
208
|
|| catalog?.dataVersion !== store.dataVersion) return null
|
|
195
|
-
return detectEntities(state.automaton, catalog.value, text)
|
|
209
|
+
return withAliasConflicts(store, detectEntities(state.automaton, catalog.value, text), text)
|
|
196
210
|
}
|
|
197
211
|
|
|
198
212
|
function messageText(message) {
|
|
@@ -204,8 +218,9 @@ function messageText(message) {
|
|
|
204
218
|
function recognitionContext(result, enabledGames) {
|
|
205
219
|
const aliases = new Map()
|
|
206
220
|
for (const match of result.matches) {
|
|
207
|
-
const value = aliases.get(match.canonical) || { aliases: new Set(), games: new Set() }
|
|
221
|
+
const value = aliases.get(match.canonical) || { aliases: new Set(), games: new Set(), curated: false }
|
|
208
222
|
value.aliases.add(match.alias)
|
|
223
|
+
if (match.curated) value.curated = true
|
|
209
224
|
for (const game of match.games || []) value.games.add(game)
|
|
210
225
|
aliases.set(match.canonical, value)
|
|
211
226
|
}
|
|
@@ -226,19 +241,23 @@ function recognitionContext(result, enabledGames) {
|
|
|
226
241
|
}
|
|
227
242
|
const lines = [...aliases].map(([canonical, value]) => {
|
|
228
243
|
const ownership = [...value.games].map((game) => GAME_LABELS[game]).filter(Boolean)
|
|
244
|
+
const basis = value.curated ? ' · 依据:人工别名表' : ''
|
|
229
245
|
return `- ${promptData(canonical)} — ${ownership.length ? ownership.join(' + ') : '归属未确定'}` +
|
|
230
|
-
`(问题中命中:${[...value.aliases].map((alias) => promptData(alias)).join('、')})`
|
|
246
|
+
`(问题中命中:${[...value.aliases].map((alias) => promptData(alias)).join('、')}${basis})`
|
|
231
247
|
})
|
|
232
248
|
const enabled = enabledGames.map((game) => GAME_LABELS[game]).filter(Boolean)
|
|
233
249
|
const relationLines = (result.relation_hints || []).map((hint) =>
|
|
234
250
|
hint.kind === 'retraveler_memory_prototype'
|
|
235
251
|
? `- ${promptData(hint.endfield_name)}:再旅者;泰拉记忆原型=${promptData(hint.terra_memory_prototype || '未登记')}。检索词:${(Array.isArray(hint.query_terms) ? hint.query_terms : []).map((term) => promptData(term)).join('、')}。两者不是别名。`
|
|
236
252
|
: `- ${promptData(hint.endfield_name)} / ${promptData(hint.arknights_name)}:仅登记外观相似;现有剧情没有关系证据,不得推断为再旅者或记忆原型。`)
|
|
253
|
+
const conflictLines = (result.alias_conflicts || []).map((item) =>
|
|
254
|
+
`- 别名冲突:${promptData(item.alias)} 同时被 ${item.canonicals.map((name) => promptData(name)).join('、')} 声明为别名。本轮已忽略该别名、不归属任何实体;需要这些实体时请直呼规范名或分别检索。`)
|
|
237
255
|
return [
|
|
238
256
|
'<prts:retrieval-context>',
|
|
239
257
|
`当前搭载资料:${enabled.join('、') || '无'}。`,
|
|
240
258
|
...(lines.length ? ['用户问题中识别到的规范实体与游戏归属:', ...lines] : []),
|
|
241
259
|
...relationLines,
|
|
260
|
+
...conflictLines,
|
|
242
261
|
'</prts:retrieval-context>',
|
|
243
262
|
].join('\n')
|
|
244
263
|
}
|
|
@@ -302,6 +321,8 @@ export function applyEntityRecognition(ctx, store, shared = null) {
|
|
|
302
321
|
ctx.systemPrompt.context({
|
|
303
322
|
name: 'prts-terrarchive:retrieval-entities',
|
|
304
323
|
order: 1000,
|
|
324
|
+
// Recognized entity names are user content: never resolve {{name}} in them.
|
|
325
|
+
interpolate: false,
|
|
305
326
|
text: ({ scope }) => {
|
|
306
327
|
const state = turns.get(scope)
|
|
307
328
|
return state?.context && hasVisibleRetrievalSkill(scope, []) ? state.context : ''
|
package/src/entity-routing.js
CHANGED
|
@@ -30,8 +30,11 @@ function mergeRelationCatalog(fallback, installed) {
|
|
|
30
30
|
/** 资料包内关系表优先,插件内置表只为旧包提供最小兼容路由。 */
|
|
31
31
|
export async function loadEntityRelationCatalog(store) {
|
|
32
32
|
const dataVersion = store?.dataVersion ?? 'no-store'
|
|
33
|
+
const scopeFingerprint = store?.scopeFingerprint ?? null
|
|
34
|
+
const installationId = store?.installationId ?? null
|
|
33
35
|
const cached = store?._endfieldRelationCatalog
|
|
34
|
-
if (cached?.dataVersion === dataVersion
|
|
36
|
+
if (cached?.dataVersion === dataVersion && cached.scopeFingerprint === scopeFingerprint
|
|
37
|
+
&& cached.installationId === installationId) return cached.value
|
|
35
38
|
let installed = {}
|
|
36
39
|
try {
|
|
37
40
|
const loaded = await store?.getDocumentByPath?.('config/retravelers.json')
|
|
@@ -39,7 +42,7 @@ export async function loadEntityRelationCatalog(store) {
|
|
|
39
42
|
installed = JSON.parse((record?.lines || []).map((line) => line.text).join('\n') || '{}')
|
|
40
43
|
} catch { installed = {} }
|
|
41
44
|
const value = mergeRelationCatalog(await bundledRelations(), installed)
|
|
42
|
-
if (store) store._endfieldRelationCatalog = { dataVersion, value }
|
|
45
|
+
if (store) store._endfieldRelationCatalog = { dataVersion, scopeFingerprint, installationId, value }
|
|
43
46
|
return value
|
|
44
47
|
}
|
|
45
48
|
|
|
@@ -72,11 +75,22 @@ export function relevantRetravelerRelations(catalog, ...values) {
|
|
|
72
75
|
}))
|
|
73
76
|
}
|
|
74
77
|
|
|
75
|
-
/**
|
|
78
|
+
/**
|
|
79
|
+
* 仅双模块启用、且本次请求本身覆盖两款游戏时,把人工审校关系作为独立附属字段挂到
|
|
80
|
+
* 检索结果。只按请求参数与命中文档标题判断相关性:命中正文里顺带出现某个名字
|
|
81
|
+
* (如语音里提到“艾雅法拉”)不代表用户在问跨游戏关系,挂上只会制造噪声。
|
|
82
|
+
*/
|
|
76
83
|
export async function attachRetravelerRelations(store, response, request, enabledGames) {
|
|
77
84
|
if (!enabledGames?.includes('arknights') || !enabledGames?.includes('endfield')
|
|
78
85
|
|| response?.status === 'error' || response?.error) return response
|
|
86
|
+
const requestedGames = Array.isArray(request?.games) && request.games.length ? request.games : null
|
|
87
|
+
if (requestedGames && !(requestedGames.includes('arknights') && requestedGames.includes('endfield'))) {
|
|
88
|
+
return response
|
|
89
|
+
}
|
|
79
90
|
const catalog = await loadEntityRelationCatalog(store)
|
|
80
|
-
const
|
|
91
|
+
const { after: _after, cursor: _cursor, ...requestFields } = request || {}
|
|
92
|
+
const titles = (Array.isArray(response?.documents) ? response.documents : [])
|
|
93
|
+
.map((document) => document?.title).filter(Boolean)
|
|
94
|
+
const relations = relevantRetravelerRelations(catalog, requestFields, titles)
|
|
81
95
|
return relations.length ? { ...response, retraveler_relations: relations } : response
|
|
82
96
|
}
|
package/src/evidence-state.js
CHANGED
|
@@ -293,7 +293,8 @@ export function replayCoveredRead(state, requested, contract) {
|
|
|
293
293
|
stats: { elapsed_ms: 0, scanned_documents: 0, scanned_lines: 0, returned_chars: 0,
|
|
294
294
|
estimated_input_tokens: 0, truncated: hasMore, reused_local_evidence: true },
|
|
295
295
|
warnings: [],
|
|
296
|
-
guidance: '该范围已在当前模型可见的上文工具结果中完整出现;这里复用已核验的本地原文,不要再次读取相同范围。'
|
|
296
|
+
guidance: '该范围已在当前模型可见的上文工具结果中完整出现;这里复用已核验的本地原文,不要再次读取相同范围。'
|
|
297
|
+
+ '若上文其实已经看不到这些行(被压缩或折叠),传 reuse:false 重发本页全文。',
|
|
297
298
|
}
|
|
298
299
|
}
|
|
299
300
|
|
|
@@ -327,6 +328,7 @@ export function reuseReadCoverage(response, plan) {
|
|
|
327
328
|
},
|
|
328
329
|
warnings: [...(response.warnings || []), { code: 'PARTIAL_READ_REUSED',
|
|
329
330
|
message: `本页已有 ${plan.reusedRanges.length} 段经过核验,本次只返回新读取行。` }],
|
|
330
|
-
guidance: '本页与当前模型可见的上文原文部分重叠;这里只返回新读取行,coverage.reused_ranges 对应旧行就在上方可见工具结果中。'
|
|
331
|
+
guidance: '本页与当前模型可见的上文原文部分重叠;这里只返回新读取行,coverage.reused_ranges 对应旧行就在上方可见工具结果中。'
|
|
332
|
+
+ '若上文其实已经看不到这些行(被压缩或折叠),传 reuse:false 重发本页全文。',
|
|
331
333
|
}
|
|
332
334
|
}
|
package/src/i18n.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** Official localizations: exact source identities, independent of reading coverage. */
|
|
2
2
|
import { createHash } from 'node:crypto'
|
|
3
3
|
import { assertCorpusVersion, corpusVersionSnapshot, documentGame, documentUid,
|
|
4
|
-
naturalDocumentTitle } from './store.js'
|
|
4
|
+
naturalDocumentTitle, DATA_VERSION_REF_PATTERN, dataVersionMatches, shortDataVersion } from './store.js'
|
|
5
5
|
import { LOCALIZATION_LANGUAGES } from './localization-format.js'
|
|
6
6
|
|
|
7
7
|
const PACK = 'endfield_official_game'
|
|
@@ -23,7 +23,7 @@ for (const [locale, code] of Object.entries({ 'en-us': 'EN', 'ja-jp': 'JP', 'ko-
|
|
|
23
23
|
const fault = (code, message) => Object.assign(new Error(message), { code,
|
|
24
24
|
retryable: code === 'PACKAGE_VERSION_MISMATCH' })
|
|
25
25
|
|
|
26
|
-
export const I18N_DESCRIPTION = '查询终末地官方本地化原文。query/title/document_uid/text_ids
|
|
26
|
+
export const I18N_DESCRIPTION = '查询终末地官方本地化原文。query/title/document_uid/text_ids 四选一;同文异译按来源消歧,中文行号不是译文行号。续页原样提交返回的“续页”调用。原文与各语言译文完全相同的条目会合并为一组并汇总出处。'
|
|
27
27
|
|
|
28
28
|
export const I18N_PARAMETERS = {
|
|
29
29
|
type: 'object', additionalProperties: false, required: ['languages'],
|
|
@@ -40,9 +40,9 @@ export const I18N_PARAMETERS = {
|
|
|
40
40
|
include_ids: { type: 'boolean', description: '核验时返回内部 ID,默认 false' },
|
|
41
41
|
max_matches: { type: 'integer', description: '每页候选数,默认 5,最多 20' },
|
|
42
42
|
max_chars: { type: 'integer', description: '每页原文和译文的字符预算,默认 16000,范围 1000–48000' },
|
|
43
|
-
data_version: { type: 'string', description: '
|
|
43
|
+
data_version: { type: 'string', description: '已有结果的资料版本(可省略;续页给出的短版本号即可)' },
|
|
44
44
|
after: { type: 'object', additionalProperties: false,
|
|
45
|
-
required: ['
|
|
45
|
+
required: ['position', 'text_offset'],
|
|
46
46
|
properties: { data_version: { type: 'string' }, request_hash: { type: 'string' },
|
|
47
47
|
position: { type: 'integer' }, text_offset: { type: 'integer' } },
|
|
48
48
|
description: '原样复制 page.continuation.after' },
|
|
@@ -119,14 +119,15 @@ export function normalizeI18nRequest(args) {
|
|
|
119
119
|
request.max_matches = integer(args.max_matches ?? 5, 1, 20, 'max_matches')
|
|
120
120
|
request.max_chars = integer(args.max_chars ?? 16000, 1000, 48000, 'max_chars')
|
|
121
121
|
if (args.data_version !== undefined) {
|
|
122
|
-
if (typeof args.data_version !== 'string' ||
|
|
122
|
+
if (typeof args.data_version !== 'string' || !DATA_VERSION_REF_PATTERN.test(args.data_version)) throw fault('INVALID_REQUEST', 'data_version 必须是续页给出的资料版本(至少 8 位十六进制)')
|
|
123
123
|
request.data_version = args.data_version
|
|
124
124
|
}
|
|
125
125
|
if (args.after !== undefined) {
|
|
126
126
|
const after = args.after
|
|
127
127
|
if (!after || typeof after !== 'object' || Array.isArray(after)
|
|
128
|
-
|| Object.keys(after).
|
|
129
|
-
||
|
|
128
|
+
|| Object.keys(after).some((key) => !['data_version', 'position', 'request_hash', 'text_offset'].includes(key))
|
|
129
|
+
|| (after.data_version !== undefined && !DATA_VERSION_REF_PATTERN.test(String(after.data_version)))
|
|
130
|
+
|| (after.request_hash !== undefined && !/^[0-9a-f]{8,64}$/u.test(String(after.request_hash)))) {
|
|
130
131
|
throw fault('INVALID_REQUEST', 'after 必须原样使用工具返回的续页位置')
|
|
131
132
|
}
|
|
132
133
|
request.after = { ...after,
|
|
@@ -254,13 +255,13 @@ export async function executeI18n(store, raw, { signal, enabledGames = ['endfiel
|
|
|
254
255
|
await store.ready()
|
|
255
256
|
const snapshot = corpusVersionSnapshot(store)
|
|
256
257
|
checkpoint(store, snapshot, signal)
|
|
257
|
-
if ((request.data_version && request.data_version
|
|
258
|
-
|| (request.after && request.after.data_version
|
|
258
|
+
if ((request.data_version && !dataVersionMatches(request.data_version, snapshot.dataVersion))
|
|
259
|
+
|| (request.after?.data_version && !dataVersionMatches(request.after.data_version, snapshot.dataVersion))) {
|
|
259
260
|
throw fault('PACKAGE_VERSION_MISMATCH', '本地化查询所属资料版本已变化,请重新查询')
|
|
260
261
|
}
|
|
261
262
|
const { after, data_version: _version, ...identity } = request
|
|
262
263
|
const requestHash = sha256(JSON.stringify(identity))
|
|
263
|
-
if (after && after.request_hash
|
|
264
|
+
if (after?.request_hash && !requestHash.startsWith(after.request_hash)) throw fault('INVALID_REQUEST', '续页参数与原查询不一致')
|
|
264
265
|
const base = { game: 'endfield', data_version: snapshot.dataVersion,
|
|
265
266
|
source_language: request.source_language, languages: request.languages }
|
|
266
267
|
const pack = store.packs.get(PACK), meta = pack?.localization
|
|
@@ -319,6 +320,19 @@ export async function executeI18n(store, raw, { signal, enabledGames = ['endfiel
|
|
|
319
320
|
return a[0] - b[0] || a[1] - b[1] || a[2].localeCompare(b[2], 'en', { numeric: true })
|
|
320
321
|
})
|
|
321
322
|
}
|
|
323
|
+
// 按名称查询时同一译名常对应上千个文本 ID(每段对话的说话人字段各一份);原文与所有
|
|
324
|
+
// 请求语言完全一致的条目合并为一组,出处汇总到代表条目,避免逐页翻同一个译名。
|
|
325
|
+
const groupMembers = new Map()
|
|
326
|
+
if (request.query && ids.length > 1) {
|
|
327
|
+
const bySignature = new Map()
|
|
328
|
+
for (const id of ids) {
|
|
329
|
+
const signature = JSON.stringify(requestedLanguages.map((lang) => dictionaries.get(lang)?.byId.get(id) ?? null))
|
|
330
|
+
const representative = bySignature.get(signature)
|
|
331
|
+
if (representative === undefined) { bySignature.set(signature, id); groupMembers.set(id, [id]) }
|
|
332
|
+
else groupMembers.get(representative).push(id)
|
|
333
|
+
}
|
|
334
|
+
ids = [...groupMembers.keys()]
|
|
335
|
+
}
|
|
322
336
|
// Preserve source/catalog order for document reads and deterministic query pages.
|
|
323
337
|
let position = after?.position ?? 0, offset = after?.text_offset ?? 0
|
|
324
338
|
if (position > ids.length || (position === ids.length && offset)) throw fault('INVALID_REQUEST', '续页位置超出查询结果')
|
|
@@ -334,8 +348,11 @@ export async function executeI18n(store, raw, { signal, enabledGames = ['endfiel
|
|
|
334
348
|
const values = [sourceValue, ...Object.values(translations)]
|
|
335
349
|
const maximumLength = Math.max(...requestedLanguages.map((lang) => [...(dictionaries.get(lang)?.byId.get(id) || '')].length))
|
|
336
350
|
if (offset && offset >= maximumLength) throw fault('INVALID_REQUEST', '记录续页偏移超出正文')
|
|
337
|
-
const
|
|
351
|
+
const members = groupMembers.get(id) || [id]
|
|
352
|
+
const refs = members.flatMap((member) => catalog.byId.get(member).references)
|
|
353
|
+
.filter((ref) => !documentId || ref.document_id === documentId)
|
|
338
354
|
matches.push({ source: { language: request.source_language, ...sourceValue }, translations,
|
|
355
|
+
...(members.length > 1 ? { merged_text_count: members.length } : {}),
|
|
339
356
|
references: refs.slice(0, 8).map((ref) => publicReference(store, ref)), reference_count: refs.length,
|
|
340
357
|
...(request.include_ids ? { text_id: id, source_fields: entry.sources.slice(0, 8),
|
|
341
358
|
source_field_count: entry.sources.length } : {}) })
|
|
@@ -349,11 +366,49 @@ export async function executeI18n(store, raw, { signal, enabledGames = ['endfiel
|
|
|
349
366
|
matches, total_matches: ids.length,
|
|
350
367
|
...(request.text_ids ? { missing_text_ids: request.text_ids.filter((id) => !catalog.byId.has(id)) } : {}),
|
|
351
368
|
note: '返回官方本地化原文;引用请注明语言。中文行号仅用于定位,record/document 对齐不表示跨语言逐行对应。',
|
|
352
|
-
page: { has_more: hasMore, continuation: hasMore ? { ...identity,
|
|
353
|
-
|
|
354
|
-
request_hash: requestHash, position, text_offset: offset } } : null } }
|
|
369
|
+
page: { has_more: hasMore, continuation: hasMore ? { ...compactIdentity(identity),
|
|
370
|
+
after: { data_version: shortDataVersion(snapshot.dataVersion),
|
|
371
|
+
request_hash: requestHash.slice(0, 12), position, text_offset: offset } } : null } }
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** 续页调用只保留用户实际给出的参数,默认值不重复回显。 */
|
|
375
|
+
function compactIdentity(identity) {
|
|
376
|
+
const defaults = { source_language: 'CN', match_mode: 'exact', include_ids: false, max_matches: 5, max_chars: 16000 }
|
|
377
|
+
return Object.fromEntries(Object.entries(identity).filter(([key, value]) =>
|
|
378
|
+
value !== undefined && value !== null && defaults[key] !== value))
|
|
355
379
|
}
|
|
356
380
|
|
|
381
|
+
function renderValue(value) {
|
|
382
|
+
if (!value) return '(无)'
|
|
383
|
+
if (value.status === 'unavailable') return '(未安装该语言)'
|
|
384
|
+
if (value.status === 'missing_localization') return '(该语言无对应译文)'
|
|
385
|
+
const range = value.truncated || value.character_start
|
|
386
|
+
? ` 〔第 ${value.character_start + 1}–${value.character_end} 字 / 共 ${value.total_characters} 字〕` : ''
|
|
387
|
+
return `${value.text}${range}`
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** 文本投影:与其他检索工具一致,按条目分块展示原文、译文与出处,不直接回显 JSON。 */
|
|
357
391
|
export function renderI18n(_args, value) {
|
|
358
|
-
return [{ type: 'text', text:
|
|
392
|
+
if (!value || typeof value !== 'object') return [{ type: 'text', text: '[corpus_i18n:error] 无法投影本地化结果' }]
|
|
393
|
+
const lines = [`# 终末地官方本地化(${value.game_version || '版本未知'}):${value.source_language} → ${(value.languages || []).join('/')}`]
|
|
394
|
+
if (value.status !== 'ok') {
|
|
395
|
+
lines.push(value.message || (value.status === 'not_found'
|
|
396
|
+
? '未找到匹配原文。名称默认 exact 精确匹配整条文本;片段请设 match_mode="literal"。本工具只收录终末地官方本地化,明日方舟文本不在范围内。'
|
|
397
|
+
: value.status === 'unmapped_source' ? '该定位没有可对齐的本地化文本。' : `状态:${value.status}`))
|
|
398
|
+
}
|
|
399
|
+
if (value.total_matches) {
|
|
400
|
+
const raw = value.matches.reduce((sum, match) => sum + (match.merged_text_count || 1), 0)
|
|
401
|
+
lines.push(`共 ${value.total_matches} 组不同原文/译文${raw > value.matches.length ? `;本页 ${value.matches.length} 组合并自 ${raw} 条同文文本` : ''}。`)
|
|
402
|
+
}
|
|
403
|
+
value.matches.forEach((match, index) => {
|
|
404
|
+
lines.push('', `${index + 1}. ${match.source.language}:${renderValue(match.source)}`)
|
|
405
|
+
for (const [lang, translation] of Object.entries(match.translations || {})) lines.push(` ${lang}:${renderValue(translation)}`)
|
|
406
|
+
const refs = (match.references || []).slice(0, 3).map((ref) => ref.citation)
|
|
407
|
+
if (refs.length) lines.push(` 出处${match.reference_count > refs.length ? `(共 ${match.reference_count} 处,示例)` : ''}:${refs.join(';')}`)
|
|
408
|
+
if (match.text_id) lines.push(` text_id=${match.text_id}`)
|
|
409
|
+
})
|
|
410
|
+
if (value.missing_text_ids?.length) lines.push(`未找到的 text_ids:${value.missing_text_ids.join('、')}`)
|
|
411
|
+
if (value.note) lines.push('', value.note)
|
|
412
|
+
if (value.page?.has_more && value.page.continuation) lines.push(`续页:corpus_i18n(${JSON.stringify(value.page.continuation)})`)
|
|
413
|
+
return [{ type: 'text', text: lines.join('\n') }]
|
|
359
414
|
}
|