1use crate::controllers::mock_document_storage::{MOCK_DOCUMENTS, MockDocument};
2use crate::prelude::*;
3use headless_lms_chatbot::{
4 azure_chatbot::azure::{protocol::InputItem, tools::AzureLLMToolDefinition},
5 chatbot_tools::{
6 ChatbotToolDeclaration,
7 client_tools::ask_multiple_choice_question::AskMultipleChoiceQuestionTool,
8 custom_tools::course_structure::CourseStructureTool, get_chatbot_tool_definitions,
9 tool_is_answered_by_client,
10 },
11 cms_ai_suggestion::RESPONSE_FORMAT_NAME as CMS_SUGGESTION_FORMAT,
12 course_description_summary::RESPONSE_FORMAT_NAME as COURSE_DESCRIPTION_FORMAT,
13 llm_utils::AzureCompletionRequest,
14 message_suggestion::RESPONSE_FORMAT_NAME as MESSAGE_SUGGESTION_FORMAT,
15 prompt_creation::RESPONSE_FORMAT_NAME as PROMPT_CREATION_FORMAT,
16};
17use headless_lms_utils::azure_embedding::{
18 Embedding, EmbeddingRequest, EmbeddingResponse, EmbeddingResponseUsage,
19};
20use serde_json::{Value, json};
21
22const TOOL_CALL_TRIGGER: &str = "!MOCK_TOOL_CALL!";
27
28const CLIENT_TOOL_CALL_TRIGGER: &str = "!MOCK_CLIENT_TOOL_CALL!";
32
33const TOOL_CALL_BY_NAME_PREFIX: &str = "!MOCK_TOOL_CALL:";
36
37const INCOMPLETE_ANSWER_TRIGGER: &str = "!MOCK_INCOMPLETE_ANSWER!";
40
41const TRUNCATED_STREAM_TRIGGER: &str = "!MOCK_TRUNCATED_STREAM!";
44
45const MALFORMED_SEARCH_OUTPUT_TRIGGER: &str = "!MOCK_MALFORMED_SEARCH_OUTPUT!";
49
50const UNKNOWN_ITEM_TYPE_TRIGGER: &str = "!MOCK_UNKNOWN_ITEM_TYPE!";
52
53fn tool_call_by_name_trigger(tool_name: &str, arguments: &str) -> String {
55 format!("{TOOL_CALL_BY_NAME_PREFIX}{tool_name}:{arguments}!")
56}
57
58struct TriggeredToolCall {
60 tool_name: String,
61 arguments: String,
62}
63
64fn parse_tool_call_by_name(message: &str) -> Option<TriggeredToolCall> {
70 let (_, rest) = message.split_once(TOOL_CALL_BY_NAME_PREFIX)?;
71 let (call, _) = rest.split_once('!')?;
72 let (tool_name, arguments) = call.split_once(':')?;
73 is_registered_tool(tool_name).then(|| TriggeredToolCall {
74 tool_name: tool_name.to_string(),
75 arguments: arguments.to_string(),
76 })
77}
78
79fn is_registered_tool(tool_name: &str) -> bool {
81 tool_is_answered_by_client(tool_name)
82 || get_chatbot_tool_definitions()
83 .iter()
84 .any(|definition| match definition {
85 AzureLLMToolDefinition::Function(function) => function.name == tool_name,
86 AzureLLMToolDefinition::Search(_) => false,
87 })
88}
89
90struct MockRequest {
92 message: Option<String>,
95 format_name: Option<String>,
97 stream: bool,
99}
100
101impl MockRequest {
102 fn chat(message: &str) -> Self {
104 MockRequest {
105 message: Some(message.to_string()),
106 format_name: None,
107 stream: true,
108 }
109 }
110
111 fn after_tool_run() -> Self {
114 MockRequest {
115 message: None,
116 format_name: None,
117 stream: true,
118 }
119 }
120
121 fn structured_output(format_name: &str) -> Self {
124 MockRequest {
125 message: Some(TOOL_CALL_TRIGGER.to_string()),
126 format_name: Some(format_name.to_string()),
127 stream: false,
128 }
129 }
130
131 fn wants_format(&self, format_name: &str) -> bool {
135 !self.stream && self.format_name.as_deref() == Some(format_name)
136 }
137
138 fn triggered_tool_call(&self) -> Option<TriggeredToolCall> {
140 if !self.stream {
141 return None;
142 }
143 parse_tool_call_by_name(self.message.as_deref()?)
144 }
145
146 fn message_contains(&self, trigger: &str) -> bool {
148 self.stream
149 && self
150 .message
151 .as_deref()
152 .is_some_and(|message| message.contains(trigger))
153 }
154}
155
156struct Scenario {
161 name: &'static str,
163 matches: fn(&MockRequest) -> bool,
164 respond: fn(&MockRequest, &str) -> String,
166 #[cfg_attr(not(test), allow(dead_code))]
168 example: fn() -> MockRequest,
169}
170
171const SCENARIOS: &[Scenario] = &[
179 Scenario {
180 name: "the next message suggestion",
181 matches: |request| request.wants_format(MESSAGE_SUGGESTION_FORMAT),
182 respond: |_, _| blocking_response(MESSAGE_SUGGESTION_PAYLOAD),
183 example: || MockRequest::structured_output(MESSAGE_SUGGESTION_FORMAT),
184 },
185 Scenario {
186 name: "the CMS paragraph suggestion",
187 matches: |request| request.wants_format(CMS_SUGGESTION_FORMAT),
188 respond: |_, _| blocking_response(CMS_SUGGESTION_PAYLOAD),
189 example: || MockRequest::structured_output(CMS_SUGGESTION_FORMAT),
190 },
191 Scenario {
192 name: "the course description summary",
193 matches: |request| request.wants_format(COURSE_DESCRIPTION_FORMAT),
194 respond: |_, _| blocking_response(COURSE_DESCRIPTION_PAYLOAD),
195 example: || MockRequest::structured_output(COURSE_DESCRIPTION_FORMAT),
196 },
197 Scenario {
198 name: "the client tool call round",
199 matches: |request| request.message_contains(CLIENT_TOOL_CALL_TRIGGER),
200 respond: |_, _| {
201 function_call_round(
202 <AskMultipleChoiceQuestionTool as ChatbotToolDeclaration>::NAME,
203 MOCK_MULTIPLE_CHOICE_ARGUMENTS,
204 )
205 },
206 example: || MockRequest::chat(CLIENT_TOOL_CALL_TRIGGER),
207 },
208 Scenario {
209 name: "the tool call round for a named tool",
210 matches: |request| request.triggered_tool_call().is_some(),
211 respond: |request, _| {
212 let call = request
213 .triggered_tool_call()
214 .expect("the scenario only answers a request carrying a tool call trigger");
215 function_call_round(&call.tool_name, &call.arguments)
216 },
217 example: || {
218 MockRequest::chat(&tool_call_by_name_trigger(
219 <CourseStructureTool as ChatbotToolDeclaration>::NAME,
220 "{}",
221 ))
222 },
223 },
224 Scenario {
225 name: "the function call round",
226 matches: |request| request.message_contains(TOOL_CALL_TRIGGER),
227 respond: |_, _| {
228 function_call_round(<CourseStructureTool as ChatbotToolDeclaration>::NAME, "{}")
229 },
230 example: || MockRequest::chat(TOOL_CALL_TRIGGER),
231 },
232 Scenario {
233 name: "the answer after a tool ran",
234 matches: |request| request.stream && request.message.is_none(),
235 respond: |_, _| tool_answer_round(),
236 example: MockRequest::after_tool_run,
237 },
238 Scenario {
239 name: "the prompt and first message generation",
240 matches: |request| request.wants_format(PROMPT_CREATION_FORMAT),
241 respond: |_, _| blocking_response(PROMPT_CREATION_PAYLOAD),
242 example: || MockRequest::structured_output(PROMPT_CREATION_FORMAT),
243 },
244 Scenario {
245 name: "an answer cut short by the token limit",
246 matches: |request| request.message_contains(INCOMPLETE_ANSWER_TRIGGER),
247 respond: |_, _| incomplete_answer_round(),
248 example: || MockRequest::chat(INCOMPLETE_ANSWER_TRIGGER),
249 },
250 Scenario {
251 name: "a stream that ends before response.completed",
252 matches: |request| request.message_contains(TRUNCATED_STREAM_TRIGGER),
253 respond: |_, _| truncated_stream_round(),
254 example: || MockRequest::chat(TRUNCATED_STREAM_TRIGGER),
255 },
256 Scenario {
257 name: "a completed search output that fails to parse",
258 matches: |request| request.message_contains(MALFORMED_SEARCH_OUTPUT_TRIGGER),
259 respond: |_, _| malformed_search_output_round(),
260 example: || MockRequest::chat(MALFORMED_SEARCH_OUTPUT_TRIGGER),
261 },
262 Scenario {
263 name: "an output item type the chatbot does not know",
264 matches: |request| request.message_contains(UNKNOWN_ITEM_TYPE_TRIGGER),
265 respond: |_, _| unknown_item_type_round(),
266 example: || MockRequest::chat(UNKNOWN_ITEM_TYPE_TRIGGER),
267 },
268 Scenario {
269 name: "the default chat answer",
270 matches: |request| request.stream && request.message.is_some(),
271 respond: |_, base_url| search_and_text_round(base_url),
272 example: || MockRequest::chat("Tell me more"),
273 },
274];
275
276fn pick_scenario(request: &MockRequest) -> Option<&'static Scenario> {
278 SCENARIOS
279 .iter()
280 .find(|scenario| (scenario.matches)(request))
281}
282
283async fn mock_azure_chat_responses(
290 app_conf: web::Data<ApplicationConfiguration>,
291 payload: web::Json<AzureCompletionRequest>,
292) -> ControllerResult<String> {
293 assert!(app_conf.test_chatbot && app_conf.test_mode);
294
295 let last_input_item = &payload
296 .base
297 .input
298 .last()
299 .ok_or_else(|| {
300 controller_err!(
301 BadRequest,
302 "No messages in request, there should be at least one."
303 )
304 })?
305 .message_type;
306
307 let message = match last_input_item {
308 InputItem::Message { content, .. } => Some(content.clone().get_content_text()),
309 InputItem::FunctionCallOutput { .. } => None,
310 InputItem::FunctionCall { .. } | InputItem::Reasoning { .. } => {
311 return Err(controller_err!(
312 BadRequest,
313 "The mock has no response for a request that ends in a function call or a reasoning item."
314 ));
315 }
316 };
317
318 let request = MockRequest {
319 message,
320 format_name: payload
321 .base
322 .text
323 .as_ref()
324 .and_then(|text| text.format.as_ref())
325 .map(|format| format.name.clone()),
326 stream: payload.stream,
327 };
328 let scenario = pick_scenario(&request).ok_or_else(|| {
329 controller_err!(
330 BadRequest,
331 "The mock has no response for this shape of request."
332 )
333 })?;
334 debug!(scenario = scenario.name, "Answering as the mock Azure API");
335 let res = (scenario.respond)(&request, &app_conf.base_url);
336
337 let token = skip_authorize();
338 token.authorized_ok(res)
339}
340
341fn sse_body(events: Vec<(&str, Value)>) -> String {
346 events
347 .into_iter()
348 .map(|(event, mut data)| {
349 if let Some(object) = data.as_object_mut() {
350 object.insert("type".to_string(), json!(event));
351 }
352 format!("event: {event}\ndata: {data}\n\n")
353 })
354 .collect()
355}
356
357fn round(response_id: &str, events: Vec<(&'static str, Value)>, usage: Value) -> String {
361 let mut all = vec![(
362 "response.created",
363 json!({"response": response_object(response_id)}),
364 )];
365 all.extend(events);
366 all.push((
367 "response.completed",
368 json!({"response": completed_response_object(response_id, usage)}),
369 ));
370 sse_body(all)
371}
372
373fn usage(
376 input_tokens: u32,
377 cached_tokens: u32,
378 output_tokens: u32,
379 reasoning_tokens: u32,
380) -> Value {
381 json!({
382 "input_tokens": input_tokens,
383 "input_tokens_details": {
384 "cached_tokens": cached_tokens,
385 "cache_write_tokens": input_tokens.saturating_sub(cached_tokens),
386 },
387 "output_tokens": output_tokens,
388 "output_tokens_details": {"reasoning_tokens": reasoning_tokens},
389 "total_tokens": input_tokens + output_tokens,
390 })
391}
392
393fn response_object(response_id: &str) -> Value {
397 json!({
398 "id": response_id,
399 "object": "response",
400 "status": "in_progress",
401 "usage": null,
402 })
403}
404
405fn completed_response_object(response_id: &str, usage: Value) -> Value {
408 json!({
409 "id": response_id,
410 "object": "response",
411 "status": "completed",
412 "reasoning": {"effort": "medium", "summary": null, "context": "current_turn"},
413 "usage": usage,
414 })
415}
416
417fn message_item(item_id: &str, response_id: &str, content: Value, status: &str) -> Value {
420 json!({
421 "type": "message",
422 "id": item_id,
423 "response_id": response_id,
424 "phase": "final_answer",
425 "role": "assistant",
426 "content": content,
427 "status": status,
428 })
429}
430
431fn message_item_events(
435 item_id: &str,
436 response_id: &str,
437 output_index: u32,
438 deltas: &[&str],
439) -> Vec<(&'static str, Value)> {
440 let text = deltas.concat();
441
442 let mut events = vec![
443 (
444 "response.output_item.added",
445 json!({
446 "output_index": output_index,
447 "item": message_item(item_id, response_id, json!([]), "in_progress"),
448 }),
449 ),
450 (
451 "response.content_part.added",
452 json!({
453 "content_index": 0,
454 "item_id": item_id,
455 "output_index": output_index,
456 "part": { "type": "output_text", "text": "" },
457 }),
458 ),
459 ];
460 events.extend(deltas.iter().map(|delta| {
461 (
462 "response.output_text.delta",
463 json!({
464 "content_index": 0,
465 "item_id": item_id,
466 "output_index": output_index,
467 "delta": delta,
468 }),
469 )
470 }));
471 events.extend([
472 (
473 "response.output_text.done",
474 json!({
475 "content_index": 0,
476 "item_id": item_id,
477 "output_index": output_index,
478 "text": text,
479 }),
480 ),
481 (
482 "response.content_part.done",
483 json!({
484 "content_index": 0,
485 "item_id": item_id,
486 "output_index": output_index,
487 "part": { "type": "output_text", "text": text },
488 }),
489 ),
490 (
491 "response.output_item.done",
492 json!({
493 "output_index": output_index,
494 "item": message_item(
495 item_id,
496 response_id,
497 json!([{ "type": "output_text", "text": text }]),
498 "completed",
499 ),
500 }),
501 ),
502 ]);
503 events
504}
505
506fn reasoning_item_events(response_id: &str) -> Vec<(&'static str, Value)> {
509 let item = reasoning_item(response_id);
510 vec![
511 (
512 "response.output_item.added",
513 json!({"output_index": 0, "item": item}),
514 ),
515 (
516 "response.output_item.done",
517 json!({"output_index": 0, "item": item}),
518 ),
519 ]
520}
521
522fn reasoning_item(response_id: &str) -> Value {
524 json!({
525 "type": "reasoning",
526 "id": format!("rs_{}", Uuid::new_v4()),
527 "response_id": response_id,
528 "summary": [],
529 "encrypted_content": "mock-encrypted-reasoning",
532 })
533}
534
535const MOCK_MULTIPLE_CHOICE_ARGUMENTS: &str =
538 r#"{"question":"Which loop do you mean?","choices":["while","for"]}"#;
539
540fn function_call_round(tool_name: &str, arguments: &str) -> String {
549 let response_id = format!("resp_{}", Uuid::new_v4());
550 let item_id = format!("fc_{}", Uuid::new_v4());
551 let call_id = format!("call_{}", Uuid::new_v4());
552
553 let function_call = |arguments: &str, status: &str| {
554 json!({
555 "type": "function_call",
556 "id": item_id,
557 "response_id": response_id,
558 "call_id": call_id,
559 "name": tool_name,
560 "arguments": arguments,
561 "status": status,
562 })
563 };
564 let mut events = reasoning_item_events(&response_id);
565 events.extend([
566 (
567 "response.output_item.added",
568 json!({"output_index": 1, "item": function_call("", "in_progress")}),
569 ),
570 (
571 "response.function_call_arguments.delta",
572 json!({"item_id": item_id, "output_index": 1, "delta": arguments}),
573 ),
574 (
575 "response.function_call_arguments.done",
576 json!({"item_id": item_id, "output_index": 1, "arguments": arguments}),
577 ),
578 (
579 "response.output_item.done",
580 json!({"output_index": 1, "item": function_call(arguments, "completed")}),
581 ),
582 ]);
583
584 round(&response_id, events, usage(42, 0, 88, 64))
585}
586
587fn tool_answer_round() -> String {
589 let response_id = format!("resp_{}", Uuid::new_v4());
590 let item_id = format!("msg_{}", Uuid::new_v4());
591 let deltas = [
592 "Here", " is", " the", " mock", " answer", " after", " a", " tool", " ran.",
593 ];
594
595 round(
596 &response_id,
597 message_item_events(&item_id, &response_id, 0, &deltas),
598 usage(96, 42, 24, 8),
601 )
602}
603
604fn incomplete_answer_round() -> String {
607 let response_id = format!("resp_{}", Uuid::new_v4());
608 let item_id = format!("msg_{}", Uuid::new_v4());
609
610 let events = vec![
611 (
612 "response.created",
613 json!({"response": response_object(&response_id)}),
614 ),
615 (
616 "response.output_item.added",
617 json!({
618 "output_index": 0,
619 "item": message_item(&item_id, &response_id, json!([]), "in_progress"),
620 }),
621 ),
622 (
623 "response.output_text.delta",
624 json!({
625 "content_index": 0,
626 "item_id": item_id,
627 "output_index": 0,
628 "delta": "This answer gets cut",
629 }),
630 ),
631 (
632 "response.incomplete",
633 json!({"response": {
634 "id": response_id,
635 "object": "response",
636 "status": "incomplete",
637 "incomplete_details": {"reason": "max_output_tokens"},
638 "usage": usage(30, 0, 8, 0),
639 }}),
640 ),
641 ];
642 sse_body(events)
643}
644
645fn truncated_stream_round() -> String {
649 let response_id = format!("resp_{}", Uuid::new_v4());
650 let item_id = format!("fc_{}", Uuid::new_v4());
651 let call_id = format!("call_{}", Uuid::new_v4());
652
653 sse_body(vec![
654 (
655 "response.created",
656 json!({"response": response_object(&response_id)}),
657 ),
658 (
659 "response.output_item.done",
660 json!({
661 "output_index": 0,
662 "item": {
663 "type": "function_call",
664 "id": item_id,
665 "response_id": response_id,
666 "call_id": call_id,
667 "name": <CourseStructureTool as ChatbotToolDeclaration>::NAME,
668 "arguments": "{}",
669 "status": "completed",
670 },
671 }),
672 ),
673 ])
674}
675
676fn malformed_search_output_round() -> String {
681 let response_id = format!("resp_{}", Uuid::new_v4());
682 let call_id = format!("call_{}", Uuid::new_v4());
683 let search_item_id = format!("fc_{}", Uuid::new_v4());
684 let output_item_id = format!("fco_{}", Uuid::new_v4());
685 let message_item_id = format!("msg_{}", Uuid::new_v4());
686
687 let mut events = reasoning_item_events(&response_id);
688 events.extend([
689 (
690 "response.output_item.done",
691 json!({
692 "output_index": 1,
693 "item": {
694 "type": "azure_ai_search_call",
695 "id": search_item_id,
696 "response_id": response_id,
697 "call_id": call_id,
698 "arguments": r#"{"query":"tell me more"}"#,
699 "status": "completed",
700 },
701 }),
702 ),
703 (
704 "response.output_item.done",
705 json!({
706 "output_index": 2,
707 "item": {
708 "type": "azure_ai_search_call_output",
709 "id": output_item_id,
710 "response_id": response_id,
711 "call_id": call_id,
712 "output": "remote tool call failed",
713 "status": "completed",
714 },
715 }),
716 ),
717 ]);
718 events.extend(message_item_events(
719 &message_item_id,
720 &response_id,
721 3,
722 &["Sorry", ", search is unavailable."],
723 ));
724
725 round(&response_id, events, usage(38, 0, 40, 32))
726}
727
728fn unknown_item_type_round() -> String {
731 let response_id = format!("resp_{}", Uuid::new_v4());
732 let item_id = format!("ws_{}", Uuid::new_v4());
733 let message_item_id = format!("msg_{}", Uuid::new_v4());
734
735 let mut events = reasoning_item_events(&response_id);
736 events.push((
737 "response.output_item.done",
738 json!({
739 "output_index": 1,
740 "item": {
741 "type": "web_search_call",
742 "id": item_id,
743 "response_id": response_id,
744 "status": "completed",
745 },
746 }),
747 ));
748 events.extend(message_item_events(
749 &message_item_id,
750 &response_id,
751 2,
752 &["Handled", " gracefully."],
753 ));
754
755 round(&response_id, events, usage(20, 0, 20, 16))
756}
757
758fn search_and_text_round(base_url: &str) -> String {
761 let response_id = format!("resp_{}", Uuid::new_v4());
762 let call_id = format!("call_{}", Uuid::new_v4());
763 let search_item_id = format!("fc_{}", Uuid::new_v4());
764 let output_item_id = format!("fco_{}", Uuid::new_v4());
765 let message_item_id = format!("msg_{}", Uuid::new_v4());
766
767 let search_call = |arguments: &str, status: &str| {
768 json!({
769 "type": "azure_ai_search_call",
770 "id": search_item_id,
771 "response_id": response_id,
772 "call_id": call_id,
773 "arguments": arguments,
774 "status": status,
775 })
776 };
777 let search_call_output = |output: &str, status: &str| {
778 json!({
779 "type": "azure_ai_search_call_output",
780 "id": output_item_id,
781 "response_id": response_id,
782 "call_id": call_id,
783 "output": output,
784 "status": status,
785 })
786 };
787
788 let mut events = reasoning_item_events(&response_id);
789 events.extend([
790 (
791 "response.output_item.added",
792 json!({"output_index": 1, "item": search_call("", "in_progress")}),
793 ),
794 (
795 "response.output_item.done",
796 json!({
797 "output_index": 1,
798 "item": search_call(r#"{"query":"tell me more"}"#, "completed"),
799 }),
800 ),
801 (
802 "response.output_item.added",
803 json!({"output_index": 2, "item": search_call_output("[]", "in_progress")}),
804 ),
805 (
806 "response.output_item.done",
807 json!({
808 "output_index": 2,
809 "item": search_call_output(&search_results(base_url), "completed"),
810 }),
811 ),
812 ]);
813 events.extend(message_item_events(
814 &message_item_id,
815 &response_id,
816 3,
817 &SEARCH_ANSWER_DELTAS,
818 ));
819
820 round(&response_id, events, usage(38, 0, 79, 64))
821}
822
823const SEARCH_ANSWER_DELTAS: [&str; 12] = [
826 "Hello",
827 "!",
828 " How",
829 " can",
830 " I",
831 " assist",
832 " 【0:2†source】",
833 " you",
834 " 【0:1†source】",
835 " today",
836 "?",
837 "【0:2†source】",
838];
839
840fn search_results(base_url: &str) -> String {
844 let [document1, document2, document3] = &MOCK_DOCUMENTS;
845 let hit = |id: &str, document: &MockDocument, content: &str| {
846 json!({
847 "id": id,
848 "content": content,
849 "filepath": document.id,
850 "title": document.title,
851 "url": "",
852 "score": 0.016666668,
853 "knowledgeSourceIndex": 0,
854 })
855 };
856 let get_urls: Vec<String> = MOCK_DOCUMENTS
857 .iter()
858 .map(|document| {
859 format!(
860 "{base_url}/api/v0/mock-document-storage/test/documents/{}",
861 document.id
862 )
863 })
864 .collect();
865
866 json!({
867 "documents": [
868 hit(
869 "doc1",
870 document1,
871 "This chunk is a snippet from page {} of the course {}. Mock test page content This is test content blah",
872 ),
873 hit(
874 "doc2",
875 document2,
876 "Mock test page content 2 This is another test page.",
877 ),
878 hit("doc3", document1, document3.chunk),
881 ],
882 "get_urls": get_urls,
883 })
884 .to_string()
885}
886
887fn blocking_response(payload: &str) -> String {
890 let response_id = format!("resp_{}", Uuid::new_v4());
891 let item_id = format!("msg_{}", Uuid::new_v4());
892
893 let mut response = completed_response_object(&response_id, usage(30, 0, 15, 0));
894 response["output"] = json!([message_item(
895 &item_id,
896 &response_id,
897 json!([{ "type": "output_text", "text": payload }]),
898 "completed",
899 )]);
900 response.to_string()
901}
902
903const MESSAGE_SUGGESTION_PAYLOAD: &str =
905 r#"{"suggestions":["Can you pls help me?","Nice weather we're having.","Hello?"]}"#;
906
907const CMS_SUGGESTION_PAYLOAD: &str = r#"{"suggestions":["Mock suggestion 1: The paragraph has been improved.","Mock suggestion 2: Here is an alternative version of the paragraph.","Mock suggestion 3: A third distinct rewrite of the paragraph."]}"#;
909
910const COURSE_DESCRIPTION_PAYLOAD: &str = r#"{"modules":[{"description":"Introductory course to containers and containerization with Docker. Introduces containerization with Docker and relevant concepts such as image and volume. After completion, students are able to run containerized applications, containerize applications, utilize volumes to store data persistently outside containers, use port mapping to enable access via TCP to containerized applications, and share their own containers publicly. No hard prerequisites; Linux operating systems and web development experience are useful.","prerequisites":["No hard prerequisites","Linux operating systems and web development experience are useful"],"course_code":"TKT21036"}],"audience":["everyone"],"course_description":"Introductory course to containers and containerization with Docker. Introduces containerization with Docker and relevant concepts such as image and volume. After completion, students are able to run containerized applications, containerize applications, utilize volumes to store data persistently outside containers, use port mapping to enable access via TCP to containerized applications, and share their own containers publicly."}"#;
912
913const PROMPT_CREATION_PAYLOAD: &str = r#"{"prompt":"You are a helpful, clear, and concise chatbot for a course. Your purpose is to help learners understand and navigate the course, answer questions about its content when information is available, explain chatbot-related concepts at an appropriate level, and support learning with examples or step-by-step guidance. Do not invent course details, lessons, assignments, policies, or resources that have not been provided. If a question cannot be answered from the available information, say so plainly and ask the learner to provide more context or consult the course materials. Be friendly, professional, and focused. Keep responses relevant and avoid overwhelming the learner. When appropriate, suggest a practical next step or ask a clarifying question.","first_message":"Hi! I’m here to help you. Ask me about anything you’d like!","suggested_messages":["Can you pls help me?","Nice weather we're having.","Hello?"]}"#;
914
915async fn mock_azure_embeddings(
918 app_conf: web::Data<ApplicationConfiguration>,
919 payload: web::Json<EmbeddingRequest>,
920) -> ControllerResult<String> {
921 assert!(app_conf.test_chatbot && app_conf.test_mode);
922
923 if payload.input.iter().any(|s| s.trim().is_empty()) {
924 return Err(ControllerError::new(
925 ControllerErrorType::BadRequest,
926 "input must not be empty".to_string(),
927 None,
928 ));
929 }
930
931 let mock_response = EmbeddingResponse {
932 object: "list".to_string(),
933 model: "mock-embedder-3-small".to_string(),
934 usage: EmbeddingResponseUsage {
935 prompt_tokens: payload.input.len() as i32,
936 total_tokens: payload.input.len() as i32,
937 },
938 data: payload
939 .input
940 .iter()
941 .enumerate()
942 .map(|(index, _)| Embedding {
943 index: index as i32,
944 embedding: vec![0.0; 1536],
945 object: "embedding".to_string(),
946 })
947 .collect(),
948 };
949 let res = serde_json::to_string(&mock_response)?;
950 let token = skip_authorize();
951 token.authorized_ok(res)
952}
953
954pub fn _add_routes(cfg: &mut ServiceConfig) {
955 cfg.route(
956 "/api/projects/test/openai/v1/responses",
957 web::get().to(mock_azure_chat_responses),
958 )
959 .route(
960 "/api/projects/test/openai/v1/responses",
961 web::post().to(mock_azure_chat_responses),
962 )
963 .route("openai/v1/embeddings", web::get().to(mock_azure_embeddings))
964 .route(
965 "openai/v1/embeddings",
966 web::post().to(mock_azure_embeddings),
967 );
968}
969
970#[cfg(test)]
971mod tests {
972 use headless_lms_chatbot::{
973 azure_chatbot::azure::protocol::{
974 AISearchOutput, OutputItem, ReceivedOutputItem, ResponseOutput,
975 },
976 chatbot_tools::ClientChatbotTool,
977 llm_utils::{LLMResponse, parse_text_completion},
978 };
979 use regex::Regex;
980
981 use super::*;
982
983 const BASE_URL: &str = "http://project-331.local";
984
985 fn sse_events(body: &str) -> Vec<(&str, &str)> {
987 let mut events = Vec::new();
988 let mut pending = None;
989 for line in body.lines() {
990 if let Some(event) = line.strip_prefix("event: ") {
991 pending = Some(event);
992 } else if let Some(data) = line.strip_prefix("data: ")
993 && let Some(event) = pending.take()
994 {
995 events.push((event, data));
996 }
997 }
998 events
999 }
1000
1001 fn respond(request: &MockRequest) -> String {
1003 let scenario = pick_scenario(request).expect("the mock answers this request");
1004 (scenario.respond)(request, BASE_URL)
1005 }
1006
1007 fn example_bodies(stream: bool) -> Vec<(&'static str, String)> {
1009 SCENARIOS
1010 .iter()
1011 .filter(|scenario| (scenario.example)().stream == stream)
1012 .map(|scenario| {
1013 let example = (scenario.example)();
1014 (scenario.name, (scenario.respond)(&example, BASE_URL))
1015 })
1016 .collect()
1017 }
1018
1019 fn called_tool_names(events: &[(&str, &str)]) -> Vec<String> {
1021 events
1022 .iter()
1023 .filter_map(|(_, data)| {
1024 match serde_json::from_str::<ResponseOutput>(data)
1025 .ok()?
1026 .item?
1027 .known()?
1028 {
1029 OutputItem::FunctionCall { tool_name, .. } => Some(tool_name),
1030 _ => None,
1031 }
1032 })
1033 .collect()
1034 }
1035
1036 fn first_delta(events: &[(&str, &str)]) -> usize {
1039 let index = events
1040 .iter()
1041 .position(|(event, _)| event.ends_with(".delta"))
1042 .expect("The round streams a delta event");
1043 assert!(
1044 events[..index]
1045 .iter()
1046 .any(|(event, _)| *event == "response.created"),
1047 "The response id has to be known before the first delta"
1048 );
1049 assert_eq!(
1050 events.last().map(|(event, _)| *event),
1051 Some("response.completed")
1052 );
1053 index
1054 }
1055
1056 fn registered_tool_names() -> Vec<String> {
1058 get_chatbot_tool_definitions()
1059 .into_iter()
1060 .filter_map(|definition| match definition {
1061 AzureLLMToolDefinition::Function(function) => Some(function.name),
1062 AzureLLMToolDefinition::Search(_) => None,
1063 })
1064 .collect()
1065 }
1066
1067 #[test]
1071 fn every_streamed_data_line_parses_into_chatbot_types() {
1072 let bodies = example_bodies(true);
1073 assert!(!bodies.is_empty(), "no streamed scenario is registered");
1074 for (name, body) in bodies {
1075 let events = sse_events(&body);
1076 assert!(!events.is_empty(), "{name} streams no events");
1077 for (event, data) in events {
1078 let parsed: ResponseOutput = serde_json::from_str(data).unwrap_or_else(|e| {
1079 panic!("{name} streams a {event} the chatbot cannot parse: {e}\n{data}")
1080 });
1081 if event.starts_with("response.output_item.") {
1082 assert!(
1083 parsed.item.is_some(),
1084 "{name}: {event} carries no item\n{data}"
1085 );
1086 }
1087 if event == "response.created" {
1088 assert!(
1089 parsed.response.and_then(|response| response.id).is_some(),
1090 "{name}: {event} carries no response id\n{data}"
1091 );
1092 }
1093 }
1094 }
1095 }
1096
1097 #[test]
1100 fn every_scenario_answers_its_own_example() {
1101 for scenario in SCENARIOS {
1102 let request = (scenario.example)();
1103 let picked = pick_scenario(&request).map(|picked| picked.name);
1104 assert_eq!(
1105 picked,
1106 Some(scenario.name),
1107 "{} is not the scenario its own example is answered by",
1108 scenario.name
1109 );
1110 }
1111 }
1112
1113 #[test]
1116 fn structured_output_responses_parse_into_chatbot_types() {
1117 let bodies = example_bodies(false);
1118 assert!(!bodies.is_empty(), "no blocking scenario is registered");
1119 for (name, body) in bodies {
1120 let completion: LLMResponse = serde_json::from_str(&body)
1121 .unwrap_or_else(|e| panic!("{name} does not parse as an LLM response: {e}"));
1122 let content = parse_text_completion(completion)
1123 .unwrap_or_else(|e| panic!("{name} has no text content: {e}"));
1124 serde_json::from_str::<Value>(&content).unwrap_or_else(|e| {
1125 panic!("{name} content is not the JSON the feature parses: {e}\n{content}")
1126 });
1127 }
1128 }
1129
1130 #[test]
1133 fn a_blocking_response_carries_its_payload_as_the_whole_text_content() {
1134 for payload in [
1135 MESSAGE_SUGGESTION_PAYLOAD,
1136 CMS_SUGGESTION_PAYLOAD,
1137 COURSE_DESCRIPTION_PAYLOAD,
1138 ] {
1139 let completion: LLMResponse = serde_json::from_str(&blocking_response(payload))
1140 .expect("the blocking response parses as an LLM response");
1141 let content = parse_text_completion(completion).expect("the response has text content");
1142 assert_eq!(content, payload);
1143 }
1144 }
1145
1146 #[test]
1150 fn the_function_call_round_drives_the_tool_call_parser() {
1151 let body = respond(&MockRequest::chat(TOOL_CALL_TRIGGER));
1152 let events = sse_events(&body);
1153
1154 let first_delta = first_delta(&events);
1155 assert_eq!(
1156 events[first_delta].0,
1157 "response.function_call_arguments.delta"
1158 );
1159 assert!(
1160 !events
1161 .iter()
1162 .any(|(event, _)| *event == "response.output_text.delta"),
1163 "A text delta makes the tool parser error out"
1164 );
1165
1166 let called = called_tool_names(&events[first_delta + 1..]);
1167 assert!(
1168 !called.is_empty(),
1169 "The tool parser only sees function calls delivered after the first delta"
1170 );
1171
1172 let registered = registered_tool_names();
1173 for tool_name in called {
1174 assert!(
1175 registered.contains(&tool_name),
1176 "The mock calls {tool_name}, which no chatbot tool is registered under"
1177 );
1178 }
1179 }
1180
1181 #[test]
1184 fn the_client_tool_call_round_calls_a_tool_only_the_client_answers() {
1185 let body = respond(&MockRequest::chat(CLIENT_TOOL_CALL_TRIGGER));
1186
1187 let called = called_tool_names(&sse_events(&body));
1188 assert!(!called.is_empty(), "The round calls no tool");
1189 let registered = registered_tool_names();
1190 for tool_name in called {
1191 assert!(
1192 tool_is_answered_by_client(&tool_name),
1193 "The mock calls {tool_name} to suspend a turn, but the client does not answer it"
1194 );
1195 assert!(
1196 !registered.contains(&tool_name),
1197 "{tool_name} is registered as a chatbot tool, so the turn would never suspend"
1198 );
1199 }
1200 }
1201
1202 #[test]
1205 fn the_client_tool_call_round_asks_a_question_the_tool_accepts() {
1206 AskMultipleChoiceQuestionTool::parse_arguments(MOCK_MULTIPLE_CHOICE_ARGUMENTS)
1207 .expect("the mock's question passes the tool's own validation");
1208 }
1209
1210 #[test]
1211 fn the_answer_after_a_tool_ran_drives_the_text_parser() {
1212 let body = respond(&MockRequest::after_tool_run());
1213 let events = sse_events(&body);
1214
1215 assert_eq!(events[first_delta(&events)].0, "response.output_text.delta");
1216
1217 let streamed: String = events
1218 .iter()
1219 .filter(|(event, _)| *event == "response.output_text.delta")
1220 .filter_map(|(_, data)| serde_json::from_str::<ResponseOutput>(data).ok()?.delta)
1221 .collect();
1222 assert!(!streamed.is_empty(), "The round streams no text");
1223 }
1224
1225 #[test]
1229 fn the_search_round_answers_the_text_the_system_tests_wait_for() {
1230 let answer = SEARCH_ANSWER_DELTAS.concat();
1231 assert_eq!(
1232 answer,
1233 "Hello! How can I assist 【0:2†source】 you 【0:1†source】 today?【0:2†source】"
1234 );
1235
1236 let stripped = Regex::new(r"\s*?【\d+:\d+†source】")
1238 .expect("the citation regex compiles")
1239 .replace_all(&answer, "");
1240 assert_eq!(stripped, "Hello! How can I assist you today?");
1241 }
1242
1243 #[test]
1247 fn the_search_round_streams_document_urls() {
1248 let body = search_and_text_round(BASE_URL);
1249
1250 let search_outputs: Vec<String> = sse_events(&body)
1251 .into_iter()
1252 .filter_map(|(_, data)| {
1253 match serde_json::from_str::<ResponseOutput>(data)
1254 .ok()?
1255 .item?
1256 .known()?
1257 {
1258 OutputItem::AzureAiSearchCallOutput { output, .. }
1259 if output.contains("get_urls") =>
1260 {
1261 Some(output)
1262 }
1263 _ => None,
1264 }
1265 })
1266 .collect();
1267 assert!(
1268 !search_outputs.is_empty(),
1269 "The search round streams no search output with urls"
1270 );
1271 for output in search_outputs {
1272 let parsed: AISearchOutput = serde_json::from_str(&output)
1273 .unwrap_or_else(|e| panic!("The search output does not parse: {e}\n{output}"));
1274 assert_eq!(parsed.get_urls.len(), 3);
1275 for url in parsed.get_urls {
1276 assert!(url.as_str().starts_with(BASE_URL), "{url}");
1277 }
1278 }
1279 }
1280
1281 #[test]
1284 fn the_incomplete_answer_round_ends_on_incomplete_not_completed() {
1285 let body = respond(&MockRequest::chat(INCOMPLETE_ANSWER_TRIGGER));
1286 let events = sse_events(&body);
1287 assert_eq!(
1288 events.last().map(|(event, _)| *event),
1289 Some("response.incomplete")
1290 );
1291 assert!(
1292 !events
1293 .iter()
1294 .any(|(event, _)| *event == "response.completed"),
1295 "an incomplete round must not also carry a response.completed"
1296 );
1297 }
1298
1299 #[test]
1302 fn the_truncated_stream_round_carries_no_terminal_event() {
1303 let body = respond(&MockRequest::chat(TRUNCATED_STREAM_TRIGGER));
1304 let events = sse_events(&body);
1305 assert!(!events.is_empty(), "the round streams no events at all");
1306 assert!(
1307 !events
1308 .iter()
1309 .any(|(event, _)| *event == "response.completed" || *event == "response.incomplete"),
1310 "a truncated stream must carry neither response.completed nor response.incomplete"
1311 );
1312 }
1313
1314 #[test]
1318 fn the_malformed_search_output_round_carries_a_completed_output_that_fails_to_parse() {
1319 let body = respond(&MockRequest::chat(MALFORMED_SEARCH_OUTPUT_TRIGGER));
1320 let events = sse_events(&body);
1321 let output = events
1322 .iter()
1323 .find_map(|(_, data)| {
1324 match serde_json::from_str::<ResponseOutput>(data)
1325 .ok()?
1326 .item?
1327 .known()?
1328 {
1329 OutputItem::AzureAiSearchCallOutput { output, .. } => Some(output),
1330 _ => None,
1331 }
1332 })
1333 .expect("the round carries a search output item");
1334 assert!(
1335 serde_json::from_str::<AISearchOutput>(&output).is_err(),
1336 "the output was expected to fail the chatbot's search-output schema: {output}"
1337 );
1338 }
1339
1340 #[test]
1343 fn the_unknown_item_type_round_deserializes_as_unreadable() {
1344 let body = respond(&MockRequest::chat(UNKNOWN_ITEM_TYPE_TRIGGER));
1345 let events = sse_events(&body);
1346 let saw_unreadable = events.iter().any(|(event, data)| {
1347 event.starts_with("response.output_item.")
1348 && matches!(
1349 serde_json::from_str::<ResponseOutput>(data)
1350 .ok()
1351 .and_then(|r| r.item),
1352 Some(ReceivedOutputItem::Unreadable(_))
1353 )
1354 });
1355 assert!(saw_unreadable, "the round carries no unreadable item");
1356 }
1357}