botrun-horse 1.0.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 (64) hide show
  1. package/README.md +1 -0
  2. package/bin/bh.mjs +193 -0
  3. package/bin/commands/dag-cmd.mjs +74 -0
  4. package/bin/commands/db-cmd.mjs +73 -0
  5. package/bin/commands/doc.mjs +185 -0
  6. package/bin/commands/gemini.mjs +120 -0
  7. package/bin/commands/help.mjs +109 -0
  8. package/bin/commands/legal.mjs +174 -0
  9. package/bin/commands/nchc.mjs +212 -0
  10. package/bin/commands/openrouter.mjs +154 -0
  11. package/bin/commands/prompt.mjs +175 -0
  12. package/bin/commands/schema.mjs +258 -0
  13. package/bin/commands/search.mjs +46 -0
  14. package/bin/commands/writing.mjs +33 -0
  15. package/lib/core/adapters/base.mjs +52 -0
  16. package/lib/core/adapters/claude.mjs +13 -0
  17. package/lib/core/adapters/gemini-api.mjs +174 -0
  18. package/lib/core/adapters/gemini-shared.mjs +164 -0
  19. package/lib/core/adapters/gemini-vertex.mjs +232 -0
  20. package/lib/core/adapters/local.mjs +13 -0
  21. package/lib/core/adapters/nchc.mjs +236 -0
  22. package/lib/core/adapters/openai-shared.mjs +34 -0
  23. package/lib/core/adapters/openrouter.mjs +304 -0
  24. package/lib/core/ai-cache.mjs +277 -0
  25. package/lib/core/ai-router.mjs +217 -0
  26. package/lib/core/cli-utils.mjs +170 -0
  27. package/lib/core/dag.mjs +114 -0
  28. package/lib/core/db.mjs +412 -0
  29. package/lib/core/env.mjs +64 -0
  30. package/lib/core/llm.mjs +58 -0
  31. package/lib/core/paths.mjs +115 -0
  32. package/lib/core/proxy.mjs +46 -0
  33. package/lib/core/watermelon.mjs +9 -0
  34. package/lib/doc/index.mjs +419 -0
  35. package/lib/doc/office2text.mjs +234 -0
  36. package/lib/doc/pdf2text.mjs +133 -0
  37. package/lib/doc/split.mjs +132 -0
  38. package/lib/flows/draft-writing.mjs +29 -0
  39. package/lib/flows/gemini-ask.mjs +185 -0
  40. package/lib/flows/hatch-portal.mjs +13 -0
  41. package/lib/flows/legal-ask.mjs +325 -0
  42. package/lib/flows/openai-agent.mjs +167 -0
  43. package/lib/flows/opencode-agent.mjs +240 -0
  44. package/lib/flows/openrouter-ask.mjs +111 -0
  45. package/lib/flows/review-doc.mjs +18 -0
  46. package/lib/ocr/index.mjs +6 -0
  47. package/lib/portal/hatch.mjs +6 -0
  48. package/lib/portal/index.mjs +6 -0
  49. package/lib/prompt/prompt-search.mjs +55 -0
  50. package/lib/prompt/prompt-store.mjs +94 -0
  51. package/lib/prompt/prompts/zero-framework/coding.md +15 -0
  52. package/lib/prompt/prompts/zero-framework/search.md +12 -0
  53. package/lib/prompt/prompts/zero-framework/slice.md +11 -0
  54. package/lib/search/crawler.mjs +6 -0
  55. package/lib/search/index.mjs +7 -0
  56. package/lib/tools/fs-tools.mjs +268 -0
  57. package/lib/tools/index.mjs +27 -0
  58. package/lib/writing/generate.mjs +86 -0
  59. package/lib/writing/generators/nstc-generators.mjs +279 -0
  60. package/lib/writing/generators/nstc-top5.mjs +554 -0
  61. package/lib/writing/index.mjs +5 -0
  62. package/lib/writing/layouts/nstc-layout.mjs +249 -0
  63. package/lib/writing/renderer.mjs +61 -0
  64. package/package.json +35 -0
@@ -0,0 +1,249 @@
1
+ // lib/writing/layouts/nstc-layout.mjs — 國科會公文 PDF 版面佈局 (繪圖原語)
2
+ // 支援多頁、表格、分頁、頁碼
3
+
4
+ import { pick, CHIEFS } from '../../../projects/nstc/data/fake-data.mjs';
5
+
6
+ const PAGE_TOP = 50;
7
+ const PAGE_BOTTOM = 760;
8
+ const CONTENT_LEFT = 60;
9
+ const CONTENT_RIGHT = 540;
10
+
11
+ // 自動分頁:如果 y 超過底部,加新頁面並回傳新 y
12
+ export function checkPage(doc, y, needed = 40) {
13
+ if (y + needed > PAGE_BOTTOM) {
14
+ doc.addPage();
15
+ return PAGE_TOP + 10;
16
+ }
17
+ return y;
18
+ }
19
+
20
+ export function drawPageNumber(doc, pageNum, totalPages) {
21
+ doc.font('Song').fontSize(9);
22
+ doc.text(`第 ${pageNum} 頁,共 ${totalPages} 頁`, 0, 785, { width: 595, align: 'center' });
23
+ }
24
+
25
+ export function drawHeader(doc, title) {
26
+ doc.font('SongBold').fontSize(11);
27
+ doc.text('(國徽)', 265, 40, { width: 60, align: 'center' });
28
+
29
+ doc.font('SongBold').fontSize(22);
30
+ doc.text(title, 0, 65, { width: 595, align: 'center' });
31
+
32
+ // 裝訂線
33
+ doc.save();
34
+ doc.lineWidth(0.5);
35
+ doc.dash(5, { space: 3 });
36
+ doc.moveTo(30, 50).lineTo(30, 790).stroke();
37
+ doc.restore();
38
+
39
+ // 橫線
40
+ doc.lineWidth(1);
41
+ doc.moveTo(55, 135).lineTo(540, 135).stroke();
42
+ doc.lineWidth(0.5);
43
+ doc.moveTo(55, 137).lineTo(540, 137).stroke();
44
+ }
45
+
46
+ // 附件頁標題
47
+ export function drawAttachmentHeader(doc, title) {
48
+ doc.font('SongBold').fontSize(16);
49
+ doc.text(title, 0, 45, { width: 595, align: 'center' });
50
+ doc.lineWidth(1);
51
+ doc.moveTo(55, 70).lineTo(540, 70).stroke();
52
+ return 80;
53
+ }
54
+
55
+ export function drawField(doc, label, value, y, wrap = false) {
56
+ y = checkPage(doc, y, 25);
57
+ doc.font('SongBold').fontSize(12);
58
+ doc.text(label, CONTENT_LEFT, y);
59
+ const labelWidth = doc.widthOfString(label);
60
+ doc.font('Song').fontSize(12);
61
+ if (wrap) {
62
+ const textOpts = { width: 470 - labelWidth, lineGap: 4 };
63
+ doc.text(value, CONTENT_LEFT + labelWidth, y, textOpts);
64
+ const height = doc.heightOfString(value, textOpts);
65
+ return y + Math.max(height, 18) + 4;
66
+ }
67
+ doc.text(value, CONTENT_LEFT + labelWidth, y);
68
+ return y + 20;
69
+ }
70
+
71
+ export function drawSection(doc, title, y) {
72
+ y = checkPage(doc, y, 25);
73
+ doc.font('SongBold').fontSize(12);
74
+ doc.text(title, CONTENT_LEFT, y);
75
+ return y + 20;
76
+ }
77
+
78
+ export function drawNumberedText(doc, num, text, y) {
79
+ y = checkPage(doc, y, 30);
80
+ doc.font('Song').fontSize(12);
81
+ doc.text(num, 80, y);
82
+ const numWidth = doc.widthOfString(num) + 2;
83
+ const opts = { width: 435 - numWidth, lineGap: 4 };
84
+ doc.text(text, 80 + numWidth, y, opts);
85
+ const h = doc.heightOfString(text, opts);
86
+ return y + Math.max(h, 18) + 4;
87
+ }
88
+
89
+ // 二級子項目(如 (一)、(二))
90
+ export function drawSubNumberedText(doc, num, text, y) {
91
+ y = checkPage(doc, y, 25);
92
+ doc.font('Song').fontSize(11);
93
+ doc.text(num, 105, y);
94
+ const numWidth = doc.widthOfString(num) + 2;
95
+ const opts = { width: 405 - numWidth, lineGap: 3 };
96
+ doc.text(text, 105 + numWidth, y, opts);
97
+ const h = doc.heightOfString(text, opts);
98
+ return y + Math.max(h, 16) + 3;
99
+ }
100
+
101
+ export function drawParagraph(doc, text, y) {
102
+ y = checkPage(doc, y, 25);
103
+ doc.font('Song').fontSize(12);
104
+ const opts = { width: 450, lineGap: 4, indent: 20 };
105
+ doc.text(text, 80, y, opts);
106
+ const h = doc.heightOfString(text, opts);
107
+ return y + Math.max(h, 18) + 4;
108
+ }
109
+
110
+ export function drawSignature(doc, y) {
111
+ y = checkPage(doc, y, 60);
112
+ const chief = pick(CHIEFS);
113
+ doc.font('Song').fontSize(11);
114
+ doc.text('主任委員', 380, y);
115
+ doc.font('SongBold').fontSize(14);
116
+ doc.text(chief, 380, y + 20);
117
+
118
+ doc.font('Song').fontSize(8);
119
+ doc.text('(本件依分層負責規定授權單位主管決行)', 60, 750);
120
+ doc.text('聯絡電話:(02)2737-7992', 60, 762);
121
+ doc.text('地址:10622 臺北市大安區和平東路二段106號', 60, 774);
122
+ }
123
+
124
+ // ===== 表格繪圖 =====
125
+
126
+ /**
127
+ * 繪製表格
128
+ * @param {object} doc - PDFDocument
129
+ * @param {object} opts
130
+ * @param {string[]} opts.headers - 欄位標題
131
+ * @param {number[]} opts.colWidths - 各欄寬度
132
+ * @param {string[][]} opts.rows - 資料列
133
+ * @param {number} opts.startY - 起始 Y
134
+ * @param {number} [opts.startX=55] - 起始 X
135
+ * @param {number} [opts.rowHeight=22] - 行高
136
+ * @param {number} [opts.headerHeight=28] - 表頭行高
137
+ * @param {number} [opts.fontSize=9] - 字型大小
138
+ * @returns {number} 結束 Y
139
+ */
140
+ export function drawTable(doc, opts) {
141
+ const {
142
+ headers, colWidths, rows,
143
+ startX = 55, rowHeight = 22, headerHeight = 28, fontSize = 9,
144
+ } = opts;
145
+ let y = opts.startY;
146
+ const totalWidth = colWidths.reduce((a, b) => a + b, 0);
147
+
148
+ // 表頭
149
+ y = checkPage(doc, y, headerHeight + rowHeight * 2);
150
+ doc.font('SongBold').fontSize(fontSize);
151
+ doc.lineWidth(0.8);
152
+
153
+ // 表頭背景
154
+ doc.save();
155
+ doc.rect(startX, y, totalWidth, headerHeight).fillAndStroke('#E8E8E8', '#000');
156
+ doc.restore();
157
+
158
+ let cx = startX;
159
+ for (let i = 0; i < headers.length; i++) {
160
+ doc.font('SongBold').fontSize(fontSize).fillColor('#000');
161
+ doc.text(headers[i], cx + 3, y + 7, { width: colWidths[i] - 6, align: 'center' });
162
+ cx += colWidths[i];
163
+ }
164
+ y += headerHeight;
165
+
166
+ // 資料列
167
+ doc.font('Song').fontSize(fontSize).fillColor('#000');
168
+ for (const row of rows) {
169
+ y = checkPage(doc, y, rowHeight + 5);
170
+
171
+ // 列框線
172
+ doc.lineWidth(0.5);
173
+ doc.rect(startX, y, totalWidth, rowHeight).stroke();
174
+
175
+ cx = startX;
176
+ for (let i = 0; i < row.length; i++) {
177
+ // 欄分隔線
178
+ if (i > 0) {
179
+ doc.moveTo(cx, y).lineTo(cx, y + rowHeight).stroke();
180
+ }
181
+ const align = (typeof row[i] === 'string' && /^\d/.test(row[i])) ? 'right' : 'left';
182
+ doc.font('Song').fontSize(fontSize).fillColor('#000');
183
+ doc.text(String(row[i]), cx + 3, y + 5, {
184
+ width: colWidths[i] - 6,
185
+ align: align === 'right' ? 'right' : 'left',
186
+ });
187
+ cx += colWidths[i];
188
+ }
189
+ y += rowHeight;
190
+ }
191
+
192
+ return y + 5;
193
+ }
194
+
195
+ /**
196
+ * 繪製修正條文對照表
197
+ * @returns {number} 結束 Y
198
+ */
199
+ export function drawComparisonTable(doc, opts) {
200
+ const { items, startY, startX = 55, colWidths = [60, 200, 200] } = opts;
201
+ let y = startY;
202
+ const totalWidth = colWidths.reduce((a, b) => a + b, 0);
203
+
204
+ // 表頭
205
+ y = checkPage(doc, y, 50);
206
+ doc.lineWidth(0.8);
207
+ doc.save();
208
+ doc.rect(startX, y, totalWidth, 28).fillAndStroke('#E8E8E8', '#000');
209
+ doc.restore();
210
+
211
+ doc.font('SongBold').fontSize(10).fillColor('#000');
212
+ let cx = startX;
213
+ for (const h of ['條次', '修正條文', '現行條文']) {
214
+ doc.text(h, cx + 3, y + 7, { width: colWidths[['條次', '修正條文', '現行條文'].indexOf(h)] - 6, align: 'center' });
215
+ cx += colWidths[['條次', '修正條文', '現行條文'].indexOf(h)];
216
+ }
217
+ y += 28;
218
+
219
+ // 資料列
220
+ for (const item of items) {
221
+ const textOpts = { width: colWidths[1] - 10, lineGap: 3 };
222
+ doc.font('Song').fontSize(9);
223
+ const newH = doc.heightOfString(item.newText, textOpts);
224
+ const oldH = doc.heightOfString(item.oldText, textOpts);
225
+ const rh = Math.max(newH, oldH, 30) + 12;
226
+
227
+ y = checkPage(doc, y, rh + 5);
228
+
229
+ // 列框
230
+ doc.lineWidth(0.5);
231
+ doc.rect(startX, y, totalWidth, rh).stroke();
232
+ // 欄分隔
233
+ let lx = startX + colWidths[0];
234
+ doc.moveTo(lx, y).lineTo(lx, y + rh).stroke();
235
+ lx += colWidths[1];
236
+ doc.moveTo(lx, y).lineTo(lx, y + rh).stroke();
237
+
238
+ doc.font('SongBold').fontSize(10).fillColor('#000');
239
+ doc.text(item.article, startX + 5, y + 6, { width: colWidths[0] - 10, align: 'center' });
240
+
241
+ doc.font('Song').fontSize(9).fillColor('#000');
242
+ doc.text(item.newText, startX + colWidths[0] + 5, y + 6, textOpts);
243
+ doc.text(item.oldText, startX + colWidths[0] + colWidths[1] + 5, y + 6, { ...textOpts, width: colWidths[2] - 10 });
244
+
245
+ y += rh;
246
+ }
247
+
248
+ return y + 5;
249
+ }
@@ -0,0 +1,61 @@
1
+ // lib/writing/renderer.mjs — PDF 渲染基礎設施
2
+ // 通用化:generators 和 overrides 改為參數注入,支援多專案
3
+
4
+ import PDFDocument from 'pdfkit';
5
+ import fs from 'fs';
6
+ import path from 'path';
7
+
8
+ const DEFAULT_FONT_PATH = '/System/Library/Fonts/Supplemental/Songti.ttc';
9
+ const DEFAULT_FONT_NAME = 'STSongti-TC-Regular';
10
+
11
+ /**
12
+ * 渲染單一 PDF 文件
13
+ * @param {object} docConfig - 文件設定(id, type, title 等)
14
+ * @param {string} outputDir - 輸出目錄
15
+ * @param {object} opts - 選項
16
+ * @param {object} opts.generators - 公文類型 → 生成函式 mapping
17
+ * @param {object} [opts.overrides] - 按 ID 覆蓋的生成函式
18
+ * @param {string} [opts.fontPath] - 字型路徑
19
+ * @param {string} [opts.fontName] - 字型名稱
20
+ * @param {string} [opts.author] - 作者名稱
21
+ */
22
+ export async function renderPdf(docConfig, outputDir, opts = {}) {
23
+ const {
24
+ generators = {},
25
+ overrides = {},
26
+ fontPath = DEFAULT_FONT_PATH,
27
+ fontName = DEFAULT_FONT_NAME,
28
+ author = '國家科學及技術委員會',
29
+ } = opts;
30
+
31
+ const doc = new PDFDocument({
32
+ size: 'A4',
33
+ margins: { top: 40, bottom: 40, left: 55, right: 55 },
34
+ info: {
35
+ Title: docConfig.title,
36
+ Author: author,
37
+ Subject: docConfig.type,
38
+ },
39
+ });
40
+
41
+ doc.registerFont('Song', fontPath, fontName);
42
+ doc.registerFont('SongBold', fontPath, fontName);
43
+
44
+ const filename = `${String(docConfig.id).padStart(3, '0')}_${docConfig.type}_${docConfig.title.replace(/[/\\:*?"<>|]/g, '_').substring(0, 40)}.pdf`;
45
+ const outputPath = path.join(outputDir, filename);
46
+
47
+ fs.mkdirSync(outputDir, { recursive: true });
48
+ const writeStream = fs.createWriteStream(outputPath);
49
+ doc.pipe(writeStream);
50
+
51
+ // overrides(多頁高擬真版本)優先
52
+ const gen = overrides[docConfig.id] || generators[docConfig.type] || generators['函'];
53
+ gen(doc, docConfig);
54
+
55
+ doc.end();
56
+
57
+ return new Promise((resolve, reject) => {
58
+ writeStream.on('finish', () => resolve({ id: docConfig.id, path: outputPath, filename }));
59
+ writeStream.on('error', reject);
60
+ });
61
+ }
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "botrun-horse",
3
+ "version": "1.0.0",
4
+ "description": "多專案文件處理系統 CLI — AI 提示詞管理、文件擷取、公文撰寫",
5
+ "type": "module",
6
+ "bin": {
7
+ "bh": "./bin/bh.mjs"
8
+ },
9
+ "scripts": {
10
+ "test": "node --test test/**/*.test.mjs",
11
+ "test:core": "node --test test/core/*.test.mjs",
12
+ "test:adapters": "node --test test/adapters/*.test.mjs",
13
+ "test:doc": "node --test test/doc/pdf-pipeline.test.mjs",
14
+ "test:db": "node --experimental-sqlite --test test/doc/db.test.mjs",
15
+ "test:flows": "node --test test/flows/*.test.mjs",
16
+ "test:nchc": "node --test test/adapters/nchc.test.mjs",
17
+ "test:openai": "node --test test/flows/openai-agent.test.mjs",
18
+ "test:opencode": "node --test test/flows/opencode-agent.test.mjs",
19
+ "test:all": "node --test test/**/*.test.mjs test/core/*.test.mjs",
20
+ "test:coverage": "node --experimental-test-coverage --test test/core/*.test.mjs test/adapters/*.test.mjs",
21
+ "generate": "node src/application/run-all.mjs",
22
+ "generate:batch": "node src/application/batch-worker.mjs",
23
+ "dag:init": "node src/infrastructure/dag-tracker.mjs init",
24
+ "dag:status": "node src/infrastructure/dag-tracker.mjs status",
25
+ "dag:ready": "node src/infrastructure/dag-tracker.mjs ready"
26
+ },
27
+ "dependencies": {
28
+ "@ai-sdk/openai-compatible": "^2.0.30",
29
+ "@google/genai": "^1.43.0",
30
+ "@opencode-ai/sdk": "^1.2.15",
31
+ "openai": "^6.25.0",
32
+ "pdfkit": "^0.17.2",
33
+ "undici": "^7.22.0"
34
+ }
35
+ }