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

@@ -1,6 +1,6 @@
"""Course plan generation pipeline.
Two public entry points:
Three public entry points:
* :py:meth:`generate_plan` — given a short brief (course title, CEFR level,
duration, skill coverage, grammar focus, resources), produce a full
@@ -14,11 +14,22 @@ Two public entry points:
+ practice, writing prompt, vocabulary list) and persist each as an
:py:class:`encoach.course.plan.material` row.
* :py:meth:`generate_deliverables_from_outline` — NEW: Parse a course
outline (like GE1 PDF) and extract structured deliverables (learning
outcomes) that the AI uses to generate targeted materials.
* :py:meth:`generate_rich_media` — NEW: Generate images, audio, or video
to accompany teaching materials.
We deliberately ask the LLM to return strict JSON and then normalise it
server-side — the frontend gets a stable shape no matter how loose the
model's output is. Any parse failure is swallowed and reported back
through the standard error channel so the caller can retry without the
server crashing.
Resource-aware generation: Before generating materials, the pipeline
fetches registered resources (textbooks, videos) and includes them in the
prompt so the AI can reference real content instead of hallucinating.
"""
import json
@@ -188,6 +199,12 @@ class CoursePlanPipeline:
# Decide once per instance whether to route through the LangGraph
# AgentRuntime or fall back to the direct chat_json path.
self._use_agent = self._resolve_agent_flag(env)
# Import agent tools for resource-aware generation
try:
from odoo.addons.encoach_ai.services.agent_tools import invoke as agent_invoke
self._agent_invoke = agent_invoke
except ImportError:
self._agent_invoke = None
@staticmethod
def _resolve_agent_flag(env):
@@ -494,3 +511,291 @@ class CoursePlanPipeline:
elif isinstance(value, dict):
lines.append(f"{key}: " + json.dumps(value, ensure_ascii=False))
return "\n".join(lines)
# ------------------------------------------------------------------
# NEW: Deliverable detection from course outlines (GE1-style)
# ------------------------------------------------------------------
def generate_deliverables_from_outline(self, plan_id, course_outline_text):
"""Parse a course outline (PDF/text) and extract structured deliverables.
Creates :py:class:`encoach.course.plan.deliverable` rows that
the AI uses when generating week materials.
"""
plan = self.env['encoach.course.plan'].sudo().browse(int(plan_id))
if not plan.exists():
raise ValueError('Plan not found')
# Use agent tool to detect deliverables
if self._agent_invoke:
result = self._agent_invoke(
self.env, "deliverables.detect",
{
"course_outline_text": course_outline_text,
"cefr_level": plan.cefr_level,
"total_weeks": plan.total_weeks,
}
)
if result.get("ok"):
# Create deliverable records
Deliverable = self.env['encoach.course.plan.deliverable'].sudo()
created = []
for d in result.get("deliverables", []):
try:
rec = Deliverable.create({
'plan_id': plan.id,
'week_number': int(d.get('week_number', 1)),
'code': d.get('code', ''),
'skill': d.get('skill', 'integrated'),
'description': d.get('description', ''),
'cefr_level': d.get('cefr_level', plan.cefr_level),
'resource_dependencies_json': json.dumps(d.get('resource_hints', []), ensure_ascii=False),
'ai_generated': True,
'generation_prompt': f"Extracted from course outline: {course_outline_text[:200]}...",
})
created.append(rec)
except Exception as exc:
_logger.warning("Failed to create deliverable: %s", exc)
# Save resource dependencies too
ResourceDep = self.env['encoach.course.plan.resource.dep'].sudo()
for r in result.get("resources_needed", []):
try:
ResourceDep.create({
'plan_id': plan.id,
'name': r.get('title', 'Unknown Resource'),
'resource_type': r.get('type', 'other'),
'citation': r.get('citation', ''),
'ai_usage_notes': r.get('purpose', ''),
'is_required': True,
})
except Exception as exc:
_logger.warning("Failed to save resource: %s", exc)
return {
'deliverables_created': len(created),
'resources_needed': len(result.get('resources_needed', [])),
}
else:
raise RuntimeError(result.get('error', 'Deliverable detection failed'))
else:
raise RuntimeError('Agent tools not available for deliverable detection')
# ------------------------------------------------------------------
# NEW: Resource-aware week material generation
# ------------------------------------------------------------------
def generate_week_materials_with_resources(self, plan_id, week_number):
"""Generate materials using registered resource dependencies."""
plan = self.env['encoach.course.plan'].sudo().browse(int(plan_id))
if not plan.exists():
raise ValueError('Plan not found')
week = plan.week_ids.filtered(lambda w: w.week_number == int(week_number))
if not week:
raise ValueError(f'Week {week_number} not found')
week = week[0]
# Fetch available resources
resources = []
if self._agent_invoke:
res_result = self._agent_invoke(
self.env, "resources.fetch",
{"plan_id": plan.id, "is_available": True}
)
if res_result.get("ok"):
resources = res_result.get("resources", [])
# Fetch deliverables for this week
deliverables = []
if self._agent_invoke:
del_result = self._agent_invoke(
self.env, "deliverables.fetch",
{"plan_id": plan.id, "week_number": int(week_number)}
)
if del_result.get("ok"):
deliverables = del_result.get("deliverables", [])
# Build enhanced prompt with resources and deliverables
resource_context = ""
if resources:
resource_context = "\n\nAvailable Resources:\n" + json.dumps(resources, indent=2, ensure_ascii=False)
deliverable_context = ""
if deliverables:
deliverable_context = "\n\nTarget Deliverables (must be addressed in materials):\n" + json.dumps(deliverables, indent=2, ensure_ascii=False)
# Call base generation with enhanced context
outcomes = plan._loads(plan.outcomes_json, {})
items = week._loads(week.items_json, [])
system_msg = (
"You are an expert English language teacher creating ready-to-use classroom materials. "
"Your output MUST be valid JSON matching the schema. USE the available resources "
"provided below. Address ALL deliverables listed."
)
user_msg = (
f"Course: {plan.name}\n"
f"CEFR: {(plan.cefr_level or '').upper()}\n"
f"Week {week.week_number}{week.date_label or ''}\n"
f"Unit: {week.unit or ''}\n"
f"Focus: {week.focus or ''}\n\n"
f"Week items:\n{json.dumps(items, indent=2, ensure_ascii=False)}\n\n"
f"Full outcome catalogue:\n{json.dumps(outcomes, indent=2, ensure_ascii=False)}"
f"{resource_context}"
f"{deliverable_context}\n\n"
+ _WEEK_JSON_HINT
)
content = self._invoke_agent_or_chat(
agent_key="course_week_materials",
system_msg=system_msg,
user_msg=user_msg,
variables={
"course": plan.name,
"cefr_level": (plan.cefr_level or "").lower(),
"week_number": week.week_number,
},
temperature=0.6,
max_tokens=6000,
action="course_plan.generate_week_with_resources",
)
if content is None or 'error' in content:
raise RuntimeError(
(content or {}).get('error', 'AI generation failed.')
)
# Wipe previous materials
existing = self.env['encoach.course.plan.material'].sudo().search([
('plan_id', '=', plan.id), ('week_id', '=', week.id),
])
if existing:
existing.unlink()
# Create new materials with resource tracking
Material = self.env['encoach.course.plan.material'].sudo()
created = []
for m in content.get('materials') or []:
try:
rec = Material.create({
'plan_id': plan.id,
'week_id': week.id,
'skill': (m.get('skill') or 'integrated').strip().lower(),
'material_type': (m.get('material_type') or 'other').strip(),
'title': (m.get('title') or '').strip() or 'Untitled',
'summary': (m.get('summary') or '').strip(),
'body_json': json.dumps(m.get('body') or {}, ensure_ascii=False),
'body_text': self._flatten_body(m.get('body') or {}),
'required_resources_json': json.dumps(m.get('resources_used', []), ensure_ascii=False),
'ai_generation_notes': m.get('generation_notes', ''),
})
created.append(rec)
except Exception as exc:
_logger.warning("Skipping bad material row: %s", exc)
return created
# ------------------------------------------------------------------
# NEW: Rich media generation for materials
# ------------------------------------------------------------------
def suggest_media_for_material(self, material_id):
"""Suggest what media (images, audio) would enhance a material."""
material = self.env['encoach.course.plan.material'].sudo().browse(int(material_id))
if not material.exists():
raise ValueError('Material not found')
if not self._agent_invoke:
raise RuntimeError('Agent tools not available')
# Get visual suggestions
result = self._agent_invoke(
self.env, "media.suggest_visuals",
{
"content_description": material.body_text or material.summary or '',
"material_type": material.material_type,
"target_audience": f"{material.plan_id.cefr_level} level learners",
}
)
if result.get("ok"):
return {
'suggestions': result.get('visuals', []),
'material_id': material_id,
}
return {'error': result.get('error', 'Could not generate suggestions')}
def generate_media_for_material(self, material_id, media_type='image'):
"""Generate actual media (image/audio) for a material."""
material = self.env['encoach.course.plan.material'].sudo().browse(int(material_id))
if not material.exists():
raise ValueError('Material not found')
if not self._agent_invoke:
raise RuntimeError('Agent tools not available')
if media_type == 'image':
# Get suggestions first
suggestions = self.suggest_media_for_material(material_id)
if 'error' in suggestions:
return suggestions
# Use first suggestion's prompt
visuals = suggestions.get('suggestions', [])
if not visuals:
return {'error': 'No visual suggestions available'}
prompt = visuals[0].get('prompt_for_ai', visuals[0].get('description', ''))
result = self._agent_invoke(
self.env, "media.generate_image",
{
"prompt": prompt,
"material_id": material_id,
"style": visuals[0].get('complexity', 'educational'),
}
)
if result.get("ok"):
# Update material with media info
material.write({
'media_type': 'image',
'media_generation_prompt': result.get('prompt_used', prompt),
'media_asset_url': result.get('note', ''), # Would be actual URL after generation
})
return {
'media_type': 'image',
'prompt': prompt,
'suggestion': visuals[0],
'material_id': material_id,
}
return {'error': result.get('error', 'Image generation failed')}
elif media_type == 'audio':
# For listening scripts, generate audio
body = material._loads(material.body_json, {})
text = body.get('script', body.get('text', ''))
if not text:
return {'error': 'No text available for audio generation'}
result = self._agent_invoke(
self.env, "media.generate_audio",
{
"text": text[:3000], # Limit length
"material_id": material_id,
"purpose": "listening_exercise" if material.material_type == 'listening_script' else "pronunciation_guide",
}
)
if result.get("ok"):
material.write({
'media_type': 'audio',
'media_generation_prompt': f"Audio for: {text[:200]}...",
})
return {
'media_type': 'audio',
'service': result.get('service'),
'material_id': material_id,
}
return {'error': result.get('error', 'Audio generation failed')}
return {'error': f'Unsupported media type: {media_type}'}