Skip to main content

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(Debug, Serialize, Deserialize, ToSchema)]
41
42pub struct ChartSpecGenerationRequest {
43    pub prompt: String,
44    pub current_spec: Option<String>,
45    pub data_url: Option<String>,
46    pub data_format: Option<String>,
47    pub data_sample: Option<String>,
48    pub page_id: Option<Uuid>,
49}
50
51#[derive(Serialize, Deserialize, ToSchema)]
52
53pub struct ChartSpecGenerationResponse {
54    pub spec: String,
55}
56
57#[derive(OpenApi)]
58#[openapi(paths(suggest_paragraph, generate_chart_spec))]
59pub(crate) struct CmsAiSuggestionsApiDoc;
60
61/**
62POST `/api/v0/cms/ai-suggestions/paragraph` - Generate AI suggestions for a CMS paragraph.
63
64This endpoint is intended for CMS editors. It requires the user to have edit access
65to the referenced page when `context.page_id` is provided, otherwise it falls back
66to requiring a teaching role for some course via `Res::AnyCourse`.
67*/
68#[instrument(skip(pool, app_conf))]
69#[utoipa::path(
70    post,
71    path = "/paragraph",
72    operation_id = "requestParagraphSuggestions",
73    tag = "cms_ai_suggestions",
74    request_body = ParagraphSuggestionRequest,
75    responses(
76        (status = 200, description = "Generated paragraph suggestions", body = ParagraphSuggestionResponse)
77    )
78)]
79async fn suggest_paragraph(
80    pool: web::Data<PgPool>,
81    app_conf: web::Data<ApplicationConfiguration>,
82    user: AuthUser,
83    payload: web::Json<ParagraphSuggestionRequest>,
84) -> ControllerResult<web::Json<ParagraphSuggestionResponse>> {
85    let mut conn = pool.acquire().await?;
86
87    // Basic validation of input content.
88    if payload.content.trim().is_empty() {
89        return Err(ControllerError::new(
90            ControllerErrorType::BadRequest,
91            "Paragraph content must not be empty.".to_string(),
92            None,
93        ));
94    }
95
96    // Authorize: prefer page-level edit permission when page_id is available,
97    // otherwise require that the user can teach at least one course.
98    let token = if let Some(ParagraphSuggestionContext {
99        page_id: Some(page_id),
100        ..
101    }) = &payload.context
102    {
103        authorize(&mut conn, Act::Edit, Some(user.id), Res::Page(*page_id)).await?
104    } else {
105        authorize(&mut conn, Act::Teach, Some(user.id), Res::AnyCourse).await?
106    };
107
108    let task_lm = application_task_default_language_models::get_for_task(
109        &mut conn,
110        ApplicationTask::CmsParagraphSuggestion,
111    )
112    .await?;
113
114    let meta = payload.meta.as_ref();
115    let generator_input = headless_lms_chatbot::cms_ai_suggestion::CmsParagraphSuggestionInput {
116        action: payload.action,
117        content: payload.content.clone(),
118        is_html: payload.is_html,
119        meta_tone: meta.and_then(|m| m.tone.clone()),
120        meta_language: meta.and_then(|m| m.language.clone()),
121        meta_setting_type: meta.and_then(|m| m.setting_type.clone()),
122    };
123
124    // Return the DB connection to the pool before the LLM call.
125    drop(conn);
126
127    let suggestions = headless_lms_chatbot::cms_ai_suggestion::generate_paragraph_suggestions(
128        &app_conf,
129        task_lm,
130        &generator_input,
131    )
132    .await?;
133
134    token.authorized_ok(web::Json(ParagraphSuggestionResponse { suggestions }))
135}
136
137/**
138POST `/api/v0/cms/ai-suggestions/chart-spec` - Generate a Vega-Lite chart specification from a prompt.
139
140This endpoint is intended for the CMS chart block editor. It requires the user to have
141edit access to the referenced page when `page_id` is provided, otherwise it falls back
142to requiring a teaching role for some course via `Res::AnyCourse`.
143*/
144// The payload carries the teacher's prompt, specification and a sample of their data; none of that
145// belongs in tracing spans.
146#[instrument(skip(pool, app_conf, payload, user))]
147#[utoipa::path(
148    post,
149    path = "/chart-spec",
150    operation_id = "requestChartSpecGeneration",
151    tag = "cms_ai_suggestions",
152    request_body = ChartSpecGenerationRequest,
153    responses(
154        (status = 200, description = "Generated Vega-Lite chart specification", body = ChartSpecGenerationResponse)
155    )
156)]
157async fn generate_chart_spec(
158    pool: web::Data<PgPool>,
159    app_conf: web::Data<ApplicationConfiguration>,
160    user: AuthUser,
161    payload: web::Json<ChartSpecGenerationRequest>,
162) -> ControllerResult<web::Json<ChartSpecGenerationResponse>> {
163    if payload.prompt.trim().is_empty() {
164        return Err(controller_err!(
165            BadRequest,
166            "The chart generation prompt must not be empty.".to_string()
167        ));
168    }
169
170    let mut conn = pool.acquire().await?;
171
172    let token = if let Some(page_id) = payload.page_id {
173        authorize(&mut conn, Act::Edit, Some(user.id), Res::Page(page_id)).await?
174    } else {
175        authorize(&mut conn, Act::Teach, Some(user.id), Res::AnyCourse).await?
176    };
177
178    // Resolve the course language so the chart's caption and labels are generated in it.
179    let language = match payload.page_id {
180        Some(page_id) => {
181            let page = models::pages::get_page(&mut conn, page_id).await?;
182            match page.course_id {
183                Some(course_id) => Some(
184                    models::courses::get_course(&mut conn, course_id)
185                        .await?
186                        .language_code,
187                ),
188                None => None,
189            }
190        }
191        None => None,
192    };
193
194    let task_lm = application_task_default_language_models::get_for_task(
195        &mut conn,
196        ApplicationTask::ChartSpecGeneration,
197    )
198    .await?;
199
200    let generator_input = headless_lms_chatbot::chart_spec_generation::ChartSpecGenerationInput {
201        prompt: payload.prompt.clone(),
202        current_spec: payload.current_spec.clone(),
203        data_url: payload.data_url.clone(),
204        data_format: payload.data_format.clone(),
205        data_sample: payload.data_sample.clone(),
206        language,
207    };
208
209    // Return the DB connection to the pool before the LLM call.
210    drop(conn);
211
212    let spec = headless_lms_chatbot::chart_spec_generation::generate_chart_spec(
213        &app_conf,
214        task_lm,
215        &generator_input,
216    )
217    .await?;
218
219    token.authorized_ok(web::Json(ChartSpecGenerationResponse { spec }))
220}
221
222/**
223Add a route for each controller in this module.
224
225The name starts with an underline in order to appear before other functions in the module documentation.
226
227We add the routes by calling the route method instead of using the route annotations because this method preserves the function signatures for documentation.
228*/
229pub fn _add_routes(cfg: &mut ServiceConfig) {
230    cfg.route("/paragraph", web::post().to(suggest_paragraph))
231        .route("/chart-spec", web::post().to(generate_chart_spec));
232}