feat(elearning): add course-building capability to elearning agent
- 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_<name> 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 <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,12 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import logging
|
import logging
|
||||||
from .base_agent import BaseAgent, AgentReport, AgentDirective, SweepReport
|
from .base_agent import BaseAgent, AgentReport, SweepReport
|
||||||
from ..tools.elearning_tools import ElearningTools
|
from ..tools.elearning_tools import ElearningTools
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
ELEARNING_TOOLS = [
|
ELEARNING_TOOLS = [
|
||||||
|
# ── Read ──────────────────────────────────────────────────────────────
|
||||||
{'name': 'get_courses', 'description': 'List eLearning courses',
|
{'name': 'get_courses', 'description': 'List eLearning courses',
|
||||||
'parameters': {'active': {'type': 'boolean', 'optional': True},
|
'parameters': {'active': {'type': 'boolean', 'optional': True},
|
||||||
'limit': {'type': 'integer', 'optional': True}}},
|
'limit': {'type': 'integer', 'optional': True}}},
|
||||||
@@ -14,131 +15,283 @@ ELEARNING_TOOLS = [
|
|||||||
{'name': 'get_enrolled_users', 'description': 'Get users enrolled in a course',
|
{'name': 'get_enrolled_users', 'description': 'Get users enrolled in a course',
|
||||||
'parameters': {'channel_id': {'type': 'integer'},
|
'parameters': {'channel_id': {'type': 'integer'},
|
||||||
'limit': {'type': 'integer', 'optional': True}}},
|
'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'},
|
'parameters': {'channel_id': {'type': 'integer'},
|
||||||
'min_completion': {'type': 'number', 'optional': True}}},
|
'min_completion': {'type': 'number', 'optional': True}}},
|
||||||
{'name': 'get_learning_summary', 'description': 'Get overall learning summary', 'parameters': {}},
|
{'name': 'get_learning_summary', 'description': 'Get overall eLearning summary', 'parameters': {}},
|
||||||
{'name': 'flag_low_completion', 'description': 'Flag a course with low completion',
|
# ── 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'}}},
|
'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'}}},
|
'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'},
|
'parameters': {'model': {'type': 'string'}, 'record_id': {'type': 'integer'},
|
||||||
'note': {'type': 'string'}}},
|
'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):
|
class ElearningAgent(BaseAgent):
|
||||||
name = 'elearning_agent'
|
name = 'elearning_agent'
|
||||||
domain = 'elearning'
|
domain = 'elearning'
|
||||||
required_odoo_module = 'website_slides'
|
required_odoo_module = 'website_slides'
|
||||||
system_prompt_file = 'elearning_system.txt'
|
system_prompt_file = ''
|
||||||
tools = ELEARNING_TOOLS
|
tools = ELEARNING_TOOLS
|
||||||
|
|
||||||
def __init__(self, odoo, llm, peer_bus=None):
|
def __init__(self, odoo, llm, peer_bus=None):
|
||||||
super().__init__(odoo, llm, peer_bus)
|
super().__init__(odoo, llm, peer_bus)
|
||||||
self._el = ElearningTools(odoo)
|
self._el = ElearningTools(odoo)
|
||||||
self._gathered_data = {}
|
self._actions_taken: list = []
|
||||||
self._actions_taken = []
|
|
||||||
self._escalations_list = []
|
|
||||||
|
|
||||||
async def _plan(self, directive: AgentDirective) -> dict:
|
# ── BaseAgent lifecycle ─────────────────────────────────────────────────
|
||||||
intent = (directive.intent or '').lower()
|
|
||||||
|
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 {
|
return {
|
||||||
'fetch_summary': any(k in intent for k in ('summary', 'overview', 'learning')),
|
'intent': (
|
||||||
'fetch_courses': 'course' in intent,
|
'create' if any(k in task for k in create_kws) else
|
||||||
'channel_id': directive.context.get('channel_id'),
|
'enroll' if any(k in task for k in enroll_kws) else
|
||||||
'partner_id': directive.context.get('partner_id'),
|
'read'
|
||||||
|
),
|
||||||
|
'channel_id': self._directive.params.get('channel_id'),
|
||||||
|
'partner_id': self._directive.params.get('partner_id'),
|
||||||
}
|
}
|
||||||
|
|
||||||
async def _gather(self, ctx: dict) -> dict:
|
async def _gather(self, plan: dict) -> None:
|
||||||
plan = ctx.get('plan', {})
|
self._gathered['intent'] = plan['intent']
|
||||||
data: dict = {}
|
channel_id = plan.get('channel_id')
|
||||||
data['summary'] = await self._el.get_learning_summary()
|
if plan['intent'] == 'read' and channel_id:
|
||||||
if plan.get('fetch_courses') or plan.get('channel_id'):
|
self._gathered['course_stats'] = await self._el.get_course_stats(channel_id=channel_id)
|
||||||
if plan.get('channel_id'):
|
else:
|
||||||
data['course_stats'] = await self._el.get_course_stats(channel_id=plan['channel_id'])
|
self._gathered['summary'] = await self._el.get_learning_summary()
|
||||||
else:
|
|
||||||
data['courses'] = await self._el.get_courses(limit=20)
|
|
||||||
self._gathered_data = data
|
|
||||||
return data
|
|
||||||
|
|
||||||
async def _reason(self, ctx: dict) -> dict:
|
async def _reason(self) -> dict:
|
||||||
data = self._gathered_data
|
if self._gathered.get('intent') == 'create':
|
||||||
analysis: dict = {'escalations': [], 'low_completion': []}
|
messages = [
|
||||||
summary = data.get('summary', {})
|
{'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', [])
|
low = summary.get('low_completion_courses', [])
|
||||||
analysis['low_completion'] = low
|
return {'intent': 'read', '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
|
|
||||||
|
|
||||||
async def _act(self, ctx: dict) -> list:
|
async def _act(self, reasoning: dict) -> None:
|
||||||
actions = []
|
if reasoning.get('intent') != 'read':
|
||||||
analysis = ctx.get('analysis', {})
|
return
|
||||||
for course in analysis.get('low_completion', [])[:3]:
|
for course in reasoning.get('low_completion', [])[:3]:
|
||||||
try:
|
try:
|
||||||
await self._el.flag_low_completion(
|
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',
|
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:
|
except Exception as exc:
|
||||||
logger.warning('flag_low_completion failed: %s', exc)
|
logger.warning('flag_low_completion failed course_id=%s: %s', course.get('id'), exc)
|
||||||
self._actions_taken = actions
|
|
||||||
return actions
|
|
||||||
|
|
||||||
async def _report(self, ctx: dict) -> AgentReport:
|
async def _report(self) -> AgentReport:
|
||||||
data = self._gathered_data
|
|
||||||
summary = data.get('summary', {})
|
|
||||||
parts = []
|
parts = []
|
||||||
|
summary = self._gathered.get('summary', {})
|
||||||
|
course_stats = self._gathered.get('course_stats', {})
|
||||||
if summary:
|
if summary:
|
||||||
parts.append(
|
parts.append(
|
||||||
f'eLearning: {summary.get("total_courses", 0)} courses, '
|
f'eLearning: {summary.get("total_courses", 0)} courses, '
|
||||||
f'{summary.get("total_enrollments", 0)} enrollments, '
|
f'{summary.get("total_enrollments", 0)} enrollments, '
|
||||||
f'{summary.get("avg_completion", 0):.1f}% avg completion.'
|
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:
|
if not parts:
|
||||||
parts.append('eLearning review complete.')
|
parts.append('eLearning operation complete.')
|
||||||
return AgentReport(agent=self.name, summary=chr(10).join(parts),
|
return AgentReport(
|
||||||
data=data, escalations=self._escalations_list, actions_taken=self._actions_taken)
|
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):
|
# ── Tool dispatch (_run_tool in BaseAgent calls _tool_<name>) ──────────
|
||||||
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)
|
|
||||||
|
|
||||||
async def handle_peer_request(self, request: dict) -> dict:
|
async def _tool_get_courses(self, active: bool = True, limit: int = 50) -> list:
|
||||||
req_type = request.get('type', '')
|
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:
|
try:
|
||||||
if req_type == 'learning_summary':
|
if request_type == 'learning_summary':
|
||||||
return await self._el.get_learning_summary()
|
return {'success': True, **await self._el.get_learning_summary()}
|
||||||
if req_type == 'suggest_courses':
|
if request_type == 'suggest_courses':
|
||||||
return {'courses': await self._el.suggest_next_course(partner_id=request['partner_id'])}
|
partner_id = params.get('partner_id')
|
||||||
return {'error': f'Unknown type: {req_type}'}
|
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:
|
except Exception as exc:
|
||||||
return {'error': str(exc)}
|
return {'success': False, 'error': str(exc)}
|
||||||
|
|
||||||
|
# ── Sweep ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async def sweep(self) -> SweepReport:
|
async def sweep(self) -> SweepReport:
|
||||||
findings = []
|
findings = []
|
||||||
try:
|
try:
|
||||||
summary = await self._el.get_learning_summary()
|
summary = await self._el.get_learning_summary()
|
||||||
for course in summary.get('low_completion_courses', []):
|
for course in summary.get('low_completion_courses', []):
|
||||||
findings.append({'type': 'low_completion', 'course_id': course.get('id'),
|
findings.append({
|
||||||
'name': course.get('name'), 'completion': course.get('completion_rate', 0),
|
'issue': 'low_completion',
|
||||||
'severity': 'medium'})
|
'course_id': course.get('id'),
|
||||||
|
'name': course.get('name'),
|
||||||
|
'completion': course.get('completion_rate', 0),
|
||||||
|
'severity': 'medium',
|
||||||
|
})
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return SweepReport(agent=self.name, findings=[], actions=[], error=str(exc))
|
return SweepReport(
|
||||||
return SweepReport(agent=self.name, findings=findings, actions=[],
|
agent=self.name,
|
||||||
summary=f'eLearning sweep: {len(findings)} low-completion courses.')
|
findings=[{'issue': 'sweep_failed', 'detail': str(exc), 'severity': 'low'}],
|
||||||
|
)
|
||||||
|
return SweepReport(agent=self.name, findings=findings)
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ class ElearningTools:
|
|||||||
def __init__(self, odoo: OdooClient):
|
def __init__(self, odoo: OdooClient):
|
||||||
self._o = odoo
|
self._o = odoo
|
||||||
|
|
||||||
|
# ── Read ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async def get_courses(self, active: bool = True, limit: int = 50) -> list:
|
async def get_courses(self, active: bool = True, limit: int = 50) -> list:
|
||||||
domain = [('active', '=', active)]
|
domain = [('active', '=', active)]
|
||||||
fields = ['name', 'description_short', 'website_published', 'total_slides',
|
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)
|
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:
|
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',
|
'slide.channel.partner',
|
||||||
[('channel_id', '=', channel_id), ('channel_completion', '>=', min_completion)],
|
[('channel_id', '=', channel_id), ('channel_completion', '>=', min_completion)],
|
||||||
['partner_id', 'channel_completion', 'last_activity_date'],
|
['partner_id', 'channel_completion', 'last_activity_date'],
|
||||||
limit=200,
|
limit=200,
|
||||||
)
|
)
|
||||||
return partners
|
|
||||||
|
|
||||||
async def get_learning_summary(self) -> dict:
|
async def get_learning_summary(self) -> dict:
|
||||||
channels = await self._o.search_read(
|
channels = await self._o.search_read(
|
||||||
@@ -64,24 +65,125 @@ class ElearningTools:
|
|||||||
'low_completion_courses': low_completion,
|
'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:
|
async def flag_low_completion(self, channel_id: int, reason: str) -> bool:
|
||||||
msg = f'[AI FLAG] {reason}'
|
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
|
return True
|
||||||
|
|
||||||
async def suggest_next_course(self, partner_id: int) -> list:
|
async def suggest_next_course(self, partner_id: int) -> list:
|
||||||
completed = await self._o.search_read(
|
completed = await self._o.search_read(
|
||||||
'slide.channel.partner',
|
'slide.channel.partner',
|
||||||
[('partner_id', '=', partner_id), ('channel_completion', '>=', 90)],
|
[('partner_id', '=', partner_id), ('channel_completion', '>=', 90)],
|
||||||
['channel_id'],
|
['channel_id'], limit=50,
|
||||||
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)]
|
domain = [('active', '=', True), ('website_published', '=', True)]
|
||||||
if completed_ids:
|
if completed_ids:
|
||||||
domain.append(('id', 'not in', 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:
|
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
|
return True
|
||||||
|
|||||||
Reference in New Issue
Block a user