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