feat(course-plan): GE1-style AI course planning with deliverables, resources, media, assignments

Based on UTAS GE1 Course Outline structure (Reading/Writing 10hrs + Listening/Speaking 8hrs)

New Models:
- encoach.course.plan.deliverable: Explicit learning outcome tracking by week/skill
- encoach.course.plan.resource.dep: Resource dependencies (textbooks, videos, etc.)
- encoach.course.plan.assignment: Assign plans to classes/students with progress tracking
- encoach.course.plan.assignment.deliverable: Per-student deliverable completion status

Extended Models:
- course.plan.material: Added media fields (media_type, media_asset_url, media_asset_id,
  media_generation_prompt, media_metadata_json) for rich content
- New material types: video_lesson, audio_recording, image_visual, interactive, assessment

AI Agent Tools (agent_tools.py):
- deliverables.detect: Parse course outlines (like GE1 PDF) and extract structured outcomes
- deliverables.fetch: Get deliverables for AI to reference when generating
- resources.fetch: Check available resources before generating content
- resources.save: Persist resource dependencies
- media.suggest_visuals: AI suggests images/diagrams for materials
- media.generate_image: Generate educational images (DALL-E integration ready)
- media.generate_audio: Generate TTS audio (ElevenLabs/Polly integration ready)
- assignment.*: Create assignments and track progress

Pipeline Enhancements (course_plan_pipeline.py):
- generate_deliverables_from_outline(): Parse PDF/text outlines into structured deliverables
- generate_week_materials_with_resources(): Resource-aware content generation
- suggest_media_for_material(): AI visual aid suggestions
- generate_media_for_material(): Actual image/audio generation

New AI Agents (agents_defaults.xml):
- deliverable_detector: Parses GE1-style outlines, extracts deliverables week-by-week
- media_generator: Creates images/audio for teaching materials
- Updated course_planner & course_week_materials with resource tools

REST APIs (course_plan.py):
POST /api/ai/course-plan/<id>/deliverables/detect - Parse outline
GET  /api/ai/course-plan/<id>/deliverables - List deliverables
PUT  /api/ai/course-plan/deliverables/<id> - Update status

GET  /api/ai/course-plan/<id>/resources - List resources
POST /api/ai/course-plan/<id>/resources - Add resource

POST /api/ai/course-plan/materials/<id>/media/suggest - Get visual suggestions
POST /api/ai/course-plan/materials/<id>/media/generate - Generate image/audio

POST /api/ai/course-plan/<id>/assignments - Assign to class/student
GET  /api/ai/course-plan/<id>/assignments - List assignments
GET  /api/ai/course-plan/assignments/<id> - Get with progress
PUT  /api/ai/course-plan/assignments/<id>/deliverables/<del_id> - Update status

Security: Added ir.model.access.csv entries for all new models
Made-with: Cursor
This commit is contained in:
Yamen Ahmad
2026-04-25 14:57:04 +04:00
parent 882179870c
commit 1dd1168fee
6 changed files with 1587 additions and 5 deletions

View File

@@ -6,9 +6,10 @@ endpoints. Every route is JWT-guarded via the shared ``@jwt_required``
decorator and returns JSON.
"""
import json
import logging
from odoo import http
from odoo import http, fields
from odoo.http import request
from odoo.addons.encoach_api.controllers.base import (
jwt_required,
@@ -169,3 +170,315 @@ class CoursePlanController(http.Controller):
except Exception as exc:
_logger.exception('course-plan.list_week_materials failed')
return _error_response(str(exc), 500)
# ==================================================================
# NEW: Deliverable Detection & Management
# ==================================================================
# POST /api/ai/course-plan/<id>/deliverables/detect
@http.route('/api/ai/course-plan/<int:plan_id>/deliverables/detect',
type='http', auth='none', methods=['POST'], csrf=False)
@jwt_required
def detect_deliverables(self, plan_id, **kw):
"""Parse course outline text and extract structured deliverables."""
try:
body = _get_json_body()
outline_text = body.get('outline_text', '').strip()
if not outline_text:
return _error_response('outline_text is required', 400)
pipeline = CoursePlanPipeline(
request.env, language=_request_language(),
)
result = pipeline.generate_deliverables_from_outline(plan_id, outline_text)
return _json_response(result)
except ValueError as exc:
return _error_response(str(exc), 404)
except Exception as exc:
_logger.exception('course-plan.detect_deliverables failed')
return _error_response(str(exc), 500)
# GET /api/ai/course-plan/<id>/deliverables
@http.route('/api/ai/course-plan/<int:plan_id>/deliverables',
type='http', auth='none', methods=['GET'], csrf=False)
@jwt_required
def list_deliverables(self, plan_id, **kw):
"""List deliverables for a course plan."""
try:
params = request.httprequest.args
domain = [('plan_id', '=', int(plan_id))]
week = params.get('week')
if week:
domain.append(('week_number', '=', int(week)))
skill = params.get('skill')
if skill:
domain.append(('skill', '=', skill))
Deliverable = request.env['encoach.course.plan.deliverable'].sudo()
records = Deliverable.search(domain, order='week_number, code')
return _json_response({
'items': [d.to_api_dict() for d in records],
'count': len(records),
})
except Exception as exc:
_logger.exception('course-plan.list_deliverables failed')
return _error_response(str(exc), 500)
# PUT /api/ai/course-plan/deliverables/<id>
@http.route('/api/ai/course-plan/deliverables/<int:deliverable_id>',
type='http', auth='none', methods=['PUT'], csrf=False)
@jwt_required
def update_deliverable(self, deliverable_id, **kw):
"""Update deliverable status or details."""
try:
body = _get_json_body()
Deliverable = request.env['encoach.course.plan.deliverable'].sudo()
rec = Deliverable.browse(int(deliverable_id))
if not rec.exists():
return _error_response('Deliverable not found', 404)
updatable = {}
if 'status' in body:
updatable['status'] = body['status']
if 'target_date' in body:
updatable['target_date'] = body['target_date']
if 'description' in body:
updatable['description'] = body['description']
if updatable:
rec.write(updatable)
return _json_response({'data': rec.to_api_dict()})
except Exception as exc:
_logger.exception('course-plan.update_deliverable failed')
return _error_response(str(exc), 500)
# ==================================================================
# NEW: Resource Dependencies
# ==================================================================
# GET /api/ai/course-plan/<id>/resources
@http.route('/api/ai/course-plan/<int:plan_id>/resources',
type='http', auth='none', methods=['GET'], csrf=False)
@jwt_required
def list_resources(self, plan_id, **kw):
"""List resource dependencies for a course plan."""
try:
params = request.httprequest.args
domain = [('plan_id', '=', int(plan_id))]
resource_type = params.get('type')
if resource_type:
domain.append(('resource_type', '=', resource_type))
ResourceDep = request.env['encoach.course.plan.resource.dep'].sudo()
records = ResourceDep.search(domain)
return _json_response({
'items': [r.to_api_dict() for r in records],
'count': len(records),
})
except Exception as exc:
_logger.exception('course-plan.list_resources failed')
return _error_response(str(exc), 500)
# POST /api/ai/course-plan/<id>/resources
@http.route('/api/ai/course-plan/<int:plan_id>/resources',
type='http', auth='none', methods=['POST'], csrf=False)
@jwt_required
def add_resource(self, plan_id, **kw):
"""Add a resource dependency to a course plan."""
try:
body = _get_json_body()
if not body.get('name'):
return _error_response('name is required', 400)
ResourceDep = request.env['encoach.course.plan.resource.dep'].sudo()
rec = ResourceDep.create({
'plan_id': int(plan_id),
'name': body['name'],
'resource_type': body.get('resource_type', 'textbook'),
'citation': body.get('citation', ''),
'url': body.get('url', ''),
'ai_usage_notes': body.get('ai_usage_notes', ''),
'is_required': body.get('is_required', True),
'extracted_content_json': json.dumps(body.get('extracted_content', {})),
})
return _json_response({'data': rec.to_api_dict()})
except Exception as exc:
_logger.exception('course-plan.add_resource failed')
return _json_response(str(exc), 500)
# ==================================================================
# NEW: Rich Media Generation
# ==================================================================
# POST /api/ai/course-plan/materials/<id>/media/suggest
@http.route('/api/ai/course-plan/materials/<int:material_id>/media/suggest',
type='http', auth='none', methods=['POST'], csrf=False)
@jwt_required
def suggest_media(self, material_id, **kw):
"""Get AI suggestions for media that would enhance this material."""
try:
pipeline = CoursePlanPipeline(
request.env, language=_request_language(),
)
result = pipeline.suggest_media_for_material(material_id)
if 'error' in result:
return _error_response(result['error'], 400)
return _json_response(result)
except ValueError as exc:
return _error_response(str(exc), 404)
except Exception as exc:
_logger.exception('course-plan.suggest_media failed')
return _error_response(str(exc), 500)
# POST /api/ai/course-plan/materials/<id>/media/generate
@http.route('/api/ai/course-plan/materials/<int:material_id>/media/generate',
type='http', auth='none', methods=['POST'], csrf=False)
@jwt_required
def generate_media(self, material_id, **kw):
"""Generate media (image, audio) for a teaching material."""
try:
body = _get_json_body()
media_type = body.get('media_type', 'image')
pipeline = CoursePlanPipeline(
request.env, language=_request_language(),
)
result = pipeline.generate_media_for_material(material_id, media_type)
if 'error' in result:
return _error_response(result['error'], 400)
return _json_response(result)
except ValueError as exc:
return _error_response(str(exc), 404)
except Exception as exc:
_logger.exception('course-plan.generate_media failed')
return _error_response(str(exc), 500)
# ==================================================================
# NEW: Course Plan Assignment to Classes/Students
# ==================================================================
# POST /api/ai/course-plan/<id>/assignments
@http.route('/api/ai/course-plan/<int:plan_id>/assignments',
type='http', auth='none', methods=['POST'], csrf=False)
@jwt_required
def create_assignment(self, plan_id, **kw):
"""Assign a course plan to a class or student."""
try:
body = _get_json_body()
Assignment = request.env['encoach.course.plan.assignment'].sudo()
vals = {
'plan_id': int(plan_id),
'assignment_type': body.get('assignment_type', 'class'),
'delivery_mode': body.get('delivery_mode', 'sequential'),
'status': 'scheduled',
}
if body.get('batch_id'):
vals['batch_id'] = int(body['batch_id'])
if body.get('student_id'):
vals['student_id'] = int(body['student_id'])
if body.get('start_date'):
vals['start_date'] = body['start_date']
assignment = Assignment.create(vals)
# Create deliverable tracking rows
Deliverable = request.env['encoach.course.plan.deliverable'].sudo()
AssignmentDeliverable = request.env['encoach.course.plan.assignment.deliverable'].sudo()
deliverables = Deliverable.search([('plan_id', '=', int(plan_id))])
for d in deliverables:
AssignmentDeliverable.create({
'assignment_id': assignment.id,
'deliverable_id': d.id,
'status': 'not_started',
})
return _json_response({
'data': assignment.to_api_dict(),
'deliverables_tracked': len(deliverables),
})
except Exception as exc:
_logger.exception('course-plan.create_assignment failed')
return _error_response(str(exc), 500)
# GET /api/ai/course-plan/<id>/assignments
@http.route('/api/ai/course-plan/<int:plan_id>/assignments',
type='http', auth='none', methods=['GET'], csrf=False)
@jwt_required
def list_assignments(self, plan_id, **kw):
"""List assignments for a course plan."""
try:
Assignment = request.env['encoach.course.plan.assignment'].sudo()
records = Assignment.search([('plan_id', '=', int(plan_id))])
return _json_response({
'items': [a.to_api_dict() for a in records],
'count': len(records),
})
except Exception as exc:
_logger.exception('course-plan.list_assignments failed')
return _error_response(str(exc), 500)
# GET /api/ai/course-plan/assignments/<id>
@http.route('/api/ai/course-plan/assignments/<int:assignment_id>',
type='http', auth='none', methods=['GET'], csrf=False)
@jwt_required
def get_assignment(self, assignment_id, **kw):
"""Get assignment details with progress."""
try:
Assignment = request.env['encoach.course.plan.assignment'].sudo()
AssignmentDeliverable = request.env['encoach.course.plan.assignment.deliverable'].sudo()
assignment = Assignment.browse(int(assignment_id))
if not assignment.exists():
return _error_response('Assignment not found', 404)
# Get deliverable tracking
tracking = AssignmentDeliverable.search([('assignment_id', '=', int(assignment_id))])
status_counts = {}
for t in tracking:
status_counts[t.status] = status_counts.get(t.status, 0) + 1
return _json_response({
'data': assignment.to_api_dict(),
'deliverables': {
'total': len(tracking),
'by_status': status_counts,
'items': [t.to_api_dict() for t in tracking],
},
})
except Exception as exc:
_logger.exception('course-plan.get_assignment failed')
return _error_response(str(exc), 500)
# PUT /api/ai/course-plan/assignments/<id>/deliverables/<del_id>
@http.route('/api/ai/course-plan/assignments/<int:assignment_id>/deliverables/<int:deliverable_id>',
type='http', auth='none', methods=['PUT'], csrf=False)
@jwt_required
def update_assignment_deliverable(self, assignment_id, deliverable_id, **kw):
"""Update deliverable status for an assignment."""
try:
body = _get_json_body()
AssignmentDeliverable = request.env['encoach.course.plan.assignment.deliverable'].sudo()
rec = AssignmentDeliverable.search([
('assignment_id', '=', int(assignment_id)),
('deliverable_id', '=', int(deliverable_id)),
], limit=1)
if not rec:
return _error_response('Assignment deliverable not found', 404)
vals = {}
if 'status' in body:
vals['status'] = body['status']
if 'score' in body:
vals['score'] = float(body['score'])
if 'notes' in body:
vals['notes'] = body['notes']
if body.get('status') == 'completed':
vals['completion_date'] = fields.Datetime.now()
rec.write(vals)
return _json_response({'data': rec.to_api_dict()})
except Exception as exc:
_logger.exception('course-plan.update_assignment_deliverable failed')
return _error_response(str(exc), 500)