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::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        // Sleep indefinitely to prevent the program from exiting. This only happens in development.
50        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        // Sleep indefinitely to prevent the program from exiting. This only happens in development.
59        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    // TODO: Audit that the environment access only happens in single-threaded code.
89    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
121/// Initializes the PostgreSQL connection pool.
122async 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
132/// Initializes the Azure Blob Storage client.
133async 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
139/// Synchronizes pages to the chatbot backend.
140async 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    // (page_id, page_history_id)
164    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
317/// Ensures that the specified search index exists, creating it if necessary.
318async 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
339/// Processes and synchronizes a batch of pages.
340async fn sync_pages_batch(
341    conn: &mut PgConnection,
342    pages: &[Page],
343    // map from page id to course page markdown content id
344    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    // newly created md. map page_id to page_history_id and md content
377    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                    // Check if the markdown is empty, or if it just contains all spaces or newlines
416                    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                    // Fallback to original content
444                    serde_json::to_string(&sanitized_blocks)?
445                }
446            }
447        };
448
449        // save markdown content if new markdown was generated
450        // if there is an error saving it to blobs, we can try uploading the same content
451        // if the page hasn't been changed between tries.
452        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        // Azure Blob Storage metadata values must be ASCII-only. URL-encode values that may
469        // contain non-ASCII characters (e.g., Finnish characters like ä, ö) to ensure they
470        // are ASCII-compatible. We decode the url and the title before we save them in our database.
471        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    // update revision ids for all pages
532    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
541/// Generates the blob storage path for a given page.
542fn 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
550/// Deletes files from blob storage that are no longer associated with any public page.
551/// This includes files for deleted pages, hidden pages, and any other pages that are no longer public.
552async 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}