Skip to main content

headless_lms_chatbot/
azure_search_indexer.rs

1use crate::prelude::*;
2use secrecy::ExposeSecret;
3use serde_json::json;
4
5const API_VERSION: &str = "2024-07-01";
6
7#[derive(Debug, Deserialize)]
8struct IndexerStatusResponse {
9    pub status: String,
10    #[serde(rename = "lastResult")]
11    pub last_result: Option<LastResult>,
12}
13
14#[derive(Debug, Deserialize)]
15struct LastResult {
16    pub status: String,
17    pub errors: Vec<IndexerError>,
18    pub warnings: Vec<IndexerWarning>,
19}
20
21#[derive(Debug, Deserialize)]
22struct IndexerError {
23    pub key: Option<String>,
24    pub name: Option<String>,
25    pub message: Option<String>,
26    pub details: Option<String>,
27    #[serde(rename = "documentationLink")]
28    pub documentation_link: Option<String>,
29}
30
31#[derive(Debug, Deserialize)]
32struct IndexerWarning {
33    key: Option<String>,
34    name: Option<String>,
35    message: Option<String>,
36    details: Option<String>,
37    #[serde(rename = "documentationLink")]
38    documentation_link: Option<String>,
39}
40
41pub async fn does_search_indexer_exist(
42    indexer_name: &str,
43    app_config: &ApplicationConfiguration,
44) -> ChatbotResult<bool> {
45    let azure_config = app_config.azure_configuration.as_ref().ok_or_else(|| {
46        chatbot_err!(
47            AzureRequestBuildError,
48            "Azure configuration is missing from the application configuration"
49        )
50    })?;
51
52    let search_config = azure_config.search_config.as_ref().ok_or_else(|| {
53        chatbot_err!(
54            AzureRequestBuildError,
55            "Azure search configuration is missing from the Azure configuration"
56        )
57    })?;
58    let mut url = search_config.search_endpoint.clone();
59    url.set_path(&format!("indexers('{}')", indexer_name));
60    url.set_query(Some(&format!("api-version={}", API_VERSION)));
61
62    let response = REQWEST_CLIENT
63        .get(url)
64        .header("Content-Type", "application/json")
65        .header("api-key", search_config.search_api_key.expose_secret())
66        .send()
67        .await?;
68
69    if response.status().is_success() {
70        Ok(true)
71    } else if response.status() == 404 {
72        Ok(false)
73    } else {
74        let status = response.status();
75        let error_text = response.text().await?;
76        Err(chatbot_err!(
77            FailedAzureResponse,
78            format!(
79                "Error checking if index exists. Status: {}. Error: {}",
80                status, error_text
81            )
82        ))
83    }
84}
85
86pub async fn create_search_indexer(
87    indexer_name: &str,
88    data_source_name: &str,
89    skillset_name: &str,
90    target_index_name: &str,
91    app_config: &ApplicationConfiguration,
92) -> ChatbotResult<()> {
93    let azure_config = app_config.azure_configuration.as_ref().ok_or_else(|| {
94        chatbot_err!(
95            AzureRequestBuildError,
96            "Azure configuration is missing from the application configuration"
97        )
98    })?;
99
100    let search_config = azure_config.search_config.as_ref().ok_or_else(|| {
101        chatbot_err!(
102            AzureRequestBuildError,
103            "Azure search configuration is missing from the Azure configuration"
104        )
105    })?;
106
107    let mut url = search_config.search_endpoint.clone();
108    url.set_path(&format!("indexers/{}", indexer_name));
109    url.set_query(Some(&format!("api-version={}", API_VERSION)));
110
111    let indexer_definition = json!({
112        "name": indexer_name,
113        "description": null,
114        "dataSourceName": data_source_name,
115        "skillsetName": skillset_name,
116        "targetIndexName": target_index_name,
117        "disabled": null,
118        "schedule": null,
119        "parameters": {
120            "batchSize": null,
121            "maxFailedItems": null,
122            "maxFailedItemsPerBatch": null,
123            "base64EncodeKeys": null,
124            "configuration": {
125                "dataToExtract": "contentAndMetadata"
126            }
127        },
128        "fieldMappings": [
129            {
130                "sourceFieldName": "metadata_storage_path",
131                "targetFieldName": "chunk_id",
132                "mappingFunction": { "name": "base64Encode" }
133            },
134        ],
135        "outputFieldMappings": [
136
137        ],
138        "encryptionKey": null
139    });
140
141    let response = REQWEST_CLIENT
142        .put(url)
143        .header("Content-Type", "application/json")
144        .header("api-key", search_config.search_api_key.expose_secret())
145        .json(&indexer_definition)
146        .send()
147        .await?;
148
149    if response.status().is_success() {
150        Ok(())
151    } else {
152        let status = response.status();
153        let error_text = response.text().await?;
154        Err(chatbot_err!(
155            FailedAzureResponse,
156            format!(
157                "Error creating search indexer. Status: {}. Error: {}",
158                status, error_text
159            )
160        ))
161    }
162}
163
164pub async fn run_search_indexer_now(
165    indexer_name: &str,
166    app_config: &ApplicationConfiguration,
167) -> ChatbotResult<()> {
168    let azure_config = app_config.azure_configuration.as_ref().ok_or_else(|| {
169        chatbot_err!(
170            AzureRequestBuildError,
171            "Azure configuration is missing from the application configuration"
172        )
173    })?;
174
175    let search_config = azure_config.search_config.as_ref().ok_or_else(|| {
176        chatbot_err!(
177            AzureRequestBuildError,
178            "Azure search configuration is missing from the Azure configuration"
179        )
180    })?;
181
182    let mut url = search_config.search_endpoint.clone();
183    url.set_path(&format!("indexers/{}/run", indexer_name));
184    url.set_query(Some(&format!("api-version={}", API_VERSION)));
185
186    let response = REQWEST_CLIENT
187        .post(url)
188        .header("Content-Type", "application/json")
189        .header("api-key", search_config.search_api_key.expose_secret())
190        .send()
191        .await?;
192
193    if response.status().is_success() {
194        Ok(())
195    } else {
196        let status = response.status();
197        let error_text = response.text().await?;
198        Err(chatbot_err!(
199            FailedAzureResponse,
200            format!(
201                "Error triggering search indexer. Status: {}. Error: {}",
202                status, error_text
203            )
204        ))
205    }
206}
207
208/// Checks if the search indexer exists, is not running, and the last execution is not in progress.
209/// Also prints any errors and warnings from the last execution in a nicely formatted manner.
210///
211/// # Arguments
212///
213/// * `indexer_name` - The name of the indexer to check.
214/// * `app_config` - The application configuration containing Azure settings.
215///
216/// # Returns
217///
218/// * `Ok(true)` if the indexer exists, is not running, and the last execution is not in progress.
219/// * `Ok(false)` if the indexer is running or the last execution is in progress.
220/// * An error if the indexer does not exist or if there's an issue fetching its status.
221pub async fn check_search_indexer_status(
222    indexer_name: &str,
223    app_config: &ApplicationConfiguration,
224) -> ChatbotResult<bool> {
225    let azure_config = app_config.azure_configuration.as_ref().ok_or_else(|| {
226        chatbot_err!(
227            AzureRequestBuildError,
228            "Azure configuration is missing from the application configuration"
229        )
230    })?;
231
232    let search_config = azure_config.search_config.as_ref().ok_or_else(|| {
233        chatbot_err!(
234            AzureRequestBuildError,
235            "Azure search configuration is missing from the Azure configuration"
236        )
237    })?;
238
239    let mut url = search_config.search_endpoint.clone();
240    url.set_path(&format!("indexers('{}')/search.status", indexer_name));
241    url.set_query(Some(&format!("api-version={}", API_VERSION)));
242
243    let response = REQWEST_CLIENT
244        .get(url)
245        .header("Content-Type", "application/json")
246        .header("api-key", search_config.search_api_key.expose_secret())
247        .send()
248        .await?;
249
250    if response.status().is_success() {
251        let response_text = response.text().await?;
252        let indexer_status: IndexerStatusResponse = match serde_json::from_str(&response_text) {
253            Ok(status) => status,
254            Err(e) => {
255                error!("Failed to parse indexer status JSON: {}", e);
256                error!(
257                    "{}",
258                    serde_json::to_string_pretty(&response_text)
259                        .unwrap_or_else(|_| "Invalid JSON".to_string())
260                );
261                return Err(chatbot_err!(
262                    SerdeJson,
263                    "Failed to parse indexer status JSON.",
264                    e
265                ));
266            }
267        };
268
269        // If the indexer is not running, it is not healthy
270        let is_running = indexer_status.status.eq_ignore_ascii_case("running");
271
272        // If the last run is in progress, we cannot start a new run.
273        let last_result_in_progress = indexer_status
274            .last_result
275            .as_ref()
276            .is_some_and(|lr| lr.status.eq_ignore_ascii_case("inprogress"));
277
278        if !is_running {
279            info!("Indexer '{}' is not running normally.", indexer_name);
280        }
281
282        if last_result_in_progress {
283            warn!(
284                "Last execution of indexer '{}' is in progress.",
285                indexer_name
286            );
287        }
288
289        if let Some(last_result) = &indexer_status.last_result {
290            if !last_result.errors.is_empty() {
291                error!("Errors in the last execution:");
292                for error in &last_result.errors {
293                    error!(
294                        "  - **Key**: {}\n    **Name**: {}\n    **Message**: {}\n    **Details**: {}\n    **Documentation**: {}\n",
295                        error.key.as_deref().unwrap_or("N/A"),
296                        error.name.as_deref().unwrap_or("N/A"),
297                        error.message.as_deref().unwrap_or("N/A"),
298                        error.details.as_deref().unwrap_or("N/A"),
299                        error.documentation_link.as_deref().unwrap_or("N/A"),
300                    );
301                }
302            }
303
304            if !last_result.warnings.is_empty() {
305                warn!("Warnings in the last execution:");
306                for warning in &last_result.warnings {
307                    warn!(
308                        "  - **Key**: {}\n    **Name**: {}\n    **Message**: {}\n    **Details**: {}\n    **Documentation**: {}\n",
309                        warning.key.as_deref().unwrap_or("N/A"),
310                        warning.name.as_deref().unwrap_or("N/A"),
311                        warning.message.as_deref().unwrap_or("N/A"),
312                        warning.details.as_deref().unwrap_or("N/A"),
313                        warning.documentation_link.as_deref().unwrap_or("N/A"),
314                    );
315                }
316            }
317        } else {
318            warn!(
319                "No last result information available for indexer '{}'. Assuming the index is not ready yet.",
320                indexer_name
321            );
322            return Ok(false);
323        }
324
325        if is_running && !last_result_in_progress {
326            Ok(true)
327        } else {
328            Ok(false)
329        }
330    } else if response.status() == reqwest::StatusCode::NOT_FOUND {
331        error!("Indexer '{}' does not exist.", indexer_name);
332        Ok(false)
333    } else {
334        let status = response.status();
335        let error_text = response
336            .text()
337            .await
338            .unwrap_or_else(|_| "No error text".to_string());
339        error!(
340            "Error fetching indexer status. Status: {}. Error: {}",
341            status, error_text
342        );
343        Err(chatbot_err!(
344            FailedAzureResponse,
345            format!(
346                "Error fetching indexer status. Status: {}. Error: {}",
347                status, error_text
348            )
349        ))
350    }
351}