headless_lms_server/controllers/cms/
ai_suggestions.rs

1//! Controllers for requests starting with `/api/v0/cms/ai-suggestions`.
2use headless_lms_models::application_task_default_language_models::{self, ApplicationTask};
3use headless_lms_models::cms_ai::ParagraphSuggestionAction;
4use utoipa::{OpenApi, ToSchema};
5
6use crate::prelude::*;
7
8#[derive(Debug, Serialize, Deserialize, ToSchema)]
9
10pub struct ParagraphSuggestionMeta {
11    pub tone: Option<String>,
12    pub language: Option<String>,
13    pub setting_type: Option<String>,
14}
15
16#[derive(Debug, Serialize, Deserialize, ToSchema)]
17
18pub struct ParagraphSuggestionContext {
19    pub page_id: Option<Uuid>,
20    pub course_id: Option<Uuid>,
21    pub locale: Option<String>,
22}
23
24#[derive(Debug, Serialize, Deserialize, ToSchema)]
25
26pub struct ParagraphSuggestionRequest {
27    pub action: ParagraphSuggestionAction,
28    pub content: String,
29    pub is_html: bool,
30    pub meta: Option<ParagraphSuggestionMeta>,
31    pub context: Option<ParagraphSuggestionContext>,
32}
33
34#[derive(Serialize, Deserialize, ToSchema)]
35
36pub struct ParagraphSuggestionResponse {
37    pub suggestions: Vec<String>,
38}
39
40#[derive(OpenApi)]
41#[openapi(paths(suggest_paragraph))]
42pub(crate) struct CmsAiSuggestionsApiDoc;
43
44/**
45POST `/api/v0/cms/ai-suggestions/paragraph` - Generate AI suggestions for a CMS paragraph.
46
47This endpoint is intended for CMS editors. It requires the user to have edit access
48to the referenced page when `context.page_id` is provided, otherwise it falls back
49to requiring a teaching role for some course via `Res::AnyCourse`.
50*/
51#[instrument(skip(pool, app_conf))]
52#[utoipa::path(
53    post,
54    path = "/paragraph",
55    operation_id = "requestParagraphSuggestions",
56    tag = "cms_ai_suggestions",
57    request_body = ParagraphSuggestionRequest,
58    responses(
59        (status = 200, description = "Generated paragraph suggestions", body = ParagraphSuggestionResponse)
60    )
61)]
62async fn suggest_paragraph(
63    pool: web::Data<PgPool>,
64    app_conf: web::Data<ApplicationConfiguration>,
65    user: AuthUser,
66    payload: web::Json<ParagraphSuggestionRequest>,
67) -> ControllerResult<web::Json<ParagraphSuggestionResponse>> {
68    let mut conn = pool.acquire().await?;
69
70    // Basic validation of input content.
71    if payload.content.trim().is_empty() {
72        return Err(ControllerError::new(
73            ControllerErrorType::BadRequest,
74            "Paragraph content must not be empty.".to_string(),
75            None,
76        ));
77    }
78
79    // Authorize: prefer page-level edit permission when page_id is available,
80    // otherwise require that the user can teach at least one course.
81    let token = if let Some(ParagraphSuggestionContext {
82        page_id: Some(page_id),
83        ..
84    }) = &payload.context
85    {
86        authorize(&mut conn, Act::Edit, Some(user.id), Res::Page(*page_id)).await?
87    } else {
88        authorize(&mut conn, Act::Teach, Some(user.id), Res::AnyCourse).await?
89    };
90
91    let task_lm = application_task_default_language_models::get_for_task(
92        &mut conn,
93        ApplicationTask::CmsParagraphSuggestion,
94    )
95    .await?;
96
97    let meta = payload.meta.as_ref();
98    let generator_input = headless_lms_chatbot::cms_ai_suggestion::CmsParagraphSuggestionInput {
99        action: payload.action,
100        content: payload.content.clone(),
101        is_html: payload.is_html,
102        meta_tone: meta.and_then(|m| m.tone.clone()),
103        meta_language: meta.and_then(|m| m.language.clone()),
104        meta_setting_type: meta.and_then(|m| m.setting_type.clone()),
105    };
106
107    // Return the DB connection to the pool before the LLM call.
108    drop(conn);
109
110    let suggestions = headless_lms_chatbot::cms_ai_suggestion::generate_paragraph_suggestions(
111        &app_conf,
112        task_lm,
113        &generator_input,
114    )
115    .await?;
116
117    token.authorized_ok(web::Json(ParagraphSuggestionResponse { suggestions }))
118}
119
120/**
121Add a route for each controller in this module.
122
123The name starts with an underline in order to appear before other functions in the module documentation.
124
125We add the routes by calling the route method instead of using the route annotations because this method preserves the function signatures for documentation.
126*/
127pub fn _add_routes(cfg: &mut ServiceConfig) {
128    cfg.route("/paragraph", web::post().to(suggest_paragraph));
129}