Skip to main content

headless_lms_server/controllers/
mock_azure.rs

1use crate::controllers::mock_document_storage::{MOCK_DOCUMENTS, MockDocument};
2use crate::prelude::*;
3use headless_lms_chatbot::{
4    azure_chatbot::azure::{
5        protocol::{InputItem, LLMRequestResponseFormatParam},
6        tools::AzureLLMToolDefinition,
7    },
8    chart_spec_generation::{
9        DATA_SAMPLE_HEADING, EXISTING_SPEC_HEADING, USER_PROMPT_PREFIX as CHART_SPEC_REQUEST,
10    },
11    chatbot_tools::{
12        ChatbotToolDeclaration,
13        client_tools::ask_multiple_choice_question::AskMultipleChoiceQuestionTool,
14        custom_tools::course_structure::CourseStructureTool, get_chatbot_tool_definitions,
15        tool_is_answered_by_client,
16    },
17    cms_ai_suggestion::RESPONSE_FORMAT_NAME as CMS_SUGGESTION_FORMAT,
18    course_description_summary::RESPONSE_FORMAT_NAME as COURSE_DESCRIPTION_FORMAT,
19    feedback_categorization::RESPONSE_FORMAT_NAME as FEEDBACK_CATEGORIZATION_FORMAT,
20    llm_utils::AzureCompletionRequest,
21    message_suggestion::RESPONSE_FORMAT_NAME as MESSAGE_SUGGESTION_FORMAT,
22    prompt_creation::RESPONSE_FORMAT_NAME as PROMPT_CREATION_FORMAT,
23};
24use headless_lms_utils::azure_embedding::{
25    Embedding, EmbeddingRequest, EmbeddingResponse, EmbeddingResponseUsage,
26};
27use serde_json::{Value, json};
28
29/// Anywhere in a chat message, this makes the mock answer with a function call instead of a
30/// text answer, which is how a test drives the chatbot's tool loop. The chatbot then runs the
31/// tool and asks again with the tool output as the last input item, and the mock answers that
32/// with a text round, completing a two-round turn.
33const TOOL_CALL_TRIGGER: &str = "!MOCK_TOOL_CALL!";
34
35/// Like [TOOL_CALL_TRIGGER], but the call is one only the client can answer, so the chatbot
36/// suspends the turn instead of running anything. The turn is finished by the tool-response
37/// endpoint, whose request ends in the tool output and so gets the same text round.
38const CLIENT_TOOL_CALL_TRIGGER: &str = "!MOCK_CLIENT_TOOL_CALL!";
39
40/// Opens the trigger `!MOCK_TOOL_CALL:<tool_name>:<arguments>!`, which makes the mock call any
41/// registered tool, so exercising a new one end to end needs no scenario of its own here.
42const TOOL_CALL_BY_NAME_PREFIX: &str = "!MOCK_TOOL_CALL:";
43
44/// Drives an answer Azure cut short by `max_output_tokens`: the round ends on
45/// `response.incomplete` instead of `response.completed`.
46const INCOMPLETE_ANSWER_TRIGGER: &str = "!MOCK_INCOMPLETE_ANSWER!";
47
48/// Drives a stream a proxy closed cleanly before Azure ever sent a terminal event — neither
49/// `response.completed` nor `response.incomplete`.
50const TRUNCATED_STREAM_TRIGGER: &str = "!MOCK_TRUNCATED_STREAM!";
51
52/// Drives a search round whose completed output fails to parse as the chatbot's search-output
53/// schema: the same plain-text failure shape a real search backend can return, but on a
54/// `completed` item rather than the in-progress placeholder every other scenario here uses.
55const MALFORMED_SEARCH_OUTPUT_TRIGGER: &str = "!MOCK_MALFORMED_SEARCH_OUTPUT!";
56
57/// Drives a round that includes an output item kind the chatbot has no variant for.
58const UNKNOWN_ITEM_TYPE_TRIGGER: &str = "!MOCK_UNKNOWN_ITEM_TYPE!";
59
60/// The trigger text that makes the mock call `tool_name` with `arguments`.
61fn tool_call_by_name_trigger(tool_name: &str, arguments: &str) -> String {
62    format!("{TOOL_CALL_BY_NAME_PREFIX}{tool_name}:{arguments}!")
63}
64
65/// A tool call a message asked the mock to make.
66struct TriggeredToolCall {
67    tool_name: String,
68    arguments: String,
69}
70
71/// Reads a [TOOL_CALL_BY_NAME_PREFIX] trigger out of `message`.
72///
73/// A learner can type the trigger into the chat, so a name no registry claims has to read as an
74/// ordinary message rather than reach the chatbot as a hallucinated call. `!` closes the trigger
75/// and so cannot appear in `arguments`.
76fn parse_tool_call_by_name(message: &str) -> Option<TriggeredToolCall> {
77    let (_, rest) = message.split_once(TOOL_CALL_BY_NAME_PREFIX)?;
78    let (call, _) = rest.split_once('!')?;
79    let (tool_name, arguments) = call.split_once(':')?;
80    is_registered_tool(tool_name).then(|| TriggeredToolCall {
81        tool_name: tool_name.to_string(),
82        arguments: arguments.to_string(),
83    })
84}
85
86/// Whether either registry claims `tool_name`.
87fn is_registered_tool(tool_name: &str) -> bool {
88    tool_is_answered_by_client(tool_name)
89        || get_chatbot_tool_definitions()
90            .iter()
91            .any(|definition| match definition {
92                AzureLLMToolDefinition::Function(function) => function.name == tool_name,
93                AzureLLMToolDefinition::Search(_) => false,
94            })
95}
96
97/// The parts of a request the mock picks its answer from.
98struct MockRequest {
99    /// The text of the last input message, or `None` when the request ends in a tool output
100    /// instead. The other two endings never reach here.
101    message: Option<String>,
102    /// Every message in the request, for recognising a round of a conversation whose latest
103    /// message says nothing about which feature it belongs to.
104    conversation: Vec<String>,
105    /// The structured output schema the answer has to parse as, `None` when none was named.
106    format_name: Option<String>,
107    /// Whether the caller asked for any valid JSON object instead of a named schema.
108    wants_json_object: bool,
109    /// Whether the caller reads the answer as a Server-Sent Events stream or as one JSON object.
110    stream: bool,
111}
112
113impl MockRequest {
114    /// A streamed chat request carrying `message`.
115    fn chat(message: &str) -> Self {
116        MockRequest {
117            message: Some(message.to_string()),
118            conversation: vec![message.to_string()],
119            format_name: None,
120            wants_json_object: false,
121            stream: true,
122        }
123    }
124
125    /// A streamed request resuming a tool loop, which ends in the tool's output rather than in a
126    /// message.
127    fn after_tool_run() -> Self {
128        MockRequest {
129            message: None,
130            conversation: Vec::new(),
131            format_name: None,
132            wants_json_object: false,
133            stream: true,
134        }
135    }
136
137    /// A request for structured output in `format_name`. Its message is one that would drive a
138    /// function call round if the message decided anything here, which it must not.
139    fn structured_output(format_name: &str) -> Self {
140        MockRequest {
141            message: Some(TOOL_CALL_TRIGGER.to_string()),
142            conversation: vec![TOOL_CALL_TRIGGER.to_string()],
143            format_name: Some(format_name.to_string()),
144            wants_json_object: false,
145            stream: false,
146        }
147    }
148
149    /// A request for any valid JSON object, whose conversation contains `request_text`.
150    fn json_object(request_text: &str) -> Self {
151        MockRequest {
152            message: Some(request_text.to_string()),
153            conversation: vec![request_text.to_string()],
154            format_name: None,
155            wants_json_object: true,
156            stream: false,
157        }
158    }
159
160    /// Whether this asks for the structured output named `format_name`. False for a streamed
161    /// request even when it names the schema, since the answer to one is a whole JSON object that
162    /// a streaming caller cannot read.
163    fn wants_format(&self, format_name: &str) -> bool {
164        !self.stream && self.format_name.as_deref() == Some(format_name)
165    }
166
167    /// Whether this asks for a JSON object and is part of a conversation that opened with
168    /// `request_text`. A JSON-mode request names no schema, so the feature it belongs to can only
169    /// be told from its prompt -- and not from the latest message alone, which on a repair round is
170    /// the correction rather than the request.
171    fn wants_json_object_for(&self, request_text: &str) -> bool {
172        !self.stream
173            && self.wants_json_object
174            && self
175                .conversation
176                .iter()
177                .any(|message| message.contains(request_text))
178    }
179
180    /// The part of the prompt that `heading` introduces, ending where `next_heading` begins or at
181    /// the end of the message. None when no message in the conversation carries `heading`.
182    ///
183    /// Reads the first message that has it: on a repair round the latest message is the correction
184    /// rather than the request the headings belong to.
185    fn prompt_section(&self, heading: &str, next_heading: &str) -> Option<&str> {
186        let (_, after) = self
187            .conversation
188            .iter()
189            .find_map(|message| message.split_once(heading))?;
190        Some(after.split(next_heading).next().unwrap_or(after))
191    }
192
193    /// The tool call a streamed chat message asks the mock to make, if any.
194    fn triggered_tool_call(&self) -> Option<TriggeredToolCall> {
195        if !self.stream {
196            return None;
197        }
198        parse_tool_call_by_name(self.message.as_deref()?)
199    }
200
201    /// Whether this is a streamed chat message containing `trigger`.
202    fn message_contains(&self, trigger: &str) -> bool {
203        self.stream
204            && self
205                .message
206                .as_deref()
207                .is_some_and(|message| message.contains(trigger))
208    }
209}
210
211/// One shape of request the mock answers, and the answer it gives.
212///
213/// The tests drive every registered scenario through its own [`example`](Scenario::example), so
214/// registering a round here is what gets it verified at all.
215struct Scenario {
216    /// Names the scenario in the handler's log line and in test failures.
217    name: &'static str,
218    matches: fn(&MockRequest) -> bool,
219    /// Builds the answer to `request` against the base url its document urls have to point at.
220    respond: fn(&MockRequest, &str) -> String,
221    /// A request this scenario answers.
222    #[cfg_attr(not(test), allow(dead_code))]
223    example: fn() -> MockRequest,
224}
225
226/// The first scenario that matches answers, so the default chat answer, which takes any streamed
227/// message at all, comes last.
228///
229/// A blocking caller parses the whole body as one JSON object and a streaming one reads it event by
230/// event, so no answer suits both, and every scenario says which kind it is. Among the blocking
231/// ones the schema alone decides: a learner is free to type anything into the chat, so no part of a
232/// message may reach a feature's answer.
233const SCENARIOS: &[Scenario] = &[
234    Scenario {
235        name: "the next message suggestion",
236        matches: |request| request.wants_format(MESSAGE_SUGGESTION_FORMAT),
237        respond: |_, _| blocking_response(MESSAGE_SUGGESTION_PAYLOAD),
238        example: || MockRequest::structured_output(MESSAGE_SUGGESTION_FORMAT),
239    },
240    Scenario {
241        name: "the CMS paragraph suggestion",
242        matches: |request| request.wants_format(CMS_SUGGESTION_FORMAT),
243        respond: |_, _| blocking_response(CMS_SUGGESTION_PAYLOAD),
244        example: || MockRequest::structured_output(CMS_SUGGESTION_FORMAT),
245    },
246    Scenario {
247        name: "the course description summary",
248        matches: |request| request.wants_format(COURSE_DESCRIPTION_FORMAT),
249        respond: |_, _| blocking_response(COURSE_DESCRIPTION_PAYLOAD),
250        example: || MockRequest::structured_output(COURSE_DESCRIPTION_FORMAT),
251    },
252    Scenario {
253        name: "the feedback categorization",
254        matches: |request| request.wants_format(FEEDBACK_CATEGORIZATION_FORMAT),
255        respond: |_, _| blocking_response(FEEDBACK_CATEGORIZATION_PAYLOAD),
256        example: || MockRequest::structured_output(FEEDBACK_CATEGORIZATION_FORMAT),
257    },
258    Scenario {
259        name: "the chart block specification",
260        matches: |request| request.wants_json_object_for(CHART_SPEC_REQUEST),
261        respond: |request, _| blocking_response(&chart_spec_payload(request)),
262        example: || MockRequest::json_object(CHART_SPEC_REQUEST),
263    },
264    Scenario {
265        name: "the client tool call round",
266        matches: |request| request.message_contains(CLIENT_TOOL_CALL_TRIGGER),
267        respond: |_, _| {
268            function_call_round(
269                <AskMultipleChoiceQuestionTool as ChatbotToolDeclaration>::NAME,
270                MOCK_MULTIPLE_CHOICE_ARGUMENTS,
271            )
272        },
273        example: || MockRequest::chat(CLIENT_TOOL_CALL_TRIGGER),
274    },
275    Scenario {
276        name: "the tool call round for a named tool",
277        matches: |request| request.triggered_tool_call().is_some(),
278        respond: |request, _| {
279            let call = request
280                .triggered_tool_call()
281                .expect("the scenario only answers a request carrying a tool call trigger");
282            function_call_round(&call.tool_name, &call.arguments)
283        },
284        example: || {
285            MockRequest::chat(&tool_call_by_name_trigger(
286                <CourseStructureTool as ChatbotToolDeclaration>::NAME,
287                "{}",
288            ))
289        },
290    },
291    Scenario {
292        name: "the function call round",
293        matches: |request| request.message_contains(TOOL_CALL_TRIGGER),
294        respond: |_, _| {
295            function_call_round(<CourseStructureTool as ChatbotToolDeclaration>::NAME, "{}")
296        },
297        example: || MockRequest::chat(TOOL_CALL_TRIGGER),
298    },
299    Scenario {
300        name: "the answer after a tool ran",
301        matches: |request| request.stream && request.message.is_none(),
302        respond: |_, _| tool_answer_round(),
303        example: MockRequest::after_tool_run,
304    },
305    Scenario {
306        name: "the prompt and first message generation",
307        matches: |request| request.wants_format(PROMPT_CREATION_FORMAT),
308        respond: |_, _| blocking_response(PROMPT_CREATION_PAYLOAD),
309        example: || MockRequest::structured_output(PROMPT_CREATION_FORMAT),
310    },
311    Scenario {
312        name: "an answer cut short by the token limit",
313        matches: |request| request.message_contains(INCOMPLETE_ANSWER_TRIGGER),
314        respond: |_, _| incomplete_answer_round(),
315        example: || MockRequest::chat(INCOMPLETE_ANSWER_TRIGGER),
316    },
317    Scenario {
318        name: "a stream that ends before response.completed",
319        matches: |request| request.message_contains(TRUNCATED_STREAM_TRIGGER),
320        respond: |_, _| truncated_stream_round(),
321        example: || MockRequest::chat(TRUNCATED_STREAM_TRIGGER),
322    },
323    Scenario {
324        name: "a completed search output that fails to parse",
325        matches: |request| request.message_contains(MALFORMED_SEARCH_OUTPUT_TRIGGER),
326        respond: |_, _| malformed_search_output_round(),
327        example: || MockRequest::chat(MALFORMED_SEARCH_OUTPUT_TRIGGER),
328    },
329    Scenario {
330        name: "an output item type the chatbot does not know",
331        matches: |request| request.message_contains(UNKNOWN_ITEM_TYPE_TRIGGER),
332        respond: |_, _| unknown_item_type_round(),
333        example: || MockRequest::chat(UNKNOWN_ITEM_TYPE_TRIGGER),
334    },
335    Scenario {
336        name: "the default chat answer",
337        matches: |request| request.stream && request.message.is_some(),
338        respond: |_, base_url| search_and_text_round(base_url),
339        example: || MockRequest::chat("Tell me more"),
340    },
341];
342
343/// The scenario that answers `request`, or `None` when the mock answers nothing like it.
344fn pick_scenario(request: &MockRequest) -> Option<&'static Scenario> {
345    SCENARIOS
346        .iter()
347        .find(|scenario| (scenario.matches)(request))
348}
349
350/// GET /api/v0/mock-azure/api/projects/test/openai/v1/responses
351/// POST /api/v0/mock-azure/api/projects/test/openai/v1/responses
352///
353/// Stands in for the Azure Responses API while the chatbot runs in test mode. Answers with the
354/// first scenario in [`SCENARIOS`] whose request shape matches, and 400s on a request no scenario
355/// answers.
356async fn mock_azure_chat_responses(
357    app_conf: web::Data<ApplicationConfiguration>,
358    payload: web::Json<AzureCompletionRequest>,
359) -> ControllerResult<String> {
360    assert!(app_conf.test_chatbot && app_conf.test_mode);
361
362    let last_input_item = &payload
363        .base
364        .input
365        .last()
366        .ok_or_else(|| {
367            controller_err!(
368                BadRequest,
369                "No messages in request, there should be at least one."
370            )
371        })?
372        .message_type;
373
374    let message = match last_input_item {
375        InputItem::Message { content, .. } => Some(content.clone().get_content_text()),
376        InputItem::FunctionCallOutput { .. } => None,
377        InputItem::FunctionCall { .. } | InputItem::Reasoning { .. } => {
378            return Err(controller_err!(
379                BadRequest,
380                "The mock has no response for a request that ends in a function call or a reasoning item."
381            ));
382        }
383    };
384
385    let requested_format = payload
386        .base
387        .text
388        .as_ref()
389        .and_then(|text| text.format.as_ref());
390    let request = MockRequest {
391        message,
392        conversation: payload
393            .base
394            .input
395            .iter()
396            .filter_map(|item| match &item.message_type {
397                InputItem::Message { content, .. } => Some(content.clone().get_content_text()),
398                _ => None,
399            })
400            .collect(),
401        format_name: match requested_format {
402            Some(LLMRequestResponseFormatParam::JsonSchema { name, .. }) => Some(name.clone()),
403            _ => None,
404        },
405        wants_json_object: matches!(
406            requested_format,
407            Some(LLMRequestResponseFormatParam::JsonObject)
408        ),
409        stream: payload.stream,
410    };
411    let scenario = pick_scenario(&request).ok_or_else(|| {
412        controller_err!(
413            BadRequest,
414            "The mock has no response for this shape of request."
415        )
416    })?;
417    debug!(scenario = scenario.name, "Answering as the mock Azure API");
418    let res = (scenario.respond)(&request, &app_conf.base_url);
419
420    let token = skip_authorize();
421    token.authorized_ok(res)
422}
423
424/// Renders `events` as a Server-Sent Events body in the order given, stamping every `data:` object
425/// with its own event name as `type`, the way Azure repeats it. The chatbot reads the `event:` line
426/// to decide what the `data:` line after it means, so the pairing and the order are what make a
427/// round parse as a tool call or as text.
428fn sse_body(events: Vec<(&str, Value)>) -> String {
429    events
430        .into_iter()
431        .map(|(event, mut data)| {
432            if let Some(object) = data.as_object_mut() {
433                object.insert("type".to_string(), json!(event));
434            }
435            format!("event: {event}\ndata: {data}\n\n")
436        })
437        .collect()
438}
439
440/// Wraps a round's own `events` in the lifecycle events every round shares: `response.created`,
441/// which is where the chatbot picks up the response id it needs before the round is classified,
442/// and the terminal `response.completed`.
443fn round(response_id: &str, events: Vec<(&'static str, Value)>, usage: Value) -> String {
444    let mut all = vec![(
445        "response.created",
446        json!({"response": response_object(response_id)}),
447    )];
448    all.extend(events);
449    all.push((
450        "response.completed",
451        json!({"response": completed_response_object(response_id, usage)}),
452    ));
453    sse_body(all)
454}
455
456/// What one round was billed for. `cached_tokens` is the part of the input Azure served from the
457/// prompt cache, and the rest of it is what the round wrote there.
458fn usage(
459    input_tokens: u32,
460    cached_tokens: u32,
461    output_tokens: u32,
462    reasoning_tokens: u32,
463) -> Value {
464    json!({
465        "input_tokens": input_tokens,
466        "input_tokens_details": {
467            "cached_tokens": cached_tokens,
468            "cache_write_tokens": input_tokens.saturating_sub(cached_tokens),
469        },
470        "output_tokens": output_tokens,
471        "output_tokens_details": {"reasoning_tokens": reasoning_tokens},
472        "total_tokens": input_tokens + output_tokens,
473    })
474}
475
476/// The response object of a lifecycle event before the last one. Only `id` and a possible `error`
477/// are read by the chatbot; Azure sends the full request parameters here as well. See
478/// [`completed_response_object`] for the terminal event.
479fn response_object(response_id: &str) -> Value {
480    json!({
481        "id": response_id,
482        "object": "response",
483        "status": "in_progress",
484        "usage": null,
485    })
486}
487
488/// The response object of the `response.completed` event, the only lifecycle event Azure reports
489/// token usage and the reasoning context on.
490fn completed_response_object(response_id: &str, usage: Value) -> Value {
491    json!({
492        "id": response_id,
493        "object": "response",
494        "status": "completed",
495        "reasoning": {"effort": "medium", "summary": null, "context": "current_turn"},
496        "usage": usage,
497    })
498}
499
500/// One assistant message as an output item, in the two shapes a round streams it in: `content` is
501/// empty while the item is in progress and carries the whole text once it is done.
502fn message_item(item_id: &str, response_id: &str, content: Value, status: &str) -> Value {
503    json!({
504        "type": "message",
505        "id": item_id,
506        "response_id": response_id,
507        "phase": "final_answer",
508        "role": "assistant",
509        "content": content,
510        "status": status,
511    })
512}
513
514/// The events that stream one assistant message: the item, its content part, one event per delta,
515/// and the finished item. `output_index` is the message's place among the round's output items, so
516/// it depends on how many items the round emitted before this one.
517fn message_item_events(
518    item_id: &str,
519    response_id: &str,
520    output_index: u32,
521    deltas: &[&str],
522) -> Vec<(&'static str, Value)> {
523    let text = deltas.concat();
524
525    let mut events = vec![
526        (
527            "response.output_item.added",
528            json!({
529                "output_index": output_index,
530                "item": message_item(item_id, response_id, json!([]), "in_progress"),
531            }),
532        ),
533        (
534            "response.content_part.added",
535            json!({
536                "content_index": 0,
537                "item_id": item_id,
538                "output_index": output_index,
539                "part": { "type": "output_text", "text": "" },
540            }),
541        ),
542    ];
543    events.extend(deltas.iter().map(|delta| {
544        (
545            "response.output_text.delta",
546            json!({
547                "content_index": 0,
548                "item_id": item_id,
549                "output_index": output_index,
550                "delta": delta,
551            }),
552        )
553    }));
554    events.extend([
555        (
556            "response.output_text.done",
557            json!({
558                "content_index": 0,
559                "item_id": item_id,
560                "output_index": output_index,
561                "text": text,
562            }),
563        ),
564        (
565            "response.content_part.done",
566            json!({
567                "content_index": 0,
568                "item_id": item_id,
569                "output_index": output_index,
570                "part": { "type": "output_text", "text": text },
571            }),
572        ),
573        (
574            "response.output_item.done",
575            json!({
576                "output_index": output_index,
577                "item": message_item(
578                    item_id,
579                    response_id,
580                    json!([{ "type": "output_text", "text": text }]),
581                    "completed",
582                ),
583            }),
584        ),
585    ]);
586    events
587}
588
589/// The events that stream the reasoning item a round emits before it calls anything or answers,
590/// which is what makes it the round's first output item.
591fn reasoning_item_events(response_id: &str) -> Vec<(&'static str, Value)> {
592    let item = reasoning_item(response_id);
593    vec![
594        (
595            "response.output_item.added",
596            json!({"output_index": 0, "item": item}),
597        ),
598        (
599            "response.output_item.done",
600            json!({"output_index": 0, "item": item}),
601        ),
602    ]
603}
604
605/// The item [`reasoning_item_events`] streams, unchanged in both of its events.
606fn reasoning_item(response_id: &str) -> Value {
607    json!({
608        "type": "reasoning",
609        "id": format!("rs_{}", Uuid::new_v4()),
610        "response_id": response_id,
611        "summary": [],
612        // Azure returns this whenever `store` is false, and the chatbot only replays a reasoning
613        // item that has it, so without it the mock never exercises the replay path.
614        "encrypted_content": "mock-encrypted-reasoning",
615    })
616}
617
618/// The question [CLIENT_TOOL_CALL_TRIGGER] makes the mock ask. Has to pass the tool's own
619/// argument validation, which the chatbot runs before it suspends the turn.
620const MOCK_MULTIPLE_CHOICE_ARGUMENTS: &str =
621    r#"{"question":"Which loop do you mean?","choices":["while","for"]}"#;
622
623/// A round in which the model calls `tool_name` with `arguments`.
624///
625/// Every tool it is used with works without a user, so the round works for an anonymous course
626/// material visitor.
627///
628/// The chatbot classifies the round from the function call item it announces and passes the tool
629/// parser only what follows that item, so the completed call has to arrive in a later
630/// `output_item.done`, and the round must contain no text delta at all.
631fn function_call_round(tool_name: &str, arguments: &str) -> String {
632    let response_id = format!("resp_{}", Uuid::new_v4());
633    let item_id = format!("fc_{}", Uuid::new_v4());
634    let call_id = format!("call_{}", Uuid::new_v4());
635
636    let function_call = |arguments: &str, status: &str| {
637        json!({
638            "type": "function_call",
639            "id": item_id,
640            "response_id": response_id,
641            "call_id": call_id,
642            "name": tool_name,
643            "arguments": arguments,
644            "status": status,
645        })
646    };
647    let mut events = reasoning_item_events(&response_id);
648    events.extend([
649        (
650            "response.output_item.added",
651            json!({"output_index": 1, "item": function_call("", "in_progress")}),
652        ),
653        (
654            "response.function_call_arguments.delta",
655            json!({"item_id": item_id, "output_index": 1, "delta": arguments}),
656        ),
657        (
658            "response.function_call_arguments.done",
659            json!({"item_id": item_id, "output_index": 1, "arguments": arguments}),
660        ),
661        (
662            "response.output_item.done",
663            json!({"output_index": 1, "item": function_call(arguments, "completed")}),
664        ),
665    ]);
666
667    round(&response_id, events, usage(42, 0, 88, 64))
668}
669
670/// The text answer the model gives once a tool has run.
671fn tool_answer_round() -> String {
672    let response_id = format!("resp_{}", Uuid::new_v4());
673    let item_id = format!("msg_{}", Uuid::new_v4());
674    let deltas = [
675        "Here", " is", " the", " mock", " answer", " after", " a", " tool", " ran.",
676    ];
677
678    round(
679        &response_id,
680        message_item_events(&item_id, &response_id, 0, &deltas),
681        // Second round of the same turn, so what the first round wrote to the cache comes back as
682        // a cache read here.
683        usage(96, 42, 24, 8),
684    )
685}
686
687/// An answer `max_output_tokens` cut short: it ends on `response.incomplete`, never on
688/// `response.completed`, with only a partial delta having streamed.
689fn incomplete_answer_round() -> String {
690    let response_id = format!("resp_{}", Uuid::new_v4());
691    let item_id = format!("msg_{}", Uuid::new_v4());
692
693    let events = vec![
694        (
695            "response.created",
696            json!({"response": response_object(&response_id)}),
697        ),
698        (
699            "response.output_item.added",
700            json!({
701                "output_index": 0,
702                "item": message_item(&item_id, &response_id, json!([]), "in_progress"),
703            }),
704        ),
705        (
706            "response.output_text.delta",
707            json!({
708                "content_index": 0,
709                "item_id": item_id,
710                "output_index": 0,
711                "delta": "This answer gets cut",
712            }),
713        ),
714        (
715            "response.incomplete",
716            json!({"response": {
717                "id": response_id,
718                "object": "response",
719                "status": "incomplete",
720                "incomplete_details": {"reason": "max_output_tokens"},
721                "usage": usage(30, 0, 8, 0),
722            }}),
723        ),
724    ];
725    sse_body(events)
726}
727
728/// A stream a proxy closed cleanly before Azure ever sent `response.completed` or
729/// `response.incomplete` — the shape a clean EOF produces, as opposed to the idle-timeout error a
730/// stalled connection produces.
731fn truncated_stream_round() -> String {
732    let response_id = format!("resp_{}", Uuid::new_v4());
733    let item_id = format!("fc_{}", Uuid::new_v4());
734    let call_id = format!("call_{}", Uuid::new_v4());
735
736    sse_body(vec![
737        (
738            "response.created",
739            json!({"response": response_object(&response_id)}),
740        ),
741        (
742            "response.output_item.done",
743            json!({
744                "output_index": 0,
745                "item": {
746                    "type": "function_call",
747                    "id": item_id,
748                    "response_id": response_id,
749                    "call_id": call_id,
750                    "name": <CourseStructureTool as ChatbotToolDeclaration>::NAME,
751                    "arguments": "{}",
752                    "status": "completed",
753                },
754            }),
755        ),
756    ])
757}
758
759/// A completed Azure AI Search output whose `output` is the same plain-text failure shape a real
760/// search backend can return, rather than the `AISearchOutput` JSON the round otherwise expects —
761/// on a `completed` item, unlike the in-progress placeholder [`search_and_text_round`] always
762/// resolves through.
763fn malformed_search_output_round() -> String {
764    let response_id = format!("resp_{}", Uuid::new_v4());
765    let call_id = format!("call_{}", Uuid::new_v4());
766    let search_item_id = format!("fc_{}", Uuid::new_v4());
767    let output_item_id = format!("fco_{}", Uuid::new_v4());
768    let message_item_id = format!("msg_{}", Uuid::new_v4());
769
770    let mut events = reasoning_item_events(&response_id);
771    events.extend([
772        (
773            "response.output_item.done",
774            json!({
775                "output_index": 1,
776                "item": {
777                    "type": "azure_ai_search_call",
778                    "id": search_item_id,
779                    "response_id": response_id,
780                    "call_id": call_id,
781                    "arguments": r#"{"query":"tell me more"}"#,
782                    "status": "completed",
783                },
784            }),
785        ),
786        (
787            "response.output_item.done",
788            json!({
789                "output_index": 2,
790                "item": {
791                    "type": "azure_ai_search_call_output",
792                    "id": output_item_id,
793                    "response_id": response_id,
794                    "call_id": call_id,
795                    "output": "remote tool call failed",
796                    "status": "completed",
797                },
798            }),
799        ),
800    ]);
801    events.extend(message_item_events(
802        &message_item_id,
803        &response_id,
804        3,
805        &["Sorry", ", search is unavailable."],
806    ));
807
808    round(&response_id, events, usage(38, 0, 40, 32))
809}
810
811/// A round that includes an output item kind the chatbot has no variant for, between the
812/// reasoning item and the answer, the way an Azure feature this code predates would arrive.
813fn unknown_item_type_round() -> String {
814    let response_id = format!("resp_{}", Uuid::new_v4());
815    let item_id = format!("ws_{}", Uuid::new_v4());
816    let message_item_id = format!("msg_{}", Uuid::new_v4());
817
818    let mut events = reasoning_item_events(&response_id);
819    events.push((
820        "response.output_item.done",
821        json!({
822            "output_index": 1,
823            "item": {
824                "type": "web_search_call",
825                "id": item_id,
826                "response_id": response_id,
827                "status": "completed",
828            },
829        }),
830    ));
831    events.extend(message_item_events(
832        &message_item_id,
833        &response_id,
834        2,
835        &["Handled", " gracefully."],
836    ));
837
838    round(&response_id, events, usage(20, 0, 20, 16))
839}
840
841/// The default chat answer: a search of the course material, the results it returns, and a text
842/// answer citing them.
843fn search_and_text_round(base_url: &str) -> String {
844    let response_id = format!("resp_{}", Uuid::new_v4());
845    let call_id = format!("call_{}", Uuid::new_v4());
846    let search_item_id = format!("fc_{}", Uuid::new_v4());
847    let output_item_id = format!("fco_{}", Uuid::new_v4());
848    let message_item_id = format!("msg_{}", Uuid::new_v4());
849
850    let search_call = |arguments: &str, status: &str| {
851        json!({
852            "type": "azure_ai_search_call",
853            "id": search_item_id,
854            "response_id": response_id,
855            "call_id": call_id,
856            "arguments": arguments,
857            "status": status,
858        })
859    };
860    let search_call_output = |output: &str, status: &str| {
861        json!({
862            "type": "azure_ai_search_call_output",
863            "id": output_item_id,
864            "response_id": response_id,
865            "call_id": call_id,
866            "output": output,
867            "status": status,
868        })
869    };
870
871    let mut events = reasoning_item_events(&response_id);
872    events.extend([
873        (
874            "response.output_item.added",
875            json!({"output_index": 1, "item": search_call("", "in_progress")}),
876        ),
877        (
878            "response.output_item.done",
879            json!({
880                "output_index": 1,
881                "item": search_call(r#"{"query":"tell me more"}"#, "completed"),
882            }),
883        ),
884        (
885            "response.output_item.added",
886            json!({"output_index": 2, "item": search_call_output("[]", "in_progress")}),
887        ),
888        (
889            "response.output_item.done",
890            json!({
891                "output_index": 2,
892                "item": search_call_output(&search_results(base_url), "completed"),
893            }),
894        ),
895    ]);
896    events.extend(message_item_events(
897        &message_item_id,
898        &response_id,
899        3,
900        &SEARCH_ANSWER_DELTAS,
901    ));
902
903    round(&response_id, events, usage(38, 0, 79, 64))
904}
905
906/// The default round's answer, one delta per element. Each `【x:y†source】` is a citation marker the
907/// frontend replaces with a link to the document it points at.
908const SEARCH_ANSWER_DELTAS: [&str; 12] = [
909    "Hello",
910    "!",
911    " How",
912    " can",
913    " I",
914    " assist",
915    " 【0:2†source】",
916    " you",
917    " 【0:1†source】",
918    " today",
919    "?",
920    "【0:2†source】",
921];
922
923/// What the search returns, as the JSON string Azure nests it in. Only `get_urls` is read: the
924/// chatbot fetches each of those to build the answer's citations, so both they and the hits come
925/// from [`MOCK_DOCUMENTS`], the documents the mock document storage serves.
926fn search_results(base_url: &str) -> String {
927    let [document1, document2, document3] = &MOCK_DOCUMENTS;
928    let hit = |id: &str, document: &MockDocument, content: &str| {
929        json!({
930            "id": id,
931            "content": content,
932            "filepath": document.id,
933            "title": document.title,
934            "url": "",
935            "score": 0.016666668,
936            "knowledgeSourceIndex": 0,
937        })
938    };
939    let get_urls: Vec<String> = MOCK_DOCUMENTS
940        .iter()
941        .map(|document| {
942            format!(
943                "{base_url}/api/v0/mock-document-storage/test/documents/{}",
944                document.id
945            )
946        })
947        .collect();
948
949    json!({
950        "documents": [
951            hit(
952                "doc1",
953                document1,
954                "This chunk is a snippet from page {} of the course {}. Mock test page content This is test content blah",
955            ),
956            hit(
957                "doc2",
958                document2,
959                "Mock test page content 2 This is another test page.",
960            ),
961            // A second hit on doc1's page, so it repeats that page's title and filepath and only
962            // the chunk is document3's.
963            hit("doc3", document1, document3.chunk),
964        ],
965        "get_urls": get_urls,
966    })
967    .to_string()
968}
969
970/// A response to a request that asked for structured output instead of a stream: one whole JSON
971/// object, whose text content is `payload`, the JSON the caller's own schema describes.
972fn blocking_response(payload: &str) -> String {
973    let response_id = format!("resp_{}", Uuid::new_v4());
974    let item_id = format!("msg_{}", Uuid::new_v4());
975
976    let mut response = completed_response_object(&response_id, usage(30, 0, 15, 0));
977    response["output"] = json!([message_item(
978        &item_id,
979        &response_id,
980        json!([{ "type": "output_text", "text": payload }]),
981        "completed",
982    )]);
983    response.to_string()
984}
985
986/// The suggestions the chatbot offers as the learner's next message.
987const MESSAGE_SUGGESTION_PAYLOAD: &str =
988    r#"{"suggestions":["Can you pls help me?","Nice weather we're having.","Hello?"]}"#;
989
990/// The rewrites the CMS offers for a paragraph.
991const 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."]}"#;
992
993/// A field of the teacher's data file, as the mock reads it out of the sample in the prompt.
994struct SampledField {
995    name: String,
996    /// The Vega-Lite type the sampled value calls for.
997    field_type: &'static str,
998}
999
1000/// What the chart encodes when the request carries no sample to read fields out of.
1001fn placeholder_fields() -> (SampledField, SampledField) {
1002    (
1003        SampledField {
1004            name: "category".to_string(),
1005            field_type: "nominal",
1006        },
1007        SampledField {
1008            name: "value".to_string(),
1009            field_type: "quantitative",
1010        },
1011    )
1012}
1013
1014/// Splits on the commas that separate an object's members or a CSV line's cells, which is every
1015/// comma outside a quoted string.
1016fn split_unquoted_commas(text: &str) -> Vec<&str> {
1017    let mut cells = Vec::new();
1018    let mut start = 0;
1019    let mut in_string = false;
1020    let mut escaped = false;
1021    for (i, character) in text.char_indices() {
1022        match character {
1023            _ if escaped => escaped = false,
1024            '\\' if in_string => escaped = true,
1025            '"' => in_string = !in_string,
1026            ',' if !in_string => {
1027                cells.push(&text[start..i]);
1028                start = i + character.len_utf8();
1029            }
1030            _ => {}
1031        }
1032    }
1033    cells.push(&text[start..]);
1034    cells
1035}
1036
1037fn unquote(cell: &str) -> &str {
1038    cell.trim().trim_matches('"').trim()
1039}
1040
1041/// The fields of the first row of a JSON array sample, in the order they are written there.
1042///
1043/// Read out of the text rather than through serde_json, whose maps sort an object's keys and would
1044/// so hand the chart its axes in alphabetical order. A quoted value is a string however numeric it
1045/// looks, which is what tells a year apart from a label.
1046fn json_sample_fields(sample: &str) -> Vec<SampledField> {
1047    let Some(open) = sample.find('{') else {
1048        return Vec::new();
1049    };
1050    let Some(close) = sample[open..].find('}') else {
1051        return Vec::new();
1052    };
1053    split_unquoted_commas(&sample[open + 1..open + close])
1054        .into_iter()
1055        .filter_map(|member| {
1056            let (key, value) = member.split_once(':')?;
1057            let name = unquote(key).to_string();
1058            let numeric = !value.trim().starts_with('"') && value.trim().parse::<f64>().is_ok();
1059            (!name.is_empty()).then_some(SampledField {
1060                name,
1061                field_type: if numeric { "quantitative" } else { "nominal" },
1062            })
1063        })
1064        .collect()
1065}
1066
1067/// The columns of a CSV sample, typed from the first row of values under the header.
1068fn csv_sample_fields(sample: &str) -> Vec<SampledField> {
1069    let mut rows = sample.lines().filter(|line| !line.trim().is_empty());
1070    let header = rows.next().unwrap_or_default();
1071    let first_row = split_unquoted_commas(rows.next().unwrap_or_default());
1072    split_unquoted_commas(header)
1073        .into_iter()
1074        .enumerate()
1075        .filter_map(|(column, name)| {
1076            let name = unquote(name).to_string();
1077            let numeric = first_row
1078                .get(column)
1079                .is_some_and(|value| unquote(value).parse::<f64>().is_ok());
1080            (!name.is_empty()).then_some(SampledField {
1081                name,
1082                field_type: if numeric { "quantitative" } else { "nominal" },
1083            })
1084        })
1085        .collect()
1086}
1087
1088/// The first two fields of the teacher's data file, or [`placeholder_fields`] when the request
1089/// carries no sample or the sample has fewer than two fields to chart.
1090///
1091/// A real model is told to encode the fields the sample shows it, and a chart of fields the file
1092/// does not have draws nothing at all.
1093fn charted_fields(request: &MockRequest) -> (SampledField, SampledField) {
1094    let Some(sample) = request.prompt_section(DATA_SAMPLE_HEADING, EXISTING_SPEC_HEADING) else {
1095        return placeholder_fields();
1096    };
1097    let fields = if sample.trim_start().starts_with(['[', '{']) {
1098        json_sample_fields(sample)
1099    } else {
1100        csv_sample_fields(sample)
1101    };
1102    let mut fields = fields.into_iter();
1103    match (fields.next(), fields.next()) {
1104        (Some(x), Some(y)) => (x, y),
1105        _ => placeholder_fields(),
1106    }
1107}
1108
1109/// The Vega-Lite specification the CMS chart block offers, which is the whole answer. It carries no
1110/// data of its own, as a generated specification must not: the teacher's data file is attached to it
1111/// afterwards.
1112fn chart_spec_payload(request: &MockRequest) -> String {
1113    let (x, y) = charted_fields(request);
1114    json!({
1115        "$schema": "https://vega.github.io/schema/vega-lite/v6.json",
1116        "description": "Mock AI generated bar chart",
1117        "mark": "bar",
1118        "encoding": {
1119            "x": {"field": x.name, "type": x.field_type},
1120            "y": {"field": y.name, "type": y.field_type}
1121        }
1122    })
1123    .to_string()
1124}
1125
1126/// The course description summary, in the shape Sisu expects it in.
1127const 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."}"#;
1128
1129const 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?"]}"#;
1130
1131const FEEDBACK_CATEGORIZATION_PAYLOAD: &str = r#"{"category_name":"Useful feedback"}"#;
1132
1133// GET /api/v0/mock_azure/openai/v1/embeddings
1134// POST /api/v0/mock_azure/openai/v1/embeddings
1135async fn mock_azure_embeddings(
1136    app_conf: web::Data<ApplicationConfiguration>,
1137    payload: web::Json<EmbeddingRequest>,
1138) -> ControllerResult<String> {
1139    assert!(app_conf.test_chatbot && app_conf.test_mode);
1140
1141    if payload.input.iter().any(|s| s.trim().is_empty()) {
1142        return Err(ControllerError::new(
1143            ControllerErrorType::BadRequest,
1144            "input must not be empty".to_string(),
1145            None,
1146        ));
1147    }
1148
1149    let mock_response = EmbeddingResponse {
1150        object: "list".to_string(),
1151        model: "mock-embedder-3-small".to_string(),
1152        usage: EmbeddingResponseUsage {
1153            prompt_tokens: payload.input.len() as i32,
1154            total_tokens: payload.input.len() as i32,
1155        },
1156        data: payload
1157            .input
1158            .iter()
1159            .enumerate()
1160            .map(|(index, _)| Embedding {
1161                index: index as i32,
1162                embedding: vec![0.0; 1536],
1163                object: "embedding".to_string(),
1164            })
1165            .collect(),
1166    };
1167    let res = serde_json::to_string(&mock_response)?;
1168    let token = skip_authorize();
1169    token.authorized_ok(res)
1170}
1171
1172pub fn _add_routes(cfg: &mut ServiceConfig) {
1173    cfg.route(
1174        "/api/projects/test/openai/v1/responses",
1175        web::get().to(mock_azure_chat_responses),
1176    )
1177    .route(
1178        "/api/projects/test/openai/v1/responses",
1179        web::post().to(mock_azure_chat_responses),
1180    )
1181    .route("openai/v1/embeddings", web::get().to(mock_azure_embeddings))
1182    .route(
1183        "openai/v1/embeddings",
1184        web::post().to(mock_azure_embeddings),
1185    );
1186}
1187
1188#[cfg(test)]
1189mod tests {
1190    use headless_lms_chatbot::{
1191        azure_chatbot::azure::protocol::{
1192            AISearchOutput, OutputItem, ReceivedOutputItem, ResponseOutput,
1193        },
1194        chatbot_tools::ClientChatbotTool,
1195        llm_utils::{LLMResponse, parse_text_completion},
1196    };
1197    use regex::Regex;
1198
1199    use super::*;
1200
1201    const BASE_URL: &str = "http://project-331.local";
1202
1203    /// Pairs every `event:` line of a Server-Sent Events body with the `data:` line after it.
1204    fn sse_events(body: &str) -> Vec<(&str, &str)> {
1205        let mut events = Vec::new();
1206        let mut pending = None;
1207        for line in body.lines() {
1208            if let Some(event) = line.strip_prefix("event: ") {
1209                pending = Some(event);
1210            } else if let Some(data) = line.strip_prefix("data: ")
1211                && let Some(event) = pending.take()
1212            {
1213                events.push((event, data));
1214            }
1215        }
1216        events
1217    }
1218
1219    /// The body the mock answers `request` with, through the dispatch the handler runs.
1220    fn respond(request: &MockRequest) -> String {
1221        let scenario = pick_scenario(request).expect("the mock answers this request");
1222        (scenario.respond)(request, BASE_URL)
1223    }
1224
1225    /// Every registered scenario's example body of the given kind, named for failure messages.
1226    fn example_bodies(stream: bool) -> Vec<(&'static str, String)> {
1227        SCENARIOS
1228            .iter()
1229            .filter(|scenario| (scenario.example)().stream == stream)
1230            .map(|scenario| {
1231                let example = (scenario.example)();
1232                (scenario.name, (scenario.respond)(&example, BASE_URL))
1233            })
1234            .collect()
1235    }
1236
1237    /// The tools called by the function call items among `events`.
1238    fn called_tool_names(events: &[(&str, &str)]) -> Vec<String> {
1239        events
1240            .iter()
1241            .filter_map(|(_, data)| {
1242                match serde_json::from_str::<ResponseOutput>(data)
1243                    .ok()?
1244                    .item?
1245                    .known()?
1246                {
1247                    OutputItem::FunctionCall { tool_name, .. } => Some(tool_name),
1248                    _ => None,
1249                }
1250            })
1251            .collect()
1252    }
1253
1254    /// Where the round's first delta is, having checked the lifecycle events every round needs
1255    /// around it: the response id before the first delta, and the terminal event last.
1256    fn first_delta(events: &[(&str, &str)]) -> usize {
1257        let index = events
1258            .iter()
1259            .position(|(event, _)| event.ends_with(".delta"))
1260            .expect("The round streams a delta event");
1261        assert!(
1262            events[..index]
1263                .iter()
1264                .any(|(event, _)| *event == "response.created"),
1265            "The response id has to be known before the first delta"
1266        );
1267        assert_eq!(
1268            events.last().map(|(event, _)| *event),
1269            Some("response.completed")
1270        );
1271        index
1272    }
1273
1274    /// The names of the tools the chatbot runs itself.
1275    fn registered_tool_names() -> Vec<String> {
1276        get_chatbot_tool_definitions()
1277            .into_iter()
1278            .filter_map(|definition| match definition {
1279                AzureLLMToolDefinition::Function(function) => Some(function.name),
1280                AzureLLMToolDefinition::Search(_) => None,
1281            })
1282            .collect()
1283    }
1284
1285    /// The chatbot parses every streamed `data:` line into its own types and kills the whole
1286    /// conversation on one it cannot read, so a mistyped field here is otherwise only visible by
1287    /// running the whole stack.
1288    #[test]
1289    fn every_streamed_data_line_parses_into_chatbot_types() {
1290        let bodies = example_bodies(true);
1291        assert!(!bodies.is_empty(), "no streamed scenario is registered");
1292        for (name, body) in bodies {
1293            let events = sse_events(&body);
1294            assert!(!events.is_empty(), "{name} streams no events");
1295            for (event, data) in events {
1296                let parsed: ResponseOutput = serde_json::from_str(data).unwrap_or_else(|e| {
1297                    panic!("{name} streams a {event} the chatbot cannot parse: {e}\n{data}")
1298                });
1299                if event.starts_with("response.output_item.") {
1300                    assert!(
1301                        parsed.item.is_some(),
1302                        "{name}: {event} carries no item\n{data}"
1303                    );
1304                }
1305                if event == "response.created" {
1306                    assert!(
1307                        parsed.response.and_then(|response| response.id).is_some(),
1308                        "{name}: {event} carries no response id\n{data}"
1309                    );
1310                }
1311            }
1312        }
1313    }
1314
1315    /// A scenario answering an example its own `matches` rejects would leave the round it stands
1316    /// for untested while every test built from the registry passed.
1317    #[test]
1318    fn every_scenario_answers_its_own_example() {
1319        for scenario in SCENARIOS {
1320            let request = (scenario.example)();
1321            let picked = pick_scenario(&request).map(|picked| picked.name);
1322            assert_eq!(
1323                picked,
1324                Some(scenario.name),
1325                "{} is not the scenario its own example is answered by",
1326                scenario.name
1327            );
1328        }
1329    }
1330
1331    /// The structured output features parse the text content again as their own response shape, so
1332    /// both layers have to hold.
1333    #[test]
1334    fn structured_output_responses_parse_into_chatbot_types() {
1335        let bodies = example_bodies(false);
1336        assert!(!bodies.is_empty(), "no blocking scenario is registered");
1337        for (name, body) in bodies {
1338            let completion: LLMResponse = serde_json::from_str(&body)
1339                .unwrap_or_else(|e| panic!("{name} does not parse as an LLM response: {e}"));
1340            let content = parse_text_completion(completion)
1341                .unwrap_or_else(|e| panic!("{name} has no text content: {e}"));
1342            serde_json::from_str::<Value>(&content).unwrap_or_else(|e| {
1343                panic!("{name} content is not the JSON the feature parses: {e}\n{content}")
1344            });
1345        }
1346    }
1347
1348    /// Each feature parses the text content as its own response shape, so a payload nested inside
1349    /// another object would satisfy the test above and still break all three.
1350    #[test]
1351    fn a_blocking_response_carries_its_payload_as_the_whole_text_content() {
1352        for payload in [
1353            MESSAGE_SUGGESTION_PAYLOAD,
1354            CMS_SUGGESTION_PAYLOAD,
1355            COURSE_DESCRIPTION_PAYLOAD,
1356        ] {
1357            let completion: LLMResponse = serde_json::from_str(&blocking_response(payload))
1358                .expect("the blocking response parses as an LLM response");
1359            let content = parse_text_completion(completion).expect("the response has text content");
1360            assert_eq!(content, payload);
1361        }
1362    }
1363
1364    /// A chart spec request whose prompt carries `sample` as the teacher's data file.
1365    fn chart_request_sampling(sample: &str) -> MockRequest {
1366        MockRequest::json_object(&format!(
1367            "{CHART_SPEC_REQUEST}\n\nRequest:\nA bar chart{DATA_SAMPLE_HEADING}{sample}"
1368        ))
1369    }
1370
1371    /// The fields the answer to `request` encodes, as (x, y) pairs of field name and type.
1372    fn charted_encoding(request: &MockRequest) -> ((String, String), (String, String)) {
1373        let spec: Value = serde_json::from_str(&chart_spec_payload(request))
1374            .expect("the chart specification is the whole answer, so it must be JSON");
1375        let channel = |name: &str| {
1376            let channel = &spec["encoding"][name];
1377            (
1378                channel["field"]
1379                    .as_str()
1380                    .expect("the channel names a field")
1381                    .to_string(),
1382                channel["type"]
1383                    .as_str()
1384                    .expect("the channel gives a type")
1385                    .to_string(),
1386            )
1387        };
1388        (channel("x"), channel("y"))
1389    }
1390
1391    /// The chart generator rejects any specification that carries data of its own, so an answer
1392    /// offering one would fail every generation in development and in the system tests.
1393    #[test]
1394    fn the_mock_chart_specification_carries_no_data() {
1395        let spec: Value = serde_json::from_str(&chart_spec_payload(&MockRequest::json_object(
1396            CHART_SPEC_REQUEST,
1397        )))
1398        .expect("the chart specification is the whole answer, so it must be JSON");
1399
1400        assert!(spec.get("data").is_none(), "{spec}");
1401        assert!(spec.get("datasets").is_none(), "{spec}");
1402    }
1403
1404    #[test]
1405    fn the_mock_chart_falls_back_to_placeholder_fields_without_a_sample() {
1406        let (x, y) = charted_encoding(&MockRequest::json_object(CHART_SPEC_REQUEST));
1407
1408        assert_eq!(x, ("category".to_string(), "nominal".to_string()));
1409        assert_eq!(y, ("value".to_string(), "quantitative".to_string()));
1410    }
1411
1412    #[test]
1413    fn the_mock_chart_encodes_the_fields_of_a_json_sample() {
1414        let request = chart_request_sampling(
1415            "[\n  { \"maara\": 0, \"hinta\": 8 },\n  { \"maara\": 50, \"hinta\": 7 }\n]",
1416        );
1417
1418        let (x, y) = charted_encoding(&request);
1419
1420        assert_eq!(x, ("maara".to_string(), "quantitative".to_string()));
1421        assert_eq!(y, ("hinta".to_string(), "quantitative".to_string()));
1422    }
1423
1424    /// serde_json sorts an object's keys, so reading the sample through it would put `apple` on x.
1425    #[test]
1426    fn the_mock_chart_keeps_the_field_order_of_the_sample() {
1427        let request = chart_request_sampling("[{ \"zebra\": \"A\", \"apple\": 1 }]");
1428
1429        let (x, y) = charted_encoding(&request);
1430
1431        assert_eq!(x.0, "zebra");
1432        assert_eq!(y.0, "apple");
1433    }
1434
1435    #[test]
1436    fn the_mock_chart_encodes_the_columns_of_a_csv_sample() {
1437        let request = chart_request_sampling("category,value\nA,28\nB,55\n");
1438
1439        let (x, y) = charted_encoding(&request);
1440
1441        assert_eq!(x, ("category".to_string(), "nominal".to_string()));
1442        assert_eq!(y, ("value".to_string(), "quantitative".to_string()));
1443    }
1444
1445    /// The CMS cuts the sample off at a fixed length, so it can end anywhere in the file.
1446    #[test]
1447    fn the_mock_chart_reads_a_sample_cut_off_mid_row() {
1448        let request =
1449            chart_request_sampling("[{ \"year\": \"2024\", \"sold\": 12 }, { \"year\": \"20");
1450
1451        let (x, y) = charted_encoding(&request);
1452
1453        // Quoted, so a year is a label rather than a number to plot.
1454        assert_eq!(x, ("year".to_string(), "nominal".to_string()));
1455        assert_eq!(y, ("sold".to_string(), "quantitative".to_string()));
1456    }
1457
1458    #[test]
1459    fn the_mock_chart_ignores_a_sample_it_cannot_find_two_fields_in() {
1460        let (x, y) = charted_encoding(&chart_request_sampling("nothing chartable here"));
1461
1462        assert_eq!(x.0, "category");
1463        assert_eq!(y.0, "value");
1464    }
1465
1466    /// A repair round's latest message is the correction, so the sample has to be found further
1467    /// back in the conversation.
1468    #[test]
1469    fn the_mock_chart_reads_the_sample_on_a_repair_round() {
1470        let mut request = chart_request_sampling("category,value\nA,28\n");
1471        request
1472            .conversation
1473            .push("Fix this specification".to_string());
1474        request.message = Some("Fix this specification".to_string());
1475
1476        let (x, _) = charted_encoding(&request);
1477
1478        assert_eq!(x.0, "category");
1479    }
1480
1481    /// The chatbot classifies the round from the function call item it announces and hands the
1482    /// tool parser what follows. The tool parser needs a completed function call and a
1483    /// `response.completed`, and errors on a text delta.
1484    #[test]
1485    fn the_function_call_round_drives_the_tool_call_parser() {
1486        let body = respond(&MockRequest::chat(TOOL_CALL_TRIGGER));
1487        let events = sse_events(&body);
1488
1489        let first_delta = first_delta(&events);
1490        assert_eq!(
1491            events[first_delta].0,
1492            "response.function_call_arguments.delta"
1493        );
1494        assert!(
1495            !events
1496                .iter()
1497                .any(|(event, _)| *event == "response.output_text.delta"),
1498            "A text delta makes the tool parser error out"
1499        );
1500
1501        let called = called_tool_names(&events[first_delta + 1..]);
1502        assert!(
1503            !called.is_empty(),
1504            "The tool parser only sees function calls delivered after the first delta"
1505        );
1506
1507        let registered = registered_tool_names();
1508        for tool_name in called {
1509            assert!(
1510                registered.contains(&tool_name),
1511                "The mock calls {tool_name}, which no chatbot tool is registered under"
1512            );
1513        }
1514    }
1515
1516    /// The round that suspends a turn: the tool it calls has to be one the client answers, and
1517    /// must not be one the chatbot would run itself instead of suspending.
1518    #[test]
1519    fn the_client_tool_call_round_calls_a_tool_only_the_client_answers() {
1520        let body = respond(&MockRequest::chat(CLIENT_TOOL_CALL_TRIGGER));
1521
1522        let called = called_tool_names(&sse_events(&body));
1523        assert!(!called.is_empty(), "The round calls no tool");
1524        let registered = registered_tool_names();
1525        for tool_name in called {
1526            assert!(
1527                tool_is_answered_by_client(&tool_name),
1528                "The mock calls {tool_name} to suspend a turn, but the client does not answer it"
1529            );
1530            assert!(
1531                !registered.contains(&tool_name),
1532                "{tool_name} is registered as a chatbot tool, so the turn would never suspend"
1533            );
1534        }
1535    }
1536
1537    /// The chatbot validates a client tool's arguments before it suspends, so a round the tool
1538    /// would reject never reaches the client: it becomes a failure reported to the LLM instead.
1539    #[test]
1540    fn the_client_tool_call_round_asks_a_question_the_tool_accepts() {
1541        AskMultipleChoiceQuestionTool::parse_arguments(MOCK_MULTIPLE_CHOICE_ARGUMENTS)
1542            .expect("the mock's question passes the tool's own validation");
1543    }
1544
1545    #[test]
1546    fn the_answer_after_a_tool_ran_drives_the_text_parser() {
1547        let body = respond(&MockRequest::after_tool_run());
1548        let events = sse_events(&body);
1549
1550        assert_eq!(events[first_delta(&events)].0, "response.output_text.delta");
1551
1552        let streamed: String = events
1553            .iter()
1554            .filter(|(event, _)| *event == "response.output_text.delta")
1555            .filter_map(|(_, data)| serde_json::from_str::<ResponseOutput>(data).ok()?.delta)
1556            .collect();
1557        assert!(!streamed.is_empty(), "The round streams no text");
1558    }
1559
1560    /// The system tests wait for this answer with the citation markers stripped out by the
1561    /// frontend, and the committed screenshots show it with them rendered as citation pills, so
1562    /// rewording either form here, or shifting a space around a marker, fails the chatbot specs.
1563    #[test]
1564    fn the_search_round_answers_the_text_the_system_tests_wait_for() {
1565        let answer = SEARCH_ANSWER_DELTAS.concat();
1566        assert_eq!(
1567            answer,
1568            "Hello! How can I assist 【0:2†source】 you 【0:1†source】 today?【0:2†source】"
1569        );
1570
1571        // The frontend's REMOVE_CITATIONS_REGEX, which produces the text the specs wait for.
1572        let stripped = Regex::new(r"\s*?【\d+:\d+†source】")
1573            .expect("the citation regex compiles")
1574            .replace_all(&answer, "");
1575        assert_eq!(stripped, "Hello! How can I assist you today?");
1576    }
1577
1578    /// The urls are the only part of the search output the chatbot reads, and it reads them out of
1579    /// a JSON string nested in a JSON string, so the nesting only fails where it is parsed: when
1580    /// the answer's citations are saved.
1581    #[test]
1582    fn the_search_round_streams_document_urls() {
1583        let body = search_and_text_round(BASE_URL);
1584
1585        let search_outputs: Vec<String> = sse_events(&body)
1586            .into_iter()
1587            .filter_map(|(_, data)| {
1588                match serde_json::from_str::<ResponseOutput>(data)
1589                    .ok()?
1590                    .item?
1591                    .known()?
1592                {
1593                    OutputItem::AzureAiSearchCallOutput { output, .. }
1594                        if output.contains("get_urls") =>
1595                    {
1596                        Some(output)
1597                    }
1598                    _ => None,
1599                }
1600            })
1601            .collect();
1602        assert!(
1603            !search_outputs.is_empty(),
1604            "The search round streams no search output with urls"
1605        );
1606        for output in search_outputs {
1607            let parsed: AISearchOutput = serde_json::from_str(&output)
1608                .unwrap_or_else(|e| panic!("The search output does not parse: {e}\n{output}"));
1609            assert_eq!(parsed.get_urls.len(), 3);
1610            for url in parsed.get_urls {
1611                assert!(url.as_str().starts_with(BASE_URL), "{url}");
1612            }
1613        }
1614    }
1615
1616    /// An answer cut short by the token limit ends on `response.incomplete`, never on
1617    /// `response.completed`.
1618    #[test]
1619    fn the_incomplete_answer_round_ends_on_incomplete_not_completed() {
1620        let body = respond(&MockRequest::chat(INCOMPLETE_ANSWER_TRIGGER));
1621        let events = sse_events(&body);
1622        assert_eq!(
1623            events.last().map(|(event, _)| *event),
1624            Some("response.incomplete")
1625        );
1626        assert!(
1627            !events
1628                .iter()
1629                .any(|(event, _)| *event == "response.completed"),
1630            "an incomplete round must not also carry a response.completed"
1631        );
1632    }
1633
1634    /// A stream closed before Azure sends a terminal event must not carry one, or it stops
1635    /// exercising the shape a proxy's clean EOF produces.
1636    #[test]
1637    fn the_truncated_stream_round_carries_no_terminal_event() {
1638        let body = respond(&MockRequest::chat(TRUNCATED_STREAM_TRIGGER));
1639        let events = sse_events(&body);
1640        assert!(!events.is_empty(), "the round streams no events at all");
1641        assert!(
1642            !events
1643                .iter()
1644                .any(|(event, _)| *event == "response.completed" || *event == "response.incomplete"),
1645            "a truncated stream must carry neither response.completed nor response.incomplete"
1646        );
1647    }
1648
1649    /// The completed search output this scenario carries fails to parse as the chatbot's
1650    /// search-output schema, on a `completed` item rather than the in-progress placeholder every
1651    /// other search scenario here uses.
1652    #[test]
1653    fn the_malformed_search_output_round_carries_a_completed_output_that_fails_to_parse() {
1654        let body = respond(&MockRequest::chat(MALFORMED_SEARCH_OUTPUT_TRIGGER));
1655        let events = sse_events(&body);
1656        let output = events
1657            .iter()
1658            .find_map(|(_, data)| {
1659                match serde_json::from_str::<ResponseOutput>(data)
1660                    .ok()?
1661                    .item?
1662                    .known()?
1663                {
1664                    OutputItem::AzureAiSearchCallOutput { output, .. } => Some(output),
1665                    _ => None,
1666                }
1667            })
1668            .expect("the round carries a search output item");
1669        assert!(
1670            serde_json::from_str::<AISearchOutput>(&output).is_err(),
1671            "the output was expected to fail the chatbot's search-output schema: {output}"
1672        );
1673    }
1674
1675    /// An item type the chatbot has no variant for still deserializes as
1676    /// `ReceivedOutputItem::Unreadable` rather than failing the line it arrives on.
1677    #[test]
1678    fn the_unknown_item_type_round_deserializes_as_unreadable() {
1679        let body = respond(&MockRequest::chat(UNKNOWN_ITEM_TYPE_TRIGGER));
1680        let events = sse_events(&body);
1681        let saw_unreadable = events.iter().any(|(event, data)| {
1682            event.starts_with("response.output_item.")
1683                && matches!(
1684                    serde_json::from_str::<ResponseOutput>(data)
1685                        .ok()
1686                        .and_then(|r| r.item),
1687                    Some(ReceivedOutputItem::Unreadable(_))
1688                )
1689        });
1690        assert!(saw_unreadable, "the round carries no unreadable item");
1691    }
1692}