Skip to main content

headless_lms_models/
external_courses.rs

1use crate::prelude::*;
2use headless_lms_utils::azure_embedding::create_embeddings;
3use pgvector::Vector;
4use serde::{Deserialize, Serialize};
5use utoipa::ToSchema;
6
7#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, ToSchema)]
8pub struct ExternalCourse {
9    pub id: Uuid,
10    pub created_at: DateTime<Utc>,
11    pub updated_at: DateTime<Utc>,
12    pub deleted_at: Option<DateTime<Utc>>,
13    pub name: String,
14    pub description: Option<String>,
15    pub url: String,
16    #[schema(value_type = Option<Vec<f32>>)]
17    pub name_embedding: Option<Vector>,
18    #[schema(value_type = Option<Vec<f32>>)]
19    pub description_embedding: Option<Vector>,
20    pub on_old_platform: bool,
21}
22
23#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, ToSchema)]
24pub struct NewExternalCourse {
25    name: String,
26    description: Option<String>,
27    url: String,
28    pub on_old_platform: bool,
29}
30
31#[derive(Debug, Serialize, Deserialize, ToSchema)]
32pub struct ExternalCourseOutput {
33    pub id: Uuid,
34    pub name: String,
35    pub description: Option<String>,
36    pub url: String,
37    pub on_old_platform: bool,
38}
39pub async fn create_external_course(
40    conn: &mut PgConnection,
41    app_config: &ApplicationConfiguration,
42    new: NewExternalCourse,
43) -> ModelResult<ExternalCourseOutput> {
44    let name_embedding = create_embeddings(app_config, vec![new.name.clone()])
45        .await?
46        .into_iter()
47        .next()
48        .ok_or_else(|| model_err!(Generic, "The embedding API returned no title embedding."))
49        .map(Vector::from)?;
50
51    let description_embedding = if let Some(description) = &new.description {
52        Some(
53            create_embeddings(app_config, vec![description.to_owned()])
54                .await?
55                .into_iter()
56                .next()
57                .ok_or_else(|| {
58                    model_err!(
59                        Generic,
60                        "The embedding API returned no description embedding."
61                    )
62                })
63                .map(Vector::from)?,
64        )
65    } else {
66        None
67    };
68
69    let res = sqlx::query_as!(
70        ExternalCourseOutput,
71        "
72INSERT INTO external_courses(
73    name,
74    description,
75    url,
76    name_embedding,
77    description_embedding,
78    on_old_platform
79    )
80VALUES(
81    $1,
82    $2,
83    $3,
84    $4,
85    $5,
86    $6
87    )
88RETURNING
89    id,
90    name,
91    description,
92    url,
93    on_old_platform
94        ",
95        new.name,
96        new.description,
97        new.url,
98        name_embedding,
99        description_embedding,
100        new.on_old_platform
101    )
102    .fetch_one(&mut *conn)
103    .await?;
104
105    Ok(res)
106}
107
108/**
109Gets all the external courses added to the database
110*/
111pub async fn get_all_external_courses(
112    conn: &mut PgConnection,
113) -> ModelResult<Vec<ExternalCourseOutput>> {
114    let res = sqlx::query_as!(
115        ExternalCourseOutput,
116        r#"
117SELECT
118    id,
119    name,
120    description,
121    url,
122    on_old_platform
123FROM external_courses
124WHERE deleted_at IS NULL
125        "#
126    )
127    .fetch_all(conn)
128    .await?;
129    Ok(res)
130}
131
132pub async fn get_external_course_by_id(
133    conn: &mut PgConnection,
134    id: Uuid,
135) -> ModelResult<ExternalCourseOutput> {
136    let res = sqlx::query_as!(
137        ExternalCourseOutput,
138        r#"
139SELECT
140    id,
141    name,
142    description,
143    url,
144    on_old_platform
145FROM external_courses
146WHERE id = $1
147AND deleted_at IS NULL
148        "#,
149        id
150    )
151    .fetch_one(conn)
152    .await?;
153    Ok(res)
154}
155
156/**
157Searches for external courses with a list of given keywords, with both matching its embedding vector to embeddings of external course name and description,
158and doing a keyword search to concatenated name and description tsvector.
159*/
160pub async fn get_external_courses_by_embeddings(
161    conn: &mut PgConnection,
162    keywords: Vec<String>,
163    embeddings: Vec<Vec<f32>>,
164) -> ModelResult<Vec<ExternalCourseOutput>> {
165    let embed_vecs: Vec<Vector> = embeddings.into_iter().map(Vector::from).collect();
166    let res = sqlx::query_as!(
167        ExternalCourseOutput,
168        r#"
169SELECT  t.id AS "id!",
170    t.name AS "name!",
171    t.description,
172    t.url AS "url!",
173    t.on_old_platform AS "on_old_platform!"
174FROM (
175    SELECT
176        ec.*,
177        LEAST(MIN(name_embedding <#> v.embedding),
178              MIN(description_embedding <#> v.embedding)) AS distance
179    FROM external_courses ec
180    CROSS JOIN unnest($1::vector[]) AS v(embedding)
181    WHERE deleted_at IS NULL
182    GROUP BY id
183    ORDER BY distance ASC
184    LIMIT 5
185) t
186UNION
187SELECT ec.id,
188       ec.name,
189       ec.description,
190       ec.url,
191       ec.on_old_platform
192FROM external_courses ec
193CROSS JOIN unnest($2::text[]) AS k(keyword)
194WHERE deleted_at IS NULL
195AND to_tsvector(
196    'english',
197    ec.name || ' ' || coalesce(ec.description, '')
198) @@ websearch_to_tsquery('english', k.keyword)
199
200      "#,
201        &embed_vecs as _,
202        &keywords,
203    )
204    .fetch_all(conn)
205    .await?;
206    Ok(res)
207}
208
209/**
210Delete external course based on id
211*/
212pub async fn delete_by_id(
213    conn: &mut PgConnection,
214    external_course_id: Uuid,
215) -> ModelResult<ExternalCourseOutput> {
216    let res = sqlx::query_as!(
217        ExternalCourseOutput,
218        r#"
219UPDATE external_courses
220SET deleted_at = now()
221WHERE id = $1
222AND deleted_at IS NULL
223RETURNING
224    id,
225    name,
226    description,
227    url,
228    on_old_platform
229        "#,
230        external_course_id
231    )
232    .fetch_one(conn)
233    .await?;
234    Ok(res)
235}
236
237/**
238Edit external course information
239*/
240pub async fn update_by_id(
241    conn: &mut PgConnection,
242    app_config: &ApplicationConfiguration,
243    update: ExternalCourseOutput,
244) -> ModelResult<ExternalCourseOutput> {
245    let old = get_external_course_by_id(conn, update.id).await?;
246    let name_embedding = if old.name != update.name {
247        Some(
248            create_embeddings(app_config, vec![update.name.clone()])
249                .await?
250                .into_iter()
251                .next()
252                .ok_or_else(|| model_err!(Generic, "The embedding API returned no name embedding."))
253                .map(Vector::from)?,
254        )
255    } else {
256        None
257    };
258    let description_embedding = if old.description != update.description {
259        if let Some(description) = &update.description {
260            Some(
261                create_embeddings(app_config, vec![description.clone()])
262                    .await?
263                    .into_iter()
264                    .next()
265                    .ok_or_else(|| {
266                        model_err!(
267                            Generic,
268                            "The embedding API returned no description embedding."
269                        )
270                    })
271                    .map(Vector::from)?,
272            )
273        } else {
274            None
275        }
276    } else {
277        None
278    };
279
280    let res = sqlx::query_as!(
281        ExternalCourseOutput,
282        r#"
283UPDATE external_courses
284SET name = $1,
285    description = $2,
286    url = $3,
287    name_embedding = COALESCE($4, name_embedding),
288    description_embedding = CASE WHEN $2::text IS NULL THEN NULL ELSE COALESCE($5, description_embedding) END,
289    on_old_platform = $6
290WHERE id = $7
291  AND deleted_at IS NULL
292  AND name = $8
293  AND description IS NOT DISTINCT FROM $9
294RETURNING
295        id,
296        name,
297        description,
298        url,
299        on_old_platform
300        "#,
301        update.name,
302        update.description,
303        update.url,
304        name_embedding,
305        description_embedding,
306        update.on_old_platform,
307        update.id,
308        old.name,
309        old.description
310    )
311    .fetch_one(conn)
312    .await?;
313
314    Ok(res)
315}