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