@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,209 @@
1
+ /**
2
+ * PaddleOCR 输出 → 模型可读 markdown 的归一化(`ocr` 的呈现层,唯一一份实现)。
3
+ *
4
+ * 为什么必须归一化(2026-09-25 拿真报文实测,贵州茅台 15 页研报):上游把**表格输出成内联
5
+ * HTML**,每个单元格都带 `style='text-align: center; word-wrap: break-word;'`——
6
+ * 105,639 个字符里 63,232 个(60%)是 `style='...'` 样板,去掉再转成 GFM 管道表只剩 27,156。
7
+ * 本仓的场景恰恰是"大量表格、图形",不处理就等于**预算的六成买的是 HTML 属性**,
8
+ * 而 `prettifyMarkdown` 这类 flag 在上游被静默忽略(未知键也返回 Success),帮不上。
9
+ *
10
+ * 归一化只做**无损排版**:不删正文、不改数字、不做摘要。数字一律照抄上游。
11
+ */
12
+ const PAGE_MARKER_PREFIX = '<!-- page:';
13
+ /** HTML 实体:财报文档里 `&amp;` 出现在公司名与科目名里,不解开就是脏数据。 */
14
+ const NAMED_ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' };
15
+ function decodeEntities(value) {
16
+ return value.replace(/&(#\d+|#x[0-9a-f]+|[a-z]+);/giu, (whole, body) => {
17
+ const lowered = body.toLowerCase();
18
+ if (lowered.startsWith('#x')) {
19
+ const code = Number.parseInt(lowered.slice(2), 10);
20
+ return Number.isFinite(code) ? String.fromCodePoint(code) : whole;
21
+ }
22
+ if (lowered.startsWith('#')) {
23
+ const code = Number.parseInt(lowered.slice(1), 10);
24
+ return Number.isFinite(code) ? String.fromCodePoint(code) : whole;
25
+ }
26
+ return NAMED_ENTITIES[lowered] ?? whole;
27
+ });
28
+ }
29
+ function stripTags(value) {
30
+ return decodeEntities(value.replace(/<[^>]*>/gu, ' '))
31
+ .replace(/\s+/gu, ' ')
32
+ .trim();
33
+ }
34
+ function escapeCell(value) {
35
+ // GFM 单元格里的竖线必须转义,否则一行被切成更多列——表格串列比报错危险(声明与正文不一致)。
36
+ return value.replace(/\|/gu, '\\|');
37
+ }
38
+ /** 取一行 `<tr>` 里的单元格文本。 */
39
+ function rowCells(row) {
40
+ const cells = [];
41
+ // 先按**开**标签切:畸形行(少写 `</td>`,实测存在)也能一格一切;再按闭标签截断本段,
42
+ // 防止 `1</td><td>2` 这类残留把下一格的内容并进来。第一格之前的 `<thead>` 碎片一律丢弃。
43
+ // ⚠️ 单元格标签是 `<td>` **或** `<th>`(上游表头行用 `<th>`,实测):写成 `<t(?:d|th)`
44
+ // 只匹配得到 `<td`,`<th>` 行切不出格、整行被 `kept` 丢掉——表头静默消失。
45
+ // `\b` 在这里是必要的:它让 `<thead>` / `<table>` 不被误当成单元格开标签。
46
+ for (const segment of row.split(/<t(?:d|h)\b[^>]*>/giu).slice(1)) {
47
+ cells.push(stripTags(segment.split(/<\/t(?:d|h)\s*>/iu)[0] ?? ''));
48
+ }
49
+ return cells;
50
+ }
51
+ /**
52
+ * 一张 HTML 表 → GFM 管道表。
53
+ *
54
+ * 表头按上游第一行处理(上游会把 `<thead><th>` 放第一行);列数以各行最大值为准并补齐,
55
+ * 因为 GFM 要求分隔行列数与表头一致,少一格整张表退化成裸文本。
56
+ */
57
+ function tableToMarkdown(tableHtml) {
58
+ const rows = [...tableHtml.matchAll(/<tr\b[^>]*>([\s\S]*?)(?:<\/tr>|$)/giu)].map((match) => rowCells(match[1]));
59
+ const kept = rows.filter((cells) => cells.length > 0);
60
+ if (kept.length === 0)
61
+ return stripTags(tableHtml);
62
+ const width = Math.max(...kept.map((cells) => cells.length));
63
+ const lines = kept.map((cells) => `| ${cells.map(escapeCell).join(' | ')}${'|'.repeat(width - cells.length)} |`);
64
+ lines.splice(1, 0, `|${' --- |'.repeat(width)}`);
65
+ return lines.join('\n');
66
+ }
67
+ /** 剥样式 + HTML 表转 GFM + 收空行。对不含 HTML 的正文近似恒等。 */
68
+ export function normalizeOcrMarkdown(source) {
69
+ const styleMatches = source.match(/\s(?:style|class)\s*=\s*(?:"[^"]*"|'[^']*')/giu) ?? [];
70
+ const styleCharsRemoved = styleMatches.reduce((total, match) => total + match.length, 0);
71
+ let tables = 0;
72
+ const withoutTables = source.replace(/<table\b[^>]*>[\s\S]*?(?:<\/table>|$)/giu, (match) => {
73
+ tables += 1;
74
+ return `\n${tableToMarkdown(match)}\n`;
75
+ });
76
+ const cleaned = withoutTables
77
+ .replace(/<br\s*\/?\s*>/giu, ' ')
78
+ .replace(/<\/?(?:td|th|tr|thead|tbody|tfoot|table|div|span|p|font|img|a|sup|sub|b|i|u|s|em|strong)\b[^>]*>/giu, '')
79
+ .replace(/\s(?:style|class)\s*=\s*(?:"[^"]*"|'[^']*')/giu, '')
80
+ // 上游会把标题粘在正文尾巴上(实测 `2024-11-12## 盈利预测`),标题不在行首就没法进索引;
81
+ // 要求 `#` 后必须有空格,避免把 "评级#买入" 这类没有空格的井号误拆。
82
+ .replace(/(?<=\S)(#{1,6}\s+\S)/gu, '\n\n$1')
83
+ .replace(/[ \t]+\n/gu, '\n')
84
+ .replace(/\n{3,}/gu, '\n\n');
85
+ return { markdown: cleaned.trim(), tables, styleCharsRemoved };
86
+ }
87
+ function firstHeading(markdown) {
88
+ for (const line of markdown.split('\n')) {
89
+ const heading = /^#{1,6}\s+(.+)$/u.exec(line.trim());
90
+ if (heading?.[1])
91
+ return heading[1].trim().slice(0, 120);
92
+ }
93
+ return undefined;
94
+ }
95
+ /** 把逐页 markdown 拼成一份文档,并算出**页偏移索引**(按页取回与关键词定位都靠它)。 */
96
+ export function buildOcrDocument(sourcePages) {
97
+ const chunks = [];
98
+ const pages = [];
99
+ let offset = 0;
100
+ let tables = 0;
101
+ let images = 0;
102
+ let styleCharsRemoved = 0;
103
+ for (const sourcePage of sourcePages) {
104
+ const normalized = normalizeOcrMarkdown(sourcePage.markdown);
105
+ tables += normalized.tables;
106
+ images += sourcePage.images;
107
+ styleCharsRemoved += normalized.styleCharsRemoved;
108
+ const marker = `${PAGE_MARKER_PREFIX}${sourcePage.page} -->\n`;
109
+ const body = normalized.markdown;
110
+ const entry = {
111
+ page: sourcePage.page,
112
+ start: offset + marker.length,
113
+ end: offset + marker.length + body.length,
114
+ chars: body.length,
115
+ };
116
+ const heading = firstHeading(body);
117
+ if (heading !== undefined)
118
+ entry.heading = heading;
119
+ pages.push(entry);
120
+ chunks.push(`${marker}${body}\n\n`);
121
+ offset += marker.length + body.length + 2;
122
+ }
123
+ const document = chunks.join('').trimEnd();
124
+ return { document, pages, chars: document.length, images, tables, styleCharsRemoved };
125
+ }
126
+ function pageOf(pages, at) {
127
+ return pages.find((entry) => at >= entry.start && at < entry.end) ?? pages[pages.length - 1];
128
+ }
129
+ /**
130
+ * 页区间解析:`"1-3"` / `"5"` / `"2-4,9"`(1 起始,闭区间)。
131
+ * 越界页码**响亮失败**而不是静默少给——"这页没有"和"我没给"在回执里必须能区分。
132
+ */
133
+ export function parsePageRange(spec, totalPages) {
134
+ const taken = [];
135
+ for (const part of spec.split(',')) {
136
+ const trimmed = part.trim();
137
+ if (trimmed.length === 0)
138
+ continue;
139
+ const span = /^(\d+)\s*(?:-|–|~)\s*(\d+)$/u.exec(trimmed);
140
+ if (span) {
141
+ const from = Number(span[1]);
142
+ const to = Number(span[2]);
143
+ if (from < 1 || to > totalPages || from > to) {
144
+ throw new Error(`pages out of range: '${trimmed}' (document has ${totalPages} pages)`);
145
+ }
146
+ for (let page = from; page <= to; page += 1)
147
+ if (!taken.includes(page))
148
+ taken.push(page);
149
+ continue;
150
+ }
151
+ const single = /^\d+$/u.test(trimmed) ? Number(trimmed) : Number.NaN;
152
+ if (!Number.isInteger(single) || single < 1 || single > totalPages) {
153
+ throw new Error(`pages out of range: '${trimmed}' (document has ${totalPages} pages)`);
154
+ }
155
+ if (!taken.includes(single))
156
+ taken.push(single);
157
+ }
158
+ if (taken.length === 0)
159
+ throw new Error(`pages is empty after normalization: '${spec}'`);
160
+ return taken;
161
+ }
162
+ /** 按页切出正文(纯字符串切片,不重跑上游)。 */
163
+ export function selectPages(document, pages) {
164
+ return pages.map((page) => {
165
+ const entry = document.pages.find((item) => item.page === page);
166
+ if (!entry)
167
+ throw new Error(`page ${page} is not in this document`);
168
+ const excerpt = { page, chars: entry.chars, text: document.document.slice(entry.start, entry.end) };
169
+ if (entry.heading !== undefined)
170
+ excerpt.heading = entry.heading;
171
+ return excerpt;
172
+ });
173
+ }
174
+ /**
175
+ * 关键词定位:在**已落盘的文档**里找命中,回带页号的上下文片段。
176
+ *
177
+ * 为什么不靠 `pages` 硬读:一份 15 页研报归一化后约 2.7 万字符,而工具输出预算 6,144——
178
+ * 想拿财务预测表就得能"跳着看"。上游页区间不影响算力(整篇都算过),所以定位必须在这份
179
+ * 归一化文本上做,不再出网。
180
+ */
181
+ export function locateQuery(document, query, limit, windowChars) {
182
+ const haystack = document.document;
183
+ const needle = query.trim();
184
+ if (needle.length === 0)
185
+ throw new Error('query must not be empty');
186
+ const hits = [];
187
+ const loweredNeedle = needle.toLowerCase();
188
+ const lowered = haystack.toLowerCase();
189
+ let cursor = 0;
190
+ let lastEnd = -1;
191
+ while (hits.length < limit) {
192
+ const at = lowered.indexOf(loweredNeedle, cursor);
193
+ if (at < 0)
194
+ break;
195
+ cursor = at + needle.length;
196
+ // 相邻命中合并成一段窗口:否则同一句话的三次命中会占满 limit,看着像三条证据其实是一条。
197
+ if (at < lastEnd)
198
+ continue;
199
+ const entry = pageOf(document.pages, at);
200
+ // 窗口**不越页**:片段被标成"第 N 页"就是要能被引用回第 N 页,跨了页就是错引(错引比少引危险)。
201
+ // 顺带把 `<!-- page:N -->` 存储标记挡在外面——那是落盘布局,不是给模型回传正文的东西。
202
+ const from = Math.max(entry?.start ?? 0, at - windowChars);
203
+ const to = Math.min(entry?.end ?? haystack.length, at + needle.length + windowChars);
204
+ hits.push({ page: entry?.page ?? 1, at, excerpt: haystack.slice(from, to).replace(/\n+/gu, ' ⏎ ') });
205
+ lastEnd = to;
206
+ }
207
+ return hits;
208
+ }
209
+ export { PAGE_MARKER_PREFIX };
@@ -0,0 +1,119 @@
1
+ /**
2
+ * `ocr` 的**产物层**:解析结果一律落 `capital-data/ocr/<doc_id>/`,模型侧只拿 `doc_id` 与索引。
3
+ *
4
+ * 为什么必须落盘而不是留在内存:一份 15 页研报归一化后约 2.7 万字符,而工具输出预算
5
+ * 只有 6,144(`SOURCE_OUTPUT_BUDGET_CHARS`)。落盘之后"按页取回 / 关键词定位"才是**纯本地
6
+ * 零花费**的第二、第三次读取;否则每次翻页都要重跑上游、再花一次 job。
7
+ *
8
+ * ```text
9
+ * capital-data/ocr/<doc_id>/
10
+ * ├── meta.json # 页偏移索引与花费口径(model / charts / job_id / created_at)
11
+ * └── document.md # 全篇归一化 markdown,页分隔 `<!-- page:N -->`
12
+ * ```
13
+ *
14
+ * `doc_id` 是**内容寻址**的:`sha1(归一化输入 + model + flags)` 前 12 位。因此"同一份文档
15
+ * 重复解析"自动命中缓存不再出网;`refresh` 才强制重跑(上游对同一份 PDF 两次给出过
16
+ * 18% 与 19% 的不同数字,重跑**不是**独立验证,引用必须带 `doc_id` 与 `created_at`)。
17
+ *
18
+ * 写入顺序是 `document.md` → `meta.json`:**meta 的在场即代表产物完整**。中途被中断
19
+ * (超时、取消)留下半件产物时,下一次读取按"未缓存"处理并重写(`overwrite`),
20
+ * 不会把模型永久锁死在一个坏 `doc_id` 上。
21
+ */
22
+ import type { SessionLike, WorkspaceDatasetStore } from '../data-collector/store.js';
23
+ import type { OcrDocument, OcrPageEntry } from './markdown.js';
24
+ export declare const OCR_ARTIFACT_SUBDIR = "capital-data/ocr";
25
+ export declare const OCR_DOC_ID_PATTERN: RegExp;
26
+ /** 本地文档字节的读取上限:上游 100 页口径下的研报/公告量级,超限宁可报错也不要缓冲整份。 */
27
+ export declare const OCR_MAX_SOURCE_BYTES: number;
28
+ /** 产物元数据(落盘形态)。字段名与回执一致,模型看到的和盘上的不是两份真相。 */
29
+ export interface OcrArtifactMeta {
30
+ doc_id: string;
31
+ /**
32
+ * 输入指认:`url:<http(s) URL>` 或 `file:<workspace 相对路径>`。**绝不落绝对路径**。
33
+ * 续查(只传 `job_id` + `doc_id`)时本机没有重算种子的输入,此时**留空而不是编一个**——
34
+ * 把作业号写成来源会被当成"这条 URL 就是文档出处"。
35
+ */
36
+ source?: string;
37
+ source_kind: 'url' | 'file' | 'job';
38
+ job_id: string;
39
+ model: string;
40
+ /** 提交时的图表识别开关;续查时无从得知,留空。 */
41
+ charts?: boolean;
42
+ pages: number;
43
+ chars: number;
44
+ images: number;
45
+ tables: number;
46
+ style_chars_removed: number;
47
+ page_entries: OcrPageEntry[];
48
+ created_at: string;
49
+ }
50
+ export interface OcrArtifact {
51
+ meta: OcrArtifactMeta;
52
+ document: OcrDocument;
53
+ }
54
+ /** `seed` 由各形态自己拼(URL 文本 / 文件字节),统一在此收敛成 `ocr_<12 hex>`。 */
55
+ export declare function deriveOcrDocId(seed: string | Uint8Array, model: string, charts: boolean): string;
56
+ /** 回执里给模型的 opaque 引用(与 Dataset/profile 引用同一套 `workspace://` 口径)。 */
57
+ export declare function ocrArtifactRef(docId: string): string;
58
+ /**
59
+ * 落盘产物**自己**坏了(调用方没做错任何事)。
60
+ *
61
+ * 为什么要单独一个类型:翻成回执的那一层要把"下一步该传什么"写清楚,而那一步需要的指认
62
+ * (当初解析用过的 url / file)只有这份 meta 里有。让调用方重新去猜链接,等于把一次
63
+ * 可机械执行的修复推回给人或模型。
64
+ */
65
+ export declare class OcrArtifactError extends Error {
66
+ readonly reason: 'meta_unreadable' | 'meta_invalid' | 'document_missing';
67
+ readonly docId: string;
68
+ readonly sourceKind: OcrArtifactMeta['source_kind'] | undefined;
69
+ readonly source: string | undefined;
70
+ constructor(reason: 'meta_unreadable' | 'meta_invalid' | 'document_missing', docId: string, sourceKind: OcrArtifactMeta['source_kind'] | undefined, source: string | undefined, message: string);
71
+ }
72
+ /**
73
+ * 读回一份产物。三种"读不到"必须能区分:
74
+ * - `undefined`:从未解析过(meta 与 document 都不在)——调用方据此决定提交新 job。
75
+ * - 抛错:meta 在但正文丢失/损坏,或半件产物——**不能**当成"没有内容",也不能静默重跑
76
+ * (重跑要花一次 job,用户看不到钱去哪了),交给调用方翻成带 `doc_id` 的失败回执。
77
+ */
78
+ export declare function readOcrArtifact(store: WorkspaceDatasetStore, input: {
79
+ session: SessionLike;
80
+ doc_id: string;
81
+ signal?: AbortSignal;
82
+ }): Promise<OcrArtifact | undefined>;
83
+ /** 落盘。`overwrite` 只在调用方**已经决定**重新生成时传(见文件头的写入顺序)。 */
84
+ export declare function writeOcrArtifact(store: WorkspaceDatasetStore, input: {
85
+ session: SessionLike;
86
+ meta: OcrArtifactMeta;
87
+ document: OcrDocument;
88
+ signal?: AbortSignal;
89
+ overwrite?: boolean;
90
+ }): Promise<{
91
+ artifact_ref: string;
92
+ paths: string[];
93
+ }>;
94
+ /** 拼一条与盘上记录同形状的 meta(`document` 只提供算好的排版统计)。 */
95
+ export declare function buildArtifactMeta(input: {
96
+ doc_id: string;
97
+ source?: string;
98
+ source_kind: OcrArtifactMeta['source_kind'];
99
+ charts?: boolean;
100
+ job_id: string;
101
+ model: string;
102
+ document: OcrDocument;
103
+ createdAt: string;
104
+ }): OcrArtifactMeta;
105
+ /**
106
+ * 读取**待解析**的本地文档字节(`file` 形态的入口)。
107
+ *
108
+ * 只接受 session workspace 内的相对路径:绝对路径(含 Windows 盘符)与 `..` 段在
109
+ * `normalizeWorkspaceRelativePath` 就被拒,之后仍要过沙箱归属校验。模型从 `@xxx.pdf`
110
+ * mention 里看到的是路径末段,因此回执话术要求它传 workspace 相对路径。
111
+ */
112
+ export declare function readOcrSourceBytes(store: WorkspaceDatasetStore, input: {
113
+ session: SessionLike;
114
+ path: string;
115
+ signal?: AbortSignal;
116
+ }): Promise<{
117
+ bytes: Uint8Array;
118
+ filename: string;
119
+ }>;
@@ -0,0 +1,226 @@
1
+ /**
2
+ * `ocr` 的**产物层**:解析结果一律落 `capital-data/ocr/<doc_id>/`,模型侧只拿 `doc_id` 与索引。
3
+ *
4
+ * 为什么必须落盘而不是留在内存:一份 15 页研报归一化后约 2.7 万字符,而工具输出预算
5
+ * 只有 6,144(`SOURCE_OUTPUT_BUDGET_CHARS`)。落盘之后"按页取回 / 关键词定位"才是**纯本地
6
+ * 零花费**的第二、第三次读取;否则每次翻页都要重跑上游、再花一次 job。
7
+ *
8
+ * ```text
9
+ * capital-data/ocr/<doc_id>/
10
+ * ├── meta.json # 页偏移索引与花费口径(model / charts / job_id / created_at)
11
+ * └── document.md # 全篇归一化 markdown,页分隔 `<!-- page:N -->`
12
+ * ```
13
+ *
14
+ * `doc_id` 是**内容寻址**的:`sha1(归一化输入 + model + flags)` 前 12 位。因此"同一份文档
15
+ * 重复解析"自动命中缓存不再出网;`refresh` 才强制重跑(上游对同一份 PDF 两次给出过
16
+ * 18% 与 19% 的不同数字,重跑**不是**独立验证,引用必须带 `doc_id` 与 `created_at`)。
17
+ *
18
+ * 写入顺序是 `document.md` → `meta.json`:**meta 的在场即代表产物完整**。中途被中断
19
+ * (超时、取消)留下半件产物时,下一次读取按"未缓存"处理并重写(`overwrite`),
20
+ * 不会把模型永久锁死在一个坏 `doc_id` 上。
21
+ */
22
+ import { DatasetStoreError } from '../data-collector/store.js';
23
+ import { WORKSPACE_DATA_DIR } from '../data-collector/store.js';
24
+ export const OCR_ARTIFACT_SUBDIR = `${WORKSPACE_DATA_DIR}/ocr`;
25
+ export const OCR_DOC_ID_PATTERN = /^ocr_[0-9a-f]{12}$/u;
26
+ const META_FILE = 'meta.json';
27
+ const DOCUMENT_FILE = 'document.md';
28
+ /** 本地文档字节的读取上限:上游 100 页口径下的研报/公告量级,超限宁可报错也不要缓冲整份。 */
29
+ export const OCR_MAX_SOURCE_BYTES = 32 * 1024 * 1024;
30
+ /**
31
+ * 与 `hub.ts` / `sources.ts` 同一个 `process.getBuiltinModule` 惯用法(本仓刻意不装
32
+ * `@types/node`)。拿不到就**响亮失败**:`doc_id` 是缓存键,退回一个更弱的哈希会让两份不同
33
+ * 文档撞在同一个 `doc_id` 上、把 A 的正文当 B 交出去。
34
+ */
35
+ function createHash(algorithm) {
36
+ const processLike = globalThis.process;
37
+ const hash = processLike?.getBuiltinModule?.('node:crypto')?.createHash?.(algorithm);
38
+ if (!hash)
39
+ throw new Error('node:crypto is unavailable; cannot derive a content-addressed doc_id');
40
+ return hash;
41
+ }
42
+ /** `seed` 由各形态自己拼(URL 文本 / 文件字节),统一在此收敛成 `ocr_<12 hex>`。 */
43
+ export function deriveOcrDocId(seed, model, charts) {
44
+ const hash = createHash('sha1');
45
+ hash.update(seed);
46
+ hash.update(`\u0000${model}\u0000${charts ? 'charts' : 'no-charts'}`);
47
+ return `ocr_${hash.digest('hex').slice(0, 12)}`;
48
+ }
49
+ /** 回执里给模型的 opaque 引用(与 Dataset/profile 引用同一套 `workspace://` 口径)。 */
50
+ export function ocrArtifactRef(docId) {
51
+ return `workspace://${OCR_ARTIFACT_SUBDIR}/${docId}`;
52
+ }
53
+ function requireDocId(docId) {
54
+ if (!OCR_DOC_ID_PATTERN.test(docId))
55
+ throw new Error('doc_id is invalid: expected ocr_<12 hex characters>');
56
+ return docId;
57
+ }
58
+ function metaPath(docId) {
59
+ return `${OCR_ARTIFACT_SUBDIR}/${requireDocId(docId)}/${META_FILE}`;
60
+ }
61
+ function documentPath(docId) {
62
+ return `${OCR_ARTIFACT_SUBDIR}/${requireDocId(docId)}/${DOCUMENT_FILE}`;
63
+ }
64
+ function isNotFound(error) {
65
+ return error instanceof DatasetStoreError && error.code === 'dataset_not_found';
66
+ }
67
+ function isTooLarge(error) {
68
+ return error instanceof DatasetStoreError && error.code === 'dataset_too_large';
69
+ }
70
+ /**
71
+ * 落盘产物**自己**坏了(调用方没做错任何事)。
72
+ *
73
+ * 为什么要单独一个类型:翻成回执的那一层要把"下一步该传什么"写清楚,而那一步需要的指认
74
+ * (当初解析用过的 url / file)只有这份 meta 里有。让调用方重新去猜链接,等于把一次
75
+ * 可机械执行的修复推回给人或模型。
76
+ */
77
+ export class OcrArtifactError extends Error {
78
+ reason;
79
+ docId;
80
+ sourceKind;
81
+ source;
82
+ constructor(reason, docId, sourceKind, source, message) {
83
+ super(message);
84
+ this.reason = reason;
85
+ this.docId = docId;
86
+ this.sourceKind = sourceKind;
87
+ this.source = source;
88
+ this.name = 'OcrArtifactError';
89
+ }
90
+ }
91
+ /**
92
+ * 读回一份产物。三种"读不到"必须能区分:
93
+ * - `undefined`:从未解析过(meta 与 document 都不在)——调用方据此决定提交新 job。
94
+ * - 抛错:meta 在但正文丢失/损坏,或半件产物——**不能**当成"没有内容",也不能静默重跑
95
+ * (重跑要花一次 job,用户看不到钱去哪了),交给调用方翻成带 `doc_id` 的失败回执。
96
+ */
97
+ export async function readOcrArtifact(store, input) {
98
+ const docId = requireDocId(input.doc_id);
99
+ let metaText;
100
+ try {
101
+ metaText = await store.readWorkspaceText({ session: input.session, path: metaPath(docId), signal: input.signal });
102
+ }
103
+ catch (error) {
104
+ if (isNotFound(error))
105
+ return undefined;
106
+ if (isTooLarge(error))
107
+ throw error;
108
+ throw new OcrArtifactError('meta_unreadable', docId, undefined, undefined, `产物 ${docId} 的 meta.json 读不出来:${error instanceof Error ? error.message : String(error)}`);
109
+ }
110
+ let meta;
111
+ try {
112
+ meta = normalizeMeta(JSON.parse(metaText), docId);
113
+ }
114
+ catch (error) {
115
+ throw new OcrArtifactError('meta_invalid', docId, undefined, undefined, `产物 ${docId} 的 meta.json 不是可识别的记录:${error instanceof Error ? error.message : String(error)}`);
116
+ }
117
+ let text;
118
+ try {
119
+ text = await store.readWorkspaceText({ session: input.session, path: documentPath(docId), signal: input.signal });
120
+ }
121
+ catch (error) {
122
+ if (isNotFound(error)) {
123
+ throw new OcrArtifactError('document_missing', docId, meta.source_kind, meta.source, `产物 ${docId} 的 document.md 缺失(meta 在、正文不在)`);
124
+ }
125
+ throw error;
126
+ }
127
+ return { meta, document: { document: text, pages: meta.page_entries, chars: meta.chars, images: meta.images, tables: meta.tables, styleCharsRemoved: meta.style_chars_removed } };
128
+ }
129
+ /** 落盘。`overwrite` 只在调用方**已经决定**重新生成时传(见文件头的写入顺序)。 */
130
+ export async function writeOcrArtifact(store, input) {
131
+ const docId = requireDocId(input.meta.doc_id);
132
+ const written = await store.writeWorkspaceFiles({
133
+ session: input.session,
134
+ dir: `${OCR_ARTIFACT_SUBDIR}/${docId}`,
135
+ files: [
136
+ { name: DOCUMENT_FILE, content: input.document.document },
137
+ { name: META_FILE, content: `${JSON.stringify(input.meta)}\n` },
138
+ ],
139
+ ...(input.signal === undefined ? {} : { signal: input.signal }),
140
+ ...(input.overwrite ? { overwrite: true } : {}),
141
+ });
142
+ return { artifact_ref: ocrArtifactRef(docId), paths: written.map((file) => file.path) };
143
+ }
144
+ /** meta 是**我们**写出去的记录,读回来时仍逐字段核一遍——手改过的盘也要能响亮失败。 */
145
+ function normalizeMeta(value, docId) {
146
+ const record = (typeof value === 'object' && value !== null ? value : {});
147
+ const pages = Array.isArray(record.page_entries) ? record.page_entries : [];
148
+ const entries = pages.map((item) => {
149
+ const page = (typeof item === 'object' && item !== null ? item : {});
150
+ if (typeof page.page !== 'number' || typeof page.start !== 'number' || typeof page.end !== 'number' || typeof page.chars !== 'number') {
151
+ throw new Error('page_entries 项缺字段');
152
+ }
153
+ const heading = typeof page.heading === 'string' ? { heading: page.heading } : {};
154
+ return { page: page.page, start: page.start, end: page.end, chars: page.chars, ...heading };
155
+ });
156
+ const text = (key) => {
157
+ if (typeof record[key] !== 'string')
158
+ throw new Error(`meta.${key} 不是字符串`);
159
+ return record[key];
160
+ };
161
+ const num = (key) => {
162
+ if (typeof record[key] !== 'number')
163
+ throw new Error(`meta.${key} 不是数字`);
164
+ return record[key];
165
+ };
166
+ if (record.doc_id !== docId)
167
+ throw new Error(`meta.doc_id 与目录不一致(${String(record.doc_id)})`);
168
+ const source = typeof record.source === 'string' ? { source: record.source } : {};
169
+ const charts = typeof record.charts === 'boolean' ? { charts: record.charts } : {};
170
+ return {
171
+ doc_id: docId,
172
+ ...source,
173
+ source_kind: sourceKind(record.source_kind),
174
+ job_id: text('job_id'),
175
+ model: text('model'),
176
+ ...charts,
177
+ pages: num('pages'),
178
+ chars: num('chars'),
179
+ images: num('images'),
180
+ tables: num('tables'),
181
+ style_chars_removed: num('style_chars_removed'),
182
+ page_entries: entries,
183
+ created_at: text('created_at'),
184
+ };
185
+ }
186
+ /** 来源类别缺省或写歪时**响亮失败**:把不认识的 provenance 默认成 `url` 会伪造出处。 */
187
+ function sourceKind(value) {
188
+ if (value === 'url' || value === 'file' || value === 'job')
189
+ return value;
190
+ throw new Error(`meta.source_kind 不认识:${String(value)}`);
191
+ }
192
+ /** 拼一条与盘上记录同形状的 meta(`document` 只提供算好的排版统计)。 */
193
+ export function buildArtifactMeta(input) {
194
+ return {
195
+ doc_id: requireDocId(input.doc_id),
196
+ ...(input.source === undefined ? {} : { source: input.source }),
197
+ source_kind: input.source_kind,
198
+ job_id: input.job_id,
199
+ model: input.model,
200
+ ...(input.charts === undefined ? {} : { charts: input.charts }),
201
+ pages: input.document.pages.length,
202
+ chars: input.document.chars,
203
+ images: input.document.images,
204
+ tables: input.document.tables,
205
+ style_chars_removed: input.document.styleCharsRemoved,
206
+ page_entries: input.document.pages,
207
+ created_at: input.createdAt,
208
+ };
209
+ }
210
+ /**
211
+ * 读取**待解析**的本地文档字节(`file` 形态的入口)。
212
+ *
213
+ * 只接受 session workspace 内的相对路径:绝对路径(含 Windows 盘符)与 `..` 段在
214
+ * `normalizeWorkspaceRelativePath` 就被拒,之后仍要过沙箱归属校验。模型从 `@xxx.pdf`
215
+ * mention 里看到的是路径末段,因此回执话术要求它传 workspace 相对路径。
216
+ */
217
+ export async function readOcrSourceBytes(store, input) {
218
+ const bytes = await store.readWorkspaceBytes({
219
+ session: input.session,
220
+ path: input.path,
221
+ maxBytes: OCR_MAX_SOURCE_BYTES,
222
+ ...(input.signal === undefined ? {} : { signal: input.signal }),
223
+ });
224
+ const filename = input.path.split('/').pop() ?? 'document';
225
+ return { bytes, filename };
226
+ }
@@ -0,0 +1,2 @@
1
+ import type { DataSource } from '../data-collector/hub.js';
2
+ export declare function createEastmoneySources(): DataSource[];