cortico-world-simple-trpg-check 0.2.1 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,114 +1,118 @@
1
- <!-- Owner: src/definition.ts, src/world.ts, src/config.ts, src/request.ts, src/readout.ts, src/level-readout.ts, src/console/client.ts -->
2
-
3
- # cortico-world-simple-trpg-check
4
-
5
- `cortico-world-simple-trpg-check` 是一个面向 Cortico 的 World 扩展。agent 调用 `simple_trpg_check_roll` 提交角色、情境与逐项技能,插件给出每项技能的难度并逐项掷骰;两个智能体互相比试时改用 `simple_trpg_check_roll_contest`。回执末尾自带面向用户的成品格式,agent 照抄即可。
6
-
7
- ## 功能
8
-
9
- - `simple_trpg_check_roll` 接收结构化的 `character`、`situation` 与 `checks`;`situation` 的五个字段加上 `character.condition` 是必填的情境六维,没有影响也要写「无」。
10
- - 每项技能的难度就是角色在该技能上的水平,也就是这次行动的达成概率百分数;可表达区间是 [1%, 99%]。
11
- - 1 是大成功,100 是大失败;其余骰值不超过难度的 ¼ 为极难成功、½ 为困难成功、全值为普通成功,否则普通失败。
12
- - 判定模型只从 `evidence` 读出角色属于哪一档(十档水平表),读出把档位分布映射成难度;运行时只查表,不训练。
13
- - 情景代偿不进模型:`checks[i].bonus` 是 −2…2 的奖励/惩罚骰合计值(包含本项花掉的 banked 奖励骰),掷 1+|bonus| 颗取极值;`|bonus| ≥ 3` 时直接自动成功/失败,不调用判定模型也不掷骰。`checks[i].grade` 取 1(困难)或 2(极难),只把档位标签整体下压,阈值不动。
14
- - 顶层 `joint`(`and`/`or`)把一次调用里的多项技能合成一次投掷:难度与奖惩骰都按 `and` 取最小、`or` 取最大。
15
- - `simple_trpg_check_roll_contest` 对抗检定:两侧各写一份完整请求、各判一次,比成功等级定胜负;同级比难度数值,相等判玩家胜。任一侧失败则整次失败、不掷骰;对抗不触发奖励骰,也不接受 `grade`。
16
- - 奖励骰的授予、记账与落盘由 agent 在外部工具里完成,本插件只在回执里给建议,不保存任何跨调用状态。
17
- - 请求按 token 估算设三条上限(总 1000 / 情境 768 / 单问 256,判定时留一成余量);对抗检定把两侧当作两份请求、各自检测。模型不可用、答案无效或超限时返回失败回执,不掷骰。
18
- - 支持 Jev(TypeSafe / OpenRouter / 自定义)与本地 Laya;控制台提供配置页、模型状态灯和「测试连接」。
19
-
20
- ## 安装
21
-
22
- 要求 Node.js 22 或更新版本,以及 Cortico World API 5。
23
-
24
- 在 Cortico 仓库的 `extensions/` 目录安装:
25
-
26
- ```powershell
27
- Set-Location "<Cortico 仓库路径>\extensions"
28
- corepack pnpm add --ignore-workspace cortico-world-simple-trpg-check
29
- ```
30
-
31
- 然后在目标实例的“扩展”页启用本 World,并重启进程。默认判定模型是本地多语言 Laya;实测它读不出角色水平(难度几乎恒定在 60 上下),要拿到可用的难度请在配置页把“判定模型”改成 Jev。
32
-
33
- ## 判定模型
34
-
35
- `worlds.simple-trpg-check.backend` 可选 `laya-multilingual`(默认)、`laya` 和 `jev`。
36
-
37
- - `laya-multilingual`:在“多语言 Laya Python 环境”下拉选单中选择已安装 `laya` 的 Conda 环境,使用 `convaiinnovations/laya` 的 `multilingual` 检查点。选项来自本机 `~/.conda/environments.txt`,缺失或不可读时只显示当前配置值。首次运行可能由 Laya 下载权重;后续使用本地缓存。
38
- - `laya`:通过可选依赖 `@receptron/laya` 在本机运行英文检查点。
39
- - `jev`:按 `worlds.simple-trpg-check.jevSource` 选择 TypeSafe、OpenRouter 或自定义 System One API。自定义来源使用 `jevEndpoint` 地址和 `CORTICO_JEV_API_KEY`。工具调用会把序列化成一个 `state.message` 的情境与逐项技能问题发送给所选服务。配置页在“Jev 来源”旁显示密钥状态和“打开密钥文件”按钮;点击后打开部署目录的 `.env`,缺少当前来源密钥时补入空的密钥行。填写并保存文件后,下一次模型请求读取密钥。旧的 World 专用变量名仍可读取。
40
-
41
- World 左栏的状态灯在首次成功完成模型评估后变绿;配置改变或评估失败后更新状态。
42
- 配置页顶部的“测试连接”会发送一项英文技能评分请求,不掷骰;结果显示在按钮旁,并更新左栏状态灯。
43
-
44
- 同一 Cortico 进程中,若 continuity 与此 World 使用相同的 Laya 类型(multilingual 还需同一 Python 路径),两者共用已加载的模型或 worker。请求依次执行;模型在所有使用者空闲后按其中最长的 TTL 回收。`layaIdleTtlMinutes` 默认 10;设为 `0` 且无其他使用者需要保留模型时,每次评分后释放。不同进程不共用运行实例。
45
-
46
- “强制适配多语言”勾选后,World 提示 agent 用英文填写 `character`、`situation` 与 `checks` 的取值;它不翻译输入,也不切换判定模型。问题文本里技能与目标照抄输入,因此问题语言跟随开关。
47
-
48
- 环境提示词让 agent 只在结果不确定且具有戏剧性的情境中酌情投骰、在投骰前提醒明显不奏效的方法(不强迫),并把回执末尾的「显示:」块原样拄成面向用户的文本。
49
-
50
- ## 判定规则
51
-
52
- `checks` 里的 `evidence` 是角色的水平证据,也是唯一直接决定难度的字段:不写数字与档位名,也不把情境写进去,只写是否以此谋生、训练与实操年限、学历或认证、同行中的相对地位、过往成败。`skill` 用环境提示词里的常用技能表(力量、体质、敏捷、智力…信用),有子分类的写具体子项;表外的技能名也能用。同一调用内多项技能默认是同一时刻的独立检定,顺序只决定回执排列;需要它们共同决定一个 `goal` 时用 `joint`;「先 A 再 B」的链条分两次调用,第二段要重写变化后的六维。
53
-
54
- 难度是角色水平的百分数。没有代偿时,在 1–99 内成功条件等价于 `骰值 ≤ 难度`,所以 P(成功) = 难度/100 精确成立,大成功与大失败不改变这个概率;钳位不是可选项——允许 0 时掷出的 1 会把概率抬到 1%,允许 100 时掷出的 100 会把它压到 99%。加上代偿后这条不再成立:`grade` 1 把成功率压到约难度/200、`grade` 2 压到约难度/400(阈值不动,只覆盖标签,大成功与大失败免疫);`bonus ≠ 0` 则掷 1+|bonus| 颗取极值,优势取最低、劣势取最高,暴击只判选中的那一颗。
55
-
56
- ### 情景代偿
57
-
58
- `bonus` 的标尺是 −2…2(−2 巨大劣势、−1 劣势、0 基准、1 优势、2 巨大优势),优势与劣势 1:1 抵消,agent 只填合计值;超出 ±2 合法,而且是用奖励骰跳过检定的正路。`grade` 只用于「任务本身更难」的场合,它不改变难度数值也不改变 `evidence`,只把显示出来的档位往下压一档(1)或两档(2)。`grade` 与 `bonus` 可以同时用,`grade` 与 `joint` 不能。自动判定(`|bonus| ≥ 3`)压倒一切:不调模型、不掷骰,`grade` 被静默忽略。
59
-
60
- ### 组合检定
61
-
62
- `joint: "and"` 要求列表里每项都成功,`joint: "or"` 只要任意一项成功。整组只掷一次骰,按有效难度判:`and` 取最小、`or` 取最大;有效 `bonus` 同构。出现 `joint` 时 `checks` 至少两项,且各项不得带 `grade`。自动判定按有效 `bonus` 判。
63
-
64
- ### 对抗检定
65
-
66
- 两侧各写一份完整请求(`actor` / `opponent`,含 `name`、`traits`、`condition`、各自的五维 `situation` 与 `checks`),各发一次判定请求。`bonus` 与 `joint` 挂在侧上,因为每侧只掷一次骰;某侧 `checks` 多于一项时必须给这一侧指定 `joint`。比档序列是 大成功 > 极难成功 > 困难成功 > 普通成功 > 普通失败 > 大失败;同级比难度数值,**数值高者胜**,相等判玩家胜,所以对抗永远有一个赢家。任一侧失败(模型失败、答案无效、超预算)则整次失败且不掷任何骰。某一侧 `|bonus| ≥ 3` 时按序短路定胜负(我方 ≥ 3 → 我方胜;对方 ≤ −3 → 我方胜;我方 ≤ −3 → 对方胜;对方 ≥ 3 → 对方胜),命中时两侧都不调模型、不掷骰,未定调的一侧显示「跳过检定」。对抗不触发奖励骰。
67
-
68
- ### 回执
69
-
70
- 每项两行诊断(档位、骰值/难度、一句结果陈述与固定建议),其后追加一个「显示:」块——那就是面向用户的成品格式,标记顺序固定为组合 → 难度 → 奖惩,普通成功与普通失败简写为「成功」「失败」。模型请求失败或输入超限时整次调用失败,回执只有 `技能判定失败:<原因>`,不附显示块。
71
-
72
- ## 水平读出
73
-
74
- 模型对每项技能提一个问题:「在技能 X 上,角色处于哪一档」,选项是十档水平描述:六个语义锚点(外行 / 初学者 / 业余者 / 职业=学士 / 专家=硕士博士 / 世界顶尖)相邻之间各插一档,键用中性字母 A–J。World 把回答分布过一遍**水平读出**再四舍五入、限制在 1–99。
75
-
76
- 读出是离线训练的产物,写在生成文件 `src/level-readout.ts`(一条单调分段线性表,字段与运行时行为见 `src/readout.ts`)。**发布包只带拟合好的读出,运行时不做任何训练**;本地 Laya 的检查点读不出水平证据,保持恒等读出。
77
-
78
- 配置页在“判定模型”下拉选单下方会提示:本地 Laya 检查点实测读不出角色水平(难度几乎恒定在 60 上下),建议改用 Jev。该提示不阻止选择,Laya 仍可用。
79
-
80
- 训练与实测留在本仓库的 `evals/`,不随包发布:
81
-
82
- ```sh
83
- corepack pnpm eval:train -- --collect # 调 Jev 收集语料,写 evals/dataset-readout-<档位数>.json
84
- corepack pnpm eval:train -- --fit # 拟合读出,重写 src/level-readout.ts 与报告
85
- ```
86
-
87
- 2026-09-28(十档,44 条语料:4 技能 × 5 锚点 + 2 技能 × 12 个细分水平):留一平均绝对误差 恒等 4.0 / 等渗 1.5(选中),全量平均绝对误差 0.33、带符号偏差 0.00。同日端到端 37 条:3 / 62 / 82 三档完全归位,12 与 35 各差 1 点,技能层平均绝对误差 0.25。信用评级沿用技能尺,仍是已知近似(CR 70 → 53、CR 94 → 34)。
88
-
89
- 读出默认完全采用拟合值;`--shrinkage=<γ>` 可把它往未训练基线收缩(实测更保守的设置误差更大,故默认 γ = 1)。
90
-
91
- ## 目录结构
92
-
93
- - `src/`(随包发布):运行时核心;`src/level-readout.ts` 是训练好的读出,属生成文件,不要手改。
94
- - `python/`(随包发布):多语言 Laya 的 worker。
95
- - `dist/`(随包发布):控制台面板产物。
96
- - `evals/`、`tests/`(仅本地):语料、训练与实测脚本、测试,不随包发布。
97
-
98
- ## 本地开发
99
-
100
- ```sh
101
- corepack pnpm install
102
- corepack pnpm typecheck
103
- corepack pnpm typecheck:web
104
- corepack pnpm test
105
- corepack pnpm build:console
106
- ```
107
-
108
- 包内的 `pnpm-workspace.yaml` 已允许 `esbuild` 与 `onnxruntime-node` 的安装脚本。multilingual 模式需要在下拉选单中选择已安装 `laya` 的 Python 环境。
109
-
110
- `evals/` 下的脚本走 `tsx`,不参与 `vitest`;重量级实测需要 Python 环境或 Jev 密钥,因此不放在 `pnpm test` 里。
111
-
112
- 在 Cortico 仓库根目录运行 `corepack pnpm check:extension <本包绝对路径>`。安装到运行中的实例需要重启进程;本包的测试和装载检查不会启动 bot。
113
-
114
- 项目采用 MIT License,详见 [LICENSE](LICENSE)。
1
+ <!-- Owner: src/definition.ts, src/world.ts, src/config.ts, src/request.ts, src/model.ts, src/ENV_PROMPT.md, src/display.ts, src/contest.ts -->
2
+
3
+ # Simple TRPG Check
4
+
5
+ Cortico 的 TRPG 技能检定 World 扩展,检定规则由COC7规则启发。agent 提供角色背景、技能证据和当前情境,Laya 或 Jev 估计角色的技能水平,扩展据此进行 d100 检定,返回骰值、结果和可直接展示的文本。
6
+
7
+ 支持单项检定、组合检定、对抗检定,以及奖励骰、惩罚骰和困难/极难任务。技能名可以自定义,不需要预先填写数值角色卡。
8
+
9
+ ## 安装与配置
10
+
11
+ 要求 Node.js 22 或更新版本,以及 Cortico World API 5。在 Cortico 仓库的 `extensions/` 目录安装:
12
+
13
+ ```powershell
14
+ Set-Location "<Cortico 仓库路径>\extensions"
15
+ corepack pnpm add --ignore-workspace cortico-world-simple-trpg-check
16
+ ```
17
+
18
+ 在目标部署的“扩展”页启用本 World,重启进程,再进入 World 的配置页选择判定模型。
19
+
20
+ | 判定模型 | 配置方式 |
21
+ | --- | --- |
22
+ | Jev | 将“判定模型”设为 `jev`,选择 TypeSafe、OpenRouter 或自定义来源,填写对应密钥。自定义来源还需配置服务地址。 |
23
+ | 多语言 Laya(默认) | 选择已安装 `laya` 的 Conda Python 环境。首次使用可能下载模型权重,后续使用本地缓存。 |
24
+ | 英文 Laya | 将“判定模型”设为 `laya`,通过可选依赖 `@receptron/laya` 在本机运行。 |
25
+
26
+ 现有评测中,本地 Laya 对不同技能水平证据的区分较弱,建议使用 Jev。选择后点击“测试连接”验证服务与密钥;该操作不掷骰。
27
+
28
+ Jev 密钥保存在部署目录的 `.env` 中,配置页的“打开密钥文件”可打开该文件。填写并保存后,下一次模型请求读取密钥。
29
+
30
+ | Jev 来源 | 密钥变量 |
31
+ | --- | --- |
32
+ | TypeSafe | `CORTICO_JEV_TYPESAFE_API_KEY` |
33
+ | OpenRouter | `CORTICO_JEV_OPENROUTER_API_KEY` |
34
+ | 自定义 | `CORTICO_JEV_API_KEY` |
35
+
36
+ “强制适配多语言”要求 agent 用英文填写请求内容,不翻译已有输入。切换判定模型或 Python 环境后需要重启进程。Laya 空闲释放时间默认 10 分钟;同一进程内共用模型时取各使用者中最长的保留时间。
37
+
38
+ ## World 兼容性
39
+
40
+ 按 [Cortico World 兼容等级](https://github.com/Pal-AI-Lab/Cortico/blob/main/docs/world-compatibility.md),本 World 的最低与完整等级均为 **L1**。
41
+
42
+ 宿主须挂载工具,用 `envPromptVars()` 填充 `ENV_PROMPT.md` 并将环境描述加入 system 前缀,调用 `start(host)` / `stop()`。检定返回文本回执,Laya 或 Jev 评分由本 World 自行执行,不使用事件投递、实时输出或 `WorldHost.cognition`。
43
+
44
+ 挂载时须提供 `WorldContext` 的配置与密钥读取。控制台不计入等级;没有控制台时,在宿主配置中选择评分后端,并按所选后端提供 Python 环境、服务地址、依赖或密钥。
45
+
46
+ ## 使用示例
47
+
48
+ 环境提示词会指导 agent 在结果不确定且情境具有戏剧性时调用检定工具。单项和组合检定使用 `simple_trpg_check_roll`:
49
+
50
+ ```json
51
+ {
52
+ "character": {
53
+ "traits": "常年带队登山的向导。",
54
+ "condition": "无。"
55
+ },
56
+ "situation": {
57
+ "weather": "晴天,无风。",
58
+ "gear": "绳索与铁锁齐全,状态良好。",
59
+ "info": "路线与岩点位置已知。",
60
+ "time": "无时限。",
61
+ "senses": "光线充足。"
62
+ },
63
+ "checks": [
64
+ {
65
+ "skill": "攀爬",
66
+ "goal": "攀上岩壁,到达平台。",
67
+ "evidence": "靠带队攀岩谋生,处理过大量同类线路,也能应付少见的难例。"
68
+ }
69
+ ]
70
+ }
71
+ ```
72
+
73
+ `evidence` 描述已经确立的能力证据,例如训练经历、实操经验和同行评价,不直接填写技能数值或档位名。伤势、工具、天气等情境因素填写在对应字段,由 agent 据此选择 `bonus` 或 `grade`。`character.condition` 与 `situation` 的五个字段都必填,无影响时写“无”。
74
+
75
+ 回执包含诊断结果,末尾的“显示:”块供 agent 原样展示。例如:
76
+
77
+ ```text
78
+ 【攀爬检定:80/62 失败】
79
+ ```
80
+
81
+ 两个会推理、会反应的角色互相比试时使用 `simple_trpg_check_roll_contest`。`actor` 和 `opponent` 各提供 `name`、`traits`、`condition`、`situation` 与 `checks`;每侧的 `bonus`、`joint` 填在该侧顶层。
82
+
83
+ 完整工具约定见 [环境提示词](src/ENV_PROMPT.md)。
84
+
85
+ ## 检定规则
86
+
87
+ 模型根据 `evidence` 估计技能水平,转换成 1–99 的检定阈值,回执中称为“难度”。无修正时,骰值不超过难度即成功;不超过难度的一半为困难成功,四分之一为极难成功。1 固定为大成功,100 固定为大失败。
88
+
89
+ 财富按收入、资产、生活水准和可支配资金评估,使用从贫困到富裕的档位;“信用”“信用评级”和对应英文名也可使用。
90
+
91
+ | 选项 | 行为 |
92
+ | --- | --- |
93
+ | `bonus` | 合计优势/劣势与花掉的奖励骰。通常为 −2…2;掷 `1 + |bonus|` 颗 d100,优势取最低、劣势取最高。达到 +3 自动成功,达到 −3 自动失败,均不调用模型、不掷骰。 |
94
+ | `grade` | 1 要求至少困难成功,2 要求至少极难成功。大成功与大失败不受影响。不能与 `joint` 同用。 |
95
+ | `joint` | 至少两项技能共用一次投骰。`and` 取各项难度和 `bonus` 的最小值,`or` 取最大值。 |
96
+ | 对抗 | 每侧投一次骰,先比成功等级,同级比难度数值,数值高者胜;再相等则 `actor` 胜。每侧多项技能必须指定该侧的 `joint`;对抗不接受 `grade`。 |
97
+
98
+ 同一次普通调用中的多项技能默认独立检定。需要“先 A 再 B”时分两次调用,第二次根据变化后的事实重写情境。
99
+
100
+ 奖励骰的授予和记账由 agent 通过外部工具完成,扩展不保存跨调用状态。对抗检定不触发奖励骰;某侧达到自动判定条件时,两侧都不调用模型、不掷骰,胜负规则见环境提示词。
101
+
102
+ 模型不可用、答案无效或输入超出预算时,整次调用返回失败原因,不掷骰。请求按 token 估算限制长度:情境 768、单项问题 256、总输入 1000,检测时预留 10% 余量。对抗的两侧分别检测。
103
+
104
+ ## 本地开发
105
+
106
+ ```sh
107
+ corepack pnpm install
108
+ corepack pnpm test
109
+ corepack pnpm typecheck
110
+ corepack pnpm typecheck:web
111
+ corepack pnpm build:console
112
+ ```
113
+
114
+ 在 Cortico 仓库根目录运行 `corepack pnpm check:extension <本包绝对路径>` 验证扩展。构建控制台前需停止本机正在运行的 bot 进程。
115
+
116
+ 评测方法、语料和离线读出训练见仓库内的 `evals/README.md`。运行时使用包内已有的读出表;`evals/` 和 `tests/` 不随 npm 包发布。
117
+
118
+ MIT License,见 [LICENSE](LICENSE)。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cortico-world-simple-trpg-check",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "type": "module",
5
5
  "description": "Cortico 的 TRPG 技能检定 World 扩展,使用 Laya 或 Jev 评估技能水平,支持 d100 掷骰、组合与对抗检定。",
6
6
  "license": "MIT",
package/src/ENV_PROMPT.md CHANGED
@@ -10,7 +10,9 @@
10
10
 
11
11
  技能名可以用下面这套常用表(有子分类的写具体项,例如格斗的「斗殴」「刀」、射击的「手枪」「步枪」、科学的「化学」「生物学」;表外的技能名也可以用,前后保持一致):
12
12
 
13
- 力量、体质、敏捷、智力(别名灵感)、意志、教育(别名知识)、说服、取悦、恐吓、欺骗、侦查、聆听、运动、潜行(别名乔装、追踪)、巧手(别名偷窃、伪造、锁匠)、驾驶、骑乘、导航、格斗、射击、闪避、投掷、爆破、急救、医学、科学、神秘学、历史、信用。
13
+ 力量、体质、敏捷、智力(别名灵感)、意志、教育(别名知识)、说服、取悦、恐吓、欺骗、侦查、聆听、运动、潜行(别名乔装、追踪)、巧手(别名偷窃、伪造、锁匠)、驾驶、骑乘、导航、格斗、射击、闪避、投掷、爆破、急救、医学、科学、神秘学、历史、财富(别名信用、信用评级)。
14
+
15
+ 财富的 `evidence` 写已确立的收入、资产、生活水准和可支配资金,判定按贫困至富裕的档位衡量。
14
16
 
15
17
  `situation` 的五个字段加上 `character.condition` 就是情境的全部六个维度,一个都不能省略;某项确实没有影响就写「无」。
16
18
 
package/src/model.ts CHANGED
@@ -10,7 +10,7 @@ import {
10
10
  } from './config.ts';
11
11
  import { startMultilingualLaya } from './multilingual-laya.ts';
12
12
  import { applyReadout, readoutFor } from './readout.ts';
13
- import { LEVEL_OPTIONS, questionsFor, serializeState, type CheckRequest, type LevelQuestion } from './request.ts';
13
+ import { LEVEL_OPTIONS, isWealthSkill, levelBands, questionsFor, serializeState, type CheckRequest, type LevelQuestion } from './request.ts';
14
14
  import { layaRuntimeKey, sharedLayaPool, type SharedLayaClient, type SharedLayaModel } from './shared-laya.ts';
15
15
 
16
16
  type Questions = Record<string, LevelQuestion>;
@@ -35,17 +35,16 @@ export async function loadEnglishLaya(): Promise<SharedLayaModel> {
35
35
  }
36
36
 
37
37
  /**
38
- * 水平读出:模型对每项技能给出档位分布(十档),读出把分布映射成 0–100 的水平。
39
- * Jev 走离线训练产物(src/level-readout.ts;训练与选择见 evals/train.ts),运行时只做插值,不训练。
40
- * 本地 Laya 检查点实测读不出水平证据(极端证据只挪动 15 点),保持恒等读出。
38
+ * 水平读出:普通技能的十档分布经读出映射为 0–100;财富的六档分布直接按代表值取期望。
39
+ * 普通技能的 Jev 读出使用 src/level-readout.ts,本地 Laya 保持恒等读出;运行时不训练。
41
40
  */
42
41
 
43
42
  /** 读一项技能的档位概率分布并归一化;缺项按 0 计。无效时抛错。 */
44
- export function probabilityVector(item: unknown, label: string): number[] {
43
+ export function probabilityVector(item: unknown, label: string, options: readonly string[] = LEVEL_OPTIONS): number[] {
45
44
  const probabilities = item && typeof item === 'object' ? (item as { probabilities?: unknown }).probabilities : undefined;
46
45
  if (!probabilities || typeof probabilities !== 'object') throw new Error(`${label}的模型答案无效`);
47
46
  const distribution = probabilities as Record<string, unknown>;
48
- const shares = LEVEL_OPTIONS.map((option) => {
47
+ const shares = options.map((option) => {
49
48
  const share = distribution[option];
50
49
  if (share === undefined) return 0;
51
50
  if (typeof share !== 'number' || !Number.isFinite(share) || share < 0 || share > 1) {
@@ -58,18 +57,21 @@ export function probabilityVector(item: unknown, label: string): number[] {
58
57
  return shares.map((share) => share / mass);
59
58
  }
60
59
 
61
- /** 读模型答案里的档位分布,过一遍对应后端的水平读出。 */
62
- export function levelsFromAnswers(result: unknown, count: number, backend: CheckBackend): number[] {
60
+ /** 按技能对应的标尺读出水平,返回顺序与技能列表一致。 */
61
+ export function levelsFromAnswers(result: unknown, skills: readonly string[], backend: CheckBackend): number[] {
63
62
  if (!result || typeof result !== 'object') throw new Error('模型没有返回判定结果');
64
63
  const answers = (result as { answers?: unknown }).answers;
65
64
  if (!answers || typeof answers !== 'object' || Array.isArray(answers)) throw new Error('模型没有返回技能答案');
66
65
  const keyed = answers as Record<string, unknown>;
67
- if (Object.keys(keyed).length !== count) throw new Error('模型返回的技能答案数量不匹配');
68
- const readout = readoutFor(backend);
69
- return Array.from({ length: count }, (_, index) => applyReadout(
70
- probabilityVector(keyed[`check_${index}`], `第 ${index + 1} 项技能`),
71
- readout,
72
- ));
66
+ if (Object.keys(keyed).length !== skills.length) throw new Error('模型返回的技能答案数量不匹配');
67
+ const readout = readoutFor(backend);
68
+ return skills.map((skill, index) => {
69
+ const bands = levelBands(true, skill);
70
+ const p = probabilityVector(keyed[`check_${index}`], `第 ${index + 1} 项技能`, LEVEL_OPTIONS.slice(0, bands.length));
71
+ return isWealthSkill(skill)
72
+ ? bands.reduce((sum, band, position) => sum + p[position]! * band.representative, 0)
73
+ : applyReadout(p, readout);
74
+ });
73
75
  }
74
76
 
75
77
  export class SystemOneSkillScorer implements SkillScorer {
@@ -84,7 +86,7 @@ export class SystemOneSkillScorer implements SkillScorer {
84
86
  const result = this.ctx.cfg.backend === 'jev'
85
87
  ? await this.requestJev(state, questions)
86
88
  : await this.getLayaClient().systemOne(state, questions);
87
- return levelsFromAnswers(result, request.checks.length, this.ctx.cfg.backend);
89
+ return levelsFromAnswers(result, request.checks.map((check) => check.skill), this.ctx.cfg.backend);
88
90
  }
89
91
 
90
92
  async close(): Promise<void> {
package/src/request.ts CHANGED
@@ -44,11 +44,7 @@ export function checksLabel(checks: CheckSpec[], joint?: Joint): string {
44
44
  }
45
45
 
46
46
  /**
47
- * 水平档位表:十档的标签与代表值。六个语义锚点(外行 / 初学者 / 业余者 / 职业=学士 /
48
- * 专家=硕士博士 / 世界顶尖)之间各插一档,使模型能表达两个锚点之间的位置;档位越密,
49
- * 读出越不会粘在锚点上。整张表对所有判定通用,不区分技能与信用;
50
- * 信用那套切点不同(0/1/10/50/90/99),这里用的是技能尺的代表值,属已知近似,
51
- * 等后续用别的手段代偿情境修正时一并重估。
47
+ * 档位描述与代表值。普通技能按训练与专业水平分十档,财富按生活水准与资产分六档。
52
48
  */
53
49
  export interface LevelBand {
54
50
  label: string;
@@ -80,12 +76,37 @@ const LEVEL_BANDS: Record<'zh' | 'en', LevelBand[]> = {
80
76
  { label: 'One of the very few recognised as the strongest in the field', representative: 89 },
81
77
  { label: 'Among the best in the world at this skill', representative: 95 },
82
78
  ],
83
- };
84
-
85
- export const LEVEL_OPTIONS = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J'] as const;
86
-
87
- export function levelBands(chinese: boolean): LevelBand[] {
88
- return LEVEL_BANDS[chinese ? 'zh' : 'en'];
79
+ };
80
+
81
+ const WEALTH_BANDS: Record<'zh' | 'en', LevelBand[]> = {
82
+ zh: [
83
+ { label: '身无分文,流落街头', representative: 0 },
84
+ { label: '极端贫困,勉强果腹', representative: 5 },
85
+ { label: '中等水平,在合理的前提下生活得比较舒适', representative: 30 },
86
+ { label: '富裕,某些条件下近乎奢侈', representative: 70 },
87
+ { label: '富有,生活无忧并极为奢侈', representative: 94 },
88
+ { label: '超级有钱,钱对你来说只是数字', representative: 99 },
89
+ ],
90
+ en: [
91
+ { label: 'Penniless and living on the streets', representative: 0 },
92
+ { label: 'Extremely poor, barely able to eat', representative: 5 },
93
+ { label: 'Average means, living reasonably comfortably', representative: 30 },
94
+ { label: 'Wealthy, with some luxuries', representative: 70 },
95
+ { label: 'Rich, living carefree and very luxuriously', representative: 94 },
96
+ { label: 'Extremely wealthy; money is just a number', representative: 99 },
97
+ ],
98
+ };
99
+
100
+ export const LEVEL_OPTIONS = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J'] as const;
101
+
102
+ const WEALTH_SKILLS = new Set(['财富', '信用', '信用评级', 'wealth', 'credit', 'credit rating']);
103
+
104
+ export function isWealthSkill(skill: string): boolean {
105
+ return WEALTH_SKILLS.has(skill.trim().toLowerCase());
106
+ }
107
+
108
+ export function levelBands(chinese: boolean, skill = ''): LevelBand[] {
109
+ return (isWealthSkill(skill) ? WEALTH_BANDS : LEVEL_BANDS)[chinese ? 'zh' : 'en'];
89
110
  }
90
111
 
91
112
  export interface LevelQuestion {
@@ -163,11 +184,13 @@ export function serializeState(request: CheckRequest): string {
163
184
  }
164
185
 
165
186
  /** 每项技能一个问题:角色在这一点上处于哪一档。选项键用中性字母,档位描述进 criteria。 */
166
- export function questionsFor(request: CheckRequest, chinese: boolean): Record<string, LevelQuestion> {
167
- const bands = levelBands(chinese);
168
- return Object.fromEntries(request.checks.map((check, index) => [`check_${index}`, {
169
- type: 'choice' as const,
170
- instructions: `In the skill "${check.skill}", which level is the character at? Character: ${check.evidence}`,
171
- criteria: Object.fromEntries(bands.map((band, position) => [LEVEL_OPTIONS[position]!, band.label])),
172
- }]));
173
- }
187
+ export function questionsFor(request: CheckRequest, chinese: boolean): Record<string, LevelQuestion> {
188
+ return Object.fromEntries(request.checks.map((check, index) => {
189
+ const bands = levelBands(chinese, check.skill);
190
+ return [`check_${index}`, {
191
+ type: 'choice' as const,
192
+ instructions: `In the skill "${check.skill}", which level is the character at? Character: ${check.evidence}`,
193
+ criteria: Object.fromEntries(bands.map((band, position) => [LEVEL_OPTIONS[position]!, band.label])),
194
+ }];
195
+ }));
196
+ }