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 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
145fn 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 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 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 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 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 }
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()?; 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 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
332fn 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 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 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}