Added changes for onboarding checking, content validation and progress monitoring pages, with tests and migration changes

This commit is contained in:
Viswamedha Nalabotu 2026-03-11 13:19:31 +00:00
parent 0f57c5ed1e
commit 3361906784
10 changed files with 967 additions and 222 deletions

View file

@ -22,14 +22,16 @@ class OnboardingConsumer(AsyncWebsocketConsumer):
self.context_uuid = self.scope["url_route"]["kwargs"].get("session_uuid") self.context_uuid = self.scope["url_route"]["kwargs"].get("session_uuid")
if not self.user.is_authenticated: if not self.user.is_authenticated:
logger.warning("WebSocket connect denied: unauthenticated user")
await self.close() await self.close()
return return
self.router = MCPRouter() self.router = MCPRouter()
logger.info("WebSocket connected: user_id=%s context_uuid=%s", self.user.id, self.context_uuid)
await self.accept() await self.accept()
async def disconnect(self, close_code): 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): def _build_system_prompt(self, config):
base_prompt = config.system_prompt or "You are a helpful onboarding assistant." base_prompt = config.system_prompt or "You are a helpful onboarding assistant."
@ -42,6 +44,7 @@ class OnboardingConsumer(AsyncWebsocketConsumer):
try: try:
data = json.loads(text_data) data = json.loads(text_data)
action = data.get("action") 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": if action == "start_full_onboarding":
role_uuid = data.get("role_uuid") role_uuid = data.get("role_uuid")
@ -96,7 +99,7 @@ class OnboardingConsumer(AsyncWebsocketConsumer):
} }
})) }))
except Exception as e: 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)}") await self.send_log("error", f"Consumer encountered an error: {str(e)}")
async def run_full_onboarding_generation(self, role_uuid): async def run_full_onboarding_generation(self, role_uuid):
@ -105,6 +108,7 @@ class OnboardingConsumer(AsyncWebsocketConsumer):
Pipeline: Curriculum Agent -> Knowledge Agent -> Assessment Agent 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") await self.send_log("status", "Phase 1: Generating Curriculum...", "curriculum")
ca_config = await self.get_config_by_type(role_uuid, 'curriculum') ca_config = await self.get_config_by_type(role_uuid, 'curriculum')
if not ca_config: if not ca_config:
@ -124,6 +128,7 @@ class OnboardingConsumer(AsyncWebsocketConsumer):
) )
topics = self._extract_json_list(ca_response) topics = self._extract_json_list(ca_response)
if not topics: 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") await self.send_log("error", "Curriculum generation returned no topics")
return return
@ -185,13 +190,22 @@ class OnboardingConsumer(AsyncWebsocketConsumer):
await self.send_log("error", "Missing assessment AgentConfig for this role") await self.send_log("error", "Missing assessment AgentConfig for this role")
return 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 = ( quiz_prompt = (
"Create a final onboarding quiz that assesses all generated modules. " "Create a final onboarding quiz that assesses all generated modules. "
"Output ONLY a valid JSON array of 8 multiple-choice question objects. " f"Output ONLY a valid JSON array of exactly {question_count} question objects. "
"Each object MUST include: 'key' (snake_case), 'label', 'field_type' ('select'), " "Use a mix of question types: at least 2 short-answer questions and at least 2 multiple-choice questions. "
"'options' (array of 4 unique strings), 'required' (true), and 'validation' with " "For multiple-choice objects: field_type='select', options (4 unique strings), and validation.correct_option. "
"'correct_option' (exactly matching one option) and 'explanation' (short rationale). " "For short-answer objects: field_type='textarea' (or 'text') and validation.accepted_answers (array of valid answers/keywords). "
"Cover all topics with balanced difficulty and avoid ambiguous choices.\n\n" "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)}" f"Modules JSON:\n{json.dumps(module_briefs, ensure_ascii=False)}"
) )
quiz_response = await self.orchestrate_ai( quiz_response = await self.orchestrate_ai(
@ -214,7 +228,7 @@ class OnboardingConsumer(AsyncWebsocketConsumer):
if not quiz_fields: if not quiz_fields:
await self.send_log("status", "Assessment output still invalid. Using fallback final quiz.", "assessment") 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({ full_structure.append({
"title": "Final Assessment Quiz", "title": "Final Assessment Quiz",
@ -233,6 +247,7 @@ class OnboardingConsumer(AsyncWebsocketConsumer):
await self.save_full_flow(role_uuid, full_structure) 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({ 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): 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") await self.send_log("status", "Progress Monitor is analyzing your onboarding progress...", "monitor")
monitor_config = await self.get_config_by_type(role_uuid, '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. " "You are a progress monitoring agent for onboarding. "
"Analyze the role onboarding data below and provide concise feedback with:\n" "Analyze the role onboarding data below and provide concise feedback with:\n"
"1) current status\n2) strengths\n3) gaps\n4) next actions\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" "Keep it short and practical.\n\n"
f"Progress context JSON:\n{json.dumps(progress_context)}" f"Progress context JSON:\n{json.dumps(progress_context)}"
) )
@ -292,6 +316,7 @@ class OnboardingConsumer(AsyncWebsocketConsumer):
"is_completed": progress_context.get("is_completed", False), "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( async def orchestrate_ai(
self, self,
@ -386,6 +411,7 @@ class OnboardingConsumer(AsyncWebsocketConsumer):
except Exception as e: except Exception as e:
await self.send_log("error", f"Inference failed: {str(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: if raise_on_error:
raise raise
return f"Error: {str(e)}" return f"Error: {str(e)}"
@ -507,6 +533,10 @@ class OnboardingConsumer(AsyncWebsocketConsumer):
if not label: if not label:
continue 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 [] raw_options = field.get('options') if isinstance(field.get('options'), list) else []
options = [] options = []
for option in raw_options: for option in raw_options:
@ -514,37 +544,81 @@ class OnboardingConsumer(AsyncWebsocketConsumer):
if option_text and option_text not in options: if option_text and option_text not in options:
options.append(option_text) 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 continue
validation = field.get('validation') if isinstance(field.get('validation'), dict) else {} accepted_answers_raw = validation.get('accepted_answers')
correct_option = str(validation.get('correct_option') or '').strip() if isinstance(accepted_answers_raw, list):
if correct_option not in options: accepted_answers = [str(item).strip() for item in accepted_answers_raw if str(item).strip()]
correct_option = options[0] 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({ sanitized.append({
'key': key, 'key': key,
'label': label, 'label': label,
'field_type': 'select', 'field_type': 'textarea' if field_type == 'textarea' else 'text',
'options': options[:5], 'options': [],
'required': True, 'required': True,
'validation': { 'validation': {
'correct_option': correct_option, 'accepted_answers': accepted_answers,
'explanation': str(validation.get('explanation') or ''), 'explanation': str(validation.get('explanation') or ''),
}, },
}) })
return sanitized 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()] safe_topics = [str(topic).strip() for topic in (topics or []) if str(topic).strip()]
if not safe_topics: if not safe_topics:
safe_topics = ['onboarding fundamentals'] safe_topics = ['onboarding fundamentals']
fallback_fields = [] fallback_fields = []
for index in range(8): for index in range(count):
topic = safe_topics[index % len(safe_topics)] topic = safe_topics[index % len(safe_topics)]
key = f'final_quiz_q_{index + 1}' 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}." correct = f"Use documented best practices for {topic}."
options = [ options = [
correct, correct,
@ -584,6 +658,11 @@ class OnboardingConsumer(AsyncWebsocketConsumer):
normalized_validation = { normalized_validation = {
'correct_option': correct_option if correct_option in options else None, '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 ''), 'explanation': str(validation.get('explanation') or ''),
} }
@ -628,6 +707,12 @@ class OnboardingConsumer(AsyncWebsocketConsumer):
return flow return flow
async def send_log(self, log_type, message, content=None): 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({ await self.send(json.dumps({
"type": log_type, "type": log_type,
"message": message, "message": message,
@ -702,7 +787,7 @@ class OnboardingConsumer(AsyncWebsocketConsumer):
sessions = OnboardingSession.objects.filter(user_id=user_id, role=role).order_by('-updated_at') sessions = OnboardingSession.objects.filter(user_id=user_id, role=role).order_by('-updated_at')
if flow_uuid: 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() latest_session = sessions.first()
@ -718,12 +803,77 @@ class OnboardingConsumer(AsyncWebsocketConsumer):
"responses_count": 0, "responses_count": 0,
"completed_modules": [], "completed_modules": [],
"is_completed": False, "is_completed": False,
"final_quiz_result": {},
"final_quiz_qa": [],
} }
state = latest_session.state or {} state = latest_session.state or {}
responses = state.get("responses", {}) responses = state.get("responses", {})
completed_modules = state.get("completed_modules", []) completed_modules = state.get("completed_modules", [])
progress = state.get("progress_percentage", state.get("progress", 0)) 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 { return {
"role_uuid": str(role.uuid), "role_uuid": str(role.uuid),
@ -731,12 +881,14 @@ class OnboardingConsumer(AsyncWebsocketConsumer):
"latest_status": latest_session.status, "latest_status": latest_session.status,
"session_count": sessions.count(), "session_count": sessions.count(),
"flow_exists": bool(scoped_flow or active_flow), "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, "progress": progress,
"responses_count": len(responses) if isinstance(responses, dict) else 0, "responses_count": len(responses) if isinstance(responses, dict) else 0,
"completed_modules": completed_modules if isinstance(completed_modules, list) else [], "completed_modules": completed_modules if isinstance(completed_modules, list) else [],
"updated_at": latest_session.updated_at.isoformat() if latest_session.updated_at else None, "updated_at": latest_session.updated_at.isoformat() if latest_session.updated_at else None,
"is_completed": latest_session.status == 'completed', "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 @database_sync_to_async

View file

@ -1,4 +1,7 @@
import httpx import httpx
import logging
import random
from channels.db import database_sync_to_async from channels.db import database_sync_to_async
from django.conf import settings from django.conf import settings
from pgvector.django import CosineDistance from pgvector.django import CosineDistance
@ -6,97 +9,186 @@ from pgvector.django import CosineDistance
from apps.knowledge.models import RoleRagDocument from apps.knowledge.models import RoleRagDocument
from apps.onboarding.models import OnboardingSession 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: class MCPRouter:
def get_tool_definitions(self): def get_tool_definitions(self):
return [ return self.tools
{
"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"]
}
}
]
async def handle_tool_call(self, name, arguments): async def handle_tool_call(self, name, arguments):
if name == "search_knowledge": logger.info('MCP tool call received: tool=%s args=%s', name, arguments)
return await self._search_knowledge(arguments) arguments = arguments or {}
elif name == "update_progress":
return await self._update_progress(arguments) method_name = self._tool_name_to_method.get(name)
return {"error": f"Tool {name} not found"} 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): async def _get_embedding(self, text):
logger.info('MCP embedding request started')
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
response = await client.post( response = await client.post(
settings.INFERENCE_EMBEDDINGS_ENDPOINT, settings.INFERENCE_EMBEDDINGS_ENDPOINT,
json={"input": text} json={'input': text},
) )
response.raise_for_status()
embedding = response.json()['data'][0]['embedding']
logger.info('MCP embedding request completed')
return embedding
return response.json()["data"][0]["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): async def _search_knowledge(self, args):
query = args.get("query") query = args.get('query')
role_uuid = args.get("role_uuid") role_uuid = args.get('role_uuid')
if not query or not role_uuid: if not query or not role_uuid:
logger.warning('MCP search_knowledge missing query or role_uuid')
return [] return []
query_vector = await self._get_embedding(query) query_vector = await self._get_embedding(query)
return await self._search_knowledge_documents(role_uuid, query_vector) return await self._search_knowledge_documents(role_uuid, query_vector)
@database_sync_to_async @database_sync_to_async
def _search_knowledge_documents(self, role_uuid, query_vector): def _search_knowledge_documents(self, role_uuid, query_vector):
docs = RoleRagDocument.objects.filter( docs = RoleRagDocument.objects.filter(
role__uuid=role_uuid, role__uuid=role_uuid,
is_active=True is_active=True,
).annotate( ).annotate(
distance=CosineDistance('embedding', query_vector) distance=CosineDistance('embedding', query_vector)
).order_by('distance')[:5] ).order_by('distance')[:5]
results = [
return [
{ {
"content": d.content, 'content': d.content,
"source": d.metadata.get("file_name", "Unknown Source"), 'source': d.metadata.get('file_name', 'Unknown Source'),
"relevance": round(1 - d.distance, 4) 'relevance': round(1 - d.distance, 4),
} }
for d in docs 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 @database_sync_to_async
def _update_progress(self, args): 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 {} state = session.state or {}
if "score" in args: if 'score' in args:
state["last_score"] = args["score"] state['last_score'] = args['score']
if "completed_module" in args: if 'completed_module' in args:
state.setdefault("completed_modules", []).append(args["completed_module"]) state.setdefault('completed_modules', []).append(args['completed_module'])
session.state = state session.state = state
session.save() session.save()
return {"status": "success", "new_state": state} 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}

View file

@ -62,6 +62,7 @@ class Migration(migrations.Migration):
('state', models.JSONField(blank=True, default=dict, verbose_name='Session State')), ('state', models.JSONField(blank=True, default=dict, verbose_name='Session State')),
('active_configs', models.JSONField(default=dict, verbose_name='Active Configs')), ('active_configs', models.JSONField(default=dict, verbose_name='Active Configs')),
('completed_at', models.DateTimeField(blank=True, null=True, verbose_name='Completed At')), ('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')), ('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')), ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='onboarding_sessions', to=settings.AUTH_USER_MODEL, verbose_name='User')),
], ],

View file

@ -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 django.utils.translation import gettext_lazy as _
from apps.accounts.mixins import IdentifierMixin, TimeStampMixin from apps.accounts.mixins import IdentifierMixin, TimeStampMixin
@ -28,6 +28,20 @@ class AgentConfig(IdentifierMixin, TimeStampMixin, Model):
def __str__(self): def __str__(self):
return f"{self.name} ({self.get_agent_type_display()})" 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): class OnboardingSession(IdentifierMixin, TimeStampMixin, Model):
STATUS_CHOICES = [ STATUS_CHOICES = [
('active', 'Active'), ('active', 'Active'),
@ -37,6 +51,7 @@ class OnboardingSession(IdentifierMixin, TimeStampMixin, Model):
user = ForeignKey(User, on_delete=CASCADE, related_name='onboarding_sessions', verbose_name=_("User")) 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")) 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")) status = CharField(max_length=20, choices=STATUS_CHOICES, default='active', verbose_name=_("Session Status"))
state = JSONField(default=dict, blank=True, verbose_name=_("Session State")) state = JSONField(default=dict, blank=True, verbose_name=_("Session State"))
@ -73,15 +88,3 @@ class AgentInteractionLog(IdentifierMixin, TimeStampMixin, Model):
def __str__(self): def __str__(self):
return f"{self.sender_type} in {self.session.uuid}" 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

View file

@ -30,13 +30,14 @@ class AgentInteractionLogSerializer(ModelSerializer):
class OnboardingSessionSerializer(ModelSerializer): class OnboardingSessionSerializer(ModelSerializer):
user = UserSerializer(read_only=True) user = UserSerializer(read_only=True)
role = RoleSerializer(read_only=True) role = RoleSerializer(read_only=True)
flow = SerializerMethodField()
logs = AgentInteractionLogSerializer(many=True, read_only=True) logs = AgentInteractionLogSerializer(many=True, read_only=True)
progress_percentage = SerializerMethodField() progress_percentage = SerializerMethodField()
class Meta: class Meta:
model = OnboardingSession model = OnboardingSession
fields = [ fields = [
'id', 'uuid', 'user', 'role', 'status', 'state', 'id', 'uuid', 'user', 'role', 'flow', 'status', 'state',
'active_configs', 'logs', 'completed_at', 'created_at', 'active_configs', 'logs', 'completed_at', 'created_at',
'updated_at', 'progress_percentage' 'updated_at', 'progress_percentage'
] ]
@ -45,6 +46,15 @@ class OnboardingSessionSerializer(ModelSerializer):
def get_progress_percentage(self, obj: OnboardingSession) -> int: def get_progress_percentage(self, obj: OnboardingSession) -> int:
return obj.state.get('progress_percentage', 0) 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): class OnboardingFlowSerializer(ModelSerializer):
role = RoleSerializer(read_only=True) role = RoleSerializer(read_only=True)
session_count = SerializerMethodField() session_count = SerializerMethodField()

View file

@ -428,12 +428,17 @@ class OnboardingApiTests(TestCase):
self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(response.status_code, HTTP_200_OK)
self.assertTrue(response.data['updated_page']) self.assertTrue(response.data['updated_page'])
self.assertEqual(response.data.get('revised_page_body'), revised_body)
self.flow.refresh_from_db() self.flow.refresh_from_db()
self.session.refresh_from_db()
page = self.flow.structure[0] 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 '')) 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): def test_onboarding_session_complete_blocks_when_quiz_score_below_pass_mark(self):
self.flow.structure = [ self.flow.structure = [
{ {
@ -468,10 +473,24 @@ class OnboardingApiTests(TestCase):
self.session.save(update_fields=['state', 'updated_at']) self.session.save(update_fields=['state', 'updated_at'])
self.client.force_authenticate(self.member) self.client.force_authenticate(self.member)
response = self.client.post( with patch.object(
f'/api/onboarding-session/{self.session.uuid}/complete/', OnboardingSessionViewSet,
format='json', '_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.status_code, HTTP_400_BAD_REQUEST)
self.assertEqual(response.data['quiz_result']['score_percentage'], 0) 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.session.save(update_fields=['state', 'updated_at'])
self.client.force_authenticate(self.member) 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.status_code, HTTP_200_OK)
self.assertEqual(response.data['quiz_result']['score_percentage'], 100) self.assertEqual(response.data['quiz_result']['score_percentage'], 100)
self.assertTrue(response.data['quiz_result']['passed']) self.assertTrue(response.data['quiz_result']['passed'])

View file

@ -1,4 +1,6 @@
import httpx import httpx
import json
import re
from django.conf import settings from django.conf import settings
from django.db import transaction from django.db import transaction
from django.db.models import Q from django.db.models import Q
@ -109,7 +111,7 @@ class OnboardingFlowViewSet(ModelViewSet):
def destroy(self, request, *args, **kwargs): def destroy(self, request, *args, **kwargs):
flow = self.get_object() flow = self.get_object()
with transaction.atomic(): with transaction.atomic():
OnboardingSession.objects.filter(role=flow.role).delete() OnboardingSession.objects.filter(flow=flow).delete()
self.perform_destroy(flow) self.perform_destroy(flow)
return Response(status=204) return Response(status=204)
@ -147,25 +149,40 @@ class OnboardingFlowViewSet(ModelViewSet):
status=HTTP_403_FORBIDDEN, status=HTTP_403_FORBIDDEN,
) )
session, created = OnboardingSession.objects.get_or_create( session = OnboardingSession.objects.filter(user=request.user, role=flow.role, flow=flow).first()
user=request.user, created = False
role=flow.role,
defaults={ if not session:
'status': 'active', # Backward compatibility for legacy sessions before flow FK existed.
'state': { legacy_session = OnboardingSession.objects.filter(
'progress': 0, user=request.user,
'current_step': 'intro', role=flow.role,
'flow_uuid': str(flow.uuid), flow__isnull=True,
}, ).order_by('-updated_at').first()
'active_configs': {},
} 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: if not created:
state = session.state or {} state = session.state if isinstance(session.state, dict) else {}
state['flow_uuid'] = str(flow.uuid) state['flow_uuid'] = str(flow.uuid)
session.flow = flow
session.state = state session.state = state
session.save(update_fields=['state', 'updated_at']) session.save(update_fields=['flow', 'state', 'updated_at'])
serializer = OnboardingSessionSerializer(session) serializer = OnboardingSessionSerializer(session)
return Response(serializer.data, status=HTTP_201_CREATED if created else HTTP_200_OK) 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, ''): if flow_uuid in (None, ''):
flow_uuid = self.request.data.get('flow_uuid') flow_uuid = self.request.data.get('flow_uuid')
if 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') status_value = self.request.query_params.get('status')
if status_value: if status_value:
@ -369,7 +386,7 @@ class OnboardingSessionViewSet(ModelViewSet):
latest_by_user_flow = {} latest_by_user_flow = {}
for session in role_sessions: for session in role_sessions:
state = session.state if isinstance(session.state, dict) else {} 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: if not session_flow_uuid:
continue continue
@ -434,6 +451,9 @@ class OnboardingSessionViewSet(ModelViewSet):
return True return True
def _get_flow_for_session(self, session): def _get_flow_for_session(self, session):
if session.flow_id:
return session.flow
state = session.state or {} state = session.state or {}
flow_uuid = state.get('flow_uuid') flow_uuid = state.get('flow_uuid')
@ -527,6 +547,217 @@ class OnboardingSessionViewSet(ModelViewSet):
agent_type='knowledge', agent_type='knowledge',
).order_by('-updated_at').first() ).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): def _run_ka_help(self, session, page_title, page_body, user_message):
config = self._get_knowledge_agent_config(session) config = self._get_knowledge_agent_config(session)
fallback = ( fallback = (
@ -627,27 +858,16 @@ class OnboardingSessionViewSet(ModelViewSet):
session.state = state session.state = state
session.save(update_fields=['state', 'updated_at']) session.save(update_fields=['state', 'updated_at'])
def _update_flow_page_body(self, session, page_uuid, new_body): def _save_session_page_override(self, session, page_uuid, new_body):
flow = self._get_flow_for_session(session) state = session.state if isinstance(session.state, dict) else {}
if not flow: overrides = state.get('page_overrides', {})
return False if not isinstance(overrides, dict):
overrides = {}
structure = flow.structure if isinstance(flow.structure, list) else [] overrides[str(page_uuid)] = str(new_body)
updated = False state['page_overrides'] = overrides
for page in structure: session.state = state
if not isinstance(page, dict): session.save(update_fields=['state', 'updated_at'])
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'])
return True return True
def _evaluate_final_quiz(self, session): def _evaluate_final_quiz(self, session):
@ -697,8 +917,6 @@ class OnboardingSessionViewSet(ModelViewSet):
required_count = 0 required_count = 0
missing_required_keys = [] missing_required_keys = []
gradable_count = 0
correct_count = 0
for field in quiz_fields: for field in quiz_fields:
if not isinstance(field, dict): if not isinstance(field, dict):
@ -716,18 +934,22 @@ class OnboardingSessionViewSet(ModelViewSet):
if not attempted: if not attempted:
missing_required_keys.append(key) missing_required_keys.append(key)
validation = field.get('validation') if isinstance(field.get('validation'), dict) else {} grading, grading_error = self._grade_final_quiz_with_assessment_agent(
correct_option = validation.get('correct_option') session,
if correct_option is None: quiz_fields,
continue page_responses,
pass_mark,
)
if grading_error:
return None, grading_error
gradable_count += 1 score_percentage = int(grading.get('score_percentage', 0))
if attempted and str(answer) == str(correct_option): correct_count = int(grading.get('correct_count', 0))
correct_count += 1 gradable_count = int(grading.get('gradable_count', 0))
score_percentage = int(round((correct_count / gradable_count) * 100)) if gradable_count else 0
passed = len(missing_required_keys) == 0 and gradable_count > 0 and score_percentage >= pass_mark 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 = { quiz_result = {
'page_uuid': page_uuid, 'page_uuid': page_uuid,
'pass_mark': pass_mark, 'pass_mark': pass_mark,
@ -737,6 +959,7 @@ class OnboardingSessionViewSet(ModelViewSet):
'correct_count': correct_count, 'correct_count': correct_count,
'score_percentage': score_percentage, 'score_percentage': score_percentage,
'passed': passed, 'passed': passed,
'grading_details': grading_details,
} }
state['final_quiz_result'] = quiz_result state['final_quiz_result'] = quiz_result
@ -818,10 +1041,11 @@ class OnboardingSessionViewSet(ModelViewSet):
page_body = str(page.get('body') or '') page_body = str(page.get('body') or '')
updated_page = False updated_page = False
assistant_message = '' assistant_message = ''
revised_body = None
if str(mode) == 'update_page': if str(mode) == 'update_page':
revised_body = self._run_ka_page_revision(session, page_title, page_body, str(user_message)) revised_body = self._run_ka_page_revision(session, page_title, page_body, str(user_message))
if revised_body: 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: if updated_page:
assistant_message = ( assistant_message = (
"Updated this page by integrating your clarification request into the core content. " "Updated this page by integrating your clarification request into the core content. "
@ -859,6 +1083,7 @@ class OnboardingSessionViewSet(ModelViewSet):
'status': 'ok', 'status': 'ok',
'answer': assistant_message, 'answer': assistant_message,
'updated_page': updated_page, 'updated_page': updated_page,
'revised_page_body': revised_body if str(mode) == 'update_page' else None,
'session_state': session.state, 'session_state': session.state,
}, status=HTTP_200_OK) }, status=HTTP_200_OK)

View file

@ -36,6 +36,11 @@ export type OnboardingSession = {
uuid: string uuid: string
status: OnboardingSessionStatus | string status: OnboardingSessionStatus | string
role?: string | UuidNameRef role?: string | UuidNameRef
flow?: {
uuid: string
title?: string
is_active?: boolean
} | null
state?: Record<string, unknown> state?: Record<string, unknown>
active_configs?: Record<string, unknown> active_configs?: Record<string, unknown>
completed_at?: string | null completed_at?: string | null

View file

@ -17,6 +17,7 @@ import {
Tag, Tag,
Popconfirm, Popconfirm,
} from 'ant-design-vue' } from 'ant-design-vue'
import { CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons-vue'
import { apiClient, API, isAxiosError } from '../router/api' import { apiClient, API, isAxiosError } from '../router/api'
import { useOnboardingAgentStore } from '../stores/onboardingAgentStore' import { useOnboardingAgentStore } from '../stores/onboardingAgentStore'
import type { import type {
@ -43,13 +44,22 @@ const isAutoGenerating = ref(false)
const generationHandled = ref(false) const generationHandled = ref(false)
const deletingFlow = ref(false) const deletingFlow = ref(false)
const visitedPageUuids = ref<string[]>([]) const visitedPageUuids = ref<string[]>([])
const quizResult = ref<{ type QuizGradingDetail = {
key?: string
correct?: boolean | string | number
reason?: string
}
type QuizResult = {
score_percentage: number score_percentage: number
pass_mark: number pass_mark: number
correct_count: number correct_count: number
gradable_count: number gradable_count: number
missing_required_keys?: string[] missing_required_keys?: string[]
} | null>(null) grading_details?: QuizGradingDetail[]
}
const quizResult = ref<QuizResult | null>(null)
const kaQuestion = ref('') const kaQuestion = ref('')
const kaLoading = ref(false) const kaLoading = ref(false)
const kaMode = ref<'separate' | 'update_page'>('separate') const kaMode = ref<'separate' | 'update_page'>('separate')
@ -70,7 +80,9 @@ const completedModules = computed<string[]>(() => {
return Array.isArray(raw) ? raw.map((item) => String(item)) : [] return Array.isArray(raw) ? raw.map((item) => String(item)) : []
}) })
const pageHelpByPage = computed<Record<string, Array<{ question: string; answer: string; timestamp: string }>>>(() => { const pageHelpByPage = computed<
Record<string, Array<{ question: string; answer: string; timestamp: string }>>
>(() => {
const state = (session.value as unknown as { state?: Record<string, unknown> } | null)?.state const state = (session.value as unknown as { state?: Record<string, unknown> } | null)?.state
const raw = state?.page_help const raw = state?.page_help
return raw && typeof raw === 'object' return raw && typeof raw === 'object'
@ -83,11 +95,56 @@ const currentPageHelp = computed(() => {
return pageHelpByPage.value[currentPage.value.uuid] || [] return pageHelpByPage.value[currentPage.value.uuid] || []
}) })
const renderedBody = computed(() => { const currentPageBody = computed(() => {
if (!currentPage.value?.body) return '' const baseBody = currentPage.value?.body || ''
return DOMPurify.sanitize(marked.parse(currentPage.value.body) as string) const sessionState = (session.value as unknown as { state?: Record<string, unknown> } | null)?.state
const overridesRaw = sessionState?.page_overrides
if (!currentPage.value || !overridesRaw || typeof overridesRaw !== 'object') {
return baseBody
}
const override = (overridesRaw as Record<string, unknown>)[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<string, { correct: boolean; reason: string }> = {}
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 => { const getFlowRoleUuid = (flowData: OnboardingFlowSummary): string | undefined => {
if (typeof flowData.role === 'string') return flowData.role if (typeof flowData.role === 'string') return flowData.role
return flowData.role?.uuid return flowData.role?.uuid
@ -463,13 +520,7 @@ const onSubmitPage = async () => {
const completeResponse = await apiClient.post<{ const completeResponse = await apiClient.post<{
message: string message: string
quiz_result?: { quiz_result?: QuizResult
score_percentage: number
pass_mark: number
correct_count: number
gradable_count: number
missing_required_keys?: string[]
}
}>(API.onboarding.sessions.complete(session.value.uuid)) }>(API.onboarding.sessions.complete(session.value.uuid))
if (completeResponse.data?.quiz_result) { if (completeResponse.data?.quiz_result) {
@ -479,7 +530,7 @@ const onSubmitPage = async () => {
message.success('Onboarding Finished!') message.success('Onboarding Finished!')
router.push('/organization') router.push('/organization')
} catch (error: unknown) { } 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 const data = error.response?.data
if (data?.quiz_result) { if (data?.quiz_result) {
quizResult.value = data.quiz_result quizResult.value = data.quiz_result
@ -502,6 +553,7 @@ const askKnowledgeAgent = async () => {
status: string status: string
answer: string answer: string
updated_page: boolean updated_page: boolean
revised_page_body?: string | null
session_state?: Record<string, unknown> session_state?: Record<string, unknown>
}>(API.onboarding.sessions.askKa(session.value.uuid), { }>(API.onboarding.sessions.askKa(session.value.uuid), {
page_uuid: currentPage.value.uuid, page_uuid: currentPage.value.uuid,
@ -515,14 +567,6 @@ const askKnowledgeAgent = async () => {
} }
syncVisitedPages() syncVisitedPages()
if (response.data?.updated_page && flowDetails.value) {
const flowResponse = await apiClient.get<OnboardingFlow>(
API.onboarding.flows.byId(flowDetails.value.uuid),
)
flowDetails.value = flowResponse.data
}
kaQuestion.value = '' kaQuestion.value = ''
} catch { } catch {
message.error('Could not retrieve clarification right now') message.error('Could not retrieve clarification right now')
@ -674,9 +718,40 @@ watch(
<Form.Item <Form.Item
v-for="(field, fieldIndex) in currentPage.fields" v-for="(field, fieldIndex) in currentPage.fields"
:key="field.uuid" :key="field.uuid"
:label="`${fieldIndex + 1}. ${field.label}`"
class="white-label" class="white-label"
> >
<template #label>
<span class="field-label-inline">
<span>{{ `${fieldIndex + 1}. ${field.label}` }}</span>
<span
v-if="getFieldMarking(field.key)"
class="field-marking-inline"
>
<CheckCircleOutlined
v-if="getFieldMarking(field.key)?.correct"
class="answer-icon correct"
/>
<CloseCircleOutlined
v-else
class="answer-icon incorrect"
/>
<Tag
:color="
getFieldMarking(field.key)?.correct
? 'green'
: 'red'
"
>
{{
getFieldMarking(field.key)?.correct
? 'Correct'
: 'Incorrect'
}}
</Tag>
</span>
</span>
</template>
<Input <Input
v-if="field.field_type === 'text'" v-if="field.field_type === 'text'"
v-model:value="formState[field.key]" v-model:value="formState[field.key]"
@ -699,6 +774,17 @@ watch(
v-else-if="field.field_type === 'boolean'" v-else-if="field.field_type === 'boolean'"
v-model:checked="formState[field.key]" v-model:checked="formState[field.key]"
/> />
<Typography.Text
v-if="
getFieldMarking(field.key) &&
!getFieldMarking(field.key)?.correct &&
getFieldMarking(field.key)?.reason
"
class="field-marking-reason"
>
{{ getFieldMarking(field.key)?.reason }}
</Typography.Text>
</Form.Item> </Form.Item>
<div class="form-actions"> <div class="form-actions">
@ -730,60 +816,80 @@ watch(
Missing required answers: Missing required answers:
{{ quizResult.missing_required_keys.join(', ') }} {{ quizResult.missing_required_keys.join(', ') }}
</Typography.Paragraph> </Typography.Paragraph>
</div> </div>
<Divider dashed style="border-color: #dbe3ec" /> <template v-if="hasNext">
<Divider dashed style="border-color: #dbe3ec" />
<div class="ka-help-box"> <div class="ka-help-box">
<Typography.Title :level="5" class="white-text" style="margin-bottom: 8px"> <Typography.Title
Need clarification? :level="5"
</Typography.Title> class="white-text"
<Typography.Paragraph class="white-text" style="opacity: 0.8"> style="margin-bottom: 8px"
Ask the Knowledge Agent to explain this page or refine the page content. >
</Typography.Paragraph> Need clarification?
<Input.TextArea </Typography.Title>
v-model:value="kaQuestion" <Typography.Paragraph class="white-text" style="opacity: 0.8">
:auto-size="{ minRows: 2, maxRows: 5 }" Ask the Knowledge Agent to explain this page or refine the page content.
placeholder="Ask what you dont understand about this module..." </Typography.Paragraph>
/> <Input.TextArea
<div class="ka-actions"> v-model:value="kaQuestion"
<Select :auto-size="{ minRows: 2, maxRows: 5 }"
v-model:value="kaMode" placeholder="Ask what you dont understand about this module..."
:options="[
{ label: 'Show separate answer below (will not save)', value: 'separate' },
{ label: 'Update current page content', value: 'update_page' },
]"
style="min-width: 280px"
/> />
<Button <div class="ka-actions">
type="default" <Select
:loading="kaLoading" v-model:value="kaMode"
:disabled="!kaQuestion.trim()" :options="[
@click="askKnowledgeAgent" {
> label: 'Show separate answer below (will not save)',
Ask KA value: 'separate',
</Button> },
</div> {
label: 'Update current page content',
value: 'update_page',
},
]"
style="min-width: 280px"
/>
<Button
type="default"
:loading="kaLoading"
:disabled="!kaQuestion.trim()"
@click="askKnowledgeAgent"
>
Ask KA
</Button>
</div>
<div v-if="currentPageHelp.length" class="ka-thread"> <div v-if="currentPageHelp.length" class="ka-thread">
<div <div
v-for="(entry, idx) in currentPageHelp" v-for="(entry, idx) in currentPageHelp"
:key="`${entry.timestamp}-${idx}`" :key="`${entry.timestamp}-${idx}`"
class="ka-thread-item" class="ka-thread-item"
> >
<Typography.Text class="white-text" strong> <Typography.Text class="white-text" strong>
You: You:
</Typography.Text> </Typography.Text>
<Typography.Paragraph class="white-text" style="opacity: 0.9; margin-bottom: 6px"> <Typography.Paragraph
{{ entry.question }} class="white-text"
</Typography.Paragraph> style="opacity: 0.9; margin-bottom: 6px"
<Typography.Text class="white-text" strong> >
KA: {{ entry.question }}
</Typography.Text> </Typography.Paragraph>
<div class="markdown-body" v-html="DOMPurify.sanitize(marked.parse(entry.answer) as string)"></div> <Typography.Text class="white-text" strong>
KA:
</Typography.Text>
<div
class="markdown-body"
v-html="DOMPurify.sanitize(marked.parse(entry.answer) as string)"
></div>
</div>
</div> </div>
</div> </div>
</div> </template>
</Form> </Form>
</section> </section>
</Card> </Card>
@ -972,6 +1078,37 @@ watch(
background: #f8fafc; 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 { .ka-help-box {
margin-top: 1rem; margin-top: 1rem;
padding: 1rem; padding: 1rem;

View file

@ -4,6 +4,8 @@ import { useRoute, useRouter, onBeforeRouteLeave } from 'vue-router'
import { Card, Typography, Button, Spin, Tag, List, message } from 'ant-design-vue' import { Card, Typography, Button, Spin, Tag, List, message } from 'ant-design-vue'
import { apiClient, API } from '../router/api' import { apiClient, API } from '../router/api'
import type { ProgressSessionApi, FlowLookup } from '../types/onboarding' import type { ProgressSessionApi, FlowLookup } from '../types/onboarding'
import { Marked } from 'marked'
import DOMPurify from 'dompurify'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@ -22,7 +24,60 @@ const monitorLogs = ref<string[]>([])
const monitorSocket = ref<WebSocket | null>(null) const monitorSocket = ref<WebSocket | null>(null)
const monitorTimeout = ref<number | null>(null) const monitorTimeout = ref<number | null>(null)
const marked = new Marked()
const latestSession = computed(() => sessions.value[0] || null) 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 '<p>No feedback yet.</p>'
}
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<string, unknown>).progress_percentage ??
(state as Record<string, unknown>).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<string, unknown>).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 websocketUrl = (id: string) => {
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws' const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws'
@ -41,6 +96,12 @@ const closeMonitorSocket = () => {
} }
const runProgressMonitor = async () => { 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 monitoring.value = true
monitorLogs.value = [] monitorLogs.value = []
@ -175,7 +236,14 @@ const loadData = async () => {
flowTitle.value = String(flowRes.data?.title || '') 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 { } catch {
message.error('Failed to load role progress') message.error('Failed to load role progress')
} finally { } finally {
@ -225,16 +293,18 @@ onBeforeRouteLeave(() => {
{{ latestSession?.status || 'not_started' }} {{ latestSession?.status || 'not_started' }}
</Tag> </Tag>
<Button type="primary" :loading="monitoring" @click="runProgressMonitor"> <Button type="primary" :loading="monitoring" @click="runProgressMonitor">
Refresh Monitor Feedback {{
latestSessionCompleted
? 'Refresh Monitor Feedback'
: 'Refresh Progress Report'
}}
</Button> </Button>
</div> </div>
<Typography.Title :level="5" style="margin-top: 1.2rem"> <Typography.Title :level="5" style="margin-top: 1.2rem">
Progress Monitor Feedback Progress Monitor Feedback
</Typography.Title> </Typography.Title>
<Typography.Paragraph class="feedback"> <div class="feedback markdown-body" v-html="renderedFeedback"></div>
{{ feedback || 'No feedback yet.' }}
</Typography.Paragraph>
<Typography.Title :level="5">Monitor Activity</Typography.Title> <Typography.Title :level="5">Monitor Activity</Typography.Title>
<List <List
@ -298,7 +368,24 @@ onBeforeRouteLeave(() => {
flex-wrap: wrap; flex-wrap: wrap;
} }
.feedback { .feedback {
white-space: pre-wrap;
color: #6b7280; 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;
}
</style> </style>