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,185 @@
|
|
|
1
|
+
// lib/flows/gemini-ask.mjs — Gemini 提問流程(Application Layer)
|
|
2
|
+
// 職責: 組裝 adapter + 格式化輸出 + 效能測量
|
|
3
|
+
// 原則: KISS — 流程越簡單越好,複雜邏輯在 adapter
|
|
4
|
+
|
|
5
|
+
import { createLLM } from '../core/llm.mjs';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* 取得臺灣當前時間字串,自動注入 system prompt
|
|
9
|
+
* @returns {string} 例如 "現在時間:2026年2月27日(週四)15:30 (UTC+8 臺灣時間)"
|
|
10
|
+
*/
|
|
11
|
+
function getTaiwanTimePrefix() {
|
|
12
|
+
const now = new Date();
|
|
13
|
+
const tw = new Intl.DateTimeFormat('zh-TW', {
|
|
14
|
+
timeZone: 'Asia/Taipei',
|
|
15
|
+
year: 'numeric',
|
|
16
|
+
month: '2-digit',
|
|
17
|
+
day: '2-digit',
|
|
18
|
+
weekday: 'short',
|
|
19
|
+
hour: '2-digit',
|
|
20
|
+
minute: '2-digit',
|
|
21
|
+
hour12: false,
|
|
22
|
+
}).formatToParts(now);
|
|
23
|
+
|
|
24
|
+
const get = (type) => tw.find(p => p.type === type)?.value || '';
|
|
25
|
+
return `現在時間:${get('year')}年${get('month')}月${get('day')}日(${get('weekday')})${get('hour')}:${get('minute')} (UTC+8 臺灣時間)`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 組裝 system instruction:自動加上臺灣時間前綴
|
|
30
|
+
* @param {string} [userSystemInstruction] - 使用者提供的 system prompt
|
|
31
|
+
* @returns {string} 含時間前綴的完整 system instruction
|
|
32
|
+
*/
|
|
33
|
+
function buildSystemInstruction(userSystemInstruction) {
|
|
34
|
+
const timePrefix = getTaiwanTimePrefix();
|
|
35
|
+
if (userSystemInstruction) {
|
|
36
|
+
return `${timePrefix}\n\n${userSystemInstruction}`;
|
|
37
|
+
}
|
|
38
|
+
return timePrefix;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 執行 Gemini 提問流程
|
|
43
|
+
* @param {object} params
|
|
44
|
+
* @param {string} params.prompt - 使用者提問
|
|
45
|
+
* @param {string} [params.systemInstruction] - 系統提示詞(與 prompt 分離,讓 Gemini 正確區分角色)
|
|
46
|
+
* @param {string} [params.thinking='MEDIUM'] - 思考等級
|
|
47
|
+
* @param {boolean} [params.grounding=true] - 啟用搜尋接地
|
|
48
|
+
* @param {boolean} [params.urlContext=true] - 啟用 URL 擷取
|
|
49
|
+
* @param {string} [params.project] - GCP 專案 ID
|
|
50
|
+
* @param {string} [params.location] - GCP 區域
|
|
51
|
+
* @returns {Promise<object>} 結構化回應(含 perf 效能指標)
|
|
52
|
+
*/
|
|
53
|
+
export async function geminiAsk({
|
|
54
|
+
provider = 'gemini-auto',
|
|
55
|
+
prompt,
|
|
56
|
+
systemInstruction,
|
|
57
|
+
thinking = 'MEDIUM',
|
|
58
|
+
grounding = true,
|
|
59
|
+
urlContext = true,
|
|
60
|
+
project,
|
|
61
|
+
location,
|
|
62
|
+
}) {
|
|
63
|
+
const adapter = await createLLM({
|
|
64
|
+
provider,
|
|
65
|
+
project,
|
|
66
|
+
location,
|
|
67
|
+
thinkingLevel: thinking,
|
|
68
|
+
grounding,
|
|
69
|
+
urlContext,
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
return adapter.generateContent({ prompt, systemInstruction: buildSystemInstruction(systemInstruction) });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* 串流執行 Gemini 提問流程(AsyncGenerator)
|
|
77
|
+
* yield* 委託 adapter.generateContentStream()
|
|
78
|
+
* @param {object} params - 同 geminiAsk 參數
|
|
79
|
+
* @yields {{ type: 'text', text: string } | { type: 'metadata', ... }}
|
|
80
|
+
*/
|
|
81
|
+
export async function* geminiAskStream({
|
|
82
|
+
provider = 'gemini-auto',
|
|
83
|
+
prompt,
|
|
84
|
+
systemInstruction,
|
|
85
|
+
thinking = 'MEDIUM',
|
|
86
|
+
grounding = true,
|
|
87
|
+
urlContext = true,
|
|
88
|
+
project,
|
|
89
|
+
location,
|
|
90
|
+
}) {
|
|
91
|
+
const adapter = await createLLM({
|
|
92
|
+
provider,
|
|
93
|
+
project,
|
|
94
|
+
location,
|
|
95
|
+
thinkingLevel: thinking,
|
|
96
|
+
grounding,
|
|
97
|
+
urlContext,
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
yield* adapter.generateContentStream({ prompt, systemInstruction: buildSystemInstruction(systemInstruction) });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* 格式化效能摘要為純文字(多快 + 多省)
|
|
105
|
+
* @param {object} perf - 效能指標
|
|
106
|
+
* @param {object} usage - token 用量
|
|
107
|
+
* @returns {string} 效能摘要
|
|
108
|
+
*/
|
|
109
|
+
export function formatPerfSummary(perf, usage) {
|
|
110
|
+
const lines = [];
|
|
111
|
+
lines.push('--- 效能測量 ---');
|
|
112
|
+
|
|
113
|
+
// 多快
|
|
114
|
+
lines.push(` 延遲: ${perf.latencySec}s (${perf.latencyMs}ms)`);
|
|
115
|
+
lines.push(` 輸出速度: ${perf.outputTokensPerSec} tokens/s | ${perf.charsPerSec} 字/s`);
|
|
116
|
+
lines.push(` 總吞吐量: ${perf.totalTokensPerSec} tokens/s`);
|
|
117
|
+
|
|
118
|
+
// 多省
|
|
119
|
+
lines.push(` token 分佈: prompt=${usage.promptTokens} think=${usage.thoughtsTokens} output=${usage.candidatesTokens} total=${usage.totalTokens}`);
|
|
120
|
+
lines.push(` 思考佔比: ${(perf.thinkRatio * 100).toFixed(1)}% (思考/總) | 思考:輸出 = ${perf.thinkToOutputRatio}:1`);
|
|
121
|
+
lines.push(` 輸出效率: ${(perf.outputRatio * 100).toFixed(1)}% (有效輸出/總) | ${perf.charsPerToken} 字/token`);
|
|
122
|
+
|
|
123
|
+
return lines.join('\n');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* 格式化回應為純文字(含引證 + 效能)
|
|
128
|
+
* @param {object} result - geminiAsk 回應
|
|
129
|
+
* @returns {string} 格式化文字
|
|
130
|
+
*/
|
|
131
|
+
export function formatTextOutput(result) {
|
|
132
|
+
const lines = [];
|
|
133
|
+
|
|
134
|
+
// 主要回應
|
|
135
|
+
lines.push(result.text);
|
|
136
|
+
|
|
137
|
+
// 引證來源
|
|
138
|
+
if (result.sources.length > 0) {
|
|
139
|
+
lines.push('');
|
|
140
|
+
lines.push('--- 引證來源 ---');
|
|
141
|
+
for (const src of result.sources) {
|
|
142
|
+
lines.push(` [${src.title || src.domain}] ${src.uri}`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// URL 擷取狀態
|
|
147
|
+
if (result.urlContext.length > 0) {
|
|
148
|
+
lines.push('');
|
|
149
|
+
lines.push('--- URL 擷取 ---');
|
|
150
|
+
for (const uc of result.urlContext) {
|
|
151
|
+
const icon = uc.status === 'success' ? 'OK' : 'FAIL';
|
|
152
|
+
lines.push(` ${icon} ${uc.url}`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// 效能測量
|
|
157
|
+
if (result.perf) {
|
|
158
|
+
lines.push('');
|
|
159
|
+
lines.push(formatPerfSummary(result.perf, result.usage));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return lines.join('\n');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* 格式化回應為 JSON(含效能指標)
|
|
167
|
+
* @param {object} result - geminiAsk 回應
|
|
168
|
+
* @param {string} model - 模型名稱
|
|
169
|
+
* @param {string} elapsed - 總耗時(含 CLI 初始化)
|
|
170
|
+
* @returns {string} JSON 字串
|
|
171
|
+
*/
|
|
172
|
+
export function formatJsonOutput(result, model, elapsed) {
|
|
173
|
+
const output = {
|
|
174
|
+
_meta: { command: 'gemini ask', model, elapsed },
|
|
175
|
+
text: result.text,
|
|
176
|
+
thinking: result.thinking || null,
|
|
177
|
+
sources: result.sources,
|
|
178
|
+
searchQueries: result.searchQueries || [],
|
|
179
|
+
supports: result.supports || [],
|
|
180
|
+
urlContext: result.urlContext,
|
|
181
|
+
usage: result.usage,
|
|
182
|
+
perf: result.perf || null,
|
|
183
|
+
};
|
|
184
|
+
return JSON.stringify(output, null, 2);
|
|
185
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// lib/flows/hatch-portal.mjs — 智慧入口孵化業務流程
|
|
2
|
+
// 串接:portal → 全自動孵化流程
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 執行孵化流程
|
|
6
|
+
* @param {object} opts
|
|
7
|
+
* @param {string} opts.project - 專案名稱
|
|
8
|
+
* @param {object} opts.input - 輸入資料
|
|
9
|
+
*/
|
|
10
|
+
export async function hatchPortal(opts = {}) {
|
|
11
|
+
// TODO: 實作端到端孵化流程
|
|
12
|
+
throw new Error('hatchPortal flow 尚未實作');
|
|
13
|
+
}
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
// lib/flows/legal-ask.mjs — 法律查詢單次提問流程(Application Layer)
|
|
2
|
+
//
|
|
3
|
+
// 設計原則:SOLID / DDD / KISS
|
|
4
|
+
// SRP — 組裝 adapter + cache + router,不直接處理 SQLite 或 HTTP
|
|
5
|
+
// DDD — 以 LegalQueryResult Value Object 為核心輸出
|
|
6
|
+
// KISS — 單次 request 完成搜尋+分析(合併多步驟提示工程)
|
|
7
|
+
//
|
|
8
|
+
// 流程:
|
|
9
|
+
// 1. AI Router(Mistral 14B)判斷是否使用快取
|
|
10
|
+
// → cache hit:直接回傳快取答案,不呼叫大模型
|
|
11
|
+
// → cache miss:執行單次大模型查詢(搜尋法規 + 法律分析一口氣完成)
|
|
12
|
+
// 2. 儲存到快取,下次 Mistral 14B 可命中
|
|
13
|
+
//
|
|
14
|
+
// LegalQueryResult Value Object:
|
|
15
|
+
// { question, fromCache, cacheId, tags, step1Laws, step2Answer, sources,
|
|
16
|
+
// model, routerDecision, perf }
|
|
17
|
+
|
|
18
|
+
import { createLLM } from '../core/llm.mjs';
|
|
19
|
+
import { AiCache } from '../core/ai-cache.mjs';
|
|
20
|
+
import { AiRouter } from '../core/ai-router.mjs';
|
|
21
|
+
import { dbPath as resolveDbPath } from '../core/paths.mjs';
|
|
22
|
+
|
|
23
|
+
// ── 合併提示詞(單次 request 完成搜尋 + 分析) ─────────────────────────────
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 單次 request 提示:搜尋法規 + 白話法律分析
|
|
27
|
+
* 利用 Gemini 3 Flash 的 webSearch + thinking 能力,一次完成兩階段工作
|
|
28
|
+
*/
|
|
29
|
+
const LEGAL_SYSTEM = `你是專業的臺灣法律諮詢助理,同時具備法規搜尋與白話解釋能力。
|
|
30
|
+
|
|
31
|
+
你的工作分為兩個階段,在同一次回答中完成:
|
|
32
|
+
|
|
33
|
+
【階段一:搜尋法規】
|
|
34
|
+
1. 使用網路搜尋功能搜尋全國法規資料庫(law.moj.gov.tw)及可靠法律來源
|
|
35
|
+
2. 嚴格保留法條原文,一字不差,禁止修改或改寫法條內容
|
|
36
|
+
3. 標注每條法規的完整來源(法規名稱、條號)
|
|
37
|
+
4. 若有相關的司法院解釋或判例,一併列出
|
|
38
|
+
|
|
39
|
+
【階段二:白話分析】
|
|
40
|
+
1. 只能基於你搜尋到的法律條文回答,嚴禁添加法條以外的推測或臆測
|
|
41
|
+
2. 使用白話文解釋,讓一般民眾也能理解
|
|
42
|
+
3. 清楚說明使用者的「權利」與「義務」
|
|
43
|
+
4. 若法律有例外情形,必須清楚說明
|
|
44
|
+
|
|
45
|
+
輸出格式(嚴格遵守 JSON):
|
|
46
|
+
{
|
|
47
|
+
"laws": [
|
|
48
|
+
{
|
|
49
|
+
"law_name": "民法",
|
|
50
|
+
"article": "第 425 條",
|
|
51
|
+
"content": "(完整原始法條文字)",
|
|
52
|
+
"source_url": "https://law.moj.gov.tw/..."
|
|
53
|
+
}
|
|
54
|
+
],
|
|
55
|
+
"answer": {
|
|
56
|
+
"summary": "(一句話直接回答是否可行)",
|
|
57
|
+
"detail": "(白話解釋相關法條,分段說明)",
|
|
58
|
+
"rights": "(使用者可以主張的權利)",
|
|
59
|
+
"warnings": "(例外情形或限制)",
|
|
60
|
+
"cited_laws": ["民法 第 425 條", "土地法 第 100 條"]
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
若找不到相關法條,laws 陣列為空,answer 中說明找不到的原因。`;
|
|
65
|
+
|
|
66
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 從模型回應中萃取 JSON(容錯:markdown code block、純文字混雜)
|
|
70
|
+
* @param {string} text
|
|
71
|
+
* @returns {{ laws: Array, answer: object } | null}
|
|
72
|
+
*/
|
|
73
|
+
function parseResponse(text) {
|
|
74
|
+
const trimmed = (text || '').trim();
|
|
75
|
+
// 1. 直接 JSON
|
|
76
|
+
try { return JSON.parse(trimmed); } catch { /* 繼續 */ }
|
|
77
|
+
// 2. markdown code block
|
|
78
|
+
const mdMatch = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/);
|
|
79
|
+
if (mdMatch) {
|
|
80
|
+
try { return JSON.parse(mdMatch[1].trim()); } catch { /* 繼續 */ }
|
|
81
|
+
}
|
|
82
|
+
// 3. 從文字中提取 {...}
|
|
83
|
+
const braceMatch = trimmed.match(/\{[\s\S]*\}/);
|
|
84
|
+
if (braceMatch) {
|
|
85
|
+
try { return JSON.parse(braceMatch[0]); } catch { /* 繼續 */ }
|
|
86
|
+
}
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* 將結構化 answer 物件組合成可讀文字
|
|
92
|
+
* @param {object} answer - { summary, detail, rights, warnings, cited_laws }
|
|
93
|
+
* @returns {string}
|
|
94
|
+
*/
|
|
95
|
+
function answerToText(answer) {
|
|
96
|
+
if (!answer) return '';
|
|
97
|
+
if (typeof answer === 'string') return answer;
|
|
98
|
+
const parts = [];
|
|
99
|
+
if (answer.summary) parts.push(`**法律上的答案**:${answer.summary}`);
|
|
100
|
+
if (answer.detail) parts.push(`\n**詳細說明**:\n${answer.detail}`);
|
|
101
|
+
if (answer.rights) parts.push(`\n**您的權利**:\n${answer.rights}`);
|
|
102
|
+
if (answer.warnings) parts.push(`\n**注意事項**:\n${answer.warnings}`);
|
|
103
|
+
if (answer.cited_laws?.length) {
|
|
104
|
+
parts.push(`\n**相關法條引用**:\n${answer.cited_laws.map(l => `- ${l}`).join('\n')}`);
|
|
105
|
+
}
|
|
106
|
+
return parts.join('\n');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* 執行法律查詢(含 AI Router 快取判斷 + 單次大模型提問)
|
|
113
|
+
*
|
|
114
|
+
* @param {object} params
|
|
115
|
+
* @param {string} params.question - 使用者問題(白話文)
|
|
116
|
+
* @param {string} [params.project] - 專案名稱(決定 SQLite 路徑)
|
|
117
|
+
* @param {object} [params.config] - 專案 config.json 內容
|
|
118
|
+
* @param {boolean} [params.skipCache] - 強制跳過快取,直接呼叫大模型
|
|
119
|
+
* @param {boolean} [params.skipSave] - 不將結果存入快取
|
|
120
|
+
* @returns {Promise<LegalQueryResult>}
|
|
121
|
+
*/
|
|
122
|
+
export async function legalAsk({
|
|
123
|
+
question,
|
|
124
|
+
project = 'legal-cache',
|
|
125
|
+
config = {},
|
|
126
|
+
skipCache = false,
|
|
127
|
+
skipSave = false,
|
|
128
|
+
}) {
|
|
129
|
+
const t0 = performance.now();
|
|
130
|
+
|
|
131
|
+
// ── 初始化元件 ──────────────────────────────────────────────────────────
|
|
132
|
+
const dbPath = resolveDbPath(project);
|
|
133
|
+
const cache = new AiCache(dbPath);
|
|
134
|
+
cache.initSchema();
|
|
135
|
+
|
|
136
|
+
const routerConfig = config.router || {};
|
|
137
|
+
const router = new AiRouter({
|
|
138
|
+
provider: routerConfig.provider || 'nchc',
|
|
139
|
+
model: routerConfig.model || 'Ministral-3-14B-Instruct-2512',
|
|
140
|
+
tagCacheThreshold: routerConfig.tagCacheThreshold ?? 2,
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// ── Phase 1:AI Router 決策 ──────────────────────────────────────────────
|
|
144
|
+
let routerDecision = null;
|
|
145
|
+
if (!skipCache) {
|
|
146
|
+
routerDecision = await router.route(question, cache);
|
|
147
|
+
|
|
148
|
+
// 記錄路由決策
|
|
149
|
+
cache.logRouterDecision({
|
|
150
|
+
question,
|
|
151
|
+
decision: routerDecision.decision,
|
|
152
|
+
cacheId: routerDecision.cacheId,
|
|
153
|
+
confidence: routerDecision.confidence,
|
|
154
|
+
tags: routerDecision.tags,
|
|
155
|
+
reason: routerDecision.reason,
|
|
156
|
+
latencyMs: routerDecision.latencyMs,
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// Cache Hit:直接回傳快取答案
|
|
160
|
+
if (routerDecision.decision === 'cache' && routerDecision.cacheId) {
|
|
161
|
+
const cached = cache.getById(routerDecision.cacheId);
|
|
162
|
+
if (cached) {
|
|
163
|
+
cache.incrementHit(routerDecision.cacheId);
|
|
164
|
+
const totalMs = Math.round(performance.now() - t0);
|
|
165
|
+
cache.close();
|
|
166
|
+
return {
|
|
167
|
+
question,
|
|
168
|
+
fromCache: true,
|
|
169
|
+
cacheId: routerDecision.cacheId,
|
|
170
|
+
tags: routerDecision.tags,
|
|
171
|
+
step1Laws: cached.step1Laws ? JSON.parse(cached.step1Laws) : [],
|
|
172
|
+
step2Answer: cached.step2Answer,
|
|
173
|
+
sources: cached.sources,
|
|
174
|
+
model: cached.modelStep1,
|
|
175
|
+
routerDecision,
|
|
176
|
+
perf: { totalMs, routerMs: routerDecision.latencyMs },
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// 萃取標籤(skipCache 模式下仍萃取,以便存快取)
|
|
183
|
+
const tags = routerDecision?.tags || await router.extractTags(question);
|
|
184
|
+
|
|
185
|
+
// ── Phase 2:單次大模型查詢(搜尋 + 分析合併) ─────────────────────────
|
|
186
|
+
const searchConfig = config.search || {};
|
|
187
|
+
|
|
188
|
+
const tLlm = performance.now();
|
|
189
|
+
const llm = await createLLM({
|
|
190
|
+
provider: searchConfig.provider || 'openrouter',
|
|
191
|
+
model: searchConfig.model || 'google/gemini-3-flash-preview',
|
|
192
|
+
webSearch: searchConfig.webSearch ?? true,
|
|
193
|
+
maxResults: searchConfig.maxResults || 10,
|
|
194
|
+
maxTokens: searchConfig.maxTokens || 4096,
|
|
195
|
+
reasoningEffort: searchConfig.reasoningEffort || 'medium',
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
const llmResult = await llm.generateContent({
|
|
199
|
+
systemInstruction: LEGAL_SYSTEM,
|
|
200
|
+
prompt: `使用者問題:${question}`,
|
|
201
|
+
});
|
|
202
|
+
const llmMs = Math.round(performance.now() - tLlm);
|
|
203
|
+
|
|
204
|
+
// 解析結構化回應
|
|
205
|
+
const parsed = parseResponse(llmResult.text);
|
|
206
|
+
let laws = [];
|
|
207
|
+
let answerText = '';
|
|
208
|
+
|
|
209
|
+
if (parsed) {
|
|
210
|
+
laws = Array.isArray(parsed.laws) ? parsed.laws : [];
|
|
211
|
+
answerText = answerToText(parsed.answer);
|
|
212
|
+
} else {
|
|
213
|
+
// 解析失敗:整段當作純文字回答
|
|
214
|
+
answerText = llmResult.text;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// 整合引證來源
|
|
218
|
+
const sources = [
|
|
219
|
+
...llmResult.sources,
|
|
220
|
+
...laws.filter(l => l.source_url).map(l => ({ uri: l.source_url, title: `${l.law_name} ${l.article}` })),
|
|
221
|
+
];
|
|
222
|
+
|
|
223
|
+
const totalMs = Math.round(performance.now() - t0);
|
|
224
|
+
|
|
225
|
+
// ── Phase 3:儲存到快取 ──────────────────────────────────────────────────
|
|
226
|
+
let newCacheId = null;
|
|
227
|
+
if (!skipSave) {
|
|
228
|
+
newCacheId = cache.insertQa({
|
|
229
|
+
question,
|
|
230
|
+
tags,
|
|
231
|
+
step1Laws: JSON.stringify(laws),
|
|
232
|
+
step2Answer: answerText,
|
|
233
|
+
sources,
|
|
234
|
+
modelStep1: llmResult.model,
|
|
235
|
+
modelStep2: llmResult.model,
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
cache.close();
|
|
240
|
+
|
|
241
|
+
return {
|
|
242
|
+
question,
|
|
243
|
+
fromCache: false,
|
|
244
|
+
cacheId: newCacheId,
|
|
245
|
+
tags,
|
|
246
|
+
step1Laws: laws,
|
|
247
|
+
step2Answer: answerText,
|
|
248
|
+
sources,
|
|
249
|
+
model: llmResult.model,
|
|
250
|
+
routerDecision,
|
|
251
|
+
perf: {
|
|
252
|
+
totalMs,
|
|
253
|
+
routerMs: routerDecision?.latencyMs || 0,
|
|
254
|
+
llmMs,
|
|
255
|
+
},
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* 格式化 LegalQueryResult 為純文字輸出
|
|
261
|
+
* @param {LegalQueryResult} result
|
|
262
|
+
* @returns {string}
|
|
263
|
+
*/
|
|
264
|
+
export function formatLegalResult(result) {
|
|
265
|
+
const lines = [];
|
|
266
|
+
|
|
267
|
+
if (result.fromCache) {
|
|
268
|
+
lines.push(`✓ 快取命中(ID: ${result.cacheId},已被使用 ${result.hitCount ?? '?'} 次)`);
|
|
269
|
+
lines.push(` 標籤:${result.tags.join('、')}`);
|
|
270
|
+
lines.push('');
|
|
271
|
+
} else {
|
|
272
|
+
lines.push(`→ 單次大模型查詢(標籤:${result.tags.join('、')})`);
|
|
273
|
+
if (result.cacheId) lines.push(` 已存入快取 ID: ${result.cacheId}`);
|
|
274
|
+
lines.push('');
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
lines.push('── 法律回答 ─────────────────────────────────────────');
|
|
278
|
+
lines.push(result.step2Answer);
|
|
279
|
+
|
|
280
|
+
if (!result.fromCache && result.step1Laws?.length > 0) {
|
|
281
|
+
lines.push('');
|
|
282
|
+
lines.push('── 搜尋到的法條 ─────────────────────────────────────');
|
|
283
|
+
for (const law of result.step1Laws) {
|
|
284
|
+
lines.push(` 【${law.law_name} ${law.article}】`);
|
|
285
|
+
if (law.source_url) lines.push(` 來源:${law.source_url}`);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (result.sources?.length > 0) {
|
|
290
|
+
lines.push('');
|
|
291
|
+
lines.push('── 引證來源 ─────────────────────────────────────────');
|
|
292
|
+
for (const src of result.sources) {
|
|
293
|
+
lines.push(` [${src.title || src.uri}] ${src.uri}`);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
lines.push('');
|
|
298
|
+
lines.push('── 效能 ─────────────────────────────────────────────');
|
|
299
|
+
lines.push(` 總耗時:${result.perf.totalMs}ms`);
|
|
300
|
+
if (!result.fromCache) {
|
|
301
|
+
lines.push(` 路由耗時:${result.perf.routerMs}ms`);
|
|
302
|
+
lines.push(` 大模型(搜尋+分析):${result.perf.llmMs}ms`);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return lines.join('\n');
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* 格式化為 JSON 輸出
|
|
310
|
+
* @param {LegalQueryResult} result
|
|
311
|
+
* @returns {string} JSON 字串
|
|
312
|
+
*/
|
|
313
|
+
export function formatLegalResultJson(result) {
|
|
314
|
+
return JSON.stringify({
|
|
315
|
+
_meta: { command: 'legal ask', fromCache: result.fromCache },
|
|
316
|
+
question: result.question,
|
|
317
|
+
answer: result.step2Answer,
|
|
318
|
+
laws: result.step1Laws,
|
|
319
|
+
sources: result.sources,
|
|
320
|
+
tags: result.tags,
|
|
321
|
+
cacheId: result.cacheId,
|
|
322
|
+
routerDecision: result.routerDecision,
|
|
323
|
+
perf: result.perf,
|
|
324
|
+
}, null, 2);
|
|
325
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// lib/flows/openai-agent.mjs — openai SDK 系列代理(NCHC 直連)
|
|
2
|
+
//
|
|
3
|
+
// 使用 `openai` npm 套件(OpenAI 相容介面)直連 NCHC GenAI 端點。
|
|
4
|
+
// 包含兩個代理類別:
|
|
5
|
+
//
|
|
6
|
+
// NchcAgent — 通用代理(唯讀工具:read_file, list_files, grep_content)
|
|
7
|
+
// NchcCodeAgent — Coding 代理(繼承 NchcAgent + 寫入工具:write_file, edit_file,
|
|
8
|
+
// run_command, create_dir),支援 dryRun 模式
|
|
9
|
+
//
|
|
10
|
+
// 對比:opencode-agent.mjs 使用 @opencode-ai/sdk,需要 opencode server
|
|
11
|
+
// 本檔不需要任何外部 server,直接由 Node.js 呼叫 NCHC API
|
|
12
|
+
//
|
|
13
|
+
// DDD 領域概念:
|
|
14
|
+
// AgentTask — 使用者交付的任務描述
|
|
15
|
+
// ToolDef — 工具定義(名稱、描述、JSON Schema)
|
|
16
|
+
// ToolCall — 代理發出的工具呼叫
|
|
17
|
+
// ToolResult — 工具執行結果
|
|
18
|
+
// AgentEvent — 事件流(tool_call / tool_result / text / done)
|
|
19
|
+
// WriteOp — 寫入操作(path + content,Value Object)
|
|
20
|
+
// EditOp — 精確替換操作(old_string 唯一性)
|
|
21
|
+
// CommandRun — shell 指令執行(含逾時與退出碼)
|
|
22
|
+
//
|
|
23
|
+
// SOLID:
|
|
24
|
+
// SRP — 代理只負責對話迴圈;工具定義與執行分開維護
|
|
25
|
+
// OCP — 工具透過 tools / extraTools 注入,不修改核心邏輯
|
|
26
|
+
// LSP — NchcCodeAgent IS-A NchcAgent,可互換使用
|
|
27
|
+
// DIP — _client / toolExecutor 可注入替換(測試友善)
|
|
28
|
+
|
|
29
|
+
import OpenAI from 'openai';
|
|
30
|
+
|
|
31
|
+
// ── NCHC 端點常數 ──────────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
const NCHC_BASE_URL = 'https://portal.genai.nchc.org.tw/api/v1';
|
|
34
|
+
export const NCHC_DEFAULT_MODEL = 'Mistral-Large-3-675B-Instruct-2512';
|
|
35
|
+
export const NCHC_MAX_AGENT_TURNS = 20;
|
|
36
|
+
|
|
37
|
+
// ── 工具 schema 從 lib/tools/ 動態載入(OCP:新增工具只需在 lib/tools/ 加檔案)──
|
|
38
|
+
|
|
39
|
+
export {
|
|
40
|
+
BUILT_IN_TOOLS,
|
|
41
|
+
CODING_TOOLS,
|
|
42
|
+
CODING_TOOL_NAMES,
|
|
43
|
+
CODING_SYSTEM_PROMPT,
|
|
44
|
+
executeTool,
|
|
45
|
+
executeCodeTool,
|
|
46
|
+
} from '../tools/fs-tools.mjs';
|
|
47
|
+
|
|
48
|
+
import {
|
|
49
|
+
BUILT_IN_TOOLS,
|
|
50
|
+
CODING_TOOLS,
|
|
51
|
+
CODING_TOOL_NAMES,
|
|
52
|
+
CODING_SYSTEM_PROMPT,
|
|
53
|
+
executeTool,
|
|
54
|
+
executeCodeTool,
|
|
55
|
+
} from '../tools/fs-tools.mjs';
|
|
56
|
+
|
|
57
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
58
|
+
// NchcAgent — 通用代理(唯讀工具)
|
|
59
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
60
|
+
|
|
61
|
+
export class NchcAgent {
|
|
62
|
+
/**
|
|
63
|
+
* @param {object} [opts]
|
|
64
|
+
* @param {string} [opts.apiKey] - NCHC_GENAI_API_KEY
|
|
65
|
+
* @param {string} [opts.model] - 模型名稱
|
|
66
|
+
* @param {object[]} [opts.tools] - 工具定義陣列(預設 BUILT_IN_TOOLS)
|
|
67
|
+
* @param {function} [opts.toolExecutor] - 工具執行函式(DIP)
|
|
68
|
+
* @param {string} [opts.systemPrompt] - 系統提示詞
|
|
69
|
+
* @param {number} [opts.maxTurns] - 最大迴圈次數
|
|
70
|
+
* @param {string} [opts.cwd] - 工作目錄
|
|
71
|
+
* @param {object} [opts._client] - 注入 OpenAI client(測試用)
|
|
72
|
+
*/
|
|
73
|
+
constructor(opts = {}) {
|
|
74
|
+
const apiKey = opts.apiKey || process.env.NCHC_GENAI_API_KEY;
|
|
75
|
+
if (!apiKey && !opts._client) {
|
|
76
|
+
throw new Error(
|
|
77
|
+
'NchcAgent: 需要 NCHC GenAI API Key。\n' +
|
|
78
|
+
'請設定環境變數 NCHC_GENAI_API_KEY 或傳入 apiKey 參數。'
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
this._client = opts._client || new OpenAI({ apiKey, baseURL: NCHC_BASE_URL });
|
|
82
|
+
this.model = opts.model || NCHC_DEFAULT_MODEL;
|
|
83
|
+
this.tools = opts.tools || BUILT_IN_TOOLS;
|
|
84
|
+
this.toolExecutor = opts.toolExecutor || executeTool;
|
|
85
|
+
this.systemPrompt = opts.systemPrompt ||
|
|
86
|
+
'你是一個智慧代理(Agentic AI),可以使用工具來協助完成任務。' +
|
|
87
|
+
'請使用繁體中文回應。在完成任務時,優先使用工具收集資訊,再給出完整答案。';
|
|
88
|
+
this.maxTurns = opts.maxTurns || NCHC_MAX_AGENT_TURNS;
|
|
89
|
+
this.cwd = opts.cwd || process.cwd();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* 執行任務(AsyncGenerator)
|
|
94
|
+
* @yields {{ type: 'tool_call'|'tool_result'|'text'|'done', ... }}
|
|
95
|
+
*/
|
|
96
|
+
async *run(task) {
|
|
97
|
+
const messages = [
|
|
98
|
+
{ role: 'system', content: this.systemPrompt },
|
|
99
|
+
{ role: 'user', content: task },
|
|
100
|
+
];
|
|
101
|
+
let turns = 0;
|
|
102
|
+
let finalText = '';
|
|
103
|
+
|
|
104
|
+
while (turns < this.maxTurns) {
|
|
105
|
+
turns++;
|
|
106
|
+
const response = await this._client.chat.completions.create({
|
|
107
|
+
model: this.model, messages, tools: this.tools, tool_choice: 'auto',
|
|
108
|
+
});
|
|
109
|
+
const choice = response.choices[0];
|
|
110
|
+
const assistantMsg = choice.message;
|
|
111
|
+
messages.push(assistantMsg);
|
|
112
|
+
|
|
113
|
+
if (choice.finish_reason !== 'tool_calls') {
|
|
114
|
+
finalText = assistantMsg.content || '';
|
|
115
|
+
yield { type: 'text', text: finalText };
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
for (const tc of (assistantMsg.tool_calls || [])) {
|
|
120
|
+
const name = tc.function.name;
|
|
121
|
+
let args;
|
|
122
|
+
try { args = JSON.parse(tc.function.arguments); } catch { args = {}; }
|
|
123
|
+
|
|
124
|
+
yield { type: 'tool_call', name, args, callId: tc.id };
|
|
125
|
+
const result = await this.toolExecutor(name, args, this.cwd);
|
|
126
|
+
yield { type: 'tool_result', name, result, callId: tc.id };
|
|
127
|
+
messages.push({ role: 'tool', tool_call_id: tc.id, content: result });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (turns >= this.maxTurns) {
|
|
132
|
+
finalText = `[已達最大迴圈次數 ${this.maxTurns},任務中止]`;
|
|
133
|
+
yield { type: 'text', text: finalText };
|
|
134
|
+
}
|
|
135
|
+
yield { type: 'done', turns, text: finalText };
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
140
|
+
// NchcCodeAgent — Coding 代理(IS-A NchcAgent + 寫入工具)
|
|
141
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
142
|
+
|
|
143
|
+
export class NchcCodeAgent extends NchcAgent {
|
|
144
|
+
/**
|
|
145
|
+
* @param {object} [opts]
|
|
146
|
+
* @param {boolean} [opts.dryRun=false] - true 時不實際寫入/執行
|
|
147
|
+
* @param {object[]} [opts.extraTools] - 額外自訂工具(OCP 注入)
|
|
148
|
+
* @param {function} [opts.toolExecutor] - 工具執行注入(測試用,DIP)
|
|
149
|
+
* @param {string} [opts.systemPrompt] - 覆蓋預設系統提示詞
|
|
150
|
+
* (其餘同 NchcAgent)
|
|
151
|
+
*/
|
|
152
|
+
constructor(opts = {}) {
|
|
153
|
+
const dryRun = opts.dryRun || false;
|
|
154
|
+
const combinedExecutor = async (name, args, toolCwd) =>
|
|
155
|
+
CODING_TOOL_NAMES.has(name)
|
|
156
|
+
? executeCodeTool(name, args, toolCwd, dryRun)
|
|
157
|
+
: executeTool(name, args, toolCwd);
|
|
158
|
+
|
|
159
|
+
super({
|
|
160
|
+
...opts,
|
|
161
|
+
tools: [...BUILT_IN_TOOLS, ...CODING_TOOLS, ...(opts.extraTools || [])],
|
|
162
|
+
toolExecutor: opts.toolExecutor || combinedExecutor,
|
|
163
|
+
systemPrompt: opts.systemPrompt || CODING_SYSTEM_PROMPT,
|
|
164
|
+
});
|
|
165
|
+
this.dryRun = dryRun;
|
|
166
|
+
}
|
|
167
|
+
}
|