dsh-plugin-show-me-data 0.1.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/LICENSE +27 -0
- package/README.md +96 -0
- package/cordis.patch.yml +40 -0
- package/docs/01-product-effect.md +178 -0
- package/docs/02-architecture.md +275 -0
- package/docs/03-data-contracts.md +291 -0
- package/docs/04-sources.md +342 -0
- package/docs/05-ui-spec.md +167 -0
- package/docs/06-ai-layer.md +194 -0
- package/docs/07-implementation-plan.md +399 -0
- package/docs/08-test-plan.md +133 -0
- package/docs/09-packaging-install.md +249 -0
- package/docs/10-kickoff-prompt.md +94 -0
- package/docs/11-decisions.md +203 -0
- package/docs/12-runtime-verified.md +115 -0
- package/docs/13-acceptance.md +153 -0
- package/docs/14-progress.md +150 -0
- package/docs/15-publish.md +185 -0
- package/lib/app/ai-deterministic.js +327 -0
- package/lib/app/ai-validate.js +284 -0
- package/lib/app/ai.js +440 -0
- package/lib/app/health.js +77 -0
- package/lib/app/overview.js +349 -0
- package/lib/app/propose-indicator.js +122 -0
- package/lib/app/refresh.js +251 -0
- package/lib/app/series-view.js +195 -0
- package/lib/app/watchlist.js +102 -0
- package/lib/client.js +4322 -0
- package/lib/core/ai/prompts.js +213 -0
- package/lib/core/chart/axis.js +133 -0
- package/lib/core/chart/bar.js +58 -0
- package/lib/core/chart/candle.js +216 -0
- package/lib/core/chart/line.js +186 -0
- package/lib/core/chart/scale.js +132 -0
- package/lib/core/format.js +143 -0
- package/lib/core/indicators/catalog.js +1011 -0
- package/lib/core/indicators/resolve.js +196 -0
- package/lib/core/insight/digest.js +250 -0
- package/lib/core/insight/rank.js +115 -0
- package/lib/core/insight/related.js +90 -0
- package/lib/core/insight/rules.js +417 -0
- package/lib/core/stats/derive.js +123 -0
- package/lib/core/stats/series.js +465 -0
- package/lib/core/time/range.js +242 -0
- package/lib/core/types.js +478 -0
- package/lib/host/ai/discussion.js +559 -0
- package/lib/host/ai/dsh-llm-gateway.js +333 -0
- package/lib/host/config.js +194 -0
- package/lib/host/http/respond.js +165 -0
- package/lib/host/http/routes.js +689 -0
- package/lib/host/index.js +293 -0
- package/lib/host/infra/fs-repos.js +179 -0
- package/lib/host/infra/memory-fallback.js +64 -0
- package/lib/host/tools/define-tool.js +295 -0
- package/lib/host/tools/register.js +431 -0
- package/lib/host.js +7 -0
- package/lib/ports/clock.js +57 -0
- package/lib/ports/snapshot-repo.js +48 -0
- package/lib/sources/eastmoney-macro.js +197 -0
- package/lib/sources/eastmoney-quote.js +201 -0
- package/lib/sources/ecb.js +179 -0
- package/lib/sources/fred.js +207 -0
- package/lib/sources/http.js +136 -0
- package/lib/sources/ohlc.js +36 -0
- package/lib/sources/quote-cascade.js +177 -0
- package/lib/sources/registry.js +153 -0
- package/lib/sources/sina-cn.js +197 -0
- package/lib/sources/sina-us.js +187 -0
- package/lib/sources/tencent.js +158 -0
- package/lib/sources/us-treasury-rates.js +275 -0
- package/lib/sources/us-treasury.js +196 -0
- package/lib/sources/worldbank.js +170 -0
- package/package.json +69 -0
- package/src/app/ai-deterministic.js +327 -0
- package/src/app/ai-validate.js +284 -0
- package/src/app/ai.js +440 -0
- package/src/app/health.js +77 -0
- package/src/app/overview.js +349 -0
- package/src/app/propose-indicator.js +122 -0
- package/src/app/refresh.js +251 -0
- package/src/app/series-view.js +195 -0
- package/src/app/watchlist.js +102 -0
- package/src/client/api.js +323 -0
- package/src/client/components.js +1877 -0
- package/src/client/copy.js +368 -0
- package/src/client/index.js +169 -0
- package/src/client/store.js +219 -0
- package/src/core/ai/prompts.js +213 -0
- package/src/core/chart/axis.js +133 -0
- package/src/core/chart/bar.js +58 -0
- package/src/core/chart/candle.js +216 -0
- package/src/core/chart/line.js +186 -0
- package/src/core/chart/scale.js +132 -0
- package/src/core/format.js +143 -0
- package/src/core/indicators/catalog.js +1011 -0
- package/src/core/indicators/resolve.js +196 -0
- package/src/core/insight/digest.js +250 -0
- package/src/core/insight/rank.js +115 -0
- package/src/core/insight/related.js +90 -0
- package/src/core/insight/rules.js +417 -0
- package/src/core/stats/derive.js +123 -0
- package/src/core/stats/series.js +465 -0
- package/src/core/time/range.js +242 -0
- package/src/core/types.js +478 -0
- package/src/host/ai/discussion.js +559 -0
- package/src/host/ai/dsh-llm-gateway.js +333 -0
- package/src/host/config.js +194 -0
- package/src/host/http/respond.js +165 -0
- package/src/host/http/routes.js +689 -0
- package/src/host/index.js +293 -0
- package/src/host/infra/fs-repos.js +179 -0
- package/src/host/infra/memory-fallback.js +64 -0
- package/src/host/tools/define-tool.js +295 -0
- package/src/host/tools/register.js +431 -0
- package/src/ports/clock.js +57 -0
- package/src/ports/snapshot-repo.js +48 -0
- package/src/sources/eastmoney-macro.js +197 -0
- package/src/sources/eastmoney-quote.js +201 -0
- package/src/sources/ecb.js +179 -0
- package/src/sources/fred.js +207 -0
- package/src/sources/http.js +136 -0
- package/src/sources/ohlc.js +36 -0
- package/src/sources/quote-cascade.js +177 -0
- package/src/sources/registry.js +153 -0
- package/src/sources/sina-cn.js +197 -0
- package/src/sources/sina-us.js +187 -0
- package/src/sources/tencent.js +158 -0
- package/src/sources/us-treasury-rates.js +275 -0
- package/src/sources/us-treasury.js +196 -0
- package/src/sources/worldbank.js +170 -0
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# 15 · 打包与发布(npm / GitHub)
|
|
2
|
+
|
|
3
|
+
本文回答两件事:**这个插件能不能打包发布、能不能推到远程 GitHub 仓库**,以及把当前状态、还差的步骤和
|
|
4
|
+
所有需要你自己决定/授权的项一次列清。相关背景见 `09-packaging-install.md`(本机安装与挂载)。
|
|
5
|
+
|
|
6
|
+
结论先说:
|
|
7
|
+
|
|
8
|
+
| 问题 | 答案 | 依据 |
|
|
9
|
+
| --- | --- | --- |
|
|
10
|
+
| 能打成 npm 包吗? | **能**。`npm pack` 产出 337 KB / 127 文件(解包 1.5 MB),**不含** 8.3 MB 的测试 fixtures | `npm pack --dry-run` |
|
|
11
|
+
| 打出来的包装得上、跑得起来吗? | **能**。把 tarball 解到 profile 的 `node_modules` 里(**不是**软链回源码树)后真实启动:11 个源、60 个指标、8 个工具、前端半被 boot 页面引用 | `RUN_BOOT=1 node --test test/net/boot.test.js` → `the packed tarball boots a profile without the source tree` ✅ |
|
|
12
|
+
| 能推到 GitHub 吗? | **能**,仓库已经在本地初始化并 `git add` 完毕(284 文件)。推送需要**你的**远端与凭据 | 见 §3 |
|
|
13
|
+
| 能直接 `npm publish` 吗? | **还不能**,需要你先定两件事:**包名**、**LICENSE 署名**(`private: true` 也要去掉) | 见 §2 |
|
|
14
|
+
|
|
15
|
+
> 本机现状:没有 `gh` CLI、没有 SSH key、没有 git 身份配置、`npm whoami` 未登录。所以**任何需要凭据的动作
|
|
16
|
+
> 都必须由你执行**(或把 token 配好后再让我跑)。
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## 1. 已经做好的(本次提交前已就绪)
|
|
21
|
+
|
|
22
|
+
| 项 | 状态 |
|
|
23
|
+
| --- | --- |
|
|
24
|
+
| `npm pack` 自洽 | `files` 只含 `lib`/`src`/`docs`/`cordis.patch.yml`/`README.md`/`LICENSE`;`test/`(含 8.3 MB fixtures)不入包 |
|
|
25
|
+
| 发布前自动重建产物 | `prepublishOnly` = 构建 host + client 并跑全量测试(157 项测试文件 → 683 个断言级用例) |
|
|
26
|
+
| 打包后的启动门禁 | `test/net/boot.test.js` 新增用例:`npm pack` → 解包 → 真启动 → 断言路由/工具/指标数/前端半 |
|
|
27
|
+
| `dsh.bundle.patch` | 已声明 `./cordis.patch.yml`,这是 `dsh plugin add <包名>` 能自动挂载那一行的前提(对照 `dsh-ai-brief`、`@wilond/dsh-news`、`dsh-holdem` 的写法) |
|
|
28
|
+
| `publishConfig.access: public` | scoped 包默认是 restricted,不声明就只能发私有包 |
|
|
29
|
+
| 机器相关路径 | `scripts/diag-discuss.mjs`、`test/net/boot.test.js` 全部改成 `SMD_ROOT` / `SMD_PROFILE_SOURCE` / `SMD_DSH_BIN` 等环境变量,默认值只是本机习惯 |
|
|
30
|
+
| 仓库卫生 | 新增 `.gitignore`(排除 `node_modules/`、`*.tgz`、一次性备份 `.backup-web-cordis.patch.yml`);**`lib/` 故意入库**,因为加载器直接读 `main: lib/host.js`,而从 git 安装不会跑 `prepublishOnly` |
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## 2. 发布前你必须决定/授权的事
|
|
35
|
+
|
|
36
|
+
### 2.1 包名 —— 已定:`dsh-plugin-show-me-data`
|
|
37
|
+
|
|
38
|
+
- 与已有生态的命名一致(`dsh-plugin-usage-meter`),并且保留了插件自身的 `name: 'show-me-data'`、行 id
|
|
39
|
+
`show-me-data`、前端 bundle id;
|
|
40
|
+
- **不带 scope**:不需要 npm org,别人一句 `dsh plugin add dsh-plugin-show-me-data` 就能装;
|
|
41
|
+
- 已查 registry:**未被占用**(404)。若日后想收进自己名下,`@hhhcbw/dsh-plugin-show-me-data` 同样可用,
|
|
42
|
+
换 scope 时记得四件套一起换:`package.json#name`、`cordis.patch.yml#name`、`scripts/build-client.mjs#CLIENT_ID`
|
|
43
|
+
(前端半的 loader id 必须与包名一致)、`package.json#publishConfig.access = public`。
|
|
44
|
+
|
|
45
|
+
改名已同步完成,且**测试与脚本改为从 `package.json` 读包名**(`test/net/boot.test.js`、`scripts/diag-discuss.mjs`),
|
|
46
|
+
所以以后再改名不需要满仓库找字符串。
|
|
47
|
+
|
|
48
|
+
### 2.2 `private: true` —— 已去掉
|
|
49
|
+
|
|
50
|
+
npm 会拒绝发布 `private: true` 的包(`This package has been marked as private`)。该字段已移除,
|
|
51
|
+
现在 `npm publish` 可以直接执行(需要有权限的 npm 账号)。
|
|
52
|
+
|
|
53
|
+
### 2.3 LICENSE —— 已加(MIT,署名 hhhcbw)
|
|
54
|
+
|
|
55
|
+
`LICENSE` 文件已写入 `Copyright (c) 2026 hhhcbw`,并在文末补了**数据来源说明**:`test/fixtures/` 是从
|
|
56
|
+
FRED / 财政部 / 东方财富 / 新浪 / 腾讯 / 世界银行 / ECB 录制的公开数据快照,版权归各上游,仅供研究测试参考。
|
|
57
|
+
|
|
58
|
+
### 2.4 README 要改成"对外版"
|
|
59
|
+
|
|
60
|
+
现在的 `README.md` 是**交付文档**("本插件已实现并安装完成 2026-09-12"、写死了 `/root/show_me_data`、
|
|
61
|
+
`/data/profiles/web`)。对外发布建议补/改为:安装命令、一张面板截图、支持哪些上游、已知不可用上游、
|
|
62
|
+
需要哪个 harness 版本、以及"数据仅研究参考"的免责声明。设计文档 `docs/01–14` 可原样保留(它们是本项目的
|
|
63
|
+
一部分,也解释了为什么这样做)。
|
|
64
|
+
|
|
65
|
+
### 2.5 harness 兼容性
|
|
66
|
+
|
|
67
|
+
插件**不依赖任何 npm 包**(这是刻意的,见 `src/host/tools/define-tool.js` 顶部注释:引入第二份
|
|
68
|
+
`@deepseek-ai/dsh-tools` 会 fork 工具运行时的 Symbol)。它依赖的是宿主服务:
|
|
69
|
+
|
|
70
|
+
```
|
|
71
|
+
inject: ['webServer', 'tools']
|
|
72
|
+
可选:llm · agentDefaultModel · agentPresets · agents · sessions · timer
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
所以 README 里应写明"需要提供上述服务的 DSH 版本"(本机验证于 `dsh` 0.1.0-rc.6 一类的宿主)。
|
|
76
|
+
`dsh.client.inject` 只声明 `@deepseek-ai/dsh-client-runtime`(与已发布插件一致)。
|
|
77
|
+
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
## 3. 具体命令
|
|
81
|
+
|
|
82
|
+
### 3.1 推到 GitHub
|
|
83
|
+
|
|
84
|
+
仓库已经是 git 仓库并已 `git add`(**未提交**,因为本机没有 git 身份):
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
cd /root/show_me_data
|
|
88
|
+
git config user.name "<你的名字>" # 或 --global
|
|
89
|
+
git config user.email "<你的邮箱>"
|
|
90
|
+
git commit -m "feat: show-me-data 插件 0.1.0(host 半 + 浏览器半 + 683 用例)"
|
|
91
|
+
|
|
92
|
+
# 方式一:HTTPS(需要 PAT)
|
|
93
|
+
git remote add origin https://github.com/<你>/<repo>.git
|
|
94
|
+
git push -u origin main
|
|
95
|
+
|
|
96
|
+
# 方式二:SSH(需要先把公钥加到 GitHub)
|
|
97
|
+
git remote add origin git@github.com:<你>/<repo>.git
|
|
98
|
+
git push -u origin main
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
`main` 分支名:本机 `git init` 的默认分支取决于 git 版本,先 `git branch -M main` 再推更稳。
|
|
102
|
+
|
|
103
|
+
### 3.1b 本机推不上去时:用 GitHub API 推(本容器实测必需)
|
|
104
|
+
|
|
105
|
+
本容器里 **`github.com:443` 不可达,而 `api.github.com` 可达**(同一时刻实测:`api.github.com/zen` 200,
|
|
106
|
+
`github.com` 与 `codeload.github.com` 超时),所以 `git push` 必然失败(报 `Failed to connect to github.com:443`)。
|
|
107
|
+
另外全局 git 配置里有 `url.git@github.com:.insteadOf=https://github.com/`,而本容器**没有 `ssh` 可执行文件**,
|
|
108
|
+
因此连 HTTPS 地址也会被改写成 SSH 再失败(`cannot run ssh`)。
|
|
109
|
+
|
|
110
|
+
绕开办法是走 **Git Data API**(blobs → tree → commit → ref),已封装成脚本:
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
GH_TOKEN=<token> node scripts/push-via-api.mjs --repo hhhcbw/dsh-plugin-show-me-data --branch main
|
|
114
|
+
GH_TOKEN=<token> node scripts/push-via-api.mjs --repo hhhcbw/dsh-plugin-show-me-data --dry-run
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
脚本刻意做成"不会悄悄推错内容":
|
|
118
|
+
|
|
119
|
+
- 文件内容取自 **git 对象库**(`git cat-file blob`),不是工作区,所以未提交的改动不可能混进推送;
|
|
120
|
+
- 每个 blob 上传后返回的 sha 必须与本地一致,**不一致即中止**;
|
|
121
|
+
- 上传完的 **tree sha 必须等于本地 tree sha**(实测 `3991ad35…` 相同)——这才是"发布内容与本地提交逐字节一致"的真正保证;
|
|
122
|
+
- 首次发布时,空仓库不允许创建 blob(`409 Git Repository is empty`),脚本会先用 Contents API 提交一份**本仓库自己的 README** 作为种子提交,然后**把分支强制指向本地那个根提交**(sha 与本地完全相同,`d7c9e6f…`),种子提交随即被丢弃;之后再推送都是 fast-forward(`force: false`),不是快进就直接报错而不是改写历史;
|
|
123
|
+
- 已经推过的提交会先比对远端 tip,**幂等**("already at this commit — nothing to do")。
|
|
124
|
+
|
|
125
|
+
验证(推完自己查):
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
node -e 'fetch("https://api.github.com/repos/hhhcbw/dsh-plugin-show-me-data/git/ref/heads/main",{headers:{authorization:"Bearer $GH_TOKEN"}}).then(r=>r.json()).then(j=>console.log(j.object.sha))'
|
|
129
|
+
git rev-parse HEAD # 两个 sha 应完全相同
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
等某天 `github.com` 通了,也可以直接用 `git fetch && git reset --hard origin/main` 把本地对齐(内容零差异)。
|
|
133
|
+
|
|
134
|
+
### 3.2 发布到 npm
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
cd /root/show_me_data
|
|
138
|
+
# 1) 改包名(§2.1)+ 删掉 "private": true(§2.2)+ 加 LICENSE 署名(§2.3)
|
|
139
|
+
# 2) 同步 cordis.patch.yml 与测试里的包名
|
|
140
|
+
# 3) 预览要发布的内容(不会真的发布)
|
|
141
|
+
npm pack --dry-run
|
|
142
|
+
# 4) 登录并发布
|
|
143
|
+
npm login # 需要浏览器/OTP
|
|
144
|
+
npm publish # prepublishOnly 会自动重建 lib/ 并跑全量测试
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
发布后任何人都可以:
|
|
148
|
+
|
|
149
|
+
```bash
|
|
150
|
+
dsh plugin --profile web add dsh-plugin-show-me-data # 或 @scope/dsh-plugin-show-me-data
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
装完要**重启 web profile 进程**(插件在启动时加载),浏览器再硬刷新一次。
|
|
154
|
+
|
|
155
|
+
### 3.3 只想发 GitHub、不想发 npm
|
|
156
|
+
|
|
157
|
+
完全可以:把 `private` 留着,别人用
|
|
158
|
+
|
|
159
|
+
```bash
|
|
160
|
+
dsh plugin --profile web add github:<你>/<repo>
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
从 git 安装时 npm 会跑 `prepare`(本仓库没写 `prepare`),所以**`lib/` 必须已提交**——`.gitignore` 里已经
|
|
164
|
+
特意保留了它,改 `src/` 后记得 `npm run build` 再一起提交。
|
|
165
|
+
|
|
166
|
+
---
|
|
167
|
+
|
|
168
|
+
## 4. 发布前的自检清单
|
|
169
|
+
|
|
170
|
+
```bash
|
|
171
|
+
cd /root/show_me_data
|
|
172
|
+
npm run build # 重新生成 lib/(改过 src 必须做)
|
|
173
|
+
node --test "test/**/*.test.js" # 683 通过 / 0 失败
|
|
174
|
+
RUN_BOOT=1 node --test test/net/boot.test.js # 4 通过:含"打包后能真启动"
|
|
175
|
+
npm pack --dry-run # 看清单与体积
|
|
176
|
+
git status --short # 确认没有把 .tgz / 备份文件 / node_modules 提交进去
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
## 5. 已知边界(写进 README 更诚实)
|
|
180
|
+
|
|
181
|
+
- **数据上游会封 IP**:BLS(403)、Yahoo(403)、Stooq(JS 墙)、新浪 hq(403)、`data.stats.gov.cn`(403)、
|
|
182
|
+
东财 `push2his` 偶发断连。面板对每个源分类报错并降级,不猜接口(见 `docs/04-sources.md`)。
|
|
183
|
+
- **面板只读**:运行时无法注册新数据源;「我的」里加不了时会开一个**迭代会话**去改插件代码。
|
|
184
|
+
- **AI 是可选能力**:没有 `llm` 服务时全部走确定性摘要(这是产品特性,不是故障)。
|
|
185
|
+
- **讨论会话会真开会话**:每次点「讨论」新建一个 DSH 会话并把面板数据作为上下文,不消耗面板自己的进程。
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The deterministic AI gateway — the offline product path (docs/06 §1).
|
|
3
|
+
*
|
|
4
|
+
* This is **not a mock**: when no LLM is configured, the panel still explains,
|
|
5
|
+
* summarises and answers, using the same digest the model would have received
|
|
6
|
+
* and citing the exact values it cites. Every result is labelled
|
|
7
|
+
* 'mode: 'deterministic'' so the user always knows which one they are reading.
|
|
8
|
+
*
|
|
9
|
+
* @module app/ai-deterministic
|
|
10
|
+
*/
|
|
11
|
+
import { fingerprint } from '../core/insight/rank.js'
|
|
12
|
+
import { formatNumber } from '../core/insight/digest.js'
|
|
13
|
+
|
|
14
|
+
/** Marker the UI renders as 「确定性摘要模式」. */
|
|
15
|
+
export const MODE = 'deterministic'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Below this score a catalog hit is too weak to present as a candidate.
|
|
19
|
+
*
|
|
20
|
+
* Measured against the shipped catalog: a real question about a catalog
|
|
21
|
+
* indicator scores 96-310 (label and alias hits), while an incidental token
|
|
22
|
+
* overlap - asking about tomorrow's oil price and hitting the WTI series on the
|
|
23
|
+
* word 油 - scores 38. The floor therefore sits between the two, so an
|
|
24
|
+
* unanswerable question gets "数据不足" instead of a price.
|
|
25
|
+
*/
|
|
26
|
+
export const WEAK_MATCH_SCORE = 60
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Floor for relevance computed against digest entries by {@link matchEntries}.
|
|
30
|
+
*
|
|
31
|
+
* The two scales are not comparable: a catalog search (core/indicators/resolve)
|
|
32
|
+
* weighs exact ids and aliases and scores a real hit 96-310, while the local
|
|
33
|
+
* digest matcher is additive token overlap and scores the same hit around 7.
|
|
34
|
+
* Using one number for both would either drop real answers or keep weak ones,
|
|
35
|
+
* so each path carries its own floor.
|
|
36
|
+
*/
|
|
37
|
+
export const WEAK_ENTRY_SCORE = 3
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Build the cited points for one digest entry.
|
|
41
|
+
*
|
|
42
|
+
* @param {object} entry - digest entry.
|
|
43
|
+
* @returns {Array<{ indicatorId: string, t: string, v: number, sourceRefUrl: string }>} cited points.
|
|
44
|
+
*/
|
|
45
|
+
function citesOf(entry) {
|
|
46
|
+
const stats = entry.stats ?? {}
|
|
47
|
+
const points = []
|
|
48
|
+
if (typeof stats.latest === 'number' && typeof stats.latestAt === 'string') {
|
|
49
|
+
points.push({ indicatorId: entry.indicator.id, t: stats.latestAt, v: stats.latest, sourceRefUrl: entry.sourceRef?.url })
|
|
50
|
+
}
|
|
51
|
+
if (typeof stats.prev === 'number' && entry.points?.length >= 2) {
|
|
52
|
+
const previous = entry.points[entry.points.length - 2]
|
|
53
|
+
points.push({ indicatorId: entry.indicator.id, t: previous.t, v: previous.v, sourceRefUrl: entry.sourceRef?.url })
|
|
54
|
+
}
|
|
55
|
+
return points
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Create the deterministic gateway.
|
|
60
|
+
*
|
|
61
|
+
* @returns {object} 'AiGateway' implementation.
|
|
62
|
+
*/
|
|
63
|
+
export function createDeterministicGateway() {
|
|
64
|
+
/**
|
|
65
|
+
* Explain one indicator without a model.
|
|
66
|
+
*
|
|
67
|
+
* @param {{ entries: object[], indicatorId: string, range?: object }} request - request.
|
|
68
|
+
* @returns {Promise<object>} 'AiResult'.
|
|
69
|
+
*/
|
|
70
|
+
async function explain({ entries, indicatorId, range }) {
|
|
71
|
+
const entry = (entries ?? []).find((candidate) => candidate.indicator.id === indicatorId)
|
|
72
|
+
if (entry === undefined) {
|
|
73
|
+
return {
|
|
74
|
+
markdown: '数据不足:没有找到该指标在当前范围内的观测,无法生成解析。',
|
|
75
|
+
usedPoints: [],
|
|
76
|
+
insufficient: '该指标在当前范围内没有可用观测',
|
|
77
|
+
mode: MODE,
|
|
78
|
+
fingerprint: fingerprint(`explain|${indicatorId}|empty`),
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const { indicator, stats } = entry
|
|
82
|
+
const decimals = indicator.display?.decimals ?? 2
|
|
83
|
+
const lines = []
|
|
84
|
+
lines.push(`### ${indicator.label.zh}(${indicator.label.en})`)
|
|
85
|
+
lines.push('')
|
|
86
|
+
if (stats === undefined) {
|
|
87
|
+
return {
|
|
88
|
+
markdown: `${lines.join('\n')}\n数据不足:该指标在本范围内没有任何可用观测。`,
|
|
89
|
+
usedPoints: [],
|
|
90
|
+
insufficient: '该指标在本范围内没有可用观测',
|
|
91
|
+
mode: MODE,
|
|
92
|
+
fingerprint: fingerprint(`explain|${indicatorId}|nostats`),
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
lines.push(
|
|
96
|
+
`**事实**:最新观测为 ${formatNumber(stats.latest, decimals)}${indicator.unit}(${stats.latestAt})` +
|
|
97
|
+
`[${indicator.id}@${stats.latestAt}, ${stats.latest}]。`,
|
|
98
|
+
)
|
|
99
|
+
if (stats.changeAbs !== undefined) {
|
|
100
|
+
lines.push(
|
|
101
|
+
`- 较上期变化 ${stats.changeAbs >= 0 ? '+' : ''}${formatNumber(stats.changeAbs, decimals)}${indicator.unit}` +
|
|
102
|
+
(stats.changePct === undefined ? '' : `(${formatNumber(stats.changePct, 1)}%)`),
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
if (stats.yoy !== undefined) lines.push(`- 同比 ${formatNumber(stats.yoy, 1)}%`)
|
|
106
|
+
lines.push(`- 口径:${indicator.freq} 频,单位 ${indicator.unit}${indicator.seasonal === 'SA' ? ',季调' : ''}`)
|
|
107
|
+
if (indicator.notes?.zh) lines.push(`- 说明:${indicator.notes.zh}`)
|
|
108
|
+
if (stats.percentile !== undefined) {
|
|
109
|
+
lines.push(`- 分位:最新值位于本区间 ${formatNumber(stats.percentile * 100, 0)}% 分位`)
|
|
110
|
+
}
|
|
111
|
+
if (stats.zScoreLatestChange !== undefined) {
|
|
112
|
+
lines.push(`- 变化强度:最近一次变化为 ${formatNumber(stats.zScoreLatestChange, 1)}σ`)
|
|
113
|
+
}
|
|
114
|
+
if (range !== undefined) lines.push(`- 区间:${range.from} .. ${range.to}`)
|
|
115
|
+
lines.push('')
|
|
116
|
+
lines.push('**可能的影响**(以下为规则化解读,不构成投资建议):')
|
|
117
|
+
const polarity = indicator.display?.polarity
|
|
118
|
+
lines.push(
|
|
119
|
+
polarity === 'up-is-good'
|
|
120
|
+
? '- 该指标上行通常被视为改善,下行为恶化;请结合其他指标交叉验证。'
|
|
121
|
+
: polarity === 'down-is-good'
|
|
122
|
+
? '- 该指标下行通常被视为改善,上行为恶化;请结合其他指标交叉验证。'
|
|
123
|
+
: '- 该指标的方向本身不直接代表好坏,需要看它与其他变量(增长、通胀、政策)的相对关系。',
|
|
124
|
+
)
|
|
125
|
+
return {
|
|
126
|
+
markdown: lines.join('\n'),
|
|
127
|
+
usedPoints: citesOf(entry),
|
|
128
|
+
mode: MODE,
|
|
129
|
+
fingerprint: fingerprint(`explain|${indicatorId}|${range?.from ?? ''}|${range?.to ?? ''}|${stats.latest}`),
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Summarise a period without a model.
|
|
135
|
+
*
|
|
136
|
+
* @param {{ entries: object[], range?: object, text?: string }} request - request; 'text' overrides the rendered body.
|
|
137
|
+
* @returns {Promise<object>} 'AiResult'.
|
|
138
|
+
*/
|
|
139
|
+
async function summarize({ entries, range, text }) {
|
|
140
|
+
const markdown = text ?? renderFallback(entries, range)
|
|
141
|
+
const usedPoints = (entries ?? []).flatMap(citesOf)
|
|
142
|
+
if (usedPoints.length === 0) {
|
|
143
|
+
return {
|
|
144
|
+
markdown: '数据不足:当前没有任何可用的指标观测,无法生成时段总结。',
|
|
145
|
+
usedPoints: [],
|
|
146
|
+
insufficient: '本次没有任何可用观测',
|
|
147
|
+
mode: MODE,
|
|
148
|
+
fingerprint: fingerprint('summary|empty'),
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return {
|
|
152
|
+
markdown,
|
|
153
|
+
usedPoints,
|
|
154
|
+
mode: MODE,
|
|
155
|
+
fingerprint: fingerprint(`summary|${range?.from ?? ''}|${range?.to ?? ''}|${usedPoints.length}`),
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Answer a question using only the digest, deterministically.
|
|
161
|
+
*
|
|
162
|
+
* @param {{ entries: object[], question: string, range?: object, matches?: object[] }} request - request.
|
|
163
|
+
* @returns {Promise<object>} 'AiResult'.
|
|
164
|
+
*/
|
|
165
|
+
async function answer({ entries, question, range, matches }) {
|
|
166
|
+
// The same confidence floor the propose path uses: a weak match must produce
|
|
167
|
+
// "数据不足" rather than an unrelated number.
|
|
168
|
+
// Only entries that were resolved through search carry a `matchScore`; a
|
|
169
|
+
// caller handing pre-selected digest entries is taken at its word.
|
|
170
|
+
// `matches` arrives from a catalog search (its own scale); otherwise the
|
|
171
|
+
// question is matched locally against the digest entries.
|
|
172
|
+
const floor = matches === undefined ? WEAK_ENTRY_SCORE : WEAK_MATCH_SCORE
|
|
173
|
+
const candidates = (matches ?? matchEntries(entries, question)).filter((entry) => {
|
|
174
|
+
// `matchScore` is relevance; `score` is the entry's attention score and
|
|
175
|
+
// must never be read as relevance.
|
|
176
|
+
const relevance = entry.matchScore ?? entry.score
|
|
177
|
+
return relevance === undefined || relevance >= floor
|
|
178
|
+
})
|
|
179
|
+
if (candidates.length === 0) {
|
|
180
|
+
return {
|
|
181
|
+
markdown:
|
|
182
|
+
`数据不足:面板中的数据无法回答「${question ?? ''}」。\n\n` +
|
|
183
|
+
'- 当前可用指标里没有与该问题相关的口径。\n' +
|
|
184
|
+
'- 可尝试:换一个更具体的指标名称(例如「美国 CPI 同比」「中国制造业 PMI」),或缩小时间范围。',
|
|
185
|
+
usedPoints: [],
|
|
186
|
+
insufficient: '问题超出面板数据覆盖范围',
|
|
187
|
+
mode: MODE,
|
|
188
|
+
fingerprint: fingerprint(`answer|${question ?? ''}|none`),
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
// Callers may hand either digest entries ({ indicator, stats, points }) or
|
|
192
|
+
// flat catalog matches ({ id, label, ... }); normalize to digest entries so
|
|
193
|
+
// a lookup path can never crash the answer.
|
|
194
|
+
const byId = new Map((entries ?? []).map((entry) => [entry.indicator?.id, entry]))
|
|
195
|
+
const top = candidates
|
|
196
|
+
.slice(0, 3)
|
|
197
|
+
.map((candidate) => byId.get(candidate.indicator?.id ?? candidate.id) ?? candidate)
|
|
198
|
+
.filter((candidate) => candidate.indicator !== undefined)
|
|
199
|
+
const lines = []
|
|
200
|
+
lines.push(`在面板数据范围内,与「${question}」最相关的是:`)
|
|
201
|
+
for (const entry of top) {
|
|
202
|
+
const decimals = entry.indicator.display?.decimals ?? 2
|
|
203
|
+
if (entry.stats === undefined) {
|
|
204
|
+
lines.push(`- ${entry.indicator.label.zh}:数据不足,本范围内没有观测。`)
|
|
205
|
+
continue
|
|
206
|
+
}
|
|
207
|
+
// The citation form is included so the deterministic answer satisfies the
|
|
208
|
+
// same validator the model's answer has to pass.
|
|
209
|
+
lines.push(
|
|
210
|
+
`- ${entry.indicator.label.zh}:最新 ${formatNumber(entry.stats.latest, decimals)}${entry.indicator.unit}(${entry.stats.latestAt})` +
|
|
211
|
+
`[${entry.indicator.id}@${entry.stats.latestAt}, ${entry.stats.latest}]` +
|
|
212
|
+
(entry.stats.changeAbs === undefined ? '' : `,较上期 ${entry.stats.changeAbs >= 0 ? '+' : ''}${formatNumber(entry.stats.changeAbs, decimals)}`),
|
|
213
|
+
)
|
|
214
|
+
}
|
|
215
|
+
lines.push('')
|
|
216
|
+
lines.push('**依据**:以上数值均来自面板内的公开数据源,可在每条指标卡片的来源徽章处打开原始页面。')
|
|
217
|
+
lines.push('**数据边界**:只覆盖面板中列出的指标与时间范围;不包含新闻、事件与预测。')
|
|
218
|
+
return {
|
|
219
|
+
markdown: lines.join('\n'),
|
|
220
|
+
usedPoints: top.flatMap(citesOf),
|
|
221
|
+
mode: MODE,
|
|
222
|
+
fingerprint: fingerprint(`answer|${question ?? ''}|${top.map((entry) => entry.indicator.id).join(',')}`),
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* 'propose' without a model can only answer from the existing catalog.
|
|
228
|
+
*
|
|
229
|
+
* @param {{ request: string, matches?: object[] }} input - propose input.
|
|
230
|
+
* @returns {Promise<{ candidates: object[], unsupported: object[], mode: string }>} result.
|
|
231
|
+
*/
|
|
232
|
+
async function propose({ request, matches = [] }) {
|
|
233
|
+
// Only a genuinely strong catalog hit becomes a candidate. A weak fuzzy match
|
|
234
|
+
// (asking for a German business-climate index and getting "US dollar index"
|
|
235
|
+
// because both contain 指数) must not be dressed up as an answer.
|
|
236
|
+
const confident = matches.filter((entry) => (entry.score ?? 0) >= WEAK_MATCH_SCORE)
|
|
237
|
+
if (confident.length > 0) {
|
|
238
|
+
const best = confident[0].indicator
|
|
239
|
+
return {
|
|
240
|
+
candidates: [
|
|
241
|
+
{
|
|
242
|
+
// The catalog definition is echoed whole: it already passed
|
|
243
|
+
// validateIndicatorDef in the catalog suite, and re-validating it
|
|
244
|
+
// through the propose gate would reject fields this echo omits
|
|
245
|
+
// (seasonal, notes) for no reason.
|
|
246
|
+
...best,
|
|
247
|
+
fromCatalog: true,
|
|
248
|
+
confidence: Number(((matches[0].score ?? 0) / 150).toFixed(2)),
|
|
249
|
+
rationale: `目录检索命中已有指标 ${best.id}(确定性模式不做自然语言推理)。`,
|
|
250
|
+
},
|
|
251
|
+
],
|
|
252
|
+
unsupported: [],
|
|
253
|
+
mode: MODE,
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return {
|
|
257
|
+
candidates: [],
|
|
258
|
+
unsupported: [
|
|
259
|
+
{
|
|
260
|
+
request,
|
|
261
|
+
reason: '确定性摘要模式无法解析自然语言需求,也不会猜测数据源标识。',
|
|
262
|
+
alternatives: ['在指标目录中检索并手动添加', '配置 LLM 后重试对话式添加'],
|
|
263
|
+
},
|
|
264
|
+
],
|
|
265
|
+
mode: MODE,
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* @returns {{ mode: string }} mode description.
|
|
271
|
+
*/
|
|
272
|
+
function describe() {
|
|
273
|
+
return { mode: MODE }
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return { explain, summarize, answer, propose, describe }
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Rank digest entries against a free-text question.
|
|
281
|
+
*
|
|
282
|
+
* @param {object[]} entries - digest entries.
|
|
283
|
+
* @param {string} question - question text.
|
|
284
|
+
* @returns {object[]} matching entries, best first.
|
|
285
|
+
*/
|
|
286
|
+
export function matchEntries(entries, question) {
|
|
287
|
+
const text = String(question ?? '').toLowerCase()
|
|
288
|
+
if (text.trim() === '') return []
|
|
289
|
+
return (entries ?? [])
|
|
290
|
+
.map((entry) => {
|
|
291
|
+
const haystack = [entry.indicator.id, entry.indicator.label.zh, entry.indicator.label.en, ...(entry.indicator.aliases ?? [])]
|
|
292
|
+
.join(' ')
|
|
293
|
+
.toLowerCase()
|
|
294
|
+
let score = 0
|
|
295
|
+
for (const token of text.split(/[\s,,。??、]+/).filter((token) => token.length >= 2)) {
|
|
296
|
+
if (haystack.includes(token)) score += 2
|
|
297
|
+
}
|
|
298
|
+
for (const alias of entry.indicator.aliases ?? []) {
|
|
299
|
+
if (text.includes(alias.toLowerCase())) score += 3
|
|
300
|
+
}
|
|
301
|
+
if (text.includes(entry.indicator.label.zh)) score += 4
|
|
302
|
+
return { entry, score }
|
|
303
|
+
})
|
|
304
|
+
.filter((candidate) => candidate.score > 0)
|
|
305
|
+
.sort((a, b) => b.score - a.score || b.entry.indicator.importance - a.entry.indicator.importance)
|
|
306
|
+
.map((candidate) => ({ ...candidate.entry, matchScore: candidate.score }))
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* The plain-text fallback summary used when 'core/insight/digest' did not
|
|
311
|
+
* already render one.
|
|
312
|
+
*
|
|
313
|
+
* @param {object[]} entries - digest entries.
|
|
314
|
+
* @param {{ from: string, to: string }} [range] - range.
|
|
315
|
+
* @returns {string} markdown.
|
|
316
|
+
*/
|
|
317
|
+
function renderFallback(entries, range) {
|
|
318
|
+
if (!entries || entries.length === 0) return '数据不足:当前没有任何可用的指标观测。'
|
|
319
|
+
const lines = ['### 时段数据摘要']
|
|
320
|
+
if (range !== undefined) lines.push(`区间:${range.from} .. ${range.to}`)
|
|
321
|
+
for (const entry of entries.slice(0, 10)) {
|
|
322
|
+
const decimals = entry.indicator.display?.decimals ?? 2
|
|
323
|
+
lines.push(`- ${entry.indicator.label.zh}:${formatNumber(entry.stats?.latest, decimals)}${entry.indicator.unit}(${entry.stats?.latestAt ?? 'n/a'})`)
|
|
324
|
+
}
|
|
325
|
+
lines.push('- 数据来自公开源,仅供研究参考,不构成投资建议。')
|
|
326
|
+
return lines.join('\n')
|
|
327
|
+
}
|