Skip to main content

headless_lms_server/programs/
chatbot_syncer.rs

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        // Sleep indefinitely to prevent the program from exiting. This only happens in development.
51        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        // Sleep indefinitely to prevent the program from exiting. This only happens in development.
60        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            // Acquired per tick so that the pool's liveness check replaces a connection that died
82            // while we were idle; a checked out connection is never healed.
83            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    // TODO: Audit that the environment access only happens in single-threaded code.
106    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
138/// Initializes the PostgreSQL connection pool.
139async 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
149/// Initializes the Azure Blob Storage client.
150async 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
156/// Synchronizes pages to the chatbot backend.
157/// `reported_permanently_failing_page_ids` carries the previously warned-about set across ticks so
158/// the warning is emitted on change rather than on every sweep.
159async 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    // (page_id, page_history_id)
184    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    // Only on change: a page stays permanently failing until someone intervenes, and this runs
328    // every SYNC_INTERVAL_SECS, so warning unconditionally would repeat the same line all day.
329    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
349/// Ensures that the specified search index exists, creating it if necessary.
350async 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
371/// Processes and synchronizes a batch of pages.
372async fn sync_pages_batch(
373    conn: &mut PgConnection,
374    pages: &[Page],
375    // map from page id to course page markdown content id
376    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    // newly created md. map page_id to page_history_id and md content
409    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                    // Check if the markdown is empty, or if it just contains all spaces or newlines
448                    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                    // Fallback to original content
476                    serde_json::to_string(&sanitized_blocks)?
477                }
478            }
479        };
480
481        // save markdown content if new markdown was generated
482        // if there is an error saving it to blobs, we can try uploading the same content
483        // if the page hasn't been changed between tries.
484        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        // Azure Blob Storage metadata values must be ASCII-only. URL-encode values that may
501        // contain non-ASCII characters (e.g., Finnish characters like ä, ö) to ensure they
502        // are ASCII-compatible. We decode the url and the title before we save them in our database.
503        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    // update revision ids for all pages
564    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
573/// Generates the blob storage path for a given page.
574fn 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
582/// Deletes files from blob storage that are no longer associated with any public page.
583/// This includes files for deleted pages, hidden pages, and any other pages that are no longer public.
584async 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}