headless_lms_mcp/handlers/exercise/
specs.rs1use std::collections::HashMap;
4
5use headless_lms_models::{exercise_tasks, library::entity_snapshots::PageSnapshot};
6use serde_json::{Value, json};
7use sqlx::PgConnection;
8use uuid::Uuid;
9
10use crate::engine::path::escape_segment;
11use crate::engine::read::ReadState;
12use crate::engine::{EditError, EditErrorCode};
13use crate::text::serialized_len;
14use crate::tools::ToolError;
15
16const AUTHORITY: &str = "private_spec is what the editor saves and the only authoritative spec; the service derives the others from it when it changes.";
17const DERIVED_AT_PUBLISH: &str = "This task's private spec isn't published yet, so its public and model solution specs are derived at publish; they are null until then.";
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum SpecKind {
22 Private,
23 Public,
24 ModelSolution,
25}
26
27impl SpecKind {
28 const ALL: [SpecKind; 3] = [SpecKind::Private, SpecKind::Public, SpecKind::ModelSolution];
29
30 fn name(self) -> &'static str {
31 match self {
32 SpecKind::Private => "private_spec",
33 SpecKind::Public => "public_spec",
34 SpecKind::ModelSolution => "model_solution_spec",
35 }
36 }
37}
38
39#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct SpecPath {
42 pub task_id: Uuid,
43 pub kind: Option<SpecKind>,
45 pub pointer: String,
47}
48
49impl SpecPath {
50 fn to_path(&self, kind: Option<SpecKind>, pointer: &str) -> String {
52 let mut path = format!("/tasks/{}/specs", self.task_id);
53 if let Some(kind) = kind {
54 path.push('/');
55 path.push_str(kind.name());
56 path.push_str(pointer);
57 }
58 path
59 }
60}
61
62pub fn parse_spec_path(path: &str) -> Option<Result<SpecPath, EditError>> {
64 let rest = path.strip_prefix("/tasks/")?;
65 let (task, rest) = rest.split_once('/')?;
66 let rest = rest.strip_prefix("specs")?;
67 if !rest.is_empty() && !rest.starts_with('/') {
68 return None;
69 }
70 let invalid = || {
71 EditError::new(
72 EditErrorCode::InvalidPath,
73 format!("{path:?} isn't a spec path"),
74 )
75 .with_fix("spec paths are /tasks/<task id>/specs, /tasks/<task id>/specs/<private_spec|public_spec|model_solution_spec> and a JSON pointer after that, e.g. /tasks/<task id>/specs/private_spec/items/0")
76 };
77 let Ok(task_id) = Uuid::parse_str(task) else {
78 return Some(Err(invalid()));
79 };
80 if rest.is_empty() {
81 return Some(Ok(SpecPath {
82 task_id,
83 kind: None,
84 pointer: String::new(),
85 }));
86 }
87 let rest = &rest[1..];
88 let (kind, pointer) = match rest.split_once('/') {
89 Some((kind, pointer)) => (kind, format!("/{pointer}")),
90 None => (rest, String::new()),
91 };
92 let Some(kind) = SpecKind::ALL.into_iter().find(|k| k.name() == kind) else {
93 return Some(Err(invalid()));
94 };
95 Some(Ok(SpecPath {
96 task_id,
97 kind: Some(kind),
98 pointer,
99 }))
100}
101
102#[derive(Debug, Clone, Default, PartialEq)]
104pub struct TaskSpecs {
105 pub private_spec: Option<Value>,
106 pub public_spec: Option<Value>,
107 pub model_solution_spec: Option<Value>,
108 pub private_spec_files: Vec<Uuid>,
110 pub is_derived_at_publish: bool,
112}
113
114impl TaskSpecs {
115 fn spec(&self, kind: SpecKind) -> Option<&Value> {
116 match kind {
117 SpecKind::Private => self.private_spec.as_ref(),
118 SpecKind::Public => self.public_spec.as_ref(),
119 SpecKind::ModelSolution => self.model_solution_spec.as_ref(),
120 }
121 }
122}
123
124pub async fn load_task_specs(
127 conn: &mut PgConnection,
128 exercise_id: Uuid,
129 page: Option<&PageSnapshot>,
130 read_state: ReadState,
131) -> Result<HashMap<Uuid, TaskSpecs>, ToolError> {
132 let live: HashMap<Uuid, exercise_tasks::ExerciseTask> =
133 exercise_tasks::get_exercise_tasks_by_exercise_id(conn, exercise_id)
134 .await?
135 .into_iter()
136 .map(|task| (task.id, task))
137 .collect();
138 let snapshot_tasks = page.into_iter().flat_map(|page| {
139 let slides: Vec<Uuid> = page
140 .exercise_slides
141 .iter()
142 .filter(|slide| slide.exercise_id == exercise_id)
143 .map(|slide| slide.id)
144 .collect();
145 page.exercise_tasks
146 .iter()
147 .filter(move |task| slides.contains(&task.exercise_slide_id))
148 });
149 let mut specs = HashMap::new();
150 for task in snapshot_tasks {
151 let live_task = live.get(&task.id);
152 let is_published =
153 live_task.is_some_and(|live_task| live_task.private_spec == task.private_spec);
154 let derived = |spec: fn(&exercise_tasks::ExerciseTask) -> &Option<Value>| {
155 live_task
156 .filter(|_| is_published)
157 .and_then(|live_task| spec(live_task).clone())
158 };
159 specs.insert(
160 task.id,
161 TaskSpecs {
162 private_spec: task.private_spec.clone(),
163 public_spec: derived(|t| &t.public_spec),
164 model_solution_spec: derived(|t| &t.model_solution_spec),
165 private_spec_files: task.private_spec_files.clone(),
166 is_derived_at_publish: read_state == ReadState::Staged && !is_published,
167 },
168 );
169 }
170 Ok(specs)
171}
172
173pub fn read_specs(
175 path: &SpecPath,
176 specs: Option<&TaskSpecs>,
177 budget_bytes: usize,
178) -> Result<Value, ToolError> {
179 let specs = specs.ok_or_else(|| {
180 ToolError::Edit(EditError::new(
181 EditErrorCode::NotFound,
182 format!("the exercise has no task {} in this state", path.task_id),
183 ))
184 })?;
185 let value = match path.kind {
186 None => json!({
187 "private_spec": specs.private_spec,
188 "public_spec": specs.public_spec,
189 "model_solution_spec": specs.model_solution_spec,
190 }),
191 Some(kind) => {
192 let spec = specs.spec(kind).cloned().unwrap_or(Value::Null);
193 spec.pointer(&path.pointer).cloned().ok_or_else(|| {
194 ToolError::Edit(EditError::new(
195 EditErrorCode::NotFound,
196 format!("{} has nothing at {:?}", kind.name(), path.pointer),
197 ))
198 })?
199 }
200 };
201 let size = serialized_len(&value);
202 if size > budget_bytes {
203 let children: Vec<(Option<SpecKind>, String, &Value)> = match (path.kind, &value) {
204 (None, _) => SpecKind::ALL
205 .into_iter()
206 .filter_map(|kind| Some((Some(kind), String::new(), specs.spec(kind)?)))
207 .collect(),
208 (kind, Value::Object(map)) => map
209 .iter()
210 .map(|(key, child)| {
211 (
212 kind,
213 format!("{}/{}", path.pointer, escape_segment(key)),
214 child,
215 )
216 })
217 .collect(),
218 (kind, Value::Array(items)) => items
219 .iter()
220 .enumerate()
221 .map(|(index, child)| (kind, format!("{}/{index}", path.pointer), child))
222 .collect(),
223 _ => Vec::new(),
224 };
225 let path_count = children.len();
226 let smaller: Vec<Value> = children
227 .into_iter()
228 .take(100)
229 .map(|(kind, pointer, child)| {
230 json!({"path": path.to_path(kind, &pointer), "bytes": serialized_len(child)})
231 })
232 .collect();
233 return Err(ToolError::Edit(
234 EditError::new(
235 EditErrorCode::TooLarge,
236 format!(
237 "{} is {size} bytes, over the {budget_bytes} bytes a read may return",
238 path.to_path(path.kind, &path.pointer)
239 ),
240 )
241 .with_fix("read the smaller paths listed in details, one or a few per selector")
242 .with_details(json!({ "paths": smaller, "path_count": path_count })),
243 ));
244 }
245 let mut read = json!({
246 "path": path.to_path(path.kind, &path.pointer),
247 "value": value,
248 "note": AUTHORITY,
249 });
250 if path.kind.is_none_or(|kind| kind == SpecKind::Private) {
251 read["private_spec_files"] = json!(specs.private_spec_files);
252 }
253 if specs.is_derived_at_publish {
254 read["derived_at_publish"] = json!(DERIVED_AT_PUBLISH);
255 }
256 Ok(read)
257}