use crate::optimizer::ModelProvenance;
use rig::completion::Prompt;
use rig::providers::openai;
use serde::Serialize;
#[derive(Serialize)]
pub struct DiagnosisRequest {
pub policy: String,
pub bundle_summary: String, pub traces_summary: String,
pub scores: Vec<f32>,
}
#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
pub struct StructuredCandidate {
pub focus: String, pub description: String,
pub expected_improvement: String,
}
#[derive(Debug, Clone)]
pub struct DiagnosisResult {
pub summary: String,
pub candidates: Vec<StructuredCandidate>,
}
pub struct LlmClient {
model: String,
}
impl LlmClient {
pub fn new(model: impl Into<String>) -> Self {
Self {
model: model.into(),
}
}
pub fn provenance(&self, used: bool) -> ModelProvenance {
ModelProvenance {
role: "diagnosis".to_string(),
provider: "openai".to_string(),
model: self.model.clone(),
used,
}
}
pub async fn diagnose(&self, req: DiagnosisRequest) -> anyhow::Result<DiagnosisResult> {
if std::env::var("OPENAI_API_KEY").is_err() {
return Err(anyhow::anyhow!("No OPENAI_API_KEY"));
}
let client = openai::Client::from_env();
let agent = client
.agent(&self.model)
.preamble(
"You are an expert at debugging and improving LLM agents written in Rust. \
Always respond with a JSON object: {\"summary\": \"short diagnosis\", \"candidates\": [{\"focus\": \"system_prompt|tool_description|fallback_logic|output_schema|model_config\", \"description\": \"...\", \"expected_improvement\": \"...\"}]}. \
Be concise and actionable. Focus on minimal, high-impact changes to prompts, tool usage, schemas, model config, or control flow.",
)
.build();
let prompt = format!(
"Policy:\n{}\n\nCode analysis (extracted preambles, tools, structure):\n{}\n\nTraces summary:\n{}\n\nCurrent scores: {:?}\n\n\
Return ONLY a JSON object with keys \"summary\" and \"candidates\" (array of objects with focus, description, expected_improvement). No prose outside the JSON.",
req.policy, req.bundle_summary, req.traces_summary, req.scores
);
let response = agent.prompt(prompt).await?;
#[derive(serde::Deserialize)]
struct LlmOutput {
summary: Option<String>,
candidates: Option<Vec<StructuredCandidate>>,
}
let parsed: Option<LlmOutput> = serde_json::from_str(&response).ok().or_else(|| {
if let Some(start) = response.find('{') {
if let Some(end) = response.rfind('}') {
serde_json::from_str(&response[start..=end]).ok()
} else {
None
}
} else {
None
}
});
if let Some(out) = parsed {
let summary = out
.summary
.unwrap_or_else(|| "LLM diagnosis completed.".to_string());
let candidates = out.candidates.unwrap_or_default();
if !candidates.is_empty() {
return Ok(DiagnosisResult {
summary,
candidates,
});
}
}
let summary = response
.lines()
.next()
.unwrap_or("LLM returned free text")
.to_string();
let fallback = vec![StructuredCandidate {
focus: "system_prompt".to_string(),
description: response.chars().take(280).collect(),
expected_improvement: "Model suggestion".to_string(),
}];
Ok(DiagnosisResult {
summary,
candidates: fallback,
})
}
}
impl Default for LlmClient {
fn default() -> Self {
Self::new("gpt-4o")
}
}