Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 105 additions & 11 deletions docs/local-llm-offload-analysis.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
# ローカル LLM オフロード可能性調査

> **状態**: 試験運用 (本ドキュメントは「調査レポート」であり、提案が ADR 化または却下された時点で役割を終える)
> **状態**: 試験運用 (調査 → 提案 2 (cli-finding-classifier) は ADR-038 として land 済 / PR #119, 2026-05-06)。提案 1 (lint screen facet) と提案 3 (PR body draft) は未着手。フォローアップ作業の判断材料として保持。
>
> **引退条件**: 以下のいずれかを満たした時点で本ファイルを削除する。
> - ADR 化された (例: "ADR-038: ローカル LLM オフロード戦略") 場合 → ADR にエッセンスを移し、本ファイルを削除
> - 提案が却下された場合 → 却下理由を ADR または commit message に残し、本ファイルを削除
> - 6 ヶ月経過しても採否が決まらない場合 → 採用見込みなしとみなして削除
> **引退条件**: 以下のいずれかを満たした時点で本ファイルを削除する (docs-governance.md retirement workflow 準拠)
> - 提案 1〜3 すべてが land または却下された場合 → ADR-038 等にエッセンスを移し、本ファイルを削除
> - 提案 1 / 提案 3 が却下された場合 → 却下理由を本ファイルに記録した上で、ADR-038 関連の learnings を ADR-038 に migrate して本ファイルを削除
> - 6 ヶ月経過しても提案 1 / 3 の採否が決まらない場合 → 採用見込みなしとみなして削除
>
> **由来**: セッション ID `5ca01479-6d71-4328-91d0-861343120c3f` (2026-05-05〜2026-05-06, Bundle b 関連) の作業ログ分析

Expand Down Expand Up @@ -135,18 +135,112 @@ Claude Code のトークン消費・レートリミットを抑える目的で
- セキュリティ判定 (false negative が事故になる領域)
- 不確実性の解釈が必要な分析 (root cause, conflict resolution の戦略決定)

## 7. Next Steps
## 7. 実装進捗ログ

提案を採用する場合の推奨着手順序:
### 2026-05-06: 提案 2 (cli-finding-classifier) land — PR #119

1. **提案 2 (CodeRabbit findings 分類) から着手** — ROI 最大、scope が閉じている、効果実測しやすい
2. 効果が確認できれば **提案 1 (lint screen facet)** を追加
3. 最後に **提案 3 (PR body draft)** を導入
#### 完了範囲

却下する場合: 本ファイルを削除し、却下理由を commit message または専用 ADR に残す。
- **新 crate `lib-ollama-client`** (8 unit tests pass、ureq blocking + dyn-compatible trait + thiserror)
- **新 crate `cli-finding-classifier`** (10 lib + 6 bin + 1 後追い回帰テスト = 17 unit tests pass、stdin/stdout pipe 可能 CLI)
- **ADR-038 (試験運用)** 起草・land 済
- **package.json `build:all` 統合**、`.claude/cli-finding-classifier.exe` (2.2MB) 配置
- **PR #119**: feat → CR review 3 round → squash merge `9f368a25` (2026-05-06T14:17:29Z, master)

#### 実 Ollama dogfood (commit 前検証)

- 5 件サンプルを mistral:7b で classify
- JSON parse 100%、3.6s/件、全件妥当な classification (Critical state-bug → human_review、stale comment → auto_fix 等)
- `normalized_issue` が一部英語混じり (mistral:7b の Japanese 指示違反、実害小)

#### CR review 経過 (3 round)

| Round | Commit | Findings | 対応 |
|---|---|---|---|
| 1 | `e9a422e7` | Nitpick 3 件 (ADR Cons, fallback_reason, send_json) | 全 3 件適用、`6f12963c` で push |
| 2 | `6f12963c` | Actionable 3 件 (ADR ureq 統一, build_prompt 単一スキャン化, normalized_issue 検証) | A 手動、B/C は takt auto-fix 適用、`dac2e15c` で push |
| 3 | `dac2e15c` | Actionable 1 件 (Duplicate=C strict 化) + 新 1 件 (ADR `confidence` → `action_confidence` 名称統一) | 両方 `resolved:` reply で却下、merge へ |

#### 検証で見えた予期せぬ知見

- **prompt injection リスク**: `{issue}` / `{suggestion}` 連続 `.replace()` で再展開される問題を CR と takt pre-push reviewer が独立に指摘。CR round 2 を契機に single-pass scanner に書き換え (B fix)
- **OllamaError::Parse 誤マッピング bug**: `serde_json::to_value(&body)?` が**リクエスト構築失敗**を**レスポンス解析エラー**に分類していた問題。CR round 1 で発見、`send_json(&body)` 直接渡しで意味論修正
- **takt fix-trust shortcut (ADR-037) が機能**: round 2 push で convergence_verdict による Iter 3 短絡が動作、2 iter 7m54s で APPROVE
- **monitor edge case**: `review_state: "not_found"` (CR 未処理) → findings 空 → takt approved → park スキップで終了する経路がある (post-pr-monitor 改善 follow-up 候補)

### 効果実測の現状

未測定。次回 Claude Code session で:

1. cli-finding-classifier を post-pr-review フローに統合した後の token 消費を比較
2. CodeRabbit triage タスクが Claude を経由しなくなった分の体感計測

統合 (Phase 5) 完了までは「効果見込み 15-25%」は推定値のまま。

## 8. 次の作業候補 (Phase 5 + 残作業)

優先度順に列挙。各項目はそれぞれ独立 PR を想定。

### A. ★ Phase 5: cli-pr-monitor / takt facet への classifier 統合 (Tier 1)

- **目的**: 本 PR (#119) で land した classifier を実フローに乗せ、効果を実測する
- **作業**:
- cli-pr-monitor poll stage で `cli-finding-classifier.exe` を invoke (config-driven flag で ON/OFF)
- もしくは takt facet (例 `.takt/post-pr-review.yaml`) に classifier step 追加
- hooks-config.toml に section 追加 (default OFF、experimental flag)
- **依存**: なし (本 PR で前提が整っている)
- **見積**: 1〜2 日
- **ROI**: ★★★ (ADR-038 の試験運用 → 本採用 昇格条件 の 2 番目 = 「`cli-pr-monitor` または takt facet への統合」を満たす)
- **同時に検討**: prompt injection サニタイズ (auto_fix 経路ができたタイミングで実装、PR #119 の WARN ベース)

### B. Finding C strict 化 (Phase 5 と同時着手推奨)

- **目的**: PR #119 で却下した normalized_issue の改行/長さ check を、auto_fix 経路ができたタイミングで導入
- **作業**: `from_llm_output` で `s.lines().count() != 1 || s.chars().count() > 80` を弾いて `fallback("invalid normalized_issue from LLM")` に倒す + 回帰テスト 2 件追加
- **依存**: Phase 5 と同 PR か直前 PR で着手
- **見積**: 半日
- **ROI**: ★★ (LLM 出力契約の堅牢化、Phase 5 の自動修正経路で必須)

### C. Finding D: ADR-038 line 61 textual fix (low priority)

- **目的**: ADR-038 line 61 の `confidence=0.0` を `action_confidence=0.0` に統一 (実装の schema 名称と整合)
- **作業**: 1 単語修正
- **依存**: なし
- **見積**: 5 分 (他の Phase 5 PR に bundle 可)
- **ROI**: ★ (永続 doc の整合は重要だが単独 PR を立てるほどではない)

### D. プロンプト v2: `normalized_issue` 言語制約強化 (low priority)

- **目的**: dogfood で観測された「mistral:7b が日本語指示でも英語混じりで返す」問題を改善
- **作業**: `prompts/classify.txt` でより強い言語固定指示 + few-shot examples を追加
- **依存**: なし
- **見積**: 半日 (prompt 変更 + 簡易ベンチで安定性検証)
- **ROI**: ★ (実害は小、UX 微改善)

### E. 提案 1 (lint screen facet) — 実効果見極め後

- **目的**: takt の新 facet `ollama-lint-screen` で pre-push 時に diff の lint 一次フィルタを mistral:7b に逃す
- **依存**: Phase 5 で classifier の実効果が確認できた後
- **見積**: 1〜2 日
- **ROI**: 提案 1 として中程度。Phase 5 の効果次第で優先度が変動

### F. 提案 3 (PR body draft) — 提案 1 採用後

- **目的**: `prepare-pr` skill で `jj diff` 要約を mistral:7b で前処理し、Claude への入力 token を圧縮
- **依存**: 提案 1 が land して `lib-ollama-client` の運用知見が貯まった後
- **見積**: 半日
- **ROI**: input token 削減への寄与は最大ライン (cache 倍率の影響が大きい領域)

## 9. 過去判断のサマリ (引き継ぎ用)

- **scope decision (2026-05-06)**: 初版 PR は提案 2 のみ。Phase 5 / 提案 1 / 提案 3 は別 PR
- **action category (Finding C 関連)**: KISS で `trim + non-empty filter` のみ採用、改行/長さ check は Phase 5 で実装するという保留判断
- **architecture decision (Finding 3 関連)**: `OllamaApi` trait は dyn-compatible にするため `generate_raw_json -> Result<String, _>` シグネチャ。型付き convert は trait 外の自由関数 `generate_json::<T>` で提供
- **input sanitization (PR #119 WARN 関連)**: prompt injection 対策は本 PR では skip (`auto_fix` を実行する経路がない)。Phase 5 で auto_fix execution を実装するタイミングで input sanitize / プレースホルダのブラケット化を導入予定

## 関連リンク

- [ADR-018: cli-pr-monitor の takt ベース移行と CronCreate 廃止](adr/adr-018-pr-monitor-takt-migration.md)
- [ADR-020: takt facets (fix/supervise) の pre-push/post-pr 共通化戦略](adr/adr-020-takt-facets-sharing.md)
- [ADR-034: CodeRabbit 監視・対話の自動化戦略 — Bundle a 設計根拠](adr/adr-034-coderabbit-auto-monitoring.md)
- [ADR-038: ローカル LLM による CodeRabbit findings classification](adr/adr-038-local-llm-finding-classification.md) — 本ファイル提案 2 の land 結果
75 changes: 72 additions & 3 deletions src/cli-finding-classifier/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,12 @@ const VALID_ACTIONS: &[&str] = &[
"informational",
];

/// `normalized_issue` の長さ上限 (characters)。
///
/// `prompts/classify.txt` の出力契約 ("max 80 characters") と一致させる。
/// 上限超過は LLM 出力契約違反として fallback に倒す。
const NORMALIZED_ISSUE_MAX_CHARS: usize = 80;

/// LLM 出力を ClassifiedFinding に変換 + バリデーション
fn from_llm_output(finding: &Finding, llm: LlmClassification) -> ClassifiedFinding {
let action = if VALID_ACTIONS.contains(&llm.action.as_str()) {
Expand All @@ -84,9 +90,27 @@ fn from_llm_output(finding: &Finding, llm: LlmClassification) -> ClassifiedFindi
);
};
let confidence = llm.action_confidence.clamp(0.0, 1.0);
let normalized = llm.normalized_issue
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
let normalized = match llm.normalized_issue.map(|s| s.trim().to_string()) {
None => None,
Some(s) if s.is_empty() => None,
Some(s) if s.lines().count() > 1 => {
return fallback(
finding,
"normalized_issue contract violation: multi-line",
);
}
Some(s) if s.chars().count() > NORMALIZED_ISSUE_MAX_CHARS => {
return fallback(
finding,
format!(
"normalized_issue contract violation: length {} > {}",
s.chars().count(),
NORMALIZED_ISSUE_MAX_CHARS
),
);
}
Some(s) => Some(s),
};

ClassifiedFinding {
finding: finding.clone(),
Expand Down Expand Up @@ -280,6 +304,51 @@ mod tests {
assert!(result.normalized_issue.is_none());
}

#[test]
fn classify_one_falls_back_when_normalized_issue_is_multiline() {
let stub = StubOllama::new(vec![Ok(
r#"{"action":"auto_fix","action_confidence":0.9,"normalized_issue":"line one\nline two"}"#.to_string(),
)]);
let result = classify_one(&stub, "T", &sample_finding());
assert_eq!(result.action, "human_review");
assert_eq!(result.action_confidence, 0.0);
assert!(result
.fallback_reason
.as_deref()
.unwrap()
.contains("multi-line"));
}

#[test]
fn classify_one_falls_back_when_normalized_issue_exceeds_80_chars() {
let long = "a".repeat(81);
let payload = format!(
r#"{{"action":"auto_fix","action_confidence":0.9,"normalized_issue":"{}"}}"#,
long
);
let stub = StubOllama::new(vec![Ok(payload)]);
let result = classify_one(&stub, "T", &sample_finding());
assert_eq!(result.action, "human_review");
assert!(result
.fallback_reason
.as_deref()
.unwrap()
.contains("length 81 > 80"));
}

#[test]
fn classify_one_accepts_normalized_issue_at_80_chars_boundary() {
let exact_80 = "a".repeat(80);
let payload = format!(
r#"{{"action":"auto_fix","action_confidence":0.9,"normalized_issue":"{}"}}"#,
exact_80
);
let stub = StubOllama::new(vec![Ok(payload)]);
let result = classify_one(&stub, "T", &sample_finding());
assert_eq!(result.action, "auto_fix");
assert_eq!(result.normalized_issue.as_deref(), Some(exact_80.as_str()));
}

#[test]
fn classify_one_trims_whitespace_from_normalized_issue() {
let stub = StubOllama::new(vec![Ok(
Expand Down
Loading