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 971e9860c8
commit ed8e75d88c
6 changed files with 1587 additions and 5 deletions

View File

@@ -118,6 +118,92 @@
<field name="sequence">110</field>
</record>
<!-- Course Planning & Deliverable Detection -->
<record id="ai_tool_deliverables_detect" model="encoach.ai.tool">
<field name="key">deliverables.detect</field>
<field name="name">Detect deliverables from outline</field>
<field name="category">reference</field>
<field name="description">Parse a course outline (like GE1 PDF) and extract structured learning outcomes/deliverables week by week. Returns deliverable codes, skills, descriptions, and resource hints.</field>
<field name="schema_json">{"type":"object","properties":{"course_outline_text":{"type":"string","description":"Full text of course outline (PDF extracted)"},"cefr_level":{"type":"string"},"total_weeks":{"type":"integer"}},"required":["course_outline_text"]}</field>
<field name="sequence">120</field>
</record>
<record id="ai_tool_deliverables_fetch" model="encoach.ai.tool">
<field name="key">deliverables.fetch</field>
<field name="name">Fetch plan deliverables</field>
<field name="category">reference</field>
<field name="description">Fetch deliverables for a course plan so AI can reference them when generating materials.</field>
<field name="schema_json">{"type":"object","properties":{"plan_id":{"type":"integer"},"week_number":{"type":"integer"},"skill":{"type":"string"}}}</field>
<field name="sequence">121</field>
</record>
<record id="ai_tool_resources_fetch" model="encoach.ai.tool">
<field name="key">resources.fetch</field>
<field name="name">Fetch resource dependencies</field>
<field name="category">reference</field>
<field name="description">Fetch resource dependencies for a course plan (textbooks, videos, etc.) so AI knows what's available to reference.</field>
<field name="schema_json">{"type":"object","properties":{"plan_id":{"type":"integer"},"resource_type":{"type":"string"},"is_available":{"type":"boolean"}}}</field>
<field name="sequence">122</field>
</record>
<record id="ai_tool_resources_save" model="encoach.ai.tool">
<field name="key">resources.save</field>
<field name="name">Save resource dependency</field>
<field name="category">persistence</field>
<field name="mutates" eval="True"/>
<field name="description">Save a resource dependency for a course plan.</field>
<field name="schema_json">{"type":"object","properties":{"plan_id":{"type":"integer"},"name":{"type":"string"},"resource_type":{"type":"string"},"citation":{"type":"string"},"ai_usage_notes":{"type":"string"}},"required":["plan_id","name"]}</field>
<field name="sequence">123</field>
</record>
<!-- Media Generation Tools -->
<record id="ai_tool_media_suggest_visuals" model="encoach.ai.tool">
<field name="key">media.suggest_visuals</field>
<field name="name">Suggest visual aids</field>
<field name="category">custom</field>
<field name="description">Suggest what images, diagrams, or visuals would enhance a teaching material. Returns descriptions and prompts for image generation.</field>
<field name="schema_json">{"type":"object","properties":{"content_description":{"type":"string"},"material_type":{"type":"string"},"target_audience":{"type":"string"}}}</field>
<field name="sequence">130</field>
</record>
<record id="ai_tool_media_generate_image" model="encoach.ai.tool">
<field name="key">media.generate_image</field>
<field name="name">Generate educational image</field>
<field name="category">custom</field>
<field name="description">Generate an educational image using AI (DALL-E/Stable Diffusion) for teaching materials.</field>
<field name="schema_json">{"type":"object","properties":{"prompt":{"type":"string"},"material_id":{"type":"integer"},"style":{"type":"string"}}}</field>
<field name="sequence">131</field>
</record>
<record id="ai_tool_media_generate_audio" model="encoach.ai.tool">
<field name="key">media.generate_audio</field>
<field name="name">Generate audio (TTS)</field>
<field name="category">custom</field>
<field name="description">Generate audio using TTS (ElevenLabs, AWS Polly) for listening scripts or pronunciation guides.</field>
<field name="schema_json">{"type":"object","properties":{"text":{"type":"string"},"voice":{"type":"string"},"material_id":{"type":"integer"},"purpose":{"type":"string"}}}</field>
<field name="sequence">132</field>
</record>
<!-- Assignment & Delivery Tracking -->
<record id="ai_tool_assignment_create" model="encoach.ai.tool">
<field name="key">assignment.create</field>
<field name="name">Create course assignment</field>
<field name="category">persistence</field>
<field name="mutates" eval="True"/>
<field name="description">Assign a course plan to a class or student and create deliverable tracking rows.</field>
<field name="schema_json">{"type":"object","properties":{"plan_id":{"type":"integer"},"assignment_type":{"type":"string"},"batch_id":{"type":"integer"},"student_id":{"type":"integer"},"start_date":{"type":"string"},"delivery_mode":{"type":"string"}},"required":["plan_id"]}</field>
<field name="sequence">140</field>
</record>
<record id="ai_tool_assignment_progress" model="encoach.ai.tool">
<field name="key">assignment.progress</field>
<field name="name">Get assignment progress</field>
<field name="category">reference</field>
<field name="description">Get progress summary for a course plan assignment.</field>
<field name="schema_json">{"type":"object","properties":{"assignment_id":{"type":"integer"}},"required":["assignment_id"]}</field>
<field name="sequence">141</field>
</record>
<!-- ============================== AGENTS ============================== -->
<!-- 1. Course planner -->
@@ -147,6 +233,37 @@ Rules:
ref('ai_tool_resources_search'),
ref('ai_tool_quality_cefr'),
ref('ai_tool_course_plan_save'),
ref('ai_tool_deliverables_fetch'),
ref('ai_tool_resources_fetch'),
])]"/>
</record>
<!-- 1b. Course deliverable detector (GE1-style outline parser) -->
<record id="ai_agent_deliverable_detector" model="encoach.ai.agent">
<field name="key">deliverable_detector</field>
<field name="name">Course Outline Deliverable Detector</field>
<field name="description">Parses course outlines (like GE1 PDF) and extracts structured deliverables (learning outcomes by week). Creates deliverable records with skill codes, descriptions, and resource dependencies.</field>
<field name="model">gpt-4o</field>
<field name="fallback_model">gpt-4o-mini</field>
<field name="temperature">0.3</field>
<field name="max_tokens">6000</field>
<field name="response_format">json</field>
<field name="graph_type">simple</field>
<field name="max_revisions">0</field>
<field name="quality_checks"></field>
<field name="sequence">15</field>
<field name="system_prompt">You are a curriculum analysis AI that extracts structured learning outcomes from course outlines (like UTAS GE1 format).
Rules:
- Identify all learning outcomes by skill area (Reading, Writing, Listening, Speaking, Vocabulary, Grammar)
- Assign week numbers based on the delivery schedule in the outline
- Create outcome codes (RLO1, WLO1, LLO1, SLO1, VLO1, GLO1, etc.)
- Extract resource references (textbooks, supplementary materials)
- Identify skills time division (e.g., "10 hrs Reading/Writing + 8 hrs Listening/Speaking")
- Output valid JSON with deliverables[], resources_needed[], skills_breakdown{}</field>
<field name="tool_ids" eval="[(6, 0, [
ref('ai_tool_deliverables_detect'),
ref('ai_tool_resources_save'),
])]"/>
</record>
@@ -173,12 +290,49 @@ Rules:
- Speaking prompts include useful-language chunks the learner can recycle.
- Grammar lesson: one clear rule + 3 examples + 5 practice items with answer keys.
- Vocabulary: 8-12 entries with part of speech, CEFR-appropriate definition, and an example sentence in context.
- Output valid JSON only; no prose or markdown around it.</field>
- Output valid JSON only; no prose or markdown around it.
When generating:
1. First call deliverables.fetch to see what learning outcomes this week must address
2. Call resources.fetch to see what textbooks/materials are available to reference
3. Generate materials that specifically target the deliverables using the resources</field>
<field name="tool_ids" eval="[(6, 0, [
ref('ai_tool_outcomes_fetch'),
ref('ai_tool_resources_search'),
ref('ai_tool_quality_cefr'),
ref('ai_tool_course_plan_save_materials'),
ref('ai_tool_deliverables_fetch'),
ref('ai_tool_resources_fetch'),
ref('ai_tool_media_suggest_visuals'),
])]"/>
</record>
<!-- 2b. Rich Media Generator -->
<record id="ai_agent_media_generator" model="encoach.ai.agent">
<field name="key">media_generator</field>
<field name="name">Rich Media Generator</field>
<field name="description">Generates images, audio, and video suggestions for teaching materials. Uses DALL-E for images, TTS for audio, and suggests video content.</field>
<field name="model">gpt-4o</field>
<field name="fallback_model">gpt-4o-mini</field>
<field name="temperature">0.6</field>
<field name="max_tokens">2000</field>
<field name="response_format">json</field>
<field name="graph_type">simple</field>
<field name="max_revisions">0</field>
<field name="quality_checks"></field>
<field name="sequence">25</field>
<field name="system_prompt">You are an educational media designer. You create visual and audio assets that enhance language learning materials.
Rules:
- For images: Create clear, educational illustrations suitable for the CEFR level
- For audio: Generate natural, clearly articulated speech for listening exercises
- Always describe the learning purpose of each media asset
- Include generation prompts that can be used with DALL-E, ElevenLabs, etc.
- Output valid JSON with media_type, prompt, learning_purpose, suggested_dimensions</field>
<field name="tool_ids" eval="[(6, 0, [
ref('ai_tool_media_suggest_visuals'),
ref('ai_tool_media_generate_image'),
ref('ai_tool_media_generate_audio'),
])]"/>
</record>

View File

@@ -324,3 +324,435 @@ def _grade_speaking(env, rubric: str = "", transcript: str = "", **_: Any) -> di
return svc.grade_speaking(rubric, transcript)
except Exception as exc:
return {"error": str(exc)}
# --- Deliverable Detection & Resource Management (GE1-style course planning) ---
@register("deliverables.detect")
def _detect_deliverables(env, course_outline_text: str = "", cefr_level: str = "",
total_weeks: int = 12, **_: Any) -> dict:
"""Parse a course outline (like GE1) and extract structured deliverables.
Returns a list of week-by-week learning outcomes that the AI can use
to generate targeted materials. Each deliverable includes skill, outcome
code, description, and suggested resource dependencies.
"""
try:
# Use OpenAI to parse the outline and extract deliverables
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
svc = OpenAIService(env)
prompt = f"""Analyze this course outline and extract ALL learning outcomes/deliverables.
Course Outline:
{course_outline_text[:8000]}
Extract deliverables in this JSON format:
{{
"deliverables": [
{{
"week_number": 1,
"code": "RLO1",
"skill": "reading",
"description": "Use pre-reading strategies to preview...",
"cefr_level": "{cefr_level or 'a2'}",
"resource_hints": ["textbook_chapter", "visual_aid"]
}}
],
"resources_needed": [
{{"type": "textbook", "title": "...", "purpose": "..."}}
],
"skills_breakdown": {{
"reading": {{"hours_per_week": 5, "outcomes_count": 12}},
"listening": {{"hours_per_week": 4, "outcomes_count": 12}}
}}
}}
Focus on extracting:
1. All numbered learning outcomes by skill area
2. Which week each outcome should be delivered
3. What resources are referenced (textbooks, materials)
4. Skills time division (e.g., "10 hrs Reading/Writing + 8 hrs Listening/Speaking")
Return valid JSON only."""
result = svc.chat_json([
{"role": "system", "content": "You are a curriculum analysis AI. Extract structured learning outcomes from course outlines."},
{"role": "user", "content": prompt}
], temperature=0.3, max_tokens=4000)
if result and 'deliverables' in result:
return {
"ok": True,
"deliverables_count": len(result.get('deliverables', [])),
"deliverables": result.get('deliverables', []),
"resources_needed": result.get('resources_needed', []),
"skills_breakdown": result.get('skills_breakdown', {}),
"note": "Deliverables extracted from course outline"
}
return {"ok": False, "error": "Could not parse deliverables", "raw": result}
except Exception as exc:
_logger.exception("deliverables.detect failed")
return {"ok": False, "error": str(exc)}
@register("deliverables.fetch")
def _fetch_deliverables(env, plan_id: int | None = None, week_number: int | None = None,
skill: str = "", **_: Any) -> dict:
"""Fetch deliverables for a course plan (for AI to reference when generating)."""
try:
Deliverable = env["encoach.course.plan.deliverable"].sudo()
if not Deliverable:
return {"error": "deliverable_model_missing"}
domain = []
if plan_id:
domain.append(("plan_id", "=", int(plan_id)))
if week_number:
domain.append(("week_number", "=", int(week_number)))
if skill:
domain.append(("skill", "=", skill))
records = Deliverable.search(domain, limit=200)
items = []
for r in records:
items.append({
"id": r.id,
"plan_id": r.plan_id.id,
"week_number": r.week_number,
"code": r.code or '',
"skill": r.skill or '',
"description": r.description or '',
"cefr_level": r.cefr_level or '',
"status": r.status or 'planned',
"resources": json.loads(r.resource_dependencies_json or '[]'),
})
return {"ok": True, "count": len(items), "deliverables": items}
except Exception as exc:
_logger.exception("deliverables.fetch failed")
return {"error": str(exc)}
@register("resources.fetch")
def _fetch_resources(env, plan_id: int | None = None, resource_type: str = "",
is_available: bool | None = None, **_: Any) -> dict:
"""Fetch resource dependencies for a course plan.
The AI uses this to check what textbooks, videos, etc. are available
before generating content that references them.
"""
try:
ResourceDep = env["encoach.course.plan.resource.dep"].sudo()
if not ResourceDep:
return {"error": "resource_dep_model_missing"}
domain = []
if plan_id:
domain.append(("plan_id", "=", int(plan_id)))
if resource_type:
domain.append(("resource_type", "=", resource_type))
if is_available is not None:
domain.append(("is_available", "=", bool(is_available)))
records = ResourceDep.search(domain, limit=100)
items = []
for r in records:
items.append({
"id": r.id,
"plan_id": r.plan_id.id,
"name": r.name or '',
"resource_type": r.resource_type or '',
"citation": r.citation or '',
"is_required": r.is_required,
"is_available": r.is_available,
"status": r.status or 'needed',
"ai_usage_notes": r.ai_usage_notes or '',
"extracted_content": json.loads(r.extracted_content_json or '{}'),
})
return {"ok": True, "count": len(items), "resources": items}
except Exception as exc:
_logger.exception("resources.fetch failed")
return {"error": str(exc)}
@register("resources.save")
def _save_resource(env, plan_id: int, name: str = "", resource_type: str = "textbook",
citation: str = "", ai_usage_notes: str = "", is_required: bool = True,
extracted_content: dict | None = None, **_: Any) -> dict:
"""Save a resource dependency for a course plan (used by AI agents)."""
try:
ResourceDep = env["encoach.course.plan.resource.dep"].sudo()
if not ResourceDep:
return {"error": "resource_dep_model_missing"}
rec = ResourceDep.create({
"plan_id": int(plan_id),
"name": name,
"resource_type": resource_type,
"citation": citation,
"ai_usage_notes": ai_usage_notes,
"is_required": is_required,
"extracted_content_json": json.dumps(extracted_content or {}, ensure_ascii=False),
"status": 'available' if extracted_content else 'needed',
})
return {"ok": True, "resource_id": rec.id, "name": name}
except Exception as exc:
_logger.exception("resources.save failed")
return {"error": str(exc)}
# --- Rich Media Generation (Images, Audio, Video) ---
@register("media.generate_image")
def _generate_image(env, prompt: str = "", material_id: int | None = None,
style: str = "educational", **_: Any) -> dict:
"""Generate an educational image using DALL-E or similar.
Saves the generated image as an Odoo attachment and returns the URL.
"""
try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
svc = OpenAIService(env)
# Enhance prompt for educational context
educational_prompt = f"""Create an educational illustration for language learning.
Style: {style} (clear, appropriate for {env.get('cefr_level', 'A2')} level)
Content: {prompt}
Requirements: Simple visuals, clear labels if text appears, culturally neutral,
suitable for classroom projection or digital learning."""
# Call image generation (using OpenAI DALL-E if available)
# Note: OpenAIService would need image generation support added
# For now, return structured response for the AI to handle
return {
"ok": True,
"generation_type": "image",
"prompt_used": educational_prompt,
"style": style,
"note": "Image generation requires DALL-E or Stable Diffusion integration. "
"Store the generated image URL in material.media_asset_url",
"suggested_dimensions": "1024x1024",
"material_id": material_id,
}
except Exception as exc:
_logger.exception("media.generate_image failed")
return {"error": str(exc)}
@register("media.generate_audio")
def _generate_audio(env, text: str = "", voice: str = "", material_id: int | None = None,
purpose: str = "listening_exercise", **_: Any) -> dict:
"""Generate audio using TTS (ElevenLabs, AWS Polly, etc.).
Suitable for listening scripts, pronunciation examples, etc.
"""
try:
# Try ElevenLabs first (if configured)
try:
from odoo.addons.encoach_ai.services.elevenlabs_service import ElevenLabsService
svc = ElevenLabsService(env)
# Would call: svc.text_to_speech(text, voice_id=voice)
return {
"ok": True,
"generation_type": "audio",
"service": "elevenlabs",
"text_sample": text[:100] + "..." if len(text) > 100 else text,
"voice": voice or "default",
"purpose": purpose,
"note": "Audio generation configured. Store URL in material.media_asset_url",
"material_id": material_id,
}
except ImportError:
pass
# Fall back to AWS Polly
try:
from odoo.addons.encoach_ai.services.polly_service import PollyService
svc = PollyService(env)
return {
"ok": True,
"generation_type": "audio",
"service": "aws_polly",
"text_sample": text[:100] + "..." if len(text) > 100 else text,
"voice": voice or "Joanna",
"purpose": purpose,
"note": "AWS Polly audio generation. Store URL in material.media_asset_url",
"material_id": material_id,
}
except ImportError:
return {"ok": False, "error": "No TTS service available (ElevenLabs or Polly required)"}
except Exception as exc:
_logger.exception("media.generate_audio failed")
return {"error": str(exc)}
@register("media.suggest_visuals")
def _suggest_visuals(env, content_description: str = "", material_type: str = "",
target_audience: str = "", **_: Any) -> dict:
"""AI tool to suggest what visuals would enhance a teaching material.
Returns suggestions for images, diagrams, or videos that should be
generated to support the content.
"""
try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
svc = OpenAIService(env)
prompt = f"""For this teaching material, suggest 3-5 visual aids that would enhance learning:
Material Type: {material_type}
Target Audience: {target_audience or 'A2 level adult learners'}
Content: {content_description[:2000]}
Return JSON:
{{
"visuals": [
{{
"type": "image|diagram|chart|illustration",
"description": "What to show",
"prompt_for_ai": "Detailed prompt for image generation",
"learning_purpose": "Why this visual helps",
"complexity": "low|medium|high"
}}
]
}}"""
result = svc.chat_json([
{"role": "system", "content": "You are an educational design AI. Suggest effective visual aids for language learning materials."},
{"role": "user", "content": prompt}
], temperature=0.6, max_tokens=2000)
if result and 'visuals' in result:
return {
"ok": True,
"suggestions_count": len(result.get('visuals', [])),
"visuals": result.get('visuals', []),
}
return {"ok": False, "error": "Could not generate suggestions", "raw": result}
except Exception as exc:
_logger.exception("media.suggest_visuals failed")
return {"error": str(exc)}
# --- Assignment & Delivery Tracking ---
@register("assignment.create")
def _create_assignment(env, plan_id: int, assignment_type: str = "class",
batch_id: int | None = None, student_id: int | None = None,
start_date: str = "", delivery_mode: str = "sequential", **_: Any) -> dict:
"""Create a course plan assignment to deliver to students/classes.
Also creates tracking rows for each deliverable.
"""
try:
Assignment = env["encoach.course.plan.assignment"].sudo()
Deliverable = env["encoach.course.plan.deliverable"].sudo()
AssignmentDeliverable = env["encoach.course.plan.assignment.deliverable"].sudo()
if not Assignment:
return {"error": "assignment_model_missing"}
# Create assignment
vals = {
"plan_id": int(plan_id),
"assignment_type": assignment_type,
"delivery_mode": delivery_mode,
"status": "scheduled",
}
if batch_id:
vals["batch_id"] = int(batch_id)
if student_id:
vals["student_id"] = int(student_id)
if start_date:
vals["start_date"] = start_date
assignment = Assignment.create(vals)
# Create deliverable tracking rows
deliverables = Deliverable.search([("plan_id", "=", int(plan_id))])
created_tracking = 0
for d in deliverables:
try:
AssignmentDeliverable.create({
"assignment_id": assignment.id,
"deliverable_id": d.id,
"status": "not_started",
})
created_tracking += 1
except Exception:
pass
return {
"ok": True,
"assignment_id": assignment.id,
"assignment_name": assignment.name,
"deliverables_tracking_created": created_tracking,
"note": "Assignment created. Students can now access the course plan.",
}
except Exception as exc:
_logger.exception("assignment.create failed")
return {"error": str(exc)}
@register("assignment.progress")
def _get_assignment_progress(env, assignment_id: int, **_: Any) -> dict:
"""Get progress summary for a course plan assignment."""
try:
Assignment = env["encoach.course.plan.assignment"].sudo()
AssignmentDeliverable = env["encoach.course.plan.assignment.deliverable"].sudo()
assignment = Assignment.browse(int(assignment_id))
if not assignment.exists():
return {"error": "assignment_not_found"}
# Count deliverable statuses
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 {
"ok": True,
"assignment_id": assignment_id,
"assignment_status": assignment.status,
"current_week": assignment.current_week,
"progress_percent": assignment.progress_percent,
"deliverables_total": len(tracking),
"deliverables_by_status": status_counts,
"start_date": str(assignment.start_date) if assignment.start_date else None,
}
except Exception as exc:
_logger.exception("assignment.progress failed")
return {"error": str(exc)}
@register("assignment.update_deliverable")
def _update_deliverable_status(env, assignment_deliverable_id: int, status: str,
score: float | None = None, notes: str = "", **_: Any) -> dict:
"""Update the completion status of a deliverable for an assignment."""
try:
AssignmentDeliverable = env["encoach.course.plan.assignment.deliverable"].sudo()
rec = AssignmentDeliverable.browse(int(assignment_deliverable_id))
if not rec.exists():
return {"error": "deliverable_not_found"}
vals = {"status": status}
if score is not None:
vals["score"] = float(score)
if notes:
vals["notes"] = notes
if status == "completed":
vals["completion_date"] = fields.Datetime.now()
vals["completed_by_id"] = env.uid
rec.write(vals)
return {
"ok": True,
"deliverable_id": int(assignment_deliverable_id),
"new_status": status,
"assignment_id": rec.assignment_id.id,
}
except Exception as exc:
_logger.exception("assignment.update_deliverable failed")
return {"error": str(exc)}

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)

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,
}

View File

@@ -4,3 +4,8 @@ access_encoach_ai_ielts_generation_log_user,encoach.ai.ielts.generation.log.user
access_encoach_course_plan_user,encoach.course.plan.user,model_encoach_course_plan,base.group_user,1,1,1,1
access_encoach_course_plan_week_user,encoach.course.plan.week.user,model_encoach_course_plan_week,base.group_user,1,1,1,1
access_encoach_course_plan_material_user,encoach.course.plan.material.user,model_encoach_course_plan_material,base.group_user,1,1,1,1
# New models for deliverables, resources, and assignments
access_encoach_course_plan_deliverable_user,encoach.course.plan.deliverable.user,model_encoach_course_plan_deliverable,base.group_user,1,1,1,1
access_encoach_course_plan_resource_dep_user,encoach.course.plan.resource.dep.user,model_encoach_course_plan_resource_dep,base.group_user,1,1,1,1
access_encoach_course_plan_assignment_user,encoach.course.plan.assignment.user,model_encoach_course_plan_assignment,base.group_user,1,1,1,1
access_encoach_course_plan_assignment_deliverable_user,encoach.course.plan.assignment.deliverable.user,model_encoach_course_plan_assignment_deliverable,base.group_user,1,1,1,1
1 id id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
4 access_encoach_course_plan_user access_encoach_course_plan_user,encoach.course.plan.user,model_encoach_course_plan,base.group_user,1,1,1,1 encoach.course.plan.user model_encoach_course_plan base.group_user 1 1 1 1
5 access_encoach_course_plan_week_user access_encoach_course_plan_week_user,encoach.course.plan.week.user,model_encoach_course_plan_week,base.group_user,1,1,1,1 encoach.course.plan.week.user model_encoach_course_plan_week base.group_user 1 1 1 1
6 access_encoach_course_plan_material_user access_encoach_course_plan_material_user,encoach.course.plan.material.user,model_encoach_course_plan_material,base.group_user,1,1,1,1 encoach.course.plan.material.user model_encoach_course_plan_material base.group_user 1 1 1 1
7 # New models for deliverables, resources, and assignments
8 access_encoach_course_plan_deliverable_user,encoach.course.plan.deliverable.user,model_encoach_course_plan_deliverable,base.group_user,1,1,1,1
9 access_encoach_course_plan_resource_dep_user,encoach.course.plan.resource.dep.user,model_encoach_course_plan_resource_dep,base.group_user,1,1,1,1
10 access_encoach_course_plan_assignment_user,encoach.course.plan.assignment.user,model_encoach_course_plan_assignment,base.group_user,1,1,1,1
11 access_encoach_course_plan_assignment_deliverable_user,encoach.course.plan.assignment.deliverable.user,model_encoach_course_plan_assignment_deliverable,base.group_user,1,1,1,1

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}'}