1use crate::optimizer::ModelProvenance;
7use rig::completion::Prompt;
8use rig::providers::openai;
9use serde::Serialize;
10
11#[derive(Serialize)]
13pub struct DiagnosisRequest {
14 pub policy: String,
15 pub bundle_summary: String, pub traces_summary: String,
17 pub scores: Vec<f32>,
18}
19
20#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
22pub struct StructuredCandidate {
23 pub focus: String, pub description: String,
25 pub expected_improvement: String,
26}
27
28#[derive(Debug, Clone)]
30pub struct DiagnosisResult {
31 pub summary: String,
32 pub candidates: Vec<StructuredCandidate>,
33}
34
35pub struct LlmClient {
37 model: String,
38}
39
40impl LlmClient {
41 pub fn new(model: impl Into<String>) -> Self {
42 Self {
43 model: model.into(),
44 }
45 }
46
47 pub fn provenance(&self, used: bool) -> ModelProvenance {
48 ModelProvenance {
49 role: "diagnosis".to_string(),
50 provider: "openai".to_string(),
51 model: self.model.clone(),
52 used,
53 }
54 }
55
56 pub async fn diagnose(&self, req: DiagnosisRequest) -> anyhow::Result<DiagnosisResult> {
58 if std::env::var("OPENAI_API_KEY").is_err() {
60 return Err(anyhow::anyhow!("No OPENAI_API_KEY"));
61 }
62
63 let client = openai::Client::from_env();
64 let agent = client
65 .agent(&self.model)
66 .preamble(
67 "You are an expert at debugging and improving LLM agents written in Rust. \
68 Always respond with a JSON object: {\"summary\": \"short diagnosis\", \"candidates\": [{\"focus\": \"system_prompt|tool_description|fallback_logic|output_schema|model_config\", \"description\": \"...\", \"expected_improvement\": \"...\"}]}. \
69 Be concise and actionable. Focus on minimal, high-impact changes to prompts, tool usage, schemas, model config, or control flow.",
70 )
71 .build();
72
73 let prompt = format!(
74 "Policy:\n{}\n\nCode analysis (extracted preambles, tools, structure):\n{}\n\nTraces summary:\n{}\n\nCurrent scores: {:?}\n\n\
75 Return ONLY a JSON object with keys \"summary\" and \"candidates\" (array of objects with focus, description, expected_improvement). No prose outside the JSON.",
76 req.policy, req.bundle_summary, req.traces_summary, req.scores
77 );
78
79 let response = agent.prompt(prompt).await?;
80
81 #[derive(serde::Deserialize)]
84 struct LlmOutput {
85 summary: Option<String>,
86 candidates: Option<Vec<StructuredCandidate>>,
87 }
88
89 let parsed: Option<LlmOutput> = serde_json::from_str(&response).ok().or_else(|| {
90 if let Some(start) = response.find('{') {
92 if let Some(end) = response.rfind('}') {
93 serde_json::from_str(&response[start..=end]).ok()
94 } else {
95 None
96 }
97 } else {
98 None
99 }
100 });
101
102 if let Some(out) = parsed {
103 let summary = out
104 .summary
105 .unwrap_or_else(|| "LLM diagnosis completed.".to_string());
106 let candidates = out.candidates.unwrap_or_default();
107 if !candidates.is_empty() {
108 return Ok(DiagnosisResult {
109 summary,
110 candidates,
111 });
112 }
113 }
114
115 let summary = response
117 .lines()
118 .next()
119 .unwrap_or("LLM returned free text")
120 .to_string();
121 let fallback = vec![StructuredCandidate {
122 focus: "system_prompt".to_string(),
123 description: response.chars().take(280).collect(),
124 expected_improvement: "Model suggestion".to_string(),
125 }];
126
127 Ok(DiagnosisResult {
128 summary,
129 candidates: fallback,
130 })
131 }
132}
133
134impl Default for LlmClient {
135 fn default() -> Self {
136 Self::new("gpt-4o")
137 }
138}