systemprompt_evaluation/services/
loop_runner.rs1use serde_json::Value;
7use systemprompt_identifiers::EvalRunId;
8
9use crate::error::{EvaluationError, Result};
10use crate::models::{
11 DimensionScore, NewResultParams, Rubric, SampleFilter, SampledRequest, Verdict,
12};
13use crate::repository::{EvalResultRepository, EvalRunRepository};
14use crate::services::judge::{JudgeService, JudgeTarget, ScoredVerdict};
15use crate::services::replay::ReplayService;
16use crate::services::sampler::SamplerService;
17
18const EXCERPT_CHARS: usize = 500;
19
20#[derive(Debug, Clone, Copy)]
21pub struct LoopLimits {
22 pub budget_microdollars: Option<i64>,
23}
24
25#[derive(Debug, Clone, Copy, Default)]
26pub struct LoopReport {
27 pub scored: u32,
28 pub failed: u32,
29 pub replayed: u32,
30 pub repaired: u32,
31 pub judge_cost_microdollars: i64,
32}
33
34pub struct AutoImproveLoop {
36 pub(super) sampler: SamplerService,
37 pub(super) judge: JudgeService,
38 pub(super) replay: ReplayService,
39 pub(super) runs: EvalRunRepository,
40 pub(super) results: EvalResultRepository,
41}
42
43impl std::fmt::Debug for AutoImproveLoop {
44 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45 f.debug_struct("AutoImproveLoop")
46 .field("judge", &self.judge)
47 .finish_non_exhaustive()
48 }
49}
50
51impl AutoImproveLoop {
52 pub async fn run(
53 &self,
54 run_id: &EvalRunId,
55 rubric: &Rubric,
56 filter: &SampleFilter,
57 limits: LoopLimits,
58 ) -> Result<LoopReport> {
59 let mut report = LoopReport::default();
60 let sampled = self.sampler.sample(filter).await?;
61
62 for request in &sampled {
63 check_budget(&report, limits)?;
64 match self.judge_one(run_id, rubric, request, &mut report).await {
65 Ok(()) => {},
66 Err(e) => {
67 tracing::warn!(
68 error = %e,
69 ai_request_id = %request.ai_request_id,
70 "judge pass failed for sampled request"
71 );
72 },
73 }
74 }
75 Ok(report)
76 }
77
78 async fn judge_one(
79 &self,
80 run_id: &EvalRunId,
81 rubric: &Rubric,
82 request: &SampledRequest,
83 report: &mut LoopReport,
84 ) -> Result<()> {
85 let target = judge_target(request);
86 let scored = self.judge.score(rubric, &target).await?;
87 report.scored += 1;
88 report.judge_cost_microdollars += scored.judge_cost_microdollars;
89
90 let failed = matches!(scored.outcome, Verdict::Fail | Verdict::Partial);
91 if failed {
92 report.failed += 1;
93 }
94 let result_id = self
95 .results
96 .insert(&result_params(run_id, request, &scored, None))
97 .await?;
98 self.runs
99 .record_scored(run_id, failed, scored.judge_cost_microdollars)
100 .await?;
101
102 if !failed {
103 return Ok(());
104 }
105 let Some(repair_hint) = scored.verdict.repair_hint.clone().filter(|h| !h.is_empty()) else {
106 return Ok(());
107 };
108
109 let prompt = request.canonical_prompt();
110 let replayed = self.replay.replay(&prompt, &repair_hint).await?;
111 report.replayed += 1;
112
113 let repaired_target = JudgeTarget {
114 transcript: target.transcript,
115 response: replayed.content.clone(),
116 expectation: target.expectation,
117 };
118 let rescored = self.judge.score(rubric, &repaired_target).await?;
119 report.judge_cost_microdollars += rescored.judge_cost_microdollars;
120
121 let mut params = result_params(run_id, request, &rescored, Some(result_id.clone()));
122 params.response_excerpt = Some(excerpt(&replayed.content));
123 self.results.insert(¶ms).await?;
124 self.runs
125 .record_scored(
126 run_id,
127 matches!(rescored.outcome, Verdict::Fail | Verdict::Partial),
128 rescored.judge_cost_microdollars,
129 )
130 .await?;
131
132 if matches!(rescored.outcome, Verdict::Pass) {
133 self.results.mark_repaired(&result_id).await?;
134 report.repaired += 1;
135 }
136 Ok(())
137 }
138}
139
140const fn check_budget(report: &LoopReport, limits: LoopLimits) -> Result<()> {
141 match limits.budget_microdollars {
142 Some(budget) if report.judge_cost_microdollars >= budget => {
143 Err(EvaluationError::BudgetExhausted {
144 spent: report.judge_cost_microdollars,
145 budget,
146 })
147 },
148 _ => Ok(()),
149 }
150}
151
152fn judge_target(request: &SampledRequest) -> JudgeTarget {
153 let transcript = request
154 .messages
155 .iter()
156 .map(|m| format!("{}: {}", m.role, m.content))
157 .collect::<Vec<_>>()
158 .join("\n");
159 JudgeTarget {
160 transcript,
161 response: request.response_text.clone().unwrap_or_default(),
162 expectation: None,
163 }
164}
165
166fn result_params(
167 run_id: &EvalRunId,
168 request: &SampledRequest,
169 scored: &ScoredVerdict,
170 replay_of: Option<systemprompt_identifiers::EvalResultId>,
171) -> NewResultParams {
172 NewResultParams {
173 run_id: run_id.clone(),
174 ai_request_id: Some(request.ai_request_id.clone()),
175 case_id: None,
176 provider: request.provider.clone(),
177 model: request.model.clone(),
178 overall_score: Some(scored.verdict.overall_score),
179 dimension_scores: dimension_scores_json(&scored.verdict.dimension_scores),
180 verdict: scored.outcome,
181 rationale: Some(scored.verdict.rationale.clone()),
182 repair_hint: scored.verdict.repair_hint.clone().filter(|h| !h.is_empty()),
183 prompt_excerpt: request.messages.first().map(|m| excerpt(&m.content)),
184 response_excerpt: request.response_text.as_deref().map(excerpt),
185 judge_cost_microdollars: scored.judge_cost_microdollars,
186 repaired: replay_of.is_some(),
187 replay_of_result_id: replay_of,
188 judge_ai_request_id: Some(scored.judge_ai_request_id.clone()),
189 }
190}
191
192fn dimension_scores_json(scores: &[DimensionScore]) -> Value {
193 match serde_json::to_value(scores) {
194 Ok(value) => value,
195 Err(e) => {
196 tracing::warn!(error = %e, "Failed to serialize dimension scores");
197 Value::Object(serde_json::Map::new())
198 },
199 }
200}
201
202fn excerpt(text: &str) -> String {
203 text.chars().take(EXCERPT_CHARS).collect()
204}