diff --git a/apps/onboarding/consumers.py b/apps/onboarding/consumers.py index 5a22488..c6df7a0 100644 --- a/apps/onboarding/consumers.py +++ b/apps/onboarding/consumers.py @@ -22,14 +22,16 @@ class OnboardingConsumer(AsyncWebsocketConsumer): self.context_uuid = self.scope["url_route"]["kwargs"].get("session_uuid") if not self.user.is_authenticated: + logger.warning("WebSocket connect denied: unauthenticated user") await self.close() return self.router = MCPRouter() + logger.info("WebSocket connected: user_id=%s context_uuid=%s", self.user.id, self.context_uuid) await self.accept() async def disconnect(self, close_code): - pass + logger.info("WebSocket disconnected: user_id=%s context_uuid=%s close_code=%s", getattr(self.user, 'id', None), self.context_uuid, close_code) def _build_system_prompt(self, config): base_prompt = config.system_prompt or "You are a helpful onboarding assistant." @@ -42,6 +44,7 @@ class OnboardingConsumer(AsyncWebsocketConsumer): try: data = json.loads(text_data) action = data.get("action") + logger.info("WebSocket received action=%s user_id=%s context_uuid=%s", action, self.user.id, self.context_uuid) if action == "start_full_onboarding": role_uuid = data.get("role_uuid") @@ -96,7 +99,7 @@ class OnboardingConsumer(AsyncWebsocketConsumer): } })) except Exception as e: - logger.error(f"WebSocket Receive Error: {str(e)}") + logger.exception("WebSocket receive error: user_id=%s context_uuid=%s", getattr(self.user, 'id', None), self.context_uuid) await self.send_log("error", f"Consumer encountered an error: {str(e)}") async def run_full_onboarding_generation(self, role_uuid): @@ -105,6 +108,7 @@ class OnboardingConsumer(AsyncWebsocketConsumer): Pipeline: Curriculum Agent -> Knowledge Agent -> Assessment Agent """ + logger.info("Starting full onboarding generation: role_uuid=%s user_id=%s", role_uuid, self.user.id) await self.send_log("status", "Phase 1: Generating Curriculum...", "curriculum") ca_config = await self.get_config_by_type(role_uuid, 'curriculum') if not ca_config: @@ -124,6 +128,7 @@ class OnboardingConsumer(AsyncWebsocketConsumer): ) topics = self._extract_json_list(ca_response) if not topics: + logger.warning("Curriculum generation produced no topics: role_uuid=%s", role_uuid) await self.send_log("error", "Curriculum generation returned no topics") return @@ -185,13 +190,22 @@ class OnboardingConsumer(AsyncWebsocketConsumer): await self.send_log("error", "Missing assessment AgentConfig for this role") return + question_count = 8 + try: + random_result = await self.router.handle_tool_call("random_int", {"min": 6, "max": 10}) + if isinstance(random_result, dict) and isinstance(random_result.get("value"), int): + question_count = int(random_result["value"]) + except Exception: + question_count = 8 + quiz_prompt = ( "Create a final onboarding quiz that assesses all generated modules. " - "Output ONLY a valid JSON array of 8 multiple-choice question objects. " - "Each object MUST include: 'key' (snake_case), 'label', 'field_type' ('select'), " - "'options' (array of 4 unique strings), 'required' (true), and 'validation' with " - "'correct_option' (exactly matching one option) and 'explanation' (short rationale). " - "Cover all topics with balanced difficulty and avoid ambiguous choices.\n\n" + f"Output ONLY a valid JSON array of exactly {question_count} question objects. " + "Use a mix of question types: at least 2 short-answer questions and at least 2 multiple-choice questions. " + "For multiple-choice objects: field_type='select', options (4 unique strings), and validation.correct_option. " + "For short-answer objects: field_type='textarea' (or 'text') and validation.accepted_answers (array of valid answers/keywords). " + "Each object MUST include key, label, field_type, required=true, and validation.explanation. " + "Cover all topics with balanced difficulty and avoid ambiguous wording.\n\n" f"Modules JSON:\n{json.dumps(module_briefs, ensure_ascii=False)}" ) quiz_response = await self.orchestrate_ai( @@ -214,7 +228,7 @@ class OnboardingConsumer(AsyncWebsocketConsumer): if not quiz_fields: await self.send_log("status", "Assessment output still invalid. Using fallback final quiz.", "assessment") - quiz_fields = self._build_fallback_quiz_fields([str(topic) for topic in topics]) + quiz_fields = self._build_fallback_quiz_fields([str(topic) for topic in topics], count=question_count) full_structure.append({ "title": "Final Assessment Quiz", @@ -233,6 +247,7 @@ class OnboardingConsumer(AsyncWebsocketConsumer): await self.save_full_flow(role_uuid, full_structure) + logger.info("Full onboarding generation completed: role_uuid=%s pages=%s", role_uuid, len(full_structure)) await self.send(json.dumps({ @@ -242,6 +257,13 @@ class OnboardingConsumer(AsyncWebsocketConsumer): })) async def run_progress_monitor(self, role_uuid, target_user_id=None, flow_uuid=None): + logger.info( + "Starting progress monitor: role_uuid=%s requester_id=%s target_user_id=%s flow_uuid=%s", + role_uuid, + self.user.id, + target_user_id or self.user.id, + flow_uuid, + ) await self.send_log("status", "Progress Monitor is analyzing your onboarding progress...", "monitor") monitor_config = await self.get_config_by_type(role_uuid, 'monitor') @@ -259,6 +281,8 @@ class OnboardingConsumer(AsyncWebsocketConsumer): "You are a progress monitoring agent for onboarding. " "Analyze the role onboarding data below and provide concise feedback with:\n" "1) current status\n2) strengths\n3) gaps\n4) next actions\n" + "Use prior learner question/answer evidence and any saved marking details when available. " + "If evidence is insufficient, explicitly state what is missing.\n" "Keep it short and practical.\n\n" f"Progress context JSON:\n{json.dumps(progress_context)}" ) @@ -292,6 +316,7 @@ class OnboardingConsumer(AsyncWebsocketConsumer): "is_completed": progress_context.get("is_completed", False), } })) + logger.info("Progress monitor completed: role_uuid=%s target_user_id=%s", role_uuid, target_user_id or self.user.id) async def orchestrate_ai( self, @@ -386,6 +411,7 @@ class OnboardingConsumer(AsyncWebsocketConsumer): except Exception as e: await self.send_log("error", f"Inference failed: {str(e)}") + logger.exception("Inference failed: user_id=%s context_uuid=%s", self.user.id, self.context_uuid) if raise_on_error: raise return f"Error: {str(e)}" @@ -507,6 +533,10 @@ class OnboardingConsumer(AsyncWebsocketConsumer): if not label: continue + field_type = str(field.get('field_type') or 'select').strip().lower() + if field_type not in ('select', 'text', 'textarea'): + field_type = 'select' + raw_options = field.get('options') if isinstance(field.get('options'), list) else [] options = [] for option in raw_options: @@ -514,37 +544,81 @@ class OnboardingConsumer(AsyncWebsocketConsumer): if option_text and option_text not in options: options.append(option_text) - if len(options) < 2: + validation = field.get('validation') if isinstance(field.get('validation'), dict) else {} + if field_type == 'select': + if len(options) < 2: + continue + + correct_option = str(validation.get('correct_option') or '').strip() + if correct_option not in options: + correct_option = options[0] + + sanitized.append({ + 'key': key, + 'label': label, + 'field_type': 'select', + 'options': options[:5], + 'required': True, + 'validation': { + 'correct_option': correct_option, + 'explanation': str(validation.get('explanation') or ''), + }, + }) continue - validation = field.get('validation') if isinstance(field.get('validation'), dict) else {} - correct_option = str(validation.get('correct_option') or '').strip() - if correct_option not in options: - correct_option = options[0] + accepted_answers_raw = validation.get('accepted_answers') + if isinstance(accepted_answers_raw, list): + accepted_answers = [str(item).strip() for item in accepted_answers_raw if str(item).strip()] + else: + accepted_single = str(validation.get('correct_answer') or '').strip() + accepted_answers = [accepted_single] if accepted_single else [] + + if not accepted_answers: + continue sanitized.append({ 'key': key, 'label': label, - 'field_type': 'select', - 'options': options[:5], + 'field_type': 'textarea' if field_type == 'textarea' else 'text', + 'options': [], 'required': True, 'validation': { - 'correct_option': correct_option, + 'accepted_answers': accepted_answers, 'explanation': str(validation.get('explanation') or ''), }, }) return sanitized - def _build_fallback_quiz_fields(self, topics): + def _build_fallback_quiz_fields(self, topics, count=8): safe_topics = [str(topic).strip() for topic in (topics or []) if str(topic).strip()] if not safe_topics: safe_topics = ['onboarding fundamentals'] fallback_fields = [] - for index in range(8): + for index in range(count): topic = safe_topics[index % len(safe_topics)] key = f'final_quiz_q_{index + 1}' + + if index % 3 == 0: + fallback_fields.append({ + 'key': key, + 'label': f"In one or two sentences, what is the safest approach when handling {topic}?", + 'field_type': 'textarea', + 'options': [], + 'required': True, + 'validation': { + 'accepted_answers': [ + 'best practices', + 'documentation', + 'quality', + 'compliance', + ], + 'explanation': 'Good answers reference documented best practices, quality checks, and compliance.', + }, + }) + continue + correct = f"Use documented best practices for {topic}." options = [ correct, @@ -584,6 +658,11 @@ class OnboardingConsumer(AsyncWebsocketConsumer): normalized_validation = { 'correct_option': correct_option if correct_option in options else None, + 'accepted_answers': [ + str(item).strip() + for item in (validation.get('accepted_answers') if isinstance(validation.get('accepted_answers'), list) else []) + if str(item).strip() + ], 'explanation': str(validation.get('explanation') or ''), } @@ -628,6 +707,12 @@ class OnboardingConsumer(AsyncWebsocketConsumer): return flow async def send_log(self, log_type, message, content=None): + if log_type == "error": + logger.error("Consumer log event: type=%s message=%s content=%s", log_type, message, content) + elif log_type == "status": + logger.info("Consumer log event: type=%s message=%s content=%s", log_type, message, content) + else: + logger.debug("Consumer log event: type=%s message=%s content=%s", log_type, message, content) await self.send(json.dumps({ "type": log_type, "message": message, @@ -702,7 +787,7 @@ class OnboardingConsumer(AsyncWebsocketConsumer): sessions = OnboardingSession.objects.filter(user_id=user_id, role=role).order_by('-updated_at') if flow_uuid: - sessions = sessions.filter(state__flow_uuid=str(flow_uuid)) + sessions = sessions.filter(Q(flow__uuid=flow_uuid) | Q(flow__isnull=True, state__flow_uuid=str(flow_uuid))) latest_session = sessions.first() @@ -718,12 +803,77 @@ class OnboardingConsumer(AsyncWebsocketConsumer): "responses_count": 0, "completed_modules": [], "is_completed": False, + "final_quiz_result": {}, + "final_quiz_qa": [], } state = latest_session.state or {} responses = state.get("responses", {}) completed_modules = state.get("completed_modules", []) progress = state.get("progress_percentage", state.get("progress", 0)) + final_quiz_result = state.get("final_quiz_result", {}) + + flow_for_context = latest_session.flow or scoped_flow or active_flow + structure = flow_for_context.structure if flow_for_context and isinstance(flow_for_context.structure, list) else [] + + quiz_page = None + for page in structure: + if not isinstance(page, dict): + continue + meta = page.get("meta") if isinstance(page.get("meta"), dict) else {} + if str(meta.get("page_type") or "").strip() == "final_quiz": + quiz_page = page + break + + if quiz_page is None and structure: + quiz_page = structure[-1] if isinstance(structure[-1], dict) else None + + quiz_fields = quiz_page.get("fields") if isinstance(quiz_page, dict) and isinstance(quiz_page.get("fields"), list) else [] + quiz_page_uuid = str(quiz_page.get("uuid") or "") if isinstance(quiz_page, dict) else "" + + quiz_responses = {} + if isinstance(responses, dict) and quiz_page_uuid: + candidate = responses.get(quiz_page_uuid, {}) + if isinstance(candidate, dict): + quiz_responses = candidate + + grading_details = ( + final_quiz_result.get("grading_details", []) + if isinstance(final_quiz_result, dict) + else [] + ) + grading_by_key = {} + if isinstance(grading_details, list): + for detail in grading_details: + if not isinstance(detail, dict): + continue + key = str(detail.get("key") or "").strip() + if not key: + continue + grading_by_key[key] = { + "correct": bool(detail.get("correct")), + "reason": str(detail.get("reason") or ""), + } + + final_quiz_qa = [] + for field in quiz_fields: + if not isinstance(field, dict): + continue + key = str(field.get("key") or "").strip() + if not key: + continue + + detail = grading_by_key.get(key, {}) + final_quiz_qa.append( + { + "key": key, + "label": str(field.get("label") or key), + "field_type": str(field.get("field_type") or ""), + "answer": quiz_responses.get(key), + "marked_correct": detail.get("correct"), + "marking_reason": detail.get("reason", ""), + } + ) return { "role_uuid": str(role.uuid), @@ -731,12 +881,14 @@ class OnboardingConsumer(AsyncWebsocketConsumer): "latest_status": latest_session.status, "session_count": sessions.count(), "flow_exists": bool(scoped_flow or active_flow), - "flow_uuid": str((scoped_flow or active_flow).uuid) if (scoped_flow or active_flow) else None, + "flow_uuid": str(latest_session.flow.uuid) if latest_session.flow_id else str((scoped_flow or active_flow).uuid) if (scoped_flow or active_flow) else None, "progress": progress, "responses_count": len(responses) if isinstance(responses, dict) else 0, "completed_modules": completed_modules if isinstance(completed_modules, list) else [], "updated_at": latest_session.updated_at.isoformat() if latest_session.updated_at else None, "is_completed": latest_session.status == 'completed', + "final_quiz_result": final_quiz_result if isinstance(final_quiz_result, dict) else {}, + "final_quiz_qa": final_quiz_qa, } @database_sync_to_async diff --git a/apps/onboarding/mcp.py b/apps/onboarding/mcp.py index d04e384..23df4e5 100644 --- a/apps/onboarding/mcp.py +++ b/apps/onboarding/mcp.py @@ -1,4 +1,7 @@ import httpx +import logging +import random + from channels.db import database_sync_to_async from django.conf import settings from pgvector.django import CosineDistance @@ -6,97 +9,186 @@ from pgvector.django import CosineDistance from apps.knowledge.models import RoleRagDocument from apps.onboarding.models import OnboardingSession +logger = logging.getLogger(__name__) + + +def mcp_tool(name, description, input_schema): + def decorator(func): + func._mcp_tool_meta = { + 'name': name, + 'description': description, + 'inputSchema': input_schema, + } + return func + + return decorator + + +def _collect_tools(class_namespace): + tools = [] + for method_name, value in class_namespace.items(): + metadata = getattr(value, '_mcp_tool_meta', None) + if not metadata: + continue + + tools.append( + { + 'name': metadata['name'], + 'method': method_name, + 'description': metadata['description'], + 'inputSchema': metadata['inputSchema'], + } + ) + + return tools + class MCPRouter: - def get_tool_definitions(self): - return [ - { - "name": "search_knowledge", - "description": "Search the RAG database for role-specific training content.", - "inputSchema": { - "type": "object", - "properties": { - "query": {"type": "string"}, - "role_uuid": {"type": "string"} - }, - "required": ["query", "role_uuid"] - } - }, - { - "name": "update_progress", - "description": "Update the user's score or current module in their session.", - "inputSchema": { - "type": "object", - "properties": { - "session_uuid": {"type": "string"}, - "score": {"type": "integer"}, - "completed_module": {"type": "string"} - }, - "required": ["session_uuid"] - } - } - ] + return self.tools async def handle_tool_call(self, name, arguments): - if name == "search_knowledge": - return await self._search_knowledge(arguments) - elif name == "update_progress": - return await self._update_progress(arguments) - return {"error": f"Tool {name} not found"} + logger.info('MCP tool call received: tool=%s args=%s', name, arguments) + arguments = arguments or {} + + method_name = self._tool_name_to_method.get(name) + if method_name: + method = getattr(self, method_name, None) + if method: + result = await method(arguments) + logger.info( + 'MCP tool call completed: tool=%s result=%s', + name, + result, + ) + return result + + logger.warning('MCP tool call rejected: unknown tool=%s', name) + return {'error': f'Tool {name} not found'} async def _get_embedding(self, text): + logger.info('MCP embedding request started') async with httpx.AsyncClient() as client: response = await client.post( settings.INFERENCE_EMBEDDINGS_ENDPOINT, - json={"input": text} + json={'input': text}, ) - - return response.json()["data"][0]["embedding"] + response.raise_for_status() + embedding = response.json()['data'][0]['embedding'] + logger.info('MCP embedding request completed') + return embedding + @mcp_tool( + name='search_knowledge', + description='Search the RAG database for role-specific training content.', + input_schema={ + 'type': 'object', + 'properties': { + 'query': {'type': 'string'}, + 'role_uuid': {'type': 'string'}, + }, + 'required': ['query', 'role_uuid'], + }, + ) async def _search_knowledge(self, args): - query = args.get("query") - role_uuid = args.get("role_uuid") + query = args.get('query') + role_uuid = args.get('role_uuid') if not query or not role_uuid: + logger.warning('MCP search_knowledge missing query or role_uuid') return [] - query_vector = await self._get_embedding(query) - return await self._search_knowledge_documents(role_uuid, query_vector) @database_sync_to_async def _search_knowledge_documents(self, role_uuid, query_vector): - - docs = RoleRagDocument.objects.filter( role__uuid=role_uuid, - is_active=True + is_active=True, ).annotate( distance=CosineDistance('embedding', query_vector) ).order_by('distance')[:5] - - return [ + results = [ { - "content": d.content, - "source": d.metadata.get("file_name", "Unknown Source"), - "relevance": round(1 - d.distance, 4) + 'content': d.content, + 'source': d.metadata.get('file_name', 'Unknown Source'), + 'relevance': round(1 - d.distance, 4), } for d in docs ] + logger.info( + 'MCP search_knowledge_documents completed: role_uuid=%s results=%s', + role_uuid, + len(results), + ) + return results + @mcp_tool( + name='update_progress', + description="Update the user's score or current module in their session.", + input_schema={ + 'type': 'object', + 'properties': { + 'session_uuid': {'type': 'string'}, + 'score': {'type': 'integer'}, + 'completed_module': {'type': 'string'}, + }, + 'required': ['session_uuid'], + }, + ) @database_sync_to_async def _update_progress(self, args): - session = OnboardingSession.objects.get(uuid=args.get("session_uuid")) - + session = OnboardingSession.objects.get(uuid=args.get('session_uuid')) + state = session.state or {} - if "score" in args: - state["last_score"] = args["score"] - if "completed_module" in args: - state.setdefault("completed_modules", []).append(args["completed_module"]) - + if 'score' in args: + state['last_score'] = args['score'] + if 'completed_module' in args: + state.setdefault('completed_modules', []).append(args['completed_module']) + session.state = state session.save() - return {"status": "success", "new_state": state} \ No newline at end of file + logger.info( + 'MCP update_progress completed: session_uuid=%s', + args.get('session_uuid'), + ) + return {'status': 'success', 'new_state': state} + + @mcp_tool( + name='random_int', + description='Generate a random integer in an inclusive range.', + input_schema={ + 'type': 'object', + 'properties': { + 'min': {'type': 'integer'}, + 'max': {'type': 'integer'}, + }, + 'required': ['min', 'max'], + }, + ) + async def _random_int(self, args): + min_value = args.get('min') + max_value = args.get('max') + try: + min_value = int(min_value) + max_value = int(max_value) + except Exception: + logger.warning('MCP random_int invalid args: %s', args) + return {'error': 'min and max must be integers'} + + if min_value > max_value: + min_value, max_value = max_value, min_value + + value = random.randint(min_value, max_value) + logger.info( + 'MCP random_int generated value=%s range=[%s,%s]', + value, + min_value, + max_value, + ) + return {'value': value, 'min': min_value, 'max': max_value} + + tools = _collect_tools(locals()) + _tool_name_to_method = {tool['name']: tool['method'] for tool in tools} \ No newline at end of file diff --git a/apps/onboarding/migrations/0001_initial.py b/apps/onboarding/migrations/0001_initial.py index 1684fcb..112dd0e 100644 --- a/apps/onboarding/migrations/0001_initial.py +++ b/apps/onboarding/migrations/0001_initial.py @@ -62,6 +62,7 @@ class Migration(migrations.Migration): ('state', models.JSONField(blank=True, default=dict, verbose_name='Session State')), ('active_configs', models.JSONField(default=dict, verbose_name='Active Configs')), ('completed_at', models.DateTimeField(blank=True, null=True, verbose_name='Completed At')), + ('flow', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sessions', to='onboarding.onboardingflow', verbose_name='Onboarding Flow')), ('role', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='onboarding_sessions', to='accounts.role', verbose_name='Target Role')), ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='onboarding_sessions', to=settings.AUTH_USER_MODEL, verbose_name='User')), ], diff --git a/apps/onboarding/models.py b/apps/onboarding/models.py index 4b82ac5..ddc7431 100644 --- a/apps/onboarding/models.py +++ b/apps/onboarding/models.py @@ -1,4 +1,4 @@ -from django.db.models import CASCADE, BooleanField, CharField, DateTimeField, ForeignKey, JSONField, Model, TextField +from django.db.models import CASCADE, SET_NULL, BooleanField, CharField, DateTimeField, ForeignKey, JSONField, Model, TextField from django.utils.translation import gettext_lazy as _ from apps.accounts.mixins import IdentifierMixin, TimeStampMixin @@ -28,6 +28,20 @@ class AgentConfig(IdentifierMixin, TimeStampMixin, Model): def __str__(self): return f"{self.name} ({self.get_agent_type_display()})" + +class OnboardingFlow(IdentifierMixin, TimeStampMixin, Model): + title = CharField(max_length=255, verbose_name=_("Flow Title")) + role = ForeignKey(Role, on_delete=CASCADE, related_name='flows', verbose_name=_("Role")) + structure = JSONField(default=list, blank=True, verbose_name=_("Flow Structure")) + is_active = BooleanField(default=True, verbose_name=_("Is Active")) + + class Meta: + verbose_name = _('Onboarding Flow') + verbose_name_plural = _('Onboarding Flows') + + def __str__(self): + return self.title + class OnboardingSession(IdentifierMixin, TimeStampMixin, Model): STATUS_CHOICES = [ ('active', 'Active'), @@ -37,6 +51,7 @@ class OnboardingSession(IdentifierMixin, TimeStampMixin, Model): user = ForeignKey(User, on_delete=CASCADE, related_name='onboarding_sessions', verbose_name=_("User")) role = ForeignKey(Role, on_delete=CASCADE, related_name='onboarding_sessions', verbose_name=_("Target Role")) + flow = ForeignKey(OnboardingFlow, on_delete=SET_NULL, null=True, blank=True, related_name='sessions', verbose_name=_("Onboarding Flow")) status = CharField(max_length=20, choices=STATUS_CHOICES, default='active', verbose_name=_("Session Status")) state = JSONField(default=dict, blank=True, verbose_name=_("Session State")) @@ -73,15 +88,3 @@ class AgentInteractionLog(IdentifierMixin, TimeStampMixin, Model): def __str__(self): return f"{self.sender_type} in {self.session.uuid}" -class OnboardingFlow(IdentifierMixin, TimeStampMixin, Model): - title = CharField(max_length=255, verbose_name=_("Flow Title")) - role = ForeignKey(Role, on_delete=CASCADE, related_name='flows', verbose_name=_("Role")) - structure = JSONField(default=list, blank=True, verbose_name=_("Flow Structure")) - is_active = BooleanField(default=True, verbose_name=_("Is Active")) - - class Meta: - verbose_name = _('Onboarding Flow') - verbose_name_plural = _('Onboarding Flows') - - def __str__(self): - return self.title \ No newline at end of file diff --git a/apps/onboarding/serializers.py b/apps/onboarding/serializers.py index 074aec0..a496d54 100644 --- a/apps/onboarding/serializers.py +++ b/apps/onboarding/serializers.py @@ -30,13 +30,14 @@ class AgentInteractionLogSerializer(ModelSerializer): class OnboardingSessionSerializer(ModelSerializer): user = UserSerializer(read_only=True) role = RoleSerializer(read_only=True) + flow = SerializerMethodField() logs = AgentInteractionLogSerializer(many=True, read_only=True) progress_percentage = SerializerMethodField() class Meta: model = OnboardingSession fields = [ - 'id', 'uuid', 'user', 'role', 'status', 'state', + 'id', 'uuid', 'user', 'role', 'flow', 'status', 'state', 'active_configs', 'logs', 'completed_at', 'created_at', 'updated_at', 'progress_percentage' ] @@ -45,6 +46,15 @@ class OnboardingSessionSerializer(ModelSerializer): def get_progress_percentage(self, obj: OnboardingSession) -> int: return obj.state.get('progress_percentage', 0) + def get_flow(self, obj: OnboardingSession): + if not obj.flow: + return None + return { + 'uuid': str(obj.flow.uuid), + 'title': obj.flow.title, + 'is_active': obj.flow.is_active, + } + class OnboardingFlowSerializer(ModelSerializer): role = RoleSerializer(read_only=True) session_count = SerializerMethodField() diff --git a/apps/onboarding/tests/test_api.py b/apps/onboarding/tests/test_api.py index 4514302..a6f80ef 100644 --- a/apps/onboarding/tests/test_api.py +++ b/apps/onboarding/tests/test_api.py @@ -428,12 +428,17 @@ class OnboardingApiTests(TestCase): self.assertEqual(response.status_code, HTTP_200_OK) self.assertTrue(response.data['updated_page']) + self.assertEqual(response.data.get('revised_page_body'), revised_body) self.flow.refresh_from_db() + self.session.refresh_from_db() page = self.flow.structure[0] - self.assertEqual(page.get('body'), revised_body) + self.assertEqual(page.get('body'), original_body) self.assertNotIn('### Clarification', str(page.get('body') or '')) + overrides = self.session.state.get('page_overrides', {}) + self.assertEqual(overrides.get('page-1'), revised_body) + def test_onboarding_session_complete_blocks_when_quiz_score_below_pass_mark(self): self.flow.structure = [ { @@ -468,10 +473,24 @@ class OnboardingApiTests(TestCase): self.session.save(update_fields=['state', 'updated_at']) self.client.force_authenticate(self.member) - response = self.client.post( - f'/api/onboarding-session/{self.session.uuid}/complete/', - format='json', - ) + with patch.object( + OnboardingSessionViewSet, + '_grade_final_quiz_with_assessment_agent', + return_value=( + { + 'correct_count': 0, + 'gradable_count': 1, + 'score_percentage': 0, + 'pass_mark': 80, + 'per_question': [], + }, + None, + ), + ): + response = self.client.post( + f'/api/onboarding-session/{self.session.uuid}/complete/', + format='json', + ) self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) self.assertEqual(response.data['quiz_result']['score_percentage'], 0) @@ -512,7 +531,21 @@ class OnboardingApiTests(TestCase): self.session.save(update_fields=['state', 'updated_at']) self.client.force_authenticate(self.member) - response = self.client.post(f'/api/onboarding-session/{self.session.uuid}/complete/') + with patch.object( + OnboardingSessionViewSet, + '_grade_final_quiz_with_assessment_agent', + return_value=( + { + 'correct_count': 1, + 'gradable_count': 1, + 'score_percentage': 100, + 'pass_mark': 80, + 'per_question': [], + }, + None, + ), + ): + response = self.client.post(f'/api/onboarding-session/{self.session.uuid}/complete/') self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(response.data['quiz_result']['score_percentage'], 100) self.assertTrue(response.data['quiz_result']['passed']) diff --git a/apps/onboarding/viewsets.py b/apps/onboarding/viewsets.py index 6fd2dea..fa750a2 100644 --- a/apps/onboarding/viewsets.py +++ b/apps/onboarding/viewsets.py @@ -1,4 +1,6 @@ import httpx +import json +import re from django.conf import settings from django.db import transaction from django.db.models import Q @@ -109,7 +111,7 @@ class OnboardingFlowViewSet(ModelViewSet): def destroy(self, request, *args, **kwargs): flow = self.get_object() with transaction.atomic(): - OnboardingSession.objects.filter(role=flow.role).delete() + OnboardingSession.objects.filter(flow=flow).delete() self.perform_destroy(flow) return Response(status=204) @@ -147,25 +149,40 @@ class OnboardingFlowViewSet(ModelViewSet): status=HTTP_403_FORBIDDEN, ) - session, created = OnboardingSession.objects.get_or_create( - user=request.user, - role=flow.role, - defaults={ - 'status': 'active', - 'state': { - 'progress': 0, - 'current_step': 'intro', - 'flow_uuid': str(flow.uuid), - }, - 'active_configs': {}, - } - ) + session = OnboardingSession.objects.filter(user=request.user, role=flow.role, flow=flow).first() + created = False + + if not session: + # Backward compatibility for legacy sessions before flow FK existed. + legacy_session = OnboardingSession.objects.filter( + user=request.user, + role=flow.role, + flow__isnull=True, + ).order_by('-updated_at').first() + + if legacy_session: + session = legacy_session + else: + session = OnboardingSession.objects.create( + user=request.user, + role=flow.role, + flow=flow, + status='active', + state={ + 'progress': 0, + 'current_step': 'intro', + 'flow_uuid': str(flow.uuid), + }, + active_configs={}, + ) + created = True if not created: - state = session.state or {} + state = session.state if isinstance(session.state, dict) else {} state['flow_uuid'] = str(flow.uuid) + session.flow = flow session.state = state - session.save(update_fields=['state', 'updated_at']) + session.save(update_fields=['flow', 'state', 'updated_at']) serializer = OnboardingSessionSerializer(session) return Response(serializer.data, status=HTTP_201_CREATED if created else HTTP_200_OK) @@ -321,7 +338,7 @@ class OnboardingSessionViewSet(ModelViewSet): if flow_uuid in (None, ''): flow_uuid = self.request.data.get('flow_uuid') if flow_uuid: - queryset = queryset.filter(state__flow_uuid=str(flow_uuid)) + queryset = queryset.filter(Q(flow__uuid=flow_uuid) | Q(flow__isnull=True, state__flow_uuid=str(flow_uuid))) status_value = self.request.query_params.get('status') if status_value: @@ -369,7 +386,7 @@ class OnboardingSessionViewSet(ModelViewSet): latest_by_user_flow = {} for session in role_sessions: state = session.state if isinstance(session.state, dict) else {} - session_flow_uuid = str(state.get('flow_uuid') or '') + session_flow_uuid = str(session.flow.uuid) if session.flow_id else str(state.get('flow_uuid') or '') if not session_flow_uuid: continue @@ -434,6 +451,9 @@ class OnboardingSessionViewSet(ModelViewSet): return True def _get_flow_for_session(self, session): + if session.flow_id: + return session.flow + state = session.state or {} flow_uuid = state.get('flow_uuid') @@ -527,6 +547,217 @@ class OnboardingSessionViewSet(ModelViewSet): agent_type='knowledge', ).order_by('-updated_at').first() + def _get_assessment_agent_config(self, session): + role_specific = AgentConfig.objects.filter( + role=session.role, + agent_type='assessment', + ).order_by('-updated_at').first() + if role_specific: + return role_specific + + return AgentConfig.objects.filter( + organization=session.role.organization, + role__isnull=True, + agent_type='assessment', + ).order_by('-updated_at').first() + + def _extract_json_object(self, text): + if not text: + return None + + candidate = str(text).strip() + + try: + return json.loads(candidate) + except Exception: + pass + + matches = re.findall(r'```(?:json)?\s*([\s\S]*?)```', candidate, re.IGNORECASE) + for block in matches: + try: + return json.loads(block.strip()) + except Exception: + continue + + decoder = json.JSONDecoder() + for idx, char in enumerate(candidate): + if char != '{': + continue + try: + obj, _ = decoder.raw_decode(candidate[idx:]) + if isinstance(obj, dict): + return obj + except Exception: + continue + + return None + + def _grade_final_quiz_with_assessment_agent(self, session, quiz_fields, page_responses, pass_mark): + select_results = [] + ai_fields = [] + select_correct_count = 0 + + for field in quiz_fields: + if not isinstance(field, dict): + continue + + key = str(field.get('key') or '').strip() + if not key: + continue + + field_type = str(field.get('field_type') or '').strip().lower() + validation = field.get('validation') if isinstance(field.get('validation'), dict) else {} + + if field_type == 'select': + correct_option = str(validation.get('correct_option') or '').strip() + if correct_option: + answer = page_responses.get(key) + attempted = self._has_attempt(answer) + answer_text = str(answer).strip() if attempted else '' + is_correct = attempted and answer_text == correct_option + if is_correct: + select_correct_count += 1 + + select_results.append( + { + 'key': key, + 'correct': is_correct, + 'reason': '' if is_correct else 'Selected option does not match the expected choice.', + } + ) + continue + + ai_fields.append(field) + + ai_correct_count = 0 + ai_gradable_count = 0 + ai_per_question = [] + + if ai_fields: + config = self._get_assessment_agent_config(session) or self._get_knowledge_agent_config(session) + if not config: + return None, {'error': 'No assessment/knowledge agent configured for grading.'} + + prompt = ( + 'You are grading a completed onboarding final quiz. ' + 'Evaluate each learner answer for correctness using the question prompt and validation hints. ' + 'Do NOT grade multiple-choice select questions here; they are graded separately. ' + 'Grade only the provided non-select questions (for example short-answer/textarea). ' + 'For short-answer questions, use validation.accepted_answers semantically and allow equivalent phrasing. ' + 'For incorrect answers, provide a brief coaching reason that explains what is missing or incorrect, ' + 'but DO NOT reveal the correct answer, exact option text, or accepted-answer phrases. ' + 'Keep each reason to one short sentence. ' + 'Return ONLY JSON object with keys: correct_count (int), gradable_count (int), per_question (array of ' + '{key, correct, reason}). Do not include markdown.' + f"\n\nQuiz fields JSON:\n{json.dumps(ai_fields, ensure_ascii=False)}" + f"\n\nLearner answers JSON:\n{json.dumps(page_responses, ensure_ascii=False)}" + ) + + try: + with httpx.Client(timeout=60.0) as client: + response = client.post( + settings.INFERENCE_CHAT_COMPLETIONS_ENDPOINT, + json={ + 'model': (config.llm_config or {}).get('model_id', 'meta-llama-3.1-8b'), + 'messages': [ + {'role': 'system', 'content': self._build_system_prompt(config)}, + {'role': 'user', 'content': prompt}, + ], + 'max_tokens': 1000, + }, + ) + response.raise_for_status() + content = response.json().get('choices', [{}])[0].get('message', {}).get('content', '') + except Exception: + return None, {'error': 'Assessment grading model unavailable.'} + + parsed = self._extract_json_object(content) + if not isinstance(parsed, dict): + return None, {'error': 'Assessment grading returned invalid JSON.'} + + try: + ai_correct_count = int(parsed.get('correct_count', 0)) + ai_gradable_count = int(parsed.get('gradable_count', len(ai_fields))) + except Exception: + return None, {'error': 'Assessment grading returned invalid counts.'} + + if ai_gradable_count < 0: + ai_gradable_count = 0 + if ai_correct_count < 0: + ai_correct_count = 0 + if ai_correct_count > ai_gradable_count: + ai_correct_count = ai_gradable_count + + ai_per_question = parsed.get('per_question', []) if isinstance(parsed.get('per_question', []), list) else [] + + correct_count = select_correct_count + ai_correct_count + gradable_count = len(select_results) + ai_gradable_count + score_percentage = int(round((correct_count / gradable_count) * 100)) if gradable_count else 0 + + merged_per_question = list(select_results) + list(ai_per_question) + return { + 'correct_count': correct_count, + 'gradable_count': gradable_count, + 'score_percentage': score_percentage, + 'pass_mark': pass_mark, + 'per_question': merged_per_question, + }, None + + def _sanitize_grading_details(self, quiz_fields, per_question): + if not isinstance(per_question, list): + return [] + + validation_tokens_by_key = {} + for field in quiz_fields: + if not isinstance(field, dict): + continue + + key = str(field.get('key') or '').strip() + if not key: + continue + + validation = field.get('validation') if isinstance(field.get('validation'), dict) else {} + tokens = [] + + correct_option = str(validation.get('correct_option') or '').strip() + if correct_option: + tokens.append(correct_option.lower()) + + accepted_answers = validation.get('accepted_answers') + if isinstance(accepted_answers, list): + for answer in accepted_answers: + answer_text = str(answer or '').strip().lower() + if answer_text: + tokens.append(answer_text) + + validation_tokens_by_key[key] = tokens + + sanitized = [] + for item in per_question: + if not isinstance(item, dict): + continue + + key = str(item.get('key') or '').strip() + correct = bool(item.get('correct')) + raw_reason = str(item.get('reason') or '').strip() + + reason = raw_reason + if not correct and reason: + lowered = reason.lower() + has_leak = any(token and token in lowered for token in validation_tokens_by_key.get(key, [])) + if has_leak: + reason = 'Your response missed key requirements from the prompt. Review the guidance and try again.' + + sanitized.append( + { + 'key': key, + 'correct': correct, + 'reason': reason, + } + ) + + return sanitized + def _run_ka_help(self, session, page_title, page_body, user_message): config = self._get_knowledge_agent_config(session) fallback = ( @@ -627,27 +858,16 @@ class OnboardingSessionViewSet(ModelViewSet): session.state = state session.save(update_fields=['state', 'updated_at']) - def _update_flow_page_body(self, session, page_uuid, new_body): - flow = self._get_flow_for_session(session) - if not flow: - return False + def _save_session_page_override(self, session, page_uuid, new_body): + state = session.state if isinstance(session.state, dict) else {} + overrides = state.get('page_overrides', {}) + if not isinstance(overrides, dict): + overrides = {} - structure = flow.structure if isinstance(flow.structure, list) else [] - updated = False - for page in structure: - if not isinstance(page, dict): - continue - if str(page.get('uuid')) != str(page_uuid): - continue - page['body'] = str(new_body) - updated = True - break - - if not updated: - return False - - flow.structure = structure - flow.save(update_fields=['structure', 'updated_at']) + overrides[str(page_uuid)] = str(new_body) + state['page_overrides'] = overrides + session.state = state + session.save(update_fields=['state', 'updated_at']) return True def _evaluate_final_quiz(self, session): @@ -697,8 +917,6 @@ class OnboardingSessionViewSet(ModelViewSet): required_count = 0 missing_required_keys = [] - gradable_count = 0 - correct_count = 0 for field in quiz_fields: if not isinstance(field, dict): @@ -716,18 +934,22 @@ class OnboardingSessionViewSet(ModelViewSet): if not attempted: missing_required_keys.append(key) - validation = field.get('validation') if isinstance(field.get('validation'), dict) else {} - correct_option = validation.get('correct_option') - if correct_option is None: - continue + grading, grading_error = self._grade_final_quiz_with_assessment_agent( + session, + quiz_fields, + page_responses, + pass_mark, + ) + if grading_error: + return None, grading_error - gradable_count += 1 - if attempted and str(answer) == str(correct_option): - correct_count += 1 - - score_percentage = int(round((correct_count / gradable_count) * 100)) if gradable_count else 0 + score_percentage = int(grading.get('score_percentage', 0)) + correct_count = int(grading.get('correct_count', 0)) + gradable_count = int(grading.get('gradable_count', 0)) passed = len(missing_required_keys) == 0 and gradable_count > 0 and score_percentage >= pass_mark + grading_details = self._sanitize_grading_details(quiz_fields, grading.get('per_question', [])) + quiz_result = { 'page_uuid': page_uuid, 'pass_mark': pass_mark, @@ -737,6 +959,7 @@ class OnboardingSessionViewSet(ModelViewSet): 'correct_count': correct_count, 'score_percentage': score_percentage, 'passed': passed, + 'grading_details': grading_details, } state['final_quiz_result'] = quiz_result @@ -818,10 +1041,11 @@ class OnboardingSessionViewSet(ModelViewSet): page_body = str(page.get('body') or '') updated_page = False assistant_message = '' + revised_body = None if str(mode) == 'update_page': revised_body = self._run_ka_page_revision(session, page_title, page_body, str(user_message)) if revised_body: - updated_page = self._update_flow_page_body(session, page_uuid, revised_body) + updated_page = self._save_session_page_override(session, page_uuid, revised_body) if updated_page: assistant_message = ( "Updated this page by integrating your clarification request into the core content. " @@ -859,6 +1083,7 @@ class OnboardingSessionViewSet(ModelViewSet): 'status': 'ok', 'answer': assistant_message, 'updated_page': updated_page, + 'revised_page_body': revised_body if str(mode) == 'update_page' else None, 'session_state': session.state, }, status=HTTP_200_OK) diff --git a/site/src/types/onboarding.ts b/site/src/types/onboarding.ts index 47280e2..a0af53b 100644 --- a/site/src/types/onboarding.ts +++ b/site/src/types/onboarding.ts @@ -36,6 +36,11 @@ export type OnboardingSession = { uuid: string status: OnboardingSessionStatus | string role?: string | UuidNameRef + flow?: { + uuid: string + title?: string + is_active?: boolean + } | null state?: Record active_configs?: Record completed_at?: string | null diff --git a/site/src/views/OnboardingView.vue b/site/src/views/OnboardingView.vue index 7d8f6a0..124db8a 100644 --- a/site/src/views/OnboardingView.vue +++ b/site/src/views/OnboardingView.vue @@ -17,6 +17,7 @@ import { Tag, Popconfirm, } from 'ant-design-vue' +import { CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons-vue' import { apiClient, API, isAxiosError } from '../router/api' import { useOnboardingAgentStore } from '../stores/onboardingAgentStore' import type { @@ -43,13 +44,22 @@ const isAutoGenerating = ref(false) const generationHandled = ref(false) const deletingFlow = ref(false) const visitedPageUuids = ref([]) -const quizResult = ref<{ +type QuizGradingDetail = { + key?: string + correct?: boolean | string | number + reason?: string +} + +type QuizResult = { score_percentage: number pass_mark: number correct_count: number gradable_count: number missing_required_keys?: string[] -} | null>(null) + grading_details?: QuizGradingDetail[] +} + +const quizResult = ref(null) const kaQuestion = ref('') const kaLoading = ref(false) const kaMode = ref<'separate' | 'update_page'>('separate') @@ -70,7 +80,9 @@ const completedModules = computed(() => { return Array.isArray(raw) ? raw.map((item) => String(item)) : [] }) -const pageHelpByPage = computed>>(() => { +const pageHelpByPage = computed< + Record> +>(() => { const state = (session.value as unknown as { state?: Record } | null)?.state const raw = state?.page_help return raw && typeof raw === 'object' @@ -83,11 +95,56 @@ const currentPageHelp = computed(() => { return pageHelpByPage.value[currentPage.value.uuid] || [] }) -const renderedBody = computed(() => { - if (!currentPage.value?.body) return '' - return DOMPurify.sanitize(marked.parse(currentPage.value.body) as string) +const currentPageBody = computed(() => { + const baseBody = currentPage.value?.body || '' + const sessionState = (session.value as unknown as { state?: Record } | null)?.state + const overridesRaw = sessionState?.page_overrides + if (!currentPage.value || !overridesRaw || typeof overridesRaw !== 'object') { + return baseBody + } + + const override = (overridesRaw as Record)[currentPage.value.uuid] + return typeof override === 'string' && override.trim() ? override : baseBody }) +const renderedBody = computed(() => { + if (!currentPageBody.value) return '' + return DOMPurify.sanitize(marked.parse(currentPageBody.value) as string) +}) + +const isAnswerCorrect = (value: unknown) => { + if (typeof value === 'boolean') return value + if (typeof value === 'number') return value === 1 + if (typeof value === 'string') return value.trim().toLowerCase() === 'true' + return false +} + +const quizQuestionResults = computed(() => { + const details = quizResult.value?.grading_details ?? [] + return details.map((detail, index) => { + const key = String(detail.key || `question_${index + 1}`) + const field = currentPage.value?.fields?.find((candidate) => candidate.key === key) + return { + key, + label: field?.label || key, + correct: isAnswerCorrect(detail.correct), + reason: typeof detail.reason === 'string' ? detail.reason : '', + } + }) +}) + +const quizQuestionResultByKey = computed(() => { + const byKey: Record = {} + quizQuestionResults.value.forEach((item) => { + byKey[item.key] = { correct: item.correct, reason: item.reason } + }) + return byKey +}) + +const getFieldMarking = (fieldKey: string) => { + return quizQuestionResultByKey.value[fieldKey] ?? null +} + const getFlowRoleUuid = (flowData: OnboardingFlowSummary): string | undefined => { if (typeof flowData.role === 'string') return flowData.role return flowData.role?.uuid @@ -463,13 +520,7 @@ const onSubmitPage = async () => { const completeResponse = await apiClient.post<{ message: string - quiz_result?: { - score_percentage: number - pass_mark: number - correct_count: number - gradable_count: number - missing_required_keys?: string[] - } + quiz_result?: QuizResult }>(API.onboarding.sessions.complete(session.value.uuid)) if (completeResponse.data?.quiz_result) { @@ -479,7 +530,7 @@ const onSubmitPage = async () => { message.success('Onboarding Finished!') router.push('/organization') } catch (error: unknown) { - if (isAxiosError<{ error?: string; quiz_result?: typeof quizResult.value }>(error)) { + if (isAxiosError<{ error?: string; quiz_result?: QuizResult }>(error)) { const data = error.response?.data if (data?.quiz_result) { quizResult.value = data.quiz_result @@ -502,6 +553,7 @@ const askKnowledgeAgent = async () => { status: string answer: string updated_page: boolean + revised_page_body?: string | null session_state?: Record }>(API.onboarding.sessions.askKa(session.value.uuid), { page_uuid: currentPage.value.uuid, @@ -515,14 +567,6 @@ const askKnowledgeAgent = async () => { } syncVisitedPages() - - if (response.data?.updated_page && flowDetails.value) { - const flowResponse = await apiClient.get( - API.onboarding.flows.byId(flowDetails.value.uuid), - ) - flowDetails.value = flowResponse.data - } - kaQuestion.value = '' } catch { message.error('Could not retrieve clarification right now') @@ -674,9 +718,40 @@ watch( + + + + + {{ getFieldMarking(field.key)?.reason }} +
@@ -730,60 +816,80 @@ watch( Missing required answers: {{ quizResult.missing_required_keys.join(', ') }} +
- + + @@ -972,6 +1078,37 @@ watch( background: #f8fafc; } +.field-label-inline { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} + +.field-marking-inline { + display: inline-flex; + align-items: center; + gap: 0.35rem; +} + +.field-marking-reason { + display: block; + margin-top: 0.4rem; + color: #4b5563; +} + +.answer-icon { + font-size: 1rem; +} + +.answer-icon.correct { + color: #16a34a; +} + +.answer-icon.incorrect { + color: #dc2626; +} + .ka-help-box { margin-top: 1rem; padding: 1rem; diff --git a/site/src/views/ProgressDetailView.vue b/site/src/views/ProgressDetailView.vue index 7e939e8..38e9cc5 100644 --- a/site/src/views/ProgressDetailView.vue +++ b/site/src/views/ProgressDetailView.vue @@ -4,6 +4,8 @@ import { useRoute, useRouter, onBeforeRouteLeave } from 'vue-router' import { Card, Typography, Button, Spin, Tag, List, message } from 'ant-design-vue' import { apiClient, API } from '../router/api' import type { ProgressSessionApi, FlowLookup } from '../types/onboarding' +import { Marked } from 'marked' +import DOMPurify from 'dompurify' const route = useRoute() const router = useRouter() @@ -22,7 +24,60 @@ const monitorLogs = ref([]) const monitorSocket = ref(null) const monitorTimeout = ref(null) +const marked = new Marked() + const latestSession = computed(() => sessions.value[0] || null) +const latestSessionCompleted = computed(() => latestSession.value?.status === 'completed') +const renderedFeedback = computed(() => { + const raw = String(feedback.value || '').trim() + if (!raw) { + return '

No feedback yet.

' + } + return DOMPurify.sanitize(marked.parse(raw) as string) +}) + +const getProgressPercent = (session: ProgressSessionApi | null) => { + const state = session?.state + if (!state || typeof state !== 'object') return 0 + + const rawProgress = (state as Record).progress_percentage ?? + (state as Record).progress + + const numeric = Number(rawProgress) + if (!Number.isFinite(numeric)) return 0 + const normalized = numeric <= 1 ? numeric * 100 : numeric + return Math.max(0, Math.min(100, Math.round(normalized))) +} + +const getCompletedModulesCount = (session: ProgressSessionApi | null) => { + const state = session?.state + if (!state || typeof state !== 'object') return 0 + const completedRaw = (state as Record).completed_modules + return Array.isArray(completedRaw) ? completedRaw.length : 0 +} + +const buildProgressReport = (session: ProgressSessionApi | null) => { + if (!session) { + return 'No onboarding session found for this learner and flow yet.' + } + + const status = String(session.status || 'not_started') + const progress = getProgressPercent(session) + const completedModules = getCompletedModulesCount(session) + const remaining = Math.max(0, 100 - progress) + + return [ + `**Progress Report (${roleName.value})**`, + '', + `1. **Current status:** ${status}`, + `2. **Progress:** ${progress}% complete (${remaining}% remaining)`, + `3. **Completed modules:** ${completedModules}`, + '', + '**Next actions:**', + '* Continue remaining onboarding modules.', + '* Re-run AI monitor feedback after the onboarding status becomes completed.', + ].join('\n') +} const websocketUrl = (id: string) => { const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws' @@ -41,6 +96,12 @@ const closeMonitorSocket = () => { } const runProgressMonitor = async () => { + if (!latestSessionCompleted.value) { + feedback.value = buildProgressReport(latestSession.value) + monitorLogs.value = ['AI monitor skipped: onboarding is not completed yet.'] + return + } + monitoring.value = true monitorLogs.value = [] @@ -175,7 +236,14 @@ const loadData = async () => { flowTitle.value = String(flowRes.data?.title || '') } - await runProgressMonitor() + feedback.value = buildProgressReport(latestSession.value) + monitorLogs.value = latestSessionCompleted.value + ? [] + : ['AI monitor skipped: onboarding is not completed yet.'] + + if (latestSessionCompleted.value) { + await runProgressMonitor() + } } catch { message.error('Failed to load role progress') } finally { @@ -225,16 +293,18 @@ onBeforeRouteLeave(() => { {{ latestSession?.status || 'not_started' }} Progress Monitor Feedback - + Monitor Activity { flex-wrap: wrap; } .feedback { - white-space: pre-wrap; color: #6b7280; } + +.markdown-body :deep(h1), +.markdown-body :deep(h2), +.markdown-body :deep(h3), +.markdown-body :deep(h4) { + color: #111827; + margin: 0.75rem 0 0.5rem; +} + +.markdown-body :deep(ul), +.markdown-body :deep(ol) { + margin: 0.5rem 0 0.75rem 1.25rem; +} + +.markdown-body :deep(p), +.markdown-body :deep(li) { + color: #4b5563; +}