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.
- package/README.md +1 -0
- package/bin/bh.mjs +193 -0
- package/bin/commands/dag-cmd.mjs +74 -0
- package/bin/commands/db-cmd.mjs +73 -0
- package/bin/commands/doc.mjs +185 -0
- package/bin/commands/gemini.mjs +120 -0
- package/bin/commands/help.mjs +109 -0
- package/bin/commands/legal.mjs +174 -0
- package/bin/commands/nchc.mjs +212 -0
- package/bin/commands/openrouter.mjs +154 -0
- package/bin/commands/prompt.mjs +175 -0
- package/bin/commands/schema.mjs +258 -0
- package/bin/commands/search.mjs +46 -0
- package/bin/commands/writing.mjs +33 -0
- package/lib/core/adapters/base.mjs +52 -0
- package/lib/core/adapters/claude.mjs +13 -0
- package/lib/core/adapters/gemini-api.mjs +174 -0
- package/lib/core/adapters/gemini-shared.mjs +164 -0
- package/lib/core/adapters/gemini-vertex.mjs +232 -0
- package/lib/core/adapters/local.mjs +13 -0
- package/lib/core/adapters/nchc.mjs +236 -0
- package/lib/core/adapters/openai-shared.mjs +34 -0
- package/lib/core/adapters/openrouter.mjs +304 -0
- package/lib/core/ai-cache.mjs +277 -0
- package/lib/core/ai-router.mjs +217 -0
- package/lib/core/cli-utils.mjs +170 -0
- package/lib/core/dag.mjs +114 -0
- package/lib/core/db.mjs +412 -0
- package/lib/core/env.mjs +64 -0
- package/lib/core/llm.mjs +58 -0
- package/lib/core/paths.mjs +115 -0
- package/lib/core/proxy.mjs +46 -0
- package/lib/core/watermelon.mjs +9 -0
- package/lib/doc/index.mjs +419 -0
- package/lib/doc/office2text.mjs +234 -0
- package/lib/doc/pdf2text.mjs +133 -0
- package/lib/doc/split.mjs +132 -0
- package/lib/flows/draft-writing.mjs +29 -0
- package/lib/flows/gemini-ask.mjs +185 -0
- package/lib/flows/hatch-portal.mjs +13 -0
- package/lib/flows/legal-ask.mjs +325 -0
- package/lib/flows/openai-agent.mjs +167 -0
- package/lib/flows/opencode-agent.mjs +240 -0
- package/lib/flows/openrouter-ask.mjs +111 -0
- package/lib/flows/review-doc.mjs +18 -0
- package/lib/ocr/index.mjs +6 -0
- package/lib/portal/hatch.mjs +6 -0
- package/lib/portal/index.mjs +6 -0
- package/lib/prompt/prompt-search.mjs +55 -0
- package/lib/prompt/prompt-store.mjs +94 -0
- package/lib/prompt/prompts/zero-framework/coding.md +15 -0
- package/lib/prompt/prompts/zero-framework/search.md +12 -0
- package/lib/prompt/prompts/zero-framework/slice.md +11 -0
- package/lib/search/crawler.mjs +6 -0
- package/lib/search/index.mjs +7 -0
- package/lib/tools/fs-tools.mjs +268 -0
- package/lib/tools/index.mjs +27 -0
- package/lib/writing/generate.mjs +86 -0
- package/lib/writing/generators/nstc-generators.mjs +279 -0
- package/lib/writing/generators/nstc-top5.mjs +554 -0
- package/lib/writing/index.mjs +5 -0
- package/lib/writing/layouts/nstc-layout.mjs +249 -0
- package/lib/writing/renderer.mjs +61 -0
- package/package.json +35 -0
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
// lib/tools/fs-tools.mjs — 檔案系統工具定義與執行器
|
|
2
|
+
//
|
|
3
|
+
// 唯讀工具(read_file, list_files, grep_content)— 無副作用,NchcAgent 預設工具集
|
|
4
|
+
// 寫入工具(write_file, edit_file, run_command, create_dir)— 有副作用,NchcCodeAgent 專用
|
|
5
|
+
//
|
|
6
|
+
// 宣告式設計(OCP):新增工具只需在此檔案加入 schema + executor case
|
|
7
|
+
// 1. 在 BUILT_IN_TOOLS 或 CODING_TOOLS 加入 JSON Schema 宣告
|
|
8
|
+
// 2. 在 executeTool() 或 executeCodeTool() switch 加入對應 case
|
|
9
|
+
|
|
10
|
+
import { exec } from 'child_process';
|
|
11
|
+
import fs from 'fs';
|
|
12
|
+
import path from 'path';
|
|
13
|
+
import { glob } from 'node:fs/promises';
|
|
14
|
+
|
|
15
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
16
|
+
// 唯讀工具 schema(無副作用)
|
|
17
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
18
|
+
|
|
19
|
+
export const BUILT_IN_TOOLS = [
|
|
20
|
+
{
|
|
21
|
+
type: 'function',
|
|
22
|
+
function: {
|
|
23
|
+
name: 'read_file',
|
|
24
|
+
description: '讀取指定路徑的檔案內容(支援文字檔、.mjs、.md、.json 等)',
|
|
25
|
+
parameters: {
|
|
26
|
+
type: 'object',
|
|
27
|
+
properties: {
|
|
28
|
+
path: { type: 'string', description: '檔案絕對路徑或相對於工作目錄的路徑' },
|
|
29
|
+
max_chars: { type: 'integer', description: '最多讀取字元數(預設 8000)' },
|
|
30
|
+
},
|
|
31
|
+
required: ['path'],
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
type: 'function',
|
|
37
|
+
function: {
|
|
38
|
+
name: 'list_files',
|
|
39
|
+
description: '列出目錄中的檔案(支援 glob 模式,如 "lib/**/*.mjs")',
|
|
40
|
+
parameters: {
|
|
41
|
+
type: 'object',
|
|
42
|
+
properties: {
|
|
43
|
+
pattern: { type: 'string', description: 'glob 模式,例如 "lib/**/*.mjs"' },
|
|
44
|
+
base_dir: { type: 'string', description: '搜尋基準目錄(預設工作目錄)' },
|
|
45
|
+
},
|
|
46
|
+
required: ['pattern'],
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
type: 'function',
|
|
52
|
+
function: {
|
|
53
|
+
name: 'grep_content',
|
|
54
|
+
description: '在檔案中搜尋關鍵字(回傳含行號的匹配結果)',
|
|
55
|
+
parameters: {
|
|
56
|
+
type: 'object',
|
|
57
|
+
properties: {
|
|
58
|
+
pattern: { type: 'string', description: '搜尋模式(字串)' },
|
|
59
|
+
file_path: { type: 'string', description: '要搜尋的檔案路徑' },
|
|
60
|
+
context_lines: { type: 'integer', description: '匹配行前後各顯示幾行(預設 2)' },
|
|
61
|
+
},
|
|
62
|
+
required: ['pattern', 'file_path'],
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 執行唯讀工具
|
|
70
|
+
* @param {string} name - 工具名稱
|
|
71
|
+
* @param {object} args - 工具參數
|
|
72
|
+
* @param {string} cwd - 工作目錄
|
|
73
|
+
* @returns {Promise<string>}
|
|
74
|
+
*/
|
|
75
|
+
export async function executeTool(name, args, cwd = process.cwd()) {
|
|
76
|
+
switch (name) {
|
|
77
|
+
case 'read_file': {
|
|
78
|
+
const filePath = path.resolve(cwd, args.path);
|
|
79
|
+
const maxChars = args.max_chars || 8000;
|
|
80
|
+
try {
|
|
81
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
82
|
+
const truncated = content.length > maxChars
|
|
83
|
+
? content.slice(0, maxChars) + `\n\n...[截斷,共 ${content.length} 字元]`
|
|
84
|
+
: content;
|
|
85
|
+
return `# 檔案:${filePath}\n\n${truncated}`;
|
|
86
|
+
} catch (err) {
|
|
87
|
+
return `錯誤:無法讀取 ${filePath}:${err.message}`;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
case 'list_files': {
|
|
91
|
+
const baseDir = path.resolve(cwd, args.base_dir || '.');
|
|
92
|
+
try {
|
|
93
|
+
const files = [];
|
|
94
|
+
for await (const entry of glob(args.pattern, { cwd: baseDir })) files.push(entry);
|
|
95
|
+
if (files.length === 0) return `找不到符合 "${args.pattern}" 的檔案`;
|
|
96
|
+
return files.sort().join('\n');
|
|
97
|
+
} catch (err) {
|
|
98
|
+
return `錯誤:list_files 失敗:${err.message}`;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
case 'grep_content': {
|
|
102
|
+
const filePath = path.resolve(cwd, args.file_path);
|
|
103
|
+
const contextLines = args.context_lines ?? 2;
|
|
104
|
+
try {
|
|
105
|
+
const lines = fs.readFileSync(filePath, 'utf8').split('\n');
|
|
106
|
+
const results = [];
|
|
107
|
+
for (let i = 0; i < lines.length; i++) {
|
|
108
|
+
if (lines[i].includes(args.pattern)) {
|
|
109
|
+
const start = Math.max(0, i - contextLines);
|
|
110
|
+
const end = Math.min(lines.length - 1, i + contextLines);
|
|
111
|
+
results.push(
|
|
112
|
+
lines.slice(start, end + 1).map((l, idx) => `${start + idx + 1}: ${l}`).join('\n')
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (results.length === 0) return `找不到 "${args.pattern}"`;
|
|
117
|
+
return results.join('\n---\n');
|
|
118
|
+
} catch (err) {
|
|
119
|
+
return `錯誤:grep_content 失敗:${err.message}`;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
default:
|
|
123
|
+
return `錯誤:未知工具 "${name}"`;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
128
|
+
// 寫入工具 schema(有副作用,NchcCodeAgent 額外工具集)
|
|
129
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
130
|
+
|
|
131
|
+
export const CODING_SYSTEM_PROMPT =
|
|
132
|
+
'你是一個 Agentic Coding 代理(NchcCodeAgent),專精於 Node.js / MJS 程式碼開發。\n' +
|
|
133
|
+
'可用工具:讀取 / 寫入 / 編輯檔案、執行 shell 指令(測試、git、lint)。\n' +
|
|
134
|
+
'開發原則:SOLID / DRY / KISS / DDD,所有程式碼和回應一律使用繁體中文(臺灣正體)。\n' +
|
|
135
|
+
'工作流程:先讀取再修改,先執行測試再確認結果。\n' +
|
|
136
|
+
'謹慎使用破壞性指令(rm -rf、git reset --hard 等),必要時先確認。';
|
|
137
|
+
|
|
138
|
+
export const CODING_TOOLS = [
|
|
139
|
+
{
|
|
140
|
+
type: 'function',
|
|
141
|
+
function: {
|
|
142
|
+
name: 'write_file',
|
|
143
|
+
description: '建立或覆蓋一個檔案的完整內容。若父目錄不存在,自動建立。',
|
|
144
|
+
parameters: {
|
|
145
|
+
type: 'object',
|
|
146
|
+
properties: {
|
|
147
|
+
path: { type: 'string', description: '檔案路徑' },
|
|
148
|
+
content: { type: 'string', description: '要寫入的完整內容' },
|
|
149
|
+
},
|
|
150
|
+
required: ['path', 'content'],
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
type: 'function',
|
|
156
|
+
function: {
|
|
157
|
+
name: 'edit_file',
|
|
158
|
+
description:
|
|
159
|
+
'精確替換檔案中的字串(old_string → new_string)。' +
|
|
160
|
+
'old_string 必須在檔案中恰好出現一次,否則回傳錯誤。',
|
|
161
|
+
parameters: {
|
|
162
|
+
type: 'object',
|
|
163
|
+
properties: {
|
|
164
|
+
path: { type: 'string', description: '檔案路徑' },
|
|
165
|
+
old_string: { type: 'string', description: '要被替換的字串(須唯一)' },
|
|
166
|
+
new_string: { type: 'string', description: '替換後的字串' },
|
|
167
|
+
},
|
|
168
|
+
required: ['path', 'old_string', 'new_string'],
|
|
169
|
+
},
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
type: 'function',
|
|
174
|
+
function: {
|
|
175
|
+
name: 'run_command',
|
|
176
|
+
description: '執行 shell 指令,回傳 stdout / stderr / 退出碼(預設 30s 逾時)。',
|
|
177
|
+
parameters: {
|
|
178
|
+
type: 'object',
|
|
179
|
+
properties: {
|
|
180
|
+
command: { type: 'string', description: '要執行的完整 shell 指令' },
|
|
181
|
+
timeout_ms: { type: 'integer', description: '逾時毫秒(預設 30000)' },
|
|
182
|
+
},
|
|
183
|
+
required: ['command'],
|
|
184
|
+
},
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
type: 'function',
|
|
189
|
+
function: {
|
|
190
|
+
name: 'create_dir',
|
|
191
|
+
description: '建立目錄(含父目錄,等同 mkdir -p)',
|
|
192
|
+
parameters: {
|
|
193
|
+
type: 'object',
|
|
194
|
+
properties: {
|
|
195
|
+
path: { type: 'string', description: '要建立的目錄路徑' },
|
|
196
|
+
},
|
|
197
|
+
required: ['path'],
|
|
198
|
+
},
|
|
199
|
+
},
|
|
200
|
+
},
|
|
201
|
+
];
|
|
202
|
+
|
|
203
|
+
export const CODING_TOOL_NAMES = new Set(CODING_TOOLS.map(t => t.function.name));
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* 執行寫入工具
|
|
207
|
+
* @param {string} name - 工具名稱
|
|
208
|
+
* @param {object} args - 工具參數
|
|
209
|
+
* @param {string} cwd - 工作目錄
|
|
210
|
+
* @param {boolean} dryRun - true 時不實際寫入/執行
|
|
211
|
+
* @returns {Promise<string>}
|
|
212
|
+
*/
|
|
213
|
+
export async function executeCodeTool(name, args, cwd = process.cwd(), dryRun = false) {
|
|
214
|
+
switch (name) {
|
|
215
|
+
case 'write_file': {
|
|
216
|
+
const filePath = path.resolve(cwd, args.path);
|
|
217
|
+
if (dryRun) return `[dry-run] 不寫入:write_file ${filePath}(${args.content.length} 字元)`;
|
|
218
|
+
try {
|
|
219
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
220
|
+
fs.writeFileSync(filePath, args.content, 'utf8');
|
|
221
|
+
return `已寫入 ${filePath}(${args.content.length} 字元)`;
|
|
222
|
+
} catch (err) {
|
|
223
|
+
return `錯誤:write_file 失敗:${err.message}`;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
case 'edit_file': {
|
|
227
|
+
const filePath = path.resolve(cwd, args.path);
|
|
228
|
+
if (dryRun) return `[dry-run] 不編輯:edit_file ${filePath}("${args.old_string.slice(0, 30)}...")`;
|
|
229
|
+
try {
|
|
230
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
231
|
+
const count = content.split(args.old_string).length - 1;
|
|
232
|
+
if (count === 0) return `錯誤:找不到 old_string,請確認字串完整。\n提示:可用 read_file 先確認內容。`;
|
|
233
|
+
if (count > 1) return `錯誤:old_string 在檔案中出現 ${count} 次,請提供更多上下文使其唯一。`;
|
|
234
|
+
fs.writeFileSync(filePath, content.replace(args.old_string, args.new_string), 'utf8');
|
|
235
|
+
return `已編輯 ${filePath}(${args.old_string.length} → ${args.new_string.length} 字元)`;
|
|
236
|
+
} catch (err) {
|
|
237
|
+
return `錯誤:edit_file 失敗:${err.message}`;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
case 'run_command': {
|
|
241
|
+
if (dryRun) return `[dry-run] 不執行:run_command \`${args.command}\``;
|
|
242
|
+
const timeout = args.timeout_ms || 30000;
|
|
243
|
+
return new Promise((resolve) => {
|
|
244
|
+
exec(args.command, { cwd, timeout, maxBuffer: 1024 * 1024, env: process.env },
|
|
245
|
+
(err, stdout, stderr) => {
|
|
246
|
+
const parts = [];
|
|
247
|
+
if (stdout?.trim()) parts.push(`STDOUT:\n${stdout.trim()}`);
|
|
248
|
+
if (stderr?.trim()) parts.push(`STDERR:\n${stderr.trim()}`);
|
|
249
|
+
if (err?.killed) parts.push(`[逾時:${timeout}ms]`);
|
|
250
|
+
parts.push(`退出碼:${err ? (err.code ?? 1) : 0}`);
|
|
251
|
+
resolve(parts.join('\n'));
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
case 'create_dir': {
|
|
256
|
+
const dirPath = path.resolve(cwd, args.path);
|
|
257
|
+
if (dryRun) return `[dry-run] 不建立:create_dir ${dirPath}`;
|
|
258
|
+
try {
|
|
259
|
+
fs.mkdirSync(dirPath, { recursive: true });
|
|
260
|
+
return `已建立目錄 ${dirPath}`;
|
|
261
|
+
} catch (err) {
|
|
262
|
+
return `錯誤:create_dir 失敗:${err.message}`;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
default:
|
|
266
|
+
return `錯誤:未知的 coding 工具 "${name}"`;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// lib/tools/index.mjs — 工具 schema 聚合器(OCP 擴展點)
|
|
2
|
+
//
|
|
3
|
+
// 新增工具只需:
|
|
4
|
+
// 1. 在 lib/tools/ 建立新的 *-tools.mjs(依工具功能分組)
|
|
5
|
+
// 2. 在此檔案 import 並加入 tools[] 陣列
|
|
6
|
+
//
|
|
7
|
+
// 匯出:
|
|
8
|
+
// tools — 所有唯讀工具的 schema 陣列(NchcAgent 預設)
|
|
9
|
+
// codingTools — 所有寫入工具的 schema 陣列(NchcCodeAgent 專用)
|
|
10
|
+
// allTools — 完整工具集(唯讀 + 寫入)
|
|
11
|
+
// executeTool — 唯讀工具執行器
|
|
12
|
+
// executeCodeTool — 寫入工具執行器
|
|
13
|
+
// CODING_TOOL_NAMES — 寫入工具名稱集合(Set<string>)
|
|
14
|
+
// CODING_SYSTEM_PROMPT — NchcCodeAgent 系統提示詞
|
|
15
|
+
|
|
16
|
+
export {
|
|
17
|
+
BUILT_IN_TOOLS as tools,
|
|
18
|
+
CODING_TOOLS as codingTools,
|
|
19
|
+
CODING_TOOL_NAMES,
|
|
20
|
+
CODING_SYSTEM_PROMPT,
|
|
21
|
+
executeTool,
|
|
22
|
+
executeCodeTool,
|
|
23
|
+
} from './fs-tools.mjs';
|
|
24
|
+
|
|
25
|
+
// allTools:唯讀 + 寫入(完整工具集)
|
|
26
|
+
import { BUILT_IN_TOOLS, CODING_TOOLS } from './fs-tools.mjs';
|
|
27
|
+
export const allTools = [...BUILT_IN_TOOLS, ...CODING_TOOLS];
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// lib/writing/generate.mjs — 平行生成用例
|
|
2
|
+
// 依照 DAG 依賴順序,平行批次生成 PDF
|
|
3
|
+
// 支援多專案:透過 project 參數決定路徑與 generators
|
|
4
|
+
|
|
5
|
+
import path from 'path';
|
|
6
|
+
import { renderPdf } from './renderer.mjs';
|
|
7
|
+
import {
|
|
8
|
+
loadDag, loadState, saveState, initState,
|
|
9
|
+
markRunning, markDone, markFailed, getReady, formatStatus,
|
|
10
|
+
} from '../core/dag.mjs';
|
|
11
|
+
import * as paths from '../core/paths.mjs';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @param {object} opts
|
|
15
|
+
* @param {number} [opts.concurrency=10]
|
|
16
|
+
* @param {number[]|null} [opts.ids]
|
|
17
|
+
* @param {string} [opts.project='nstc']
|
|
18
|
+
* @param {object} opts.generators - 公文類型 → 生成函式
|
|
19
|
+
* @param {object} [opts.overrides] - 按 ID 覆蓋的生成函式
|
|
20
|
+
* @param {string} [opts.fontPath]
|
|
21
|
+
* @param {string} [opts.fontName]
|
|
22
|
+
* @param {string} [opts.author]
|
|
23
|
+
*/
|
|
24
|
+
export async function generateAll({ concurrency = 10, ids = null, project = 'nstc', generators = {}, overrides = {}, fontPath, fontName, author } = {}) {
|
|
25
|
+
const OUTPUT_DIR = path.join(paths.projectOutput(project), 'pdfs');
|
|
26
|
+
const { documents } = loadDag(project);
|
|
27
|
+
let state = loadState(project);
|
|
28
|
+
|
|
29
|
+
if (!state) {
|
|
30
|
+
state = initState(documents);
|
|
31
|
+
saveState(state, project);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const docMap = new Map(documents.map(d => [d.id, d]));
|
|
35
|
+
const targetIds = ids ? new Set(ids) : new Set(documents.map(d => d.id));
|
|
36
|
+
const results = { ok: 0, fail: 0 };
|
|
37
|
+
|
|
38
|
+
let iterations = 0;
|
|
39
|
+
const maxIterations = 200; // 防止無限迴圈
|
|
40
|
+
|
|
41
|
+
while (iterations++ < maxIterations) {
|
|
42
|
+
// 找出就緒的任務
|
|
43
|
+
const ready = getReady(state).filter(t => targetIds.has(t.id));
|
|
44
|
+
if (ready.length === 0) {
|
|
45
|
+
const remaining = Object.values(state).filter(t => targetIds.has(t.id) && t.status === 'pending');
|
|
46
|
+
if (remaining.length === 0) break;
|
|
47
|
+
// 等待其他依賴完成
|
|
48
|
+
await new Promise(r => setTimeout(r, 100));
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// 平行處理一批
|
|
53
|
+
const batch = ready.slice(0, concurrency);
|
|
54
|
+
const promises = batch.map(async (task) => {
|
|
55
|
+
markRunning(state, task.id);
|
|
56
|
+
saveState(state, project);
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
const docConfig = docMap.get(task.id);
|
|
60
|
+
const result = await renderPdf(docConfig, OUTPUT_DIR, { generators, overrides, fontPath, fontName, author });
|
|
61
|
+
markDone(state, task.id, result.path);
|
|
62
|
+
results.ok++;
|
|
63
|
+
return { id: task.id, status: 'ok', path: result.path };
|
|
64
|
+
} catch (err) {
|
|
65
|
+
markFailed(state, task.id, err.message);
|
|
66
|
+
results.fail++;
|
|
67
|
+
return { id: task.id, status: 'fail', error: err.message };
|
|
68
|
+
} finally {
|
|
69
|
+
saveState(state, project);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const batchResults = await Promise.all(promises);
|
|
74
|
+
|
|
75
|
+
// 輸出到 stdout (支援 pipe)
|
|
76
|
+
for (const r of batchResults) {
|
|
77
|
+
if (r.status === 'ok') {
|
|
78
|
+
process.stdout.write(`OK\t${r.id}\t${r.path}\n`);
|
|
79
|
+
} else {
|
|
80
|
+
process.stderr.write(`FAIL\t${r.id}\t${r.error}\n`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return { ...results, status: formatStatus(state) };
|
|
86
|
+
}
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
// lib/writing/generators/nstc-generators.mjs — 國科會各類公文內容生成策略
|
|
2
|
+
// Strategy pattern: 每種公文類型一個生成函式
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
pick, randInt, padZero, genDocNum, genDate, genBudget,
|
|
6
|
+
SCHOOLS, PIS, DEPTS, TOPICS, VENDORS, CHIEFS, MEETING_PLACES,
|
|
7
|
+
} from '../../../projects/nstc/data/fake-data.mjs';
|
|
8
|
+
import {
|
|
9
|
+
drawHeader, drawField, drawSection, drawNumberedText, drawParagraph, drawSignature,
|
|
10
|
+
} from '../layouts/nstc-layout.mjs';
|
|
11
|
+
|
|
12
|
+
// ===== 函 =====
|
|
13
|
+
export function generate_函(doc, meta) {
|
|
14
|
+
const docNum = genDocNum('函', meta.id);
|
|
15
|
+
const date = genDate();
|
|
16
|
+
const school = pick(SCHOOLS);
|
|
17
|
+
const pi = pick(PIS);
|
|
18
|
+
const topic = pick(TOPICS);
|
|
19
|
+
const dept = pick(DEPTS);
|
|
20
|
+
const budget = genBudget();
|
|
21
|
+
|
|
22
|
+
drawHeader(doc, '國家科學及技術委員會 函');
|
|
23
|
+
let y = 160;
|
|
24
|
+
|
|
25
|
+
y = drawField(doc, '受文者:', school, y);
|
|
26
|
+
y = drawField(doc, '發文日期:', date, y);
|
|
27
|
+
y = drawField(doc, '發文字號:', docNum, y);
|
|
28
|
+
y = drawField(doc, '速別:', pick(['最速件', '速件', '普通件']), y);
|
|
29
|
+
y = drawField(doc, '密等及解密條件或保密期限:', '普通', y);
|
|
30
|
+
y = drawField(doc, '附件:', meta.attachment || '如說明三', y);
|
|
31
|
+
y += 10;
|
|
32
|
+
|
|
33
|
+
y = drawField(doc, '主旨:', meta.subject || `有關貴校${pi}教授申請本會${meta.title}案,復如說明,請查照。`, y, true);
|
|
34
|
+
y += 5;
|
|
35
|
+
|
|
36
|
+
y = drawSection(doc, '說明:', y);
|
|
37
|
+
y = drawNumberedText(doc, '一、', `依據本會${dept}${date}審查會議決議辦理。`, y);
|
|
38
|
+
y = drawNumberedText(doc, '二、', meta.desc2 || `旨揭計畫「${topic}」(計畫編號:NSTC ${114}-${randInt(2100, 2900)}-${pick(['E', 'H', 'M', 'B', 'I'])}-${padZero(randInt(1, 200), 3)}-${padZero(randInt(1, 99))}),經本會審查通過,核定補助經費${budget},執行期限自114年8月1日起至115年7月31日止。`, y);
|
|
39
|
+
y = drawNumberedText(doc, '三、', meta.desc3 || '檢附計畫經費核定清單乙份,請依「國家科學及技術委員會補助專題研究計畫經費處理原則」相關規定辦理核撥及執行事宜。', y);
|
|
40
|
+
y = drawNumberedText(doc, '四、', meta.desc4 || '計畫主持人應依核定計畫內容執行,如需變更,應依規定程序報本會核准。', y);
|
|
41
|
+
y += 15;
|
|
42
|
+
|
|
43
|
+
y = drawField(doc, '正本:', school, y);
|
|
44
|
+
y = drawField(doc, '副本:', `${school}研究發展處、${dept}`, y);
|
|
45
|
+
y += 30;
|
|
46
|
+
|
|
47
|
+
drawSignature(doc, y);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ===== 令 =====
|
|
51
|
+
export function generate_令(doc, meta) {
|
|
52
|
+
const docNum = genDocNum('令', meta.id);
|
|
53
|
+
const date = genDate();
|
|
54
|
+
|
|
55
|
+
drawHeader(doc, '國家科學及技術委員會 令');
|
|
56
|
+
let y = 160;
|
|
57
|
+
|
|
58
|
+
y = drawField(doc, '發文日期:', date, y);
|
|
59
|
+
y = drawField(doc, '發文字號:', docNum, y);
|
|
60
|
+
y += 10;
|
|
61
|
+
|
|
62
|
+
if (meta.title.includes('派令') || meta.title.includes('任命')) {
|
|
63
|
+
y = drawSection(doc, '茲派(令)', y);
|
|
64
|
+
y += 5;
|
|
65
|
+
const person = pick(CHIEFS);
|
|
66
|
+
const position = meta.title.includes('主任委員') ? '主任委員' :
|
|
67
|
+
meta.title.includes('副主任委員') ? '副主任委員' : '處長';
|
|
68
|
+
y = drawParagraph(doc, `茲派${person}為本會${position},應即到職。`, y);
|
|
69
|
+
y += 10;
|
|
70
|
+
y = drawParagraph(doc, '此令。', y);
|
|
71
|
+
} else if (meta.title.includes('修正')) {
|
|
72
|
+
y = drawField(doc, '主旨:', `修正「${meta.title.replace('修正令', '')}」部分規定,並自即日生效。`, y, true);
|
|
73
|
+
y += 5;
|
|
74
|
+
y = drawSection(doc, '依據:', y);
|
|
75
|
+
y = drawParagraph(doc, '科學技術基本法第六條及國家科學及技術委員會組織法第二條規定。', y);
|
|
76
|
+
y += 5;
|
|
77
|
+
y = drawSection(doc, '公告事項:', y);
|
|
78
|
+
y = drawNumberedText(doc, '一、', `修正「${meta.title.replace('修正令', '')}」第三條、第五條、第十二條及第十五條條文。`, y);
|
|
79
|
+
y = drawNumberedText(doc, '二、', '修正條文如附件。', y);
|
|
80
|
+
y = drawNumberedText(doc, '三、', '本令自發布日施行。', y);
|
|
81
|
+
} else {
|
|
82
|
+
y = drawField(doc, '主旨:', `${meta.title},自即日生效。`, y, true);
|
|
83
|
+
y += 5;
|
|
84
|
+
y = drawSection(doc, '說明:', y);
|
|
85
|
+
y = drawNumberedText(doc, '一、', '依據國家科學及技術委員會組織法相關規定辦理。', y);
|
|
86
|
+
y = drawNumberedText(doc, '二、', '詳如附件。', y);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
y += 30;
|
|
90
|
+
drawSignature(doc, y);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ===== 公告 =====
|
|
94
|
+
export function generate_公告(doc, meta) {
|
|
95
|
+
const docNum = genDocNum('公告', meta.id);
|
|
96
|
+
const date = genDate();
|
|
97
|
+
|
|
98
|
+
drawHeader(doc, '國家科學及技術委員會 公告');
|
|
99
|
+
let y = 160;
|
|
100
|
+
|
|
101
|
+
y = drawField(doc, '發文日期:', date, y);
|
|
102
|
+
y = drawField(doc, '發文字號:', docNum, y);
|
|
103
|
+
y = drawField(doc, '附件:', meta.attachment || '如公告事項', y);
|
|
104
|
+
y += 10;
|
|
105
|
+
|
|
106
|
+
y = drawField(doc, '主旨:', `公告${meta.title}相關事項。`, y, true);
|
|
107
|
+
y += 5;
|
|
108
|
+
|
|
109
|
+
y = drawSection(doc, '依據:', y);
|
|
110
|
+
y = drawParagraph(doc, '國家科學及技術委員會補助專題研究計畫作業要點第四點規定。', y);
|
|
111
|
+
y += 5;
|
|
112
|
+
|
|
113
|
+
y = drawSection(doc, '公告事項:', y);
|
|
114
|
+
if (meta.title.includes('徵求') || meta.title.includes('申請')) {
|
|
115
|
+
y = drawNumberedText(doc, '一、', '申請資格:凡國內公私立大專校院及學術研究機構之專任教學或研究人員,具有博士學位者,均得向本會提出申請。', y);
|
|
116
|
+
y = drawNumberedText(doc, '二、', `申請期限:自即日起至114年${randInt(5, 10)}月${randInt(1, 28)}日止,逾期不予受理。`, y);
|
|
117
|
+
y = drawNumberedText(doc, '三、', '申請方式:請至本會學術研發服務網(https://wsts.nstc.gov.tw)線上提出申請,並由申請機構統一彙送。', y);
|
|
118
|
+
y = drawNumberedText(doc, '四、', '補助項目及額度:依本會相關補助作業要點規定辦理。', y);
|
|
119
|
+
y = drawNumberedText(doc, '五、', '聯絡方式:請洽本會承辦人員(電話:02-2737-7992)。', y);
|
|
120
|
+
} else if (meta.title.includes('招標') || meta.title.includes('決標')) {
|
|
121
|
+
y = drawNumberedText(doc, '一、', `案號:NSTC-${114}${padZero(randInt(1, 12))}-${padZero(randInt(1, 50), 3)}`, y);
|
|
122
|
+
y = drawNumberedText(doc, '二、', `標案名稱:${meta.title}`, y);
|
|
123
|
+
y = drawNumberedText(doc, '三、', meta.title.includes('決標') ?
|
|
124
|
+
`決標金額:新臺幣${randInt(100, 5000)}萬元整` :
|
|
125
|
+
`預算金額:新臺幣${randInt(100, 5000)}萬元整`, y);
|
|
126
|
+
y = drawNumberedText(doc, '四、', meta.title.includes('決標') ?
|
|
127
|
+
`得標廠商:${pick(VENDORS)}` :
|
|
128
|
+
`投標截止日期:114年${randInt(3, 11)}月${randInt(1, 28)}日下午5時`, y);
|
|
129
|
+
} else if (meta.title.includes('甄選')) {
|
|
130
|
+
y = drawNumberedText(doc, '一、', `甄選職缺:本會${pick(DEPTS)}研究員${randInt(1, 3)}名。`, y);
|
|
131
|
+
y = drawNumberedText(doc, '二、', '資格條件:具有相關領域博士學位,並具二年以上相關工作經驗者。', y);
|
|
132
|
+
y = drawNumberedText(doc, '三、', `報名期限:自即日起至114年${randInt(3, 11)}月${randInt(1, 28)}日止。`, y);
|
|
133
|
+
y = drawNumberedText(doc, '四、', '報名方式:請至本會人事室網站下載報名表,填妥後連同相關證件影本寄送本會人事室。', y);
|
|
134
|
+
} else {
|
|
135
|
+
y = drawNumberedText(doc, '一、', `本公告事項自114年${randInt(1, 12)}月${randInt(1, 28)}日生效。`, y);
|
|
136
|
+
y = drawNumberedText(doc, '二、', '相關細節請參閱附件說明。', y);
|
|
137
|
+
y = drawNumberedText(doc, '三、', '如有疑義,請洽本會綜合規劃處(電話:02-2737-7590)。', y);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
y += 30;
|
|
141
|
+
drawSignature(doc, y);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ===== 書函 =====
|
|
145
|
+
export function generate_書函(doc, meta) {
|
|
146
|
+
const docNum = genDocNum('書函', meta.id);
|
|
147
|
+
const date = genDate();
|
|
148
|
+
const school = pick(SCHOOLS);
|
|
149
|
+
|
|
150
|
+
drawHeader(doc, '國家科學及技術委員會 書函');
|
|
151
|
+
let y = 160;
|
|
152
|
+
|
|
153
|
+
y = drawField(doc, '受文者:', school, y);
|
|
154
|
+
y = drawField(doc, '發文日期:', date, y);
|
|
155
|
+
y = drawField(doc, '發文字號:', docNum, y);
|
|
156
|
+
y = drawField(doc, '速別:', '普通件', y);
|
|
157
|
+
y = drawField(doc, '密等及解密條件或保密期限:', '普通', y);
|
|
158
|
+
y = drawField(doc, '附件:', '無', y);
|
|
159
|
+
y += 10;
|
|
160
|
+
|
|
161
|
+
y = drawField(doc, '主旨:', `有關${meta.title}事宜,請查照配合辦理。`, y, true);
|
|
162
|
+
y += 5;
|
|
163
|
+
|
|
164
|
+
y = drawSection(doc, '說明:', y);
|
|
165
|
+
y = drawNumberedText(doc, '一、', '依據本會114年度施政計畫辦理。', y);
|
|
166
|
+
y = drawNumberedText(doc, '二、', '為辦理旨揭事項,請貴校(機構)依規定期限配合辦理相關作業。', y);
|
|
167
|
+
y = drawNumberedText(doc, '三、', '如有疑義,請逕洽本會承辦人員。', y);
|
|
168
|
+
y += 15;
|
|
169
|
+
|
|
170
|
+
y = drawField(doc, '正本:', '各公私立大專校院', y);
|
|
171
|
+
y = drawField(doc, '副本:', '本會相關處室', y);
|
|
172
|
+
y += 30;
|
|
173
|
+
|
|
174
|
+
drawSignature(doc, y);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ===== 開會通知單 =====
|
|
178
|
+
export function generate_開會通知單(doc, meta) {
|
|
179
|
+
const docNum = genDocNum('開會通知單', meta.id);
|
|
180
|
+
const date = genDate();
|
|
181
|
+
const meetMonth = randInt(3, 12);
|
|
182
|
+
const meetDay = randInt(1, 28);
|
|
183
|
+
const meetHour = pick([9, 10, 14, 15]);
|
|
184
|
+
const place = pick(MEETING_PLACES);
|
|
185
|
+
|
|
186
|
+
drawHeader(doc, '國家科學及技術委員會 開會通知單');
|
|
187
|
+
let y = 160;
|
|
188
|
+
|
|
189
|
+
y = drawField(doc, '發文日期:', date, y);
|
|
190
|
+
y = drawField(doc, '發文字號:', docNum, y);
|
|
191
|
+
y = drawField(doc, '速別:', '速件', y);
|
|
192
|
+
y += 15;
|
|
193
|
+
|
|
194
|
+
y = drawField(doc, '會議名稱:', meta.title, y);
|
|
195
|
+
y = drawField(doc, '開會時間:', `114年${meetMonth}月${meetDay}日(${pick(['星期一', '星期二', '星期三', '星期四', '星期五'])})${meetHour < 12 ? '上' : '下'}午${meetHour > 12 ? meetHour - 12 : meetHour}時${pick(['00', '30'])}分`, y);
|
|
196
|
+
y = drawField(doc, '開會地點:', place, y);
|
|
197
|
+
y = drawField(doc, '主持人:', `${pick(CHIEFS)} ${pick(['主任委員', '副主任委員', '處長'])}`, y);
|
|
198
|
+
y = drawField(doc, '聯絡人及電話:', `${pick(PIS)}(02-2737-${randInt(7000, 7999)})`, y);
|
|
199
|
+
y += 15;
|
|
200
|
+
|
|
201
|
+
y = drawSection(doc, '開會事由:', y);
|
|
202
|
+
y = drawParagraph(doc, `為辦理${meta.title}相關事宜,召開本次會議研商。`, y);
|
|
203
|
+
y += 10;
|
|
204
|
+
|
|
205
|
+
y = drawSection(doc, '討論事項:', y);
|
|
206
|
+
y = drawNumberedText(doc, '一、', '前次會議決議事項辦理情形報告。', y);
|
|
207
|
+
y = drawNumberedText(doc, '二、', `${meta.title}相關議題討論。`, y);
|
|
208
|
+
y = drawNumberedText(doc, '三、', '臨時動議。', y);
|
|
209
|
+
y += 10;
|
|
210
|
+
|
|
211
|
+
y = drawSection(doc, '出(列)席者:', y);
|
|
212
|
+
y = drawParagraph(doc, '詳如附件出席名單。', y);
|
|
213
|
+
y += 10;
|
|
214
|
+
|
|
215
|
+
y = drawSection(doc, '備註:', y);
|
|
216
|
+
y = drawNumberedText(doc, '一、', '請準時出席,如不克出席,請於會前指派代理人參加並告知聯絡人。', y);
|
|
217
|
+
y = drawNumberedText(doc, '二、', '開會當日請攜帶本通知單以利換證入場。', y);
|
|
218
|
+
y += 30;
|
|
219
|
+
|
|
220
|
+
drawSignature(doc, y);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ===== 簽 =====
|
|
224
|
+
export function generate_簽(doc, meta) {
|
|
225
|
+
const date = genDate();
|
|
226
|
+
const dept = pick(DEPTS);
|
|
227
|
+
|
|
228
|
+
drawHeader(doc, '簽');
|
|
229
|
+
let y = 145;
|
|
230
|
+
|
|
231
|
+
doc.font('SongBold').fontSize(12);
|
|
232
|
+
doc.text(`主管: 會辦: 承辦單位:${dept}`, 60, y);
|
|
233
|
+
y += 35;
|
|
234
|
+
|
|
235
|
+
y = drawField(doc, '簽於:', date, y);
|
|
236
|
+
y += 10;
|
|
237
|
+
|
|
238
|
+
y = drawField(doc, '主旨:', `為辦理${meta.title}案,擬具處理意見,簽請核示。`, y, true);
|
|
239
|
+
y += 5;
|
|
240
|
+
|
|
241
|
+
y = drawSection(doc, '說明:', y);
|
|
242
|
+
y = drawNumberedText(doc, '一、', '依據本會業務需要及相關規定辦理。', y);
|
|
243
|
+
y = drawNumberedText(doc, '二、', `案經${dept}研議,擬具處理意見如擬辦事項。`, y);
|
|
244
|
+
y += 5;
|
|
245
|
+
|
|
246
|
+
y = drawSection(doc, '擬辦:', y);
|
|
247
|
+
y = drawNumberedText(doc, '一、', `擬請核准辦理${meta.title},並依規定程序簽報。`, y);
|
|
248
|
+
y = drawNumberedText(doc, '二、', '奉核後,由承辦單位依權責辦理後續事宜。', y);
|
|
249
|
+
y += 15;
|
|
250
|
+
|
|
251
|
+
doc.font('Song').fontSize(11);
|
|
252
|
+
y = drawField(doc, '承辦人:', pick(PIS), y);
|
|
253
|
+
y = drawField(doc, '單位主管:', '', y);
|
|
254
|
+
y = drawField(doc, '核稿:', '', y);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
import {
|
|
258
|
+
generate_top5_核定通知, generate_top5_申請公告,
|
|
259
|
+
generate_top5_開會通知, generate_top5_修正令, generate_top5_簽呈,
|
|
260
|
+
} from './nstc-top5.mjs';
|
|
261
|
+
|
|
262
|
+
// 通用 Registry(按公文類型)
|
|
263
|
+
export const GENERATORS = {
|
|
264
|
+
'函': generate_函,
|
|
265
|
+
'令': generate_令,
|
|
266
|
+
'公告': generate_公告,
|
|
267
|
+
'書函': generate_書函,
|
|
268
|
+
'開會通知單': generate_開會通知單,
|
|
269
|
+
'簽': generate_簽,
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
// Top5 特殊覆蓋(按文件 ID,多頁高擬真版本)
|
|
273
|
+
export const TOP5_OVERRIDES = {
|
|
274
|
+
1: generate_top5_核定通知,
|
|
275
|
+
34: generate_top5_申請公告,
|
|
276
|
+
41: generate_top5_開會通知,
|
|
277
|
+
31: generate_top5_修正令,
|
|
278
|
+
51: generate_top5_簽呈,
|
|
279
|
+
};
|