diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e6f06aa..f29c987 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -216,8 +216,12 @@ Each agent follows the same lifecycle: `execute()` → `_plan()` → `_act()` | `employees_agent` | `hr.employee`, `hr.leave` | Headcount, leave, HR queries | | `odoo_doc_agent` | RAG (Qdrant) | Odoo documentation Q&A | -**`elearning_agent`** — defined but not registered: exceeds the 8-tool limit per -agent. Needs to be split into contextual tool groups before it can be used. +**`elearning_agent`** — registered. Reduced from 14 → 8 tools by merging the three +per-course read calls (`get_course_stats`, `get_enrolled_users`, `get_slide_completion`) +into `get_course_details`, folding `publish_course` into `update_course` via a +`website_published` param, and dropping `flag_low_completion` (superseded by +`post_chatter_note`) and `suggest_next_course` (still available internally via the +peer-bus `suggest_courses` request). --- diff --git a/agent_service/agents/elearning_agent.py b/agent_service/agents/elearning_agent.py index 9e8136a..405eafd 100644 --- a/agent_service/agents/elearning_agent.py +++ b/agent_service/agents/elearning_agent.py @@ -10,15 +10,9 @@ ELEARNING_TOOLS = [ {'name': 'get_courses', 'description': 'List eLearning courses', 'parameters': {'active': {'type': 'boolean', 'optional': True}, 'limit': {'type': 'integer', 'optional': True}}}, - {'name': 'get_course_stats', 'description': 'Get detailed stats for a course', + {'name': 'get_course_details', + 'description': 'Get full details for a course: stats, enrolled users, and per-slide completion', 'parameters': {'channel_id': {'type': 'integer'}}}, - {'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 rates by user', - 'parameters': {'channel_id': {'type': 'integer'}, - 'min_completion': {'type': 'number', 'optional': True}}}, - {'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.', @@ -27,14 +21,14 @@ ELEARNING_TOOLS = [ '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', + 'description': ( + 'Update an existing course. Pass website_published=True to publish it to the website.' + ), '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'}}}, + 'enroll_policy': {'type': 'string', 'optional': True}, + 'website_published': {'type': 'boolean', 'optional': True}}}, {'name': 'add_section', 'description': 'Add a section (category) to a course. Sections group slides into topics.', 'parameters': {'channel_id': {'type': 'integer'}, @@ -58,14 +52,8 @@ ELEARNING_TOOLS = [ '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 the next course for a learner based on completed courses', - 'parameters': {'partner_id': {'type': 'integer'}}}, {'name': 'post_chatter_note', - 'description': 'Post a note on any Odoo record', + 'description': 'Post a note or flag on any Odoo record (use model="slide.channel" for courses)', 'parameters': {'model': {'type': 'string'}, 'record_id': {'type': 'integer'}, 'note': {'type': 'string'}}}, ] @@ -84,7 +72,7 @@ When asked to build a course, follow this exact workflow: - '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. +4. Call update_course with website_published=True 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. @@ -124,7 +112,7 @@ class ElearningAgent(BaseAgent): 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) + self._gathered['course_details'] = await self._el.get_course_details(channel_id=channel_id) else: self._gathered['summary'] = await self._el.get_learning_summary() @@ -146,10 +134,10 @@ class ElearningAgent(BaseAgent): return for course in reasoning.get('low_completion', [])[:3]: try: - await self._el.flag_low_completion( - channel_id=course['id'], - reason=f'Completion rate {course.get("completion_rate", 0):.1f}% is below 30% threshold', - ) + note = (f'[AI FLAG] Completion rate {course.get("completion_rate", 0):.1f}%' + f' is below 30% threshold') + await self._el.post_chatter_note( + model='slide.channel', record_id=course['id'], note=note) self._actions_taken.append({'action': 'flag_low_completion', 'course_id': course['id']}) except Exception as exc: logger.warning('flag_low_completion failed course_id=%s: %s', course.get('id'), exc) @@ -157,16 +145,16 @@ class ElearningAgent(BaseAgent): async def _report(self) -> AgentReport: parts = [] summary = self._gathered.get('summary', {}) - course_stats = self._gathered.get('course_stats', {}) + course_details = self._gathered.get('course_details', {}) 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.') + if course_details: + ch = course_details.get('channel', {}) + parts.append(f'Course "{ch.get("name", "?")}": {course_details.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) @@ -187,17 +175,8 @@ class ElearningAgent(BaseAgent): 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_get_course_details(self, channel_id: int) -> dict: + return await self._el.get_course_details(channel_id=channel_id) async def _tool_create_course(self, name: str, description: str = '', enroll_policy: str = 'public', @@ -210,19 +189,15 @@ class ElearningAgent(BaseAgent): return result async def _tool_update_course(self, channel_id: int, name: str = None, - description: str = None, enroll_policy: str = None) -> dict: + description: str = None, enroll_policy: str = None, + website_published: bool = None) -> dict: result = await self._el.update_course(channel_id=channel_id, name=name, - description=description, enroll_policy=enroll_policy) + description=description, enroll_policy=enroll_policy, + website_published=website_published) 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'): @@ -248,13 +223,6 @@ class ElearningAgent(BaseAgent): '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} diff --git a/agent_service/tools/elearning_tools.py b/agent_service/tools/elearning_tools.py index 2c0c8bc..cd9f18a 100644 --- a/agent_service/tools/elearning_tools.py +++ b/agent_service/tools/elearning_tools.py @@ -83,10 +83,18 @@ class ElearningTools: return {'id': result.record_id, 'name': name, 'success': True} return {'success': False, 'error': result.error} + async def get_course_details(self, channel_id: int) -> dict: + """Combined course stats + enrolled users + per-slide completion.""" + stats = await self.get_course_stats(channel_id=channel_id) + enrolled = await self.get_enrolled_users(channel_id=channel_id) + completion = await self.get_slide_completion(channel_id=channel_id) + return {**stats, 'enrolled_users': enrolled, 'slide_completion': completion} + 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.""" + enroll_policy: str | None = None, + website_published: bool | None = None) -> dict: + """Update fields on an existing course. Pass website_published=True to publish.""" vals: dict = {} if name is not None: vals['name'] = name @@ -94,16 +102,13 @@ class ElearningTools: vals['description_short'] = description if enroll_policy is not None: vals['enroll_policy'] = enroll_policy + if website_published is not None: + vals['website_published'] = website_published 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', {