From bee8e205809fc8c30983176938ce5c6de5e4246e Mon Sep 17 00:00:00 2001 From: Carlos Garcia Date: Thu, 14 May 2026 23:49:11 -0400 Subject: [PATCH] feat(elearning): add course-building capability to elearning agent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ElearningTools: add create_course, update_course, publish_course, add_section, create_slide, enroll_user write methods using OdooClient - ElearningAgent: fix all BaseAgent method signatures (_plan/_gather/ _reason/_act/_report no longer take wrong positional args) - Replace dead _dispatch_tool pattern with _tool_ methods so BaseAgent._run_tool() can drive them via LLM tool calls in _loop() - Add LLM-driven course creation in _reason(): when intent is create, _loop() is called with a course-building system prompt and all tools; the LLM calls create_course → add_section → create_slide → publish - Fix handle_peer_request signature to match BaseAgent interface - Fix AgentReport missing directive_id; fix SweepReport invalid kwargs - Extend ELEARNING_TOOLS list with all new write-side tools Co-Authored-By: Claude Sonnet 4.6 --- agent_service/agents/elearning_agent.py | 311 ++++++++++++++++++------ agent_service/tools/elearning_tools.py | 118 ++++++++- 2 files changed, 342 insertions(+), 87 deletions(-) diff --git a/agent_service/agents/elearning_agent.py b/agent_service/agents/elearning_agent.py index 29ef06c..9e8136a 100644 --- a/agent_service/agents/elearning_agent.py +++ b/agent_service/agents/elearning_agent.py @@ -1,11 +1,12 @@ from __future__ import annotations import logging -from .base_agent import BaseAgent, AgentReport, AgentDirective, SweepReport +from .base_agent import BaseAgent, AgentReport, SweepReport from ..tools.elearning_tools import ElearningTools logger = logging.getLogger(__name__) ELEARNING_TOOLS = [ + # ── Read ────────────────────────────────────────────────────────────── {'name': 'get_courses', 'description': 'List eLearning courses', 'parameters': {'active': {'type': 'boolean', 'optional': True}, 'limit': {'type': 'integer', 'optional': True}}}, @@ -14,131 +15,283 @@ ELEARNING_TOOLS = [ {'name': 'get_enrolled_users', 'description': 'Get users enrolled in a course', 'parameters': {'channel_id': {'type': 'integer'}, 'limit': {'type': 'integer', 'optional': True}}}, - {'name': 'get_slide_completion', 'description': 'Get slide completion by user', + {'name': 'get_slide_completion', 'description': 'Get slide completion rates by user', 'parameters': {'channel_id': {'type': 'integer'}, 'min_completion': {'type': 'number', 'optional': True}}}, - {'name': 'get_learning_summary', 'description': 'Get overall learning summary', 'parameters': {}}, - {'name': 'flag_low_completion', 'description': 'Flag a course with low completion', + {'name': 'get_learning_summary', 'description': 'Get overall eLearning summary', 'parameters': {}}, + # ── Create / Write ───────────────────────────────────────────────────── + {'name': 'create_course', + 'description': 'Create a new eLearning course (slide.channel). Returns the new course id.', + 'parameters': {'name': {'type': 'string'}, + 'description': {'type': 'string', 'optional': True}, + 'enroll_policy': {'type': 'string', 'optional': True}, + 'website_published': {'type': 'boolean', 'optional': True}}}, + {'name': 'update_course', + 'description': 'Update name, description, or enroll_policy on an existing course', + 'parameters': {'channel_id': {'type': 'integer'}, + 'name': {'type': 'string', 'optional': True}, + 'description': {'type': 'string', 'optional': True}, + 'enroll_policy': {'type': 'string', 'optional': True}}}, + {'name': 'publish_course', + 'description': 'Publish a course to the website (sets website_published=True)', + 'parameters': {'channel_id': {'type': 'integer'}}}, + {'name': 'add_section', + 'description': 'Add a section (category) to a course. Sections group slides into topics.', + 'parameters': {'channel_id': {'type': 'integer'}, + 'name': {'type': 'string'}, + 'sequence': {'type': 'integer', 'optional': True}}}, + {'name': 'create_slide', + 'description': ( + 'Create a slide/lesson inside a course. ' + 'slide_type: document | video | infographic | webpage | quiz. ' + 'Use html_content for webpage slides, url for video slides.' + ), + 'parameters': {'channel_id': {'type': 'integer'}, + 'name': {'type': 'string'}, + 'slide_type': {'type': 'string', 'optional': True}, + 'sequence': {'type': 'integer', 'optional': True}, + 'description': {'type': 'string', 'optional': True}, + 'html_content': {'type': 'string', 'optional': True}, + 'url': {'type': 'string', 'optional': True}}}, + {'name': 'enroll_user', + 'description': 'Enroll a partner (by partner_id) in a course', + 'parameters': {'channel_id': {'type': 'integer'}, + 'partner_id': {'type': 'integer'}}}, + # ── Notify ───────────────────────────────────────────────────────────── + {'name': 'flag_low_completion', + 'description': 'Post a chatter flag on a course with low completion rate', 'parameters': {'channel_id': {'type': 'integer'}, 'reason': {'type': 'string'}}}, - {'name': 'suggest_next_course', 'description': 'Suggest next course for a learner', + {'name': 'suggest_next_course', + 'description': 'Suggest the next course for a learner based on completed courses', 'parameters': {'partner_id': {'type': 'integer'}}}, - {'name': 'post_chatter_note', 'description': 'Post a note on a record', + {'name': 'post_chatter_note', + 'description': 'Post a note on any Odoo record', 'parameters': {'model': {'type': 'string'}, 'record_id': {'type': 'integer'}, 'note': {'type': 'string'}}}, ] +_SYSTEM_PROMPT = """\ +You are the ActiveBlue eLearning Agent with full access to Odoo 18 eLearning (website_slides module). + +When asked to build a course, follow this exact workflow: +1. Call create_course with the course name and a short description. + Record the 'id' returned — this is the channel_id for all subsequent calls. +2. Call add_section for each major topic or module (sequence: 10, 20, 30 ...). +3. Call create_slide for each lesson within a section. Place slides after their + section using sequence numbers (section at 10 → slides at 11, 12, 13 ...). + Slide types: + - 'document' — PDF / document lesson + - 'webpage' — rich HTML lesson (provide html_content with the lesson text) + - 'video' — video lesson (provide url) + - 'quiz' — assessment / knowledge check +4. Call publish_course if the task requests a live/published course. +5. Call enroll_user for any specific learners mentioned in the task. + +Always use the actual IDs returned by tool calls — never invent IDs. +After all tools are done, summarise what was created. +""" + class ElearningAgent(BaseAgent): name = 'elearning_agent' domain = 'elearning' required_odoo_module = 'website_slides' - system_prompt_file = 'elearning_system.txt' + system_prompt_file = '' tools = ELEARNING_TOOLS def __init__(self, odoo, llm, peer_bus=None): super().__init__(odoo, llm, peer_bus) self._el = ElearningTools(odoo) - self._gathered_data = {} - self._actions_taken = [] - self._escalations_list = [] + self._actions_taken: list = [] - async def _plan(self, directive: AgentDirective) -> dict: - intent = (directive.intent or '').lower() + # ── BaseAgent lifecycle ───────────────────────────────────────────────── + + async def _plan(self) -> dict: + task = (self._directive.task or '').lower() + create_kws = ('create', 'build', 'make', 'new course', 'set up course', 'add course') + enroll_kws = ('enroll', 'register', 'sign up') return { - 'fetch_summary': any(k in intent for k in ('summary', 'overview', 'learning')), - 'fetch_courses': 'course' in intent, - 'channel_id': directive.context.get('channel_id'), - 'partner_id': directive.context.get('partner_id'), + 'intent': ( + 'create' if any(k in task for k in create_kws) else + 'enroll' if any(k in task for k in enroll_kws) else + 'read' + ), + 'channel_id': self._directive.params.get('channel_id'), + 'partner_id': self._directive.params.get('partner_id'), } - async def _gather(self, ctx: dict) -> dict: - plan = ctx.get('plan', {}) - data: dict = {} - data['summary'] = await self._el.get_learning_summary() - if plan.get('fetch_courses') or plan.get('channel_id'): - if plan.get('channel_id'): - data['course_stats'] = await self._el.get_course_stats(channel_id=plan['channel_id']) - else: - data['courses'] = await self._el.get_courses(limit=20) - self._gathered_data = data - return data + async def _gather(self, plan: dict) -> None: + self._gathered['intent'] = plan['intent'] + channel_id = plan.get('channel_id') + if plan['intent'] == 'read' and channel_id: + self._gathered['course_stats'] = await self._el.get_course_stats(channel_id=channel_id) + else: + self._gathered['summary'] = await self._el.get_learning_summary() - async def _reason(self, ctx: dict) -> dict: - data = self._gathered_data - analysis: dict = {'escalations': [], 'low_completion': []} - summary = data.get('summary', {}) + async def _reason(self) -> dict: + if self._gathered.get('intent') == 'create': + messages = [ + {'role': 'system', 'content': _SYSTEM_PROMPT}, + {'role': 'user', 'content': self._directive.task}, + ] + llm_output = await self._loop(messages, tools=ELEARNING_TOOLS, max_iter=20) + return {'intent': 'create', 'llm_output': llm_output} + + summary = self._gathered.get('summary', {}) low = summary.get('low_completion_courses', []) - analysis['low_completion'] = low - if len(low) > 3: - analysis['escalations'].append(f'{len(low)} courses have <30% completion rate.') - self._escalations_list = analysis['escalations'] - return analysis + return {'intent': 'read', 'low_completion': low} - async def _act(self, ctx: dict) -> list: - actions = [] - analysis = ctx.get('analysis', {}) - for course in analysis.get('low_completion', [])[:3]: + async def _act(self, reasoning: dict) -> None: + if reasoning.get('intent') != 'read': + return + for course in reasoning.get('low_completion', [])[:3]: try: await self._el.flag_low_completion( - channel_id=course.get('id'), + channel_id=course['id'], reason=f'Completion rate {course.get("completion_rate", 0):.1f}% is below 30% threshold', ) - actions.append({'action': 'flag_low_completion', 'course_id': course.get('id'), 'success': True}) + self._actions_taken.append({'action': 'flag_low_completion', 'course_id': course['id']}) except Exception as exc: - logger.warning('flag_low_completion failed: %s', exc) - self._actions_taken = actions - return actions + logger.warning('flag_low_completion failed course_id=%s: %s', course.get('id'), exc) - async def _report(self, ctx: dict) -> AgentReport: - data = self._gathered_data - summary = data.get('summary', {}) + async def _report(self) -> AgentReport: parts = [] + summary = self._gathered.get('summary', {}) + course_stats = self._gathered.get('course_stats', {}) if summary: parts.append( f'eLearning: {summary.get("total_courses", 0)} courses, ' f'{summary.get("total_enrollments", 0)} enrollments, ' f'{summary.get("avg_completion", 0):.1f}% avg completion.' ) + if course_stats: + ch = course_stats.get('channel', {}) + parts.append(f'Course "{ch.get("name", "?")}": {course_stats.get("slide_count", 0)} slides.') + creates = [a for a in self._actions_taken if 'create' in a.get('action', '')] + if creates: + names = ', '.join(a.get('name', f'id={a.get("id")}') for a in creates) + parts.append(f'Created: {names}.') if not parts: - parts.append('eLearning review complete.') - return AgentReport(agent=self.name, summary=chr(10).join(parts), - data=data, escalations=self._escalations_list, actions_taken=self._actions_taken) + parts.append('eLearning operation complete.') + return AgentReport( + directive_id=self._directive.directive_id, + agent=self.name, + status='complete', + summary='\n'.join(parts), + actions_taken=self._actions_taken, + data={k: v for k, v in self._gathered.items() if not k.startswith('_')}, + ) - async def _dispatch_tool(self, name: str, args: dict): - dispatch = { - 'get_courses': self._el.get_courses, - 'get_course_stats': self._el.get_course_stats, - 'get_enrolled_users': self._el.get_enrolled_users, - 'get_slide_completion': self._el.get_slide_completion, - 'get_learning_summary': self._el.get_learning_summary, - 'flag_low_completion': self._el.flag_low_completion, - 'suggest_next_course': self._el.suggest_next_course, - 'post_chatter_note': self._el.post_chatter_note, - } - if name not in dispatch: - raise ValueError(f'Unknown tool: {name}') - return await dispatch[name](**args) + # ── Tool dispatch (_run_tool in BaseAgent calls _tool_) ────────── - async def handle_peer_request(self, request: dict) -> dict: - req_type = request.get('type', '') + async def _tool_get_courses(self, active: bool = True, limit: int = 50) -> list: + return await self._el.get_courses(active=active, limit=limit) + + async def _tool_get_course_stats(self, channel_id: int) -> dict: + return await self._el.get_course_stats(channel_id=channel_id) + + async def _tool_get_enrolled_users(self, channel_id: int, limit: int = 100) -> list: + return await self._el.get_enrolled_users(channel_id=channel_id, limit=limit) + + async def _tool_get_slide_completion(self, channel_id: int, min_completion: float = 0.0) -> list: + return await self._el.get_slide_completion(channel_id=channel_id, min_completion=min_completion) + + async def _tool_get_learning_summary(self) -> dict: + return await self._el.get_learning_summary() + + async def _tool_create_course(self, name: str, description: str = '', + enroll_policy: str = 'public', + website_published: bool = False) -> dict: + result = await self._el.create_course(name=name, description=description, + enroll_policy=enroll_policy, + website_published=website_published) + if result.get('success'): + self._actions_taken.append({'action': 'create_course', 'id': result['id'], 'name': name}) + return result + + async def _tool_update_course(self, channel_id: int, name: str = None, + description: str = None, enroll_policy: str = None) -> dict: + result = await self._el.update_course(channel_id=channel_id, name=name, + description=description, enroll_policy=enroll_policy) + if result.get('success'): + self._actions_taken.append({'action': 'update_course', 'id': channel_id}) + return result + + async def _tool_publish_course(self, channel_id: int) -> dict: + result = await self._el.publish_course(channel_id=channel_id) + if result.get('success'): + self._actions_taken.append({'action': 'publish_course', 'id': channel_id}) + return result + + async def _tool_add_section(self, channel_id: int, name: str, sequence: int = 0) -> dict: + result = await self._el.add_section(channel_id=channel_id, name=name, sequence=sequence) + if result.get('success'): + self._actions_taken.append({'action': 'add_section', 'id': result['id'], 'name': name}) + return result + + async def _tool_create_slide(self, channel_id: int, name: str, + slide_type: str = 'document', sequence: int = 0, + description: str = '', html_content: str = '', + url: str = '') -> dict: + result = await self._el.create_slide(channel_id=channel_id, name=name, + slide_type=slide_type, sequence=sequence, + description=description, html_content=html_content, + url=url) + if result.get('success'): + self._actions_taken.append({'action': 'create_slide', 'id': result['id'], 'name': name}) + return result + + async def _tool_enroll_user(self, channel_id: int, partner_id: int) -> dict: + result = await self._el.enroll_user(channel_id=channel_id, partner_id=partner_id) + if result.get('success'): + self._actions_taken.append({'action': 'enroll_user', + 'channel_id': channel_id, 'partner_id': partner_id}) + return result + + async def _tool_flag_low_completion(self, channel_id: int, reason: str) -> dict: + success = await self._el.flag_low_completion(channel_id=channel_id, reason=reason) + return {'success': success} + + async def _tool_suggest_next_course(self, partner_id: int) -> list: + return await self._el.suggest_next_course(partner_id=partner_id) + + async def _tool_post_chatter_note(self, model: str, record_id: int, note: str) -> dict: + success = await self._el.post_chatter_note(model=model, record_id=record_id, note=note) + return {'success': success} + + # ── Peer bus ──────────────────────────────────────────────────────────── + + async def handle_peer_request(self, request_type: str, params: dict, directive_id: str) -> dict: try: - if req_type == 'learning_summary': - return await self._el.get_learning_summary() - if req_type == 'suggest_courses': - return {'courses': await self._el.suggest_next_course(partner_id=request['partner_id'])} - return {'error': f'Unknown type: {req_type}'} + if request_type == 'learning_summary': + return {'success': True, **await self._el.get_learning_summary()} + if request_type == 'suggest_courses': + partner_id = params.get('partner_id') + if not partner_id: + return {'success': False, 'error': 'partner_id required'} + courses = await self._el.suggest_next_course(partner_id=partner_id) + return {'success': True, 'courses': courses} + return {'success': False, 'error': f'Unknown request type: {request_type}'} except Exception as exc: - return {'error': str(exc)} + return {'success': False, 'error': str(exc)} + + # ── Sweep ─────────────────────────────────────────────────────────────── async def sweep(self) -> SweepReport: findings = [] try: summary = await self._el.get_learning_summary() for course in summary.get('low_completion_courses', []): - findings.append({'type': 'low_completion', 'course_id': course.get('id'), - 'name': course.get('name'), 'completion': course.get('completion_rate', 0), - 'severity': 'medium'}) + findings.append({ + 'issue': 'low_completion', + 'course_id': course.get('id'), + 'name': course.get('name'), + 'completion': course.get('completion_rate', 0), + 'severity': 'medium', + }) except Exception as exc: - return SweepReport(agent=self.name, findings=[], actions=[], error=str(exc)) - return SweepReport(agent=self.name, findings=findings, actions=[], - summary=f'eLearning sweep: {len(findings)} low-completion courses.') + return SweepReport( + agent=self.name, + findings=[{'issue': 'sweep_failed', 'detail': str(exc), 'severity': 'low'}], + ) + return SweepReport(agent=self.name, findings=findings) diff --git a/agent_service/tools/elearning_tools.py b/agent_service/tools/elearning_tools.py index a7f38d9..2c0c8bc 100644 --- a/agent_service/tools/elearning_tools.py +++ b/agent_service/tools/elearning_tools.py @@ -9,6 +9,8 @@ class ElearningTools: def __init__(self, odoo: OdooClient): self._o = odoo + # ── Read ──────────────────────────────────────────────────────────────── + async def get_courses(self, active: bool = True, limit: int = 50) -> list: domain = [('active', '=', active)] fields = ['name', 'description_short', 'website_published', 'total_slides', @@ -42,13 +44,12 @@ class ElearningTools: return await self._o.search_read('slide.channel.partner', domain, fields, limit=limit) async def get_slide_completion(self, channel_id: int, min_completion: float = 0.0) -> list: - partners = await self._o.search_read( + return await self._o.search_read( 'slide.channel.partner', [('channel_id', '=', channel_id), ('channel_completion', '>=', min_completion)], ['partner_id', 'channel_completion', 'last_activity_date'], limit=200, ) - return partners async def get_learning_summary(self) -> dict: channels = await self._o.search_read( @@ -64,24 +65,125 @@ class ElearningTools: 'low_completion_courses': low_completion, } + # ── Create / Write ────────────────────────────────────────────────────── + + async def create_course(self, name: str, description: str = '', + enroll_policy: str = 'public', + website_published: bool = False) -> dict: + """Create a new slide.channel (course). Returns {'id', 'name', 'success'}.""" + vals: dict = { + 'name': name, + 'description_short': description, + 'enroll_policy': enroll_policy, + 'website_published': website_published, + } + result = await self._o.create('slide.channel', vals) + if result.success: + logger.info('elearning: created course id=%s name=%s', result.record_id, name) + return {'id': result.record_id, 'name': name, 'success': True} + return {'success': False, 'error': result.error} + + async def update_course(self, channel_id: int, name: str | None = None, + description: str | None = None, + enroll_policy: str | None = None) -> dict: + """Update fields on an existing course.""" + vals: dict = {} + if name is not None: + vals['name'] = name + if description is not None: + vals['description_short'] = description + if enroll_policy is not None: + vals['enroll_policy'] = enroll_policy + if not vals: + return {'success': False, 'error': 'No values to update'} + result = await self._o.write('slide.channel', [channel_id], vals) + return {'success': result.success, 'error': result.error} + + async def publish_course(self, channel_id: int) -> dict: + """Set website_published=True on a course.""" + result = await self._o.write('slide.channel', [channel_id], {'website_published': True}) + return {'success': result.success, 'error': result.error} + + async def add_section(self, channel_id: int, name: str, sequence: int = 0) -> dict: + """Add a section (category slide with is_category=True) to a course.""" + result = await self._o.create('slide.slide', { + 'channel_id': channel_id, + 'name': name, + 'is_category': True, + 'sequence': sequence, + }) + if result.success: + logger.info('elearning: added section id=%s name=%s channel=%s', result.record_id, name, channel_id) + return {'id': result.record_id, 'name': name, 'success': True} + return {'success': False, 'error': result.error} + + async def create_slide(self, channel_id: int, name: str, + slide_type: str = 'document', sequence: int = 0, + description: str = '', html_content: str = '', + url: str = '') -> dict: + """ + Create a slide/lesson inside a course. + slide_type choices: 'document', 'video', 'infographic', 'webpage', 'quiz' + """ + vals: dict = { + 'channel_id': channel_id, + 'name': name, + 'slide_type': slide_type, + 'sequence': sequence, + } + if description: + vals['description'] = description + if html_content: + vals['html_content'] = html_content + if url: + vals['url'] = url + result = await self._o.create('slide.slide', vals) + if result.success: + logger.info('elearning: created slide id=%s name=%s type=%s channel=%s', + result.record_id, name, slide_type, channel_id) + return {'id': result.record_id, 'name': name, 'slide_type': slide_type, 'success': True} + return {'success': False, 'error': result.error} + + async def enroll_user(self, channel_id: int, partner_id: int) -> dict: + """Enroll a partner in a course (creates slide.channel.partner if not already enrolled).""" + existing = await self._o.search_read( + 'slide.channel.partner', + [('channel_id', '=', channel_id), ('partner_id', '=', partner_id)], + ['id'], limit=1, + ) + if existing: + return {'success': True, 'already_enrolled': True} + result = await self._o.create('slide.channel.partner', { + 'channel_id': channel_id, + 'partner_id': partner_id, + }) + return {'success': result.success, 'error': result.error} + + # ── Notify ────────────────────────────────────────────────────────────── + async def flag_low_completion(self, channel_id: int, reason: str) -> bool: msg = f'[AI FLAG] {reason}' - await self._o.call('slide.channel', 'message_post', [[channel_id]], {'body': msg, 'message_type': 'comment'}) + await self._o.call('slide.channel', 'message_post', [[channel_id]], + {'body': msg, 'message_type': 'comment'}) return True async def suggest_next_course(self, partner_id: int) -> list: completed = await self._o.search_read( 'slide.channel.partner', [('partner_id', '=', partner_id), ('channel_completion', '>=', 90)], - ['channel_id'], - limit=50, + ['channel_id'], limit=50, ) - completed_ids = [c['channel_id'][0] if isinstance(c['channel_id'], list) else c['channel_id'] for c in completed] + completed_ids = [ + c['channel_id'][0] if isinstance(c['channel_id'], list) else c['channel_id'] + for c in completed + ] domain = [('active', '=', True), ('website_published', '=', True)] if completed_ids: domain.append(('id', 'not in', completed_ids)) - return await self._o.search_read('slide.channel', domain, ['name', 'total_slides', 'completion_rate'], limit=5) + return await self._o.search_read( + 'slide.channel', domain, ['name', 'total_slides', 'completion_rate'], limit=5) async def post_chatter_note(self, model: str, record_id: int, note: str) -> bool: - await self._o.call(model, 'message_post', [[record_id]], {'body': note, 'message_type': 'comment'}) + await self._o.call(model, 'message_post', [[record_id]], + {'body': note, 'message_type': 'comment'}) return True