blendtutor_core/llm/feedback.rs
1//! The effectful feedback request: drive rig's `Extractor` and map its DTO to a
2//! domain verdict.
3//!
4//! Owns the domain [`Verdict`], the boundary DTO it maps from, the typed
5//! [`FeedbackError`], and [`request_feedback`] — the only effectful function in the
6//! LLM layer (§2.2). The pure prompt it sends is built by
7//! [`prompt`](super::prompt). Callers depend on [`request_feedback`] + [`Verdict`],
8//! never on rig types (§3.4); rig stays an implementation detail here.
9
10use std::fmt;
11
12use rig_core::prelude::CompletionClient;
13use rig_core::providers::{anthropic, openai};
14use schemars::JsonSchema;
15use serde::{Deserialize, Serialize};
16
17use super::prompt::Prompt;
18use super::provider::ProviderChoice;
19
20/// A graded verdict on a submission: correct or incorrect, each carrying the
21/// learner-facing message.
22///
23/// A sum type, not a `bool` plus a message, so "correct with no message" and
24/// contradictory states are unrepresentable and there is no public `is_correct`
25/// (§1.2). This is what callers consume; the rig DTO (`Feedback`) stays an
26/// implementation detail of this module.
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub enum Verdict {
29 /// The submission satisfies the exercise.
30 Correct {
31 /// The learner-facing feedback message.
32 message: String,
33 },
34 /// The submission does not satisfy the exercise.
35 Incorrect {
36 /// The learner-facing feedback message.
37 message: String,
38 },
39}
40
41/// The structured feedback the model returns through its tool call — the boundary
42/// DTO rig's `Extractor` fills from the tool-call arguments (ADR-0006).
43///
44/// `is_correct` is a non-`Option` `bool` and the struct derives **no** `Default`,
45/// so a tool call that omits a field is a deserialize error, never a
46/// silently-defaulted grade. It is mapped into [`Verdict`] at the boundary so the
47/// bool's meaning lives in the variant; callers never see this type.
48#[derive(Debug, Deserialize, Serialize, JsonSchema)]
49struct Feedback {
50 /// Whether the submission satisfies the exercise.
51 is_correct: bool,
52 /// The feedback message for the learner.
53 feedback_message: String,
54}
55
56impl From<Feedback> for Verdict {
57 fn from(feedback: Feedback) -> Self {
58 let Feedback {
59 is_correct,
60 feedback_message,
61 } = feedback;
62 if is_correct {
63 Verdict::Correct {
64 message: feedback_message,
65 }
66 } else {
67 Verdict::Incorrect {
68 message: feedback_message,
69 }
70 }
71 }
72}
73
74/// Why feedback could not be produced.
75///
76/// A typed error implementing [`std::error::Error`], so `core` stays free of
77/// `anyhow` (ADR-0001); the CLI maps it at the edge.
78#[derive(Debug)]
79pub enum FeedbackError {
80 /// The active provider's API key environment variable is unset or empty.
81 MissingApiKey {
82 /// The env var that must be set (e.g. `FIREWORKS_API_KEY`).
83 var: &'static str,
84 },
85 /// The provider client could not be constructed.
86 Client(String),
87 /// The completion request itself failed — a transport, auth, or model error
88 /// (e.g. the provider rejected the key, or the network was unreachable). The
89 /// caller's recourse is to retry or fix credentials, not to re-prompt.
90 Completion(String),
91 /// The model responded, but its output could not be turned into a verdict — a
92 /// malformed or missing field, or no tool call at all. The model's output, not
93 /// the transport, is at fault.
94 Extraction(String),
95}
96
97impl fmt::Display for FeedbackError {
98 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
99 match self {
100 FeedbackError::MissingApiKey { var } => {
101 write!(f, "{var} is not set — set your {var} to use this provider")
102 }
103 FeedbackError::Client(msg) => write!(f, "could not build the provider client: {msg}"),
104 FeedbackError::Completion(msg) => write!(f, "the feedback request failed: {msg}"),
105 FeedbackError::Extraction(msg) => {
106 write!(
107 f,
108 "could not extract a verdict from the model response: {msg}"
109 )
110 }
111 }
112 }
113}
114
115impl std::error::Error for FeedbackError {}
116
117impl From<rig_core::extractor::ExtractionError> for FeedbackError {
118 /// Preserve rig's own distinction between a failed request and an
119 /// unparseable response (§1.2): a `CompletionError` is a transport/auth/model
120 /// failure, while a deserialize failure or missing tool call means the
121 /// response could not be turned into a verdict.
122 ///
123 /// The match is deliberately exhaustive with no wildcard arm: if a future rig
124 /// version adds an `ExtractionError` variant, this fails to compile until we
125 /// decide which `FeedbackError` it maps to, rather than silently funnelling it
126 /// into the wrong kind.
127 fn from(error: rig_core::extractor::ExtractionError) -> Self {
128 use rig_core::extractor::ExtractionError;
129 match error {
130 ExtractionError::CompletionError(e) => FeedbackError::Completion(e.to_string()),
131 ExtractionError::DeserializationError(e) => FeedbackError::Extraction(e.to_string()),
132 ExtractionError::NoData => {
133 FeedbackError::Extraction("the model returned no structured feedback".to_string())
134 }
135 }
136 }
137}
138
139/// Request structured feedback for a `prompt` from the chosen provider.
140///
141/// The effectful counterpart to [`build_prompt`](super::prompt::build_prompt)
142/// (§2.2): it reads the active provider's API key, builds the rig client, runs an
143/// `Extractor<Feedback>` over the prompt, and maps the extracted DTO into a
144/// [`Verdict`]. Fireworks is reached through rig's OpenAI **chat-completions**
145/// client (Fireworks is OpenAI-compatible); Anthropic through its native client.
146/// `base_url_override` points the client at a mock server in tests — the test seam
147/// (ADR-0006); production passes `None`. Callers depend only on this function and
148/// [`Verdict`], never on rig types.
149pub async fn request_feedback(
150 provider: ProviderChoice,
151 prompt: &Prompt,
152 base_url_override: Option<&str>,
153) -> Result<Verdict, FeedbackError> {
154 // The boundary guard fires first (§1.3.1): a missing key is a clear typed
155 // error naming the var, before any rig client is built or any socket opened.
156 let key = require_api_key(provider)?;
157 let base_url = base_url_override.unwrap_or(provider.default_base_url());
158 let model = provider.default_model();
159
160 let extracted = match provider {
161 ProviderChoice::Fireworks => {
162 let client = openai::Client::builder()
163 .api_key(key)
164 .base_url(base_url)
165 .build()
166 .map_err(|e| FeedbackError::Client(e.to_string()))?;
167 client
168 .completions_api()
169 .extractor::<Feedback>(model)
170 .build()
171 .extract(prompt.as_str())
172 .await
173 }
174 ProviderChoice::Anthropic => {
175 // TODO(anthropic-happy-path): the Anthropic success path has no
176 // wiremock coverage — its native messages-API response shape differs
177 // from the OpenAI envelope `mount_tool_call` builds, so it needs its
178 // own tool_use-block fixture. AC3 exercises only the Anthropic key
179 // guard. See docs/agent-notes/llm.md.
180 let client = anthropic::Client::builder()
181 .api_key(key)
182 .base_url(base_url)
183 .build()
184 .map_err(|e| FeedbackError::Client(e.to_string()))?;
185 client
186 .extractor::<Feedback>(model)
187 .build()
188 .extract(prompt.as_str())
189 .await
190 }
191 };
192
193 extracted.map(Verdict::from).map_err(FeedbackError::from)
194}
195
196/// Read the active provider's API key, treating unset **or empty** as missing.
197///
198/// The boundary guard for [`request_feedback`] (§1.3.1): it runs before any rig
199/// client is built or socket opened, so a missing key surfaces as a clear typed
200/// error naming the variable rather than a downstream auth failure or connection
201/// error. An empty string is treated as unset — some shells export an empty value.
202fn require_api_key(provider: ProviderChoice) -> Result<String, FeedbackError> {
203 let var = provider.key_var();
204 match std::env::var(var) {
205 Ok(key) if !key.is_empty() => Ok(key),
206 _ => Err(FeedbackError::MissingApiKey { var }),
207 }
208}
209
210#[cfg(test)]
211mod tests {
212 use super::*;
213
214 #[test]
215 fn feedback_maps_to_the_matching_verdict_variant() {
216 // The bool's meaning lives in the variant: is_correct true → Correct,
217 // false → Incorrect, and the message passes through unchanged.
218 let correct: Verdict = Feedback {
219 is_correct: true,
220 feedback_message: "well done".to_string(),
221 }
222 .into();
223 assert_eq!(
224 correct,
225 Verdict::Correct {
226 message: "well done".to_string()
227 }
228 );
229
230 let incorrect: Verdict = Feedback {
231 is_correct: false,
232 feedback_message: "try again".to_string(),
233 }
234 .into();
235 assert_eq!(
236 incorrect,
237 Verdict::Incorrect {
238 message: "try again".to_string()
239 }
240 );
241 }
242
243 #[test]
244 fn extraction_failures_map_to_the_extraction_kind() {
245 use rig_core::extractor::ExtractionError;
246
247 // No tool call at all → an Extraction error: the response, not transport,
248 // failed to yield a verdict.
249 let no_data: FeedbackError = ExtractionError::NoData.into();
250 assert!(
251 matches!(no_data, FeedbackError::Extraction(_)),
252 "NoData is an extraction failure, got {no_data:?}"
253 );
254
255 // A malformed/missing field → an Extraction error, kept distinct from a
256 // transport-level Completion failure (§1.2).
257 let deser_err = serde_json::from_str::<i32>("not-an-int").unwrap_err();
258 let bad_field: FeedbackError = ExtractionError::DeserializationError(deser_err).into();
259 assert!(
260 matches!(bad_field, FeedbackError::Extraction(_)),
261 "a deserialize failure is an extraction failure, got {bad_field:?}"
262 );
263 }
264
265 #[test]
266 fn completion_errors_map_to_the_completion_kind() {
267 use rig_core::completion::CompletionError;
268 use rig_core::extractor::ExtractionError;
269
270 // A provider/transport-level failure (e.g. a 401) maps to Completion, kept
271 // distinct from a malformed-response Extraction failure (§1.2) so the
272 // caller can tell "fix the key / retry" from "the model's output was bad".
273 let provider_err = ExtractionError::CompletionError(CompletionError::ProviderError(
274 "401 unauthorized".to_string(),
275 ));
276 let mapped: FeedbackError = provider_err.into();
277 assert!(
278 matches!(mapped, FeedbackError::Completion(_)),
279 "a completion failure maps to Completion, got {mapped:?}"
280 );
281 }
282}