diff --git a/docs/local-llm-offload-analysis.md b/docs/local-llm-offload-analysis.md index 1c1c045..6e18f12 100644 --- a/docs/local-llm-offload-analysis.md +++ b/docs/local-llm-offload-analysis.md @@ -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 関連) の作業ログ分析 @@ -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` シグネチャ。型付き convert は trait 外の自由関数 `generate_json::` で提供 +- **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 結果 diff --git a/src/cli-finding-classifier/src/lib.rs b/src/cli-finding-classifier/src/lib.rs index 0c09f57..2e449b0 100644 --- a/src/cli-finding-classifier/src/lib.rs +++ b/src/cli-finding-classifier/src/lib.rs @@ -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()) { @@ -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(), @@ -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( diff --git a/src/cli-pr-monitor/src/classifier_runner.rs b/src/cli-pr-monitor/src/classifier_runner.rs new file mode 100644 index 0000000..2b8c312 --- /dev/null +++ b/src/cli-pr-monitor/src/classifier_runner.rs @@ -0,0 +1,297 @@ +//! cli-finding-classifier.exe を subprocess invoke する runner (ADR-038、Phase 5) +//! +//! 設計方針: +//! - subprocess で疎結合 (cli-pr-monitor の依存ツリーに ureq / Ollama を持ち込まない) +//! - 失敗 (exe 不在 / spawn 失敗 / timeout / parse 失敗) は **空の Vec を返す**: +//! classifier 自体が internal で fallback (human_review) するため、cli-pr-monitor 側は +//! classifier が一切呼べなかった場合のみ「enrichment なし」として扱えば十分 +//! - stdin 経由で findings JSON を渡し、stdout で classified findings JSON を受ける +//! +//! 関連: ADR-038 §「失敗時の振る舞い (ブロックしない設計)」 + +use lib_report_formatter::Finding; +use serde::{Deserialize, Serialize}; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output, Stdio}; +use std::time::Duration; + +use crate::config::ClassifierConfig; +use crate::log::{log_info, truncate_safe}; + +/// classifier の出力 schema (cli-finding-classifier::ClassifiedFinding と一致) +/// +/// 別 crate を build dep に引き入れず schema だけ複製する。乖離防止のため、 +/// ADR-038 で schema 変更があった際は両方を同期する責務を持つ。 +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] +pub(crate) struct ClassifiedFinding { + #[serde(flatten)] + pub(crate) finding: Finding, + pub(crate) action: String, + pub(crate) action_confidence: f32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) normalized_issue: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) fallback_reason: Option, +} + +/// cli-finding-classifier.exe のパスを解決する。 +/// +/// 通常は cli-pr-monitor.exe と同 dir に置かれる (.claude/ 配下デプロイ前提)。 +pub(crate) fn classifier_exe_path() -> PathBuf { + std::env::current_exe() + .unwrap_or_default() + .parent() + .unwrap_or(Path::new(".")) + .join("cli-finding-classifier.exe") +} + +/// findings を classifier に流して enrich する。 +/// +/// 戻り値: 成功時は `Vec` (findings.len() と同じ長さ)、失敗時は空 Vec。 +/// caller は `is_empty()` で「classifier が動かなかった」を判定し、 +/// 元の findings をそのまま使えばよい。 +pub(crate) fn classify_findings( + config: &ClassifierConfig, + findings: &[Finding], +) -> Vec { + if findings.is_empty() { + return Vec::new(); + } + + let exe = classifier_exe_path(); + if !exe.exists() { + log_info(&format!( + "classifier exe が見つかりません (skip): {}", + exe.display() + )); + return Vec::new(); + } + + let input = match serde_json::to_string(findings) { + Ok(s) => s, + Err(e) => { + log_info(&format!( + "classifier 入力 findings の JSON 化に失敗 (skip): {}", + e + )); + return Vec::new(); + } + }; + + spawn_and_collect(&exe, config, &input) +} + +fn spawn_and_collect( + exe: &Path, + config: &ClassifierConfig, + stdin_payload: &str, +) -> Vec { + let timeout = Duration::from_secs(config.timeout_secs.saturating_add(5)); + let cmd = build_command(exe, config); + + let child = match cmd_spawn(cmd) { + Some(c) => c, + None => return Vec::new(), + }; + + let child_with_stdin = match feed_stdin(child, stdin_payload) { + Some(c) => c, + None => return Vec::new(), + }; + + let output = match wait_with_timeout(child_with_stdin, timeout) { + Some(o) => o, + None => { + log_info(&format!( + "classifier timeout ({}s, +5s buffer 含む) — skip", + config.timeout_secs + )); + return Vec::new(); + } + }; + + parse_classifier_output(&output) +} + +fn build_command(exe: &Path, config: &ClassifierConfig) -> Command { + let mut cmd = Command::new(exe); + cmd.arg("--model") + .arg(&config.model) + .arg("--endpoint") + .arg(&config.endpoint) + .arg("--timeout-secs") + .arg(config.timeout_secs.to_string()) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + cmd +} + +fn cmd_spawn(mut cmd: Command) -> Option { + match cmd.spawn() { + Ok(c) => Some(c), + Err(e) => { + log_info(&format!("classifier spawn 失敗 (skip): {}", e)); + None + } + } +} + +fn feed_stdin( + mut child: std::process::Child, + stdin_payload: &str, +) -> Option { + if let Some(stdin) = child.stdin.as_mut() { + if let Err(e) = stdin.write_all(stdin_payload.as_bytes()) { + log_info(&format!("classifier stdin 書き込み失敗 (skip): {}", e)); + let _ = child.kill(); + return None; + } + } + drop(child.stdin.take()); + Some(child) +} + +fn parse_classifier_output(output: &Output) -> Vec { + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + log_info(&format!( + "classifier non-zero exit ({}): {}", + output.status, + truncate_safe(&stderr, 200) + )); + return Vec::new(); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + match serde_json::from_str::>(&stdout) { + Ok(v) => v, + Err(e) => { + log_info(&format!( + "classifier 出力 JSON parse 失敗 (skip): {} (head: {})", + e, + truncate_safe(&stdout, 200) + )); + Vec::new() + } + } +} + +/// child process を timeout 付きで待機する。 +/// timeout 到達時は None を返す。 +/// +/// `wait_with_output()` をスレッド内で呼び出すことで stdout/stderr パイプを +/// 並行にドレインする。`try_wait()` のスピンループは OS パイプバッファ +/// (~64 KB) を超える出力でデッドロックするため使用しない。 +/// +/// タイムアウト時に child を kill できないのは child がスレッド内へ move されるため。 +/// classifier exe は `--timeout-secs` で自己終了するため、スレッドは自然に終了する。 +fn wait_with_timeout(child: std::process::Child, timeout: Duration) -> Option { + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let _ = tx.send(child.wait_with_output()); + }); + match rx.recv_timeout(timeout) { + Ok(result) => result.ok(), + Err(_) => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn finding(severity: &str, file: &str) -> Finding { + Finding { + severity: severity.into(), + file: file.into(), + line: "1".into(), + issue: "x".into(), + suggestion: "y".into(), + source: "CodeRabbit".into(), + } + } + + #[test] + fn empty_findings_returns_empty_without_spawning() { + let cfg = ClassifierConfig::default(); + let result = classify_findings(&cfg, &[]); + assert!(result.is_empty()); + } + + #[test] + fn classifier_exe_path_resolves_to_sibling_of_current_exe() { + let p = classifier_exe_path(); + assert!(p.to_string_lossy().ends_with("cli-finding-classifier.exe")); + } + + #[test] + fn classified_finding_serde_roundtrip() { + let cf = ClassifiedFinding { + finding: finding("Critical", "src/main.rs"), + action: "human_review".into(), + action_confidence: 0.85, + normalized_issue: Some("test".into()), + fallback_reason: None, + }; + let json = serde_json::to_string(&cf).unwrap(); + assert!(json.contains("\"severity\":\"Critical\"")); + assert!(json.contains("\"action\":\"human_review\"")); + assert!(!json.contains("fallback_reason")); + + let parsed: ClassifiedFinding = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed, cf); + } + + #[test] + fn classified_finding_parses_real_classifier_output_shape() { + let json = r#"[{ + "severity": "Critical", + "file": "src/main.rs", + "line": "641", + "issue": "issue text", + "suggestion": "suggestion text", + "source": "CodeRabbit", + "action": "human_review", + "action_confidence": 1.0, + "normalized_issue": "summary" + }]"#; + let parsed: Vec = serde_json::from_str(json).unwrap(); + assert_eq!(parsed.len(), 1); + assert_eq!(parsed[0].action, "human_review"); + assert_eq!(parsed[0].finding.severity, "Critical"); + } + + /// `wait_with_timeout` success path: a fast process completes within a generous timeout. + #[test] + fn wait_with_timeout_returns_some_when_process_completes() { + let child = Command::new("cmd") + .arg("/c") + .arg("echo ok") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("failed to spawn cmd"); + let result = wait_with_timeout(child, Duration::from_secs(5)); + assert!(result.is_some(), "process should complete within 5-second timeout"); + } + + /// `wait_with_timeout` timeout path: a long-running process exceeds a short timeout. + /// + /// Note: the child process (ping) continues running in a background thread until it + /// terminates naturally (~2 s). This is intentional — the thread-based design cannot + /// kill the child after the timeout (child is moved into the thread). Acceptable in tests. + #[test] + fn wait_with_timeout_returns_none_on_timeout() { + let child = Command::new("cmd") + .arg("/c") + .arg("ping 127.0.0.1 -n 3") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("failed to spawn cmd"); + let result = wait_with_timeout(child, Duration::from_millis(50)); + assert!(result.is_none(), "process should not complete within 50 ms timeout"); + } +} diff --git a/src/cli-pr-monitor/src/config.rs b/src/cli-pr-monitor/src/config.rs index bbab8d2..bb055b2 100644 --- a/src/cli-pr-monitor/src/config.rs +++ b/src/cli-pr-monitor/src/config.rs @@ -18,6 +18,8 @@ pub(crate) struct Config { pub(crate) rate_limit: RateLimitConfig, #[serde(default)] pub(crate) review_recheck: ReviewRecheckConfig, + #[serde(default)] + pub(crate) classifier: ClassifierConfig, } #[derive(Deserialize, Clone)] @@ -193,6 +195,52 @@ impl ReviewRecheckConfig { } } +/// CodeRabbit findings をローカル LLM (Ollama) で classify する設定 (ADR-038、Phase 5)。 +/// +/// `cli-finding-classifier.exe` を subprocess invoke し、`Vec` を +/// `Vec` に enrich する。デフォルトは無効 (`enabled = false`、 +/// 試験運用)。Ollama 不在 / 失敗時は classifier 側 fallback で全件 `human_review` +/// に倒れるため、有効化しても polling が block しない。 +#[derive(Deserialize, Clone)] +pub(crate) struct ClassifierConfig { + /// classifier を invoke するかどうか + #[serde(default = "default_classifier_enabled")] + pub(crate) enabled: bool, + /// Ollama モデル名 (`--model` に渡す) + #[serde(default = "default_classifier_model")] + pub(crate) model: String, + /// Ollama HTTP endpoint (`--endpoint` に渡す) + #[serde(default = "default_classifier_endpoint")] + pub(crate) endpoint: String, + /// 1 リクエストあたりタイムアウト秒 (`--timeout-secs` に渡す) + #[serde(default = "default_classifier_timeout_secs")] + pub(crate) timeout_secs: u64, +} + +fn default_classifier_enabled() -> bool { + false +} +fn default_classifier_model() -> String { + "mistral:7b".into() +} +fn default_classifier_endpoint() -> String { + "http://localhost:11434".into() +} +fn default_classifier_timeout_secs() -> u64 { + 30 +} + +impl Default for ClassifierConfig { + fn default() -> Self { + Self { + enabled: default_classifier_enabled(), + model: default_classifier_model(), + endpoint: default_classifier_endpoint(), + timeout_secs: default_classifier_timeout_secs(), + } + } +} + fn config_path() -> PathBuf { let filename = "pr-monitor-config.toml"; @@ -469,6 +517,34 @@ max_review_rechecks = 5 assert_eq!(cfg.max_review_rechecks, 5); } + #[test] + fn config_classifier_defaults() { + let toml_str = "[monitor]\n"; + let config: Config = toml::from_str(toml_str).unwrap(); + assert!(!config.classifier.enabled, "デフォルトは無効 (試験運用)"); + assert_eq!(config.classifier.model, "mistral:7b"); + assert_eq!(config.classifier.endpoint, "http://localhost:11434"); + assert_eq!(config.classifier.timeout_secs, 30); + } + + #[test] + fn config_classifier_custom() { + let toml_str = r#" +[monitor] + +[classifier] +enabled = true +model = "llama2:13b" +endpoint = "http://192.168.1.10:11434" +timeout_secs = 60 +"#; + let config: Config = toml::from_str(toml_str).unwrap(); + assert!(config.classifier.enabled); + assert_eq!(config.classifier.model, "llama2:13b"); + assert_eq!(config.classifier.endpoint, "http://192.168.1.10:11434"); + assert_eq!(config.classifier.timeout_secs, 60); + } + /// PR #115 CR Major #2: 1 年 (MAX_SAFE_WAIT_SECS) ぎりぎりは valid、 /// 1 年 + 1 秒は default に置換される境界値を machine-enforce する。 /// 加えて、`now_unix + sanitize 後の値 < i64::MAX` invariant が成立することを assert。 diff --git a/src/cli-pr-monitor/src/main.rs b/src/cli-pr-monitor/src/main.rs index 79bf0ca..01d051a 100644 --- a/src/cli-pr-monitor/src/main.rs +++ b/src/cli-pr-monitor/src/main.rs @@ -17,6 +17,7 @@ //! 0 - 正常終了 (park 含む、PARK signal は stdout に出力済) //! 1 - gh pr create 失敗 (PR 作成モードのみ) +mod classifier_runner; mod config; mod fix_commit; mod lock; diff --git a/src/cli-pr-monitor/src/stages/poll.rs b/src/cli-pr-monitor/src/stages/poll.rs index 9514ffc..2178323 100644 --- a/src/cli-pr-monitor/src/stages/poll.rs +++ b/src/cli-pr-monitor/src/stages/poll.rs @@ -1,7 +1,10 @@ use lib_report_formatter::Finding; use std::time::Duration; -use crate::config::{Config, MonitorConfig, RateLimitConfig, DEFAULT_CHECK_TIMEOUT_SECS}; +use crate::classifier_runner::classify_findings; +use crate::config::{ + ClassifierConfig, Config, MonitorConfig, RateLimitConfig, DEFAULT_CHECK_TIMEOUT_SECS, +}; use crate::log::{log_info, truncate_safe}; use crate::runner::{checker_exe_path, run_cmd_direct, run_gh_quiet}; use crate::state::{ @@ -29,6 +32,7 @@ struct PollContext<'a> { push_time: &'a str, pr_info: &'a PrInfo, rate_limit_config: &'a RateLimitConfig, + classifier_config: &'a ClassifierConfig, start: std::time::Instant, max_duration: u64, skip_ci: bool, @@ -69,6 +73,7 @@ pub(crate) fn run_poll_loop(full_config: &Config, pr_info: &PrInfo, is_wakeup: b .unwrap_or("1970-01-01T00:00:00Z"), pr_info, rate_limit_config: &full_config.rate_limit, + classifier_config: &full_config.classifier, start: std::time::Instant::now(), max_duration: config.max_duration_secs, skip_ci: !config.check_ci, @@ -101,6 +106,7 @@ fn run_one_iteration(ctx: &PollContext<'_>) -> Option { ctx.skip_ci, ctx.skip_coderabbit, ); + enrich_with_classifier(&mut state, ctx.classifier_config); log_info(&format!( "ポーリング: action={}, summary={}", state.action, state.summary @@ -199,6 +205,7 @@ fn build_state_for_iteration( state.rate_limit_last_retriggered_at = existing.rate_limit_last_retriggered_at; state.review_recheck_count = existing.review_recheck_count; state.head_commit = existing.head_commit; + state.classified_findings = existing.classified_findings; } apply_skip_handling(&mut state, skip_ci, skip_coderabbit); @@ -207,6 +214,27 @@ fn build_state_for_iteration( state } +/// classifier (ADR-038, Phase 5) で findings を enrich する。 +/// +/// `config.classifier.enabled = false` または findings が空のときは何もしない。 +/// 実行成功時は state.classified_findings を populate して state file を再書き出す。 +/// 失敗時は state.classified_findings は空のまま (caller は findings をそのまま使えばよい)。 +fn enrich_with_classifier(state: &mut PrMonitorState, config: &ClassifierConfig) { + if !config.enabled || state.findings.is_empty() { + return; + } + let classified = classify_findings(config, &state.findings); + if classified.is_empty() { + return; + } + log_info(&format!( + "classifier: {} findings を分類完了", + classified.len() + )); + state.classified_findings = classified; + let _ = write_state(state); +} + fn apply_skip_handling(state: &mut PrMonitorState, skip_ci: bool, skip_coderabbit: bool) { if skip_ci { state.ci = Some(CiState { @@ -1139,11 +1167,13 @@ mod tests { head_commit: None, }; let rate_limit_config = RateLimitConfig::default(); + let classifier_config = ClassifierConfig::default(); let ctx = PollContext { checker: &checker_path, push_time: "2026-05-01T00:00:00Z", pr_info: &pr_info, rate_limit_config: &rate_limit_config, + classifier_config: &classifier_config, start: std::time::Instant::now(), max_duration: 600, skip_ci: false, @@ -1181,11 +1211,13 @@ mod tests { state.review_recheck_count = 1; let checker_path = std::path::PathBuf::from("dummy"); let rate_limit_config = RateLimitConfig::default(); + let classifier_config = ClassifierConfig::default(); let ctx = PollContext { checker: &checker_path, push_time: "2026-05-01T00:00:00Z", pr_info, rate_limit_config: &rate_limit_config, + classifier_config: &classifier_config, start: std::time::Instant::now(), max_duration: 600, skip_ci: false, @@ -1202,11 +1234,13 @@ mod tests { ) -> PollResult { let checker_path = std::path::PathBuf::from("dummy"); let rate_limit_config = RateLimitConfig::default(); + let classifier_config = ClassifierConfig::default(); let ctx = PollContext { checker: &checker_path, push_time: "2026-05-01T00:00:00Z", pr_info, rate_limit_config: &rate_limit_config, + classifier_config: &classifier_config, start: std::time::Instant::now(), max_duration: 600, skip_ci: false, @@ -1240,11 +1274,13 @@ mod tests { }; let checker = std::path::PathBuf::from("dummy"); let rate_limit_config = RateLimitConfig::default(); + let classifier_config = ClassifierConfig::default(); let ctx = PollContext { checker: &checker, push_time: "2026-05-01T00:00:00Z", pr_info: &pr_info, rate_limit_config: &rate_limit_config, + classifier_config: &classifier_config, start: std::time::Instant::now(), max_duration: 600, skip_ci: false, @@ -1289,11 +1325,13 @@ mod tests { }; let checker = std::path::PathBuf::from("dummy"); let rate_limit_config = RateLimitConfig::default(); + let classifier_config = ClassifierConfig::default(); let ctx = PollContext { checker: &checker, push_time: "2026-05-01T00:00:00Z", pr_info: &pr_info, rate_limit_config: &rate_limit_config, + classifier_config: &classifier_config, start: std::time::Instant::now(), max_duration: 600, skip_ci: false, @@ -1321,6 +1359,31 @@ mod tests { ); } + /// `enabled = false` のとき `enrich_with_classifier` は early return し + /// `classified_findings` を変更しない。 + /// + /// `classified_findings` を空のまま渡すことで `!config.enabled` ガードのみを純粋に分離する。 + #[test] + fn enrich_with_classifier_skips_when_disabled() { + use lib_report_formatter::Finding; + + let mut state = PrMonitorState::new(Some(1), Some("o/r".into()), "t".into()); + state.findings = vec![Finding { + severity: "Major".into(), + file: "f.rs".into(), + line: "1".into(), + issue: "issue".into(), + suggestion: "fix".into(), + source: "coderabbit".into(), + }]; + let disabled = ClassifierConfig { enabled: false, ..ClassifierConfig::default() }; + enrich_with_classifier(&mut state, &disabled); + assert!( + state.classified_findings.is_empty(), + "disabled guard should prevent classification from running" + ); + } + /// Bb-2 (T2-2) + Bb-3 follow-up: 3 つの finalize_* park sibling /// (`finalize_parked` / `schedule_next_review_recheck_park` / `finalize_initial_review_park`) /// は全て write_state 失敗で `action_required` を返す invariant を 1 テストで diff --git a/src/cli-pr-monitor/src/state.rs b/src/cli-pr-monitor/src/state.rs index f52083c..b6f3d41 100644 --- a/src/cli-pr-monitor/src/state.rs +++ b/src/cli-pr-monitor/src/state.rs @@ -1,3 +1,4 @@ +use crate::classifier_runner::ClassifiedFinding; use lib_report_formatter::Finding; use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; @@ -14,6 +15,13 @@ pub(crate) struct PrMonitorState { pub(crate) summary: String, #[serde(default)] pub(crate) findings: Vec, + /// classifier (ADR-038, Phase 5) で enrich した findings。 + /// + /// `config.classifier.enabled = false` または classifier 失敗時は空 Vec で残る。 + /// 既存 consumers (takt facets / Claude) が `findings` のみ参照する経路を破壊しない + /// よう、独立 field として保持する。 + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub(crate) classified_findings: Vec, pub(crate) notified: bool, pub(crate) daemon_pid: Option, pub(crate) daemon_status: String, @@ -117,6 +125,7 @@ impl PrMonitorState { action: "continue_monitoring".to_string(), summary: "監視開始...".to_string(), findings: Vec::new(), + classified_findings: Vec::new(), notified: false, daemon_pid: None, daemon_status: "running".to_string(), @@ -252,6 +261,7 @@ mod tests { suggestion: "write first".into(), source: "CodeRabbit".into(), }], + classified_findings: Vec::new(), notified: false, daemon_pid: Some(12345), daemon_status: "running".into(),