Added changes for onboarding checking, content validation and progress monitoring pages, with tests and migration changes
This commit is contained in:
parent
0f57c5ed1e
commit
3361906784
10 changed files with 967 additions and 222 deletions
|
|
@ -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,10 +544,11 @@ class OnboardingConsumer(AsyncWebsocketConsumer):
|
|||
if option_text and option_text not in options:
|
||||
options.append(option_text)
|
||||
|
||||
validation = field.get('validation') if isinstance(field.get('validation'), dict) else {}
|
||||
if field_type == 'select':
|
||||
if len(options) < 2:
|
||||
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]
|
||||
|
|
@ -533,18 +564,61 @@ class OnboardingConsumer(AsyncWebsocketConsumer):
|
|||
'explanation': str(validation.get('explanation') or ''),
|
||||
},
|
||||
})
|
||||
continue
|
||||
|
||||
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': 'textarea' if field_type == 'textarea' else 'text',
|
||||
'options': [],
|
||||
'required': True,
|
||||
'validation': {
|
||||
'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
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
)
|
||||
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):
|
||||
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}
|
||||
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}
|
||||
|
|
@ -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')),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,6 +473,20 @@ class OnboardingApiTests(TestCase):
|
|||
self.session.save(update_fields=['state', 'updated_at'])
|
||||
|
||||
self.client.force_authenticate(self.member)
|
||||
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',
|
||||
|
|
@ -512,6 +531,20 @@ class OnboardingApiTests(TestCase):
|
|||
self.session.save(update_fields=['state', 'updated_at'])
|
||||
|
||||
self.client.force_authenticate(self.member)
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
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,
|
||||
defaults={
|
||||
'status': 'active',
|
||||
'state': {
|
||||
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': {},
|
||||
}
|
||||
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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>
|
||||
active_configs?: Record<string, unknown>
|
||||
completed_at?: string | null
|
||||
|
|
|
|||
|
|
@ -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<string[]>([])
|
||||
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<QuizResult | null>(null)
|
||||
const kaQuestion = ref('')
|
||||
const kaLoading = ref(false)
|
||||
const kaMode = ref<'separate' | 'update_page'>('separate')
|
||||
|
|
@ -70,7 +80,9 @@ const completedModules = computed<string[]>(() => {
|
|||
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 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<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 => {
|
||||
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<string, unknown>
|
||||
}>(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<OnboardingFlow>(
|
||||
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(
|
|||
<Form.Item
|
||||
v-for="(field, fieldIndex) in currentPage.fields"
|
||||
:key="field.uuid"
|
||||
:label="`${fieldIndex + 1}. ${field.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
|
||||
v-if="field.field_type === 'text'"
|
||||
v-model:value="formState[field.key]"
|
||||
|
|
@ -699,6 +774,17 @@ watch(
|
|||
v-else-if="field.field_type === 'boolean'"
|
||||
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>
|
||||
|
||||
<div class="form-actions">
|
||||
|
|
@ -730,12 +816,18 @@ watch(
|
|||
Missing required answers:
|
||||
{{ quizResult.missing_required_keys.join(', ') }}
|
||||
</Typography.Paragraph>
|
||||
|
||||
</div>
|
||||
|
||||
<template v-if="hasNext">
|
||||
<Divider dashed style="border-color: #dbe3ec" />
|
||||
|
||||
<div class="ka-help-box">
|
||||
<Typography.Title :level="5" class="white-text" style="margin-bottom: 8px">
|
||||
<Typography.Title
|
||||
:level="5"
|
||||
class="white-text"
|
||||
style="margin-bottom: 8px"
|
||||
>
|
||||
Need clarification?
|
||||
</Typography.Title>
|
||||
<Typography.Paragraph class="white-text" style="opacity: 0.8">
|
||||
|
|
@ -750,8 +842,14 @@ watch(
|
|||
<Select
|
||||
v-model:value="kaMode"
|
||||
:options="[
|
||||
{ label: 'Show separate answer below (will not save)', value: 'separate' },
|
||||
{ label: 'Update current page content', value: 'update_page' },
|
||||
{
|
||||
label: 'Show separate answer below (will not save)',
|
||||
value: 'separate',
|
||||
},
|
||||
{
|
||||
label: 'Update current page content',
|
||||
value: 'update_page',
|
||||
},
|
||||
]"
|
||||
style="min-width: 280px"
|
||||
/>
|
||||
|
|
@ -774,16 +872,24 @@ watch(
|
|||
<Typography.Text class="white-text" strong>
|
||||
You:
|
||||
</Typography.Text>
|
||||
<Typography.Paragraph class="white-text" style="opacity: 0.9; margin-bottom: 6px">
|
||||
<Typography.Paragraph
|
||||
class="white-text"
|
||||
style="opacity: 0.9; margin-bottom: 6px"
|
||||
>
|
||||
{{ entry.question }}
|
||||
</Typography.Paragraph>
|
||||
<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
|
||||
class="markdown-body"
|
||||
v-html="DOMPurify.sanitize(marked.parse(entry.answer) as string)"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
</Form>
|
||||
</section>
|
||||
</Card>
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<string[]>([])
|
|||
const monitorSocket = ref<WebSocket | null>(null)
|
||||
const monitorTimeout = ref<number | null>(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 '<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 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 || '')
|
||||
}
|
||||
|
||||
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' }}
|
||||
</Tag>
|
||||
<Button type="primary" :loading="monitoring" @click="runProgressMonitor">
|
||||
Refresh Monitor Feedback
|
||||
{{
|
||||
latestSessionCompleted
|
||||
? 'Refresh Monitor Feedback'
|
||||
: 'Refresh Progress Report'
|
||||
}}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Typography.Title :level="5" style="margin-top: 1.2rem">
|
||||
Progress Monitor Feedback
|
||||
</Typography.Title>
|
||||
<Typography.Paragraph class="feedback">
|
||||
{{ feedback || 'No feedback yet.' }}
|
||||
</Typography.Paragraph>
|
||||
<div class="feedback markdown-body" v-html="renderedFeedback"></div>
|
||||
|
||||
<Typography.Title :level="5">Monitor Activity</Typography.Title>
|
||||
<List
|
||||
|
|
@ -298,7 +368,24 @@ onBeforeRouteLeave(() => {
|
|||
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;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
Loading…
Reference in a new issue