1use std::{
2 collections::{HashMap, HashSet},
3 env,
4 time::Duration,
5};
6
7use chrono::Utc;
8use dotenvy::dotenv;
9use sqlx::{PgConnection, PgPool};
10use url::Url;
11use uuid::Uuid;
12
13use crate::config::program_config::ProgramConfig;
14use crate::setup_tracing;
15
16use headless_lms_base::config::ApplicationConfiguration;
17use headless_lms_chatbot::{
18 azure_blob_storage::AzureBlobClient,
19 azure_datasources::{create_azure_datasource, does_azure_datasource_exist},
20 azure_search_index::{create_search_index, does_search_index_exist},
21 azure_search_indexer::{
22 check_search_indexer_status, create_search_indexer, does_search_indexer_exist,
23 run_search_indexer_now,
24 },
25 azure_skillset::{create_skillset, does_skillset_exist},
26 content_cleaner::convert_material_blocks_to_markdown_with_llm,
27};
28use headless_lms_models::{
29 application_task_default_language_models::ApplicationTask,
30 chapters::DatabaseChapter,
31 course_page_markdown_content::CoursePageMarkdownContent,
32 pages::{Page, PageVisibility},
33};
34use headless_lms_utils::{
35 document_schema_processor::{GutenbergBlock, remove_sensitive_attributes},
36 url_encoding::url_encode,
37};
38
39const SYNC_INTERVAL_SECS: u64 = 10;
40const PRINT_STILL_RUNNING_MESSAGE_TICKS_THRESHOLD: u32 = 60;
41const FAILURE_COOLDOWN_SECS: i64 = 300;
42const MAX_CONSECUTIVE_FAILURES: i32 = 5;
43
44pub async fn main() -> anyhow::Result<()> {
45 initialize_environment()?;
46 let config = initialize_configuration().await?;
47 if config.app_configuration.azure_configuration.is_none() {
48 warn!("Azure configuration not provided. Not running chatbot syncer.");
49 loop {
51 tokio::time::sleep(Duration::from_secs(u64::MAX)).await;
52 }
53 }
54 if config.app_configuration.test_chatbot {
55 warn!(
56 "Using mock azure configuration, this must be a test/dev environment. Not running chatbot syncer."
57 );
58 loop {
60 tokio::time::sleep(Duration::from_secs(u64::MAX)).await;
61 }
62 }
63
64 let db_pool = initialize_database_pool(&config.database_url).await?;
65 let mut conn = db_pool.acquire().await?;
66 let blob_client = initialize_blob_client(&config).await?;
67
68 let mut interval = tokio::time::interval(Duration::from_secs(SYNC_INTERVAL_SECS));
69 let mut ticks = 0;
70
71 info!("Starting chatbot syncer.");
72
73 loop {
74 interval.tick().await;
75 ticks += 1;
76
77 if ticks >= PRINT_STILL_RUNNING_MESSAGE_TICKS_THRESHOLD {
78 ticks = 0;
79 info!("Still syncing for chatbot.");
80 }
81 if let Err(e) = sync_pages(&mut conn, &config, &blob_client).await {
82 error!("Error during synchronization: {:?}", e);
83 }
84 }
85}
86
87fn initialize_environment() -> anyhow::Result<()> {
88 unsafe { env::set_var("RUST_LOG", "info,actix_web=info,sqlx=warn") };
90 dotenv().ok();
91 setup_tracing()?;
92 Ok(())
93}
94
95struct SyncerConfig {
96 database_url: String,
97 name: String,
98 app_configuration: ApplicationConfiguration,
99}
100
101async fn initialize_configuration() -> anyhow::Result<SyncerConfig> {
102 let database_url = ProgramConfig::database_url_with_default();
103 let base_url_raw = ProgramConfig::required("BASE_URL")?;
104 let base_url =
105 Url::parse(&base_url_raw).map_err(|e| anyhow::anyhow!("invalid BASE_URL: {}", e))?;
106
107 let name = base_url
108 .host_str()
109 .ok_or_else(|| anyhow::anyhow!("BASE_URL must have a host"))?
110 .replace(".", "-");
111
112 let app_configuration = ApplicationConfiguration::try_from_env()?;
113
114 Ok(SyncerConfig {
115 database_url,
116 name,
117 app_configuration,
118 })
119}
120
121async fn initialize_database_pool(database_url: &str) -> anyhow::Result<PgPool> {
123 PgPool::connect(database_url).await.map_err(|e| {
124 anyhow::anyhow!(
125 "Failed to connect to the database at {}: {:?}",
126 database_url,
127 e
128 )
129 })
130}
131
132async fn initialize_blob_client(config: &SyncerConfig) -> anyhow::Result<AzureBlobClient> {
134 let blob_client = AzureBlobClient::new(&config.app_configuration, &config.name).await?;
135 blob_client.ensure_container_exists().await?;
136 Ok(blob_client)
137}
138
139async fn sync_pages(
141 conn: &mut PgConnection,
142 config: &SyncerConfig,
143 blob_client: &AzureBlobClient,
144) -> anyhow::Result<()> {
145 let base_url = Url::parse(&config.app_configuration.base_url)?;
146 let chatbot_configs =
147 headless_lms_models::chatbot_configurations::get_for_azure_search_maintenance(conn).await?;
148
149 let course_ids: Vec<Uuid> = chatbot_configs
150 .iter()
151 .filter_map(|config| config.course_id)
152 .collect::<HashSet<_>>()
153 .into_iter()
154 .collect();
155
156 let sync_statuses =
157 headless_lms_models::chatbot_page_sync_statuses::ensure_sync_statuses_exist(
158 conn,
159 &course_ids,
160 )
161 .await?;
162
163 let latest_history_ids =
165 headless_lms_models::page_history::get_latest_page_history_ids_by_course_ids(
166 conn,
167 &course_ids,
168 )
169 .await?;
170
171 let shared_index_name = config.name.clone();
172 ensure_search_index_exists(
173 &shared_index_name,
174 &config.app_configuration,
175 &blob_client.container_name,
176 )
177 .await?;
178
179 if !check_search_indexer_status(&shared_index_name, &config.app_configuration).await? {
180 warn!("Search indexer is not ready to index. Skipping synchronization.");
181 return Ok(());
182 }
183
184 let mut any_changes = false;
185
186 for (course_id, statuses) in sync_statuses.iter() {
187 let page_ids: Vec<Uuid> = statuses.iter().map(|s| s.page_id).collect();
188 let public_pages_set: HashSet<Uuid> =
189 headless_lms_models::pages::get_by_ids_and_visibility(
190 conn,
191 &page_ids,
192 PageVisibility::Public,
193 )
194 .await?
195 .into_iter()
196 .map(|p| p.id)
197 .collect();
198
199 let outdated_statuses: Vec<_> = statuses
200 .iter()
201 .filter(|status| {
202 if !public_pages_set.contains(&status.page_id) {
203 return false;
204 }
205
206 let is_outdated = latest_history_ids
207 .get(&status.page_id)
208 .is_some_and(|history_id| {
209 status.synced_page_revision_id != Some(*history_id)
210 });
211
212 if !is_outdated {
213 return false;
214 }
215
216 if status.consecutive_failures >= MAX_CONSECUTIVE_FAILURES {
217 debug!(
218 "Skipping page {} due to permanent failure ({} consecutive failures). Manual intervention required.",
219 status.page_id, status.consecutive_failures
220 );
221 return false;
222 }
223
224 if let Some(error_msg) = &status.error_message
225 && !error_msg.is_empty() {
226 let error_age_seconds = (Utc::now() - status.updated_at).num_seconds();
227 if error_age_seconds < FAILURE_COOLDOWN_SECS {
228 debug!(
229 "Skipping page {} due to recent failure ({} seconds ago, {} consecutive failures): {}",
230 status.page_id, error_age_seconds, status.consecutive_failures, error_msg
231 );
232 return false;
233 }
234 }
235
236 true
237 })
238 .collect();
239
240 if outdated_statuses.is_empty() {
241 continue;
242 }
243
244 any_changes = true;
245 info!(
246 "Syncing {} pages for course id: {}.",
247 outdated_statuses.len(),
248 course_id
249 );
250 for status in &outdated_statuses {
251 info!(
252 "Page id: {}, synced page revision id: {:?}.",
253 status.page_id, status.synced_page_revision_id
254 );
255 }
256
257 let page_ids: Vec<Uuid> = outdated_statuses.iter().map(|s| s.page_id).collect();
258 let md_ids: Vec<Uuid> = outdated_statuses
259 .iter()
260 .filter_map(|s| s.converted_markdown_content_id)
261 .collect();
262 let pages = headless_lms_models::pages::get_by_ids_and_visibility(
263 conn,
264 &page_ids,
265 PageVisibility::Public,
266 )
267 .await?;
268
269 if !pages.is_empty() {
270 sync_pages_batch(
271 conn,
272 &pages,
273 &md_ids,
274 blob_client,
275 &base_url,
276 &config.app_configuration,
277 &latest_history_ids,
278 )
279 .await?;
280 } else {
281 info!("No pages to sync for course id: {}.", course_id);
282 }
283
284 let hidden_page_ids: Vec<Uuid> = statuses
285 .iter()
286 .filter(|status| {
287 !public_pages_set.contains(&status.page_id)
288 && status.synced_page_revision_id.is_some()
289 })
290 .map(|s| s.page_id)
291 .collect();
292
293 if !hidden_page_ids.is_empty() {
294 info!(
295 "Clearing sync statuses for {} hidden pages: {:?}",
296 hidden_page_ids.len(),
297 hidden_page_ids
298 );
299 headless_lms_models::chatbot_page_sync_statuses::clear_sync_statuses(
300 conn,
301 &hidden_page_ids,
302 )
303 .await?;
304 }
305
306 delete_old_files(conn, *course_id, blob_client).await?;
307 }
308
309 if any_changes {
310 run_search_indexer_now(&shared_index_name, &config.app_configuration).await?;
311 info!("New files have been synced and the search indexer has been started.");
312 }
313
314 Ok(())
315}
316
317async fn ensure_search_index_exists(
319 name: &str,
320 app_config: &ApplicationConfiguration,
321 container_name: &str,
322) -> anyhow::Result<()> {
323 if !does_search_index_exist(name, app_config).await? {
324 create_search_index(name.to_owned(), app_config).await?;
325 }
326 if !does_skillset_exist(name, app_config).await? {
327 create_skillset(name, name, app_config).await?;
328 }
329 if !does_azure_datasource_exist(name, app_config).await? {
330 create_azure_datasource(name, container_name, app_config).await?;
331 }
332 if !does_search_indexer_exist(name, app_config).await? {
333 create_search_indexer(name, name, name, name, app_config).await?;
334 }
335
336 Ok(())
337}
338
339async fn sync_pages_batch(
341 conn: &mut PgConnection,
342 pages: &[Page],
343 md_ids: &[Uuid],
345 blob_client: &AzureBlobClient,
346 base_url: &Url,
347 app_config: &ApplicationConfiguration,
348 latest_history_ids: &HashMap<Uuid, Uuid>,
349) -> anyhow::Result<()> {
350 let course_id = pages
351 .first()
352 .ok_or_else(|| anyhow::anyhow!("No pages to sync."))?
353 .course_id
354 .ok_or_else(|| anyhow::anyhow!("The first page does not belong to any course."))?;
355
356 let course = headless_lms_models::courses::get_course(conn, course_id).await?;
357 let chapters = headless_lms_models::chapters::get_course_chapters(conn, course_id).await?;
358 let md_contents =
359 headless_lms_models::course_page_markdown_content::get_many(conn, md_ids).await?;
360 let organization =
361 headless_lms_models::organizations::get_organization(conn, course.organization_id).await?;
362 let task_lm = headless_lms_models::application_task_default_language_models::get_for_task(
363 conn,
364 ApplicationTask::ContentCleaning,
365 )
366 .await?;
367
368 let mut base_url = base_url.clone();
369 base_url.set_path(&format!(
370 "/org/{}/courses/{}",
371 organization.slug, course.slug
372 ));
373
374 let mut allowed_file_paths = Vec::new();
375 let mut page_revision_map = HashMap::new();
376 let mut new_markdown_contents_map = HashMap::new();
378
379 for page in pages {
380 info!("Syncing page id: {}.", page.id);
381
382 let mut page_url = base_url.clone();
383 page_url.set_path(&format!("{}{}", base_url.path(), page.url_path));
384
385 let parsed_content: Vec<GutenbergBlock> = serde_json::from_value(page.content.clone())?;
386 let sanitized_blocks = remove_sensitive_attributes(parsed_content);
387
388 let page_md_content: Option<&CoursePageMarkdownContent> =
389 md_contents.iter().find(|x| x.page_id == page.id);
390 let latest_page_history_id: Option<&Uuid> = latest_history_ids.get(&page.id);
391
392 let up_to_date_md_content = page_md_content.and_then(|c| {
393 latest_page_history_id.and_then(|id| {
394 if id == &c.page_history_id {
395 Some(c.markdown_content.to_string())
396 } else {
397 None
398 }
399 })
400 });
401
402 let content_as_markdown = if let Some(content) = up_to_date_md_content.to_owned() {
403 info!("Using previously generated Markdown for page {}", page.id);
404 content
405 } else {
406 match convert_material_blocks_to_markdown_with_llm(
407 &sanitized_blocks,
408 app_config,
409 &task_lm,
410 )
411 .await
412 {
413 Ok(markdown) => {
414 info!("Successfully cleaned content for page {}", page.id);
415 if markdown.trim().is_empty() {
417 warn!(
418 "Markdown is empty for page {}. Generating fallback content with a fake heading.",
419 page.id
420 );
421 format!("# {}", page.title)
422 } else {
423 markdown
424 }
425 }
426 Err(e) => {
427 let error_msg = format!("Sync failed: LLM processing error: {}", e);
428 warn!(
429 "Failed to clean content with LLM for page {}: {}. Using serialized sanitized content instead.",
430 page.id, error_msg
431 );
432 if let Err(db_err) =
433 headless_lms_models::chatbot_page_sync_statuses::set_page_sync_error(
434 conn, page.id, &error_msg,
435 )
436 .await
437 {
438 warn!(
439 "Failed to record sync error for page {}: {:?}",
440 page.id, db_err
441 );
442 }
443 serde_json::to_string(&sanitized_blocks)?
445 }
446 }
447 };
448
449 if let Some(history_id) = latest_page_history_id
453 && up_to_date_md_content.is_none()
454 {
455 new_markdown_contents_map.insert(
456 page.id,
457 (history_id.to_owned(), content_as_markdown.to_owned()),
458 );
459 }
460
461 let blob_path = generate_blob_path(page)?;
462 let chapter: Option<&DatabaseChapter> = chapters
463 .iter()
464 .find(|c| page.chapter_id.is_some_and(|c_id| c_id == c.id));
465
466 allowed_file_paths.push(blob_path.clone());
467 let mut metadata = HashMap::new();
468 metadata.insert("url".to_string(), url_encode(page_url.as_ref()));
472 metadata.insert("title".to_string(), url_encode(&page.title));
473 metadata.insert(
474 "course_id".to_string(),
475 page.course_id.unwrap_or(Uuid::nil()).to_string().into(),
476 );
477 metadata.insert(
478 "language".to_string(),
479 course.language_code.to_string().into(),
480 );
481 metadata.insert("filepath".to_string(), blob_path.clone().into());
482 if let Some(c) = chapter {
483 metadata.insert(
484 "chunk_context".to_string(),
485 url_encode(&format!(
486 "This chunk is a snippet from page {} from chapter {}: {} of the course {}.",
487 page.title, c.chapter_number, c.name, course.name,
488 )),
489 );
490 } else {
491 metadata.insert(
492 "chunk_context".to_string(),
493 url_encode(&format!(
494 "This chunk is a snippet from page {} of the course {}.",
495 page.title, course.name,
496 )),
497 );
498 }
499
500 if let Err(e) = blob_client
501 .upload_file(&blob_path, content_as_markdown.as_bytes(), Some(metadata))
502 .await
503 {
504 let error_msg = format!("Sync failed: Upload error: {}", e);
505 warn!("Failed to upload file {}: {:?}", blob_path, e);
506 if let Err(db_err) =
507 headless_lms_models::chatbot_page_sync_statuses::set_page_sync_error(
508 conn, page.id, &error_msg,
509 )
510 .await
511 {
512 warn!(
513 "Failed to record upload error for page {}: {:?}",
514 page.id, db_err
515 );
516 }
517 } else if let Some(history_id) = latest_page_history_id {
518 page_revision_map.insert(page.id, *history_id);
519 }
520 }
521
522 if let Err(e) = headless_lms_models::chatbot_page_sync_statuses::save_markdown_content(
523 conn,
524 new_markdown_contents_map,
525 )
526 .await
527 {
528 warn!("Failed to save converted page content in DB: {}", e);
529 };
530
531 headless_lms_models::chatbot_page_sync_statuses::update_page_revision_ids(
533 conn,
534 page_revision_map,
535 )
536 .await?;
537
538 Ok(())
539}
540
541fn generate_blob_path(page: &Page) -> anyhow::Result<String> {
543 let course_id = page
544 .course_id
545 .ok_or_else(|| anyhow::anyhow!("Page {} does not belong to any course.", page.id))?;
546
547 Ok(format!("courses/{}/pages/{}.md", course_id, page.id))
548}
549
550async fn delete_old_files(
553 conn: &mut PgConnection,
554 course_id: Uuid,
555 blob_client: &AzureBlobClient,
556) -> anyhow::Result<()> {
557 let mut courses_prefix = "courses/".to_string();
558 courses_prefix.push_str(&course_id.to_string());
559 let existing_files = blob_client.list_files_with_prefix(&courses_prefix).await?;
560
561 let pages = headless_lms_models::pages::get_all_by_course_id_and_visibility(
562 conn,
563 course_id,
564 PageVisibility::Public,
565 )
566 .await?;
567
568 let allowed_paths: HashSet<String> = pages
569 .iter()
570 .filter_map(|page| generate_blob_path(page).ok())
571 .collect();
572
573 for file in existing_files {
574 if !allowed_paths.contains(&file) {
575 info!("Deleting obsolete file: {}", file);
576 blob_client.delete_file(&file).await?;
577 }
578 }
579
580 Ok(())
581}