@rex0220/llm-task-router 0.1.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.
@@ -0,0 +1,1069 @@
1
+ # 薄い ModelRouter 設計書
2
+
3
+ 作成日: 2026-06-16
4
+ 目的: Qiita記事作成などの個人・小規模用途で、複数の生成AIを安全に使い分けるための最小限のModelRouterを設計する。
5
+
6
+ ---
7
+
8
+ ## 1. 背景
9
+
10
+ ChatGPT、Claude、Geminiなどの生成AIには、Web版の時間制限、週制限、混雑、モデルごとの得意不得意がある。
11
+ また、LiteLLMのような高機能プロキシは便利だが、APIキーやプロンプトを集約するため、セキュリティ上の攻撃面が広がる。
12
+
13
+ そこで、Qiita記事作成や技術記事作成に必要な範囲に限定した、薄いModelRouterを自作する。
14
+
15
+ ---
16
+
17
+ ## 2. 設計方針
18
+
19
+ ### 2.1 やること
20
+
21
+ - タスク別のモデル選択
22
+ - 複数AIへのフォールバック
23
+ - リトライ
24
+ - タイムアウト
25
+ - 途中成果物の保存
26
+ - 出力スキーマ検証
27
+ - 最小限の使用ログ
28
+ - コスト概算
29
+ - APIキーの分離管理
30
+
31
+ ### 2.2 やらないこと
32
+
33
+ - Web管理画面
34
+ - 外部公開API
35
+ - ユーザー管理
36
+ - 動的な設定変更API
37
+ - 任意コード実行
38
+ - 任意URLアクセス
39
+ - プラグイン実行
40
+ - DBへの全文プロンプト保存
41
+ - 複雑な認証基盤
42
+
43
+ 薄く作ることで、セキュリティリスクと保守コストを抑える。
44
+
45
+ ---
46
+
47
+ ## 3. 想定ユースケース
48
+
49
+ 主な用途はQiita記事作成。
50
+
51
+ ```text
52
+ 記事テーマ
53
+
54
+ Article Brief作成
55
+
56
+ 構成案作成
57
+
58
+ コード例作成
59
+
60
+ Qiita向けMarkdown生成
61
+
62
+ 技術レビュー
63
+
64
+ リライト
65
+
66
+ final.md出力
67
+ ```
68
+
69
+ ---
70
+
71
+ ## 4. 全体アーキテクチャ
72
+
73
+ ```text
74
+ Article Workflow
75
+
76
+ ModelRouter
77
+
78
+ Provider
79
+ ├─ OpenAIProvider
80
+ ├─ AnthropicProvider
81
+ ├─ GeminiProvider
82
+ └─ LocalProvider
83
+
84
+ SchemaValidator
85
+
86
+ RunStore
87
+
88
+ Markdown / JSON成果物
89
+ ```
90
+
91
+ ---
92
+
93
+ ## 5. 推奨技術スタック
94
+
95
+ ### 5.1 言語
96
+
97
+ TypeScriptを推奨する。
98
+
99
+ 理由:
100
+
101
+ - JSON / YAML / Zodとの相性が良い
102
+ - CLIツール化しやすい
103
+ - Codex / Claude Code のどちらでも扱いやすい
104
+ - 将来的にWeb UIへ拡張しやすい
105
+ - Qiita記事生成やMarkdown処理と相性が良い
106
+
107
+ ### 5.2 主なライブラリ候補
108
+
109
+ | 用途 | 候補 |
110
+ |---|---|
111
+ | スキーマ検証 | Zod |
112
+ | YAML読み込み | yaml |
113
+ | CLI | commander / cac |
114
+ | ファイル操作 | Node.js fs/promises |
115
+ | ログ | pino または独自JSONログ |
116
+ | OpenAI API | openai |
117
+ | Anthropic API | @anthropic-ai/sdk |
118
+ | Gemini API | @google/genai |
119
+ | ローカルLLM | Ollama APIなど |
120
+
121
+ ---
122
+
123
+ ## 6. ディレクトリ構成
124
+
125
+ ```text
126
+ model-router/
127
+ package.json
128
+ tsconfig.json
129
+ .env.example
130
+ config/
131
+ models.yaml
132
+ src/
133
+ index.ts
134
+ router/
135
+ ModelRouter.ts
136
+ config.ts
137
+ errors.ts
138
+ types.ts
139
+ providers/
140
+ ModelProvider.ts
141
+ OpenAIProvider.ts
142
+ AnthropicProvider.ts
143
+ GeminiProvider.ts
144
+ LocalProvider.ts
145
+ schemas/
146
+ ArticleBriefSchema.ts
147
+ ArticleOutlineSchema.ts
148
+ ReviewResultSchema.ts
149
+ workflows/
150
+ createQiitaArticle.ts
151
+ resumeQiitaArticle.ts
152
+ storage/
153
+ RunStore.ts
154
+ logger/
155
+ RunLogger.ts
156
+ utils/
157
+ cost.ts
158
+ hash.ts
159
+ json.ts
160
+ timeout.ts
161
+ runs/
162
+ .gitkeep
163
+ ```
164
+
165
+ ---
166
+
167
+ ## 7. タスク定義
168
+
169
+ ```ts
170
+ export type ModelTask =
171
+ | "article_brief"
172
+ | "outline"
173
+ | "draft_markdown"
174
+ | "technical_review"
175
+ | "rewrite"
176
+ | "markdown_format"
177
+ | "title_suggestions";
178
+ ```
179
+
180
+ タスクを分けることで、モデルごとの得意不得意に応じてルーティングできる。
181
+
182
+ ---
183
+
184
+ ## 8. 共通リクエスト型
185
+
186
+ ```ts
187
+ export type ModelRequest = {
188
+ task: ModelTask;
189
+ input: string;
190
+ system?: string;
191
+ schemaName?: string;
192
+ maxTokens?: number;
193
+ temperature?: number;
194
+ };
195
+ ```
196
+
197
+ MVPでは `priority` は実装しない。
198
+ 品質・コスト・速度の切り替えが必要になった時点で、`models.yaml` 側に候補グループを追加してから型へ戻す。
199
+
200
+ ---
201
+
202
+ ## 9. 共通レスポンス型
203
+
204
+ ```ts
205
+ export type ModelResponse = {
206
+ provider: string;
207
+ model: string;
208
+ text: string;
209
+ usage?: {
210
+ inputTokens?: number;
211
+ outputTokens?: number;
212
+ costUsd?: number;
213
+ };
214
+ elapsedMs: number;
215
+ };
216
+ ```
217
+
218
+ ---
219
+
220
+ ## 10. Providerインターフェース
221
+
222
+ ```ts
223
+ export type ProviderRequest = {
224
+ model: string;
225
+ system?: string;
226
+ input: string;
227
+ temperature?: number;
228
+ maxTokens?: number;
229
+ timeoutMs?: number;
230
+ abortSignal?: AbortSignal;
231
+ responseFormat?: {
232
+ type: "text" | "json_schema";
233
+ schemaName?: string;
234
+ jsonSchema?: unknown;
235
+ };
236
+ };
237
+
238
+ export type ProviderResponse = {
239
+ text: string;
240
+ usage?: {
241
+ inputTokens?: number;
242
+ outputTokens?: number;
243
+ costUsd?: number;
244
+ };
245
+ };
246
+
247
+ export interface ModelProvider {
248
+ generate(request: ProviderRequest): Promise<ProviderResponse>;
249
+ }
250
+ ```
251
+
252
+ ModelRouterは各AIのAPI仕様を直接知らない。
253
+ OpenAI、Claude、Geminiなどの差分はProvider側に閉じ込める。
254
+
255
+ ProviderはSDK固有の例外も正規化する。
256
+ RouterはOpenAI / Anthropicなどの例外型や生レスポンスを直接扱わず、`RouterErrorKind` のような安全な分類だけを見てフォールバック可否を判断する。
257
+
258
+ ```ts
259
+ export type RouterErrorKind =
260
+ | "rate_limit"
261
+ | "timeout"
262
+ | "overloaded"
263
+ | "service_unavailable"
264
+ | "connection"
265
+ | "auth"
266
+ | "billing_quota"
267
+ | "context_length"
268
+ | "schema_validation"
269
+ | "bad_request"
270
+ | "config"
271
+ | "unknown";
272
+
273
+ export class RouterError extends Error {
274
+ constructor(
275
+ message: string,
276
+ public readonly kind: RouterErrorKind,
277
+ public readonly statusCode?: number
278
+ ) {
279
+ super(message);
280
+ }
281
+ }
282
+ ```
283
+
284
+ 一部Providerやモデルでは `temperature`、`top_p`、`top_k` などの推論パラメータを受け付けない場合がある。
285
+ Provider側はモデル仕様に応じて未対応パラメータを送らない、または設定エラーとして明示的に扱う。
286
+
287
+ Anthropic APIのように `max_tokens` が必須のProviderでは、`maxTokens` 未指定時にProvider側の安全なデフォルト値を使う。
288
+
289
+ `responseFormat` は将来的にOpenAI / Anthropicなどの構造化出力を使うための拡張点である。MVPではプロンプト + JSON parse + Zod検証でもよいが、Providerインターフェース上は構造化出力へ寄せられる余地を残す。
290
+
291
+ ---
292
+
293
+ ## 11. models.yaml 設定例
294
+
295
+ ```yaml
296
+ providers:
297
+ openai:
298
+ api_key_env: OPENAI_API_KEY_ARTICLE
299
+ anthropic:
300
+ api_key_env: ANTHROPIC_API_KEY_ARTICLE
301
+
302
+ prices:
303
+ openai:
304
+ gpt-5.5:
305
+ input_usd_per_1m_tokens: 0
306
+ output_usd_per_1m_tokens: 0
307
+ anthropic:
308
+ claude-opus:
309
+ input_usd_per_1m_tokens: 0
310
+ output_usd_per_1m_tokens: 0
311
+
312
+ defaults:
313
+ timeout_ms: 120000
314
+
315
+ tasks:
316
+ article_brief:
317
+ primary:
318
+ provider: openai
319
+ model: gpt-5.5
320
+ fallback:
321
+ - provider: anthropic
322
+ model: claude-opus
323
+ - provider: gemini
324
+ model: gemini-pro
325
+ temperature: 0.4
326
+ max_tokens: 4000
327
+
328
+ outline:
329
+ primary:
330
+ provider: anthropic
331
+ model: claude-opus
332
+ fallback:
333
+ - provider: openai
334
+ model: gpt-5.5
335
+ - provider: gemini
336
+ model: gemini-pro
337
+ temperature: 0.4
338
+ max_tokens: 4000
339
+
340
+ draft_markdown:
341
+ primary:
342
+ provider: openai
343
+ model: gpt-5.5
344
+ fallback:
345
+ - provider: anthropic
346
+ model: claude-sonnet
347
+ - provider: local
348
+ model: qwen-local
349
+ temperature: 0.6
350
+ max_tokens: 12000
351
+ timeout_ms: 180000
352
+
353
+ technical_review:
354
+ primary:
355
+ provider: anthropic
356
+ model: claude-opus
357
+ fallback:
358
+ - provider: openai
359
+ model: gpt-5.5
360
+ - provider: gemini
361
+ model: gemini-pro
362
+ temperature: 0.2
363
+ max_tokens: 6000
364
+
365
+ markdown_format:
366
+ primary:
367
+ provider: local
368
+ model: qwen-local
369
+ fallback:
370
+ - provider: openai
371
+ model: mini
372
+ temperature: 0.2
373
+ max_tokens: 8000
374
+ ```
375
+
376
+ 実際のモデル名は利用時点のAPIで確認して設定する。
377
+ `prices` はコスト概算用の任意設定であり、未設定または `0` の場合は `costUsd` を出さないか、ベストエフォートの概算として扱う。
378
+ 単価は頻繁に変わるため、コードに固定せず設定ファイル側で管理する。
379
+
380
+ `providers.*.api_key_env` が指定されている場合はそのenv名を優先する。
381
+ 未指定時は `OPENAI_API_KEY`、`ANTHROPIC_API_KEY`、`GEMINI_API_KEY` のようなProvider標準名を使う。
382
+
383
+ ---
384
+
385
+ ## 12. ModelRouterの中核処理
386
+
387
+ ```ts
388
+ export class ModelRouter {
389
+ constructor(
390
+ private providers: Record<string, ModelProvider>,
391
+ private config: RouterConfig,
392
+ private logger: RunLogger
393
+ ) {}
394
+
395
+ async run(request: ModelRequest): Promise<ModelResponse> {
396
+ const taskConfig = this.config.tasks[request.task];
397
+
398
+ const candidates = [
399
+ taskConfig.primary,
400
+ ...(taskConfig.fallback ?? []),
401
+ ];
402
+
403
+ let lastError: unknown;
404
+
405
+ for (const candidate of candidates) {
406
+ const provider = this.providers[candidate.provider];
407
+
408
+ if (!provider) {
409
+ continue;
410
+ }
411
+
412
+ try {
413
+ const startedAt = Date.now();
414
+
415
+ const response = await provider.generate({
416
+ model: candidate.model,
417
+ input: request.input,
418
+ system: request.system,
419
+ temperature: request.temperature ?? taskConfig.temperature,
420
+ maxTokens: request.maxTokens ?? taskConfig.max_tokens,
421
+ timeoutMs: taskConfig.timeout_ms ?? this.config.defaults.timeout_ms,
422
+ responseFormat: resolveResponseFormat(request.schemaName),
423
+ });
424
+
425
+ const result: ModelResponse = {
426
+ provider: candidate.provider,
427
+ model: candidate.model,
428
+ text: response.text,
429
+ usage: response.usage,
430
+ elapsedMs: Date.now() - startedAt,
431
+ };
432
+
433
+ const validated = await this.validateAndMaybeRepair(
434
+ request,
435
+ candidate,
436
+ result
437
+ );
438
+
439
+ await this.logger.logSuccess(request, validated);
440
+ return validated;
441
+ } catch (error) {
442
+ const normalized = normalizeProviderError(error);
443
+ lastError = normalized;
444
+ await this.logger.logFailure(request, candidate, normalized);
445
+
446
+ if (!this.shouldFallback(normalized.kind)) {
447
+ throw normalized;
448
+ }
449
+ }
450
+ }
451
+
452
+ throw new Error(`All model candidates failed: ${String(lastError)}`);
453
+ }
454
+
455
+ private shouldFallback(kind: RouterErrorKind): boolean {
456
+ return [
457
+ "rate_limit",
458
+ "timeout",
459
+ "overloaded",
460
+ "service_unavailable",
461
+ "connection",
462
+ "schema_validation",
463
+ ].includes(kind);
464
+ }
465
+ }
466
+ ```
467
+
468
+ `normalizeProviderError()` は文字列マッチではなく、Provider側でSDKの型付き例外、HTTP status、エラーコードを見て分類する。
469
+ ログには正規化済みの `kind`、安全な短い `message`、status code程度だけを保存し、生のSDK例外やヘッダを丸ごと保存しない。
470
+
471
+ SDK内リトライとRouterのフォールバックは役割を分ける。
472
+ MVPではSDKの標準リトライをProvider内リトライ、候補モデルの切り替えをRouterの責務とする。必要ならProvider初期化時にSDKリトライ回数を明示する。
473
+
474
+ ---
475
+
476
+ ## 13. フォールバック方針
477
+
478
+ ### 13.1 フォールバックするエラー
479
+
480
+ - rate limit
481
+ - timeout
482
+ - overloaded
483
+ - 5xx
484
+ - temporary unavailable
485
+ - API connection error
486
+ - 出力JSONのparse / Zod検証失敗後、同一candidateでの修復も失敗した場合
487
+
488
+ ### 13.2 フォールバックしないエラー
489
+
490
+ - APIキー未設定
491
+ - APIキー不正
492
+ - 認証エラー
493
+ - 入力が長すぎる
494
+ - schemaName未定義・不正などの設定ミス
495
+ - 禁止された内容
496
+ - 課金枠不足
497
+ - 料金上限超過
498
+ - 設定ミス
499
+
500
+ フォールバックしないエラーまで別モデルに投げると、無駄な課金や意図しない情報送信が起きる。
501
+
502
+ `quota` という文字列だけでは判定しない。
503
+ rate limitのような一時的な制限はフォールバック対象だが、課金枠不足、支払い上限、プロジェクト上限などのbilling quota系エラーはフォールバックしない。
504
+
505
+ スキーマ系エラーは2種類に分ける。
506
+ `schemaName` が未定義・不正など、設定側が間違っている場合は `config` として即終了し、フォールバックしない。AIの出力がJSON parseまたはZod検証に失敗し、同一candidateでの修復1回にも失敗した場合は `schema_validation` として次candidateへ進む。
507
+
508
+ ---
509
+
510
+ ## 14. Qiita記事作成ワークフロー
511
+
512
+ ```ts
513
+ async function createQiitaArticle(topic: string) {
514
+ const brief = await router.run({
515
+ task: "article_brief",
516
+ input: `
517
+ 次のテーマでQiita記事のArticle Briefを作成してください。
518
+
519
+ テーマ:
520
+ ${topic}
521
+
522
+ 出力はJSON形式。
523
+ `,
524
+ schemaName: "ArticleBrief",
525
+ });
526
+
527
+ await store.save("brief.json", brief.text);
528
+
529
+ const outline = await router.run({
530
+ task: "outline",
531
+ input: `
532
+ 次のArticle BriefからQiita記事の構成を作ってください。
533
+
534
+ ${brief.text}
535
+ `,
536
+ schemaName: "ArticleOutline",
537
+ });
538
+
539
+ await store.save("outline.json", outline.text);
540
+
541
+ const draft = await router.run({
542
+ task: "draft_markdown",
543
+ input: `
544
+ 次の構成からQiita向けMarkdown本文を書いてください。
545
+
546
+ ${outline.text}
547
+ `,
548
+ });
549
+
550
+ await store.save("draft.md", draft.text);
551
+
552
+ const review = await router.run({
553
+ task: "technical_review",
554
+ input: `
555
+ 次のQiita記事を技術レビューしてください。
556
+ 問題点、改善案、修正すべき箇所をJSONで返してください。
557
+
558
+ ${draft.text}
559
+ `,
560
+ schemaName: "ReviewResult",
561
+ });
562
+
563
+ await store.save("review.json", review.text);
564
+
565
+ const final = await router.run({
566
+ task: "rewrite",
567
+ input: `
568
+ 次のレビューを反映して、Qiita記事を改善してください。
569
+
570
+ 記事:
571
+ ${draft.text}
572
+
573
+ レビュー:
574
+ ${review.text}
575
+ `,
576
+ });
577
+
578
+ await store.save("final.md", final.text);
579
+
580
+ return final.text;
581
+ }
582
+ ```
583
+
584
+ ---
585
+
586
+ ## 15. 状態保存
587
+
588
+ 制限やエラーで途中停止しても再開できるように、各工程の成果物を保存する。
589
+
590
+ ```json
591
+ {
592
+ "runId": "2026-06-16-ai-ir-article",
593
+ "topic": "AI向けIRを設計する",
594
+ "steps": {
595
+ "brief": {
596
+ "status": "done",
597
+ "file": "brief.json"
598
+ },
599
+ "outline": {
600
+ "status": "done",
601
+ "file": "outline.json"
602
+ },
603
+ "draft": {
604
+ "status": "done",
605
+ "file": "draft.md"
606
+ },
607
+ "review": {
608
+ "status": "pending"
609
+ }
610
+ }
611
+ }
612
+ ```
613
+
614
+ 再開コマンド例:
615
+
616
+ ```bash
617
+ npm run article:resume -- --run 2026-06-16-ai-ir-article
618
+ ```
619
+
620
+ ---
621
+
622
+ ## 16. 出力スキーマ検証
623
+
624
+ Zodで中間成果物を検証する。
625
+ `schemaName` が指定された場合の検証責務は `ModelRouter.run()` に置く。
626
+ ワークフロー層は検証済みの `ModelResponse.text` だけを保存し、未検証JSONを次工程へ渡さない。
627
+
628
+ ```ts
629
+ import { z } from "zod";
630
+
631
+ export const ArticleBriefSchema = z.object({
632
+ title: z.string(),
633
+ targetReaders: z.array(z.string()),
634
+ goal: z.array(z.string()),
635
+ mainClaim: z.string(),
636
+ sections: z.array(
637
+ z.object({
638
+ heading: z.string(),
639
+ points: z.array(z.string()),
640
+ })
641
+ ),
642
+ codeExamples: z.array(
643
+ z.object({
644
+ language: z.string(),
645
+ purpose: z.string(),
646
+ })
647
+ ),
648
+ });
649
+ ```
650
+
651
+ 処理方針:
652
+
653
+ ```text
654
+ AI出力
655
+
656
+ JSON parse
657
+
658
+ Zod検証
659
+ ├─ OK → 次の工程
660
+ └─ NG → 同一candidateへ修復依頼(最大1回)
661
+ ├─ OK → 次の工程
662
+ └─ NG → fallback model
663
+ ```
664
+
665
+ 修復依頼は無制限に繰り返さない。
666
+ MVPでは各candidateにつき最大1回までとし、修復失敗時は `schema_validation` として正規化する。次のcandidateへ進むかどうかは設定で制御してもよいが、初期実装では「同一candidateの修復1回、それでも失敗したらfallback候補へ1回ずつ進む」方針にする。
667
+
668
+ ここでいう `schema_validation` は「AI出力が期待スキーマに合わない」ことを指す。
669
+ `schemaName` がregistryに存在しない、schema定義自体が壊れている、といった設定ミスは `config` として扱い、修復依頼もフォールバックも行わない。
670
+
671
+ OpenAI / AnthropicなどProvider側でJSON schema形式の構造化出力を使える場合は、`ProviderRequest.responseFormat` にschema情報を渡す。
672
+ ただしMVPではProviderごとの差分を小さくするため、構造化出力は必須ではなく、プロンプト + JSON parse + Zod検証を基準実装とする。
673
+
674
+ ---
675
+
676
+ ## 17. ログ設計
677
+
678
+ ### 17.1 基本方針
679
+
680
+ ログには全文プロンプトを保存しない。
681
+ 成果物は `runs/` 配下に保存するが、ログはメタ情報中心にする。
682
+
683
+ ```json
684
+ {
685
+ "task": "draft_markdown",
686
+ "provider": "openai",
687
+ "model": "gpt-5.5",
688
+ "status": "success",
689
+ "input_hash": "sha256:xxxx",
690
+ "elapsed_ms": 12000,
691
+ "input_tokens": 5000,
692
+ "output_tokens": 3000,
693
+ "cost_usd": 0.12
694
+ }
695
+ ```
696
+
697
+ ### 17.2 保存しないもの
698
+
699
+ - APIキー
700
+ - `.env` の内容
701
+ - 認証トークン
702
+ - 外部サービスの秘密情報
703
+ - 機密性の高い入力本文
704
+
705
+ ---
706
+
707
+ ## 18. セキュリティ設計
708
+
709
+ ### 18.1 外部公開しない
710
+
711
+ MVPではCLIのみ。
712
+ HTTPサーバー化しない。
713
+ 管理画面を作らない。
714
+
715
+ ### 18.2 APIキー管理
716
+
717
+ `.env` に保存し、Git管理しない。
718
+
719
+ ```env
720
+ OPENAI_API_KEY=...
721
+ ANTHROPIC_API_KEY=...
722
+ GEMINI_API_KEY=...
723
+ ```
724
+
725
+ `.env.example` のみコミットする。
726
+
727
+ ### 18.3 権限分離
728
+
729
+ 可能であれば用途ごとにAPIキーを分ける。
730
+
731
+ ```env
732
+ OPENAI_API_KEY_ARTICLE=...
733
+ OPENAI_API_KEY_EXPERIMENT=...
734
+ ANTHROPIC_API_KEY_ARTICLE=...
735
+ ```
736
+
737
+ ### 18.4 任意コード実行を入れない
738
+
739
+ ModelRouter本体に任意シェル実行機能を入れない。
740
+ コード検証が必要な場合は、別プロセス・別サンドボックスで行う。
741
+
742
+ ### 18.5 外部URL取得を勝手にしない
743
+
744
+ 外部URL取得機能はMVPでは持たない。
745
+ 将来的に入れる場合は許可ドメイン制にする。
746
+
747
+ ---
748
+
749
+ ## 19. CLI仕様案
750
+
751
+ コマンドは Qiita 専用ではないため `article:*` を正式名とする。設定(`config/`)・`.env`・`runs/` はすべて**カレントディレクトリ相対**で解決する。
752
+
753
+ ### 19.0 init(設定雛形の展開)
754
+
755
+ グローバル導入時、作業ディレクトリに設定が無いと動かないため、同梱テンプレを展開する。
756
+
757
+ ```bash
758
+ llm-task-router init # config/ と .env.example を cwd へコピー
759
+ ```
760
+
761
+ - コピー対象: 同梱の `config/`(models.yaml・profiles/・criteria/)と `.env.example`。コピー元は bin 位置からパッケージroot相対に解決。
762
+ - 書き込み先は **cwd 配下の固定パス**(任意パスは受け取らない)。既存ファイルは `--force` 無しでは上書きしない。`.env` は生成しない(`.env.example` のみ)。
763
+ - 展開後の流れ: `cp .env.example .env` → キー設定 → `config/models.yaml` のモデルID調整 → `article:create`。
764
+
765
+ ### 19.1 新規記事作成
766
+
767
+ 短いテーマはインラインで指定する。
768
+
769
+ ```bash
770
+ npm run article:create -- --topic "AIが解釈しやすい中間言語を設計する"
771
+ ```
772
+
773
+ 長文の指示(対象読者・論点・制約など)はテキストファイルで渡す。
774
+
775
+ ```bash
776
+ npm run article:create -- --topic-file topics/ai-ir.txt
777
+ ```
778
+
779
+ - `--topic` と `--topic-file` のどちらかを指定する(両方指定時はエラー)。
780
+ - `--topic-file` のときの `runId` はファイル名ベースで生成する(例: `ai-ir.txt` → `2026-06-16-ai-ir`)。
781
+ - `--run <runId>` で `runId` を明示固定できる。
782
+ - `--profile <name>`(既定 `qiita`)で `config/profiles/<name>.yaml` のプロファイルを選ぶ。プロファイルは `platform`(プロンプトのラベル)、`style`(admonition記法・front-matter作法などをdraft/final/reviseの本文生成に注入)、`language` を持つ。同梱は `qiita` / `zenn` / `blog`。`--platform <name>` はラベルのみ上書き。解決した `platform` と `style` は `meta.json` に保存され、resume/review/revise/evaluate が自動継承する。
783
+ - プラットフォーム別の評価は対応する `--criteria-file` を併用する。プロファイルは「作法(コードを増やさず外部YAMLで管理)」、criteria は「評価観点」を担い、models.yaml(モデル選択)と合わせて設定3点で多目的化する。
784
+ - 段階的拡張:プラットフォーム固有の記法変換や `markdown_format` タスクの整形ステップ昇格は、必要になった時点で追加する(現状は未使用)。
785
+
786
+ ### 19.2 途中再開
787
+
788
+ ```bash
789
+ npm run article:resume -- --run 2026-06-16-ai-ir-article
790
+ ```
791
+
792
+ ### 19.3 レビューのみ再実行
793
+
794
+ draft.md から自動レビュー → rewrite をやり直す(利用者の指示は使わない)。
795
+
796
+ ```bash
797
+ npm run article:review -- --run 2026-06-16-ai-ir-article
798
+ ```
799
+
800
+ ### 19.4 final.md への修正指示
801
+
802
+ 現在の `final.md` に、利用者の自由な修正指示を反映して書き直す。
803
+
804
+ ```bash
805
+ npm run article:revise -- --run 2026-06-16-ai-ir-article --instruction "前半を簡潔に。専門用語は初出で1行説明"
806
+ ```
807
+
808
+ 長文の指示はファイルでも渡せる。
809
+
810
+ ```bash
811
+ npm run article:revise -- --run 2026-06-16-ai-ir-article --instruction-file work/revise.md
812
+ ```
813
+
814
+ - `--instruction` と `--instruction-file` のどちらか必須(両方指定時はエラー)。
815
+ - `rewrite` タスクで現在の `final.md` + 指示を処理し、`final.md` を上書きする。
816
+ - 上書き前の版は `final.bak.md` に退避する。繰り返し実行可能。
817
+
818
+ ### 19.5 final.md の評価と修正指示の生成
819
+
820
+ 現在の `final.md` を評価し、結果を `final-review.json` に保存する。評価で見つかった指摘から修正指示ファイル `revise-instruction.md` を生成する(自動でrewriteはしない)。
821
+
822
+ ```bash
823
+ npm run article:evaluate -- --run 2026-06-16-ai-ir-article --min-severity major
824
+ ```
825
+
826
+ 評価観点は run の profile から自動解決される(指定不要)。一回限り別観点で見たいときだけ明示する。
827
+
828
+ ```bash
829
+ # 観点は profile(meta.json)→ criteria_file から自動解決
830
+ npm run article:evaluate -- --run 2026-06-16-ai-ir-article
831
+ # 一回だけ別観点にしたいとき(インライン / ファイルで上書き)
832
+ npm run article:evaluate -- --run 2026-06-16-ai-ir-article --criteria "正確性とコード例の動作を重視"
833
+ npm run article:evaluate -- --run 2026-06-16-ai-ir-article --criteria-file config/criteria/note.md
834
+ ```
835
+
836
+ - 審査役は本文の書き手と**別系統のモデル**にする(`models.yaml` の `final_review` タスク。既定は anthropic 主審査、本文の `rewrite` は openai 主体)。
837
+ - `--min-severity`(`critical|major|minor|suggestion`、既定 `suggestion`=全件)で指示に含める指摘を絞る。
838
+ - 出力は `runs/<runId>/` 固定で3つ:`final-review.json`(生スコアカード)、`final-review.md`(人が読むサマリ=判定・severity別件数・全指摘)、`revise-instruction.md`(`--min-severity` で絞った修正指示)。
839
+ - `final-review.md` は severityフィルタを掛けず全指摘を含める(人の確認用)。`revise-instruction.md` のみフィルタ済み(rewriteへの入力用)。
840
+ - 評価結果からの指示生成は**ローカル整形で追加APIコール無し**(評価1回のみ課金)。
841
+ - 生成された `revise-instruction.md` は草案。人が確認・編集してから `article:revise --instruction-file` で適用する(自動適用はしない)。
842
+ - 指定 severity 以上の指摘が無い場合は `revise-instruction.md` を作らない。
843
+ - 評価観点は `config/criteria/*.md` に置き、profile の `criteria_file` で対象に紐づける。`evaluate` は run の profile(`meta.json`)から観点を**自動解決**する。解決順は `--criteria` > `--criteria-file` > profile の `criteria_file` > なし。共通デフォルトは `config/criteria/default.md`(qiita/zenn/blog)、note は読み物重視の `config/criteria/note.md`。観点を対象ごとに固定することで LLM-as-judge の揺れを抑え、評価を比較可能にする。
844
+
845
+ 連携フロー:
846
+
847
+ ```bash
848
+ npm run article:evaluate -- --run <runId> --min-severity major
849
+ # revise-instruction.md を確認・編集
850
+ npm run article:revise -- --run <runId> --instruction-file runs/<runId>/revise-instruction.md
851
+ ```
852
+
853
+ ### 19.6 最終記事のエクスポート
854
+
855
+ 完成した `final.md` を、指定したパスへ書き出す。
856
+
857
+ ```bash
858
+ npm run article:export -- --run <runId> --out ../zenn-content/articles/my-article.md
859
+ ```
860
+
861
+ - 対象は **`final.md` のみ**(中間成果物は出さない)。
862
+ - ガード: 出力先が `.env` 等の秘密ファイル名なら拒否、既存ファイルは `--force` 無しでは上書きしない、ワークスペース外は警告(拒否しない)。親ディレクトリは自動作成。
863
+ - これは「CLI引数から任意の書き込み先を受け取らない」原則の**明示的な例外**。内部成果物は `runs/<runId>/` に閉じたままで、ユーザーが明示した出力だけを許可する(モデル設定不要・API不要のファイル操作)。
864
+
865
+ ### 19.7 実行推移の表示
866
+
867
+ 全コマンドは工程の進捗を **stderr** に出力する。`runId` と `final` パスは **stdout** に出すため、スクリプトでの解析と進捗表示が混ざらない。
868
+
869
+ ```text
870
+ [1/5] brief (article_brief) ...
871
+ [1/5] brief - done via openai/gpt-5.4 (2310ms, ~$0.0123)
872
+ [2/5] outline (outline) ...
873
+ [2/5] outline - done via anthropic/claude-opus-4-8 (4120ms, ~$0.0456)
874
+ ...
875
+ total: ~$0.1240 (estimate)
876
+ ```
877
+
878
+ - 各工程の **使用provider/model・所要時間・概算コスト** を表示し、最後に **run合計** を出す。設定上のprimaryと異なるproviderが表示された場合はフォールバックが起きたと判断できる。
879
+ - `article:resume` / `article:review` では完了済み工程を `skip (done)` と表示する。
880
+ - 進捗は補助情報であり、機械処理が必要な値(`runId` など)は stdout 側に出す。
881
+
882
+ #### 出力ガード
883
+
884
+ 本文(スキーマ無し)工程には保存前の軽量ガードを設ける。
885
+
886
+ - **truncation検知**: Provider応答が `max_tokens` / `max_output_tokens` で打ち切られた場合、その工程に警告を表示する(`models.yaml` の `max_tokens` を増やす目安になる)。
887
+ - **全体コードフェンス除去**: モデルが本文全体を ` ``` ` で囲んで返した場合のみ、外側フェンスを剥がして保存する。文中の正当なコードブロックや、複数コードブロックを含む本文には手を加えない。
888
+ - **ラップ文検知(警告のみ)**: 本文が見出し(#)で始まらない(前置きの疑い)、または末尾が追加提案・問いかけ(『…で出し直せます』等)になっている場合に警告する。自然文は正当な導入/結論と区別しにくいため**自動削除はせず**、修正は人に委ねる(プロンプト硬化が主防御、本ガードは回帰検知の安全網)。
889
+ - スキーマ工程(`brief`/`outline`/`review`)は検証済みJSONを保存するためガード対象外。
890
+
891
+ #### コスト概算について
892
+
893
+ - コストはレスポンス同梱の `usage`(トークン数)× `models.yaml` の `prices`(USD/1Mトークン)による**ローカル概算**で、表示のための追加API(count_tokens等)は呼ばない。
894
+ - `prices` 未設定または `0` のモデルはコストを出さない(`total` も加算されない)。
895
+ - 単価は価格改定でドリフトするため設定ファイルで管理する。プロンプトキャッシュ等の割引は概算に含めない。
896
+ - 修復(schema検証失敗時の再生成)が走った場合、検証失敗した初回コール分のトークンは概算に含まれない(やや過小評価)。
897
+
898
+ ---
899
+
900
+ ## 20. MVPスコープ
901
+
902
+ ### 20.1 実装する
903
+
904
+ - TypeScript CLI
905
+ - models.yaml読み込み
906
+ - OpenAIProvider
907
+ - AnthropicProvider
908
+ - タスク別モデル選択
909
+ - フォールバック
910
+ - Zod検証
911
+ - ファイル保存
912
+ - JSONメタログ
913
+ - Qiita記事作成ワークフロー
914
+
915
+ ### 20.2 後回し
916
+
917
+ - GeminiProvider
918
+ - LocalProvider
919
+ - Web UI
920
+ - Qiita API投稿
921
+ - GitHub連携
922
+ - LangGraph連携
923
+ - Dify連携
924
+ - 複雑なコスト最適化
925
+
926
+ ---
927
+
928
+ ## 21. Codex と Claude Code のどちらで実装するか
929
+
930
+ ### 21.1 結論
931
+
932
+ このMVPは **Codex を第一候補** にする。
933
+
934
+ 理由:
935
+
936
+ - TypeScriptのCLIプロジェクトを段階的に作る用途に合う
937
+ - 設計書からファイル構成、テスト、リファクタリングまで進めやすい
938
+ - OpenAI API連携やStructured Outputs周辺との相性が良い
939
+ - 今回の設計では「仕様通りに薄く作る」ことが重要で、Codexの明示的な実装・レビュー・修正の流れと相性が良い
940
+
941
+ Claude Code は第二候補として使う。
942
+
943
+ Claude Codeが向いている場面:
944
+
945
+ - 既存コードベースを広く読ませたい
946
+ - 長い設計書や仕様書を踏まえて改善させたい
947
+ - CLIで対話しながら一気に実装したい
948
+ - 文章、README、Qiita本文、設計意図の整理も同時に進めたい
949
+
950
+ ### 21.2 おすすめ分担
951
+
952
+ | 作業 | 推奨 |
953
+ |---|---|
954
+ | 初期プロジェクト作成 | Codex |
955
+ | 型定義・Provider実装 | Codex |
956
+ | テスト追加 | Codex |
957
+ | README整備 | Claude Code |
958
+ | 設計書レビュー | Claude Code |
959
+ | Qiita記事化 | Claude Code または GPT |
960
+ | セキュリティ観点レビュー | Claude Code + 人間レビュー |
961
+
962
+ ### 21.3 実務上の最適解
963
+
964
+ どちらか一方に固定しない。
965
+ 最初はCodexでMVPを作り、その後Claude Codeでレビュー・README・改善提案を行う。
966
+
967
+ ```text
968
+ Codex:
969
+ 仕様通りに実装する担当
970
+
971
+ Claude Code:
972
+ 仕様の穴を探す、説明を整える、改善案を出す担当
973
+ ```
974
+
975
+ ---
976
+
977
+ ## 22. Codex向け初回プロンプト例
978
+
979
+ ```text
980
+ このリポジトリに、TypeScript製の薄いModelRouter CLIを実装してください。
981
+
982
+ 目的:
983
+ - Qiita記事作成フローでOpenAI/Anthropicをタスク別に呼び分ける
984
+ - LiteLLMのような高機能プロキシにはしない
985
+ - 外部公開API、Web UI、任意コード実行は実装しない
986
+
987
+ 実装範囲:
988
+ - src/router/ModelRouter.ts
989
+ - src/router/config.ts
990
+ - src/router/errors.ts
991
+ - src/router/types.ts
992
+ - src/providers/ModelProvider.ts
993
+ - src/providers/OpenAIProvider.ts
994
+ - src/providers/AnthropicProvider.ts
995
+ - src/storage/RunStore.ts
996
+ - src/logger/RunLogger.ts
997
+ - src/workflows/createQiitaArticle.ts
998
+ - config/models.yaml
999
+ - .env.example
1000
+ - README.md
1001
+
1002
+ 要件:
1003
+ - models.yamlでタスク別primary/fallbackを定義
1004
+ - rate limit / timeout / overloaded / 一時的な5xx の場合のみfallback
1005
+ - 認証エラー、入力過大、schemaName不正、課金枠不足、設定ミスではfallbackしない
1006
+ - AI出力のJSON parse / Zod検証失敗は同一candidateで最大1回修復し、失敗したらschema_validationとして次candidateへ進める
1007
+ - SDK固有例外はProvider側で正規化し、Routerは文字列マッチでfallback判定しない
1008
+ - runs/<runId>/ に brief.json, outline.json, draft.md, review.json, final.md, meta.json を保存
1009
+ - ログにAPIキーや全文プロンプトを保存しない
1010
+ - ZodでArticleBriefとReviewResultを検証し、schemaName指定時はRouter内で最大1回だけ修復依頼する
1011
+ - npm scriptで article:create と article:resume を用意する
1012
+ - テストを追加する
1013
+ ```
1014
+
1015
+ ---
1016
+
1017
+ ## 23. Claude Code向けレビュー依頼例
1018
+
1019
+ ```text
1020
+ このModelRouter実装をレビューしてください。
1021
+
1022
+ 観点:
1023
+ - 設計書に対して過剰実装になっていないか
1024
+ - セキュリティ上危険な機能が入っていないか
1025
+ - APIキーやプロンプト本文がログに漏れないか
1026
+ - fallbackすべきでないエラーをfallbackしていないか
1027
+ - Qiita記事作成フローとして再開可能になっているか
1028
+ - READMEが利用者にとって十分か
1029
+ - TypeScriptの型設計が保守しやすいか
1030
+
1031
+ 出力:
1032
+ - 重大な問題
1033
+ - 改善した方がよい点
1034
+ - 後回しでよい点
1035
+ - 具体的な修正案
1036
+ ```
1037
+
1038
+ ---
1039
+
1040
+ ## 24. 今後の拡張候補
1041
+
1042
+ 1. GeminiProvider追加
1043
+ 2. LocalProvider追加
1044
+ 3. Ollama対応
1045
+ 4. GitHubへの成果物保存
1046
+ 5. Qiita API下書き投稿
1047
+ 6. 記事テンプレート切り替え
1048
+ 7. コスト上限
1049
+ 8. LangGraph連携
1050
+ 9. Dify連携
1051
+ 10. Web UI
1052
+
1053
+ ---
1054
+
1055
+ ## 25. まとめ
1056
+
1057
+ このModelRouterは、複数AIを扱うための最小限の制御層である。
1058
+
1059
+ ```text
1060
+ 薄いModelRouter
1061
+ = タスク別モデル選択
1062
+ + フォールバック
1063
+ + 成果物保存
1064
+ + スキーマ検証
1065
+ + 最小限ログ
1066
+ ```
1067
+
1068
+ MVPでは外部公開やWeb UIを避け、CLIとファイル保存に限定する。
1069
+ 実装はCodexを第一候補、Claude Codeをレビュー・ドキュメント改善担当として併用するのが最も現実的である。