systemprompt_evaluation/repository/
sampling.rs1use sqlx::PgPool;
11use std::sync::Arc;
12use systemprompt_database::DbPool;
13use systemprompt_identifiers::AiRequestId;
14
15use crate::error::Result;
16use crate::models::{CanonicalMessage, SampleFilter, SampledRequest};
17
18#[derive(Debug, Clone)]
19pub struct SamplingRepository {
20 pool: Arc<PgPool>,
21}
22
23impl SamplingRepository {
24 pub fn new(db: &DbPool) -> Result<Self> {
25 Ok(Self {
26 pool: db.pool_arc()?,
27 })
28 }
29
30 pub async fn sample(&self, filter: &SampleFilter) -> Result<Vec<SampledRequest>> {
31 let rows = sqlx::query!(
32 r#"
33 SELECT r.id, r.provider AS "provider!", r.model AS "model!",
34 r.system_prompt_override, r.latency_ms, r.cost_microdollars,
35 r.created_at, p.offered_tools, p.prepared_body_sha256
36 FROM ai_requests r
37 LEFT JOIN ai_request_payloads p ON p.ai_request_id = r.id
38 WHERE r.status = 'completed'
39 AND r.actor_kind <> 'job'
40 AND ($1::timestamptz IS NULL OR r.created_at >= $1)
41 AND ($2::timestamptz IS NULL OR r.created_at < $2)
42 AND ($3::text IS NULL OR r.provider = $3)
43 AND ($4::text IS NULL OR r.model = $4)
44 AND ($5::text[] IS NULL OR r.id = ANY($5))
45 ORDER BY r.created_at DESC
46 LIMIT $6
47 "#,
48 filter.since,
49 filter.until,
50 filter.provider.as_deref(),
51 filter.model.as_deref(),
52 filter.ids.as_deref(),
53 filter.limit
54 )
55 .fetch_all(self.pool.as_ref())
56 .await?;
57
58 let mut sampled = Vec::with_capacity(rows.len());
59 for row in rows {
60 let id = AiRequestId::new(row.id);
61 let (messages, response_text) = self.load_messages(&id).await?;
62 sampled.push(SampledRequest {
63 ai_request_id: id,
64 provider: row.provider,
65 model: row.model,
66 system_prompt_override: row.system_prompt_override,
67 messages,
68 response_text,
69 offered_tools: row.offered_tools,
70 prepared_body_sha256: row.prepared_body_sha256,
71 latency_ms: row.latency_ms,
72 cost_microdollars: row.cost_microdollars,
73 created_at: row.created_at,
74 });
75 }
76 Ok(sampled)
77 }
78
79 pub async fn request_cost(&self, request_id: &str) -> Result<i64> {
82 let cost = sqlx::query_scalar!(
83 "SELECT cost_microdollars FROM ai_requests WHERE request_id = $1",
84 request_id
85 )
86 .fetch_optional(self.pool.as_ref())
87 .await?;
88 Ok(cost.unwrap_or(0))
89 }
90
91 async fn load_messages(
94 &self,
95 id: &AiRequestId,
96 ) -> Result<(Vec<CanonicalMessage>, Option<String>)> {
97 let rows = sqlx::query!(
98 r#"
99 SELECT role, content
100 FROM ai_request_messages
101 WHERE request_id = $1
102 ORDER BY sequence_number
103 "#,
104 id.as_str()
105 )
106 .fetch_all(self.pool.as_ref())
107 .await?;
108
109 let mut messages: Vec<CanonicalMessage> = rows
110 .into_iter()
111 .map(|row| CanonicalMessage {
112 role: row.role,
113 content: row.content,
114 })
115 .collect();
116
117 let response_text = match messages.last() {
118 Some(last) if last.role == "assistant" => messages.pop().map(|m| m.content),
119 _ => None,
120 };
121 Ok((messages, response_text))
122 }
123}