@v587d/capital-generation 0.0.0-stage → 2.6.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.
Files changed (117) hide show
  1. package/CHANGELOG.md +794 -0
  2. package/LICENSE +21 -0
  3. package/README.md +116 -2
  4. package/assets/cg-icon.png +0 -0
  5. package/capital-config/client.js +28 -0
  6. package/capital-config/client.src.cjs +388 -0
  7. package/capital-config/index.js +113 -0
  8. package/capital-config/package.json +36 -0
  9. package/capital-watchlist/client.js +182 -0
  10. package/capital-watchlist/client.src.cjs +1229 -0
  11. package/capital-watchlist/index.js +772 -0
  12. package/capital-watchlist/package.json +37 -0
  13. package/chart-ui/README.md +112 -0
  14. package/chart-ui/client.js +16 -0
  15. package/chart-ui/client.src.cjs +126 -0
  16. package/chart-ui/index.js +205 -0
  17. package/chart-ui/package.json +30 -0
  18. package/cordis.patch.yml +72 -0
  19. package/lib/agents/bash-guard.d.ts +55 -0
  20. package/lib/agents/bash-guard.js +162 -0
  21. package/lib/agents/root-tool-policy.d.ts +164 -0
  22. package/lib/agents/root-tool-policy.js +309 -0
  23. package/lib/chart/artifact-ref.d.ts +65 -0
  24. package/lib/chart/artifact-ref.js +107 -0
  25. package/lib/chart/downsample.d.ts +8 -0
  26. package/lib/chart/downsample.js +53 -0
  27. package/lib/chart/errors.d.ts +16 -0
  28. package/lib/chart/errors.js +15 -0
  29. package/lib/chart/events.d.ts +120 -0
  30. package/lib/chart/events.js +471 -0
  31. package/lib/chart/html.d.ts +21 -0
  32. package/lib/chart/html.js +154 -0
  33. package/lib/chart/runtime.d.ts +20 -0
  34. package/lib/chart/runtime.js +270 -0
  35. package/lib/chart/series.d.ts +102 -0
  36. package/lib/chart/series.js +424 -0
  37. package/lib/chart/source-token.d.ts +64 -0
  38. package/lib/chart/source-token.js +217 -0
  39. package/lib/chart/spec.d.ts +58 -0
  40. package/lib/chart/spec.js +265 -0
  41. package/lib/chart/tool.d.ts +86 -0
  42. package/lib/chart/tool.js +288 -0
  43. package/lib/chart/vendor/LICENSE +201 -0
  44. package/lib/chart/vendor/VERSION.json +8 -0
  45. package/lib/chart/vendor/lightweight-charts.standalone.production.js +7 -0
  46. package/lib/data-collector/args.d.ts +38 -0
  47. package/lib/data-collector/args.js +114 -0
  48. package/lib/data-collector/dataset-tools.d.ts +3 -0
  49. package/lib/data-collector/dataset-tools.js +421 -0
  50. package/lib/data-collector/describe.d.ts +99 -0
  51. package/lib/data-collector/describe.js +256 -0
  52. package/lib/data-collector/hub.d.ts +187 -0
  53. package/lib/data-collector/hub.js +469 -0
  54. package/lib/data-collector/query-time.d.ts +58 -0
  55. package/lib/data-collector/query-time.js +101 -0
  56. package/lib/data-collector/query.d.ts +57 -0
  57. package/lib/data-collector/query.js +473 -0
  58. package/lib/data-collector/store.d.ts +499 -0
  59. package/lib/data-collector/store.js +1486 -0
  60. package/lib/data-collector/time-axis.d.ts +169 -0
  61. package/lib/data-collector/time-axis.js +419 -0
  62. package/lib/data-collector/tools.d.ts +32 -0
  63. package/lib/data-collector/tools.js +216 -0
  64. package/lib/index.d.ts +275 -0
  65. package/lib/index.js +458 -0
  66. package/lib/net/eastmoney-client.d.ts +104 -0
  67. package/lib/net/eastmoney-client.js +118 -0
  68. package/lib/net/throttle.d.ts +16 -0
  69. package/lib/net/throttle.js +34 -0
  70. package/lib/ocr/client.d.ts +82 -0
  71. package/lib/ocr/client.js +281 -0
  72. package/lib/ocr/markdown.d.ts +75 -0
  73. package/lib/ocr/markdown.js +209 -0
  74. package/lib/ocr/store.d.ts +119 -0
  75. package/lib/ocr/store.js +226 -0
  76. package/lib/sources/eastmoney-http.d.ts +2 -0
  77. package/lib/sources/eastmoney-http.js +1253 -0
  78. package/lib/sources/fuyao-core.d.ts +52 -0
  79. package/lib/sources/fuyao-core.js +96 -0
  80. package/lib/sources/fuyao-rest.d.ts +14 -0
  81. package/lib/sources/fuyao-rest.js +2057 -0
  82. package/lib/sources/security-code.d.ts +27 -0
  83. package/lib/sources/security-code.js +104 -0
  84. package/lib/sources/security-identity.d.ts +24 -0
  85. package/lib/sources/security-identity.js +89 -0
  86. package/lib/sources/tencent-http.d.ts +2 -0
  87. package/lib/sources/tencent-http.js +1053 -0
  88. package/lib/sources/wind-mcp.d.ts +8 -0
  89. package/lib/sources/wind-mcp.js +593 -0
  90. package/lib/time/tools.d.ts +74 -0
  91. package/lib/time/tools.js +464 -0
  92. package/lib/tool-exec.d.ts +29 -0
  93. package/lib/tool-exec.js +17 -0
  94. package/lib/web-retriever/engines.d.ts +61 -0
  95. package/lib/web-retriever/engines.js +202 -0
  96. package/lib/web-retriever/html-markdown.d.ts +50 -0
  97. package/lib/web-retriever/html-markdown.js +321 -0
  98. package/lib/web-retriever/local-fetch.d.ts +121 -0
  99. package/lib/web-retriever/local-fetch.js +562 -0
  100. package/lib/web-retriever/retriever.d.ts +58 -0
  101. package/lib/web-retriever/retriever.js +94 -0
  102. package/lib/web-retriever/sources.d.ts +235 -0
  103. package/lib/web-retriever/sources.js +961 -0
  104. package/lib/web-retriever/tools.d.ts +50 -0
  105. package/lib/web-retriever/tools.js +1029 -0
  106. package/lib/web-retriever/wind-client.d.ts +69 -0
  107. package/lib/web-retriever/wind-client.js +311 -0
  108. package/locale/en.json +6 -0
  109. package/locale/zh.json +6 -0
  110. package/package.json +76 -4
  111. package/preset/capital-generation/README.md +161 -0
  112. package/preset/capital-generation/agent.patch.yml +509 -0
  113. package/preset/capital-generation/skills/capital-chart-protocol/SKILL.md +137 -0
  114. package/preset/capital-generation/skills/capital-data-protocol/SKILL.md +416 -0
  115. package/preset/capital-generation/skills/capital-orchestration/SKILL.md +174 -0
  116. package/preset/capital-generation/skills/capital-visualization-protocol/SKILL.md +296 -0
  117. package/preset/capital-generation/skills/capital-web-protocol/SKILL.md +240 -0
@@ -0,0 +1,1029 @@
1
+ import { MAX_ITEMS, SourceError, clsTelegraph, cninfoIrm, createGatedEastmoneyTransport, createSourceTransport, eastmoneyFastNews, eastmoneyReports, eastmoneyStockNews, sinaReports, sseinfoQa, thsEpsForecast, wscnLives, } from './sources.js';
2
+ import { assertPublicUrlTarget } from './local-fetch.js';
3
+ import { createEastmoneyClient, sharedEastmoneyThrottle } from '../net/eastmoney-client.js';
4
+ import { bareAshareDigits } from '../sources/security-code.js';
5
+ import { callerSession } from '../tool-exec.js';
6
+ import { DatasetStoreError } from '../data-collector/store.js';
7
+ import { OcrError } from '../ocr/client.js';
8
+ import { locateQuery, parsePageRange, selectPages } from '../ocr/markdown.js';
9
+ import { OCR_DOC_ID_PATTERN, buildArtifactMeta, deriveOcrDocId, ocrArtifactRef, readOcrArtifact, readOcrSourceBytes, writeOcrArtifact, OcrArtifactError, } from '../ocr/store.js';
10
+ const MAX_QUERY_LENGTH = 500;
11
+ const MAX_URL_LENGTH = 2048;
12
+ const MAX_RESULTS = 20;
13
+ const RECENT_TRACE_LIMIT = 8;
14
+ const TRACE_TEXT_CLIP = 200;
15
+ const jsonObject = (properties = {}, required = []) => ({
16
+ type: 'object',
17
+ properties,
18
+ required,
19
+ additionalProperties: false,
20
+ });
21
+ const render = (_args, value) => [
22
+ { type: 'text', text: JSON.stringify(value) },
23
+ ];
24
+ function requiredString(value, name, maxLength) {
25
+ if (typeof value !== 'string' || value.length === 0)
26
+ throw new Error(`${name} is required`);
27
+ if (value.length > maxLength)
28
+ throw new Error(`${name} exceeds maximum length ${maxLength}`);
29
+ return value;
30
+ }
31
+ function validHttpUrl(value) {
32
+ const url = requiredString(value, 'url', MAX_URL_LENGTH);
33
+ try {
34
+ const parsed = new URL(url);
35
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')
36
+ throw new Error('only http(s) URLs are allowed');
37
+ }
38
+ catch (error) {
39
+ throw new Error(`url must be a valid http(s) URL: ${error instanceof Error ? error.message : String(error)}`);
40
+ }
41
+ return url;
42
+ }
43
+ function integer(value, name, min, max) {
44
+ if (value === undefined)
45
+ return undefined;
46
+ if (typeof value !== 'number' || !Number.isInteger(value) || value < min || value > max) {
47
+ throw new Error(`${name} must be an integer between ${min} and ${max}`);
48
+ }
49
+ return value;
50
+ }
51
+ function createRetrievalEcho() {
52
+ const traces = new Map();
53
+ const tallies = new Map();
54
+ return (exec, entry) => {
55
+ const key = exec.agent?.session?.id ?? 'default';
56
+ const list = traces.get(key) ?? [];
57
+ const stored = { ...entry, query: entry.query.slice(0, TRACE_TEXT_CLIP) };
58
+ list.push(stored);
59
+ while (list.length > RECENT_TRACE_LIMIT)
60
+ list.shift();
61
+ traces.set(key, list);
62
+ const counts = tallies.get(key) ?? new Map();
63
+ counts.set(entry.provider, (counts.get(entry.provider) ?? 0) + 1);
64
+ tallies.set(key, counts);
65
+ const tally = {};
66
+ for (const provider of [...counts.keys()].sort())
67
+ tally[provider] = counts.get(provider);
68
+ const snapshot = () => list.map((item) => ({ ...item }));
69
+ return { recent: snapshot(), tally, entry: stored, snapshot };
70
+ };
71
+ }
72
+ function searchOutput(result, recent, tally) {
73
+ return {
74
+ provider: 'anysearch',
75
+ query: result.query,
76
+ ok: result.ok,
77
+ ...(result.error ? { error: result.error } : {}),
78
+ sources: result.sources.slice(0, MAX_RESULTS),
79
+ recent_retrievals: recent,
80
+ provider_tally: tally,
81
+ };
82
+ }
83
+ function fetchOutput(result, recent, tally) {
84
+ return {
85
+ provider: 'anysearch',
86
+ url: result.url,
87
+ ok: result.ok,
88
+ via: result.via,
89
+ ...(result.title ? { title: result.title } : {}),
90
+ ...(result.content ? { content: result.content, content_chars: result.content.length } : {}),
91
+ ...(result.truncated ? { truncated: true } : {}),
92
+ ...(result.code ? { code: result.code } : {}),
93
+ ...(result.fallback ? { fallback: result.fallback } : {}),
94
+ ...(result.local_error ? { local_error: result.local_error } : {}),
95
+ ...(result.error ? { error: result.error } : {}),
96
+ recent_retrievals: recent,
97
+ provider_tally: tally,
98
+ };
99
+ }
100
+ /**
101
+ * 把失败回执变成**工具错误**。
102
+ *
103
+ * 为什么必须抛而不是正常返回:DSH 用 `isError` 表达工具失败,而 `dsh-tools` 的
104
+ * `createSuccessResult` 硬编码 `isError: false`——**返回值没有任何办法标记失败**,
105
+ * 唯一通道是从 `execute` 抛出(`dispatchToolBody` 的 catch → `toolErrorResult`,
106
+ * 产出 `{ text: "Error: …", isError: true, error: { message } }`)。官方
107
+ * `web_fetch` / `web_search` 就是抛 `WebError`。
108
+ *
109
+ * 2026-09 实测缺陷:本工具此前把失败做成 `ok:false` 信封**正常返回**,于是子 Agent
110
+ * 会话日志里 7 次实际失败的抓取全部记成 `isError: false`,UI 与模型都当成成功,
111
+ * 任何基于 `isError` 的重试/统计/压缩逻辑也一并失效。
112
+ *
113
+ * 抛出的消息带**完整结构化回执**,所以 `via` / `fallback` / `local_error` /
114
+ * `recent_retrievals` / `provider_tally` 与来源标注协议字段一个都不丢。
115
+ *
116
+ * 范围:只覆盖 `web_retriever_fetch`(本地回退所在工具)。`anysearch_search`
117
+ * 与两个 `wind_docs_*` 工具的错误信号是同一类问题,但按 2026-09 决定**不在本次改动**
118
+ * 内,审计与后续安排见 `docs/design/tool-result-error-signal.md`。
119
+ */
120
+ function throwToolFailure(payload) {
121
+ throw new Error(JSON.stringify(payload));
122
+ }
123
+ function windOutput(tool, query, result, recent, tally) {
124
+ return {
125
+ provider: 'wind_docs',
126
+ tool,
127
+ query,
128
+ ok: result.ok,
129
+ ...(result.data !== undefined ? { data: result.data } : {}),
130
+ ...(result.content !== undefined ? { content: result.content, content_chars: result.content.length } : {}),
131
+ ...(result.error ? { error: result.error } : {}),
132
+ ...(result.code ? { code: result.code } : {}),
133
+ recent_retrievals: recent,
134
+ provider_tally: tally,
135
+ };
136
+ }
137
+ const WIND_ANNOUNCEMENTS_TOOL = 'wind_docs_announcements';
138
+ const WIND_NEWS_TOOL = 'wind_docs_news';
139
+ const WIND_ANNOUNCEMENTS_UPSTREAM = 'get_company_announcements';
140
+ const WIND_NEWS_UPSTREAM = 'get_financial_news';
141
+ /**
142
+ * 注册 web_retriever 的检索工具:anysearch(广度)两工具 + wind_docs(public_document
143
+ * 精准)两工具 + `ocr`(文档解析)一工具。wind 与 ocr 都**无条件注册**——Key 缺失/服务
144
+ * 不可用只影响调用结果(错误信封),不影响工具注册与子 Agent 创建。
145
+ *
146
+ * `ocr` 与 `datasetStore` 共用 workspace 落盘,而检索回声(`recent_retrievals` /
147
+ * `provider_tally`)**必须**与另外四个工具共享同一份闭包,所以它在这同一个 `definitions`
148
+ * 数组里注册,不另起一个注册函数。
149
+ */
150
+ export function registerWebRetrieverTools(ctx, retriever, windClient, ocr) {
151
+ const tools = ctx.get('tools');
152
+ if (!tools)
153
+ return;
154
+ const echo = createRetrievalEcho();
155
+ const trace = (exec, provider, tool, query) => echo(exec, { provider, tool, query, at: Date.now() });
156
+ const definitions = [
157
+ {
158
+ name: 'anysearch_search',
159
+ description: '使用 anysearch 检索网页(广度优先):发现候选来源与线索,结果仅作为候选,不等于已核验事实。一次只提交一个查询。需要读取网页正文或核验官方归属时,必须逐个调用 web_retriever_fetch。',
160
+ parameters: jsonObject({
161
+ query: { type: 'string', description: '单个网页检索词,最多 500 字符' },
162
+ max_results: { type: 'integer', description: '可选,返回来源数 1~20' },
163
+ }, ['query']),
164
+ output: { schema: { type: 'object', additionalProperties: true }, render },
165
+ async execute(args, exec) {
166
+ const query = requiredString(args.query, 'query', MAX_QUERY_LENGTH);
167
+ const maxResults = integer(args.max_results, 'max_results', 1, MAX_RESULTS);
168
+ const { recent, tally } = trace(exec, 'anysearch', 'anysearch_search', query);
169
+ return searchOutput(await retriever.search(query, maxResults, exec.signal), recent, tally);
170
+ },
171
+ },
172
+ {
173
+ name: 'web_retriever_fetch',
174
+ description: '使用 anysearch 抓取一个网页正文(一次只能提交一个 http(s) URL)。AnySearch 失败且值得换路、本机直连回退已启用(设置卡片开关,默认开启)时,会自动改由本机直连抓取该页面,不需要再次调用;未启用时 AnySearch 失败即回传失败。回执里的 via 标明正文来源:anysearch = AnySearch 清洗正文,local-http = 本机直连抓取。本机直连只处理公开可访问的文本页面(HTML/文本/JSON/XML),PDF、二进制与需登录页面仍会失败;正文超长时是截断(truncated: true),不是失败。证券任务的 verified_official 核验只能经本工具完成:只允许抓取已由官方页面直接证明归属和用途的官方来源域名,不得 fetch 全部搜索结果。',
175
+ parameters: jsonObject({
176
+ url: { type: 'string', description: '单个 http(s) URL;证券任务必须是已核验的官方来源 URL' },
177
+ }, ['url']),
178
+ output: { schema: { type: 'object', additionalProperties: true }, render },
179
+ async execute(args, exec) {
180
+ const url = validHttpUrl(args.url);
181
+ const { entry, snapshot, tally } = trace(exec, 'anysearch', 'web_retriever_fetch', url);
182
+ const result = await retriever.fetch(url, exec.signal);
183
+ entry.via = result.via;
184
+ const payload = fetchOutput(result, snapshot(), tally);
185
+ // 失败必须变成工具错误(isError: true),不能做成"正常返回的失败信封"。
186
+ if (result.ok === false)
187
+ throwToolFailure(payload);
188
+ return payload;
189
+ },
190
+ },
191
+ {
192
+ name: WIND_ANNOUNCEMENTS_TOOL,
193
+ description: '检索 Wind 官方文档库中的上市公司公告、年报、季报、招股书等正式文件(public_document,官方文件内容的首选来源)。query 必须一次带齐三要素:公司实体(股票代码或公司全称)、文件类型、时间范围;禁止“公告 2026年9月”这类无主体泛查询。返回内容标注“来源:万得 Wind 金融数据服务”与时间口径后即可作为证据使用,不必再走官网核验;第三方媒体报道不在此范围。消耗积分,按需少量调用;返回 AUTH/RATE_LIMIT 等错误时不得重试本工具,改用 anysearch_search 发现公告线索并 web_retriever_fetch 官方披露页。',
194
+ parameters: jsonObject({
195
+ query: { type: 'string', description: '自然语言检索要求;必须包含公司实体(股票代码或公司全称)、文件类型与时间范围三要素,最多 500 字符' },
196
+ top_k: { type: 'integer', description: '可选,返回相关文档最大数量 1~10,默认 5' },
197
+ }, ['query']),
198
+ output: { schema: { type: 'object', additionalProperties: true }, render },
199
+ async execute(args, exec) {
200
+ const query = requiredString(args.query, 'query', MAX_QUERY_LENGTH);
201
+ const topK = integer(args.top_k, 'top_k', 1, 10);
202
+ const { recent, tally } = trace(exec, 'wind_docs', WIND_ANNOUNCEMENTS_TOOL, query);
203
+ const result = await windClient.callTool(WIND_ANNOUNCEMENTS_UPSTREAM, { query, ...(topK === undefined ? {} : { top_k: topK }) }, exec.signal);
204
+ return windOutput(WIND_ANNOUNCEMENTS_TOOL, query, result, recent, tally);
205
+ },
206
+ },
207
+ {
208
+ name: WIND_NEWS_TOOL,
209
+ description: '检索 Wind 官方文档库中的财经新闻(public_document,权威新闻内容的首选来源)。query 必须一次带齐三要素:公司实体或主题、新闻类型、时间范围;禁止无主体泛查询。返回内容标注“来源:万得 Wind 金融数据服务”与时间口径后即可作为证据使用;发行人官方公告与券商研报不在此范围。消耗积分,按需少量调用;返回 AUTH/RATE_LIMIT 等错误时不得重试本工具,改用 anysearch_search + web_retriever_fetch。',
210
+ parameters: jsonObject({
211
+ query: { type: 'string', description: '自然语言检索要求;必须包含公司实体或主题、新闻类型与时间范围三要素,最多 500 字符' },
212
+ top_k: { type: 'integer', description: '可选,返回相关文档最大数量 1~10,默认 5' },
213
+ }, ['query']),
214
+ output: { schema: { type: 'object', additionalProperties: true }, render },
215
+ async execute(args, exec) {
216
+ const query = requiredString(args.query, 'query', MAX_QUERY_LENGTH);
217
+ const topK = integer(args.top_k, 'top_k', 1, 10);
218
+ const { recent, tally } = trace(exec, 'wind_docs', WIND_NEWS_TOOL, query);
219
+ const result = await windClient.callTool(WIND_NEWS_UPSTREAM, { query, ...(topK === undefined ? {} : { top_k: topK }) }, exec.signal);
220
+ return windOutput(WIND_NEWS_TOOL, query, result, recent, tally);
221
+ },
222
+ },
223
+ {
224
+ name: 'ocr',
225
+ timeoutMs: OCR_TOOL_TIMEOUT_MS,
226
+ description: '把一份 PDF 或图片解析成模型可读的 markdown(PaddleOCR 文档解析,需要 Token)。一次调用只走一种形态:url=公网 .pdf 直链(本机不抓文件,由上游取);file=session workspace 内的相对路径(pdf/png/jpg/jpeg/webp/bmp/tiff,@mention 带来的开头 @ 可省);job_id + doc_id=继续等上一次 status:pending 回执里的作业(**不重复提交、不再花钱**);doc_id=读已落盘的结果,可配 pages("1-3" / "5" / "2-4,9",一次最多 8 页)或 query(关键词定位,回命中片段与页号)。⚠️ 作业**整篇一次算完**:pages 只影响读、不影响算价,也不会更便宜;同一份文档重复调用会命中 doc_id 缓存、不再出网。结果一律落 capital-data/ocr/<doc_id>/,回执在预算内给全文、超预算只给页索引与第 1 页。没有 Token、解析失败或文档超页时须如实说明边界:不得把标题当正文引用,不得把 PDF 链接当正文来源标注(正文来自解析结果,引用写 doc_id 与页号)。',
227
+ parameters: jsonObject({
228
+ url: { type: 'string', description: '公网可达的 .pdf 直链(http/https,不含凭据,不接受内网地址与 IP 字面量)' },
229
+ file: { type: 'string', description: 'session workspace 内的相对路径,例如 refs/贵州茅台研报.pdf' },
230
+ job_id: { type: 'string', description: '上一次回执 status:pending 里的 job_id;必须与同一回执的 doc_id 一起传' },
231
+ doc_id: { type: 'string', description: '解析结果标识(形如 ocr_1a2b3c4d5e6f),来自回执' },
232
+ pages: { type: 'string', description: '配合 doc_id 读取的页区间,1 起始闭区间,例如 "2-4,9";一次最多 8 页' },
233
+ query: { type: 'string', description: `配合 doc_id 的关键词定位,最多 ${OCR_MAX_QUERY_CHARS} 字符` },
234
+ limit: { type: 'integer', description: `query 命中数上限 1~${OCR_MAX_QUERY_LIMIT},默认 ${OCR_DEFAULT_QUERY_LIMIT}` },
235
+ charts: { type: 'boolean', description: '可选,是否启用图表识别(默认 true);只影响 url/file 形态的这一次提交' },
236
+ refresh: { type: 'boolean', description: '可选,忽略缓存重新解析(默认 false);只接受与 url/file 同时传,重跑会再花一次 job' },
237
+ }, []),
238
+ output: { schema: { type: 'object', additionalProperties: true }, render },
239
+ async execute(args, exec) {
240
+ return runOcr(ocr, trace, args, exec);
241
+ },
242
+ },
243
+ ];
244
+ for (const definition of definitions) {
245
+ ctx.effect(() => tools.register(definition), `capital-generation.tool(${definition.name})`);
246
+ }
247
+ }
248
+ // ── 文档解析面:ocr(PDF / 图片 → markdown,产物一律落盘)───────────────────
249
+ //
250
+ // 与前两组的分工:search / fetch 是**检索**工作面,具名来源是**查询**工作面,`ocr` 是
251
+ // **解析**工作面——把一个已经确定的文档变成模型可读的正文。它因此不绑任何具体来源
252
+ // (谁的 `.pdf` 直链都能喂进来),也**不受 `localFetch.enabled` 支配**:那道开关的字面
253
+ // 语义是"允许本机直连提取网页内容",而这里的文件由上游解析服务取,用户同意的是另一件事
254
+ // (Token 卡片)。判据写在 `docs/dev/web-retriever.md` §4.3。
255
+ const OCR_TOOL_TIMEOUT_MS = 240_000;
256
+ /** 一次 `pages` 读取的页数上限:与"整篇一次算完"配对,限制的是**回集体积**,不是算力。 */
257
+ const OCR_MAX_PAGES_PER_READ = 8;
258
+ const OCR_QUERY_WINDOW_CHARS = 320;
259
+ const OCR_MAX_QUERY_CHARS = 64;
260
+ const OCR_DEFAULT_QUERY_LIMIT = 5;
261
+ const OCR_MAX_QUERY_LIMIT = 20;
262
+ const OCR_LOCAL_SUFFIXES = ['pdf', 'png', 'jpg', 'jpeg', 'webp', 'bmp', 'tiff'];
263
+ const PDF_URL_SUFFIX = /\.pdf$/iu;
264
+ /** 工具侧自己判定出来的失败(参数形态、缓存缺失),带一条能照做的 hint。 */
265
+ class OcrToolError extends Error {
266
+ code;
267
+ hint;
268
+ constructor(code, message, hint) {
269
+ super(message);
270
+ this.code = code;
271
+ this.hint = hint;
272
+ this.name = 'OcrToolError';
273
+ }
274
+ }
275
+ function isAbortError(error) {
276
+ const code = error?.code;
277
+ return code === 'ABORTED' || (error instanceof Error && error.name === 'AbortError');
278
+ }
279
+ function errorMessage(error) {
280
+ return error instanceof Error ? error.message : String(error);
281
+ }
282
+ /**
283
+ * 半件产物(meta 在、正文不在)怎么修:把**能照抄的那一次调用**写进 hint。
284
+ *
285
+ * 指认来自 meta,不来自调用方——`doc_id` 是内容寻址的,用同一个 source 重跑必然落回同一个
286
+ * `doc_id`,所以这条 hint 是无脑可执行的。认不出 source 形态时只说"重新解析一次",不编参数。
287
+ */
288
+ function artifactRepair(sourceKind, source) {
289
+ const prefix = sourceKind === 'url' ? 'url:' : sourceKind === 'file' ? 'file:' : undefined;
290
+ if (source === undefined || prefix === undefined || !source.startsWith(prefix)) {
291
+ return '把当初解析它用的 url 或 file 再传一次,并带 refresh: true 强制重跑';
292
+ }
293
+ const argument = JSON.stringify(source.slice(prefix.length));
294
+ return `再调一次 { ${prefix.slice(0, -1)}: ${argument}, refresh: true } 重新解析(doc_id 由内容寻址,重跑落回同一个产物)`;
295
+ }
296
+ /**
297
+ * 三条实现各自的异常(`OcrToolError` / `OcrError` / 出口闸门 / `DatasetStoreError`)
298
+ * 翻成**一套**回执码。认不出的仍退回 `INVALID_INPUT` 并照抄消息——绝不假装知道上游
299
+ * 或宿主失败的原因。
300
+ */
301
+ function ocrFailure(error) {
302
+ if (error instanceof OcrToolError) {
303
+ return { code: error.code, error: error.message, ...(error.hint ? { hint: error.hint } : {}) };
304
+ }
305
+ if (error instanceof OcrError)
306
+ return { code: error.code, error: error.message };
307
+ if (error instanceof OcrArtifactError) {
308
+ // 产物坏了不是调用方传错了:说成 INVALID_INPUT 会把模型推到"换个参数再猜",
309
+ // 而正确的下一步只有一条——拿 meta 里的 source 重跑那一次解析。
310
+ return { code: 'RESULT_INVALID', error: error.message, hint: artifactRepair(error.sourceKind, error.source) };
311
+ }
312
+ const code = error?.code;
313
+ if (code === 'INVALID_URL' || code === 'BLOCKED_URL' || code === 'DNS') {
314
+ return {
315
+ code: 'BLOCKED_URL',
316
+ error: errorMessage(error),
317
+ hint: 'url 必须是公网可达的 http(s) .pdf 直链:不含凭据,也不是内网地址或 IP 字面量',
318
+ };
319
+ }
320
+ if (error instanceof DatasetStoreError) {
321
+ if (code === 'dataset_not_found') {
322
+ return { code: 'INVALID_INPUT', error: errorMessage(error) };
323
+ }
324
+ if (code === 'workspace_path_invalid') {
325
+ return {
326
+ code: 'INVALID_INPUT',
327
+ error: errorMessage(error),
328
+ hint: 'file 只能是 session workspace 内的相对路径(绝对路径、盘符与 .. 一律拒绝)',
329
+ };
330
+ }
331
+ if (code === 'dataset_too_large') {
332
+ return { code: 'TOO_MANY_PAGES', error: errorMessage(error), hint: '这份文档超出读取上限,换更小的文件或按页取材' };
333
+ }
334
+ return {
335
+ code: 'STORE_UNAVAILABLE',
336
+ error: errorMessage(error),
337
+ hint: '落盘需要 session 的 workspace cwd 与可写权限(写到 capital-data/ocr/)',
338
+ };
339
+ }
340
+ return { code: 'INVALID_INPUT', error: errorMessage(error) };
341
+ }
342
+ function flag(value, name, fallback) {
343
+ if (value === undefined || value === null)
344
+ return fallback;
345
+ if (typeof value !== 'boolean')
346
+ throw new OcrToolError('INVALID_INPUT', `${name} must be a boolean`);
347
+ return value;
348
+ }
349
+ function optionalString(value, name, maxLength) {
350
+ if (value === undefined || value === null)
351
+ return undefined;
352
+ return requiredString(value, name, maxLength);
353
+ }
354
+ function requireDocId(value) {
355
+ const docId = requiredString(value, 'doc_id', 64);
356
+ if (!OCR_DOC_ID_PATTERN.test(docId)) {
357
+ throw new OcrToolError('INVALID_INPUT', `doc_id 形状不对:${docId}`, 'doc_id 形如 ocr_1a2b3c4d5e6f,取上一次回执里的原值');
358
+ }
359
+ return docId;
360
+ }
361
+ /**
362
+ * 四形态的**边界归一化**(`docs/dev/tool-schema.md` §9.2:多形态靠可选属性 + 归一化,一个工具名)。
363
+ *
364
+ * 歧义一律响亮拒绝而不是"挑一个":`url` + `file` 同时给、`pages` 配 `query`、`refresh`
365
+ * 配 `doc_id`——静默选边会让模型以为自己要的东西还在。
366
+ */
367
+ function ocrRequest(args) {
368
+ const present = (key) => args[key] !== undefined && args[key] !== null && args[key] !== '';
369
+ const given = ['url', 'file', 'job_id', 'doc_id'].filter(present);
370
+ if (given.length === 0) {
371
+ throw new OcrToolError('INVALID_INPUT', '必须给出 url / file / job_id(+doc_id) / doc_id 之一', '解析新文档传 url 或 file;读已解析的文档传 doc_id');
372
+ }
373
+ const charts = flag(args.charts, 'charts', true);
374
+ const refresh = flag(args.refresh, 'refresh', false);
375
+ const pages = optionalString(args.pages, 'pages', 64);
376
+ const query = optionalString(args.query, 'query', OCR_MAX_QUERY_CHARS);
377
+ const limit = integer(args.limit, 'limit', 1, OCR_MAX_QUERY_LIMIT);
378
+ if (pages !== undefined && query !== undefined) {
379
+ throw new OcrToolError('INVALID_INPUT', 'pages 与 query 只能给一个', '先 query 定位到页,再 pages 整页读');
380
+ }
381
+ if (given.includes('job_id')) {
382
+ if (!given.includes('doc_id')) {
383
+ throw new OcrToolError('INVALID_INPUT', 'job_id 必须与同一回执里的 doc_id 一起传', '续查一个已提交作业:{ job_id, doc_id }');
384
+ }
385
+ if (given.length > 2)
386
+ throw new OcrToolError('INVALID_INPUT', `续查时不要再传 ${given.filter((key) => key !== 'job_id' && key !== 'doc_id').join('/')}`);
387
+ if (pages !== undefined || query !== undefined) {
388
+ throw new OcrToolError('INVALID_INPUT', '作业还没解析完,没有页可读', '先只传 job_id + doc_id 等到 status:done');
389
+ }
390
+ const jobId = requiredString(args.job_id, 'job_id', 128);
391
+ return { mode: 'resume', label: jobId, job_id: jobId, doc_id: requireDocId(args.doc_id), charts, refresh };
392
+ }
393
+ if (given.length > 1)
394
+ throw new OcrToolError('INVALID_INPUT', `一次只走一种形态,收到 ${given.join(' + ')}`);
395
+ const mode = given[0];
396
+ if (mode === 'doc_id') {
397
+ if (refresh || !charts) {
398
+ throw new OcrToolError('INVALID_INPUT', 'refresh / charts 只对 url 或 file 形态有意义', '重跑请重新传 url 或 file,并带 refresh: true');
399
+ }
400
+ if (limit !== undefined && query === undefined)
401
+ throw new OcrToolError('INVALID_INPUT', 'limit 只在 query 形态下有意义的');
402
+ const docId = requireDocId(args.doc_id);
403
+ return { mode: 'read', label: docId, doc_id: docId, ...(pages === undefined ? {} : { pages }), ...(query === undefined ? {} : { query }), ...(limit === undefined ? {} : { limit }), charts, refresh };
404
+ }
405
+ if (pages !== undefined || query !== undefined || limit !== undefined) {
406
+ throw new OcrToolError('INVALID_INPUT', '新解析时还没有页可读', `先拿到回执里的 doc_id,再用 { doc_id, ${pages !== undefined ? 'pages' : 'query'} } 读`);
407
+ }
408
+ if (mode === 'url') {
409
+ const url = requiredString(args.url, 'url', MAX_URL_LENGTH);
410
+ return { mode: 'url', label: url, url, charts, refresh };
411
+ }
412
+ const file = requiredString(args.file, 'file', 512);
413
+ return { mode: 'file', label: file, file, charts, refresh };
414
+ }
415
+ /** 本地文档路径:剥掉 `@mention` 带进来的开头 `@`,再按后缀放行(真正的归属校验在 store)。 */
416
+ function normalizeLocalPath(value) {
417
+ const raw = value.startsWith('@') ? value.slice(1).trim() : value.trim();
418
+ const suffix = (raw.includes('.') ? raw.slice(raw.lastIndexOf('.') + 1) : '').toLowerCase();
419
+ if (!OCR_LOCAL_SUFFIXES.includes(suffix)) {
420
+ throw new OcrToolError('INVALID_INPUT', `file 只接受 ${OCR_LOCAL_SUFFIXES.join('/')} 文档,收到 "${raw}"`, 'PDF 直链请改用 url 形态');
421
+ }
422
+ return raw;
423
+ }
424
+ function pageIndex(document, withHeadings) {
425
+ return document.pages.map((entry) => ({
426
+ page: entry.page,
427
+ chars: entry.chars,
428
+ ...(withHeadings && entry.heading !== undefined ? { heading: entry.heading } : {}),
429
+ }));
430
+ }
431
+ function fitsBudget(payload) {
432
+ return JSON.stringify(payload).length <= SOURCE_OUTPUT_BUDGET_CHARS;
433
+ }
434
+ function echoFields(echo) {
435
+ return { recent_retrievals: echo.recent, provider_tally: echo.tally };
436
+ }
437
+ function metaFields(meta, cached) {
438
+ return {
439
+ provider: 'paddleocr',
440
+ status: 'done',
441
+ ok: true,
442
+ doc_id: meta.doc_id,
443
+ artifact_ref: ocrArtifactRef(meta.doc_id),
444
+ cached,
445
+ model: meta.model,
446
+ created_at: meta.created_at,
447
+ pages: meta.pages,
448
+ chars: meta.chars,
449
+ tables: meta.tables,
450
+ source_kind: meta.source_kind,
451
+ ...(meta.source === undefined ? {} : { source: meta.source }),
452
+ ...(meta.images > 0
453
+ ? { images: { count: meta.images, note: '上游图片链是临时地址,本仓不下载;图形内容以文字与表格呈现' } }
454
+ : {}),
455
+ };
456
+ }
457
+ /** 排版噪音的量化说明:证明"变小"是剥样式,不是被截断。 */
458
+ function cleanupNote(meta) {
459
+ if (meta.style_chars_removed === 0 && meta.tables === 0)
460
+ return undefined;
461
+ const parts = [];
462
+ if (meta.style_chars_removed > 0)
463
+ parts.push(`已剥掉 ${meta.style_chars_removed} 个排版噪音字符`);
464
+ if (meta.tables > 0)
465
+ parts.push(`${meta.tables} 张 HTML 表已转成 markdown 表格`);
466
+ return parts.join(';');
467
+ }
468
+ /**
469
+ * `done` 回执的装载:整篇放得下就给全文,否则给页索引 + 第 1 页;连索引都放不下就只给
470
+ * 边界说明。**永不半路截断**——截断的正文会被当成完整正文引用,比少给一页危险。
471
+ */
472
+ function doneReceipt(artifact, input) {
473
+ const { meta, document } = artifact;
474
+ const cleanup = cleanupNote(meta);
475
+ const base = {
476
+ ...metaFields(meta, input.cached),
477
+ ...(input.elapsed_ms === undefined ? {} : { elapsed_ms: input.elapsed_ms }),
478
+ ...echoFields(input.echo),
479
+ };
480
+ const full = { ...base, content: document.document, content_chars: meta.chars, ...(cleanup === undefined ? {} : { note: cleanup }) };
481
+ if (fitsBudget(full))
482
+ return full;
483
+ const index = pageIndex(document, true);
484
+ const first = document.pages[0];
485
+ const pageOne = first === undefined
486
+ ? undefined
487
+ : { page: first.page, chars: first.chars, ...(first.heading === undefined ? {} : { heading: first.heading }), text: document.document.slice(first.start, first.end) };
488
+ const withIndex = { ...base, index, note: budgetNote(meta, cleanup, false) };
489
+ // 判断装载用的是**那一条**note:换措辞后再测会让"放不下"变成"其实放不下"(多 3 个字符也算越界)。
490
+ const withPageOne = pageOne === undefined ? undefined : { ...withIndex, page_one: pageOne, note: budgetNote(meta, cleanup, true) };
491
+ if (withPageOne && fitsBudget(withPageOne))
492
+ return withPageOne;
493
+ if (fitsBudget(withIndex))
494
+ return withIndex;
495
+ const brief = { ...base, index: pageIndex(document, false) };
496
+ if (fitsBudget(brief)) {
497
+ return { ...brief, note: `${meta.pages} 页整篇超出预算:只给页号与字数,用 pages 或 query 取正文` };
498
+ }
499
+ return { ...base, note: `${meta.pages} 页超出预算,连页索引都放不下:用 pages="1-${OCR_MAX_PAGES_PER_READ}" 分段读,或用 query 定位` };
500
+ }
501
+ /**
502
+ * 超预算时的那句说明必须**算得过来**:早先写的是"整篇 N 字符超出预算 6144",而 5717 < 6144
503
+ * 也是这句话——被装的从来不只是正文,还有信封与页索引。说成"正文 + 回执装不进"才是事实。
504
+ */
505
+ function budgetNote(meta, cleanup, withPageOne) {
506
+ const line = `整篇正文 ${meta.chars} 字符连同回执装不进 ${SOURCE_OUTPUT_BUDGET_CHARS} 字符预算:这里给${withPageOne ? '页索引与首页' : '页索引'},正文用 pages 或 query 取(不再出网、不再花钱)`;
507
+ return cleanup === undefined ? line : `${line};${cleanup}`;
508
+ }
509
+ function pendingReceipt(input) {
510
+ return {
511
+ provider: 'paddleocr',
512
+ status: 'pending',
513
+ ok: true,
514
+ doc_id: input.doc_id,
515
+ job_id: input.job_id,
516
+ elapsed_ms: input.elapsed_ms,
517
+ ...(input.progress === undefined ? {} : { extracted_pages: input.progress.extracted_pages, total_pages: input.progress.total_pages }),
518
+ hint: '作业仍在跑(整篇算完才出结果,已花费这一次提交)。再调一次 ocr,**同时**传这个 job_id 与 doc_id:不会重复提交、不再花钱。',
519
+ ...echoFields(input.echo),
520
+ };
521
+ }
522
+ /** `pages` 形态:整页给,放不下就**整页**从尾部让路(不切半页)。 */
523
+ function pagesReceipt(input) {
524
+ const { meta, document } = input.artifact;
525
+ const envelope = (excerpts, omitted) => ({
526
+ ...metaFields(meta, true),
527
+ ...echoFields(input.echo),
528
+ pages_requested: input.requested,
529
+ excerpts,
530
+ ...(omitted.length > 0 ? { omitted_pages: omitted } : {}),
531
+ });
532
+ const excerpts = [];
533
+ let omitted = [];
534
+ for (const entry of selectPages(document, input.requested)) {
535
+ const item = {
536
+ page: entry.page,
537
+ chars: entry.chars,
538
+ ...(entry.heading === undefined ? {} : { heading: entry.heading }),
539
+ text: entry.text,
540
+ };
541
+ // 第一条永不因预算丢弃以外的理由跳过:单页就超预算时宁可空手报错,也不给半页。
542
+ if (!fitsBudget(envelope([...excerpts, item], []))) {
543
+ omitted = input.requested.slice(excerpts.length);
544
+ break;
545
+ }
546
+ excerpts.push(item);
547
+ }
548
+ const notes = [];
549
+ if (!input.explicit)
550
+ notes.push(`没传 pages,默认回第 1 页;全篇 ${meta.pages} 页,用 pages 或 query 继续读`);
551
+ if (omitted.length > 0)
552
+ notes.push(`预算 ${SOURCE_OUTPUT_BUDGET_CHARS} 字符:第 ${omitted.join('/')} 页未放入,单独取回或改用 query`);
553
+ return {
554
+ ...envelope(excerpts, omitted),
555
+ ...(notes.length > 0 ? { note: notes.join(';') } : {}),
556
+ };
557
+ }
558
+ /** `query` 形态:命中窗口从尾部裁,永不给出半截列表还假装完整。 */
559
+ function queryReceipt(input) {
560
+ const { meta, document } = input.artifact;
561
+ const found = locateQuery(document, input.query, input.limit, OCR_QUERY_WINDOW_CHARS);
562
+ const hits = [];
563
+ let omitted = 0;
564
+ for (const hit of found) {
565
+ const item = { page: hit.page, excerpt: hit.excerpt };
566
+ if (!fitsBudget({ ...metaFields(meta, true), ...echoFields(input.echo), hits: [...hits, item] })) {
567
+ omitted = found.length - hits.length;
568
+ break;
569
+ }
570
+ hits.push(item);
571
+ }
572
+ const notes = [`全篇 ${meta.pages} 页里命中 ${found.length} 处,显示 ${hits.length} 处`];
573
+ if (omitted > 0)
574
+ notes.push(`因预算省略其后 ${omitted} 处:调小 limit,或用 pages 取整页`);
575
+ if (hits.length === 0)
576
+ notes.push('没有命中不等于文档里没有这个主题:换关键词,或按 pages 通读');
577
+ return {
578
+ ...metaFields(meta, true),
579
+ ...echoFields(input.echo),
580
+ query: input.query,
581
+ hit_count: hits.length,
582
+ hits,
583
+ note: notes.join(';'),
584
+ };
585
+ }
586
+ /** 解析完成后的统一出口:落盘(内容寻址、默认不覆盖)→ 回执。 */
587
+ async function persistAndReceipt(deps, input) {
588
+ const meta = buildArtifactMeta({
589
+ doc_id: input.doc_id,
590
+ ...(input.source === undefined ? {} : { source: input.source }),
591
+ source_kind: input.source_kind,
592
+ ...(input.charts === undefined ? {} : { charts: input.charts }),
593
+ job_id: input.job_id,
594
+ model: deps.client.model,
595
+ document: input.document,
596
+ createdAt: new Date((deps.now ?? Date.now)()).toISOString(),
597
+ });
598
+ try {
599
+ await writeOcrArtifact(deps.store, { session: input.session, meta, document: input.document, signal: input.signal, overwrite: input.refresh });
600
+ }
601
+ catch (error) {
602
+ // 两个会话同时在解析同一份文档:后写的撞 createIfAbsent。读回先写的那份即可——
603
+ // 内容寻址保证两者同文,把它报成失败只是把一次成功说成错误。
604
+ if (input.refresh)
605
+ throw error;
606
+ const existing = await readOcrArtifact(deps.store, { session: input.session, doc_id: input.doc_id, signal: input.signal });
607
+ if (!existing)
608
+ throw error;
609
+ return doneReceipt(existing, { cached: true, echo: input.echo });
610
+ }
611
+ return doneReceipt({ meta, document: input.document }, { cached: false, elapsed_ms: input.elapsed_ms, echo: input.echo });
612
+ }
613
+ /**
614
+ * 失败回执:`{provider, status:'error', ok:false, code, error, hint?}` + 已知的 `doc_id` /
615
+ * `job_id` / 检索回声。
616
+ *
617
+ * 形态归一化(`ocrRequest`)的失败**同样**要走这里:那一层的 `hint` 是"下一步该传什么",
618
+ * 抛裸 `Error` 就只剩一句中文,模型照旧会再猜一次。回声此时还没有(连请求都无法指认),
619
+ * 所以缺省不带,而不是硬编一个空标签冒充。
620
+ *
621
+ * `spent` 是**本次调用已经产生花费**的指认:`url`/`file` 形态的 `doc_id` 是算出来的、
622
+ * `job_id` 是提交回来的,都不在入参里。失败时把它们交回去,模型才能走"只传 job_id + doc_id
623
+ * 续查"那条不花钱的路;不给就等于把一次已经付费的作业丢在半路。
624
+ */
625
+ function ocrErrorReceipt(error, input) {
626
+ const docId = input.requested?.doc_id ?? input.spent?.doc_id;
627
+ const jobId = input.requested?.job_id ?? input.spent?.job_id;
628
+ return {
629
+ provider: 'paddleocr',
630
+ status: 'error',
631
+ ok: false,
632
+ ...ocrFailure(error),
633
+ ...(docId === undefined ? {} : { doc_id: docId }),
634
+ ...(jobId === undefined ? {} : { job_id: jobId }),
635
+ ...(input.echo === undefined ? {} : echoFields(input.echo)),
636
+ };
637
+ }
638
+ async function runOcr(deps, trace, args, exec) {
639
+ let requested;
640
+ try {
641
+ requested = ocrRequest(args);
642
+ }
643
+ catch (error) {
644
+ throwToolFailure(ocrErrorReceipt(error, {}));
645
+ }
646
+ const spent = {};
647
+ const echo = trace(exec, 'paddleocr', 'ocr', requested.label);
648
+ try {
649
+ const session = callerSession(exec);
650
+ const signal = exec.signal;
651
+ if (requested.mode === 'read') {
652
+ const artifact = await readOcrArtifact(deps.store, { session, doc_id: requested.doc_id, signal });
653
+ if (!artifact) {
654
+ throw new OcrToolError('DOC_NOT_FOUND', `没有 doc_id ${requested.doc_id} 的解析结果`, '先用 url 或 file 解析,或核对回执里的 doc_id');
655
+ }
656
+ if (requested.query !== undefined) {
657
+ return queryReceipt({ artifact, query: requested.query, limit: requested.limit ?? OCR_DEFAULT_QUERY_LIMIT, echo });
658
+ }
659
+ if (requested.pages !== undefined) {
660
+ const pages = parsePageRange(requested.pages, artifact.document.pages.length);
661
+ if (pages.length > OCR_MAX_PAGES_PER_READ) {
662
+ throw new OcrToolError('INVALID_INPUT', `一次最多读 ${OCR_MAX_PAGES_PER_READ} 页,收到 ${pages.length} 页`, '缩小 pages 区间,或改用 query 定位');
663
+ }
664
+ return pagesReceipt({ artifact, requested: pages, explicit: true, echo });
665
+ }
666
+ return doneReceipt(artifact, { cached: true, echo });
667
+ }
668
+ if (requested.mode === 'resume') {
669
+ const docId = requested.doc_id;
670
+ const existing = await readOcrArtifact(deps.store, { session, doc_id: docId, signal });
671
+ if (existing)
672
+ return doneReceipt(existing, { cached: true, echo });
673
+ const outcome = await deps.client.wait(requested.job_id, signal);
674
+ if (outcome.status === 'pending' || !outcome.document) {
675
+ return pendingReceipt({ doc_id: docId, job_id: outcome.job_id, ...(outcome.progress ? { progress: outcome.progress } : {}), elapsed_ms: outcome.elapsed_ms, echo });
676
+ }
677
+ return await persistAndReceipt(deps, {
678
+ session,
679
+ signal,
680
+ doc_id: docId,
681
+ source_kind: 'job',
682
+ job_id: outcome.job_id,
683
+ document: outcome.document,
684
+ refresh: false,
685
+ elapsed_ms: outcome.elapsed_ms,
686
+ echo,
687
+ });
688
+ }
689
+ // 解析形态:先算 doc_id,命中缓存就**一次都不出网**。
690
+ const target = requested.mode === 'url'
691
+ ? await pdfUrlTarget(requested.url, signal, deps.resolveAddresses)
692
+ : await localFileTarget(deps, requested.file, session, signal);
693
+ const docId = deriveOcrDocId(target.seed, deps.client.model, requested.charts);
694
+ spent.doc_id = docId;
695
+ if (!requested.refresh) {
696
+ const cached = await readOcrArtifact(deps.store, { session, doc_id: docId, signal });
697
+ if (cached)
698
+ return doneReceipt(cached, { cached: true, echo });
699
+ }
700
+ const jobId = await deps.client.submit({ ...target.submit, charts: requested.charts, signal });
701
+ spent.job_id = jobId;
702
+ const outcome = await deps.client.wait(jobId, signal);
703
+ if (outcome.status === 'pending' || !outcome.document) {
704
+ return pendingReceipt({ doc_id: docId, job_id: jobId, ...(outcome.progress ? { progress: outcome.progress } : {}), elapsed_ms: outcome.elapsed_ms, echo });
705
+ }
706
+ return await persistAndReceipt(deps, {
707
+ session,
708
+ signal,
709
+ doc_id: docId,
710
+ source: target.source,
711
+ source_kind: target.sourceKind,
712
+ charts: requested.charts,
713
+ job_id: jobId,
714
+ document: outcome.document,
715
+ refresh: requested.refresh,
716
+ elapsed_ms: outcome.elapsed_ms,
717
+ echo,
718
+ });
719
+ }
720
+ catch (error) {
721
+ // 取消原样抛出:那是调用方的意志,不能被降级成一条"看起来像上游坏了"的错误信封。
722
+ if (isAbortError(error))
723
+ throw error;
724
+ throwToolFailure(ocrErrorReceipt(error, { requested, echo, spent }));
725
+ }
726
+ }
727
+ /**
728
+ * `url` 形态:出口闸门复用本机直连那一份实现(形态 + 主机公网性),再收 `.pdf` 后缀。
729
+ * 本机**不抓**这个文件——上游替我们取,所以这里没有体积上限,只有 SSRF 面。
730
+ */
731
+ async function pdfUrlTarget(value, signal, resolveAddresses) {
732
+ const url = await assertPublicUrlTarget(value, signal, resolveAddresses);
733
+ const pathname = new URL(url).pathname;
734
+ if (!PDF_URL_SUFFIX.test(pathname)) {
735
+ throw new OcrToolError('BLOCKED_URL', `url 指向的不是 .pdf(路径以 ${pathname.slice(-16) || '/'} 结尾)`, '上游只解析 PDF;网页正文走 web_retriever_fetch');
736
+ }
737
+ return { seed: `url:${url}`, source: `url:${url}`, sourceKind: 'url', submit: { fileUrl: url } };
738
+ }
739
+ /** `file` 形态:读出字节 + 指认(`source` 只记 workspace 相对路径,绝不记绝对路径)。 */
740
+ async function localFileTarget(deps, value, session, signal) {
741
+ const path = normalizeLocalPath(value);
742
+ const { bytes, filename } = await readOcrSourceBytes(deps.store, { session, path, signal });
743
+ return { seed: bytes, source: `file:${path}`, sourceKind: 'file', submit: { file: { bytes, filename } } };
744
+ }
745
+ // ── 具名来源的结构化查询工具 ────────────────────────────────────────────────
746
+ //
747
+ // 与上面四个工具的关系:search / fetch 是**检索**工作面(候选发现、按 URL 取正文),
748
+ // 这里是**查询**工作面(具名来源 + 业务参数 → 确定、有序、可翻页、同参可复现的结果集)。
749
+ // 九个来源各自是一次具体实现,没有配方注册表 / 通用适配层——见 `sources.ts` 文件头。
750
+ const SOURCE_RESULT_CAP = MAX_ITEMS;
751
+ const SOURCE_PAGE_SIZE_MAX = 50;
752
+ /** 六个带 code 的工具共用这一句"能写什么",描述的写法与校验的实现因此不会各说各话。 */
753
+ const CODE_FORMS = '(也可写 SH600519 / 600519.SH,会归一化成 6 位)';
754
+ /**
755
+ * 一次具名来源输出的字符预算:取宿主 tool-result 剪枝阈值的 75%。
756
+ * 阈值配在 `preset/capital-generation/agent.cordis.yml` 的 `thresholdChars: 8192`
757
+ * (由 `test/persona.test.mjs` 钉住与本常量同源)。超过阈值的工具结果在会话历史里会被
758
+ * `dsh-compaction-tool-result-pruner` 换成 head 4096 + `[... middle pruned ...]` + tail 1024,
759
+ * 也就是**中间条目被无声吃掉**,而留在头部的 `count` 还写着原来的条数。
760
+ * 留 25% 余量的做法与数据面能力目录的预算同一口径(`test/data-collector-hub.test.mjs`)。
761
+ */
762
+ export const SOURCE_OUTPUT_BUDGET_CHARS = 6144;
763
+ /** 预算里为"承认省略了几条"那句话预留的体积:不预留的话,加上这句话反而会再次越界。 */
764
+ const TRUNCATION_NOTE_RESERVE = 200;
765
+ function sourceOutput(outcome, recent, tally) {
766
+ const candidates = outcome.items.slice(0, SOURCE_RESULT_CAP);
767
+ const envelope = (items, omitted) => {
768
+ const notes = outcome.note === undefined ? [] : [outcome.note];
769
+ if (omitted > 0) {
770
+ notes.push(`输出预算 ${SOURCE_OUTPUT_BUDGET_CHARS} 字符:本次只放了前 ${items.length} 条,省略其后 ${omitted} 条${outcome.next_cursor === undefined ? '(请缩小 limit 或翻页重查)' : '(用 next_cursor 继续翻页)'}`);
771
+ }
772
+ return {
773
+ provider: outcome.source,
774
+ operation: outcome.operation,
775
+ ok: true,
776
+ count: items.length,
777
+ ...(outcome.next_cursor !== undefined ? { next_cursor: outcome.next_cursor } : {}),
778
+ ...(notes.length > 0 ? { note: notes.join(';') } : {}),
779
+ items,
780
+ recent_retrievals: recent,
781
+ provider_tally: tally,
782
+ };
783
+ };
784
+ // 按上游顺序**从尾部裁**,不跳着放:跳着放会让"同参数同结果"失效,count 也不再是前缀长度。
785
+ let taken = 0;
786
+ let used = JSON.stringify(envelope([], 0)).length + TRUNCATION_NOTE_RESERVE;
787
+ for (const item of candidates) {
788
+ const cost = JSON.stringify(item).length + (taken > 0 ? 1 : 0);
789
+ if (taken > 0 && used + cost > SOURCE_OUTPUT_BUDGET_CHARS)
790
+ break;
791
+ used += cost;
792
+ taken += 1;
793
+ }
794
+ let items = candidates.slice(0, taken);
795
+ let payload = envelope(items, candidates.length - items.length);
796
+ // 兜底自检:算术里已经预留了截断说明的体积,真超了(note 本身很长)就再退一条,
797
+ // 宁可少一条也不把整份结果交给剪枝器切成头尾两截。
798
+ while (items.length > 1 && JSON.stringify(payload).length > SOURCE_OUTPUT_BUDGET_CHARS) {
799
+ items = items.slice(0, -1);
800
+ payload = envelope(items, candidates.length - items.length);
801
+ }
802
+ return payload;
803
+ }
804
+ /**
805
+ * 失败也做成**工具错误**(`isError`),与 `web_retriever_fetch` 同一口径:
806
+ * 宿主用 `isError` 表达失败,正常返回无法标记失败(见 `throwToolFailure` 注释)。
807
+ * 抛出的消息带完整结构化回执,`recent_retrievals` / `provider_tally` 一个不丢。
808
+ */
809
+ function throwSourceFailure(source, operation, error, recent, tally) {
810
+ const code = error instanceof SourceError ? error.code : 'NETWORK';
811
+ throwToolFailure({
812
+ provider: source,
813
+ operation,
814
+ ok: false,
815
+ code,
816
+ error: error instanceof Error ? error.message : String(error),
817
+ recent_retrievals: recent,
818
+ provider_tally: tally,
819
+ });
820
+ }
821
+ function sourceItemsText(items) {
822
+ return items.length === 0 ? '(0 条)' : `${items.length} 条`;
823
+ }
824
+ /**
825
+ * 注册九个具名来源工具(provider + operation 命名)。
826
+ *
827
+ * 为什么单独一个注册函数:这九个工具只依赖 HTTP 传输(`local-fetch` 的出口校验),
828
+ * 不依赖 AnySearch / Wind;任何一路的配置缺失都不应连带影响另外几路的注册。
829
+ *
830
+ * `enabled`(设置卡片「允许启动本地提取网页内容」)**同时支配这九个工具的执行**:
831
+ * 关闭时工具仍在注册面(不静默消失),但每次调用响亮失败并说明开关位置——
832
+ * 卡片文案的字面语义对模型与用户都成立,用户同意不被旁路。
833
+ */
834
+ export function registerSourceTools(ctx, localFetchOptions) {
835
+ const tools = ctx.get('tools');
836
+ if (!tools)
837
+ return;
838
+ const echo = createRetrievalEcho();
839
+ const transport = createSourceTransport(localFetchOptions);
840
+ // 东财系三个工具(7×24 快讯 / 个股新闻 / 研报)**共用同一个节流器**:它们打到同一个上游、
841
+ // 共用主机的出口 IP,各自限流等于没限(详见 `src/net/eastmoney-client.ts`)。
842
+ // 与数据面共享**同一份进程级节流**(`sharedEastmoneyThrottle()`),但 transport 注入
843
+ // 闸门实现(`createHttpRequester`):出口校验、逐跳重定向重校验、大小/超时上限与其余
844
+ // 六个来源同一份(此前走裸 fetch,是"声称一份、实际两份"的事故形态,AGENTS.md §9.7)。
845
+ const eastmoneyClient = createEastmoneyClient({
846
+ userAgent: localFetchOptions.userAgent,
847
+ throttle: sharedEastmoneyThrottle(),
848
+ transport: createGatedEastmoneyTransport(transport),
849
+ });
850
+ const trace = (exec, provider, tool, query) => echo(exec, { provider, tool, query, at: Date.now() });
851
+ const itemsProperties = {
852
+ type: 'array',
853
+ description: '按上游返回顺序排列的条目;字段随 operation 不同,含时间与来源链接',
854
+ items: { type: 'object', additionalProperties: true },
855
+ };
856
+ const envelope = (properties) => ({
857
+ type: 'object',
858
+ additionalProperties: true,
859
+ properties: {
860
+ provider: { type: 'string' },
861
+ operation: { type: 'string' },
862
+ ok: { type: 'boolean' },
863
+ count: { type: 'integer' },
864
+ items: itemsProperties,
865
+ ...properties,
866
+ recent_retrievals: { type: 'array' },
867
+ provider_tally: { type: 'object' },
868
+ },
869
+ });
870
+ async function run(exec, provider, operation, label, produce) {
871
+ const { recent, tally } = trace(exec, provider, `${provider}_${operation}`, label);
872
+ if (localFetchOptions.enabled === false) {
873
+ throwSourceFailure(provider, operation, new SourceError(provider, 'DISABLED', '本机直连已被设置关闭(卡片「允许启动本地提取网页内容」);本工具全部条目都需本机 HTTP,开关打开后新会话可用'), recent, tally);
874
+ }
875
+ let outcome;
876
+ try {
877
+ outcome = await produce();
878
+ }
879
+ catch (error) {
880
+ // 调用方取消**原样抛出**,绝不降级成失败信封:取消不是"这个来源查不到",
881
+ // 而把它写成 ok:false 会让上层以为来源失败(与 web_retriever_fetch 同口径)。
882
+ // 传输层抛的是 `LocalFetchError('ABORTED')`,来源层抛的是 `SourceError('ABORTED')`。
883
+ const code = error instanceof SourceError ? error.code : error?.code;
884
+ if (code === 'ABORTED' || (error instanceof Error && error.name === 'AbortError'))
885
+ throw error;
886
+ throwSourceFailure(provider, operation, error, recent, tally);
887
+ }
888
+ return sourceOutput(outcome, recent, tally);
889
+ }
890
+ const definitions = [
891
+ {
892
+ name: 'cls_telegraph',
893
+ description: '取财联社 7×24 全市场快讯(直连 cls.cn v1 接口,签名由本工具本地计算,零 key)。这是**确定性来源查询**而非检索:返回最近 limit 条快讯,含时间、标题、正文与原文链接。财联社是媒体来源,内容只能作为「媒体转述旁证」,不得标 verified_official;官方公告请用 wind_docs_announcements。与 wscn_lives / wind_docs_news 互为独立备份。',
894
+ parameters: jsonObject({
895
+ limit: { type: 'integer', description: '可选,返回快讯条数 1~50,默认 20' },
896
+ }, []),
897
+ output: { schema: envelope({}), render },
898
+ async execute(args, exec) {
899
+ const limit = integer(args.limit, 'limit', 1, SOURCE_RESULT_CAP) ?? 20;
900
+ return run(exec, 'cls', 'telegraph', `limit=${limit}`, () => clsTelegraph(transport, { limit, signal: exec.signal }));
901
+ },
902
+ },
903
+ {
904
+ name: 'wscn_lives',
905
+ description: '取华尔街见闻 7×24 快讯(确定性来源查询,按频道 + 游标翻页)。channel 常用 global-channel(要闻)/ a-stock-channel(A 股);importance 取见闻的 score 字段(实测 1~3,越大越重要)。时间为北京时间。翻页把回执的 next_cursor 原样传回 cursor。媒体来源,只能作为「媒体转述旁证」,不得标 verified_official。',
906
+ parameters: jsonObject({
907
+ channel: { type: 'string', description: '频道名,形如 global-channel / a-stock-channel' },
908
+ limit: { type: 'integer', description: '可选,返回条数 1~50,默认 20' },
909
+ cursor: { type: 'string', description: '可选,上一页回执里的 next_cursor' },
910
+ }, ['channel']),
911
+ output: { schema: envelope({ next_cursor: { type: 'string' } }), render },
912
+ async execute(args, exec) {
913
+ const channel = requiredString(args.channel, 'channel', 64);
914
+ if (!/^[a-z0-9-]+-channel$/u.test(channel))
915
+ throw new Error("channel must look like 'global-channel' / 'a-stock-channel'");
916
+ const limit = integer(args.limit, 'limit', 1, SOURCE_RESULT_CAP) ?? 20;
917
+ const cursor = args.cursor === undefined ? undefined : requiredString(args.cursor, 'cursor', 64);
918
+ return run(exec, 'wscn', 'lives', `channel=${channel} limit=${limit}`, () => wscnLives(transport, { channel, limit, ...(cursor === undefined ? {} : { cursor }), signal: exec.signal }));
919
+ },
920
+ },
921
+ {
922
+ name: 'cninfo_irm',
923
+ description: '取巨潮「互动易」投资者问答(深市专用):投资者提问 + 公司回复。用于回答"公司怎么回应某传闻/关切"。code 传 6 位股票代码。status=answered 表示公司已回复(answer 有正文),unanswered 表示尚未回复——这是事实而非缺口。实测沪市公司返回 0 条,沪市请用 sseinfo_qa;北交所两个平台都没有。上游返回空表时 note 会写明覆盖范围。',
924
+ parameters: jsonObject({
925
+ code: { type: 'string', description: `6 位股票代码(深市)${CODE_FORMS}` },
926
+ page_size: { type: 'integer', description: `可选,每页条数 1~${SOURCE_PAGE_SIZE_MAX},默认 20` },
927
+ page_num: { type: 'integer', description: '可选,页码从 1 开始' },
928
+ }, ['code']),
929
+ output: { schema: envelope({}), render },
930
+ async execute(args, exec) {
931
+ const code = bareAshareDigits(args.code);
932
+ const pageSize = integer(args.page_size, 'page_size', 1, SOURCE_PAGE_SIZE_MAX) ?? 20;
933
+ const pageNum = integer(args.page_num, 'page_num', 1, 1000) ?? 1;
934
+ return run(exec, 'cninfo_irm', 'questions', `code=${code} page=${pageNum}`, () => cninfoIrm(transport, { code, pageSize, pageNum, signal: exec.signal }));
935
+ },
936
+ },
937
+ {
938
+ name: 'sseinfo_qa',
939
+ description: '取上交所「上证e互动」投资者问答(沪市专用)。不传 code = 全市场最新问答;传沪市代码(60/68/900 开头)= 该公司问答。kind=answered 取最新已回复,kind=questions 取最新提问(含未回复,status=unanswered)。平台只开放近期问答(公司维度实测约近 1 个月),更早的翻页为空属正常。首次查某公司要先在公司列表里定位 uid(上游约 10–13 次请求,之后进程内缓存;整段定位有 60 秒总时限,超时回 code=TIMEOUT——那是上游慢,不是这次调用被取消,隔一会儿再试或先查全市场)。深市问答请改用 cninfo_irm。',
940
+ parameters: jsonObject({
941
+ code: { type: 'string', description: `可选,沪市 6 位代码${CODE_FORMS}` },
942
+ kind: { type: 'string', enum: ['answered', 'questions'], description: '可选,answered=最新已回复(默认)/ questions=最新提问' },
943
+ page: { type: 'integer', description: '可选,页码从 1 开始' },
944
+ page_size: { type: 'integer', description: `可选,每页条数 1~${SOURCE_PAGE_SIZE_MAX},默认 10` },
945
+ }, []),
946
+ output: { schema: envelope({}), render },
947
+ async execute(args, exec) {
948
+ const code = args.code === undefined ? undefined : bareAshareDigits(args.code);
949
+ const kind = args.kind === undefined ? 'answered' : requiredString(args.kind, 'kind', 16);
950
+ if (kind !== 'answered' && kind !== 'questions')
951
+ throw new Error("kind must be 'answered' or 'questions'");
952
+ const page = integer(args.page, 'page', 1, 1000) ?? 1;
953
+ const pageSize = integer(args.page_size, 'page_size', 1, SOURCE_PAGE_SIZE_MAX) ?? 10;
954
+ return run(exec, 'sseinfo', code === undefined ? `market_${kind}` : `company_${kind}`, code === undefined ? `kind=${kind}` : `code=${code} kind=${kind}`, () => sseinfoQa(transport, { ...(code === undefined ? {} : { code }), kind, page, pageSize, signal: exec.signal }));
955
+ },
956
+ },
957
+ {
958
+ name: 'eastmoney_724',
959
+ description: '取东方财富 7×24 全球财经快讯(确定性来源查询)。与 cls_telegraph / wscn_lives 是三条独立来源,互为备份(不同源、不同风控面,一条不可用另一条仍在)。返回时间/标题/摘要/链接。注意:东财会把它聚合到的财联社内容一并返回,同一事件可能与其他来源重复,按标题去重。媒体来源,只能作旁证,不得标 verified_official。',
960
+ parameters: jsonObject({
961
+ limit: { type: 'integer', description: '可选,返回条数 1~50,默认 20' },
962
+ }, []),
963
+ output: { schema: envelope({}), render },
964
+ async execute(args, exec) {
965
+ const limit = integer(args.limit, 'limit', 1, SOURCE_RESULT_CAP) ?? 20;
966
+ return run(exec, 'eastmoney', 'fast_news', `limit=${limit}`, () => eastmoneyFastNews(eastmoneyClient, { limit, signal: exec.signal }));
967
+ },
968
+ },
969
+ {
970
+ name: 'eastmoney_stock_news',
971
+ description: '取某只股票的东方财富新闻列表(确定性来源查询,按 6 位代码)。用于"这只票最近有什么新闻"。回执含时间/标题/正文片段/媒体/链接。⚠️ 上游对部分 IP 会间歇风控,此时只返回股民资料而无文章列表——本工具按"文章列表键是否存在"区分,报错说明是风控而非"该股没有新闻";遇到该错误隔几分钟或换网络再试,不要重试同一路径。媒体来源,只能作旁证。',
972
+ parameters: jsonObject({
973
+ code: { type: 'string', description: `6 位股票代码${CODE_FORMS}` },
974
+ limit: { type: 'integer', description: '可选,返回条数 1~50,默认 20' },
975
+ }, ['code']),
976
+ output: { schema: envelope({}), render },
977
+ async execute(args, exec) {
978
+ const code = bareAshareDigits(args.code);
979
+ const limit = integer(args.limit, 'limit', 1, SOURCE_RESULT_CAP) ?? 20;
980
+ return run(exec, 'eastmoney', 'stock_news', `code=${code} limit=${limit}`, () => eastmoneyStockNews(eastmoneyClient, { code, limit, signal: exec.signal }));
981
+ },
982
+ },
983
+ {
984
+ name: 'eastmoney_reports',
985
+ description: '取个股研报列表(东财研报库,按 6 位代码;上游只认纯数字——实测带 SH/SZ 前缀返回 0 条,所以 `SH600519` / `600519.SH` 这类写法由本工具先归一化成 6 位再查)。返回日期/标题/机构/研究员/评级/评级变动与该篇研报的当年预测 EPS,并给出每条的 `pdf_url`(研报 PDF 直链)与 `attach_pages`。⚠️ 列表**不含摘要**:正文只在 PDF 里,`web_retriever_fetch` 拿到 PDF 会失败(本机直连只处理文本),要正文把那条 `pdf_url` 交 `ocr` 解析。`ocr` 也不可用时才如实说明该边界,**不得用标题当结论**。券商观点属第三方预测,不是官方事实,回传时标注机构名与发布日期。',
986
+ parameters: jsonObject({
987
+ code: { type: 'string', description: `6 位股票代码${CODE_FORMS}` },
988
+ page_size: { type: 'integer', description: '可选,每页条数 1~50,默认 20' },
989
+ page: { type: 'integer', description: '可选,页码从 1 开始' },
990
+ }, ['code']),
991
+ output: { schema: envelope({ next_cursor: { type: 'string' } }), render },
992
+ async execute(args, exec) {
993
+ const code = bareAshareDigits(args.code);
994
+ const pageSize = integer(args.page_size, 'page_size', 1, SOURCE_RESULT_CAP) ?? 20;
995
+ const pageNo = integer(args.page, 'page', 1, 1000) ?? 1;
996
+ return run(exec, 'eastmoney', 'reports', `code=${code} page=${pageNo}`, () => eastmoneyReports(eastmoneyClient, { code, pageSize, pageNo, signal: exec.signal }));
997
+ },
998
+ },
999
+ {
1000
+ name: 'sina_reports',
1001
+ description: '取新浪研报列表(研报第二来源,与 eastmoney_reports 互为备份)。不传 code 取全市场最新一页(约 40 条);传 6 位代码只看该股(本工具会自动补交易所前缀——实测缺前缀时科创板会返回假空页)。只给标题/类型/日期/机构/研究员/详情页链接,**不含评级与目标价**(需要这些用 eastmoney_reports)。上游空页是 HTTP 200 且与"确实没有研报"同形,本工具已按行数与空页提示双重判定区分。',
1002
+ parameters: jsonObject({
1003
+ code: { type: 'string', description: `可选,6 位股票代码;省略则取全市场最新${CODE_FORMS}` },
1004
+ page: { type: 'integer', description: '可选,页码从 1 开始' },
1005
+ }, []),
1006
+ output: { schema: envelope({}), render },
1007
+ async execute(args, exec) {
1008
+ const code = args.code === undefined ? undefined : bareAshareDigits(args.code);
1009
+ const page = integer(args.page, 'page', 1, 1000) ?? 1;
1010
+ return run(exec, 'sina', code === undefined ? 'latest' : 'by_stock', code === undefined ? `page=${page}` : `code=${code} page=${page}`, () => sinaReports(transport, { ...(code === undefined ? {} : { code }), page, signal: exec.signal }));
1011
+ },
1012
+ },
1013
+ {
1014
+ name: 'ths_eps_forecast',
1015
+ description: '取同花顺「机构一致预期 EPS」表(按 6 位代码)。每年一行:预测机构数 / 最小值 / **均值(即机构一致预期 EPS)** / 最大值 / 行业平均。用于"机构预计明年 EPS 多少、有几家机构在预测"。⚠️ 预测机构数偏少(<3 家)时一致预期参考价值有限,须把 institutions 一并向用户披露;这是一致预期(观点聚合),不是官方事实,也不是已实现业绩。',
1016
+ parameters: jsonObject({
1017
+ code: { type: 'string', description: `6 位股票代码${CODE_FORMS}` },
1018
+ }, ['code']),
1019
+ output: { schema: envelope({}), render },
1020
+ async execute(args, exec) {
1021
+ const code = bareAshareDigits(args.code);
1022
+ return run(exec, 'ths', 'eps_forecast', `code=${code}`, () => thsEpsForecast(transport, { code, signal: exec.signal }));
1023
+ },
1024
+ },
1025
+ ];
1026
+ for (const definition of definitions) {
1027
+ ctx.effect(() => tools.register(definition), `capital-generation.tool(${definition.name})`);
1028
+ }
1029
+ }