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

@@ -44,10 +44,28 @@ MATERIAL_TYPE_SELECTION = [
('grammar_lesson', 'Grammar Lesson'),
('vocabulary_list', 'Vocabulary List'),
('practice', 'Practice Exercises'),
# Rich media types for comprehensive teaching materials
('video_lesson', 'Video Lesson'),
('audio_recording', 'Audio Recording'),
('image_visual', 'Image / Visual Aid'),
('interactive', 'Interactive Activity'),
('assessment', 'Assessment / Quiz'),
('other', 'Other'),
]
# Deliverable status tracking
DELIVERABLE_STATUS_SELECTION = [
('planned', 'Planned'),
('generating', 'Generating'),
('generated', 'Generated'),
('reviewing', 'Under Review'),
('approved', 'Approved'),
('delivered', 'Delivered to Students'),
('archived', 'Archived'),
]
class CoursePlan(models.Model):
_name = 'encoach.course.plan'
_description = 'AI-generated Course Plan'
@@ -260,9 +278,53 @@ class CoursePlanMaterial(models.Model):
except (TypeError, ValueError):
return default
def to_api_dict(self):
# Rich media asset fields for generated content
media_type = fields.Selection([
('none', 'No Media'),
('image', 'Image (AI Generated)'),
('audio', 'Audio (AI Generated)'),
('video', 'Video (AI Generated)'),
('external', 'External Media URL'),
], default='none', string='Media Type')
media_asset_url = fields.Char(
string='Media Asset URL',
help='URL to stored/generated media (image, audio, video)'
)
media_asset_id = fields.Many2one(
'ir.attachment',
string='Media Attachment',
help='Stored media file in Odoo'
)
media_generation_prompt = fields.Text(
string='Media Generation Prompt',
help='The prompt used to generate this media asset'
)
media_metadata_json = fields.Text(
string='Media Metadata',
help='JSON with dimensions, duration, file size, etc.'
)
# Resource dependencies tracking
required_resources_json = fields.Text(
string='Required Resources',
help='JSON list of resource IDs/URLs this material depends on'
)
ai_generation_notes = fields.Text(
string='AI Generation Notes',
help='Internal notes from AI about generation decisions'
)
def _loads(self, raw, default):
if not raw:
return default
try:
return json.loads(raw)
except (TypeError, ValueError):
return default
def to_api_dict(self, include_media=False):
self.ensure_one()
return {
data = {
'id': self.id,
'plan_id': self.plan_id.id,
'week_id': self.week_id.id if self.week_id else None,
@@ -273,4 +335,315 @@ class CoursePlanMaterial(models.Model):
'summary': self.summary or '',
'body': self._loads(self.body_json, {}),
'body_text': self.body_text or '',
'media_type': self.media_type or 'none',
'required_resources': self._loads(self.required_resources_json, []),
}
if include_media:
data['media_asset_url'] = self.media_asset_url or ''
data['media_metadata'] = self._loads(self.media_metadata_json, {})
data['media_generation_prompt'] = self.media_generation_prompt or ''
return data
class CoursePlanDeliverable(models.Model):
"""Explicit deliverable tracking for GE1-style course planning.
Each deliverable represents a specific learning outcome that must be
achieved by students. The AI uses these to generate targeted materials
and track completion status.
"""
_name = 'encoach.course.plan.deliverable'
_description = 'Course Plan Deliverable (Learning Outcome)'
_order = 'plan_id, week_number, skill, code'
plan_id = fields.Many2one(
'encoach.course.plan', required=True, ondelete='cascade', index=True,
)
week_id = fields.Many2one(
'encoach.course.plan.week', ondelete='cascade', index=True,
)
week_number = fields.Integer(required=True, index=True)
# GE1-style outcome identification
code = fields.Char(required=True, index=True, string='Outcome Code')
skill = fields.Selection(SKILL_SELECTION, required=True, index=True)
description = fields.Text(required=True)
cefr_level = fields.Selection([
('pre_a1', 'Pre-A1'), ('a1', 'A1'), ('a2', 'A2'),
('b1', 'B1'), ('b2', 'B2'), ('c1', 'C1'), ('c2', 'C2'),
], string='CEFR Level')
# Deliverable status and tracking
status = fields.Selection(DELIVERABLE_STATUS_SELECTION, default='planned')
target_date = fields.Date(string='Target Delivery Date')
completion_date = fields.Datetime(string='Completed At')
# AI generation tracking
ai_generated = fields.Boolean(default=False, string='AI Generated')
generation_prompt = fields.Text(string='Generation Prompt Used')
# Resource dependencies for this specific deliverable
resource_dependencies_json = fields.Text(
string='Required Resources',
help='JSON list of {resource_id, name, type, required_for} objects'
)
material_ids = fields.Many2many(
'encoach.course.plan.material',
'deliverable_material_rel',
'deliverable_id', 'material_id',
string='Generated Materials',
help='Materials generated to support this deliverable'
)
def to_api_dict(self):
self.ensure_one()
return {
'id': self.id,
'plan_id': self.plan_id.id,
'week_number': self.week_number,
'code': self.code or '',
'skill': self.skill or '',
'description': self.description or '',
'cefr_level': self.cefr_level or '',
'status': self.status or 'planned',
'target_date': str(self.target_date) if self.target_date else None,
'ai_generated': self.ai_generated,
'resources': json.loads(self.resource_dependencies_json or '[]'),
'material_ids': [m.id for m in self.material_ids],
}
class CoursePlanResourceDependency(models.Model):
"""Track what resources the AI depends on for course generation.
This model stores the relationship between course plans and the external
resources (textbooks, videos, etc.) that the AI references when generating
content. It enables the system to check resource availability before
generation and suggest alternatives if resources are missing.
"""
_name = 'encoach.course.plan.resource.dep'
_description = 'Course Plan Resource Dependency'
_order = 'plan_id, resource_type, name'
plan_id = fields.Many2one(
'encoach.course.plan', required=True, ondelete='cascade', index=True,
)
name = fields.Char(required=True, string='Resource Name')
resource_type = fields.Selection([
('textbook', 'Textbook'),
('workbook', 'Workbook'),
('video', 'Video / Film'),
('audio', 'Audio Recording'),
('website', 'Website / Online Resource'),
('software', 'Software / App'),
('assessment', 'Assessment Bank'),
('other', 'Other'),
], required=True, default='textbook')
# Resource identification
citation = fields.Text(string='Full Citation')
isbn = fields.Char(string='ISBN / Identifier')
url = fields.Char(string='URL / Link')
file_attachment_id = fields.Many2one('ir.attachment', string='Attached File')
# AI dependency tracking
is_required = fields.Boolean(default=True, string='Required for Generation')
is_available = fields.Boolean(default=False, string='Resource Available')
ai_usage_notes = fields.Text(
string='AI Usage Notes',
help='How the AI should use this resource (e.g., "extract dialogue scripts", "reference grammar explanations")'
)
# Content extraction for AI consumption
extracted_content_json = fields.Text(
string='Extracted Content for AI',
help='JSON with key excerpts, chapter summaries, or structured content the AI can reference'
)
# Status
status = fields.Selection([
('needed', 'Needed'),
('sourcing', 'Sourcing'),
('available', 'Available'),
('extracted', 'Content Extracted'),
('unavailable', 'Unavailable'),
('replaced', 'Replaced with Alternative'),
], default='needed', string='Status')
def to_api_dict(self):
self.ensure_one()
return {
'id': self.id,
'plan_id': self.plan_id.id,
'name': self.name or '',
'resource_type': self.resource_type or '',
'citation': self.citation or '',
'is_required': self.is_required,
'is_available': self.is_available,
'status': self.status or 'needed',
'ai_usage_notes': self.ai_usage_notes or '',
'extracted_content': json.loads(self.extracted_content_json or '{}'),
}
class CoursePlanAssignment(models.Model):
"""Assignment of a course plan to classes or individual students.
After a course plan is generated and approved, it can be assigned to
delivery targets (classes, batches, or individual students). This model
tracks the assignment, delivery progress, and completion status.
"""
_name = 'encoach.course.plan.assignment'
_description = 'Course Plan Assignment'
_order = 'create_date desc, id desc'
plan_id = fields.Many2one(
'encoach.course.plan', required=True, ondelete='cascade', index=True,
)
name = fields.Char(compute='_compute_name', store=True)
# Assignment target
assignment_type = fields.Selection([
('class', 'Class / Batch'),
('student', 'Individual Student'),
('group', 'Custom Group'),
], required=True, default='class')
# Link to OpenEduCat models
course_id = fields.Many2one('op.course', string='Course', index=True)
batch_id = fields.Many2one('op.batch', string='Batch / Class', index=True)
student_id = fields.Many2one('op.student', string='Student', index=True)
# Assignment metadata
assigned_by_id = fields.Many2one('res.users', string='Assigned By', default=lambda s: s.env.uid)
assigned_date = fields.Datetime(default=fields.Datetime.now, string='Assigned At')
start_date = fields.Date(string='Start Date')
end_date = fields.Date(string='End Date')
# Delivery status
status = fields.Selection([
('draft', 'Draft'),
('scheduled', 'Scheduled'),
('active', 'Active - In Progress'),
('paused', 'Paused'),
('completed', 'Completed'),
('cancelled', 'Cancelled'),
], default='draft', string='Assignment Status')
# Progress tracking
current_week = fields.Integer(default=1, string='Current Week')
progress_percent = fields.Float(compute='_compute_progress', store=True, string='Progress %')
# Delivery configuration
delivery_mode = fields.Selection([
('sequential', 'Sequential (Week by Week)'),
('parallel', 'Parallel (All Skills)'),
('adaptive', 'Adaptive (AI-Paced)'),
], default='sequential', string='Delivery Mode')
# Notes
notes = fields.Text(string='Assignment Notes')
# Related records
deliverable_ids = fields.One2many(
'encoach.course.plan.assignment.deliverable',
'assignment_id',
string='Deliverable Tracking',
)
@api.depends('plan_id', 'batch_id', 'student_id')
def _compute_name(self):
for rec in self:
parts = []
if rec.plan_id:
parts.append(rec.plan_id.name)
if rec.batch_id:
parts.append(f"{rec.batch_id.name}")
if rec.student_id:
parts.append(f"{rec.student_id.name}")
rec.name = ' '.join(parts) if parts else 'Assignment'
@api.depends('current_week', 'plan_id.total_weeks')
def _compute_progress(self):
for rec in self:
total = rec.plan_id.total_weeks or 1
rec.progress_percent = min(100.0, (rec.current_week / total) * 100)
def to_api_dict(self):
self.ensure_one()
return {
'id': self.id,
'name': self.name or '',
'plan_id': self.plan_id.id if self.plan_id else None,
'plan_name': self.plan_id.name if self.plan_id else '',
'assignment_type': self.assignment_type or '',
'batch_id': self.batch_id.id if self.batch_id else None,
'batch_name': self.batch_id.name if self.batch_id else '',
'student_id': self.student_id.id if self.student_id else None,
'student_name': self.student_id.name if self.student_id else '',
'status': self.status or 'draft',
'start_date': str(self.start_date) if self.start_date else None,
'end_date': str(self.end_date) if self.end_date else None,
'current_week': self.current_week,
'progress_percent': self.progress_percent,
'delivery_mode': self.delivery_mode or 'sequential',
}
class CoursePlanAssignmentDeliverable(models.Model):
"""Per-assignment tracking of deliverable completion.
When a course plan is assigned, this creates a tracking row for each
deliverable so instructors can see which students have completed which
learning outcomes.
"""
_name = 'encoach.course.plan.assignment.deliverable'
_description = 'Assignment Deliverable Status'
_order = 'assignment_id, week_number, code'
assignment_id = fields.Many2one(
'encoach.course.plan.assignment', required=True, ondelete='cascade', index=True,
)
deliverable_id = fields.Many2one(
'encoach.course.plan.deliverable', required=True, ondelete='cascade', index=True,
)
plan_id = fields.Many2one(
related='assignment_id.plan_id', store=True, index=True,
)
# Copy of deliverable info for quick reference
week_number = fields.Integer(related='deliverable_id.week_number', store=True)
code = fields.Char(related='deliverable_id.code', store=True)
skill = fields.Selection(related='deliverable_id.skill', store=True)
description = fields.Text(related='deliverable_id.description', store=True)
# Student completion tracking
status = fields.Selection([
('not_started', 'Not Started'),
('in_progress', 'In Progress'),
('submitted', 'Submitted for Review'),
('completed', 'Completed'),
('exempt', 'Exempt'),
], default='not_started', string='Student Status')
# Progress details
completion_date = fields.Datetime(string='Completed At')
completed_by_id = fields.Many2one('res.users', string='Completed By')
evidence_url = fields.Char(string='Evidence / Submission URL')
score = fields.Float(string='Assessment Score')
notes = fields.Text(string='Instructor Notes')
def to_api_dict(self):
self.ensure_one()
return {
'id': self.id,
'assignment_id': self.assignment_id.id,
'deliverable_id': self.deliverable_id.id,
'week_number': self.week_number,
'code': self.code or '',
'skill': self.skill or '',
'description': self.description or '',
'status': self.status or 'not_started',
'completion_date': self.completion_date.isoformat() if self.completion_date else None,
'score': self.score or 0.0,
}