1use std::sync::Arc;
5
6use anyhow::{Context, Result};
7use chrono::Utc;
8use headless_lms_base::config::ApplicationConfiguration;
9use headless_lms_models::{
10 PKeyPolicy,
11 agent_changeset_calls::CallActor,
12 exercise_slide_submissions::{self, NewExerciseSlideSubmission},
13 exercise_task_gradings::UserPointsUpdateStrategy,
14 library::{
15 agent_changesets::{
16 RiskBaseline, StageCall, StagedEntity, StagedStateRead,
17 course_state::{ExerciseRecord, ExerciseReviewConfig},
18 stage_call,
19 },
20 entity_snapshots::{self, ContentEntityType, EntityKey, EntitySnapshot},
21 oauth::{mcp::McpScope, scopes::metadata_document_client_scopes},
22 },
23 oauth_client::{MetadataDocumentClient, OAuthClient},
24 oauth_user_client_scopes::OAuthUserClientScopes,
25 pages::{self, PageDetailsUpdate},
26 peer_or_self_review_configs::{self, CmsPeerOrSelfReviewConfig, PeerReviewProcessingStrategy},
27 peer_or_self_review_submissions,
28};
29use serde_json::json;
30use sqlx::PgConnection;
31use tracing::info;
32use uuid::Uuid;
33
34use crate::programs::seed::{
35 builder::{
36 chapter::ChapterBuilder,
37 context::SeedContext,
38 course::{CourseBuilder, CourseInstanceConfig},
39 exercise::{ExerciseBuilder, ExerciseIds},
40 module::ModuleBuilder,
41 page::PageBuilder,
42 },
43 seed_courses::CommonCourseData,
44 seed_helpers::{heading, paragraph},
45 seed_users::SeedUsersResult,
46};
47
48const AGENT_CLIENT_ID: &str = "https://agent.example.com/oauth/client-metadata.json";
50const AGENT_CLIENT_NAME: &str = "Example Agent";
51
52pub async fn seed_agent_changesets_course(
54 app_config: ApplicationConfiguration,
55 course_id: Uuid,
56 course_name: &str,
57 course_slug: &str,
58 common_course_data: CommonCourseData,
59 seed_users_result: SeedUsersResult,
60) -> Result<Uuid> {
61 let CommonCourseData {
62 db_pool,
63 organization_id: org,
64 teacher_user_id,
65 student_user_id: student,
66 ..
67 } = common_course_data;
68 let mut conn = db_pool.acquire().await?;
69 let cx = SeedContext {
70 teacher: teacher_user_id,
71 org,
72 base_course_ns: course_id,
73 };
74
75 info!("Inserting agent changesets course {}", course_name);
76
77 let publisher = seed_users_result.admin_user_id;
80 let conflicted = teacher_user_id;
81 let review_settings = seed_users_result.language_teacher_user_id;
82 let grant_holder = seed_users_result.course_or_exam_creator_user_id;
83
84 let course = CourseBuilder::new(course_name, course_slug)
85 .desc("Sample course with changes an AI agent has staged for review.")
86 .course_id(course_id)
87 .instance(CourseInstanceConfig {
88 name: None,
89 description: None,
90 support_email: None,
91 teacher_in_charge_name: "admin".to_string(),
92 teacher_in_charge_email: "admin@example.com".to_string(),
93 opening_time: None,
94 closing_time: None,
95 instance_id: Some(cx.v5(b"instance:default")),
96 })
97 .module(
98 ModuleBuilder::new().order(0).chapter(
99 ChapterBuilder::new(1, "Getting started")
100 .opens(Utc::now())
101 .fixed_ids(cx.v5(b"chapter:1"), cx.v5(b"chapter:1:instance"))
102 .page(
103 PageBuilder::new("/chapter-1/variables", "Variables")
104 .block(heading("Variables", cx.v5(b"variables:heading"), 2))
105 .block(paragraph(
106 "A variable stores a value under a name.",
107 cx.v5(b"variables:paragraph"),
108 )),
109 )
110 .page(
111 PageBuilder::new("/chapter-1/old-notes", "Old notes").block(paragraph(
112 "These notes are no longer needed.",
113 cx.v5(b"old-notes:paragraph"),
114 )),
115 )
116 .page(
117 PageBuilder::new("/chapter-1/functions", "Functions")
118 .block(paragraph(
119 "A function is a named piece of code.",
120 cx.v5(b"functions:paragraph"),
121 ))
122 .exercise(ExerciseBuilder::example_exercise(
123 "Functions quiz",
124 ExerciseIds {
125 exercise_id: cx.v5(b"functions:exercise"),
126 slide_id: cx.v5(b"functions:exercise:slide"),
127 task_id: cx.v5(b"functions:exercise:task"),
128 block_id: cx.v5(b"functions:exercise:block"),
129 },
130 vec![paragraph(
131 "Which keyword defines a function in Python?",
132 cx.v5(b"functions:exercise:prompt"),
133 )],
134 json!([
135 {
136 "name": "def",
137 "correct": true,
138 "id": cx.v5(b"functions:exercise:option:1")
139 },
140 {
141 "name": "fun",
142 "correct": false,
143 "id": cx.v5(b"functions:exercise:option:2")
144 }
145 ]),
146 false,
147 None,
148 None,
149 )),
150 )
151 .page(
152 PageBuilder::new("/chapter-1/classes", "Classes")
153 .block(paragraph(
154 "A class describes what objects of one kind have in common.",
155 cx.v5(b"classes:paragraph"),
156 ))
157 .exercise(ExerciseBuilder::example_exercise(
158 "Classes quiz",
159 ExerciseIds {
160 exercise_id: cx.v5(b"classes:exercise"),
161 slide_id: cx.v5(b"classes:exercise:slide"),
162 task_id: cx.v5(b"classes:exercise:task"),
163 block_id: cx.v5(b"classes:exercise:block"),
164 },
165 vec![paragraph(
166 "Which keyword defines a class in Python?",
167 cx.v5(b"classes:exercise:prompt"),
168 )],
169 json!([
170 {
171 "name": "class",
172 "correct": true,
173 "id": cx.v5(b"classes:exercise:option:1")
174 },
175 {
176 "name": "object",
177 "correct": false,
178 "id": cx.v5(b"classes:exercise:option:2")
179 }
180 ]),
181 false,
182 None,
183 None,
184 )),
185 )
186 .page(
187 PageBuilder::new("/chapter-1/strings", "Strings").block(paragraph(
188 "A string is a piece of text.",
189 cx.v5(b"strings:paragraph"),
190 )),
191 )
192 .page(
193 PageBuilder::new("/chapter-1/objects", "Objects")
194 .block(paragraph(
195 "An object is a value made from a class.",
196 cx.v5(b"objects:paragraph"),
197 ))
198 .exercise(peer_reviewed_exercise(&cx, "Objects essay", "objects"))
199 .exercise(peer_reviewed_exercise(&cx, "Methods essay", "methods")),
200 ),
201 ),
202 );
203 let (course, _default_instance, _last_module) =
204 course.seed(&mut conn, &app_config, &cx).await?;
205
206 let agent_client = OAuthClient::upsert_metadata_document_client(
207 &mut conn,
208 MetadataDocumentClient {
209 client_id: AGENT_CLIENT_ID,
210 client_name: AGENT_CLIENT_NAME,
211 redirect_uris: &["http://127.0.0.1/callback".to_string()],
212 client_uri: Some("https://agent.example.com"),
213 scopes: &metadata_document_client_scopes(),
214 },
215 )
216 .await?;
217 let agent_scopes = [
218 McpScope::MaterialRead,
219 McpScope::MaterialStage,
220 McpScope::MaterialPublish,
221 ]
222 .map(|scope| scope.as_str().to_string());
223 for user_id in [publisher, conflicted, grant_holder, review_settings] {
224 OAuthUserClientScopes::insert(&mut conn, user_id, agent_client.id, &agent_scopes).await?;
225 }
226
227 let page_ids = PageIds::load(&mut conn, course.id).await?;
228
229 let publisher_actor = agent_actor(&mut conn, publisher, agent_client.id).await?;
230 stage_page_edit(
231 &mut conn,
232 &publisher_actor,
233 course.id,
234 page_ids.variables,
235 "Explain variables with an example",
236 |snapshot| {
237 Arc::make_mut(&mut snapshot.content).push(paragraph(
238 "For example, age = 30 stores the number 30 under the name age.",
239 cx.v5(b"variables:staged-example"),
240 ));
241 },
242 )
243 .await?;
244 let old_notes = EntityKey::new(ContentEntityType::Page, page_ids.old_notes);
245 let read = StagedStateRead::current(&mut conn, publisher, course.id, &[old_notes]).await?;
246 stage_call(
247 &mut conn,
248 StageCall {
249 actor: &publisher_actor,
250 course_id: course.id,
251 tool_name: "patch_course_structure",
252 ops: &json!([{ "op": "remove", "path": format!("/pages/{}", page_ids.old_notes) }]),
253 summary: Some("Remove the outdated notes page"),
254 entities: vec![StagedEntity::Delete(old_notes)],
255 revert_of_call_id: None,
256 read: &read,
257 risk: &RiskBaseline::Current,
258 },
259 )
260 .await?;
261
262 let conflicted_actor = agent_actor(&mut conn, conflicted, agent_client.id).await?;
263 stage_page_edit(
264 &mut conn,
265 &conflicted_actor,
266 course.id,
267 page_ids.functions,
268 "Add a heading and an example to the functions page",
269 |snapshot| {
270 Arc::make_mut(&mut snapshot.content).insert(
271 0,
272 heading("Functions", cx.v5(b"functions:staged-heading"), 2),
273 );
274 Arc::make_mut(&mut snapshot.content).insert(
275 2,
276 paragraph(
277 "For example, def greet(): defines a function called greet.",
278 cx.v5(b"functions:staged-example"),
279 ),
280 );
281 },
282 )
283 .await?;
284 stage_page_edit(
285 &mut conn,
286 &conflicted_actor,
287 course.id,
288 page_ids.strings,
289 "Explain string concatenation",
290 |snapshot| {
291 Arc::make_mut(&mut snapshot.content).push(paragraph(
292 "Strings can be joined with the + operator.",
293 cx.v5(b"strings:staged-concatenation"),
294 ));
295 },
296 )
297 .await?;
298 stage_page_edit_with_tool(
299 &mut conn,
300 &conflicted_actor,
301 course.id,
302 page_ids.classes,
303 "patch_exercise",
304 &json!([{ "op": "replace", "path": "/exercises/0/name" }]),
305 "Clarify the classes exercise",
306 |snapshot| {
307 snapshot.exercises[0].name = "Classes quiz: keywords".to_string();
308 snapshot.exercise_tasks[0].assignment = json!([paragraph(
309 "Which Python keyword starts the definition of a class?",
310 cx.v5(b"classes:exercise:staged-prompt"),
311 )]);
312 },
313 )
314 .await?;
315
316 let course_default = peer_or_self_review_configs::upsert_with_id(
317 &mut conn,
318 PKeyPolicy::Fixed(cx.v5(b"course-default-review")),
319 &CmsPeerOrSelfReviewConfig {
320 id: cx.v5(b"course-default-review"),
321 course_id: course.id,
322 exercise_id: None,
323 peer_reviews_to_give: 2,
324 peer_reviews_to_receive: 1,
325 accepting_threshold: 2.1,
326 processing_strategy: PeerReviewProcessingStrategy::AutomaticallyGradeByAverage,
327 points_are_all_or_nothing: true,
328 reset_answer_if_zero_points_from_review: false,
329 review_instructions: Some(json!([paragraph(
330 "Read the whole answer before you rate it.",
331 cx.v5(b"course-default-review:instructions"),
332 )])),
333 },
334 )
335 .await?;
336 let methods_exercise_id = cx.v5(b"methods:exercise");
337 let submission = exercise_slide_submissions::insert_exercise_slide_submission_with_id(
338 &mut conn,
339 cx.v5(b"methods:student-submission"),
340 &NewExerciseSlideSubmission {
341 exercise_slide_id: cx.v5(b"methods:exercise:slide"),
342 course_id: Some(course.id),
343 exam_id: None,
344 exercise_id: methods_exercise_id,
345 user_id: student,
346 user_points_update_strategy: UserPointsUpdateStrategy::CanAddPointsAndCanRemovePoints,
347 },
348 )
349 .await?;
350 peer_or_self_review_submissions::insert(
351 &mut conn,
352 PKeyPolicy::Fixed(cx.v5(b"methods:student-review")),
353 student,
354 methods_exercise_id,
355 course.id,
356 course_default.id,
357 submission.id,
358 )
359 .await?;
360
361 let review_settings_actor = agent_actor(&mut conn, review_settings, agent_client.id).await?;
362 stage_course_default_review_instructions(
363 &mut conn,
364 &review_settings_actor,
365 course.id,
366 course_default.id,
367 json!([paragraph(
368 "Read the whole answer before you rate it, and say what was done well.",
369 cx.v5(b"course-default-review:staged-instructions"),
370 )]),
371 )
372 .await?;
373 let objects = entity_snapshots::load_page(&mut conn, page_ids.objects)
374 .await?
375 .context("seeded page not found")?;
376 let course_default_with_questions =
377 peer_or_self_review_configs::get_course_default_cms_peer_review_configuration(
378 &mut conn, course.id,
379 )
380 .await?;
381 let mut bundles = objects.exercise_bundles();
382 let methods = bundles
383 .iter_mut()
384 .find(|bundle| bundle.exercise.id == methods_exercise_id)
385 .context("seeded methods exercise not found")?;
386 let before = ExerciseRecord::of_bundle(objects.id, methods);
387 let forked = ExerciseRecord {
388 review_config: ExerciseReviewConfig::Own {
389 config_id: cx.v5(b"methods:own-review-config"),
390 review_instructions: Some(Arc::new(json!([paragraph(
391 "Check that the answer calls a method on an object.",
392 cx.v5(b"methods:staged-review-instructions"),
393 )]))),
394 },
395 ..before.clone()
396 };
397 forked
398 .apply_to(
399 &before,
400 methods,
401 course_default_with_questions.as_ref(),
402 Uuid::new_v4,
403 )
404 .map_err(|error| anyhow::anyhow!("forking the seeded review config failed: {error:?}"))?;
405 stage_page_edit_with_tool(
406 &mut conn,
407 &review_settings_actor,
408 course.id,
409 page_ids.objects,
410 "patch_exercise",
411 &json!([{ "op": "replace", "path": "/review_instructions/0" }]),
412 "Give the methods essay its own review instructions",
413 |snapshot| snapshot.set_exercise_bundles(&bundles),
414 )
415 .await?;
416
417 pages::update_page_details(
419 &mut conn,
420 page_ids.strings,
421 &PageDetailsUpdate {
422 title: "Strings and text".to_string(),
423 url_path: "/chapter-1/strings".to_string(),
424 },
425 )
426 .await?;
427
428 Ok(course.id)
429}
430
431struct PageIds {
432 variables: Uuid,
433 old_notes: Uuid,
434 functions: Uuid,
435 classes: Uuid,
436 strings: Uuid,
437 objects: Uuid,
438}
439
440impl PageIds {
441 async fn load(conn: &mut PgConnection, course_id: Uuid) -> Result<Self> {
442 let course_pages = pages::get_pages_by_course_id(conn, course_id).await?;
443 let find = |url_path: &str| {
444 course_pages
445 .iter()
446 .find(|page| page.url_path == url_path)
447 .map(|page| page.id)
448 .with_context(|| format!("seeded page {url_path} not found"))
449 };
450 Ok(Self {
451 variables: find("/chapter-1/variables")?,
452 old_notes: find("/chapter-1/old-notes")?,
453 functions: find("/chapter-1/functions")?,
454 classes: find("/chapter-1/classes")?,
455 strings: find("/chapter-1/strings")?,
456 objects: find("/chapter-1/objects")?,
457 })
458 }
459}
460
461fn peer_reviewed_exercise(cx: &SeedContext, name: &str, key: &str) -> ExerciseBuilder {
463 let id = |part: &str| cx.v5(format!("{key}:exercise{part}").as_bytes());
464 ExerciseBuilder::example_exercise(
465 name,
466 ExerciseIds {
467 exercise_id: id(""),
468 slide_id: id(":slide"),
469 task_id: id(":task"),
470 block_id: id(":block"),
471 },
472 vec![paragraph(
473 &format!("Write a short essay for the {name}."),
474 id(":prompt"),
475 )],
476 json!([
477 { "name": "Done", "correct": true, "id": id(":option:1") }
478 ]),
479 true,
480 None,
481 None,
482 )
483}
484
485async fn agent_actor(
486 conn: &mut PgConnection,
487 user_id: Uuid,
488 oauth_client_id: Uuid,
489) -> Result<CallActor> {
490 let grant = OAuthUserClientScopes::find_grant(conn, user_id, oauth_client_id)
491 .await?
492 .context("seeded agent grant not found")?;
493 Ok(CallActor {
494 user_id,
495 oauth_client_id,
496 oauth_grant_id: grant.id,
497 client_name: AGENT_CLIENT_NAME.to_string(),
498 })
499}
500
501async fn stage_page_edit(
503 conn: &mut PgConnection,
504 actor: &CallActor,
505 course_id: Uuid,
506 page_id: Uuid,
507 summary: &str,
508 edit: impl FnOnce(&mut entity_snapshots::PageSnapshot),
509) -> Result<()> {
510 stage_page_edit_with_tool(
511 conn,
512 actor,
513 course_id,
514 page_id,
515 "patch_page_content",
516 &json!([{ "op": "add", "path": "/content/-" }]),
517 summary,
518 edit,
519 )
520 .await
521}
522
523async fn stage_course_default_review_instructions(
526 conn: &mut PgConnection,
527 actor: &CallActor,
528 course_id: Uuid,
529 config_id: Uuid,
530 review_instructions: serde_json::Value,
531) -> Result<()> {
532 let key = EntityKey::new(ContentEntityType::CourseDefaultReviewConfig, config_id);
533 let Some(Some(EntitySnapshot::CourseDefaultReviewConfig(mut snapshot))) =
534 entity_snapshots::load_live_many(conn, &[key])
535 .await?
536 .remove(&key)
537 else {
538 anyhow::bail!("seeded course default review config not found");
539 };
540 snapshot.review_instructions = Some(review_instructions);
541 let read = StagedStateRead::current(conn, actor.user_id, course_id, &[key]).await?;
542 stage_call(
543 conn,
544 StageCall {
545 actor,
546 course_id,
547 tool_name: "patch_exercise",
548 ops: &json!([{ "op": "replace", "path": "/review_instructions/0" }]),
549 summary: Some("Ask reviewers to say what was done well"),
550 entities: vec![StagedEntity::Write(snapshot.into())],
551 revert_of_call_id: None,
552 read: &read,
553 risk: &RiskBaseline::Current,
554 },
555 )
556 .await?;
557 Ok(())
558}
559
560#[allow(clippy::too_many_arguments)]
561async fn stage_page_edit_with_tool(
562 conn: &mut PgConnection,
563 actor: &CallActor,
564 course_id: Uuid,
565 page_id: Uuid,
566 tool_name: &str,
567 ops: &serde_json::Value,
568 summary: &str,
569 edit: impl FnOnce(&mut entity_snapshots::PageSnapshot),
570) -> Result<()> {
571 let mut snapshot = entity_snapshots::load_page(conn, page_id)
572 .await?
573 .context("seeded page not found")?;
574 edit(&mut snapshot);
575 let read = StagedStateRead::current(
576 conn,
577 actor.user_id,
578 course_id,
579 &[EntityKey::new(ContentEntityType::Page, page_id)],
580 )
581 .await?;
582 stage_call(
583 conn,
584 StageCall {
585 actor,
586 course_id,
587 tool_name,
588 ops,
589 summary: Some(summary),
590 entities: vec![StagedEntity::Write(EntitySnapshot::Page(snapshot))],
591 revert_of_call_id: None,
592 read: &read,
593 risk: &RiskBaseline::Current,
594 },
595 )
596 .await?;
597 Ok(())
598}