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
108pub 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
156pub 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
209pub 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
237pub 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}