Added streaming and chunking with other fixes with KA consumer

This commit is contained in:
Viswamedha Nalabotu 2026-03-18 20:07:24 +00:00
parent d19c50cf77
commit e818991ae3
17 changed files with 644 additions and 414 deletions

View file

@ -1,4 +1,5 @@
from .base import * from .base import *
from .chat import * from .chat import *
from .generate import * from .generate import *
from .knowledge import *
from .progress import * from .progress import *

View file

@ -27,6 +27,7 @@ class LogType(Enum):
THOUGHT = "thought" # AI internal reasoning turns THOUGHT = "thought" # AI internal reasoning turns
TOOL_START = "tool_start" # When an MCP tool is called TOOL_START = "tool_start" # When an MCP tool is called
TOOL_RESULT = "tool_result" # When data comes back from a tool TOOL_RESULT = "tool_result" # When data comes back from a tool
STREAM_CHUNK = "stream_chunk" # Incremental token from a streaming LLM response
COMPLETED = "completed" # The final completion signal COMPLETED = "completed" # The final completion signal
class BaseOnboardingConsumer(AsyncWebsocketConsumer): class BaseOnboardingConsumer(AsyncWebsocketConsumer):
@ -135,6 +136,53 @@ class BaseOnboardingConsumer(AsyncWebsocketConsumer):
return f"Error: {str(e)}" return f"Error: {str(e)}"
return last_content return last_content
async def stream_llm(self, config, prompt: str, *, max_tokens: int = 1024, stop: list[str] | None = None, system_prompt_suffix: str | None = None) -> str | None:
"""Single-turn streaming LLM call. Sends STREAM_CHUNK events for each token and returns the full text."""
if not config:
return None
system_prompt = config.system_prompt or OnboardingPrompts.default_system_prompt()
if system_prompt_suffix:
system_prompt = system_prompt + "\n\n" + system_prompt_suffix
llm_config = config.llm_config if isinstance(config.llm_config, dict) else {}
payload: dict = {
"model": llm_config.get("model_id", "meta-llama-3.1-8b"),
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": prompt},
],
"max_tokens": max_tokens,
"stream": True,
}
if stop:
payload["stop"] = stop
try:
chunks: list[str] = []
async with httpx.AsyncClient(timeout=120.0) as client:
async with client.stream("POST", settings.INFERENCE_CHAT_COMPLETIONS_ENDPOINT, json=payload) as response:
response.raise_for_status()
async for line in response.aiter_lines():
if not line.startswith("data: "):
continue
data = line[6:].strip()
if data == "[DONE]":
break
try:
chunk_obj = json.loads(data)
choice = chunk_obj["choices"][0]
delta = choice.get("delta", {}).get("content", "")
if delta:
chunks.append(delta)
await self.send_log(LogType.STREAM_CHUNK, delta)
if choice.get("finish_reason") == "length":
self.logger.warning("LLM response truncated (finish_reason=length)")
await self.send_log(LogType.STATUS, "Response was cut off, try increasing Max Tokens.")
except Exception:
continue
return "".join(chunks).strip() or None
except Exception as e:
self.logger.exception("Streaming LLM call failed: %s", e)
return None
### Regular Helpers ### ### Regular Helpers ###
async def send_log(self, log_type: LogType, message: str, content: str | dict | None = None): async def send_log(self, log_type: LogType, message: str, content: str | dict | None = None):
if log_type == LogType.ERROR: if log_type == LogType.ERROR:

View file

@ -21,5 +21,10 @@ class OnboardingChatConsumer(BaseOnboardingConsumer):
if config is None: if config is None:
await self.send_error("Forbidden or Invalid Config UUID") await self.send_error("Forbidden or Invalid Config UUID")
return return
response = await self.orchestrate(user_query, config, max_tokens=max_tokens) response = await self.stream_llm(
await self.send_log(LogType.COMPLETED, "Inferenced complete.", {"response": response}) config,
user_query,
max_tokens=max_tokens or 1024,
system_prompt_suffix="Respond in plain text only. Do not use markdown formatting, bullet points, headers, bold, italics, or code blocks.",
)
await self.send_log(LogType.COMPLETED, "Inference complete.", {"response": response or ""})

View file

@ -54,7 +54,7 @@ class OnboardingGenerateConsumer(BaseOnboardingConsumer):
context_markdown = self.format_knowledge_context(knowledge_hits) context_markdown = self.format_knowledge_context(knowledge_hits)
ka_response = await self.orchestrate( ka_response = await self.orchestrate(
OnboardingPrompts.knowledge_generation_prompt(topic, context_markdown), OnboardingPrompts.knowledge_generation_prompt(topic, context_markdown),
ka_config, minimum_turns=2, max_tokens=2400 ka_config, minimum_turns=2, max_tokens=3500
) )
full_structure.append({ full_structure.append({
"title": topic, "title": topic,

View file

@ -0,0 +1,213 @@
import json
import re
import httpx
from channels.db import database_sync_to_async
from django.conf import settings
from django.utils import timezone
from apps.onboarding.consumers.base import BaseOnboardingConsumer, LogType
from apps.onboarding.consumers.prompts import OnboardingPrompts
from apps.onboarding.models import AgentInteractionLog, OnboardingSession
__all__ = ['OnboardingKnowledgeConsumer']
class OnboardingKnowledgeConsumer(BaseOnboardingConsumer):
"""
Route: /ws/onboarding/knowledge/<uuid:session_uuid>/
"""
session_uuid: str
def parse_extra(self):
self.session_uuid = self.scope['url_route']['kwargs'].get('session_uuid')
async def action_ask(self, data: dict):
page_uuid = data.get('page_uuid')
user_message = data.get('message')
mode = str(data.get('mode', 'separate'))
if not page_uuid or not user_message:
return await self.send_error('page_uuid and message are required.')
session = await self.get_session(self.session_uuid, self.user.id)
if not session:
return await self.send_error('Session not found or access denied.')
if not session.flow:
return await self.send_error('Onboarding flow not found.')
page = self._get_page(session.flow, str(page_uuid))
if not isinstance(page, dict):
return await self.send_error('Page not found in this flow.')
page_title = str(page.get('title') or 'Onboarding Page')
page_body = str(page.get('body') or '')
role_name = session.role.name
role_uuid = str(session.role.uuid)
config = await self.get_config_by_type(role_uuid, 'knowledge')
updated_page = False
revised_body = None
assistant_message = ''
if mode == 'update_page':
await self.send_log(LogType.STATUS, 'Revising page content...')
revised_body = await self._call_llm(
config,
OnboardingPrompts.ka_page_revision_prompt(role_name, page_title, page_body, str(user_message)),
max_tokens=3000,
stop=['\n[END]', '[END]'],
)
if revised_body:
await self.save_page_override(session, str(page_uuid), revised_body)
updated_page = True
assistant_message = (
'Updated this page by integrating your clarification request into the core content. '
'Please review the revised page text above.'
)
if not assistant_message:
await self.send_log(LogType.STATUS, 'Thinking...')
if config:
assistant_message = await self._call_llm(
config,
OnboardingPrompts.ka_help_prompt(role_name, page_title, page_body, str(user_message)),
max_tokens=1024,
) or OnboardingPrompts.KA_HELP_FALLBACK
else:
assistant_message = OnboardingPrompts.KA_HELP_FALLBACK
await self.save_page_help(session, str(page_uuid), str(user_message), assistant_message)
await self.log_interaction(session, str(user_message), assistant_message, str(page_uuid), mode, updated_page)
await self.send_log(LogType.COMPLETED, assistant_message, {
'updated_page': updated_page,
'revised_page_body': revised_body if mode == 'update_page' else None,
})
async def _call_llm(
self,
config,
prompt: str,
*,
max_tokens: int = 1024,
stop: list[str] | None = None,
) -> str | None:
if not config:
return None
system_prompt = config.system_prompt or OnboardingPrompts.FALLBACK_SYSTEM_PROMPT
llm_config = config.llm_config if isinstance(config.llm_config, dict) else {}
payload: dict = {
'model': llm_config.get('model_id', 'meta-llama-3.1-8b'),
'messages': [
{'role': 'system', 'content': system_prompt},
{'role': 'user', 'content': prompt},
],
'max_tokens': max_tokens,
'stream': True,
}
if stop:
payload['stop'] = stop
try:
chunks: list[str] = []
async with httpx.AsyncClient(timeout=120.0) as client:
async with client.stream('POST', settings.INFERENCE_CHAT_COMPLETIONS_ENDPOINT, json=payload) as response:
response.raise_for_status()
async for line in response.aiter_lines():
if not line.startswith('data: '):
continue
data = line[6:].strip()
if data == '[DONE]':
break
try:
chunk_obj = json.loads(data)
choice = chunk_obj['choices'][0]
delta = choice.get('delta', {}).get('content', '')
if delta:
chunks.append(delta)
await self.send_log(LogType.STREAM_CHUNK, delta)
if choice.get('finish_reason') == 'length':
self.logger.warning('Knowledge LLM response truncated (finish_reason=length)')
except Exception:
continue
result = ''.join(chunks).strip()
result = re.sub(r'\n?\[END\]\s*$', '', result).strip()
return result or None
except Exception as e:
self.logger.exception('Knowledge LLM call failed: %s', e)
return None
def _get_page(self, flow, page_uuid: str) -> dict | None:
pages = flow.structure if isinstance(flow.structure, list) else []
return next(
(p for p in pages if isinstance(p, dict) and str(p.get('uuid')) == page_uuid),
None,
)
@database_sync_to_async
def get_session(self, session_uuid: str, user_id: int):
return (
OnboardingSession.objects
.select_related('flow', 'role')
.filter(uuid=session_uuid, user_id=user_id)
.first()
)
@database_sync_to_async
def save_page_help(self, session, page_uuid: str, user_message: str, assistant_message: str):
state = session.state or {}
page_help = state.get('page_help', {})
if not isinstance(page_help, dict):
page_help = {}
thread = page_help.get(page_uuid, [])
if not isinstance(thread, list):
thread = []
thread.append({
'question': user_message,
'answer': assistant_message,
'timestamp': timezone.now().isoformat(),
})
page_help[page_uuid] = thread[-20:]
state['page_help'] = page_help
session.state = state
session.save(update_fields=['state', 'updated_at'])
@database_sync_to_async
def save_page_override(self, session, page_uuid: str, new_body: str):
state = session.state if isinstance(session.state, dict) else {}
overrides = state.get('page_overrides', {})
if not isinstance(overrides, dict):
overrides = {}
overrides[page_uuid] = new_body
state['page_overrides'] = overrides
session.state = state
session.save(update_fields=['state', 'updated_at'])
@database_sync_to_async
def log_interaction(
self, session, user_message: str, assistant_message: str,
page_uuid: str, mode: str, updated_page: bool,
):
AgentInteractionLog.objects.create(
session=session,
sender_type='user',
content=user_message,
tool_call_metadata={'action': 'ask_ka', 'page_uuid': page_uuid, 'mode': mode},
)
AgentInteractionLog.objects.create(
session=session,
sender_type='ai',
content=assistant_message,
tool_call_metadata={
'action': 'ask_ka_response',
'page_uuid': page_uuid,
'mode': mode,
'updated_page': updated_page,
},
)

View file

@ -47,12 +47,10 @@ class OnboardingProgressConsumer(BaseOnboardingConsumer):
flow_uuid=self.flow_uuid, flow_uuid=self.flow_uuid,
) )
feedback = await self.orchestrate( feedback = await self.stream_llm(
OnboardingPrompts.progress_monitoring_prompt(progress_context),
monitor_config, monitor_config,
minimum_turns=1, OnboardingPrompts.progress_monitoring_prompt(progress_context),
max_tokens=640, max_tokens=640,
raise_on_error=True
) )
await self.send_log(LogType.COMPLETED, "Progress analysis complete.", { await self.send_log(LogType.COMPLETED, "Progress analysis complete.", {

View file

@ -1,6 +1,6 @@
import json import json
__all__ = ["OnboardingPrompts"] __all__ = ['OnboardingPrompts']
class OnboardingPrompts: class OnboardingPrompts:
@ -69,3 +69,52 @@ class OnboardingPrompts:
"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)}"
) )
FALLBACK_SYSTEM_PROMPT = 'You are a helpful onboarding assistant.'
KA_HELP_FALLBACK = (
"I couldn't reach the knowledge model right now. "
"Please try again, or clarify which part of this module is confusing and I can provide a shorter explanation."
)
@staticmethod
def grading_prompt(ai_fields, page_responses):
return (
'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)}"
)
@staticmethod
def ka_help_prompt(role_name, page_title, page_body, user_message):
return (
"Help the learner understand this onboarding page. Keep the explanation concise and practical. "
"Use markdown with bullets when useful.\n\n"
f"Role: {role_name}\n"
f"Page Title: {page_title}\n"
f"Page Body (excerpt): {str(page_body)[:2000]}\n"
f"Learner question: {user_message}"
)
@staticmethod
def ka_page_revision_prompt(role_name, page_title, page_body, user_message):
return (
"Revise the onboarding page content by integrating the learner's clarification request directly into the main page text. "
"Use the current page as the source of truth, preserve useful structure, and improve clarity and examples where needed. "
"Do not append a separate 'Clarification' section. "
"Return ONLY the fully revised markdown page body. "
"When you have finished the revision, write [END] on its own line and stop.\n\n"
f"Role: {role_name}\n"
f"Page Title: {page_title}\n"
f"Learner clarification request: {user_message}\n\n"
f"Current page markdown:\n{str(page_body)[:12000]}"
)

10
apps/onboarding/mixins.py Normal file
View file

@ -0,0 +1,10 @@
__all__ = ['RequestParamMixin']
class RequestParamMixin:
"""Resolve a named parameter from the query string, falling back to the request body."""
def _get_param(self, name: str) -> str | None:
value = self.request.query_params.get(name)
if not value:
value = self.request.data.get(name)
return value or None

View file

@ -1,9 +1,15 @@
from django.urls import path from django.urls import path
from apps.onboarding.consumers import OnboardingChatConsumer, OnboardingGenerateConsumer, OnboardingProgressConsumer from apps.onboarding.consumers import (
OnboardingChatConsumer,
OnboardingGenerateConsumer,
OnboardingKnowledgeConsumer,
OnboardingProgressConsumer,
)
websocket_urlpatterns = [ websocket_urlpatterns = [
path("ws/onboarding/chat/<uuid:config_uuid>/", OnboardingChatConsumer.as_asgi()), path("ws/onboarding/chat/<uuid:config_uuid>/", OnboardingChatConsumer.as_asgi()),
path("ws/onboarding/generate/<uuid:role_uuid>/", OnboardingGenerateConsumer.as_asgi()), path("ws/onboarding/generate/<uuid:role_uuid>/", OnboardingGenerateConsumer.as_asgi()),
path("ws/onboarding/knowledge/<uuid:session_uuid>/", OnboardingKnowledgeConsumer.as_asgi()),
path("ws/onboarding/progress/<uuid:role_uuid>/<uuid:flow_uuid>/<uuid:user_uuid>/", OnboardingProgressConsumer.as_asgi()), path("ws/onboarding/progress/<uuid:role_uuid>/<uuid:flow_uuid>/<uuid:user_uuid>/", OnboardingProgressConsumer.as_asgi()),
] ]

View file

@ -1,6 +1,7 @@
import httpx import httpx
import json import json
import re 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
@ -9,16 +10,28 @@ from rest_framework.decorators import action
from rest_framework.exceptions import NotFound, PermissionDenied, ValidationError from rest_framework.exceptions import NotFound, PermissionDenied, ValidationError
from rest_framework.permissions import IsAuthenticated from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.status import HTTP_200_OK, HTTP_201_CREATED, HTTP_400_BAD_REQUEST, HTTP_403_FORBIDDEN from rest_framework.status import (
HTTP_200_OK,
HTTP_201_CREATED,
HTTP_400_BAD_REQUEST,
HTTP_403_FORBIDDEN,
)
from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet
from apps.accounts.models import Organization, Role, User from apps.accounts.models import Organization, Role
from apps.accounts.permissions import CanManageOrganization, can_manage_organization from apps.accounts.permissions import CanManageOrganization, can_manage_organization
from apps.onboarding.consumers.prompts import OnboardingPrompts
from apps.onboarding.mixins import RequestParamMixin
from apps.onboarding.models import AgentConfig, AgentInteractionLog, OnboardingFlow, OnboardingSession from apps.onboarding.models import AgentConfig, AgentInteractionLog, OnboardingFlow, OnboardingSession
from apps.onboarding.serializers import AgentConfigSerializer, AgentInteractionLogSerializer, OnboardingFlowSerializer, OnboardingSessionSerializer from apps.onboarding.serializers import (
AgentConfigSerializer,
AgentInteractionLogSerializer,
OnboardingFlowSerializer,
OnboardingSessionSerializer,
)
class OnboardingFlowViewSet(ModelViewSet): class OnboardingFlowViewSet(RequestParamMixin, ModelViewSet):
queryset = OnboardingFlow.objects.all() queryset = OnboardingFlow.objects.all()
serializer_class = OnboardingFlowSerializer serializer_class = OnboardingFlowSerializer
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]
@ -37,16 +50,10 @@ class OnboardingFlowViewSet(ModelViewSet):
Q(role__organization__members=user) Q(role__organization__members=user)
).distinct().order_by('-created_at') ).distinct().order_by('-created_at')
organization_uuid = self.request.query_params.get('organization_uuid') if organization_uuid := self._get_param('organization_uuid'):
if organization_uuid in (None, ''):
organization_uuid = self.request.data.get('organization_uuid')
if organization_uuid:
queryset = queryset.filter(role__organization__uuid=organization_uuid) queryset = queryset.filter(role__organization__uuid=organization_uuid)
role_uuid = self.request.query_params.get('role_uuid') if role_uuid := self._get_param('role_uuid'):
if role_uuid in (None, ''):
role_uuid = self.request.data.get('role_uuid')
if role_uuid:
queryset = queryset.filter(role__uuid=role_uuid) queryset = queryset.filter(role__uuid=role_uuid)
return queryset return queryset
@ -150,19 +157,8 @@ class OnboardingFlowViewSet(ModelViewSet):
) )
session = OnboardingSession.objects.filter(user=request.user, role=flow.role, flow=flow).first() session = OnboardingSession.objects.filter(user=request.user, role=flow.role, flow=flow).first()
created = False
if not session: if not session:
# Backward compatibility for legacy sessions before flow FK existed.
legacy_session = OnboardingSession.objects.filter(
user=request.user,
role=flow.role,
flow__isnull=True,
).order_by('-updated_at').first()
if legacy_session:
session = legacy_session
else:
session = OnboardingSession.objects.create( session = OnboardingSession.objects.create(
user=request.user, user=request.user,
role=flow.role, role=flow.role,
@ -171,23 +167,17 @@ class OnboardingFlowViewSet(ModelViewSet):
state={ state={
'progress': 0, 'progress': 0,
'current_step': 'intro', 'current_step': 'intro',
'flow_uuid': str(flow.uuid),
}, },
active_configs={}, active_configs={},
) )
created = True serializer = OnboardingSessionSerializer(session)
return Response(serializer.data, status=HTTP_201_CREATED)
if not created:
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=['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_200_OK)
class AgentConfigViewSet(ModelViewSet):
class AgentConfigViewSet(RequestParamMixin, ModelViewSet):
queryset = AgentConfig.objects.all() queryset = AgentConfig.objects.all()
serializer_class = AgentConfigSerializer serializer_class = AgentConfigSerializer
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]
@ -204,24 +194,16 @@ class AgentConfigViewSet(ModelViewSet):
Q(organization__owner=self.request.user) | Q(organization__members=self.request.user) Q(organization__owner=self.request.user) | Q(organization__members=self.request.user)
).distinct().order_by('-updated_at') ).distinct().order_by('-updated_at')
organization_uuid = self.request.query_params.get('organization_uuid') if organization_uuid := self._get_param('organization_uuid'):
if organization_uuid in (None, ''):
organization_uuid = self.request.data.get('organization_uuid')
if organization_uuid:
queryset = queryset.filter(organization__uuid=organization_uuid) queryset = queryset.filter(organization__uuid=organization_uuid)
role_uuid = self.request.query_params.get('role_uuid') if role_uuid := self._get_param('role_uuid'):
if role_uuid in (None, ''):
role_uuid = self.request.data.get('role_uuid')
if role_uuid:
queryset = queryset.filter(role__uuid=role_uuid) queryset = queryset.filter(role__uuid=role_uuid)
return queryset return queryset
def create(self, request, *args, **kwargs): def create(self, request, *args, **kwargs):
organization_uuid = request.query_params.get('organization_uuid') organization_uuid = self._get_param('organization_uuid')
if organization_uuid in (None, ''):
organization_uuid = request.data.get('organization_uuid')
if not organization_uuid: if not organization_uuid:
raise ValidationError({'organization_uuid': 'organization_uuid is required.'}) raise ValidationError({'organization_uuid': 'organization_uuid is required.'})
@ -261,43 +243,21 @@ class AgentConfigViewSet(ModelViewSet):
return Response(serializer.data, status=HTTP_201_CREATED) return Response(serializer.data, status=HTTP_201_CREATED)
def update(self, request, *args, **kwargs): def update(self, request, *args, **kwargs):
config = self.get_object() return self.partial_update(request, *args, **kwargs)
updatable_fields = {
'name': request.data.get('name'),
'agent_type': request.data.get('agent_type'),
'system_prompt': request.data.get('system_prompt'),
'llm_config': request.data.get('llm_config'),
}
for field, value in updatable_fields.items():
if value is not None:
setattr(config, field, value)
config.save(update_fields=['name', 'agent_type', 'system_prompt', 'llm_config', 'updated_at'])
serializer = self.get_serializer(config)
return Response(serializer.data, status=HTTP_200_OK)
def partial_update(self, request, *args, **kwargs): def partial_update(self, request, *args, **kwargs):
config = self.get_object() config = self.get_object()
fields = ['name', 'agent_type', 'system_prompt', 'llm_config']
for field in fields:
if field in request.data and request.data.get(field) is not None:
setattr(config, field, request.data[field])
if 'name' in request.data: config.save(update_fields=fields + ['updated_at'])
config.name = request.data.get('name')
if 'agent_type' in request.data:
config.agent_type = request.data.get('agent_type')
if 'system_prompt' in request.data:
config.system_prompt = request.data.get('system_prompt')
if 'llm_config' in request.data:
config.llm_config = request.data.get('llm_config')
config.save(update_fields=['name', 'agent_type', 'system_prompt', 'llm_config', 'updated_at'])
serializer = self.get_serializer(config) serializer = self.get_serializer(config)
return Response(serializer.data, status=HTTP_200_OK) return Response(serializer.data, status=HTTP_200_OK)
def destroy(self, request, *args, **kwargs):
return super().destroy(request, *args, **kwargs)
class OnboardingSessionViewSet(ModelViewSet): class OnboardingSessionViewSet(RequestParamMixin, ModelViewSet):
queryset = OnboardingSession.objects.all() queryset = OnboardingSession.objects.all()
serializer_class = OnboardingSessionSerializer serializer_class = OnboardingSessionSerializer
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]
@ -310,34 +270,21 @@ class OnboardingSessionViewSet(ModelViewSet):
else: else:
queryset = OnboardingSession.objects.filter(user=user) queryset = OnboardingSession.objects.filter(user=user)
organization_uuid = self.request.query_params.get('organization_uuid') if organization_uuid := self._get_param('organization_uuid'):
if organization_uuid in (None, ''):
organization_uuid = self.request.data.get('organization_uuid')
if organization_uuid:
queryset = queryset.filter(role__organization__uuid=organization_uuid) queryset = queryset.filter(role__organization__uuid=organization_uuid)
role_uuid = self.request.query_params.get('role_uuid') if role_uuid := self._get_param('role_uuid'):
if role_uuid in (None, ''):
role_uuid = self.request.data.get('role_uuid')
if role_uuid:
queryset = queryset.filter(role__uuid=role_uuid) queryset = queryset.filter(role__uuid=role_uuid)
user_uuid = self.request.query_params.get('user_uuid') if user_uuid := self._get_param('user_uuid'):
if user_uuid in (None, ''):
user_uuid = self.request.data.get('user_uuid')
if user_uuid:
if not user.is_manager and str(user.uuid) != str(user_uuid): if not user.is_manager and str(user.uuid) != str(user_uuid):
raise PermissionDenied('You can only view your own progress sessions.') raise PermissionDenied('You can only view your own progress sessions.')
queryset = queryset.filter(user__uuid=user_uuid) queryset = queryset.filter(user__uuid=user_uuid)
flow_uuid = self.request.query_params.get('flow_uuid') if flow_uuid := self._get_param('flow_uuid'):
if flow_uuid in (None, ''): queryset = queryset.filter(flow__uuid=flow_uuid)
flow_uuid = self.request.data.get('flow_uuid')
if 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 := self.request.query_params.get('status'):
if status_value:
queryset = queryset.filter(status=status_value) queryset = queryset.filter(status=status_value)
return queryset.order_by('-created_at') return queryset.order_by('-created_at')
@ -381,12 +328,10 @@ 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 {} if not session.flow_id:
session_flow_uuid = str(session.flow.uuid) if session.flow_id else str(state.get('flow_uuid') or '')
if not session_flow_uuid:
continue continue
key = (session.user_id, session_flow_uuid) key = (session.user_id, str(session.flow.uuid))
if key not in latest_by_user_flow: if key not in latest_by_user_flow:
latest_by_user_flow[key] = session latest_by_user_flow[key] = session
@ -443,25 +388,12 @@ class OnboardingSessionViewSet(ModelViewSet):
if isinstance(value, str): if isinstance(value, str):
return bool(value.strip()) return bool(value.strip())
if isinstance(value, (list, dict, tuple, set)): if isinstance(value, (list, dict, tuple, set)):
return len(value) > 0 return bool(value)
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 return session.flow
state = session.state or {}
flow_uuid = state.get('flow_uuid')
flow = None
if flow_uuid:
flow = OnboardingFlow.objects.filter(uuid=flow_uuid, role=session.role).first()
if not flow:
flow = OnboardingFlow.objects.filter(role=session.role, is_active=True).order_by('-updated_at').first()
return flow
def _get_page_from_flow(self, flow, page_uuid): def _get_page_from_flow(self, flow, page_uuid):
pages = flow.structure if isinstance(flow.structure, list) else [] pages = flow.structure if isinstance(flow.structure, list) else []
page = next( page = next(
@ -521,15 +453,12 @@ class OnboardingSessionViewSet(ModelViewSet):
session.save(update_fields=['state', 'updated_at']) session.save(update_fields=['state', 'updated_at'])
def _build_system_prompt(self, config): def _build_system_prompt(self, config):
if not config: return (config and config.system_prompt) or OnboardingPrompts.FALLBACK_SYSTEM_PROMPT
return "You are a helpful onboarding assistant."
base_prompt = config.system_prompt or "You are a helpful onboarding assistant."
return base_prompt
def _get_knowledge_agent_config(self, session): def _get_agent_config(self, session, agent_type):
role_specific = AgentConfig.objects.filter( role_specific = AgentConfig.objects.filter(
role=session.role, role=session.role,
agent_type='knowledge', agent_type=agent_type,
).order_by('-updated_at').first() ).order_by('-updated_at').first()
if role_specific: if role_specific:
return role_specific return role_specific
@ -537,21 +466,7 @@ class OnboardingSessionViewSet(ModelViewSet):
return AgentConfig.objects.filter( return AgentConfig.objects.filter(
organization=session.role.organization, organization=session.role.organization,
role__isnull=True, role__isnull=True,
agent_type='knowledge', agent_type=agent_type,
).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() ).order_by('-updated_at').first()
def _extract_json_object(self, text): def _extract_json_object(self, text):
@ -627,24 +542,11 @@ class OnboardingSessionViewSet(ModelViewSet):
ai_per_question = [] ai_per_question = []
if ai_fields: if ai_fields:
config = self._get_assessment_agent_config(session) or self._get_knowledge_agent_config(session) config = self._get_agent_config(session, 'assessment') or self._get_agent_config(session, 'knowledge')
if not config: if not config:
return None, {'error': 'No assessment/knowledge agent configured for grading.'} return None, {'error': 'No assessment/knowledge agent configured for grading.'}
prompt = ( prompt = OnboardingPrompts.grading_prompt(ai_fields, page_responses)
'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: try:
with httpx.Client(timeout=60.0) as client: with httpx.Client(timeout=60.0) as client:
@ -751,118 +653,6 @@ class OnboardingSessionViewSet(ModelViewSet):
return sanitized return sanitized
def _run_ka_help(self, session, page_title, page_body, user_message):
config = self._get_knowledge_agent_config(session)
fallback = (
"I couldn't reach the knowledge model right now. "
"Please try again, or clarify which part of this module is confusing and I can provide a shorter explanation."
)
if not config:
return fallback
prompt = (
"Help the learner understand this onboarding page. Keep the explanation concise and practical. "
"Use markdown with bullets when useful.\n\n"
f"Role: {session.role.name}\n"
f"Page Title: {page_title}\n"
f"Page Body (excerpt): {str(page_body)[:2000]}\n"
f"Learner question: {user_message}"
)
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},
],
},
)
response.raise_for_status()
res_json = response.json()
content = res_json.get('choices', [{}])[0].get('message', {}).get('content')
if isinstance(content, str) and content.strip():
return content.strip()
except Exception:
pass
return fallback
def _run_ka_page_revision(self, session, page_title, page_body, user_message):
config = self._get_knowledge_agent_config(session)
if not config:
return None
prompt = (
"Revise the onboarding page content by integrating the learner's clarification request directly into the main page text. "
"Use the current page as the source of truth, preserve useful structure, and improve clarity and examples where needed. "
"Do not append a separate 'Clarification' section. Return ONLY the fully revised markdown page body.\n\n"
f"Role: {session.role.name}\n"
f"Page Title: {page_title}\n"
f"Learner clarification request: {user_message}\n\n"
f"Current page markdown:\n{str(page_body)[:12000]}"
)
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},
],
},
)
response.raise_for_status()
res_json = response.json()
content = res_json.get('choices', [{}])[0].get('message', {}).get('content')
revised = str(content or '').strip()
if revised:
return revised
except Exception:
pass
return None
def _append_page_help(self, session, page_uuid, user_message, assistant_message):
state = session.state or {}
page_help = state.get('page_help', {})
if not isinstance(page_help, dict):
page_help = {}
thread = page_help.get(str(page_uuid), [])
if not isinstance(thread, list):
thread = []
thread.append({
'question': str(user_message),
'answer': str(assistant_message),
'timestamp': timezone.now().isoformat(),
})
page_help[str(page_uuid)] = thread[-20:]
state['page_help'] = page_help
session.state = state
session.save(update_fields=['state', 'updated_at'])
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 = {}
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): def _evaluate_final_quiz(self, session):
flow = self._get_flow_for_session(session) flow = self._get_flow_for_session(session)
if not flow: if not flow:
@ -1005,81 +795,11 @@ class OnboardingSessionViewSet(ModelViewSet):
tool_call_metadata={'page_uuid': page_uuid, 'has_responses': isinstance(responses, dict)} tool_call_metadata={'page_uuid': page_uuid, 'has_responses': isinstance(responses, dict)}
) )
return Response({ return Response({
'status': 'received', 'status': 'received',
'session_state': session.state, 'session_state': session.state,
}) })
@action(detail=True, methods=['post'], url_path='ask-ka')
def ask_ka(self, request, uuid=None):
session = self.get_object()
page_uuid = request.data.get('page_uuid')
user_message = request.data.get('message')
mode = request.data.get('mode', 'separate')
if not page_uuid or not user_message:
return Response({'error': 'page_uuid and message are required.'}, status=HTTP_400_BAD_REQUEST)
flow = self._get_flow_for_session(session)
if not flow:
return Response({'error': 'Onboarding flow not found.'}, status=HTTP_400_BAD_REQUEST)
page, _ = self._get_page_from_flow(flow, page_uuid)
if not isinstance(page, dict):
return Response({'error': 'Page not found for this flow.'}, status=HTTP_400_BAD_REQUEST)
page_title = str(page.get('title') or 'Onboarding Page')
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._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. "
"Please review the revised page text above."
)
if not assistant_message:
assistant_message = self._run_ka_help(session, page_title, page_body, str(user_message))
self._append_page_help(session, page_uuid, user_message, assistant_message)
AgentInteractionLog.objects.create(
session=session,
sender_type='user',
content=str(user_message),
tool_call_metadata={
'action': 'ask_ka',
'page_uuid': str(page_uuid),
'mode': str(mode),
},
)
AgentInteractionLog.objects.create(
session=session,
sender_type='ai',
content=str(assistant_message),
tool_call_metadata={
'action': 'ask_ka_response',
'page_uuid': str(page_uuid),
'mode': str(mode),
'updated_page': updated_page,
},
)
return Response({
'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)
@action(detail=True, methods=['get'], url_path='history') @action(detail=True, methods=['get'], url_path='history')
def history(self, request, uuid=None): def history(self, request, uuid=None):
session = self.get_object() session = self.get_object()
@ -1118,7 +838,8 @@ class OnboardingSessionViewSet(ModelViewSet):
session.save(update_fields=['status', 'completed_at', 'state', 'updated_at']) session.save(update_fields=['status', 'completed_at', 'state', 'updated_at'])
return Response({'message': 'Session marked as completed', 'quiz_result': quiz_result}) return Response({'message': 'Session marked as completed', 'quiz_result': quiz_result})
class AgentInteractionLogViewSet(ReadOnlyModelViewSet):
class AgentInteractionLogViewSet(RequestParamMixin, ReadOnlyModelViewSet):
queryset = AgentInteractionLog.objects.all() queryset = AgentInteractionLog.objects.all()
serializer_class = AgentInteractionLogSerializer serializer_class = AgentInteractionLogSerializer
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]
@ -1136,22 +857,13 @@ class AgentInteractionLogViewSet(ReadOnlyModelViewSet):
manager_scope manager_scope
).distinct() ).distinct()
session_uuid = self.request.query_params.get('session_uuid') if session_uuid := self._get_param('session_uuid'):
if session_uuid in (None, ''):
session_uuid = self.request.data.get('session_uuid')
if session_uuid:
queryset = queryset.filter(session__uuid=session_uuid) queryset = queryset.filter(session__uuid=session_uuid)
role_uuid = self.request.query_params.get('role_uuid') if role_uuid := self._get_param('role_uuid'):
if role_uuid in (None, ''):
role_uuid = self.request.data.get('role_uuid')
if role_uuid:
queryset = queryset.filter(session__role__uuid=role_uuid) queryset = queryset.filter(session__role__uuid=role_uuid)
organization_uuid = self.request.query_params.get('organization_uuid') if organization_uuid := self._get_param('organization_uuid'):
if organization_uuid in (None, ''):
organization_uuid = self.request.data.get('organization_uuid')
if organization_uuid:
queryset = queryset.filter(session__role__organization__uuid=organization_uuid) queryset = queryset.filter(session__role__organization__uuid=organization_uuid)
return queryset.order_by('created_at') return queryset.order_by('created_at')

View file

@ -147,7 +147,6 @@ export const API = {
progressOverview: () => 'onboarding-session/progress-overview/', progressOverview: () => 'onboarding-session/progress-overview/',
byId: (uuid: string) => `onboarding-session/${uuid}/`, byId: (uuid: string) => `onboarding-session/${uuid}/`,
interact: (uuid: string) => `onboarding-session/${uuid}/interact/`, interact: (uuid: string) => `onboarding-session/${uuid}/interact/`,
askKa: (uuid: string) => `onboarding-session/${uuid}/ask-ka/`,
history: (uuid: string) => `onboarding-session/${uuid}/history/`, history: (uuid: string) => `onboarding-session/${uuid}/history/`,
complete: (uuid: string) => `onboarding-session/${uuid}/complete/`, complete: (uuid: string) => `onboarding-session/${uuid}/complete/`,
}, },

View file

@ -14,6 +14,7 @@ export const useAgentStore = defineStore('agent', () => {
const eventLog = ref<AgentEvent[]>([]) const eventLog = ref<AgentEvent[]>([])
const lastExecutionId = ref<string | null>(null) const lastExecutionId = ref<string | null>(null)
const socket = ref<WebSocket | null>(null) const socket = ref<WebSocket | null>(null)
const streamBuffer = ref('')
let currentUrl = '' let currentUrl = ''
let reconnectAttempts = 0 let reconnectAttempts = 0
@ -68,7 +69,10 @@ export const useAgentStore = defineStore('agent', () => {
lastExecutionId.value = String(payload.execution_id) lastExecutionId.value = String(payload.execution_id)
} }
if (type === 'status' || type === 'thought' || type === 'tool_start') { if (type === 'stream_chunk') {
executionStatus.value = 'running'
streamBuffer.value += payload.message || ''
} else if (type === 'status' || type === 'thought' || type === 'tool_start') {
executionStatus.value = 'running' executionStatus.value = 'running'
pushEvent({ pushEvent({
type, type,
@ -87,6 +91,7 @@ export const useAgentStore = defineStore('agent', () => {
}) })
} else if (type === 'completed') { } else if (type === 'completed') {
executionStatus.value = 'completed' executionStatus.value = 'completed'
streamBuffer.value = ''
pushEvent({ pushEvent({
type: 'completed', type: 'completed',
message: 'Generation loop finished successfully', message: 'Generation loop finished successfully',
@ -117,6 +122,7 @@ export const useAgentStore = defineStore('agent', () => {
intentionalClose = false intentionalClose = false
clearReconnectTimer() clearReconnectTimer()
reconnectAttempts = 0 reconnectAttempts = 0
streamBuffer.value = ''
if (socket.value) { if (socket.value) {
socket.value.close() socket.value.close()
@ -172,6 +178,7 @@ export const useAgentStore = defineStore('agent', () => {
executionStatus, executionStatus,
eventLog, eventLog,
socket, socket,
streamBuffer,
connect, connect,
disconnect, disconnect,
startAgent, startAgent,

135
site/src/stores/kaStore.ts Normal file
View file

@ -0,0 +1,135 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { BACKOFF_BASE_MS, BACKOFF_MAX_MS, BACKOFF_MAX_ATTEMPTS } from './agentBackoff'
export type KaResponse = {
answer: string
updatedPage: boolean
revisedPageBody: string | null
}
type PendingRequest = {
resolve: (value: KaResponse) => void
reject: (reason: string) => void
}
export const useKaStore = defineStore('ka', () => {
const isConnected = ref(false)
const isAsking = ref(false)
const streamBuffer = ref('')
const socket = ref<WebSocket | null>(null)
let currentUrl = ''
let reconnectAttempts = 0
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
let intentionalClose = false
let pending: PendingRequest | null = null
const clearReconnectTimer = () => {
if (reconnectTimer !== null) {
clearTimeout(reconnectTimer)
reconnectTimer = null
}
}
const scheduleReconnect = () => {
if (reconnectAttempts >= BACKOFF_MAX_ATTEMPTS) return
const delay = Math.min(BACKOFF_BASE_MS * 2 ** reconnectAttempts, BACKOFF_MAX_MS)
reconnectAttempts++
reconnectTimer = setTimeout(() => {
if (!intentionalClose) openSocket(currentUrl)
}, delay)
}
const openSocket = (url: string) => {
socket.value = new WebSocket(url)
socket.value.onopen = () => {
reconnectAttempts = 0
isConnected.value = true
}
socket.value.onmessage = (event) => {
try {
const payload = JSON.parse(event.data)
if (payload.type === 'stream_chunk') {
streamBuffer.value += payload.message || ''
} else if (payload.type === 'completed') {
isAsking.value = false
streamBuffer.value = ''
const content = payload.content || {}
pending?.resolve({
answer: payload.message || '',
updatedPage: Boolean(content.updated_page),
revisedPageBody: content.revised_page_body ?? null,
})
pending = null
} else if (payload.type === 'error') {
isAsking.value = false
streamBuffer.value = ''
pending?.reject(payload.message || 'KA error')
pending = null
}
} catch (e) {
console.error('KA store message error', e)
}
}
socket.value.onclose = (event) => {
isConnected.value = false
if (!intentionalClose && event.code !== 1000) {
scheduleReconnect()
}
}
}
const connect = (sessionUuid: string) => {
intentionalClose = false
clearReconnectTimer()
reconnectAttempts = 0
if (socket.value) {
socket.value.close()
socket.value = null
}
const wsProtocol = window.location.protocol === 'https:' ? 'wss' : 'ws'
currentUrl = `${wsProtocol}://${window.location.host}/ws/onboarding/knowledge/${sessionUuid}/`
openSocket(currentUrl)
}
const disconnect = () => {
intentionalClose = true
clearReconnectTimer()
if (pending) {
pending.reject('Disconnected')
pending = null
}
isAsking.value = false
if (socket.value) {
socket.value.close()
socket.value = null
}
isConnected.value = false
}
const ask = (
pageUuid: string,
userMessage: string,
mode: 'separate' | 'update_page',
): Promise<KaResponse> => {
return new Promise((resolve, reject) => {
if (!socket.value || socket.value.readyState !== WebSocket.OPEN) {
return reject('Not connected')
}
isAsking.value = true
streamBuffer.value = ''
pending = { resolve, reject }
socket.value.send(
JSON.stringify({ action: 'ask', page_uuid: pageUuid, message: userMessage, mode }),
)
})
}
return { isConnected, isAsking, streamBuffer, connect, disconnect, ask }
})

View file

@ -11,6 +11,7 @@ import { BACKOFF_BASE_MS, BACKOFF_MAX_MS, BACKOFF_MAX_ATTEMPTS } from './agentBa
export const useOnboardingAgentStore = defineStore('onboarding-agent', () => { export const useOnboardingAgentStore = defineStore('onboarding-agent', () => {
const isConnected = ref(false) const isConnected = ref(false)
const executionStatus = ref<AgentExecutionStatus>('idle') const executionStatus = ref<AgentExecutionStatus>('idle')
const currentPhase = ref<'curriculum' | 'knowledge' | 'assessment' | null>(null)
const eventLog = ref<AgentEvent[]>([]) const eventLog = ref<AgentEvent[]>([])
const lastExecutionId = ref<string | null>(null) const lastExecutionId = ref<string | null>(null)
const socket = ref<WebSocket | null>(null) const socket = ref<WebSocket | null>(null)
@ -70,6 +71,10 @@ export const useOnboardingAgentStore = defineStore('onboarding-agent', () => {
if (type === 'status' || type === 'thought' || type === 'tool_start') { if (type === 'status' || type === 'thought' || type === 'tool_start') {
executionStatus.value = 'running' executionStatus.value = 'running'
if (type === 'status' && typeof payload.content === 'string' &&
['curriculum', 'knowledge', 'assessment'].includes(payload.content)) {
currentPhase.value = payload.content as 'curriculum' | 'knowledge' | 'assessment'
}
pushEvent({ pushEvent({
type, type,
message: payload.message || payload.thought, message: payload.message || payload.thought,
@ -87,6 +92,7 @@ export const useOnboardingAgentStore = defineStore('onboarding-agent', () => {
}) })
} else if (type === 'completed') { } else if (type === 'completed') {
executionStatus.value = 'completed' executionStatus.value = 'completed'
currentPhase.value = null
pushEvent({ pushEvent({
type: 'completed', type: 'completed',
message: 'Generation loop finished successfully', message: 'Generation loop finished successfully',
@ -95,6 +101,7 @@ export const useOnboardingAgentStore = defineStore('onboarding-agent', () => {
}) })
} else if (type === 'error') { } else if (type === 'error') {
executionStatus.value = 'failed' executionStatus.value = 'failed'
currentPhase.value = null
pushEvent({ type: 'error', message: payload.message }) pushEvent({ type: 'error', message: payload.message })
} }
} catch (e) { } catch (e) {
@ -137,6 +144,7 @@ export const useOnboardingAgentStore = defineStore('onboarding-agent', () => {
} }
isConnected.value = false isConnected.value = false
executionStatus.value = 'idle' executionStatus.value = 'idle'
currentPhase.value = null
} }
const startAgent = (data: AgentStartPayload) => { const startAgent = (data: AgentStartPayload) => {
@ -170,6 +178,7 @@ export const useOnboardingAgentStore = defineStore('onboarding-agent', () => {
return { return {
isConnected, isConnected,
executionStatus, executionStatus,
currentPhase,
eventLog, eventLog,
socket, socket,
connect, connect,

View file

@ -13,12 +13,12 @@ import {
Tag, Tag,
InputNumber, InputNumber,
Select, Select,
Tooltip,
} from 'ant-design-vue' } from 'ant-design-vue'
import { marked } from 'marked'
import DOMPurify from 'dompurify'
import { useAgentStore } from '../stores/agentStore' import { useAgentStore } from '../stores/agentStore'
import { apiClient, isAxiosError, API } from '../router/api' import { apiClient, isAxiosError, API } from '../router/api'
import type { AgentConfig, AgentRunResult } from '../types/agent' import type { AgentConfig, AgentRunResult } from '../types/agent'
import { InfoCircleOutlined } from '@ant-design/icons-vue'
const route = useRoute() const route = useRoute()
const agentStore = useAgentStore() const agentStore = useAgentStore()
@ -142,13 +142,7 @@ const saveConfig = async () => {
} }
} }
const renderedAgentResponse = computed(() => { const displayedResponse = computed(() => agentResponse.value || agentStore.streamBuffer || '')
const rawMarkdown = agentResponse.value
if (!rawMarkdown) return ''
const html = marked.parse(rawMarkdown) as string
return DOMPurify.sanitize(html)
})
const startAgent = () => { const startAgent = () => {
if (!agentStore.isConnected) { if (!agentStore.isConnected) {
@ -260,7 +254,12 @@ onUnmounted(() => {
</div> </div>
<div> <div>
<Typography.Text>Max Tokens:</Typography.Text> <Space :size="4" align="center" style="margin-bottom: 4px">
<Typography.Text>Max Tokens</Typography.Text>
<Tooltip title="The maximum number of tokens the agent can generate in a single response. One token ≈ 4 characters. If the response cuts off mid-sentence, increase this value.">
<InfoCircleOutlined style="color: #9ca3af; cursor: help" />
</Tooltip>
</Space>
<InputNumber <InputNumber
v-model:value="maxTokens" v-model:value="maxTokens"
:min="1" :min="1"
@ -283,13 +282,12 @@ onUnmounted(() => {
</Space> </Space>
</div> </div>
<div v-if="agentResponse" class="response-section"> <div v-if="agentResponse || agentStore.streamBuffer" class="response-section">
<Typography.Title :level="4" class="section-title">Final Response</Typography.Title> <Typography.Title :level="4" class="section-title">
{{ agentStore.streamBuffer && !agentResponse ? 'Response' : 'Final Response' }}
</Typography.Title>
<Card class="response-card response-final" :bordered="false"> <Card class="response-card response-final" :bordered="false">
<div <Typography.Text>{{ displayedResponse }}</Typography.Text>
class="response-content markdown-body"
v-html="renderedAgentResponse"
></div>
</Card> </Card>
</div> </div>
<Typography.Title :level="4" class="section-title">Execution Log</Typography.Title> <Typography.Title :level="4" class="section-title">Execution Log</Typography.Title>

View file

@ -20,6 +20,7 @@ import {
import { CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons-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 { useKaStore } from '../stores/kaStore'
import { useUserStore } from '../stores/userStore' import { useUserStore } from '../stores/userStore'
import type { import type {
OnboardingFlow, OnboardingFlow,
@ -35,6 +36,7 @@ const marked = new Marked()
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const agentStore = useOnboardingAgentStore() const agentStore = useOnboardingAgentStore()
const kaStore = useKaStore()
const userStore = useUserStore() const userStore = useUserStore()
const roleId = computed(() => route.params.roleId as string) const roleId = computed(() => route.params.roleId as string)
@ -63,7 +65,6 @@ type QuizResult = {
const quizResult = ref<QuizResult | null>(null) const quizResult = ref<QuizResult | null>(null)
const kaQuestion = ref('') const kaQuestion = ref('')
const kaLoading = ref(false)
const kaMode = ref<'separate' | 'update_page'>('separate') const kaMode = ref<'separate' | 'update_page'>('separate')
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const formState = reactive<Record<string, any>>({}) const formState = reactive<Record<string, any>>({})
@ -112,7 +113,8 @@ const currentPageBody = computed(() => {
const renderedBody = computed(() => { const renderedBody = computed(() => {
if (!currentPageBody.value) return '' if (!currentPageBody.value) return ''
return DOMPurify.sanitize(marked.parse(currentPageBody.value) as string) const body = currentPageBody.value.replace(/^#{1,6}\s+.+\n?/, '')
return DOMPurify.sanitize(marked.parse(body) as string)
}) })
const isAnswerCorrect = (value: unknown) => { const isAnswerCorrect = (value: unknown) => {
@ -263,6 +265,7 @@ const resetCurrentFlow = async () => {
isAutoGenerating.value = false isAutoGenerating.value = false
agentStore.disconnect() agentStore.disconnect()
agentStore.clearLog() agentStore.clearLog()
kaStore.disconnect()
message.success('Onboarding flow deleted. Generating a fresh flow...') message.success('Onboarding flow deleted. Generating a fresh flow...')
await initOnboarding() await initOnboarding()
@ -353,6 +356,7 @@ watch(
Object.keys(formState).forEach((k) => delete formState[k]) Object.keys(formState).forEach((k) => delete formState[k])
agentStore.disconnect() agentStore.disconnect()
agentStore.clearLog() agentStore.clearLog()
kaStore.disconnect()
await initOnboarding() await initOnboarding()
}, },
) )
@ -370,6 +374,7 @@ const loadFlow = async (flowUuid: string) => {
return return
} }
kaStore.connect(session.value.uuid)
restorePageProgressFromSession() restorePageProgressFromSession()
syncVisitedPages() syncVisitedPages()
hydrateFormState() hydrateFormState()
@ -554,36 +559,47 @@ const onSubmitPage = async () => {
const askKnowledgeAgent = async () => { const askKnowledgeAgent = async () => {
if (!session.value || !currentPage.value || !kaQuestion.value.trim()) return if (!session.value || !currentPage.value || !kaQuestion.value.trim()) return
kaLoading.value = true const pageUuid = currentPage.value.uuid
try { const question = kaQuestion.value.trim()
const response = await apiClient.post<{ kaQuestion.value = ''
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,
message: kaQuestion.value,
mode: kaMode.value,
})
const apiSessionState = response.data?.session_state try {
if (apiSessionState && session.value) { const result = await kaStore.ask(pageUuid, question, kaMode.value)
;(session.value as unknown as { state?: Record<string, unknown> }).state = apiSessionState
const sessionObj = session.value as unknown as { state?: Record<string, unknown> }
const state: Record<string, unknown> = sessionObj.state ?? {}
const pageHelp: Record<string, unknown[]> =
state.page_help && typeof state.page_help === 'object'
? { ...(state.page_help as Record<string, unknown[]>) }
: {}
const thread = Array.isArray(pageHelp[pageUuid]) ? [...pageHelp[pageUuid]] : []
thread.push({ question, answer: result.answer, timestamp: new Date().toISOString() })
pageHelp[pageUuid] = thread.slice(-20)
state.page_help = pageHelp
if (result.updatedPage && result.revisedPageBody) {
const overrides: Record<string, unknown> =
state.page_overrides && typeof state.page_overrides === 'object'
? { ...(state.page_overrides as Record<string, unknown>) }
: {}
overrides[pageUuid] = result.revisedPageBody
state.page_overrides = overrides
} }
sessionObj.state = state
syncVisitedPages() syncVisitedPages()
kaQuestion.value = ''
} catch { } catch {
message.error('Could not retrieve clarification right now') message.error('Could not retrieve clarification right now')
} finally { kaQuestion.value = question
kaLoading.value = false
} }
} }
onMounted(() => initOnboarding()) onMounted(() => initOnboarding())
onUnmounted(() => agentStore.disconnect()) onUnmounted(() => {
agentStore.disconnect()
kaStore.disconnect()
})
watch( watch(
() => currentPageIndex.value, () => currentPageIndex.value,
@ -632,7 +648,7 @@ watch(
</div> </div>
<div class="pipeline-status"> <div class="pipeline-status">
<Steps size="small" :current="agentStore.executionStatus === 'running' ? 1 : 2"> <Steps size="small" :current="({ curriculum: 0, knowledge: 1, assessment: 2 } as Record<string, number>)[agentStore.currentPhase ?? ''] ?? (agentStore.executionStatus === 'completed' ? 3 : -1)">
<Steps.Step title="Curriculum" /> <Steps.Step title="Curriculum" />
<Steps.Step title="Knowledge" /> <Steps.Step title="Knowledge" />
<Steps.Step title="Assessment" /> <Steps.Step title="Assessment" />
@ -724,7 +740,21 @@ watch(
<Typography.Title :level="4" class="white-text"> <Typography.Title :level="4" class="white-text">
{{ currentPage.title }} {{ currentPage.title }}
</Typography.Title> </Typography.Title>
<div class="markdown-body" v-html="renderedBody"></div> <div style="position: relative">
<div
v-if="kaStore.isAsking && !kaStore.streamBuffer"
style="display: flex; align-items: center; gap: 8px; margin-bottom: 12px; opacity: 0.75"
>
<Spin size="small" />
<Typography.Text class="white-text" style="font-size: 13px">Generating...</Typography.Text>
</div>
<div
class="markdown-body"
v-html="kaStore.isAsking && kaStore.streamBuffer
? DOMPurify.sanitize(marked.parse(kaStore.streamBuffer) as string)
: renderedBody"
></div>
</div>
<Divider dashed style="border-color: #dbe3ec" /> <Divider dashed style="border-color: #dbe3ec" />
<Form layout="vertical" :model="formState" @finish="onSubmitPage"> <Form layout="vertical" :model="formState" @finish="onSubmitPage">
@ -868,7 +898,7 @@ watch(
/> />
<Button <Button
type="default" type="default"
:loading="kaLoading" :loading="kaStore.isAsking"
:disabled="!kaQuestion.trim()" :disabled="!kaQuestion.trim()"
@click="askKnowledgeAgent" @click="askKnowledgeAgent"
> >

View file

@ -123,16 +123,26 @@ const runProgressMonitor = async () => {
ws.send(JSON.stringify({action: 'progress_monitor'}),) ws.send(JSON.stringify({action: 'progress_monitor'}),)
} }
let streamingFeedback = false
ws.onmessage = (event) => { ws.onmessage = (event) => {
try { try {
const payload = JSON.parse(event.data) const payload = JSON.parse(event.data)
if (payload.message) { if (payload.message && payload.type !== 'stream_chunk') {
monitorLogs.value.push(String(payload.message)) monitorLogs.value.push(String(payload.message))
} }
if (payload.type === 'stream_chunk') {
if (!streamingFeedback) {
feedback.value = ''
streamingFeedback = true
}
feedback.value += payload.message || ''
}
if (payload.type === 'completed') { if (payload.type === 'completed') {
feedback.value = String( feedback.value = String(
payload.content?.feedback || payload.message || 'No feedback returned.', payload.content?.feedback || feedback.value || 'No feedback returned.',
) )
if (monitorTimeout.value !== null) { if (monitorTimeout.value !== null) {
window.clearTimeout(monitorTimeout.value) window.clearTimeout(monitorTimeout.value)