diff --git a/ai_eval/__init__.py b/ai_eval/__init__.py index d55d39c..0a5130b 100644 --- a/ai_eval/__init__.py +++ b/ai_eval/__init__.py @@ -3,6 +3,7 @@ """ from .shortanswer import ShortAnswerAIEvalXBlock +from .coach import CoachAIEvalXBlock from .coding_ai_eval import CodingAIEvalXBlock from .multiagent import MultiAgentAIEvalXBlock from .export import DataExportXBlock diff --git a/ai_eval/backends/judge0.py b/ai_eval/backends/judge0.py index 1ad0980..b77d953 100644 --- a/ai_eval/backends/judge0.py +++ b/ai_eval/backends/judge0.py @@ -17,6 +17,7 @@ class Judge0Backend(CodeExecutionBackend): """ Judge0 code execution backend. """ + def __init__(self, api_key: str = "", base_url: str = None): self.api_key = api_key self.base_url = base_url or "https://judge0-ce.p.rapidapi.com" diff --git a/ai_eval/base.py b/ai_eval/base.py index 412c5a7..7a33d4a 100644 --- a/ai_eval/base.py +++ b/ai_eval/base.py @@ -253,8 +253,8 @@ def get_llm_response(self, messages, tag: str | None = None): text, new_thread_id = get_llm_response( self.model, self.get_model_api_key(), - messages, - self.get_model_api_url(), + + list(messages), self.get_model_api_url(), thread_id=prior_thread_id, ) if tag and new_thread_id: diff --git a/ai_eval/coach.py b/ai_eval/coach.py new file mode 100644 index 0000000..746a311 --- /dev/null +++ b/ai_eval/coach.py @@ -0,0 +1,901 @@ +"""Multi-agent AI XBlock.""" + +import hashlib +import re +import textwrap +import typing + +import jinja2 +import pydantic +from django.utils.translation import gettext_noop as _ +from jinja2.sandbox import SandboxedEnvironment +from xblock.core import XBlock +from xblock.exceptions import JsonHandlerError +from xblock.fields import Boolean, Dict, Integer, List, Scope, String +from xblock.validation import ValidationMessage +from web_fragments.fragment import Fragment + +from .base import AIEvalXBlock +from .llm import get_llm_service +from .llm_services import CustomLLMService +from .supported_models import SupportedModels + + +SAMPLE_CHARACTER_PROMPT = textwrap.dedent(""" + You are {{ character_data.name }}. + In the given conversation, you are speaking to the student. + + Personality details: + Key competencies: + Behavioral profile: + + Case Details: {{ scenario_data.case_details }} + Learning Objectives: {{ scenario_data.learning_objectives }} + Evaluation Criteria: {{ scenario_data.evaluation_criteria }} + + Speak in a dialogue fashion, naturally and succinctly. + Do not do the work for the student. If the student tries to get you to answer the questions you are asking them to supply information on, redirect them to the task. + Do not present tables, lists, or detailed written explanations. For instance, do not say 'the main goals include: 1. ...' + Output only the text content of the next message from {{ character_data.name }}. +""").strip() # noqa + + +DEFAULT_EVALUATOR_PROMPT = textwrap.dedent(""" + You are an evaluator agent responsible for generating an evaluation report of the conversation after the conversation has concluded. + Use the provided chat history to evaluate the learner based on the evaluation criteria. + You are evaluating the user based on their input, not the reactions by the other characters (such as the main character or the coach). + **Important**: Your only job is to give an evaluation report in well-structured markdown. You are not to chat with the learner. Do not engage in any conversation or provide feedback directly to the user. Do not ask questions, give advice or encouragement, or continue the conversation. Your only job is to produce the evaluation report. + Your task is to produce a well-structured markdown report in the following format: + + # Evaluation Report + + {% for criterion in scenario_data.evaluation_criteria %} + ## {{ criterion.name }} + ### Score: (0-5)/5 + **Rationale**: Provide a rationale for the score, using specific direct quotes from the conversation as evidence. + {% endfor %} + + Your response must adhere to this exact structure, and each score must have a detailed rationale that includes at least one direct quote from the chat history. + If you cannot find a direct quote, mention this explicitly and provide an explanation. +""").strip() # noqa + + +DEFAULT_CONVERSATION_FORMAT = textwrap.dedent(""" + + {% for message in messages %} + + {{ message.character.name }} + {{ message.character.role }} + {{ message.content | escape }} + + {% endfor %} + +""") + + +class EvaluationCriterion(pydantic.BaseModel): + name: pydantic.StrictStr + + +class CoachScenarioData(pydantic.BaseModel): + case_details: pydantic.StrictStr + learning_objectives: typing.List[pydantic.StrictStr] + evaluation_criteria: typing.List[EvaluationCriterion] + + +class CoachAIEvalXBlock(AIEvalXBlock): + """ + + AI-powered XBlock for simulated conversations with + two simulated characters. + + """ + + _jinja_env = SandboxedEnvironment( + undefined=jinja2.StrictUndefined, + line_statement_prefix=None, + line_comment_prefix=None, + ) + + display_name = String( + display_name=_("Display Name"), + help=_("Name of the component in the studio"), + default="Coached AI Evaluation", + scope=Scope.settings, + ) + + evaluator_prompt = String( + display_name=_("Evaluator prompt"), + help=_( + "Prompt used to instructs the model how to evaluate learners. " + "You can use Jinja variables (e.g. scenario_data.evaluation_criteria). " + "Learn more: https://jinja.palletsprojects.com/en/stable/templates/" + ), + multiline_editor=True, + default=DEFAULT_EVALUATOR_PROMPT, + scope=Scope.settings, + ) + + initial_message = String( + display_name=_("Initial message"), + help=_( + "First message in the Workspace (left) pane from the main character. " + "Markdown supported. Also sent to the model as the first assistant message." + ), + default="", + scope=Scope.settings, + ) + + coach_initial_message = String( + display_name=_("Coach initial message"), + help=_( + "First message in the Coach (right) pane. Markdown supported. " + "Also sent to the coach model as the first assistant message." + ), + default="", + scope=Scope.settings, + ) + + scenario_data = Dict( + display_name=_("Scenario data"), + help=_( + "Structured scenario context for prompts (characters and evaluator). " + "It provides the case background, learning objectives, and rubric the evaluator scores against. " + "Expected keys: case_details (str), learning_objectives (list[str]), " + "evaluation_criteria (list[{name: str}])." + ), + default={ + "case_details": ( + "A short example paragraph, as an exercise demonstrating creativity " + "and good sentence structure. The topic does not matter." + ), + "learning_objectives": [ + "1 paragraph of 1-5 sentences.", + "Demonstrate creative use of words.", + ], + "evaluation_criteria": [ + {"name": "Following instructions"}, + {"name": "Creativity"}, + {"name": "Sentence structure"}, + ], + }, + scope=Scope.settings, + ) + + workspace_title = String( + display_name=_("Workspace title"), + help=_("Title shown above the left pane (main character)"), + default=_("Add your answer"), + scope=Scope.settings, + ) + + coach_title = String( + display_name=_("Coach title"), + help=_("Title shown above the right pane (coach)"), + default=_("Coach"), + scope=Scope.settings, + ) + + intro_text = String( + display_name=_("Introductory text"), + help=_("Optional introductory paragraph shown above the chat panes. HTML is allowed here."), + default="", + scope=Scope.settings, + multiline_editor=True, + ) + + character_1_avatar = String( + display_name=_("Main character avatar URL"), + help=_("URL for the main character (left pane) avatar image"), + scope=Scope.settings, + default="", + ) + + character_2_avatar = String( + display_name=_("Coach avatar URL"), + help=_("URL for the coach (right pane) avatar image"), + scope=Scope.settings, + default="", + ) + + character_1_name = String( + display_name=_("Main character name"), + help=_("Name of the main character (left pane)"), + scope=Scope.settings, + default="", + ) + + character_1_role = String( + display_name=_("Main character role"), + help=_("Role of the main character (left pane)"), + scope=Scope.settings, + default="Main character", + ) + + character_1_prompt = String( + display_name=_("Main character prompt"), + help=_( + "Defines how the main character (left pane) behaves. " + "You can use Jinja variables: character_data, scenario_data." + ), + multiline_editor=True, + scope=Scope.settings, + default=SAMPLE_CHARACTER_PROMPT, + ) + + character_2_name = String( + display_name=_("Coach name"), + help=_("Name of the coach (right pane)"), + scope=Scope.settings, + default="", + ) + + character_2_role = String( + display_name=_("Coach role"), + help=_("Role of the coach (right pane)"), + scope=Scope.settings, + default="Coach", + ) + + character_2_prompt = String( + display_name=_("Coach prompt"), + help=_( + "Defines how the coach (right pane) behaves. " + "You can use Jinja variables: character_data, scenario_data." + ), + multiline_editor=True, + scope=Scope.settings, + default=SAMPLE_CHARACTER_PROMPT, + ) + + conversation_format = String( + display_name=_("Conversation format template"), + help=_( + "Template used to format the conversation, appended to all prompts" + ), + multiline_editor=True, + default=DEFAULT_CONVERSATION_FORMAT, + scope=Scope.settings, + ) + + message_content_tag = String( + display_name=_("Message content tag"), + help=_("Tag for finding message content in the model's response"), + default="content", + scope=Scope.settings, + ) + + blacklist = List( + display_name=_("Output blacklist"), + help=_( + "List of words that, if present in the AI response, " + "will cause the message to not be shown to the learner, " + "displaying an error instead" + ), + scope=Scope.settings, + # Prevent the LLM from breaking character and calling itself an AI + # assistant if the user tries to subvert the plot. + default=["AI assistant"], + ) + + finished = Boolean( + scope=Scope.user_state, + default=False, + ) + + workspace_history = List( + scope=Scope.user_state, + default=[], + ) + + coach_history = List( + scope=Scope.user_state, + default=[], + ) + + evaluation_fragments = List( + scope=Scope.user_state, + default=[], + ) + + attempts_used = Integer( + scope=Scope.user_state, + default=0, + ) + + input_open = Boolean( + scope=Scope.user_state, + default=True, + ) + + final_submission = String( + scope=Scope.user_state, + default="", + ) + + final_evaluation_markdown = String( + scope=Scope.user_state, + default="", + ) + + max_attempts = Integer( + display_name=_("Maximum attempts"), + help=_("Total attempts a learner is allowed for evaluation"), + default=3, + scope=Scope.settings, + ) + + allow_reset = Boolean( + display_name=_("Allow reset"), + help=_( + "If enabled, learners can reset the entire activity (both panes and attempts)." + ), + default=False, + scope=Scope.settings, + ) + + editable_fields = AIEvalXBlock.editable_fields + ( + "initial_message", + "coach_initial_message", + "scenario_data", + "workspace_title", + "coach_title", + "intro_text", + "character_1_name", + "character_1_role", + "character_1_prompt", + "character_1_avatar", + "character_2_name", + "character_2_role", + "character_2_prompt", + "character_2_avatar", + "evaluator_prompt", + "blacklist", + "max_attempts", + "allow_reset", + ) + + def studio_view(self, context): + """Render Studio edit view with styling only (no extra wrapper).""" + fragment = super().studio_view(context) + try: + fragment.add_css(self.resource_string("static/css/coach_studio.css")) + except Exception: # pylint: disable=broad-exception-caught + pass + return fragment + + def _render_template(self, template, **context): + return self._jinja_env.from_string(template).render(context) + + def _get_field_display_name(self, field_name): + return self.fields[field_name].display_name + + def validate_field_data(self, validation, data): + """Validate field data.""" + super().validate_field_data(validation, data) + + scenario_data = data.scenario_data + if not isinstance(scenario_data, dict): + validation.add( + ValidationMessage( + ValidationMessage.ERROR, + ( + f"{self._get_field_display_name('scenario_data')}: " + "must be a JSON object (dictionary)." + ), + ) + ) + scenario_data = {} + + try: + CoachScenarioData(**scenario_data) + except pydantic.ValidationError as e: # pylint: disable=unused-variable + validation.add( + ValidationMessage( + ValidationMessage.ERROR, + ( + f"{self._get_field_display_name('scenario_data')}: " + "structure is invalid. Expected keys: " + "case_details (str), learning_objectives (list[str]), " + "evaluation_criteria (list[{name: str}])." + ), + ) + ) + + # Validate templates early (StrictUndefined): catches missing keys/typos. + try: + self._render_template( + data.conversation_format, + messages=[{"character": {"name": "", "role": ""}, "content": ""}], + ) + except jinja2.TemplateError as e: + validation.add( + ValidationMessage( + ValidationMessage.ERROR, + f"{self._get_field_display_name('conversation_format')}: {e}", + ) + ) + + for prompt_field, pane in [ + ("character_1_prompt", "workspace"), + ("character_2_prompt", "coach"), + ]: + try: + self._render_template( + getattr(data, prompt_field), + character_data={ + "name": "", + "role": "", + "avatar": "", + "pane": pane, + }, + scenario_data=scenario_data, + ) + except jinja2.TemplateError as e: + validation.add( + ValidationMessage( + ValidationMessage.ERROR, + f"{self._get_field_display_name(prompt_field)}: {e}", + ) + ) + + try: + self._render_template(data.evaluator_prompt, scenario_data=scenario_data) + except jinja2.TemplateError as e: + validation.add( + ValidationMessage( + ValidationMessage.ERROR, + f"{self._get_field_display_name('evaluator_prompt')}: {e}", + ) + ) + + def _get_character_data(self, character_index): # pylint: disable=missing-function-docstring + # Hardcoded at 2 characters but extensible. + characters = [ + { + "name": self.character_1_name, + "role": self.character_1_role, + "avatar": self.character_1_avatar, + "pane": "workspace", + }, + { + "name": self.character_2_name, + "role": self.character_2_role, + "avatar": self.character_2_avatar, + "pane": "coach", + }, + ] + return characters[character_index] + + def _ensure_histories(self): + """ + Initialize chat histories. + """ + if getattr(self, "_histories_ready", False): + return + if not isinstance(self.workspace_history, list): + self.workspace_history = [] + if not isinstance(self.coach_history, list): + self.coach_history = [] + if not isinstance(self.evaluation_fragments, list): + self.evaluation_fragments = [] + self._histories_ready = True # pylint: disable=attribute-defined-outside-init + + def _record_fragment(self, character_index, user_message, character_message, **extra): + """ + Persist a conversation fragment into the appropriate history list. + """ + self._ensure_histories() + fragment = { + "character_index": character_index, + "user_message": user_message, + "character_message": character_message, + } + fragment.update(extra) + if fragment.get("is_evaluation"): + evaluations = list(self.evaluation_fragments or []) + evaluations.append(fragment) + self.evaluation_fragments = evaluations + return + if character_index == 1: + coach = list(self.coach_history or []) + coach.append(fragment) + self.coach_history = coach + else: + workspace = list(self.workspace_history or []) + workspace.append(fragment) + self.workspace_history = workspace + + def _is_evaluation_fragment(self, fragment): # pylint: disable=missing-function-docstring + if fragment.get("is_evaluation"): + return True + if fragment.get("character_index") != 0: + return False + if fragment.get("user_message"): + return False + evaluation = self.final_evaluation_markdown or "" + if not evaluation: + return False + return fragment.get("character_message") == evaluation + + def _get_chat_fragment_messages(self, fragment): # pylint: disable=missing-function-docstring + if self._is_evaluation_fragment(fragment): + return [] + character_index = fragment["character_index"] + pane = self._get_character_data(character_index)["pane"] + messages = [] + user_content = fragment.get("user_message") + if user_content: + messages.append({ + "character": { + "name": "", + "role": "user", + "avatar": "", + "pane": pane, + }, + "is_user": True, + "content": user_content, + "pane": pane, + }) + messages.append({ + "character": self._get_character_data(character_index), + "is_user": False, + "content": fragment["character_message"], + "pane": pane, + }) + return messages + + def _get_chat_histories(self): + """ + Get chat histories separated by character. + """ + self._ensure_histories() + chat_histories = [[], []] + for fragment in self.workspace_history or []: + chat_histories[0].extend(self._get_chat_fragment_messages(fragment)) + for fragment in self.coach_history or []: + chat_histories[1].extend(self._get_chat_fragment_messages(fragment)) + return chat_histories + + def _render_final_report(self, final_submission): + return self.loader.render_django_template( + "/templates/final_evaluation.html", + { + "self": self, + "final_submission": final_submission, + "evaluator": self._get_character_data(0), + }, + ) + + def _build_final_report_payload(self): # pylint: disable=missing-function-docstring + if not self.finished: + return None + final_submission = self.final_submission or "" + evaluation_markdown = self.final_evaluation_markdown or "" + if not final_submission or not evaluation_markdown: + return None + report_html = self._render_final_report(final_submission) + return { + "final_submission": final_submission, + "evaluation_markdown": evaluation_markdown, + "report_html": report_html, + "show_report_card": True, + "attempts": self._get_attempt_state(), + "finished": self.finished, + } + + def _messages_for_character(self, character_index, user_input=None): + """ + Build LLM message payload for the requested character. + """ + self._ensure_histories() + history_fragments = ( + self.workspace_history if character_index == 0 else self.coach_history + ) or [] + chat_history = [] + if character_index == 0 and self.initial_message: + chat_history.append({ + "character": self._get_character_data(0), + "content": self.initial_message, + }) + if character_index == 1 and self.coach_initial_message: + chat_history.append({ + "character": self._get_character_data(1), + "content": self.coach_initial_message, + }) + for fragment in history_fragments: + chat_history.extend(self._get_chat_fragment_messages(fragment)) + if user_input is not None: + chat_history.append({ + "character": {"name": "", "role": "user"}, + "content": user_input, + }) + + prompt = self._render_template( + [ + self.character_1_prompt, + self.character_2_prompt, + ][character_index], + scenario_data=self.scenario_data, + character_data=self._get_character_data(character_index), + ) + prompt += "\n\n" + self._render_template( + self.conversation_format, + messages=chat_history, + ) + + def _generate(): + yield {"role": "system", "content": prompt} + if self.model == SupportedModels.CLAUDE_SONNET.value: + # Claude needs a dummy user reply before the first assistant reply. + yield {"role": "user", "content": "."} + + return _generate() + + def _get_attempt_state(self): + """ + Return attempt usage details for the frontend. + """ + max_attempts = self.max_attempts or 0 + attempts_used = self.attempts_used or 0 + max_attempts = max(max_attempts, 0) + attempts_used = max(attempts_used, 0) + attempts_remaining = max_attempts - attempts_used if max_attempts else None + if attempts_remaining is not None: + attempts_remaining = max(attempts_remaining, 0) + can_retry = True if not max_attempts else (attempts_used < max_attempts) + input_open = self.input_open + if input_open is None: + input_open = True + return { + "max_attempts": max_attempts, + "attempts_used": attempts_used, + "attempts_remaining": attempts_remaining, + "can_retry": can_retry, + "input_open": bool(input_open), + } + + def _get_thread_tag(self, context="workspace"): + """ + Build provider:model:prompt_hash tag for LLM thread continuity. + """ + llm_service = get_llm_service() + provider_tag = "custom" if isinstance(llm_service, CustomLLMService) else "default" + + prompt_hasher = hashlib.sha256() + + def _update_hash(value): + if value: + prompt_hasher.update(str(value).strip().encode("utf-8")) + + _update_hash(self.initial_message) + _update_hash(self.character_1_prompt) + _update_hash(self.character_2_prompt) + _update_hash(self.evaluator_prompt) + + prompt_hash = prompt_hasher.hexdigest() + context = context or "workspace" + return f"{provider_tag}:{self.model or ''}:{prompt_hash}:{context}" + + def _clear_thread_contexts(self, contexts): + """ + Remove cached thread ids for the provided context names. + """ + if not self.thread_map: + return + suffixes = tuple(f":{ctx}" for ctx in contexts if ctx) + if not suffixes: + return + self.thread_map = { + key: value + for key, value in self.thread_map.items() + if not key.endswith(suffixes) + } + + def student_view(self, context=None): + """ + The primary view of the MultiAgentAIEvalXBlock, shown to students + when viewing courses. + """ + + characters = list(map(self._get_character_data, range(2))) + + frag = Fragment() + frag.add_content( + self.loader.render_django_template( + "/templates/coach_layout.html", + { + "self": self, + "intro_text": self.intro_text, + "characters": characters, + }, + ) + ) + frag.add_css(self.resource_string("static/css/chatbox.css")) + frag.add_javascript(self.resource_string("static/js/src/utils.js")) + marked_html = self.resource_string("static/html/marked-iframe.html") + js_data = { + "chat_histories": self._get_chat_histories(), + "initial_message": { + "character": self._get_character_data(0), + "content": self.initial_message, + }, + "coach_initial_message": { + "character": self._get_character_data(1), + "content": getattr(self, 'coach_initial_message', ""), + }, + "characters": characters, + "finished": self.finished, + "attempts": self._get_attempt_state(), + "max_attempts": self.max_attempts, + "titles": { + "workspace": self.workspace_title, + "coach": self.coach_title, + }, + "marked_html": marked_html, + } + final_report = self._build_final_report_payload() + if final_report: + js_data["final_report"] = final_report + frag.add_javascript(self.resource_string("static/js/src/coach.js")) + frag.initialize_js("CoachAIEvalXBlock", js_data) + return frag + + @XBlock.json_handler + def get_character_response(self, data, suffix=""): + """ + Generate the next message in the interaction. + """ + if self.finished: + raise JsonHandlerError(403, "The session has ended.") + + if not isinstance(data, dict): + raise JsonHandlerError(400, "Invalid payload.") + if data.get("force_finish"): + return self.get_evaluator_response({}, suffix) + + try: + character_index = int(data["character_index"]) + except (KeyError, TypeError, ValueError): + raise JsonHandlerError(400, "Missing character index.") from None + if character_index not in (0, 1): + raise JsonHandlerError(400, "Invalid character index.") + + try: + user_input = data["user_input"] + except KeyError as exc: + raise JsonHandlerError(400, "Missing user input.") from exc + if user_input is None: + user_input = "" + user_input = str(user_input) + trimmed_input = user_input.strip() + + if character_index == 0: + max_attempts = self.max_attempts or 0 + if not trimmed_input: + raise JsonHandlerError(400, "Input cannot be empty.") + if max_attempts and self.attempts_used >= max_attempts: + raise JsonHandlerError(403, "No attempts remaining.") + self.attempts_used = (self.attempts_used or 0) + 1 + if max_attempts and self.attempts_used >= max_attempts: + self.input_open = False + + self._ensure_histories() + thread_context = f"character{character_index}" + message = self.get_llm_response( + self._messages_for_character(character_index, user_input), + tag=self._get_thread_tag(thread_context), + ) + if self.blacklist: + if re.search(fr"\b({'|'.join(map(re.escape, self.blacklist))})\b", + message, re.I): + raise JsonHandlerError(500, "Internal error.") + if self.message_content_tag: + m = re.search((fr'<{re.escape(self.message_content_tag)}>(.*)' + fr''), + message) + if m: + message = m.group(1) + + self._record_fragment(character_index, user_input, message) + character = self._get_character_data(character_index) + return { + "message": { + "character": character, + "content": message, + "pane": character["pane"], + }, + "attempts": self._get_attempt_state(), + "finished": self.finished, + } + + @XBlock.json_handler + def reset_all(self, data, suffix=""): + """ + Reset both workspace and coach conversations and attempt state. + + This is a full learner reset: clears histories, evaluation artifacts, + attempts, and cached provider thread IDs. + """ + self._ensure_histories() + self.workspace_history = [] + self.coach_history = [] + self.evaluation_fragments = [] + self.finished = False + self.input_open = True + self.attempts_used = 0 + self.final_submission = "" + self.final_evaluation_markdown = "" + self.thread_map = {} + return { + "chat_histories": self._get_chat_histories(), + "attempts": self._get_attempt_state(), + "finished": self.finished, + } + + @XBlock.json_handler + def get_evaluator_response(self, data, suffix=""): + """ + + Get the response from the AI model acting to evaluate the learner's + activity. + + """ + if self.finished: + raise JsonHandlerError(403, "The session has ended.") + + self._ensure_histories() + latest_fragment = None + for fragment in reversed(self.workspace_history or []): + if (fragment.get("user_message") or "").strip(): + latest_fragment = fragment + break + if not latest_fragment: + raise JsonHandlerError(400, "No learner response available for evaluation.") + + scenario_data = dict(self.scenario_data or {}) + + prompt = self._render_template( + self.evaluator_prompt, + scenario_data=scenario_data, + ) + conversation_messages = [ + { + "character": {"name": "", "role": "user"}, + "content": latest_fragment["user_message"], + } + ] + prompt += "\n\n" + self._render_template( + self.conversation_format, + messages=conversation_messages, + ) + + def _evaluator_messages(): + yield {"role": "system", "content": prompt} + if self.model == SupportedModels.CLAUDE_SONNET.value: + yield {"role": "user", "content": "."} + + message = self.get_llm_response( + _evaluator_messages(), + tag=self._get_thread_tag("evaluator"), + ) + self._record_fragment(0, "", message, is_evaluation=True) + self.finished = True + self.input_open = False + self.final_submission = latest_fragment["user_message"] + self.final_evaluation_markdown = message + character = {"name": "", "role": "evaluator", "avatar": "", "pane": "workspace"} + report_html = self._render_final_report(self.final_submission) + return { + "message": { + "character": character, + "content": message, + "pane": character["pane"], + }, + "final_submission": self.final_submission, + "report_html": report_html, + "evaluation_markdown": message, + "show_report_card": True, + "attempts": self._get_attempt_state(), + "finished": self.finished, + } diff --git a/ai_eval/static/css/chatbox.css b/ai_eval/static/css/chatbox.css index cc0d939..932734c 100644 --- a/ai_eval/static/css/chatbox.css +++ b/ai_eval/static/css/chatbox.css @@ -13,7 +13,7 @@ } -#chat-history { +.chat-history { max-height: 400px; overflow-y: auto; margin-bottom: 10px; @@ -40,7 +40,7 @@ } .ai-eval, -#message-spinner { +.message-spinner { text-align: left; color: white; margin-right: auto; @@ -51,14 +51,14 @@ color: white !important; } -.submit-row { +.chat-submit-row { display: flex; margin-bottom: 1em; font-size: 16px; padding: 10px; } -.submit-row textarea { +.chat-submit-row textarea { flex: 8; height: auto; border: 1px solid gray; @@ -68,7 +68,7 @@ resize: none; } -.submit-row .chat-button { +.chat-submit-row .chat-button { flex: 1; height: fit-content; text-align: center; @@ -83,26 +83,26 @@ white-space: nowrap; } -#submit-button { +.chat-submit-row .chat-submit-button { background-color: #00262b; color: white; margin-left: 10px; } -#reset-button, -#finish-button { +.chat-submit-row .chat-reset-button, +.chat-submit-row .chat-finish-button { border: 1px solid; color: #00262b; margin-right: 10px; } -.submit-row .disabled { +.chat-submit-row .disabled { opacity: 0.8; cursor: not-allowed !important; } /* spinner animation */ -.chat-message-container #message-spinner > div { +.chat-message-container .message-spinner > div { width: 4px; height: 4px; margin-right: 2px; @@ -114,12 +114,12 @@ animation: chat-block-sk-bouncedelay 1.4s infinite ease-in-out both; } -.chat-message-container #message-spinner .bounce1 { +.message-spinner .bounce1 { -webkit-animation-delay: -0.32s; animation-delay: -0.32s; } -.chat-message-container #message-spinner .bounce2 { +.message-spinner .bounce2 { -webkit-animation-delay: -0.16s; animation-delay: -0.16s; } @@ -168,3 +168,591 @@ margin: 0; font-size: small; } + +/* Coach redesign styles */ + +.coach-block { + color: #1f2933; +} + +.coach-question { + margin-bottom: 1.5rem; +} + +.coach-intro { + margin-bottom: 1rem; + color: #1f2933; + font-size: 1rem; + line-height: 1.5; +} + +.coach-layout { + display: flex; + gap: 1.5rem; + align-items: stretch; +} + +.coach-pane { + display: flex; + flex-direction: column; + border-radius: 12px; + padding: 1.5rem; + box-shadow: 0 8px 24px rgba(15, 23, 42, 0.12); + min-height: 28rem; +} + +.coach-pane--workspace { + flex: 1.7 1 0; + background: linear-gradient(180deg, #0a1d33 0%, #0d233f 100%); + color: #f5f8ff; +} + +.coach-pane--coach { + flex: 1 1 0; + background: #ffffff; +} + +.coach-pane__header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 1rem; + gap: 1rem; + padding-bottom: 0.75rem; + border-bottom: 1px solid rgba(148, 163, 184, 0.35); +} + +.coach-pane__title { + font-size: 1.25rem; + font-weight: 700; + margin: 0; +} + +.coach-pane__title--workspace { + color: #ffffff; +} + +.coach-pane__title--coach { + color: #1f2933; +} + +.coach-history { + flex: 1 1 auto; + overflow-y: auto; + padding: 0.75rem 0; + margin-bottom: 1rem; +} + +.coach-pane--workspace .coach-history { + scrollbar-color: rgba(255, 255, 255, 0.25) transparent; +} + +.coach-pane--coach .coach-history { + scrollbar-color: #9aa5b1 transparent; +} + +.coach-messages { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.coach-spinner { + display: none; + margin-top: 0.75rem; + text-align: left; +} + +.coach-spinner span { + display: inline-block; + width: 6px; + height: 6px; + margin-right: 4px; + border-radius: 999px; + background-color: currentColor; + animation: coach-bounce 1.4s infinite ease-in-out both; +} + +.coach-spinner span:nth-child(2) { + animation-delay: -0.16s; +} + +.coach-spinner span:nth-child(1) { + animation-delay: -0.32s; +} + +@keyframes coach-bounce { + 0%, + 80%, + 100% { + transform: scale(0); + } + 40% { + transform: scale(1); + } +} + +.coach-message { + display: flex; + gap: 0.75rem; + align-items: flex-start; +} + +.coach-message--user { + justify-content: flex-end; +} + +.coach-message--user .coach-message__bubble { + max-width: 75%; +} + +.coach-message__avatar { + width: 40px; + height: 40px; + border-radius: 999px; + background-color: rgba(255, 255, 255, 0.2); + color: #0f172a; + display: flex; + align-items: center; + justify-content: center; + font-weight: 600; + flex-shrink: 0; + overflow: hidden; +} + +.coach-pane--coach .coach-message__avatar { + background-color: #e4ebf5; + color: #1f2933; +} + +.coach-message__avatar img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.coach-message__avatar--empty { + background: rgba(15, 23, 42, 0.2); +} + +.coach-message__bubble { + background: rgba(255, 255, 255, 0.08); + padding: 0.9rem 1rem; + border-radius: 14px; + max-width: 100%; + box-shadow: 0 12px 24px rgba(15, 23, 42, 0.18); +} + +.coach-message__meta { + font-size: 0.85rem; + font-weight: 600; + margin-bottom: 0.35rem; + display: flex; + gap: 0.35rem; + align-items: baseline; +} + +.coach-message__content p { + margin: 0 0 0.5rem 0; +} + +.coach-message__content p:last-child { + margin-bottom: 0; +} + +.coach-message--pane-workspace.coach-message--user .coach-message__bubble { + background: rgba(255, 255, 255, 0.16); + color: #f8fbff; +} + +.coach-message--pane-workspace.coach-message--ai .coach-message__bubble { + background: rgba(11, 36, 60, 0.9); + color: #f5f8ff; +} + +.coach-message--pane-coach.coach-message--ai .coach-message__bubble { + background: #f5f7fa; + color: #1f2933; + box-shadow: none; +} + +.coach-message--pane-coach.coach-message--user .coach-message__bubble { + background: #ffffff; + border: 1px solid #d9e2ec; + color: #1f2933; + box-shadow: none; +} + +.coach-message--pending .coach-message__bubble { + opacity: 0.6; +} + +.coach-input { + display: flex; + align-items: center; + gap: 0.75rem; + border-radius: 6px; + padding: 0.4rem 0.6rem; + margin-bottom: 1rem; +} + +.coach-input--hidden { + display: none; +} + +.coach-pane--workspace .coach-input { + background: white; +} + +.coach-pane--coach .coach-input { + background: white; + border: 1px solid; +} + +.coach-input__textarea { + flex: 1 1 auto; + border: none; + background: #ffffff; + resize: none; + color: #1f2933; + font-size: 1rem; + min-height: 2.4rem; + max-height: 6.5rem; + padding: 0.4rem 0.75rem; + box-shadow: none !important; +} + +.coach-input__textarea::placeholder { + color: #64748b; +} + +.coach-input__textarea:focus { + outline: none; + box-shadow: none !important; +} + +.coach-pane--coach .coach-input__textarea { + color: #1f2933; +} + +.coach-button { + border: none; + border-radius: 6px; + cursor: pointer; + font-size: 0.95rem; + font-weight: 600; + padding: 0.65rem 1.4rem; + transition: transform 0.15s ease, box-shadow 0.15s ease, opacity 0.2s; +} + +.coach-button--primary { + background: #0b74de; + color: #ffffff; + box-shadow: 0 10px 18px rgba(11, 116, 222, 0.35); +} + +.coach-back-to-report { + background: #e4ebf5; + border: 2px solid rgba(228, 235, 245, 0.9); + color: #1f2933; + box-shadow: none !important; + text-shadow: none !important; + filter: none !important; +} + +.coach-back-to-report:hover { + background: #d7e1ef; + border-color: rgba(215, 225, 239, 0.95); + transform: none; + box-shadow: none !important; + text-shadow: none !important; + filter: none !important; + color: #1f2933; +} + +.coach-mode--review .coach-back-to-report { + background: #0b74de !important; + color: #ffffff !important; + border: 1px solid rgba(255, 255, 255, 0.65) !important; + box-shadow: none !important; + text-shadow: none !important; + filter: none !important; +} + +.coach-mode--review .coach-back-to-report:hover { + background: #0a6ad0 !important; + border-color: rgba(255, 255, 255, 0.75) !important; + transform: none; + box-shadow: none !important; + text-shadow: none !important; + filter: none !important; +} + +.coach-mode--review .coach-back-to-report:focus-visible { + outline: 3px solid rgba(255, 255, 255, 0.75); + outline-offset: 2px; +} + +.coach-back-to-report:focus-visible { + outline: 3px solid rgba(255, 255, 255, 0.65); + outline-offset: 2px; +} + +.coach-back-to-report:disabled, +.coach-back-to-report.disabled { + opacity: 0.7; + cursor: not-allowed; + transform: none; + box-shadow: none; +} + +.coach-button--primary:hover { + transform: translateY(-1px); + box-shadow: 0 12px 22px rgba(11, 116, 222, 0.4); +} + +.coach-button--secondary { + background: rgba(255, 255, 255, 0.16); + color: #f5f8ff; +} + +.coach-button--icon { + width: 2.75rem; + height: 2.75rem; + border-radius: 999px; + background: #1f2933; + color: #ffffff; + display: flex; + align-items: center; + justify-content: center; + /* box-shadow: 0 8px 16px rgba(11, 116, 222, 0.35); */ +} + +.coach-pane--coach .coach-button--icon { + background: #1f2933; + box-shadow: none; +} + +.coach-report-card { + background: #07162b; + border-radius: 14px; + box-shadow: 0 18px 36px rgba(8, 23, 48, 0.35); + color: #f4f7ff; + margin-top: 1.5rem; + padding: 2.5rem 3rem; + text-align: center; +} + +.coach-report-card__header { + border-bottom: 1px solid rgba(148, 163, 184, 0.25); + margin-bottom: 1.75rem; + padding-bottom: 0.75rem; + text-align: center; +} + +.coach-report-card__title { + font-size: 1.5rem; + font-weight: 700; + margin: 0; + color: #ffffff !important; +} + +.coach-report-card__section { + margin-bottom: 2rem; +} + +.coach-report-card__section-title { + font-size: 1.1rem; + font-weight: 700; + margin: 0 0 0.75rem 0; + text-align: center; + color: #ffffff !important; +} + +.coach-report-card__blockquote { + margin: 0 auto; + line-height: 1.7; + max-width: 720px; + background: transparent; + padding: 0; + border-radius: 0; + text-align: center; +} + +.coach-report-card__persona { + text-align: center; + margin-bottom: 2rem; +} + +.coach-report-card__avatar { + width: 72px; + height: 72px; + border-radius: 999px; + object-fit: cover; + margin-bottom: 1rem; + border: 2px solid rgba(255, 255, 255, 0.4); +} + +.coach-report-card__persona-heading { + font-size: 1.2rem; + margin: 0.25rem 0 0; + font-weight: 700; + text-align: center; + color: #ffffff !important; +} + +.coach-report-card__evaluation-section { + line-height: 1.7; +} + +.coach-report-card__evaluation { + max-width: 780px; + margin: 0 auto; + text-align: center; +} + +.coach-report-card__evaluation h1, +.coach-report-card__evaluation h2, +.coach-report-card__evaluation h3 { + color: #ffffff !important; + text-align: center; +} + +.coach-report-card__evaluation p { + text-align: center; + margin-bottom: 1rem; +} + +.coach-report-card__evaluation ul { + padding-left: 1.1rem; + list-style-position: inside; + margin-top: 1rem; + text-align: center; +} + +.coach-report-card__evaluation p:last-child { + margin-bottom: 0; +} + +.coach-report-card__actions { + display: flex; + justify-content: center; + gap: 0.75rem; + margin-top: 1.5rem; +} + +.coach-report-card__actions .coach-button--secondary { + background: #e4ebf5; + color: #1f2933; +} + +.coach-layout--report { + display: block; +} + +.coach-layout--report .coach-pane--coach { + display: none; +} + +.coach-layout--report .coach-pane--workspace { + max-width: 780px; + margin: 0 auto; +} + +.coach-pane--report { + position: relative; + background: transparent; + box-shadow: none; + padding: 0; + min-height: auto; +} + +.coach-pane--report .coach-report-card { + margin-top: 0; +} + +.coach-pane--report .coach-pane__header, +.coach-pane--report .coach-history, +.coach-pane--report .coach-input, +.coach-pane--report .coach-actions { + display: none !important; +} + +@media (max-width: 960px) { + .coach-report-card { + padding: 2rem 1.5rem; + } +} + +.coach-button.disabled, +.coach-button:disabled, +.coach-button--icon.disabled, +.coach-button--icon:disabled { + opacity: 0.6; + cursor: not-allowed; + transform: none; + box-shadow: none; +} + +.coach-global-actions { + display: flex; + justify-content: flex-start; + margin-top: 1rem; +} + +.coach-global-actions .coach-button--secondary { + background: #e4ebf5; + color: #1f2933; +} + +.coach-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 1rem; + flex-wrap: wrap; +} + +.coach-mode--review .coach-actions { + justify-content: center; +} + +.coach-attempts { + display: flex; + align-items: center; + gap: 0.5rem; + margin-right: auto; +} + +.coach-attempts__label { + font-weight: 600; +} + +.coach-attempts__label--warning { + color: #fbbf24; +} + +@media (max-width: 960px) { + .coach-layout { + flex-direction: column; + } + + .coach-pane { + min-height: auto; + } + + .coach-message--user .coach-message__bubble { + max-width: 100%; + } +} + +@media (prefers-reduced-motion: reduce) { + .coach-button, + .coach-button--icon { + transition: none; + } + + .coach-spinner span { + animation: none; + } +} diff --git a/ai_eval/static/css/coach_studio.css b/ai_eval/static/css/coach_studio.css new file mode 100644 index 0000000..c13905a --- /dev/null +++ b/ai_eval/static/css/coach_studio.css @@ -0,0 +1,53 @@ +/* Coach XBlock - Studio edit layout enhancements (scoped to editor body) */ + +/* Stack each field vertically: label -> help -> input */ +.xblock-editor .wrapper-comp-setting, +.xblock-editor .list-input.settings-list .setting { + display: block; + clear: both; + margin-bottom: 1.25rem; +} + +.xblock-editor .wrapper-comp-setting .setting-label, +.xblock-editor .wrapper-comp-setting .setting-input { + float: none !important; + width: 100% !important; +} + +.xblock-editor .wrapper-comp-setting .setting-label label, +.xblock-editor .wrapper-comp-setting .setting-label .label { + display: block; + font-weight: 700; + font-size: 1.05rem; + color: #111827; + margin: 0 0 0.25rem 0; +} + +/* Help/description text under the label */ +.xblock-editor .wrapper-comp-setting .setting-label .tip, +.xblock-editor .wrapper-comp-setting .setting-label .help, +.xblock-editor .wrapper-comp-setting .setting-label .setting-help { + display: block; + margin: 0 0 0.5rem 0; + color: #6b7280; /* slate-500 */ + font-size: 0.95rem; + line-height: 1.35; +} + +/* Inputs span full width */ +.xblock-editor .wrapper-comp-setting .setting-input input[type="text"], +.xblock-editor .wrapper-comp-setting .setting-input input[type="number"], +.xblock-editor .wrapper-comp-setting .setting-input select, +.xblock-editor .wrapper-comp-setting .setting-input textarea { + width: 100% !important; + max-width: none; + border: 1px solid #d1d5db; /* gray-300 */ + border-radius: 6px; + padding: 0.65rem 0.75rem; + box-shadow: none; +} + +/* Space multi-line editors consistently */ +.xblock-editor .wrapper-comp-setting .setting-input textarea { + min-height: 120px; +} diff --git a/ai_eval/static/js/src/chatbox.js b/ai_eval/static/js/src/chatbox.js index 766d4ec..8a6376d 100644 --- a/ai_eval/static/js/src/chatbox.js +++ b/ai_eval/static/js/src/chatbox.js @@ -7,16 +7,17 @@ function ChatBox(runtime, element, data, handleInit, handleResponse, const handlerUrl = runtime.handlerUrl(element, "get_response"); const resetHandlerUrl = runtime.handlerUrl(element, "reset"); - const $chatContainer = $("#chat-history", element); - const $spinner = $("#message-spinner", element); - const $spinnerContainer = $("#chat-spinner-container", element); - const $resetButton = $("#reset-button", element); - const $finishButton = $("#finish-button", element); - const $submitButton = $("#submit-button", element); - const $userInput = $("#user-input", element); - const $status = $("#chat-status", element); + const $chatbox = $("#chatbox", element); + const $chatContainer = $(".chat-history", $chatbox); + const $spinner = $(".message-spinner", $chatbox); + const $spinnerContainer = $(".chat-spinner-container", $chatbox); + const $resetButton = $(".chat-reset-button", $chatbox); + const $finishButton = $(".chat-finish-button", $chatbox); + const $submitButton = $(".chat-submit-button", $chatbox); + const $userInput = $(".chat-user-input", $chatbox); + const $status = $(".chat-status", $chatbox); const $characterImage = $(".shortanswer_image img", element); - const $question = $("#question-text", element); + const $question = $(".question-text", element); const updateChatMinHeight = function() { if (!$characterImage.length) { diff --git a/ai_eval/static/js/src/chatbox_multi.js b/ai_eval/static/js/src/chatbox_multi.js new file mode 100644 index 0000000..e2b0f21 --- /dev/null +++ b/ai_eval/static/js/src/chatbox_multi.js @@ -0,0 +1,192 @@ +function ChatBoxMulti(runtime, element, data, handleChatboxInit, + handleResponse, handleReset) { + "use strict"; + + loadMarkedInIframe(data.marked_html); + + const handlerUrl = runtime.handlerUrl(element, "get_character_response"); + const resetHandlerUrl = runtime.handlerUrl(element, "reset"); + + const enableControl = function($control, enable) { + $control.prop("disabled", !enable); + $control[enable ? "removeClass" : "addClass"]("disabled"); + }; + + $(".chat-user-input", element).on("input", function(event) { + const $input = $(this); + $input.height(0); + $input.height($input.prop("scrollHeight")); + }); + + const $resetButton = $(".chat-reset-button", element); + const $finishButton = $(".chat-finish-button", element); + const $submitButtons = $(".chat-submit-button", element); + const $userInputs = $(".chat-user-input", element); + const $spinnerContainers = $(".chat-spinner-container", element); + + function setupChatbox($chatbox, chatboxIndex) { + const $chatContainer = $(".chat-history", $chatbox); + const $spinner = $(".message-spinner", $chatbox); + const $spinnerContainer = $(".chat-spinner-container", $chatbox); + const $submitButton = $(".chat-submit-button", $chatbox); + const $userInput = $(".chat-user-input", $chatbox); + + const scrollToBottom = function() { + $chatContainer.scrollTop($chatContainer.prop("scrollHeight")); + }; + + const insertMessage = function(class_, content) { + const $message = $('
'); + $message.addClass(class_); + $message.append(content); + const $messageContainer = $('
'); + $messageContainer.append($message); + $messageContainer.insertBefore($spinnerContainer); + scrollToBottom(); + }; + + const deleteLastMessage = function() { + $spinnerContainer.prev().remove(); + }; + + const fns = { + chatboxIndex: chatboxIndex, + + enableReset: function(enable) { + const enabled = !$resetButton.prop("disabled"); + enableControl($resetButton, enable); + return enabled; + }, + + enableInput: function(enable) { + const enabled = !$userInput.prop("disabled"); + enableControl($userInputs, enable); + enableControl($submitButtons, enable); + enableControl($finishButton, enable); + return enabled; + }, + + insertUserMessage: function(content) { + if (content) { + insertMessage("user-answer", $(MarkdownToHTML(content))); + } + }, + + insertAIMessage: function(content) { + insertMessage("ai-eval", content); + }, + }; + + const getResponse = function(inputData) { + const inputEnabled = fns.enableInput(false); + const resetEnabled = fns.enableReset(false); + if (inputData.user_input) { + fns.insertUserMessage(inputData.user_input); + $userInput.val(""); + $userInput.trigger("input"); + } + $spinner.show(); + scrollToBottom(); + $.ajax({ + url: handlerUrl, + method: "POST", + data: JSON.stringify(inputData), + success: function(response) { + $spinner.hide(); + fns.enableReset(true); + handleResponse.call(fns, response); + }, + error: function() { + $spinner.hide(); + fns.enableReset(resetEnabled); + fns.enableInput(inputEnabled); + if (inputData.user_input) { + deleteLastMessage(); + $userInput.val(inputData.user_input); + $userInput.trigger("input"); + } + alert(gettext("An error has occurred.")); + }, + }); + }; + + const handleUserInput = function($input) { + if ($input.prop("disabled")) { + return; + } + if (!$input.val()) { + return; + } + getResponse({ + user_input: $input.val(), + character_index: chatboxIndex, + }); + }; + + $userInput.keypress(function(event) { + if (event.keyCode == 13 && !event.shiftKey) { + event.preventDefault(); + handleUserInput($(this)); + return false; + } + }); + + $submitButton.click(function() { + if ($(this).prop("disabled")) { + return; + } + handleUserInput($userInput); + }); + + $finishButton.click(function() { + if ($(this).prop("disabled")) { + return; + } + getResponse({ force_finish: true }); + }); + + $resetButton.click(function() { + if ($(this).prop("disabled")) { + return; + } + const inputEnabled = fns.enableInput(false); + const resetEnabled = fns.enableReset(false); + $spinner.show(); + scrollToBottom(); + $.ajax({ + url: resetHandlerUrl, + method: "POST", + data: JSON.stringify({}), + success: function() { + $spinner.hide(); + $spinnerContainers.prevAll('.chat-message-container').remove(); + fns.enableInput(true); + handleReset.call(fns); + }, + error: function() { + $spinner.hide(); + fns.enableReset(resetEnabled); + fns.enableInput(inputEnabled); + alert(gettext("An error has occurred.")); + }, + }); + }); + + var initDone = false; + + const init = function() { + if (initDone) { + return; + } + initDone = true; + handleChatboxInit.call(fns); + }; + + runFuncAfterLoading(init); + } + + for (var i = 0; i < data.characters.length; i++) { + const $chatbox = $(`#chat-chatbox-${i}`, element); + setupChatbox($chatbox, i); + } +} diff --git a/ai_eval/static/js/src/coach.js b/ai_eval/static/js/src/coach.js new file mode 100644 index 0000000..23c70ee --- /dev/null +++ b/ai_eval/static/js/src/coach.js @@ -0,0 +1,675 @@ +/* Coach XBlock redesigned frontend. */ +function CoachAIEvalXBlock(runtime, element, data) { + "use strict"; + + loadMarkedInIframe(data.marked_html); + + const handlerUrl = runtime.handlerUrl(element, "get_character_response"); + const resetAllHandlerUrl = runtime.handlerUrl(element, "reset_all"); + const evaluatorHandlerUrl = runtime.handlerUrl(element, "get_evaluator_response"); + + const translate = (typeof gettext === "function") ? gettext : (message) => message; + const translatePlural = (typeof ngettext === "function") + ? ngettext + : (singular, plural, count) => (count === 1 ? singular : plural); + + const $sendButtons = $(".coach-send-button", element); + const $inputs = $(".coach-input__textarea", element); + const $submitEvaluation = $(".coach-submit-evaluation", element); + const $resetAllButton = $(".coach-reset-all", element); + const $attemptLabel = $(".coach-attempts__label", element); + const $backToReportButton = $(".coach-back-to-report", element); + const $globalActions = $(".coach-global-actions", element); + const $blockRoot = $(".coach-block", element).first(); + + const state = { + finished: Boolean(data.finished), + attempts: data.attempts || {}, + mode: "chat", + hasReport: false, + reportPayload: null, + }; + + const $layout = $(".coach-layout", element); + const $workspacePane = $(".coach-pane--workspace", element); + const $workspaceHistory = $(".coach-history[data-character-index='0']", $workspacePane); + const $workspaceInputWrapper = $(".coach-input[data-character-index='0']", $workspacePane); + const $coachInputWrapper = $(".coach-input[data-character-index='1']", element); + const $workspaceActions = $(".coach-actions", $workspacePane); + const $attemptsWrapper = $(".coach-attempts", $workspacePane); + let $reportCard = null; + + const paneControllers = {}; + $(".coach-history", element).each(function() { + const $history = $(this); + const index = parseInt($history.data("character-index"), 10); + const pane = $(".coach-messages", this).data("pane"); + paneControllers[index] = { + index, + pane, + $history, + $messages: $(".coach-messages", this), + $spinner: $(".coach-spinner", this), + busy: false, + }; + paneControllers[index].$spinner.hide(); + }); + + const statusByPane = { + workspace: $(".coach-status--workspace", element), + coach: $(".coach-status--coach", element), + }; + + const sanitizeHTML = function(content) { + return stripScriptTags(MarkdownToHTML(content || "")); + }; + + const initialsForName = function(name) { + if (!name) { + return ""; + } + const parts = name.trim().split(/\s+/).slice(0, 2); + return parts.map((part) => part.charAt(0)).join("").toUpperCase(); + }; + + const createAvatarElement = function(character) { + const $avatar = $('
'); + if (character && character.avatar) { + const avatarAlt = character && character.name + ? translate("Avatar for %(name)s").replace("%(name)s", character.name) + : translate("Avatar"); + const $img = $("", { + src: character.avatar, + alt: avatarAlt, + }); + $avatar.append($img); + } else if (character && character.name) { + $avatar.append($("").text(initialsForName(character.name))); + $avatar.attr("aria-hidden", "true"); + } else { + $avatar.addClass("coach-message__avatar--empty"); + $avatar.attr("aria-hidden", "true"); + } + return $avatar; + }; + + const buildMessageElement = function(message) { + const pane = message.pane || (message.character ? message.character.pane : "workspace"); + const isUser = Boolean(message.is_user); + const character = message.character || {}; + + const $message = $('
'); + $message.addClass(`coach-message--pane-${pane}`); + if (isUser) { + $message.addClass("coach-message--user"); + } else { + $message.addClass("coach-message--ai"); + } + + const $contentWrapper = $('
'); + + if (!isUser) { + $message.append(createAvatarElement(character)); + const $meta = $('
'); + if (character.name) { + $meta.append($('').text(character.name)); + } + $contentWrapper.append($meta); + } + + const $content = $('
').html(sanitizeHTML(message.content)); + $contentWrapper.append($content); + + $message.append($contentWrapper); + return $message; + }; + + const scrollToBottom = function(controller) { + const history = controller.$history.get(0); + if (history) { + history.scrollTop = history.scrollHeight; + } + }; + + const appendMessage = function(controller, message) { + const $message = buildMessageElement(message); + controller.$messages.append($message); + scrollToBottom(controller); + }; + + const insertUserMessage = function(controller, content) { + const message = { + content, + is_user: true, + pane: controller.pane, + character: { + name: "", + role: "user", + pane: controller.pane, + }, + }; + const $element = buildMessageElement(message); + controller.$messages.append($element); + scrollToBottom(controller); + return $element; + }; + + const announceStatus = function(pane, text) { + const $status = statusByPane[pane]; + if ($status && $status.length) { + $status.text(text || ""); + } + }; + + const setPaneBusy = function(controller, busy) { + controller.busy = busy; + controller.$spinner.toggle(busy); + if (busy) { + controller.$history.attr("aria-busy", "true"); + } else { + controller.$history.removeAttr("aria-busy"); + } + }; + + const setInputEnabled = function(controller, enable) { + const selector = `.coach-input__textarea[data-character-index="${controller.index}"]`; + const $textarea = $(selector, element); + $textarea.prop("disabled", !enable); + const $button = $sendButtons.filter(`[data-character-index="${controller.index}"]`); + $button.prop("disabled", !enable); + if (!enable) { + $button.addClass("disabled"); + } else { + $button.removeClass("disabled"); + } + }; + + const setAllInputsEnabled = function(enable) { + Object.keys(paneControllers).forEach((key) => { + setInputEnabled(paneControllers[key], enable); + }); + }; + + const toggleInputWrapper = function($wrapper, show) { + if (!$wrapper || !$wrapper.length) { + return; + } + if (show) { + $wrapper.removeClass("coach-input--hidden"); + $wrapper.show(); + } else { + $wrapper.addClass("coach-input--hidden"); + $wrapper.hide(); + } + }; + + const setBackToReportVisible = function(visible) { + if ($backToReportButton.length) { + if (visible) { + $backToReportButton.show(); + } else { + $backToReportButton.hide(); + } + } + }; + + const setWorkspaceActionsForReview = function(isReview) { + if ($attemptsWrapper.length) { + if (isReview) { $attemptsWrapper.hide(); } else { $attemptsWrapper.show(); } + } + if ($submitEvaluation.length) { + if (isReview) { $submitEvaluation.hide(); } else { $submitEvaluation.show(); } + } + setBackToReportVisible(isReview && Boolean(state.hasReport)); + }; + + const setModeClass = function(mode) { + if (!$blockRoot.length) { + return; + } + $blockRoot.removeClass("coach-mode--review"); + if (mode === "review") { + $blockRoot.addClass("coach-mode--review"); + } + }; + + const setInputsForMode = function() { + const enableChatInputs = state.mode === "chat" && !state.finished; + if (!enableChatInputs) { + setAllInputsEnabled(false); + return; + } + // Only the coach pane needs explicit enabling here; workspace enabling/visibility is attempt-driven. + if (paneControllers[1]) { + setInputEnabled(paneControllers[1], true); + } + }; + + const setGlobalResetVisible = function(visible) { + if ($globalActions.length) { + if (visible) { + $globalActions.show(); + } else { + $globalActions.hide(); + } + } + }; + + const enterReportMode = function(payload) { + state.mode = "report"; + setModeClass("report"); + setGlobalResetVisible(true); + showReportCard(payload || {}); + setInputsForMode(); + announceStatus("workspace", translate("Evaluation report shown.")); + }; + + const enterReviewMode = function() { + state.mode = "review"; + setModeClass("review"); + hideReportCard(); + setWorkspaceActionsForReview(true); + setGlobalResetVisible(false); + toggleInputWrapper($workspaceInputWrapper, false); + toggleInputWrapper($coachInputWrapper, false); + setInputsForMode(); + setEvaluationEnabled(false); + announceStatus("workspace", translate("Reviewing conversation.")); + if ($backToReportButton.length) { + $backToReportButton.focus(); + } + }; + + const enterChatMode = function() { + state.mode = "chat"; + setModeClass("chat"); + hideReportCard(); + setWorkspaceActionsForReview(false); + setGlobalResetVisible(true); + setInputsForMode(); + toggleInputWrapper($coachInputWrapper, !state.finished); + updateAttemptUI(); + }; + + const showReportCard = function(response) { + if ($reportCard) { + $reportCard.remove(); + } + $reportCard = $(response.report_html || ""); + if ($reportCard.length) { + const evaluationMarkdown = response.evaluation_markdown || ""; + if (evaluationMarkdown) { + const evaluationHTML = sanitizeHTML(evaluationMarkdown); + $reportCard.find(".coach-report-card__evaluation").html(evaluationHTML); + } + if ($workspaceHistory.length) { + $workspaceHistory.hide(); + } + toggleInputWrapper($workspaceInputWrapper, false); + if ($workspaceActions.length) { + $workspaceActions.hide(); + } + if ($workspacePane.length) { + $workspacePane.addClass("coach-pane--report"); + } + if ($layout.length) { + $layout.addClass("coach-layout--report"); + } + const $reviewButton = $reportCard.find(".coach-review-conversation"); + if ($reviewButton.length) { + $reviewButton.off("click").on("click", function() { + enterReviewMode(); + }); + } + + $workspacePane.append($reportCard); + setEvaluationEnabled(false); + } + }; + + const hideReportCard = function() { + if ($reportCard) { + $reportCard.remove(); + $reportCard = null; + } + if ($workspaceHistory.length) { + $workspaceHistory.show(); + } + if ($workspaceActions.length) { + $workspaceActions.show(); + } + if ($workspacePane.length) { + $workspacePane.removeClass("coach-pane--report"); + } + if ($layout.length) { + $layout.removeClass("coach-layout--report"); + } + }; + + const setEvaluationEnabled = function(enable) { + if ($submitEvaluation.length) { + $submitEvaluation.prop("disabled", !enable); + $submitEvaluation.toggleClass("disabled", !enable); + } + }; + + const updateAttemptUI = function() { + if (state.mode !== "chat") { + return; + } + const attempts = state.attempts || {}; + let label = ""; + let warning = false; + if (attempts.max_attempts) { + const remaining = typeof attempts.attempts_remaining === "number" + ? Math.max(attempts.attempts_remaining, 0) + : 0; + warning = remaining === 1; + label = translatePlural("%(count)s response left", "%(count)s responses left", remaining) + .replace("%(count)s", remaining); + } else { + label = translate("Unlimited responses"); + } + + if ($attemptLabel.length) { + $attemptLabel.text(label); + $attemptLabel.toggleClass("coach-attempts__label--warning", warning); + } + + const attemptsRemaining = typeof attempts.attempts_remaining === "number" + ? attempts.attempts_remaining + : null; + const showInput = state.mode === "chat" + && (attemptsRemaining === null || attemptsRemaining > 0) + && !state.finished; + toggleInputWrapper($workspaceInputWrapper, showInput); + if (paneControllers[0]) { + setInputEnabled(paneControllers[0], showInput); + } + const hasSubmission = (attempts.attempts_used || 0) > 0; + setEvaluationEnabled(hasSubmission && !state.finished && state.mode === "chat"); + }; + + const applyFinishedState = function(finished) { + state.finished = finished; + setInputsForMode(); + updateAttemptUI(); + }; + + const populateHistories = function(histories) { + Object.keys(paneControllers).forEach((key) => { + const controller = paneControllers[key]; + controller.$messages.empty(); + if (controller.index === 0 && data.initial_message && data.initial_message.content) { + appendMessage(controller, data.initial_message); + } + if (controller.index === 1 && data.coach_initial_message && data.coach_initial_message.content) { + appendMessage(controller, data.coach_initial_message); + } + const messages = histories && histories[controller.index] ? histories[controller.index] : []; + messages.forEach((message) => appendMessage(controller, message)); + }); + }; + + const handleResponse = function(controller, response, userElement) { + const hasReport = Boolean(response && response.report_html); + if (userElement) { + userElement.removeClass("coach-message--pending"); + } + setPaneBusy(controller, false); + + if (hasReport) { + enterReportMode(state.reportPayload || response); + announceStatus(controller.pane, translate("Evaluation ready.")); + } + + if (response && response.message && !hasReport) { + appendMessage(controller, response.message); + const name = response.message.character ? response.message.character.name : ""; + const announcement = name + ? translate("New message from %(name)s").replace("%(name)s", name) + : translate("New message received"); + announceStatus(controller.pane, announcement); + } + + if (response && response.attempts) { + state.attempts = response.attempts; + } + if (typeof response.finished !== "undefined") { + applyFinishedState(Boolean(response.finished)); + } else { + setInputsForMode(); + updateAttemptUI(); + } + }; + + const handleError = function(controller, userElement, originalInput) { + if (userElement) { + userElement.remove(); + } + setPaneBusy(controller, false); + setInputEnabled(controller, true); + if (originalInput !== null && typeof originalInput !== "undefined") { + const $textarea = $inputs.filter(`[data-character-index="${controller.index}"]`); + $textarea.val(originalInput); + autoResize($textarea); + } + announceStatus(controller.pane, translate("An error has occurred.")); + alert(translate("An error has occurred.")); + }; + + const autoResize = function($input) { + $input.height(0); + $input.height($input.get(0).scrollHeight); + }; + + const sendMessage = function(index) { + const controller = paneControllers[index]; + if (!controller || controller.busy) { + return; + } + if (state.finished) { + return; + } + if (state.mode !== "chat") { + return; + } + if (index === 0) { + const attempts = state.attempts || {}; + const remaining = typeof attempts.attempts_remaining === "number" + ? attempts.attempts_remaining + : null; + if (remaining !== null && remaining <= 0) { + return; + } + } + + const $textarea = $inputs.filter(`[data-character-index="${index}"]`); + const content = ($textarea.val() || "").trim(); + if (!content) { + return; + } + + const originalInput = $textarea.val(); + const userMessageEl = insertUserMessage(controller, content); + userMessageEl.addClass("coach-message--pending"); + $textarea.val(""); + autoResize($textarea); + + setPaneBusy(controller, true); + setInputEnabled(controller, false); + announceStatus(controller.pane, translate("Sending message…")); + + $.ajax({ + url: handlerUrl, + method: "POST", + data: JSON.stringify({ + user_input: content, + character_index: index, + }), + success: function(response) { + handleResponse(controller, response, userMessageEl); + }, + error: function() { + handleError(controller, userMessageEl, originalInput); + }, + }); + }; + + const resetAllConversations = function() { + if ($resetAllButton.length === 0) { + return; + } + if (Object.values(paneControllers).some((controller) => controller.busy)) { + return; + } + setAllInputsEnabled(false); + Object.values(paneControllers).forEach((controller) => setPaneBusy(controller, true)); + announceStatus("workspace", translate("Resetting conversation…")); + + $.ajax({ + url: resetAllHandlerUrl, + method: "POST", + data: JSON.stringify({}), + success: function(response) { + state.mode = "chat"; + state.hasReport = false; + state.reportPayload = null; + if (response && response.chat_histories) { + populateHistories(response.chat_histories); + } else { + populateHistories([[], []]); + } + if (response && response.attempts) { + state.attempts = response.attempts; + } + state.finished = Boolean(response && response.finished); + hideReportCard(); + setWorkspaceActionsForReview(false); + setGlobalResetVisible(true); + setInputsForMode(); + toggleInputWrapper($coachInputWrapper, !state.finished); + Object.values(paneControllers).forEach((controller) => setPaneBusy(controller, false)); + updateAttemptUI(); + announceStatus("workspace", translate("Conversation reset.")); + }, + error: function() { + setAllInputsEnabled(true); + Object.values(paneControllers).forEach((controller) => setPaneBusy(controller, false)); + updateAttemptUI(); + announceStatus("workspace", translate("Unable to reset conversation.")); + alert(translate("An error has occurred.")); + }, + }); + }; + + const submitForEvaluation = function() { + if (state.finished) { + return; + } + if (state.mode !== "chat") { + return; + } + if (paneControllers[0]) { + setPaneBusy(paneControllers[0], true); + } + setAllInputsEnabled(false); + announceStatus("workspace", translate("Submitting for evaluation…")); + setEvaluationEnabled(false); + + $.ajax({ + url: evaluatorHandlerUrl, + method: "POST", + data: JSON.stringify({}), + success: function(response) { + if (paneControllers[0]) { + if (response && response.report_html) { + state.hasReport = true; + state.reportPayload = { + report_html: response.report_html, + evaluation_markdown: response.evaluation_markdown, + final_submission: response.final_submission, + attempts: response.attempts, + finished: response.finished, + }; + } + handleResponse(paneControllers[0], response, null); + } + }, + error: function() { + if (paneControllers[0]) { + setPaneBusy(paneControllers[0], false); + } + setAllInputsEnabled(true); + updateAttemptUI(); + announceStatus("workspace", translate("Unable to submit for evaluation.")); + alert(translate("An error has occurred.")); + }, + }); + }; + + $inputs.on("input", function() { + autoResize($(this)); + }); + + $inputs.on("keypress", function(event) { + if (event.keyCode === 13 && !event.shiftKey) { + event.preventDefault(); + const index = parseInt($(this).data("character-index"), 10); + sendMessage(index); + return false; + } + return true; + }); + + $sendButtons.on("click", function() { + const index = parseInt($(this).data("character-index"), 10); + sendMessage(index); + }); + + $inputs.each(function() { + autoResize($(this)); + }); + + if ($resetAllButton.length) { + $resetAllButton.on("click", function() { + resetAllConversations(); + }); + } + + if ($backToReportButton.length) { + $backToReportButton.on("click", function() { + if (!state.hasReport || !state.reportPayload) { + return; + } + enterReportMode(state.reportPayload); + }); + } + + if ($submitEvaluation.length) { + $submitEvaluation.on("click", function() { + submitForEvaluation(); + }); + } + + runFuncAfterLoading(function init() { + populateHistories(data.chat_histories); + applyFinishedState(state.finished); + if (data.final_report && data.final_report.report_html) { + state.hasReport = true; + state.reportPayload = { + report_html: data.final_report.report_html, + evaluation_markdown: data.final_report.evaluation_markdown, + final_submission: data.final_report.final_submission, + attempts: data.final_report.attempts || state.attempts, + finished: state.finished, + }; + if (data.final_report.attempts) { + state.attempts = data.final_report.attempts; + } + enterReportMode(state.reportPayload); + } else { + enterChatMode(); + } + }); +} diff --git a/ai_eval/templates/chatbox.html b/ai_eval/templates/chatbox.html index 7707cbc..07a5a7c 100644 --- a/ai_eval/templates/chatbox.html +++ b/ai_eval/templates/chatbox.html @@ -12,10 +12,10 @@
-
-
-
-