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}