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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user