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:
@@ -118,6 +118,92 @@
|
|||||||
<field name="sequence">110</field>
|
<field name="sequence">110</field>
|
||||||
</record>
|
</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 ============================== -->
|
<!-- ============================== AGENTS ============================== -->
|
||||||
|
|
||||||
<!-- 1. Course planner -->
|
<!-- 1. Course planner -->
|
||||||
@@ -147,6 +233,37 @@ Rules:
|
|||||||
ref('ai_tool_resources_search'),
|
ref('ai_tool_resources_search'),
|
||||||
ref('ai_tool_quality_cefr'),
|
ref('ai_tool_quality_cefr'),
|
||||||
ref('ai_tool_course_plan_save'),
|
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>
|
</record>
|
||||||
|
|
||||||
@@ -173,12 +290,49 @@ Rules:
|
|||||||
- Speaking prompts include useful-language chunks the learner can recycle.
|
- Speaking prompts include useful-language chunks the learner can recycle.
|
||||||
- Grammar lesson: one clear rule + 3 examples + 5 practice items with answer keys.
|
- 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.
|
- 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, [
|
<field name="tool_ids" eval="[(6, 0, [
|
||||||
ref('ai_tool_outcomes_fetch'),
|
ref('ai_tool_outcomes_fetch'),
|
||||||
ref('ai_tool_resources_search'),
|
ref('ai_tool_resources_search'),
|
||||||
ref('ai_tool_quality_cefr'),
|
ref('ai_tool_quality_cefr'),
|
||||||
ref('ai_tool_course_plan_save_materials'),
|
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>
|
</record>
|
||||||
|
|
||||||
|
|||||||
@@ -324,3 +324,435 @@ def _grade_speaking(env, rubric: str = "", transcript: str = "", **_: Any) -> di
|
|||||||
return svc.grade_speaking(rubric, transcript)
|
return svc.grade_speaking(rubric, transcript)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return {"error": str(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)}
|
||||||
|
|||||||
@@ -6,9 +6,10 @@ endpoints. Every route is JWT-guarded via the shared ``@jwt_required``
|
|||||||
decorator and returns JSON.
|
decorator and returns JSON.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from odoo import http
|
from odoo import http, fields
|
||||||
from odoo.http import request
|
from odoo.http import request
|
||||||
from odoo.addons.encoach_api.controllers.base import (
|
from odoo.addons.encoach_api.controllers.base import (
|
||||||
jwt_required,
|
jwt_required,
|
||||||
@@ -169,3 +170,315 @@ class CoursePlanController(http.Controller):
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
_logger.exception('course-plan.list_week_materials failed')
|
_logger.exception('course-plan.list_week_materials failed')
|
||||||
return _error_response(str(exc), 500)
|
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)
|
||||||
|
|||||||
@@ -44,10 +44,28 @@ MATERIAL_TYPE_SELECTION = [
|
|||||||
('grammar_lesson', 'Grammar Lesson'),
|
('grammar_lesson', 'Grammar Lesson'),
|
||||||
('vocabulary_list', 'Vocabulary List'),
|
('vocabulary_list', 'Vocabulary List'),
|
||||||
('practice', 'Practice Exercises'),
|
('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'),
|
('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):
|
class CoursePlan(models.Model):
|
||||||
_name = 'encoach.course.plan'
|
_name = 'encoach.course.plan'
|
||||||
_description = 'AI-generated Course Plan'
|
_description = 'AI-generated Course Plan'
|
||||||
@@ -260,9 +278,53 @@ class CoursePlanMaterial(models.Model):
|
|||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return default
|
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()
|
self.ensure_one()
|
||||||
return {
|
data = {
|
||||||
'id': self.id,
|
'id': self.id,
|
||||||
'plan_id': self.plan_id.id,
|
'plan_id': self.plan_id.id,
|
||||||
'week_id': self.week_id.id if self.week_id else None,
|
'week_id': self.week_id.id if self.week_id else None,
|
||||||
@@ -273,4 +335,315 @@ class CoursePlanMaterial(models.Model):
|
|||||||
'summary': self.summary or '',
|
'summary': self.summary or '',
|
||||||
'body': self._loads(self.body_json, {}),
|
'body': self._loads(self.body_json, {}),
|
||||||
'body_text': self.body_text or '',
|
'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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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_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_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
|
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,6 +1,6 @@
|
|||||||
"""Course plan generation pipeline.
|
"""Course plan generation pipeline.
|
||||||
|
|
||||||
Two public entry points:
|
Three public entry points:
|
||||||
|
|
||||||
* :py:meth:`generate_plan` — given a short brief (course title, CEFR level,
|
* :py:meth:`generate_plan` — given a short brief (course title, CEFR level,
|
||||||
duration, skill coverage, grammar focus, resources), produce a full
|
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
|
+ practice, writing prompt, vocabulary list) and persist each as an
|
||||||
:py:class:`encoach.course.plan.material` row.
|
: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
|
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
|
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
|
model's output is. Any parse failure is swallowed and reported back
|
||||||
through the standard error channel so the caller can retry without the
|
through the standard error channel so the caller can retry without the
|
||||||
server crashing.
|
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
|
import json
|
||||||
@@ -188,6 +199,12 @@ class CoursePlanPipeline:
|
|||||||
# Decide once per instance whether to route through the LangGraph
|
# Decide once per instance whether to route through the LangGraph
|
||||||
# AgentRuntime or fall back to the direct chat_json path.
|
# AgentRuntime or fall back to the direct chat_json path.
|
||||||
self._use_agent = self._resolve_agent_flag(env)
|
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
|
@staticmethod
|
||||||
def _resolve_agent_flag(env):
|
def _resolve_agent_flag(env):
|
||||||
@@ -494,3 +511,291 @@ class CoursePlanPipeline:
|
|||||||
elif isinstance(value, dict):
|
elif isinstance(value, dict):
|
||||||
lines.append(f"{key}: " + json.dumps(value, ensure_ascii=False))
|
lines.append(f"{key}: " + json.dumps(value, ensure_ascii=False))
|
||||||
return "\n".join(lines)
|
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