Skip to main content

assay_core/doctor/
mod.rs

1pub mod analyzers;
2pub mod model;
3
4use chrono::Utc;
5use std::collections::HashMap;
6use std::io::BufRead;
7use std::path::{Path, PathBuf};
8
9use crate::config::path_resolver::PathResolver;
10use crate::errors::diagnostic::{codes, Diagnostic};
11use crate::model::{EvalConfig, Expected, Policy};
12use crate::validate::{validate, ValidateOptions};
13
14use model::*;
15
16#[derive(Debug, Clone)]
17pub struct DoctorOptions {
18    pub config_path: PathBuf,
19    pub trace_file: Option<PathBuf>,
20    pub baseline_file: Option<PathBuf>,
21    pub db_path: Option<PathBuf>,
22    pub replay_strict: bool,
23}
24
25pub async fn doctor(
26    cfg: &EvalConfig,
27    opts: &DoctorOptions,
28    resolver: &PathResolver,
29) -> anyhow::Result<DoctorReport> {
30    let mut notes = vec![];
31    let mut diagnostics: Vec<Diagnostic> = vec![];
32
33    let vopts = ValidateOptions {
34        trace_file: opts.trace_file.clone(),
35        baseline_file: opts.baseline_file.clone(),
36        replay_strict: opts.replay_strict,
37    };
38    let vreport = validate(cfg, &vopts, resolver).await?;
39    diagnostics.extend(vreport.diagnostics);
40
41    let mut loaded_policies = HashMap::new();
42
43    let unknown_field_re = regex::Regex::new(r"unknown field `([^`]+)`, expected one of (.*)")
44        .expect("Invalid regex for unknown field parsing");
45
46    for test in &cfg.tests {
47        if let Some(path) = test.expected.get_policy_path() {
48            let mut p_str = path.to_string();
49            resolver.resolve_str(&mut p_str);
50            let pb = PathBuf::from(p_str);
51            if pb.exists() {
52                match Policy::load(&pb) {
53                    Ok(p) => {
54                        loaded_policies.insert(path.to_string(), p);
55                    }
56                    Err(e) => {
57                        let msg = e.to_string();
58                        let mut diag = Diagnostic::new(
59                            codes::E_CFG_PARSE,
60                            format!("Failed to parse policy '{}': {}", path, msg),
61                        )
62                        .with_source("doctor.policy_load")
63                        .with_context(serde_json::json!({ "path": pb, "error": msg }));
64
65                        if let Some(caps) = unknown_field_re.captures(&msg) {
66                            let unknown = &caps[1];
67                            let expected_str = &caps[2];
68                            // expected_str usually looks like "`a`, `b`, `c`"
69                            let candidates: Vec<String> = expected_str
70                                .split(',')
71                                .map(|s| s.trim().trim_matches('`').to_string())
72                                .collect();
73
74                            if let Some(hint) = crate::errors::similarity::closest_prompt(
75                                unknown,
76                                candidates.iter(),
77                            ) {
78                                diag = diag.with_fix_step(format!(
79                                    "Replace `{}` with `{}`",
80                                    unknown, hint.prompt
81                                ));
82                            }
83                        }
84                        diagnostics.push(diag);
85                    }
86                }
87            }
88        }
89    }
90
91    analyzers::config::analyze_config_integrity(cfg, resolver, &mut diagnostics);
92    analyzers::policy::analyze_policy_usage(cfg, &loaded_policies, &mut diagnostics);
93
94    if let Some(p) = &opts.trace_file {
95        analyzers::trace::analyze_trace_schema(p, &mut diagnostics);
96    }
97
98    let config_summary = Some(summarize_config(cfg));
99
100    let trace_summary = match &opts.trace_file {
101        Some(p) => summarize_trace(p, cfg, &mut diagnostics).ok(),
102        None => None,
103    };
104
105    let baseline_summary = match &opts.baseline_file {
106        Some(p) => summarize_baseline(p, &mut diagnostics).ok(),
107        None => None,
108    };
109
110    let db_summary = match &opts.db_path {
111        Some(p) => summarize_db(p, &mut diagnostics).ok(),
112        None => None,
113    };
114
115    let caches = summarize_caches(&mut notes);
116
117    let suggested_actions = suggest_from(&diagnostics, cfg, &trace_summary, &baseline_summary);
118
119    Ok(DoctorReport {
120        schema_version: 1,
121        generated_at: Utc::now().to_rfc3339(),
122        assay_version: env!("CARGO_PKG_VERSION").to_string(),
123        platform: PlatformInfo {
124            os: std::env::consts::OS.to_string(),
125            arch: std::env::consts::ARCH.to_string(),
126        },
127        inputs: DoctorInputs {
128            config_path: opts.config_path.display().to_string(),
129            trace_file: opts.trace_file.as_ref().map(|p| p.display().to_string()),
130            baseline_file: opts.baseline_file.as_ref().map(|p| p.display().to_string()),
131            db_path: opts.db_path.as_ref().map(|p| p.display().to_string()),
132            replay_strict: opts.replay_strict,
133        },
134        config: config_summary,
135        trace: trace_summary,
136        baseline: baseline_summary,
137        db: db_summary,
138        caches,
139        diagnostics,
140        suggested_actions,
141        notes,
142    })
143}
144
145// ... include existing summarize helpers ...
146// (Omitting full copy-paste of helpers to keep context small, assuming I can append them or they are preserved if I use smart edit, verify?)
147// Since I'm replacing the whole file content essentially (or a large chunk), I need to be careful.
148// I will use `replace_file_content` targeting the top section effectively.
149
150fn summarize_config(cfg: &EvalConfig) -> ConfigSummary {
151    use std::collections::BTreeMap;
152    let mut metric_counts: BTreeMap<String, u32> = BTreeMap::new();
153
154    for tc in &cfg.tests {
155        let key = match &tc.expected {
156            Expected::MustContain { .. } => "must_contain",
157            Expected::MustNotContain { .. } => "must_not_contain",
158            Expected::RegexMatch { .. } => "regex_match",
159            Expected::RegexNotMatch { .. } => "regex_not_match",
160            Expected::JsonSchema { .. } => "json_schema",
161            Expected::SemanticSimilarityTo { .. } => "semantic_similarity_to",
162            Expected::Faithfulness { .. } => "faithfulness",
163            Expected::Relevance { .. } => "relevance",
164            Expected::JudgeCriteria { .. } => "judge_criteria",
165            Expected::ArgsValid { .. } => "args_valid",
166            Expected::SequenceValid { .. } => "sequence_valid",
167            Expected::ToolBlocklist { .. } => "tool_blocklist",
168            Expected::ToolDescriptionIntegrity { .. } => "tool_description_integrity",
169            Expected::ToolOutputValid { .. } => "tool_output_valid",
170            Expected::ToolCollisionDetect { .. } => "tool_collision_detect",
171            Expected::Reference { .. } => "reference",
172        }
173        .to_string();
174
175        *metric_counts.entry(key).or_insert(0) += 1;
176    }
177
178    let (mode, max_drop, min_floor) = cfg
179        .settings
180        .thresholding
181        .as_ref()
182        .map(|t| (t.mode.clone(), t.max_drop, t.min_floor))
183        .unwrap_or((None, None, None));
184
185    ConfigSummary {
186        suite: cfg.suite.clone(),
187        model: cfg.model.clone(),
188        test_count: cfg.tests.len() as u32,
189        metric_counts,
190        thresholding_mode: mode,
191        max_drop,
192        min_floor,
193    }
194}
195
196fn summarize_trace(
197    path: &Path,
198    _cfg: &EvalConfig,
199    _diags: &mut Vec<Diagnostic>,
200) -> anyhow::Result<TraceSummary> {
201    // Keep it cheap: count lines, peek first line for schema_version/meta shape.
202    let md = std::fs::metadata(path).ok();
203    let approx_size_bytes = md.map(|m| m.len());
204
205    let f = std::fs::File::open(path)?;
206    let rdr = std::io::BufReader::new(f);
207
208    let mut entries: u64 = 0;
209    let mut first_schema: Option<u32> = None;
210    let mut has_assay_meta = false;
211
212    // Coverage: best-effort scan until N lines (avoid huge files)
213    let mut has_embeddings = false;
214    let mut has_judge_faithfulness = false;
215    let mut has_judge_relevance = false;
216
217    for (i, line) in rdr.lines().enumerate() {
218        let line = line?;
219        if line.trim().is_empty() {
220            continue;
221        }
222        // Attempt to ignore non-JSON lines if possible, but assume JSONL
223        entries += 1;
224
225        if i == 0 {
226            if let Ok(v) = serde_json::from_str::<serde_json::Value>(&line) {
227                first_schema = v
228                    .get("schema_version")
229                    .and_then(|x| x.as_u64())
230                    .map(|x| x as u32);
231                if v.get("meta").and_then(|m| m.get("assay")).is_some() {
232                    has_assay_meta = true;
233                }
234            }
235        }
236
237        // scan first 200 entries for assay meta coverage
238        if i < 200 {
239            if let Ok(v) = serde_json::from_str::<serde_json::Value>(&line) {
240                if let Some(meta) = v.get("meta").and_then(|m| m.get("assay")) {
241                    if meta.pointer("/embeddings").is_some() {
242                        has_embeddings = true;
243                    }
244                    if meta.pointer("/judge/faithfulness").is_some() {
245                        has_judge_faithfulness = true;
246                    }
247                    if meta.pointer("/judge/relevance").is_some() {
248                        has_judge_relevance = true;
249                    }
250                }
251            }
252        } else if has_embeddings && has_judge_faithfulness && has_judge_relevance {
253            // Found everything, mostly likely. But we want to count entries completely?
254            // If file is huge, counting lines might be slow. But usually fast enough.
255            // Let's iterate all to count.
256        }
257    }
258
259    Ok(TraceSummary {
260        path: path.display().to_string(),
261        entries,
262        schema_version: first_schema,
263        has_assay_meta,
264        coverage: TraceCoverage {
265            has_embeddings,
266            has_judge_faithfulness,
267            has_judge_relevance,
268        },
269        approx_size_bytes,
270    })
271}
272
273fn summarize_baseline(
274    path: &Path,
275    _diags: &mut Vec<Diagnostic>,
276) -> anyhow::Result<BaselineSummary> {
277    let b = crate::baseline::Baseline::load(path)?;
278    Ok(BaselineSummary {
279        path: path.display().to_string(),
280        suite: b.suite.clone(),
281        schema_version: b.schema_version,
282        assay_version: Some(b.assay_version.clone()),
283        entry_count: b.entries.len() as u32,
284    })
285}
286
287fn summarize_db(path: &Path, _diags: &mut Vec<Diagnostic>) -> anyhow::Result<DbSummary> {
288    let size_bytes = std::fs::metadata(path).ok().map(|m| m.len());
289    let store = crate::storage::store::Store::open(path)?;
290    store.init_schema()?; // ensure migrations
291
292    // These queries are intentionally light
293    let stats = store
294        .stats_best_effort()
295        .unwrap_or(crate::storage::store::StoreStats {
296            runs: None,
297            results: None,
298            last_run_id: None,
299            last_run_at: None,
300            version: None,
301        });
302
303    Ok(DbSummary {
304        path: path.display().to_string(),
305        size_bytes,
306        runs: stats.runs,
307        results: stats.results,
308        last_run_id: stats.last_run_id,
309        last_run_started_at: stats.last_run_at,
310    })
311}
312
313fn summarize_caches(notes: &mut Vec<String>) -> CacheSummary {
314    // best effort: read HOME and check ~/.assay/*
315    let home = std::env::var("HOME").ok();
316    if home.is_none() {
317        notes.push("HOME not set; cannot inspect ~/.assay caches".to_string());
318        return CacheSummary::default();
319    }
320    let home = home.unwrap();
321    let cache_dir = format!("{}/.assay/cache", home);
322    let emb_dir = format!("{}/.assay/embeddings", home);
323
324    CacheSummary {
325        assay_cache_dir: Some(cache_dir.clone()),
326        assay_embeddings_dir: Some(emb_dir.clone()),
327        cache_size_bytes: dir_size_bytes(&cache_dir).ok(),
328        embeddings_size_bytes: dir_size_bytes(&emb_dir).ok(),
329    }
330}
331
332// Simple recursive directory size without external crates
333fn dir_size_bytes(p: &str) -> anyhow::Result<u64> {
334    let mut total = 0u64;
335    let path = std::path::Path::new(p);
336    if !path.exists() {
337        return Ok(0);
338    }
339
340    if path.is_file() {
341        return Ok(path.metadata()?.len());
342    }
343
344    let entries = std::fs::read_dir(path)?;
345    for entry in entries {
346        let entry = entry?;
347        let ft = entry.file_type()?;
348        if ft.is_file() {
349            total += entry.metadata()?.len();
350        } else if ft.is_dir() {
351            // Heuristic: limit recursion depth or just do 1 level?
352            // Standard recursion is fine for cache dirs (usually flat or few levels)
353            // But let's be careful about symlinks/cycles (ignore symlinks)
354            if !ft.is_symlink() {
355                total += dir_size_bytes(entry.path().to_str().unwrap_or(""))?;
356            }
357        }
358    }
359    Ok(total)
360}
361
362fn suggest_from(
363    diags: &[Diagnostic],
364    _cfg: &EvalConfig,
365    trace: &Option<TraceSummary>,
366    _baseline: &Option<BaselineSummary>,
367) -> Vec<SuggestedAction> {
368    let mut out = vec![];
369
370    if diags.iter().any(|d| d.code == codes::E_TRACE_MISS) {
371        out.push(SuggestedAction {
372            title: "Fix trace miss (prompt drift)".into(),
373            relates_to: "failure_mode_1_trace_miss".into(),
374            why: "Config prompts must match trace prompts exactly in replay/offline modes.".into(),
375            steps: vec![
376                "Run: assay trace verify --trace <trace.jsonl> --config <eval.yaml>".into(),
377                "If prompts changed intentionally: re-ingest + precompute.".into(),
378            ],
379        });
380    }
381
382    if diags
383        .iter()
384        .any(|d| d.code == codes::E_REPLAY_STRICT_MISSING)
385    {
386        out.push(SuggestedAction {
387            title: "Make trace strict-replay ready".into(),
388            relates_to: "failure_mode_??_strict_replay_missing".into(),
389            why: "In --replay-strict, missing embeddings/judge meta is a hard setup error.".into(),
390            steps: vec![
391                "Run: assay trace precompute-embeddings --trace <trace.jsonl> --output <trace_enriched.jsonl> ...".into(),
392                "Run: assay trace precompute-judge --trace <trace_enriched.jsonl> --output <trace_enriched.jsonl> ...".into(),
393            ],
394        });
395    }
396
397    if diags.iter().any(|d| d.code == codes::E_BASE_MISMATCH) {
398        out.push(SuggestedAction {
399            title: "Regenerate or select correct baseline".into(),
400            relates_to: "failure_mode_3_schema_version_drift".into(),
401            why: "Baseline suite/schema must match config suite/schema.".into(),
402            steps: vec![
403                "Export on main: assay ci --config <eval.yaml> --trace-file <main.jsonl> --export-baseline baseline.json".into(),
404                "Gate PR: assay ci --baseline baseline.json".into(),
405            ],
406        });
407    }
408
409    // Heuristic: large trace performance
410    if let Some(t) = trace {
411        if t.entries > 50_000 {
412            out.push(SuggestedAction {
413                title: "Speed up CI for large traces".into(),
414                relates_to: "failure_mode_9_large_trace_performance".into(),
415                why: "Large trace files increase parse time; CI should use a smaller slice + incremental.".into(),
416                steps: vec![
417                    "Use a CI slice trace (e.g. top 1k).".into(),
418                    "Enable incremental: assay ci --incremental".into(),
419                    "Use precompute + --replay-strict for offline CI.".into(),
420                ],
421            });
422        }
423    }
424
425    out
426}