Skip to main content

headless_lms_server/programs/seed/seed_courses/
seed_agent_changesets.rs

1//! A live course with AI agent changesets waiting for review, for the review page, preview, page
2//! editor notice and agent grant system tests.
3
4use 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
48/// The client id of the seeded agent: the URL of its client ID metadata document.
49const AGENT_CLIENT_ID: &str = "https://agent.example.com/oauth/client-metadata.json";
50const AGENT_CLIENT_NAME: &str = "Example Agent";
51
52/// Seeds the agent changesets course with changesets waiting for review.
53pub 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    // One open changeset per user and course, so each system test that changes its changeset or
78    // grant gets its own user; `utils/agentChangesets.ts` in the system tests names them.
79    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    // Someone else edits the page after the agent staged it, so publishing must be refused.
418    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
461/// A peer reviewed exercise that uses the course default review config; `key` namespaces its ids.
462fn 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
501/// Stages a routine edit of the live page, as a `patch_page_content` call would.
502async 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
523/// Stages new course default review instructions, as a `patch_exercise` call with
524/// `review_config: edit_course_default` would.
525async 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}